feat(public): implement chatwoot public inbox flow

This commit is contained in:
2026-06-04 22:21:52 +08:00
parent a0003c7c06
commit 8dce39f85f
6 changed files with 880 additions and 19 deletions
+349
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/search"
ws "github.com/gochat/gochat/internal/ws"
@@ -162,6 +163,32 @@ type WidgetMessageUpdate struct {
SubmittedValues []map[string]any
}
type PublicContactRequest struct {
SourceID string
Identifier string
IdentifierHash string
Email string
Name string
AvatarURL string
PhoneNumber string
CustomAttributes map[string]any
}
type PublicContactResponse struct {
ContactInbox *model.ContactInbox
Contact *model.Contact
}
type PublicConversationRequest struct {
CustomAttributes map[string]any
}
type PublicMessageRequest struct {
Content string
EchoID string
SubmittedValues []map[string]any
}
type WidgetInboxMember struct {
ID uint `json:"id"`
Name string `json:"name"`
@@ -547,6 +574,240 @@ func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.
return contact, nil
}
func (s *WidgetService) PublicGetInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, bool, error) {
inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
if err != nil {
return nil, false, err
}
return inbox, channelAPI.HMACToken != "", nil
}
func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier string, req PublicContactRequest) (*PublicContactResponse, error) {
inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
if err != nil {
return nil, err
}
if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil {
return nil, err
}
if req.SourceID == "" {
req.SourceID, err = generateToken(16)
if err != nil {
return nil, err
}
}
if existingInbox, err := s.contactInboxRepo.FindBySourceID(ctx, inbox.ID, req.SourceID); err == nil {
contact, err := s.contactRepo.FindByID(ctx, existingInbox.ContactID)
if err != nil {
return nil, err
}
existingInbox.Contact = *contact
return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil
}
contact, err := s.findPublicContact(ctx, inbox.AccountID, req)
if err != nil {
return nil, err
}
pubsubToken, err := generateToken(32)
if err != nil {
return nil, err
}
hmacToken, err := generateToken(32)
if err != nil {
return nil, err
}
contactInbox := &model.ContactInbox{
ContactID: contact.ID,
InboxID: inbox.ID,
SourceID: req.SourceID,
PubsubToken: pubsubToken,
HMACToken: hmacToken,
HMACVerified: req.IdentifierHash != "",
}
if err := s.contactInboxRepo.Create(ctx, contactInbox); err != nil {
return nil, err
}
return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil
}
func (s *WidgetService) PublicGetContact(ctx context.Context, inboxIdentifier, sourceID string) (*PublicContactResponse, error) {
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
if err != nil {
return nil, err
}
return &PublicContactResponse{ContactInbox: contactInbox, Contact: &contactInbox.Contact}, nil
}
func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier, sourceID string, req PublicContactRequest) (*PublicContactResponse, error) {
channelAPI, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
if err != nil {
return nil, err
}
if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil {
return nil, err
}
contact, err := s.updateContactFields(ctx, &contactInbox.Contact, WidgetContactUpdate{
Name: req.Name,
Email: strings.ToLower(req.Email),
PhoneNumber: req.PhoneNumber,
Identifier: req.Identifier,
AvatarURL: req.AvatarURL,
CustomAttributes: req.CustomAttributes,
})
if err != nil {
return nil, err
}
if req.IdentifierHash != "" && !contactInbox.HMACVerified {
contactInbox.HMACVerified = true
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
return nil, err
}
}
return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil
}
func (s *WidgetService) PublicListConversations(ctx context.Context, inboxIdentifier, sourceID string) ([]model.Conversation, error) {
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
if err != nil {
return nil, err
}
conversations, _, err := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 100)
if err != nil {
return nil, err
}
return filterPublicConversations(conversations, contactInbox), nil
}
func (s *WidgetService) PublicCreateConversation(ctx context.Context, inboxIdentifier, sourceID string, req PublicConversationRequest) (*model.Conversation, error) {
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
if err != nil {
return nil, err
}
conversation, err := s.createWidgetConversation(ctx, contactInbox)
if err != nil {
return nil, err
}
if len(req.CustomAttributes) > 0 {
conversation.CustomAttributes = mustJSON(req.CustomAttributes)
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
return nil, err
}
}
return conversation, nil
}
func (s *WidgetService) PublicGetConversation(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
if err != nil {
return nil, err
}
conversations, err := s.PublicListConversations(ctx, inboxIdentifier, sourceID)
if err != nil {
return nil, err
}
for i := range conversations {
if publicConversationID(conversations[i]) == displayID && conversations[i].InboxID == contactInbox.InboxID {
return &conversations[i], nil
}
}
return nil, errors.New("conversation not found")
}
func (s *WidgetService) PublicToggleStatus(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
if err != nil {
return nil, err
}
conversation.Status = string(model.ConversationStatusResolved)
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
return nil, err
}
return conversation, nil
}
func (s *WidgetService) PublicUpdateLastSeen(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
if err != nil {
return nil, err
}
now := time.Now().Unix()
conversation.ContactLastSeenAt = &now
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
return nil, err
}
return conversation, nil
}
func (s *WidgetService) PublicToggleTyping(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, typing bool) error {
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
if err != nil {
return err
}
if s.typingIndicator == nil {
return nil
}
performer := &ws.Performer{ID: conversation.ContactID, Type: "contact"}
if typing {
return s.typingIndicator.SetTypingOn(ctx, conversation.AccountID, conversation.ID, performer)
}
return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversation.ID, performer)
}
func (s *WidgetService) PublicListMessages(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, offset, limit int) ([]model.Message, int64, *model.Conversation, error) {
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
if err != nil {
return nil, 0, nil, err
}
messages, total, err := s.messageRepo.FindByConversation(ctx, conversation.ID, offset, limit)
return messages, total, conversation, err
}
func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) {
if strings.TrimSpace(req.Content) == "" {
return nil, nil, errors.New("content is required")
}
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
if err != nil {
return nil, nil, err
}
message := &model.Message{
ConversationID: conversation.ID,
AccountID: conversation.AccountID,
InboxID: conversation.InboxID,
SenderID: &conversation.ContactID,
SenderType: "Contact",
Content: req.Content,
ContentType: "text",
MessageType: "incoming",
Status: "sent",
SourceID: req.EchoID,
}
if err := s.messageRepo.Create(ctx, message); err != nil {
return nil, nil, err
}
return message, conversation, nil
}
func (s *WidgetService) PublicUpdateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID, messageID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) {
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
if err != nil {
return nil, nil, err
}
message, err := s.messageRepo.FindByConversationAndID(ctx, conversation.ID, messageID)
if err != nil {
return nil, nil, err
}
attrs := jsonMap(message.ContentAttributes)
if req.SubmittedValues != nil {
attrs["submitted_values"] = req.SubmittedValues
message.ContentAttributes = mustJSON(attrs)
}
if err := s.messageRepo.Update(ctx, message); err != nil {
return nil, nil, err
}
return message, conversation, nil
}
// GetMessages returns messages for a conversation belonging to a widget contact.
func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
@@ -1241,3 +1502,91 @@ func splitWidgetLabels(raw string) []string {
}
return labels
}
func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, req PublicContactRequest) (*model.Contact, error) {
if req.Identifier != "" {
if contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, req.Identifier); err == nil {
return contact, nil
}
}
if req.Email != "" {
if contact, err := s.contactRepo.FindByEmail(ctx, accountID, strings.ToLower(req.Email)); err == nil {
return contact, nil
}
}
contact := &model.Contact{
AccountID: accountID,
Name: req.Name,
Email: strings.ToLower(req.Email),
PhoneNumber: req.PhoneNumber,
AvatarURL: req.AvatarURL,
Identifier: req.Identifier,
ContactType: "visitor",
CustomAttributes: mustJSON(req.CustomAttributes),
}
if err := s.contactRepo.Create(ctx, contact); err != nil {
return nil, err
}
return contact, nil
}
func (s *WidgetService) resolvePublicInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, *channelmodel.ChannelAPI, error) {
if inboxIdentifier == "" {
return nil, nil, errors.New("inbox identifier is required")
}
inbox, channelAPI, err := s.inboxRepo.FindAPIInboxByIdentifier(ctx, inboxIdentifier)
if err != nil {
return nil, nil, err
}
return inbox, channelAPI, nil
}
func (s *WidgetService) resolvePublicContactInbox(ctx context.Context, inboxIdentifier, sourceID string) (*channelmodel.ChannelAPI, *model.ContactInbox, error) {
if sourceID == "" {
return nil, nil, errors.New("contact source_id is required")
}
_, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
if err != nil {
return nil, nil, err
}
contactInbox, err := s.contactInboxRepo.FindBySourceID(ctx, channelAPI.InboxID, sourceID)
if err != nil {
return nil, nil, err
}
contact, err := s.contactRepo.FindByID(ctx, contactInbox.ContactID)
if err != nil {
return nil, nil, err
}
contactInbox.Contact = *contact
return channelAPI, contactInbox, nil
}
func validatePublicHMAC(hmacToken, identifier, signature string) error {
if signature == "" {
return nil
}
if !VerifyHMAC(hmacToken, identifier, signature) {
return errors.New("HMAC failed: Invalid Identifier Hash Provided")
}
return nil
}
func filterPublicConversations(conversations []model.Conversation, contactInbox *model.ContactInbox) []model.Conversation {
if contactInbox.HMACVerified {
return conversations
}
filtered := make([]model.Conversation, 0, len(conversations))
for _, conversation := range conversations {
if conversation.ContactInboxID != nil && *conversation.ContactInboxID == contactInbox.ID {
filtered = append(filtered, conversation)
}
}
return filtered
}
func publicConversationID(conversation model.Conversation) uint {
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return *conversation.DisplayID
}
return conversation.ID
}