package service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strconv" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" ws "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" ) // 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 typingIndicator TypingIndicator themeConfigRepo *repository.WidgetThemeConfigRepo preChatFormRepo *repository.PreChatFormRepo fileUploadRepo *repository.WidgetFileUploadRepo offlineMessageRepo *repository.WidgetOfflineMessageRepo } // 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, ) *WidgetService { return &WidgetService{ inboxRepo: inboxRepo, contactRepo: contactRepo, contactInboxRepo: contactInboxRepo, conversationRepo: conversationRepo, messageRepo: messageRepo, typingIndicator: typingIndicator, themeConfigRepo: themeConfigRepo, preChatFormRepo: preChatFormRepo, fileUploadRepo: fileUploadRepo, offlineMessageRepo: offlineMessageRepo, } } // --- 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"` 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"` AccountID uint `json:"account_id"` WidgetConfig WebWidgetConfig `json:"widget_config"` } // 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 } // WidgetSendMessageResponse is returned after sending a message. type WidgetSendMessageResponse struct { ConversationID uint `json:"conversation_id"` Message model.Message `json:"message"` } // 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) } 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) } // Step 2: Find or create contact 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 contactInbox, err := s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID) if err != nil { return nil, fmt.Errorf("failed to create 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, AccountID: inbox.AccountID, WidgetConfig: *widgetConfig, }, 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 == "" { return nil, errors.New("content is required") } // 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 { // Create new conversation conversation, err = s.createWidgetConversation(ctx, contactInbox) 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 err := s.messageRepo.Create(ctx, &msg); err != nil { return nil, fmt.Errorf("failed to create message: %w", 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, }, nil } // 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 } // 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) } // 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) { 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 } if name != "" { contact.Name = name } if email != "" { contact.Email = email } 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) } // --- 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 } // 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) (*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, } if err := s.conversationRepo.Create(ctx, &conversation); err != nil { return nil, err } return &conversation, nil } // 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) }