feat(macros): align chatwoot macro payloads

This commit is contained in:
2026-06-05 10:11:11 +08:00
parent 382933e601
commit c00d313576
5 changed files with 476 additions and 69 deletions
+190 -32
View File
@@ -1,11 +1,13 @@
package v1
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/pkg/response"
)
@@ -38,26 +40,35 @@ func (h *MacroHandler) List(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"payload": macros,
"payload": serializeMacros(macros),
})
}
// Get retrieves a single macro by ID.
// GET /api/v1/accounts/:account_id/macros/:id
func (h *MacroHandler) Get(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
macroID, err := parseUintParam(c, "macro_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
macro, svcErr := h.svc.GetByID(c.Request.Context(), macroID)
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
if !h.svc.CanAccess(macro, getUserID(c), getRole(c), "show") {
abortMacroUnauthorized(c)
return
}
response.OK(c, macro)
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)})
}
// Create creates a new macro.
@@ -69,8 +80,8 @@ func (h *MacroHandler) Create(c *gin.Context) {
return
}
var macro automation.Macro
if err := c.ShouldBindJSON(&macro); err != nil {
macro, err := bindMacroRequest(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
@@ -86,25 +97,40 @@ func (h *MacroHandler) Create(c *gin.Context) {
macro.Visibility = automation.MacroVisibilityPersonal
}
if svcErr := h.svc.Create(c.Request.Context(), &macro); svcErr != nil {
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.Created(c, macro)
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)})
}
// Update updates an existing macro.
// PUT /api/v1/accounts/:account_id/macros/:id
func (h *MacroHandler) Update(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
macroID, err := parseUintParam(c, "macro_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
var macro automation.Macro
if err := c.ShouldBindJSON(&macro); err != nil {
existing, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
if !h.svc.CanAccess(existing, getUserID(c), getRole(c), "update") {
abortMacroUnauthorized(c)
return
}
macro, err := bindMacroRequest(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
@@ -112,39 +138,52 @@ func (h *MacroHandler) Update(c *gin.Context) {
macro.ID = macroID
macro.UpdatedByID = getUserID(c)
if svcErr := h.svc.Update(c.Request.Context(), &macro); svcErr != nil {
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, macro)
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(updated)})
}
// Delete soft-deletes a macro.
// DELETE /api/v1/accounts/:account_id/macros/:id
func (h *MacroHandler) Delete(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
macroID, err := parseUintParam(c, "macro_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
if svcErr := h.svc.Delete(c.Request.Context(), macroID); svcErr != nil {
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
if !h.svc.CanAccess(macro, getUserID(c), getRole(c), "destroy") {
abortMacroUnauthorized(c)
return
}
if svcErr := h.svc.DeleteForAccount(c.Request.Context(), accountID, macroID); svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, gin.H{
"id": strconv.FormatUint(uint64(macroID), 10),
"deleted": true,
})
c.Status(http.StatusOK)
}
// Execute runs a macro on a conversation.
// POST /api/v1/accounts/:account_id/macros/:id/execute
// Reference: Chatwoot MacrosController#execute — MacroService.Execute returns error only
func (h *MacroHandler) Execute(c *gin.Context) {
macroID, err := parseUintParam(c, "id")
macroID, err := parseUintParam(c, "macro_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid macro id")
return
@@ -156,26 +195,30 @@ func (h *MacroHandler) Execute(c *gin.Context) {
return
}
var body struct {
ConversationID uint `json:"conversation_id"`
}
var body macroExecuteRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_id is required")
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_ids is required")
return
}
displayIDs := body.DisplayIDs()
userID := getUserID(c)
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
if !h.svc.CanAccess(macro, userID, getRole(c), "execute") {
abortMacroUnauthorized(c)
return
}
userID := getUserID(c)
if svcErr := h.svc.Execute(c.Request.Context(), accountID, body.ConversationID, macroID, userID); svcErr != nil {
if svcErr := h.svc.ExecuteForDisplayIDs(c.Request.Context(), accountID, macroID, displayIDs, userID); svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"macro_id": strconv.FormatUint(uint64(macroID), 10),
"conversation_id": strconv.FormatUint(uint64(body.ConversationID), 10),
})
c.Status(http.StatusOK)
}
// Clone duplicates a macro.
@@ -230,7 +273,122 @@ func (h *MacroHandler) ToggleActive(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"id": strconv.FormatUint(uint64(macroID), 10),
"id": macroID,
"active": req.Active,
})
}
}
type macroActionRequest struct {
ActionName string `json:"action_name"`
ActionParams json.RawMessage `json:"action_params"`
}
type macroRequest struct {
Name string `json:"name"`
Visibility interface{} `json:"visibility"`
Actions []macroActionRequest `json:"actions"`
}
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
var req macroRequest
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
actions := make(automation.Actions, 0, len(req.Actions))
for _, actionReq := range req.Actions {
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
if err != nil {
return nil, err
}
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
}
return &automation.Macro{
Name: strings.TrimSpace(req.Name),
Visibility: parseMacroVisibility(req.Visibility),
Actions: actions,
}, nil
}
func parseMacroVisibility(value interface{}) automation.MacroVisibility {
switch v := value.(type) {
case string:
if strings.EqualFold(v, "global") {
return automation.MacroVisibilityGlobal
}
case float64:
if int(v) == int(automation.MacroVisibilityGlobal) {
return automation.MacroVisibilityGlobal
}
case int:
if v == int(automation.MacroVisibilityGlobal) {
return automation.MacroVisibilityGlobal
}
}
return automation.MacroVisibilityPersonal
}
type macroExecuteRequest struct {
ConversationIDs []uint `json:"conversation_ids"`
ConversationID uint `json:"conversation_id"`
}
func (r macroExecuteRequest) DisplayIDs() []uint {
if len(r.ConversationIDs) > 0 {
return r.ConversationIDs
}
if r.ConversationID > 0 {
return []uint{r.ConversationID}
}
return nil
}
func serializeMacros(macros []automation.Macro) []gin.H {
result := make([]gin.H, 0, len(macros))
for i := range macros {
result = append(result, serializeMacro(&macros[i]))
}
return result
}
func serializeMacro(macro *automation.Macro) gin.H {
item := gin.H{
"id": macro.ID,
"name": macro.Name,
"visibility": macroVisibilityString(macro.Visibility),
"account_id": macro.AccountID,
"actions": serializeAutomationActions(macro.Actions),
}
if macro.CreatedBy != nil && macro.CreatedBy.ID != 0 {
item["created_by"] = serializeMacroAgent(macro.CreatedBy)
}
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
}
return item
}
func macroVisibilityString(visibility automation.MacroVisibility) string {
if visibility == automation.MacroVisibilityGlobal {
return "global"
}
return "personal"
}
func serializeMacroAgent(user *model.User) gin.H {
availability := "offline"
if user.Available {
availability = "online"
}
return gin.H{
"id": user.ID,
"name": user.Name,
"available_name": user.Name,
"email": user.Email,
"availability_status": availability,
"thumbnail": user.AvatarURL,
}
}
func abortMacroUnauthorized(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "You are not authorized to do this action"})
}
+121 -5
View File
@@ -39,7 +39,10 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &automation.Macro{}))
s.Require().NoError(db.AutoMigrate(
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
))
s.db = db
macroSvc := automation.NewMacroService(&testMacroDBProvider{db: db})
@@ -82,8 +85,8 @@ func (s *MacroHandlerTestSuite) TestCreate_Success() {
body := map[string]interface{}{
"name": "test-macro",
"visibility": 0,
"actions": []map[string]interface{}{{"action_name": "assign_agent", "action_params": map[string]interface{}{"agent_id": "1"}}},
"visibility": "global",
"actions": []map[string]interface{}{{"action_name": "assign_agent", "action_params": []interface{}{"self"}}},
}
b, _ := json.Marshal(body)
@@ -92,7 +95,17 @@ func (s *MacroHandlerTestSuite) TestCreate_Success() {
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusCreated, w.Code)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].(map[string]interface{})
assert.Equal(s.T(), "test-macro", payload["name"])
assert.Equal(s.T(), "global", payload["visibility"])
createdBy := payload["created_by"].(map[string]interface{})
assert.Equal(s.T(), float64(s.user.ID), createdBy["id"])
actions := payload["actions"].([]interface{})
action := actions[0].(map[string]interface{})
assert.Equal(s.T(), []interface{}{"self"}, action["action_params"])
}
func (s *MacroHandlerTestSuite) TestCreate_BadRequest() {
@@ -105,4 +118,107 @@ func (s *MacroHandlerTestSuite) TestCreate_BadRequest() {
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
}
func (s *MacroHandlerTestSuite) TestUpdateDeleteAuthorizationAndPayload() {
admin := &model.User{Name: "Admin", Email: "macro-admin@example.com", Role: "administrator"}
s.Require().NoError(s.db.Create(admin).Error)
agent := &model.User{Name: "Agent", Email: "macro-agent@example.com", Role: "agent"}
s.Require().NoError(s.db.Create(agent).Error)
macro := &automation.Macro{
AccountID: s.account.ID,
Name: "public macro",
Actions: automation.Actions{},
Visibility: automation.MacroVisibilityGlobal,
Active: true,
CreatedByID: admin.ID,
UpdatedByID: admin.ID,
}
s.Require().NoError(s.db.Create(macro).Error)
r := gin.New()
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
c.Set("user_id", agent.ID)
c.Set("role", "agent")
c.Next()
}, s.handler.Update)
body := []byte(`{"name":"agent edit","visibility":"global","actions":[]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
r = gin.New()
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
c.Set("user_id", admin.ID)
c.Set("role", "administrator")
c.Next()
}, s.handler.Update)
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].(map[string]interface{})
assert.Equal(s.T(), "agent edit", payload["name"])
assert.Equal(s.T(), "global", payload["visibility"])
}
func (s *MacroHandlerTestSuite) TestExecute_UsesConversationDisplayIDsAndMutatesConversation() {
admin := &model.User{Name: "Exec Admin", Email: "macro-exec-admin@example.com", Role: "administrator"}
s.Require().NoError(s.db.Create(admin).Error)
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Macro Inbox", ChannelType: "web"}
s.Require().NoError(s.db.Create(inbox).Error)
contact := &model.Contact{AccountID: s.account.ID, Name: "Macro Contact", Email: "macro-contact@example.com"}
s.Require().NoError(s.db.Create(contact).Error)
displayID := uint(444)
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", Priority: "low", ChannelType: "web", Channel: "web_widget"}
s.Require().NoError(s.db.Create(conversation).Error)
macro := &automation.Macro{
AccountID: s.account.ID,
Name: "execute macro",
Actions: automation.Actions{
{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"vip"}}},
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
{ActionName: "send_message", ActionParams: map[string]interface{}{"content": "Hello from macro"}},
{ActionName: "add_private_note", ActionParams: map[string]interface{}{"content": "Internal macro note"}},
},
Visibility: automation.MacroVisibilityGlobal,
Active: true,
CreatedByID: admin.ID,
UpdatedByID: admin.ID,
}
s.Require().NoError(s.db.Create(macro).Error)
r := gin.New()
r.POST("/api/v1/accounts/:account_id/macros/:macro_id/execute", func(c *gin.Context) {
c.Set("user_id", admin.ID)
c.Set("role", "administrator")
c.Next()
}, s.handler.Execute)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), bytes.NewBufferString(`{"conversation_ids":[444]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
var reloaded model.Conversation
s.Require().NoError(s.db.First(&reloaded, conversation.ID).Error)
assert.Equal(s.T(), "resolved", reloaded.Status)
var label automation.ConversationLabel
s.Require().NoError(s.db.Where("conversation_id = ? AND label = ?", conversation.ID, "vip").First(&label).Error)
var messages []model.Message
s.Require().NoError(s.db.Where("conversation_id = ?", conversation.ID).Order("id ASC").Find(&messages).Error)
s.Require().Len(messages, 2)
assert.Equal(s.T(), "Hello from macro", messages[0].Content)
assert.Equal(s.T(), admin.ID, *messages[0].SenderID)
assert.False(s.T(), messages[0].Private)
assert.Equal(s.T(), "Internal macro note", messages[1].Content)
assert.True(s.T(), messages[1].Private)
}