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;
|
||||
Reference in New Issue
Block a user