H-337: fix Web Channel availability and realtime delivery (#60)

* H-337: fix Web Channel availability and realtime delivery

* fix(widget): preserve realtime sender and activity contracts

* fix(widget): keep realtime sender payloads consistent

* fix(widget): make public message persistence atomic

* fix(inbox): keep availability projection out of schema

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-20 14:30:05 +08:00
committed by GitHub
co-authored by rogee
parent 55b56ee172
commit 3d9817c9f5
17 changed files with 769 additions and 247 deletions
@@ -224,21 +224,6 @@ func TestWidgetHandler_Cov3_PublicGetContact_InvalidInbox_Cov3(t *testing.T) {
assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusBadRequest, "expected 404 or 400, got %d: %s", w.Code, w.Body.String())
}
// ---------- uintFromPayload unit test ----------
func TestWidgetHandler_Cov3_UintFromPayload_Cov3(t *testing.T) {
assert.Equal(t, uint(42), uintFromPayload(uint(42)))
assert.Equal(t, uint(42), uintFromPayload(int(42)))
assert.Equal(t, uint(0), uintFromPayload(int(-1)))
assert.Equal(t, uint(42), uintFromPayload(int64(42)))
assert.Equal(t, uint(0), uintFromPayload(int64(-1)))
assert.Equal(t, uint(42), uintFromPayload(float64(42)))
assert.Equal(t, uint(0), uintFromPayload(float64(-1)))
assert.Equal(t, uint(0), uintFromPayload("not a number"))
assert.Equal(t, uint(0), uintFromPayload(nil))
assert.Equal(t, uint(0), uintFromPayload(uint(0)))
}
// ---------- GetCableToken ----------
func TestWidgetHandler_Cov3_GetCableToken_NoToken_Cov3(t *testing.T) {
@@ -311,37 +311,6 @@ func TestWidgetHandler_Cov3_GetConversations_ChatwootRoute_Cov3(t *testing.T) {
}
}
// ---------- SendMessage with event publisher ----------
func TestWidgetHandler_Cov3_SendMessage_WithEventPublisher_Cov3(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
seedWidgetHandlerData(t, db)
// Set event publisher
publisher := &recordingWidgetEventPublisher{}
handler.WithEventPublisher(publisher)
// Config to get a widget token
wConfig := httptest.NewRecorder()
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
router.ServeHTTP(wConfig, reqConfig)
require.Equal(t, http.StatusOK, wConfig.Code)
var configResp map[string]interface{}
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
// Send message
messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "hello with publisher"}})
wMsg := httptest.NewRecorder()
reqMsg, _ := http.NewRequest("POST", "/api/v1/widget/messages?cw_conversation="+authToken, bytes.NewReader(messageBody))
reqMsg.Header.Set("Content-Type", "application/json")
router.ServeHTTP(wMsg, reqMsg)
require.Equal(t, http.StatusOK, wMsg.Code, wMsg.Body.String())
// The event publisher should have been called
assert.NotZero(t, publisher.accountID)
}
// ---------- SendTranscript with valid token (needs conversation) ----------
func TestWidgetHandler_Cov3_SendTranscript_WithConversation_Cov3(t *testing.T) {
@@ -86,17 +86,6 @@ func TestConfig_InvalidWebsiteToken_Cov4(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestConfig_WithEventPublisher_Cov4(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
seedWidgetHandlerData(t, db)
pub := &recordingWidgetEventPublisher{}
handler.WithEventPublisher(pub)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// SendMessage — edge cases
// ============================================================
@@ -12,20 +12,14 @@ import (
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/internal/webhookutil"
wspkg "github.com/gochat/gochat/internal/ws"
)
type widgetEventPublisher interface {
PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{})
}
// WidgetHandler handles the public-facing widget API endpoints.
// These endpoints are accessed by the embedded JS widget on customer websites
// and do not require agent JWT authentication — they use a widget_token instead.
// Reference: Chatwoot app/controllers/api/v1/widget_messages_controller.rb
type WidgetHandler struct {
widgetService *service.WidgetService
eventPublisher widgetEventPublisher
widgetService *service.WidgetService
}
// NewHandler creates a new WidgetHandler with the WidgetService dependency.
@@ -35,11 +29,6 @@ func NewHandler(widgetService *service.WidgetService) *WidgetHandler {
}
}
func (h *WidgetHandler) WithEventPublisher(publisher widgetEventPublisher) *WidgetHandler {
h.eventPublisher = publisher
return h
}
// Init handles widget initialization — authenticates/creates a contact
// and returns a widget_token (pubsub_token) for subsequent requests.
// POST /widget/init
@@ -212,7 +201,6 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
if len(resp.Attachments) > 0 {
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
}
h.publishWidgetMessageEvent(req.WidgetToken, payload)
c.JSON(http.StatusOK, resp)
return
}
@@ -220,7 +208,6 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
if len(resp.Attachments) > 0 {
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
}
h.publishWidgetMessageEvent(req.WidgetToken, payload)
c.JSON(http.StatusOK, payload)
}
@@ -262,40 +249,6 @@ func (h *WidgetHandler) UpdateMessage(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"contact": widgetContactFullPayload(contact)})
}
func (h *WidgetHandler) publishWidgetMessageEvent(pubsubToken string, payload gin.H) {
if h.eventPublisher == nil || payload == nil {
return
}
accountID := uintFromPayload(payload["account_id"])
if accountID == 0 {
accountID = uintFromPayload(payload["accountId"])
}
if accountID == 0 {
return
}
h.eventPublisher.PublishWidgetEvent(accountID, pubsubToken, wspkg.EventMessageCreated, payload)
}
func uintFromPayload(value any) uint {
switch typed := value.(type) {
case uint:
return typed
case int:
if typed > 0 {
return uint(typed)
}
case int64:
if typed > 0 {
return uint(typed)
}
case float64:
if typed > 0 {
return uint(typed)
}
}
return 0
}
// GetLatestMessages implements Chatwoot's GET /api/v1/widget/messages endpoint.
func (h *WidgetHandler) GetLatestMessages(c *gin.Context) {
widgetToken := widgetTokenFromRequest(c)
@@ -43,13 +43,6 @@ func (n *noopTypingIndicatorWidget) SetTypingOff(_ context.Context, _ uint, _ ui
return nil
}
type recordingWidgetEventPublisher struct {
accountID uint
pubsubToken string
eventType string
payload interface{}
}
func TestPublicMessagePayloadSanitizesIdentity(t *testing.T) {
senderID := uint(9)
message := model.Message{
@@ -73,13 +66,6 @@ func TestPublicMessagePayloadSanitizesIdentity(t *testing.T) {
assert.Empty(t, publicMessagePayload(message, model.Conversation{})["content_attributes"])
}
func (p *recordingWidgetEventPublisher) PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) {
p.accountID = accountID
p.pubsubToken = pubsubToken
p.eventType = eventType
p.payload = payload
}
type recordingWidgetTranscriptDeliverer struct {
requests []automation.AutomationTranscriptRequest
err error
@@ -779,42 +765,6 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
assertWidgetContactFixtureShape(t, contactResp)
}
func TestWidgetHandler_ChatwootMessagePublishesWidgetRealtimePayload(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
publisher := &recordingWidgetEventPublisher{}
handler.WithEventPublisher(publisher)
wConfig := httptest.NewRecorder()
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
router.ServeHTTP(wConfig, reqConfig)
require.Equal(t, http.StatusOK, wConfig.Code)
var configResp map[string]interface{}
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
body, _ := json.Marshal(map[string]interface{}{
"message": map[string]interface{}{"content": "Realtime widget message"},
})
wMessage := httptest.NewRecorder()
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(body))
reqMessage.Header.Set("Content-Type", "application/json")
reqMessage.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wMessage, reqMessage)
require.Equal(t, http.StatusOK, wMessage.Code)
assert.Equal(t, uint(1), publisher.accountID)
assert.Equal(t, authToken, publisher.pubsubToken)
assert.Equal(t, ws.EventMessageCreated, publisher.eventType)
payload, ok := publisher.payload.(gin.H)
require.True(t, ok, "expected widget realtime payload to use Chatwoot widget message shape")
assert.Equal(t, "Realtime widget message", payload["content"])
assert.Equal(t, uint(1), payload["account_id"])
assert.NotEmpty(t, payload["conversation_id"])
assertWidgetMessageFixtureShape(t, payload)
}
func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox := seedWidgetHandlerData(t, db)
@@ -1656,21 +1606,26 @@ func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox := seedWidgetHandlerData(t, db)
user := &model.User{
userA := &model.User{
AccountID: inbox.AccountID,
Name: "Ada Agent",
Name: "Agent A",
Email: "ada@handler.test",
Password: "secret",
AvatarURL: "https://example.test/ada.png",
Active: true,
}
require.NoError(t, db.Create(user).Error)
userB := &model.User{AccountID: inbox.AccountID, Name: "Agent B", Email: "b@handler.test", Password: "secret", Active: true}
require.NoError(t, db.Create(userA).Error)
require.NoError(t, db.Create(userB).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: inbox.AccountID, UserID: userA.ID, Role: "agent", Availability: "online"}).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: inbox.AccountID, UserID: userB.ID, Role: "agent", Availability: "online"}).Error)
require.NoError(t, db.Create(&model.InboxMember{
InboxID: inbox.ID,
UserID: user.ID,
UserID: userA.ID,
Role: "agent",
AvailabilityStatus: "online",
AvailabilityStatus: "offline",
}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: userB.ID, Role: "agent", AvailabilityStatus: "offline"}).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/widget/inbox_members?website_token=handler_ws_token_123", nil)
@@ -1680,10 +1635,11 @@ func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) {
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].([]interface{})
require.Len(t, payload, 1)
member := payload[0].(map[string]interface{})
assert.Equal(t, "Ada Agent", member["name"])
assert.Equal(t, "online", member["availability_status"])
require.Len(t, payload, 2)
assert.Equal(t, "Agent A", payload[0].(map[string]interface{})["name"])
assert.Equal(t, "online", payload[0].(map[string]interface{})["availability_status"])
assert.Equal(t, "Agent B", payload[1].(map[string]interface{})["name"])
assert.Equal(t, "online", payload[1].(map[string]interface{})["availability_status"])
}
func TestWidgetHandler_ChatwootInboxMembers_InvalidWebsiteTokenReturnsNotFound(t *testing.T) {