feat(public): implement chatwoot public inbox flow
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user