feat(widget): support chatwoot direct upload attachments
This commit is contained in:
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -48,6 +49,23 @@ func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
// DirectUpload handles POST /api/v1/widget/direct_uploads — widget direct file upload.
|
||||
// Reference: Chatwoot POST /widget/direct_uploads
|
||||
func (h *UploadHandler) DirectUpload(c *gin.Context) {
|
||||
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
|
||||
var req service.ActiveStorageDirectUploadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid direct upload metadata")
|
||||
return
|
||||
}
|
||||
req.WebsiteToken = c.Query("website_token")
|
||||
req.AuthToken = c.GetHeader("X-Auth-Token")
|
||||
result, svcErr := h.svc.CreateWidgetDirectUpload(c.Request.Context(), req)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "file is required")
|
||||
@@ -65,6 +83,15 @@ func (h *UploadHandler) DirectUpload(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *UploadHandler) CompleteWidgetDirectUpload(c *gin.Context) {
|
||||
result, svcErr := h.svc.CompleteWidgetDirectUpload(c.Request.Context(), c.Param("upload_uuid"), c.Request.Body)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// AccountDirectUpload handles POST /api/v1/accounts/:id/direct_uploads — account-level staged upload.
|
||||
// Returns a blob/UUID for later attachment to messages.
|
||||
// Reference: Chatwoot POST /api/v1/accounts/:account_id/direct_uploads
|
||||
@@ -90,4 +117,4 @@ func (h *UploadHandler) AccountDirectUpload(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.OK(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,20 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
|
||||
@@ -32,6 +41,11 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
|
||||
// Widget direct upload route
|
||||
widget := r.Group("/widget")
|
||||
widget.POST("/direct_uploads", h.DirectUpload)
|
||||
widget.PUT("/direct_uploads/:upload_uuid", h.CompleteWidgetDirectUpload)
|
||||
|
||||
chatwootWidget := r.Group("/api/v1/widget")
|
||||
chatwootWidget.POST("/direct_uploads", h.DirectUpload)
|
||||
chatwootWidget.PUT("/direct_uploads/:upload_uuid", h.CompleteWidgetDirectUpload)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -71,7 +85,7 @@ func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
|
||||
r := setupUploadHandlerRouter(h)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/widget/direct_uploads", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-Type", "multipart/form-data")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -79,6 +93,76 @@ func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestUploadHandler_WidgetActiveStorageDirectUploadFlow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.Inbox{},
|
||||
&model.Contact{},
|
||||
&model.ContactInbox{},
|
||||
&model.DirectUpload{},
|
||||
))
|
||||
|
||||
account := &model.Account{Name: "Widget Upload Org", Status: "active"}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
channelConfig, err := json.Marshal(service.WebWidgetConfig{WebsiteToken: "upload_ws_token"})
|
||||
require.NoError(t, err)
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "Upload Widget", ChannelType: "web_widget", Enabled: true, ChannelConfig: string(channelConfig)}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Uploader"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "upload_pubsub_token"}
|
||||
require.NoError(t, db.Create(contactInbox).Error)
|
||||
|
||||
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{
|
||||
Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20},
|
||||
}).WithWidgetAuth(repository.NewInboxRepo(db), repository.NewContactInboxRepo(db))
|
||||
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
|
||||
|
||||
metadataBody, err := json.Marshal(map[string]any{
|
||||
"blob": map[string]any{
|
||||
"filename": "visitor.png",
|
||||
"byte_size": 11,
|
||||
"checksum": "checksum-token",
|
||||
"content_type": "image/png",
|
||||
"metadata": map[string]any{"identified": true},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wCreate := httptest.NewRecorder()
|
||||
reqCreate, _ := http.NewRequest("POST", "/api/v1/widget/direct_uploads?website_token=upload_ws_token", bytes.NewReader(metadataBody))
|
||||
reqCreate.Header.Set("Content-Type", "application/json")
|
||||
reqCreate.Header.Set("X-Auth-Token", "upload_pubsub_token")
|
||||
router.ServeHTTP(wCreate, reqCreate)
|
||||
require.Equal(t, http.StatusOK, wCreate.Code)
|
||||
|
||||
var createResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||
signedID, ok := createResp["signed_id"].(string)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, signedID)
|
||||
assert.Equal(t, "visitor.png", createResp["filename"])
|
||||
directUpload := createResp["direct_upload"].(map[string]any)
|
||||
assert.Equal(t, "/api/v1/widget/direct_uploads/"+signedID, directUpload["url"])
|
||||
|
||||
wPut := httptest.NewRecorder()
|
||||
reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader([]byte("hello image")))
|
||||
reqPut.Header.Set("Content-Type", "image/png")
|
||||
router.ServeHTTP(wPut, reqPut)
|
||||
require.Equal(t, http.StatusOK, wPut.Code)
|
||||
|
||||
var upload model.DirectUpload
|
||||
require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error)
|
||||
assert.Equal(t, account.ID, upload.AccountID)
|
||||
storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "widget_direct", signedID+".png"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("hello image"), storedBytes)
|
||||
}
|
||||
|
||||
func TestUploadHandler_AccountDirectUpload_NoFile(t *testing.T) {
|
||||
// Create handler with nil service — we only test validation before service call
|
||||
h := &UploadHandler{svc: nil}
|
||||
@@ -163,4 +247,4 @@ func TestUploadHandler_ResponseStructure(t *testing.T) {
|
||||
assert.Equal(t, "/uploads/account/1/test.png", parsed["file_url"])
|
||||
assert.Equal(t, "/uploads/account/1/test.png", parsed["thumb_url"])
|
||||
assert.Equal(t, "pending", parsed["status"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user