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
+28 -1
View File
@@ -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)
}
}
+86 -2
View File
@@ -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"])
}
}