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;