feat(inboxes): bind chatwoot channel settings

This commit is contained in:
2026-06-05 05:07:09 +08:00
parent cd83c82ee0
commit ee93546def
3 changed files with 692 additions and 15 deletions
+345 -13
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/gochat/gochat/internal/campaign"
whatsapp "github.com/gochat/gochat/internal/channel/whatsapp"
@@ -72,10 +73,24 @@ func (s *InboxService) GetByAccountAndID(ctx context.Context, accountID, id uint
// CreateInboxRequest is the DTO for creating an inbox.
type CreateInboxRequest struct {
Name string `json:"name" validate:"required,min=2"`
ChannelType string `json:"channel_type" validate:"required,oneof=web_widget telegram facebook instagram whatsapp email api tiktok line twilio_sms"`
Enabled bool `json:"enabled"`
EnableAutoAssignment bool `json:"enable_auto_assignment"`
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"`
}
// Create creates a new inbox.
@@ -83,22 +98,48 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb
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,
"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: req.Enabled,
EnableAutoAssignment: req.EnableAutoAssignment,
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
@@ -109,14 +150,33 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb
applogger.L().Errorf("Failed to create inbox: %v", err)
return nil, err
}
applyCreateInboxSettings(inbox, req)
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"`
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"`
}
// Update modifies an existing inbox.
@@ -139,6 +199,19 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat
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 err := s.repo.Update(ctx, inbox); err != nil {
return nil, err
@@ -146,6 +219,265 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat
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["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["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()
}
}
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)
}
}
// Delete soft-deletes an inbox.
func (s *InboxService) Delete(ctx context.Context, id uint) error {
return s.repo.Delete(ctx, id)