feat(captain): gate custom tools
This commit is contained in:
@@ -49,11 +49,11 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-07 P5.3i CSAT survey search indexing, prepared as `feat(search): index csat surveys`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): index csat surveys`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records Meilisearch reindex fan-out after CSAT survey message creation and submitted-value updates.
|
||||
- Current tracking checkpoint: 2026-06-07 P3.104 Captain custom tools feature gate, prepared as `feat(captain): gate custom tools`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): gate custom tools`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot-compatible feature gating for Captain custom tools.
|
||||
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
|
||||
- Worktree status at this implementation checkpoint: CSAT survey sends and customer submissions now synchronize Meilisearch message and conversation documents from the public service, resolved-conversation listener, durable `csat:survey_send` replay, and `message.updated` CSAT submission path. This retains P5.3h provider webhook indexing, P5.3g message delivery indexing, P5.3f automation action indexing, P5.3e conversation maintenance indexing, P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- Worktree status at this implementation checkpoint: Captain custom tool index/show/create/update/delete/test routes now enforce Chatwoot's `custom_tools` or `captain_integration_v2` account feature gate and return the raw forbidden payload when disabled. This retains P5.3i CSAT survey indexing, P5.3h provider webhook indexing, P5.3g message delivery indexing, P5.3f automation action indexing, P5.3e conversation maintenance indexing, P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
|
||||
- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full.
|
||||
- Route dump succeeds with `972` registered routes after enterprise account route tracking.
|
||||
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
|
||||
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0 | P3.104 Captain custom tools feature gate | Implemented for reused Captain custom-tool screens: `GET/POST/PUT/DELETE /api/v1/accounts/:account_id/captain/custom_tools` and `POST /captain/custom_tools/test` now enforce the inspected Chatwoot `ensure_custom_tools_enabled` gate. Accounts must have `custom_tools` or `captain_integration_v2` enabled; disabled accounts receive raw `403 { error: "Custom tools are not enabled for this account" }`. Bootstrap passes the account repository into the custom-tool service so normal app routes use persisted account feature flags. | Keep in Review; reopen from B12 Captain custom-tools smoke or fresh reference evidence for exact authorization ordering, feature-flag defaults, custom-tool limit behavior, or request execution drift beyond the inspected controller/frontend contract. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| 0 | P5.3i CSAT survey search indexing | Implemented for Meilisearch CSAT parity: `CsatSurveyService` reindexes CSAT survey messages and parent conversations after resolved-conversation survey creation and public survey submission; `CsatSurveyListener` propagates the durable search indexer into synchronous and queued `csat:survey_send` paths and reindexes message/conversation documents after `message.updated` CSAT submitted-value processing; bootstrap wires both the public CSAT handler service and event listener to the durable search indexer. | Keep in Review; reopen from live Meilisearch gate, CSAT smoke, or a fresh CSAT mutation path that changes searchable message/conversation fields without indexing. | Focused CSAT survey service/listener search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| 0 | P5.3h provider webhook search indexing | Implemented for Meilisearch provider-webhook parity: `IncomingPersister` indexes contacts, conversations, and messages after non-duplicate incoming persistence; indexes direct and durable provider message status updates; indexes every message touched by contact-wide status/read receipts; Facebook, Telegram, WhatsApp, TikTok, LINE, and Twilio webhook handlers receive the durable search indexer from bootstrap. | Keep in Review; reopen from live Meilisearch gate, provider webhook smoke, or a fresh incoming/status mutation path that changes searchable contact/conversation/message fields without indexing. | Focused webhook persister search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| 0 | P5.3g message delivery search indexing | Implemented for Meilisearch outgoing-delivery parity: durable `message:send_reply` replay updates the search document after provider success writes `status`/`source_id` and after provider failure writes `status` plus `content_attributes.external_error`; `MessageService` now propagates its search indexer into already-registered delivery workers. | Keep in Review; reopen from live Meilisearch gate, outgoing delivery smoke, or a fresh delivery/status mutation path that changes searchable message fields without indexing. | Focused message delivery worker search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
@@ -2812,3 +2813,4 @@ Verification milestone gates:
|
||||
- 2026-06-07: P5.3g message delivery search-index checkpoint prepared as `feat(search): index message delivery`; audited GoChat durable `message:send_reply` worker against the P5.3 Meilisearch durability contract. GoChat now propagates `MessageService` search indexing into message delivery runners registered before the indexer exists, and the worker reindexes messages after provider success persists `sent`/`source_id` or provider failure persists `failed`/`external_error`. Focused message delivery worker search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P5.3h provider webhook search-index checkpoint prepared as `feat(search): index provider webhooks`; audited GoChat `IncomingPersister` incoming persistence, provider message status, contact-wide read receipt jobs, and provider webhook handler bootstrap wiring against the P5.3 Meilisearch durability contract. GoChat now indexes contacts/conversations/messages after provider incoming message persistence, skips duplicate indexing, indexes durable incoming replay, indexes direct/durable provider delivery status updates, indexes every contact-wide read-receipt message mutation, and wires Facebook/Telegram/WhatsApp/TikTok/LINE/Twilio webhook handlers to the durable search indexer. Focused webhook persister search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P5.3i CSAT survey search-index checkpoint prepared as `feat(search): index csat surveys`; audited GoChat CSAT survey creation/submission mutation paths against the P5.3 Meilisearch durability contract. GoChat now reindexes CSAT survey messages and parent conversations after resolved-conversation survey sends, durable `csat:survey_send` replay, public CSAT survey submissions, and `message.updated` submitted-value processing; bootstrap wires the public CSAT survey service and CSAT event listener to the durable search indexer. Focused CSAT survey service/listener search tests passed; full `go test ./...` passed outside the restricted socket sandbox after the expected sandbox-only local socket failures; `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P3.104 Captain custom-tools feature-gate checkpoint prepared as `feat(captain): gate custom tools`; audited Chatwoot `Api::V1::Accounts::Captain::CustomToolsController#ensure_custom_tools_enabled`, reused dashboard `api/captain/customTools.js`, and GoChat Captain custom-tool routes. GoChat now gates custom-tool index/show/create/update/delete/test routes on persisted account `custom_tools` or `captain_integration_v2` feature flags, returns raw `403 { error: "Custom tools are not enabled for this account" }` for disabled accounts, and wires the account repository into the production Captain custom-tool service. Focused Captain custom-tool/resource handler tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
|
||||
|
||||
@@ -584,7 +584,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
applogger.L().Warnf("failed to enqueue initial Captain document sync scheduler: %v", err)
|
||||
}
|
||||
captainScenarioService := service.NewCaptainScenarioService(captainScenarioRepo, captainAssistantRepo)
|
||||
captainCustomToolService := service.NewCaptainCustomToolService(captainCustomToolRepo)
|
||||
captainCustomToolService := service.NewCaptainCustomToolService(captainCustomToolRepo, accountRepo)
|
||||
copilotService := service.NewCopilotService(copilotThreadRepo, copilotMessageRepo, copilotSuggestionRepo, llmProvider, captainAssistantRepo)
|
||||
copilotService.SetWorkerPool(workerPool)
|
||||
captainConversationService := service.NewCaptainConversationService(db, llmProvider)
|
||||
|
||||
@@ -51,12 +51,13 @@ func (s *CaptainCustomToolCRUDTestSuite) SetupSuite() {
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
|
||||
account := &model.Account{Name: "CustomToolTestOrg", Locale: "en", Active: true}
|
||||
account := &model.Account{Name: "CustomToolTestOrg", Locale: "en", Active: true, FeatureFlags: `{"custom_tools":true}`}
|
||||
s.Require().NoError(db.Create(account).Error)
|
||||
s.account = account
|
||||
|
||||
toolRepo := repository.NewCaptainCustomToolRepo(db)
|
||||
svc := service.NewCaptainCustomToolService(toolRepo)
|
||||
accountRepo := repository.NewAccountRepo(db)
|
||||
svc := service.NewCaptainCustomToolService(toolRepo, accountRepo)
|
||||
s.handler = NewCaptainCustomToolHandler(svc)
|
||||
|
||||
// Unified router: :id = account_id, :tool_id = tool_id
|
||||
@@ -180,6 +181,24 @@ func (s *CaptainCustomToolCRUDTestSuite) TestCreate_无效accountID返回400() {
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_FeatureDisabledReturnsForbidden() {
|
||||
disabledAccount := &model.Account{Name: "CustomToolDisabledOrg", Locale: "en", Active: true}
|
||||
s.Require().NoError(s.db.Create(disabledAccount).Error)
|
||||
path := "/api/v1/accounts/" + strconv.FormatUint(uint64(disabledAccount.ID), 10) + "/captain/custom_tools/"
|
||||
|
||||
w := s.makeRequest("POST", path, map[string]interface{}{
|
||||
"custom_tool": map[string]interface{}{
|
||||
"title": "blocked",
|
||||
"endpoint_url": "https://example.com/blocked",
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(s.T(), http.StatusForbidden, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), "Custom tools are not enabled for this account", resp["error"])
|
||||
}
|
||||
|
||||
// ========== 获取自定义工具测试 ==========
|
||||
|
||||
func (s *CaptainCustomToolCRUDTestSuite) TestGet_成功获取自定义工具() {
|
||||
|
||||
@@ -29,6 +29,9 @@ func (h *CaptainCustomToolHandler) Create(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
if !h.ensureCustomToolsEnabled(c, accountID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CreateCustomToolRequest
|
||||
if err := bindNestedJSONPayload(c, "custom_tool", &req); err != nil {
|
||||
@@ -54,6 +57,9 @@ func (h *CaptainCustomToolHandler) Get(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
if !h.ensureCustomToolsEnabled(c, accountID) {
|
||||
return
|
||||
}
|
||||
id, err := parseUintAnyParam(c, "tool_id", "id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
@@ -78,6 +84,9 @@ func (h *CaptainCustomToolHandler) Update(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
if !h.ensureCustomToolsEnabled(c, accountID) {
|
||||
return
|
||||
}
|
||||
id, err := parseUintAnyParam(c, "tool_id", "id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
@@ -108,6 +117,9 @@ func (h *CaptainCustomToolHandler) Delete(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
if !h.ensureCustomToolsEnabled(c, accountID) {
|
||||
return
|
||||
}
|
||||
id, err := parseUintAnyParam(c, "tool_id", "id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
@@ -131,6 +143,9 @@ func (h *CaptainCustomToolHandler) List(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
if !h.ensureCustomToolsEnabled(c, accountID) {
|
||||
return
|
||||
}
|
||||
|
||||
tools, count, err := h.svc.List(c.Request.Context(), accountID, 0, 1000)
|
||||
if err != nil {
|
||||
@@ -179,6 +194,9 @@ func (h *CaptainCustomToolHandler) TestTool(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
if !h.ensureCustomToolsEnabled(c, accountID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.TestToolRequest
|
||||
if err := bindNestedJSONPayload(c, "custom_tool", &req); err != nil {
|
||||
@@ -196,6 +214,14 @@ func (h *CaptainCustomToolHandler) TestTool(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *CaptainCustomToolHandler) ensureCustomToolsEnabled(c *gin.Context, accountID uint) bool {
|
||||
if h.svc.CustomToolsEnabled(c.Request.Context(), accountID) {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Custom tools are not enabled for this account"})
|
||||
return false
|
||||
}
|
||||
|
||||
func captainCustomToolPayload(tool *model.CaptainCustomTool) gin.H {
|
||||
return gin.H{
|
||||
"id": tool.ID,
|
||||
|
||||
@@ -50,7 +50,7 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() {
|
||||
s.Require().NoError(err)
|
||||
|
||||
// 创建测试账户
|
||||
account := &model.Account{Name: "CustomToolTestOrg", Locale: "en", Active: true}
|
||||
account := &model.Account{Name: "CustomToolTestOrg", Locale: "en", Active: true, FeatureFlags: `{"custom_tools":true}`}
|
||||
s.Require().NoError(db.Create(account).Error)
|
||||
s.account = account
|
||||
|
||||
@@ -70,7 +70,8 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() {
|
||||
|
||||
// 创建 repo + service + handler
|
||||
toolRepo := repository.NewCaptainCustomToolRepo(db)
|
||||
svc := service.NewCaptainCustomToolService(toolRepo)
|
||||
accountRepo := repository.NewAccountRepo(db)
|
||||
svc := service.NewCaptainCustomToolService(toolRepo, accountRepo)
|
||||
svc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusCreated, Body: http.NoBody, Header: make(http.Header)}, nil
|
||||
}))
|
||||
|
||||
@@ -46,8 +46,8 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
|
||||
sqlDB.Close()
|
||||
})
|
||||
|
||||
account := &model.Account{Name: "Captain Account", Locale: "en", Active: true}
|
||||
otherAccount := &model.Account{Name: "Other Account", Locale: "en", Active: true}
|
||||
account := &model.Account{Name: "Captain Account", Locale: "en", Active: true, FeatureFlags: `{"custom_tools":true}`}
|
||||
otherAccount := &model.Account{Name: "Other Account", Locale: "en", Active: true, FeatureFlags: `{"custom_tools":true}`}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
require.NoError(t, db.Create(otherAccount).Error)
|
||||
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive}
|
||||
@@ -59,7 +59,8 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
|
||||
scenarioHandler := NewCaptainScenarioHandler(scenarioSvc)
|
||||
|
||||
toolRepo := repository.NewCaptainCustomToolRepo(db)
|
||||
toolSvc := service.NewCaptainCustomToolService(toolRepo)
|
||||
accountRepo := repository.NewAccountRepo(db)
|
||||
toolSvc := service.NewCaptainCustomToolService(toolRepo, accountRepo)
|
||||
toolSvc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Header: make(http.Header)}, nil
|
||||
}))
|
||||
|
||||
@@ -20,8 +20,9 @@ import (
|
||||
// CaptainCustomToolService implements business logic for CaptainCustomTool operations.
|
||||
// Reference: Chatwoot enterprise/app/controllers/api/v1/captain/custom_tools_controller.rb
|
||||
type CaptainCustomToolService struct {
|
||||
toolRepo *repository.CaptainCustomToolRepo
|
||||
httpClient HTTPDoer
|
||||
toolRepo *repository.CaptainCustomToolRepo
|
||||
accountRepo *repository.AccountRepo
|
||||
httpClient HTTPDoer
|
||||
}
|
||||
|
||||
type HTTPDoer interface {
|
||||
@@ -29,8 +30,12 @@ type HTTPDoer interface {
|
||||
}
|
||||
|
||||
// NewCaptainCustomToolService creates a new CaptainCustomToolService.
|
||||
func NewCaptainCustomToolService(toolRepo *repository.CaptainCustomToolRepo) *CaptainCustomToolService {
|
||||
return &CaptainCustomToolService{toolRepo: toolRepo, httpClient: &http.Client{Timeout: 30 * time.Second}}
|
||||
func NewCaptainCustomToolService(toolRepo *repository.CaptainCustomToolRepo, accountRepo ...*repository.AccountRepo) *CaptainCustomToolService {
|
||||
svc := &CaptainCustomToolService{toolRepo: toolRepo, httpClient: &http.Client{Timeout: 30 * time.Second}}
|
||||
if len(accountRepo) > 0 {
|
||||
svc.accountRepo = accountRepo[0]
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolService) SetHTTPClient(client HTTPDoer) {
|
||||
@@ -39,6 +44,17 @@ func (s *CaptainCustomToolService) SetHTTPClient(client HTTPDoer) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolService) CustomToolsEnabled(ctx context.Context, accountID uint) bool {
|
||||
if s.accountRepo == nil {
|
||||
return true
|
||||
}
|
||||
account, err := s.accountRepo.FindByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return featureFlagStringEnabled(account.FeatureFlags, "custom_tools") || featureFlagStringEnabled(account.FeatureFlags, "captain_integration_v2")
|
||||
}
|
||||
|
||||
// --- Request DTOs ---
|
||||
|
||||
// CreateCustomToolRequest is the DTO for creating a custom tool.
|
||||
|
||||
Reference in New Issue
Block a user