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
+231 -2
View File
@@ -1,7 +1,10 @@
package v1
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
@@ -116,7 +119,7 @@ func (h *InboxHandler) Create(c *gin.Context) {
}
var req service.CreateInboxRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
if bindErr := bindCreateInboxRequest(c, &req); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
return
}
@@ -162,7 +165,7 @@ func (h *InboxHandler) Update(c *gin.Context) {
}
var req service.UpdateInboxRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
if bindErr := bindUpdateInboxRequest(c, &req); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
return
}
@@ -214,6 +217,232 @@ func (h *InboxHandler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Your inbox deletion request will be processed in some time."})
}
func bindCreateInboxRequest(c *gin.Context, req *service.CreateInboxRequest) error {
if strings.Contains(c.ContentType(), "json") {
return c.ShouldBindJSON(req)
}
values, err := inboxFormValues(c)
if err != nil {
return err
}
applyCreateInboxForm(req, values)
return nil
}
func bindUpdateInboxRequest(c *gin.Context, req *service.UpdateInboxRequest) error {
if strings.Contains(c.ContentType(), "json") {
return c.ShouldBindJSON(req)
}
values, err := inboxFormValues(c)
if err != nil {
return err
}
applyUpdateInboxForm(req, values)
return nil
}
func inboxFormValues(c *gin.Context) (map[string][]string, error) {
values := map[string][]string{}
if strings.Contains(c.ContentType(), "multipart/form-data") {
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
return nil, err
}
if c.Request.MultipartForm != nil {
for key, list := range c.Request.MultipartForm.Value {
values[key] = append(values[key], list...)
}
}
return values, nil
} else if err := c.Request.ParseForm(); err != nil {
return nil, err
}
for key, list := range c.Request.PostForm {
values[key] = append(values[key], list...)
}
for key, list := range c.Request.Form {
if _, ok := values[key]; !ok {
values[key] = append(values[key], list...)
}
}
return values, nil
}
func applyCreateInboxForm(req *service.CreateInboxRequest, values map[string][]string) {
req.Channel = map[string]any{}
for key, list := range values {
value := inboxFormValue(list)
if strings.HasPrefix(key, "channel[") {
applyInboxNestedValue(req.Channel, key, list)
continue
}
if strings.HasPrefix(key, "csat_config[") {
if req.CsatConfig == nil {
req.CsatConfig = map[string]any{}
}
applyInboxNestedValue(req.CsatConfig, key, list)
continue
}
switch key {
case "name":
req.Name = value
case "channel_type":
req.ChannelType = value
case "enabled":
req.Enabled = inboxBool(value)
case "enable_auto_assignment":
req.EnableAutoAssignment = inboxBool(value)
case "greeting_enabled":
req.GreetingEnabled = inboxBoolPtr(value)
case "greeting_message":
req.GreetingMessage = inboxStringPtr(value)
case "enable_email_collect":
req.EnableEmailCollect = inboxBoolPtr(value)
case "csat_survey_enabled":
req.CsatSurveyEnabled = inboxBoolPtr(value)
case "working_hours_enabled":
req.WorkingHoursEnabled = inboxBoolPtr(value)
case "out_of_office_message":
req.OutOfOfficeMessage = inboxStringPtr(value)
case "timezone":
req.Timezone = inboxStringPtr(value)
case "allow_messages_after_resolved":
req.AllowMessagesAfterResolved = inboxBoolPtr(value)
case "lock_to_single_conversation":
req.LockToSingleConversation = inboxBoolPtr(value)
case "portal_id":
req.PortalID = inboxUintPtr(value)
case "sender_name_type":
req.SenderNameType = inboxStringPtr(value)
case "business_name":
req.BusinessName = inboxStringPtr(value)
case "csat_config":
req.CsatConfig = inboxJSONObject(value)
}
}
}
func applyUpdateInboxForm(req *service.UpdateInboxRequest, values map[string][]string) {
req.Channel = map[string]any{}
for key, list := range values {
value := inboxFormValue(list)
if strings.HasPrefix(key, "channel[") {
applyInboxNestedValue(req.Channel, key, list)
continue
}
if strings.HasPrefix(key, "csat_config[") {
if req.CsatConfig == nil {
req.CsatConfig = map[string]any{}
}
applyInboxNestedValue(req.CsatConfig, key, list)
continue
}
switch key {
case "name":
req.Name = value
case "enabled":
req.Enabled = inboxBoolPtr(value)
case "enable_auto_assignment":
req.EnableAutoAssignment = inboxBoolPtr(value)
case "greeting_enabled":
req.GreetingEnabled = inboxBoolPtr(value)
case "greeting_message":
req.GreetingMessage = inboxStringPtr(value)
case "enable_email_collect":
req.EnableEmailCollect = inboxBoolPtr(value)
case "csat_survey_enabled":
req.CsatSurveyEnabled = inboxBoolPtr(value)
case "working_hours_enabled":
req.WorkingHoursEnabled = inboxBoolPtr(value)
case "out_of_office_message":
req.OutOfOfficeMessage = inboxStringPtr(value)
case "timezone":
req.Timezone = inboxStringPtr(value)
case "allow_messages_after_resolved":
req.AllowMessagesAfterResolved = inboxBoolPtr(value)
case "lock_to_single_conversation":
req.LockToSingleConversation = inboxBoolPtr(value)
case "portal_id":
req.PortalID = inboxUintPtr(value)
case "sender_name_type":
req.SenderNameType = inboxStringPtr(value)
case "business_name":
req.BusinessName = inboxStringPtr(value)
case "csat_config":
req.CsatConfig = inboxJSONObject(value)
}
}
}
func applyInboxNestedValue(target map[string]any, key string, values []string) {
inner := strings.TrimSuffix(strings.TrimPrefix(key[strings.Index(key, "[")+1:], ""), "]")
parts := strings.Split(inner, "][")
if len(parts) == 0 || parts[0] == "" {
return
}
if len(parts) == 1 || (len(parts) == 2 && parts[1] == "") {
if len(parts) == 2 && parts[1] == "" {
target[parts[0]] = append([]string(nil), values...)
return
}
target[parts[0]] = inboxFormValue(values)
return
}
nested, _ := target[parts[0]].(map[string]any)
if nested == nil {
nested = map[string]any{}
target[parts[0]] = nested
}
if len(parts) == 3 && parts[2] == "" {
nested[parts[1]] = append([]string(nil), values...)
return
}
nested[parts[1]] = inboxFormValue(values)
}
func inboxFormValue(values []string) string {
if len(values) == 0 || values[0] == "null" {
return ""
}
return values[0]
}
func inboxBool(value string) bool {
parsed, _ := strconv.ParseBool(value)
return parsed
}
func inboxBoolPtr(value string) *bool {
parsed := inboxBool(value)
return &parsed
}
func inboxStringPtr(value string) *string {
return &value
}
func inboxUintPtr(value string) *uint {
if value == "" {
return nil
}
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return nil
}
uintValue := uint(parsed)
return &uintValue
}
func inboxJSONObject(value string) map[string]any {
if value == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(value), &data); err != nil {
return nil
}
return data
}
// ========================================
// Member-action handlers (Chatwoot InboxesController member routes)
// ========================================
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
@@ -109,6 +110,104 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
require.Equal(t, "Your inbox deletion request will be processed in some time.", inboxParityObject(t, destroy)["message"])
}
func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:inbox_handler_binding?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{}))
account := &model.Account{Name: "Inbox Binding", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
router := setupInboxParityRouter(db)
websiteCreate := inboxParityMultipartRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string][]string{
"name": {"Website"},
"greeting_enabled": {"true"},
"greeting_message": {"Hello from Chatwoot"},
"enable_email_collect": {"false"},
"allow_messages_after_resolved": {"true"},
"sender_name_type": {"business_name"},
"business_name": {"Acme Support"},
"channel[type]": {"web_widget"},
"channel[website_url]": {"https://acme.example"},
"channel[widget_color]": {"#f97316"},
"channel[welcome_title]": {"Hi there"},
"channel[welcome_tagline]": {"We reply quickly"},
"channel[selected_feature_flags][]": {"attachments", "emoji_picker"},
})
require.Equal(t, http.StatusOK, websiteCreate.Code, websiteCreate.Body.String())
websiteData := inboxParityObject(t, websiteCreate)
require.Equal(t, "Channel::WebWidget", websiteData["channel_type"])
require.Equal(t, "https://acme.example", websiteData["website_url"])
require.Equal(t, "#f97316", websiteData["widget_color"])
require.Equal(t, "Hi there", websiteData["welcome_title"])
require.NotEmpty(t, websiteData["website_token"])
require.NotEmpty(t, websiteData["hmac_token"])
require.Equal(t, false, websiteData["enable_email_collect"])
require.Equal(t, "business_name", websiteData["sender_name_type"])
require.Equal(t, "Acme Support", websiteData["business_name"])
require.ElementsMatch(t, []any{"attachments", "emoji_picker"}, websiteData["selected_feature_flags"].([]any))
apiCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
"name": "API",
"channel": map[string]any{
"type": "api",
"webhook_url": "https://hooks.example/chatwoot",
},
})
require.Equal(t, http.StatusOK, apiCreate.Code, apiCreate.Body.String())
apiData := inboxParityObject(t, apiCreate)
require.Equal(t, "Channel::Api", apiData["channel_type"])
require.Equal(t, "https://hooks.example/chatwoot", apiData["webhook_url"])
require.NotEmpty(t, apiData["secret"])
require.NotEmpty(t, apiData["hmac_token"])
require.NotEmpty(t, apiData["inbox_identifier"])
telegramCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
"channel": map[string]any{
"type": "telegram",
"bot_token": "123:telegram-token",
},
})
require.Equal(t, http.StatusOK, telegramCreate.Code, telegramCreate.Body.String())
telegramData := inboxParityObject(t, telegramCreate)
require.Equal(t, "Telegram", telegramData["name"])
require.Equal(t, "Channel::Telegram", telegramData["channel_type"])
websiteID := uint(websiteData["id"].(float64))
update := inboxParityMultipartRequest(t, router, http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, websiteID), map[string][]string{
"name": {"Website Updated"},
"enable_auto_assignment": {"true"},
"csat_survey_enabled": {"true"},
"csat_config[display_type]": {"emoji"},
"csat_config[message]": {"Rate this conversation"},
"csat_config[survey_rules][operator]": {"contains"},
"csat_config[survey_rules][values][]": {"vip", "priority"},
"channel[website_url]": {"https://updated.example"},
"channel[welcome_tagline]": {"Updated tagline"},
})
require.Equal(t, http.StatusOK, update.Code, update.Body.String())
updateData := inboxParityObject(t, update)
require.Equal(t, "Website Updated", updateData["name"])
require.Equal(t, true, updateData["enable_auto_assignment"])
require.Equal(t, true, updateData["csat_survey_enabled"])
require.Equal(t, "https://updated.example", updateData["website_url"])
require.Equal(t, "Updated tagline", updateData["welcome_tagline"])
csatConfig := updateData["csat_config"].(map[string]any)
require.Equal(t, "Rate this conversation", csatConfig["message"])
require.Equal(t, "Please rate us", csatConfig["button_text"])
require.ElementsMatch(t, []any{"vip", "priority"}, csatConfig["survey_rules"].(map[string]any)["values"].([]any))
}
func setupInboxParityRouter(db *gorm.DB) *gin.Engine {
inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil)
handler := NewInboxHandler(inboxSvc)
@@ -143,6 +242,23 @@ func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path st
return w
}
func inboxParityMultipartRequest(t *testing.T, router *gin.Engine, method string, path string, fields map[string][]string) *httptest.ResponseRecorder {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for key, values := range fields {
for _, value := range values {
require.NoError(t, writer.WriteField(key, value))
}
}
require.NoError(t, writer.Close())
req := httptest.NewRequest(method, path, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func inboxParityObject(t *testing.T, response *httptest.ResponseRecorder) map[string]any {
t.Helper()
var data map[string]any