feat(inboxes): deepen channel config parity

This commit is contained in:
2026-06-05 05:41:48 +08:00
parent 8f69ef490e
commit f0aae79d6d
6 changed files with 367 additions and 41 deletions
+67 -2
View File
@@ -2,6 +2,7 @@ package v1
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
@@ -220,7 +221,7 @@ func (h *InboxHandler) Delete(c *gin.Context) {
func bindCreateInboxRequest(c *gin.Context, req *service.CreateInboxRequest) error {
if strings.Contains(c.ContentType(), "json") {
return c.ShouldBindJSON(req)
return bindCreateInboxJSON(c, req)
}
values, err := inboxFormValues(c)
if err != nil {
@@ -232,7 +233,7 @@ func bindCreateInboxRequest(c *gin.Context, req *service.CreateInboxRequest) err
func bindUpdateInboxRequest(c *gin.Context, req *service.UpdateInboxRequest) error {
if strings.Contains(c.ContentType(), "json") {
return c.ShouldBindJSON(req)
return bindUpdateInboxJSON(c, req)
}
values, err := inboxFormValues(c)
if err != nil {
@@ -268,6 +269,70 @@ func inboxFormValues(c *gin.Context) (map[string][]string, error) {
return values, nil
}
func bindCreateInboxJSON(c *gin.Context, req *service.CreateInboxRequest) error {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return err
}
if err := json.Unmarshal(body, req); err != nil {
return err
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return err
}
hoistInboxChannelJSONFields(&req.Channel, raw)
return nil
}
func bindUpdateInboxJSON(c *gin.Context, req *service.UpdateInboxRequest) error {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return err
}
if err := json.Unmarshal(body, req); err != nil {
return err
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return err
}
hoistInboxChannelJSONFields(&req.Channel, raw)
return nil
}
func hoistInboxChannelJSONFields(channel *map[string]any, raw map[string]any) {
for key, value := range raw {
if !isInboxChannelConfigKey(key) || value == nil {
continue
}
if *channel == nil {
*channel = map[string]any{}
}
if _, exists := (*channel)[key]; !exists {
(*channel)[key] = value
}
}
}
func isInboxChannelConfigKey(key string) bool {
switch key {
case "website_url", "widget_color", "welcome_title", "welcome_tagline", "reply_time",
"pre_chat_form_enabled", "pre_chat_form_options", "continuity_via_email", "hmac_mandatory",
"allowed_domains", "selected_feature_flags", "webhook_url", "additional_attributes",
"email", "forward_to_email", "imap_login", "imap_password", "imap_address", "imap_port",
"imap_enabled", "imap_enable_ssl", "imap_authentication", "smtp_login", "smtp_password",
"smtp_address", "smtp_port", "smtp_enabled", "smtp_domain", "smtp_enable_ssl_tls",
"smtp_enable_starttls_auto", "smtp_openssl_verify_mode", "smtp_authentication", "provider",
"provider_config", "phone_number", "message_templates", "account_sid", "auth_token", "api_key_sid",
"api_key_secret", "messaging_service_sid", "medium", "content_templates", "twiml_app_sid",
"voice_enabled", "line_channel_id", "line_channel_secret", "line_channel_token", "bot_token":
return true
default:
return false
}
}
func applyCreateInboxForm(req *service.CreateInboxRequest, values map[string][]string) {
req.Channel = map[string]any{}
for key, list := range values {
@@ -243,6 +243,120 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) {
require.Len(t, inboxParityObject(t, showUpdated)["working_hours"].([]any), 7)
}
func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:inbox_handler_channel_depth?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
t.Cleanup(func() {
sqlDB, dbErr := db.DB()
if dbErr == nil {
_ = sqlDB.Close()
}
})
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}))
account := &model.Account{Name: "Inbox Channel Depth", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
router := setupInboxParityRouter(db)
emailCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
"name": "Email Support",
"channel": map[string]any{
"type": "email",
"email": "support@example.com",
"forward_to_email": "support+forward@example.test",
"imap_enabled": true,
"imap_login": "imap-user",
"imap_password": "imap-secret",
"imap_address": "imap.example.com",
"imap_port": 993,
"imap_enable_ssl": true,
"imap_authentication": "plain",
"smtp_enabled": true,
"smtp_login": "smtp-user",
"smtp_password": "smtp-secret",
"smtp_address": "smtp.example.com",
"smtp_port": 587,
"smtp_domain": "example.com",
"smtp_enable_starttls_auto": true,
"smtp_enable_ssl_tls": false,
"smtp_openssl_verify_mode": "none",
"smtp_authentication": "login",
},
})
require.Equal(t, http.StatusOK, emailCreate.Code, emailCreate.Body.String())
emailData := inboxParityObject(t, emailCreate)
require.Equal(t, "Channel::Email", emailData["channel_type"])
require.Equal(t, "support@example.com", emailData["email"])
require.Equal(t, "support+forward@example.test", emailData["forward_to_email"])
require.Equal(t, true, emailData["forwarding_enabled"])
require.Equal(t, "imap.example.com", emailData["imap_address"])
require.Equal(t, float64(993), emailData["imap_port"])
require.Equal(t, "smtp.example.com", emailData["smtp_address"])
require.Equal(t, float64(587), emailData["smtp_port"])
emailID := uint(emailData["id"].(float64))
smtpUpdate := inboxParityRequest(t, router, http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, emailID), map[string]any{
"smtp_enabled": true,
"smtp_address": "smtp2.example.com",
"smtp_port": 465,
"smtp_login": "smtp2-user",
"smtp_password": "smtp2-secret",
"smtp_domain": "example.org",
"smtp_enable_ssl_tls": true,
"smtp_enable_starttls_auto": false,
"smtp_openssl_verify_mode": "peer",
"smtp_authentication": "login",
})
require.Equal(t, http.StatusOK, smtpUpdate.Code, smtpUpdate.Body.String())
smtpData := inboxParityObject(t, smtpUpdate)
require.Equal(t, "smtp2.example.com", smtpData["smtp_address"])
require.Equal(t, float64(465), smtpData["smtp_port"])
require.Equal(t, true, smtpData["smtp_enable_ssl_tls"])
require.Equal(t, false, smtpData["smtp_enable_starttls_auto"])
whatsappCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
"name": "WhatsApp",
"channel": map[string]any{
"type": "whatsapp",
"phone_number": "+1555010000",
"provider": "whatsapp_cloud",
"provider_config": map[string]any{
"api_key": "wa-key",
"phone_number_id": "phone-id",
"business_account_id": "waba-id",
},
},
})
require.Equal(t, http.StatusOK, whatsappCreate.Code, whatsappCreate.Body.String())
whatsappData := inboxParityObject(t, whatsappCreate)
require.Equal(t, "Channel::Whatsapp", whatsappData["channel_type"])
require.Equal(t, "+1555010000", whatsappData["phone_number"])
providerConfig := whatsappData["provider_config"].(map[string]any)
require.Equal(t, "wa-key", providerConfig["api_key"])
require.Equal(t, "phone-id", providerConfig["phone_number_id"])
require.NotEmpty(t, providerConfig["webhook_verify_token"])
lineCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
"name": "LINE",
"channel": map[string]any{
"type": "line",
"line_channel_id": "line-id",
"line_channel_secret": "line-secret",
"line_channel_token": "line-token",
},
})
require.Equal(t, http.StatusOK, lineCreate.Code, lineCreate.Body.String())
lineData := inboxParityObject(t, lineCreate)
require.Equal(t, "Channel::Line", lineData["channel_type"])
require.Equal(t, "line-id", lineData["line_channel_id"])
require.Equal(t, "line-secret", lineData["line_channel_secret"])
require.Equal(t, "line-token", lineData["line_channel_token"])
}
func setupInboxParityRouter(db *gorm.DB) *gin.Engine {
inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil)
handler := NewInboxHandler(inboxSvc)
+31 -1
View File
@@ -82,15 +82,45 @@ func serializeInbox(inbox *model.Inbox) map[string]any {
payload["auth_token"] = configValue(config, "auth_token")
payload["account_sid"] = configValue(config, "account_sid")
payload["api_key_sid"] = configValue(config, "api_key_sid")
payload["voice_enabled"] = configValue(config, "voice_enabled")
payload["voice_configured"] = configValue(config, "twiml_app_sid") != nil
payload["has_api_key_secret"] = configValue(config, "api_key_secret") != nil
payload["voice_call_webhook_url"] = configValue(config, "voice_call_webhook_url")
payload["voice_status_webhook_url"] = configValue(config, "voice_status_webhook_url")
case "Channel::Email":
payload["email"] = configValue(config, "email")
payload["forwarding_enabled"] = false
payload["forwarding_enabled"] = configValue(config, "forward_to_email") != nil
payload["forward_to_email"] = configValue(config, "forward_to_email")
payload["imap_login"] = configValue(config, "imap_login")
payload["imap_password"] = configValue(config, "imap_password")
payload["imap_address"] = configValue(config, "imap_address")
payload["imap_port"] = configValue(config, "imap_port")
payload["imap_enabled"] = configValue(config, "imap_enabled")
payload["imap_enable_ssl"] = configValue(config, "imap_enable_ssl")
payload["imap_authentication"] = configValue(config, "imap_authentication")
payload["smtp_login"] = configValue(config, "smtp_login")
payload["smtp_password"] = configValue(config, "smtp_password")
payload["smtp_address"] = configValue(config, "smtp_address")
payload["smtp_port"] = configValue(config, "smtp_port")
payload["smtp_enabled"] = configValue(config, "smtp_enabled")
payload["smtp_domain"] = configValue(config, "smtp_domain")
payload["smtp_enable_ssl_tls"] = configValue(config, "smtp_enable_ssl_tls")
payload["smtp_enable_starttls_auto"] = configValue(config, "smtp_enable_starttls_auto")
payload["smtp_openssl_verify_mode"] = configValue(config, "smtp_openssl_verify_mode")
payload["smtp_authentication"] = configValue(config, "smtp_authentication")
case "Channel::Whatsapp":
payload["phone_number"] = configValue(config, "phone_number")
payload["message_templates"] = configValue(config, "message_templates")
payload["provider_config"] = configValue(config, "provider_config")
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
payload["voice_enabled"] = configValue(config, "voice_enabled")
case "Channel::Line":
payload["line_channel_id"] = configValue(config, "line_channel_id")
payload["line_channel_secret"] = configValue(config, "line_channel_secret")
payload["line_channel_token"] = configValue(config, "line_channel_token")
case "Channel::Sms":
payload["phone_number"] = configValue(config, "phone_number")
payload["provider_config"] = configValue(config, "provider_config")
}
return payload
@@ -13,8 +13,11 @@ package v1
// - GET /api/v1/accounts/:id/channels/twilio_channel → list Twilio SMS channels
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -49,9 +52,12 @@ func NewTwilioChannelHandler(
// CreateTwilioSMSChannelRequest is the DTO for creating a Twilio SMS channel.
type CreateTwilioSMSChannelRequest struct {
AccountSID string `json:"account_sid" validate:"required"`
APIKeySID string `json:"api_key_sid"`
AuthToken string `json:"auth_token" validate:"required"`
PhoneNumber string `json:"phone_number" validate:"required"`
MessagingServiceSID string `json:"messaging_service_sid"`
Medium string `json:"medium"`
Name string `json:"name"`
InboxName string `json:"inbox_name"`
}
@@ -66,19 +72,25 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) {
return
}
var req CreateTwilioSMSChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
applogger.L().Errorf("Failed to bind Twilio SMS channel create request: %v", err)
req, bindErr := bindTwilioCreateRequest(c)
if bindErr != nil {
applogger.L().Errorf("Failed to bind Twilio SMS channel create request: %v", bindErr)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if req.AccountSID == "" || req.AuthToken == "" || (req.PhoneNumber == "" && req.MessagingServiceSID == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
ctx := c.Request.Context()
medium := firstNonEmptyString(req.Medium, "sms")
phoneNumber := twilioPhoneNumberForMedium(req.PhoneNumber, medium)
// Create the channel record first (without InboxID)
ch := &channelmodel.ChannelTwilioSMS{
AccountSID: req.AccountSID,
PhoneNumber: req.PhoneNumber,
PhoneNumber: phoneNumber,
MessagingServiceSID: req.MessagingServiceSID,
AccountID: uint(accountID),
}
@@ -92,12 +104,23 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) {
// Create inbox for the Twilio SMS channel
inboxName := req.InboxName
if inboxName == "" {
inboxName = req.PhoneNumber
inboxName = req.Name
}
if inboxName == "" {
inboxName = firstNonEmptyString(phoneNumber, req.MessagingServiceSID)
}
inboxReq := service.CreateInboxRequest{
Name: inboxName,
ChannelType: "twilio_sms",
Enabled: true,
Channel: map[string]any{
"account_sid": req.AccountSID,
"auth_token": req.AuthToken,
"api_key_sid": req.APIKeySID,
"phone_number": phoneNumber,
"messaging_service_sid": req.MessagingServiceSID,
"medium": medium,
},
}
inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq)
@@ -117,10 +140,44 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) {
applogger.L().Errorf("Failed to update Twilio SMS channel with inbox_id: %v", err)
}
c.JSON(http.StatusCreated, gin.H{
"channel": ch,
"inbox": inbox,
})
c.JSON(http.StatusOK, serializeInbox(inbox))
}
func twilioPhoneNumberForMedium(phoneNumber, medium string) string {
if medium == "whatsapp" && phoneNumber != "" && !strings.HasPrefix(phoneNumber, "whatsapp:") {
return "whatsapp:" + phoneNumber
}
return phoneNumber
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func bindTwilioCreateRequest(c *gin.Context) (CreateTwilioSMSChannelRequest, error) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return CreateTwilioSMSChannelRequest{}, err
}
var req CreateTwilioSMSChannelRequest
if err := json.Unmarshal(body, &req); err != nil {
return CreateTwilioSMSChannelRequest{}, err
}
if req.AccountSID != "" || req.AuthToken != "" {
return req, nil
}
var wrapped struct {
TwilioChannel CreateTwilioSMSChannelRequest `json:"twilio_channel"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return CreateTwilioSMSChannelRequest{}, err
}
return wrapped.TwilioChannel, nil
}
// Get retrieves a Twilio SMS channel by ID.
@@ -213,13 +270,13 @@ func (h *TwilioChannelHandler) Update(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"id": ch.ID,
"account_id": ch.AccountID,
"inbox_id": ch.InboxID,
"account_sid": ch.AccountSID,
"phone_number": ch.PhoneNumber,
"id": ch.ID,
"account_id": ch.AccountID,
"inbox_id": ch.InboxID,
"account_sid": ch.AccountSID,
"phone_number": ch.PhoneNumber,
"messaging_service_sid": ch.MessagingServiceSID,
"message": "Twilio SMS channel updated successfully",
"message": "Twilio SMS channel updated successfully",
})
}
@@ -291,4 +348,4 @@ func (h *TwilioChannelHandler) List(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"channels": channels})
}
}
@@ -14,8 +14,8 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/channel/whatsapp"
"github.com/gochat/gochat/internal/repository"
@@ -79,6 +79,7 @@ func setupTwilioTestRouter(handler *TwilioChannelHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
ag := r.Group("/api/v1/accounts/:id")
ag.POST("/channels/twilio_channel", handler.Create)
ag.POST("/twilio_sms_channels", handler.Create)
ag.GET("/twilio_sms_channels", handler.List)
ig := ag.Group("/inboxes/:inbox_id/twilio_sms_channels")
@@ -95,32 +96,37 @@ func TestTwilioChannel_Create_Success(t *testing.T) {
router := setupTwilioTestRouter(handler)
accountID := twilioAccountID(db)
body := CreateTwilioSMSChannelRequest{
AccountSID: "ACtest123",
AuthToken: "authtoken_secret",
PhoneNumber: "+15551234567",
InboxName: "Support SMS",
body := map[string]any{
"twilio_channel": map[string]any{
"account_sid": "ACtest123",
"api_key_sid": "SKtest123",
"auth_token": "authtoken_secret",
"phone_number": "+15551234567",
"messaging_service_sid": "MG123",
"medium": "sms",
"name": "Support SMS",
},
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+accountID+"/twilio_sms_channels", bytes.NewReader(b))
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+accountID+"/channels/twilio_channel", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
channelData, ok := resp["channel"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "ACtest123", channelData["account_sid"])
assert.Equal(t, "+15551234567", channelData["phone_number"])
// Verify inbox was created
inboxData, ok := resp["inbox"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "Support SMS", inboxData["name"])
assert.Equal(t, "Support SMS", resp["name"])
assert.Equal(t, "Channel::TwilioSms", resp["channel_type"])
assert.Equal(t, "ACtest123", resp["account_sid"])
assert.Equal(t, "SKtest123", resp["api_key_sid"])
assert.Equal(t, "+15551234567", resp["phone_number"])
assert.Equal(t, "MG123", resp["messaging_service_sid"])
assert.Equal(t, "sms", resp["medium"])
require.NotContains(t, resp, "channel")
require.NotContains(t, resp, "inbox")
}
func TestTwilioChannel_Create_InvalidAccountID(t *testing.T) {
@@ -357,10 +363,10 @@ func TestTwilioChannel_List_Success(t *testing.T) {
// Seed multiple channels
for i := 0; i < 3; i++ {
ch := &channelmodel.ChannelTwilioSMS{
AccountSID: "AClist" + strconv.Itoa(i),
PhoneNumber: "+1555" + strconv.Itoa(i) + "00000",
AccountID: accountID,
InboxID: uint(i + 100), // unique inbox IDs to satisfy uniqueIndex constraint
AccountSID: "AClist" + strconv.Itoa(i),
PhoneNumber: "+1555" + strconv.Itoa(i) + "00000",
AccountID: accountID,
InboxID: uint(i + 100), // unique inbox IDs to satisfy uniqueIndex constraint
}
require.NoError(t, db.Create(ch).Error)
}
@@ -403,4 +409,4 @@ func TestTwilioChannel_List_Empty(t *testing.T) {
channels, ok := resp["channels"].([]interface{})
require.True(t, ok)
assert.Len(t, channels, 0)
}
}
+54
View File
@@ -323,6 +323,24 @@ func buildInitialInboxChannelConfig(channelType string, channel map[string]any)
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
@@ -338,6 +356,12 @@ func buildInitialInboxChannelConfig(channelType string, channel map[string]any)
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
@@ -347,6 +371,36 @@ func buildInitialInboxChannelConfig(channelType string, channel map[string]any)
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