feat(widget): support chatwoot direct upload attachments

This commit is contained in:
2026-06-04 22:43:35 +08:00
parent 8dce39f85f
commit 8cd564e1da
10 changed files with 617 additions and 44 deletions
+88 -8
View File
@@ -113,8 +113,11 @@ func (h *WidgetHandler) Config(c *gin.Context) {
"welcome_tagline": resp.WidgetConfig.WelcomeTagline,
"website_name": resp.InboxName,
},
"contact": contact,
"global_config": gin.H{},
"contact": contact,
"global_config": gin.H{
"directUploadsEnabled": true,
"maximumFileUploadSize": 40,
},
})
}
@@ -149,7 +152,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusOK, resp)
return
}
c.JSON(http.StatusOK, widgetMessagePayload(resp.Message, resp.ConversationID))
payload := widgetMessagePayload(resp.Message, resp.ConversationID)
if len(resp.Attachments) > 0 {
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
}
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) UpdateMessage(c *gin.Context) {
@@ -216,7 +223,11 @@ func (h *WidgetHandler) GetLatestMessages(c *gin.Context) {
payload := make([]gin.H, 0, len(messages))
for _, msg := range messages {
payload = append(payload, widgetMessagePayload(msg, msg.ConversationID))
messagePayload := widgetMessagePayload(msg, msg.ConversationID)
if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), msg.ID); err == nil && len(attachments) > 0 {
messagePayload["attachments"] = widgetAttachmentPayloads(attachments)
}
payload = append(payload, messagePayload)
}
meta := gin.H{"total": total, "offset": offset, "limit": limit}
if conversation != nil && conversation.ContactLastSeenAt != nil {
@@ -988,12 +999,40 @@ func widgetTokenFromRequest(c *gin.Context) string {
}
func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequest, error) {
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
return service.WidgetSendMessageRequest{}, err
}
form := c.Request.MultipartForm
content := firstFormValue(form.Value, "content", "message[content]")
contentType := firstFormValue(form.Value, "content_type", "message[content_type]")
var conversationID *uint
if rawID := firstFormValue(form.Value, "conversation_id", "message[conversation_id]"); rawID != "" {
if id, err := strconv.ParseUint(rawID, 10, 64); err == nil && id > 0 {
value := uint(id)
conversationID = &value
}
}
attachments := form.Value["message[attachments][]"]
if len(attachments) == 0 {
attachments = form.Value["attachments[]"]
}
return service.WidgetSendMessageRequest{
Content: content,
ContentType: contentType,
ConversationID: conversationID,
AttachmentIDs: attachments,
}, nil
}
var body struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
ConversationID *uint `json:"conversation_id"`
Content string `json:"content"`
ContentType string `json:"content_type"`
ConversationID *uint `json:"conversation_id"`
Attachments []string `json:"attachments"`
Message struct {
Content string `json:"content"`
Content string `json:"content"`
Attachments []string `json:"attachments"`
} `json:"message"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -1003,13 +1042,27 @@ func bindWidgetSendMessageRequest(c *gin.Context) (service.WidgetSendMessageRequ
if content == "" {
content = body.Message.Content
}
attachments := body.Attachments
if len(attachments) == 0 {
attachments = body.Message.Attachments
}
return service.WidgetSendMessageRequest{
Content: content,
ContentType: body.ContentType,
ConversationID: body.ConversationID,
AttachmentIDs: attachments,
}, nil
}
func firstFormValue(values map[string][]string, keys ...string) string {
for _, key := range keys {
if list := values[key]; len(list) > 0 {
return list[0]
}
}
return ""
}
func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
return gin.H{
"id": message.ID,
@@ -1025,6 +1078,33 @@ func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
}
}
func widgetAttachmentPayloads(attachments []model.Attachment) []gin.H {
payload := make([]gin.H, 0, len(attachments))
for _, attachment := range attachments {
payload = append(payload, gin.H{
"id": attachment.ID,
"message_id": attachment.MessageID,
"thumb_url": attachment.ThumbURL,
"data_url": attachment.FileURL,
"file_size": attachment.FileSize,
"file_type": attachment.FileType,
"extension": strings.TrimPrefix(strings.ToLower(attachmentExtension(attachment.FileName)), "."),
"width": attachment.Width,
"height": attachment.Height,
"created_at": attachment.CreatedAt.Unix(),
})
}
return payload
}
func attachmentExtension(filename string) string {
idx := strings.LastIndex(filename, ".")
if idx == -1 {
return ""
}
return filename[idx:]
}
func widgetConversationPayload(conversation model.Conversation) gin.H {
return gin.H{
"id": conversation.ID,
@@ -7,10 +7,12 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -55,6 +57,8 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.Attachment{},
&model.DirectUpload{},
&model.WidgetThemeConfig{},
&model.PreChatForm{},
&model.WidgetFileUpload{},
@@ -353,6 +357,77 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
require.Equal(t, http.StatusOK, wContact.Code)
}
func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
account, _ := seedWidgetHandlerData(t, db)
wConfig := httptest.NewRecorder()
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
router.ServeHTTP(wConfig, reqConfig)
require.Equal(t, http.StatusOK, wConfig.Code)
var configResp map[string]interface{}
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
upload := &model.DirectUpload{
UploadUUID: "signed-widget-upload-1",
AccountID: account.ID,
Status: model.DirectUploadStatusPending,
Source: model.DirectUploadSourceWidget,
OriginalName: "screenshot.png",
FileType: "image",
MimeType: "image/png",
FileSize: 12,
FileURL: "/uploads/widget_direct/signed-widget-upload-1.png",
ThumbURL: "/uploads/widget_direct/signed-widget-upload-1.png",
ExpiresAt: time.Now().Add(time.Hour),
}
require.NoError(t, db.Create(upload).Error)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
require.NoError(t, writer.WriteField("message[attachments][]", upload.UploadUUID))
require.NoError(t, writer.Close())
wMessage := httptest.NewRecorder()
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", body)
reqMessage.Header.Set("Content-Type", writer.FormDataContentType())
reqMessage.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wMessage, reqMessage)
require.Equal(t, http.StatusOK, wMessage.Code)
var messageResp map[string]interface{}
require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp))
assert.Empty(t, messageResp["content"])
attachments := messageResp["attachments"].([]interface{})
require.Len(t, attachments, 1)
attachmentPayload := attachments[0].(map[string]interface{})
assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", attachmentPayload["data_url"])
assert.Equal(t, "image", attachmentPayload["file_type"])
var attachment model.Attachment
require.NoError(t, db.Where("file_name = ?", "screenshot.png").First(&attachment).Error)
assert.Equal(t, upload.FileURL, attachment.FileURL)
require.NoError(t, db.First(upload, upload.ID).Error)
assert.Equal(t, model.DirectUploadStatusCompleted, upload.Status)
wIndex := httptest.NewRecorder()
reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil)
reqIndex.Header.Set("X-Auth-Token", authToken)
router.ServeHTTP(wIndex, reqIndex)
require.Equal(t, http.StatusOK, wIndex.Code)
var indexResp map[string]interface{}
require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp))
payload := indexResp["payload"].([]interface{})
require.Len(t, payload, 1)
indexedMessage := payload[0].(map[string]interface{})
indexedAttachments := indexedMessage["attachments"].([]interface{})
require.Len(t, indexedAttachments, 1)
assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", indexedAttachments[0].(map[string]interface{})["data_url"])
}
func TestWidgetHandler_ChatwootMessageUpdate_SubmitsEmail(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox := seedWidgetHandlerData(t, db)