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
@@ -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) {