* fix(security): harden auth and credential handling (HH-444) * fix(security): address HH-444 review blockers * fix(security): close remaining HH-444 review blockers --------- Co-authored-by: Rogee <rogee@ipao.vip>
2219 lines
76 KiB
Go
2219 lines
76 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/channel"
|
|
"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"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
ErrWidgetConversationNotFound = errors.New("conversation not found")
|
|
ErrWidgetEndConversationDisabled = errors.New("end conversation is not permitted")
|
|
ErrWidgetMessageContentTooLong = errors.New("Content is too long (maximum is 150000 characters)")
|
|
errWidgetConversationOwnership = errors.New("conversation does not belong to this contact")
|
|
)
|
|
|
|
const widgetMessageContentLimit = 150000
|
|
|
|
// TypingIndicator is the interface for broadcasting typing events.
|
|
// Decoupled from the concrete ws.TypingTracker to avoid circular imports.
|
|
type TypingIndicator interface {
|
|
SetTypingOn(ctx context.Context, accountID, conversationID uint, performer *ws.Performer) error
|
|
SetTypingOff(ctx context.Context, accountID, conversationID uint, performer *ws.Performer) error
|
|
}
|
|
|
|
// WidgetService implements business logic for the Web Widget channel.
|
|
// Reference: Chatwoot app/controllers/api/v1/widget_messages_controller.rb
|
|
// + app/services/web_widget/send_on_web_widget_service.rb
|
|
// + app/javascript/widget/sdk.js (frontend SDK)
|
|
//
|
|
// The Widget is a public-facing API — no agent JWT required.
|
|
// Contact identity is established via widget_token (pubsub_token in Chatwoot),
|
|
// which is issued on widget init/auth and carried in subsequent requests.
|
|
type WidgetService struct {
|
|
inboxRepo *repository.InboxRepo
|
|
contactRepo *repository.ContactRepo
|
|
contactInboxRepo *repository.ContactInboxRepo
|
|
conversationRepo *repository.ConversationRepo
|
|
messageRepo *repository.MessageRepo
|
|
transcriptMailer automation.AutomationTranscriptDeliverer
|
|
typingIndicator TypingIndicator
|
|
themeConfigRepo *repository.WidgetThemeConfigRepo
|
|
preChatFormRepo *repository.PreChatFormRepo
|
|
fileUploadRepo *repository.WidgetFileUploadRepo
|
|
offlineMessageRepo *repository.WidgetOfflineMessageRepo
|
|
inboxMemberRepo *repository.InboxMemberRepo
|
|
tagRepo *repository.TagRepo
|
|
campaignRepo *repository.CampaignRepo
|
|
worker *worker.WorkerPool
|
|
dispatcher *channel.Dispatcher
|
|
}
|
|
|
|
// NewWidgetService creates a new Widget service.
|
|
func NewWidgetService(
|
|
inboxRepo *repository.InboxRepo,
|
|
contactRepo *repository.ContactRepo,
|
|
contactInboxRepo *repository.ContactInboxRepo,
|
|
conversationRepo *repository.ConversationRepo,
|
|
messageRepo *repository.MessageRepo,
|
|
typingIndicator TypingIndicator,
|
|
themeConfigRepo *repository.WidgetThemeConfigRepo,
|
|
preChatFormRepo *repository.PreChatFormRepo,
|
|
fileUploadRepo *repository.WidgetFileUploadRepo,
|
|
offlineMessageRepo *repository.WidgetOfflineMessageRepo,
|
|
inboxMemberRepo *repository.InboxMemberRepo,
|
|
tagRepo *repository.TagRepo,
|
|
campaignRepo *repository.CampaignRepo,
|
|
) *WidgetService {
|
|
return &WidgetService{
|
|
inboxRepo: inboxRepo,
|
|
contactRepo: contactRepo,
|
|
contactInboxRepo: contactInboxRepo,
|
|
conversationRepo: conversationRepo,
|
|
messageRepo: messageRepo,
|
|
transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer(),
|
|
typingIndicator: typingIndicator,
|
|
themeConfigRepo: themeConfigRepo,
|
|
preChatFormRepo: preChatFormRepo,
|
|
fileUploadRepo: fileUploadRepo,
|
|
offlineMessageRepo: offlineMessageRepo,
|
|
inboxMemberRepo: inboxMemberRepo,
|
|
tagRepo: tagRepo,
|
|
campaignRepo: campaignRepo,
|
|
}
|
|
}
|
|
|
|
func (s *WidgetService) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
}
|
|
|
|
func (s *WidgetService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) {
|
|
s.transcriptMailer = deliverer
|
|
}
|
|
|
|
func (s *WidgetService) SetDispatcher(dispatcher *channel.Dispatcher) {
|
|
s.dispatcher = dispatcher
|
|
}
|
|
|
|
// --- DTOs ---
|
|
|
|
// WidgetInitRequest is the DTO for the /widget/init endpoint.
|
|
// Reference: Chatwoot widget SDK init — website_token identifies the inbox,
|
|
// contact attributes are optional (anonymous visitor if not provided).
|
|
type WidgetInitRequest struct {
|
|
WebsiteToken string `json:"website_token" validate:"required"`
|
|
WidgetToken string `json:"widget_token,omitempty"`
|
|
ContactName string `json:"contact_name,omitempty"`
|
|
ContactEmail string `json:"contact_email,omitempty"`
|
|
ContactPhone string `json:"contact_phone,omitempty"`
|
|
Identifier string `json:"identifier,omitempty"`
|
|
HMACVerified bool `json:"hmac_verified,omitempty"` // true if client validated HMAC
|
|
}
|
|
|
|
// WidgetInitResponse is returned after successful widget init/auth.
|
|
// Contains widget_token for subsequent requests + inbox config for UI rendering.
|
|
type WidgetInitResponse struct {
|
|
WidgetToken string `json:"widget_token"` // pubsub_token for WebSocket + auth
|
|
ContactID uint `json:"contact_id"`
|
|
ContactInboxID uint `json:"contact_inbox_id"`
|
|
InboxID uint `json:"inbox_id"`
|
|
InboxName string `json:"inbox_name"`
|
|
AccountID uint `json:"account_id"`
|
|
WidgetConfig WebWidgetConfig `json:"widget_config"`
|
|
Contact *model.Contact `json:"-"`
|
|
}
|
|
|
|
// WidgetSendMessageRequest is the DTO for the /widget/messages endpoint.
|
|
// Reference: Chatwoot WidgetMessagesController#create
|
|
type WidgetSendMessageRequest struct {
|
|
WidgetToken string `json:"widget_token" validate:"required"`
|
|
Content string `json:"content" validate:"required"`
|
|
ContentType string `json:"content_type,omitempty"` // default: text
|
|
ConversationID *uint `json:"conversation_id,omitempty"` // nil → create new conversation
|
|
AttachmentIDs []string
|
|
CustomAttributes map[string]any
|
|
Labels []string
|
|
ReplyTo *uint
|
|
}
|
|
|
|
// WidgetSendMessageResponse is returned after sending a message.
|
|
type WidgetSendMessageResponse struct {
|
|
ConversationID uint `json:"conversation_id"`
|
|
Message model.Message `json:"message"`
|
|
Attachments []model.Attachment `json:"attachments,omitempty"`
|
|
}
|
|
|
|
type WidgetContactUpdate struct {
|
|
Name string
|
|
Email string
|
|
PhoneNumber string
|
|
Identifier string
|
|
AvatarURL string
|
|
CustomAttributes map[string]any
|
|
AdditionalAttributes map[string]any
|
|
}
|
|
|
|
type WidgetSetUserRequest struct {
|
|
WebsiteToken string
|
|
WidgetToken string
|
|
Identifier string
|
|
IdentifierHash string
|
|
Email string
|
|
Name string
|
|
AvatarURL string
|
|
PhoneNumber string
|
|
CustomAttributes map[string]any
|
|
AdditionalAttributes map[string]any
|
|
}
|
|
|
|
type WidgetSetUserResponse struct {
|
|
Contact *model.Contact
|
|
WidgetAuthToken string
|
|
}
|
|
|
|
type WidgetMessageUpdate struct {
|
|
MessageID uint
|
|
WidgetToken string
|
|
ContactEmail string
|
|
ContactName string
|
|
SubmittedValues []map[string]any
|
|
}
|
|
|
|
type PublicContactRequest struct {
|
|
SourceID string
|
|
Identifier string
|
|
IdentifierHash string
|
|
Email string
|
|
Name string
|
|
AvatarURL string
|
|
PhoneNumber string
|
|
CustomAttributes map[string]any
|
|
AdditionalAttributes map[string]any
|
|
}
|
|
|
|
type PublicContactResponse struct {
|
|
ContactInbox *model.ContactInbox
|
|
Contact *model.Contact
|
|
}
|
|
|
|
type PublicConversationRequest struct {
|
|
CustomAttributes map[string]any
|
|
}
|
|
|
|
type PublicMessageRequest struct {
|
|
Content string
|
|
EchoID string
|
|
SubmittedValues []map[string]any
|
|
AttachmentIDs []string
|
|
}
|
|
|
|
type PublicMessageListOptions struct {
|
|
Before uint
|
|
Offset int
|
|
Limit int
|
|
}
|
|
|
|
type WidgetInboxMember struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
AvailabilityStatus string `json:"availability_status"`
|
|
}
|
|
|
|
type WidgetCampaign struct {
|
|
ID uint `json:"id"`
|
|
TriggerRules map[string]any `json:"trigger_rules"`
|
|
TriggerOnlyDuringBusinessHours bool `json:"trigger_only_during_business_hours"`
|
|
Message string `json:"message"`
|
|
Sender any `json:"sender"`
|
|
}
|
|
|
|
// WidgetGetCableTokenResponse returns the pubsub_token for WebSocket connection.
|
|
// Reference: Chatwoot widget SDK — fetches token for ActionCable subscription
|
|
type WidgetGetCableTokenResponse struct {
|
|
PubsubToken string `json:"pubsub_token"`
|
|
ContactID uint `json:"contact_id"`
|
|
InboxID uint `json:"inbox_id"`
|
|
AccountID uint `json:"account_id"`
|
|
}
|
|
|
|
// --- Service methods ---
|
|
|
|
// Init authenticates/creates a contact for the widget and returns a widget_token.
|
|
// Reference: Chatwoot widget SDK init flow — website_token → inbox lookup →
|
|
// contact creation/identification → pubsub_token generation → UI config delivery
|
|
//
|
|
// Flow:
|
|
// 1. Resolve inbox by website_token (from channel_config JSON)
|
|
// 2. Find or create contact (by email/phone/identifier, or anonymous)
|
|
// 3. Find or create ContactInbox (join table with pubsub_token)
|
|
// 4. Return widget_token (= pubsub_token) + config
|
|
func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*WidgetInitResponse, error) {
|
|
if req.WebsiteToken == "" {
|
|
return nil, errors.New("website_token is required")
|
|
}
|
|
|
|
// Step 1: Find inbox by website_token in channel_config
|
|
inbox, err := s.findInboxByWebsiteToken(ctx, req.WebsiteToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid website_token: %w", err)
|
|
}
|
|
var account model.Account
|
|
if err := s.conversationRepo.DB().WithContext(ctx).Select("active", "status").First(&account, inbox.AccountID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if !account.Active || strings.EqualFold(account.Status, "suspended") {
|
|
return nil, errors.New("Account is suspended")
|
|
}
|
|
if !inbox.Enabled {
|
|
return nil, errors.New("inbox is disabled")
|
|
}
|
|
|
|
// Parse widget config
|
|
widgetConfig, err := ParseWebWidgetConfig(inbox.ChannelConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget config: %w", err)
|
|
}
|
|
|
|
var contactInbox *model.ContactInbox
|
|
var contact *model.Contact
|
|
if req.WidgetToken != "" {
|
|
contactInbox, err = s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
|
|
if err == nil && contactInbox.InboxID == inbox.ID {
|
|
contact = &contactInbox.Contact
|
|
} else {
|
|
contactInbox = nil
|
|
}
|
|
}
|
|
if contact == nil {
|
|
contact, err = s.findOrCreateWidgetContact(ctx, inbox.AccountID, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to identify contact: %w", err)
|
|
}
|
|
}
|
|
|
|
// Step 3: Find or create ContactInbox
|
|
if contactInbox == nil {
|
|
contactInbox, err = s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create contact inbox: %w", err)
|
|
}
|
|
}
|
|
if req.HMACVerified && !contactInbox.HMACVerified {
|
|
contactInbox.HMACVerified = true
|
|
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
|
|
return nil, fmt.Errorf("failed to update contact inbox: %w", err)
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("Widget init: contact=%d inbox=%d contactInbox=%d",
|
|
contact.ID, inbox.ID, contactInbox.ID)
|
|
|
|
return &WidgetInitResponse{
|
|
WidgetToken: contactInbox.PubsubToken,
|
|
ContactID: contact.ID,
|
|
ContactInboxID: contactInbox.ID,
|
|
InboxID: inbox.ID,
|
|
InboxName: inbox.Name,
|
|
AccountID: inbox.AccountID,
|
|
WidgetConfig: *widgetConfig,
|
|
Contact: contact,
|
|
}, nil
|
|
}
|
|
|
|
// SendMessage sends a message from a widget contact.
|
|
// Reference: Chatwoot WidgetMessagesController#create
|
|
// If no conversation_id is provided, creates a new conversation.
|
|
func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRequest) (*WidgetSendMessageResponse, error) {
|
|
if req.WidgetToken == "" {
|
|
return nil, errors.New("widget_token is required")
|
|
}
|
|
if req.Content == "" && len(req.AttachmentIDs) == 0 {
|
|
return nil, errors.New("content is required")
|
|
}
|
|
if len([]rune(req.Content)) > widgetMessageContentLimit {
|
|
return nil, ErrWidgetMessageContentTooLong
|
|
}
|
|
|
|
// Resolve contact by pubsub_token
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
|
|
contentType := req.ContentType
|
|
if contentType == "" {
|
|
contentType = "text"
|
|
}
|
|
|
|
// Resolve conversation
|
|
var conversation *model.Conversation
|
|
conversationCreated := false
|
|
if req.ConversationID != nil {
|
|
conversation, err = s.resolveWidgetConversation(ctx, contactInbox, *req.ConversationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("conversation not found: %w", err)
|
|
}
|
|
} else {
|
|
conversations, _, findErr := s.findWidgetConversations(ctx, contactInbox, 0, 1)
|
|
if findErr != nil {
|
|
return nil, findErr
|
|
}
|
|
if len(conversations) > 0 {
|
|
conversation = &conversations[0]
|
|
} else {
|
|
conversation, err = s.createWidgetConversation(ctx, contactInbox, req.CustomAttributes, req.Labels)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create conversation: %w", err)
|
|
}
|
|
conversationCreated = true
|
|
}
|
|
}
|
|
|
|
// Create message
|
|
msg := model.Message{
|
|
ConversationID: conversation.ID,
|
|
AccountID: conversation.AccountID,
|
|
InboxID: conversation.InboxID,
|
|
SenderID: &contactInbox.ContactID,
|
|
SenderType: "Contact",
|
|
Content: req.Content,
|
|
ContentType: contentType,
|
|
MessageType: "incoming",
|
|
Status: "sent",
|
|
}
|
|
if req.ReplyTo != nil {
|
|
if _, err := s.messageRepo.FindByConversationAndID(ctx, conversation.ID, *req.ReplyTo); err == nil {
|
|
msg.ContentAttributes = mustJSON(map[string]any{"in_reply_to": *req.ReplyTo})
|
|
}
|
|
}
|
|
|
|
attachments, err := s.createIncomingMessage(ctx, conversation, &msg, req.AttachmentIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Dispatch the same complete lifecycle context Chatwoot gives ActionCable.
|
|
if s.dispatcher != nil {
|
|
inbox, _ := s.inboxRepo.FindByID(ctx, conversation.InboxID)
|
|
data := map[string]interface{}{
|
|
"inbox": inbox,
|
|
"conversation": conversation,
|
|
"contact": &contactInbox.Contact,
|
|
"widget_token": contactInbox.PubsubToken,
|
|
"channel_type": "web_widget",
|
|
}
|
|
if conversationCreated {
|
|
event := &channel.ChannelEvent{
|
|
Type: channel.EventConversationCreated, Channel: channel.ChannelWebWidget,
|
|
ConversationID: conversation.ID, InboxID: conversation.InboxID,
|
|
AccountID: conversation.AccountID, ContactID: conversation.ContactID,
|
|
Timestamp: time.Now().Unix(), Data: data,
|
|
}
|
|
if dispatchErr := s.dispatcher.Dispatch(ctx, event); dispatchErr != nil {
|
|
applogger.L().Warnf("widget conversation event dispatch failed: inbox=%d conv=%d err=%v",
|
|
conversation.InboxID, conversation.ID, dispatchErr)
|
|
}
|
|
}
|
|
messageData := make(map[string]interface{}, len(data)+1)
|
|
for key, value := range data {
|
|
messageData[key] = value
|
|
}
|
|
messageData["message"] = &msg
|
|
event := &channel.ChannelEvent{
|
|
Type: channel.EventMessageCreated,
|
|
Channel: channel.ChannelWebWidget,
|
|
ConversationID: conversation.ID,
|
|
InboxID: conversation.InboxID,
|
|
AccountID: conversation.AccountID,
|
|
Timestamp: time.Now().Unix(),
|
|
ContactID: conversation.ContactID,
|
|
Data: messageData,
|
|
}
|
|
if dispatchErr := s.dispatcher.Dispatch(ctx, event); dispatchErr != nil {
|
|
applogger.L().Warnf("widget message event dispatch failed: inbox=%d conv=%d err=%v",
|
|
conversation.InboxID, conversation.ID, dispatchErr)
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("Widget message: contact=%d conversation=%d message=%d",
|
|
contactInbox.ContactID, conversation.ID, msg.ID)
|
|
|
|
return &WidgetSendMessageResponse{
|
|
ConversationID: conversation.ID,
|
|
Message: msg,
|
|
Attachments: attachments,
|
|
}, nil
|
|
}
|
|
|
|
func (s *WidgetService) createIncomingMessage(ctx context.Context, conversation *model.Conversation, message *model.Message, attachmentIDs []string) ([]model.Attachment, error) {
|
|
var messageTimestamp int64
|
|
var attachments []model.Attachment
|
|
var captainJob *model.BackgroundJob
|
|
var captainJobCreated bool
|
|
reopen := conversation != nil && !conversation.Muted &&
|
|
(conversation.Status == string(model.ConversationStatusSnoozed) || conversation.Status == string(model.ConversationStatusResolved))
|
|
if err := s.messageRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(message).Error; err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
attachments, err = s.attachWidgetUploadsInTransaction(ctx, tx, message, attachmentIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
messageTimestamp = message.CreatedAt.Unix()
|
|
updates := map[string]any{
|
|
"last_activity_at": messageTimestamp,
|
|
"last_message_at": messageTimestamp,
|
|
}
|
|
if reopen {
|
|
updates["status"] = string(model.ConversationStatusOpen)
|
|
updates["snoozed_until"] = nil
|
|
}
|
|
if err := tx.Model(&model.Conversation{}).
|
|
Where("id = ? AND account_id = ?", conversation.ID, conversation.AccountID).
|
|
Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
captainJob, captainJobCreated, err = enqueueCaptainConversationResponseForMessageInTransaction(ctx, s.worker, tx, message.ID)
|
|
return err
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to create message: %w", err)
|
|
}
|
|
conversation.LastActivityAt = &messageTimestamp
|
|
conversation.LastMessageAt = &messageTimestamp
|
|
if reopen {
|
|
conversation.Status = string(model.ConversationStatusOpen)
|
|
conversation.SnoozedUntil = nil
|
|
}
|
|
if captainJobCreated {
|
|
s.worker.Publish(ctx, captainJob)
|
|
}
|
|
return 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.findWidgetConversations(ctx, contactInbox, 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.findWidgetConversations(ctx, contactInbox, 0, 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(conversations) == 0 {
|
|
return nil, ErrWidgetConversationNotFound
|
|
}
|
|
return &conversations[0], nil
|
|
}
|
|
|
|
func (s *WidgetService) GetConversation(ctx context.Context, widgetToken string, conversationID uint) (*model.Conversation, error) {
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
return s.resolveWidgetConversation(ctx, contactInbox, conversationID)
|
|
}
|
|
|
|
func (s *WidgetService) findWidgetConversations(ctx context.Context, contactInbox *model.ContactInbox, offset, limit int) ([]model.Conversation, int64, error) {
|
|
count, err := s.contactInboxRepo.CountByContactAndInbox(ctx, contactInbox.ContactID, contactInbox.InboxID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return s.conversationRepo.FindByContactInbox(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID,
|
|
contactInbox.InboxID, contactInbox.ID, count == 1, offset, limit)
|
|
}
|
|
|
|
func (s *WidgetService) resolveWidgetConversation(ctx context.Context, contactInbox *model.ContactInbox, conversationID uint) (*model.Conversation, error) {
|
|
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conversation.ContactID != contactInbox.ContactID || conversation.InboxID != contactInbox.InboxID {
|
|
return nil, errWidgetConversationOwnership
|
|
}
|
|
if conversation.ContactInboxID != nil {
|
|
if *conversation.ContactInboxID == contactInbox.ID {
|
|
return conversation, nil
|
|
}
|
|
return nil, errWidgetConversationOwnership
|
|
}
|
|
count, err := s.contactInboxRepo.CountByContactAndInbox(ctx, contactInbox.ContactID, contactInbox.InboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count != 1 {
|
|
return nil, errWidgetConversationOwnership
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) GetLatestConversationMessages(ctx context.Context, widgetToken string, after, before uint) ([]model.Message, int64, *model.Conversation, error) {
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
if err.Error() == "conversation not found" {
|
|
return []model.Message{}, 0, nil, nil
|
|
}
|
|
return nil, 0, nil, err
|
|
}
|
|
messages, total, err := s.messageRepo.FindByConversationFinder(ctx, conversation.ID, after, before, true)
|
|
return messages, total, conversation, err
|
|
}
|
|
|
|
func (s *WidgetService) GetInboxMembersByWebsiteToken(ctx context.Context, websiteToken string) ([]WidgetInboxMember, error) {
|
|
inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.inboxMemberRepo == nil {
|
|
return []WidgetInboxMember{}, nil
|
|
}
|
|
members, err := s.inboxMemberRepo.FindByInbox(ctx, inbox.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload := make([]WidgetInboxMember, 0, len(members))
|
|
for _, member := range members {
|
|
if member.User.ID == 0 {
|
|
continue
|
|
}
|
|
name := member.User.Name
|
|
if name == "" {
|
|
name = member.User.Email
|
|
}
|
|
availability := member.AccountAvailability
|
|
if availability == "" {
|
|
availability = "offline"
|
|
}
|
|
if !member.User.Active {
|
|
availability = "offline"
|
|
}
|
|
payload = append(payload, WidgetInboxMember{
|
|
ID: member.User.ID,
|
|
Name: name,
|
|
AvatarURL: member.User.AvatarURL,
|
|
AvailabilityStatus: availability,
|
|
})
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *WidgetService) GetCampaignsByWebsiteToken(ctx context.Context, websiteToken string) ([]WidgetCampaign, error) {
|
|
inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var account model.Account
|
|
if err := s.conversationRepo.DB().WithContext(ctx).Select("feature_flags").First(&account, inbox.AccountID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if !widgetAccountFeatureEnabled(account.FeatureFlags, "campaigns") {
|
|
return []WidgetCampaign{}, nil
|
|
}
|
|
if s.campaignRepo == nil {
|
|
return []WidgetCampaign{}, nil
|
|
}
|
|
campaigns, err := s.campaignRepo.ListByInbox(ctx, inbox.AccountID, inbox.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload := make([]WidgetCampaign, 0, len(campaigns))
|
|
for _, campaign := range campaigns {
|
|
if !campaign.Enabled {
|
|
continue
|
|
}
|
|
id := campaign.DisplayID
|
|
if id == 0 {
|
|
id = campaign.ID
|
|
}
|
|
payload = append(payload, WidgetCampaign{
|
|
ID: id,
|
|
TriggerRules: jsonMapBytes([]byte(campaign.TriggerRules)),
|
|
TriggerOnlyDuringBusinessHours: campaign.TriggerOnlyDuringBusinessHours,
|
|
Message: campaign.Message,
|
|
Sender: nil,
|
|
})
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func widgetAccountFeatureEnabled(raw, flag string) bool {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return false
|
|
}
|
|
values := map[string]bool{}
|
|
if err := json.Unmarshal([]byte(raw), &values); err == nil {
|
|
return values[flag]
|
|
}
|
|
var list []string
|
|
if err := json.Unmarshal([]byte(raw), &list); err == nil {
|
|
for _, value := range list {
|
|
if value == flag {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *WidgetService) TrackEvent(ctx context.Context, websiteToken, widgetToken, name string, eventInfo map[string]any) error {
|
|
if websiteToken == "" {
|
|
return errors.New("website_token is required")
|
|
}
|
|
if name == "" {
|
|
return errors.New("event name is required")
|
|
}
|
|
inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if widgetToken == "" {
|
|
return errors.New("widget_token required")
|
|
}
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
if contactInbox.InboxID != inbox.ID {
|
|
return errors.New("widget_token does not belong to this inbox")
|
|
}
|
|
if strings.EqualFold(name, "webwidget.triggered") {
|
|
if s.worker != nil {
|
|
if _, err := s.worker.Enqueue(ctx, "webhook:webwidget_triggered", map[string]any{
|
|
"account_id": inbox.AccountID,
|
|
"inbox_id": inbox.ID,
|
|
"contact_inbox": contactInbox,
|
|
"event_info": eventInfo,
|
|
"website_token": websiteToken,
|
|
}); err != nil {
|
|
applogger.L().Warnf("WidgetService.TrackEvent: failed to enqueue webwidget_triggered webhook: %v", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *WidgetService) AddLabelToLatestConversation(ctx context.Context, widgetToken, label string) error {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
return nil
|
|
}
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.tagRepo != nil {
|
|
if _, err := s.tagRepo.FindByNameAndAccountID(ctx, conversation.AccountID, label); err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
labels := splitWidgetLabels(conversation.Labels)
|
|
for _, existing := range labels {
|
|
if existing == label {
|
|
return nil
|
|
}
|
|
}
|
|
labels = append(labels, label)
|
|
return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(labels, ","))
|
|
}
|
|
|
|
func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, widgetToken, label string) error {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
return nil
|
|
}
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
labels := splitWidgetLabels(conversation.Labels)
|
|
filtered := labels[:0]
|
|
for _, existing := range labels {
|
|
if existing != label {
|
|
filtered = append(filtered, existing)
|
|
}
|
|
}
|
|
return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(filtered, ","))
|
|
}
|
|
|
|
func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.Contact, req WidgetContactUpdate) (*model.Contact, error) {
|
|
if req.Name != "" {
|
|
if !isGenericShangwutongName(req.Name) || isGenericShangwutongName(contact.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 isGenericShangwutongName(name string) bool {
|
|
name = strings.TrimSpace(name)
|
|
return name == "" || name == "商务通访客" || strings.HasPrefix(name, "商务通访客·")
|
|
}
|
|
|
|
func (s *WidgetService) PublicGetInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, bool, error) {
|
|
inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return inbox, channelAPI.HMACMandatory, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier string, req PublicContactRequest) (*PublicContactResponse, error) {
|
|
inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validatePublicHMAC(channelAPI.HMACToken, channelAPI.HMACMandatory, req.Identifier, req.IdentifierHash); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.SourceID == "" {
|
|
req.SourceID, err = generateToken(16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if existingInbox, err := s.contactInboxRepo.FindBySourceID(ctx, inbox.ID, req.SourceID); err == nil {
|
|
contact, err := s.contactRepo.FindByID(ctx, existingInbox.ContactID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
existingInbox.Contact = *contact
|
|
return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil
|
|
}
|
|
contact, err := s.findPublicContact(ctx, inbox.AccountID, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pubsubToken, err := generateToken(32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hmacToken, err := generateToken(32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
contactInbox := &model.ContactInbox{
|
|
ContactID: contact.ID,
|
|
InboxID: inbox.ID,
|
|
SourceID: req.SourceID,
|
|
PubsubToken: pubsubToken,
|
|
HMACToken: hmacToken,
|
|
HMACVerified: req.IdentifierHash != "",
|
|
}
|
|
if err := s.contactInboxRepo.Create(ctx, contactInbox); err != nil {
|
|
return nil, err
|
|
}
|
|
return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicGetContact(ctx context.Context, inboxIdentifier, sourceID string) (*PublicContactResponse, error) {
|
|
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PublicContactResponse{ContactInbox: contactInbox, Contact: &contactInbox.Contact}, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier, sourceID string, req PublicContactRequest) (*PublicContactResponse, error) {
|
|
channelAPI, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validatePublicHMAC(channelAPI.HMACToken, channelAPI.HMACMandatory, req.Identifier, req.IdentifierHash); err != nil {
|
|
return nil, err
|
|
}
|
|
contact, err := s.updateContactFields(ctx, &contactInbox.Contact, WidgetContactUpdate{
|
|
Name: req.Name,
|
|
Email: strings.ToLower(req.Email),
|
|
PhoneNumber: req.PhoneNumber,
|
|
Identifier: req.Identifier,
|
|
AvatarURL: req.AvatarURL,
|
|
CustomAttributes: req.CustomAttributes,
|
|
AdditionalAttributes: req.AdditionalAttributes,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.IdentifierHash != "" && !contactInbox.HMACVerified {
|
|
contactInbox.HMACVerified = true
|
|
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicListConversations(ctx context.Context, inboxIdentifier, sourceID string) ([]model.Conversation, error) {
|
|
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conversations, _, err := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 100)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return filterPublicConversations(conversations, contactInbox), nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicCreateConversation(ctx context.Context, inboxIdentifier, sourceID string, req PublicConversationRequest) (*model.Conversation, error) {
|
|
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conversation, err := s.createWidgetConversation(ctx, contactInbox, nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(req.CustomAttributes) > 0 {
|
|
conversation.CustomAttributes = mustJSON(req.CustomAttributes)
|
|
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicGetConversation(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
|
|
_, _, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conversations, err := s.PublicListConversations(ctx, inboxIdentifier, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range conversations {
|
|
if publicConversationID(conversations[i]) == displayID {
|
|
return &conversations[i], nil
|
|
}
|
|
}
|
|
return nil, errors.New("conversation not found")
|
|
}
|
|
|
|
func (s *WidgetService) PublicToggleStatus(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
|
|
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.Status = string(model.ConversationStatusResolved)
|
|
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicUpdateLastSeen(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
|
|
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().Unix()
|
|
conversation.ContactLastSeenAt = &now
|
|
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := EnqueueConversationMessageStatusUpdate(ctx, s.worker, conversation.ID, time.Unix(now, 0), string(model.MessageStatusRead)); err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicToggleTyping(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, typing bool) error {
|
|
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.typingIndicator == nil {
|
|
return nil
|
|
}
|
|
performer := &ws.Performer{ID: conversation.ContactID, Type: "contact"}
|
|
if typing {
|
|
return s.typingIndicator.SetTypingOn(ctx, conversation.AccountID, conversation.ID, performer)
|
|
}
|
|
return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversation.ID, performer)
|
|
}
|
|
|
|
func (s *WidgetService) PublicListMessages(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, opts PublicMessageListOptions) ([]model.Message, int64, *model.Conversation, error) {
|
|
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
messages, total, err := s.publicConversationMessages(ctx, conversation.ID, opts)
|
|
return messages, total, conversation, err
|
|
}
|
|
|
|
func (s *WidgetService) publicConversationMessages(ctx context.Context, conversationID uint, opts PublicMessageListOptions) ([]model.Message, int64, error) {
|
|
query := s.messageRepo.DB().WithContext(ctx).Model(&model.Message{}).
|
|
Where("conversation_id = ?", conversationID).
|
|
Where("NOT (private = ? OR message_type = ?)", true, model.MessageTypeActivity)
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var messages []model.Message
|
|
if opts.Before != 0 {
|
|
err := query.Where("id < ?", opts.Before).Order("created_at DESC, id DESC").Limit(20).Find(&messages).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
reverseMessages(messages)
|
|
return messages, total, nil
|
|
}
|
|
limit := opts.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
offset := opts.Offset
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
err := query.Offset(offset).Limit(limit).Order("created_at DESC, id DESC").Find(&messages).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
reverseMessages(messages)
|
|
return messages, total, err
|
|
}
|
|
|
|
func reverseMessages(messages []model.Message) {
|
|
for left, right := 0, len(messages)-1; left < right; left, right = left+1, right-1 {
|
|
messages[left], messages[right] = messages[right], messages[left]
|
|
}
|
|
}
|
|
|
|
func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, []model.Attachment, error) {
|
|
if strings.TrimSpace(req.Content) == "" && len(req.AttachmentIDs) == 0 {
|
|
return nil, nil, nil, errors.New("content is required")
|
|
}
|
|
if len([]rune(req.Content)) > widgetMessageContentLimit {
|
|
return nil, nil, nil, ErrWidgetMessageContentTooLong
|
|
}
|
|
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
message := &model.Message{
|
|
ConversationID: conversation.ID,
|
|
AccountID: conversation.AccountID,
|
|
InboxID: conversation.InboxID,
|
|
SenderID: &conversation.ContactID,
|
|
SenderType: "Contact",
|
|
Content: req.Content,
|
|
ContentType: "text",
|
|
MessageType: "incoming",
|
|
Status: "sent",
|
|
SourceID: req.EchoID,
|
|
}
|
|
attachments, err := s.createIncomingMessage(ctx, conversation, message, req.AttachmentIDs)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
// Public API messages use the same committed lifecycle payload as widget messages.
|
|
if s.dispatcher != nil {
|
|
inbox, _ := s.inboxRepo.FindByID(ctx, conversation.InboxID)
|
|
event := &channel.ChannelEvent{
|
|
Type: channel.EventMessageCreated,
|
|
Channel: channel.ChannelAPI,
|
|
ConversationID: conversation.ID,
|
|
InboxID: conversation.InboxID,
|
|
AccountID: conversation.AccountID,
|
|
ContactID: conversation.ContactID,
|
|
Timestamp: time.Now().Unix(),
|
|
Data: map[string]interface{}{
|
|
"inbox": inbox,
|
|
"conversation": conversation,
|
|
"contact": &contactInbox.Contact,
|
|
"widget_token": contactInbox.PubsubToken,
|
|
"channel_type": "api",
|
|
"message": message,
|
|
},
|
|
}
|
|
if dispatchErr := s.dispatcher.Dispatch(ctx, event); dispatchErr != nil {
|
|
applogger.L().Warnf("widget message event dispatch failed: inbox=%d conv=%d err=%v",
|
|
conversation.InboxID, conversation.ID, dispatchErr)
|
|
}
|
|
}
|
|
|
|
return message, conversation, attachments, nil
|
|
}
|
|
|
|
func (s *WidgetService) PublicUpdateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID, messageID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) {
|
|
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
message, err := s.messageRepo.FindByConversationAndID(ctx, conversation.ID, messageID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
attrs := jsonMap(message.ContentAttributes)
|
|
if req.SubmittedValues != nil {
|
|
if message.ContentType == "input_csat" && automation.IsCsatSurveyLocked(message.CreatedAt, time.Now()) {
|
|
return nil, nil, automation.ErrCsatSurveyLocked
|
|
}
|
|
attrs["submitted_values"] = req.SubmittedValues
|
|
message.ContentAttributes = mustJSON(attrs)
|
|
}
|
|
if err := s.messageRepo.Update(ctx, message); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if message.ContentType == "input_csat" && req.SubmittedValues != nil {
|
|
if _, err := automation.ApplyCsatSubmission(ctx, s.messageRepo.DB(), message, conversation, req.SubmittedValues); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
return message, conversation, nil
|
|
}
|
|
|
|
// GetMessages returns messages for a conversation belonging to a widget contact.
|
|
func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
|
|
if _, err := s.resolveWidgetConversation(ctx, contactInbox, conversationID); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return s.messageRepo.FindByConversation(ctx, conversationID, offset, limit)
|
|
}
|
|
|
|
func (s *WidgetService) GetMessageAttachments(ctx context.Context, messageID uint) ([]model.Attachment, error) {
|
|
var attachments []model.Attachment
|
|
err := s.messageRepo.DB().WithContext(ctx).Where("message_id = ?", messageID).Order("id ASC").Find(&attachments).Error
|
|
return attachments, err
|
|
}
|
|
|
|
// GetCableToken returns the pubsub_token for WebSocket connection.
|
|
// Reference: Chatwoot widget SDK — fetches token for ActionCable subscription
|
|
// The contact connects to /cable with pubsub_token to receive real-time events.
|
|
func (s *WidgetService) GetCableToken(ctx context.Context, widgetToken string) (*WidgetGetCableTokenResponse, error) {
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
|
|
// Resolve inbox to get account_id
|
|
inbox, err := s.inboxRepo.FindByID(ctx, contactInbox.InboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
return &WidgetGetCableTokenResponse{
|
|
PubsubToken: contactInbox.PubsubToken,
|
|
ContactID: contactInbox.ContactID,
|
|
InboxID: contactInbox.InboxID,
|
|
AccountID: inbox.AccountID,
|
|
}, nil
|
|
}
|
|
|
|
// UpdateContact updates the contact's profile from the widget.
|
|
// Reference: Chatwoot widget SDK — update contact name/email
|
|
func (s *WidgetService) UpdateContact(ctx context.Context, widgetToken string, name, email string) (*model.Contact, error) {
|
|
return s.UpdateContactProfile(ctx, widgetToken, WidgetContactUpdate{Name: name, Email: email})
|
|
}
|
|
|
|
func (s *WidgetService) GetContact(ctx context.Context, widgetToken string) (*model.Contact, error) {
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
return s.contactRepo.FindByID(ctx, contactInbox.ContactID)
|
|
}
|
|
|
|
func (s *WidgetService) UpdateContactProfile(ctx context.Context, widgetToken string, req WidgetContactUpdate) (*model.Contact, error) {
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
|
|
contact, err := s.contactRepo.FindByID(ctx, contactInbox.ContactID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.updateContactFields(ctx, contact, req)
|
|
}
|
|
|
|
func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) (*WidgetSetUserResponse, error) {
|
|
if req.WebsiteToken == "" {
|
|
return nil, errors.New("website_token is required")
|
|
}
|
|
if req.WidgetToken == "" {
|
|
return nil, errors.New("widget_token required")
|
|
}
|
|
inbox, err := s.GetInboxByWebsiteToken(ctx, req.WebsiteToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
widgetConfig, err := ParseWebWidgetConfig(inbox.ChannelConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget config: %w", err)
|
|
}
|
|
if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !VerifyHMAC(widgetConfig.HMACToken, req.Identifier, req.IdentifierHash) {
|
|
return nil, errors.New("HMAC failed: Invalid Identifier Hash Provided")
|
|
}
|
|
|
|
currentContactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
if currentContactInbox.InboxID != inbox.ID {
|
|
return nil, errors.New("widget_token does not belong to this inbox")
|
|
}
|
|
|
|
contact := ¤tContactInbox.Contact
|
|
widgetAuthToken := ""
|
|
if contact.Identifier != "" && contact.Identifier != req.Identifier {
|
|
contact, err = s.findOrCreateContactByIdentifier(ctx, inbox.AccountID, req.Identifier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentContactInbox, err = s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
widgetAuthToken = currentContactInbox.PubsubToken
|
|
}
|
|
|
|
contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{
|
|
Name: req.Name,
|
|
Email: strings.ToLower(req.Email),
|
|
PhoneNumber: req.PhoneNumber,
|
|
Identifier: req.Identifier,
|
|
AvatarURL: req.AvatarURL,
|
|
CustomAttributes: req.CustomAttributes,
|
|
AdditionalAttributes: req.AdditionalAttributes,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if shouldVerifyWidgetSetUserHMAC(widgetConfig, req) && !currentContactInbox.HMACVerified {
|
|
currentContactInbox.HMACVerified = true
|
|
if err := s.contactInboxRepo.Update(ctx, currentContactInbox); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &WidgetSetUserResponse{Contact: contact, WidgetAuthToken: widgetAuthToken}, nil
|
|
}
|
|
|
|
func shouldVerifyWidgetSetUserHMAC(widgetConfig *WebWidgetConfig, req WidgetSetUserRequest) bool {
|
|
if widgetConfig == nil {
|
|
return req.IdentifierHash != ""
|
|
}
|
|
if req.IdentifierHash == "" && !widgetConfig.HMACMandatory {
|
|
return false
|
|
}
|
|
if len(req.CustomAttributes) > 0 && req.Identifier == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpdate) (*model.Contact, *model.Message, error) {
|
|
if req.WidgetToken == "" {
|
|
return nil, nil, errors.New("widget_token required")
|
|
}
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
message, err := s.messageRepo.FindByID(ctx, req.MessageID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
conversation, err := s.resolveWidgetConversation(ctx, contactInbox, message.ConversationID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
contact := &contactInbox.Contact
|
|
if strings.TrimSpace(req.ContactEmail) != "" && message.ContentType == string(model.MessageContentTypeInputEmail) {
|
|
contact, err = s.identifyWidgetInputEmailContact(ctx, contact, contactInbox, conversation, req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
} else if strings.TrimSpace(req.ContactEmail) != "" || strings.TrimSpace(req.ContactName) != "" {
|
|
contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{
|
|
Name: strings.TrimSpace(req.ContactName),
|
|
Email: strings.ToLower(strings.TrimSpace(req.ContactEmail)),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
attrs := jsonMap(message.ContentAttributes)
|
|
if strings.TrimSpace(req.ContactEmail) != "" {
|
|
attrs["submitted_email"] = strings.ToLower(strings.TrimSpace(req.ContactEmail))
|
|
attrs["submitted_values"] = nil
|
|
} else if req.SubmittedValues != nil {
|
|
attrs["submitted_values"] = req.SubmittedValues
|
|
}
|
|
message.ContentAttributes = mustJSON(attrs)
|
|
if err := s.messageRepo.Update(ctx, message); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return contact, message, nil
|
|
}
|
|
|
|
func (s *WidgetService) identifyWidgetInputEmailContact(ctx context.Context, contact *model.Contact, contactInbox *model.ContactInbox, conversation *model.Conversation, req WidgetMessageUpdate) (*model.Contact, error) {
|
|
email := strings.ToLower(strings.TrimSpace(req.ContactEmail))
|
|
name := strings.TrimSpace(req.ContactName)
|
|
if name == "" {
|
|
name = strings.Split(email, "@")[0]
|
|
}
|
|
existing, err := s.contactRepo.FindByEmail(ctx, conversation.AccountID, email)
|
|
if err == nil && existing.ID != contact.ID {
|
|
db := s.conversationRepo.DB().WithContext(ctx)
|
|
if err := db.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Update("contact_id", existing.ID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Model(&model.ContactInbox{}).Where("id = ?", contactInbox.ID).Update("contact_id", existing.ID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Model(&model.Message{}).Where("sender_id = ? AND sender_type IN ?", contact.ID, []string{"Contact", "contact"}).Update("sender_id", existing.ID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Delete(&model.Contact{}, contact.ID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return existing, nil
|
|
}
|
|
return s.updateContactFields(ctx, contact, WidgetContactUpdate{Name: name, Email: email})
|
|
}
|
|
|
|
func (s *WidgetService) SendTranscript(ctx context.Context, widgetToken string) error {
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contact, err := s.contactRepo.FindByID(ctx, conversation.ContactID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if contact.Email == "" {
|
|
return nil
|
|
}
|
|
var account model.Account
|
|
if err := s.conversationRepo.DB().WithContext(ctx).First(&account, conversation.AccountID).Error; err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
if !account.EmailTranscriptEnabled() {
|
|
return ErrEmailTranscriptDisabled
|
|
}
|
|
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
|
|
return ErrEmailRateLimited
|
|
}
|
|
subject, body, err := s.buildWidgetTranscriptEmail(ctx, conversation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.transcriptMailer != nil {
|
|
_, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{
|
|
AccountID: conversation.AccountID,
|
|
ConversationID: conversation.ID,
|
|
Recipient: contact.Email,
|
|
Subject: subject,
|
|
Body: body,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := account.IncrementEmailSentCount(now); err != nil {
|
|
return err
|
|
}
|
|
if err := s.conversationRepo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *WidgetService) buildWidgetTranscriptEmail(ctx context.Context, conversation *model.Conversation) (string, string, error) {
|
|
var messages []model.Message
|
|
if err := s.messageRepo.DB().WithContext(ctx).
|
|
Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, conversation.AccountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
|
|
Order("id ASC").
|
|
Find(&messages).Error; err != nil {
|
|
return "", "", err
|
|
}
|
|
displayID := conversation.ID
|
|
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
|
|
displayID = *conversation.DisplayID
|
|
}
|
|
subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID)
|
|
var body strings.Builder
|
|
body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID))
|
|
for _, message := range messages {
|
|
if strings.TrimSpace(message.Content) == "" {
|
|
continue
|
|
}
|
|
body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content))
|
|
}
|
|
return subject, body.String(), nil
|
|
}
|
|
|
|
func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken, widgetToken string, messageID uint) (map[string]any, error) {
|
|
if websiteToken == "" {
|
|
return nil, errors.New("website_token is required")
|
|
}
|
|
if widgetToken == "" {
|
|
return nil, errors.New("widget_token required")
|
|
}
|
|
inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
if contactInbox.InboxID != inbox.ID {
|
|
return nil, errors.New("widget_token does not belong to this inbox")
|
|
}
|
|
message, err := s.messageRepo.FindByID(ctx, messageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if message.InboxID != inbox.ID {
|
|
return nil, errors.New("message does not belong to this inbox")
|
|
}
|
|
if _, err := s.resolveWidgetConversation(ctx, contactInbox, message.ConversationID); err != nil {
|
|
return nil, err
|
|
}
|
|
if message.ContentType != "integrations" {
|
|
return nil, errors.New("Invalid message type. Action not permitted")
|
|
}
|
|
attrs := jsonMap(message.ContentAttributes)
|
|
data, _ := attrs["data"].(map[string]any)
|
|
meetingID, _ := data["meeting_id"].(string)
|
|
if meetingID == "" {
|
|
return nil, errors.New("meeting_id is required")
|
|
}
|
|
return map[string]any{"token": "dyte_" + meetingID}, nil
|
|
}
|
|
|
|
func (s *WidgetService) DeleteContactCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Contact, error) {
|
|
contact, err := s.GetContact(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attrs := jsonMap(contact.CustomAttributes)
|
|
for _, key := range keys {
|
|
delete(attrs, key)
|
|
}
|
|
contact.CustomAttributes = mustJSON(attrs)
|
|
if err := s.contactRepo.Update(ctx, contact); err != nil {
|
|
return nil, err
|
|
}
|
|
return contact, nil
|
|
}
|
|
|
|
// ToggleTyping signals that the widget contact is typing or stopped typing.
|
|
// Reference: Chatwoot ConversationTypingOnJob / ConversationTypingOffJob
|
|
func (s *WidgetService) ToggleTyping(ctx context.Context, widgetToken string, conversationID uint, typing bool) error {
|
|
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid widget_token: %w", err)
|
|
}
|
|
|
|
conversation, err := s.resolveWidgetConversation(ctx, contactInbox, conversationID)
|
|
if err != nil {
|
|
if errors.Is(err, errWidgetConversationOwnership) {
|
|
return err
|
|
}
|
|
return fmt.Errorf("conversation not found: %w", err)
|
|
}
|
|
|
|
performer := &ws.Performer{
|
|
ID: contactInbox.ContactID,
|
|
Name: contactInbox.Contact.Name,
|
|
Type: "contact",
|
|
}
|
|
|
|
if typing {
|
|
return s.typingIndicator.SetTypingOn(ctx, conversation.AccountID, conversationID, performer)
|
|
}
|
|
return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversationID, performer)
|
|
}
|
|
|
|
func (s *WidgetService) UpdateLastSeen(ctx context.Context, widgetToken string) (*model.Conversation, error) {
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().Unix()
|
|
conversation.ContactLastSeenAt = &now
|
|
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := EnqueueConversationMessageStatusUpdate(ctx, s.worker, conversation.ID, time.Unix(now, 0), string(model.MessageStatusRead)); err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) ResolveLatestConversation(ctx context.Context, widgetToken string) (*model.Conversation, error) {
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allowed, err := s.latestConversationAllowsEnd(ctx, conversation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !allowed {
|
|
return nil, ErrWidgetEndConversationDisabled
|
|
}
|
|
if conversation.Status == string(model.ConversationStatusResolved) {
|
|
return conversation, nil
|
|
}
|
|
contactName := "visitor"
|
|
contact, contactErr := s.contactRepo.FindByID(ctx, conversation.ContactID)
|
|
if contactErr == nil && strings.TrimSpace(contact.Name) != "" {
|
|
contactName = strings.TrimSpace(contact.Name)
|
|
}
|
|
conversation.Status = string(model.ConversationStatusResolved)
|
|
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.messageRepo.Create(ctx, &model.Message{
|
|
ConversationID: conversation.ID,
|
|
AccountID: conversation.AccountID,
|
|
InboxID: conversation.InboxID,
|
|
SenderID: &conversation.ContactID,
|
|
SenderType: string(model.SenderTypeContact),
|
|
Content: "Conversation was resolved by " + contactName,
|
|
ContentType: string(model.MessageContentTypeText),
|
|
MessageType: string(model.MessageTypeActivity),
|
|
Status: string(model.MessageStatusSent),
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) latestConversationAllowsEnd(ctx context.Context, conversation *model.Conversation) (bool, error) {
|
|
inbox, err := s.inboxRepo.FindByID(ctx, conversation.InboxID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
config := parseChannelConfigMap(inbox.ChannelConfig)
|
|
flags, ok := config["selected_feature_flags"]
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
switch values := flags.(type) {
|
|
case []interface{}:
|
|
for _, value := range values {
|
|
if text, ok := value.(string); ok && text == "end_conversation" {
|
|
return true, nil
|
|
}
|
|
}
|
|
case []string:
|
|
for _, value := range values {
|
|
if value == "end_conversation" {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *WidgetService) SetLatestConversationCustomAttributes(ctx context.Context, widgetToken string, attrs map[string]any) (*model.Conversation, error) {
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conversation.CustomAttributes = mustJSON(attrs)
|
|
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) DeleteLatestConversationCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Conversation, error) {
|
|
conversation, err := s.GetLatestConversation(ctx, widgetToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attrs := jsonMap(conversation.CustomAttributes)
|
|
for _, key := range keys {
|
|
delete(attrs, key)
|
|
}
|
|
conversation.CustomAttributes = mustJSON(attrs)
|
|
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
// --- Helper methods ---
|
|
|
|
// findInboxByWebsiteToken scans all web_widget inboxes to find one with matching
|
|
// website_token in its channel_config JSON. Website tokens are unique per inbox.
|
|
func (s *WidgetService) findInboxByWebsiteToken(ctx context.Context, websiteToken string) (*model.Inbox, error) {
|
|
inboxes, err := s.inboxRepo.FindByChannelType(ctx, "web_widget", 500)
|
|
if err != nil || len(inboxes) == 0 {
|
|
return nil, fmt.Errorf("no web_widget inboxes found: %w", err)
|
|
}
|
|
|
|
for _, inbox := range inboxes {
|
|
config, err := ParseWebWidgetConfig(inbox.ChannelConfig)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if config.WebsiteToken == websiteToken {
|
|
return &inbox, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("no inbox found for website token")
|
|
}
|
|
|
|
// GetInboxByWebsiteToken is the public wrapper for findInboxByWebsiteToken,
|
|
// used by the widget handler's offline message submission endpoint.
|
|
func (s *WidgetService) GetInboxByWebsiteToken(ctx context.Context, websiteToken string) (*model.Inbox, error) {
|
|
return s.findInboxByWebsiteToken(ctx, websiteToken)
|
|
}
|
|
|
|
// findOrCreateWidgetContact identifies or creates a contact for the widget session.
|
|
// If email/phone/identifier is provided, tries to find existing contact.
|
|
// If none found or no identifiers provided, creates an anonymous contact.
|
|
func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID uint, req WidgetInitRequest) (*model.Contact, error) {
|
|
// Try to find existing contact by email (FindByEmail takes accountID + email)
|
|
if req.ContactEmail != "" {
|
|
contact, err := s.contactRepo.FindByEmail(ctx, accountID, req.ContactEmail)
|
|
if err == nil {
|
|
return contact, nil
|
|
}
|
|
}
|
|
// Try to find existing contact by identifier or phone using Search
|
|
if req.Identifier != "" || req.ContactPhone != "" {
|
|
searchQuery := req.Identifier
|
|
if searchQuery == "" {
|
|
searchQuery = req.ContactPhone
|
|
}
|
|
contacts, _, err := s.contactRepo.Search(ctx, accountID, searchQuery, 0, 5, "id ASC", search.SearchModeILike)
|
|
if err == nil && len(contacts) > 0 {
|
|
return &contacts[0], nil
|
|
}
|
|
}
|
|
|
|
// Create new contact
|
|
name := req.ContactName
|
|
if name == "" {
|
|
name = "Anonymous Visitor"
|
|
}
|
|
|
|
contact := model.Contact{
|
|
AccountID: accountID,
|
|
Name: name,
|
|
Email: req.ContactEmail,
|
|
PhoneNumber: req.ContactPhone,
|
|
Identifier: req.Identifier,
|
|
ContactType: "visitor",
|
|
}
|
|
|
|
if err := s.contactRepo.Create(ctx, &contact); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &contact, nil
|
|
}
|
|
|
|
func (s *WidgetService) findOrCreateContactByIdentifier(ctx context.Context, accountID uint, identifier string) (*model.Contact, error) {
|
|
contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, identifier)
|
|
if err == nil {
|
|
return contact, nil
|
|
}
|
|
contact = &model.Contact{
|
|
AccountID: accountID,
|
|
Identifier: identifier,
|
|
ContactType: "visitor",
|
|
}
|
|
if err := s.contactRepo.Create(ctx, contact); err != nil {
|
|
return nil, err
|
|
}
|
|
return contact, nil
|
|
}
|
|
|
|
// findOrCreateContactInbox ensures a ContactInbox exists for the contact+inbox pair.
|
|
// Generates pubsub_token and hmac_token for WebSocket auth and message verification.
|
|
func (s *WidgetService) findOrCreateContactInbox(ctx context.Context, contactID, inboxID uint) (*model.ContactInbox, error) {
|
|
// Check if one already exists
|
|
ci, err := s.contactInboxRepo.FindByContactAndInbox(ctx, contactID, inboxID)
|
|
if err == nil {
|
|
// If pubsub_token is empty, generate one
|
|
if ci.PubsubToken == "" {
|
|
pubsubToken, err := generateToken(32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate pubsub_token: %w", err)
|
|
}
|
|
hmacToken, err := generateToken(32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate hmac_token: %w", err)
|
|
}
|
|
ci.PubsubToken = pubsubToken
|
|
ci.HMACToken = hmacToken
|
|
if err := s.contactInboxRepo.Update(ctx, ci); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return ci, nil
|
|
}
|
|
|
|
// Create new ContactInbox
|
|
pubsubToken, err := generateToken(32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate pubsub_token: %w", err)
|
|
}
|
|
hmacToken, err := generateToken(32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate hmac_token: %w", err)
|
|
}
|
|
sourceID := strconv.FormatUint(uint64(contactID), 10)
|
|
ci = &model.ContactInbox{
|
|
ContactID: contactID,
|
|
InboxID: inboxID,
|
|
SourceID: sourceID,
|
|
PubsubToken: pubsubToken,
|
|
HMACToken: hmacToken,
|
|
}
|
|
|
|
if err := s.contactInboxRepo.Create(ctx, ci); err != nil {
|
|
return nil, err
|
|
}
|
|
return ci, nil
|
|
}
|
|
|
|
// createWidgetConversation creates a new conversation for a widget contact.
|
|
func (s *WidgetService) createWidgetConversation(ctx context.Context, contactInbox *model.ContactInbox, customAttributes map[string]any, labels []string) (*model.Conversation, error) {
|
|
inbox, err := s.inboxRepo.FindByID(ctx, contactInbox.InboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db := s.conversationRepo.DB().WithContext(ctx)
|
|
conversation := &model.Conversation{
|
|
AccountID: inbox.AccountID,
|
|
InboxID: inbox.ID,
|
|
ContactID: contactInbox.ContactID,
|
|
ContactInboxID: &contactInbox.ID,
|
|
Status: string(model.ConversationStatusOpen),
|
|
ChannelType: inbox.ChannelType,
|
|
Channel: inbox.ChannelType,
|
|
CustomAttributes: mustJSON(customAttributes),
|
|
Labels: strings.Join(s.validWidgetLabels(ctx, inbox.AccountID, labels), ","),
|
|
}
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
if tx.Migrator().HasTable(&model.CaptainPreference{}) && tx.Migrator().HasTable(&model.CaptainInbox{}) {
|
|
var autoReplyCount int64
|
|
if err := tx.Model(&model.CaptainPreference{}).Where("account_id = ? AND auto_reply_enabled = ?", inbox.AccountID, true).Count(&autoReplyCount).Error; err != nil {
|
|
return err
|
|
}
|
|
if autoReplyCount > 0 {
|
|
var captainInbox model.CaptainInbox
|
|
if err := tx.Where("account_id = ? AND inbox_id = ?", inbox.AccountID, inbox.ID).First(&captainInbox).Error; err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
} else {
|
|
var assistant model.CaptainAssistant
|
|
if err := tx.Where("account_id = ? AND id = ? AND status = ?", inbox.AccountID, captainInbox.AssistantID, model.AssistantStatusActive).First(&assistant).Error; err != nil {
|
|
return err
|
|
}
|
|
bot, err := ensureCaptainAgentBotBinding(ctx, tx, &assistant, inbox.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conversation.Status = string(model.ConversationStatusPending)
|
|
conversation.AssigneeAgentBotID = &bot.ID
|
|
conversation.AITakeoverVersion = 1
|
|
}
|
|
}
|
|
}
|
|
return s.conversationRepo.CreateWithDB(ctx, tx, conversation)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (s *WidgetService) validWidgetLabels(ctx context.Context, accountID uint, labels []string) []string {
|
|
if len(labels) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
valid := make([]string, 0, len(labels))
|
|
for _, label := range labels {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[label]; ok {
|
|
continue
|
|
}
|
|
if s.tagRepo != nil {
|
|
if _, err := s.tagRepo.FindByNameAndAccountID(ctx, accountID, label); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
seen[label] = struct{}{}
|
|
valid = append(valid, label)
|
|
}
|
|
return valid
|
|
}
|
|
|
|
// VerifyHMAC validates the HMAC signature from the widget client.
|
|
// Reference: Chatwoot web_widget HMAC verification — ensures the client
|
|
// hasn't tampered with the identifier (used for authenticated contacts).
|
|
func VerifyHMAC(hmacToken, identifier, signature string) bool {
|
|
if hmacToken == "" || identifier == "" || signature == "" {
|
|
return false
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(hmacToken))
|
|
mac.Write([]byte(identifier))
|
|
expectedMAC := hex.EncodeToString(mac.Sum(nil))
|
|
return hmac.Equal([]byte(signature), []byte(expectedMAC))
|
|
}
|
|
|
|
// ParseWebWidgetConfig parses the JSON channel_config string into WebWidgetConfig.
|
|
func ParseWebWidgetConfig(channelConfig string) (*WebWidgetConfig, error) {
|
|
if channelConfig == "" {
|
|
return nil, errors.New("empty channel_config")
|
|
}
|
|
var config WebWidgetConfig
|
|
if err := json.Unmarshal([]byte(channelConfig), &config); err != nil {
|
|
// Tolerate type mismatch errors (e.g., "" for a bool field).
|
|
// Rails/Chatwoot seed data sometimes serializes boolean fields as
|
|
// empty strings, which Go's strict json.Unmarshal rejects. Go still
|
|
// populates fields that parse correctly (e.g. website_token), so we
|
|
// only abort on genuine JSON syntax errors.
|
|
if _, isTypeErr := err.(*json.UnmarshalTypeError); !isTypeErr {
|
|
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),
|
|
AdditionalAttributes: mustJSON(req.AdditionalAttributes),
|
|
}
|
|
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) {
|
|
var attachments []model.Attachment
|
|
err := s.messageRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var err error
|
|
attachments, err = s.attachWidgetUploadsInTransaction(ctx, tx, message, signedIDs)
|
|
return err
|
|
})
|
|
return attachments, err
|
|
}
|
|
|
|
func (s *WidgetService) attachWidgetUploadsInTransaction(ctx context.Context, tx *gorm.DB, 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 := tx.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
|
|
_ = tx.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 := tx.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 := tx.WithContext(ctx).Save(&upload).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to mark direct upload completed: %w", err)
|
|
}
|
|
attachments = append(attachments, attachment)
|
|
}
|
|
return attachments, nil
|
|
}
|
|
|
|
func (s *WidgetService) resolvePublicInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, *channelmodel.ChannelAPI, error) {
|
|
if inboxIdentifier == "" {
|
|
return nil, nil, errors.New("inbox identifier is required")
|
|
}
|
|
inbox, channelAPI, err := s.inboxRepo.FindAPIInboxByIdentifier(ctx, inboxIdentifier)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return inbox, channelAPI, nil
|
|
}
|
|
|
|
func (s *WidgetService) resolvePublicContactInbox(ctx context.Context, inboxIdentifier, sourceID string) (*channelmodel.ChannelAPI, *model.ContactInbox, error) {
|
|
if sourceID == "" {
|
|
return nil, nil, errors.New("contact source_id is required")
|
|
}
|
|
_, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
contactInbox, err := s.contactInboxRepo.FindBySourceID(ctx, channelAPI.InboxID, sourceID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
contact, err := s.contactRepo.FindByID(ctx, contactInbox.ContactID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
contactInbox.Contact = *contact
|
|
return channelAPI, contactInbox, nil
|
|
}
|
|
|
|
func validatePublicHMAC(hmacToken string, mandatory bool, identifier, signature string) error {
|
|
if signature == "" {
|
|
if mandatory {
|
|
return errors.New("HMAC failed: Invalid Identifier Hash Provided")
|
|
}
|
|
return nil
|
|
}
|
|
if !VerifyHMAC(hmacToken, identifier, signature) {
|
|
return errors.New("HMAC failed: Invalid Identifier Hash Provided")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func filterPublicConversations(conversations []model.Conversation, contactInbox *model.ContactInbox) []model.Conversation {
|
|
if contactInbox.HMACVerified {
|
|
return conversations
|
|
}
|
|
filtered := make([]model.Conversation, 0, len(conversations))
|
|
for _, conversation := range conversations {
|
|
if conversation.ContactInboxID != nil && *conversation.ContactInboxID == contactInbox.ID {
|
|
filtered = append(filtered, conversation)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func publicConversationID(conversation model.Conversation) uint {
|
|
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
|
return *conversation.DisplayID
|
|
}
|
|
return conversation.ID
|
|
}
|