Files
gochat/internal/service/widget_service.go
T

1679 lines
57 KiB
Go

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"
)
// 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
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,
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
}
// --- 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"`
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
}
// 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
}
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)
}
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)
}
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")
}
// 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)
}
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
}
// 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, errors.New("conversation not found")
}
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, offset, limit int) ([]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.FindByConversation(ctx, conversation.ID, offset, limit)
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
}
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 (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")
}
_ = eventInfo
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.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
}
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, 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 {
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")
}
if req.Identifier == "" {
return nil, errors.New("identifier is 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 req.IdentifierHash != "" && !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 := &currentContactInbox.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 req.IdentifierHash != "" && !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 (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) != "" || 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) 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
}
return nil
}
func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken string, messageID uint) (map[string]any, error) {
if websiteToken == "" {
return nil, errors.New("website_token is required")
}
inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken)
if err != nil {
return nil, err
}
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")
}
if message.ContentType != "integrations" {
return nil, errors.New("invalid message type")
}
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
}
conversation.Status = string(model.ConversationStatusResolved)
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
return nil, err
}
return conversation, 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) (*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)
}
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, 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
}