HH-469: close WebSocket fanout contract gaps (#109)

* HH-469: close websocket fanout contract gaps

* fix: unify message sender contracts

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-23 00:22:01 +08:00
committed by GitHub
co-authored by rogee
parent f91dc6ca1a
commit ef8931099c
14 changed files with 377 additions and 114 deletions
@@ -1227,7 +1227,8 @@ func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsTimelineDepthMatchesCha
firstSender := first["sender"].(map[string]any)
s.Equal(float64(s.user.ID), firstSender["id"])
s.Equal("CRUDTestUser", firstSender["name"])
s.Equal("agent", firstSender["role"])
s.Equal("user", firstSender["type"])
s.NotContains(firstSender, "role")
s.Equal(float64(newMessage.CreatedAt.Unix()), first["created_at"])
second := payload[1].(map[string]any)
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
@@ -457,22 +456,22 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message,
case "contact":
var contact model.Contact
if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil {
payload.Sender = serializeContactWithContext(ctx, &contact)
payload.Sender = contact.PushEventData()
}
case "agent_bot":
var bot model.AgentBot
if err := db.WithContext(ctx).First(&bot, *message.SenderID).Error; err == nil {
payload.Sender = serializeAgentBotSender(&bot)
payload.Sender = bot.PushEventData()
}
case "captain_assistant":
var assistant model.CaptainAssistant
if err := db.WithContext(ctx).First(&assistant, *message.SenderID).Error; err == nil {
payload.Sender = serializeCaptainAssistantSender(&assistant)
payload.Sender = assistant.PushEventData()
}
default:
var user model.User
if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil {
payload.Sender = serializeUser(&user, message.AccountID)
payload.Sender = user.PushEventData()
}
}
}
@@ -616,27 +615,7 @@ func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Att
}
func serializeAttachmentPushEventData(attachment *model.Attachment) map[string]any {
extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".")
dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL)
payload := map[string]any{
"id": attachment.ID,
"message_id": attachment.MessageID,
"file_type": attachment.FileType,
"account_id": attachment.AccountID,
"data_url": dataURL,
"thumb_url": attachment.ThumbURL,
"file_size": attachment.FileSize,
"extension": extension,
"width": attachment.Width,
"height": attachment.Height,
}
if strings.TrimSpace(attachment.Metadata) != "" {
metadata := map[string]any{}
if json.Unmarshal([]byte(attachment.Metadata), &metadata) == nil {
payload["metadata"] = metadata
}
}
return payload
return attachment.PushEventData()
}
func serializeAttachmentWithConversation(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any {
@@ -662,27 +641,27 @@ func serializeMessageSender(ctx context.Context, db *gorm.DB, message *model.Mes
if senderType == "contact" {
var contact model.Contact
if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil {
return serializeContactWithContext(ctx, &contact)
return contact.PushEventData()
}
return nil
}
if senderType == "agent_bot" {
var bot model.AgentBot
if err := db.WithContext(ctx).First(&bot, *message.SenderID).Error; err == nil {
return serializeAgentBotSender(&bot)
return bot.PushEventData()
}
return nil
}
if senderType == "captain_assistant" {
var assistant model.CaptainAssistant
if err := db.WithContext(ctx).First(&assistant, *message.SenderID).Error; err == nil {
return serializeCaptainAssistantSender(&assistant)
return assistant.PushEventData()
}
return nil
}
var user model.User
if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil {
return serializeUser(&user, message.AccountID)
return user.PushEventData()
}
return nil
}
@@ -764,11 +743,6 @@ func serializeAgentBotSender(bot *model.AgentBot) map[string]any {
}
}
func serializeCaptainAssistantSender(assistant *model.CaptainAssistant) map[string]any {
avatarURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") + "/assets/images/dashboard/captain/logo.svg"
return map[string]any{"id": assistant.ID, "name": assistant.Name, "avatar_url": avatarURL, "description": assistant.Description, "created_at": assistant.CreatedAt, "type": "captain_assistant"}
}
func serializeAgentBotSlim(bot *model.AgentBot) map[string]any {
return map[string]any{
"id": bot.ID,
@@ -495,7 +495,8 @@ func (s *MessageHandlerTestSuite) TestCreate_ChatwootFrontendPayloadDefaultsOutg
assert.Equal(s.T(), float64(s.testUser.ID), sender["id"])
assert.Equal(s.T(), "Msg Handler Agent", sender["name"])
assert.Equal(s.T(), "Message Agent", sender["available_name"])
assert.Equal(s.T(), "message-agent@example.com", sender["email"])
assert.Equal(s.T(), "user", sender["type"])
assert.NotContains(s.T(), sender, "email")
}
func (s *MessageHandlerTestSuite) TestCreate_MultipartAttachmentPersistsAndSerializes() {
+32 -1
View File
@@ -1,5 +1,11 @@
package model
import (
"encoding/json"
"path/filepath"
"strings"
)
// Attachment represents a file attachment on a message.
// Reference: Chatwoot Attachment model + P2B M3 spec
type Attachment struct {
@@ -21,4 +27,29 @@ type Attachment struct {
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
}
func (Attachment) TableName() string { return "attachments" }
func (Attachment) TableName() string { return "attachments" }
// PushEventData matches Chatwoot's attachment payload shared by HTTP and WebSocket messages.
func (a *Attachment) PushEventData() map[string]any {
dataURL := a.FileURL
if dataURL == "" {
dataURL = a.ExternalURL
}
payload := map[string]any{
"id": a.ID,
"message_id": a.MessageID,
"file_type": a.FileType,
"account_id": a.AccountID,
"data_url": dataURL,
"thumb_url": a.ThumbURL,
"file_size": a.FileSize,
"extension": strings.TrimPrefix(filepath.Ext(a.FileName), "."),
"width": a.Width,
"height": a.Height,
}
metadata := map[string]any{}
if json.Unmarshal([]byte(a.Metadata), &metadata) == nil && len(metadata) > 0 {
payload["metadata"] = metadata
}
return payload
}
+15
View File
@@ -3,6 +3,8 @@ package model
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/pgvector/pgvector-go"
)
@@ -89,6 +91,19 @@ type CaptainAssistant struct {
func (CaptainAssistant) TableName() string { return "captain_assistants" }
// PushEventData matches Chatwoot's public message-sender contract.
func (a *CaptainAssistant) PushEventData() map[string]any {
avatarURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") + "/assets/images/dashboard/captain/logo.svg"
return map[string]any{
"id": a.ID,
"name": a.Name,
"avatar_url": avatarURL,
"description": a.Description,
"created_at": a.CreatedAt,
"type": "captain_assistant",
}
}
// DefaultAssistantConfig returns the default configuration for a new Captain Assistant.
// Reference: Chatwoot Captain::Assistant default config values
func DefaultAssistantConfig() map[string]interface{} {
+16
View File
@@ -34,6 +34,22 @@ type Contact struct {
func (Contact) TableName() string { return "contacts" }
// PushEventData matches Chatwoot's public message-sender contract.
func (c *Contact) PushEventData() map[string]any {
return map[string]any{
"additional_attributes": jsonMap(c.AdditionalAttributes),
"custom_attributes": jsonMap(c.CustomAttributes),
"email": c.Email,
"id": c.ID,
"identifier": c.Identifier,
"name": c.Name,
"phone_number": c.PhoneNumber,
"thumbnail": c.AvatarURL,
"blocked": c.Blocked,
"type": "contact",
}
}
// BeforeSave syncs location/country_code from additional_attributes and upgrades contact_type.
// Reference: Chatwoot before_save :sync_contact_attributes → Contacts::SyncAttributes
func (c *Contact) BeforeSave(tx *gorm.DB) error {
+21
View File
@@ -47,6 +47,27 @@ type User struct {
func (User) TableName() string { return "users" }
// PushEventData matches Chatwoot's public message-sender contract.
func (u *User) PushEventData() map[string]any {
availableName := u.DisplayName
if availableName == "" {
availableName = u.Name
}
availabilityStatus := "offline"
if u.Available {
availabilityStatus = "online"
}
return map[string]any{
"id": u.ID,
"name": u.Name,
"available_name": availableName,
"avatar_url": u.AvatarURL,
"type": "user",
"availability_status": availabilityStatus,
"thumbnail": u.AvatarURL,
}
}
// BeforeCreate mirrors Chatwoot's Pubsubable concern by assigning every user a cable token.
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.PubsubToken == "" {
+6 -1
View File
@@ -422,9 +422,14 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint
if req.IsVoiceMessage && fileType == "audio" {
attachment.Metadata = `{"is_voice_message":true}`
}
if err := tx.Create(attachment).Error; err != nil {
createAttachment := tx
if attachment.Metadata == "" {
createAttachment = tx.Omit("Metadata")
}
if err := createAttachment.Create(attachment).Error; err != nil {
return err
}
message.Attachments = append(message.Attachments, *attachment)
}
if queueShangwutong && s.worker != nil {
var err error
@@ -375,6 +375,7 @@ func TestMessageService_Create(t *testing.T) {
Content: "测试消息内容",
MessageType: "outgoing",
ContentType: "text",
Attachments: []MessageAttachmentInput{{FileName: "fanout.txt", FileSize: 17, ContentType: "text/plain"}},
}
created, err := svc.Create(ctx, account.ID, user.ID, req)
assert.NoError(t, err)
@@ -386,6 +387,8 @@ func TestMessageService_Create(t *testing.T) {
assert.Equal(t, user.ID, *created.SenderID)
assert.Equal(t, "user", created.SenderType)
assert.False(t, created.Private)
require.Len(t, created.Attachments, 1)
assert.Empty(t, created.Attachments[0].Metadata)
var updatedConversation model.Conversation
require.NoError(t, db.First(&updatedConversation, conv.ID).Error)
require.NotNil(t, updatedConversation.LastActivityAt)
+22 -22
View File
@@ -137,9 +137,23 @@ func messagePushPayload(message *model.Message, data map[string]interface{}) map
payload["message_type"] = messageTypeValue(message.MessageType)
payload["content_type"] = nonEmpty(message.ContentType, "text")
payload["status"] = nonEmpty(message.Status, "sent")
payload["source_id"] = message.SourceID
if message.ContentAttributes == nil {
payload["content_attributes"] = map[string]interface{}{}
}
if message.AdditionalAttributes == nil {
payload["additional_attributes"] = map[string]interface{}{}
}
if message.ExternalSourceIDs == nil {
payload["external_source_ids"] = map[string]interface{}{}
}
if len(message.Attachments) > 0 {
attachments := make([]any, 0, len(message.Attachments))
for i := range message.Attachments {
attachments = append(attachments, message.Attachments[i].PushEventData())
}
payload["attachments"] = attachments
}
conversationID := message.ConversationID
conversationPayload := map[string]interface{}{
@@ -160,20 +174,14 @@ func messagePushPayload(message *model.Message, data map[string]interface{}) map
"source_id": contact.Identifier,
}
if senderType == "Contact" {
payload["sender"] = contactPushPayload(contact)
payload["sender"] = contact.PushEventData()
}
}
payload["conversation_id"] = conversationID
payload["conversation"] = conversationPayload
if senderType == "User" || senderType == "AgentBot" {
if senderType == "User" || senderType == "AgentBot" || senderType == "Captain::Assistant" {
if sender, ok := eventSender(data); ok {
expectedType := "user"
if senderType == "AgentBot" {
expectedType = "agent_bot"
}
if sender["type"] == expectedType {
payload["sender"] = sender
}
payload["sender"] = sender
}
}
payload["sender_type"] = senderType
@@ -186,15 +194,7 @@ func eventSender(data map[string]interface{}) (map[string]interface{}, bool) {
if sender == nil {
return nil, false
}
availableName := sender.DisplayName
if availableName == "" {
availableName = sender.Name
}
return map[string]interface{}{
"id": sender.ID, "name": sender.Name, "available_name": availableName,
"avatar_url": sender.AvatarURL, "thumbnail": sender.AvatarURL,
"type": "user",
}, true
return sender.PushEventData(), true
case model.User:
return eventSender(map[string]interface{}{"sender": &sender})
case *model.AgentBot:
@@ -208,9 +208,7 @@ func eventSender(data map[string]interface{}) (map[string]interface{}, bool) {
if sender == nil {
return nil, false
}
return map[string]interface{}{
"id": sender.ID, "name": sender.Name, "type": "agent_bot",
}, true
return sender.PushEventData(), true
case model.CaptainAssistant:
return eventSender(map[string]interface{}{"sender": &sender})
case map[string]interface{}:
@@ -310,8 +308,10 @@ func senderTypeName(value string) string {
switch strings.ToLower(trimmed) {
case "contact":
return "Contact"
case "agentbot", "agent_bot", "captain::assistant", "captainassistant", "captain_assistant":
case "agentbot", "agent_bot":
return "AgentBot"
case "captain::assistant", "captainassistant", "captain_assistant":
return "Captain::Assistant"
case "user":
return "User"
default:
@@ -68,8 +68,8 @@ func TestBridgeListenerPreservesWebWidgetSenderTypeContract(t *testing.T) {
expectedName string
}{
{senderType: "AgentBot", expectedType: "AgentBot", sender: &model.AgentBot{ID: 7, Name: "Reply Bot"}, expectedName: "Reply Bot"},
{senderType: "Captain::Assistant", expectedType: "AgentBot", sender: &model.CaptainAssistant{Base: model.Base{ID: 8}, Name: "Captain"}, expectedName: "Captain"},
{senderType: "CaptainAssistant", expectedType: "AgentBot", sender: &model.CaptainAssistant{Base: model.Base{ID: 8}, Name: "Captain"}, expectedName: "Captain"},
{senderType: "Captain::Assistant", expectedType: "Captain::Assistant", sender: &model.CaptainAssistant{Base: model.Base{ID: 8}, Name: "Captain"}, expectedName: "Captain"},
{senderType: "CaptainAssistant", expectedType: "Captain::Assistant", sender: &model.CaptainAssistant{Base: model.Base{ID: 8}, Name: "Captain"}, expectedName: "Captain"},
{senderType: "CustomSender", expectedType: "CustomSender"},
}
for _, tt := range tests {
@@ -105,7 +105,11 @@ func TestBridgeListenerPreservesWebWidgetSenderTypeContract(t *testing.T) {
return
}
sender, ok := payload["sender"].(map[string]interface{})
if !ok || sender["name"] != tt.expectedName || sender["type"] != "agent_bot" {
expectedSenderObjectType := "agent_bot"
if tt.expectedType == "Captain::Assistant" {
expectedSenderObjectType = "captain_assistant"
}
if !ok || sender["name"] != tt.expectedName || sender["type"] != expectedSenderObjectType {
t.Fatalf("sender does not match sender_type: %#v", payload)
}
})