diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index d72dff24..5e530679 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -376,6 +376,7 @@ func Bootstrap(env string) (*App, error) { channelDispatcher.Register(igListener) channelDispatcher.Register(channel.NewWebhookListenerWithDB(db)) channelDispatcher.Register(service.NewShangwutongTypingWebhookListener(db)) + channelDispatcher.Register(service.NewShangwutongContactListener(db, workerPool)) // Create Facebook webhook handler (Gin HTTP handler for FB/IG webhook endpoints) facebookWebhookHandler := webhook.NewFacebookWebhookHandler(fbProvider, igProvider, db, channelDispatcher) facebookWebhookHandler.WithWorkerPool(workerPool) @@ -781,7 +782,7 @@ func Bootstrap(env string) (*App, error) { Auth: v1.NewAuthHandler(authService, profileService), Account: v1.NewAccountHandler(accountService), EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService), - Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher), + Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher).WithChannelDispatcher(channelDispatcher), Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker), Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService), InboxMember: v1.NewInboxMemberHandler(inboxMemberService), diff --git a/backend/internal/handler/api/v1/contact_handler.go b/backend/internal/handler/api/v1/contact_handler.go index c84bb7e3..ecb433ac 100644 --- a/backend/internal/handler/api/v1/contact_handler.go +++ b/backend/internal/handler/api/v1/contact_handler.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" @@ -23,13 +24,14 @@ import ( // ContactHandler handles contact-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb type ContactHandler struct { - svc *service.ContactService - contactInboxSvc *service.ContactInboxService - mergeSvc *service.ContactMergeService - contactNoteSvc *service.ContactNoteService - conversationSvc *service.ConversationService - presence contactPresenceReader - eventPublisher *ws.EventPublisher + svc *service.ContactService + contactInboxSvc *service.ContactInboxService + mergeSvc *service.ContactMergeService + contactNoteSvc *service.ContactNoteService + conversationSvc *service.ConversationService + presence contactPresenceReader + eventPublisher *ws.EventPublisher + channelDispatcher *channel.Dispatcher } const chatwootContactResultsPerPage = 15 @@ -48,6 +50,11 @@ func (h *ContactHandler) WithEventPublisher(publisher *ws.EventPublisher) *Conta return h } +func (h *ContactHandler) WithChannelDispatcher(dispatcher *channel.Dispatcher) *ContactHandler { + h.channelDispatcher = dispatcher + return h +} + func (h *ContactHandler) WithContactPresence(presence contactPresenceReader) *ContactHandler { h.presence = presence return h @@ -269,6 +276,7 @@ func (h *ContactHandler) Update(c *gin.Context) { return } + before, _ := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID) contact, svcErr := h.svc.Update(c.Request.Context(), accountID, contactID, req) if svcErr != nil { var validationErr *service.ContactValidationError @@ -282,6 +290,12 @@ func (h *ContactHandler) Update(c *gin.Context) { payload := contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c)) h.publishContactEvent(accountID, ws.EventContactUpdated, contact) + if h.channelDispatcher != nil && req.Name != "" && (before == nil || before.Name != contact.Name) { + event := channel.NewChannelEvent(channel.EventContactUpdated, channel.ChannelAPI, accountID, 0) + event.ContactID, event.Data["contact"] = contact.ID, contact + event.Data["changed_attributes"] = map[string]any{"name": contact.Name} + _ = h.channelDispatcher.Dispatch(c.Request.Context(), event) + } c.JSON(http.StatusOK, payload) } diff --git a/backend/internal/handler/widget/widget_handler.go b/backend/internal/handler/widget/widget_handler.go index ef414fe6..4a34fff7 100644 --- a/backend/internal/handler/widget/widget_handler.go +++ b/backend/internal/handler/widget/widget_handler.go @@ -1402,6 +1402,7 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err PhoneNumber string `json:"phone_number"` CustomAttributes map[string]any `json:"custom_attributes"` AdditionalAttributes map[string]any `json:"additional_attributes"` + ChannelMetadata map[string]any `json:"channel_metadata"` } if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 { return service.PublicContactRequest{}, err @@ -1425,6 +1426,7 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err PhoneNumber: body.PhoneNumber, CustomAttributes: body.CustomAttributes, AdditionalAttributes: body.AdditionalAttributes, + ChannelMetadata: body.ChannelMetadata, }, nil } diff --git a/backend/internal/model/contact_inbox.go b/backend/internal/model/contact_inbox.go index 48597243..dbb7e1ae 100644 --- a/backend/internal/model/contact_inbox.go +++ b/backend/internal/model/contact_inbox.go @@ -1,16 +1,19 @@ package model +import "gorm.io/datatypes" + // ContactInbox represents the join between Contact and Inbox with source info. // Reference: Chatwoot ContactInbox model + P2B M4 spec type ContactInbox struct { Base - ContactID uint `gorm:"column:contact_id;not null;index" json:"contact_id"` - InboxID uint `gorm:"column:inbox_id;not null;index" json:"inbox_id"` - SourceID string `gorm:"column:source_id;size:255;index" json:"source_id"` - HMACToken string `gorm:"column:hmac_token;size:255" json:"hmac_token"` - HMACVerified bool `gorm:"column:hmac_verified;default:false" json:"hmac_verified"` - PubsubToken string `gorm:"column:pubsub_token;size:255" json:"pubsub_token"` - HasAroundFlag bool `gorm:"column:has_around_flag;default:false" json:"has_around_flag"` + ContactID uint `gorm:"column:contact_id;not null;index" json:"contact_id"` + InboxID uint `gorm:"column:inbox_id;not null;index" json:"inbox_id"` + SourceID string `gorm:"column:source_id;size:255;index" json:"source_id"` + HMACToken string `gorm:"column:hmac_token;size:255" json:"hmac_token"` + HMACVerified bool `gorm:"column:hmac_verified;default:false" json:"hmac_verified"` + PubsubToken string `gorm:"column:pubsub_token;size:255" json:"pubsub_token"` + HasAroundFlag bool `gorm:"column:has_around_flag;default:false" json:"has_around_flag"` + ChannelMetadata datatypes.JSON `gorm:"column:channel_metadata;type:jsonb;default:'{}'" json:"-"` Contact Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"` Inbox Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"` diff --git a/backend/internal/service/shangwutong_contact_listener.go b/backend/internal/service/shangwutong_contact_listener.go new file mode 100644 index 00000000..5af47d64 --- /dev/null +++ b/backend/internal/service/shangwutong_contact_listener.go @@ -0,0 +1,54 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + + "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/worker" + "gorm.io/gorm" +) + +type ShangwutongContactListener struct { + db *gorm.DB + worker *worker.WorkerPool +} + +func NewShangwutongContactListener(db *gorm.DB, pool *worker.WorkerPool) *ShangwutongContactListener { + return &ShangwutongContactListener{db: db, worker: pool} +} + +func (l *ShangwutongContactListener) Name() string { return "shangwutong-contact-webhook" } + +func (l *ShangwutongContactListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { + if l == nil || l.db == nil || l.worker == nil || event == nil || event.Type != channel.EventContactUpdated || event.ContactID == 0 { + return nil + } + var contact model.Contact + if err := l.db.WithContext(ctx).Where("id = ? AND account_id = ?", event.ContactID, event.AccountID).First(&contact).Error; err != nil { + return err + } + var inboxes []model.ContactInbox + if err := l.db.WithContext(ctx).Preload("Inbox").Where("contact_id = ?", contact.ID).Find(&inboxes).Error; err != nil { + return err + } + for i := range inboxes { + if inboxes[i].Inbox.ChannelType != "shangwutong" || strings.TrimSpace(inboxes[i].SourceID) == "" { + continue + } + var metadata struct { + CID string `json:"cid"` + } + if err := json.Unmarshal(inboxes[i].ChannelMetadata, &metadata); err != nil || strings.TrimSpace(metadata.CID) == "" { + continue + } + job := newShangwutongContactJob(&contact, &inboxes[i]) + job.CID = strings.TrimSpace(metadata.CID) + if _, err := l.worker.Enqueue(ctx, TaskTypeShangwutongWebhookDelivery, job, worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/service/shangwutong_webhook_delivery.go b/backend/internal/service/shangwutong_webhook_delivery.go index 6286424e..c2f0ba75 100644 --- a/backend/internal/service/shangwutong_webhook_delivery.go +++ b/backend/internal/service/shangwutong_webhook_delivery.go @@ -36,6 +36,10 @@ type shangwutongWebhookDeliveryJob struct { InboxID uint `json:"inbox_id"` ConfigVersion int64 `json:"config_version"` MessageID uint `json:"message_id,omitempty"` + ContactID uint `json:"contact_id,omitempty"` + SourceID string `json:"source_id,omitempty"` + CID string `json:"cid,omitempty"` + ContactName string `json:"contact_name,omitempty"` RetryVersion int64 `json:"retry_version,omitempty"` ConversationID uint `json:"conversation_id,omitempty"` ConversationVersion int64 `json:"conversation_version,omitempty"` @@ -202,6 +206,10 @@ func (r *shangwutongWebhookDeliveryRunner) payload(ctx context.Context, job shan return nil, err } data = typingData + case "contact_updated": + data = map[string]any{"contact": map[string]any{ + "id": job.ContactID, "source_id": job.SourceID, "name": job.ContactName, + }, "cid": job.CID, "cnote": ""} default: return nil, fmt.Errorf("unsupported shangwutong webhook event %q", job.Event) } @@ -376,6 +384,15 @@ func newShangwutongConversationStatusJob(conversation *model.Conversation, previ } } +func newShangwutongContactJob(contact *model.Contact, contactInbox *model.ContactInbox) shangwutongWebhookDeliveryJob { + version := contact.UpdatedAt.UTC().UnixNano() + return shangwutongWebhookDeliveryJob{ + Event: "contact_updated", EventID: fmt.Sprintf("contact:%d:inbox:%d:name:%d", contact.ID, contactInbox.InboxID, version), + OccurredAt: contact.UpdatedAt.UTC(), AccountID: contact.AccountID, InboxID: contactInbox.InboxID, + ContactID: contact.ID, SourceID: contactInbox.SourceID, ContactName: contact.Name, + } +} + type ShangwutongTypingWebhookListener struct { runner *shangwutongWebhookDeliveryRunner } diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index 0426aa6d..5f3faa66 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -206,6 +206,7 @@ type PublicContactRequest struct { PhoneNumber string CustomAttributes map[string]any AdditionalAttributes map[string]any + ChannelMetadata map[string]any } type PublicContactResponse struct { @@ -778,6 +779,7 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier if err != nil { return nil, err } + setContactInboxChannelMetadata(existingInbox, req.ChannelMetadata) existingInbox.Contact = *contact return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil } @@ -801,6 +803,7 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier HMACToken: hmacToken, HMACVerified: req.IdentifierHash != "", } + setContactInboxChannelMetadata(contactInbox, req.ChannelMetadata) if err := s.contactInboxRepo.Create(ctx, contactInbox); err != nil { return nil, err } @@ -835,6 +838,11 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier if err != nil { return nil, err } + if metadataChanged := setContactInboxChannelMetadata(contactInbox, req.ChannelMetadata); metadataChanged { + if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil { + return nil, err + } + } if req.IdentifierHash != "" && !contactInbox.HMACVerified { contactInbox.HMACVerified = true if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil { @@ -844,6 +852,18 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil } +func setContactInboxChannelMetadata(contactInbox *model.ContactInbox, metadata map[string]any) bool { + if contactInbox == nil || len(metadata) == 0 { + return false + } + cid, ok := metadata["cid"].(string) + if !ok || strings.TrimSpace(cid) == "" { + return false + } + contactInbox.ChannelMetadata = mustJSON(map[string]any{"cid": strings.TrimSpace(cid)}) + return true +} + func (s *WidgetService) PublicListConversations(ctx context.Context, inboxIdentifier, sourceID string) ([]model.Conversation, error) { _, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID) if err != nil { diff --git a/backend/migrations/000077_add_contact_inbox_channel_metadata.down.sql b/backend/migrations/000077_add_contact_inbox_channel_metadata.down.sql new file mode 100644 index 00000000..b3982860 --- /dev/null +++ b/backend/migrations/000077_add_contact_inbox_channel_metadata.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE contact_inboxes + DROP COLUMN IF EXISTS channel_metadata; diff --git a/backend/migrations/000077_add_contact_inbox_channel_metadata.up.sql b/backend/migrations/000077_add_contact_inbox_channel_metadata.up.sql new file mode 100644 index 00000000..94c763a9 --- /dev/null +++ b/backend/migrations/000077_add_contact_inbox_channel_metadata.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE contact_inboxes + ADD COLUMN IF NOT EXISTS channel_metadata JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/channels/shangwutong/db/migrations/001_init.up.sql b/channels/shangwutong/db/migrations/001_init.up.sql index eefca02e..8dbc9374 100644 --- a/channels/shangwutong/db/migrations/001_init.up.sql +++ b/channels/shangwutong/db/migrations/001_init.up.sql @@ -195,7 +195,7 @@ CREATE TABLE outbound_operations ( last_error TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CHECK(operation IN ('end_conversation')), + CHECK(operation IN ('end_conversation', 'change_contact_name')), CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')) ); diff --git a/channels/shangwutong/db/migrations/002_add_rename_operation.down.sql b/channels/shangwutong/db/migrations/002_add_rename_operation.down.sql new file mode 100644 index 00000000..8a5b7fa4 --- /dev/null +++ b/channels/shangwutong/db/migrations/002_add_rename_operation.down.sql @@ -0,0 +1,21 @@ +CREATE TABLE outbound_operations_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + event_id TEXT NOT NULL UNIQUE, + operation TEXT NOT NULL CHECK(operation IN ('end_conversation')), + payload TEXT NOT NULL, + occurred_at DATETIME NOT NULL, + delivery_status TEXT NOT NULL DEFAULT 'pending' CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')), + claimed_at DATETIME, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO outbound_operations_old SELECT * FROM outbound_operations WHERE operation = 'end_conversation'; +DROP TABLE outbound_operations; +ALTER TABLE outbound_operations_old RENAME TO outbound_operations; +CREATE INDEX idx_outbound_operations_ready ON outbound_operations(delivery_status, next_attempt_at, account_id, id); +CREATE UNIQUE INDEX idx_outbound_one_delivering_operation_per_account ON outbound_operations(account_id) WHERE delivery_status = 'delivering'; diff --git a/channels/shangwutong/db/migrations/002_add_rename_operation.up.sql b/channels/shangwutong/db/migrations/002_add_rename_operation.up.sql new file mode 100644 index 00000000..11326b7d --- /dev/null +++ b/channels/shangwutong/db/migrations/002_add_rename_operation.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE outbound_operations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + event_id TEXT NOT NULL UNIQUE, + operation TEXT NOT NULL, + payload TEXT NOT NULL, + occurred_at DATETIME NOT NULL, + delivery_status TEXT NOT NULL DEFAULT 'pending', + claimed_at DATETIME, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(operation IN ('end_conversation', 'change_contact_name')), + CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')) +); +INSERT INTO outbound_operations_new SELECT * FROM outbound_operations; +DROP TABLE outbound_operations; +ALTER TABLE outbound_operations_new RENAME TO outbound_operations; +CREATE INDEX idx_outbound_operations_ready ON outbound_operations(delivery_status, next_attempt_at, account_id, id); +CREATE UNIQUE INDEX idx_outbound_one_delivering_operation_per_account ON outbound_operations(account_id) WHERE delivery_status = 'delivering'; diff --git a/channels/shangwutong/internal/command/root_test.go b/channels/shangwutong/internal/command/root_test.go index 8e574cf0..f6ec62ab 100644 --- a/channels/shangwutong/internal/command/root_test.go +++ b/channels/shangwutong/internal/command/root_test.go @@ -87,7 +87,7 @@ func TestOperationalCommands(t *testing.T) { t.Fatalf("%v produced no output", args) } } - if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 1 { + if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 2 { t.Fatalf("backup version = %d, %v", version, err) } diff --git a/channels/shangwutong/internal/delivery/inbound.go b/channels/shangwutong/internal/delivery/inbound.go index f220521b..f546ba5d 100644 --- a/channels/shangwutong/internal/delivery/inbound.go +++ b/channels/shangwutong/internal/delivery/inbound.go @@ -162,6 +162,9 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone, CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional, } + if mapped.ContactCID != "" { + contactRequest.ChannelMetadata = map[string]any{"cid": mapped.ContactCID} + } needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0 if mapped.RequiresContact && needsContactWrite { var contact gochat.Contact diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go index 99757885..47483ccc 100644 --- a/channels/shangwutong/internal/delivery/mapping.go +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -19,6 +19,7 @@ type mappedEvent struct { Strategy string Subtype string ContactName string + ContactCID string ContactPhone string ContactAttributes map[string]any ContactAdditional map[string]any @@ -167,7 +168,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true mapped.ContactName = cleanText(text) case 52: - // History formats vary by server version; unverified batches stay replayable raw. + mapped.ContactCID = parseHistoryCID(text) case 56: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true mapped.ConversationAttrs = map[string]any{"swt_conversation_type": cleanText(text)} @@ -191,6 +192,18 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour return mapped } +func parseHistoryCID(text string) string { + for _, record := range strings.Split(text, "#") { + fields := strings.Split(record, "|") + if len(fields) > 5 { + if cid, err := url.QueryUnescape(fields[1]); err == nil && strings.TrimSpace(cid) != "" { + return strings.TrimSpace(cid) + } + } + } + return "" +} + func mapSystemEvent(seqID int64, text, operator, rawTimestamp, sourceID string, inboxID int64, fallbackTime time.Time, baseAttributes map[string]any) mappedEvent { parts := strings.Split(text, "|") subtype := strings.TrimSpace(parts[0]) diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go index 5c35471b..7b34da8f 100644 --- a/channels/shangwutong/internal/delivery/mapping_test.go +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -63,6 +63,13 @@ func TestKind12UpdatesContactNickname(t *testing.T) { } } +func TestKind52ExtractsCIDWithoutTreatingHistoryAsMessages(t *testing.T) { + mapped := mapInboundEvent(52, 42, "a|cid%2F123|x|y|z|name", "", "", "source", 10, time.Now()) + if mapped.ContactCID != "cid/123" || !mapped.RawOnly || mapped.Message != nil { + t.Fatalf("history identity mapping = %#v", mapped) + } +} + func TestKind26UpdatesCurrentContactPhone(t *testing.T) { mapped := mapInboundEvent(26, 42, "张三|13800138000", "", "", "source", 10, time.Now()) if mapped.ContactName != "张三" || mapped.ContactPhone != "+8613800138000" || !mapped.RequiresContact { diff --git a/channels/shangwutong/internal/delivery/outbound.go b/channels/shangwutong/internal/delivery/outbound.go index 63609bb1..b21662df 100644 --- a/channels/shangwutong/internal/delivery/outbound.go +++ b/channels/shangwutong/internal/delivery/outbound.go @@ -3,6 +3,7 @@ package delivery import ( "context" "database/sql" + "encoding/json" "errors" "fmt" randv2 "math/rand/v2" @@ -35,6 +36,7 @@ type MessageSender interface { SendFile(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) SendVoice(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) EndConversation(context.Context, swt.Session, string) error + ChangeContactName(context.Context, swt.Session, string, string, string) error } type ResultClient interface { @@ -408,6 +410,19 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) { switch operation.Operation { case "end_conversation": return o.sender.EndConversation(ctx, session, operation.SwtSid) + case "change_contact_name": + var envelope struct { + Data struct { + CID string `json:"cid"` + Contact struct { + Name string `json:"name"` + } `json:"contact"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || envelope.Data.CID == "" || envelope.Data.Contact.Name == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("cid and cname are required")} + } + return o.sender.ChangeContactName(ctx, session, operation.SwtSid, envelope.Data.CID, envelope.Data.Contact.Name) default: return fmt.Errorf("unsupported outbound operation %q", operation.Operation) } diff --git a/channels/shangwutong/internal/delivery/outbound_test.go b/channels/shangwutong/internal/delivery/outbound_test.go index a6bcd051..51f34030 100644 --- a/channels/shangwutong/internal/delivery/outbound_test.go +++ b/channels/shangwutong/internal/delivery/outbound_test.go @@ -313,6 +313,9 @@ func (s senderStub) SendVoice(context.Context, swt.Session, string, swt.Upload) } func (s senderStub) EndConversation(context.Context, swt.Session, string) error { return s.err } +func (s senderStub) ChangeContactName(context.Context, swt.Session, string, string, string) error { + return s.err +} type operationSender struct{ sid string } @@ -336,6 +339,9 @@ func (s *operationSender) EndConversation(_ context.Context, _ swt.Session, sid s.sid = sid return nil } +func (*operationSender) ChangeContactName(context.Context, swt.Session, string, string, string) error { + return nil +} type partialSender struct { textCalls int @@ -365,6 +371,9 @@ func (*partialSender) SendVoice(context.Context, swt.Session, string, swt.Upload } func (*partialSender) EndConversation(context.Context, swt.Session, string) error { return nil } +func (*partialSender) ChangeContactName(context.Context, swt.Session, string, string, string) error { + return nil +} func testUploadFetcher(t *testing.T) mediaFetcher { t.Helper() diff --git a/channels/shangwutong/internal/gochat/messaging.go b/channels/shangwutong/internal/gochat/messaging.go index 3d1a6dcb..618d6d74 100644 --- a/channels/shangwutong/internal/gochat/messaging.go +++ b/channels/shangwutong/internal/gochat/messaging.go @@ -31,6 +31,7 @@ type ContactRequest struct { AvatarURL string `json:"avatar_url,omitempty"` CustomAttributes map[string]any `json:"custom_attributes,omitempty"` AdditionalAttributes map[string]any `json:"additional_attributes,omitempty"` + ChannelMetadata map[string]any `json:"channel_metadata,omitempty"` } type Contact struct { diff --git a/channels/shangwutong/internal/gochat/webhook.go b/channels/shangwutong/internal/gochat/webhook.go index f0188d38..27c117cc 100644 --- a/channels/shangwutong/internal/gochat/webhook.go +++ b/channels/shangwutong/internal/gochat/webhook.go @@ -54,6 +54,12 @@ type ConversationStatusWebhookData struct { Actor WebhookActor `json:"actor"` } +type ContactUpdatedWebhookData struct { + Contact WebhookContact `json:"contact"` + CID string `json:"cid"` + CNote string `json:"cnote"` +} + type TypingWebhookData struct { Conversation WebhookConversation `json:"conversation"` Actor WebhookActor `json:"actor"` diff --git a/channels/shangwutong/internal/httpapi/server.go b/channels/shangwutong/internal/httpapi/server.go index 43ff898d..edf9b150 100644 --- a/channels/shangwutong/internal/httpapi/server.go +++ b/channels/shangwutong/internal/httpapi/server.go @@ -280,6 +280,8 @@ func (s *Server) dispatchVerifiedWebhook(c fiber.Ctx, envelope gochat.WebhookEnv return s.acceptMessage(c, envelope, body, deliveryID, local) case "conversation_status_changed": return s.acceptConversationStatus(c, envelope, body, deliveryID, local) + case "contact_updated": + return s.acceptContactUpdate(c, envelope, body, deliveryID, local) case "conversation_typing_on", "conversation_typing_off": return s.acceptTyping(c, envelope, deliveryID, local) default: @@ -287,6 +289,27 @@ func (s *Server) dispatchVerifiedWebhook(c fiber.Ctx, envelope gochat.WebhookEnv } } +func (s *Server) acceptContactUpdate(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { + var data gochat.ContactUpdatedWebhookData + if err := json.Unmarshal(envelope.Data, &data); err != nil || data.Contact.ID <= 0 || strings.TrimSpace(data.Contact.SourceID) == "" || strings.TrimSpace(data.CID) == "" || strings.TrimSpace(data.Contact.Name) == "" { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_contact_update", "contact update payload is invalid", false) + } + queued, duplicate, err := s.store.EnqueueOutboundOperation(c.Context(), store.OutboundOperationInput{ + AccountID: local.ID, SWTSessionID: data.Contact.SourceID, EventID: envelope.EventID, Operation: "change_contact_name", Payload: string(body), OccurredAt: envelope.OccurredAt, + }) + if errors.Is(err, store.ErrOutboundConflict) { + return s.writeError(c, http.StatusConflict, "idempotency_conflict", "contact operation conflicts with webhook", false) + } + if err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "contact operation queue persistence failed", true) + } + status := http.StatusAccepted + if duplicate { + status = http.StatusOK + } + return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0) +} + func (s *Server) acceptConversationStatus(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { var data gochat.ConversationStatusWebhookData if err := json.Unmarshal(envelope.Data, &data); err != nil { diff --git a/channels/shangwutong/internal/store/outbound.go b/channels/shangwutong/internal/store/outbound.go index 2de2cc89..77b60d14 100644 --- a/channels/shangwutong/internal/store/outbound.go +++ b/channels/shangwutong/internal/store/outbound.go @@ -127,7 +127,7 @@ type OutboundOperationInput struct { } func (s *Store) EnqueueOutboundOperation(ctx context.Context, input OutboundOperationInput) (*dbgen.OutboundOperation, bool, error) { - if input.AccountID <= 0 || strings.TrimSpace(input.SWTSessionID) == "" || strings.TrimSpace(input.EventID) == "" || input.Operation != "end_conversation" || input.OccurredAt.IsZero() { + if input.AccountID <= 0 || strings.TrimSpace(input.SWTSessionID) == "" || strings.TrimSpace(input.EventID) == "" || (input.Operation != "end_conversation" && input.Operation != "change_contact_name") || input.OccurredAt.IsZero() { return nil, false, errors.New("valid outbound operation fields are required") } existing, err := s.writerQueries.GetOutboundOperationByEventID(ctx, input.EventID) diff --git a/channels/shangwutong/internal/store/store_test.go b/channels/shangwutong/internal/store/store_test.go index cfb117d1..8d3113b3 100644 --- a/channels/shangwutong/internal/store/store_test.go +++ b/channels/shangwutong/internal/store/store_test.go @@ -451,7 +451,7 @@ func TestOnlineBackupCanBeOpenedReadOnly(t *testing.T) { t.Fatalf("backup mode = %v", info.Mode().Perm()) } version, err := InspectDatabase(ctx, backup) - if err != nil || version != 1 { + if err != nil || version != 2 { t.Fatalf("backup version = %d, %v", version, err) } } diff --git a/channels/shangwutong/internal/swt/operations.go b/channels/shangwutong/internal/swt/operations.go index 9d96c84c..dbab23a6 100644 --- a/channels/shangwutong/internal/swt/operations.go +++ b/channels/shangwutong/internal/swt/operations.go @@ -98,6 +98,15 @@ func (c *Client) EndConversation(ctx context.Context, session Session, sid strin return c.sessionOperation(ctx, session, "oc/end.aspx", map[string]string{"sid": sid}, "end_conversation") } +func (c *Client) ChangeContactName(ctx context.Context, session Session, sid, cid, name string) error { + if strings.TrimSpace(cid) == "" || strings.TrimSpace(name) == "" { + return errors.New("cid and name are required") + } + return c.sessionOperation(ctx, session, "oc/changecname.aspx", map[string]string{ + "sid": sid, "cid": cid, "cname": name, "cnote": "", + }, "change_contact_name") +} + func (c *Client) AcceptTransfer(ctx context.Context, session Session, sid string) error { return c.sessionOperation(ctx, session, "oc/accepttransfer.aspx", map[string]string{"sid": sid}, "accept_transfer") } diff --git a/channels/shangwutong/internal/swt/operations_test.go b/channels/shangwutong/internal/swt/operations_test.go index 8ed17d79..58513b51 100644 --- a/channels/shangwutong/internal/swt/operations_test.go +++ b/channels/shangwutong/internal/swt/operations_test.go @@ -99,3 +99,22 @@ func TestSessionOperationsUseDocumentedEndpoints(t *testing.T) { } } } + +func TestChangeContactNameUsesDocumentedForm(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/oc/changecname.aspx" { + t.Fatalf("path = %q", request.URL.Path) + } + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + if request.Form.Get("sid") != "visitor" || request.Form.Get("cid") != "cid-1" || request.Form.Get("cname") != "新名字" || request.Form.Get("cnote") != "" { + t.Fatalf("form = %#v", request.Form) + } + response.Header().Set("r", "ok") + })) + defer server.Close() + if err := NewClient(rewriteTransportClient(server.URL)).ChangeContactName(context.Background(), testSession(), "visitor", "cid-1", "新名字"); err != nil { + t.Fatal(err) + } +}