2568 lines
92 KiB
Go
2568 lines
92 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gochat/gochat/internal/campaign"
|
|
"github.com/gochat/gochat/internal/channel"
|
|
whatsapp "github.com/gochat/gochat/internal/channel/whatsapp"
|
|
"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/worker"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const InboxLimitExceededMessage = "Account limit exceeded. Upgrade to a higher plan"
|
|
const InboxHealthWhatsAppCloudOnlyMessage = "Health data only available for WhatsApp Cloud API channels"
|
|
const InboxTemplateSyncInitiatedMessage = "Template sync initiated successfully"
|
|
const InboxTemplateSyncWhatsAppOnlyMessage = "Template sync is only available for WhatsApp channels"
|
|
const InboxWhatsAppCallingUnsupportedMessage = "Inbox does not support WhatsApp calling"
|
|
const InboxWhatsAppCallingFeatureRequiredMessage = "WhatsApp calling requires the channel_voice feature"
|
|
const InboxInboundCallsUnsupportedMessage = "Inbox does not support calling"
|
|
const TaskTypeInboxSyncTemplates = "inbox:sync_templates"
|
|
|
|
var ErrInboxLimitExceeded = errors.New(InboxLimitExceededMessage)
|
|
var ErrInboxHealthWhatsAppCloudOnly = errors.New(InboxHealthWhatsAppCloudOnlyMessage)
|
|
var ErrInboxTemplateSyncWhatsAppOnly = errors.New(InboxTemplateSyncWhatsAppOnlyMessage)
|
|
var ErrInboxWhatsAppCallingUnsupported = errors.New(InboxWhatsAppCallingUnsupportedMessage)
|
|
var ErrInboxWhatsAppCallingFeatureRequired = errors.New(InboxWhatsAppCallingFeatureRequiredMessage)
|
|
var ErrInboxInboundCallsUnsupported = errors.New(InboxInboundCallsUnsupportedMessage)
|
|
|
|
type WhatsAppChannelService interface {
|
|
FetchMessageTemplates(ctx context.Context, channel *channelmodel.ChannelWhatsApp) ([]interface{}, error)
|
|
FetchHealthStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error)
|
|
SetupWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string) error
|
|
SetupWebhookFields(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error
|
|
UpdateCallingStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp, status string) error
|
|
}
|
|
|
|
// InboxService implements business logic for Inbox operations.
|
|
// Reference: Chatwoot app/controllers/api/v1/inboxes_controller.rb
|
|
type InboxService struct {
|
|
repo *repository.InboxRepo
|
|
agentBotInboxRepo *repository.AgentBotInboxRepo
|
|
agentBotRepo *repository.AgentBotRepo
|
|
campaignRepo *repository.CampaignRepo
|
|
webhookSubRepo *repository.WebhookSubscriptionRepo
|
|
whatsappService WhatsAppChannelService
|
|
whatsappRepo *whatsapp.Repository
|
|
worker *worker.WorkerPool
|
|
}
|
|
|
|
// NewInboxService creates a new Inbox service.
|
|
func NewInboxService(
|
|
repo *repository.InboxRepo,
|
|
agentBotInboxRepo *repository.AgentBotInboxRepo,
|
|
agentBotRepo *repository.AgentBotRepo,
|
|
campaignRepo *repository.CampaignRepo,
|
|
webhookSubRepo *repository.WebhookSubscriptionRepo,
|
|
whatsappService WhatsAppChannelService,
|
|
whatsappRepo *whatsapp.Repository,
|
|
) *InboxService {
|
|
return &InboxService{
|
|
repo: repo,
|
|
agentBotInboxRepo: agentBotInboxRepo,
|
|
agentBotRepo: agentBotRepo,
|
|
campaignRepo: campaignRepo,
|
|
webhookSubRepo: webhookSubRepo,
|
|
whatsappService: whatsappService,
|
|
whatsappRepo: whatsappRepo,
|
|
}
|
|
}
|
|
|
|
// Ready reports whether the service has its primary repository configured.
|
|
func (s *InboxService) Ready() bool {
|
|
return s != nil && s.repo != nil
|
|
}
|
|
|
|
func (s *InboxService) DB() *gorm.DB {
|
|
if s == nil || s.repo == nil {
|
|
return nil
|
|
}
|
|
return s.repo.DB()
|
|
}
|
|
|
|
func (s *InboxService) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
}
|
|
|
|
// ListByAccount retrieves all inboxes for an account.
|
|
func (s *InboxService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Inbox, int64, error) {
|
|
return s.repo.FindByAccount(ctx, accountID, offset, limit)
|
|
}
|
|
|
|
// ListByAccountAndUser retrieves inboxes assigned to a user within an account.
|
|
func (s *InboxService) ListByAccountAndUser(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.Inbox, int64, error) {
|
|
return s.repo.FindByAccountAndUser(ctx, accountID, userID, offset, limit)
|
|
}
|
|
|
|
// GetByID retrieves a single inbox.
|
|
func (s *InboxService) GetByID(ctx context.Context, id uint) (*model.Inbox, error) {
|
|
return s.repo.FindByID(ctx, id)
|
|
}
|
|
|
|
// GetByAccountAndID retrieves an inbox scoped to an account.
|
|
func (s *InboxService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Inbox, error) {
|
|
return s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
}
|
|
|
|
// EnsureCanCreateInbox enforces Chatwoot's account usage_limits[:inboxes]
|
|
// guard before any inbox or dedicated channel records are persisted.
|
|
func (s *InboxService) EnsureCanCreateInbox(ctx context.Context, accountID uint) error {
|
|
if s == nil || s.repo == nil {
|
|
return nil
|
|
}
|
|
var account model.Account
|
|
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
|
return fmt.Errorf("get account: %w", err)
|
|
}
|
|
if account.InboxLimit <= 0 {
|
|
return nil
|
|
}
|
|
count, err := s.repo.CountByAccount(ctx, accountID)
|
|
if err != nil {
|
|
return fmt.Errorf("count inboxes: %w", err)
|
|
}
|
|
if count >= int64(account.InboxLimit) {
|
|
return ErrInboxLimitExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func IsInboxLimitExceeded(err error) bool {
|
|
return errors.Is(err, ErrInboxLimitExceeded)
|
|
}
|
|
|
|
// CreateInboxRequest is the DTO for creating an inbox.
|
|
type CreateInboxRequest struct {
|
|
Name string `json:"name" validate:"omitempty,min=2"`
|
|
ChannelType string `json:"channel_type"`
|
|
Channel map[string]any `json:"channel,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
EnableAutoAssignment bool `json:"enable_auto_assignment"`
|
|
GreetingEnabled *bool `json:"greeting_enabled,omitempty"`
|
|
GreetingMessage *string `json:"greeting_message,omitempty"`
|
|
EnableEmailCollect *bool `json:"enable_email_collect,omitempty"`
|
|
CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"`
|
|
WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"`
|
|
OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"`
|
|
Timezone *string `json:"timezone,omitempty"`
|
|
AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"`
|
|
LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"`
|
|
PortalID *uint `json:"portal_id,omitempty"`
|
|
SenderNameType *string `json:"sender_name_type,omitempty"`
|
|
BusinessName *string `json:"business_name,omitempty"`
|
|
CsatConfig map[string]any `json:"csat_config,omitempty"`
|
|
WorkingHours []repository.WorkingHourUpdateParam `json:"working_hours,omitempty"`
|
|
}
|
|
|
|
// Create creates a new inbox.
|
|
func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInboxRequest) (*model.Inbox, error) {
|
|
if err := s.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
req.ChannelType = normalizeInboxChannelType(firstNonEmpty(req.ChannelType, mapString(req.Channel, "type")))
|
|
|
|
validChannelTypes := map[string]bool{
|
|
"web_widget": true, "telegram": true, "facebook": true,
|
|
"instagram": true, "whatsapp": true, "email": true, "api": true,
|
|
"tiktok": true, "line": true, "twilio_sms": true, "sms": true,
|
|
"shangwutong": true,
|
|
}
|
|
if !validChannelTypes[req.ChannelType] {
|
|
return nil, errors.New("invalid channel_type")
|
|
}
|
|
if req.Name == "" {
|
|
req.Name = defaultInboxName(req.ChannelType, req.Channel)
|
|
}
|
|
if len(req.Name) < 2 {
|
|
return nil, errors.New("name is too short")
|
|
}
|
|
if req.ChannelType == "shangwutong" {
|
|
return s.createShangwutongInbox(ctx, accountID, req)
|
|
}
|
|
|
|
channelConfig := buildInitialInboxChannelConfig(req.ChannelType, req.Channel)
|
|
channelConfigJSON, err := json.Marshal(channelConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: accountID,
|
|
Name: req.Name,
|
|
ChannelType: req.ChannelType,
|
|
Enabled: true,
|
|
EnableAutoAssignment: req.EnableAutoAssignment,
|
|
EnableEmailCollect: true,
|
|
AllowMessagesAfterResolved: true,
|
|
SenderNameType: "friendly",
|
|
Timezone: "UTC",
|
|
ChannelConfig: string(channelConfigJSON),
|
|
}
|
|
if req.Enabled {
|
|
inbox.Enabled = true
|
|
}
|
|
applyCreateInboxSettings(inbox, req)
|
|
if req.ChannelType == "api" {
|
|
inbox.WebhookURL = mapString(req.Channel, "webhook_url")
|
|
inbox.Secret = firstNonEmpty(mapString(req.Channel, "secret"), generateInboxSecret())
|
|
}
|
|
|
|
// ChannelID is set after channel-specific config is created
|
|
// For now, default to 0; channel provider will update this
|
|
inbox.ChannelID = 0
|
|
|
|
if err := s.repo.Create(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to create inbox: %v", err)
|
|
return nil, err
|
|
}
|
|
applyCreateInboxSettings(inbox, req)
|
|
if err := s.ensureInboxWorkingHours(ctx, inbox); err != nil {
|
|
applogger.L().Warnf("Failed to initialize inbox working hours: %v", err)
|
|
}
|
|
if len(req.WorkingHours) > 0 {
|
|
if err := s.updateInboxWorkingHours(ctx, inbox, req.WorkingHours); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if inbox.ChannelType == "api" {
|
|
if err := s.syncAPIChannel(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to persist inbox defaults: %v", err)
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// UpdateInboxRequest is the DTO for updating an inbox.
|
|
type UpdateInboxRequest struct {
|
|
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"`
|
|
Channel map[string]any `json:"channel,omitempty"`
|
|
GreetingEnabled *bool `json:"greeting_enabled,omitempty"`
|
|
GreetingMessage *string `json:"greeting_message,omitempty"`
|
|
EnableEmailCollect *bool `json:"enable_email_collect,omitempty"`
|
|
CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"`
|
|
WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"`
|
|
OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"`
|
|
Timezone *string `json:"timezone,omitempty"`
|
|
AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"`
|
|
LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"`
|
|
PortalID *uint `json:"portal_id,omitempty"`
|
|
SenderNameType *string `json:"sender_name_type,omitempty"`
|
|
BusinessName *string `json:"business_name,omitempty"`
|
|
CsatConfig map[string]any `json:"csat_config,omitempty"`
|
|
WorkingHours []repository.WorkingHourUpdateParam `json:"working_hours,omitempty"`
|
|
}
|
|
|
|
// Update modifies an existing inbox.
|
|
func (s *InboxService) Update(ctx context.Context, accountID, id uint, req UpdateInboxRequest) (*model.Inbox, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if inbox.ChannelType == "shangwutong" {
|
|
return s.updateShangwutongInbox(ctx, inbox, req)
|
|
}
|
|
|
|
if req.Name != "" {
|
|
inbox.Name = req.Name
|
|
}
|
|
if req.Enabled != nil {
|
|
inbox.Enabled = *req.Enabled
|
|
}
|
|
if req.EnableAutoAssignment != nil {
|
|
inbox.EnableAutoAssignment = *req.EnableAutoAssignment
|
|
}
|
|
applyUpdateInboxSettings(inbox, req)
|
|
if len(req.Channel) > 0 {
|
|
config := parseChannelConfigMap(inbox.ChannelConfig)
|
|
mergeInboxChannelConfig(config, req.Channel)
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inbox.ChannelConfig = string(configJSON)
|
|
if webhookURL := mapString(req.Channel, "webhook_url"); webhookURL != "" {
|
|
inbox.WebhookURL = webhookURL
|
|
}
|
|
}
|
|
if inbox.ChannelType == "api" {
|
|
if err := s.syncAPIChannel(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(req.WorkingHours) > 0 {
|
|
if err := s.updateInboxWorkingHours(ctx, inbox, req.WorkingHours); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
func (s *InboxService) syncAPIChannel(ctx context.Context, inbox *model.Inbox) error {
|
|
if s == nil || s.repo == nil || s.repo.DB() == nil || inbox == nil || !channel.IsAPIInboxLike(inbox.ChannelType) {
|
|
return nil
|
|
}
|
|
config := parseChannelConfigMap(inbox.ChannelConfig)
|
|
channelAPI := channelmodel.ChannelAPI{}
|
|
err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error
|
|
if err != nil && err != gorm.ErrRecordNotFound {
|
|
return err
|
|
}
|
|
if err == gorm.ErrRecordNotFound {
|
|
channelAPI.InboxID = inbox.ID
|
|
}
|
|
channelAPI.Secret = firstNonEmpty(channelAPI.Secret, inbox.Secret, mapString(config, "secret"), generateInboxSecret())
|
|
channelAPI.Identifier = firstNonEmpty(mapString(config, "identifier"), mapString(config, "inbox_identifier"), generateInboxSecret())
|
|
channelAPI.HMACToken = firstNonEmpty(mapString(config, "hmac_token"), generateInboxSecret())
|
|
channelAPI.HMACMandatory = mapBool(config, "hmac_mandatory")
|
|
channelAPI.WebhookURL = firstNonEmpty(mapString(config, "webhook_url"), inbox.WebhookURL)
|
|
if err := validateChannelAPIAdditionalAttributes(config["additional_attributes"]); err != nil {
|
|
return err
|
|
}
|
|
channelAPI.AdditionalAttributes = marshalChannelAPIAdditionalAttributes(config["additional_attributes"])
|
|
if err := s.repo.DB().WithContext(ctx).Save(&channelAPI).Error; err != nil {
|
|
return err
|
|
}
|
|
if inbox.ChannelID != channelAPI.ID {
|
|
inbox.ChannelID = channelAPI.ID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var shangwutongSessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{11}$`)
|
|
|
|
func (s *InboxService) createShangwutongInbox(ctx context.Context, accountID uint, req CreateInboxRequest) (*model.Inbox, error) {
|
|
if err := validateShangwutongChannelKeys(req.Channel); err != nil {
|
|
return nil, err
|
|
}
|
|
sessionID := mapString(req.Channel, "session_id")
|
|
username := strings.TrimSpace(mapString(req.Channel, "username"))
|
|
password := mapString(req.Channel, "password")
|
|
desiredPresence := firstNonEmpty(mapString(req.Channel, "desired_presence"), "online")
|
|
webhookURL := mapString(req.Channel, "webhook_url")
|
|
if err := validateShangwutongConfig(sessionID, username, password, desiredPresence, webhookURL); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateShangwutongWebhookConsistency(ctx, s.repo.DB(), 0, webhookURL, true, desiredPresence, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: accountID, Name: req.Name, ChannelType: "shangwutong", Enabled: true,
|
|
EnableAutoAssignment: req.EnableAutoAssignment, EnableEmailCollect: true,
|
|
AllowMessagesAfterResolved: true, SenderNameType: "friendly", Timezone: "UTC",
|
|
ChannelConfig: "{}", WebhookURL: webhookURL, Secret: generateInboxSecret(),
|
|
}
|
|
applyCreateInboxSettings(inbox, req)
|
|
channelAPI := &channelmodel.ChannelAPI{
|
|
WebhookURL: webhookURL, Secret: inbox.Secret, Identifier: generateInboxSecret(),
|
|
HMACToken: generateInboxSecret(), HMACMandatory: true, AdditionalAttributes: []byte(`{}`),
|
|
}
|
|
config := &model.ChannelShangwutongConfig{
|
|
SessionID: sessionID, Username: username, Password: password, DesiredPresence: desiredPresence,
|
|
ConfigVersion: 1, ActualPresence: "offline", ConnectionStatus: "pending", CredentialStatus: "pending",
|
|
}
|
|
var lifecycleJob *model.BackgroundJob
|
|
var lifecycleCreated bool
|
|
err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(inbox).Error; err != nil {
|
|
return err
|
|
}
|
|
channelAPI.InboxID = inbox.ID
|
|
config.InboxID = inbox.ID
|
|
if err := tx.Create(channelAPI).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(config).Error; err != nil {
|
|
return err
|
|
}
|
|
inbox.ChannelID = channelAPI.ID
|
|
if err := tx.Omit("WorkingHours").Save(inbox).Error; err != nil {
|
|
return err
|
|
}
|
|
if tx.Migrator().HasTable(&model.WorkingHour{}) {
|
|
hours := repository.NewWorkingHourRepo(tx)
|
|
if err := hours.CreateDefaultWorkingHours(ctx, inbox.ID, accountID); err != nil {
|
|
return err
|
|
}
|
|
if len(req.WorkingHours) > 0 {
|
|
if err := hours.UpdateWorkingHours(ctx, inbox.ID, req.WorkingHours); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if s.worker != nil {
|
|
var err error
|
|
lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction(
|
|
ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongLifecycleJob("inbox_created", inbox, config.ConfigVersion),
|
|
worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, config.ConfigVersion)),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lifecycleCreated {
|
|
s.worker.Publish(ctx, lifecycleJob)
|
|
}
|
|
_ = s.loadInboxWorkingHours(ctx, inbox)
|
|
return inbox, nil
|
|
}
|
|
|
|
func (s *InboxService) updateShangwutongInbox(ctx context.Context, inbox *model.Inbox, req UpdateInboxRequest) (*model.Inbox, error) {
|
|
if err := validateShangwutongChannelKeys(req.Channel); err != nil {
|
|
return nil, err
|
|
}
|
|
var config model.ChannelShangwutongConfig
|
|
if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if value, exists := req.Channel["session_id"]; exists && strings.TrimSpace(fmt.Sprint(value)) != config.SessionID {
|
|
return nil, errors.New("session_id cannot be changed in place")
|
|
}
|
|
if value, exists := req.Channel["username"]; exists && strings.TrimSpace(fmt.Sprint(value)) != config.Username {
|
|
return nil, errors.New("username cannot be changed in place")
|
|
}
|
|
|
|
password, passwordChanged := config.Password, false
|
|
if value, exists := req.Channel["password"]; exists {
|
|
text, ok := value.(string)
|
|
if !ok || text == "" {
|
|
return nil, errors.New("password cannot be empty")
|
|
}
|
|
password, passwordChanged = text, text != config.Password
|
|
}
|
|
desiredPresence := config.DesiredPresence
|
|
if value, exists := req.Channel["desired_presence"]; exists {
|
|
desiredPresence = strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
webhookURL := inbox.WebhookURL
|
|
if value, exists := req.Channel["webhook_url"]; exists {
|
|
text, ok := value.(string)
|
|
if !ok || strings.TrimSpace(text) == "" {
|
|
return nil, errors.New("webhook_url cannot be empty")
|
|
}
|
|
webhookURL = text
|
|
}
|
|
enabled := inbox.Enabled
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
if err := validateShangwutongConfig(config.SessionID, config.Username, password, desiredPresence, webhookURL); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateShangwutongWebhookConsistency(ctx, s.repo.DB(), inbox.ID, webhookURL, enabled, desiredPresence, inbox.WebhookURL); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectorChanged := passwordChanged || desiredPresence != config.DesiredPresence || enabled != inbox.Enabled || normalizeShangwutongURL(webhookURL) != normalizeShangwutongURL(inbox.WebhookURL)
|
|
if req.Name != "" {
|
|
inbox.Name = req.Name
|
|
}
|
|
if req.EnableAutoAssignment != nil {
|
|
inbox.EnableAutoAssignment = *req.EnableAutoAssignment
|
|
}
|
|
inbox.Enabled, inbox.WebhookURL = enabled, webhookURL
|
|
applyUpdateInboxSettings(inbox, req)
|
|
config.Password, config.DesiredPresence = password, desiredPresence
|
|
if connectorChanged {
|
|
config.ConfigVersion++
|
|
if passwordChanged {
|
|
config.CredentialStatus = "pending"
|
|
}
|
|
}
|
|
|
|
var lifecycleJob *model.BackgroundJob
|
|
var lifecycleCreated bool
|
|
err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Omit("WorkingHours").Save(inbox).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(&config).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&channelmodel.ChannelAPI{}).Where("inbox_id = ?", inbox.ID).Update("webhook_url", webhookURL).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(req.WorkingHours) > 0 {
|
|
if err := repository.NewWorkingHourRepo(tx).UpdateWorkingHours(ctx, inbox.ID, req.WorkingHours); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if connectorChanged && s.worker != nil {
|
|
var err error
|
|
lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction(
|
|
ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongLifecycleJob("inbox_updated", inbox, config.ConfigVersion),
|
|
worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, config.ConfigVersion)),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lifecycleCreated {
|
|
s.worker.Publish(ctx, lifecycleJob)
|
|
}
|
|
_ = s.loadInboxWorkingHours(ctx, inbox)
|
|
return inbox, nil
|
|
}
|
|
|
|
func validateShangwutongChannelKeys(channelConfig map[string]any) error {
|
|
allowed := map[string]bool{
|
|
"type": true, "session_id": true, "username": true, "password": true,
|
|
"desired_presence": true, "webhook_url": true,
|
|
}
|
|
for key := range channelConfig {
|
|
if !allowed[normalizeInboxConfigKey(key)] {
|
|
return fmt.Errorf("unsupported shangwutong channel field %q", key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateShangwutongConfig(sessionID, username, password, desiredPresence, webhookURL string) error {
|
|
if !shangwutongSessionIDPattern.MatchString(sessionID) {
|
|
return errors.New("session_id must contain exactly 11 ASCII letters or digits")
|
|
}
|
|
if username == "" || len(username) > 255 {
|
|
return errors.New("username is required and must not exceed 255 characters")
|
|
}
|
|
if password == "" || len(password) > 4096 {
|
|
return errors.New("password is required and must not exceed 4096 characters")
|
|
}
|
|
switch desiredPresence {
|
|
case "online", "busy", "away", "offline":
|
|
default:
|
|
return errors.New("desired_presence must be online, busy, away, or offline")
|
|
}
|
|
parsed, err := url.Parse(webhookURL)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
|
return errors.New("webhook_url must be an absolute HTTP(S) URL without userinfo or fragment")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateShangwutongWebhookConsistency(ctx context.Context, db *gorm.DB, inboxID uint, webhookURL string, enabled bool, desiredPresence, currentURL string) error {
|
|
normalized := normalizeShangwutongURL(webhookURL)
|
|
var others []model.Inbox
|
|
query := db.WithContext(ctx).Where("channel_type = ? AND enabled = ?", "shangwutong", true)
|
|
if inboxID != 0 {
|
|
query = query.Where("id <> ?", inboxID)
|
|
}
|
|
if err := query.Find(&others).Error; err != nil {
|
|
return err
|
|
}
|
|
for i := range others {
|
|
if normalizeShangwutongURL(others[i].WebhookURL) != normalized {
|
|
return errors.New("all enabled shangwutong inboxes must use the same webhook_url")
|
|
}
|
|
}
|
|
if currentURL != "" && normalizeShangwutongURL(currentURL) != normalized && (enabled || desiredPresence != "offline" || len(others) > 0) {
|
|
return errors.New("webhook_url can only change while the inbox is disabled and offline with no other enabled shangwutong inbox")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeShangwutongURL(value string) string {
|
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
|
parsed.Host = strings.ToLower(parsed.Host)
|
|
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
|
parsed.RawPath = ""
|
|
return parsed.String()
|
|
}
|
|
|
|
func validateChannelAPIAdditionalAttributes(value any) error {
|
|
attrs, ok := value.(map[string]any)
|
|
if !ok || attrs == nil {
|
|
return nil
|
|
}
|
|
window, ok := attrs["agent_reply_time_window"]
|
|
if !ok || window == nil || fmt.Sprint(window) == "" {
|
|
return nil
|
|
}
|
|
parsed, err := strconv.Atoi(fmt.Sprint(window))
|
|
if err != nil || parsed <= 0 {
|
|
return errors.New("agent_reply_time_window must be greater than 0")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func marshalChannelAPIAdditionalAttributes(value any) []byte {
|
|
if value == nil {
|
|
return []byte(`{}`)
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil || !json.Valid(data) {
|
|
return []byte(`{}`)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// BindChannel persists the channel id and channel_config for channel-specific
|
|
// controllers that still create their dedicated channel record first.
|
|
func (s *InboxService) BindChannel(ctx context.Context, accountID, id, channelID uint, channel map[string]any) (*model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inbox.ChannelID = channelID
|
|
if len(channel) > 0 {
|
|
config := parseChannelConfigMap(inbox.ChannelConfig)
|
|
mergeInboxChannelConfig(config, channel)
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inbox.ChannelConfig = string(configJSON)
|
|
}
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
func applyCreateInboxSettings(inbox *model.Inbox, req CreateInboxRequest) {
|
|
if req.GreetingEnabled != nil {
|
|
inbox.GreetingEnabled = *req.GreetingEnabled
|
|
}
|
|
if req.GreetingMessage != nil {
|
|
inbox.GreetingMessage = *req.GreetingMessage
|
|
}
|
|
if req.EnableEmailCollect != nil {
|
|
inbox.EnableEmailCollect = *req.EnableEmailCollect
|
|
}
|
|
if req.CsatSurveyEnabled != nil {
|
|
inbox.CsatSurveyEnabled = *req.CsatSurveyEnabled
|
|
}
|
|
if req.WorkingHoursEnabled != nil {
|
|
inbox.WorkingHoursEnabled = *req.WorkingHoursEnabled
|
|
}
|
|
if req.OutOfOfficeMessage != nil {
|
|
inbox.OutOfOfficeMessage = *req.OutOfOfficeMessage
|
|
}
|
|
if req.Timezone != nil && *req.Timezone != "" {
|
|
inbox.Timezone = *req.Timezone
|
|
}
|
|
if req.AllowMessagesAfterResolved != nil {
|
|
inbox.AllowMessagesAfterResolved = *req.AllowMessagesAfterResolved
|
|
}
|
|
if req.LockToSingleConversation != nil {
|
|
inbox.LockToSingleConversation = *req.LockToSingleConversation
|
|
}
|
|
if req.PortalID != nil {
|
|
inbox.PortalID = req.PortalID
|
|
}
|
|
if req.SenderNameType != nil && *req.SenderNameType != "" {
|
|
inbox.SenderNameType = normalizeInboxSenderNameType(*req.SenderNameType)
|
|
}
|
|
if req.BusinessName != nil {
|
|
inbox.BusinessName = *req.BusinessName
|
|
}
|
|
if req.CsatConfig != nil {
|
|
inbox.CsatConfig = marshalInboxJSON(formatInboxCsatConfig(req.CsatConfig))
|
|
}
|
|
}
|
|
|
|
func applyUpdateInboxSettings(inbox *model.Inbox, req UpdateInboxRequest) {
|
|
if req.GreetingEnabled != nil {
|
|
inbox.GreetingEnabled = *req.GreetingEnabled
|
|
}
|
|
if req.GreetingMessage != nil {
|
|
inbox.GreetingMessage = *req.GreetingMessage
|
|
}
|
|
if req.EnableEmailCollect != nil {
|
|
inbox.EnableEmailCollect = *req.EnableEmailCollect
|
|
}
|
|
if req.CsatSurveyEnabled != nil {
|
|
inbox.CsatSurveyEnabled = *req.CsatSurveyEnabled
|
|
}
|
|
if req.WorkingHoursEnabled != nil {
|
|
inbox.WorkingHoursEnabled = *req.WorkingHoursEnabled
|
|
}
|
|
if req.OutOfOfficeMessage != nil {
|
|
inbox.OutOfOfficeMessage = *req.OutOfOfficeMessage
|
|
}
|
|
if req.Timezone != nil {
|
|
inbox.Timezone = *req.Timezone
|
|
}
|
|
if req.AllowMessagesAfterResolved != nil {
|
|
inbox.AllowMessagesAfterResolved = *req.AllowMessagesAfterResolved
|
|
}
|
|
if req.LockToSingleConversation != nil {
|
|
inbox.LockToSingleConversation = *req.LockToSingleConversation
|
|
}
|
|
if req.PortalID != nil {
|
|
inbox.PortalID = req.PortalID
|
|
}
|
|
if req.SenderNameType != nil {
|
|
inbox.SenderNameType = normalizeInboxSenderNameType(*req.SenderNameType)
|
|
}
|
|
if req.BusinessName != nil {
|
|
inbox.BusinessName = *req.BusinessName
|
|
}
|
|
if req.CsatConfig != nil {
|
|
inbox.CsatConfig = marshalInboxJSON(formatInboxCsatConfig(req.CsatConfig))
|
|
}
|
|
}
|
|
|
|
func normalizeInboxSenderNameType(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", "friendly", "friendly_name":
|
|
return "friendly"
|
|
case "professional", "business_name":
|
|
return "professional"
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func buildInitialInboxChannelConfig(channelType string, channel map[string]any) map[string]interface{} {
|
|
if channelType == "shangwutong" {
|
|
return map[string]interface{}{}
|
|
}
|
|
config := map[string]interface{}{}
|
|
mergeInboxChannelConfig(config, channel)
|
|
switch channelType {
|
|
case "web_widget":
|
|
if _, ok := config["allowed_domains"]; !ok {
|
|
config["allowed_domains"] = ""
|
|
}
|
|
if _, ok := config["continuity_via_email"]; !ok {
|
|
config["continuity_via_email"] = true
|
|
}
|
|
if _, ok := config["hmac_mandatory"]; !ok {
|
|
config["hmac_mandatory"] = false
|
|
}
|
|
if _, ok := config["pre_chat_form_enabled"]; !ok {
|
|
config["pre_chat_form_enabled"] = false
|
|
}
|
|
if _, ok := config["reply_time"]; !ok {
|
|
config["reply_time"] = "in_a_few_minutes"
|
|
}
|
|
if _, ok := config["selected_feature_flags"]; !ok {
|
|
config["selected_feature_flags"] = []string{"attachments", "emoji_picker", "end_conversation"}
|
|
}
|
|
if _, ok := config["website_token"]; !ok {
|
|
if token, err := widgetGenerateToken(12); err == nil {
|
|
config["website_token"] = token
|
|
config["identifier"] = token
|
|
}
|
|
}
|
|
if _, ok := config["hmac_token"]; !ok {
|
|
if token, err := widgetGenerateToken(24); err == nil {
|
|
config["hmac_token"] = token
|
|
}
|
|
}
|
|
if _, ok := config["widget_color"]; !ok {
|
|
config["widget_color"] = "#1f93ff"
|
|
}
|
|
case "api":
|
|
if _, ok := config["additional_attributes"]; !ok {
|
|
config["additional_attributes"] = map[string]any{}
|
|
}
|
|
if _, ok := config["hmac_mandatory"]; !ok {
|
|
config["hmac_mandatory"] = false
|
|
}
|
|
if _, ok := config["identifier"]; !ok {
|
|
if token, err := widgetGenerateToken(12); err == nil {
|
|
config["identifier"] = token
|
|
config["inbox_identifier"] = token
|
|
}
|
|
}
|
|
if _, ok := config["hmac_token"]; !ok {
|
|
config["hmac_token"] = generateInboxSecret()
|
|
}
|
|
case "email":
|
|
if _, ok := config["imap_authentication"]; !ok {
|
|
config["imap_authentication"] = "plain"
|
|
}
|
|
if _, ok := config["imap_enable_ssl"]; !ok {
|
|
config["imap_enable_ssl"] = true
|
|
}
|
|
if _, ok := config["smtp_authentication"]; !ok {
|
|
config["smtp_authentication"] = "login"
|
|
}
|
|
if _, ok := config["smtp_enable_starttls_auto"]; !ok {
|
|
config["smtp_enable_starttls_auto"] = true
|
|
}
|
|
if _, ok := config["smtp_enable_ssl_tls"]; !ok {
|
|
config["smtp_enable_ssl_tls"] = false
|
|
}
|
|
if _, ok := config["smtp_openssl_verify_mode"]; !ok {
|
|
config["smtp_openssl_verify_mode"] = "none"
|
|
}
|
|
case "whatsapp":
|
|
providerConfig, _ := config["provider_config"].(map[string]any)
|
|
if providerConfig == nil {
|
|
providerConfig = map[string]any{}
|
|
config["provider_config"] = providerConfig
|
|
}
|
|
if mapString(config, "provider") == "whatsapp_cloud" {
|
|
if _, ok := providerConfig["webhook_verify_token"]; !ok {
|
|
providerConfig["webhook_verify_token"] = generateInboxSecret()
|
|
}
|
|
}
|
|
case "fake":
|
|
if _, ok := config["identifier"]; !ok {
|
|
config["identifier"] = ""
|
|
}
|
|
if _, ok := config["webhook_url"]; !ok {
|
|
config["webhook_url"] = ""
|
|
}
|
|
if _, ok := config["token"]; !ok {
|
|
config["token"] = ""
|
|
}
|
|
}
|
|
delete(config, "type")
|
|
return config
|
|
}
|
|
|
|
func mergeInboxChannelConfig(config map[string]interface{}, channel map[string]any) {
|
|
for key, value := range channel {
|
|
normalizedKey := normalizeInboxConfigKey(key)
|
|
if normalizedKey == "type" || value == nil {
|
|
continue
|
|
}
|
|
if stringValue, ok := value.(string); ok && stringValue == "null" {
|
|
config[normalizedKey] = nil
|
|
continue
|
|
}
|
|
config[normalizedKey] = value
|
|
}
|
|
}
|
|
|
|
func normalizeInboxChannelType(channelType string) string {
|
|
channelType = strings.TrimSpace(channelType)
|
|
aliases := map[string]string{
|
|
"Channel::WebWidget": "web_widget",
|
|
"Channel::Api": "api",
|
|
"Channel::Shangwutong": "shangwutong",
|
|
"Channel::Email": "email",
|
|
"Channel::Line": "line",
|
|
"Channel::Telegram": "telegram",
|
|
"Channel::Whatsapp": "whatsapp",
|
|
"Channel::Sms": "sms",
|
|
"Channel::TwilioSms": "twilio_sms",
|
|
"Channel::FacebookPage": "facebook",
|
|
"Channel::Instagram": "instagram",
|
|
"Channel::Tiktok": "tiktok",
|
|
"Channel::TikTok": "tiktok",
|
|
}
|
|
if mapped, ok := aliases[channelType]; ok {
|
|
return mapped
|
|
}
|
|
return channelType
|
|
}
|
|
|
|
func defaultInboxName(channelType string, channel map[string]any) string {
|
|
for _, key := range []string{"name", "bot_name", "email", "website_url", "phone_number", "line_channel_id"} {
|
|
if value := mapString(channel, key); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
names := map[string]string{
|
|
"web_widget": "Website",
|
|
"telegram": "Telegram",
|
|
"facebook": "Facebook",
|
|
"instagram": "Instagram",
|
|
"whatsapp": "WhatsApp",
|
|
"email": "Email",
|
|
"api": "API",
|
|
"shangwutong": "商务通",
|
|
"line": "LINE",
|
|
"sms": "SMS",
|
|
"twilio_sms": "Twilio SMS",
|
|
"tiktok": "TikTok",
|
|
}
|
|
if name, ok := names[channelType]; ok {
|
|
return name
|
|
}
|
|
return "Inbox"
|
|
}
|
|
|
|
func normalizeInboxConfigKey(key string) string {
|
|
switch key {
|
|
case "selectedFeatureFlags":
|
|
return "selected_feature_flags"
|
|
case "welcomeTagline":
|
|
return "welcome_tagline"
|
|
case "welcomeTitle":
|
|
return "welcome_title"
|
|
case "websiteUrl":
|
|
return "website_url"
|
|
case "widgetColor":
|
|
return "widget_color"
|
|
case "webhookUrl":
|
|
return "webhook_url"
|
|
}
|
|
return key
|
|
}
|
|
|
|
func formatInboxCsatConfig(config map[string]any) map[string]any {
|
|
formatted := map[string]any{
|
|
"display_type": "emoji",
|
|
"message": "",
|
|
"button_text": "Please rate us",
|
|
"language": "en",
|
|
"survey_rules": map[string]any{"operator": "contains", "values": []any{}},
|
|
}
|
|
for key, value := range config {
|
|
formatted[key] = value
|
|
}
|
|
if rules, ok := formatted["survey_rules"].(map[string]any); ok {
|
|
if _, ok := rules["operator"]; !ok {
|
|
rules["operator"] = "contains"
|
|
}
|
|
if _, ok := rules["values"]; !ok {
|
|
rules["values"] = []any{}
|
|
}
|
|
} else {
|
|
formatted["survey_rules"] = map[string]any{"operator": "contains", "values": []any{}}
|
|
}
|
|
return formatted
|
|
}
|
|
|
|
func marshalInboxJSON(value any) string {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func mapString(values map[string]any, key string) string {
|
|
if values == nil {
|
|
return ""
|
|
}
|
|
value, ok := values[key]
|
|
if !ok {
|
|
value, ok = values[normalizeInboxConfigKey(key)]
|
|
}
|
|
if !ok || value == nil {
|
|
return ""
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
case fmt.Stringer:
|
|
return typed.String()
|
|
default:
|
|
return fmt.Sprint(typed)
|
|
}
|
|
}
|
|
|
|
func mapBool(values map[string]interface{}, key string) bool {
|
|
if values == nil {
|
|
return false
|
|
}
|
|
value, ok := values[key]
|
|
if !ok {
|
|
value, ok = values[normalizeInboxConfigKey(key)]
|
|
}
|
|
if !ok || value == nil {
|
|
return false
|
|
}
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case string:
|
|
return strings.EqualFold(typed, "true") || typed == "1"
|
|
case float64:
|
|
return typed != 0
|
|
case int:
|
|
return typed != 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *InboxService) ensureInboxWorkingHours(ctx context.Context, inbox *model.Inbox) error {
|
|
if s == nil || s.repo == nil || s.repo.DB() == nil {
|
|
return nil
|
|
}
|
|
db := s.repo.DB()
|
|
if !db.Migrator().HasTable(&model.WorkingHour{}) {
|
|
return nil
|
|
}
|
|
var count int64
|
|
if err := db.WithContext(ctx).Model(&model.WorkingHour{}).Where("inbox_id = ?", inbox.ID).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return s.loadInboxWorkingHours(ctx, inbox)
|
|
}
|
|
if err := repository.NewWorkingHourRepo(db).CreateDefaultWorkingHours(ctx, inbox.ID, inbox.AccountID); err != nil {
|
|
return err
|
|
}
|
|
return s.loadInboxWorkingHours(ctx, inbox)
|
|
}
|
|
|
|
func (s *InboxService) updateInboxWorkingHours(ctx context.Context, inbox *model.Inbox, params []repository.WorkingHourUpdateParam) error {
|
|
if err := s.ensureInboxWorkingHours(ctx, inbox); err != nil {
|
|
return err
|
|
}
|
|
for i, param := range params {
|
|
temp := &model.WorkingHour{
|
|
InboxID: inbox.ID,
|
|
AccountID: inbox.AccountID,
|
|
DayOfWeek: param.DayOfWeek,
|
|
ClosedAllDay: param.ClosedAllDay,
|
|
OpenAllDay: param.OpenAllDay,
|
|
OpenHour: param.OpenHour,
|
|
OpenMinutes: param.OpenMinutes,
|
|
CloseHour: param.CloseHour,
|
|
CloseMinutes: param.CloseMinutes,
|
|
}
|
|
temp.EnsureOpenAllDayHours()
|
|
if errs := temp.Validate(); len(errs) > 0 {
|
|
return fmt.Errorf("validation error for day %d (entry %d): %v", param.DayOfWeek, i, errs)
|
|
}
|
|
}
|
|
if err := repository.NewWorkingHourRepo(s.repo.DB()).UpdateWorkingHours(ctx, inbox.ID, params); err != nil {
|
|
return err
|
|
}
|
|
return s.loadInboxWorkingHours(ctx, inbox)
|
|
}
|
|
|
|
func (s *InboxService) loadInboxWorkingHours(ctx context.Context, inbox *model.Inbox) error {
|
|
hours, err := repository.NewWorkingHourRepo(s.repo.DB()).FindByInbox(ctx, inbox.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inbox.WorkingHours = hours
|
|
return nil
|
|
}
|
|
|
|
// Delete soft-deletes an inbox.
|
|
func (s *InboxService) Delete(ctx context.Context, id uint) error {
|
|
return s.repo.Delete(ctx, id)
|
|
}
|
|
|
|
// DeleteByAccount soft-deletes an inbox scoped to an account.
|
|
func (s *InboxService) DeleteByAccount(ctx context.Context, accountID, id uint) error {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inbox.ChannelType == "shangwutong" {
|
|
return s.deleteShangwutongInbox(ctx, inbox)
|
|
}
|
|
return s.repo.Delete(ctx, inbox.ID)
|
|
}
|
|
|
|
func (s *InboxService) deleteShangwutongInbox(ctx context.Context, inbox *model.Inbox) error {
|
|
var config model.ChannelShangwutongConfig
|
|
if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil {
|
|
return err
|
|
}
|
|
var channelAPI channelmodel.ChannelAPI
|
|
if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error; err != nil {
|
|
return err
|
|
}
|
|
tombstoneVersion := config.ConfigVersion + 1
|
|
jobPayload := newShangwutongLifecycleJob("inbox_deleted", inbox, tombstoneVersion)
|
|
jobPayload.Tombstone = true
|
|
jobPayload.WebhookURL = channelAPI.WebhookURL
|
|
jobPayload.SigningSecret = channelAPI.Secret
|
|
var lifecycleJob *model.BackgroundJob
|
|
var lifecycleCreated bool
|
|
err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
config.ConfigVersion = tombstoneVersion
|
|
if err := tx.Save(&config).Error; err != nil {
|
|
return err
|
|
}
|
|
if s.worker != nil {
|
|
var err error
|
|
lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction(
|
|
ctx, tx, TaskTypeShangwutongWebhookDelivery, jobPayload,
|
|
worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, tombstoneVersion)),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Delete(&config).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Delete(&model.Inbox{}, inbox.ID).Error
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if lifecycleCreated {
|
|
s.worker.Publish(ctx, lifecycleJob)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- WebWidget-specific methods ---
|
|
// Reference: Chatwoot app/models/channel/web_widget.rb + web_widget_config_controller.rb
|
|
|
|
// WebWidgetConfig represents the channel_config JSON structure for a web_widget inbox.
|
|
// Reference: Chatwoot app/models/channel/web_widget.rb — stores widget_token, hmac_token,
|
|
// widget_color, welcome_title, welcome_tagline, greeting_enabled, etc.
|
|
type WebWidgetConfig struct {
|
|
WebsiteToken string `json:"website_token"`
|
|
HMACToken string `json:"hmac_token"`
|
|
WidgetColor string `json:"widget_color,omitempty"`
|
|
WelcomeTitle string `json:"welcome_title,omitempty"`
|
|
WelcomeTagline string `json:"welcome_tagline,omitempty"`
|
|
GreetingEnabled bool `json:"greeting_enabled,omitempty"`
|
|
GreetingMessage string `json:"greeting_message,omitempty"`
|
|
ReplyTime string `json:"reply_time,omitempty"` // "a_few_minutes", "a_few_hours", "in_a_day"
|
|
PreChatMessage string `json:"pre_chat_message,omitempty"`
|
|
PreChatFieldsEnabled bool `json:"pre_chat_fields_enabled,omitempty"`
|
|
AutoAssignmentEnabled bool `json:"auto_assignment_enabled,omitempty"`
|
|
ContinuityViaEmail bool `json:"continuity_via_email,omitempty"`
|
|
OfflineMessageEnabled bool `json:"offline_message_enabled,omitempty"` // M11: Allow offline messages
|
|
OfflineMessageTitle string `json:"offline_message_title,omitempty"` // M11: Offline form title
|
|
OfflineMessageDesc string `json:"offline_message_description,omitempty"` // M11: Offline form description
|
|
HMACMandatory bool `json:"hmac_mandatory,omitempty"`
|
|
SelectedFeatureFlags []string `json:"selected_feature_flags,omitempty"`
|
|
}
|
|
|
|
// CreateWebWidgetInboxRequest is the DTO for creating a web_widget inbox.
|
|
// Reference: Chatwoot inboxes#create with channel_type=web_widget
|
|
type CreateWebWidgetInboxRequest struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
WidgetColor string `json:"widget_color,omitempty"`
|
|
WelcomeTitle string `json:"welcome_title,omitempty"`
|
|
WelcomeTagline string `json:"welcome_tagline,omitempty"`
|
|
GreetingEnabled bool `json:"greeting_enabled,omitempty"`
|
|
GreetingMessage string `json:"greeting_message,omitempty"`
|
|
ReplyTime string `json:"reply_time,omitempty"`
|
|
PreChatMessage string `json:"pre_chat_message,omitempty"`
|
|
PreChatFieldsEnabled bool `json:"pre_chat_fields_enabled,omitempty"`
|
|
AutoAssignmentEnabled bool `json:"auto_assignment_enabled,omitempty"`
|
|
ContinuityViaEmail bool `json:"continuity_via_email,omitempty"`
|
|
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
|
}
|
|
|
|
// UpdateWebWidgetConfigRequest is the DTO for updating widget config.
|
|
// Reference: Chatwoot web_widget_config#update — only widget-specific fields
|
|
type UpdateWebWidgetConfigRequest struct {
|
|
WidgetColor *string `json:"widget_color,omitempty"`
|
|
WelcomeTitle *string `json:"welcome_title,omitempty"`
|
|
WelcomeTagline *string `json:"welcome_tagline,omitempty"`
|
|
GreetingEnabled *bool `json:"greeting_enabled,omitempty"`
|
|
GreetingMessage *string `json:"greeting_message,omitempty"`
|
|
ReplyTime *string `json:"reply_time,omitempty"`
|
|
PreChatMessage *string `json:"pre_chat_message,omitempty"`
|
|
PreChatFieldsEnabled *bool `json:"pre_chat_fields_enabled,omitempty"`
|
|
AutoAssignmentEnabled *bool `json:"auto_assignment_enabled,omitempty"`
|
|
ContinuityViaEmail *bool `json:"continuity_via_email,omitempty"`
|
|
}
|
|
|
|
// CreateWebWidgetInbox creates a new inbox with channel_type=web_widget
|
|
// and auto-generates website_token and hmac_token.
|
|
// Reference: Chatwoot Inbox#create_web_widget — generates tokens on creation
|
|
func (s *InboxService) CreateWebWidgetInbox(ctx context.Context, accountID uint, req CreateWebWidgetInboxRequest) (*model.Inbox, error) {
|
|
if err := s.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Generate website_token and hmac_token (ref: Chatwoot's SecureRandom.hex)
|
|
websiteToken, err := widgetGenerateToken(12)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to generate website_token: %v", err)
|
|
return nil, err
|
|
}
|
|
hmacToken, err := widgetGenerateToken(24)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to generate hmac_token: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
// Build the widget config
|
|
widgetConfig := WebWidgetConfig{
|
|
WebsiteToken: websiteToken,
|
|
HMACToken: hmacToken,
|
|
WidgetColor: req.WidgetColor,
|
|
WelcomeTitle: req.WelcomeTitle,
|
|
WelcomeTagline: req.WelcomeTagline,
|
|
GreetingEnabled: req.GreetingEnabled,
|
|
GreetingMessage: req.GreetingMessage,
|
|
ReplyTime: req.ReplyTime,
|
|
PreChatMessage: req.PreChatMessage,
|
|
PreChatFieldsEnabled: req.PreChatFieldsEnabled,
|
|
AutoAssignmentEnabled: req.AutoAssignmentEnabled,
|
|
ContinuityViaEmail: req.ContinuityViaEmail,
|
|
}
|
|
|
|
configJSON, err := json.Marshal(widgetConfig)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to marshal widget config: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: accountID,
|
|
Name: req.Name,
|
|
ChannelType: "web_widget",
|
|
Enabled: true,
|
|
EnableAutoAssignment: req.EnableAutoAssignment,
|
|
ChannelConfig: string(configJSON),
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to create web_widget inbox: %v", err)
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// GetWebWidgetConfig retrieves and parses the web_widget channel_config for an inbox.
|
|
// Reference: Chatwoot web_widget_config#show
|
|
func (s *InboxService) GetWebWidgetConfig(ctx context.Context, accountID, inboxID uint) (*WebWidgetConfig, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if inbox.ChannelType != "web_widget" {
|
|
return nil, errors.New("inbox is not a web_widget channel")
|
|
}
|
|
|
|
var config WebWidgetConfig
|
|
if inbox.ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
applogger.L().Errorf("Failed to unmarshal widget config: %v", err)
|
|
return nil, err
|
|
}
|
|
}
|
|
return &config, nil
|
|
}
|
|
|
|
// UpdateWebWidgetConfig updates the widget-specific fields in channel_config.
|
|
// Reference: Chatwoot web_widget_config#update — merge update into existing config
|
|
func (s *InboxService) UpdateWebWidgetConfig(ctx context.Context, accountID, inboxID uint, req UpdateWebWidgetConfigRequest) (*model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if inbox.ChannelType != "web_widget" {
|
|
return nil, errors.New("inbox is not a web_widget channel")
|
|
}
|
|
|
|
// Parse existing config
|
|
var config WebWidgetConfig
|
|
if inbox.ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
applogger.L().Errorf("Failed to unmarshal existing widget config: %v", err)
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Merge partial updates
|
|
if req.WidgetColor != nil {
|
|
config.WidgetColor = *req.WidgetColor
|
|
}
|
|
if req.WelcomeTitle != nil {
|
|
config.WelcomeTitle = *req.WelcomeTitle
|
|
}
|
|
if req.WelcomeTagline != nil {
|
|
config.WelcomeTagline = *req.WelcomeTagline
|
|
}
|
|
if req.GreetingEnabled != nil {
|
|
config.GreetingEnabled = *req.GreetingEnabled
|
|
}
|
|
if req.GreetingMessage != nil {
|
|
config.GreetingMessage = *req.GreetingMessage
|
|
}
|
|
if req.ReplyTime != nil {
|
|
config.ReplyTime = *req.ReplyTime
|
|
}
|
|
if req.PreChatMessage != nil {
|
|
config.PreChatMessage = *req.PreChatMessage
|
|
}
|
|
if req.PreChatFieldsEnabled != nil {
|
|
config.PreChatFieldsEnabled = *req.PreChatFieldsEnabled
|
|
}
|
|
if req.AutoAssignmentEnabled != nil {
|
|
config.AutoAssignmentEnabled = *req.AutoAssignmentEnabled
|
|
}
|
|
if req.ContinuityViaEmail != nil {
|
|
config.ContinuityViaEmail = *req.ContinuityViaEmail
|
|
}
|
|
|
|
// Re-serialize config
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to marshal updated widget config: %v", err)
|
|
return nil, err
|
|
}
|
|
inbox.ChannelConfig = string(configJSON)
|
|
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// widgetGenerateToken creates a random hex token of the specified byte length.
|
|
// Reference: Chatwoot uses SecureRandom.hex for token generation
|
|
func widgetGenerateToken(byteLen int) (string, error) {
|
|
b := make([]byte, byteLen)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// --- Telegram-specific methods ---
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/channels/telegram_bots_controller.rb
|
|
// Reference: Chatwoot app/models/channel/telegram.rb
|
|
|
|
// TelegramInboxConfig represents the channel_config JSON structure for a telegram inbox.
|
|
// Reference: Chatwoot stores bot_token and bot_name in ChannelTelegram model.
|
|
// In gochat, we store them both in ChannelTelegram model AND in channel_config
|
|
// for quick Inbox-level access.
|
|
type TelegramInboxConfig struct {
|
|
BotToken string `json:"bot_token"`
|
|
BotName string `json:"bot_name"`
|
|
WelcomeMessage string `json:"welcome_message,omitempty"`
|
|
WebhookURL string `json:"webhook_url,omitempty"`
|
|
}
|
|
|
|
// CreateTelegramInboxRequest is the DTO for creating a telegram inbox.
|
|
// Reference: Chatwoot TelegramBotsController#create
|
|
type CreateTelegramInboxRequest struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
BotToken string `json:"bot_token" validate:"required"`
|
|
WelcomeMessage string `json:"welcome_message,omitempty"`
|
|
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
|
}
|
|
|
|
// UpdateTelegramInboxRequest is the DTO for updating a telegram inbox.
|
|
// Reference: Chatwoot TelegramBotsController#update — only bot_token is editable
|
|
type UpdateTelegramInboxRequest struct {
|
|
BotToken *string `json:"bot_token,omitempty"`
|
|
WelcomeMessage *string `json:"welcome_message,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
|
|
// CreateTelegramInbox creates a new Telegram channel inbox.
|
|
// Reference: Chatwoot TelegramBotsController#create
|
|
// Flow: validate bot_token via getMe → create ChannelTelegram → setup webhook → create Inbox
|
|
//
|
|
// The TelegramService handles bot token validation and webhook setup.
|
|
// The InboxService handles the Inbox creation and links it to the ChannelTelegram.
|
|
func (s *InboxService) CreateTelegramInbox(ctx context.Context, accountID uint, req CreateTelegramInboxRequest) (*model.Inbox, error) {
|
|
if err := s.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Note: bot token validation and webhook setup are handled by TelegramService
|
|
// at the handler/service layer. The InboxService focuses on Inbox persistence.
|
|
// The handler calls TelegramService.CreateChannel first, then creates the Inbox.
|
|
|
|
// Build the Telegram config
|
|
telConfig := TelegramInboxConfig{
|
|
BotToken: req.BotToken,
|
|
WelcomeMessage: req.WelcomeMessage,
|
|
}
|
|
|
|
configJSON, err := json.Marshal(telConfig)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to marshal telegram config: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: accountID,
|
|
Name: req.Name,
|
|
ChannelType: "telegram",
|
|
Enabled: true,
|
|
EnableAutoAssignment: req.EnableAutoAssignment,
|
|
ChannelConfig: string(configJSON),
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to create telegram inbox: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
applogger.L().Infof("Telegram inbox created (account_id=%d, inbox_id=%d)", accountID, inbox.ID)
|
|
return inbox, nil
|
|
}
|
|
|
|
// UpdateTelegramInbox updates a Telegram channel inbox.
|
|
// Reference: Chatwoot TelegramBotsController#update
|
|
// Only bot_token is editable. Updating bot_token triggers re-validation + re-webhook setup.
|
|
func (s *InboxService) UpdateTelegramInbox(ctx context.Context, accountID, inboxID uint, req UpdateTelegramInboxRequest) (*model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("telegram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "telegram" {
|
|
return nil, fmt.Errorf("inbox is not a telegram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
// Parse existing config
|
|
var config TelegramInboxConfig
|
|
if inbox.ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
applogger.L().Errorf("Failed to unmarshal existing telegram config: %v", err)
|
|
return nil, fmt.Errorf("failed to parse telegram config: %w", err)
|
|
}
|
|
}
|
|
|
|
// Apply updates
|
|
if req.BotToken != nil {
|
|
config.BotToken = *req.BotToken
|
|
// Note: Bot token re-validation and webhook re-setup are handled by TelegramService
|
|
// at the handler/service layer, not here.
|
|
}
|
|
if req.WelcomeMessage != nil {
|
|
config.WelcomeMessage = *req.WelcomeMessage
|
|
}
|
|
if req.Name != nil {
|
|
inbox.Name = *req.Name
|
|
}
|
|
if req.Enabled != nil {
|
|
inbox.Enabled = *req.Enabled
|
|
}
|
|
|
|
// Re-serialize config
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to marshal updated telegram config: %v", err)
|
|
return nil, err
|
|
}
|
|
inbox.ChannelConfig = string(configJSON)
|
|
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// DeleteTelegramInbox deletes a Telegram channel inbox.
|
|
// Reference: Chatwoot TelegramBotsController#destroy
|
|
// Flow: delete webhook from Telegram → delete ChannelTelegram → delete Inbox
|
|
//
|
|
// Note: Webhook deletion is handled by TelegramService at the handler/service layer
|
|
// BEFORE calling this method. Go doesn't have after_destroy callbacks like Chatwoot.
|
|
func (s *InboxService) DeleteTelegramInbox(ctx context.Context, accountID, inboxID uint) error {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return fmt.Errorf("telegram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "telegram" {
|
|
return fmt.Errorf("inbox is not a telegram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
// Note: Telegram webhook deletion should be done BEFORE this call
|
|
// (by the handler calling TelegramService.DeleteChannel)
|
|
// and ChannelTelegram record deletion should also happen before this call.
|
|
|
|
if err := s.repo.Delete(ctx, inbox.ID); err != nil {
|
|
applogger.L().Errorf("Failed to delete telegram inbox: %v", err)
|
|
return err
|
|
}
|
|
|
|
applogger.L().Infof("Telegram inbox deleted (account_id=%d, inbox_id=%d)", accountID, inboxID)
|
|
return nil
|
|
}
|
|
|
|
// ReauthorizeTelegramInbox re-validates the bot token and re-sets the webhook.
|
|
// Reference: Chatwoot sets reauthorization_required=true when webhook/bot fails.
|
|
// This method re-validates the bot token via getMe API and re-sets the webhook.
|
|
func (s *InboxService) ReauthorizeTelegramInbox(ctx context.Context, accountID, inboxID uint) (*model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("telegram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "telegram" {
|
|
return nil, fmt.Errorf("inbox is not a telegram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
// Parse existing config
|
|
var config TelegramInboxConfig
|
|
if inbox.ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
applogger.L().Errorf("Failed to unmarshal existing telegram config: %v", err)
|
|
return nil, fmt.Errorf("failed to parse telegram config: %w", err)
|
|
}
|
|
}
|
|
|
|
// Note: Bot token re-validation and webhook re-setup are handled by TelegramService
|
|
// at the handler/service layer. The handler calls TelegramService.validateBotToken
|
|
// and TelegramService.setupWebhook, then updates the config here.
|
|
|
|
// The reauthorization flow clears the reauthorization_required flag
|
|
// and updates webhook_url in the config after successful validation.
|
|
// This is done by the handler after calling TelegramService methods.
|
|
|
|
applogger.L().Infof("Telegram inbox reauthorized (account_id=%d, inbox_id=%d)", accountID, inboxID)
|
|
return inbox, nil
|
|
}
|
|
|
|
// GetTelegramInboxConfig retrieves and parses the telegram channel_config for an inbox.
|
|
func (s *InboxService) GetTelegramInboxConfig(ctx context.Context, accountID, inboxID uint) (*TelegramInboxConfig, *model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("telegram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "telegram" {
|
|
return nil, nil, fmt.Errorf("inbox is not a telegram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
var config TelegramInboxConfig
|
|
if inbox.ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
applogger.L().Errorf("Failed to unmarshal telegram config: %v", err)
|
|
return nil, nil, fmt.Errorf("failed to parse telegram config: %w", err)
|
|
}
|
|
}
|
|
|
|
return &config, inbox, nil
|
|
}
|
|
|
|
// FindTelegramInboxByBotToken finds a telegram inbox by bot token.
|
|
// Reference: Chatwoot: Inbox.find_by(channel: telegram, bot_token: token)
|
|
// Used by the webhook handler to route incoming Telegram messages.
|
|
func (s *InboxService) FindTelegramInboxByBotToken(ctx context.Context, botToken string) (*model.Inbox, error) {
|
|
// Use FindByChannelType to get all telegram inboxes without account scoping
|
|
inboxes, err := s.repo.FindByChannelType(ctx, "telegram", 10000)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query inboxes: %w", err)
|
|
}
|
|
|
|
for i := range inboxes {
|
|
var config TelegramInboxConfig
|
|
if inboxes[i].ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inboxes[i].ChannelConfig), &config); err != nil {
|
|
continue
|
|
}
|
|
if config.BotToken == botToken {
|
|
return &inboxes[i], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("telegram inbox not found for bot_token")
|
|
}
|
|
|
|
// UpdateTelegramConfig updates the Telegram channel_config for an inbox.
|
|
// Used after webhook setup or bot token validation to store webhook_url and bot_name.
|
|
func (s *InboxService) UpdateTelegramConfig(ctx context.Context, accountID, inboxID uint, config TelegramInboxConfig) (*model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("telegram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "telegram" {
|
|
return nil, fmt.Errorf("inbox is not a telegram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to marshal telegram config: %v", err)
|
|
return nil, err
|
|
}
|
|
inbox.ChannelConfig = string(configJSON)
|
|
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// --- Instagram-specific methods ---
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/channels/instagram_controller.rb
|
|
//
|
|
// Instagram DMs use the Facebook Graph API under the hood (Meta Business Suite).
|
|
// The Instagram channel requires a connected Facebook Page since
|
|
// Instagram DMs are managed via the Page's Graph API permissions.
|
|
|
|
// InstagramInboxConfig represents the channel_config JSON structure for an Instagram inbox.
|
|
// This stores the IG-specific fields alongside the ChannelInstagram table record.
|
|
type InstagramInboxConfig struct {
|
|
InstagramAccountID string `json:"instagram_account_id"`
|
|
InstagramBusinessAccountID string `json:"instagram_business_account_id,omitempty"`
|
|
PageAccessToken string `json:"page_access_token"`
|
|
ConnectedFBPageID string `json:"connected_fb_page_id"`
|
|
InstagramAccountName string `json:"instagram_account_name,omitempty"`
|
|
}
|
|
|
|
// CreateInstagramInboxRequest is the DTO for creating an Instagram inbox.
|
|
// Reference: Chatwoot inboxes#create with channel_type=instagram
|
|
type CreateInstagramInboxRequest struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
InstagramAccountID string `json:"instagram_account_id" validate:"required"`
|
|
PageAccessToken string `json:"page_access_token" validate:"required"`
|
|
ConnectedFBPageID string `json:"connected_fb_page_id" validate:"required"`
|
|
InstagramBusinessAccountID string `json:"instagram_business_account_id,omitempty"`
|
|
InstagramAccountName string `json:"instagram_account_name,omitempty"`
|
|
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
|
}
|
|
|
|
// UpdateInstagramInboxRequest is the DTO for updating an Instagram inbox.
|
|
type UpdateInstagramInboxRequest struct {
|
|
Name *string `json:"name,omitempty" validate:"omitempty,min=2"`
|
|
InstagramAccountName *string `json:"instagram_account_name,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"`
|
|
}
|
|
|
|
// CreateInstagramInbox creates a new Instagram channel inbox.
|
|
// Reference: Chatwoot accounts/channels/instagram_controller#create
|
|
// Flow: validate IG config → create ChannelInstagram → create Inbox → link
|
|
//
|
|
// Instagram DMs require a connected Facebook Page. The inbox wraps both the
|
|
// ChannelInstagram record (for IG-specific fields) and the Inbox record.
|
|
func (s *InboxService) CreateInstagramInbox(ctx context.Context, accountID uint, req CreateInstagramInboxRequest, igRepo *repository.ChannelInstagramRepo) (*model.Inbox, error) {
|
|
if err := s.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the ChannelInstagram record
|
|
ch := &channelmodel.ChannelInstagram{
|
|
AccountID: accountID,
|
|
InstagramAccountID: req.InstagramAccountID,
|
|
InstagramBusinessAccountID: req.InstagramBusinessAccountID,
|
|
PageAccessToken: req.PageAccessToken,
|
|
ConnectedFBPageID: req.ConnectedFBPageID,
|
|
InstagramAccountName: req.InstagramAccountName,
|
|
ReauthorizationRequired: false,
|
|
}
|
|
|
|
// Serialize the Instagram config into ChannelConfig JSON
|
|
config := InstagramInboxConfig{
|
|
InstagramAccountID: req.InstagramAccountID,
|
|
InstagramBusinessAccountID: req.InstagramBusinessAccountID,
|
|
PageAccessToken: req.PageAccessToken,
|
|
ConnectedFBPageID: req.ConnectedFBPageID,
|
|
InstagramAccountName: req.InstagramAccountName,
|
|
}
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to marshal instagram config: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: accountID,
|
|
Name: req.Name,
|
|
ChannelType: "instagram",
|
|
Enabled: true,
|
|
EnableAutoAssignment: req.EnableAutoAssignment,
|
|
ChannelConfig: string(configJSON),
|
|
}
|
|
|
|
// Create Inbox first to get the ID
|
|
if err := s.repo.Create(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to create instagram inbox: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
// Link ChannelInstagram to the Inbox
|
|
ch.InboxID = inbox.ID
|
|
if err := igRepo.Create(ctx, ch); err != nil {
|
|
applogger.L().Errorf("Failed to create channel_instagram record: %v", err)
|
|
// Rollback inbox creation
|
|
_ = s.repo.Delete(ctx, inbox.ID)
|
|
return nil, err
|
|
}
|
|
|
|
// Update Inbox.ChannelID to point to the ChannelInstagram record
|
|
inbox.ChannelID = ch.ID
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to update inbox channel_id: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
applogger.L().Infof("Instagram inbox created (account_id=%d, inbox_id=%d, channel_instagram_id=%d)", accountID, inbox.ID, ch.ID)
|
|
return inbox, nil
|
|
}
|
|
|
|
// GetInstagramInboxConfig retrieves and parses the Instagram channel_config for an inbox.
|
|
func (s *InboxService) GetInstagramInboxConfig(ctx context.Context, accountID, inboxID uint) (*InstagramInboxConfig, *model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("instagram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "instagram" {
|
|
return nil, nil, fmt.Errorf("inbox is not an instagram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
var config InstagramInboxConfig
|
|
if inbox.ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
applogger.L().Errorf("Failed to unmarshal instagram config: %v", err)
|
|
return nil, nil, fmt.Errorf("failed to parse instagram config: %w", err)
|
|
}
|
|
}
|
|
|
|
return &config, inbox, nil
|
|
}
|
|
|
|
// UpdateInstagramInbox updates an Instagram channel inbox.
|
|
// Reference: Chatwoot accounts/channels/instagram_controller#update
|
|
func (s *InboxService) UpdateInstagramInbox(ctx context.Context, accountID, inboxID uint, req UpdateInstagramInboxRequest) (*model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("instagram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "instagram" {
|
|
return nil, fmt.Errorf("inbox is not an instagram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
if req.Name != nil {
|
|
inbox.Name = *req.Name
|
|
}
|
|
if req.Enabled != nil {
|
|
inbox.Enabled = *req.Enabled
|
|
}
|
|
if req.EnableAutoAssignment != nil {
|
|
inbox.EnableAutoAssignment = *req.EnableAutoAssignment
|
|
}
|
|
|
|
// Update Instagram-specific fields in ChannelConfig
|
|
if req.InstagramAccountName != nil {
|
|
var config InstagramInboxConfig
|
|
if inbox.ChannelConfig != "" {
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
applogger.L().Errorf("Failed to unmarshal instagram config: %v", err)
|
|
return nil, err
|
|
}
|
|
}
|
|
config.InstagramAccountName = *req.InstagramAccountName
|
|
configJSON, marshalErr := json.Marshal(config)
|
|
if marshalErr != nil {
|
|
return nil, marshalErr
|
|
}
|
|
inbox.ChannelConfig = string(configJSON)
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, err
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// DeleteInstagramInbox deletes an Instagram channel inbox.
|
|
// Reference: Chatwoot accounts/channels/instagram_controller#destroy
|
|
// Flow: delete ChannelInstagram → delete Inbox
|
|
func (s *InboxService) DeleteInstagramInbox(ctx context.Context, accountID, inboxID uint, igRepo *repository.ChannelInstagramRepo) error {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return fmt.Errorf("instagram inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "instagram" {
|
|
return fmt.Errorf("inbox is not an instagram channel (channel_type=%s)", inbox.ChannelType)
|
|
}
|
|
|
|
// Delete the ChannelInstagram record first
|
|
igChannel, igErr := igRepo.FindByInboxID(ctx, inboxID)
|
|
if igErr == nil {
|
|
if delErr := igRepo.Delete(ctx, igChannel.ID); delErr != nil {
|
|
applogger.L().Errorf("Failed to delete channel_instagram record: %v", delErr)
|
|
// Continue with inbox deletion even if channel record deletion fails
|
|
}
|
|
}
|
|
|
|
// Delete the Inbox
|
|
if err := s.repo.Delete(ctx, inbox.ID); err != nil {
|
|
applogger.L().Errorf("Failed to delete instagram inbox: %v", err)
|
|
return err
|
|
}
|
|
|
|
applogger.L().Infof("Instagram inbox deleted (account_id=%d, inbox_id=%d)", accountID, inboxID)
|
|
return nil
|
|
}
|
|
|
|
// FindInstagramInboxByFBPageID finds an Instagram inbox by the connected FB Page ID.
|
|
// Used by the webhook handler to route incoming Instagram messages.
|
|
// Reference: Chatwoot: Inbox.find_by(channel: instagram, connected_fb_page_id: page_id)
|
|
func (s *InboxService) FindInstagramInboxByFBPageID(ctx context.Context, fbPageID string, igRepo *repository.ChannelInstagramRepo) (*model.Inbox, error) {
|
|
igChannel, err := igRepo.FindByConnectedFBPageID(ctx, fbPageID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("instagram channel not found for fb_page_id: %w", err)
|
|
}
|
|
|
|
inbox, err := s.repo.FindByID(ctx, igChannel.InboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found for instagram channel: %w", err)
|
|
}
|
|
return inbox, nil
|
|
}
|
|
|
|
// === Facebook Inbox ===
|
|
|
|
// FacebookInboxConfig holds the Facebook-specific config stored in Inbox.ChannelConfig JSON.
|
|
type FacebookInboxConfig struct {
|
|
PageID string `json:"page_id"`
|
|
PageAccessToken string `json:"page_access_token"`
|
|
PageName string `json:"page_name,omitempty"`
|
|
WebhookVerifyToken string `json:"verify_token"`
|
|
FBAppID string `json:"fb_app_id,omitempty"`
|
|
FBAppSecret string `json:"fb_app_secret,omitempty"`
|
|
}
|
|
|
|
// CreateFacebookInboxRequest is the DTO for creating a Facebook Messenger inbox.
|
|
// Reference: Chatwoot inboxes#create with channel_type=facebook
|
|
type CreateFacebookInboxRequest struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
PageID string `json:"page_id" validate:"required"`
|
|
PageAccessToken string `json:"page_access_token" validate:"required"`
|
|
UserAccessToken string `json:"user_access_token,omitempty"`
|
|
PageName string `json:"page_name,omitempty"`
|
|
WebhookVerifyToken string `json:"webhook_verify_token,omitempty"` // auto-generated if empty
|
|
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
|
// Per-inbox Meta App credentials
|
|
FBAppID string `json:"fb_app_id,omitempty"`
|
|
FBAppSecret string `json:"fb_app_secret,omitempty"`
|
|
}
|
|
|
|
// CreateFacebookInbox creates a new Facebook Messenger channel inbox.
|
|
// Reference: Chatwoot accounts/channels/facebook_pages_controller#create
|
|
// Flow: generate verify token → create ChannelFacebook → create Inbox → link
|
|
func (s *InboxService) CreateFacebookInbox(ctx context.Context, accountID uint, req CreateFacebookInboxRequest, fbRepo *repository.ChannelFacebookRepo) (*model.Inbox, error) {
|
|
if err := s.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Auto-generate webhook verify token if not provided
|
|
verifyToken := req.WebhookVerifyToken
|
|
if verifyToken == "" {
|
|
b := make([]byte, 24)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return nil, fmt.Errorf("failed to generate verify token: %w", err)
|
|
}
|
|
verifyToken = hex.EncodeToString(b)
|
|
}
|
|
|
|
// Create the ChannelFacebook record
|
|
ch := &channelmodel.ChannelFacebook{
|
|
AccountID: accountID,
|
|
PageID: req.PageID,
|
|
PageAccessToken: req.PageAccessToken,
|
|
UserAccessToken: req.UserAccessToken,
|
|
PageName: req.PageName,
|
|
AppID: req.FBAppID,
|
|
WebhookVerifyToken: verifyToken,
|
|
ReauthorizationRequired: false,
|
|
}
|
|
|
|
// Serialize the Facebook config into ChannelConfig JSON
|
|
config := FacebookInboxConfig{
|
|
PageID: req.PageID,
|
|
PageAccessToken: req.PageAccessToken,
|
|
PageName: req.PageName,
|
|
WebhookVerifyToken: verifyToken,
|
|
FBAppID: req.FBAppID,
|
|
FBAppSecret: req.FBAppSecret,
|
|
}
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to marshal facebook config: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: accountID,
|
|
Name: req.Name,
|
|
ChannelType: "facebook",
|
|
Enabled: true,
|
|
EnableAutoAssignment: req.EnableAutoAssignment,
|
|
ChannelConfig: string(configJSON),
|
|
}
|
|
|
|
// Create Inbox first to get the ID
|
|
if err := s.repo.Create(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to create facebook inbox: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
// Link ChannelFacebook to the Inbox
|
|
ch.InboxID = inbox.ID
|
|
if err := fbRepo.Create(ctx, ch); err != nil {
|
|
applogger.L().Errorf("Failed to create channel_facebook record: %v", err)
|
|
// Rollback inbox creation
|
|
_ = s.repo.Delete(ctx, inbox.ID)
|
|
return nil, err
|
|
}
|
|
|
|
// Update Inbox.ChannelID to point to the ChannelFacebook record
|
|
inbox.ChannelID = ch.ID
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
applogger.L().Errorf("Failed to update inbox channel_id: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
applogger.L().Infof("Facebook inbox created (account_id=%d, inbox_id=%d, channel_facebook_id=%d)", accountID, inbox.ID, ch.ID)
|
|
return inbox, nil
|
|
}
|
|
|
|
// ========================================
|
|
// Member-action methods (Chatwoot InboxesController member routes)
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/inboxes_controller.rb
|
|
// - set_agent_bot (POST)
|
|
// - health (GET)
|
|
// - sync_templates (POST)
|
|
// - register_webhook (POST)
|
|
// ========================================
|
|
|
|
// SetAgentBotRequest represents the request body for setting an agent bot on an inbox.
|
|
// Reference: Chatwoot InboxesController#set_agent_bot
|
|
type SetAgentBotRequest struct {
|
|
AgentBotID *uint `json:"-"` // nil means "remove" current bot.
|
|
}
|
|
|
|
// UnmarshalJSON accepts Chatwoot's frontend key (`agent_bot`) and the old local
|
|
// compatibility key (`agent_bot_id`). A null or missing value disconnects the bot.
|
|
func (r *SetAgentBotRequest) UnmarshalJSON(data []byte) error {
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
value, ok := raw["agent_bot"]
|
|
if !ok {
|
|
value, ok = raw["agent_bot_id"]
|
|
}
|
|
if !ok || string(value) == "null" || string(value) == `""` {
|
|
r.AgentBotID = nil
|
|
return nil
|
|
}
|
|
var id uint
|
|
if err := json.Unmarshal(value, &id); err == nil {
|
|
r.AgentBotID = &id
|
|
return nil
|
|
}
|
|
var idString string
|
|
if err := json.Unmarshal(value, &idString); err != nil {
|
|
return err
|
|
}
|
|
parsed, err := strconv.ParseUint(idString, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id = uint(parsed)
|
|
r.AgentBotID = &id
|
|
return nil
|
|
}
|
|
|
|
// SetAgentBot assigns or removes an agent bot from an inbox.
|
|
// If agent_bot is absent/null, the existing bot assignment is destroyed.
|
|
// If agent_bot is present, the inbox's single AgentBotInbox binding is created or updated.
|
|
// Reference: Chatwoot InboxesController#set_agent_bot
|
|
func (s *InboxService) SetAgentBot(ctx context.Context, accountID, inboxID uint, req SetAgentBotRequest) (*model.AgentBotInbox, error) {
|
|
// Verify inbox belongs to account
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
existingBindings, err := s.agentBotInboxRepo.FindByInboxID(ctx, inbox.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find bot bindings: %w", err)
|
|
}
|
|
|
|
// Chatwoot destroys the existing has_one binding when no agent_bot param is present.
|
|
if req.AgentBotID == nil {
|
|
for _, binding := range existingBindings {
|
|
if err := s.agentBotInboxRepo.Delete(ctx, binding.ID); err != nil {
|
|
applogger.L().Warnf("Failed to delete agent_bot_inbox id=%d: %v", binding.ID, err)
|
|
}
|
|
}
|
|
applogger.L().Infof("Removed agent bot from inbox (account_id=%d, inbox_id=%d)", accountID, inboxID)
|
|
return nil, nil
|
|
}
|
|
agentBotID := *req.AgentBotID
|
|
|
|
// Verify the agent bot exists and is accessible to this account
|
|
bot, err := s.agentBotRepo.FindByID(ctx, agentBotID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agent bot not found: %w", err)
|
|
}
|
|
if bot.AccountID != nil && *bot.AccountID != accountID {
|
|
return nil, fmt.Errorf("agent bot %d not accessible to account %d", agentBotID, accountID)
|
|
}
|
|
|
|
if len(existingBindings) > 0 {
|
|
binding := existingBindings[0]
|
|
binding.AgentBotID = agentBotID
|
|
binding.AccountID = &accountID
|
|
binding.Status = model.AgentBotInboxActive
|
|
if err := s.agentBotInboxRepo.Update(ctx, &binding); err != nil {
|
|
return nil, fmt.Errorf("failed to update agent_bot_inbox: %w", err)
|
|
}
|
|
for _, stale := range existingBindings[1:] {
|
|
if err := s.agentBotInboxRepo.Delete(ctx, stale.ID); err != nil {
|
|
applogger.L().Warnf("Failed to delete stale agent_bot_inbox id=%d: %v", stale.ID, err)
|
|
}
|
|
}
|
|
applogger.L().Infof("Set agent bot %d on inbox %d (account_id=%d)", agentBotID, inboxID, accountID)
|
|
return &binding, nil
|
|
}
|
|
|
|
// Create new binding
|
|
binding := &model.AgentBotInbox{
|
|
AccountID: &accountID,
|
|
InboxID: inbox.ID,
|
|
AgentBotID: agentBotID,
|
|
Status: model.AgentBotInboxActive,
|
|
}
|
|
if err := s.agentBotInboxRepo.Create(ctx, binding); err != nil {
|
|
return nil, fmt.Errorf("failed to create agent_bot_inbox: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("Set agent bot %d on inbox %d (account_id=%d)", agentBotID, inboxID, accountID)
|
|
return binding, nil
|
|
}
|
|
|
|
// GetAgentBot retrieves the currently active agent bot assigned to an inbox.
|
|
// Reference: Chatwoot InboxesController#agent_bot (GET member action)
|
|
func (s *InboxService) GetAgentBot(ctx context.Context, accountID, inboxID uint) (*model.AgentBot, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
activeBindings, err := s.agentBotInboxRepo.FindActiveByInboxID(ctx, inbox.ID)
|
|
if err != nil || len(activeBindings) == 0 {
|
|
return nil, nil // No bot assigned
|
|
}
|
|
|
|
bot, err := s.agentBotRepo.FindByID(ctx, activeBindings[0].AgentBotID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agent bot not found: %w", err)
|
|
}
|
|
return bot, nil
|
|
}
|
|
|
|
// Health returns Chatwoot's WhatsApp Cloud health payload for an inbox.
|
|
// Reference: Api::V1::Accounts::Concerns::WhatsappHealthManagement#health
|
|
func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (map[string]interface{}, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType == "shangwutong" {
|
|
var config model.ChannelShangwutongConfig
|
|
if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]interface{}{
|
|
"config_version": config.ConfigVersion, "desired_presence": config.DesiredPresence,
|
|
"actual_presence": config.ActualPresence, "connection_status": config.ConnectionStatus,
|
|
"credential_status": config.CredentialStatus, "last_heartbeat_at": config.LastHeartbeatAt,
|
|
"last_error_code": config.LastErrorCode, "status_updated_at": config.StatusUpdatedAt,
|
|
}, nil
|
|
}
|
|
if inbox.ChannelType != "whatsapp" {
|
|
return nil, ErrInboxHealthWhatsAppCloudOnly
|
|
}
|
|
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !waChannel.IsCloudAPI() {
|
|
return nil, ErrInboxHealthWhatsAppCloudOnly
|
|
}
|
|
return s.fetchWhatsAppHealthStatus(ctx, waChannel)
|
|
}
|
|
|
|
// SyncTemplates queues message template sync for an inbox's WhatsApp channel.
|
|
// Reference: Chatwoot InboxesController#sync_templates (POST member action, WhatsApp only)
|
|
func (s *InboxService) SyncTemplates(ctx context.Context, accountID, inboxID uint) error {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "whatsapp" {
|
|
return ErrInboxTemplateSyncWhatsAppOnly
|
|
}
|
|
|
|
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
|
|
}
|
|
if s.worker == nil {
|
|
return fmt.Errorf("worker pool not available")
|
|
}
|
|
|
|
_, err = s.worker.Enqueue(ctx, TaskTypeInboxSyncTemplates, inboxTemplateSyncJob{AccountID: accountID, InboxID: inbox.ID, ChannelID: waChannel.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3))
|
|
return err
|
|
}
|
|
|
|
// RegisterWebhookRequest represents the request body for registering a webhook on an inbox.
|
|
// Reference: Chatwoot InboxesController#register_webhook (POST member action)
|
|
type RegisterWebhookRequest struct {
|
|
URL string `json:"url"`
|
|
Events []string `json:"events,omitempty"` // e.g. ["message_created", "conversation_updated"]
|
|
}
|
|
|
|
// RegisterWebhook registers a webhook URL with the channel provider for an inbox.
|
|
// Chatwoot exposes this member action only for WhatsApp Cloud inboxes.
|
|
// Reference: Chatwoot InboxesController#register_webhook
|
|
func (s *InboxService) RegisterWebhook(ctx context.Context, accountID, inboxID uint, req RegisterWebhookRequest) error {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType != "whatsapp" {
|
|
return ErrInboxHealthWhatsAppCloudOnly
|
|
}
|
|
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
|
|
}
|
|
if !waChannel.IsCloudAPI() {
|
|
return ErrInboxHealthWhatsAppCloudOnly
|
|
}
|
|
webhookURL := req.URL
|
|
if webhookURL == "" {
|
|
webhookURL = fmt.Sprintf("%s/webhooks/whatsapp/%s", strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"), waChannel.PhoneNumber)
|
|
}
|
|
if err := s.setupWhatsAppWebhook(ctx, waChannel, webhookURL); err != nil {
|
|
return fmt.Errorf("failed to register webhook with WhatsApp: %w", err)
|
|
}
|
|
applogger.L().Infof("Registered webhook for WhatsApp inbox %d (account_id=%d)", inboxID, accountID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// EnableWhatsAppCalling matches Chatwoot Enterprise InboxesController#enable_whatsapp_calling.
|
|
// It enables Meta calling remotely, registers the calls webhook field, then persists calling_enabled.
|
|
func (s *InboxService) EnableWhatsAppCalling(ctx context.Context, accountID, inboxID uint) error {
|
|
account, inbox, waChannel, err := s.whatsAppCallingPrereqs(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !featureFlagStringEnabled(account.FeatureFlags, "channel_voice") {
|
|
return ErrInboxWhatsAppCallingFeatureRequired
|
|
}
|
|
if s.whatsappService == nil {
|
|
return fmt.Errorf("WhatsApp service not available")
|
|
}
|
|
if err := s.whatsappService.UpdateCallingStatus(ctx, waChannel, "ENABLED"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.setupWhatsAppWebhookFields(ctx, waChannel, whatsappWebhookCallbackURL(waChannel.PhoneNumber), nil); err != nil {
|
|
return err
|
|
}
|
|
setWhatsAppCallingEnabled(waChannel, true)
|
|
if err := s.whatsappRepo.Update(ctx, waChannel); err != nil {
|
|
return err
|
|
}
|
|
return s.refreshWhatsAppInboxConfig(ctx, inbox, waChannel, true)
|
|
}
|
|
|
|
// DisableWhatsAppCalling matches Chatwoot Enterprise InboxesController#disable_whatsapp_calling.
|
|
// It only gates calling locally; webhook re-registration without calls is best-effort.
|
|
func (s *InboxService) DisableWhatsAppCalling(ctx context.Context, accountID, inboxID uint) error {
|
|
_, inbox, waChannel, err := s.whatsAppCallingPrereqs(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
setWhatsAppCallingEnabled(waChannel, false)
|
|
if err := s.whatsappRepo.Update(ctx, waChannel); err != nil {
|
|
return err
|
|
}
|
|
if err := s.refreshWhatsAppInboxConfig(ctx, inbox, waChannel, false); err != nil {
|
|
return err
|
|
}
|
|
if err := s.setupWhatsAppWebhookFields(ctx, waChannel, whatsappWebhookCallbackURL(waChannel.PhoneNumber), []string{"messages", "smb_message_echoes"}); err != nil {
|
|
applogger.L().Warnf("WhatsApp calling disable webhook re-subscribe failed for inbox %d: %v", inbox.ID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *InboxService) SetInboundCalls(ctx context.Context, accountID, inboxID uint, enabled bool) error {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
config := parseJSONMap(inbox.ChannelConfig)
|
|
channelType := strings.ToLower(inbox.ChannelType)
|
|
if channelType != "whatsapp" && channelType != "twilio_sms" && channelType != "twilio" {
|
|
return ErrInboxInboundCallsUnsupported
|
|
}
|
|
if !boolFromAny(config["voice_enabled"]) {
|
|
return ErrInboxInboundCallsUnsupported
|
|
}
|
|
config["inbound_calls_enabled"] = enabled
|
|
inbox.ChannelConfig = marshalInboxJSON(config)
|
|
return s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if channelType == "whatsapp" {
|
|
var channel channelmodel.ChannelWhatsApp
|
|
if err := tx.Where("account_id = ? AND inbox_id = ?", accountID, inboxID).First(&channel).Error; err == nil {
|
|
providerConfig := parseJSONMap(channel.ProviderConfig)
|
|
providerConfig["inbound_calls_enabled"] = enabled
|
|
if err := tx.Model(&channel).Update("provider_config", marshalInboxJSON(providerConfig)).Error; err != nil {
|
|
return err
|
|
}
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Save(inbox).Error
|
|
})
|
|
}
|
|
|
|
func boolFromAny(value any) bool {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case string:
|
|
return typed == "true" || typed == "1"
|
|
case float64:
|
|
return typed != 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *InboxService) whatsAppCallingPrereqs(ctx context.Context, accountID, inboxID uint) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
if inbox.ChannelType != "whatsapp" {
|
|
return nil, nil, nil, ErrInboxWhatsAppCallingUnsupported
|
|
}
|
|
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
|
|
if err != nil || !waChannel.IsCloudAPI() {
|
|
return nil, nil, nil, ErrInboxWhatsAppCallingUnsupported
|
|
}
|
|
var account model.Account
|
|
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
|
return nil, nil, nil, fmt.Errorf("account not found: %w", err)
|
|
}
|
|
return &account, inbox, waChannel, nil
|
|
}
|
|
|
|
func setWhatsAppCallingEnabled(channel *channelmodel.ChannelWhatsApp, enabled bool) {
|
|
config := parseJSONMap(channel.ProviderConfig)
|
|
config["calling_enabled"] = enabled
|
|
channel.ProviderConfig = marshalInboxJSON(config)
|
|
}
|
|
|
|
func (s *InboxService) refreshWhatsAppInboxConfig(ctx context.Context, inbox *model.Inbox, channel *channelmodel.ChannelWhatsApp, voiceEnabled bool) error {
|
|
config := whatsappInboxChannelConfig(channel)
|
|
config["voice_enabled"] = voiceEnabled
|
|
inbox.ChannelConfig = marshalInboxJSON(config)
|
|
return s.repo.Update(ctx, inbox)
|
|
}
|
|
|
|
// DeleteAvatar removes the avatar URL from an inbox and dispatches an update event.
|
|
// Reference: Chatwoot InboxesController#destroy_avatar
|
|
func (s *InboxService) DeleteAvatar(ctx context.Context, accountID, inboxID uint) (*model.Inbox, error) {
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
inbox.AvatarURL = ""
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, fmt.Errorf("failed to update inbox: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("Deleted avatar for inbox %d (account_id=%d)", inboxID, accountID)
|
|
return inbox, nil
|
|
}
|
|
|
|
// ListCampaigns retrieves all campaigns for a specific inbox.
|
|
// Reference: Chatwoot InboxesController#campaigns
|
|
func (s *InboxService) ListCampaigns(ctx context.Context, accountID, inboxID uint) ([]campaign.Campaign, error) {
|
|
// Verify inbox belongs to account
|
|
_, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
campaigns, err := s.campaignRepo.ListByInbox(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list campaigns: %w", err)
|
|
}
|
|
return campaigns, nil
|
|
}
|
|
|
|
// ========================================
|
|
// Channel-specific helper methods
|
|
// ========================================
|
|
|
|
// getWhatsAppChannel retrieves the WhatsApp channel record for a given inbox.
|
|
func (s *InboxService) getWhatsAppChannel(ctx context.Context, inboxID uint) (*channelmodel.ChannelWhatsApp, error) {
|
|
if s.whatsappRepo == nil {
|
|
return nil, fmt.Errorf("WhatsApp repository not available")
|
|
}
|
|
ch, err := s.whatsappRepo.GetByInboxID(ctx, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("WhatsApp channel not found for inbox_id=%d: %w", inboxID, err)
|
|
}
|
|
return ch, nil
|
|
}
|
|
|
|
// fetchWhatsAppTemplates calls the WhatsApp Business API to fetch message templates.
|
|
func (s *InboxService) fetchWhatsAppTemplates(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
|
|
if s.whatsappService == nil {
|
|
return nil, fmt.Errorf("WhatsApp service not available")
|
|
}
|
|
return s.whatsappService.FetchMessageTemplates(ctx, waChannel)
|
|
}
|
|
|
|
type inboxTemplateSyncJob struct {
|
|
AccountID uint `json:"account_id"`
|
|
InboxID uint `json:"inbox_id"`
|
|
ChannelID uint `json:"channel_id"`
|
|
}
|
|
|
|
func RegisterInboxTemplateSyncJobs(wp *worker.WorkerPool, svc *InboxService) {
|
|
if wp == nil || svc == nil {
|
|
return
|
|
}
|
|
wp.Register(TaskTypeInboxSyncTemplates, svc.performTemplateSyncJob)
|
|
}
|
|
|
|
func (s *InboxService) performTemplateSyncJob(ctx context.Context, job *model.BackgroundJob) error {
|
|
var payload inboxTemplateSyncJob
|
|
if len(job.Payload) > 0 {
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
|
return fmt.Errorf("unmarshal inbox template sync job: %w", err)
|
|
}
|
|
}
|
|
if payload.AccountID == 0 || payload.InboxID == 0 {
|
|
return fmt.Errorf("invalid inbox template sync job payload: %#v", payload)
|
|
}
|
|
inbox, err := s.repo.FindByAccountAndID(ctx, payload.AccountID, payload.InboxID)
|
|
if err != nil {
|
|
return fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
if inbox.ChannelType != "whatsapp" {
|
|
return ErrInboxTemplateSyncWhatsAppOnly
|
|
}
|
|
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
|
|
}
|
|
if payload.ChannelID != 0 && payload.ChannelID != waChannel.ID {
|
|
return fmt.Errorf("WhatsApp channel mismatch for inbox_id=%d", inbox.ID)
|
|
}
|
|
templates, err := s.fetchWhatsAppTemplates(ctx, waChannel)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch WhatsApp templates: %w", err)
|
|
}
|
|
applogger.L().Infof("Synced WhatsApp templates for inbox %d (account_id=%d), got %d templates", inbox.ID, inbox.AccountID, len(templates))
|
|
return nil
|
|
}
|
|
|
|
func (s *InboxService) fetchWhatsAppHealthStatus(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
|
|
if s.whatsappService == nil {
|
|
return nil, fmt.Errorf("WhatsApp service not available")
|
|
}
|
|
return s.whatsappService.FetchHealthStatus(ctx, waChannel)
|
|
}
|
|
|
|
// setupWhatsAppWebhook registers a webhook URL with the WhatsApp Business API.
|
|
func (s *InboxService) setupWhatsAppWebhook(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp, webhookURL string) error {
|
|
if s.whatsappService == nil {
|
|
return fmt.Errorf("WhatsApp service not available")
|
|
}
|
|
return s.whatsappService.SetupWebhook(ctx, waChannel, webhookURL)
|
|
}
|
|
|
|
func (s *InboxService) setupWhatsAppWebhookFields(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error {
|
|
if s.whatsappService == nil {
|
|
return fmt.Errorf("WhatsApp service not available")
|
|
}
|
|
return s.whatsappService.SetupWebhookFields(ctx, waChannel, webhookURL, fields)
|
|
}
|
|
|
|
// generateInboxSecret creates a random HMAC secret for webhook verification.
|
|
func generateInboxSecret() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return hex.EncodeToString([]byte("fallback-secret-change-me"))
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// parseChannelConfigMap is a helper that parses the ChannelConfig JSON string.
|
|
func parseChannelConfigMap(configJSON string) map[string]interface{} {
|
|
if configJSON == "" {
|
|
return map[string]interface{}{}
|
|
}
|
|
var config map[string]interface{}
|
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
|
return map[string]interface{}{}
|
|
}
|
|
return config
|
|
}
|
|
|
|
// ResetSecret regenerates the webhook signing secret for an API-type inbox channel.
|
|
// Reference: Chatwoot inboxes_controller#reset_secret — only works for API inboxes
|
|
func (s *InboxService) ResetSecret(ctx context.Context, accountID, inboxID uint) (*model.Inbox, error) {
|
|
inbox, err := s.GetByAccountAndID(ctx, accountID, inboxID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox not found: %w", err)
|
|
}
|
|
|
|
if inbox.ChannelType == "shangwutong" {
|
|
return s.resetShangwutongSecret(ctx, inbox)
|
|
}
|
|
// Chatwoot: returns 404 for non-API inboxes
|
|
if inbox.ChannelType != "api" && inbox.ChannelType != string(model.InboxChannelTypeAPI) {
|
|
return nil, fmt.Errorf("inbox not found: only API inboxes support reset_secret")
|
|
}
|
|
|
|
var chAPI channelmodel.ChannelAPI
|
|
if err := s.repo.DB().Where("inbox_id = ?", inboxID).First(&chAPI).Error; err != nil {
|
|
return nil, fmt.Errorf("channel api not found: %w", err)
|
|
}
|
|
|
|
newSecret, randErr := generateRandomHex(32)
|
|
if randErr != nil {
|
|
return nil, fmt.Errorf("failed to generate secret: %w", randErr)
|
|
}
|
|
|
|
chAPI.Secret = newSecret
|
|
if err := s.repo.DB().Save(&chAPI).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to save new secret: %w", err)
|
|
}
|
|
inbox.Secret = newSecret
|
|
if err := s.repo.Update(ctx, inbox); err != nil {
|
|
return nil, fmt.Errorf("failed to save inbox secret: %w", err)
|
|
}
|
|
|
|
// Refresh inbox to return updated state
|
|
return s.GetByAccountAndID(ctx, accountID, inboxID)
|
|
}
|
|
|
|
func (s *InboxService) resetShangwutongSecret(ctx context.Context, inbox *model.Inbox) (*model.Inbox, error) {
|
|
var channelAPI channelmodel.ChannelAPI
|
|
if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var config model.ChannelShangwutongConfig
|
|
if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
newSecret, err := generateRandomHex(32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
oldSecret := channelAPI.Secret
|
|
channelAPI.Secret, inbox.Secret = newSecret, newSecret
|
|
config.ConfigVersion++
|
|
jobPayload := newShangwutongLifecycleJob("inbox_updated", inbox, config.ConfigVersion)
|
|
jobPayload.SigningSecret = oldSecret
|
|
var lifecycleJob *model.BackgroundJob
|
|
var lifecycleCreated bool
|
|
err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Save(&channelAPI).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Omit("WorkingHours").Save(inbox).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(&config).Error; err != nil {
|
|
return err
|
|
}
|
|
if s.worker != nil {
|
|
var err error
|
|
lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction(
|
|
ctx, tx, TaskTypeShangwutongWebhookDelivery, jobPayload,
|
|
worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, config.ConfigVersion)),
|
|
)
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lifecycleCreated {
|
|
s.worker.Publish(ctx, lifecycleJob)
|
|
}
|
|
return s.GetByAccountAndID(ctx, inbox.AccountID, inbox.ID)
|
|
}
|
|
|
|
// generateRandomHex generates a cryptographically random hex string of n bytes.
|
|
func generateRandomHex(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|