feat(agent-bots): align account payloads
This commit is contained in:
@@ -37,6 +37,15 @@ var criticalRoutes = []route{
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/agents/bulk_create", Controller: "api/v1/accounts/agents#bulk_create", Source: "routes.rb:60"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/assignable_agents", Controller: "api/v1/accounts/assignable_agents#index", Source: "routes.rb:104"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/audit_logs/", Controller: "api/v1/accounts/audit_logs#show", Source: "routes.rb:105"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/agent_bots", Controller: "api/v1/accounts/agent_bots#index", Source: "routes.rb:94"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/agent_bots", Controller: "api/v1/accounts/agent_bots#create", Source: "routes.rb:94"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", Controller: "api/v1/accounts/agent_bots#show", Source: "routes.rb:94"},
|
||||
{Method: "PUT", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", Controller: "api/v1/accounts/agent_bots#update", Source: "routes.rb:94"},
|
||||
{Method: "PATCH", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", Controller: "api/v1/accounts/agent_bots#update", Source: "routes.rb:94"},
|
||||
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", Controller: "api/v1/accounts/agent_bots#destroy", Source: "routes.rb:94"},
|
||||
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/avatar", Controller: "api/v1/accounts/agent_bots#avatar", Source: "routes.rb:95"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_access_token", Controller: "api/v1/accounts/agent_bots#reset_access_token", Source: "routes.rb:96"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_secret", Controller: "api/v1/accounts/agent_bots#reset_secret", Source: "routes.rb:97"},
|
||||
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/canned_responses/", Controller: "api/v1/accounts/canned_responses#index", Source: "routes.rb:114"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/canned_responses/", Controller: "api/v1/accounts/canned_responses#create", Source: "routes.rb:114"},
|
||||
|
||||
@@ -37,14 +37,14 @@ Hermes plan landing map:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(contacts): initiate voice calls`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): initiate voice calls`.
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(agent-bots): align account payloads`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(agent-bots): align account payloads`.
|
||||
- Latest documentation/tooling checkpoint: `docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Worktree status at this implementation checkpoint: P6.8 contact outbound voice call parity is now implemented for the reused dashboard `ContactAPI.initiateCall`/voice client route and Chatwoot enterprise `Contacts::CallsController#create` plus `Voice::OutboundCallBuilder` boundary. `POST /contacts/:contact_id/call` validates the account contact, current user's assigned Twilio voice inbox, `voice_enabled` channel config, and contact phone number; reuses only matching open display-ID conversations; otherwise creates a ContactInbox and open conversation; persists Twilio outgoing call metadata plus a `voice_call` message payload; and returns `{ conversation_id, inbox_id, call_sid, conference_sid }`. P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. 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: P3.9 account agent-bot API parity is now implemented for reused dashboard `AgentBotsAPI` and `agentBots` store flows. Account `/agent_bots` now exposes frontend no-trailing-slash GET/POST and PATCH update routes, tracks Chatwoot `routes.rb:94-97`, returns raw AgentBot Jbuilder-style arrays/objects instead of local success/data/meta envelopes, accepts JSON or multipart form bodies, preserves explicit empty update fields, scopes mutations to `Current.account.agent_bots`, allows show/index of system bots through `accessible_to`, returns empty `200 OK` delete, and returns full bot payloads for reset-access-token/reset-secret/avatar actions. P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. 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: 861` after adding the enterprise contact outbound voice call route.
|
||||
- Route dump succeeds with `TOTAL: 864` after adding account agent-bot no-trailing-slash and PATCH update routes.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
- Tracked frontend-critical route audit covers 306 Chatwoot routes: 299 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent.
|
||||
- Tracked frontend-critical route audit covers 315 Chatwoot routes: 308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent.
|
||||
- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers.
|
||||
- Handler test stability fixes are committed into the baseline before feature parity work continues.
|
||||
- `.codegraph/` is generated indexing output and is not part of tracked product code.
|
||||
@@ -126,12 +126,13 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
|
||||
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 306-route critical set; help-center portal/category/article routes from `routes.rb:385-404` and enterprise contact outbound voice call from `routes.rb:216` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, and help-center portal/category/article payloads now match the inspected Chatwoot frontend contract. | Run the next evidence-backed route/controller/serializer drift audit. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 315-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, enterprise contact outbound voice call from `routes.rb:216`, and account agent-bot routes from `routes.rb:94-97` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, and help-center portal/category/article payloads now match the inspected Chatwoot frontend contract. | Run the next evidence-backed route/controller/serializer drift audit. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. |
|
||||
| 3 | P6.8 contact outbound voice call | Implemented for the reused dashboard route with Twilio voice-enabled inbox validation, assigned-inbox check, open-conversation reuse, ContactInbox/conversation/call/message persistence, and Chatwoot response shape. | Keep in Review; reopen only if live smoke exposes provider initiation/status-update drift beyond the fakeable persisted boundary. | Focused contact call tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 4 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. |
|
||||
| 5 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. |
|
||||
| 6 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. |
|
||||
| 3 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 4 | P6.8 contact outbound voice call | Implemented for the reused dashboard route with Twilio voice-enabled inbox validation, assigned-inbox check, open-conversation reuse, ContactInbox/conversation/call/message persistence, and Chatwoot response shape. | Keep in Review; reopen only if live smoke exposes provider initiation/status-update drift beyond the fakeable persisted boundary. | Focused contact call tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 5 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. |
|
||||
| 6 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. |
|
||||
| 7 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. |
|
||||
|
||||
## Open Checkpoint Contracts
|
||||
|
||||
@@ -150,6 +151,7 @@ These rows are the executable development plan from this point forward. A checkp
|
||||
| P3.7b help center category route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/category_handler.go`, `internal/service/category_service.go`, `internal/repository/category_repo.go`, category tests | `reference/chatwoot/app/controllers/api/v1/accounts/categories_controller.rb`, `app/views/api/v1/accounts/categories/*.json.jbuilder`, dashboard `api/helpCenter/categories.js` | Category APIs now resolve portal slugs account-scoped for `/portals/:portal_id/categories`, accept raw category bodies plus wrappers, expose frontend no-trailing-slash and `PATCH` routes, return Chatwoot `{ payload, meta }` and `{ payload: category }` serializers with locale filtering and article counts, make delete/reorder empty `200 OK`, accept Chatwoot `positions_hash`, and keep category mutations scoped to the resolved portal. | Review by `feat(help-center): align category payloads`; focused category handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Article APIs now resolve portal slugs account-scoped with numeric fallback, accept raw article bodies plus wrappers, expose no-trailing-slash index/create plus frontend `PATCH`, return Chatwoot `{ payload, meta }` list/search payloads and `{ payload: article }` show/create/update/edit payloads, serialize category/author/views/Unix updated-at/meta/associated articles, make delete/reorder/bulk actions empty `200 OK`, accept Chatwoot `positions_hash`, scope mutations to the resolved portal, and implement frontend bulk update-status/update-category/delete routes. | Review by `feat(help-center): align article payloads`; focused article handler tests, service/repository article tests, router test, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.7d enterprise article bulk translation | `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, `internal/app/bootstrap.go`, article handler/service tests | `reference/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb`, `enterprise/app/jobs/captain/articles/translate_job.rb`, `enterprise/app/services/captain/llm/article_translation_service.rb`, dashboard `api/helpCenter/articles.js`, `BulkTranslateDialog.vue` | Article bulk translate now validates `captain_tasks`, portal allowed locales, optional target-locale category, and selected articles; detects existing root-article translations and returns `409 { duplicate_articles }` unless `force` is true; queues durable low-priority `captain:article_translate` jobs; maps locale codes to English language names; and creates or updates draft translated articles through the wired LLM translation backend using Chatwoot's title/content prompts. | Review by `feat(help-center): queue article translations`; focused service/handler tests, combined service/handler/repository/app tests, full `go test ./...`, and `git diff --check` passed. No route changes. |
|
||||
| P3.9 account agent-bot API parity | `internal/router/router.go`, `internal/handler/api/v1/agent_bot_handler.go`, `internal/service/agent_bot_service.go`, `internal/repository/agent_bot_repo.go`, agent bot handler tests | `reference/chatwoot/config/routes.rb:94-97`, `reference/chatwoot/app/controllers/api/v1/accounts/agent_bots_controller.rb`, `app/views/api/v1/accounts/agent_bots/*.json.jbuilder`, `app/views/api/v1/models/_agent_bot.json.jbuilder`, `app/models/agent_bot.rb`, `app/presenters/agent_bot_presenter.rb`, dashboard `api/agentBots.js`, `store/modules/agentBots.js`, settings `agentBots` route/components | Account agent-bot APIs now expose frontend no-trailing-slash GET/POST and PATCH update routes, track the full Chatwoot account agent-bot route set, return raw Jbuilder-style arrays/objects with `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accept JSON or multipart form bodies, preserve explicit empty update fields, scope create/update/delete/reset/avatar mutations to current-account bots, allow show/index of system bots through `accessible_to`, return empty `200 OK` delete, and return full bot payloads after reset-access-token/reset-secret/avatar deletion. | Review by `feat(agent-bots): align account payloads`; focused AgentBot handler tests, `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'AgentBot\|Router' -count=1`, route dump/parity regenerated to `TOTAL: 864` and `308 exact, 7 parameter-compatible, 0 missing out of 315`, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P6.8 contact outbound voice call parity | `internal/router/router.go`, `internal/handler/api/v1/contact_handler.go`, `internal/service/contact_service.go`, `internal/model/call.go`, contact handler tests | `reference/chatwoot/config/routes.rb:216`, `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb`, `reference/chatwoot/enterprise/app/services/voice/outbound_call_builder.rb`, `reference/chatwoot/enterprise/app/services/voice/call_message_builder.rb`, `reference/chatwoot/enterprise/app/models/call.rb`, dashboard `api/contacts.js`, `store/modules/contacts/actions.js`, `api/channel/voice/voiceAPIClient.js` | Contact outbound calls now match the Chatwoot enterprise route boundary: account contact lookup, current user's assigned `Channel::TwilioSms` inbox lookup, `voice_enabled` guard, phone-number guard, open display-ID conversation reuse only for same inbox/contact, new ContactInbox/open conversation creation when needed, persisted outgoing Twilio call metadata, linked `voice_call` message content attributes, and raw `{ conversation_id, inbox_id, call_sid, conference_sid }` response. | Review by `feat(contacts): initiate voice calls`; focused contact call tests cover success/reuse/resolved-hint ignored/no-phone/non-voice/unassigned cases; route dump/parity regenerated to `TOTAL: 861` and `299 exact, 7 parameter-compatible, 0 missing out of 306`; full `go test ./...` and `git diff --check` passed. |
|
||||
| P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P6 conversation custom attributes response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, `app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder`, dashboard `api/inbox/conversation.js`, conversation store action | Account conversation custom attribute updates now return Chatwoot `{ custom_attributes: ... }` only, matching the store action that reads `response.data.custom_attributes`, instead of returning the full conversation serializer with unrelated fields. Empty/null JSON serializes as `{}`. | Review by `feat(conversations): align custom attribute response`; focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed. |
|
||||
@@ -189,6 +191,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(agent-bots): align account payloads` | Advances P3.9 account agent-bot API parity by matching Chatwoot `AgentBotsController`, account agent-bot Jbuilder views, `_agent_bot.json.jbuilder`, `AgentBotPresenter`, and the reused dashboard `AgentBotsAPI`/`agentBots` store path. GoChat now registers frontend no-trailing-slash `GET/POST /api/v1/accounts/:account_id/agent_bots` plus `PATCH /agent_bots/:id`, tracks `routes.rb:94-97`, returns raw bot arrays/objects with `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accepts JSON and multipart form bodies, preserves explicit empty update values, scopes mutation/reset/avatar actions to current-account bots while allowing index/show of system bots, returns empty `200 OK` delete, and returns full bot payloads for reset/avatar actions. | `go test ./internal/handler/api/v1 -run AgentBot -count=1`; `go test ./internal/service -run AgentBot -count=1`; `go test ./internal/router -run Router -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'AgentBot\|Router' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./cmd/route_parity -count=1`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 864`; tracked route parity is `308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315`. | P3.9 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(contacts): initiate voice calls` | Advances P6.8 contact outbound voice call parity by matching Chatwoot enterprise `Contacts::CallsController#create`, `Voice::OutboundCallBuilder`, `Voice::CallMessageBuilder`, `Call`, and the reused dashboard `ContactAPI.initiateCall`/voice client path. GoChat now registers `POST /api/v1/accounts/:account_id/contacts/:contact_id/call`, requires an assigned `Channel::TwilioSms` inbox with `voice_enabled`, requires a contact phone number, reuses only matching open display-ID conversations, creates ContactInbox/open conversation records when needed, persists Twilio outgoing call metadata and a linked `voice_call` message payload, and returns `{ conversation_id, inbox_id, call_sid, conference_sid }`. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestInitiateCall' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/router -run 'Contact\|Router' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./cmd/route_parity -count=1`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 861`; tracked route parity is `299 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 306`. | P6.8 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `docs: land parity execution tracker` | Documentation-only checkpoint that turns the Hermes-era search and automation/macro/CSAT notes, user-confirmed enterprise scope, latest help-center baseline, and fresh contact voice-call drift into one executable queue. Adds lifecycle rules, ordered next slices, explicit P6.8 contact outbound voice-call contract, and updates the active board so future work can be tracked without reopening `.hermes/plans/*`. | `git diff --check`. No Go code changed and no route artifacts changed. | Start P6.8 contact outbound voice-call parity, or run the next Phase 2/3 plus Phase 6 evidence audit before implementation. |
|
||||
| `feat(help-center): queue article translations` | Advances P3.7d enterprise help-center parity by matching Chatwoot enterprise `Articles::BulkActionsController#translate`, `Captain::Articles::TranslateJob`, `Captain::Llm::ArticleTranslationService`, and the reused dashboard `bulkTranslate` flow. Bulk translate now enforces `captain_tasks`, portal allowed locales, optional locale-matched categories, and article selection; returns `409 { duplicate_articles }` for existing translations unless `force` is true; enqueues low-priority durable `captain:article_translate` jobs; registers replay in bootstrap; and uses a wired LLM-backed article translator with Chatwoot title/content prompts and locale-to-English target language names before creating or updating draft translated articles. | `go test ./internal/service -run 'ArticleService_.*Translate\|LLMArticleTranslationBackend\|ArticleService_BulkActions\|ArticleService_BulkUpdateStatus\|ArticleService_BulkDelete' -count=1`; `go test ./internal/handler/api/v1 -run 'ArticleHandlerSuite/TestBulkTranslate\|ArticleHandlerSuite/TestBulkActions' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/repository ./internal/app -run 'Article\|Bootstrap\|App' -count=1`; `go test ./...`; `git diff --check`. No route changes. | P3.7d moves to Review; continue Phase 6 placeholder burn-down or the next evidence-backed Phase 2/3 drift slice. |
|
||||
@@ -2147,6 +2150,7 @@ Verification milestone gates:
|
||||
|
||||
## Progress Log
|
||||
|
||||
- 2026-06-06: P3.9 account agent-bot API checkpoint prepared as `feat(agent-bots): align account payloads`; audited Chatwoot `routes.rb:94-97`, `AgentBotsController`, account agent-bot Jbuilder views, `_agent_bot.json.jbuilder`, `AgentBotPresenter`, `AgentBot`, and reused dashboard `api/agentBots.js`, `store/modules/agentBots.js`, and settings agent-bot screens. Account agent-bot APIs now expose frontend no-trailing-slash GET/POST and PATCH update routes, return raw Chatwoot bot arrays/objects instead of local success/data/meta envelopes, serialize `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accept JSON or multipart form bodies, preserve explicit empty update fields, scope create/update/delete/reset/avatar mutations to current-account bots, allow show/index of system bots through `accessible_to`, return empty `200 OK` delete, and return full bot payloads after reset-access-token/reset-secret/avatar deletion. Focused AgentBot handler/service/router tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 864` and tracked route parity is `308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315`.
|
||||
- 2026-06-06: P6.8 contact outbound voice call checkpoint prepared as `feat(contacts): initiate voice calls`; audited Chatwoot `routes.rb:216`, enterprise `Contacts::CallsController#create`, `Voice::OutboundCallBuilder`, `Voice::CallMessageBuilder`, `Call`, and reused dashboard `api/contacts.js`, contact store action, and voice API client. GoChat now registers `POST /api/v1/accounts/:account_id/contacts/:contact_id/call`, validates account contact scope, current user's assigned Twilio voice inbox, `voice_enabled`, and contact phone number, reuses only matching open display-ID conversations, creates ContactInbox/open conversation rows when needed, persists outgoing Twilio call metadata and linked `voice_call` message content attributes, and returns `{ conversation_id, inbox_id, call_sid, conference_sid }`. Focused contact-call tests, combined service/handler/router tests, route dump/parity regeneration, `go test ./cmd/route_parity -count=1`, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 861` and tracked route parity is `299 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 306`.
|
||||
- 2026-06-06: Documentation checkpoint prepared as `docs: land parity execution tracker`; consolidated the active execution queue so future work can be followed from `docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md` alone. The tracker now pins current baseline commits, maps Hermes source plans to active sections, defines slice lifecycle states, records the ordered next queue, and opens P6.8 for Chatwoot enterprise contact outbound voice calls after inspecting `reference/chatwoot/config/routes.rb:216`, `enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb`, `enterprise/app/services/voice/outbound_call_builder.rb`, and reused dashboard contact/voice API clients. Verification for this docs-only checkpoint: `git diff --check`; no Go code or route artifacts changed.
|
||||
- 2026-06-06: P3.7d enterprise article translation checkpoint prepared as `feat(help-center): queue article translations`; audited Chatwoot enterprise `Articles::BulkActionsController#translate`, `Captain::Articles::TranslateJob`, `Captain::Llm::ArticleTranslationService`, reused dashboard `api/helpCenter/articles.js`, and `BulkTranslateDialog.vue`. Account article bulk translate now validates `captain_tasks`, allowed portal locale, optional locale-scoped category, and selected articles; detects existing root-article translations and returns `409 { duplicate_articles }` unless `force` is true; queues durable low-priority `captain:article_translate` jobs; replays jobs by creating or updating draft translated articles; and wires an LLM-backed translation backend in bootstrap with Chatwoot title/content prompts and English target language names. Focused service/handler tests, combined service/handler/repository/app tests, full `go test ./...`, and `git diff --check` passed; no route artifacts changed.
|
||||
|
||||
@@ -118,6 +118,7 @@ GET /api/v1/accounts/
|
||||
GET /api/v1/accounts/:account_id
|
||||
GET /api/v1/accounts/:account_id/agent_bot_inboxes/
|
||||
GET /api/v1/accounts/:account_id/agent_bot_inboxes/by_bot
|
||||
GET /api/v1/accounts/:account_id/agent_bots
|
||||
GET /api/v1/accounts/:account_id/agent_bots/
|
||||
GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id
|
||||
GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/
|
||||
@@ -452,6 +453,7 @@ GET /widget/widget/:website_token/theme_config
|
||||
GET /widget/widget/:website_token/uploads/:upload_uuid
|
||||
GET /ws
|
||||
PATCH /api/v1/accounts/:account_id/agent_bot_inboxes/:agent_bot_inbox_id/status
|
||||
PATCH /api/v1/accounts/:account_id/agent_bots/:agent_bot_id
|
||||
PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id
|
||||
PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id
|
||||
PATCH /api/v1/accounts/:account_id/agents/:agent_id
|
||||
@@ -505,6 +507,7 @@ PATCH /widget/contact
|
||||
POST /api/v1/accounts/
|
||||
POST /api/v1/accounts/:account_id/actions/contact_merge
|
||||
POST /api/v1/accounts/:account_id/agent_bot_inboxes/
|
||||
POST /api/v1/accounts/:account_id/agent_bots
|
||||
POST /api/v1/accounts/:account_id/agent_bots/
|
||||
POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/
|
||||
POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/delete_avatar
|
||||
@@ -859,4 +862,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 861
|
||||
TOTAL: 864
|
||||
|
||||
@@ -7,7 +7,7 @@ Generated from:
|
||||
|
||||
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
|
||||
|
||||
Summary: 299 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 306 tracked critical routes.
|
||||
Summary: 308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315 tracked critical routes.
|
||||
|
||||
## Missing Critical Routes
|
||||
|
||||
@@ -41,6 +41,8 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
|
||||
| Method | Chatwoot Path | GoChat Match | Controller | Source | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| DELETE | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#destroy` | `routes.rb:94` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/avatar` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/avatar` | `api/v1/accounts/agent_bots#avatar` | `routes.rb:95` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#destroy` | `routes.rb:123` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/agents/:agent_id` | `/api/v1/accounts/:account_id/agents/:agent_id` | `api/v1/accounts/agents#destroy` | `routes.rb:59` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `api/v1/accounts/assignment_policies#destroy` | `routes.rb:306` | exact |
|
||||
@@ -79,6 +81,8 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| DELETE | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#destroy` | `routes.rb:299` | exact |
|
||||
| DELETE | `/api/v1/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id` | `/api/v1/accounts/:account_id` | `api/v1/accounts#show` | `routes.rb:47` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/agent_bots` | `/api/v1/accounts/:account_id/agent_bots` | `api/v1/accounts/agent_bots#index` | `routes.rb:94` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#show` | `routes.rb:94` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `api/v1/accounts/agent_capacity_policies#index` | `routes.rb:123` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#show` | `routes.rb:123` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/agents` | `/api/v1/accounts/:account_id/agents` | `api/v1/accounts/agents#index` | `routes.rb:59` | exact |
|
||||
@@ -202,6 +206,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `public/api/v1/inboxes/conversations#index` | `routes.rb:573` | exact |
|
||||
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `public/api/v1/inboxes/conversations#show` | `routes.rb:573` | exact |
|
||||
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `public/api/v1/inboxes/messages#index` | `routes.rb:580` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/portals/:portal_id` | `/api/v1/accounts/:account_id/portals/:portal_id` | `api/v1/accounts/portals#update` | `routes.rb:385` | exact |
|
||||
@@ -214,6 +219,9 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| PATCH | `/api/v1/widget/contact/set_user` | `/api/v1/widget/contact/set_user` | `api/v1/widget/contact#set_user` | `routes.rb:461` | exact |
|
||||
| POST | `/api/v1/accounts/` | `/api/v1/accounts/` | `api/v1/accounts#create` | `routes.rb:47` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/actions/contact_merge` | `/api/v1/accounts/:account_id/actions/contact_merge` | `api/v1/accounts/actions/contact_merges#create` | `routes.rb:55` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/agent_bots` | `/api/v1/accounts/:account_id/agent_bots` | `api/v1/accounts/agent_bots#create` | `routes.rb:94` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_access_token` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_access_token` | `api/v1/accounts/agent_bots#reset_access_token` | `routes.rb:96` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_secret` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_secret` | `api/v1/accounts/agent_bots#reset_secret` | `routes.rb:97` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `api/v1/accounts/agent_capacity_policies#create` | `routes.rb:123` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/agents` | `/api/v1/accounts/:account_id/agents` | `api/v1/accounts/agents#create` | `routes.rb:59` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/agents/bulk_create` | `/api/v1/accounts/:account_id/agents/bulk_create` | `api/v1/accounts/agents#bulk_create` | `routes.rb:60` | exact |
|
||||
@@ -309,6 +317,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `public/api/v1/inboxes/conversations#toggle_typing` | `routes.rb:576` | exact |
|
||||
| POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `public/api/v1/inboxes/conversations#update_last_seen` | `routes.rb:577` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id` | `/api/v1/accounts/:account_id` | `api/v1/accounts#update` | `routes.rb:47` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/agents/:agent_id` | `/api/v1/accounts/:account_id/agents/:agent_id` | `api/v1/accounts/agents#update` | `routes.rb:59` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `api/v1/accounts/assignment_policies#update` | `routes.rb:306` | exact |
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
@@ -33,32 +34,41 @@ func (h *AgentBotHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
page := pagination.Parse(c)
|
||||
bots, total, svcErr := h.svc.ListAccessible(c.Request.Context(), accountID, page.Offset, page.PerPage)
|
||||
bots, svcErr := h.svc.ListAccessibleAll(c.Request.Context(), accountID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, bots, page.Page, page.PerPage, total)
|
||||
payload := make([]gin.H, 0, len(bots))
|
||||
for i := range bots {
|
||||
payload = append(payload, serializeAccountAgentBot(&bots[i], accountID))
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// Get retrieves a single agent bot by ID.
|
||||
// GET /api/v1/accounts/:account_id/agent_bots/:id
|
||||
func (h *AgentBotHandler) Get(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
agentBotID, err := parseUintParam(c, "agent_bot_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent bot ID")
|
||||
return
|
||||
}
|
||||
|
||||
bot, svcErr := h.svc.Get(c.Request.Context(), agentBotID)
|
||||
bot, svcErr := h.svc.GetAccessible(c.Request.Context(), accountID, agentBotID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, bot)
|
||||
c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID))
|
||||
}
|
||||
|
||||
// Create creates a new agent bot scoped to an account.
|
||||
@@ -71,7 +81,7 @@ func (h *AgentBotHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req service.CreateAgentBotRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -85,12 +95,18 @@ func (h *AgentBotHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, bot)
|
||||
c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID))
|
||||
}
|
||||
|
||||
// Update modifies an existing agent bot.
|
||||
// PUT /api/v1/accounts/:account_id/agent_bots/:id
|
||||
func (h *AgentBotHandler) Update(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
agentBotID, err := parseUintParam(c, "agent_bot_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent bot ID")
|
||||
@@ -98,96 +114,143 @@ func (h *AgentBotHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req service.UpdateAgentBotRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bot, svcErr := h.svc.Update(c.Request.Context(), agentBotID, req)
|
||||
bot, svcErr := h.svc.UpdateByAccount(c.Request.Context(), accountID, agentBotID, req)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, bot)
|
||||
c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID))
|
||||
}
|
||||
|
||||
// Delete removes an agent bot by ID.
|
||||
// DELETE /api/v1/accounts/:account_id/agent_bots/:id
|
||||
func (h *AgentBotHandler) Delete(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
agentBotID, err := parseUintParam(c, "agent_bot_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent bot ID")
|
||||
return
|
||||
}
|
||||
|
||||
if svcErr := h.svc.Delete(c.Request.Context(), agentBotID); svcErr != nil {
|
||||
if svcErr := h.svc.DeleteByAccount(c.Request.Context(), accountID, agentBotID); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// ResetToken generates a new access token for the bot.
|
||||
// POST /api/v1/accounts/:account_id/agent_bots/:id/reset_token
|
||||
// Reference: Chatwoot AgentBotsController#reset_access_token
|
||||
func (h *AgentBotHandler) ResetToken(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
agentBotID, err := parseUintParam(c, "agent_bot_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent bot ID")
|
||||
return
|
||||
}
|
||||
|
||||
newToken, svcErr := h.svc.ResetToken(c.Request.Context(), agentBotID)
|
||||
bot, svcErr := h.svc.ResetTokenByAccount(c.Request.Context(), accountID, agentBotID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"access_token": newToken,
|
||||
})
|
||||
c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID))
|
||||
}
|
||||
|
||||
// ResetSecret generates a new webhook signing secret for the bot.
|
||||
// POST /api/v1/accounts/:account_id/agent_bots/:id/reset_secret
|
||||
// Reference: Chatwoot AgentBotsController#reset_secret
|
||||
func (h *AgentBotHandler) ResetSecret(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
agentBotID, err := parseUintParam(c, "agent_bot_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent bot ID")
|
||||
return
|
||||
}
|
||||
|
||||
newSecret, svcErr := h.svc.ResetSecret(c.Request.Context(), agentBotID)
|
||||
bot, svcErr := h.svc.ResetSecretByAccount(c.Request.Context(), accountID, agentBotID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"secret": newSecret,
|
||||
})
|
||||
c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID))
|
||||
}
|
||||
|
||||
// DeleteAvatar removes the bot's avatar URL.
|
||||
// POST /api/v1/accounts/:account_id/agent_bots/:id/delete_avatar
|
||||
// Reference: Chatwoot AgentBotsController#destroy_avatar
|
||||
func (h *AgentBotHandler) DeleteAvatar(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
agentBotID, err := parseUintParam(c, "agent_bot_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent bot ID")
|
||||
return
|
||||
}
|
||||
|
||||
bot, svcErr := h.svc.DeleteAvatar(c.Request.Context(), agentBotID)
|
||||
bot, svcErr := h.svc.DeleteAvatarByAccount(c.Request.Context(), accountID, agentBotID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, bot)
|
||||
c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID))
|
||||
}
|
||||
|
||||
func serializeAccountAgentBot(bot *model.AgentBot, accountID uint) gin.H {
|
||||
if bot == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
systemBot := bot.AccountID == nil
|
||||
payload := gin.H{
|
||||
"id": bot.ID,
|
||||
"name": bot.Name,
|
||||
"description": bot.Description,
|
||||
"thumbnail": bot.AvatarURL,
|
||||
"bot_type": bot.BotType,
|
||||
"bot_config": bot.Config,
|
||||
"account_id": bot.AccountID,
|
||||
"system_bot": systemBot,
|
||||
}
|
||||
if !systemBot {
|
||||
payload["outgoing_url"] = bot.OutgoingURL
|
||||
}
|
||||
if bot.AccountID != nil && *bot.AccountID == accountID && bot.AccessToken != "" {
|
||||
payload["access_token"] = bot.AccessToken
|
||||
}
|
||||
if bot.AccountID != nil && *bot.AccountID == accountID && bot.Secret != "" {
|
||||
payload["secret"] = bot.Secret
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- Platform (super admin) AgentBot endpoints ---
|
||||
@@ -347,6 +410,7 @@ func (h *AgentBotHandler) PlatformResetConfig(c *gin.Context) {
|
||||
|
||||
response.OK(c, bot)
|
||||
}
|
||||
|
||||
// ListAccessible retrieves agent bots accessible to an account (global + account-scoped), paginated.
|
||||
// GET /api/v1/accounts/:account_id/agent_bots/accessible
|
||||
func (h *AgentBotHandler) ListAccessible(c *gin.Context) {
|
||||
|
||||
@@ -74,8 +74,8 @@ func (s *AgentBotHandlerTestSuite) TestPlatformCreate_Success() {
|
||||
r.POST("/platform/api/v1/agent_bots", s.handler.PlatformCreate)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "test-global-bot",
|
||||
"bot_type": "webhook",
|
||||
"name": "test-global-bot",
|
||||
"bot_type": "webhook",
|
||||
"outgoing_url": "https://example.com/webhook",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
@@ -116,8 +116,8 @@ func (s *AgentBotHandlerTestSuite) TestAccountCreate_Success() {
|
||||
r.POST("/api/v1/accounts/:account_id/agent_bots", s.handler.Create)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "test-account-bot",
|
||||
"bot_type": "webhook",
|
||||
"name": "test-account-bot",
|
||||
"bot_type": "webhook",
|
||||
"outgoing_url": "https://example.com/webhook",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
@@ -127,7 +127,101 @@ func (s *AgentBotHandlerTestSuite) TestAccountCreate_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)
|
||||
}
|
||||
|
||||
func (s *AgentBotHandlerTestSuite) TestAccountChatwootPayloadsAndRoutes() {
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/accounts/:account_id/agent_bots", s.handler.List)
|
||||
r.POST("/api/v1/accounts/:account_id/agent_bots", s.handler.Create)
|
||||
r.GET("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", s.handler.Get)
|
||||
r.PATCH("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", s.handler.Update)
|
||||
r.DELETE("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", s.handler.Delete)
|
||||
r.DELETE("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/avatar", s.handler.DeleteAvatar)
|
||||
r.POST("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_access_token", s.handler.ResetToken)
|
||||
r.POST("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_secret", s.handler.ResetSecret)
|
||||
|
||||
global := &model.AgentBot{Name: "system-bot", BotType: "webhook", OutgoingURL: "https://system.example/hook", AccessToken: "global-token", Secret: "global-secret", Config: json.RawMessage(`{"mode":"system"}`)}
|
||||
s.Require().NoError(s.db.Create(global).Error)
|
||||
|
||||
createBody := []byte(`{"name":"chatwoot-bot","description":"before","bot_type":"webhook","outgoing_url":"https://example.com/webhook","avatar_url":"https://example.com/bot.png","bot_config":{"handoff":true}}`)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_bots", s.account.ID), bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var created map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &created))
|
||||
botID := uint(created["id"].(float64))
|
||||
assert.NotContains(s.T(), created, "success")
|
||||
assert.Equal(s.T(), "chatwoot-bot", created["name"])
|
||||
assert.Equal(s.T(), "https://example.com/bot.png", created["thumbnail"])
|
||||
assert.Equal(s.T(), "https://example.com/webhook", created["outgoing_url"])
|
||||
assert.Equal(s.T(), false, created["system_bot"])
|
||||
assert.NotEmpty(s.T(), created["access_token"])
|
||||
assert.NotEmpty(s.T(), created["secret"])
|
||||
assert.Contains(s.T(), created, "bot_config")
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/agent_bots", s.account.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var list []map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &list))
|
||||
assert.GreaterOrEqual(s.T(), len(list), 2)
|
||||
assert.NotContains(s.T(), list[0], "success")
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
updateBody := []byte(`{"description":"","outgoing_url":"","bot_config":{"handoff":false}}`)
|
||||
req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/agent_bots/%d", s.account.ID, botID), bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var updated map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updated))
|
||||
assert.Equal(s.T(), "", updated["description"])
|
||||
assert.Equal(s.T(), "", updated["outgoing_url"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/agent_bots/%d", s.account.ID, global.ID), bytes.NewReader([]byte(`{"name":"blocked"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
||||
|
||||
oldToken := created["access_token"]
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_bots/%d/reset_access_token", s.account.ID, botID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resetToken map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resetToken))
|
||||
assert.Equal(s.T(), float64(botID), resetToken["id"])
|
||||
assert.NotEqual(s.T(), oldToken, resetToken["access_token"])
|
||||
assert.NotContains(s.T(), resetToken, "data")
|
||||
|
||||
oldSecret := resetToken["secret"]
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_bots/%d/reset_secret", s.account.ID, botID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resetSecret map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resetSecret))
|
||||
assert.NotEqual(s.T(), oldSecret, resetSecret["secret"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/agent_bots/%d/avatar", s.account.ID, botID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var avatar map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &avatar))
|
||||
assert.Equal(s.T(), "", avatar["thumbnail"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/agent_bots/%d", s.account.ID, botID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
}
|
||||
|
||||
func (s *AgentBotHandlerTestSuite) TestAccountCreate_BadRequest() {
|
||||
@@ -218,4 +312,4 @@ func (s *AgentBotHandlerTestSuite) TestDeleteAvatar_BadRequest() {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,32 @@ func (r *AgentBotRepo) FindByID(ctx context.Context, id uint) (*model.AgentBot,
|
||||
return &bot, nil
|
||||
}
|
||||
|
||||
// FindAccessibleByID retrieves a bot visible to the given account. Chatwoot
|
||||
// allows show/index to include system bots and current-account bots.
|
||||
func (r *AgentBotRepo) FindAccessibleByID(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) {
|
||||
var bot model.AgentBot
|
||||
err := r.db.WithContext(ctx).Preload("Inboxes").
|
||||
Where("id = ? AND (account_id IS NULL OR account_id = ?)", id, accountID).
|
||||
First(&bot).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bot, nil
|
||||
}
|
||||
|
||||
// FindByAccountAndID retrieves a bot owned by the account. Chatwoot mutation
|
||||
// actions use Current.account.agent_bots and must not mutate global/cross-account bots.
|
||||
func (r *AgentBotRepo) FindByAccountAndID(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) {
|
||||
var bot model.AgentBot
|
||||
err := r.db.WithContext(ctx).Preload("Inboxes").
|
||||
Where("id = ? AND account_id = ?", id, accountID).
|
||||
First(&bot).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bot, nil
|
||||
}
|
||||
|
||||
// FindByAccount retrieves all agent_bots for an account with pagination.
|
||||
func (r *AgentBotRepo) FindByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.AgentBot, int64, error) {
|
||||
var bots []model.AgentBot
|
||||
@@ -83,6 +109,13 @@ func (r *AgentBotRepo) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.AgentBot{}, id).Error
|
||||
}
|
||||
|
||||
// DeleteByAccount deletes only an account-owned bot.
|
||||
func (r *AgentBotRepo) DeleteByAccount(ctx context.Context, accountID uint, id uint) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("account_id = ?", accountID).
|
||||
Delete(&model.AgentBot{}, id).Error
|
||||
}
|
||||
|
||||
// FindAccessible retrieves agent_bots accessible to an account (account_id = nil OR account_id = given).
|
||||
// Reference: Chatwoot AgentBot.scope :accessible_to(account) — includes global bots + account bots.
|
||||
func (r *AgentBotRepo) FindAccessible(ctx context.Context, accountID uint) ([]model.AgentBot, error) {
|
||||
@@ -107,4 +140,4 @@ func (r *AgentBotRepo) FindAccessiblePaginated(ctx context.Context, accountID ui
|
||||
err := q.Offset(offset).Limit(limit).
|
||||
Order("id ASC").Find(&bots).Error
|
||||
return bots, total, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,10 +1104,13 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Reference: Chatwoot namespace :agent_bots under :account
|
||||
agentBots := accountScoped.Group("/agent_bots")
|
||||
{
|
||||
agentBots.GET("", h.AgentBot.List)
|
||||
agentBots.GET("/", h.AgentBot.List)
|
||||
agentBots.POST("", h.AgentBot.Create)
|
||||
agentBots.POST("/", h.AgentBot.Create)
|
||||
agentBots.GET("/:agent_bot_id", h.AgentBot.Get)
|
||||
agentBots.PUT("/:agent_bot_id", h.AgentBot.Update)
|
||||
agentBots.PATCH("/:agent_bot_id", h.AgentBot.Update)
|
||||
agentBots.DELETE("/:agent_bot_id", h.AgentBot.Delete)
|
||||
agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken)
|
||||
agentBots.POST("/:agent_bot_id/reset_secret", h.AgentBot.ResetSecret)
|
||||
|
||||
@@ -27,23 +27,25 @@ func NewAgentBotService(repo *repository.AgentBotRepo) *AgentBotService {
|
||||
|
||||
// CreateAgentBotRequest is the DTO for creating an agent bot.
|
||||
type CreateAgentBotRequest struct {
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" validate:"omitempty,url"`
|
||||
OutgoingURL string `json:"outgoing_url,omitempty" validate:"omitempty,url"`
|
||||
BotType string `json:"bot_type,omitempty" validate:"omitempty,oneof=webhook default custom"`
|
||||
Name string `json:"name" form:"name" validate:"required,min=2"`
|
||||
Description string `json:"description,omitempty" form:"description"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" form:"avatar_url"`
|
||||
OutgoingURL string `json:"outgoing_url,omitempty" form:"outgoing_url"`
|
||||
BotType string `json:"bot_type,omitempty" form:"bot_type" validate:"omitempty,oneof=webhook default custom"`
|
||||
AccountID *uint `json:"account_id,omitempty"` // nil = platform-level (global) bot
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
BotConfig json.RawMessage `json:"bot_config,omitempty" form:"bot_config"`
|
||||
}
|
||||
|
||||
// UpdateAgentBotRequest is the DTO for updating an agent bot.
|
||||
type UpdateAgentBotRequest struct {
|
||||
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" validate:"omitempty,url"`
|
||||
OutgoingURL string `json:"outgoing_url,omitempty" validate:"omitempty,url"`
|
||||
BotType string `json:"bot_type,omitempty" validate:"omitempty,oneof=webhook default custom"`
|
||||
Name *string `json:"name,omitempty" form:"name" validate:"omitempty,min=2"`
|
||||
Description *string `json:"description,omitempty" form:"description"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty" form:"avatar_url"`
|
||||
OutgoingURL *string `json:"outgoing_url,omitempty" form:"outgoing_url"`
|
||||
BotType *string `json:"bot_type,omitempty" form:"bot_type" validate:"omitempty,oneof=webhook default custom"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
BotConfig json.RawMessage `json:"bot_config,omitempty" form:"bot_config"`
|
||||
}
|
||||
|
||||
// generateBotAccessToken creates a cryptographically secure random token for bot access.
|
||||
@@ -70,6 +72,9 @@ func (s *AgentBotService) Create(ctx context.Context, req CreateAgentBotRequest)
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Config == nil && req.BotConfig != nil {
|
||||
req.Config = req.BotConfig
|
||||
}
|
||||
|
||||
token, err := generateBotAccessToken()
|
||||
if err != nil {
|
||||
@@ -115,6 +120,16 @@ func (s *AgentBotService) Get(ctx context.Context, id uint) (*model.AgentBot, er
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// GetAccessible retrieves a system or account-owned bot visible to the account.
|
||||
func (s *AgentBotService) GetAccessible(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) {
|
||||
bot, err := s.repo.FindAccessibleByID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get accessible agent bot %d account %d: %v", id, accountID, err)
|
||||
return nil, fmt.Errorf("agent bot not found: %w", err)
|
||||
}
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// ListAccessible retrieves agent bots accessible to an account (global + account-scoped).
|
||||
// Reference: Chatwoot AgentBot.scope :accessible_to(account)
|
||||
func (s *AgentBotService) ListAccessible(ctx context.Context, accountID uint, offset, limit int) ([]model.AgentBot, int64, error) {
|
||||
@@ -126,6 +141,17 @@ func (s *AgentBotService) ListAccessible(ctx context.Context, accountID uint, of
|
||||
return bots, total, nil
|
||||
}
|
||||
|
||||
// ListAccessibleAll retrieves all system and account-owned bots visible to the
|
||||
// account. Chatwoot AgentBotsController#index does not paginate this list.
|
||||
func (s *AgentBotService) ListAccessibleAll(ctx context.Context, accountID uint) ([]model.AgentBot, error) {
|
||||
bots, err := s.repo.FindAccessible(ctx, accountID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("ListAccessibleAll agent bots account %d: %v", accountID, err)
|
||||
return nil, err
|
||||
}
|
||||
return bots, nil
|
||||
}
|
||||
|
||||
// List retrieves all agent bots for an account (account-scoped only).
|
||||
func (s *AgentBotService) List(ctx context.Context, accountID uint) ([]model.AgentBot, error) {
|
||||
bots, err := s.repo.FindByAccountID(ctx, accountID)
|
||||
@@ -138,31 +164,48 @@ func (s *AgentBotService) List(ctx context.Context, accountID uint) ([]model.Age
|
||||
|
||||
// Update modifies an existing AgentBot.
|
||||
func (s *AgentBotService) Update(ctx context.Context, id uint, req UpdateAgentBotRequest) (*model.AgentBot, error) {
|
||||
return s.update(ctx, nil, id, req)
|
||||
}
|
||||
|
||||
// UpdateByAccount modifies an account-owned agent bot.
|
||||
func (s *AgentBotService) UpdateByAccount(ctx context.Context, accountID uint, id uint, req UpdateAgentBotRequest) (*model.AgentBot, error) {
|
||||
return s.update(ctx, &accountID, id, req)
|
||||
}
|
||||
|
||||
func (s *AgentBotService) update(ctx context.Context, accountID *uint, id uint, req UpdateAgentBotRequest) (*model.AgentBot, error) {
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bot, err := s.repo.GetByID(ctx, id)
|
||||
var bot *model.AgentBot
|
||||
var err error
|
||||
if accountID != nil {
|
||||
bot, err = s.repo.FindByAccountAndID(ctx, *accountID, id)
|
||||
} else {
|
||||
bot, err = s.repo.GetByID(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Update agent bot %d: get failed: %v", id, err)
|
||||
return nil, fmt.Errorf("agent bot not found: %w", err)
|
||||
}
|
||||
if req.Config == nil && req.BotConfig != nil {
|
||||
req.Config = req.BotConfig
|
||||
}
|
||||
|
||||
// Apply partial updates — only non-zero/non-nil fields
|
||||
if req.Name != "" {
|
||||
bot.Name = req.Name
|
||||
if req.Name != nil {
|
||||
bot.Name = *req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
bot.Description = req.Description
|
||||
if req.Description != nil {
|
||||
bot.Description = *req.Description
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
bot.AvatarURL = req.AvatarURL
|
||||
if req.AvatarURL != nil {
|
||||
bot.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.OutgoingURL != "" {
|
||||
bot.OutgoingURL = req.OutgoingURL
|
||||
if req.OutgoingURL != nil {
|
||||
bot.OutgoingURL = *req.OutgoingURL
|
||||
}
|
||||
if req.BotType != "" {
|
||||
bot.BotType = req.BotType
|
||||
if req.BotType != nil {
|
||||
bot.BotType = *req.BotType
|
||||
}
|
||||
if req.Config != nil {
|
||||
bot.Config = req.Config
|
||||
@@ -175,6 +218,18 @@ func (s *AgentBotService) Update(ctx context.Context, id uint, req UpdateAgentBo
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// DeleteByAccount removes an account-owned AgentBot by ID.
|
||||
func (s *AgentBotService) DeleteByAccount(ctx context.Context, accountID uint, id uint) error {
|
||||
if _, err := s.repo.FindByAccountAndID(ctx, accountID, id); err != nil {
|
||||
return fmt.Errorf("agent bot not found: %w", err)
|
||||
}
|
||||
if err := s.repo.DeleteByAccount(ctx, accountID, id); err != nil {
|
||||
applogger.L().Errorf("Delete agent bot %d account %d: %v", id, accountID, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an AgentBot by ID.
|
||||
func (s *AgentBotService) Delete(ctx context.Context, id uint) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
@@ -189,53 +244,98 @@ func (s *AgentBotService) Delete(ctx context.Context, id uint) error {
|
||||
// previous one. Returns the new token string.
|
||||
// Reference: Chatwoot reset_access_token action on agent_bots_controller
|
||||
func (s *AgentBotService) ResetToken(ctx context.Context, id uint) (string, error) {
|
||||
bot, err := s.repo.GetByID(ctx, id)
|
||||
bot, err := s.ResetTokenByAccount(ctx, 0, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return bot.AccessToken, nil
|
||||
}
|
||||
|
||||
// ResetTokenByAccount regenerates the access token on an account-owned bot.
|
||||
func (s *AgentBotService) ResetTokenByAccount(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) {
|
||||
var bot *model.AgentBot
|
||||
var err error
|
||||
if accountID == 0 {
|
||||
bot, err = s.repo.GetByID(ctx, id)
|
||||
} else {
|
||||
bot, err = s.repo.FindByAccountAndID(ctx, accountID, id)
|
||||
}
|
||||
if err != nil {
|
||||
applogger.L().Errorf("ResetToken agent bot %d: get failed: %v", id, err)
|
||||
return "", fmt.Errorf("agent bot not found: %w", err)
|
||||
return nil, fmt.Errorf("agent bot not found: %w", err)
|
||||
}
|
||||
|
||||
newToken, err := generateBotAccessToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate new access token: %w", err)
|
||||
return nil, fmt.Errorf("failed to generate new access token: %w", err)
|
||||
}
|
||||
|
||||
bot.AccessToken = newToken
|
||||
if err := s.repo.Update(ctx, bot); err != nil {
|
||||
applogger.L().Errorf("ResetToken agent bot %d: update failed: %v", id, err)
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
applogger.L().Infof("Reset access token for agent bot %d", id)
|
||||
return newToken, nil
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// ResetSecret generates a new random webhook signing secret for the bot.
|
||||
// Reference: Chatwoot AgentBotsController#reset_secret — regenerates the secret used for HMAC signing
|
||||
func (s *AgentBotService) ResetSecret(ctx context.Context, id uint) (string, error) {
|
||||
bot, err := s.repo.GetByID(ctx, id)
|
||||
bot, err := s.ResetSecretByAccount(ctx, 0, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return bot.Secret, nil
|
||||
}
|
||||
|
||||
// ResetSecretByAccount regenerates the webhook secret on an account-owned bot.
|
||||
func (s *AgentBotService) ResetSecretByAccount(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) {
|
||||
var bot *model.AgentBot
|
||||
var err error
|
||||
if accountID == 0 {
|
||||
bot, err = s.repo.GetByID(ctx, id)
|
||||
} else {
|
||||
bot, err = s.repo.FindByAccountAndID(ctx, accountID, id)
|
||||
}
|
||||
if err != nil {
|
||||
applogger.L().Errorf("ResetSecret agent bot %d: get failed: %v", id, err)
|
||||
return "", fmt.Errorf("agent bot not found: %w", err)
|
||||
return nil, fmt.Errorf("agent bot not found: %w", err)
|
||||
}
|
||||
|
||||
newSecret, err := generateBotSecret()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate new secret: %w", err)
|
||||
return nil, fmt.Errorf("failed to generate new secret: %w", err)
|
||||
}
|
||||
|
||||
bot.Secret = newSecret
|
||||
if err := s.repo.Update(ctx, bot); err != nil {
|
||||
applogger.L().Errorf("ResetSecret agent bot %d: update failed: %v", id, err)
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
applogger.L().Infof("Reset webhook secret for agent bot %d", id)
|
||||
return newSecret, nil
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// DeleteAvatar removes the bot's avatar URL (sets it to empty string).
|
||||
// Reference: Chatwoot AgentBotsController#destroy_avatar
|
||||
func (s *AgentBotService) DeleteAvatar(ctx context.Context, id uint) (*model.AgentBot, error) {
|
||||
bot, err := s.repo.GetByID(ctx, id)
|
||||
return s.deleteAvatar(ctx, nil, id)
|
||||
}
|
||||
|
||||
// DeleteAvatarByAccount removes the avatar from an account-owned bot.
|
||||
func (s *AgentBotService) DeleteAvatarByAccount(ctx context.Context, accountID uint, id uint) (*model.AgentBot, error) {
|
||||
return s.deleteAvatar(ctx, &accountID, id)
|
||||
}
|
||||
|
||||
func (s *AgentBotService) deleteAvatar(ctx context.Context, accountID *uint, id uint) (*model.AgentBot, error) {
|
||||
var bot *model.AgentBot
|
||||
var err error
|
||||
if accountID != nil {
|
||||
bot, err = s.repo.FindByAccountAndID(ctx, *accountID, id)
|
||||
} else {
|
||||
bot, err = s.repo.GetByID(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent bot not found: %w", err)
|
||||
}
|
||||
@@ -289,4 +389,4 @@ func (s *AgentBotService) ResetConfig(ctx context.Context, id uint) (*model.Agen
|
||||
}
|
||||
|
||||
// ErrBotNotFound is returned when the bot cannot be found.
|
||||
var ErrBotNotFound = errors.New("agent bot not found")
|
||||
var ErrBotNotFound = errors.New("agent bot not found")
|
||||
|
||||
Reference in New Issue
Block a user