feat(shangwutong): sync customer names back to channel (#3)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-12 22:58:46 +08:00
committed by GitHub
co-authored by rogee
parent 83a8ab315a
commit 406e7a07fe
25 changed files with 284 additions and 20 deletions
+2 -1
View File
@@ -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),
@@ -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)
}
@@ -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
}
+10 -7
View File
@@ -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"`
@@ -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
}
@@ -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
}
@@ -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 {
@@ -0,0 +1,2 @@
ALTER TABLE contact_inboxes
DROP COLUMN IF EXISTS channel_metadata;
@@ -0,0 +1,2 @@
ALTER TABLE contact_inboxes
ADD COLUMN IF NOT EXISTS channel_metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -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'))
);
@@ -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';
@@ -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';
@@ -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)
}
@@ -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
@@ -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])
@@ -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 {
@@ -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)
}
@@ -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()
@@ -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 {
@@ -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"`
@@ -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 {
@@ -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)
@@ -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)
}
}
@@ -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")
}
@@ -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)
}
}