feat(shangwutong): sync customer names back to channel (#3)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user