feat(inboxes): align agent bot member actions

This commit is contained in:
2026-06-06 04:59:46 +08:00
parent 7ffd11a955
commit 76604353f8
5 changed files with 211 additions and 74 deletions
+6 -4
View File
@@ -37,10 +37,10 @@ Hermes plan landing map:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(messages): align retry status parity`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(messages): align retry status parity`.
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(inboxes): align agent bot member actions`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(inboxes): align agent bot member actions`.
- Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: expand help center route parity tracking`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: P6 message retry parity now matches Chatwoot `MessagesController#retry`: retry sets the message status back to `sent`, clears provider failure data from `content_attributes`, and queues the existing durable `SendReplyJob` path when a worker pool is configured. P6 conversation custom-attribute response parity already matches Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, and the reused dashboard conversation store action. Route parity tracking includes the Chatwoot help-center portal/category/article route family from `reference/chatwoot/config/routes.rb:385-404`, so the audited critical set covers portal CRUD/member actions, nested categories, nested articles, and article bulk actions. P3.7c help center article parity matches the reused dashboard `helpCenter/articles.js` client and Chatwoot `ArticlesController`/`Articles::BulkActionsController`/Jbuilder surface. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence.
- Worktree status at this implementation checkpoint: P6.5 inbox agent-bot member actions now match Chatwoot `InboxesController#agent_bot`/`#set_agent_bot` and the reused dashboard `agentBots` store path: `set_agent_bot` accepts the frontend `{ agent_bot: id|null }` body, returns empty `200 OK`, disconnect destroys the `agent_bot_inboxes` row, reassign updates the inbox's single binding, and `agent_bot` returns the Chatwoot `{ agent_bot: ... }` partial shape with `{}` when disconnected. P6 message retry parity already matches Chatwoot `MessagesController#retry`: retry sets the message status back to `sent`, clears provider failure data from `content_attributes`, and queues the existing durable `SendReplyJob` path when a worker pool is configured. P6 conversation custom-attribute response parity already matches Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, and the reused dashboard conversation store action. Route parity tracking includes the Chatwoot help-center portal/category/article route family from `reference/chatwoot/config/routes.rb:385-404`, so the audited critical set covers portal CRUD/member actions, nested categories, nested articles, and article bulk actions. P3.7c help center article parity matches the reused dashboard `helpCenter/articles.js` client and Chatwoot `ArticlesController`/`Articles::BulkActionsController`/Jbuilder surface. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 860` after adding no-trailing-slash article index/create aliases, frontend-used article PATCH update route, and frontend article bulk-action routes.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -169,6 +169,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(inboxes): align agent bot member actions` | Advances P6.5 inbox API parity by aligning `GET /inboxes/:id/agent_bot` and `POST /inboxes/:id/set_agent_bot` with Chatwoot `InboxesController#agent_bot`/`#set_agent_bot`, `inboxes/agent_bot.json.jbuilder`, `_agent_bot.json.jbuilder`, and the reused dashboard `agentBots` store/API path. The setter accepts Chatwoot `{ agent_bot: id|null }` bodies, keeps legacy `{ agent_bot_id }` compatibility, treats omitted/null params as disconnect, returns empty `200 OK`, destroys disconnected bindings instead of leaving inactive local rows, and updates the inbox's single binding on reassignment. The getter now returns `{ agent_bot: {} }` when disconnected and serializes assigned bots with Chatwoot fields such as `thumbnail`, `bot_type`, `bot_config`, and `outgoing_url`. | `go test ./internal/service -run 'InboxService_SetAgentBot' -count=1`; `go test ./internal/handler/api/v1 -run 'InboxHandler_Set.*AgentBot\|InboxHandler_GetAgentBot\|InboxSetAgentBot' -count=1`; `go test ./internal/handler/api/v1 ./internal/service -run 'Inbox.*AgentBot\|SetAgentBot' -count=1`; `go test ./...`; `git diff --check`. No route changes. | P6.5 remains Doing for additional inbox/channel drift; continue Phase 6 placeholder audit or the next evidence-backed inbox member action gap. |
| `feat(messages): align retry status parity` | Advances P6 message API parity by matching Chatwoot `MessagesController#retry`, `messages/retry.json.jbuilder`, and the reused dashboard `MessageApi.retry` path. Message retry now resets failed messages to `sent`, clears `content_attributes` to `{}`, returns the Chatwoot message serializer, and queues the existing durable `message:send_reply` path when a WorkerPool is configured instead of leaving local `retrying` state/provider failure details visible. | `go test ./internal/service -run MessageService_Retry -count=1`; `go test ./internal/handler/api/v1 -run 'MessageHandlerTestSuite/TestRetry' -count=1`; `go test ./internal/handler/api/v1 ./internal/service -run 'Message.*Retry' -count=1`; `go test ./...`; `git diff --check`. | P6.4 message retry drift moves to Review; continue Phase 6 placeholder burn-down or fresh evidence-backed Phase 2/3 drift. |
| `feat(conversations): align custom attribute response` | Advances P6 conversation response parity by matching Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, and the reused dashboard `ConversationApi.updateCustomAttributes` store action. The account conversation custom-attributes route now returns only `{ custom_attributes: ... }`, excludes local conversation/payload/meta wrappers, and converts empty/null JSON to `{}` for frontend merge stability. The placeholder audit was rerun and still shows only webhook handler-not-injected fallbacks calling `chatwootParityStub`. | `go test ./internal/handler/api/v1 -run 'ConversationHandler.*UpdateCustomAttributes\|TestConversationHandler' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'UpdateCustomAttributes\|ConversationHandler\|ConversationService\|Router' -count=1`; `rg -n "chatwootParityStub\|TODO.*placeholder\|placeholder\|stub" internal/handler internal/service internal/router -g'*.go'`; `go test ./...`; `git diff --check`. | P6 conversation custom-attribute response drift moves to Review; continue Phase 6 placeholder burn-down from the remaining webhook nil-handler fallbacks or fresh reference/smoke evidence. |
| `docs: expand help center route parity tracking` | Audit/tooling checkpoint that expands `cmd/route_parity` to track the full Chatwoot help-center portal/category/article route family from `reference/chatwoot/config/routes.rb:385-404`, including portal member actions, nested category CRUD/reorder, nested article CRUD/reorder, and article bulk actions. Regenerated `docs/parity/route_parity.md` now reports `298 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 305 tracked critical routes`; `docs/parity/gochat_routes.txt` remains `TOTAL: 860`. | `go run ./cmd/route_parity`; `go test ./cmd/route_parity -count=1`; `git diff --check`. | Continue Phase 6 placeholder burn-down or the next evidence-backed Phase 2/3 drift slice; keep route artifacts regenerated whenever the tracked set changes. |
@@ -2036,7 +2037,7 @@ Tracking table:
| P6.2 | Contact APIs | `docs/ROUTE_GAP_ANALYSIS.md`, contact handlers/services | Finish merge, import/export/data-import, notes serializer depth, and Meilisearch-backed CRM search. | Doing |
| P6.3 | Conversation APIs | `docs/ROUTE_GAP_ANALYSIS.md`, conversation handlers/services | Frontend-critical filters, assignment, status, snooze, merge, bulk actions, transcript responses, and custom-attribute responses are implemented or covered by focused drift slices; continue only from fresh reference/smoke evidence. | Review |
| P6.4 | Message APIs | `docs/ROUTE_GAP_ANALYSIS.md`, message handlers/services | Create/list/delete/private notes/attachments/source attribution have focused coverage; retry now matches Chatwoot status reset/content-attribute clearing and durable send-reply enqueue behavior. Continue only from fresh reference/smoke evidence. | Review |
| P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. WhatsApp Cloud health/register-webhook/sync-template member actions now match the reference concern. | Doing |
| P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. WhatsApp Cloud health/register-webhook/sync-template member actions now match the reference concern; agent-bot get/set member actions now accept Chatwoot `{ agent_bot }` payloads, return `head :ok`, destroy disconnected bindings, and serialize `{ agent_bot }` through the Chatwoot partial shape. | Doing |
| P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Widget/public frontend-critical route behavior is handler-backed, including public inbox flow, direct uploads/attachments, and public CSAT survey submission. | Done |
| P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Done |
@@ -2123,6 +2124,7 @@ Verification milestone gates:
## Progress Log
- 2026-06-06: P6.5 inbox agent-bot checkpoint prepared as `feat(inboxes): align agent bot member actions`; audited Chatwoot `InboxesController#agent_bot`, `InboxesController#set_agent_bot`, `inboxes/agent_bot.json.jbuilder`, `_agent_bot.json.jbuilder`, and the reused dashboard `agentBots` store/API. `POST /inboxes/:id/set_agent_bot` now accepts the frontend `{ agent_bot: id|null }` body, keeps legacy `{ agent_bot_id }` compatibility, treats omitted/null bodies as disconnect, returns empty `200 OK`, destroys disconnected bindings like Chatwoot `@inbox.agent_bot_inbox.destroy!`, and updates the inbox's single binding when reassigning bots. `GET /inboxes/:id/agent_bot` now returns `{ agent_bot: {} }` when disconnected and Chatwoot partial fields such as `thumbnail`, `bot_type`, `bot_config`, and `outgoing_url` when assigned. Focused inbox service/handler tests, combined handler/service tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or the next evidence-backed inbox drift.
- 2026-06-06: P6 message retry checkpoint prepared as `feat(messages): align retry status parity`; audited Chatwoot `MessagesController#retry`, `messages/retry.json.jbuilder`, and reused dashboard `api/inbox/message.js`. Account message retry now resets status to `sent`, clears `content_attributes` to `{}`, returns the normal Chatwoot message serializer, and queues the existing durable send-reply job path when a WorkerPool is configured instead of exposing local `retrying` state/provider failure details. Focused retry service/handler tests, combined handler/service retry tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or a fresh evidence-backed Phase 2/3 drift slice.
- 2026-06-06: P6 conversation custom-attribute response checkpoint prepared as `feat(conversations): align custom attribute response`; audited Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, reused dashboard `api/inbox/conversation.js`, and the conversation store action that reads `response.data.custom_attributes`. Account conversation custom-attribute updates now return only `{ custom_attributes: ... }`, exclude local conversation/payload/meta wrappers, and serialize empty/null custom attributes as `{}` for frontend merge stability. Focused handler tests, combined handler/service/router tests, placeholder audit, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or a fresh evidence-backed Phase 2/3 drift slice.
- 2026-06-06: Audit/tooling checkpoint prepared as `docs: expand help center route parity tracking`; expanded `cmd/route_parity` to cover the full Chatwoot help-center portal/category/article route family from `reference/chatwoot/config/routes.rb:385-404`, including portal CRUD/member actions, nested category CRUD/reorder, nested article CRUD/reorder, and article bulk actions. Regenerated route parity reports `298 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 305 tracked critical routes`; route dump remains `TOTAL: 860`. Verification for this checkpoint: `go run ./cmd/route_parity`, `go test ./cmd/route_parity -count=1`, and `git diff --check`; continue Phase 6 placeholder burn-down or a fresh evidence-backed Phase 2/3 drift slice.
@@ -1,13 +1,23 @@
package v1
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"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/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
@@ -15,12 +25,39 @@ func setupInboxAgentBotRouter(handler *InboxHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(gin.Recovery())
r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/set_agent_bot", handler.SetAgentBot)
r.GET("/api/v1/accounts/:account_id/inboxes/:inbox_id/agent_bot", handler.GetAgentBot)
r.DELETE("/api/v1/accounts/:account_id/inboxes/:inbox_id/avatar", handler.DeleteAvatar)
r.GET("/api/v1/accounts/:account_id/inboxes/:inbox_id/campaigns", handler.ListCampaigns)
return r
}
func setupInboxAgentBotDB(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.Inbox, *model.AgentBot) {
t.Helper()
dbName := "file:" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &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{}, &model.AgentBot{}, &model.AgentBotInbox{}))
account := &model.Account{Name: "Agent bot org", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Support", ChannelType: "web_widget", ChannelID: 1}
require.NoError(t, db.Create(inbox).Error)
bot := &model.AgentBot{AccountID: &account.ID, Name: "Triage bot", Description: "Routes chats", AvatarURL: "https://example.test/bot.png", OutgoingURL: "https://example.test/hook", BotType: "webhook", Config: json.RawMessage(`{"handoff":true}`), AccessToken: "access-token", Secret: "secret"}
require.NoError(t, db.Create(bot).Error)
svc := service.NewInboxService(repository.NewInboxRepo(db), repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), nil, nil, nil, nil)
return setupInboxAgentBotRouter(NewInboxHandler(svc)), db, account, inbox, bot
}
func inboxAgentBotTestID(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}
// --- GetAgentBot tests ---
func TestInboxHandler_GetAgentBot_InvalidAccountID(t *testing.T) {
@@ -57,6 +94,55 @@ func TestInboxHandler_GetAgentBot_ValidIDs_ZeroService(t *testing.T) {
assert.True(t, w.Code == http.StatusUnprocessableEntity || w.Code == http.StatusOK)
}
func TestInboxHandler_SetAndGetAgentBot_ChatwootPayload(t *testing.T) {
router, _, account, inbox, bot := setupInboxAgentBotDB(t)
body, _ := json.Marshal(map[string]uint{"agent_bot": bot.ID})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+inboxAgentBotTestID(account.ID)+"/inboxes/"+inboxAgentBotTestID(inbox.ID)+"/set_agent_bot", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, w.Body.String(), "Chatwoot set_agent_bot responds head :ok")
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, "/api/v1/accounts/"+inboxAgentBotTestID(account.ID)+"/inboxes/"+inboxAgentBotTestID(inbox.ID)+"/agent_bot", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
agentBot := resp["agent_bot"]
assert.Equal(t, float64(bot.ID), agentBot["id"])
assert.Equal(t, bot.Name, agentBot["name"])
assert.Equal(t, bot.AvatarURL, agentBot["thumbnail"])
assert.Equal(t, bot.OutgoingURL, agentBot["outgoing_url"])
assert.Equal(t, bot.BotType, agentBot["bot_type"])
assert.NotContains(t, agentBot, "avatar_url")
}
func TestInboxHandler_SetAgentBot_NullDisconnectsWithHeadOK(t *testing.T) {
router, db, account, inbox, bot := setupInboxAgentBotDB(t)
require.NoError(t, db.Create(&model.AgentBotInbox{AccountID: &account.ID, InboxID: inbox.ID, AgentBotID: bot.ID, Status: model.AgentBotInboxActive}).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+inboxAgentBotTestID(account.ID)+"/inboxes/"+inboxAgentBotTestID(inbox.ID)+"/set_agent_bot", bytes.NewReader([]byte(`{"agent_bot":null}`)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, w.Body.String())
var count int64
require.NoError(t, db.Model(&model.AgentBotInbox{}).Where("inbox_id = ?", inbox.ID).Count(&count).Error)
assert.Equal(t, int64(0), count)
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, "/api/v1/accounts/"+inboxAgentBotTestID(account.ID)+"/inboxes/"+inboxAgentBotTestID(inbox.ID)+"/agent_bot", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.JSONEq(t, `{"agent_bot":{}}`, w.Body.String())
}
// --- DeleteAvatar tests ---
func TestInboxHandler_DeleteAvatar_InvalidAccountID(t *testing.T) {
+32 -14
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
@@ -576,29 +577,29 @@ func (h *InboxHandler) SetAgentBot(c *gin.Context) {
return
}
var req service.SetAgentBotRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
return
req := service.SetAgentBotRequest{}
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
if c.Request.ContentLength < 0 && errors.Is(err, io.EOF) {
// Chunked empty body behaves like omitted params in Chatwoot and disconnects the bot.
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
return
}
}
}
if !h.svc.Ready() {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to set agent bot")
return
}
binding, svcErr := h.svc.SetAgentBot(c.Request.Context(), accountID, inboxID, req)
_, svcErr := h.svc.SetAgentBot(c.Request.Context(), accountID, inboxID, req)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
if binding == nil {
// Agent bot was removed
c.JSON(http.StatusOK, gin.H{"message": "agent bot removed from inbox"})
return
}
c.JSON(http.StatusOK, binding)
c.Status(http.StatusOK)
}
// Health checks the health status of an inbox's channel connection.
@@ -738,11 +739,28 @@ func (h *InboxHandler) GetAgentBot(c *gin.Context) {
}
if agentBot == nil {
c.JSON(http.StatusOK, gin.H{"agent_bot": nil})
c.JSON(http.StatusOK, gin.H{"agent_bot": gin.H{}})
return
}
c.JSON(http.StatusOK, gin.H{"agent_bot": agentBot})
c.JSON(http.StatusOK, gin.H{"agent_bot": serializeAgentBot(agentBot)})
}
func serializeAgentBot(bot *model.AgentBot) gin.H {
if bot == nil {
return gin.H{}
}
return gin.H{
"id": bot.ID,
"name": bot.Name,
"description": bot.Description,
"thumbnail": bot.AvatarURL,
"outgoing_url": bot.OutgoingURL,
"bot_type": bot.BotType,
"bot_config": bot.Config,
"account_id": bot.AccountID,
"access_token": bot.AccessToken,
}
}
// DeleteAvatar removes the avatar URL from an inbox.
+64 -36
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/gochat/gochat/internal/campaign"
@@ -1516,12 +1517,45 @@ func (s *InboxService) CreateFacebookInbox(ctx context.Context, accountID uint,
// SetAgentBotRequest represents the request body for setting an agent bot on an inbox.
// Reference: Chatwoot InboxesController#set_agent_bot
type SetAgentBotRequest struct {
AgentBotID uint `json:"agent_bot_id" validate:"required"` // 0 means "remove" current bot
AgentBotID *uint `json:"-"` // nil means "remove" current bot.
}
// UnmarshalJSON accepts Chatwoot's frontend key (`agent_bot`) and the old local
// compatibility key (`agent_bot_id`). A null or missing value disconnects the bot.
func (r *SetAgentBotRequest) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
value, ok := raw["agent_bot"]
if !ok {
value, ok = raw["agent_bot_id"]
}
if !ok || string(value) == "null" || string(value) == `""` {
r.AgentBotID = nil
return nil
}
var id uint
if err := json.Unmarshal(value, &id); err == nil {
r.AgentBotID = &id
return nil
}
var idString string
if err := json.Unmarshal(value, &idString); err != nil {
return err
}
parsed, err := strconv.ParseUint(idString, 10, 64)
if err != nil {
return err
}
id = uint(parsed)
r.AgentBotID = &id
return nil
}
// SetAgentBot assigns or removes an agent bot from an inbox.
// If agent_bot_id is 0, any existing active bot assignment is deactivated.
// If agent_bot_id > 0, a new AgentBotInbox binding is created (or existing one updated).
// If agent_bot is absent/null, the existing bot assignment is destroyed.
// If agent_bot is present, the inbox's single AgentBotInbox binding is created or updated.
// Reference: Chatwoot InboxesController#set_agent_bot
func (s *InboxService) SetAgentBot(ctx context.Context, accountID, inboxID uint, req SetAgentBotRequest) (*model.AgentBotInbox, error) {
// Verify inbox belongs to account
@@ -1530,67 +1564,61 @@ func (s *InboxService) SetAgentBot(ctx context.Context, accountID, inboxID uint,
return nil, fmt.Errorf("inbox not found: %w", err)
}
// If agent_bot_id is 0, deactivate any existing active bindings for this inbox
if req.AgentBotID == 0 {
activeBindings, err := s.agentBotInboxRepo.FindActiveByInboxID(ctx, inbox.ID)
if err != nil {
return nil, fmt.Errorf("failed to find active bot bindings: %w", err)
}
for _, binding := range activeBindings {
binding.Status = model.AgentBotInboxInactive
if err := s.agentBotInboxRepo.Update(ctx, &binding); err != nil {
applogger.L().Warnf("Failed to deactivate agent_bot_inbox id=%d: %v", binding.ID, err)
existingBindings, err := s.agentBotInboxRepo.FindByInboxID(ctx, inbox.ID)
if err != nil {
return nil, fmt.Errorf("failed to find bot bindings: %w", err)
}
// Chatwoot destroys the existing has_one binding when no agent_bot param is present.
if req.AgentBotID == nil {
for _, binding := range existingBindings {
if err := s.agentBotInboxRepo.Delete(ctx, binding.ID); err != nil {
applogger.L().Warnf("Failed to delete agent_bot_inbox id=%d: %v", binding.ID, err)
}
}
applogger.L().Infof("Removed agent bot from inbox (account_id=%d, inbox_id=%d)", accountID, inboxID)
return nil, nil
}
agentBotID := *req.AgentBotID
// Verify the agent bot exists and is accessible to this account
bot, err := s.agentBotRepo.FindByID(ctx, req.AgentBotID)
bot, err := s.agentBotRepo.FindByID(ctx, agentBotID)
if err != nil {
return nil, fmt.Errorf("agent bot not found: %w", err)
}
if bot.AccountID != nil && *bot.AccountID != accountID {
return nil, fmt.Errorf("agent bot %d not accessible to account %d", req.AgentBotID, accountID)
return nil, fmt.Errorf("agent bot %d not accessible to account %d", agentBotID, accountID)
}
// Check if there's an existing binding for this inbox+bot combo
existing, err := s.agentBotInboxRepo.FindByAgentBotAndInbox(ctx, req.AgentBotID, inbox.ID)
if err == nil && existing != nil {
// Update existing binding to active
existing.Status = model.AgentBotInboxActive
if err := s.agentBotInboxRepo.Update(ctx, existing); err != nil {
if len(existingBindings) > 0 {
binding := existingBindings[0]
binding.AgentBotID = agentBotID
binding.AccountID = &accountID
binding.Status = model.AgentBotInboxActive
if err := s.agentBotInboxRepo.Update(ctx, &binding); err != nil {
return nil, fmt.Errorf("failed to update agent_bot_inbox: %w", err)
}
applogger.L().Infof("Re-activated agent bot %d on inbox %d", req.AgentBotID, inboxID)
return existing, nil
}
// Deactivate any previous active bot on this inbox first
activeBindings, err := s.agentBotInboxRepo.FindActiveByInboxID(ctx, inbox.ID)
if err != nil {
return nil, fmt.Errorf("failed to find active bot bindings: %w", err)
}
for _, binding := range activeBindings {
binding.Status = model.AgentBotInboxInactive
if err := s.agentBotInboxRepo.Update(ctx, &binding); err != nil {
applogger.L().Warnf("Failed to deactivate previous agent_bot_inbox id=%d: %v", binding.ID, err)
for _, stale := range existingBindings[1:] {
if err := s.agentBotInboxRepo.Delete(ctx, stale.ID); err != nil {
applogger.L().Warnf("Failed to delete stale agent_bot_inbox id=%d: %v", stale.ID, err)
}
}
applogger.L().Infof("Set agent bot %d on inbox %d (account_id=%d)", agentBotID, inboxID, accountID)
return &binding, nil
}
// Create new binding
binding := &model.AgentBotInbox{
AccountID: &accountID,
InboxID: inbox.ID,
AgentBotID: req.AgentBotID,
AgentBotID: agentBotID,
Status: model.AgentBotInboxActive,
}
if err := s.agentBotInboxRepo.Create(ctx, binding); err != nil {
return nil, fmt.Errorf("failed to create agent_bot_inbox: %w", err)
}
applogger.L().Infof("Set agent bot %d on inbox %d (account_id=%d)", req.AgentBotID, inboxID, accountID)
applogger.L().Infof("Set agent bot %d on inbox %d (account_id=%d)", agentBotID, inboxID, accountID)
return binding, nil
}
+23 -20
View File
@@ -102,6 +102,8 @@ func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*mod
return account, inbox
}
func ptrUint(v uint) *uint { return &v }
func createWhatsAppInboxTestPrereqs(t *testing.T, db *gorm.DB, provider string) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp) {
t.Helper()
account, inbox := createInboxTestPrereqs(t, db, "whatsapp")
@@ -193,7 +195,7 @@ func TestInboxService_SetAgentBot_AssignBot(t *testing.T) {
bot := createTestAgentBot(t, db, account.ID, "assign")
binding, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: bot.ID,
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
assert.NotNil(t, binding)
@@ -209,24 +211,26 @@ func TestInboxService_SetAgentBot_RemoveBot(t *testing.T) {
// First assign a bot
binding, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: bot.ID,
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
assert.NotNil(t, binding)
// Now remove it (agent_bot_id = 0)
result, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: 0,
})
// Now remove it (missing/null agent_bot)
result, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{})
require.NoError(t, err)
assert.Nil(t, result, "removing bot should return nil binding")
bindings, err := repository.NewAgentBotInboxRepo(db).FindByInboxID(context.Background(), inbox.ID)
require.NoError(t, err)
assert.Empty(t, bindings, "Chatwoot destroys the agent_bot_inbox row on disconnect")
}
func TestInboxService_SetAgentBot_InboxNotFound(t *testing.T) {
svc, _ := setupInboxServiceTest(t)
binding, err := svc.SetAgentBot(context.Background(), 9999, 9999, SetAgentBotRequest{
AgentBotID: 1,
AgentBotID: ptrUint(1),
})
assert.Error(t, err)
assert.Nil(t, binding)
@@ -238,7 +242,7 @@ func TestInboxService_SetAgentBot_BotNotFound(t *testing.T) {
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
binding, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: 9999,
AgentBotID: ptrUint(9999),
})
assert.Error(t, err)
assert.Nil(t, binding)
@@ -253,40 +257,39 @@ func TestInboxService_SetAgentBot_ReassignBot(t *testing.T) {
// Assign first bot
binding1, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: bot1.ID,
AgentBotID: ptrUint(bot1.ID),
})
require.NoError(t, err)
assert.Equal(t, bot1.ID, binding1.AgentBotID)
// Reassign to second bot — should deactivate bot1 and activate bot2
// Reassign to second bot — Chatwoot updates the inbox's single has_one binding.
binding2, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: bot2.ID,
AgentBotID: ptrUint(bot2.ID),
})
require.NoError(t, err)
assert.Equal(t, bot2.ID, binding2.AgentBotID)
assert.Equal(t, model.AgentBotInboxActive, binding2.Status)
assert.Equal(t, binding1.ID, binding2.ID)
}
func TestInboxService_SetAgentBot_ReactivateExistingBinding(t *testing.T) {
func TestInboxService_SetAgentBot_RecreatesBindingAfterDisconnect(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
bot := createTestAgentBot(t, db, account.ID, "reactivate")
bot := createTestAgentBot(t, db, account.ID, "recreate")
// Assign bot
_, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: bot.ID,
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
// Remove bot (deactivate)
_, err = svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: 0,
})
// Remove bot (destroy binding)
_, err = svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{})
require.NoError(t, err)
// Re-assign same bot — should reactivate the existing binding
// Re-assign same bot — should create a fresh binding.
binding2, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: bot.ID,
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
assert.NotNil(t, binding2)