package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "strconv" "strings" "github.com/gochat/gochat/internal/campaign" 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" ) 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 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) 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) 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) } // 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, } 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") } 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_name", 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 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 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 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 } // 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 = *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 = *req.SenderNameType } if req.BusinessName != nil { inbox.BusinessName = *req.BusinessName } if req.CsatConfig != nil { inbox.CsatConfig = marshalInboxJSON(formatInboxCsatConfig(req.CsatConfig)) } } func buildInitialInboxChannelConfig(channelType string, channel map[string]any) 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() } } } 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::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", "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 (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 } return s.repo.Delete(ctx, inbox.ID) } // --- 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 } // 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"` } // 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"` } // 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, WebhookVerifyToken: verifyToken, ReauthorizationRequired: false, } // Serialize the Facebook config into ChannelConfig JSON config := FacebookInboxConfig{ PageID: req.PageID, PageAccessToken: req.PageAccessToken, PageName: req.PageName, WebhookVerifyToken: verifyToken, } 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 != "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) 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 HMAC token 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) } // Chatwoot: returns 404 for non-API inboxes if inbox.ChannelType != string(model.InboxChannelTypeAPI) { return nil, fmt.Errorf("inbox not found: only API inboxes support reset_secret") } // Find the ChannelAPI record and regenerate HMAC token 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) } // Generate new random HMAC token (32 bytes = 64 hex chars) newToken, randErr := generateRandomHex(32) if randErr != nil { return nil, fmt.Errorf("failed to generate secret: %w", randErr) } chAPI.HMACToken = newToken if err := s.repo.DB().Save(&chAPI).Error; err != nil { return nil, fmt.Errorf("failed to save new secret: %w", err) } // Refresh inbox to return updated state return s.GetByAccountAndID(ctx, accountID, inboxID) } // 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 }