diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index d00c074c66..dad4ef4c60 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -49,6 +49,7 @@ import ( prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" spriteshandlers "github.com/kandev/kandev/internal/sprites" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/models" @@ -626,6 +627,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered Linear handlers (HTTP + WebSocket)") } + 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)") + } + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index f3cc9648f6..6aff72c903 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -32,6 +32,7 @@ import ( // JIRA integration jirapkg "github.com/kandev/kandev/internal/jira" linearpkg "github.com/kandev/kandev/internal/linear" + slackpkg "github.com/kandev/kandev/internal/slack" // Agent infrastructure "github.com/kandev/kandev/internal/agent/hostutility" @@ -368,6 +369,20 @@ func startAgentInfrastructure( addCleanup(func() error { linearPoller.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 }) + } + return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, addCleanup, runCleanups) } @@ -443,6 +458,24 @@ 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) + } + if err := orchestratorSvc.Start(ctx); err != nil { log.Error("Failed to start orchestrator", zap.Error(err)) return false diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 4261354217..b12381a40d 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -2,12 +2,15 @@ package main import ( "context" + "fmt" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/discovery" + "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" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" @@ -19,6 +22,7 @@ import ( "github.com/kandev/kandev/internal/linear" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -87,6 +91,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log) jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) + slackSvc := initSlackService(dbPool, repos.Secrets, log) return &Services{ Task: taskSvc, @@ -98,6 +103,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories GitHub: githubSvc, Jira: jiraSvc, Linear: linearSvc, + Slack: slackSvc, // Notification service is initialized after gateway is available. Notification: nil, }, agentSettingsController, nil @@ -246,6 +252,60 @@ func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr return svc } +// 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 +} + +// 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 +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index fee624d0a0..84098f9a35 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -13,6 +13,7 @@ import ( promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -47,4 +48,5 @@ type Services struct { GitHub *github.Service Jira *jira.Service Linear *linear.Service + Slack *slack.Service } diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index f156223f7d..3c38b244eb 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -56,11 +56,24 @@ func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilit } // ExecutePrompt runs a sessionless utility prompt against the warm instance -// for the given agent type. The caller picks the model (explicit from the -// utility agent record, user default, or probe cache fallback). +// for the given agent type. Convenience wrapper over ExecutePromptWithMCP +// that opts the call out of MCP tool access — most callers (PR title, commit +// message, etc.) want pure text-in/text-out. func (m *Manager) ExecutePrompt( ctx context.Context, agentType, model, mode, prompt string, +) (*PromptResult, error) { + return m.ExecutePromptWithMCP(ctx, agentType, model, mode, prompt, nil) +} + +// ExecutePromptWithMCP runs a sessionless utility prompt with the given MCP +// servers wired into the agent's session. The agent (Claude Code, codex, +// etc.) can call MCP tools mid-prompt and incorporate the results into its +// final reply. Pass nil for mcpServers to disable MCP for this call. +func (m *Manager) ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, ) (*PromptResult, error) { if prompt == "" { return nil, fmt.Errorf("prompt is required") @@ -83,6 +96,7 @@ func (m *Manager) ExecutePrompt( ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, }, + MCPServers: mcpServers, } resp, err := inst.client.InferencePrompt(ctx, req) if err != nil { diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index 34ad017734..039be01ba2 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -85,7 +85,12 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) }() // Execute ACP protocol - response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt, req.Model, req.Mode) + mcpServers, dropped := toACPMcpServers(req.MCPServers) + for _, name := range dropped { + e.logger.Warn("ACP inference: dropping unsupported MCP server transport", + zap.String("name", name)) + } + response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt, req.Model, req.Mode, mcpServers) if err != nil { return &PromptResponse{ Success: false, @@ -104,7 +109,9 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) // executeACPSession performs the ACP handshake, creates a session, optionally // sets the session model and mode, sends the prompt, and collects the response -// text. +// text. mcpServers, when non-empty, are forwarded to session/new so the agent +// can call MCP tools mid-prompt; an empty slice preserves the legacy "pure +// inference" behaviour. func (e *ACPInferenceExecutor) executeACPSession( ctx context.Context, stdin io.Writer, @@ -113,6 +120,7 @@ func (e *ACPInferenceExecutor) executeACPSession( prompt string, model string, mode string, + mcpServers []acp.McpServer, ) (string, error) { // Collect response text from updates var responseText strings.Builder @@ -149,10 +157,14 @@ func (e *ACPInferenceExecutor) executeACPSession( return "", fmt.Errorf("ACP initialize failed: %w", err) } - // Create new session with empty MCP servers array (required by ACP protocol) + // Create new session. ACP requires McpServers to be a non-nil slice; + // callers without tools pass nil and we substitute an empty array here. + if mcpServers == nil { + mcpServers = []acp.McpServer{} + } sessionResp, err := conn.NewSession(ctx, acp.NewSessionRequest{ Cwd: workDir, - McpServers: []acp.McpServer{}, + McpServers: mcpServers, }) if err != nil { return "", fmt.Errorf("ACP session/new failed: %w", err) @@ -198,6 +210,59 @@ func (e *ACPInferenceExecutor) executeACPSession( return result, nil } +// toACPMcpServers converts the cross-process DTO list into the ACP SDK +// shape. Uses the *Inline variants because the agentcooper fork (vendored +// via go.mod replace) names them that way; the upstream SDK would call them +// McpServerHttp / McpServerSse. Returns nil when there are no entries so +// callers can use the nil-as-empty convention upstream. The second return +// value carries the names of any DTOs we couldn't convert (unsupported +// transport, e.g. stdio) so the caller can surface them in logs rather than +// having them silently disappear from the agent's tool surface. +func toACPMcpServers(in []MCPServerDTO) ([]acp.McpServer, []string) { + if len(in) == 0 { + return nil, nil + } + out := make([]acp.McpServer, 0, len(in)) + var dropped []string + for _, s := range in { + switch strings.ToLower(s.Type) { + case "http": + out = append(out, acp.McpServer{Http: &acp.McpServerHttpInline{ + Name: s.Name, + Type: "http", + Url: s.URL, + Headers: toACPHeaders(s.HeaderKVs), + }}) + case "sse": + out = append(out, acp.McpServer{Sse: &acp.McpServerSseInline{ + Name: s.Name, + Type: "sse", + Url: s.URL, + Headers: toACPHeaders(s.HeaderKVs), + }}) + default: + // Unsupported transport (stdio, or anything else). We don't fail + // the whole inference call on a single bad entry — the agent can + // still run with the entries that did convert — but we surface + // the name so misconfiguration is visible in logs rather than + // silently leaving the agent without tools it expected to have. + dropped = append(dropped, s.Name) + } + } + return out, dropped +} + +func toACPHeaders(in []HTTPHeaderDTO) []acp.HttpHeader { + if len(in) == 0 { + return []acp.HttpHeader{} + } + out := make([]acp.HttpHeader, 0, len(in)) + for _, h := range in { + out = append(out, acp.HttpHeader{Name: h.Name, Value: h.Value}) + } + return out +} + // Probe runs an ephemeral ACP handshake (initialize + session/new) to discover // agent capabilities, auth methods, models, and modes. It does not send a prompt. func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*ProbeResponse, error) { diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index 5ec044c427..eb37b64a3c 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -24,6 +24,32 @@ type PromptRequest struct { // MaxTokens is the maximum tokens for the response (default: 1024). MaxTokens int `json:"max_tokens,omitempty"` + + // MCPServers are the MCP servers to expose to the agent for the duration + // of this single inference call. Forwarded to ACP `session/new` so the + // agent can call tools mid-prompt. Empty (the common case) keeps the + // existing "pure inference, no tools" behaviour for utility-agent + // callers that only need text-in/text-out (PR title generation, commit + // messages, etc.). + MCPServers []MCPServerDTO `json:"mcp_servers,omitempty"` +} + +// MCPServerDTO describes a single MCP server endpoint to wire into the +// agent's session. Mirrors the ACP McpServer shape but kept JSON-flat so it +// crosses the agentctl HTTP boundary cleanly. Today we use HTTP transport +// only (Type="http"); stdio/sse can be added when a caller needs them. +type MCPServerDTO struct { + Name string `json:"name"` + Type string `json:"type"` // "http" | "sse" | "stdio" + URL string `json:"url,omitempty"` // for http/sse + // HeaderKVs is an optional set of HTTP headers (key, value pairs). + HeaderKVs []HTTPHeaderDTO `json:"headers,omitempty"` +} + +// HTTPHeaderDTO is a single HTTP header for HTTP/SSE-transport MCP servers. +type HTTPHeaderDTO struct { + Name string `json:"name"` + Value string `json:"value"` } // ProbeRequest is the request for probing an agent's capabilities. diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 8d976b8072..a9e4b61710 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -144,6 +144,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces) d.RegisterFunc(ws.ActionMCPListWorkflows, h.handleListWorkflows) d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps) + d.RegisterFunc(ws.ActionMCPListRepositories, h.handleListRepositories) d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks) d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask) @@ -155,7 +156,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan) d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan) d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout) - count := 14 + count := 15 // Config-mode handlers (registered when config deps are set) if h.workflowSvc != nil { @@ -294,6 +295,25 @@ 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. +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) { + repos, err := h.taskSvc.ListRepositories(ctx, workspaceID) + if err != nil { + return nil, err + } + dtos := make([]dto.RepositoryDTO, 0, len(repos)) + for _, r := range repos { + dtos = append(dtos, dto.FromRepository(r)) + } + return dto.ListRepositoriesResponse{Repositories: dtos, Total: len(dtos)}, nil + }) +} + // handleListTasks lists tasks for a workflow. func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return h.handleListByField(ctx, msg, "workflow_id", "failed to list tasks", "Failed to list tasks", diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index 32e27057d7..268c86f13f 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -26,6 +26,13 @@ func (s *Server) registerConfigWorkflowTools() { ), s.wrapHandler("list_workflows_kandev", s.listWorkflowsHandler()), ) + s.mcpServer.AddTool( + mcp.NewTool("list_repositories_kandev", + mcp.WithDescription("List repositories in a workspace. Use this to find a repository_id to attach to create_task_kandev when the task should target a specific codebase."), + mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")), + ), + s.wrapHandler("list_repositories_kandev", s.listRepositoriesHandler()), + ) s.mcpServer.AddTool( mcp.NewTool("create_workflow_kandev", mcp.WithDescription("Create a new workflow in a workspace."), diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index b672484336..a7d2267540 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -45,6 +45,23 @@ func (s *Server) listWorkflowsHandler() server.ToolHandlerFunc { } } +func (s *Server) listRepositoriesHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + workspaceID, err := req.RequireString("workspace_id") + if err != nil { + return mcp.NewToolResultError("workspace_id is required"), nil + } + payload := map[string]string{"workspace_id": workspaceID} + // Backend returns {repositories: [...], total: N} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPListRepositories, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + func (s *Server) listWorkflowStepsHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { workflowID, err := req.RequireString("workflow_id") diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 68d2aa9836..b15bdddbb7 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -276,7 +276,7 @@ func (s *Server) registerTools() { switch s.mode { case ModeConfig: s.registerConfigWorkflowTools() - count += 10 + count += 11 s.registerConfigAgentTools() count += 4 s.registerConfigMcpTools() @@ -294,7 +294,7 @@ func (s *Server) registerTools() { // they can both manage Kandev configuration and spawn new tasks. // No interaction or plan tools (no live session to attach them to). s.registerConfigWorkflowTools() - count += 10 + count += 11 s.registerConfigAgentTools() count += 4 s.registerConfigMcpTools() diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 64a81c8e77..1d03315933 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -96,6 +96,7 @@ func TestServerModeConfig_RegistersCorrectTools(t *testing.T) { // Config mode should have workflow config tools assert.Contains(t, tools, "list_workspaces_kandev") assert.Contains(t, tools, "list_workflows_kandev") + assert.Contains(t, tools, "list_repositories_kandev") assert.Contains(t, tools, "create_workflow_kandev") assert.Contains(t, tools, "update_workflow_kandev") assert.Contains(t, tools, "delete_workflow_kandev") @@ -207,8 +208,8 @@ func TestServerModeConfig_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) - // 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 30 - assert.Equal(t, 30, len(tools)) + // 11 workflow (incl. list_repositories) + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 31 + assert.Equal(t, 31, len(tools)) } func TestServerModeConfig_ToolDescriptions(t *testing.T) { @@ -243,6 +244,7 @@ func TestServerModeExternal_RegistersCorrectTools(t *testing.T) { // External mode includes all config tools assert.Contains(t, tools, "list_workspaces_kandev") + assert.Contains(t, tools, "list_repositories_kandev") assert.Contains(t, tools, "create_workflow_kandev") assert.Contains(t, tools, "list_agents_kandev") assert.Contains(t, tools, "get_mcp_config_kandev") @@ -273,8 +275,8 @@ func TestServerModeExternal_ToolCount(t *testing.T) { s := New(backend, "", "", 0, log, "", true, ModeExternal) tools := getRegisteredToolNames(s) - // 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 30 - assert.Equal(t, 30, len(tools)) + // 11 workflow (incl. list_repositories) + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 31 + assert.Equal(t, 31, len(tools)) } func TestNewExternal_Constructs(t *testing.T) { diff --git a/apps/backend/internal/slack/client.go b/apps/backend/internal/slack/client.go new file mode 100644 index 0000000000..ba4ed6ad60 --- /dev/null +++ b/apps/backend/internal/slack/client.go @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000000..10b06f73fd --- /dev/null +++ b/apps/backend/internal/slack/cookie_client.go @@ -0,0 +1,339 @@ +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 new file mode 100644 index 0000000000..672f2bee05 --- /dev/null +++ b/apps/backend/internal/slack/cookie_client_test.go @@ -0,0 +1,255 @@ +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/handlers.go b/apps/backend/internal/slack/handlers.go new file mode 100644 index 0000000000..0b9c4acf14 --- /dev/null +++ b/apps/backend/internal/slack/handlers.go @@ -0,0 +1,207 @@ +package slack + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// 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) +} + +// --- HTTP handlers --- + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + cfg, err := c.service.GetConfig(ctx.Request.Context()) + if err != nil { + 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.SetConfig(ctx.Request.Context(), &req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalidConfig) { + status = http.StatusBadRequest + } + 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.DeleteConfig(ctx.Request.Context()); err != nil { + 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.TestConnection(ctx.Request.Context(), &req) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +// 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 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 new file mode 100644 index 0000000000..82068a5fba --- /dev/null +++ b/apps/backend/internal/slack/handlers_test.go @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000000..22cdc3193f --- /dev/null +++ b/apps/backend/internal/slack/models.go @@ -0,0 +1,132 @@ +// 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 install-wide Slack integration configuration. The xoxc +// token and the d cookie live in the encrypted secret store under SecretKeyToken +// and SecretKeyCookie respectively. +type SlackConfig struct { + 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 secret-store key for the install-wide xoxc- token. +const SecretKeyToken = "slack:singleton:token" + +// SecretKeyCookie is the secret-store key for the 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" + +// LegacySecretKeyForToken returns the pre-singleton per-workspace token key, +// kept so the provider can promote an existing per-workspace token onto +// SecretKeyToken during the schema migration. +func LegacySecretKeyForToken(workspaceID string) string { + return "slack:" + workspaceID + ":token" +} + +// LegacySecretKeyForCookie returns the pre-singleton per-workspace cookie key. +func LegacySecretKeyForCookie(workspaceID string) string { + return "slack:" + workspaceID + ":cookie" +} diff --git a/apps/backend/internal/slack/poller.go b/apps/backend/internal/slack/poller.go new file mode 100644 index 0000000000..c1bb167d00 --- /dev/null +++ b/apps/backend/internal/slack/poller.go @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000000..b8f6dd8596 --- /dev/null +++ b/apps/backend/internal/slack/provider.go @@ -0,0 +1,64 @@ +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 per-workspace token + cookie from the +// pre-singleton secret keys onto the new install-wide keys, 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) { + source := store.MigratedFromWorkspace() + if source == "" || secrets == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + migrateOne(ctx, secrets, LegacySecretKeyForToken(source), SecretKeyToken, "Slack token", log) + migrateOne(ctx, secrets, LegacySecretKeyForCookie(source), SecretKeyCookie, "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 new file mode 100644 index 0000000000..f97dbdb48f --- /dev/null +++ b/apps/backend/internal/slack/runtime.go @@ -0,0 +1,302 @@ +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/service.go b/apps/backend/internal/slack/service.go new file mode 100644 index 0000000000..5fc580886e --- /dev/null +++ b/apps/backend/internal/slack/service.go @@ -0,0 +1,359 @@ +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 + client Client // singleton, cleared on config change. + probeHook func() +} + +// 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, + } +} + +// 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 singleton config enriched with HasToken/HasCookie. +func (s *Service) GetConfig(ctx context.Context) (*SlackConfig, error) { + cfg, err := s.store.GetConfig(ctx) + if err != nil || cfg == nil { + return cfg, err + } + if s.secrets == nil { + return cfg, nil + } + cfg.HasToken = s.secretExists(ctx, SecretKeyToken, "token") + cfg.HasCookie = s.secretExists(ctx, SecretKeyCookie, "cookie") + return cfg, nil +} + +func (s *Service) secretExists(ctx context.Context, id, 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)) + } + 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, req *SetConfigRequest) error { + if s.secrets == nil { + return nil + } + if req.Token != "" { + if err := s.secrets.Set(ctx, SecretKeyToken, "Slack token", req.Token); err != nil { + return fmt.Errorf("store slack token: %w", err) + } + } + if req.Cookie != "" { + if err := s.secrets.Set(ctx, SecretKeyCookie, "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) { + 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.UpsertConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("upsert slack config: %w", err) + } + if err := s.persistSecrets(ctx, req); err != nil { + return nil, err + } + s.invalidateClient() + go func() { + s.RecordAuthHealth(context.Background()) + }() + return s.GetConfig(ctx) +} + +// DeleteConfig removes both the row and the stored secrets. +func (s *Service) DeleteConfig(ctx context.Context) error { + if err := s.store.DeleteConfig(ctx); err != nil { + return err + } + if s.secrets != nil { + s.deleteSecret(ctx, SecretKeyToken, "token") + s.deleteSecret(ctx, SecretKeyCookie, "cookie") + } + s.invalidateClient() + 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) { + cfg, token, cookie, err := s.resolveCredentials(ctx, 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) { + client, err := s.clientFor(ctx) + 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) { + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + defer cancel() + res, err := s.ProbeAuth(probeCtx) + 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.UpdateAuthHealth(writeCtx, ok, errMsg, teamID, userID, time.Now().UTC()); updateErr != nil { + s.log.Warn("slack: update auth health failed", zap.Error(updateErr)) + } + if !ok { + s.invalidateClient() + } + 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) { + return s.clientFor(ctx) +} + +func (s *Service) clientFor(ctx context.Context) (Client, error) { + s.mu.Lock() + if s.client != nil { + c := s.client + s.mu.Unlock() + return c, nil + } + s.mu.Unlock() + + cfg, err := s.store.GetConfig(ctx) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrNotConfigured + } + token, cookie, err := s.revealSecrets(ctx) + 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.client != nil { + return s.client, nil + } + s.client = client + return client, nil +} + +func (s *Service) revealSecrets(ctx context.Context) (string, string, error) { + if s.secrets == nil { + return "", "", nil + } + token, err := s.secrets.Reveal(ctx, SecretKeyToken) + if err != nil { + return "", "", fmt.Errorf("read slack token: %w", err) + } + cookie, err := s.secrets.Reveal(ctx, SecretKeyCookie) + if err != nil { + return "", "", fmt.Errorf("read slack cookie: %w", err) + } + return token, cookie, nil +} + +func (s *Service) invalidateClient() { + s.mu.Lock() + s.client = nil + s.mu.Unlock() +} + +func (s *Service) resolveCredentials(ctx context.Context, 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.secrets.Reveal(ctx, 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.secrets.Reveal(ctx, 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 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 new file mode 100644 index 0000000000..9cd5b2e23b --- /dev/null +++ b/apps/backend/internal/slack/store.go @@ -0,0 +1,297 @@ +package slack + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists the install-wide 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 + + // migratedFromWorkspace records the workspace_id of the row that was + // promoted into the singleton during initSchema. Provider reads this to + // migrate the per-workspace secrets to the new global keys. Empty when + // no migration ran. + migratedFromWorkspace 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 returns the workspace_id of the row promoted to the +// singleton during the per-workspace → singleton schema migration, or "" when +// no migration ran. +func (s *Store) MigratedFromWorkspace() string { + return s.migratedFromWorkspace +} + +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS 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 + ); +` + +// singletonID is the synthetic primary key of the (only) row in slack_configs. +const singletonID = "singleton" + +func (s *Store) initSchema() error { + if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + return err + } + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + return nil +} + +// migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where +// slack_configs was keyed by workspace_id) and rewrites it into the singleton +// shape. Picks the most-recently-updated row and records the source +// workspace_id so the provider can migrate the secrets. +func (s *Store) migrateLegacyPerWorkspaceTable() error { + cols, err := s.tableColumns() + if err != nil { + return err + } + if len(cols) == 0 { + return nil + } + if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // Build the SELECT against only columns present in this database — the + // per-workspace shape evolved across patches (utility_agent_id, + // poll_interval_seconds, the slack_team/slack_user/last_seen trio, and + // the auth-health trio were all added incrementally), so a deployment + // upgrading from any earlier intermediate would crash on a SELECT that + // assumed every column. Mirrors the Linear/Jira fix. + selectCols := "workspace_id, 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" + + var sourceWorkspace, authMethod, commandPrefix, utilityAgentID sql.NullString + var pollIntervalSeconds sql.NullInt64 + var slackTeamID, slackUserID, lastSeenTS, lastError sql.NullString + var lastCheckedAt sql.NullTime + var lastOk sql.NullInt64 + var createdAt, updatedAt sql.NullTime + row := tx.QueryRow(`SELECT ` + selectCols + ` FROM slack_configs ORDER BY updated_at DESC, workspace_id DESC LIMIT 1`) + switch err := row.Scan(&sourceWorkspace, &authMethod, + &commandPrefix, &utilityAgentID, &pollIntervalSeconds, + &slackTeamID, &slackUserID, &lastSeenTS, + &lastCheckedAt, &lastOk, &lastError, + &createdAt, &updatedAt); { + 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 + } + pollSeconds := int(pollIntervalSeconds.Int64) + if pollSeconds == 0 { + pollSeconds = DefaultPollIntervalSeconds + } + if _, err := tx.Exec(` + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + singletonID, authMethod.String, commandPrefix.String, utilityAgentID.String, pollSeconds, + slackTeamID.String, slackUserID.String, lastSeenTS.String, + nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, + nullableTime(createdAt), nullableTime(updatedAt)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + s.migratedFromWorkspace = sourceWorkspace.String + return nil +} + +// 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 = `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 singleton Slack config, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) { + var cfg SlackConfig + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM slack_configs WHERE id = ?`, singletonID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates the singleton 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 { + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO slack_configs ( + id, auth_method, + command_prefix, utility_agent_id, poll_interval_seconds, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(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`, + singletonID, cfg.AuthMethod, + cfg.CommandPrefix, cfg.UtilityAgentID, cfg.PollIntervalSeconds, + cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes the singleton row. Secrets must be cleared separately. +func (s *Store) DeleteConfig(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE id = ?`, singletonID) + return err +} + +// HasConfig reports whether the singleton 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 WHERE id = ?`, singletonID) + if err != nil { + return false, err + } + return present > 0, 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 { + 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 id = ?`, + checkedAt, ok, errMsg, teamID, userID, singletonID) + return err + } + _, err := s.db.ExecContext(ctx, ` + UPDATE slack_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE id = ?`, + checkedAt, ok, errMsg, singletonID) + return err +} + +// UpdateLastSeenTS advances the trigger's watermark. +func (s *Store) UpdateLastSeenTS(ctx context.Context, ts string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE slack_configs SET last_seen_ts = ? WHERE id = ?`, + ts, singletonID) + return err +} diff --git a/apps/backend/internal/slack/store_test.go b/apps/backend/internal/slack/store_test.go new file mode 100644 index 0000000000..8bd3176991 --- /dev/null +++ b/apps/backend/internal/slack/store_test.go @@ -0,0 +1,250 @@ +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_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 != "ws-new" { + t.Errorf("expected migration to promote ws-new (most recently updated), got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil || cfg == nil { + t.Fatalf("get post-migration: 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 new file mode 100644 index 0000000000..32ebc51783 --- /dev/null +++ b/apps/backend/internal/slack/trigger.go @@ -0,0 +1,331 @@ +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 + + // matchHook fires after each successfully-handled match. Tests use it + // to synchronise on processing without sleep-polling. + matchHook func(msg SlackMessage, reply string) + + // lastScannedAt tracks when we last ran a scan so the configured + // PollIntervalSeconds is honoured exactly. In-memory by design — backend + // restart triggers an immediate scan, which is fine. + scannedMu sync.Mutex + lastScannedAt 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} +} + +// SetInterval overrides the base-ticker cadence. Must be called before Start. +func (t *Trigger) SetInterval(d time.Duration) { + t.mu.Lock() + t.interval = d + t.mu.Unlock() +} + +// SetMatchHook installs a callback fired after each successfully-handled +// match. +func (t *Trigger) SetMatchHook(fn func(msg SlackMessage, reply string)) { + t.mu.Lock() + t.matchHook = fn + t.mu.Unlock() +} + +// 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 + } + cfg, err := t.svc.Store().GetConfig(ctx) + if err != nil || cfg == nil { + return + } + if !cfg.LastOk || cfg.SlackUserID == "" { + return + } + if cfg.UtilityAgentID == "" { + return + } + if !t.dueForScan(cfg.PollIntervalSeconds) { + return + } + prefix := cfg.CommandPrefix + if prefix == "" { + prefix = DefaultCommandPrefix + } + client, err := t.svc.Client(ctx) + if err != nil { + return + } + t.markScanned() + 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 + } + reply, 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 + } + t.fireHook(m, reply) + } + if highest != cfg.LastSeenTS { + if err := t.svc.Store().UpdateLastSeenTS(ctx, 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)) + } +} + +func (t *Trigger) fireHook(msg SlackMessage, reply string) { + t.mu.Lock() + hook := t.matchHook + t.mu.Unlock() + if hook != nil { + hook(msg, reply) + } +} + +// dueForScan reports whether the configured interval has elapsed since the +// last scan. A zero/never timestamp counts as due. +func (t *Trigger) dueForScan(intervalSeconds int) bool { + if intervalSeconds < MinPollIntervalSeconds { + intervalSeconds = DefaultPollIntervalSeconds + } + t.scannedMu.Lock() + defer t.scannedMu.Unlock() + if t.lastScannedAt.IsZero() { + return true + } + return time.Since(t.lastScannedAt) >= time.Duration(intervalSeconds)*time.Second +} + +func (t *Trigger) markScanned() { + t.scannedMu.Lock() + t.lastScannedAt = time.Now() + t.scannedMu.Unlock() +} + +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 new file mode 100644 index 0000000000..8db683b73b --- /dev/null +++ b/apps/backend/internal/slack/trigger_test.go @@ -0,0 +1,629 @@ +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 agentCLI == "" && defaults != nil { + agentCLI = defaults.AgentID + } + if model == "" && defaults != nil { + model = defaults.Model + } + 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.client = 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.client = 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.client = &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(30) { + t.Error("expected fresh trigger to be due immediately") + } + trig.markScanned() + if trig.dueForScan(30) { + t.Error("expected trigger to not be due immediately after scan") + } + + // 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 = time.Now().Add(-31 * time.Second) + trig.scannedMu.Unlock() + if !trig.dueForScan(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 = time.Now() + trig.scannedMu.Unlock() + if trig.dueForScan(0) { + t.Error("expected zero interval to behave as default 30s, not as 'always due'") + } +} diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index eac9d69828..75ea71bc6a 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -80,6 +80,12 @@ func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, ag AgentID: agentID, Model: model, Builtin: false, + // Custom agents validate agent_id + model above, so they're always + // 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. + Enabled: true, } if err := s.repo.CreateAgent(ctx, agent); err != nil { diff --git a/apps/backend/internal/utility/store/sqlite.go b/apps/backend/internal/utility/store/sqlite.go index ea95950524..112950f043 100644 --- a/apps/backend/internal/utility/store/sqlite.go +++ b/apps/backend/internal/utility/store/sqlite.go @@ -83,6 +83,22 @@ func (r *sqliteRepository) initSchema() error { // Add enabled column if it doesn't exist (migration for existing DBs) _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`) + // Heal pre-existing custom agents that were created with enabled=0 due + // to a bug in CreateAgent (the Enabled field on the model defaulted to + // the zero value, so the INSERT wrote 0 even though the DB schema's + // own DEFAULT was 1). Built-ins are skipped — they have their own + // "enabled=false means fall back to user defaults" semantics. + // + // Restricted to rows whose updated_at == created_at — i.e. agents that + // have never been modified since INSERT. UpdateAgent always bumps + // updated_at, so a user who deliberately disables an agent post-fix + // has updated_at > created_at and is left alone. Without this guard + // every restart would silently re-enable any user-disabled custom agent. + _, _ = r.db.Exec(`UPDATE utility_agents SET enabled = 1 + WHERE builtin = 0 AND enabled = 0 + AND agent_id <> '' AND model <> '' + AND updated_at = created_at`) + // Seed built-in agents if err := r.seedBuiltinAgents(); err != nil { return fmt.Errorf("failed to seed built-in agents: %w", err) diff --git a/apps/backend/internal/utility/template/engine.go b/apps/backend/internal/utility/template/engine.go index 997e671f88..adbc87c382 100644 --- a/apps/backend/internal/utility/template/engine.go +++ b/apps/backend/internal/utility/template/engine.go @@ -106,7 +106,11 @@ 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 +// 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"}, @@ -120,6 +124,12 @@ 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}, } } @@ -128,5 +138,5 @@ type VariableInfo struct { Name string `json:"name"` Description string `json:"description"` Example string `json:"example"` - Category string `json:"category"` // "git", "task", "session" + Category string `json:"category"` // "git", "task", "session", "input", "slack" } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index ee212f9a34..ea83c8ff23 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -303,6 +303,7 @@ const ( ActionMCPListWorkspaces = "mcp.list_workspaces" ActionMCPListWorkflows = "mcp.list_workflows" ActionMCPListWorkflowSteps = "mcp.list_workflow_steps" + ActionMCPListRepositories = "mcp.list_repositories" ActionMCPListTasks = "mcp.list_tasks" ActionMCPCreateTask = "mcp.create_task" ActionMCPUpdateTask = "mcp.update_task" @@ -407,6 +408,14 @@ const ( ActionLinearTeamsList = "linear.teams.list" ) +// Slack integration actions +const ( + ActionSlackConfigGet = "slack.config.get" + ActionSlackConfigSet = "slack.config.set" + ActionSlackConfigDelete = "slack.config.delete" + ActionSlackConfigTest = "slack.config.test" +) + // Error codes const ( ErrorCodeBadRequest = "BAD_REQUEST" diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index 7b705ae57d..c6b3c121fc 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { IconBrandGithub, IconHexagon, IconTicket } from "@tabler/icons-react"; +import { IconBrandGithub, IconBrandSlack, IconHexagon, IconTicket } from "@tabler/icons-react"; import { Card, CardContent } from "@kandev/ui/card"; const INTEGRATIONS = [ @@ -21,6 +21,12 @@ const INTEGRATIONS = [ description: "Personal API key and team defaults.", Icon: IconHexagon, }, + { + href: "/settings/integrations/slack", + label: "Slack", + description: "Browser-session credentials and !kandev triage agent.", + Icon: IconBrandSlack, + }, ]; export default function IntegrationsIndexPage() { diff --git a/apps/web/app/settings/integrations/slack/page.tsx b/apps/web/app/settings/integrations/slack/page.tsx new file mode 100644 index 0000000000..b4f8a43924 --- /dev/null +++ b/apps/web/app/settings/integrations/slack/page.tsx @@ -0,0 +1,5 @@ +import { SlackIntegrationPage } from "@/components/slack/slack-settings"; + +export default function IntegrationsSlackPage() { + return ; +} diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index d08f0f2906..ad012afdac 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -13,6 +13,7 @@ import { IconKey, IconMessageCircle, IconBrandGithub, + IconBrandSlack, IconHexagon, IconSparkles, IconWand, @@ -152,6 +153,7 @@ function IntegrationsSidebarSection({ pathname }: { pathname: string }) { { href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub }, { href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket }, { href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon }, + { href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack }, ]; return ( diff --git a/apps/web/components/slack/slack-settings.tsx b/apps/web/components/slack/slack-settings.tsx new file mode 100644 index 0000000000..2988793b26 --- /dev/null +++ b/apps/web/components/slack/slack-settings.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { IconBrandSlack } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, 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 { Switch } from "@kandev/ui/switch"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useSlackEnabled } from "@/hooks/domains/slack/use-slack-enabled"; +import { + IntegrationAuthStatusBanner, + 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"; + +const DEFAULT_PREFIX = "!kandev"; +const DEFAULT_POLL_INTERVAL_SECONDS = 30; +const MIN_POLL_INTERVAL_SECONDS = 5; +const MAX_POLL_INTERVAL_SECONDS = 600; + +type FormState = { + utilityAgentId: string; + commandPrefix: string; + pollIntervalSeconds: number; + token: string; + cookie: string; +}; + +const emptyForm: FormState = { + utilityAgentId: "", + commandPrefix: DEFAULT_PREFIX, + pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS, + token: "", + cookie: "", +}; + +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: "", + }; +} + +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 saveLabel(saving: boolean, hasConfig: boolean): string { + if (saving) return "Saving..."; + return hasConfig ? "Update" : "Save"; +} + +type SecretFieldsProps = { + form: FormState; + loading: boolean; + hasSavedToken: boolean; + hasSavedCookie: boolean; + update: (key: K, value: FormState[K]) => void; +}; + +function SecretFields({ form, loading, hasSavedToken, hasSavedCookie, update }: SecretFieldsProps) { + return ( +
+
+ + update("token", e.target.value)} + disabled={loading} + /> +
+
+ + update("cookie", e.target.value)} + disabled={loading} + /> +

+ Open Slack in your browser, copy the value of the `d` cookie and the `xoxc-` token from + local storage. Both are required. +

+
+
+ ); +} + +type UtilityAgentPickerProps = { + form: FormState; + loading: boolean; + agents: UtilityAgent[]; + loadingAgents: boolean; + update: (key: K, value: FormState[K]) => void; +}; + +function utilityAgentPlaceholder(agents: UtilityAgent[], loading: boolean): string { + if (loading) return "Loading…"; + if (agents.length === 0) return "Create one in Settings → Utility agents"; + return "Choose a utility agent"; +} + +function isAgentSelectable(a: UtilityAgent): boolean { + if (a.builtin) return true; + return a.enabled && !!a.agent_id && !!a.model; +} + +function modelSuffix(a: UtilityAgent): string { + if (a.model) return ` (${a.model})`; + if (a.builtin) return " (uses default model)"; + return ""; +} + +function utilityAgentLabel(a: UtilityAgent): string { + const base = `${a.name}${modelSuffix(a)}`; + if (a.builtin) return base; + if (!a.enabled) return `${base} — disabled`; + if (!a.agent_id || !a.model) return `${base} — not configured`; + return base; +} + +function UtilityAgentPicker({ + form, + loading, + agents, + loadingAgents, + update, +}: UtilityAgentPickerProps) { + return ( +
+ + +

+ The utility agent that interprets each Slack message and creates the Kandev task. It runs + with Kandev MCP tools wired in (list_workspaces_kandev, create_task_kandev, …) so it picks + the destination Kandev workspace + workflow + repo from context. Built-in agents use your + default model from{" "} + + Settings → Utility agents + + . +

+

+ Custom prompts can reference {"{{SlackInstruction}}"},{" "} + {"{{SlackThread}}"}, {"{{SlackPermalink}}"},{" "} + {"{{SlackUser}}"}, {"{{SlackChannelID}}"}, and{" "} + {"{{SlackTS}}"}. 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, + loading, + update, +}: { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; +}) { + return ( +
+ + update("commandPrefix", e.target.value)} + disabled={loading} + /> +

+ Messages you write in Slack starting with this prefix become Kandev tasks. Default:{" "} + {DEFAULT_PREFIX} <instruction>. +

+
+ ); +} + +function PollIntervalField({ + form, + loading, + update, +}: { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; +}) { + 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 {form.commandPrefix || DEFAULT_PREFIX}{" "} + messages. Lower = more responsive, higher = fewer Slack API calls. Range:{" "} + {MIN_POLL_INTERVAL_SECONDS}–{MAX_POLL_INTERVAL_SECONDS}s. Default:{" "} + {DEFAULT_POLL_INTERVAL_SECONDS}s. +

+
+ ); +} + +function TestResultAlert({ result }: { result: TestSlackConnectionResult | null }) { + if (!result) return null; + const teamSuffix = result.teamName ? ` (${result.teamName})` : ""; + return ( + + + {result.ok + ? `Connected as ${result.displayName || result.userId}${teamSuffix}` + : `Failed: ${result.error}`} + + + ); +} + +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 = { + saving: boolean; + testing: boolean; + deleting: boolean; + loading: boolean; + hasConfig: boolean; + disableSave: boolean; + disableTest: boolean; + onTest: () => void; + onSave: () => void; + onDelete: () => void; +}; + +function ActionBar({ + saving, + testing, + deleting, + loading, + hasConfig, + disableSave, + disableTest, + onTest, + onSave, + onDelete, +}: ActionBarProps) { + return ( +
+ + + {hasConfig && ( + + )} +
+ ); +} + +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 = { + form: FormState; + setConfig: (cfg: SlackConfig | null) => void; + setForm: (form: FormState) => void; + setTestResult: (r: TestSlackConnectionResult | null) => void; +}; + +function useSettingsActions({ form, setConfig, setForm, setTestResult }: SettingsActionsArgs) { + 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, + }); + setTestResult(res); + } catch (err) { + setTestResult({ ok: false, error: String(err) }); + } finally { + setTesting(false); + } + }, [form, setTestResult]); + + const handleSave = useCallback(async () => { + 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, + }); + setConfig(saved); + setForm(configToForm(saved)); + setTestResult(null); + toast({ description: "Slack configuration saved", variant: "success" }); + } catch (err) { + toast({ description: `Save failed: ${String(err)}`, variant: "error" }); + } finally { + setSaving(false); + } + }, [form, toast, setConfig, setForm, setTestResult]); + + const handleDelete = useCallback(async () => { + if (!confirm("Remove Slack configuration?")) return; + setDeleting(true); + try { + await deleteSlackConfig(); + setConfig(null); + setForm(emptyForm); + setTestResult(null); + toast({ description: "Slack configuration removed", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } finally { + setDeleting(false); + } + }, [toast, setConfig, setForm, setTestResult]); + + return { saving, testing, deleting, handleTest, handleSave, handleDelete }; +} + +function useSlackSettings() { + 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(); + setConfig(cfg); + setForm(configToForm(cfg)); + } catch (err) { + toast({ description: `Failed to load Slack config: ${String(err)}`, variant: "error" }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + void load(); + }, [load]); + + // Background refresh so the auth-health banner picks up new probe results. + useEffect(() => { + const id = setInterval(() => { + getSlackConfig() + .then((cfg) => setConfig(cfg)) + .catch(() => { + /* transient failures are fine — next tick retries */ + }); + }, INTEGRATION_STATUS_REFRESH_MS); + return () => clearInterval(id); + }, []); + + const update = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [], + ); + + const { saving, testing, deleting, handleTest, handleSave, handleDelete } = useSettingsActions({ + form, + setConfig, + setForm, + setTestResult, + }); + + return { + config, + form, + loading, + saving, + testing, + deleting, + testResult, + health, + agents, + loadingAgents, + update, + handleTest, + handleSave, + handleDelete, + }; +} + +function EnabledPill() { + const { enabled, setEnabled } = useSlackEnabled(); + return ( +
+ + +
+ ); +} + +export function SlackConnectionSection() { + const s = useSlackSettings(); + 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; + + return ( + } + title="Slack integration" + description="Capture Slack threads as Kandev tasks. Type !kandev in any thread you can see and the configured utility agent picks the workspace/workflow/repo and creates the task." + action={} + > + + + + + + + + + + + + + + + + ); +} + +export function SlackIntegrationPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/hooks/domains/slack/use-slack-availability.ts b/apps/web/hooks/domains/slack/use-slack-availability.ts new file mode 100644 index 0000000000..4625bfef71 --- /dev/null +++ b/apps/web/hooks/domains/slack/use-slack-availability.ts @@ -0,0 +1,32 @@ +"use client"; + +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. +async function fetchSlackStatus(): Promise { + const cfg = await getSlackConfig(); + if (!cfg) return null; + return { + hasSecret: cfg.hasToken && cfg.hasCookie, + lastOk: cfg.lastOk, + }; +} + +export function useSlackAuthed(): boolean { + return useIntegrationAuthed(fetchSlackStatus); +} + +export function useSlackAvailable(): boolean { + return useIntegrationAvailable({ + useEnabled: useSlackEnabled, + fetchConfig: fetchSlackStatus, + }); +} diff --git a/apps/web/hooks/domains/slack/use-slack-enabled.ts b/apps/web/hooks/domains/slack/use-slack-enabled.ts new file mode 100644 index 0000000000..a88b29bb4c --- /dev/null +++ b/apps/web/hooks/domains/slack/use-slack-enabled.ts @@ -0,0 +1,11 @@ +"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 new file mode 100644 index 0000000000..3dc5c36584 --- /dev/null +++ b/apps/web/lib/api/domains/slack-api.ts @@ -0,0 +1,36 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { + SetSlackConfigRequest, + SlackConfig, + TestSlackConnectionResult, +} from "@/lib/types/slack"; + +// getSlackConfig returns null when the backend responds 204 (no config yet). +export async function getSlackConfig(options?: ApiRequestOptions): Promise { + const res = await fetchJson(`/api/v1/slack/config`, options); + return res ?? null; +} + +export async function setSlackConfig(payload: SetSlackConfigRequest, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/slack/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} + +export async function deleteSlackConfig(options?: ApiRequestOptions) { + return fetchJson<{ deleted: boolean }>(`/api/v1/slack/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }); +} + +export async function testSlackConnection( + payload: SetSlackConfigRequest, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/slack/config/test`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} diff --git a/apps/web/lib/types/slack.ts b/apps/web/lib/types/slack.ts new file mode 100644 index 0000000000..f660eaebff --- /dev/null +++ b/apps/web/lib/types/slack.ts @@ -0,0 +1,41 @@ +export type SlackAuthMethod = "cookie"; + +export interface SlackConfig { + 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/docs/specs/INDEX.md b/docs/specs/INDEX.md index 758f008172..320e01c83f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -8,3 +8,4 @@ See `.agents/skills/spec/SKILL.md` for the workflow. |------|--------|---------|----| | [external-mcp](external-mcp/spec.md) | draft | 2026-04-28 | — | | [improve-kandev](./improve-kandev/spec.md) | draft | 2026-04-29 | — | +| [slack-integration](./slack-integration/spec.md) | draft | 2026-05-02 | — | diff --git a/docs/specs/slack-integration/spec.md b/docs/specs/slack-integration/spec.md new file mode 100644 index 0000000000..88e7dbef2c --- /dev/null +++ b/docs/specs/slack-integration/spec.md @@ -0,0 +1,46 @@ +--- +status: shipped +created: 2026-05-02 +owner: tbd +--- + +# Slack Integration + +## 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. + +## What (v1, shipped) + +- Install-wide Slack credentials (one Slack user/team per Kandev install), modeled on the Jira/Linear integrations: settings page with auth-status banner, reconnect CTA, and 90s auth-health polling. +- Single auth mode: **Browser session** — user pastes the `xoxc-` token + `d` cookie pair extracted from their browser; works on locked-down Enterprise Slack tenants where bot installs are not possible. +- Single task-creation trigger: **`!kandev` thread triage** — the trigger polls the connected user's own messages for `!kandev `, hands the surrounding thread context to a configured utility agent (with the Kandev MCP server attached), and posts the agent's reply back in-thread. The agent picks the destination Kandev workspace/workflow/repo via MCP tools. +- An 👀 reaction is added to the triggering message on detection so the user can confirm it was picked up. +- Token storage reuses the shared `secretadapter`; no Slack credentials live in plaintext on disk. + +## Scenarios + +- **GIVEN** a user has pasted their `xoxc-`/`d` cookie pair in settings, **WHEN** they type `!kandev fix the safari login bug` in any Slack channel or DM, **THEN** the trigger reacts with 👀, the configured utility agent picks a workspace/workflow/repo via the Kandev MCP, creates a task, and posts the result back as a threaded reply. +- **GIVEN** a Slack credential is connected, **WHEN** the stored token becomes invalid (revoked, rotated, expired), **THEN** the auth-health poller flips the install to "Reconnect required" within 90 seconds and `RecordAuthHealth` invalidates the cached client so the next probe rebuilds it from current secrets. +- **GIVEN** a `!kandev` message has already been processed (its TS is at or below the watermark), **WHEN** the next poll picks it up, **THEN** it is filtered out and no duplicate task is created. + +## Out of scope (v1) + +- Bot install (`xoxb-` token) and User OAuth (`xoxp-` token) auth modes. +- Reaction trigger (`:kandev:` emoji), `/kandev ` slash command, and "Create Kandev task" message-shortcut entry. +- Posting Kandev task updates back into Slack beyond the initial in-thread confirmation (no live status mirroring). +- Slack as a chat surface for talking to a running agent (no `@kandev <prompt>` runtime, no streaming agent output to Slack). +- Multi-Slack-workspace fan-out within one Kandev install (one Slack user/team per install). +- Routing tasks to specific workflows based on channel or keyword — the utility agent picks the workflow via MCP. +- Importing historical Slack messages. + +## Future scope + +- Bot-install auth mode with the full trigger set (reaction emoji, slash command, message shortcut) for teams whose admins are willing to install the Kandev Slack app. +- User OAuth auth mode for users who want a properly-scoped token without admin involvement. +- Multi-Slack-workspace fan-out for Enterprise Grid tenants. + +## Open questions + +- Does the **browser session** mode cover Slack Enterprise Grid org-level tokens, or only single-workspace sessions? (Affects whether Enterprise users get one connection or one per workspace.) +- Should the slash command and message shortcut be available in **User OAuth** mode via a per-user "personal app" install, or restricted to bot mode only?