676 lines
24 KiB
Go
676 lines
24 KiB
Go
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)
|
|
}
|