package service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/gochat/gochat/internal/automation" "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" "github.com/gochat/gochat/internal/worker" ws "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/datatypes" ) var ( ErrWidgetConversationNotFound = errors.New("conversation not found") ErrWidgetEndConversationDisabled = errors.New("end conversation is not permitted") ErrWidgetMessageContentTooLong = errors.New("Content is too long (maximum is 150000 characters)") ) const widgetMessageContentLimit = 150000 // TypingIndicator is the interface for broadcasting typing events. // Decoupled from the concrete ws.TypingTracker to avoid circular imports. type TypingIndicator interface { SetTypingOn(ctx context.Context, accountID, conversationID uint, performer *ws.Performer) error SetTypingOff(ctx context.Context, accountID, conversationID uint, performer *ws.Performer) error } // WidgetService implements business logic for the Web Widget channel. // Reference: Chatwoot app/controllers/api/v1/widget_messages_controller.rb // + app/services/web_widget/send_on_web_widget_service.rb // + app/javascript/widget/sdk.js (frontend SDK) // // The Widget is a public-facing API — no agent JWT required. // Contact identity is established via widget_token (pubsub_token in Chatwoot), // which is issued on widget init/auth and carried in subsequent requests. type WidgetService struct { inboxRepo *repository.InboxRepo contactRepo *repository.ContactRepo contactInboxRepo *repository.ContactInboxRepo conversationRepo *repository.ConversationRepo messageRepo *repository.MessageRepo transcriptMailer automation.AutomationTranscriptDeliverer typingIndicator TypingIndicator themeConfigRepo *repository.WidgetThemeConfigRepo preChatFormRepo *repository.PreChatFormRepo fileUploadRepo *repository.WidgetFileUploadRepo offlineMessageRepo *repository.WidgetOfflineMessageRepo inboxMemberRepo *repository.InboxMemberRepo tagRepo *repository.TagRepo campaignRepo *repository.CampaignRepo worker *worker.WorkerPool } // NewWidgetService creates a new Widget service. func NewWidgetService( inboxRepo *repository.InboxRepo, contactRepo *repository.ContactRepo, contactInboxRepo *repository.ContactInboxRepo, conversationRepo *repository.ConversationRepo, messageRepo *repository.MessageRepo, typingIndicator TypingIndicator, themeConfigRepo *repository.WidgetThemeConfigRepo, preChatFormRepo *repository.PreChatFormRepo, fileUploadRepo *repository.WidgetFileUploadRepo, offlineMessageRepo *repository.WidgetOfflineMessageRepo, inboxMemberRepo *repository.InboxMemberRepo, tagRepo *repository.TagRepo, campaignRepo *repository.CampaignRepo, ) *WidgetService { return &WidgetService{ inboxRepo: inboxRepo, contactRepo: contactRepo, contactInboxRepo: contactInboxRepo, conversationRepo: conversationRepo, messageRepo: messageRepo, transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer(), typingIndicator: typingIndicator, themeConfigRepo: themeConfigRepo, preChatFormRepo: preChatFormRepo, fileUploadRepo: fileUploadRepo, offlineMessageRepo: offlineMessageRepo, inboxMemberRepo: inboxMemberRepo, tagRepo: tagRepo, campaignRepo: campaignRepo, } } func (s *WidgetService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp } func (s *WidgetService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) { s.transcriptMailer = deliverer } // --- DTOs --- // WidgetInitRequest is the DTO for the /widget/init endpoint. // Reference: Chatwoot widget SDK init — website_token identifies the inbox, // contact attributes are optional (anonymous visitor if not provided). type WidgetInitRequest struct { WebsiteToken string `json:"website_token" validate:"required"` WidgetToken string `json:"widget_token,omitempty"` ContactName string `json:"contact_name,omitempty"` ContactEmail string `json:"contact_email,omitempty"` ContactPhone string `json:"contact_phone,omitempty"` Identifier string `json:"identifier,omitempty"` HMACVerified bool `json:"hmac_verified,omitempty"` // true if client validated HMAC } // WidgetInitResponse is returned after successful widget init/auth. // Contains widget_token for subsequent requests + inbox config for UI rendering. type WidgetInitResponse struct { WidgetToken string `json:"widget_token"` // pubsub_token for WebSocket + auth ContactID uint `json:"contact_id"` ContactInboxID uint `json:"contact_inbox_id"` InboxID uint `json:"inbox_id"` InboxName string `json:"inbox_name"` AccountID uint `json:"account_id"` WidgetConfig WebWidgetConfig `json:"widget_config"` Contact *model.Contact `json:"-"` } // WidgetSendMessageRequest is the DTO for the /widget/messages endpoint. // Reference: Chatwoot WidgetMessagesController#create type WidgetSendMessageRequest struct { WidgetToken string `json:"widget_token" validate:"required"` Content string `json:"content" validate:"required"` ContentType string `json:"content_type,omitempty"` // default: text ConversationID *uint `json:"conversation_id,omitempty"` // nil → create new conversation AttachmentIDs []string CustomAttributes map[string]any Labels []string ReplyTo *uint } // WidgetSendMessageResponse is returned after sending a message. type WidgetSendMessageResponse struct { ConversationID uint `json:"conversation_id"` Message model.Message `json:"message"` Attachments []model.Attachment `json:"attachments,omitempty"` } type WidgetContactUpdate struct { Name string Email string PhoneNumber string Identifier string AvatarURL string CustomAttributes map[string]any AdditionalAttributes map[string]any } type WidgetSetUserRequest struct { WebsiteToken string WidgetToken string Identifier string IdentifierHash string Email string Name string AvatarURL string PhoneNumber string CustomAttributes map[string]any AdditionalAttributes map[string]any } type WidgetSetUserResponse struct { Contact *model.Contact WidgetAuthToken string } type WidgetMessageUpdate struct { MessageID uint WidgetToken string ContactEmail string ContactName string 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 AttachmentIDs []string } type PublicMessageListOptions struct { Before uint Offset int Limit int } type WidgetInboxMember struct { ID uint `json:"id"` Name string `json:"name"` AvatarURL string `json:"avatar_url"` AvailabilityStatus string `json:"availability_status"` } type WidgetCampaign struct { ID uint `json:"id"` TriggerRules map[string]any `json:"trigger_rules"` TriggerOnlyDuringBusinessHours bool `json:"trigger_only_during_business_hours"` Message string `json:"message"` Sender any `json:"sender"` } // WidgetGetCableTokenResponse returns the pubsub_token for WebSocket connection. // Reference: Chatwoot widget SDK — fetches token for ActionCable subscription type WidgetGetCableTokenResponse struct { PubsubToken string `json:"pubsub_token"` ContactID uint `json:"contact_id"` InboxID uint `json:"inbox_id"` AccountID uint `json:"account_id"` } // --- Service methods --- // Init authenticates/creates a contact for the widget and returns a widget_token. // Reference: Chatwoot widget SDK init flow — website_token → inbox lookup → // contact creation/identification → pubsub_token generation → UI config delivery // // Flow: // 1. Resolve inbox by website_token (from channel_config JSON) // 2. Find or create contact (by email/phone/identifier, or anonymous) // 3. Find or create ContactInbox (join table with pubsub_token) // 4. Return widget_token (= pubsub_token) + config func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*WidgetInitResponse, error) { if req.WebsiteToken == "" { return nil, errors.New("website_token is required") } // Step 1: Find inbox by website_token in channel_config inbox, err := s.findInboxByWebsiteToken(ctx, req.WebsiteToken) if err != nil { return nil, fmt.Errorf("invalid website_token: %w", err) } var account model.Account if err := s.conversationRepo.DB().WithContext(ctx).Select("active", "status").First(&account, inbox.AccountID).Error; err != nil { return nil, err } if !account.Active || strings.EqualFold(account.Status, "suspended") { return nil, errors.New("Account is suspended") } if !inbox.Enabled { return nil, errors.New("inbox is disabled") } // Parse widget config widgetConfig, err := ParseWebWidgetConfig(inbox.ChannelConfig) if err != nil { return nil, fmt.Errorf("invalid widget config: %w", err) } var contactInbox *model.ContactInbox var contact *model.Contact if req.WidgetToken != "" { contactInbox, err = s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken) if err == nil && contactInbox.InboxID == inbox.ID { contact = &contactInbox.Contact } else { contactInbox = nil } } if contact == nil { contact, err = s.findOrCreateWidgetContact(ctx, inbox.AccountID, req) if err != nil { return nil, fmt.Errorf("failed to identify contact: %w", err) } } // Step 3: Find or create ContactInbox if contactInbox == nil { contactInbox, err = s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID) if err != nil { return nil, fmt.Errorf("failed to create contact inbox: %w", err) } } if req.HMACVerified && !contactInbox.HMACVerified { contactInbox.HMACVerified = true if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil { return nil, fmt.Errorf("failed to update contact inbox: %w", err) } } applogger.L().Infof("Widget init: contact=%d inbox=%d contactInbox=%d token=%s", contact.ID, inbox.ID, contactInbox.ID, contactInbox.PubsubToken) return &WidgetInitResponse{ WidgetToken: contactInbox.PubsubToken, ContactID: contact.ID, ContactInboxID: contactInbox.ID, InboxID: inbox.ID, InboxName: inbox.Name, AccountID: inbox.AccountID, WidgetConfig: *widgetConfig, Contact: contact, }, nil } // SendMessage sends a message from a widget contact. // Reference: Chatwoot WidgetMessagesController#create // If no conversation_id is provided, creates a new conversation. func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRequest) (*WidgetSendMessageResponse, error) { if req.WidgetToken == "" { return nil, errors.New("widget_token is required") } if req.Content == "" && len(req.AttachmentIDs) == 0 { return nil, errors.New("content is required") } if len([]rune(req.Content)) > widgetMessageContentLimit { return nil, ErrWidgetMessageContentTooLong } // Resolve contact by pubsub_token contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } contentType := req.ContentType if contentType == "" { contentType = "text" } // Resolve conversation var conversation *model.Conversation if req.ConversationID != nil { conversation, err = s.conversationRepo.FindByID(ctx, *req.ConversationID) if err != nil { return nil, fmt.Errorf("conversation not found: %w", err) } // Verify conversation belongs to this contact if conversation.ContactID != contactInbox.ContactID { return nil, errors.New("conversation does not belong to this contact") } } else { conversations, _, findErr := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1) if findErr != nil { return nil, findErr } if len(conversations) > 0 { conversation = &conversations[0] } else { conversation, err = s.createWidgetConversation(ctx, contactInbox, req.CustomAttributes, req.Labels) if err != nil { return nil, fmt.Errorf("failed to create conversation: %w", err) } } } // Create message msg := model.Message{ ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, SenderID: &contactInbox.ContactID, SenderType: "Contact", Content: req.Content, ContentType: contentType, MessageType: "incoming", Status: "sent", } if req.ReplyTo != nil { if _, err := s.messageRepo.FindByConversationAndID(ctx, conversation.ID, *req.ReplyTo); err == nil { msg.ContentAttributes = mustJSON(map[string]any{"in_reply_to": *req.ReplyTo}) } } if err := s.messageRepo.Create(ctx, &msg); err != nil { return nil, fmt.Errorf("failed to create message: %w", err) } if err := s.reopenWidgetConversationForIncomingMessage(ctx, conversation); err != nil { return nil, err } attachments, err := s.attachWidgetUploads(ctx, &msg, req.AttachmentIDs) if err != nil { return nil, err } applogger.L().Infof("Widget message: contact=%d conversation=%d message=%d", contactInbox.ContactID, conversation.ID, msg.ID) return &WidgetSendMessageResponse{ ConversationID: conversation.ID, Message: msg, Attachments: attachments, }, nil } func (s *WidgetService) reopenWidgetConversationForIncomingMessage(ctx context.Context, conversation *model.Conversation) error { if conversation == nil || conversation.Muted { return nil } if conversation.Status != string(model.ConversationStatusSnoozed) && conversation.Status != string(model.ConversationStatusResolved) { return nil } conversation.Status = string(model.ConversationStatusOpen) conversation.SnoozedUntil = nil return s.conversationRepo.DB().WithContext(ctx).Model(&model.Conversation{}). Where("id = ?", conversation.ID). Updates(map[string]any{"status": conversation.Status, "snoozed_until": nil}).Error } // GetConversations returns conversations for a widget contact. // Reference: Chatwoot widget SDK — fetches conversation list func (s *WidgetService) GetConversations(ctx context.Context, widgetToken string) ([]model.Conversation, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } conversations, _, err := s.conversationRepo.FindByContact( ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 50) if err != nil { return nil, err } return conversations, nil } func (s *WidgetService) GetLatestConversation(ctx context.Context, widgetToken string) (*model.Conversation, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } conversations, _, err := s.conversationRepo.FindByContact( ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1) if err != nil { return nil, err } if len(conversations) == 0 { return nil, ErrWidgetConversationNotFound } return &conversations[0], nil } func (s *WidgetService) GetConversation(ctx context.Context, widgetToken string, conversationID uint) (*model.Conversation, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } conversation, err := s.conversationRepo.FindByID(ctx, conversationID) if err != nil { return nil, err } if conversation.ContactID != contactInbox.ContactID { return nil, errors.New("conversation does not belong to this contact") } return conversation, nil } func (s *WidgetService) GetLatestConversationMessages(ctx context.Context, widgetToken string, after, before uint) ([]model.Message, int64, *model.Conversation, error) { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { if err.Error() == "conversation not found" { return []model.Message{}, 0, nil, nil } return nil, 0, nil, err } messages, total, err := s.messageRepo.FindByConversationFinder(ctx, conversation.ID, after, before, true) return messages, total, conversation, err } func (s *WidgetService) GetInboxMembersByWebsiteToken(ctx context.Context, websiteToken string) ([]WidgetInboxMember, error) { inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) if err != nil { return nil, err } if s.inboxMemberRepo == nil { return []WidgetInboxMember{}, nil } members, err := s.inboxMemberRepo.FindByInbox(ctx, inbox.ID) if err != nil { return nil, err } payload := make([]WidgetInboxMember, 0, len(members)) for _, member := range members { if member.User.ID == 0 { continue } name := member.User.Name if name == "" { name = member.User.Email } availability := member.AvailabilityStatus if availability == "" { availability = "offline" } payload = append(payload, WidgetInboxMember{ ID: member.User.ID, Name: name, AvatarURL: member.User.AvatarURL, AvailabilityStatus: availability, }) } return payload, nil } func (s *WidgetService) GetCampaignsByWebsiteToken(ctx context.Context, websiteToken string) ([]WidgetCampaign, error) { inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) if err != nil { return nil, err } var account model.Account if err := s.conversationRepo.DB().WithContext(ctx).Select("feature_flags").First(&account, inbox.AccountID).Error; err != nil { return nil, err } if !widgetAccountFeatureEnabled(account.FeatureFlags, "campaigns") { return []WidgetCampaign{}, nil } if s.campaignRepo == nil { return []WidgetCampaign{}, nil } campaigns, err := s.campaignRepo.ListByInbox(ctx, inbox.AccountID, inbox.ID) if err != nil { return nil, err } payload := make([]WidgetCampaign, 0, len(campaigns)) for _, campaign := range campaigns { if !campaign.Enabled { continue } id := campaign.DisplayID if id == 0 { id = campaign.ID } payload = append(payload, WidgetCampaign{ ID: id, TriggerRules: jsonMapBytes([]byte(campaign.TriggerRules)), TriggerOnlyDuringBusinessHours: campaign.TriggerOnlyDuringBusinessHours, Message: campaign.Message, Sender: nil, }) } return payload, nil } func widgetAccountFeatureEnabled(raw, flag string) bool { raw = strings.TrimSpace(raw) if raw == "" { return false } values := map[string]bool{} if err := json.Unmarshal([]byte(raw), &values); err == nil { return values[flag] } var list []string if err := json.Unmarshal([]byte(raw), &list); err == nil { for _, value := range list { if value == flag { return true } } } return false } func (s *WidgetService) TrackEvent(ctx context.Context, websiteToken, widgetToken, name string, eventInfo map[string]any) error { if websiteToken == "" { return errors.New("website_token is required") } if name == "" { return errors.New("event name is required") } inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) if err != nil { return err } if widgetToken == "" { return errors.New("widget_token required") } contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return fmt.Errorf("invalid widget_token: %w", err) } if contactInbox.InboxID != inbox.ID { return errors.New("widget_token does not belong to this inbox") } if strings.EqualFold(name, "webwidget.triggered") { if s.worker != nil { if _, err := s.worker.Enqueue(ctx, "webhook:webwidget_triggered", map[string]any{ "account_id": inbox.AccountID, "inbox_id": inbox.ID, "contact_inbox": contactInbox, "event_info": eventInfo, "website_token": websiteToken, }); err != nil { applogger.L().Warnf("WidgetService.TrackEvent: failed to enqueue webwidget_triggered webhook: %v", err) } } } return nil } func (s *WidgetService) AddLabelToLatestConversation(ctx context.Context, widgetToken, label string) error { label = strings.TrimSpace(label) if label == "" { return nil } conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { return err } if s.tagRepo != nil { if _, err := s.tagRepo.FindByNameAndAccountID(ctx, conversation.AccountID, label); err != nil { return nil } } labels := splitWidgetLabels(conversation.Labels) for _, existing := range labels { if existing == label { return nil } } labels = append(labels, label) return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(labels, ",")) } func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, widgetToken, label string) error { label = strings.TrimSpace(label) if label == "" { return nil } conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { return err } labels := splitWidgetLabels(conversation.Labels) filtered := labels[:0] for _, existing := range labels { if existing != label { filtered = append(filtered, existing) } } return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(filtered, ",")) } func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.Contact, req WidgetContactUpdate) (*model.Contact, error) { if req.Name != "" { contact.Name = req.Name } if req.Email != "" { contact.Email = req.Email } if req.PhoneNumber != "" { contact.PhoneNumber = req.PhoneNumber } if req.Identifier != "" { contact.Identifier = req.Identifier } if req.AvatarURL != "" { contact.AvatarURL = req.AvatarURL } if len(req.CustomAttributes) > 0 { merged := jsonMap(contact.CustomAttributes) for k, v := range req.CustomAttributes { merged[k] = v } contact.CustomAttributes = mustJSON(merged) } if len(req.AdditionalAttributes) > 0 { merged := jsonMap(contact.AdditionalAttributes) for k, v := range req.AdditionalAttributes { merged[k] = v } contact.AdditionalAttributes = mustJSON(merged) } if err := s.contactRepo.Update(ctx, contact); err != nil { return nil, err } 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.HMACMandatory, 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, channelAPI.HMACMandatory, 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, channelAPI.HMACMandatory, 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, nil, nil) 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) { _, _, 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 { 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 } if _, err := EnqueueConversationMessageStatusUpdate(ctx, s.worker, conversation.ID, time.Unix(now, 0), string(model.MessageStatusRead)); 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, opts PublicMessageListOptions) ([]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.publicConversationMessages(ctx, conversation.ID, opts) return messages, total, conversation, err } func (s *WidgetService) publicConversationMessages(ctx context.Context, conversationID uint, opts PublicMessageListOptions) ([]model.Message, int64, error) { query := s.messageRepo.DB().WithContext(ctx).Model(&model.Message{}). Where("conversation_id = ?", conversationID). Where("NOT (private = ? OR message_type = ?)", true, model.MessageTypeActivity) var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var messages []model.Message if opts.Before != 0 { err := query.Where("id < ?", opts.Before).Order("created_at DESC, id DESC").Limit(20).Find(&messages).Error if err != nil { return nil, 0, err } reverseMessages(messages) return messages, total, nil } limit := opts.Limit if limit <= 0 { limit = 20 } offset := opts.Offset if offset < 0 { offset = 0 } err := query.Offset(offset).Limit(limit).Order("created_at DESC, id DESC").Find(&messages).Error if err != nil { return nil, 0, err } reverseMessages(messages) return messages, total, err } func reverseMessages(messages []model.Message) { for left, right := 0, len(messages)-1; left < right; left, right = left+1, right-1 { messages[left], messages[right] = messages[right], messages[left] } } func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, []model.Attachment, error) { if strings.TrimSpace(req.Content) == "" && len(req.AttachmentIDs) == 0 { return nil, nil, nil, errors.New("content is required") } if len([]rune(req.Content)) > widgetMessageContentLimit { return nil, nil, nil, ErrWidgetMessageContentTooLong } conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID) if err != nil { return nil, 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, nil, err } attachments, err := s.attachWidgetUploads(ctx, message, req.AttachmentIDs) if err != nil { return nil, nil, nil, err } return message, conversation, attachments, 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 { if message.ContentType == "input_csat" && automation.IsCsatSurveyLocked(message.CreatedAt, time.Now()) { return nil, nil, automation.ErrCsatSurveyLocked } attrs["submitted_values"] = req.SubmittedValues message.ContentAttributes = mustJSON(attrs) } if err := s.messageRepo.Update(ctx, message); err != nil { return nil, nil, err } if message.ContentType == "input_csat" && req.SubmittedValues != nil { if _, err := automation.ApplyCsatSubmission(ctx, s.messageRepo.DB(), message, conversation, req.SubmittedValues); 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) if err != nil { return nil, 0, fmt.Errorf("invalid widget_token: %w", err) } // Verify conversation belongs to this contact conversation, err := s.conversationRepo.FindByID(ctx, conversationID) if err != nil { return nil, 0, err } if conversation.ContactID != contactInbox.ContactID { return nil, 0, errors.New("conversation does not belong to this contact") } return s.messageRepo.FindByConversation(ctx, conversationID, offset, limit) } func (s *WidgetService) GetMessageAttachments(ctx context.Context, messageID uint) ([]model.Attachment, error) { var attachments []model.Attachment err := s.messageRepo.DB().WithContext(ctx).Where("message_id = ?", messageID).Order("id ASC").Find(&attachments).Error return attachments, err } // GetCableToken returns the pubsub_token for WebSocket connection. // Reference: Chatwoot widget SDK — fetches token for ActionCable subscription // The contact connects to /cable with pubsub_token to receive real-time events. func (s *WidgetService) GetCableToken(ctx context.Context, widgetToken string) (*WidgetGetCableTokenResponse, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } // Resolve inbox to get account_id inbox, err := s.inboxRepo.FindByID(ctx, contactInbox.InboxID) if err != nil { return nil, fmt.Errorf("inbox not found: %w", err) } return &WidgetGetCableTokenResponse{ PubsubToken: contactInbox.PubsubToken, ContactID: contactInbox.ContactID, InboxID: contactInbox.InboxID, AccountID: inbox.AccountID, }, nil } // UpdateContact updates the contact's profile from the widget. // Reference: Chatwoot widget SDK — update contact name/email func (s *WidgetService) UpdateContact(ctx context.Context, widgetToken string, name, email string) (*model.Contact, error) { return s.UpdateContactProfile(ctx, widgetToken, WidgetContactUpdate{Name: name, Email: email}) } func (s *WidgetService) GetContact(ctx context.Context, widgetToken string) (*model.Contact, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } return s.contactRepo.FindByID(ctx, contactInbox.ContactID) } func (s *WidgetService) UpdateContactProfile(ctx context.Context, widgetToken string, req WidgetContactUpdate) (*model.Contact, error) { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } contact, err := s.contactRepo.FindByID(ctx, contactInbox.ContactID) if err != nil { return nil, err } return s.updateContactFields(ctx, contact, req) } func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) (*WidgetSetUserResponse, error) { if req.WebsiteToken == "" { return nil, errors.New("website_token is required") } if req.WidgetToken == "" { return nil, errors.New("widget_token required") } inbox, err := s.GetInboxByWebsiteToken(ctx, req.WebsiteToken) if err != nil { return nil, err } widgetConfig, err := ParseWebWidgetConfig(inbox.ChannelConfig) if err != nil { return nil, fmt.Errorf("invalid widget config: %w", err) } if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !VerifyHMAC(widgetConfig.HMACToken, req.Identifier, req.IdentifierHash) { return nil, errors.New("HMAC failed: Invalid Identifier Hash Provided") } currentContactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } if currentContactInbox.InboxID != inbox.ID { return nil, errors.New("widget_token does not belong to this inbox") } contact := ¤tContactInbox.Contact widgetAuthToken := "" if contact.Identifier != "" && contact.Identifier != req.Identifier { contact, err = s.findOrCreateContactByIdentifier(ctx, inbox.AccountID, req.Identifier) if err != nil { return nil, err } currentContactInbox, err = s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID) if err != nil { return nil, err } widgetAuthToken = currentContactInbox.PubsubToken } contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{ Name: req.Name, Email: strings.ToLower(req.Email), PhoneNumber: req.PhoneNumber, Identifier: req.Identifier, AvatarURL: req.AvatarURL, CustomAttributes: req.CustomAttributes, AdditionalAttributes: req.AdditionalAttributes, }) if err != nil { return nil, err } if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !currentContactInbox.HMACVerified { currentContactInbox.HMACVerified = true if err := s.contactInboxRepo.Update(ctx, currentContactInbox); err != nil { return nil, err } } return &WidgetSetUserResponse{Contact: contact, WidgetAuthToken: widgetAuthToken}, nil } func shouldVerifyWidgetSetUserHMAC(widgetConfig *WebWidgetConfig, req WidgetSetUserRequest) bool { if widgetConfig == nil { return req.IdentifierHash != "" } if req.IdentifierHash == "" && !widgetConfig.HMACMandatory { return false } if len(req.CustomAttributes) > 0 && req.Identifier == "" { return false } return true } func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpdate) (*model.Contact, *model.Message, error) { if req.WidgetToken == "" { return nil, nil, errors.New("widget_token required") } contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken) if err != nil { return nil, nil, fmt.Errorf("invalid widget_token: %w", err) } message, err := s.messageRepo.FindByID(ctx, req.MessageID) if err != nil { return nil, nil, err } conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID) if err != nil { return nil, nil, err } if conversation.ContactID != contactInbox.ContactID || conversation.InboxID != contactInbox.InboxID { return nil, nil, errors.New("message does not belong to this contact") } contact := &contactInbox.Contact if strings.TrimSpace(req.ContactEmail) != "" && message.ContentType == string(model.MessageContentTypeInputEmail) { contact, err = s.identifyWidgetInputEmailContact(ctx, contact, contactInbox, conversation, req) if err != nil { return nil, nil, err } } else if strings.TrimSpace(req.ContactEmail) != "" || strings.TrimSpace(req.ContactName) != "" { contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{ Name: strings.TrimSpace(req.ContactName), Email: strings.ToLower(strings.TrimSpace(req.ContactEmail)), }) if err != nil { return nil, nil, err } } attrs := jsonMap(message.ContentAttributes) if strings.TrimSpace(req.ContactEmail) != "" { attrs["submitted_email"] = strings.ToLower(strings.TrimSpace(req.ContactEmail)) attrs["submitted_values"] = nil } else 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 contact, message, nil } func (s *WidgetService) identifyWidgetInputEmailContact(ctx context.Context, contact *model.Contact, contactInbox *model.ContactInbox, conversation *model.Conversation, req WidgetMessageUpdate) (*model.Contact, error) { email := strings.ToLower(strings.TrimSpace(req.ContactEmail)) name := strings.TrimSpace(req.ContactName) if name == "" { name = strings.Split(email, "@")[0] } existing, err := s.contactRepo.FindByEmail(ctx, conversation.AccountID, email) if err == nil && existing.ID != contact.ID { db := s.conversationRepo.DB().WithContext(ctx) if err := db.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Update("contact_id", existing.ID).Error; err != nil { return nil, err } if err := db.Model(&model.ContactInbox{}).Where("id = ?", contactInbox.ID).Update("contact_id", existing.ID).Error; err != nil { return nil, err } if err := db.Model(&model.Message{}).Where("sender_id = ? AND sender_type IN ?", contact.ID, []string{"Contact", "contact"}).Update("sender_id", existing.ID).Error; err != nil { return nil, err } if err := db.Delete(&model.Contact{}, contact.ID).Error; err != nil { return nil, err } return existing, nil } return s.updateContactFields(ctx, contact, WidgetContactUpdate{Name: name, Email: email}) } func (s *WidgetService) SendTranscript(ctx context.Context, widgetToken string) error { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { return err } contact, err := s.contactRepo.FindByID(ctx, conversation.ContactID) if err != nil { return err } if contact.Email == "" { return nil } var account model.Account if err := s.conversationRepo.DB().WithContext(ctx).First(&account, conversation.AccountID).Error; err != nil { return err } now := time.Now() if !account.EmailTranscriptEnabled() { return ErrEmailTranscriptDisabled } if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit { return ErrEmailRateLimited } subject, body, err := s.buildWidgetTranscriptEmail(ctx, conversation) if err != nil { return err } if s.transcriptMailer != nil { _, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{ AccountID: conversation.AccountID, ConversationID: conversation.ID, Recipient: contact.Email, Subject: subject, Body: body, }) if err != nil { return err } } if err := account.IncrementEmailSentCount(now); err != nil { return err } if err := s.conversationRepo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil { return err } return nil } func (s *WidgetService) buildWidgetTranscriptEmail(ctx context.Context, conversation *model.Conversation) (string, string, error) { var messages []model.Message if err := s.messageRepo.DB().WithContext(ctx). Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, conversation.AccountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}). Order("id ASC"). Find(&messages).Error; err != nil { return "", "", err } displayID := conversation.ID if conversation.DisplayID != nil && *conversation.DisplayID > 0 { displayID = *conversation.DisplayID } subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID) var body strings.Builder body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID)) for _, message := range messages { if strings.TrimSpace(message.Content) == "" { continue } body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content)) } return subject, body.String(), nil } func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken, widgetToken string, messageID uint) (map[string]any, error) { if websiteToken == "" { return nil, errors.New("website_token is required") } if widgetToken == "" { return nil, errors.New("widget_token required") } inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken) if err != nil { return nil, err } contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return nil, fmt.Errorf("invalid widget_token: %w", err) } if contactInbox.InboxID != inbox.ID { return nil, errors.New("widget_token does not belong to this inbox") } message, err := s.messageRepo.FindByID(ctx, messageID) if err != nil { return nil, err } if message.InboxID != inbox.ID { return nil, errors.New("message does not belong to this inbox") } conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID) if err != nil { return nil, err } if conversation.ContactID != contactInbox.ContactID { return nil, errors.New("message does not belong to this contact") } if message.ContentType != "integrations" { return nil, errors.New("Invalid message type. Action not permitted") } attrs := jsonMap(message.ContentAttributes) data, _ := attrs["data"].(map[string]any) meetingID, _ := data["meeting_id"].(string) if meetingID == "" { return nil, errors.New("meeting_id is required") } return map[string]any{"token": "dyte_" + meetingID}, nil } func (s *WidgetService) DeleteContactCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Contact, error) { contact, err := s.GetContact(ctx, widgetToken) if err != nil { return nil, err } attrs := jsonMap(contact.CustomAttributes) for _, key := range keys { delete(attrs, key) } contact.CustomAttributes = mustJSON(attrs) if err := s.contactRepo.Update(ctx, contact); err != nil { return nil, err } return contact, nil } // ToggleTyping signals that the widget contact is typing or stopped typing. // Reference: Chatwoot ConversationTypingOnJob / ConversationTypingOffJob func (s *WidgetService) ToggleTyping(ctx context.Context, widgetToken string, conversationID uint, typing bool) error { contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken) if err != nil { return fmt.Errorf("invalid widget_token: %w", err) } conversation, err := s.conversationRepo.FindByID(ctx, conversationID) if err != nil { return fmt.Errorf("conversation not found: %w", err) } // Verify conversation belongs to this contact if conversation.ContactID != contactInbox.ContactID { return errors.New("conversation does not belong to this contact") } performer := &ws.Performer{ ID: contactInbox.ContactID, Name: contactInbox.Contact.Name, Type: "contact", } if typing { return s.typingIndicator.SetTypingOn(ctx, conversation.AccountID, conversationID, performer) } return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversationID, performer) } func (s *WidgetService) UpdateLastSeen(ctx context.Context, widgetToken string) (*model.Conversation, error) { conversation, err := s.GetLatestConversation(ctx, widgetToken) 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 } if _, err := EnqueueConversationMessageStatusUpdate(ctx, s.worker, conversation.ID, time.Unix(now, 0), string(model.MessageStatusRead)); err != nil { return nil, err } return conversation, nil } func (s *WidgetService) ResolveLatestConversation(ctx context.Context, widgetToken string) (*model.Conversation, error) { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { return nil, err } allowed, err := s.latestConversationAllowsEnd(ctx, conversation) if err != nil { return nil, err } if !allowed { return nil, ErrWidgetEndConversationDisabled } if conversation.Status == string(model.ConversationStatusResolved) { return conversation, nil } contactName := "visitor" contact, contactErr := s.contactRepo.FindByID(ctx, conversation.ContactID) if contactErr == nil && strings.TrimSpace(contact.Name) != "" { contactName = strings.TrimSpace(contact.Name) } conversation.Status = string(model.ConversationStatusResolved) if err := s.conversationRepo.Update(ctx, conversation); err != nil { return nil, err } if err := s.messageRepo.Create(ctx, &model.Message{ ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, SenderID: &conversation.ContactID, SenderType: string(model.SenderTypeContact), Content: "Conversation was resolved by " + contactName, ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeActivity), Status: string(model.MessageStatusSent), }); err != nil { return nil, err } return conversation, nil } func (s *WidgetService) latestConversationAllowsEnd(ctx context.Context, conversation *model.Conversation) (bool, error) { inbox, err := s.inboxRepo.FindByID(ctx, conversation.InboxID) if err != nil { return false, err } config := parseChannelConfigMap(inbox.ChannelConfig) flags, ok := config["selected_feature_flags"] if !ok { return false, nil } switch values := flags.(type) { case []interface{}: for _, value := range values { if text, ok := value.(string); ok && text == "end_conversation" { return true, nil } } case []string: for _, value := range values { if value == "end_conversation" { return true, nil } } } return false, nil } func (s *WidgetService) SetLatestConversationCustomAttributes(ctx context.Context, widgetToken string, attrs map[string]any) (*model.Conversation, error) { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { return nil, err } conversation.CustomAttributes = mustJSON(attrs) if err := s.conversationRepo.Update(ctx, conversation); err != nil { return nil, err } return conversation, nil } func (s *WidgetService) DeleteLatestConversationCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Conversation, error) { conversation, err := s.GetLatestConversation(ctx, widgetToken) if err != nil { return nil, err } attrs := jsonMap(conversation.CustomAttributes) for _, key := range keys { delete(attrs, key) } conversation.CustomAttributes = mustJSON(attrs) if err := s.conversationRepo.Update(ctx, conversation); err != nil { return nil, err } return conversation, nil } // --- Helper methods --- // findInboxByWebsiteToken scans all web_widget inboxes to find one with matching // website_token in its channel_config JSON. Website tokens are unique per inbox. func (s *WidgetService) findInboxByWebsiteToken(ctx context.Context, websiteToken string) (*model.Inbox, error) { inboxes, err := s.inboxRepo.FindByChannelType(ctx, "web_widget", 500) if err != nil || len(inboxes) == 0 { return nil, fmt.Errorf("no web_widget inboxes found: %w", err) } for _, inbox := range inboxes { config, err := ParseWebWidgetConfig(inbox.ChannelConfig) if err != nil { continue } if config.WebsiteToken == websiteToken { return &inbox, nil } } return nil, fmt.Errorf("no inbox found for website_token %s", websiteToken) } // GetInboxByWebsiteToken is the public wrapper for findInboxByWebsiteToken, // used by the widget handler's offline message submission endpoint. func (s *WidgetService) GetInboxByWebsiteToken(ctx context.Context, websiteToken string) (*model.Inbox, error) { return s.findInboxByWebsiteToken(ctx, websiteToken) } // findOrCreateWidgetContact identifies or creates a contact for the widget session. // If email/phone/identifier is provided, tries to find existing contact. // If none found or no identifiers provided, creates an anonymous contact. func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID uint, req WidgetInitRequest) (*model.Contact, error) { // Try to find existing contact by email (FindByEmail takes accountID + email) if req.ContactEmail != "" { contact, err := s.contactRepo.FindByEmail(ctx, accountID, req.ContactEmail) if err == nil { return contact, nil } } // Try to find existing contact by identifier or phone using Search if req.Identifier != "" || req.ContactPhone != "" { searchQuery := req.Identifier if searchQuery == "" { searchQuery = req.ContactPhone } contacts, _, err := s.contactRepo.Search(ctx, accountID, searchQuery, 0, 5, "id ASC", search.SearchModeILike) if err == nil && len(contacts) > 0 { return &contacts[0], nil } } // Create new contact name := req.ContactName if name == "" { name = "Anonymous Visitor" } contact := model.Contact{ AccountID: accountID, Name: name, Email: req.ContactEmail, PhoneNumber: req.ContactPhone, Identifier: req.Identifier, ContactType: "visitor", } if err := s.contactRepo.Create(ctx, &contact); err != nil { return nil, err } return &contact, nil } func (s *WidgetService) findOrCreateContactByIdentifier(ctx context.Context, accountID uint, identifier string) (*model.Contact, error) { contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, identifier) if err == nil { return contact, nil } contact = &model.Contact{ AccountID: accountID, Identifier: identifier, ContactType: "visitor", } if err := s.contactRepo.Create(ctx, contact); err != nil { return nil, err } return contact, nil } // findOrCreateContactInbox ensures a ContactInbox exists for the contact+inbox pair. // Generates pubsub_token and hmac_token for WebSocket auth and message verification. func (s *WidgetService) findOrCreateContactInbox(ctx context.Context, contactID, inboxID uint) (*model.ContactInbox, error) { // Check if one already exists ci, err := s.contactInboxRepo.FindByContactAndInbox(ctx, contactID, inboxID) if err == nil { // If pubsub_token is empty, generate one if ci.PubsubToken == "" { pubsubToken, err := generateToken(32) if err != nil { return nil, fmt.Errorf("failed to generate pubsub_token: %w", err) } hmacToken, err := generateToken(32) if err != nil { return nil, fmt.Errorf("failed to generate hmac_token: %w", err) } ci.PubsubToken = pubsubToken ci.HMACToken = hmacToken if err := s.contactInboxRepo.Update(ctx, ci); err != nil { return nil, err } } return ci, nil } // Create new ContactInbox pubsubToken, err := generateToken(32) if err != nil { return nil, fmt.Errorf("failed to generate pubsub_token: %w", err) } hmacToken, err := generateToken(32) if err != nil { return nil, fmt.Errorf("failed to generate hmac_token: %w", err) } sourceID := strconv.FormatUint(uint64(contactID), 10) ci = &model.ContactInbox{ ContactID: contactID, InboxID: inboxID, SourceID: sourceID, PubsubToken: pubsubToken, HMACToken: hmacToken, } if err := s.contactInboxRepo.Create(ctx, ci); err != nil { return nil, err } return ci, nil } // createWidgetConversation creates a new conversation for a widget contact. func (s *WidgetService) createWidgetConversation(ctx context.Context, contactInbox *model.ContactInbox, customAttributes map[string]any, labels []string) (*model.Conversation, error) { inbox, err := s.inboxRepo.FindByID(ctx, contactInbox.InboxID) if err != nil { return nil, err } conversation := model.Conversation{ AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contactInbox.ContactID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, CustomAttributes: mustJSON(customAttributes), Labels: strings.Join(s.validWidgetLabels(ctx, inbox.AccountID, labels), ","), } if err := s.conversationRepo.Create(ctx, &conversation); err != nil { return nil, err } return &conversation, nil } func (s *WidgetService) validWidgetLabels(ctx context.Context, accountID uint, labels []string) []string { if len(labels) == 0 { return nil } seen := map[string]struct{}{} valid := make([]string, 0, len(labels)) for _, label := range labels { label = strings.TrimSpace(label) if label == "" { continue } if _, ok := seen[label]; ok { continue } if s.tagRepo != nil { if _, err := s.tagRepo.FindByNameAndAccountID(ctx, accountID, label); err != nil { continue } } seen[label] = struct{}{} valid = append(valid, label) } return valid } // VerifyHMAC validates the HMAC signature from the widget client. // Reference: Chatwoot web_widget HMAC verification — ensures the client // hasn't tampered with the identifier (used for authenticated contacts). func VerifyHMAC(hmacToken, identifier, signature string) bool { if hmacToken == "" || identifier == "" || signature == "" { return false } mac := hmac.New(sha256.New, []byte(hmacToken)) mac.Write([]byte(identifier)) expectedMAC := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expectedMAC)) } // ParseWebWidgetConfig parses the JSON channel_config string into WebWidgetConfig. func ParseWebWidgetConfig(channelConfig string) (*WebWidgetConfig, error) { if channelConfig == "" { return nil, errors.New("empty channel_config") } var config WebWidgetConfig if err := json.Unmarshal([]byte(channelConfig), &config); err != nil { return nil, fmt.Errorf("invalid JSON: %w", err) } return &config, nil } // --- Offline Message Methods --- // Reference: Chatwoot's offline messaging feature — when no agents are available // (outside business hours or all agents offline), visitors can submit messages // that get stored and later converted to conversations when an agent returns. // SubmitOfflineMessage stores a message from a visitor when agents are offline. // Returns the created offline message record. func (s *WidgetService) SubmitOfflineMessage(ctx context.Context, inboxID uint, accountID uint, submission *model.WidgetOfflineMessageSubmission, referer, browserInfo string) (*model.WidgetOfflineMessage, error) { msg := &model.WidgetOfflineMessage{ InboxID: inboxID, AccountID: accountID, ContactName: submission.Name, ContactEmail: submission.Email, ContactPhone: submission.Phone, ContactCompany: submission.Company, ContactCity: submission.City, ContactCountry: submission.Country, Content: submission.Message, Referer: referer, BrowserInfo: browserInfo, Status: model.OfflineStatusPending, } if err := s.offlineMessageRepo.Create(ctx, msg); err != nil { return nil, fmt.Errorf("failed to create offline message: %w", err) } return msg, nil } // GetOfflineMessages retrieves all pending offline messages for an inbox. func (s *WidgetService) GetOfflineMessages(ctx context.Context, inboxID uint) ([]model.WidgetOfflineMessage, error) { return s.offlineMessageRepo.FindByInboxID(ctx, inboxID) } // ListOfflineMessagesByAccount retrieves paginated offline messages for an account (admin view). func (s *WidgetService) ListOfflineMessagesByAccount(ctx context.Context, accountID uint, page, pageSize int) ([]model.WidgetOfflineMessage, int64, error) { offset := (page - 1) * pageSize return s.offlineMessageRepo.FindByAccountID(ctx, accountID, offset, pageSize) } // ConvertOfflineMessageToConversation creates a conversation + first message // from a pending offline message, then marks it as converted. // This is the Chatwoot pattern: when an agent comes online (or manually picks up // an offline message), it becomes a real conversation the agent can respond to. func (s *WidgetService) ConvertOfflineMessageToConversation(ctx context.Context, offlineMsgID uint) (*model.Conversation, *model.Message, error) { // 1. Find the offline message offlineMsg, err := s.offlineMessageRepo.FindByID(ctx, offlineMsgID) if err != nil { return nil, nil, fmt.Errorf("offline message not found: %w", err) } if offlineMsg.Status != model.OfflineStatusPending { return nil, nil, fmt.Errorf("offline message is not pending (status=%s)", offlineMsg.Status) } // 2. Resolve the inbox inbox, err := s.inboxRepo.FindByID(ctx, offlineMsg.InboxID) if err != nil { return nil, nil, fmt.Errorf("inbox not found: %w", err) } // 3. Find or create a contact from the offline message's contact info contact, err := s.findOrCreateContactFromOfflineMessage(ctx, inbox.AccountID, offlineMsg) if err != nil { return nil, nil, fmt.Errorf("failed to identify contact: %w", err) } // 4. Find or create ContactInbox contactInbox, err := s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID) if err != nil { return nil, nil, fmt.Errorf("failed to create contact inbox: %w", err) } // 5. Create a new conversation conversation := &model.Conversation{ AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: string(model.InboxChannelTypeWebWidget), } if err := s.conversationRepo.Create(ctx, conversation); err != nil { return nil, nil, fmt.Errorf("failed to create conversation: %w", err) } // 6. Create the first message from the offline message content message := &model.Message{ ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderType: "contact", Content: offlineMsg.Content, ContentType: "text", MessageType: string(model.MessageTypeIncoming), } if err := s.messageRepo.Create(ctx, message); err != nil { return nil, nil, fmt.Errorf("failed to create message: %w", err) } // 7. Mark the offline message as converted if err := s.offlineMessageRepo.MarkConverted(ctx, offlineMsgID, conversation.ID); err != nil { return nil, nil, fmt.Errorf("failed to mark offline message as converted: %w", err) } return conversation, message, nil } // findOrCreateContactFromOfflineMessage creates a contact from an offline message's // visitor info (name, email). If email matches an existing contact, reuse it. func (s *WidgetService) findOrCreateContactFromOfflineMessage(ctx context.Context, accountID uint, offlineMsg *model.WidgetOfflineMessage) (*model.Contact, error) { // Try to find by email first if offlineMsg.ContactEmail != "" { contact, err := s.contactRepo.FindByEmail(ctx, accountID, offlineMsg.ContactEmail) if err == nil && contact != nil { return contact, nil } } // Create new contact contact := &model.Contact{ AccountID: accountID, Name: offlineMsg.ContactName, Email: offlineMsg.ContactEmail, PhoneNumber: offlineMsg.ContactPhone, } if err := s.contactRepo.Create(ctx, contact); err != nil { return nil, err } return contact, nil } // MarkOfflineMessageConverted marks an offline message as converted to a conversation. func (s *WidgetService) MarkOfflineMessageConverted(ctx context.Context, offlineMsgID uint, conversationID uint) error { return s.offlineMessageRepo.MarkConverted(ctx, offlineMsgID, conversationID) } // DismissOfflineMessage marks an offline message as dismissed by an agent. func (s *WidgetService) DismissOfflineMessage(ctx context.Context, offlineMsgID uint) error { return s.offlineMessageRepo.MarkDismissed(ctx, offlineMsgID) } // CountPendingOfflineMessages returns how many pending offline messages exist for an inbox. func (s *WidgetService) CountPendingOfflineMessages(ctx context.Context, inboxID uint) (int64, error) { return s.offlineMessageRepo.CountPendingByInboxID(ctx, inboxID) } func jsonMap(raw datatypes.JSON) map[string]any { return jsonMapBytes(raw) } func jsonMapBytes(raw []byte) map[string]any { out := map[string]any{} if len(raw) == 0 { return out } _ = json.Unmarshal(raw, &out) if out == nil { out = map[string]any{} } return out } func mustJSON(value map[string]any) datatypes.JSON { if value == nil { value = map[string]any{} } b, err := json.Marshal(value) if err != nil { return datatypes.JSON([]byte("{}")) } return datatypes.JSON(b) } func splitWidgetLabels(raw string) []string { if raw == "" { return []string{} } parts := strings.Split(raw, ",") labels := make([]string, 0, len(parts)) for _, part := range parts { label := strings.TrimSpace(part) if label != "" { labels = append(labels, label) } } 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) attachWidgetUploads(ctx context.Context, message *model.Message, signedIDs []string) ([]model.Attachment, error) { if len(signedIDs) == 0 { return nil, nil } attachments := make([]model.Attachment, 0, len(signedIDs)) for _, signedID := range signedIDs { signedID = strings.TrimSpace(signedID) if signedID == "" { continue } var upload model.DirectUpload if err := s.messageRepo.DB().WithContext(ctx).Where("upload_uuid = ?", signedID).First(&upload).Error; err != nil { return nil, fmt.Errorf("direct upload not found: %w", err) } if upload.Source != model.DirectUploadSourceWidget { return nil, errors.New("direct upload source mismatch") } if upload.Status != model.DirectUploadStatusPending { return nil, errors.New("direct upload is not pending") } if upload.AccountID != 0 && upload.AccountID != message.AccountID { return nil, errors.New("direct upload account mismatch") } if time.Now().After(upload.ExpiresAt) { upload.Status = model.DirectUploadStatusExpired _ = s.messageRepo.DB().WithContext(ctx).Save(&upload).Error return nil, errors.New("direct upload has expired") } attachment := model.Attachment{ MessageID: message.ID, AccountID: message.AccountID, FileType: upload.FileType, FileURL: upload.FileURL, ThumbURL: upload.ThumbURL, FileSize: int(upload.FileSize), FileName: upload.OriginalName, Metadata: string(upload.Metadata), } if err := s.messageRepo.DB().WithContext(ctx).Create(&attachment).Error; err != nil { return nil, fmt.Errorf("failed to create attachment: %w", err) } upload.Status = model.DirectUploadStatusCompleted upload.AccountID = message.AccountID if err := s.messageRepo.DB().WithContext(ctx).Save(&upload).Error; err != nil { return nil, fmt.Errorf("failed to mark direct upload completed: %w", err) } attachments = append(attachments, attachment) } return attachments, 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 string, mandatory bool, identifier, signature string) error { if signature == "" { if mandatory { return errors.New("HMAC failed: Invalid Identifier Hash Provided") } 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 }