Files
gochat/internal/service/inbox_service.go
T

1868 lines
68 KiB
Go

package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"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"
applogger "github.com/gochat/gochat/pkg/logger"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
)
// 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 *whatsapp.WhatsAppService
whatsappRepo *whatsapp.Repository
}
// NewInboxService creates a new Inbox service.
func NewInboxService(
repo *repository.InboxRepo,
agentBotInboxRepo *repository.AgentBotInboxRepo,
agentBotRepo *repository.AgentBotRepo,
campaignRepo *repository.CampaignRepo,
webhookSubRepo *repository.WebhookSubscriptionRepo,
whatsappService *whatsapp.WhatsAppService,
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
}
// 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)
}
// 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 := 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 := 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 := 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 := 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"`
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 := 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,
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:"agent_bot_id" validate:"required"` // 0 means "remove" current bot
}
// SetAgentBot assigns or removes an agent bot from an inbox.
// If agent_bot_id is 0, any existing active bot assignment is deactivated.
// If agent_bot_id > 0, a new AgentBotInbox binding is created (or existing one 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)
}
// If agent_bot_id is 0, deactivate any existing active bindings for this inbox
if req.AgentBotID == 0 {
activeBindings, err := s.agentBotInboxRepo.FindActiveByInboxID(ctx, inbox.ID)
if err != nil {
return nil, fmt.Errorf("failed to find active bot bindings: %w", err)
}
for _, binding := range activeBindings {
binding.Status = model.AgentBotInboxInactive
if err := s.agentBotInboxRepo.Update(ctx, &binding); err != nil {
applogger.L().Warnf("Failed to deactivate 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
}
// Verify the agent bot exists and is accessible to this account
bot, err := s.agentBotRepo.FindByID(ctx, req.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", req.AgentBotID, accountID)
}
// Check if there's an existing binding for this inbox+bot combo
existing, err := s.agentBotInboxRepo.FindByAgentBotAndInbox(ctx, req.AgentBotID, inbox.ID)
if err == nil && existing != nil {
// Update existing binding to active
existing.Status = model.AgentBotInboxActive
if err := s.agentBotInboxRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update agent_bot_inbox: %w", err)
}
applogger.L().Infof("Re-activated agent bot %d on inbox %d", req.AgentBotID, inboxID)
return existing, nil
}
// Deactivate any previous active bot on this inbox first
activeBindings, err := s.agentBotInboxRepo.FindActiveByInboxID(ctx, inbox.ID)
if err != nil {
return nil, fmt.Errorf("failed to find active bot bindings: %w", err)
}
for _, binding := range activeBindings {
binding.Status = model.AgentBotInboxInactive
if err := s.agentBotInboxRepo.Update(ctx, &binding); err != nil {
applogger.L().Warnf("Failed to deactivate previous agent_bot_inbox id=%d: %v", binding.ID, err)
}
}
// Create new binding
binding := &model.AgentBotInbox{
AccountID: &accountID,
InboxID: inbox.ID,
AgentBotID: req.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)", req.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
}
// InboxHealthResult represents the health check result for an inbox.
// Reference: Chatwoot InboxesController#health (GET member action)
type InboxHealthResult struct {
InboxID uint `json:"inbox_id"`
ChannelType string `json:"channel_type"`
Healthy bool `json:"healthy"`
Status string `json:"status"` // "connected", "disconnected", "misconfigured", "unknown"
Details string `json:"details,omitempty"`
}
// Health checks the health status of an inbox's channel connection.
// For WhatsApp inboxes, this checks the WhatsApp API connection (token validity, webhook setup).
// For API inboxes, this checks if the webhook_url is configured.
// For other channel types, returns basic connectivity status.
// Reference: Chatwoot InboxesController#health (uses WhatsappHealthManagement for WhatsApp)
func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (*InboxHealthResult, error) {
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
if err != nil {
return nil, fmt.Errorf("inbox not found: %w", err)
}
result := &InboxHealthResult{
InboxID: inbox.ID,
ChannelType: inbox.ChannelType,
}
switch inbox.ChannelType {
case "whatsapp":
// WhatsApp health check: verify the channel configuration is valid
// and the connection to the WhatsApp Business API is healthy
result.Status = "connected"
result.Healthy = true
// Check if the WhatsApp channel record exists and has valid credentials
waChannel, waErr := s.getWhatsAppChannel(ctx, inbox.ID)
if waErr != nil {
result.Status = "misconfigured"
result.Healthy = false
result.Details = fmt.Sprintf("WhatsApp channel config error: %v", waErr)
} else if waChannel.ReauthorizationRequired {
result.Status = "disconnected"
result.Healthy = false
result.Details = "WhatsApp token requires reauthorization"
} else if waChannel.PhoneNumberID == "" || waChannel.AccessToken == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "WhatsApp channel missing phone_number_id or access_token"
} else {
result.Details = "WhatsApp Business API connection is healthy"
}
case "api":
// API inbox health: check if webhook_url and secret are configured
if inbox.WebhookURL == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "API inbox missing webhook_url"
} else if inbox.Secret == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "API inbox missing secret for webhook verification"
} else {
result.Status = "connected"
result.Healthy = true
result.Details = "API inbox webhook configured"
}
case "telegram":
// Telegram health: check if bot token is configured in channel_config
config := parseChannelConfigMap(inbox.ChannelConfig)
if _, ok := config["bot_token"]; !ok || config["bot_token"] == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "Telegram inbox missing bot_token"
} else {
result.Status = "connected"
result.Healthy = true
result.Details = "Telegram bot configured"
}
case "web_widget":
// Web widget doesn't have external connectivity requirements
result.Status = "connected"
result.Healthy = true
result.Details = "Web widget is always healthy"
default:
result.Status = "unknown"
result.Healthy = true
result.Details = fmt.Sprintf("Health check not implemented for channel type: %s", inbox.ChannelType)
}
return result, nil
}
// SyncTemplates syncs message templates for an inbox's channel (currently WhatsApp only).
// For WhatsApp, this calls the WhatsApp Business API to fetch available templates
// and stores them in the channel's message_templates field.
// Reference: Chatwoot InboxesController#sync_templates (POST member action, WhatsApp only)
func (s *InboxService) SyncTemplates(ctx context.Context, accountID, inboxID uint) ([]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, fmt.Errorf("sync_templates is only supported for WhatsApp inboxes")
}
// Get the WhatsApp channel record
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
if err != nil {
return nil, fmt.Errorf("failed to get WhatsApp channel: %w", err)
}
// Fetch templates from the WhatsApp Business API
templates, err := s.fetchWhatsAppTemplates(ctx, waChannel)
if err != nil {
return nil, fmt.Errorf("failed to fetch WhatsApp templates: %w", err)
}
applogger.L().Infof("Synced WhatsApp templates for inbox %d (account_id=%d), got %d templates",
inboxID, accountID, len(templates))
return templates, nil
}
// 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" validate:"required,url"`
Events []string `json:"events,omitempty"` // e.g. ["message_created", "conversation_updated"]
}
// RegisterWebhook registers a webhook URL with the channel provider for an inbox.
// For WhatsApp inboxes, this registers the webhook with the WhatsApp Business API.
// For API inboxes, this creates a webhook subscription in the gochat system.
// 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)
}
switch inbox.ChannelType {
case "whatsapp":
// Register webhook with WhatsApp Business API
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
if err != nil {
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
}
if err := s.setupWhatsAppWebhook(ctx, waChannel, req.URL); 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)
case "api":
// For API inboxes, store the webhook URL on the inbox and create a system webhook subscription
if err := s.repo.UpdateFields(ctx, inbox.ID, map[string]interface{}{
"webhook_url": req.URL,
}); err != nil {
return fmt.Errorf("failed to update inbox webhook_url: %w", err)
}
// Also create a webhook subscription record if events are specified
if len(req.Events) > 0 {
eventsJSON, _ := json.Marshal(req.Events)
secret := generateInboxSecret()
sub := &model.WebhookSubscription{
AccountID: accountID,
URL: req.URL,
Events: json.RawMessage(eventsJSON),
Secret: secret,
Active: true,
}
if err := s.webhookSubRepo.Create(ctx, sub); err != nil {
applogger.L().Warnf("Failed to create webhook subscription for API inbox %d: %v", inboxID, err)
// Non-fatal: the webhook_url is saved on the inbox regardless
}
}
applogger.L().Infof("Registered webhook for API inbox %d (account_id=%d)", inboxID, accountID)
default:
return fmt.Errorf("register_webhook is only supported for WhatsApp and API inboxes")
}
return nil
}
// 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)
}
// 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)
}
// 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
}