From e05d620876b7339b81da3880d34f9ae683ed5a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 7 Aug 2026 11:06:17 +0200 Subject: [PATCH] refactor(plugins): move Slack out to kandev-plugin-slack and list it in the marketplace (#2344) Co-authored-by: Kandev Agent --- .../agent/runtime/lifecycle/manager_launch.go | 4 +- apps/backend/internal/backendapp/helpers.go | 11 +- .../backendapp/integration_scope_test.go | 2 +- apps/backend/internal/backendapp/main.go | 37 +- .../internal/backendapp/review_wiring.go | 6 +- apps/backend/internal/backendapp/services.go | 66 +- apps/backend/internal/backendapp/types.go | 2 - .../backend/internal/mcp/handlers/handlers.go | 4 +- apps/backend/internal/slack/client.go | 63 -- apps/backend/internal/slack/cookie_client.go | 339 ---------- .../internal/slack/cookie_client_test.go | 255 ------- apps/backend/internal/slack/copy.go | 84 --- apps/backend/internal/slack/copy_test.go | 131 ---- apps/backend/internal/slack/handlers.go | 277 -------- apps/backend/internal/slack/handlers_test.go | 54 -- apps/backend/internal/slack/models.go | 143 ---- apps/backend/internal/slack/poller.go | 52 -- apps/backend/internal/slack/provider.go | 64 -- apps/backend/internal/slack/runtime.go | 302 --------- .../backend/internal/slack/scope_conn_test.go | 37 - apps/backend/internal/slack/scope_test.go | 110 --- apps/backend/internal/slack/service.go | 553 --------------- apps/backend/internal/slack/store.go | 458 ------------- apps/backend/internal/slack/store_test.go | 328 --------- apps/backend/internal/slack/trigger.go | 322 --------- apps/backend/internal/slack/trigger_test.go | 634 ------------------ .../task/repository/sqlite/base_migrations.go | 3 + .../repository/sqlite/slack_retirement.go | 51 ++ .../sqlite/slack_retirement_postgres_test.go | 144 ++++ .../sqlite/slack_retirement_test.go | 113 ++++ .../internal/utility/service/service.go | 2 +- .../internal/utility/template/engine.go | 12 +- apps/web/app/settings/integrations/page.tsx | 7 - .../app/settings/integrations/slack/page.tsx | 9 - .../settings/settings-tree-render.test.tsx | 6 - .../sections/settings/workspaces-group.tsx | 5 - .../integrations/integration-copy-config.ts | 18 +- .../settings/settings-layout-client.tsx | 1 - .../components/slack/slack-settings-state.ts | 266 -------- .../components/slack/slack-settings.test.tsx | 127 ---- apps/web/components/slack/slack-settings.tsx | 463 ------------- apps/web/eslint.i18n.options.mjs | 13 - .../domains/slack/use-slack-availability.ts | 36 - .../hooks/domains/slack/use-slack-enabled.ts | 11 - apps/web/lib/api/domains/slack-api.ts | 76 --- .../catalog/integrations.ts | 2 - apps/web/lib/types/slack.ts | 42 -- apps/web/src/locales/en/settings.json | 1 - apps/web/src/locales/en/slack.json | 39 -- apps/web/src/locales/pseudo/settings.json | 1 - apps/web/src/locales/pseudo/slack.json | 39 -- apps/web/src/locales/zh-cn/slack.json | 39 -- apps/web/src/settings-routes.tsx | 4 - docs/features.md | 2 +- docs/i18n.md | 2 +- docs/public/architecture.md | 2 +- docs/public/authentication.md | 2 +- docs/public/coverage.json | 3 +- docs/public/extending-kandev.md | 3 +- docs/public/feature-status.md | 3 +- docs/public/index.md | 2 +- docs/public/integrations.md | 46 +- docs/public/websocket-api.md | 7 +- docs/specs/INDEX.md | 2 +- docs/specs/integrations/slack.md | 22 +- plugin-registry/plugins.yaml | 3 + 66 files changed, 375 insertions(+), 5592 deletions(-) delete mode 100644 apps/backend/internal/slack/client.go delete mode 100644 apps/backend/internal/slack/cookie_client.go delete mode 100644 apps/backend/internal/slack/cookie_client_test.go delete mode 100644 apps/backend/internal/slack/copy.go delete mode 100644 apps/backend/internal/slack/copy_test.go delete mode 100644 apps/backend/internal/slack/handlers.go delete mode 100644 apps/backend/internal/slack/handlers_test.go delete mode 100644 apps/backend/internal/slack/models.go delete mode 100644 apps/backend/internal/slack/poller.go delete mode 100644 apps/backend/internal/slack/provider.go delete mode 100644 apps/backend/internal/slack/runtime.go delete mode 100644 apps/backend/internal/slack/scope_conn_test.go delete mode 100644 apps/backend/internal/slack/scope_test.go delete mode 100644 apps/backend/internal/slack/service.go delete mode 100644 apps/backend/internal/slack/store.go delete mode 100644 apps/backend/internal/slack/store_test.go delete mode 100644 apps/backend/internal/slack/trigger.go delete mode 100644 apps/backend/internal/slack/trigger_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/slack_retirement.go create mode 100644 apps/backend/internal/task/repository/sqlite/slack_retirement_postgres_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/slack_retirement_test.go delete mode 100644 apps/web/app/settings/integrations/slack/page.tsx delete mode 100644 apps/web/components/slack/slack-settings-state.ts delete mode 100644 apps/web/components/slack/slack-settings.test.tsx delete mode 100644 apps/web/components/slack/slack-settings.tsx delete mode 100644 apps/web/hooks/domains/slack/use-slack-availability.ts delete mode 100644 apps/web/hooks/domains/slack/use-slack-enabled.ts delete mode 100644 apps/web/lib/api/domains/slack-api.ts delete mode 100644 apps/web/lib/types/slack.ts delete mode 100644 apps/web/src/locales/en/slack.json delete mode 100644 apps/web/src/locales/pseudo/slack.json delete mode 100644 apps/web/src/locales/zh-cn/slack.json diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index d13da9fc71..8db439f410 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -431,7 +431,7 @@ func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchReq // For tasks without a repository, create a scratch workspace. // - Non-ephemeral repo-less tasks: /tasks/// // (task-scoped, persists across sessions, mirrors the worktree task layout). - // - Ephemeral tasks (slack triage / quick chat): /quick-chat// + // - Ephemeral tasks (quick chat): /quick-chat// // (session-scoped, cleaned up on task delete via performTaskCleanup). // Office tasks that have no repo (onboarding, planning) take the // non-ephemeral branch and land under /tasks/... @@ -484,7 +484,7 @@ func (m *Manager) resolveScratchWorkspace(ctx context.Context, req *LaunchReques func (m *Manager) scratchWorkspacePath(req *LaunchRequest) string { if req.IsEphemeral { // Legacy quick-chat path — session-scoped, kept for backward compat with - // slack triage and other ephemeral one-shot flows. + // ephemeral one-shot flows. if strings.ContainsAny(req.SessionID, `/\`) { m.logger.Warn("session ID contains path separator, rejecting", zap.String("session_id", req.SessionID)) diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 397008668f..91bd131c19 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -74,7 +74,6 @@ import ( "github.com/kandev/kandev/internal/runtimeflags" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/sentry" - "github.com/kandev/kandev/internal/slack" spriteshandlers "github.com/kandev/kandev/internal/sprites" sshhandlers "github.com/kandev/kandev/internal/ssh" systemsvc "github.com/kandev/kandev/internal/system" @@ -642,9 +641,6 @@ func registerRoutes(p routeParams) { p.services.Linear.SetRepositoryLookup(repoLookup) p.services.Linear.SetWorkspaceAuthorizer(p.taskSvc.AuthorizeWorkspaceAccess) } - if p.services.Slack != nil { - p.services.Slack.SetWorkspaceAuthorizer(p.taskSvc.AuthorizeWorkspaceAccess) - } if p.services.Sentry != nil { p.services.Sentry.SetTaskDeleter(handoffSvc) p.services.Sentry.SetRepositoryLookup(repoLookup) @@ -1150,11 +1146,6 @@ func registerSecondaryRoutes( p.log.Debug("Registered Sentry handlers (HTTP)") } - if p.services.Slack != nil { - slack.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Slack, p.log) - p.log.Debug("Registered Slack handlers (HTTP + WebSocket)") - } - if p.services.WorkflowSync != nil { workflowsync.RegisterRoutes(p.router, p.services.WorkflowSync, p.log) p.log.Debug("Registered workflow sync handlers (HTTP)") @@ -1272,7 +1263,7 @@ func officeWorkspaceScopeMiddleware(authSvc *auth.Service, taskSvc *taskservice. // gitlab) with no per-user gate of their own, so this global middleware // authorizes ownership for them when auth is enabled. var integrationWorkspacePrefixes = []string{ - "/api/v1/jira/", "/api/v1/linear/", "/api/v1/sentry/", "/api/v1/slack/", + "/api/v1/jira/", "/api/v1/linear/", "/api/v1/sentry/", "/api/v1/azure-devops/", "/api/v1/gitlab/", "/api/v1/github/", "/api/v1/workflow-sync/", } diff --git a/apps/backend/internal/backendapp/integration_scope_test.go b/apps/backend/internal/backendapp/integration_scope_test.go index e3c6ca7343..fe4bc6f75c 100644 --- a/apps/backend/internal/backendapp/integration_scope_test.go +++ b/apps/backend/internal/backendapp/integration_scope_test.go @@ -23,7 +23,7 @@ func TestWorkspaceIDFromPath(t *testing.T) { func TestHasIntegrationPrefix(t *testing.T) { inside := []string{ "/api/v1/jira/config", "/api/v1/gitlab/status", "/api/v1/github/status", - "/api/v1/linear/config", "/api/v1/sentry/config", "/api/v1/slack/config", + "/api/v1/linear/config", "/api/v1/sentry/config", "/api/v1/azure-devops/config", "/api/v1/workflow-sync/status", } outside := []string{ diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 5457c07fcd..6788bd15f1 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -40,7 +40,6 @@ import ( jirapkg "github.com/kandev/kandev/internal/jira" linearpkg "github.com/kandev/kandev/internal/linear" sentrypkg "github.com/kandev/kandev/internal/sentry" - slackpkg "github.com/kandev/kandev/internal/slack" workflowsyncpkg "github.com/kandev/kandev/internal/workflowsync" // Agent infrastructure @@ -617,20 +616,6 @@ func startAgentInfrastructure( addCleanup(func() error { sentryPoller.Stop(); return nil }) } - // Start Slack auth-health poller and the trigger loop. The trigger - // polls each configured workspace every 30s for new `!kandev …` - // messages from the authenticated user and turns them into Kandev - // tasks via taskSvc. - if services.Slack != nil { - slackPoller := slackpkg.NewPoller(services.Slack, log) - slackPoller.Start(ctx) - addCleanup(func() error { slackPoller.Stop(); return nil }) - - slackTrigger := slackpkg.NewTrigger(services.Slack, log) - slackTrigger.Start(ctx) - addCleanup(func() error { slackTrigger.Stop(); return nil }) - } - // Start workflow-sync poller: periodically pulls workflow definition // files from each workspace's configured GitHub repo and reconciles the // workspace's synced workflows with them. @@ -752,28 +737,10 @@ func startGatewayAndServe( return nil }) - // Wire the Slack agent runner. Slack triage uses the host-utility - // inference path (single-shot ACP subprocess) with the Kandev MCP - // server attached so the agent can call list_workflows_kandev / - // create_task_kandev / etc. mid-prompt. Both deps land here at the - // same time: hostUtilityMgr just bootstrapped above, services.Utility - // was constructed in provideServices. - if services.Slack != nil && services.Utility != nil { - mcpURL := buildKandevMCPURL(cfg.Server.Port) - slackRunner := slackpkg.NewRunner( - services.Utility, - services.User, - slackHostUtilityAdapter{mgr: hostUtilityMgr}, - []slackpkg.MCPDescriptor{{Name: "kandev", URL: mcpURL}}, - log, - ) - services.Slack.SetRunner(slackRunner) - } - // Wire Host.InvokeUtilityAgent (ADR 0048): plugins delegate one-shot LLM // calls to the utility agent selected in each plugin's configuration and - // runs them through the sessionless host-utility - // tier. Same landing point as the Slack runner — hostUtilityMgr is live. + // runs them through the sessionless host-utility tier, at the first point + // where hostUtilityMgr is live. if services.Plugins != nil && services.Utility != nil { services.Plugins.SetUtilityAgent(pluginsUtilityAgentAdapter{svc: services.Utility}, pluginsHostUtilityAdapter{mgr: hostUtilityMgr}) } diff --git a/apps/backend/internal/backendapp/review_wiring.go b/apps/backend/internal/backendapp/review_wiring.go index 6b99daee1a..269a516717 100644 --- a/apps/backend/internal/backendapp/review_wiring.go +++ b/apps/backend/internal/backendapp/review_wiring.go @@ -18,9 +18,9 @@ import ( utilitytemplate "github.com/kandev/kandev/internal/utility/template" ) -// The adapters below live in this package for the same cycle-avoidance reason as -// slackHostUtilityAdapter: internal/review must not import the agent runtime, and -// the runtime must not import review. Both are already visible here. +// The adapters below live in this package for cycle avoidance: internal/review +// must not import the agent runtime, and the runtime must not import review. +// Both are already visible here. // reviewComponents is the assembled native code-review subsystem. type reviewComponents struct { diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 074966639a..30f07cb0a6 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -14,7 +14,6 @@ import ( "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/registry" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" - agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" analyticsservice "github.com/kandev/kandev/internal/analytics/service" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/azuredevops" @@ -32,7 +31,6 @@ import ( promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/sentry" - "github.com/kandev/kandev/internal/slack" systemsettings "github.com/kandev/kandev/internal/system/settings" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" @@ -136,7 +134,6 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) sentrySvc := initSentryService(dbPool, eventBus, repos.Secrets, log) - slackSvc := initSlackService(dbPool, repos.Secrets, log) workflowSyncSvc := initWorkflowSyncService(dbPool, githubSvc, workflowSvc, taskSvc, log) pluginsSvc := initPluginsService(cfg, dbPool, eventBus, repos.Secrets, log) if pluginsSvc != nil { @@ -194,7 +191,6 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Jira: jiraSvc, Linear: linearSvc, Sentry: sentrySvc, - Slack: slackSvc, WorkflowSync: workflowSyncSvc, Share: shareHTTP, Automation: automationComponents, @@ -658,16 +654,11 @@ func initShareHandlers( return h } -// initSlackService wires up the Slack integration. Failures are non-fatal. -// The agent runner is wired post-construction by main.go once hostutility + -// utility services exist. -func initSlackService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *slack.Service { - svc, _, err := slack.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), log) - if err != nil { - log.Warn("Slack service initialization failed (non-fatal)", zap.Error(err)) - } - return svc -} +// portsBackendDefault is the default backend HTTP port. We don't import +// internal/common/ports here to avoid pulling its transitive deps into +// services.go's import graph; the value is a fallback for when +// cfg.Server.Port is left at zero (which shouldn't happen in practice). +const portsBackendDefault = 38429 // initPluginsService wires up the plugin system's core Service // (registration registry, config, plugin_state store). Failures are @@ -687,54 +678,11 @@ func initPluginsService(cfg *config.Config, dbPool *db.Pool, eventBus bus.EventB return svc } -// buildKandevMCPURL is the URL passed to the Slack triage agent for the -// Kandev MCP server. The MCP server is mounted on the same port as the rest -// of the backend's HTTP API; this just centralises the path so it stays in -// sync with internal/mcp/server's mount point ("/mcp"). -func buildKandevMCPURL(port int) string { - if port == 0 { - port = portsBackendDefault - } - return fmt.Sprintf("http://localhost:%d/mcp", port) -} - -// portsBackendDefault is the default backend HTTP port. We don't import -// internal/common/ports here to avoid pulling its transitive deps into -// services.go's import graph; the value is duplicated only as a fallback for -// when cfg.Server.Port is left at zero (which shouldn't happen in practice). -const portsBackendDefault = 38429 - -// slackHostUtilityAdapter adapts *hostutility.Manager to slack.HostUtilityRunner. -// The slack package can't import hostutility without a transitive cycle (it -// would need to import agentctl + lifecycle), so we shim through the agentctl -// utility DTO here in the cmd package where both are already imported. -type slackHostUtilityAdapter struct { - mgr *hostutility.Manager -} - -func (a slackHostUtilityAdapter) ExecutePromptWithMCP( - ctx context.Context, - agentType, model, mode, prompt string, - mcpServers []agentctlutil.MCPServerDTO, -) (slack.HostPromptResult, error) { - res, err := a.mgr.ExecutePromptWithMCP(ctx, agentType, model, mode, prompt, mcpServers) - if err != nil { - return slack.HostPromptResult{}, err - } - return slack.HostPromptResult{ - Response: res.Response, - Model: res.Model, - PromptTokens: res.PromptTokens, - ResponseTokens: res.ResponseTokens, - DurationMs: res.DurationMs, - }, nil -} - // pluginsHostUtilityAdapter adapts *hostutility.Manager to the plugins // package's utilityRunner interface (Host.InvokeUtilityAgent, ADR 0048), // returning just the response text. Lives here for the same cycle-avoidance -// reason as slackHostUtilityAdapter — internal/plugins must not import the -// agent runtime. +// reason as the review adapter — internal/plugins must not import the agent +// runtime. type pluginsHostUtilityAdapter struct { mgr *hostutility.Manager } diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index d7337d857d..9856b4e577 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -27,7 +27,6 @@ import ( "github.com/kandev/kandev/internal/runtimeflags" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/sentry" - "github.com/kandev/kandev/internal/slack" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/task/share" @@ -79,7 +78,6 @@ type Services struct { Jira *jira.Service Linear *linear.Service Sentry *sentry.Service - Slack *slack.Service // WorkflowSync keeps workspace workflows in sync with definition files // in a configured GitHub repository. Nil when GitHub is unavailable. WorkflowSync *workflowsync.Service diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 7863c2a06e..9c4dad1c0f 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -513,8 +513,8 @@ func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) // handleListRepositories lists repositories for a workspace. Exposes the same // data the kanban "Edit task → Repositories" picker reads, so an MCP-driven -// agent (e.g. the Slack triage runner) can match a request against an actual -// repo instead of guessing or making up an ID. +// agent can match a request against an actual repo instead of guessing or +// making up an ID. func (h *Handlers) handleListRepositories(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return h.handleListByField(ctx, msg, "workspace_id", "failed to list repositories", "Failed to list repositories", func(ctx context.Context, workspaceID string) (any, error) { diff --git a/apps/backend/internal/slack/client.go b/apps/backend/internal/slack/client.go deleted file mode 100644 index ba4ed6ad60..0000000000 --- a/apps/backend/internal/slack/client.go +++ /dev/null @@ -1,63 +0,0 @@ -package slack - -import ( - "context" - "errors" - "fmt" -) - -// ErrNotConfigured is returned when a Slack operation is attempted without a -// stored configuration or credentials. Wording is install-wide ("not -// configured", not "workspace not configured") because the WS path surfaces -// this raw string to clients via err.Error() without rewriting; the HTTP -// path overrides with its own friendlier message in writeClientError. -var ErrNotConfigured = errors.New("slack: not configured") - -// Client is the minimal Slack Web API surface this integration needs. The real -// implementation in cookie_client.go authenticates as the user via the xoxc- -// token + d cookie pair; tests substitute a fake. -type Client interface { - // AuthTest hits the `auth.test` endpoint, which is the cheapest call - // that confirms credentials are still valid and returns the - // authenticated user/team identifiers. Used by the health poller and the - // connection-test handler. - AuthTest(ctx context.Context) (*TestConnectionResult, error) - - // SearchMessages runs Slack's `search.messages` with a query string — - // the trigger uses this to find the user's own messages with the - // configured command prefix. Returns a flat list of matches. - SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) - - // ConversationsReplies fetches the full thread for a parent message. - // When threadTS is empty (the message isn't part of a thread), returns - // just the single message anchored at triggerTS — implementations must - // pin to triggerTS, not "the channel's latest message", or concurrent - // activity in the channel between trigger and processing will give the - // agent unrelated context. - ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) - - // ChatGetPermalink resolves a message's canonical Slack URL. - ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) - - // ChatPostMessage posts a reply (in cookie mode, as the authenticated - // user). When threadTS is non-empty, the message is posted in-thread. - ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error - - // ReactionsAdd adds an emoji reaction to a message. `name` is the bare - // emoji name (no surrounding colons), e.g. "eyes" or "white_check_mark". - // Implementations swallow Slack's `already_reacted` error so the trigger - // can call this idempotently across watermark retries. - ReactionsAdd(ctx context.Context, channelID, ts, name string) error -} - -// APIError captures a Slack API failure. Slack returns 200 with `{"ok": false, -// "error": "..."}` for almost every failure mode, so StatusCode is the HTTP -// code (usually 200) and Message is Slack's `error` string. -type APIError struct { - StatusCode int - Message string -} - -func (e *APIError) Error() string { - return fmt.Sprintf("slack api: status %d: %s", e.StatusCode, e.Message) -} diff --git a/apps/backend/internal/slack/cookie_client.go b/apps/backend/internal/slack/cookie_client.go deleted file mode 100644 index 10b06f73fd..0000000000 --- a/apps/backend/internal/slack/cookie_client.go +++ /dev/null @@ -1,339 +0,0 @@ -package slack - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -// SlackAPIBase is the single host for Slack's web API. The same base is used -// regardless of the user's workspace — auth scopes the request. -const SlackAPIBase = "https://slack.com/api" - -const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)" - -// CookieClient implements Client by speaking the same Web API the Slack -// browser app uses. The xoxc- session token goes in `Authorization: Bearer -// ` and the d cookie is sent as a `Cookie` header — Slack rejects the -// token alone for the endpoints we need. -type CookieClient struct { - http *http.Client - endpoint string - token string - cookie string - maxBodySize int64 -} - -// NewCookieClient builds a client. cfg is reserved so the signature matches -// future bot/oauth clients; it isn't read today. -func NewCookieClient(_ *SlackConfig, token, cookie string) *CookieClient { - return &CookieClient{ - http: &http.Client{Timeout: 30 * time.Second}, - endpoint: SlackAPIBase, - token: token, - cookie: cookie, - maxBodySize: 8 << 20, - } -} - -// slackEnvelope captures the `{ok, error}` shape every Slack endpoint returns. -type slackEnvelope struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` -} - -// post sends a form-encoded POST to /api/, decodes the JSON envelope, -// and unmarshals the full body into `out` if it isn't nil. Slack expects -// form-encoded bodies (not JSON) for almost every endpoint. -func (c *CookieClient) post(ctx context.Context, method string, params url.Values, out interface{}) error { - if c.token == "" || c.cookie == "" { - return ErrNotConfigured - } - body := strings.NewReader(params.Encode()) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/"+method, body) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Cookie", "d="+c.cookie) - resp, err := c.http.Do(req) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize)) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return &APIError{StatusCode: resp.StatusCode, Message: summarizeBody(raw)} - } - var env slackEnvelope - if err := json.Unmarshal(raw, &env); err != nil { - return &APIError{StatusCode: resp.StatusCode, Message: "invalid Slack response: " + err.Error()} - } - if !env.OK { - return &APIError{StatusCode: resp.StatusCode, Message: env.Error} - } - if out == nil { - return nil - } - return json.Unmarshal(raw, out) -} - -func summarizeBody(raw []byte) string { - const maxMsg = 500 - s := strings.TrimSpace(string(raw)) - if len(s) > maxMsg { - return s[:maxMsg] + "…" - } - if s == "" { - return "(empty body)" - } - return s -} - -// --- auth.test --- - -type authTestResponse struct { - slackEnvelope - URL string `json:"url"` - Team string `json:"team"` - User string `json:"user"` - TeamID string `json:"team_id"` - UserID string `json:"user_id"` -} - -// AuthTest implements Client. Slack's auth.test takes no parameters and -// returns the authenticated user + team. Used as the cheapest probe. -func (c *CookieClient) AuthTest(ctx context.Context) (*TestConnectionResult, error) { - var resp authTestResponse - if err := c.post(ctx, "auth.test", url.Values{}, &resp); err != nil { - var apiErr *APIError - if asAPIErr(err, &apiErr) { - return &TestConnectionResult{OK: false, Error: apiErr.Message}, nil - } - return &TestConnectionResult{OK: false, Error: err.Error()}, nil - } - return &TestConnectionResult{ - OK: true, - UserID: resp.UserID, - TeamID: resp.TeamID, - TeamName: resp.Team, - URL: resp.URL, - DisplayName: resp.User, - }, nil -} - -// asAPIErr is a small wrapper over errors.As without the import cycle of -// importing errors twice. -func asAPIErr(err error, target **APIError) bool { - for cur := err; cur != nil; { - if e, ok := cur.(*APIError); ok { - *target = e - return true - } - type unwrap interface{ Unwrap() error } - u, ok := cur.(unwrap) - if !ok { - return false - } - cur = u.Unwrap() - } - return false -} - -// --- search.messages --- - -type searchMessagesResponse struct { - slackEnvelope - Messages struct { - Matches []searchMatch `json:"matches"` - } `json:"messages"` -} - -type searchMatch struct { - TS string `json:"ts"` - Text string `json:"text"` - User string `json:"user"` - Username string `json:"username"` - Permalink string `json:"permalink"` - Channel struct { - ID string `json:"id"` - } `json:"channel"` - // Slack's search.messages returns thread context when the matched message - // is inside a thread. We only need the parent ts here — the trigger fetches - // the full thread separately via conversations.replies. - ThreadTS string `json:"thread_ts,omitempty"` -} - -// SearchMessages implements Client. -func (c *CookieClient) SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) { - params := url.Values{} - params.Set("query", query) - params.Set("count", "30") - // Newest first so the trigger sees the most recent matches first; the - // watermark logic in trigger.go skips anything older than last_seen_ts. - params.Set("sort", "timestamp") - params.Set("sort_dir", "desc") - var resp searchMessagesResponse - if err := c.post(ctx, "search.messages", params, &resp); err != nil { - return nil, err - } - out := make([]SlackMessage, 0, len(resp.Messages.Matches)) - for _, m := range resp.Messages.Matches { - out = append(out, SlackMessage{ - TS: m.TS, - ThreadTS: m.ThreadTS, - ChannelID: m.Channel.ID, - UserID: m.User, - UserName: m.Username, - Text: m.Text, - Permalink: m.Permalink, - }) - } - return out, nil -} - -// --- conversations.replies --- - -type conversationsRepliesResponse struct { - slackEnvelope - Messages []replyMessage `json:"messages"` -} - -type replyMessage struct { - TS string `json:"ts"` - ThreadTS string `json:"thread_ts,omitempty"` - User string `json:"user"` - Username string `json:"username"` - Text string `json:"text"` -} - -// ConversationsReplies implements Client. When threadTS is empty, falls back -// to a single-message read pinned to triggerTS — Slack's conversations.history -// with `oldest=ts, latest=ts, inclusive=true, limit=1` returns exactly that -// message regardless of activity in the channel between trigger detection and -// processing. -func (c *CookieClient) ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) { - if channelID == "" { - return nil, fmt.Errorf("channel id required") - } - if threadTS == "" { - return c.historyAt(ctx, channelID, triggerTS) - } - params := url.Values{} - params.Set("channel", channelID) - params.Set("ts", threadTS) - params.Set("limit", "100") - var resp conversationsRepliesResponse - if err := c.post(ctx, "conversations.replies", params, &resp); err != nil { - return nil, err - } - out := make([]SlackMessage, 0, len(resp.Messages)) - for _, m := range resp.Messages { - out = append(out, SlackMessage{ - TS: m.TS, - ThreadTS: m.ThreadTS, - ChannelID: channelID, - UserID: m.User, - UserName: m.Username, - Text: m.Text, - }) - } - return out, nil -} - -// historyAt returns exactly the message at the given timestamp via -// conversations.history with the timestamp window pinned to ts on both sides -// and inclusive=true. Used as the non-threaded fallback for -// ConversationsReplies; an empty ts returns nothing rather than picking up -// whatever just landed in the channel. -func (c *CookieClient) historyAt(ctx context.Context, channelID, ts string) ([]SlackMessage, error) { - if ts == "" { - return nil, nil - } - params := url.Values{} - params.Set("channel", channelID) - params.Set("oldest", ts) - params.Set("latest", ts) - params.Set("inclusive", "true") - params.Set("limit", "1") - var resp conversationsRepliesResponse - if err := c.post(ctx, "conversations.history", params, &resp); err != nil { - return nil, err - } - out := make([]SlackMessage, 0, len(resp.Messages)) - for _, m := range resp.Messages { - out = append(out, SlackMessage{ - TS: m.TS, - ThreadTS: m.ThreadTS, - ChannelID: channelID, - UserID: m.User, - UserName: m.Username, - Text: m.Text, - }) - } - return out, nil -} - -// --- chat.getPermalink --- - -type permalinkResponse struct { - slackEnvelope - Permalink string `json:"permalink"` -} - -// ChatGetPermalink implements Client. -func (c *CookieClient) ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) { - params := url.Values{} - params.Set("channel", channelID) - params.Set("message_ts", ts) - var resp permalinkResponse - if err := c.post(ctx, "chat.getPermalink", params, &resp); err != nil { - return "", err - } - return resp.Permalink, nil -} - -// --- chat.postMessage --- - -// ChatPostMessage implements Client. Posts as the authenticated user since the -// cookie auth has no bot identity. -func (c *CookieClient) ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error { - params := url.Values{} - params.Set("channel", channelID) - params.Set("text", text) - if threadTS != "" { - params.Set("thread_ts", threadTS) - } - return c.post(ctx, "chat.postMessage", params, nil) -} - -// ReactionsAdd implements Client. Slack returns `already_reacted` (a non-2xx- -// shaped error inside a 2xx envelope) when the reaction is already present; -// we swallow that so the trigger can call this idempotently after a restart -// or watermark-retry without surfacing a misleading "failed" log line. -func (c *CookieClient) ReactionsAdd(ctx context.Context, channelID, ts, name string) error { - params := url.Values{} - params.Set("channel", channelID) - params.Set("timestamp", ts) - params.Set("name", name) - err := c.post(ctx, "reactions.add", params, nil) - if err == nil { - return nil - } - var apiErr *APIError - if asAPIErr(err, &apiErr) && apiErr.Message == "already_reacted" { - return nil - } - return err -} diff --git a/apps/backend/internal/slack/cookie_client_test.go b/apps/backend/internal/slack/cookie_client_test.go deleted file mode 100644 index 672f2bee05..0000000000 --- a/apps/backend/internal/slack/cookie_client_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package slack - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// newFakeServer returns an httptest.Server whose handler dispatches by URL path -// (the trailing API method name) to the supplied handlers map. -func newFakeServer(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Server { - t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - method := strings.TrimPrefix(r.URL.Path, "/") - if h, ok := handlers[method]; ok { - h(w, r) - return - } - http.Error(w, "no handler for "+method, http.StatusNotFound) - })) - t.Cleanup(srv.Close) - return srv -} - -func newFakeClient(srv *httptest.Server) *CookieClient { - c := NewCookieClient(nil, "xoxc-test", "d-test") - c.endpoint = srv.URL - return c -} - -func TestCookieClient_AuthTest_Success(t *testing.T) { - var gotAuth, gotCookie string - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "auth.test": func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - gotCookie = r.Header.Get("Cookie") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"url":"https://acme.slack.com/","team":"Acme","user":"alice","team_id":"T0001","user_id":"U0001"}`)) - }, - }) - client := newFakeClient(srv) - res, err := client.AuthTest(context.Background()) - if err != nil { - t.Fatalf("AuthTest: %v", err) - } - if !res.OK || res.UserID != "U0001" || res.TeamID != "T0001" { - t.Errorf("unexpected result: %+v", res) - } - if gotAuth != "Bearer xoxc-test" { - t.Errorf("expected bearer auth header, got %q", gotAuth) - } - if gotCookie != "d=d-test" { - t.Errorf("expected d cookie header, got %q", gotCookie) - } -} - -func TestCookieClient_AuthTest_SlackError(t *testing.T) { - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "auth.test": func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`)) - }, - }) - client := newFakeClient(srv) - res, err := client.AuthTest(context.Background()) - if err != nil { - t.Fatalf("AuthTest returned err (should surface as result): %v", err) - } - if res.OK { - t.Errorf("expected OK=false, got %+v", res) - } - if !strings.Contains(res.Error, "invalid_auth") { - t.Errorf("expected slack error in result, got %q", res.Error) - } -} - -func TestCookieClient_SearchMessages(t *testing.T) { - var gotQuery string - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "search.messages": func(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - gotQuery = r.PostForm.Get("query") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "messages": {"matches": [ - {"ts":"100.000001","text":"!kandev one","user":"U1","permalink":"https://example/1","channel":{"id":"C1"}}, - {"ts":"100.000002","text":"!kandev two","user":"U1","permalink":"https://example/2","channel":{"id":"C1"}} - ]} - }`)) - }, - }) - client := newFakeClient(srv) - msgs, err := client.SearchMessages(context.Background(), `from:U1 "!kandev"`) - if err != nil { - t.Fatalf("SearchMessages: %v", err) - } - if gotQuery != `from:U1 "!kandev"` { - t.Errorf("query passthrough failed, got %q", gotQuery) - } - if len(msgs) != 2 || msgs[0].TS != "100.000001" || msgs[0].ChannelID != "C1" { - t.Errorf("unexpected messages: %+v", msgs) - } -} - -func TestCookieClient_ConversationsReplies(t *testing.T) { - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "conversations.replies": func(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - if r.PostForm.Get("ts") == "" { - http.Error(w, "ts required", http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "messages": [ - {"ts":"100.0001","user":"U1","text":"first"}, - {"ts":"100.0002","user":"U2","text":"second","thread_ts":"100.0001"} - ] - }`)) - }, - }) - client := newFakeClient(srv) - out, err := client.ConversationsReplies(context.Background(), "C1", "100.0001", "100.0001") - if err != nil { - t.Fatalf("ConversationsReplies: %v", err) - } - if len(out) != 2 || out[0].Text != "first" || out[1].ThreadTS != "100.0001" { - t.Errorf("unexpected thread: %+v", out) - } -} - -func TestCookieClient_ConversationsReplies_PinsHistoryToTriggerTSWhenNoThread(t *testing.T) { - var got struct{ oldest, latest, inclusive, limit string } - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "conversations.history": func(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - got.oldest = r.PostForm.Get("oldest") - got.latest = r.PostForm.Get("latest") - got.inclusive = r.PostForm.Get("inclusive") - got.limit = r.PostForm.Get("limit") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"messages":[{"ts":"200.0001","user":"U1","text":"loner"}]}`)) - }, - }) - client := newFakeClient(srv) - out, err := client.ConversationsReplies(context.Background(), "C1", "", "200.0001") - if err != nil { - t.Fatalf("ConversationsReplies: %v", err) - } - // Both window bounds must be the trigger ts and inclusive=true; otherwise - // concurrent activity in the channel would land in the response instead - // of the actual trigger message. - if got.oldest != "200.0001" || got.latest != "200.0001" { - t.Errorf("expected oldest/latest pinned to trigger ts, got %+v", got) - } - if got.inclusive != "true" || got.limit != "1" { - t.Errorf("expected inclusive=true limit=1, got %+v", got) - } - if len(out) != 1 || out[0].Text != "loner" { - t.Errorf("unexpected fallback: %+v", out) - } -} - -func TestCookieClient_ChatPostMessage(t *testing.T) { - var got struct{ channel, text, threadTS string } - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "chat.postMessage": func(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - got.channel = r.PostForm.Get("channel") - got.text = r.PostForm.Get("text") - got.threadTS = r.PostForm.Get("thread_ts") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - }, - }) - client := newFakeClient(srv) - if err := client.ChatPostMessage(context.Background(), "C1", "100.0001", "hello"); err != nil { - t.Fatalf("ChatPostMessage: %v", err) - } - if got.channel != "C1" || got.text != "hello" || got.threadTS != "100.0001" { - t.Errorf("payload mismatch: %+v", got) - } -} - -func TestCookieClient_ReactionsAdd(t *testing.T) { - var got struct{ channel, ts, name string } - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "reactions.add": func(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - got.channel = r.PostForm.Get("channel") - got.ts = r.PostForm.Get("timestamp") - got.name = r.PostForm.Get("name") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - }, - }) - client := newFakeClient(srv) - if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil { - t.Fatalf("ReactionsAdd: %v", err) - } - if got.channel != "C1" || got.ts != "100.0001" || got.name != "eyes" { - t.Errorf("payload mismatch: %+v", got) - } -} - -func TestCookieClient_ReactionsAdd_AlreadyReactedSwallowed(t *testing.T) { - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "reactions.add": func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":false,"error":"already_reacted"}`)) - }, - }) - client := newFakeClient(srv) - if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil { - t.Errorf("expected already_reacted to be swallowed, got: %v", err) - } -} - -func TestCookieClient_ReactionsAdd_OtherErrorBubbles(t *testing.T) { - srv := newFakeServer(t, map[string]http.HandlerFunc{ - "reactions.add": func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`)) - }, - }) - client := newFakeClient(srv) - err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes") - if err == nil || !strings.Contains(err.Error(), "channel_not_found") { - t.Errorf("expected channel_not_found error, got %v", err) - } -} - -func TestCookieClient_RejectsEmptyCredentials(t *testing.T) { - // AuthTest absorbs ErrNotConfigured into the returned result rather than - // surfacing it as an error — assert on result.OK explicitly so the test - // catches the guard regression instead of vacuously passing on err==nil. - c := NewCookieClient(nil, "", "") - res, err := c.AuthTest(context.Background()) - if err != nil { - t.Fatalf("AuthTest: %v", err) - } - if res == nil || res.OK { - t.Errorf("expected ok=false when both credentials are empty, got %+v", res) - } - - c2 := NewCookieClient(nil, "xoxc-x", "") - _, err = c2.SearchMessages(context.Background(), "x") - if err == nil { - t.Error("expected error when cookie is missing") - } -} diff --git a/apps/backend/internal/slack/copy.go b/apps/backend/internal/slack/copy.go deleted file mode 100644 index 8587b7b477..0000000000 --- a/apps/backend/internal/slack/copy.go +++ /dev/null @@ -1,84 +0,0 @@ -package slack - -import ( - "context" - "errors" - "fmt" - "time" -) - -// ErrSameWorkspace is returned when a copy targets the same workspace it reads -// from — a no-op the caller should surface as a client error rather than -// silently succeeding. -var ErrSameWorkspace = errors.New("slack: source and target workspaces are the same") - -// ErrNothingToCopy is returned when the source workspace has no Slack config to -// copy. -var ErrNothingToCopy = errors.New("slack: source workspace has no configuration to copy") - -// CopyConfigToWorkspace copies the Slack provider config and credentials -// (token + cookie) from sourceWorkspaceID to targetWorkspaceID. Watchers and -// automations are intentionally out of scope — only the connection settings and -// secrets are duplicated. The target's health probe re-runs so runtime fields -// (team/user id) repopulate from the copied credentials. -func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*SlackConfig, error) { - // Authorize BOTH workspaces up front. The integration middleware only - // authorizes the source (workspace_id query param); the target arrives in the - // request body and is otherwise unchecked, so a caller could copy their config - // and credentials into another user's workspace by naming its ID here. Guard - // before the target health-row write below, not just before the config write. - sourceWorkspaceID, err := s.resolveWorkspaceID(ctx, sourceWorkspaceID) - if err != nil { - return nil, err - } - targetWorkspaceID, err = s.resolveWorkspaceID(ctx, targetWorkspaceID) - if err != nil { - return nil, err - } - if sourceWorkspaceID == targetWorkspaceID { - return nil, ErrSameWorkspace - } - cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID) - if err != nil { - return nil, fmt.Errorf("read source slack config: %w", err) - } - if cfg == nil { - return nil, ErrNothingToCopy - } - token, cookie, err := s.revealSecrets(ctx, sourceWorkspaceID) - if err != nil { - return nil, err - } - // Empty Token/Cookie mean "preserve existing" in SetConfigForWorkspace, so a - // source without stored secrets would silently leave the target on its old - // credentials. Treat a missing source secret as nothing to copy instead. - if token == "" || cookie == "" { - return nil, ErrNothingToCopy - } - req := &SetConfigRequest{ - AuthMethod: cfg.AuthMethod, - CommandPrefix: cfg.CommandPrefix, - UtilityAgentID: cfg.UtilityAgentID, - PollIntervalSeconds: cfg.PollIntervalSeconds, - Token: token, - Cookie: cookie, - } - // Mark the target unhealthy before the write so the UI doesn't briefly flash - // a stale "connected" state (old team/user id) from the target's previous - // connection while the async probe validates the copied credentials. The - // probe repopulates team/user id from the new credentials. - if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", "", "", time.Now().UTC()); err != nil { - return nil, fmt.Errorf("reset target slack health: %w", err) - } - cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req) - if err != nil { - // The write failed, so the target's config is unchanged but we already - // flipped its health row. Re-probe asynchronously (best effort) so the row - // reflects the target's real state again rather than a spurious - // "unhealthy", without delaying the error response by the probe timeout. - // Detach from the request context, which may already be cancelled. - go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID) - return nil, err - } - return cfgOut, nil -} diff --git a/apps/backend/internal/slack/copy_test.go b/apps/backend/internal/slack/copy_test.go deleted file mode 100644 index d4673eb0a9..0000000000 --- a/apps/backend/internal/slack/copy_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package slack - -import ( - "context" - "errors" - "sync" - "testing" - - "github.com/kandev/kandev/internal/common/logger" -) - -// fakeSecrets is an in-memory SecretStore for copy tests. -type fakeSecrets struct { - mu sync.Mutex - values map[string]string -} - -func newFakeSecrets() *fakeSecrets { return &fakeSecrets{values: map[string]string{}} } - -func (f *fakeSecrets) Reveal(_ context.Context, id string) (string, error) { - f.mu.Lock() - defer f.mu.Unlock() - return f.values[id], nil -} - -func (f *fakeSecrets) Set(_ context.Context, id, _, value string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.values[id] = value - return nil -} - -func (f *fakeSecrets) Delete(_ context.Context, id string) error { - f.mu.Lock() - defer f.mu.Unlock() - delete(f.values, id) - return nil -} - -func (f *fakeSecrets) Exists(_ context.Context, id string) (bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - _, ok := f.values[id] - return ok, nil -} - -func (f *fakeSecrets) get(id string) string { - f.mu.Lock() - defer f.mu.Unlock() - return f.values[id] -} - -func newCopyTestService(t *testing.T, secrets SecretStore) *Service { - t.Helper() - store := newTestStore(t) - // A fake client keeps the async health probe from doing real IO. - factory := func(_ *SlackConfig, _, _ string) Client { return &fakeClient{} } - return NewService(store, secrets, nil, factory, logger.Default()) -} - -func TestCopyConfigToWorkspace_CopiesConfigAndSecrets(t *testing.T) { - ctx := context.Background() - secrets := newFakeSecrets() - svc := newCopyTestService(t, secrets) - // Block on the async probe so the test doesn't race the background write. - done := make(chan struct{}, 4) - svc.SetProbeHook(func() { done <- struct{}{} }) - - const src, dst = "ws-src", "ws-dst" - if _, err := svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{ - AuthMethod: AuthMethodCookie, - CommandPrefix: "!go", - UtilityAgentID: "ua-1", - PollIntervalSeconds: 45, - Token: "xoxc-token", - Cookie: "d-cookie", - }); err != nil { - t.Fatalf("seed source: %v", err) - } - <-done - - got, err := svc.CopyConfigToWorkspace(ctx, src, dst) - if err != nil { - t.Fatalf("copy: %v", err) - } - <-done - - if got.UtilityAgentID != "ua-1" || got.CommandPrefix != "!go" || got.PollIntervalSeconds != 45 { - t.Errorf("copied config mismatch: %+v", got) - } - if v := secrets.get(SecretKeyForToken(dst)); v != "xoxc-token" { - t.Errorf("token not copied: %q", v) - } - if v := secrets.get(SecretKeyForCookie(dst)); v != "d-cookie" { - t.Errorf("cookie not copied: %q", v) - } -} - -func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) { - svc := newCopyTestService(t, newFakeSecrets()) - if _, err := svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) { - t.Fatalf("expected ErrSameWorkspace, got %v", err) - } -} - -func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) { - svc := newCopyTestService(t, newFakeSecrets()) - if _, err := svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) { - t.Fatalf("expected ErrNothingToCopy, got %v", err) - } -} - -// TestCopyConfigToWorkspace_MissingSecret guards against silently leaving the -// target on its previous credentials: a source config row without stored -// token/cookie must fail rather than copy an empty secret (which -// SetConfigForWorkspace would treat as "preserve existing"). -func TestCopyConfigToWorkspace_MissingSecret(t *testing.T) { - ctx := context.Background() - svc := newCopyTestService(t, newFakeSecrets()) - // Seed a config row directly, without storing any token/cookie secret. - if err := svc.store.UpsertConfigForWorkspace(ctx, "ws-src", &SlackConfig{ - AuthMethod: AuthMethodCookie, - UtilityAgentID: "ua-1", - PollIntervalSeconds: 30, - }); err != nil { - t.Fatalf("seed source config: %v", err) - } - if _, err := svc.CopyConfigToWorkspace(ctx, "ws-src", "ws-dst"); !errors.Is(err, ErrNothingToCopy) { - t.Fatalf("expected ErrNothingToCopy for missing secret, got %v", err) - } -} diff --git a/apps/backend/internal/slack/handlers.go b/apps/backend/internal/slack/handlers.go deleted file mode 100644 index f3009cc160..0000000000 --- a/apps/backend/internal/slack/handlers.go +++ /dev/null @@ -1,277 +0,0 @@ -package slack - -import ( - "context" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/task/repository/repoerrors" - ws "github.com/kandev/kandev/pkg/websocket" -) - -// workspaceDenied reports whether err is the per-user workspace access denial -// surfaced by the workspace authorizer. Handlers map it to 404 so a workspace -// the caller may not access is indistinguishable from a missing one. -func workspaceDenied(err error) bool { - return errors.Is(err, repoerrors.ErrWorkspaceNotFound) -} - -// RegisterRoutes wires the Slack HTTP and WebSocket handlers. -func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { - ctrl := &Controller{service: svc, logger: log} - ctrl.RegisterHTTPRoutes(router) - registerWSHandlers(dispatcher, svc) -} - -// Controller holds HTTP route handlers for the Slack integration. -type Controller struct { - service *Service - logger *logger.Logger -} - -// RegisterHTTPRoutes attaches the Slack HTTP endpoints to router. -func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { - api := router.Group("/api/v1/slack") - api.GET("/config", c.httpGetConfig) - api.POST("/config", c.httpSetConfig) - api.DELETE("/config", c.httpDeleteConfig) - api.POST("/config/test", c.httpTestConfig) - api.POST("/config/copy", c.httpCopyConfig) -} - -// copyConfigRequest is the payload for the copy-config endpoint. The source -// workspace is taken from the workspace_id query param (like every other -// handler here); the body carries the target. -type copyConfigRequest struct { - TargetWorkspaceID string `json:"targetWorkspaceId"` -} - -func (c *Controller) httpCopyConfig(ctx *gin.Context) { - sourceWorkspaceID := c.workspaceID(ctx) - if sourceWorkspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"}) - return - } - var req copyConfigRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID) - if targetWorkspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"}) - return - } - cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID) - if err != nil { - status := http.StatusInternalServerError - switch { - case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig): - status = http.StatusBadRequest - case workspaceDenied(err): - status = http.StatusNotFound - } - ctx.JSON(status, gin.H{"error": err.Error()}) - return - } - ctx.JSON(http.StatusOK, cfg) -} - -// --- HTTP handlers --- - -func (c *Controller) httpGetConfig(ctx *gin.Context) { - cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) - if err != nil { - if workspaceDenied(err) { - ctx.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if cfg == nil { - ctx.Status(http.StatusNoContent) - return - } - ctx.JSON(http.StatusOK, cfg) -} - -func (c *Controller) httpSetConfig(ctx *gin.Context) { - var req SetConfigRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) - if err != nil { - status := http.StatusInternalServerError - switch { - case errors.Is(err, ErrInvalidConfig): - status = http.StatusBadRequest - case workspaceDenied(err): - status = http.StatusNotFound - } - ctx.JSON(status, gin.H{"error": err.Error()}) - return - } - ctx.JSON(http.StatusOK, cfg) -} - -func (c *Controller) httpDeleteConfig(ctx *gin.Context) { - if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)); err != nil { - if workspaceDenied(err) { - ctx.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - ctx.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -func (c *Controller) httpTestConfig(ctx *gin.Context) { - var req SetConfigRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - result, err := c.service.TestConnectionForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) - if err != nil { - c.writeClientError(ctx, err) - return - } - ctx.JSON(http.StatusOK, result) -} - -func (c *Controller) workspaceID(ctx *gin.Context) string { - return strings.TrimSpace(ctx.Query("workspace_id")) -} - -// errCodeSlackNotConfigured is the wire-level code surfaced to the UI when -// no Slack credentials are saved. -const errCodeSlackNotConfigured = "SLACK_NOT_CONFIGURED" - -func (c *Controller) writeClientError(ctx *gin.Context, err error) { - if workspaceDenied(err) { - // A workspace the caller may not access is indistinguishable from a - // missing one — 404, not 403/500. Covers TestConnection and data-plane - // reads (via clientFor). - ctx.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - if errors.Is(err, ErrNotConfigured) { - ctx.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Slack is not configured", - "code": errCodeSlackNotConfigured, - }) - return - } - var apiErr *APIError - if errors.As(err, &apiErr) { - status := http.StatusInternalServerError - switch apiErr.StatusCode { - case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: - status = apiErr.StatusCode - } - ctx.JSON(status, gin.H{"error": apiErr.Error()}) - return - } - c.logger.Warn("slack handler error", zap.Error(err)) - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -} - -// --- WebSocket handlers --- - -func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) { - dispatcher.RegisterFunc(ws.ActionSlackConfigGet, wsGetConfig(svc)) - dispatcher.RegisterFunc(ws.ActionSlackConfigSet, wsSetConfig(svc)) - dispatcher.RegisterFunc(ws.ActionSlackConfigDelete, wsDeleteConfig(svc)) - dispatcher.RegisterFunc(ws.ActionSlackConfigTest, wsTestConfig(svc)) -} - -func wsReply(msg *ws.Message, payload interface{}) (*ws.Message, error) { - resp, err := ws.NewResponse(msg.ID, msg.Action, payload) - if err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) - } - return resp, nil -} - -// wsFail mirrors writeClientError's HTTP-side mapping so WS-driven flows -// surface bad-request / unauthorized / not-found / forbidden distinctly -// instead of collapsing every non-ErrNotConfigured failure into -// INTERNAL_ERROR. -func wsFail(msg *ws.Message, err error) (*ws.Message, error) { - if errors.Is(err, ErrNotConfigured) { - return ws.NewError(msg.ID, msg.Action, errCodeSlackNotConfigured, err.Error(), nil) - } - var apiErr *APIError - if errors.As(err, &apiErr) { - switch apiErr.StatusCode { - case http.StatusBadRequest: - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, apiErr.Error(), nil) - case http.StatusUnauthorized: - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnauthorized, apiErr.Error(), nil) - case http.StatusForbidden: - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, apiErr.Error(), nil) - case http.StatusNotFound: - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, apiErr.Error(), nil) - } - } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) -} - -func wsGetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - cfg, err := svc.GetConfig(ctx) - if err != nil { - return wsFail(msg, err) - } - return wsReply(msg, gin.H{"config": cfg}) - } -} - -func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req SetConfigRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) - } - cfg, err := svc.SetConfig(ctx, &req) - if err != nil { - if errors.Is(err, ErrInvalidConfig) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) - } - return wsFail(msg, err) - } - return wsReply(msg, gin.H{"config": cfg}) - } -} - -func wsDeleteConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - if err := svc.DeleteConfig(ctx); err != nil { - return wsFail(msg, err) - } - return wsReply(msg, gin.H{"deleted": true}) - } -} - -func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req SetConfigRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) - } - result, err := svc.TestConnection(ctx, &req) - if err != nil { - return wsFail(msg, err) - } - return wsReply(msg, result) - } -} diff --git a/apps/backend/internal/slack/handlers_test.go b/apps/backend/internal/slack/handlers_test.go deleted file mode 100644 index 82068a5fba..0000000000 --- a/apps/backend/internal/slack/handlers_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package slack - -import ( - "encoding/json" - "errors" - "testing" - - ws "github.com/kandev/kandev/pkg/websocket" -) - -// TestWsFail_MapsAPIErrorCodes locks the wire-level error codes wsFail -// produces for each input variant. wsFail mirrors writeClientError's HTTP -// mapping so WS clients see the same distinct errors (bad-request, -// unauthorized, forbidden, not-found, internal) instead of the previous -// "everything → INTERNAL_ERROR" collapse, and a regression here would put -// the WS path silently out of sync with the HTTP path again. -func TestWsFail_MapsAPIErrorCodes(t *testing.T) { - msg := &ws.Message{ID: "1", Action: ws.ActionSlackConfigTest} - - cases := []struct { - name string - err error - wantCode string - }{ - {"ErrNotConfigured → SLACK_NOT_CONFIGURED", ErrNotConfigured, errCodeSlackNotConfigured}, - {"APIError 400 → BAD_REQUEST", &APIError{StatusCode: 400, Message: "bad"}, ws.ErrorCodeBadRequest}, - {"APIError 401 → UNAUTHORIZED", &APIError{StatusCode: 401, Message: "unauth"}, ws.ErrorCodeUnauthorized}, - {"APIError 403 → FORBIDDEN", &APIError{StatusCode: 403, Message: "forbidden"}, ws.ErrorCodeForbidden}, - {"APIError 404 → NOT_FOUND", &APIError{StatusCode: 404, Message: "notfound"}, ws.ErrorCodeNotFound}, - {"APIError 500 → INTERNAL_ERROR", &APIError{StatusCode: 500, Message: "server"}, ws.ErrorCodeInternalError}, - {"plain error → INTERNAL_ERROR", errors.New("transient"), ws.ErrorCodeInternalError}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - resp, err := wsFail(msg, c.err) - if err != nil { - t.Fatalf("wsFail returned error: %v", err) - } - if resp == nil { - t.Fatal("wsFail returned nil response") - } - var payload ws.ErrorPayload - if jsonErr := json.Unmarshal(resp.Payload, &payload); jsonErr != nil { - t.Fatalf("decode payload: %v (raw=%s)", jsonErr, string(resp.Payload)) - } - if payload.Code != c.wantCode { - t.Errorf("code mismatch: got %q want %q", payload.Code, c.wantCode) - } - if payload.Message == "" { - t.Error("expected non-empty message in error payload") - } - }) - } -} diff --git a/apps/backend/internal/slack/models.go b/apps/backend/internal/slack/models.go deleted file mode 100644 index 39abd0a26c..0000000000 --- a/apps/backend/internal/slack/models.go +++ /dev/null @@ -1,143 +0,0 @@ -// Package slack implements the Slack integration: install-wide -// configuration storage, an HTTP client that authenticates as the user via -// the unofficial browser-session credentials (xoxc- token + d cookie), and a -// polling trigger that picks up `!kandev ` messages and turns -// them into Kandev tasks by handing them to a utility agent that has access -// to Kandev MCP tools. -// -// Slack credentials and triage settings are install-wide singletons (one -// Slack account per Kandev install) — the agent picks the destination Kandev -// workspace per-message via `list_workspaces_kandev`. Only "browser session" -// auth is supported in this v1 slice — bot tokens and user-OAuth modes are -// scoped out per docs/specs/slack-integration/spec.md. -package slack - -import "time" - -// AuthMethodCookie is the only auth method this slice supports: the xoxc- -// session token plus the `d` cookie that Slack's web client uses. -const AuthMethodCookie = "cookie" - -// DefaultCommandPrefix is the marker the trigger looks for at the start of a -// user's own message. Picked because it can't collide with Slack's own slash -// commands (which start with `/`) and is short enough to type. -const DefaultCommandPrefix = "!kandev" - -// DefaultPollIntervalSeconds is the cadence for checking Slack for new -// `!kandev …` matches. 30s is a comfortable middle ground: fast enough that -// captures feel responsive, slow enough to stay well inside Slack's -// `search.messages` rate limit (Tier 2 ~20/min). -const DefaultPollIntervalSeconds = 30 - -// MinPollIntervalSeconds is the floor users can set in the UI. Below 5s the -// `search.messages` cost dominates and a single match's utility-agent run -// takes longer than the polling interval anyway. -const MinPollIntervalSeconds = 5 - -// MaxPollIntervalSeconds caps the upper end of the slider. 10 minutes is -// long enough for "very low traffic" and short enough that a stuck/expired -// session shows up in the auth-status banner promptly. -const MaxPollIntervalSeconds = 600 - -// SlackConfig is the workspace-scoped Slack integration configuration. The xoxc -// token and the d cookie live in the encrypted secret store under workspace -// secret keys. -type SlackConfig struct { - WorkspaceID string `json:"workspaceId,omitempty" db:"workspace_id"` - AuthMethod string `json:"authMethod" db:"auth_method"` - CommandPrefix string `json:"commandPrefix" db:"command_prefix"` - // UtilityAgentID points at a row in `utility_agents`. The agent is - // invoked for each Slack match with Kandev MCP wired in so it can call - // list_workflows_kandev, create_task_kandev, etc. Required — the trigger - // skips matches when this is empty. - UtilityAgentID string `json:"utilityAgentId" db:"utility_agent_id"` - PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` - // SlackTeamID and SlackUserID are captured from the most recent successful - // probe so the trigger can scope `search.messages` to the user's own - // authorship without an extra round-trip per poll. - SlackTeamID string `json:"slackTeamId,omitempty" db:"slack_team_id"` - SlackUserID string `json:"slackUserId,omitempty" db:"slack_user_id"` - // LastSeenTS is the highest Slack message timestamp the trigger has - // already processed. - LastSeenTS string `json:"lastSeenTs,omitempty" db:"last_seen_ts"` - HasToken bool `json:"hasToken" db:"-"` - HasCookie bool `json:"hasCookie" db:"-"` - // LastCheckedAt / LastOk / LastError are written by the auth-health poller. - LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` - LastOk bool `json:"lastOk" db:"last_ok"` - LastError string `json:"lastError,omitempty" db:"last_error"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -// SetConfigRequest is the payload sent by the UI. Empty Token / Cookie on -// update keeps the existing stored value; non-empty replaces it. -type SetConfigRequest struct { - AuthMethod string `json:"authMethod"` - CommandPrefix string `json:"commandPrefix"` - UtilityAgentID string `json:"utilityAgentId"` - PollIntervalSeconds int `json:"pollIntervalSeconds"` - Token string `json:"token"` - Cookie string `json:"cookie"` -} - -// TestConnectionResult mirrors the Linear shape so the frontend can render -// success/failure with the same component for both integrations. -type TestConnectionResult struct { - OK bool `json:"ok"` - UserID string `json:"userId,omitempty"` - TeamID string `json:"teamId,omitempty"` - TeamName string `json:"teamName,omitempty"` - URL string `json:"url,omitempty"` - DisplayName string `json:"displayName,omitempty"` - Error string `json:"error,omitempty"` -} - -// SlackMessage is the minimal shape we extract from Slack's web API for both -// the trigger search and the thread fetch. -type SlackMessage struct { - TS string `json:"ts"` - ThreadTS string `json:"threadTs,omitempty"` - ChannelID string `json:"channelId"` - UserID string `json:"userId,omitempty"` - UserName string `json:"userName,omitempty"` - Text string `json:"text"` - Permalink string `json:"permalink,omitempty"` -} - -// ThreadContext bundles a triggering message with the surrounding thread the -// trigger fetched for it. -type ThreadContext struct { - Trigger SlackMessage `json:"trigger"` - Messages []SlackMessage `json:"messages"` -} - -// SecretKeyToken is the legacy secret-store key for the old install-wide xoxc- -// token. -const SecretKeyToken = "slack:singleton:token" - -// SecretKeyCookie is the legacy secret-store key for the old install-wide `d` -// cookie. -// Stored separately from the token so each can be rotated/replaced -// independently — Slack rotates the d cookie much more often than the token. -const SecretKeyCookie = "slack:singleton:cookie" - -// SecretKeyForToken returns the workspace-scoped token key. -func SecretKeyForToken(workspaceID string) string { - return "slack:" + workspaceID + ":token" -} - -// SecretKeyForCookie returns the workspace-scoped cookie key. -func SecretKeyForCookie(workspaceID string) string { - return "slack:" + workspaceID + ":cookie" -} - -// LegacySecretKeyForToken is kept for older tests/callers. -func LegacySecretKeyForToken(workspaceID string) string { - return SecretKeyForToken(workspaceID) -} - -// LegacySecretKeyForCookie is kept for older tests/callers. -func LegacySecretKeyForCookie(workspaceID string) string { - return SecretKeyForCookie(workspaceID) -} diff --git a/apps/backend/internal/slack/poller.go b/apps/backend/internal/slack/poller.go deleted file mode 100644 index c1bb167d00..0000000000 --- a/apps/backend/internal/slack/poller.go +++ /dev/null @@ -1,52 +0,0 @@ -package slack - -import ( - "context" - - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/integrations/healthpoll" -) - -// Poller probes the stored Slack credentials on the shared 90s auth-health -// cadence, mirroring Linear/Jira. The actual loop lives in -// internal/integrations/healthpoll; this wrapper exists so callers can write -// the familiar `slack.NewPoller(...).Start(ctx)` shape. -type Poller struct { - inner *healthpoll.Poller -} - -// NewPoller returns a poller using the default 90s cadence. Returns nil when -// svc is nil so the caller's nil-check still works. -func NewPoller(svc *Service, log *logger.Logger) *Poller { - if svc == nil { - return nil - } - return &Poller{inner: healthpoll.New("slack", svcProber{svc}, log)} -} - -// Start launches the background loop. -func (p *Poller) Start(ctx context.Context) { - if p == nil || p.inner == nil { - return - } - p.inner.Start(ctx) -} - -// Stop cancels the loop and waits for it to drain. -func (p *Poller) Stop() { - if p == nil || p.inner == nil { - return - } - p.inner.Stop() -} - -// svcProber adapts *Service to healthpoll.Prober. -type svcProber struct{ svc *Service } - -func (s svcProber) HasConfig(ctx context.Context) (bool, error) { - return s.svc.Store().HasConfig(ctx) -} - -func (s svcProber) RecordAuthHealth(ctx context.Context) { - s.svc.RecordAuthHealth(ctx) -} diff --git a/apps/backend/internal/slack/provider.go b/apps/backend/internal/slack/provider.go deleted file mode 100644 index 2a3eb70774..0000000000 --- a/apps/backend/internal/slack/provider.go +++ /dev/null @@ -1,64 +0,0 @@ -package slack - -import ( - "context" - "time" - - "github.com/jmoiron/sqlx" - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/common/logger" -) - -// Provide builds the Slack service. Cleanup is a no-op — the service holds -// only an in-memory client cache. Mirrors the other integration providers so -// callers register it uniformly. -// -// The agent runner is wired post-construction via SetRunner because the -// host-utility manager is built later in the backend's startup sequence. -func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { - store, err := NewStore(writer, reader) - if err != nil { - return nil, nil, err - } - migrateLegacySecrets(store, secrets, log) - svc := NewService(store, secrets, nil, DefaultClientFactory, log) - cleanup := func() error { return nil } - return svc, cleanup, nil -} - -// migrateLegacySecrets copies the old install-wide token + cookie onto the -// workspace-scoped keys created by the config migration, then deletes the -// legacy entries. Best-effort: any error is logged and ignored — the worst -// case is the user re-pastes their credentials in the settings UI. -func migrateLegacySecrets(store *Store, secrets SecretStore, log *logger.Logger) { - target := store.MigratedFromWorkspace() - if target == "" || secrets == nil { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - migrateOne(ctx, secrets, SecretKeyToken, SecretKeyForToken(target), "Slack token", log) - migrateOne(ctx, secrets, SecretKeyCookie, SecretKeyForCookie(target), "Slack d cookie", log) -} - -func migrateOne(ctx context.Context, secrets SecretStore, legacyKey, newKey, displayName string, log *logger.Logger) { - // If a fresh value already exists at the new key (someone migrated - // manually, or this codepath ran on a previous boot), don't clobber it. - if exists, err := secrets.Exists(ctx, newKey); err == nil && exists { - return - } - value, err := secrets.Reveal(ctx, legacyKey) - if err != nil || value == "" { - return - } - if err := secrets.Set(ctx, newKey, displayName, value); err != nil { - log.Warn("slack: legacy secret migration failed", - zap.String("key", legacyKey), zap.Error(err)) - return - } - if err := secrets.Delete(ctx, legacyKey); err != nil { - log.Warn("slack: legacy secret cleanup failed", - zap.String("key", legacyKey), zap.Error(err)) - } -} diff --git a/apps/backend/internal/slack/runtime.go b/apps/backend/internal/slack/runtime.go deleted file mode 100644 index f97dbdb48f..0000000000 --- a/apps/backend/internal/slack/runtime.go +++ /dev/null @@ -1,302 +0,0 @@ -package slack - -import ( - "context" - "errors" - "fmt" - "strings" - - "go.uber.org/zap" - - agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" - "github.com/kandev/kandev/internal/common/logger" - utilitymodels "github.com/kandev/kandev/internal/utility/models" - utilityservice "github.com/kandev/kandev/internal/utility/service" - "github.com/kandev/kandev/internal/utility/template" -) - -// agentSystemPrompt is the persona used when a utility agent's prompt template -// is empty (or doesn't reference any Slack-specific variables). It tells the -// agent it's acting as a triage assistant, names the MCP tools it has, and -// asks for a concise final reply suitable for posting back to Slack. -// -// When the user provides a custom prompt that references Slack template -// variables (e.g. {{SlackInstruction}}, {{SlackThread}}), the template wins -// and we don't append anything — the user is in full control. -const defaultSlackSystemPrompt = `You are a Kandev triage assistant. A user just sent a request from Slack — an instruction plus the surrounding thread for context. Your job: - -1. Use the Kandev MCP tools available to you (list_workspaces_kandev, list_workflows_kandev, list_workflow_steps_kandev, list_repositories_kandev, create_task_kandev, list_agents_kandev, list_executor_profiles_kandev) to figure out which Kandev workspace + workflow + column + repository the request belongs in. **Always call list_workspaces_kandev first** — Slack is install-wide, so you must pick the destination Kandev workspace from the list rather than assume one. Then list_repositories_kandev for that workspace before deciding which repo to attach — never guess a repository_id. -2. Create a Kandev task that captures the request, with a clear title and a description that gives the future agent everything it needs (the user's instruction and any thread context that matters). -3. Reply briefly with what you did — the task title, the workspace it landed in, and a short rationale. Your reply will be posted back into Slack as your final message, so write naturally for a human reader. - -Don't ask the user follow-up questions in the reply — your reply is posted in-thread without a chance for them to respond mid-run. If something is ambiguous, pick the most likely option and explain your choice.` - -// HostUtilityRunner is the slice of hostutility.Manager we depend on. -type HostUtilityRunner interface { - ExecutePromptWithMCP( - ctx context.Context, - agentType, model, mode, prompt string, - mcpServers []agentctlutil.MCPServerDTO, - ) (HostPromptResult, error) -} - -// HostPromptResult mirrors hostutility.PromptResult — duplicated here so the -// slack package doesn't import hostutility (which has heavy transitive deps). -type HostPromptResult struct { - Response string - Model string - PromptTokens int - ResponseTokens int - DurationMs int -} - -// UtilityRegistry is the slice of utility.Service the runner needs: enough to -// look up an agent and prepare a resolved prompt request that handles the -// "empty agent_id/model falls back to user defaults" path the rest of Kandev -// already implements. -type UtilityRegistry interface { - GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error) - PreparePromptRequest( - ctx context.Context, - utilityID string, - tmplCtx *template.Context, - defaults *utilityservice.DefaultUtilitySettings, - sessionless bool, - ) (*utilityservice.PromptRequest, error) -} - -// UserDefaults is the slice of user.Service the runner uses to resolve -// per-user default agent/model when the chosen utility agent leaves them -// empty (the normal state for built-in agents). -type UserDefaults interface { - GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) -} - -// MCPDescriptor describes a single MCP server to wire into the agent's -// session. Mirrors the agentctlutil.MCPServerDTO shape. -type MCPDescriptor struct { - Name string - URL string -} - -// Runner runs the utility agent for a Slack match. -type Runner struct { - utility UtilityRegistry - userDefaults UserDefaults - host HostUtilityRunner - mcpServers []MCPDescriptor - log *logger.Logger -} - -// NewRunner builds a runner. -func NewRunner(utility UtilityRegistry, userDefaults UserDefaults, host HostUtilityRunner, mcpServers []MCPDescriptor, log *logger.Logger) *Runner { - return &Runner{utility: utility, userDefaults: userDefaults, host: host, mcpServers: mcpServers, log: log} -} - -// ErrNoUtilityAgent is returned when the workspace's Slack config doesn't -// reference a utility agent. -var ErrNoUtilityAgent = errors.New("slack: no utility agent configured") - -// RunForMatch implements AgentRunner. Slack is install-wide singleton, so -// there's no workspace_id to pass — the agent picks the destination Kandev -// workspace itself via list_workspaces_kandev. -func (r *Runner) RunForMatch( - ctx context.Context, - cfg *SlackConfig, - msg SlackMessage, - instruction, permalink string, - thread []SlackMessage, -) (string, error) { - if cfg.UtilityAgentID == "" { - return "", ErrNoUtilityAgent - } - agent, err := r.utility.GetAgentByID(ctx, cfg.UtilityAgentID) - if err != nil { - return "", fmt.Errorf("load utility agent: %w", err) - } - - defaults := r.resolveDefaults(ctx) - tmplCtx := r.buildTemplateContext(msg, instruction, permalink, thread) - - prepared, err := r.utility.PreparePromptRequest(ctx, cfg.UtilityAgentID, tmplCtx, defaults, true) - if err != nil { - return "", fmt.Errorf("prepare prompt: %w", err) - } - if prepared.AgentCLI == "" { - return "", fmt.Errorf("no agent_id resolved for utility agent %q (set one on the agent or pick a default in /settings/utility-agents)", agent.Name) - } - - prompt := r.composePrompt(agent.Prompt, prepared.ResolvedPrompt, msg, instruction, permalink, thread) - mcpDTOs := r.mcpDTOs() - - r.log.Info("slack: invoking utility agent", - zap.String("utility_agent", agent.Name), - zap.String("agent_cli", prepared.AgentCLI), - zap.String("model", prepared.Model), - zap.Int("mcp_servers", len(mcpDTOs)), - zap.String("trigger_ts", msg.TS)) - - res, err := r.host.ExecutePromptWithMCP(ctx, prepared.AgentCLI, prepared.Model, "", prompt, mcpDTOs) - if err != nil { - return "", fmt.Errorf("execute utility prompt: %w", err) - } - reply := strings.TrimSpace(res.Response) - if reply == "" { - r.log.Warn("slack: agent returned empty response", - zap.String("trigger_ts", msg.TS)) - reply = "(Kandev triage agent finished without producing text — check the kanban board.)" - } - return reply, nil -} - -// resolveDefaults reads the user-default agent/model. A read failure here -// just means we'll surface a clearer "no agent_id resolved" error later -// rather than crashing the whole flow on a settings-DB blip. -func (r *Runner) resolveDefaults(ctx context.Context) *utilityservice.DefaultUtilitySettings { - if r.userDefaults == nil { - return nil - } - agentID, model, err := r.userDefaults.GetDefaultUtilitySettings(ctx) - if err != nil { - r.log.Warn("slack: read default utility settings failed", zap.Error(err)) - return nil - } - return &utilityservice.DefaultUtilitySettings{AgentID: agentID, Model: model} -} - -// buildTemplateContext maps Slack data into the existing template Context's -// Custom map so users can reference {{SlackInstruction}}, {{SlackThread}}, etc. -// in their utility agent's prompt template. UserPrompt is set too so prompts -// shared with other utility agents (which use {{UserPrompt}}) work without -// modification. -func (r *Runner) buildTemplateContext(trigger SlackMessage, instruction, permalink string, thread []SlackMessage) *template.Context { - return &template.Context{ - UserPrompt: instruction, - Custom: map[string]string{ - "SlackInstruction": instruction, - "SlackThread": formatThread(thread), - "SlackPermalink": permalink, - "SlackUser": senderLabel(trigger), - "SlackChannelID": trigger.ChannelID, - "SlackTS": trigger.TS, - }, - } -} - -// slackTemplateVars are the placeholders authored Slack-triage templates -// reference. Detecting any of these (not just "the resolver substituted -// anything") in the raw template is what tells us the user designed the -// prompt for Slack rather than reusing a generic utility-agent template -// that happens to use {{UserPrompt}}. -var slackTemplateVars = []string{ - "{{SlackInstruction}}", - "{{SlackThread}}", - "{{SlackPermalink}}", - "{{SlackUser}}", - "{{SlackChannelID}}", - "{{SlackTS}}", -} - -func referencesSlackVars(rawTemplate string) bool { - for _, v := range slackTemplateVars { - if strings.Contains(rawTemplate, v) { - return true - } - } - return false -} - -// composePrompt picks between the resolved template (when the user authored -// one and it references at least one Slack-specific placeholder) and the -// legacy "default system prompt + appended Slack context block" layout. -// -// We test for Slack-specific placeholders explicitly, not just "the resolver -// substituted anything" — a generic utility-agent prompt that uses -// {{UserPrompt}} would otherwise hijack the Slack-owned path and the agent -// would lose the MCP-tool guidance baked into the default system prompt. -// When the raw template has no Slack vars, we fall back so users with empty -// or generic templates still get a working triage prompt. -func (r *Runner) composePrompt(rawTemplate, resolved string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string { - tmpl := strings.TrimSpace(rawTemplate) - resolvedTrim := strings.TrimSpace(resolved) - if tmpl != "" && referencesSlackVars(rawTemplate) { - return resolvedTrim - } - system := resolvedTrim - if system == "" { - system = defaultSlackSystemPrompt - } - return appendSlackContextBlock(system, trigger, instruction, permalink, thread) -} - -func appendSlackContextBlock(system string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string { - var b strings.Builder - b.WriteString(system) - b.WriteString("\n\n--- Slack context ---\n") - if permalink != "" { - b.WriteString("Slack thread: ") - b.WriteString(permalink) - b.WriteString("\n") - } - if instruction != "" { - b.WriteString("Request from ") - b.WriteString(senderLabel(trigger)) - b.WriteString(":\n") - b.WriteString(instruction) - b.WriteString("\n") - } - if len(thread) > 0 { - b.WriteString("\nThread context:\n") - b.WriteString(formatThread(thread)) - } - return strings.TrimRight(b.String(), "\n") -} - -func formatThread(thread []SlackMessage) string { - if len(thread) == 0 { - return "" - } - var b strings.Builder - for _, m := range thread { - who := m.UserName - if who == "" { - who = m.UserID - } - if who == "" { - who = "(unknown)" - } - b.WriteString(who) - b.WriteString(" (") - b.WriteString(m.TS) - b.WriteString("): ") - b.WriteString(strings.TrimSpace(m.Text)) - b.WriteString("\n") - } - return strings.TrimRight(b.String(), "\n") -} - -func (r *Runner) mcpDTOs() []agentctlutil.MCPServerDTO { - if len(r.mcpServers) == 0 { - return nil - } - out := make([]agentctlutil.MCPServerDTO, 0, len(r.mcpServers)) - for _, m := range r.mcpServers { - out = append(out, agentctlutil.MCPServerDTO{ - Name: m.Name, - Type: "http", - URL: m.URL, - }) - } - return out -} - -func senderLabel(m SlackMessage) string { - switch { - case m.UserName != "": - return "@" + m.UserName - case m.UserID != "": - return "<@" + m.UserID + ">" - default: - return "the user" - } -} diff --git a/apps/backend/internal/slack/scope_conn_test.go b/apps/backend/internal/slack/scope_conn_test.go deleted file mode 100644 index 095a3360d1..0000000000 --- a/apps/backend/internal/slack/scope_conn_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package slack - -import ( - "context" - "errors" - "testing" - - "github.com/kandev/kandev/internal/task/repository/repoerrors" -) - -// TestTestConnection_DeniesForeignWorkspace is the regression for the review's -// P1 credential-exfiltration path: a caller must not cause a foreign/default -// workspace's stored Slack credentials to be used. -func TestTestConnection_DeniesForeignWorkspace(t *testing.T) { - ctx := context.Background() - svc := newCopyTestService(t, newFakeSecrets()) - svc.SetWorkspaceAuthorizer(denyOnly("ws-foreign")) - - result, err := svc.TestConnectionForWorkspace(ctx, "ws-foreign", &SetConfigRequest{ - AuthMethod: AuthMethodCookie, Token: "t", Cookie: "c", - }) - if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { - t.Fatalf("expected ErrWorkspaceNotFound, got %v (result=%+v)", err, result) - } -} - -// TestClientFor_DeniesForeignWorkspace covers the clientFor chokepoint that all -// data-plane reads funnel through. -func TestClientFor_DeniesForeignWorkspace(t *testing.T) { - ctx := context.Background() - svc := newCopyTestService(t, newFakeSecrets()) - svc.SetWorkspaceAuthorizer(denyOnly("ws-foreign")) - - if _, err := svc.ClientForWorkspace(ctx, "ws-foreign"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { - t.Fatalf("expected ErrWorkspaceNotFound, got %v", err) - } -} diff --git a/apps/backend/internal/slack/scope_test.go b/apps/backend/internal/slack/scope_test.go deleted file mode 100644 index 2daf24bce5..0000000000 --- a/apps/backend/internal/slack/scope_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package slack - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/kandev/kandev/internal/task/repository/repoerrors" -) - -// denyOnly returns a workspace authorizer that allows every workspace except -// the named ones, which it denies with the same ErrWorkspaceNotFound the real -// task-service authorizer returns. -func denyOnly(denied ...string) func(context.Context, string) error { - blocked := make(map[string]bool, len(denied)) - for _, ws := range denied { - blocked[ws] = true - } - return func(_ context.Context, ws string) error { - if blocked[ws] { - return repoerrors.ErrWorkspaceNotFound - } - return nil - } -} - -// TestCopyConfigToWorkspace_DeniesForeignTarget is the regression for the HIGH -// finding: the copy target arrives in the request body, which the query-only -// integration middleware never authorizes, so without a service-layer check a -// caller could copy their config + credentials into another user's workspace. -func TestCopyConfigToWorkspace_DeniesForeignTarget(t *testing.T) { - ctx := context.Background() - secrets := newFakeSecrets() - svc := newCopyTestService(t, secrets) - done := make(chan struct{}, 4) - svc.SetProbeHook(func() { done <- struct{}{} }) - - const src, victim = "ws-src", "ws-victim" - if _, err := svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{ - AuthMethod: AuthMethodCookie, - Token: "xoxc-attacker", - Cookie: "d-attacker", - }); err != nil { - t.Fatalf("seed source: %v", err) - } - <-done - - // The victim already has a healthy Slack connection of their own. A denied - // copy must leave it byte-for-byte untouched — including its health status, - // which the up-front guard protects from the pre-write UpdateAuthHealth flip. - if err := svc.store.UpsertConfigForWorkspace(ctx, victim, &SlackConfig{ - AuthMethod: AuthMethodCookie, CommandPrefix: "!vic", UtilityAgentID: "ua-vic", - }); err != nil { - t.Fatalf("seed victim config: %v", err) - } - if err := secrets.Set(ctx, SecretKeyForToken(victim), "Slack token", "xoxc-victim"); err != nil { - t.Fatalf("seed victim token: %v", err) - } - if err := secrets.Set(ctx, SecretKeyForCookie(victim), "Slack cookie", "d-victim"); err != nil { - t.Fatalf("seed victim cookie: %v", err) - } - if err := svc.store.UpdateAuthHealthForWorkspace(ctx, victim, true, "", "", "", time.Now().UTC()); err != nil { - t.Fatalf("seed victim health: %v", err) - } - - svc.SetWorkspaceAuthorizer(denyOnly(victim)) - - if _, err := svc.CopyConfigToWorkspace(ctx, src, victim); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { - t.Fatalf("expected ErrWorkspaceNotFound, got %v", err) - } - got, cfgErr := svc.store.GetConfigForWorkspace(ctx, victim) - if cfgErr != nil || got == nil { - t.Fatalf("victim config vanished: cfg=%+v err=%v", got, cfgErr) - } - if got.CommandPrefix != "!vic" { - t.Errorf("victim config overwritten by copy: %q", got.CommandPrefix) - } - if !got.LastOk { - t.Errorf("victim health flipped to unhealthy by unauthorized copy") - } - if v := secrets.get(SecretKeyForToken(victim)); v != "xoxc-victim" { - t.Errorf("victim token overwritten by copy: %q", v) - } - if v := secrets.get(SecretKeyForCookie(victim)); v != "d-victim" { - t.Errorf("victim cookie overwritten by copy: %q", v) - } -} - -// TestConfigForWorkspace_DeniesForeignWorkspace is the regression for the LOW -// finding: a scoped caller must not read, write, or delete a workspace's config -// it does not own. -func TestConfigForWorkspace_DeniesForeignWorkspace(t *testing.T) { - ctx := context.Background() - svc := newCopyTestService(t, newFakeSecrets()) - const foreign = "ws-foreign" - svc.SetWorkspaceAuthorizer(denyOnly(foreign)) - - if _, err := svc.GetConfigForWorkspace(ctx, foreign); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { - t.Errorf("Get: expected ErrWorkspaceNotFound, got %v", err) - } - if _, err := svc.SetConfigForWorkspace(ctx, foreign, &SetConfigRequest{ - AuthMethod: AuthMethodCookie, Token: "t", Cookie: "c", - }); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { - t.Errorf("Set: expected ErrWorkspaceNotFound, got %v", err) - } - if err := svc.DeleteConfigForWorkspace(ctx, foreign); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { - t.Errorf("Delete: expected ErrWorkspaceNotFound, got %v", err) - } -} diff --git a/apps/backend/internal/slack/service.go b/apps/backend/internal/slack/service.go deleted file mode 100644 index 52c02628b2..0000000000 --- a/apps/backend/internal/slack/service.go +++ /dev/null @@ -1,553 +0,0 @@ -package slack - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/common/logger" -) - -// SecretStore is the subset of secrets the service needs. -type SecretStore interface { - Reveal(ctx context.Context, id string) (string, error) - Set(ctx context.Context, id, name, value string) error - Delete(ctx context.Context, id string) error - Exists(ctx context.Context, id string) (bool, error) -} - -// Service orchestrates Slack config storage, the cached client, the -// auth-health probe, and the utility-agent run that turns a matched Slack -// message into a Kandev task. Slack is install-wide (one Slack user/team per -// Kandev install); the agent picks the destination Kandev workspace per -// message via MCP. -type Service struct { - store *Store - secrets SecretStore - runner AgentRunner - log *logger.Logger - - mu sync.Mutex - clientFn ClientFactory - clients map[string]Client - probeHook func() - // workspaceAuthorizer is the per-user workspace access boundary, wired - // post-construction via SetWorkspaceAuthorizer. Nil (unit tests, auth - // disabled) means unscoped — every workspace is visible, as before auth. - workspaceAuthorizer func(context.Context, string) error -} - -// SetWorkspaceAuthorizer installs the per-user workspace access boundary applied -// before user-facing Slack config reads/mutations. Wired to -// taskSvc.AuthorizeWorkspaceAccess so a caller cannot reach a workspace it does -// not own by naming its ID (e.g. the copy target, or the resolved default). -func (s *Service) SetWorkspaceAuthorizer(authorizer func(context.Context, string) error) { - if s != nil { - s.workspaceAuthorizer = authorizer - } -} - -func (s *Service) authorizeWorkspaceAccess(ctx context.Context, workspaceID string) error { - if s == nil || s.workspaceAuthorizer == nil { - return nil - } - return s.workspaceAuthorizer(ctx, workspaceID) -} - -// resolveWorkspaceID normalizes an optional workspace ID (empty → default) and -// authorizes the caller against the resolved workspace. Using it instead of -// normalizeWorkspaceID on user-facing entry points closes the default-workspace -// fallback: a scoped caller that omits workspace_id cannot land on a global -// default it does not own. -func (s *Service) resolveWorkspaceID(ctx context.Context, workspaceID string) (string, error) { - workspaceID, err := s.normalizeWorkspaceID(workspaceID) - if err != nil { - return "", err - } - if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { - return "", err - } - return workspaceID, nil -} - -// AgentRunner runs the configured utility agent for a Slack match. Defined as -// an interface so tests can inject a fake without spinning up agentctl. -type AgentRunner interface { - RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) -} - -// ClientFactory builds a Client from a config + the (token, cookie) pair. -type ClientFactory func(cfg *SlackConfig, token, cookie string) Client - -// DefaultClientFactory returns a real CookieClient. -func DefaultClientFactory(cfg *SlackConfig, token, cookie string) Client { - return NewCookieClient(cfg, token, cookie) -} - -// NewService wires the service. Pass nil for clientFn to use the default. -// runner may be nil — when nil, matched Slack messages are logged but no -// agent runs (useful in tests and during partial backend init). -func NewService( - store *Store, - secrets SecretStore, - runner AgentRunner, - clientFn ClientFactory, - log *logger.Logger, -) *Service { - if clientFn == nil { - clientFn = DefaultClientFactory - } - return &Service{ - store: store, - secrets: secrets, - runner: runner, - log: log, - clientFn: clientFn, - clients: make(map[string]Client), - } -} - -// SetRunner wires the agent runner after construction. main.go calls this -// once the host-utility manager + utility service are available. -func (s *Service) SetRunner(r AgentRunner) { - s.mu.Lock() - defer s.mu.Unlock() - s.runner = r -} - -// Runner returns the wired runner (or nil). -func (s *Service) Runner() AgentRunner { - s.mu.Lock() - defer s.mu.Unlock() - return s.runner -} - -// GetConfig returns the default workspace config enriched with HasToken/HasCookie. -func (s *Service) GetConfig(ctx context.Context) (*SlackConfig, error) { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return nil, err - } - return s.GetConfigForWorkspace(ctx, workspaceID) -} - -// GetConfigForWorkspace returns a workspace config enriched with HasToken/HasCookie. -func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*SlackConfig, error) { - workspaceID, err := s.resolveWorkspaceID(ctx, workspaceID) - if err != nil { - return nil, err - } - cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) - if err != nil || cfg == nil { - return cfg, err - } - if s.secrets == nil { - return cfg, nil - } - cfg.HasToken = s.secretExists(ctx, SecretKeyForToken(workspaceID), SecretKeyToken, "token") - cfg.HasCookie = s.secretExists(ctx, SecretKeyForCookie(workspaceID), SecretKeyCookie, "cookie") - return cfg, nil -} - -func (s *Service) secretExists(ctx context.Context, id, legacyID, kind string) bool { - exists, err := s.secrets.Exists(ctx, id) - if err != nil { - s.log.Warn("slack: secret exists check failed", - zap.String("kind", kind), zap.Error(err)) - } - if exists { - return true - } - exists, err = s.secrets.Exists(ctx, legacyID) - if err != nil { - s.log.Warn("slack: legacy secret exists check failed", - zap.String("kind", kind), zap.Error(err)) - } - return exists -} - -// ErrInvalidConfig is returned by SetConfig when the request fails validation. -var ErrInvalidConfig = errors.New("slack: invalid configuration") - -func (s *Service) persistSecrets(ctx context.Context, workspaceID string, req *SetConfigRequest) error { - if s.secrets == nil { - return nil - } - if req.Token != "" { - if err := s.secrets.Set(ctx, SecretKeyForToken(workspaceID), "Slack token", req.Token); err != nil { - return fmt.Errorf("store slack token: %w", err) - } - } - if req.Cookie != "" { - if err := s.secrets.Set(ctx, SecretKeyForCookie(workspaceID), "Slack d cookie", req.Cookie); err != nil { - return fmt.Errorf("store slack cookie: %w", err) - } - } - return nil -} - -// SetConfig is upsert. Empty Token/Cookie on update keeps the existing values. -func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SlackConfig, error) { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return nil, err - } - return s.SetConfigForWorkspace(ctx, workspaceID, req) -} - -// SetConfigForWorkspace is upsert. Empty Token/Cookie on update keeps existing values. -func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*SlackConfig, error) { - workspaceID, err := s.resolveWorkspaceID(ctx, workspaceID) - if err != nil { - return nil, err - } - if err := validateConfigRequest(req); err != nil { - return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) - } - cfg := &SlackConfig{ - AuthMethod: req.AuthMethod, - CommandPrefix: req.CommandPrefix, - UtilityAgentID: req.UtilityAgentID, - PollIntervalSeconds: req.PollIntervalSeconds, - } - if err := s.store.UpsertConfigForWorkspace(ctx, workspaceID, cfg); err != nil { - return nil, fmt.Errorf("upsert slack config: %w", err) - } - if err := s.persistSecrets(ctx, workspaceID, req); err != nil { - return nil, err - } - s.invalidateClient(workspaceID) - go func() { - s.RecordAuthHealthForWorkspace(context.Background(), workspaceID) - }() - return s.GetConfigForWorkspace(ctx, workspaceID) -} - -// DeleteConfig removes both the row and the stored secrets. -func (s *Service) DeleteConfig(ctx context.Context) error { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return err - } - return s.DeleteConfigForWorkspace(ctx, workspaceID) -} - -// DeleteConfigForWorkspace removes both the row and the stored secrets. -func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { - workspaceID, err := s.resolveWorkspaceID(ctx, workspaceID) - if err != nil { - return err - } - if err := s.store.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { - return err - } - if s.secrets != nil { - s.deleteSecret(ctx, SecretKeyForToken(workspaceID), "token") - s.deleteSecret(ctx, SecretKeyForCookie(workspaceID), "cookie") - } - s.invalidateClient(workspaceID) - return nil -} - -func (s *Service) deleteSecret(ctx context.Context, id, kind string) { - if err := s.secrets.Delete(ctx, id); err != nil { - s.log.Warn("slack: secret delete failed", - zap.String("kind", kind), zap.Error(err)) - } -} - -// TestConnection validates credentials either inline (from a fresh request) -// or from the stored secrets. -func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return &TestConnectionResult{OK: false, Error: err.Error()}, nil - } - return s.TestConnectionForWorkspace(ctx, workspaceID, req) -} - -// TestConnectionForWorkspace validates credentials for one workspace. -func (s *Service) TestConnectionForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*TestConnectionResult, error) { - workspaceID, err := s.normalizeWorkspaceID(workspaceID) - if err != nil { - return &TestConnectionResult{OK: false, Error: err.Error()}, nil - } - // Authorize before revealing/using the workspace's stored credentials: an - // omitted workspace_id resolves the global default. Return a hard error (not - // a swallowed {OK:false} result) so the denial is a 404 rather than a - // probeable "test failed". - if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { - return nil, err - } - cfg, token, cookie, err := s.resolveCredentials(ctx, workspaceID, req) - if err != nil { - return &TestConnectionResult{OK: false, Error: err.Error()}, nil - } - client := s.clientFn(cfg, token, cookie) - return client.AuthTest(ctx) -} - -// ProbeAuth validates the stored credentials. -func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return &TestConnectionResult{OK: false, Error: err.Error()}, nil - } - return s.ProbeAuthForWorkspace(ctx, workspaceID) -} - -// ProbeAuthForWorkspace validates the stored credentials for one workspace. -func (s *Service) ProbeAuthForWorkspace(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { - workspaceID, err := s.normalizeWorkspaceID(workspaceID) - if err != nil { - return &TestConnectionResult{OK: false, Error: err.Error()}, nil - } - client, err := s.clientFor(ctx, workspaceID) - if err != nil { - return &TestConnectionResult{OK: false, Error: err.Error()}, nil - } - return client.AuthTest(ctx) -} - -// Store exposes the underlying store so background workers can persist state. -func (s *Service) Store() *Store { - return s.store -} - -// authProbeTimeout caps a single auth-health probe. -const authProbeTimeout = 15 * time.Second - -// authHealthWriteTimeout bounds the DB write that persists the probe outcome. -const authHealthWriteTimeout = 5 * time.Second - -// SetProbeHook installs a callback fired at the end of each RecordAuthHealth. -func (s *Service) SetProbeHook(fn func()) { - s.mu.Lock() - defer s.mu.Unlock() - s.probeHook = fn -} - -// RecordAuthHealth probes credentials and writes the outcome onto the row. -func (s *Service) RecordAuthHealth(ctx context.Context) { - workspaceIDs, err := s.store.ListConfigWorkspaceIDs(ctx) - if err != nil { - s.log.Warn("slack: list config workspaces failed", zap.Error(err)) - return - } - if len(workspaceIDs) == 0 { - s.fireProbeHook() - return - } - for _, workspaceID := range workspaceIDs { - s.RecordAuthHealthForWorkspace(ctx, workspaceID) - } -} - -// RecordAuthHealthForWorkspace probes credentials and writes the outcome onto -// one workspace row. -func (s *Service) RecordAuthHealthForWorkspace(ctx context.Context, workspaceID string) { - workspaceID, normalizeErr := s.normalizeWorkspaceID(workspaceID) - if normalizeErr != nil { - s.log.Warn("slack: resolve workspace for auth health failed", zap.Error(normalizeErr)) - return - } - probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) - defer cancel() - res, err := s.ProbeAuthForWorkspace(probeCtx, workspaceID) - ok := err == nil && res != nil && res.OK - errMsg := "" - switch { - case err != nil: - errMsg = err.Error() - case res != nil && !res.OK: - errMsg = res.Error - } - teamID, userID := "", "" - if res != nil && ok { - teamID = res.TeamID - userID = res.UserID - } - writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) - defer writeCancel() - if updateErr := s.store.UpdateAuthHealthForWorkspace(writeCtx, workspaceID, ok, errMsg, teamID, userID, time.Now().UTC()); updateErr != nil { - s.log.Warn("slack: update auth health failed", zap.Error(updateErr)) - } - if !ok { - s.invalidateClient(workspaceID) - } - s.fireProbeHook() -} - -func (s *Service) fireProbeHook() { - s.mu.Lock() - hook := s.probeHook - s.mu.Unlock() - if hook != nil { - hook() - } -} - -// Client exposes the cached client to the trigger and runtime. -func (s *Service) Client(ctx context.Context) (Client, error) { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return nil, err - } - return s.ClientForWorkspace(ctx, workspaceID) -} - -// ClientForWorkspace exposes a cached client for one workspace. -func (s *Service) ClientForWorkspace(ctx context.Context, workspaceID string) (Client, error) { - return s.clientFor(ctx, workspaceID) -} - -func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { - // clientFor is the single chokepoint for data-plane reads, so authorizing the - // resolved workspace here scopes them — an omitted workspace_id must not - // resolve another user's default workspace. - workspaceID, err := s.resolveWorkspaceID(ctx, workspaceID) - if err != nil { - return nil, err - } - s.mu.Lock() - if s.clients == nil { - s.clients = make(map[string]Client) - } - if s.clients[workspaceID] != nil { - c := s.clients[workspaceID] - s.mu.Unlock() - return c, nil - } - s.mu.Unlock() - - cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) - if err != nil { - return nil, err - } - if cfg == nil { - return nil, ErrNotConfigured - } - token, cookie, err := s.revealSecrets(ctx, workspaceID) - if err != nil { - return nil, err - } - if token == "" || cookie == "" { - return nil, ErrNotConfigured - } - client := s.clientFn(cfg, token, cookie) - s.mu.Lock() - defer s.mu.Unlock() - if s.clients == nil { - s.clients = make(map[string]Client) - } - if s.clients[workspaceID] != nil { - return s.clients[workspaceID], nil - } - s.clients[workspaceID] = client - return client, nil -} - -func (s *Service) revealSecrets(ctx context.Context, workspaceID string) (string, string, error) { - if s.secrets == nil { - return "", "", nil - } - token, err := s.revealSecret(ctx, SecretKeyForToken(workspaceID), SecretKeyToken) - if err != nil { - return "", "", fmt.Errorf("read slack token: %w", err) - } - cookie, err := s.revealSecret(ctx, SecretKeyForCookie(workspaceID), SecretKeyCookie) - if err != nil { - return "", "", fmt.Errorf("read slack cookie: %w", err) - } - return token, cookie, nil -} - -func (s *Service) invalidateClient(workspaceID string) { - s.mu.Lock() - if s.clients != nil { - delete(s.clients, workspaceID) - } - s.mu.Unlock() -} - -func (s *Service) resolveCredentials(ctx context.Context, workspaceID string, req *SetConfigRequest) (*SlackConfig, string, string, error) { - cfg := &SlackConfig{AuthMethod: req.AuthMethod} - token, cookie := req.Token, req.Cookie - if token != "" && cookie != "" { - return cfg, token, cookie, nil - } - if s.secrets == nil { - return nil, "", "", errors.New("no secret store configured") - } - if token == "" { - stored, err := s.revealSecret(ctx, SecretKeyForToken(workspaceID), SecretKeyToken) - if err != nil { - s.log.Warn("slack: token reveal failed", zap.Error(err)) - return nil, "", "", fmt.Errorf("read slack token: %w", err) - } - token = stored - } - if cookie == "" { - stored, err := s.revealSecret(ctx, SecretKeyForCookie(workspaceID), SecretKeyCookie) - if err != nil { - s.log.Warn("slack: cookie reveal failed", zap.Error(err)) - return nil, "", "", fmt.Errorf("read slack cookie: %w", err) - } - cookie = stored - } - if token == "" || cookie == "" { - return nil, "", "", errors.New("token and cookie required — paste both to test") - } - return cfg, token, cookie, nil -} - -func (s *Service) revealSecret(ctx context.Context, key, legacyKey string) (string, error) { - value, err := s.secrets.Reveal(ctx, key) - if err == nil && value != "" { - return value, nil - } - legacy, legacyErr := s.secrets.Reveal(ctx, legacyKey) - if legacyErr == nil && legacy != "" { - return legacy, nil - } - if err != nil { - return "", err - } - return "", legacyErr -} - -func (s *Service) defaultWorkspaceID() (string, error) { - return s.store.defaultWorkspaceID() -} - -func (s *Service) normalizeWorkspaceID(workspaceID string) (string, error) { - if workspaceID != "" { - return workspaceID, nil - } - return s.defaultWorkspaceID() -} - -func validateConfigRequest(req *SetConfigRequest) error { - if req.AuthMethod == "" { - req.AuthMethod = AuthMethodCookie - } - if req.AuthMethod != AuthMethodCookie { - return fmt.Errorf("unknown auth method: %q", req.AuthMethod) - } - req.CommandPrefix = strings.TrimSpace(req.CommandPrefix) - if req.CommandPrefix == "" { - req.CommandPrefix = DefaultCommandPrefix - } - if req.PollIntervalSeconds == 0 { - req.PollIntervalSeconds = DefaultPollIntervalSeconds - } - if req.PollIntervalSeconds < MinPollIntervalSeconds || req.PollIntervalSeconds > MaxPollIntervalSeconds { - return fmt.Errorf("pollIntervalSeconds must be between %d and %d", MinPollIntervalSeconds, MaxPollIntervalSeconds) - } - return nil -} diff --git a/apps/backend/internal/slack/store.go b/apps/backend/internal/slack/store.go deleted file mode 100644 index b139e3597d..0000000000 --- a/apps/backend/internal/slack/store.go +++ /dev/null @@ -1,458 +0,0 @@ -package slack - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/jmoiron/sqlx" - - "github.com/kandev/kandev/internal/integrations/workspacescope" -) - -// Store persists workspace-scoped Slack configuration. Secret values (xoxc -// token, d cookie) live in the shared encrypted secrets store, not here. -type Store struct { - db *sqlx.DB - ro *sqlx.DB - - defaultWorkspace workspacescope.DefaultResolver - - // migratedToWorkspace records the workspace_id that received a legacy - // singleton row during initSchema. Provider reads this to migrate the - // singleton secrets to the workspace-scoped keys. Empty when no migration ran. - migratedToWorkspace string -} - -// NewStore creates a new Store and initializes the schema if needed. -func NewStore(writer, reader *sqlx.DB) (*Store, error) { - s := &Store{db: writer, ro: reader} - if err := s.initSchema(); err != nil { - return nil, fmt.Errorf("slack schema init: %w", err) - } - return s, nil -} - -// MigratedFromWorkspace is kept for older provider call sites. It now returns -// the workspace_id that received a legacy singleton row during migration. -func (s *Store) MigratedFromWorkspace() string { - return s.migratedToWorkspace -} - -const createTablesSQL = ` - CREATE TABLE IF NOT EXISTS slack_configs ( - workspace_id TEXT PRIMARY KEY, - auth_method TEXT NOT NULL, - command_prefix TEXT NOT NULL DEFAULT '', - utility_agent_id TEXT NOT NULL DEFAULT '', - poll_interval_seconds INTEGER NOT NULL DEFAULT 30, - slack_team_id TEXT NOT NULL DEFAULT '', - slack_user_id TEXT NOT NULL DEFAULT '', - last_seen_ts TEXT NOT NULL DEFAULT '', - last_checked_at DATETIME, - last_ok INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - ); -` - -// singletonID is the synthetic primary key used by the legacy install-wide -// slack_configs table. -const singletonID = "singleton" - -func (s *Store) initSchema() error { - if err := s.migrateLegacySingletonTable(); err != nil { - return err - } - if _, err := s.db.Exec(createTablesSQL); err != nil { - return err - } - if err := s.addConfigColumns(); err != nil { - return err - } - return nil -} - -func (s *Store) addConfigColumns() error { - cols, err := s.tableColumns() - if err != nil { - return err - } - if len(cols) == 0 { - return nil - } - for _, col := range []struct { - name string - sql string - }{ - {"command_prefix", `ALTER TABLE slack_configs ADD COLUMN command_prefix TEXT NOT NULL DEFAULT ''`}, - {"utility_agent_id", `ALTER TABLE slack_configs ADD COLUMN utility_agent_id TEXT NOT NULL DEFAULT ''`}, - {"poll_interval_seconds", `ALTER TABLE slack_configs ADD COLUMN poll_interval_seconds INTEGER NOT NULL DEFAULT 30`}, - {"slack_team_id", `ALTER TABLE slack_configs ADD COLUMN slack_team_id TEXT NOT NULL DEFAULT ''`}, - {"slack_user_id", `ALTER TABLE slack_configs ADD COLUMN slack_user_id TEXT NOT NULL DEFAULT ''`}, - {"last_seen_ts", `ALTER TABLE slack_configs ADD COLUMN last_seen_ts TEXT NOT NULL DEFAULT ''`}, - {"last_checked_at", `ALTER TABLE slack_configs ADD COLUMN last_checked_at DATETIME`}, - {"last_ok", `ALTER TABLE slack_configs ADD COLUMN last_ok INTEGER NOT NULL DEFAULT 0`}, - {"last_error", `ALTER TABLE slack_configs ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`}, - } { - if _, ok := cols[col.name]; !ok { - if _, err := s.db.Exec(col.sql); err != nil { - return fmt.Errorf("add %s column: %w", col.name, err) - } - } - } - return nil -} - -// migrateLegacySingletonTable detects the install-wide singleton schema and -// rewrites it into the workspace-scoped shape. Existing workspace-keyed tables -// are already canonical and are left in place. -func (s *Store) migrateLegacySingletonTable() error { - cols, err := s.tableColumns() - if err != nil { - return err - } - if !isLegacySingletonConfig(cols) { - return nil - } - targetWorkspace, err := workspacescope.ResolveMigrationTarget(s.db) - if err != nil { - return err - } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - cfg, err := scanLegacySingletonConfig(tx, cols) - switch { - case errors.Is(err, sql.ErrNoRows): - if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil { - return err - } - return tx.Commit() - case err != nil: - return err - } - if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil { - return err - } - if _, err := tx.Exec(createTablesSQL); err != nil { - return err - } - if err := insertMigratedConfig(tx, targetWorkspace, cfg); err != nil { - return err - } - if err := tx.Commit(); err != nil { - return err - } - s.migratedToWorkspace = targetWorkspace - return nil -} - -func isLegacySingletonConfig(cols map[string]struct{}) bool { - if len(cols) == 0 { - return false - } - if _, hasWorkspace := cols["workspace_id"]; hasWorkspace { - return false - } - _, hasID := cols["id"] - return hasID -} - -type legacySlackConfig struct { - authMethod sql.NullString - commandPrefix sql.NullString - utilityAgentID sql.NullString - pollIntervalSeconds sql.NullInt64 - slackTeamID sql.NullString - slackUserID sql.NullString - lastSeenTS sql.NullString - lastCheckedAt sql.NullTime - lastOk sql.NullInt64 - lastError sql.NullString - createdAt, updatedAt sql.NullTime -} - -func scanLegacySingletonConfig(tx *sql.Tx, cols map[string]struct{}) (*legacySlackConfig, error) { - // Build the SELECT against only columns present in this database — the - // per-workspace shape evolved across patches, so upgrades from earlier - // intermediates need defaults for missing columns. - selectCols := "auth_method" - selectCols += pickCol(cols, "command_prefix", "''") - selectCols += pickCol(cols, "utility_agent_id", "''") - selectCols += pickCol(cols, "poll_interval_seconds", "30") - selectCols += pickCol(cols, "slack_team_id", "''") - selectCols += pickCol(cols, "slack_user_id", "''") - selectCols += pickCol(cols, "last_seen_ts", "''") - selectCols += pickCol(cols, "last_checked_at", "NULL") - selectCols += pickCol(cols, "last_ok", "0") - selectCols += pickCol(cols, "last_error", "''") - selectCols += ", created_at, updated_at" - - cfg := &legacySlackConfig{} - row := tx.QueryRow(`SELECT `+selectCols+` FROM slack_configs WHERE id = ? LIMIT 1`, singletonID) - err := row.Scan(&cfg.authMethod, - &cfg.commandPrefix, &cfg.utilityAgentID, &cfg.pollIntervalSeconds, - &cfg.slackTeamID, &cfg.slackUserID, &cfg.lastSeenTS, - &cfg.lastCheckedAt, &cfg.lastOk, &cfg.lastError, - &cfg.createdAt, &cfg.updatedAt) - return cfg, err -} - -func insertMigratedConfig(tx *sql.Tx, workspaceID string, cfg *legacySlackConfig) error { - pollSeconds := int(cfg.pollIntervalSeconds.Int64) - if pollSeconds == 0 { - pollSeconds = DefaultPollIntervalSeconds - } - _, err := tx.Exec(` - INSERT INTO slack_configs ( - workspace_id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, - slack_team_id, slack_user_id, last_seen_ts, - last_checked_at, last_ok, last_error, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - workspaceID, cfg.authMethod.String, cfg.commandPrefix.String, cfg.utilityAgentID.String, pollSeconds, - cfg.slackTeamID.String, cfg.slackUserID.String, cfg.lastSeenTS.String, - nullableTime(cfg.lastCheckedAt), cfg.lastOk.Int64, cfg.lastError.String, - nullableTime(cfg.createdAt), nullableTime(cfg.updatedAt)) - return err -} - -// pickCol returns ", " if the column is present in the legacy schema -// or ", AS " otherwise. Lets the migration SELECT defaults -// for columns that didn't exist in earlier intermediate schemas. -func pickCol(cols map[string]struct{}, name, fallback string) string { - if _, ok := cols[name]; ok { - return ", " + name - } - return ", " + fallback + " AS " + name -} - -func nullableTime(t sql.NullTime) interface{} { - if !t.Valid { - return nil - } - return t.Time -} - -// tableColumns returns the column-name set for slack_configs. SQLite doesn't -// support parameterised identifiers in PRAGMA, and the only table this -// migration logic ever inspects is slack_configs, so the table name is -// inlined as a literal. -func (s *Store) tableColumns() (map[string]struct{}, error) { - rows, err := s.db.Query(`PRAGMA table_info(slack_configs)`) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - cols := make(map[string]struct{}) - for rows.Next() { - var ( - cid int - name string - ctype string - notnull int - dflt sql.NullString - pk int - ) - if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { - return nil, err - } - cols[name] = struct{}{} - } - return cols, rows.Err() -} - -const selectConfigColumns = `workspace_id, auth_method, command_prefix, utility_agent_id, - poll_interval_seconds, - slack_team_id, slack_user_id, last_seen_ts, - last_checked_at, last_ok, last_error, created_at, updated_at` - -// GetConfig returns the default workspace Slack config, or nil when no row -// exists. New code should call GetConfigForWorkspace. -func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return nil, err - } - return s.GetConfigForWorkspace(ctx, workspaceID) -} - -// GetConfigForWorkspace returns the Slack config for a workspace, or nil when -// no row exists. -func (s *Store) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*SlackConfig, error) { - workspaceID, err := s.resolveWorkspaceID(workspaceID) - if err != nil { - return nil, err - } - var cfg SlackConfig - err = s.ro.GetContext(ctx, &cfg, - `SELECT `+selectConfigColumns+` FROM slack_configs WHERE workspace_id = ?`, workspaceID) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - return &cfg, nil -} - -// UpsertConfig inserts or updates the default workspace row. Health columns -// (last_*) and watermark columns (last_seen_ts, slack_team_id, slack_user_id) -// are owned by the poller / trigger and aren't touched here. -func (s *Store) UpsertConfig(ctx context.Context, cfg *SlackConfig) error { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return err - } - return s.UpsertConfigForWorkspace(ctx, workspaceID, cfg) -} - -// UpsertConfigForWorkspace inserts or updates a workspace row. -func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string, cfg *SlackConfig) error { - workspaceID, err := s.resolveWorkspaceID(workspaceID) - if err != nil { - return err - } - now := time.Now().UTC() - if cfg.CreatedAt.IsZero() { - cfg.CreatedAt = now - } - cfg.UpdatedAt = now - _, err = s.db.ExecContext(ctx, ` - INSERT INTO slack_configs ( - workspace_id, auth_method, - command_prefix, utility_agent_id, poll_interval_seconds, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(workspace_id) DO UPDATE SET - auth_method = excluded.auth_method, - command_prefix = excluded.command_prefix, - utility_agent_id = excluded.utility_agent_id, - poll_interval_seconds = excluded.poll_interval_seconds, - updated_at = excluded.updated_at`, - workspaceID, cfg.AuthMethod, - cfg.CommandPrefix, cfg.UtilityAgentID, cfg.PollIntervalSeconds, - cfg.CreatedAt, cfg.UpdatedAt) - return err -} - -// DeleteConfig removes the default workspace row. Secrets must be cleared separately. -func (s *Store) DeleteConfig(ctx context.Context) error { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return err - } - return s.DeleteConfigForWorkspace(ctx, workspaceID) -} - -// DeleteConfigForWorkspace removes the workspace row. Secrets must be cleared separately. -func (s *Store) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { - workspaceID, err := s.resolveWorkspaceID(workspaceID) - if err != nil { - return err - } - _, err = s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE workspace_id = ?`, workspaceID) - return err -} - -// HasConfig reports whether any config row exists. Used by the auth-health -// poller to decide whether to probe at all. -func (s *Store) HasConfig(ctx context.Context) (bool, error) { - var present int - err := s.ro.GetContext(ctx, &present, - `SELECT COUNT(*) FROM slack_configs`) - if err != nil { - return false, err - } - return present > 0, nil -} - -// ListConfigWorkspaceIDs returns every workspace with a saved Slack config. -func (s *Store) ListConfigWorkspaceIDs(ctx context.Context) ([]string, error) { - var ids []string - if err := s.ro.SelectContext(ctx, &ids, `SELECT workspace_id FROM slack_configs ORDER BY workspace_id`); err != nil { - return nil, err - } - return ids, nil -} - -// ListConfigs returns every Slack config row ordered by workspace. -func (s *Store) ListConfigs(ctx context.Context) ([]*SlackConfig, error) { - var configs []*SlackConfig - if err := s.ro.SelectContext(ctx, &configs, `SELECT `+selectConfigColumns+` FROM slack_configs ORDER BY workspace_id`); err != nil { - return nil, err - } - return configs, nil -} - -// UpdateAuthHealth records the result of a credential probe and (when -// supplied) the user/team identifiers captured during the same probe so the -// trigger can scope its searches without an extra round-trip. -func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return err - } - return s.UpdateAuthHealthForWorkspace(ctx, workspaceID, ok, errMsg, teamID, userID, checkedAt) -} - -// UpdateAuthHealthForWorkspace records the result of a credential probe for one workspace. -func (s *Store) UpdateAuthHealthForWorkspace(ctx context.Context, workspaceID string, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error { - workspaceID, err := s.resolveWorkspaceID(workspaceID) - if err != nil { - return err - } - if teamID != "" && userID != "" { - _, err = s.db.ExecContext(ctx, ` - UPDATE slack_configs - SET last_checked_at = ?, last_ok = ?, last_error = ?, - slack_team_id = ?, slack_user_id = ? - WHERE workspace_id = ?`, - checkedAt, ok, errMsg, teamID, userID, workspaceID) - return err - } - _, err = s.db.ExecContext(ctx, ` - UPDATE slack_configs - SET last_checked_at = ?, last_ok = ?, last_error = ? - WHERE workspace_id = ?`, - checkedAt, ok, errMsg, workspaceID) - return err -} - -// UpdateLastSeenTS advances the trigger's watermark. -func (s *Store) UpdateLastSeenTS(ctx context.Context, ts string) error { - workspaceID, err := s.defaultWorkspaceID() - if err != nil { - return err - } - return s.UpdateLastSeenTSForWorkspace(ctx, workspaceID, ts) -} - -// UpdateLastSeenTSForWorkspace advances one workspace trigger watermark. -func (s *Store) UpdateLastSeenTSForWorkspace(ctx context.Context, workspaceID, ts string) error { - workspaceID, err := s.resolveWorkspaceID(workspaceID) - if err != nil { - return err - } - _, err = s.db.ExecContext(ctx, - `UPDATE slack_configs SET last_seen_ts = ? WHERE workspace_id = ?`, - ts, workspaceID) - return err -} - -func (s *Store) defaultWorkspaceID() (string, error) { - return s.defaultWorkspace.Resolve(s.db) -} - -func (s *Store) resolveWorkspaceID(workspaceID string) (string, error) { - if workspaceID != "" { - return workspaceID, nil - } - return s.defaultWorkspaceID() -} diff --git a/apps/backend/internal/slack/store_test.go b/apps/backend/internal/slack/store_test.go deleted file mode 100644 index e517957f71..0000000000 --- a/apps/backend/internal/slack/store_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package slack - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/jmoiron/sqlx" - _ "github.com/mattn/go-sqlite3" -) - -func newTestStore(t *testing.T) *Store { - t.Helper() - raw, err := sql.Open("sqlite3", ":memory:") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - raw.SetMaxOpenConns(1) - raw.SetMaxIdleConns(1) - db := sqlx.NewDb(raw, "sqlite3") - t.Cleanup(func() { _ = db.Close() }) - store, err := NewStore(db, db) - if err != nil { - t.Fatalf("new store: %v", err) - } - return store -} - -func TestStore_UpsertGetDelete(t *testing.T) { - store := newTestStore(t) - ctx := context.Background() - - cfg := &SlackConfig{ - AuthMethod: AuthMethodCookie, - CommandPrefix: "!kandev", - UtilityAgentID: "ua-1", - PollIntervalSeconds: 30, - } - if err := store.UpsertConfig(ctx, cfg); err != nil { - t.Fatalf("upsert: %v", err) - } - - got, err := store.GetConfig(ctx) - if err != nil { - t.Fatalf("get: %v", err) - } - if got == nil { - t.Fatal("expected config, got nil") - } - if got.UtilityAgentID != "ua-1" || got.CommandPrefix != "!kandev" || got.PollIntervalSeconds != 30 { - t.Errorf("round-trip mismatch: %+v", got) - } - if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { - t.Error("timestamps not set") - } - - cfg.UtilityAgentID = "ua-2" - cfg.CommandPrefix = "!todo" - if err := store.UpsertConfig(ctx, cfg); err != nil { - t.Fatalf("update upsert: %v", err) - } - got2, _ := store.GetConfig(ctx) - if got2.UtilityAgentID != "ua-2" || got2.CommandPrefix != "!todo" { - t.Errorf("expected updates, got %+v", got2) - } - - if err := store.DeleteConfig(ctx); err != nil { - t.Fatalf("delete: %v", err) - } - gone, err := store.GetConfig(ctx) - if err != nil { - t.Fatalf("get after delete: %v", err) - } - if gone != nil { - t.Errorf("expected nil after delete, got %+v", gone) - } -} - -func TestStore_GetConfig_Missing(t *testing.T) { - store := newTestStore(t) - cfg, err := store.GetConfig(context.Background()) - if err != nil { - t.Fatalf("get: %v", err) - } - if cfg != nil { - t.Errorf("expected nil for missing config, got %+v", cfg) - } -} - -func TestStore_HasConfig(t *testing.T) { - store := newTestStore(t) - ctx := context.Background() - has, err := store.HasConfig(ctx) - if err != nil || has { - t.Fatalf("expected no config, got has=%v err=%v", has, err) - } - _ = store.UpsertConfig(ctx, &SlackConfig{ - AuthMethod: AuthMethodCookie, - UtilityAgentID: "ua", - PollIntervalSeconds: 30, - }) - has, err = store.HasConfig(ctx) - if err != nil || !has { - t.Fatalf("expected has=true after upsert, got has=%v err=%v", has, err) - } -} - -func TestStore_UpdateAuthHealth(t *testing.T) { - store := newTestStore(t) - ctx := context.Background() - _ = store.UpsertConfig(ctx, &SlackConfig{ - AuthMethod: AuthMethodCookie, - UtilityAgentID: "ua", - PollIntervalSeconds: 30, - }) - - now := time.Now().UTC() - if err := store.UpdateAuthHealth(ctx, true, "", "T0001", "U0001", now); err != nil { - t.Fatalf("update auth health: %v", err) - } - got, _ := store.GetConfig(ctx) - if !got.LastOk || got.SlackTeamID != "T0001" || got.SlackUserID != "U0001" { - t.Errorf("expected health to be recorded, got %+v", got) - } - - if err := store.UpdateAuthHealth(ctx, false, "expired", "", "", now.Add(time.Minute)); err != nil { - t.Fatalf("update auth health 2: %v", err) - } - got2, _ := store.GetConfig(ctx) - if got2.LastOk { - t.Error("expected lastOk false") - } - if got2.LastError != "expired" { - t.Errorf("expected error message, got %q", got2.LastError) - } - if got2.SlackTeamID != "T0001" || got2.SlackUserID != "U0001" { - t.Errorf("expected captured ids preserved across failure, got team=%q user=%q", - got2.SlackTeamID, got2.SlackUserID) - } -} - -func TestStore_UpdateLastSeenTS(t *testing.T) { - store := newTestStore(t) - ctx := context.Background() - _ = store.UpsertConfig(ctx, &SlackConfig{ - AuthMethod: AuthMethodCookie, - UtilityAgentID: "ua", - PollIntervalSeconds: 30, - }) - if err := store.UpdateLastSeenTS(ctx, "1714659000.000100"); err != nil { - t.Fatalf("update ts: %v", err) - } - got, _ := store.GetConfig(ctx) - if got.LastSeenTS != "1714659000.000100" { - t.Errorf("expected ts persisted, got %q", got.LastSeenTS) - } -} - -func TestStore_MigrateSingletonConfigToActiveWorkspace(t *testing.T) { - raw, err := sql.Open("sqlite3", ":memory:") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - raw.SetMaxOpenConns(1) - raw.SetMaxIdleConns(1) - db := sqlx.NewDb(raw, "sqlite3") - t.Cleanup(func() { _ = db.Close() }) - - now := time.Now().UTC() - checkedAt := now.Add(-5 * time.Minute).Truncate(time.Second) - if _, err := db.Exec(` - CREATE TABLE workspaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL - ); - CREATE TABLE users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL, - settings TEXT NOT NULL DEFAULT '{}', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL - ); - CREATE TABLE slack_configs ( - id TEXT PRIMARY KEY CHECK(id = 'singleton'), - auth_method TEXT NOT NULL, - command_prefix TEXT NOT NULL DEFAULT '', - utility_agent_id TEXT NOT NULL DEFAULT '', - poll_interval_seconds INTEGER NOT NULL DEFAULT 30, - slack_team_id TEXT NOT NULL DEFAULT '', - slack_user_id TEXT NOT NULL DEFAULT '', - last_seen_ts TEXT NOT NULL DEFAULT '', - last_checked_at DATETIME, - last_ok INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - ); - INSERT INTO workspaces (id, name, created_at, updated_at) - VALUES ('ws-first', 'First', ?, ?), ('ws-active', 'Active', ?, ?); - INSERT INTO users (id, email, settings, created_at, updated_at) - VALUES ('default-user', 'default@kandev.local', '{"workspace_id":"ws-active"}', ?, ?); - INSERT INTO slack_configs ( - id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, - slack_team_id, slack_user_id, last_seen_ts, last_checked_at, last_ok, last_error, created_at, updated_at - ) VALUES ('singleton', 'cookie', '!kandev', 'ua-active', 60, 'T-active', 'U-active', '200.0', ?, 1, 'healthy', ?, ?); - `, now.Add(-time.Hour), now.Add(-time.Hour), now, now, now, now, checkedAt, now, now); err != nil { - t.Fatalf("seed singleton schema: %v", err) - } - - store, err := NewStore(db, db) - if err != nil { - t.Fatalf("new store: %v", err) - } - if got := store.MigratedFromWorkspace(); got != "ws-active" { - t.Fatalf("expected singleton config migrated to active workspace, got %q", got) - } - cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-active") - if err != nil || cfg == nil { - t.Fatalf("get active workspace config: cfg=%v err=%v", cfg, err) - } - if cfg.UtilityAgentID != "ua-active" || cfg.CommandPrefix != "!kandev" || cfg.PollIntervalSeconds != 60 { - t.Errorf("unexpected migrated settings: %+v", cfg) - } - if cfg.SlackTeamID != "T-active" || cfg.SlackUserID != "U-active" || cfg.LastSeenTS != "200.0" { - t.Errorf("unexpected migrated runtime state: %+v", cfg) - } - if !cfg.LastOk || cfg.LastError != "healthy" { - t.Errorf("unexpected migrated health state: %+v", cfg) - } - if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(checkedAt) { - t.Errorf("expected last_checked_at=%v, got %v", checkedAt, cfg.LastCheckedAt) - } -} - -func TestStore_LegacyPerWorkspaceMigration(t *testing.T) { - // Simulate a deployment running the pre-singleton schema: a slack_configs - // table keyed by workspace_id, with the most-recently-updated row to be - // promoted into the singleton on upgrade. - raw, err := sql.Open("sqlite3", ":memory:") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - raw.SetMaxOpenConns(1) - raw.SetMaxIdleConns(1) - db := sqlx.NewDb(raw, "sqlite3") - t.Cleanup(func() { _ = db.Close() }) - - if _, err := db.Exec(` - CREATE TABLE slack_configs ( - workspace_id TEXT PRIMARY KEY, - auth_method TEXT NOT NULL, - command_prefix TEXT NOT NULL DEFAULT '', - utility_agent_id TEXT NOT NULL DEFAULT '', - poll_interval_seconds INTEGER NOT NULL DEFAULT 30, - slack_team_id TEXT NOT NULL DEFAULT '', - slack_user_id TEXT NOT NULL DEFAULT '', - last_seen_ts TEXT NOT NULL DEFAULT '', - last_checked_at DATETIME, - last_ok INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - )`); err != nil { - t.Fatalf("create legacy table: %v", err) - } - now := time.Now().UTC() - older := now.Add(-time.Hour) - if _, err := db.Exec(` - INSERT INTO slack_configs (workspace_id, auth_method, command_prefix, utility_agent_id, - poll_interval_seconds, slack_team_id, slack_user_id, last_seen_ts, - last_checked_at, last_ok, last_error, created_at, updated_at) - VALUES - ('ws-old', 'cookie', '!kandev', 'ua-old', 30, 'T-old', 'U-old', '100.0', ?, 1, '', ?, ?), - ('ws-new', 'cookie', '!kandev', 'ua-new', 60, 'T-new', 'U-new', '200.0', ?, 1, '', ?, ?)`, - older, older, older, now, now, now); err != nil { - t.Fatalf("seed legacy rows: %v", err) - } - - store, err := NewStore(db, db) - if err != nil { - t.Fatalf("new store: %v", err) - } - if got := store.MigratedFromWorkspace(); got != "" { - t.Errorf("expected no singleton migration, got %q", got) - } - cfg, err := store.GetConfigForWorkspace(context.Background(), "ws-new") - if err != nil || cfg == nil { - t.Fatalf("get workspace config: cfg=%v err=%v", cfg, err) - } - if cfg.UtilityAgentID != "ua-new" || cfg.CommandPrefix != "!kandev" || cfg.PollIntervalSeconds != 60 { - t.Errorf("expected ws-new fields preserved, got %+v", cfg) - } - if cfg.SlackTeamID != "T-new" || cfg.SlackUserID != "U-new" || cfg.LastSeenTS != "200.0" { - t.Errorf("expected ws-new runtime state preserved, got %+v", cfg) - } -} - -func TestStore_LegacyEmptyTableMigration(t *testing.T) { - // A deployment that had the legacy table created but never inserted a - // row should still upgrade cleanly without crashing on the empty SELECT. - raw, _ := sql.Open("sqlite3", ":memory:") - raw.SetMaxOpenConns(1) - db := sqlx.NewDb(raw, "sqlite3") - t.Cleanup(func() { _ = db.Close() }) - if _, err := db.Exec(` - CREATE TABLE slack_configs ( - workspace_id TEXT PRIMARY KEY, - auth_method TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - )`); err != nil { - t.Fatalf("create empty legacy: %v", err) - } - store, err := NewStore(db, db) - if err != nil { - t.Fatalf("new store on empty legacy: %v", err) - } - if store.MigratedFromWorkspace() != "" { - t.Error("expected no migration when legacy table is empty") - } - cfg, _ := store.GetConfig(context.Background()) - if cfg != nil { - t.Errorf("expected no config post empty-legacy upgrade, got %+v", cfg) - } -} diff --git a/apps/backend/internal/slack/trigger.go b/apps/backend/internal/slack/trigger.go deleted file mode 100644 index 85d4365b30..0000000000 --- a/apps/backend/internal/slack/trigger.go +++ /dev/null @@ -1,322 +0,0 @@ -package slack - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/common/logger" -) - -// DefaultBaseInterval is how often the trigger's base loop wakes up to check -// whether a scan is due. The actual scan cadence comes from -// SlackConfig.PollIntervalSeconds; this just bounds how soon a freshly-due -// scan gets noticed. -const DefaultBaseInterval = 5 * time.Second - -// AcknowledgeReaction is the emoji we react with on a matched message before -// the agent runs. -const AcknowledgeReaction = "eyes" - -// Trigger drives the polling loop that turns matched Slack messages into -// utility-agent runs. The loop is install-wide singleton — there's at most -// one Slack-search-per-PollIntervalSeconds running, regardless of how many -// Kandev workspaces exist. -type Trigger struct { - svc *Service - log *logger.Logger - interval time.Duration - - mu sync.Mutex - cancel context.CancelFunc - wg sync.WaitGroup - started bool - - // lastScannedAt tracks when each workspace last ran a scan so its - // PollIntervalSeconds is honoured independently. In-memory by design — - // backend restart triggers an immediate scan, which is fine. - scannedMu sync.Mutex - lastScannedAt map[string]time.Time -} - -// NewTrigger returns a Trigger using the default base ticker. -func NewTrigger(svc *Service, log *logger.Logger) *Trigger { - if svc == nil { - return nil - } - return &Trigger{svc: svc, log: log, interval: DefaultBaseInterval} -} - -// Start launches the loop. -func (t *Trigger) Start(ctx context.Context) { - if t == nil { - return - } - t.mu.Lock() - defer t.mu.Unlock() - if t.started { - return - } - t.started = true - ctx, t.cancel = context.WithCancel(ctx) - t.wg.Add(1) - go t.loop(ctx) - t.log.Info("slack trigger started") -} - -// Stop cancels the loop and waits for it to drain. -func (t *Trigger) Stop() { - if t == nil { - return - } - t.mu.Lock() - if !t.started { - t.mu.Unlock() - return - } - cancel := t.cancel - t.mu.Unlock() - if cancel != nil { - cancel() - } - t.wg.Wait() - t.mu.Lock() - t.started = false - t.mu.Unlock() - t.log.Info("slack trigger stopped") -} - -func (t *Trigger) loop(ctx context.Context) { - defer t.wg.Done() - t.tick(ctx) - ticker := time.NewTicker(t.interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - t.tick(ctx) - } - } -} - -// tick scans Slack once if the configured cadence has elapsed. -func (t *Trigger) tick(ctx context.Context) { - if t.svc.Runner() == nil { - return - } - configs, err := t.svc.Store().ListConfigs(ctx) - if err != nil || len(configs) == 0 { - return - } - for _, cfg := range configs { - if !t.dueForScan(cfg.WorkspaceID, cfg.PollIntervalSeconds) { - continue - } - t.markScanned(cfg.WorkspaceID) - t.scanWorkspace(ctx, cfg) - } -} - -func (t *Trigger) scanWorkspace(ctx context.Context, cfg *SlackConfig) { - if !cfg.LastOk || cfg.SlackUserID == "" { - return - } - if cfg.UtilityAgentID == "" { - return - } - prefix := cfg.CommandPrefix - if prefix == "" { - prefix = DefaultCommandPrefix - } - client, err := t.svc.ClientForWorkspace(ctx, cfg.WorkspaceID) - if err != nil { - return - } - query := fmt.Sprintf("from:<@%s> %q", cfg.SlackUserID, prefix) - matches, err := client.SearchMessages(ctx, query) - if err != nil { - t.log.Warn("slack trigger: search failed", zap.Error(err)) - return - } - t.processMatches(ctx, cfg, prefix, matches, client) -} - -func (t *Trigger) processMatches(ctx context.Context, cfg *SlackConfig, prefix string, matches []SlackMessage, client Client) { - fresh := newMatchesAfter(matches, cfg.LastSeenTS, prefix) - if len(fresh) == 0 { - return - } - highest := cfg.LastSeenTS - for _, m := range fresh { - if ctx.Err() != nil { - return - } - _, err := t.handleOne(ctx, cfg, prefix, m, client) - if err != nil { - t.log.Warn("slack trigger: handle match failed", - zap.String("ts", m.TS), zap.Error(err)) - if errors.Is(err, ErrNoUtilityAgent) { - if compareTS(m.TS, highest) > 0 { - highest = m.TS - } - continue - } - // Recoverable failure: stop the loop here so the watermark stays - // at the last consecutive success. See the original commit for - // why this is a `break` (not `return`) — the post-loop - // UpdateLastSeenTS still needs to persist prior successes. - break - } - if compareTS(m.TS, highest) > 0 { - highest = m.TS - } - } - if highest != cfg.LastSeenTS { - if err := t.svc.Store().UpdateLastSeenTSForWorkspace(ctx, cfg.WorkspaceID, highest); err != nil { - t.log.Warn("slack trigger: advance watermark failed", zap.Error(err)) - } - } -} - -// handleOne runs the per-match flow: ack reaction, fetch thread, run the -// utility agent, post the reply in-thread. -func (t *Trigger) handleOne(ctx context.Context, cfg *SlackConfig, prefix string, msg SlackMessage, client Client) (string, error) { - if err := client.ReactionsAdd(ctx, msg.ChannelID, msg.TS, AcknowledgeReaction); err != nil { - t.log.Warn("slack trigger: ack reaction failed", - zap.String("ts", msg.TS), zap.Error(err)) - } - - thread, err := client.ConversationsReplies(ctx, msg.ChannelID, msg.ThreadTS, msg.TS) - if err != nil { - return "", fmt.Errorf("fetch thread: %w", err) - } - permalink := msg.Permalink - if permalink == "" { - permalink, _ = client.ChatGetPermalink(ctx, msg.ChannelID, msg.TS) - } - instruction := stripPrefix(msg.Text, prefix) - - runner := t.svc.Runner() - if runner == nil { - return "", errors.New("agent runner not configured") - } - reply, err := runner.RunForMatch(ctx, cfg, msg, instruction, permalink, thread) - if err != nil { - return "", err - } - t.replyInThread(ctx, client, msg, reply) - return reply, nil -} - -func (t *Trigger) replyInThread(ctx context.Context, client Client, msg SlackMessage, body string) { - threadTS := msg.ThreadTS - if threadTS == "" { - threadTS = msg.TS - } - if err := client.ChatPostMessage(ctx, msg.ChannelID, threadTS, body); err != nil { - t.log.Warn("slack trigger: reply post failed", - zap.String("ts", msg.TS), zap.Error(err)) - } -} - -// dueForScan reports whether the configured interval has elapsed since the -// last scan. A zero/never timestamp counts as due. -func (t *Trigger) dueForScan(workspaceID string, intervalSeconds int) bool { - if intervalSeconds < MinPollIntervalSeconds { - intervalSeconds = DefaultPollIntervalSeconds - } - t.scannedMu.Lock() - defer t.scannedMu.Unlock() - if t.lastScannedAt == nil { - return true - } - last := t.lastScannedAt[normalizeWorkspaceID(workspaceID)] - if last.IsZero() { - return true - } - return time.Since(last) >= time.Duration(intervalSeconds)*time.Second -} - -func (t *Trigger) markScanned(workspaceID string) { - t.scannedMu.Lock() - if t.lastScannedAt == nil { - t.lastScannedAt = make(map[string]time.Time) - } - t.lastScannedAt[normalizeWorkspaceID(workspaceID)] = time.Now() - t.scannedMu.Unlock() -} - -func normalizeWorkspaceID(workspaceID string) string { - if workspaceID == "" { - return "default" - } - return workspaceID -} - -func newMatchesAfter(matches []SlackMessage, watermark, prefix string) []SlackMessage { - out := make([]SlackMessage, 0, len(matches)) - for _, m := range matches { - if compareTS(m.TS, watermark) <= 0 { - continue - } - if !startsWithPrefix(m.Text, prefix) { - continue - } - out = append(out, m) - } - sort.SliceStable(out, func(i, j int) bool { - return compareTS(out[i].TS, out[j].TS) < 0 - }) - return out -} - -func startsWithPrefix(text, prefix string) bool { - t := strings.TrimSpace(text) - t = strings.TrimPrefix(t, "> ") - t = strings.TrimSpace(t) - if !strings.HasPrefix(strings.ToLower(t), strings.ToLower(prefix)) { - return false - } - rest := t[len(prefix):] - if rest == "" { - return true - } - r := rest[0] - return r == ' ' || r == '\t' || r == '\n' || r == ':' || r == ',' -} - -func stripPrefix(text, prefix string) string { - t := strings.TrimSpace(text) - t = strings.TrimPrefix(t, "> ") - t = strings.TrimSpace(t) - if len(t) >= len(prefix) && strings.EqualFold(t[:len(prefix)], prefix) { - t = t[len(prefix):] - } - t = strings.TrimSpace(t) - t = strings.TrimLeft(t, ":, ") - return strings.TrimSpace(t) -} - -func compareTS(a, b string) int { - if a == b { - return 0 - } - if a == "" { - return -1 - } - if b == "" { - return 1 - } - if a < b { - return -1 - } - return 1 -} diff --git a/apps/backend/internal/slack/trigger_test.go b/apps/backend/internal/slack/trigger_test.go deleted file mode 100644 index 4836b4681b..0000000000 --- a/apps/backend/internal/slack/trigger_test.go +++ /dev/null @@ -1,634 +0,0 @@ -package slack - -import ( - "context" - "errors" - "strings" - "sync" - "testing" - "time" - - agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" - "github.com/kandev/kandev/internal/common/logger" - utilitymodels "github.com/kandev/kandev/internal/utility/models" - utilityservice "github.com/kandev/kandev/internal/utility/service" - "github.com/kandev/kandev/internal/utility/template" -) - -// --- prefix / parser tests --- - -func TestStartsWithPrefix(t *testing.T) { - cases := []struct { - text string - prefix string - want bool - }{ - {"!kandev fix login bug", "!kandev", true}, - {" !kandev fix login bug", "!kandev", true}, - {"!kandev: do the thing", "!kandev", true}, - {"!kandev", "!kandev", true}, - {"!kandevops handle this", "!kandev", false}, - {"hey !kandev fix this", "!kandev", false}, - {"> !kandev quoted reply", "!kandev", true}, - {"!KANDEV case insensitive", "!kandev", true}, - {"", "!kandev", false}, - } - for _, c := range cases { - if got := startsWithPrefix(c.text, c.prefix); got != c.want { - t.Errorf("startsWithPrefix(%q, %q) = %v, want %v", c.text, c.prefix, got, c.want) - } - } -} - -func TestStripPrefix(t *testing.T) { - cases := []struct { - text, prefix, want string - }{ - {"!kandev fix login bug", "!kandev", "fix login bug"}, - {" !kandev fix login bug ", "!kandev", "fix login bug"}, - {"!kandev: handle this", "!kandev", "handle this"}, - {"!kandev,please", "!kandev", "please"}, - {"> !kandev quoted reply", "!kandev", "quoted reply"}, - {"!kandev", "!kandev", ""}, - } - for _, c := range cases { - if got := stripPrefix(c.text, c.prefix); got != c.want { - t.Errorf("stripPrefix(%q, %q) = %q, want %q", c.text, c.prefix, got, c.want) - } - } -} - -func TestNewMatchesAfter(t *testing.T) { - matches := []SlackMessage{ - {TS: "100.000005", Text: "!kandev five"}, - {TS: "100.000003", Text: "!kandev three"}, - {TS: "100.000004", Text: "!kandevops noop"}, - {TS: "100.000002", Text: "!kandev two"}, - } - got := newMatchesAfter(matches, "100.000003", "!kandev") - if len(got) != 1 || got[0].TS != "100.000005" { - t.Errorf("expected only the freshest matching message, got %+v", got) - } -} - -func TestNewMatchesAfter_OrdersOldestFirst(t *testing.T) { - matches := []SlackMessage{ - {TS: "100.000010", Text: "!kandev ten"}, - {TS: "100.000005", Text: "!kandev five"}, - {TS: "100.000007", Text: "!kandev seven"}, - } - got := newMatchesAfter(matches, "", "!kandev") - if len(got) != 3 { - t.Fatalf("expected 3, got %d", len(got)) - } - if got[0].TS != "100.000005" || got[1].TS != "100.000007" || got[2].TS != "100.000010" { - t.Errorf("expected oldest-first order, got %v", []string{got[0].TS, got[1].TS, got[2].TS}) - } -} - -func TestCompareTS(t *testing.T) { - cases := []struct { - a, b string - want int - }{ - {"", "", 0}, - {"", "100.0", -1}, - {"100.0", "", 1}, - {"100.0", "100.0", 0}, - {"100.0", "200.0", -1}, - {"200.0", "100.0", 1}, - } - for _, c := range cases { - if got := compareTS(c.a, c.b); got != c.want { - t.Errorf("compareTS(%q, %q) = %d, want %d", c.a, c.b, got, c.want) - } - } -} - -// --- prompt composition --- - -func TestComposePrompt_DefaultSystemWhenEmptyTemplate(t *testing.T) { - r := &Runner{} - thread := []SlackMessage{{TS: "100", UserName: "alice", Text: "thread message"}} - prompt := r.composePrompt("", "", - SlackMessage{UserName: "alice"}, "do thing", "https://slack/p100", thread) - if !strings.Contains(prompt, "Kandev triage assistant") { - t.Error("expected default system prompt when template is empty") - } - if !strings.Contains(prompt, "https://slack/p100") { - t.Error("expected permalink in appended block") - } - if !strings.Contains(prompt, "do thing") { - t.Error("expected instruction in appended block") - } -} - -func TestComposePrompt_TemplateOwnsLayoutWhenSubstituted(t *testing.T) { - r := &Runner{} - rawTemplate := "Custom: {{SlackInstruction}} for {{KandevWorkspaceID}}" - resolved := "Custom: do thing for ws-1" - prompt := r.composePrompt(rawTemplate, resolved, SlackMessage{}, "do thing", "", nil) - if prompt != "Custom: do thing for ws-1" { - t.Errorf("expected the resolved template only, got: %q", prompt) - } -} - -func TestComposePrompt_FallsBackWhenTemplateHasNoVars(t *testing.T) { - // Template with no Slack-specific vars → resolved == raw (after trim) → - // fall back so the agent still gets the Slack context block appended. - r := &Runner{} - template := "Be concise." - prompt := r.composePrompt(template, template, - SlackMessage{UserName: "alice"}, "do x", "https://slack/p", nil) - if !strings.Contains(prompt, "Be concise.") { - t.Error("expected user template at top") - } - if !strings.Contains(prompt, "Slack thread:") && !strings.Contains(prompt, "Request from") { - t.Error("expected Slack context block to be appended for non-templated systems") - } -} - -// --- runner end-to-end --- - -type fakeUtility struct { - agents map[string]*utilitymodels.UtilityAgent -} - -func (f fakeUtility) GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error) { - a, ok := f.agents[id] - if !ok { - return nil, errors.New("not found") - } - return a, nil -} - -func (f fakeUtility) PreparePromptRequest( - ctx context.Context, - utilityID string, - tmplCtx *template.Context, - defaults *utilityservice.DefaultUtilitySettings, - sessionless bool, -) (*utilityservice.PromptRequest, error) { - a, ok := f.agents[utilityID] - if !ok { - return nil, errors.New("not found") - } - // Resolve via the real engine so Custom vars actually substitute. - resolved, err := template.NewEngine().ResolveWithOptions(a.Prompt, tmplCtx, template.ResolveOptions{MissingAsEmpty: sessionless}) - if err != nil { - return nil, err - } - agentCLI, model := a.AgentID, a.Model - if defaults != nil { - if model == "" { - agentCLI = defaults.AgentID - model = defaults.Model - } else if agentCLI == "" { - agentCLI = defaults.AgentID - } - } - return &utilityservice.PromptRequest{ - UtilityID: utilityID, - ResolvedPrompt: resolved, - AgentCLI: agentCLI, - Model: model, - }, nil -} - -type fakeUserDefaults struct { - agentID, model string - err error -} - -func (f fakeUserDefaults) GetDefaultUtilitySettings(ctx context.Context) (string, string, error) { - return f.agentID, f.model, f.err -} - -type fakeHostRunner struct { - mu sync.Mutex - calls []hostCall - response string - err error -} - -type hostCall struct { - agentType, model, mode, prompt string - mcpServers []agentctlutil.MCPServerDTO -} - -func (f *fakeHostRunner) ExecutePromptWithMCP( - ctx context.Context, - agentType, model, mode, prompt string, - mcpServers []agentctlutil.MCPServerDTO, -) (HostPromptResult, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.calls = append(f.calls, hostCall{agentType, model, mode, prompt, mcpServers}) - if f.err != nil { - return HostPromptResult{}, f.err - } - return HostPromptResult{Response: f.response, Model: model, DurationMs: 1}, nil -} - -func TestRunner_RunForMatch_HappyPath(t *testing.T) { - utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ - "ua-1": { - ID: "ua-1", Name: "Slack triage", AgentID: "claude-acp", - Model: "claude-haiku-4-5", Enabled: true, - Prompt: "You are a Slack triage helper.", - }, - }} - host := &fakeHostRunner{response: "Created task 'Fix checkout bug' in workspace ws-1."} - mcp := []MCPDescriptor{{Name: "kandev", URL: "http://localhost:38429/mcp"}} - r := NewRunner(utility, fakeUserDefaults{}, host, mcp, logger.Default()) - - cfg := &SlackConfig{UtilityAgentID: "ua-1"} - msg := SlackMessage{TS: "100.0001", ChannelID: "C1", UserID: "U1", UserName: "alice", Text: "!kandev fix checkout"} - thread := []SlackMessage{msg} - - reply, err := r.RunForMatch(context.Background(), cfg, msg, "fix checkout", "https://example/p100", thread) - if err != nil { - t.Fatalf("RunForMatch: %v", err) - } - if !strings.Contains(reply, "Created task") { - t.Errorf("unexpected reply: %q", reply) - } - if len(host.calls) != 1 { - t.Fatalf("expected one host call, got %d", len(host.calls)) - } - call := host.calls[0] - if call.agentType != "claude-acp" || call.model != "claude-haiku-4-5" { - t.Errorf("expected agent_id+model passthrough, got %+v", call) - } - if !strings.Contains(call.prompt, "You are a Slack triage helper.") { - t.Error("expected utility agent's custom system prompt to be used") - } - if !strings.Contains(call.prompt, "fix checkout") { - t.Error("expected user instruction in prompt") - } - if len(call.mcpServers) != 1 || call.mcpServers[0].Name != "kandev" || call.mcpServers[0].URL != "http://localhost:38429/mcp" { - t.Errorf("expected kandev MCP wired, got %+v", call.mcpServers) - } - if call.mcpServers[0].Type != "http" { - t.Errorf("expected http transport, got %q", call.mcpServers[0].Type) - } -} - -func TestRunner_RunForMatch_NoUtilityAgent(t *testing.T) { - r := NewRunner(fakeUtility{}, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default()) - cfg := &SlackConfig{} - _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil) - if !errors.Is(err, ErrNoUtilityAgent) { - t.Errorf("expected ErrNoUtilityAgent, got %v", err) - } -} - -func TestRunner_RunForMatch_DisabledBuiltinFallsBackToDefaults(t *testing.T) { - // Built-in utility agents ship with enabled=false and empty agent_id / - // model — they're meant to use the user's default agent + model. The - // runner shouldn't reject this configuration; PreparePromptRequest - // substitutes the defaults at resolution time, matching how the rest of - // Kandev runs utility agents. - utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ - "builtin-x": {ID: "builtin-x", Name: "x", Builtin: true, Enabled: false, Prompt: ""}, - }} - host := &fakeHostRunner{response: "ok"} - r := NewRunner(utility, fakeUserDefaults{agentID: "claude-acp", model: "claude-haiku-4-5"}, - host, nil, logger.Default()) - cfg := &SlackConfig{UtilityAgentID: "builtin-x"} - if _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil); err != nil { - t.Fatalf("expected disabled built-in to run with user defaults, got: %v", err) - } - if len(host.calls) != 1 || host.calls[0].agentType != "claude-acp" || host.calls[0].model != "claude-haiku-4-5" { - t.Errorf("expected default agent_id+model used, got %+v", host.calls) - } -} - -func TestRunner_RunForMatch_NoAgentResolvedErrors(t *testing.T) { - // When the chosen agent has no agent_id and there are no user defaults - // either, we can't pick an inference subprocess to spawn — surface a - // clear error rather than silently failing. - utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ - "ua-1": {ID: "ua-1", Name: "no-config", Builtin: false, Enabled: false}, - }} - r := NewRunner(utility, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default()) - cfg := &SlackConfig{UtilityAgentID: "ua-1"} - _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil) - if err == nil || !strings.Contains(err.Error(), "no agent_id resolved") { - t.Errorf("expected 'no agent_id resolved' error, got %v", err) - } -} - -func TestRunner_RunForMatch_EmptyResponseFallsBack(t *testing.T) { - utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ - "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, - }} - host := &fakeHostRunner{response: ""} - r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) - cfg := &SlackConfig{UtilityAgentID: "ua-1"} - reply, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil) - if err != nil { - t.Fatalf("expected success with fallback reply, got error: %v", err) - } - if reply == "" { - t.Error("expected fallback reply text, got empty string") - } -} - -func TestRunner_RunForMatch_HostErrorBubbles(t *testing.T) { - utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ - "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, - }} - host := &fakeHostRunner{err: errors.New("subprocess crashed")} - r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) - cfg := &SlackConfig{UtilityAgentID: "ua-1"} - _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil) - if err == nil || !strings.Contains(err.Error(), "subprocess crashed") { - t.Errorf("expected host error to bubble, got %v", err) - } -} - -// --- trigger end-to-end with fakes --- - -type fakeClient struct { - mu sync.Mutex - thread []SlackMessage - permalink string - posted []postRecord - reactions []reactionRecord -} - -type postRecord struct{ channel, threadTS, text string } -type reactionRecord struct{ channel, ts, name string } - -func (f *fakeClient) AuthTest(ctx context.Context) (*TestConnectionResult, error) { - return &TestConnectionResult{OK: true, UserID: "U1", TeamID: "T1"}, nil -} -func (f *fakeClient) SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) { - return nil, nil -} -func (f *fakeClient) ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) { - return f.thread, nil -} -func (f *fakeClient) ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) { - return f.permalink, nil -} -func (f *fakeClient) ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.posted = append(f.posted, postRecord{channelID, threadTS, text}) - return nil -} -func (f *fakeClient) ReactionsAdd(ctx context.Context, channelID, ts, name string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.reactions = append(f.reactions, reactionRecord{channelID, ts, name}) - return nil -} - -func TestTrigger_HandleOne_PostsAgentReplyInThread(t *testing.T) { - utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ - "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "claude-haiku-4-5", Enabled: true}, - }} - host := &fakeHostRunner{response: "Created task X."} - - store := newTestStore(t) - factory := func(_ *SlackConfig, _ string, _ string) Client { - return &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev x"}}} - } - svc := NewService(store, nil, NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()), factory, logger.Default()) - ctx := context.Background() - _ = svc.Store().UpsertConfig(ctx, &SlackConfig{ - AuthMethod: AuthMethodCookie, - UtilityAgentID: "ua-1", CommandPrefix: "!kandev", - }) - - client := &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev fix it"}}, permalink: "https://example/p100"} - svc.clients["default"] = client - - cfg, _ := svc.Store().GetConfig(ctx) - trig := NewTrigger(svc, logger.Default()) - reply, err := trig.handleOne(ctx, cfg, "!kandev", SlackMessage{ - TS: "100.0001", ChannelID: "C1", Text: "!kandev fix it", - }, client) - if err != nil { - t.Fatalf("handleOne: %v", err) - } - if reply != "Created task X." { - t.Errorf("expected agent reply passthrough, got %q", reply) - } - if len(client.posted) != 1 { - t.Fatalf("expected one Slack reply, got %d", len(client.posted)) - } - if client.posted[0].text != "Created task X." || client.posted[0].channel != "C1" { - t.Errorf("unexpected slack post: %+v", client.posted[0]) - } - if client.posted[0].threadTS != "100.0001" { - t.Errorf("expected thread anchor on the trigger ts, got %q", client.posted[0].threadTS) - } - if len(client.reactions) != 1 { - t.Fatalf("expected one ack reaction, got %d", len(client.reactions)) - } - r := client.reactions[0] - if r.channel != "C1" || r.ts != "100.0001" || r.name != AcknowledgeReaction { - t.Errorf("unexpected reaction: %+v", r) - } -} - -// --- helper assertions --- - -func TestRunner_PromptIncludesAllSlackContext(t *testing.T) { - // Sanity: run a single match and inspect the prompt the host saw to - // confirm permalink + workspace + thread all made it through. Belt and - // braces against future refactors of buildAgentPrompt. - utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ - "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, - }} - host := &fakeHostRunner{response: "ok"} - r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) - cfg := &SlackConfig{UtilityAgentID: "ua-1"} - msg := SlackMessage{TS: "200", UserName: "bob", Text: "!kandev investigate"} - thread := []SlackMessage{msg, {TS: "199", UserName: "alice", Text: "saw a 500 on /api/foo"}} - if _, err := r.RunForMatch(context.Background(), cfg, msg, "investigate", "https://slack/p200", thread); err != nil { - t.Fatalf("RunForMatch: %v", err) - } - prompt := host.calls[0].prompt - for _, want := range []string{"https://slack/p200", "investigate", "saw a 500", "@bob"} { - if !strings.Contains(prompt, want) { - t.Errorf("expected %q in prompt; got:\n%s", want, prompt) - } - } -} - -// --- watermark advance under partial failure --- - -// failingRunner fails handleOne the Nth time it's called (1-indexed). -type failingRunner struct { - mu sync.Mutex - calls int - failOn int - fakeErr error -} - -func (f *failingRunner) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.calls++ - if f.calls == f.failOn { - return "", f.fakeErr - } - return "ok " + msg.TS, nil -} - -// TestTrigger_ProcessMatches_BreaksOnTransientError exercises the regression -// the previous "continue on error" loop introduced: in a batch [A, B, C] -// where B fails with a transient error and C would succeed, the watermark -// must advance only past A — not past C — so the next tick re-processes B -// and C. This is the core promise of the !kandev capture flow ("no message -// silently dropped") and easy to break with a refactor that reverts to -// `continue`. -func TestTrigger_ProcessMatches_BreaksOnTransientError(t *testing.T) { - store := newTestStore(t) - ctx := context.Background() - _ = store.UpsertConfig(ctx, &SlackConfig{ - - AuthMethod: AuthMethodCookie, - UtilityAgentID: "ua-1", - CommandPrefix: "!kandev", - }) - cfg, _ := store.GetConfig(ctx) - - runner := &failingRunner{failOn: 2, fakeErr: errors.New("transient agent failure")} - factory := func(_ *SlackConfig, _ string, _ string) Client { - return &fakeClient{} - } - svc := NewService(store, nil, runner, factory, logger.Default()) - client := &fakeClient{thread: []SlackMessage{{TS: "100.000001", Text: "!kandev a"}}} - svc.clients["default"] = client - - trig := NewTrigger(svc, logger.Default()) - matches := []SlackMessage{ - {TS: "100.000001", ChannelID: "C1", Text: "!kandev a"}, - {TS: "100.000002", ChannelID: "C1", Text: "!kandev b"}, - {TS: "100.000003", ChannelID: "C1", Text: "!kandev c"}, - } - trig.processMatches(ctx, cfg, "!kandev", matches, client) - - got, _ := svc.Store().GetConfig(ctx) - if got.LastSeenTS != "100.000001" { - t.Errorf("expected watermark to advance only past A (the consecutive prefix of successes), got %q", got.LastSeenTS) - } - if runner.calls != 2 { - t.Errorf("expected loop to break after first failure (2 calls: A success, B fail); got %d calls", runner.calls) - } -} - -// TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak verifies the -// other branch of the failure logic: ErrNoUtilityAgent is unrecoverable, so -// processing continues to subsequent matches and the watermark spans all -// observed messages. Previously this was the only configured behaviour and -// is the only one a production user can hit until they pick an agent. -func TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak(t *testing.T) { - store := newTestStore(t) - ctx := context.Background() - _ = store.UpsertConfig(ctx, &SlackConfig{ - - AuthMethod: AuthMethodCookie, - CommandPrefix: "!kandev", - // Note: no UtilityAgentID — runner will return ErrNoUtilityAgent. - }) - cfg, _ := store.GetConfig(ctx) - - // Track invocations directly so we can assert the loop *didn't* break - // on ErrNoUtilityAgent (only transient errors should break the loop). - var calls int - var callsMu sync.Mutex - noAgentRunner := agentRunnerFunc(func(ctx context.Context, _ *SlackConfig, msg SlackMessage, _, _ string, _ []SlackMessage) (string, error) { - callsMu.Lock() - calls++ - callsMu.Unlock() - return "", ErrNoUtilityAgent - }) - - factory := func(_ *SlackConfig, _ string, _ string) Client { return &fakeClient{} } - svc := NewService(store, nil, noAgentRunner, factory, logger.Default()) - svc.clients["default"] = &fakeClient{} - - trig := NewTrigger(svc, logger.Default()) - matches := []SlackMessage{ - {TS: "100.000001", ChannelID: "C1", Text: "!kandev a"}, - {TS: "100.000002", ChannelID: "C1", Text: "!kandev b"}, - } - trig.processMatches(ctx, cfg, "!kandev", matches, &fakeClient{}) - - got, _ := svc.Store().GetConfig(ctx) - if got.LastSeenTS != "100.000002" { - t.Errorf("expected watermark to advance past *all* matches under ErrNoUtilityAgent, got %q", got.LastSeenTS) - } - callsMu.Lock() - finalCalls := calls - callsMu.Unlock() - if finalCalls != 2 { - t.Errorf("expected runner to be invoked for every match (no break), got %d calls", finalCalls) - } -} - -// agentRunnerFunc adapts a function to the AgentRunner interface for tests. -type agentRunnerFunc func(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) - -func (f agentRunnerFunc) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) { - return f(ctx, cfg, msg, instruction, permalink, thread) -} - -// TestComposePrompt_GenericTemplateGetsResolvedSubstitution verifies the -// follow-up fix to the composePrompt heuristic: when a template uses only -// non-Slack placeholders (e.g. {{UserPrompt}}), the fallback path uses the -// *resolved* template text rather than the raw template, so the agent sees -// the substituted value instead of the literal {{UserPrompt}} placeholder. -func TestComposePrompt_GenericTemplateGetsResolvedSubstitution(t *testing.T) { - r := &Runner{} - rawTemplate := "Be brief. Task: {{UserPrompt}}" - resolved := "Be brief. Task: fix checkout" - prompt := r.composePrompt(rawTemplate, resolved, - SlackMessage{UserName: "alice"}, "fix checkout", "https://slack/p", nil) - if !strings.Contains(prompt, "Task: fix checkout") { - t.Errorf("expected resolved {{UserPrompt}} in fallback path; got:\n%s", prompt) - } - if strings.Contains(prompt, "{{UserPrompt}}") { - t.Errorf("expected no literal placeholder to leak through; got:\n%s", prompt) - } -} - -// --- install-wide cadence --- - -func TestTrigger_DueForScan(t *testing.T) { - trig := &Trigger{} - if !trig.dueForScan("ws-a", 30) { - t.Error("expected fresh trigger to be due immediately") - } - trig.markScanned("ws-a") - if trig.dueForScan("ws-a", 30) { - t.Error("expected trigger to not be due immediately after scan") - } - if !trig.dueForScan("ws-b", 30) { - t.Error("expected a different workspace to remain due") - } - - // Backdate the marker to simulate enough elapsed time. Using a direct - // field write here rather than time.Sleep keeps the test deterministic. - trig.scannedMu.Lock() - trig.lastScannedAt[normalizeWorkspaceID("ws-a")] = time.Now().Add(-31 * time.Second) - trig.scannedMu.Unlock() - if !trig.dueForScan("ws-a", 30) { - t.Error("expected trigger to be due again after 31s with 30s interval") - } - - // Bogus interval (below floor) falls back to the default — guards against - // a config row with 0 (or a corrupted row) holding the loop hostage. - trig.scannedMu.Lock() - trig.lastScannedAt[normalizeWorkspaceID("ws-a")] = time.Now() - trig.scannedMu.Unlock() - if trig.dueForScan("ws-a", 0) { - t.Error("expected zero interval to behave as default 30s, not as 'always due'") - } -} diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index fd4b0f0b10..ba4437cc59 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -76,6 +76,9 @@ func (r *Repository) runMigrations() error { if err := r.migrateTasksRemoveWorkflowFK(); err != nil { return err } + if err := r.dropRetiredSlackIntegration(); err != nil { + return err + } r.migrate.Apply("idx_tasks_queued_for_step", `CREATE INDEX IF NOT EXISTS idx_tasks_queued_for_step ON tasks(queued_for_step_id, queued_at)`) // Remove deprecated workflow_step_id column from task_sessions if err := r.migrateSessionsRemoveWorkflowStepID(); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/slack_retirement.go b/apps/backend/internal/task/repository/sqlite/slack_retirement.go new file mode 100644 index 0000000000..9c767fff60 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/slack_retirement.go @@ -0,0 +1,51 @@ +package sqlite + +import ( + "fmt" + + "github.com/kandev/kandev/internal/db/dialect" +) + +// dropRetiredSlackIntegration removes the storage the in-tree Slack +// integration left behind. Slack now ships as kandev-plugin-slack, which +// keeps its own configuration under the plugin config/secret namespace, so +// the old `slack_configs` row and the `slack::token` / +// `slack::cookie` vault entries are unreachable by any running +// code. Dropping them rather than orphaning them matters because the vault +// entries are live Slack credentials: leaving them encrypted-at-rest forever +// is a credential-retention problem, not merely dead data. +// +// Both statements are idempotent, and the secrets delete is skipped when the +// vault table does not exist yet — on a fresh database the task repository's +// migrations run before secrets.Provide creates it, and a fresh database has +// nothing to clean up anyway. +func (r *Repository) dropRetiredSlackIntegration() error { + r.migrate.Apply("drop_slack_configs", `DROP TABLE IF EXISTS slack_configs`) + exists, err := r.secretsTableExists() + if err != nil { + return fmt.Errorf("drop retired slack integration: %w", err) + } + if !exists { + return nil + } + // LIKE with a literal pattern rather than a bound parameter: this file is + // shared with PostgreSQL, whose placeholder syntax differs from SQLite's. + r.migrate.Apply("delete_slack_secrets", `DELETE FROM secrets WHERE id LIKE 'slack:%'`) + return nil +} + +// secretsTableExists reports whether the shared vault table has been created +// yet. The task repository and the secret store share one database but +// initialize independently, and on a fresh boot this repository's migrations +// run first — so "absent" is the normal fresh-install case, not an error. +func (r *Repository) secretsTableExists() (bool, error) { + query := `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'secrets'` + if dialect.IsPostgres(r.db.DriverName()) { + query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'secrets'` + } + var count int + if err := r.db.QueryRow(query).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/slack_retirement_postgres_test.go b/apps/backend/internal/task/repository/sqlite/slack_retirement_postgres_test.go new file mode 100644 index 0000000000..7fd56177b9 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/slack_retirement_postgres_test.go @@ -0,0 +1,144 @@ +package sqlite + +import ( + "testing" + + "github.com/kandev/kandev/internal/testutil" +) + +// secretsTableExists is dialect-sensitive — it reads sqlite_master on SQLite and +// information_schema.tables on Postgres — so the SQLite tests give no signal +// about the Postgres branch. That branch decides whether the retirement +// migration deletes the retired Slack credentials at all: a wrong query there +// fails closed and silently leaves them encrypted at rest forever, which is the +// one outcome this migration exists to prevent. +// +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. + +func TestPostgresDropRetiredSlackIntegration(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + + // Recreate what an upgrading Postgres install arrives with: the retired + // config table, and the vault holding this workspace's credentials next to + // an unrelated secret that must survive. + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS slack_configs (workspace_id TEXT PRIMARY KEY, auth_method TEXT)`); err != nil { + t.Fatalf("create legacy slack_configs: %v", err) + } + if _, err := db.Exec(db.Rebind( + `INSERT INTO slack_configs (workspace_id, auth_method) VALUES (?, ?)`), "ws-1", "cookie"); err != nil { + t.Fatalf("seed legacy slack config: %v", err) + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS secrets ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '', + encrypted_value BYTEA NOT NULL, nonce BYTEA NOT NULL)`); err != nil { + t.Fatalf("create secrets: %v", err) + } + // id is the vault key and name is the human label, exactly as + // secretadapter.Set stores them — so the seeding proves the migration + // filters on the key rather than on the label. + seed := []struct{ id, name string }{ + {"slack:ws-1:token", "Slack token"}, + {"slack:ws-1:cookie", "Slack d cookie"}, + {"slack:singleton:token", "Slack token"}, + {"jira:ws-1:token", "Jira token"}, + // A non-Slack key whose *label* starts with "slack:" must survive: + // matching on the label would delete an unrelated credential. + {"github:ws-1:token", "slack:not-a-key"}, + } + for _, s := range seed { + if _, err := db.Exec(db.Rebind( + `INSERT INTO secrets (id, name, encrypted_value, nonce) VALUES (?, ?, ?, ?)`), + s.id, s.name, []byte{0}, []byte{0}); err != nil { + t.Fatalf("seed secret %s: %v", s.id, err) + } + } + + if err := repo.dropRetiredSlackIntegration(); err != nil { + t.Fatalf("dropRetiredSlackIntegration: %v", err) + } + + var tables int + if err := db.Get(&tables, + `SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'slack_configs'`); err != nil { + t.Fatalf("query information_schema: %v", err) + } + if tables != 0 { + t.Fatal("slack_configs still exists after retirement") + } + + var remaining []string + if err := db.Select(&remaining, `SELECT id FROM secrets ORDER BY id`); err != nil { + t.Fatalf("query secrets: %v", err) + } + want := []string{"github:ws-1:token", "jira:ws-1:token"} + if len(remaining) != len(want) { + t.Fatalf("secrets = %v, want %v", remaining, want) + } + for i := range want { + if remaining[i] != want[i] { + t.Fatalf("secrets = %v, want %v", remaining, want) + } + } +} + +// On a fresh Postgres database the task repository's migrations run before the +// secret store creates its table, so "absent" is the normal case. The Postgres +// branch of secretsTableExists has to report that rather than erroring, or the +// boot fails. +func TestPostgresDropRetiredSlackIntegrationOnFreshDatabase(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + if _, err := db.Exec(`DROP TABLE IF EXISTS secrets`); err != nil { + t.Fatalf("drop secrets: %v", err) + } + + exists, err := repo.secretsTableExists() + if err != nil { + t.Fatalf("secretsTableExists: %v", err) + } + if exists { + t.Fatal("secretsTableExists reported a dropped table as present") + } + if err := repo.dropRetiredSlackIntegration(); err != nil { + t.Fatalf("dropRetiredSlackIntegration on a fresh database: %v", err) + } +} + +// Migrations replay on every boot; the second pass must be a no-op. +func TestPostgresDropRetiredSlackIntegrationReplays(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + for i := range 2 { + if err := repo.dropRetiredSlackIntegration(); err != nil { + t.Fatalf("dropRetiredSlackIntegration pass %d: %v", i+1, err) + } + } + // The positive case above only proves the query finds a table that exists; + // this proves it finds the one the schema actually created. + exists, err := repo.secretsTableExists() + if err != nil { + t.Fatalf("secretsTableExists: %v", err) + } + if _, createErr := db.Exec(`CREATE TABLE IF NOT EXISTS secrets ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, + encrypted_value BYTEA NOT NULL, nonce BYTEA NOT NULL)`); createErr != nil { + t.Fatalf("create secrets: %v", createErr) + } + after, err := repo.secretsTableExists() + if err != nil { + t.Fatalf("secretsTableExists after create: %v", err) + } + if exists == after { + t.Fatalf("secretsTableExists did not observe the table being created (before=%v after=%v)", exists, after) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/slack_retirement_test.go b/apps/backend/internal/task/repository/sqlite/slack_retirement_test.go new file mode 100644 index 0000000000..1e6b691232 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/slack_retirement_test.go @@ -0,0 +1,113 @@ +package sqlite + +import "testing" + +// The in-tree Slack integration was replaced by kandev-plugin-slack. Its +// `slack_configs` row is merely dead, but its vault entries are live Slack +// credentials, so the retirement migration has to actually remove them rather +// than leave them encrypted at rest forever. + +func TestDropRetiredSlackIntegrationRemovesConfigAndSecrets(t *testing.T) { + repo := newRepoForSessionTests(t) + + // Recreate the shape an upgrading install arrives with: the retired config + // table, and the vault holding this workspace's token and cookie plus an + // unrelated secret that must survive. + if _, err := repo.db.Exec(`CREATE TABLE slack_configs (workspace_id TEXT PRIMARY KEY, auth_method TEXT)`); err != nil { + t.Fatalf("create legacy slack_configs: %v", err) + } + if _, err := repo.db.Exec(`INSERT INTO slack_configs (workspace_id, auth_method) VALUES ('ws-1', 'cookie')`); err != nil { + t.Fatalf("seed legacy slack config: %v", err) + } + if _, err := repo.db.Exec(`CREATE TABLE IF NOT EXISTS secrets ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '', + encrypted_value BLOB NOT NULL, nonce BLOB NOT NULL)`); err != nil { + t.Fatalf("create secrets: %v", err) + } + // id is the vault key, name is the human label — exactly what + // secretadapter.Set stores. Seeding them with different values is the point: + // with id == name the test would pass just as well against a migration that + // filtered on `name`, which in production matches nothing (the label is + // "Slack token", not "slack:...") and would leave the credentials on disk. + for _, secret := range []struct{ id, name string }{ + {"slack:ws-1:token", "Slack token"}, + {"slack:ws-1:cookie", "Slack d cookie"}, + {"slack:singleton:token", "Slack token"}, + {"jira:ws-1:token", "Jira token"}, + // A non-Slack key whose *label* starts with "slack:". Matching on the + // label would delete an unrelated provider's credential. + {"github:ws-1:token", "slack:not-a-key"}, + } { + if _, err := repo.db.Exec( + `INSERT INTO secrets (id, name, encrypted_value, nonce) VALUES (?, ?, x'00', x'00')`, + secret.id, secret.name, + ); err != nil { + t.Fatalf("seed secret %s: %v", secret.id, err) + } + } + + if err := repo.dropRetiredSlackIntegration(); err != nil { + t.Fatalf("dropRetiredSlackIntegration: %v", err) + } + + var tables int + if err := repo.db.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'slack_configs'`, + ).Scan(&tables); err != nil { + t.Fatalf("query sqlite_master: %v", err) + } + if tables != 0 { + t.Fatal("slack_configs still exists after retirement") + } + + var remaining []string + rows, err := repo.db.Query(`SELECT id FROM secrets ORDER BY id`) + if err != nil { + t.Fatalf("query secrets: %v", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatalf("scan secret: %v", err) + } + remaining = append(remaining, id) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate secrets: %v", err) + } + want := []string{"github:ws-1:token", "jira:ws-1:token"} + if len(remaining) != len(want) { + t.Fatalf("secrets = %v, want %v", remaining, want) + } + for i := range want { + if remaining[i] != want[i] { + t.Fatalf("secrets = %v, want %v", remaining, want) + } + } +} + +// A fresh database reaches this migration before secrets.Provide has created +// the vault, so an absent table is the normal case and must not fail the boot. +func TestDropRetiredSlackIntegrationOnFreshDatabase(t *testing.T) { + repo := newRepoForSessionTests(t) + if _, err := repo.db.Exec(`DROP TABLE IF EXISTS secrets`); err != nil { + t.Fatalf("drop secrets: %v", err) + } + if err := repo.dropRetiredSlackIntegration(); err != nil { + t.Fatalf("dropRetiredSlackIntegration on a fresh database: %v", err) + } +} + +// Migrations replay on every boot; the second pass must be a no-op. +func TestDropRetiredSlackIntegrationReplays(t *testing.T) { + repo := newRepoForSessionTests(t) + for i := range 2 { + if err := repo.dropRetiredSlackIntegration(); err != nil { + t.Fatalf("dropRetiredSlackIntegration pass %d: %v", i+1, err) + } + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations replay: %v", err) + } +} diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index d3cd345f62..a1a0a2d8a9 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -84,7 +84,7 @@ func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, ag // configured and ready to run at creation time. The DB's NOT NULL // column persists the field value (not the schema default), so we // have to set it here — otherwise new custom agents land disabled - // and never appear as runnable in pickers like the Slack triage one. + // and never appear as runnable in the utility-agent pickers. Enabled: true, } diff --git a/apps/backend/internal/utility/template/engine.go b/apps/backend/internal/utility/template/engine.go index adbc87c382..2a339ce34d 100644 --- a/apps/backend/internal/utility/template/engine.go +++ b/apps/backend/internal/utility/template/engine.go @@ -106,11 +106,9 @@ func (e *Engine) buildTemplateData(ctx *Context) map[string]string { } // AvailableVariables returns the list of available template variables. -// Integration-specific variables (Slack, future webhooks, etc.) are listed -// in their own category so the UI can group them and users can tell which +// Each carries a category so the UI can group them and users can tell which // ones make sense in a given utility-agent context. func (e *Engine) AvailableVariables() []VariableInfo { - const categorySlack = "slack" return []VariableInfo{ {Name: "GitDiff", Description: "Output of git diff --staged or git diff", Example: "diff --git a/main.go...", Category: "git"}, {Name: "CommitLog", Description: "Git log for the current branch", Example: "abc123 Fix bug in handler", Category: "git"}, @@ -124,12 +122,6 @@ func (e *Engine) AvailableVariables() []VariableInfo { {Name: "WorkspacePath", Description: "Path to the workspace/worktree", Example: "/home/user/project", Category: "session"}, {Name: "UserPrompt", Description: "User's original prompt text", Example: "Fix the login bug", Category: "input"}, {Name: "ConversationHistory", Description: "Formatted conversation transcript", Example: "User: Fix the bug\nAgent: I'll look into...", Category: "session"}, - {Name: "SlackInstruction", Description: "User's instruction (the !kandev message minus the prefix)", Example: "investigate the safari login bug", Category: categorySlack}, - {Name: "SlackThread", Description: "Surrounding Slack thread, one line per message", Example: "alice (1714658000.0): bug repros every Monday\nbob (1714659000.0): seeing it on safari only", Category: categorySlack}, - {Name: "SlackPermalink", Description: "Canonical URL of the triggering Slack message", Example: "https://acme.slack.com/archives/C1/p1714659000000100", Category: categorySlack}, - {Name: "SlackUser", Description: "Sender of the !kandev message (@name or user id)", Example: "@alice", Category: categorySlack}, - {Name: "SlackChannelID", Description: "Slack channel id of the trigger message", Example: "C0123ABCD", Category: categorySlack}, - {Name: "SlackTS", Description: "Slack timestamp of the trigger message", Example: "1714659000.000100", Category: categorySlack}, } } @@ -138,5 +130,5 @@ type VariableInfo struct { Name string `json:"name"` Description string `json:"description"` Example string `json:"example"` - Category string `json:"category"` // "git", "task", "session", "input", "slack" + Category string `json:"category"` // "git", "task", "session", "input" } diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index ddf7f56dce..32f952e6b3 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -4,7 +4,6 @@ import { IconBrandGitlab, IconBrandAzure, IconBrandSentry, - IconBrandSlack, IconHexagon, IconTicket, } from "@tabler/icons-react"; @@ -48,12 +47,6 @@ const INTEGRATIONS = [ descriptionKey: "settings:integrationDescriptionSentry", Icon: IconBrandSentry, }, - { - slug: "slack", - label: "Slack", - descriptionKey: "settings:integrationDescriptionSlack", - Icon: IconBrandSlack, - }, ]; type IntegrationsIndexPageProps = { diff --git a/apps/web/app/settings/integrations/slack/page.tsx b/apps/web/app/settings/integrations/slack/page.tsx deleted file mode 100644 index d50bb4057f..0000000000 --- a/apps/web/app/settings/integrations/slack/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SlackIntegrationPage } from "@/components/slack/slack-settings"; - -type IntegrationsSlackPageProps = { - workspaceId?: string; -}; - -export default function IntegrationsSlackPage({ workspaceId }: IntegrationsSlackPageProps = {}) { - return ; -} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index 514b09c46a..f6301c874b 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -38,7 +38,6 @@ const integrationAvailability = vi.hoisted(() => ({ jira: false, linear: false, sentry: false, - slack: false, })); vi.mock("@/components/state-provider", () => ({ @@ -70,10 +69,6 @@ vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({ vi.mock("@/hooks/domains/sentry/use-sentry-availability", () => ({ useSentryAvailable: () => integrationAvailability.sentry, })); -vi.mock("@/hooks/domains/slack/use-slack-availability", () => ({ - useSlackAuthed: () => integrationAvailability.slack, -})); - vi.mock("@kandev/ui/collapsible", async () => { const React = await vi.importActual("react"); const CollapsibleContext = React.createContext(false); @@ -106,7 +101,6 @@ describe("SettingsTree rendering", () => { integrationAvailability.jira = false; integrationAvailability.linear = false; integrationAvailability.sentry = false; - integrationAvailability.slack = false; }); afterEach(() => cleanup()); diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx index 51adef6eb0..e981d8dfc7 100644 --- a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -8,7 +8,6 @@ import { IconBrandGithub, IconBrandGitlab, IconBrandSentry, - IconBrandSlack, IconFolder, IconGitBranch, IconHexagon, @@ -24,7 +23,6 @@ import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr"; import { useJiraAuthed } from "@/hooks/domains/jira/use-jira-availability"; import { useLinearAuthed } from "@/hooks/domains/linear/use-linear-availability"; import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability"; -import { useSlackAuthed } from "@/hooks/domains/slack/use-slack-availability"; import { WORKSPACE_INTEGRATIONS } from "@/lib/settings-discovery/catalog/integrations"; import { WORKSPACES_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/workspaces"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; @@ -38,7 +36,6 @@ const INTEGRATION_ICONS: Record<(typeof WORKSPACE_INTEGRATIONS)[number][0], Inte jira: IconTicket, linear: IconHexagon, sentry: IconBrandSentry, - slack: IconBrandSlack, }; // Rendered as components, not module-scope JSX constants: `t()` must resolve at @@ -76,7 +73,6 @@ function WorkspaceIntegrationItems({ const jira = useJiraAuthed(workspaceId); const linear = useLinearAuthed(workspaceId); const sentry = useSentryAvailable(workspaceId); - const slack = useSlackAuthed(workspaceId); const enabled = new Set([ ...(azureDevOps ? ["azure-devops"] : []), ...(githubStatus?.authenticated || githubStatus?.token_configured ? ["github"] : []), @@ -84,7 +80,6 @@ function WorkspaceIntegrationItems({ ...(jira ? ["jira"] : []), ...(linear ? ["linear"] : []), ...(sentry ? ["sentry"] : []), - ...(slack ? ["slack"] : []), ]); return WORKSPACE_INTEGRATIONS.map(([slug, label]) => { diff --git a/apps/web/components/integrations/integration-copy-config.ts b/apps/web/components/integrations/integration-copy-config.ts index 255758a48c..fb2f48d1f3 100644 --- a/apps/web/components/integrations/integration-copy-config.ts +++ b/apps/web/components/integrations/integration-copy-config.ts @@ -1,4 +1,3 @@ -import { copySlackConfig } from "@/lib/api/domains/slack-api"; import { copyJiraConfig } from "@/lib/api/domains/jira-api"; import { copyLinearConfig } from "@/lib/api/domains/linear-api"; import { copySentryInstances } from "@/lib/api/domains/sentry-api"; @@ -8,19 +7,11 @@ import { copyGitLabConfig } from "@/lib/api/domains/gitlab-api"; // IntegrationSlug is the set of integration settings pages that support copying // their per-workspace config to another workspace. -export type IntegrationSlug = - | "azure-devops" - | "slack" - | "jira" - | "linear" - | "sentry" - | "github" - | "gitlab"; +export type IntegrationSlug = "azure-devops" | "jira" | "linear" | "sentry" | "github" | "gitlab"; // integrationLabels maps each slug to how the rest of the app spells it. const integrationLabels: Record = { "azure-devops": "Azure DevOps", - slack: "Slack", jira: "Jira", linear: "Linear", sentry: "Sentry", @@ -29,8 +20,8 @@ const integrationLabels: Record = { }; // integrationFromPathname returns the integration slug for a settings pathname -// like /settings/integrations/slack or -// /settings/workspace//integrations/slack, or null when the current page is +// like /settings/integrations/jira or +// /settings/workspace//integrations/jira, or null when the current page is // not a copyable integration page. export function integrationFromPathname(pathname: string): IntegrationSlug | null { const match = pathname.match(/^\/settings(?:\/workspace\/[^/]+)?\/integrations\/([^/]+)/); @@ -59,9 +50,6 @@ export async function copyIntegrationConfig( case "azure-devops": await copyAzureDevOpsConfig(sourceWorkspaceId, targetWorkspaceId); return; - case "slack": - await copySlackConfig(targetWorkspaceId, options); - return; case "jira": await copyJiraConfig(targetWorkspaceId, options); return; diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index 63cd5cf740..c676e3a1ec 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -29,7 +29,6 @@ const SEGMENT_LABEL_OVERRIDES: Record = { github: "GitHub", jira: "Jira", linear: "Linear", - slack: "Slack", mcp: "MCP", ui: "UI", vscode: "VS Code", diff --git a/apps/web/components/slack/slack-settings-state.ts b/apps/web/components/slack/slack-settings-state.ts deleted file mode 100644 index 6b7da12255..0000000000 --- a/apps/web/components/slack/slack-settings-state.ts +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"; -import { useTranslation } from "react-i18next"; -import { useToast } from "@/components/toast-provider"; -import { type IntegrationAuthHealth } from "@/components/integrations/auth-status-banner"; -import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; -import { - getSlackConfig, - setSlackConfig, - deleteSlackConfig, - testSlackConnection, -} from "@/lib/api/domains/slack-api"; -import { listUtilityAgents, type UtilityAgent } from "@/lib/api/domains/utility-api"; -import type { SlackConfig, TestSlackConnectionResult } from "@/lib/types/slack"; - -export const DEFAULT_PREFIX = "!kandev"; -export const DEFAULT_POLL_INTERVAL_SECONDS = 30; -export const MIN_POLL_INTERVAL_SECONDS = 5; -export const MAX_POLL_INTERVAL_SECONDS = 600; - -/** - * Slack credential and prompt-template tokens. Every one of these is a value the - * user has to find or type verbatim — a cookie name, a token prefix, a - * placeholder the backend substitutes — so they are interpolated into catalog - * messages rather than written into them, where the pseudo-locale would - * transliterate them into dead pointers. - */ -export const DEFAULT_PREFIX_EXAMPLE = `${DEFAULT_PREFIX} `; -export const SLACK_TOKEN_PREFIX = "xoxc-"; -export const SLACK_COOKIE_NAME = "d"; -export const SLACK_TOKEN_PLACEHOLDER = "xoxc-..."; -export const SLACK_COOKIE_PLACEHOLDER = "xoxd-..."; -export const SECRET_MASK = "••••••••"; -export const UTILITY_AGENTS_ROUTE = "/settings/utility-agents"; -export const PROMPT_TOKENS = [ - "{{SlackInstruction}}", - "{{SlackThread}}", - "{{SlackPermalink}}", - "{{SlackUser}}", - "{{SlackChannelID}}", - "{{SlackTS}}", -] as const; - -export type FormState = { - utilityAgentId: string; - commandPrefix: string; - pollIntervalSeconds: number; - token: string; - cookie: string; -}; - -export const emptyForm: FormState = { - utilityAgentId: "", - commandPrefix: DEFAULT_PREFIX, - pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS, - token: "", - cookie: "", -}; - -export function configToForm(cfg: SlackConfig | null): FormState { - if (!cfg) return emptyForm; - return { - utilityAgentId: cfg.utilityAgentId, - commandPrefix: cfg.commandPrefix || DEFAULT_PREFIX, - pollIntervalSeconds: cfg.pollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS, - token: "", - cookie: "", - }; -} - -export function configToHealth(config: SlackConfig | null): IntegrationAuthHealth | null { - if (!config?.hasToken || !config.hasCookie) return null; - if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null }; - return { - ok: !!config.lastOk, - error: config.lastError ?? "", - checkedAt: new Date(config.lastCheckedAt), - }; -} - -function useUtilityAgentsLoader() { - const [agents, setAgents] = useState(null); - useEffect(() => { - let cancelled = false; - listUtilityAgents({ cache: "no-store" }) - .then((res) => { - if (!cancelled) setAgents(res.agents ?? []); - }) - .catch(() => { - if (!cancelled) setAgents([]); - }); - return () => { - cancelled = true; - }; - }, []); - return { agents: agents ?? [], loadingAgents: agents === null }; -} - -type SettingsActionsArgs = { - workspaceId: string; - form: FormState; - setConfig: (cfg: SlackConfig | null) => void; - setForm: Dispatch>; - setTestResult: (r: TestSlackConnectionResult | null) => void; -}; - -function useSettingsActions({ - workspaceId, - form, - setConfig, - setForm, - setTestResult, -}: SettingsActionsArgs) { - const { t } = useTranslation(); - const { toast } = useToast(); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [deleting, setDeleting] = useState(false); - - const handleTest = useCallback(async () => { - setTesting(true); - setTestResult(null); - try { - const res = await testSlackConnection( - { - authMethod: "cookie", - utilityAgentId: form.utilityAgentId, - commandPrefix: form.commandPrefix, - pollIntervalSeconds: form.pollIntervalSeconds, - token: form.token || undefined, - cookie: form.cookie || undefined, - }, - { workspaceId }, - ); - setTestResult(res); - } catch (err) { - setTestResult({ ok: false, error: String(err) }); - } finally { - setTesting(false); - } - }, [workspaceId, form, setTestResult]); - - const handleSave = useCallback(async () => { - const submitted = form; - setSaving(true); - try { - const saved = await setSlackConfig( - { - authMethod: "cookie", - utilityAgentId: form.utilityAgentId, - commandPrefix: form.commandPrefix, - pollIntervalSeconds: form.pollIntervalSeconds, - token: form.token || undefined, - cookie: form.cookie || undefined, - }, - { workspaceId }, - ); - setConfig(saved); - setForm((current) => - JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current, - ); - setTestResult(null); - toast({ description: t("slack:configurationSaved"), variant: "success" }); - } catch (err) { - toast({ description: t("slack:saveFailed", { error: String(err) }), variant: "error" }); - throw err; - } finally { - setSaving(false); - } - }, [workspaceId, form, t, toast, setConfig, setForm, setTestResult]); - - const handleDelete = useCallback(async () => { - if (!confirm(t("slack:removeConfigurationConfirm"))) return; - setDeleting(true); - try { - await deleteSlackConfig({ workspaceId }); - setConfig(null); - setForm(emptyForm); - setTestResult(null); - toast({ description: t("slack:configurationRemoved"), variant: "success" }); - } catch (err) { - toast({ description: t("slack:deleteFailed", { error: String(err) }), variant: "error" }); - } finally { - setDeleting(false); - } - }, [workspaceId, t, toast, setConfig, setForm, setTestResult]); - - return { saving, testing, deleting, handleTest, handleSave, handleDelete }; -} - -export function useSlackSettings(workspaceId: string) { - const { t } = useTranslation(); - const { toast } = useToast(); - const [config, setConfig] = useState(null); - const [form, setForm] = useState(emptyForm); - const [loading, setLoading] = useState(true); - const [testResult, setTestResult] = useState(null); - const health = configToHealth(config); - const { agents, loadingAgents } = useUtilityAgentsLoader(); - - const load = useCallback(async () => { - setLoading(true); - try { - const cfg = await getSlackConfig({ workspaceId }); - setConfig(cfg); - setForm(configToForm(cfg)); - } catch (err) { - toast({ - description: t("slack:failedToLoadConfig", { error: String(err) }), - variant: "error", - }); - } finally { - setLoading(false); - } - }, [workspaceId, t, toast]); - - useEffect(() => { - void load(); - }, [load]); - - // Background refresh so the auth-health banner picks up new probe results. - useEffect(() => { - const id = setInterval(() => { - getSlackConfig({ workspaceId }) - .then((cfg) => setConfig(cfg)) - .catch(() => { - /* transient failures are fine — next tick retries */ - }); - }, INTEGRATION_STATUS_REFRESH_MS); - return () => clearInterval(id); - }, [workspaceId]); - - const update = useCallback( - (key: K, value: FormState[K]) => - setForm((prev) => ({ ...prev, [key]: value })), - [], - ); - const discard = useCallback(() => setForm(configToForm(config)), [config]); - - const { saving, testing, deleting, handleTest, handleSave, handleDelete } = useSettingsActions({ - workspaceId, - form, - setConfig, - setForm, - setTestResult, - }); - - return { - config, - form, - loading, - saving, - testing, - deleting, - testResult, - health, - agents, - loadingAgents, - update, - discard, - handleTest, - handleSave, - handleDelete, - }; -} diff --git a/apps/web/components/slack/slack-settings.test.tsx b/apps/web/components/slack/slack-settings.test.tsx deleted file mode 100644 index 5f4204dbde..0000000000 --- a/apps/web/components/slack/slack-settings.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StateProvider } from "@/components/state-provider"; -import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; -import type { SlackConfig } from "@/lib/types/slack"; -import type { UtilityAgent } from "@/lib/api/domains/utility-api"; - -const mocks = vi.hoisted(() => ({ - getConfig: vi.fn(), - setConfig: vi.fn(), - deleteConfig: vi.fn(), - testConnection: vi.fn(), - listUtilityAgents: vi.fn(), - toast: vi.fn(), -})); - -vi.mock("@/components/toast-provider", () => ({ - useToast: () => ({ toast: mocks.toast }), -})); -vi.mock("@/hooks/domains/integrations/use-integration-availability", () => ({ - INTEGRATION_STATUS_REFRESH_MS: 100_000, -})); -vi.mock("@/hooks/domains/slack/use-slack-enabled", () => ({ - useSlackEnabled: () => ({ enabled: true, setEnabled: vi.fn() }), -})); -vi.mock("@/lib/api/domains/slack-api", () => ({ - getSlackConfig: mocks.getConfig, - setSlackConfig: mocks.setConfig, - deleteSlackConfig: mocks.deleteConfig, - testSlackConnection: mocks.testConnection, -})); -vi.mock("@/lib/api/domains/utility-api", () => ({ - listUtilityAgents: mocks.listUtilityAgents, -})); - -import { SlackConnectionSection } from "@/components/slack/slack-settings"; - -const CONFIG: SlackConfig = { - authMethod: "cookie", - utilityAgentId: "agent-1", - commandPrefix: "!kandev", - pollIntervalSeconds: 30, - hasToken: true, - hasCookie: true, - lastOk: true, - createdAt: "2026-08-01T00:00:00Z", - updatedAt: "2026-08-01T00:00:00Z", -}; - -const AGENTS = [ - { id: "agent-1", name: "Triage", model: "claude-opus-5", enabled: true, agent_id: "claude" }, - { id: "agent-2", name: "Builtin", builtin: true }, - { id: "agent-3", name: "Paused", enabled: false, agent_id: "claude" }, -] as unknown as UtilityAgent[]; - -function renderSection() { - return render( - - - - - , - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - mocks.getConfig.mockResolvedValue(CONFIG); - mocks.listUtilityAgents.mockResolvedValue({ agents: AGENTS }); -}); - -afterEach(cleanup); - -describe("SlackConnectionSection", () => { - // Each block below is a `` whose `` indices address the JSX children - // positionally, so a reflow of those children silently reassembles the - // sentence into fragments. Assert the whole reconstructed paragraph. - it("renders the credential and prompt help with their verbatim tokens", async () => { - renderSection(); - await waitFor(() => expect(mocks.getConfig).toHaveBeenCalled()); - - expect( - screen.getByText( - "Open Slack in your browser, copy the value of the `d` cookie and the `xoxc-` token from local storage. Both are required.", - ), - ).toBeTruthy(); - - const promptHelp = screen.getByText(/Custom prompts can reference/); - expect(promptHelp.textContent).toBe( - "Custom prompts can reference {{SlackInstruction}}, {{SlackThread}}, {{SlackPermalink}}, " + - "{{SlackUser}}, {{SlackChannelID}}, {{SlackTS}}. When at least one is used, your template " + - "owns the full prompt; otherwise the default Slack-triage system prompt is prepended " + - "automatically.", - ); - - const prefixHelp = screen.getByText(/Messages you write in Slack/); - expect(prefixHelp.textContent).toBe( - "Messages you write in Slack starting with this prefix become Kandev tasks. Default: " + - "!kandev .", - ); - - const pollHelp = screen.getByText(/How often Slack is checked/); - expect(pollHelp.textContent).toBe( - "How often Slack is checked for new !kandev messages. Lower = more responsive, higher = " + - "fewer Slack API calls. Range: 5–600s. Default: 30s.", - ); - - // `<0>` is the lead-in, so getByText matches it rather than the - // paragraph — assert on the parent to cover the whole reassembled sentence. - const warningLead = screen.getByText("Browser session auth (unsupported):"); - expect(warningLead.tagName).toBe("STRONG"); - expect(warningLead.parentElement?.textContent).toBe( - "Browser session auth (unsupported): Slack rotates session cookies often, so you may need " + - "to reconnect when authentication expires. Bot installs and user OAuth are on the roadmap.", - ); - }); - - it("labels utility agents from the record rather than concatenated copy", async () => { - renderSection(); - await waitFor(() => expect(mocks.listUtilityAgents).toHaveBeenCalled()); - - // The trigger renders the selected agent's label. - await waitFor(() => - expect(screen.getByText("Triage (claude-opus-5)", { exact: false })).toBeTruthy(), - ); - }); -}); diff --git a/apps/web/components/slack/slack-settings.tsx b/apps/web/components/slack/slack-settings.tsx deleted file mode 100644 index b33f091519..0000000000 --- a/apps/web/components/slack/slack-settings.tsx +++ /dev/null @@ -1,463 +0,0 @@ -"use client"; - -import { Trans, useTranslation } from "react-i18next"; -import Link from "@/components/routing/app-link"; -import { IconBrandSlack } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { CardContent } from "@kandev/ui/card"; -import { Input } from "@kandev/ui/input"; -import { Label } from "@kandev/ui/label"; -import { Separator } from "@kandev/ui/separator"; -import { Alert, AlertDescription } from "@kandev/ui/alert"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { SettingsSection } from "@/components/settings/settings-section"; -import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { useSlackEnabled } from "@/hooks/domains/slack/use-slack-enabled"; -import { IntegrationAuthStatusBanner } from "@/components/integrations/auth-status-banner"; -import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; -import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control"; -import { type UtilityAgent } from "@/lib/api/domains/utility-api"; -import type { TestSlackConnectionResult } from "@/lib/types/slack"; -import { - DEFAULT_PREFIX, - DEFAULT_PREFIX_EXAMPLE, - DEFAULT_POLL_INTERVAL_SECONDS, - MAX_POLL_INTERVAL_SECONDS, - MIN_POLL_INTERVAL_SECONDS, - PROMPT_TOKENS, - SECRET_MASK, - SLACK_COOKIE_NAME, - SLACK_COOKIE_PLACEHOLDER, - SLACK_TOKEN_PLACEHOLDER, - SLACK_TOKEN_PREFIX, - UTILITY_AGENTS_ROUTE, - configToForm, - useSlackSettings, - type FormState, -} from "./slack-settings-state"; -import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; - -type SecretFieldsProps = { - form: FormState; - baseline: FormState; - loading: boolean; - hasSavedToken: boolean; - hasSavedCookie: boolean; - update: (key: K, value: FormState[K]) => void; -}; - -function SecretFields({ - form, - baseline, - loading, - hasSavedToken, - hasSavedCookie, - update, -}: SecretFieldsProps) { - const { t } = useTranslation(); - return ( -
-
- - update("token", e.target.value)} - disabled={loading} - /> -
-
- - update("cookie", e.target.value)} - disabled={loading} - /> -

- {t("slack:credentialsHelp", { - cookie: SLACK_COOKIE_NAME, - prefix: SLACK_TOKEN_PREFIX, - })} -

-
-
- ); -} - -type UtilityAgentPickerProps = { - form: FormState; - baseline: FormState; - loading: boolean; - agents: UtilityAgent[]; - loadingAgents: boolean; - update: (key: K, value: FormState[K]) => void; -}; - -type Translate = (key: string, values?: Record) => string; - -function utilityAgentPlaceholder(t: Translate, agents: UtilityAgent[], loading: boolean): string { - if (loading) return t("slack:loadingEllipsis"); - if (agents.length === 0) return t("slack:createUtilityAgentFirst"); - return t("slack:chooseUtilityAgent"); -} - -function isAgentSelectable(a: UtilityAgent): boolean { - if (a.builtin) return true; - return a.enabled && !!a.agent_id && !!a.model; -} - -// `name` and `model` are the agent record — data, so the label is assembled from -// catalog messages that interpolate them rather than by concatenating copy. -function utilityAgentBaseLabel(t: Translate, a: UtilityAgent): string { - if (a.model) return t("slack:agentWithModel", { name: a.name, model: a.model }); - if (a.builtin) return t("slack:agentUsesDefaultModel", { name: a.name }); - return a.name; -} - -function utilityAgentLabel(t: Translate, a: UtilityAgent): string { - const base = utilityAgentBaseLabel(t, a); - if (a.builtin) return base; - if (!a.enabled) return t("slack:agentDisabled", { agent: base }); - if (!a.agent_id || !a.model) return t("slack:agentNotConfigured", { agent: base }); - return base; -} - -function UtilityAgentPicker({ - form, - baseline, - loading, - agents, - loadingAgents, - update, -}: UtilityAgentPickerProps) { - const { t } = useTranslation(); - return ( -
- - -

- {/* The three MCP tool names are identifiers, so they are interpolated - rather than written into the catalog. */} - - The utility agent that interprets each Slack message and creates the Kandev task. It runs - with Kandev MCP tools wired in ({"{{tools}}"}) so it picks the destination Kandev - workspace + workflow + repo from context. Built-in agents use your default model from{" "} - - Settings → Utility agents - - . - -

-

- {/* The six tokens are prompt syntax the backend substitutes, so they are - interpolated as one monospace run rather than written into the - catalog — six separate children would make the tag indices - drift on any reflow. */} - - Custom prompts can reference {"{{tokens}}"}. When at least one is used, your - template owns the full prompt; otherwise the default Slack-triage system prompt is - prepended automatically. - -

-
- ); -} - -function PrefixField({ - form, - baseline, - loading, - update, -}: { - form: FormState; - baseline: FormState; - loading: boolean; - update: (key: K, value: FormState[K]) => void; -}) { - const { t } = useTranslation(); - return ( -
- - update("commandPrefix", e.target.value)} - disabled={loading} - /> -

- {/* Not a : the example contains literal angle brackets, which - html-parse-stringify would read as a tag inside the message. */} - {t("slack:commandPrefixHelp")} {DEFAULT_PREFIX_EXAMPLE}. -

-
- ); -} - -function PollIntervalField({ - form, - baseline, - loading, - update, -}: { - form: FormState; - baseline: FormState; - loading: boolean; - update: (key: K, value: FormState[K]) => void; -}) { - const { t } = useTranslation(); - return ( -
- - { - const n = Number(e.target.value); - update("pollIntervalSeconds", Number.isFinite(n) ? n : DEFAULT_POLL_INTERVAL_SECONDS); - }} - disabled={loading} - /> -

- - How often Slack is checked for new {"{{prefix}}"} messages. Lower = more - responsive, higher = fewer Slack API calls. Range: {"{{min}}"}–{"{{max}}"}s. Default:{" "} - {"{{fallback}}"}s. - -

-
- ); -} - -// `displayName` / `userId` / `teamName` all come from the Slack API — data, so -// they are interpolated into the message rather than concatenated around it. -function testResultMessage(t: Translate, result: TestSlackConnectionResult): string { - if (!result.ok) return t("slack:testFailed", { error: result.error }); - const name = result.displayName || result.userId; - return result.teamName - ? t("slack:connectedAsWithTeam", { name, team: result.teamName }) - : t("slack:connectedAs", { name }); -} - -function TestResultAlert({ result }: { result: TestSlackConnectionResult | null }) { - const { t } = useTranslation(); - if (!result) return null; - return ( - - {testResultMessage(t, result)} - - ); -} - -function UnsupportedWarning() { - return ( - - - - Browser session auth (unsupported): Slack rotates session cookies often, - so you may need to reconnect when authentication expires. Bot installs and user OAuth are - on the roadmap. - - - - ); -} - -type ActionBarProps = { - testing: boolean; - deleting: boolean; - loading: boolean; - hasConfig: boolean; - disableTest: boolean; - onTest: () => void; - onDelete: () => void; -}; - -function ActionBar({ - testing, - deleting, - loading, - hasConfig, - disableTest, - onTest, - onDelete, -}: ActionBarProps) { - const { t } = useTranslation(); - return ( -
- - {hasConfig && ( - - )} -
- ); -} - -function EnabledPill() { - const { enabled, setEnabled } = useSlackEnabled(); - return ; -} - -export function SlackConnectionSection({ workspaceId }: { workspaceId: string }) { - const { t } = useTranslation(); - const s = useSlackSettings(workspaceId); - const baseline = configToForm(s.config); - const missingSecrets = - (!s.config?.hasToken && !s.form.token) || (!s.config?.hasCookie && !s.form.cookie); - const missingAgent = !s.form.utilityAgentId; - const disableSave = s.saving || missingSecrets || missingAgent; - const disableTest = missingSecrets; - const revision = JSON.stringify(s.form); - const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.config)); - let invalidReason: string | undefined; - if (missingSecrets) invalidReason = t("slack:secretsRequired"); - else if (missingAgent) invalidReason = t("slack:triageAgentRequired"); - - useSettingsSaveContributor({ - id: `slack-config:${workspaceId}`, - revision, - isDirty: dirty, - canSave: !disableSave, - invalidReason, - save: s.handleSave, - discard: s.discard, - }); - - return ( - } - title={t("slack:integrationTitle")} - description={t("slack:integrationDescription", { example: DEFAULT_PREFIX_EXAMPLE })} - action={} - > - - - - - - - - - - - - - - - - ); -} - -type SlackIntegrationPageProps = { - workspaceId?: string; -}; - -export function SlackIntegrationPage({ workspaceId }: SlackIntegrationPageProps = {}) { - return ( -
- - {(workspaceId) => } - -
- ); -} diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index ebb8e1a778..40be2513e5 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -493,19 +493,6 @@ export const i18nGuardFiles = [ // icon `key`s are the persisted enum and only the labels are copy, which now // travel as `labelKey` and resolve at render. "components/integrations/action-preset-icons.ts", - // Settings → Integrations → Slack: the page and the whole of - // `components/slack`, which holds nothing else. `slack-settings-state.ts` is - // the form state and API actions split out of the component so it stays under - // the 600-line cap; it holds no JSX, so `mode: "jsx-only"` never inspects it - // and only the pseudo-locale can prove its toast/confirm copy stays migrated. - // - // Deliberately left in English, each a value the user must find or type - // verbatim rather than copy: the `xoxc-` / `xoxd-` token prefixes, the `d` - // cookie name, the `••••••••` mask, the `!kandev ` command - // example, and the six `{{Slack…}}` prompt tokens. All are interpolated as - // values so the pseudo-locale cannot turn them into dead pointers. - "app/settings/integrations/slack/**/*.{ts,tsx}", - "components/slack/**/*.{ts,tsx}", // Settings → Agents: the agents list, the per-agent setup page, the agent // profile editor, and the `/settings/agent/:id` redirect. The whole // `app/settings/agents` tree is migrated, plus the agent half of diff --git a/apps/web/hooks/domains/slack/use-slack-availability.ts b/apps/web/hooks/domains/slack/use-slack-availability.ts deleted file mode 100644 index 7acf1bf86a..0000000000 --- a/apps/web/hooks/domains/slack/use-slack-availability.ts +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { getSlackConfig } from "@/lib/api/domains/slack-api"; -import { - useIntegrationAuthed, - useIntegrationAvailable, - type IntegrationConfigStatus, -} from "../integrations/use-integration-availability"; -import { useSlackEnabled } from "./use-slack-enabled"; - -// Slack stores two secrets (token + cookie) instead of one — the shared -// availability hook only checks `hasSecret`, so adapt by reporting authed -// when *both* halves are present. -function useSlackStatusLoader(workspaceId?: string | null) { - return useCallback(async (): Promise => { - const cfg = await getSlackConfig(workspaceId ? { workspaceId } : undefined); - if (!cfg) return null; - return { - hasSecret: cfg.hasToken && cfg.hasCookie, - lastOk: cfg.lastOk, - }; - }, [workspaceId]); -} - -export function useSlackAuthed(workspaceId?: string | null): boolean { - return useIntegrationAuthed(useSlackStatusLoader(workspaceId)); -} - -export function useSlackAvailable(workspaceId?: string | null): boolean { - const fetchConfig = useSlackStatusLoader(workspaceId); - return useIntegrationAvailable({ - useEnabled: useSlackEnabled, - fetchConfig, - }); -} diff --git a/apps/web/hooks/domains/slack/use-slack-enabled.ts b/apps/web/hooks/domains/slack/use-slack-enabled.ts deleted file mode 100644 index a88b29bb4c..0000000000 --- a/apps/web/hooks/domains/slack/use-slack-enabled.ts +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { useIntegrationEnabled } from "../integrations/use-integration-enabled"; - -const STORAGE_KEY = "kandev:slack:enabled:v1"; -const LEGACY_KEY_PREFIX = "kandev:slack:enabled:"; -const SYNC_EVENT = "kandev:slack:enabled-changed"; - -export function useSlackEnabled() { - return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT); -} diff --git a/apps/web/lib/api/domains/slack-api.ts b/apps/web/lib/api/domains/slack-api.ts deleted file mode 100644 index 93eb55bb44..0000000000 --- a/apps/web/lib/api/domains/slack-api.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { fetchJson, type ApiRequestOptions } from "../client"; -import type { - SetSlackConfigRequest, - SlackConfig, - TestSlackConnectionResult, -} from "@/lib/types/slack"; -import { invalidateIntegrationAvailabilityAfter } from "@/lib/integrations/integration-availability-events"; - -type WorkspaceApiOptions = ApiRequestOptions & { workspaceId?: string }; - -function withWorkspace(path: string, options?: WorkspaceApiOptions): string { - if (!options?.workspaceId) return path; - const separator = path.includes("?") ? "&" : "?"; - return `${path}${separator}workspace_id=${encodeURIComponent(options.workspaceId)}`; -} - -function requestOptions(options?: WorkspaceApiOptions): ApiRequestOptions | undefined { - if (!options) return undefined; - const { workspaceId: _workspaceId, ...rest } = options; - return rest; -} - -// getSlackConfig returns null when the backend responds 204 (no config yet). -export async function getSlackConfig(options?: WorkspaceApiOptions): Promise { - const res = await fetchJson( - withWorkspace(`/api/v1/slack/config`, options), - requestOptions(options), - ); - return res ?? null; -} - -export async function setSlackConfig( - payload: SetSlackConfigRequest, - options?: WorkspaceApiOptions, -) { - return invalidateIntegrationAvailabilityAfter( - fetchJson(withWorkspace(`/api/v1/slack/config`, options), { - ...requestOptions(options), - init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, - }), - ); -} - -export async function deleteSlackConfig(options?: WorkspaceApiOptions) { - return invalidateIntegrationAvailabilityAfter( - fetchJson<{ deleted: boolean }>(withWorkspace(`/api/v1/slack/config`, options), { - ...requestOptions(options), - init: { ...(options?.init ?? {}), method: "DELETE" }, - }), - ); -} - -export async function testSlackConnection( - payload: SetSlackConfigRequest, - options?: WorkspaceApiOptions, -) { - return fetchJson(withWorkspace(`/api/v1/slack/config/test`, options), { - ...requestOptions(options), - init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, - }); -} - -// copySlackConfig copies the Slack config + credentials from the workspace in -// options (source) to targetWorkspaceId. -export async function copySlackConfig(targetWorkspaceId: string, options?: WorkspaceApiOptions) { - return invalidateIntegrationAvailabilityAfter( - fetchJson(withWorkspace(`/api/v1/slack/config/copy`, options), { - ...requestOptions(options), - init: { - ...(options?.init ?? {}), - method: "POST", - body: JSON.stringify({ targetWorkspaceId }), - }, - }), - ); -} diff --git a/apps/web/lib/settings-discovery/catalog/integrations.ts b/apps/web/lib/settings-discovery/catalog/integrations.ts index 1daff3b25c..313a09f889 100644 --- a/apps/web/lib/settings-discovery/catalog/integrations.ts +++ b/apps/web/lib/settings-discovery/catalog/integrations.ts @@ -7,7 +7,6 @@ const INTEGRATIONS = [ ["jira", "Jira"], ["linear", "Linear"], ["sentry", "Sentry"], - ["slack", "Slack"], ] as const; export const INTEGRATION_SETTINGS_TARGETS = { @@ -17,7 +16,6 @@ export const INTEGRATION_SETTINGS_TARGETS = { jira: "setting-integration-jira-connection", linear: "setting-integration-linear-connection", sentry: "setting-integration-sentry-connection", - slack: "setting-integration-slack-connection", } as const; export const INTEGRATION_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ diff --git a/apps/web/lib/types/slack.ts b/apps/web/lib/types/slack.ts deleted file mode 100644 index a88bd833fe..0000000000 --- a/apps/web/lib/types/slack.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type SlackAuthMethod = "cookie"; - -export interface SlackConfig { - workspaceId?: string; - authMethod: SlackAuthMethod; - commandPrefix: string; - /** ID of a utility agent (`utility_agents.id`) the trigger invokes for each match. */ - utilityAgentId: string; - /** Polling cadence in seconds. Bounded server-side to [5, 600]. */ - pollIntervalSeconds: number; - slackTeamId?: string; - slackUserId?: string; - lastSeenTs?: string; - hasToken: boolean; - hasCookie: boolean; - lastCheckedAt?: string; - lastOk: boolean; - lastError?: string; - createdAt: string; - updatedAt: string; -} - -export interface SetSlackConfigRequest { - authMethod: SlackAuthMethod; - commandPrefix?: string; - utilityAgentId: string; - pollIntervalSeconds?: number; - /** Empty on update keeps the saved value. */ - token?: string; - /** Empty on update keeps the saved value. */ - cookie?: string; -} - -export interface TestSlackConnectionResult { - ok: boolean; - userId?: string; - teamId?: string; - teamName?: string; - url?: string; - displayName?: string; - error?: string; -} diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index faa24b36ee..b24d7f29c2 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -306,7 +306,6 @@ "integrationDescriptionJira": "Atlassian Cloud credentials and JQL issue watchers.", "integrationDescriptionLinear": "Personal API key and team defaults.", "integrationDescriptionSentry": "Auth token, org/project defaults, and issue browsing.", - "integrationDescriptionSlack": "Browser-session credentials and {{trigger}} triage agent.", "invalidJson": "Invalid JSON", "keyboardShortcuts": "Keyboard Shortcuts", "language": "Language", diff --git a/apps/web/src/locales/en/slack.json b/apps/web/src/locales/en/slack.json deleted file mode 100644 index 2680e45359..0000000000 --- a/apps/web/src/locales/en/slack.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "agentDisabled": "{{agent}} — disabled", - "agentNotConfigured": "{{agent}} — not configured", - "agentUsesDefaultModel": "{{name}} (uses default model)", - "agentWithModel": "{{name}} ({{model}})", - "chooseUtilityAgent": "Choose a utility agent", - "commandPrefix": "Command prefix", - "commandPrefixHelp": "Messages you write in Slack starting with this prefix become Kandev tasks. Default:", - "configurationRemoved": "Slack configuration removed", - "configurationSaved": "Slack configuration saved", - "connectedAs": "Connected as {{name}}", - "connectedAsWithTeam": "Connected as {{name}} ({{team}})", - "cookieValue": "{{cookie}} cookie value", - "createUtilityAgentFirst": "Create one in Settings → Utility agents", - "credentialsHelp": "Open Slack in your browser, copy the value of the `{{cookie}}` cookie and the `{{prefix}}` token from local storage. Both are required.", - "deleteFailed": "Delete failed: {{error}}", - "failedToLoadConfig": "Failed to load Slack config: {{error}}", - "integrationDescription": "Capture Slack threads as tasks for the selected workspace. Type {{example}} in any thread you can see and the configured utility agent creates the task.", - "integrationTitle": "Slack integration", - "loadingEllipsis": "Loading…", - "pasteCredentialsToTest": "Paste a token and cookie to test the connection", - "pollingInterval": "Polling interval (seconds)", - "pollingIntervalHelp": "How often Slack is checked for new <1>{{prefix}} messages. Lower = more responsive, higher = fewer Slack API calls. Range: {{min}}–{{max}}s. Default: {{fallback}}s.", - "promptTokensHelp": "Custom prompts can reference <1>{{tokens}}. When at least one is used, your template owns the full prompt; otherwise the default Slack-triage system prompt is prepended automatically.", - "removeConfiguration": "Remove configuration", - "removeConfigurationConfirm": "Remove Slack configuration?", - "removing": "Removing...", - "saveFailed": "Save failed: {{error}}", - "savedLeaveBlank": "(saved — leave blank to keep)", - "secretsRequired": "A session token and cookie are required.", - "sessionToken": "Session token ({{prefix}}…)", - "testConnection": "Test connection", - "testFailed": "Failed: {{error}}", - "testing": "Testing...", - "triageAgent": "Triage agent", - "triageAgentHelp": "The utility agent that interprets each Slack message and creates the Kandev task. It runs with Kandev MCP tools wired in ({{tools}}) so it picks the destination Kandev workspace + workflow + repo from context. Built-in agents use your default model from <4>Settings → Utility agents.", - "triageAgentRequired": "A triage agent is required.", - "unsupportedAuthWarning": "<0>Browser session auth (unsupported): Slack rotates session cookies often, so you may need to reconnect when authentication expires. Bot installs and user OAuth are on the roadmap." -} diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 2028dfaecc..a0667cfc78 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -306,7 +306,6 @@ "integrationDescriptionJira": "Àţĺàśśĩàń Ćĺōũď ćŕēďēńţĩàĺś àńď ĴQĹ ĩśśũē ŵàţćĥēŕś.", "integrationDescriptionLinear": "Ƥēŕśōńàĺ ÀƤĨ ķēŷ àńď ţēàḿ ďēƒàũĺţś.", "integrationDescriptionSentry": "Àũţĥ ţōķēń, ōŕĝ/ƥŕōĵēćţ ďēƒàũĺţś, àńď ĩśśũē ƀŕōŵśĩńĝ.", - "integrationDescriptionSlack": "Ɓŕōŵśēŕ-śēśśĩōń ćŕēďēńţĩàĺś àńď {{trigger}} ţŕĩàĝē àĝēńţ.", "invalidJson": "Ĩńvàĺĩď ĴŚŌŃ", "keyboardShortcuts": "Ķēŷƀōàŕď Śĥōŕţćũţś", "language": "Ĺàńĝũàĝē", diff --git a/apps/web/src/locales/pseudo/slack.json b/apps/web/src/locales/pseudo/slack.json deleted file mode 100644 index 8325855c02..0000000000 --- a/apps/web/src/locales/pseudo/slack.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "agentDisabled": "{{agent}} — ďĩśàƀĺēď", - "agentNotConfigured": "{{agent}} — ńōţ ćōńƒĩĝũŕēď", - "agentUsesDefaultModel": "{{name}} (ũśēś ďēƒàũĺţ ḿōďēĺ)", - "agentWithModel": "{{name}} ({{model}})", - "chooseUtilityAgent": "Ćĥōōśē à ũţĩĺĩţŷ àĝēńţ", - "commandPrefix": "Ćōḿḿàńď ƥŕēƒĩx", - "commandPrefixHelp": "Ḿēśśàĝēś ŷōũ ŵŕĩţē ĩń Śĺàćķ śţàŕţĩńĝ ŵĩţĥ ţĥĩś ƥŕēƒĩx ƀēćōḿē Ķàńďēv ţàśķś. Ďēƒàũĺţ:", - "configurationRemoved": "Śĺàćķ ćōńƒĩĝũŕàţĩōń ŕēḿōvēď", - "configurationSaved": "Śĺàćķ ćōńƒĩĝũŕàţĩōń śàvēď", - "connectedAs": "Ćōńńēćţēď àś {{name}}", - "connectedAsWithTeam": "Ćōńńēćţēď àś {{name}} ({{team}})", - "cookieValue": "{{cookie}} ćōōķĩē vàĺũē", - "createUtilityAgentFirst": "Ćŕēàţē ōńē ĩń Śēţţĩńĝś → Ũţĩĺĩţŷ àĝēńţś", - "credentialsHelp": "Ōƥēń Śĺàćķ ĩń ŷōũŕ ƀŕōŵśēŕ, ćōƥŷ ţĥē vàĺũē ōƒ ţĥē `{{cookie}}` ćōōķĩē àńď ţĥē `{{prefix}}` ţōķēń ƒŕōḿ ĺōćàĺ śţōŕàĝē. Ɓōţĥ àŕē ŕēqũĩŕēď.", - "deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}", - "failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Śĺàćķ ćōńƒĩĝ: {{error}}", - "integrationDescription": "Ćàƥţũŕē Śĺàćķ ţĥŕēàďś àś ţàśķś ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē. Ţŷƥē {{example}} ĩń àńŷ ţĥŕēàď ŷōũ ćàń śēē àńď ţĥē ćōńƒĩĝũŕēď ũţĩĺĩţŷ àĝēńţ ćŕēàţēś ţĥē ţàśķ.", - "integrationTitle": "Śĺàćķ ĩńţēĝŕàţĩōń", - "loadingEllipsis": "Ĺōàďĩńĝ…", - "pasteCredentialsToTest": "Ƥàśţē à ţōķēń àńď ćōōķĩē ţō ţēśţ ţĥē ćōńńēćţĩōń", - "pollingInterval": "Ƥōĺĺĩńĝ ĩńţēŕvàĺ (śēćōńďś)", - "pollingIntervalHelp": "Ĥōŵ ōƒţēń Śĺàćķ ĩś ćĥēćķēď ƒōŕ ńēŵ <1>{{prefix}} ḿēśśàĝēś. Ĺōŵēŕ = ḿōŕē ŕēśƥōńśĩvē, ĥĩĝĥēŕ = ƒēŵēŕ Śĺàćķ ÀƤĨ ćàĺĺś. Ŕàńĝē: {{min}}–{{max}}ś. Ďēƒàũĺţ: {{fallback}}ś.", - "promptTokensHelp": "Ćũśţōḿ ƥŕōḿƥţś ćàń ŕēƒēŕēńćē <1>{{tokens}}. Ŵĥēń àţ ĺēàśţ ōńē ĩś ũśēď, ŷōũŕ ţēḿƥĺàţē ōŵńś ţĥē ƒũĺĺ ƥŕōḿƥţ; ōţĥēŕŵĩśē ţĥē ďēƒàũĺţ Śĺàćķ-ţŕĩàĝē śŷśţēḿ ƥŕōḿƥţ ĩś ƥŕēƥēńďēď àũţōḿàţĩćàĺĺŷ.", - "removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń", - "removeConfigurationConfirm": "Ŕēḿōvē Śĺàćķ ćōńƒĩĝũŕàţĩōń?", - "removing": "Ŕēḿōvĩńĝ...", - "saveFailed": "Śàvē ƒàĩĺēď: {{error}}", - "savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ)", - "secretsRequired": "À śēśśĩōń ţōķēń àńď ćōōķĩē àŕē ŕēqũĩŕēď.", - "sessionToken": "Śēśśĩōń ţōķēń ({{prefix}}…)", - "testConnection": "Ţēśţ ćōńńēćţĩōń", - "testFailed": "Ƒàĩĺēď: {{error}}", - "testing": "Ţēśţĩńĝ...", - "triageAgent": "Ţŕĩàĝē àĝēńţ", - "triageAgentHelp": "Ţĥē ũţĩĺĩţŷ àĝēńţ ţĥàţ ĩńţēŕƥŕēţś ēàćĥ Śĺàćķ ḿēśśàĝē àńď ćŕēàţēś ţĥē Ķàńďēv ţàśķ. Ĩţ ŕũńś ŵĩţĥ Ķàńďēv ḾĆƤ ţōōĺś ŵĩŕēď ĩń ({{tools}}) śō ĩţ ƥĩćķś ţĥē ďēśţĩńàţĩōń Ķàńďēv ŵōŕķśƥàćē + ŵōŕķƒĺōŵ + ŕēƥō ƒŕōḿ ćōńţēxţ. Ɓũĩĺţ-ĩń àĝēńţś ũśē ŷōũŕ ďēƒàũĺţ ḿōďēĺ ƒŕōḿ <4>Śēţţĩńĝś → Ũţĩĺĩţŷ àĝēńţś.", - "triageAgentRequired": "À ţŕĩàĝē àĝēńţ ĩś ŕēqũĩŕēď.", - "unsupportedAuthWarning": "<0>Ɓŕōŵśēŕ śēśśĩōń àũţĥ (ũńśũƥƥōŕţēď): Śĺàćķ ŕōţàţēś śēśśĩōń ćōōķĩēś ōƒţēń, śō ŷōũ ḿàŷ ńēēď ţō ŕēćōńńēćţ ŵĥēń àũţĥēńţĩćàţĩōń ēxƥĩŕēś. Ɓōţ ĩńśţàĺĺś àńď ũśēŕ ŌÀũţĥ àŕē ōń ţĥē ŕōàďḿàƥ." -} diff --git a/apps/web/src/locales/zh-cn/slack.json b/apps/web/src/locales/zh-cn/slack.json deleted file mode 100644 index 2a60bd201e..0000000000 --- a/apps/web/src/locales/zh-cn/slack.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "agentDisabled": "{{agent}} — 已禁用", - "agentNotConfigured": "{{agent}} — 未配置", - "agentUsesDefaultModel": "{{name}}(使用默认模型)", - "agentWithModel": "{{name}}({{model}})", - "chooseUtilityAgent": "选择实用代理", - "commandPrefix": "命令前缀", - "commandPrefixHelp": "你在 Slack 中发送的消息如果以此前缀开头,就会成为 Kandev 任务。默认值:", - "configurationRemoved": "已移除 Slack 配置", - "configurationSaved": "已保存 Slack 配置", - "connectedAs": "已连接为 {{name}}", - "connectedAsWithTeam": "已连接为 {{name}}({{team}})", - "cookieValue": "{{cookie}} Cookie 值", - "createUtilityAgentFirst": "请先在“设置 → 实用代理”中创建一个", - "credentialsHelp": "在浏览器中打开 Slack,复制 `{{cookie}}` Cookie 的值和本地存储中的 `{{prefix}}` 令牌。两者都必填。", - "deleteFailed": "删除失败:{{error}}", - "failedToLoadConfig": "加载 Slack 配置失败:{{error}}", - "integrationDescription": "将 Slack 话题串收集为所选工作区的任务。在你可见的任意话题串中输入 {{example}},配置的实用代理便会创建任务。", - "integrationTitle": "Slack 集成", - "loadingEllipsis": "正在加载…", - "pasteCredentialsToTest": "粘贴令牌和 Cookie 以测试连接", - "pollingInterval": "轮询间隔(秒)", - "pollingIntervalHelp": "检查 Slack 中新 <1>{{prefix}} 消息的频率。值越小响应越快,值越大调用 Slack API 的次数越少。范围:{{min}}–{{max}} 秒。默认值:{{fallback}} 秒。", - "promptTokensHelp": "自定义提示词可以引用 <1>{{tokens}}。使用至少一个令牌时,模板将定义完整提示词;否则会自动在前面加上默认的 Slack 分流系统提示词。", - "removeConfiguration": "移除配置", - "removeConfigurationConfirm": "移除 Slack 配置?", - "removing": "正在移除...", - "saveFailed": "保存失败:{{error}}", - "savedLeaveBlank": "(已保存——留空可保持不变)", - "secretsRequired": "会话令牌和 Cookie 均为必填项。", - "sessionToken": "会话令牌({{prefix}}…)", - "testConnection": "测试连接", - "testFailed": "失败:{{error}}", - "testing": "正在测试...", - "triageAgent": "分流代理", - "triageAgentHelp": "用于解读每条 Slack 消息并创建 Kandev 任务的实用代理。运行时会接入 Kandev MCP 工具({{tools}}),以便根据上下文选择目标 Kandev 工作区、工作流和仓库。内置代理使用<4>设置 → 实用代理中的默认模型。", - "triageAgentRequired": "必须选择分流代理。", - "unsupportedAuthWarning": "<0>浏览器会话身份验证(不受支持):Slack 经常轮换会话 Cookie,因此身份验证过期后可能需要重新连接。机器人安装和用户 OAuth 已列入路线图。" -} diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index a347a989e9..c1ef62fa1f 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -19,7 +19,6 @@ import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page"; import IntegrationsJiraPage from "@/app/settings/integrations/jira/page"; import IntegrationsLinearPage from "@/app/settings/integrations/linear/page"; import IntegrationsSentryPage from "@/app/settings/integrations/sentry/page"; -import IntegrationsSlackPage from "@/app/settings/integrations/slack/page"; import PluginsSettingsPage from "@/app/settings/plugins/page"; import PluginDetailPage from "@/app/settings/plugins/[pluginId]/page"; import MessageQueueSettingsPage from "@/app/settings/general/message-queue/page"; @@ -169,7 +168,6 @@ const SETTINGS_ROUTES: Record = { "/settings/integrations/jira": () => renderIntegrationSettingsRoute("jira"), "/settings/integrations/linear": () => renderIntegrationSettingsRoute("linear"), "/settings/integrations/sentry": () => renderIntegrationSettingsRoute("sentry"), - "/settings/integrations/slack": () => renderIntegrationSettingsRoute("slack"), "/settings/system": () => , "/settings/system/users": () => ( @@ -394,8 +392,6 @@ function renderIntegrationSettingsRoute(section: string | null, workspaceId?: st return ; case "sentry": return ; - case "slack": - return ; default: return null; } diff --git a/docs/features.md b/docs/features.md index e0534ee8a9..5775ec9888 100644 --- a/docs/features.md +++ b/docs/features.md @@ -35,7 +35,7 @@ This page expands the short feature list in the README without turning the READM ## Integrations And MCP -- **Integrations:** GitHub, GitLab, Jira, Linear, Sentry, and Slack connect external work back into Kandev tasks. +- **Integrations:** GitHub, GitLab, Jira, Linear, and Sentry connect external work back into Kandev tasks. Slack ships separately as the `kandev-plugin-slack` plugin. - **External MCP:** Kandev exposes streamable HTTP and SSE MCP endpoints so external coding agents can manage Kandev from outside the app. Settings include copyable snippets for Claude Code, Cursor, Codex, Auggie CLI, OpenCode, and GitHub Copilot CLI. - **Automatic session MCP:** agents launched normally inside Kandev receive the Kandev task MCP automatically. - **Passthrough MCP:** passthrough agents can also use the Kandev MCP endpoint while keeping their native CLI interface. diff --git a/docs/i18n.md b/docs/i18n.md index a3c076c33c..7aba65538d 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -41,7 +41,7 @@ Two enforcement layers, and it matters which is which: **Namespaces** map to product areas. Current namespaces are `agents`, `automations`, `azuredevops`, `chat`, `common` (shared/anything cross-cutting), `configChat`, `executors`, `github`, `gitlab`, `jira`, `linear`, `sentry`, `settings`, -`sidebar`, `slack`, `system`, `workflows`, and `workspaces`. Each migrated area +`sidebar`, `system`, `workflows`, and `workspaces`. Each migrated area can add the namespace it needs (`statusBar`, `kanban`, `task`, …). A string used in two or more areas belongs in `common` so it is translated once. A new namespace needs no registration: `lib/i18n/index.ts` discovers catalogs by glob. diff --git a/docs/public/architecture.md b/docs/public/architecture.md index 1e0b019888..fa39d76e78 100644 --- a/docs/public/architecture.md +++ b/docs/public/architecture.md @@ -46,7 +46,7 @@ Backend code is under `apps/backend/internal/`. Common owners include: - `agent/` for agent definitions, profiles, executor mapping, and lifecycle; - `agentctl/` for the sidecar server, process/protocol adapters, Git, files, shell, terminal, and workspace operations; - `worktree/` plus runtime implementations for local, Docker, remote Docker, SSH, and Sprites environments; -- provider domains such as `github/`, `gitlab/`, `jira/`, `linear/`, `sentry/`, and `slack/`; +- provider domains such as `github/`, `gitlab/`, `jira/`, `linear/`, and `sentry/`; - `mcp/` for Kandev tool schemas and backend handlers; - `gateway/websocket/` for client broadcasts; - `system/`, `persistence/`, and `db/` for status, storage setup, pools, and SQL dialect primitives. diff --git a/docs/public/authentication.md b/docs/public/authentication.md index 2ba6027cf3..1cac0ff348 100644 --- a/docs/public/authentication.md +++ b/docs/public/authentication.md @@ -67,7 +67,7 @@ External MCP clients (Claude Code, Cursor connecting to `/mcp`) must be configur ## What is isolated -When authentication is on, everything in a workspace is private to its owner and returns "not found" to anyone else — even if they know the ID: workspaces, tasks, workflows, sessions, plans, walkthroughs, terminals, VS Code, port previews, git snapshots, Workspace secrets, repository bindings, **and the workspace's third-party integration settings (GitHub/GitLab/Jira/Linear/Sentry/Slack/Azure) and automations**. A user's Global secrets are also private to that user. Admins manage users but do not see other users' workspaces or secrets. +When authentication is on, everything in a workspace is private to its owner and returns "not found" to anyone else — even if they know the ID: workspaces, tasks, workflows, sessions, plans, walkthroughs, terminals, VS Code, port previews, git snapshots, Workspace secrets, repository bindings, **and the workspace's third-party integration settings (GitHub/GitLab/Jira/Linear/Sentry/Azure) and automations**. A user's Global secrets are also private to that user. Admins manage users but do not see other users' workspaces or secrets. Shared across the instance (by design): executors, agent profiles, environments, editors, prompts, and system pages. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 828d459d1c..b715aa022a 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -227,8 +227,7 @@ "/settings/integrations/gitlab", "/settings/integrations/jira", "/settings/integrations/linear", - "/settings/integrations/sentry", - "/settings/integrations/slack" + "/settings/integrations/sentry" ], "mcpTools": [ "get_task_mr_automation_kandev", diff --git a/docs/public/extending-kandev.md b/docs/public/extending-kandev.md index d5841df14e..0415d6628f 100644 --- a/docs/public/extending-kandev.md +++ b/docs/public/extending-kandev.md @@ -49,8 +49,7 @@ Choose the closest existing provider domain under `apps/backend/internal/`. Ther - Jira and Linear use workspace-scoped connection state; - Sentry supports multiple named workspace instances; -- GitHub keeps installation-wide authentication with workspace settings, while GitLab owns one connection and its watches per workspace; -- Slack follows a workspace-oriented pattern. +- GitHub keeps installation-wide authentication with workspace settings, while GitLab owns one connection and its watches per workspace. A domain may contain a client, store/repository, service, provider, handlers, and poller. Shared helpers under `internal/integrations/` cover secret adapters, health polling, and workspace scope. Construction and non-fatal provider startup live in `internal/backendapp/`. Web clients/hooks live in `lib/api/domains/` and `hooks/domains/`; settings and shared provider UI live under `app/settings/` and `components/integrations/`. diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index 7e1a0e1087..a8f83831f5 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -99,8 +99,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | Jira integration | Limited | Search/browse, supported transitions, launch presets, task launch, and watches require provider credentials. Launch copies data rather than creating a durable association; each watch poll reads only the first 50 JQL matches. | | Linear integration | Limited | Search/browse, state changes, task launch, and watches require provider credentials. Launch has no preset/durable association; a watch reads 50 matches in provider order or at most 250 with explicit local sorting. | | Sentry integration | Limited | Multi-instance configuration, issue watches, and current-task issue actions are shipped; there is no top-level browser. Watches read only the newest first page, and the status picker allows multiple selections while the backend accepts at most one. | -| Slack integration | Experimental | Uses a nonstandard browser-session token/cookie and polling rather than OAuth or a bot installation. A first scan can process up to 30 existing matches, and reactions/replies are best-effort. | -| Integration **Enabled** switches | Limited | Jira/Linear/Sentry/Slack switches are browser-local presentation state; they do not stop backend pollers. Pause/delete watches or remove the Slack/provider configuration when polling must stop. | +| Integration **Enabled** switches | Limited | Jira/Linear/Sentry switches are browser-local presentation state; they do not stop backend pollers. Pause/delete watches or remove the provider configuration when polling must stop. | | Manual, preset/`@every` schedule, authenticated webhook, and GitHub PR-open automations | Limited | Every automation now produces the same kind of run; the Task/Run mode selector is withdrawn and runs have their own destination instead of the board. PR triggers need explicit repositories and ignore the saved Opened checkbox. Webhooks silently retain only the first 1 MiB. **Play** remains clickable while disabled but produces no history. | | Fixed cron timezone behavior | Limited | The UI accepts fixed cron and timezone values, but the current interval scheduler does not honor the configured timezone and some fixed cron expressions may never fire. Prefer tested presets or `@every` intervals. | | GitHub push and CI automation triggers | In progress | Trigger types and UI labels exist as coming-soon/stub paths; they are not working production triggers. GitHub's separate opt-in PR CI-fix automation is shipped. | diff --git a/docs/public/index.md b/docs/public/index.md index 10d3338bd2..2f9573a365 100644 --- a/docs/public/index.md +++ b/docs/public/index.md @@ -28,7 +28,7 @@ Choose the guide that matches the job in front of you: - [Agent communication](agent-communication.md): send cross-task prompts, receive replies, and negotiate contracts with built-in Kandev MCP tools. - [Agents and profiles](agents-and-profiles.md): configure agent CLIs, models, modes, permissions, environment, passthrough, and credentials. - [Executors](executors.md): choose local, worktree, Docker, SSH, or Sprites execution and understand the isolation boundary. -- [Integrations](integrations.md): configure Azure DevOps, GitHub, GitLab, Jira, Linear, Sentry, and Slack for a workspace. +- [Integrations](integrations.md): configure Azure DevOps, GitHub, GitLab, Jira, Linear, and Sentry for a workspace. - [Developer tools](developer-tools.md): use quick chat, prompts, utility agents, voice input, editors, terminal, shortcuts, and notifications. - [Automation and MCP](automation-and-mcp.md): create scheduled or event-driven work, use task MCP, and connect an external MCP client. - [Feature status](feature-status.md): check support boundaries, dependencies, experimental features, and unfinished work. diff --git a/docs/public/integrations.md b/docs/public/integrations.md index 157715430c..7aaf04be3d 100644 --- a/docs/public/integrations.md +++ b/docs/public/integrations.md @@ -1,6 +1,6 @@ --- title: "Integrations" -description: "Connect Azure DevOps, GitHub, GitLab, Jira, Linear, Sentry, and Slack, then browse external work or create watched tasks." +description: "Connect Azure DevOps, GitHub, GitLab, Jira, Linear, and Sentry, then browse external work or create watched tasks." --- # Integrations @@ -36,17 +36,16 @@ Select **Settings > Workspaces > _Workspace_ > Integrations**, then choose a pro - `/settings/workspace/{workspaceId}/integrations/jira` - `/settings/workspace/{workspaceId}/integrations/linear` - `/settings/workspace/{workspaceId}/integrations/sentry` -- `/settings/workspace/{workspaceId}/integrations/slack` Compatibility routes under **Settings > Integrations** use the active workspace where the provider has workspace settings. -GitHub, GitLab, Azure DevOps, Jira, Linear, and Slack configuration are workspace-specific. GitHub supports one automation connection per workspace and, for App-backed workspaces, one personal identity per Kandev user and workspace. The current GitHub integration targets `github.com`; GitLab supports `gitlab.com` and self-managed origins. Sentry supports multiple named instances per workspace. Do not assume that configuring one workspace gives another the same provider scope. +GitHub, GitLab, Azure DevOps, Jira, and Linear configuration are workspace-specific. GitHub supports one automation connection per workspace and, for App-backed workspaces, one personal identity per Kandev user and workspace. The current GitHub integration targets `github.com`; GitLab supports `gitlab.com` and self-managed origins. Sentry supports multiple named instances per workspace. Do not assume that configuring one workspace gives another the same provider scope. Provider secrets saved by these forms use Kandev's encrypted secret store. The backend must still decrypt them to make API requests. Limit access to settings and the Kandev data directory, and use the narrowest provider scope that works. ### The Enabled switch -Jira, Linear, Sentry, and Slack pages show an **Enabled** switch. It is a browser-local preference, saved per installation in that browser and on by default. It controls some client-side entry points, availability checks, and configuration fetches; settings pages can still poll provider health. It does not delete backend configuration and does not stop a server-side watch or Slack poller. Pause/delete watches or remove the provider configuration when processing must stop. +Jira, Linear, and Sentry pages show an **Enabled** switch. It is a browser-local preference, saved per installation in that browser and on by default. It controls some client-side entry points, availability checks, and configuration fetches; settings pages can still poll provider health. It does not delete backend configuration and does not stop a server-side watch. Pause/delete watches or remove the provider configuration when processing must stop. Health results are cached and periodically refreshed (normally about every 90 seconds in the settings UI). Use **Test connection** after changing a URL or credential rather than waiting for the next probe. @@ -473,39 +472,6 @@ An instance cannot be deleted while a watch references it. Because the instance -## Slack - -> **Security:** Slack matching text is untrusted input, and the external MCP endpoint exposes destructive task/configuration tools. Use a constrained utility agent and model; review the [external MCP security boundary](automation-and-mcp.md#external-mcp-security-boundary). - -
-Slack details - -Slack support currently uses a browser-session polling connection. It is intended for a controlled personal workspace and is more fragile than OAuth or a bot installation. Kandev does not currently offer a Slack OAuth/bot install flow. - -Configure, per workspace: - -- an `xoxc-...` browser session token; -- only the value of the Slack `d` cookie; -- a **Utility Agent** from **Settings > Utility agents**; -- a command prefix, default `!kandev`; -- a polling interval, default 30 seconds and allowed range 5–600 seconds. - -The workspace owns this configuration record, but it does not hard-pin the destination of a created task. The built-in triage prompt deliberately lists every Kandev workspace and asks the agent to choose one. Separate workspace configurations keep separate polling cursors; reusing the same Slack account and prefix in several configurations can therefore process the same authored message more than once. - -With a saved configuration whose latest authentication health check succeeded, Kandev polls messages visible to the connected Slack user. The browser-local **Enabled** preference is not part of this backend gate. A message authored by that same Slack user and beginning with the prefix can trigger in a channel or direct message. - -On the first successful scan the watermark is empty. Slack search returns the newest 30 matching messages, and Kandev processes those matches oldest-first; enabling the integration can therefore act on up to 30 messages that already existed. Use a unique prefix, remove or edit old matching messages, or be ready to remove unintended tasks before first configuration. - -For each match, Kandev best-effort adds an eyes reaction, fetches the surrounding thread, and gives the request, thread, and external Kandev MCP endpoint to the selected utility agent. It then best-effort posts the agent's final response in-thread. Reaction failure does not stop task creation. Reply failure still advances the watermark, so a task can exist without a Slack reply. A thread-fetch or agent-run failure stops that scan's batch and retries the failed and later matches on a future scan. - -This is external/configuration MCP, not a task-scoped MCP session: the endpoint also exposes destructive task and configuration tools. Use a constrained utility agent and model, treat matching Slack text as untrusted input, and review the [external MCP security boundary](automation-and-mcp.md#external-mcp-security-boundary). - -Slack has no separate prompt editor. It uses the chosen Utility Agent's prompt from **Settings > Utility agents**, which can reference `{{SlackInstruction}}`, `{{SlackThread}}`, `{{SlackPermalink}}`, `{{SlackUser}}`, `{{SlackChannelID}}`, and `{{SlackTS}}`. If the raw utility prompt contains any Slack-specific placeholder, its resolved value is the complete prompt. Otherwise Kandev uses the resolved utility prompt as the system text and appends Slack context; the built-in triage instructions are used only when that resolved prompt is blank. - -Slack does not trigger on reactions, expose a slash command/shortcut, mirror task status, or provide a live chat bridge to a running coding agent. It searches matching messages rather than performing a one-time history import, which is why the first scan can process existing matches. Browser session credentials can expire without notice; reconnect when polling starts returning authentication failures. Turning off the browser-local **Enabled** switch does not stop the backend poller—remove the saved Slack configuration to stop it. - -
- ## Copy configuration between workspaces
@@ -514,7 +480,7 @@ Slack does not trigger on reactions, expose a slash command/shortcut, mirror tas Supported integration pages offer **Copy configuration** with provider-specific behavior: - GitHub copies repository scope, saved/default searches, and quick-action presets. It does not copy authentication or watches. -- Azure DevOps, Jira, Linear, and Slack copy the workspace configuration and encrypted credential, replacing the target's provider configuration and re-running health checks. They do not copy watches. +- Azure DevOps, Jira, and Linear copy the workspace configuration and encrypted credential, replacing the target's provider configuration and re-running health checks. They do not copy watches. - Sentry adds copies of the source instances with new IDs and copied secrets, preserves target instances, and deduplicates conflicting names. It does not copy watches. - GitLab replaces the target workspace's host, authentication method, and stored PAT. It does not copy watches, task-launch action presets, or task-to-MR links. @@ -524,13 +490,13 @@ Workspace automations are never copied by this action. Review the target workspa ## Security and troubleshooting -Issue bodies, pull-request comments, commit messages, Slack threads, and incident details are untrusted prompt input. Use read-only credentials for triage, restrict repositories/projects/channels, and keep a human workflow gate before merge, release, deployment, or sensitive transitions. +Issue bodies, pull-request comments, commit messages, and incident details are untrusted prompt input. Use read-only credentials for triage, restrict repositories/projects/channels, and keep a human workflow gate before merge, release, deployment, or sensitive transitions. - **Connection test fails:** verify the base URL, deployment type, token format, expiration, scopes, and network/DNS access from the backend host. - **Cleared token but connection remains:** for GitHub, a higher-priority CLI or environment credential may still be active. Clearing GitLab from its workspace settings removes that workspace connection. - **Repository, project, or team is missing:** confirm the connected identity can see it and check workspace filters/defaults. - **Kandev can read but cannot write:** add only the specific provider write scope needed, then repeat the test. -- **Task cannot fetch or push:** inspect the selected executor's Git/SSH credentials and repository remote. For GitHub, inspect any explicit profile `GITHUB_TOKEN`/`GH_TOKEN`; otherwise verify the workspace automation connection, repository scope, broker reachability, and App Contents permission. For GitLab HTTP remotes, confirm the task workspace connection host exactly matches the remote and its token can access the project. The Azure PAT can authenticate the backend's initial managed clone/fetch but is not exposed to the task for later pushes. Jira, Linear, Sentry, and Slack integration credentials are not task Git credentials. +- **Task cannot fetch or push:** inspect the selected executor's Git/SSH credentials and repository remote. For GitHub, inspect any explicit profile `GITHUB_TOKEN`/`GH_TOKEN`; otherwise verify the workspace automation connection, repository scope, broker reachability, and App Contents permission. For GitLab HTTP remotes, confirm the task workspace connection host exactly matches the remote and its token can access the project. The Azure PAT can authenticate the backend's initial managed clone/fetch but is not exposed to the task for later pushes. Jira, Linear, and Sentry integration credentials are not task Git credentials. - **A watch still runs after disabling the provider:** the Enabled switch is browser-local. Pause/delete the watch, or remove the backend configuration. - **Unexpected work is created:** pause the watch or automation, inspect its query, last-polled/status fields, and created-task list, then narrow provider filters before resetting or polling again. Watch tables do not provide a separate run/import history. diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index 216fe3a0e8..1b055e2069 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -573,7 +573,7 @@ gitlab.task_mrs.list Provider actions make outbound calls with the backend's configured GitHub or GitLab identity. Status and registration do not imply a provider is authenticated, reachable, or authorized for a repository. -### Jira, Linear, Slack, and Sprites +### Jira, Linear, and Sprites ```text jira.config.delete @@ -592,11 +592,6 @@ linear.issue.get linear.issue.transition linear.teams.list -slack.config.delete -slack.config.get -slack.config.set -slack.config.test - sprites.instances.destroy sprites.instances.list sprites.network_policy.get diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index f01093f8a3..7b809cf6cd 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -109,7 +109,7 @@ Per-workspace credentials and triage triggers for external services. | Spec | Status | |---|---| | [azure-devops-integration](azure-devops-integration/spec.md) | shipped | -| [slack](integrations/slack.md) | shipped | +| [slack](integrations/slack.md) | archived — moved to `kandev-plugin-slack` | | [external-mcp](integrations/external-mcp.md) | draft | | [mcp-tool-argument-validation](integrations/mcp-tool-argument-validation.md) | shipped | | [provider-aware-review-automation](integrations/provider-aware-review-automation.md) | approved | diff --git a/docs/specs/integrations/slack.md b/docs/specs/integrations/slack.md index e8173cdc73..592f7e1d82 100644 --- a/docs/specs/integrations/slack.md +++ b/docs/specs/integrations/slack.md @@ -1,11 +1,31 @@ --- -status: shipped +status: archived created: 2026-05-02 +archived: 2026-08-05 owner: tbd --- # Slack Integration +> **Archived — moved out of this repository.** Slack now ships as the +> standalone `kandev-plugin-slack` plugin +> ([kdlbs/kandev-plugin-slack](https://github.com/kdlbs/kandev-plugin-slack)), +> installed from Settings → Plugins. The in-tree `internal/slack` package, its +> `/api/v1/slack/*` routes, and the `/settings/integrations/slack` page were +> removed, and the retired `slack_configs` table plus the `slack:*` vault +> entries are dropped on upgrade. +> +> **The plugin's contract differs from what is described below.** It installs +> as a real Slack app over Socket Mode, so events are pushed over a WebSocket +> and nothing is polled, and requests are addressed with `@Kandev …` or the +> `/kandev` slash command rather than the `!kandev` text marker. The +> browser-session (`xoxc-` + `d` cookie) credentials described below survive +> only as a fallback for workspaces that forbid app installs — that fallback is +> the one place `!kandev` and polling still apply. +> +> Everything from "Why" onwards is the historical record of what shipped +> in-tree. See the plugin's README for current behaviour. + ## Why Users live in Slack and surface task-worthy work there: bug reports in `#support`, feature ideas in DMs, alerts in `#oncall`. Today they have to context-switch to Kandev to capture each one, which loses the original message link and discourages capture in the moment. Teams on locked-down Enterprise Slack tenants cannot install bots, so a bot-only integration would shut them out. diff --git a/plugin-registry/plugins.yaml b/plugin-registry/plugins.yaml index 04fcdc809c..d60293069c 100644 --- a/plugin-registry/plugins.yaml +++ b/plugin-registry/plugins.yaml @@ -32,6 +32,9 @@ plugins: - id: kandev-plugin-kandy repo: kdlbs/kandev-plugin-kandy categories: [tools] + - id: kandev-plugin-slack + repo: kdlbs/kandev-plugin-slack + categories: [integrations, automation] - id: kandev-plugin-tags repo: yattdev/kandev-plugin-tags categories: [tools]