feat(slack): cookie-mode integration that triages threads via a utility agent (#775)

* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-05-05 09:03:42 +01:00
committed by GitHub
parent ba32a44cb4
commit 7e0b46c227
40 changed files with 4419 additions and 15 deletions
+6
View File
@@ -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)")
+33
View File
@@ -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
+60
View File
@@ -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
+2
View File
@@ -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
}
@@ -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 {
@@ -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) {
@@ -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.
+21 -1
View File
@@ -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",
@@ -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."),
@@ -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")
+2 -2
View File
@@ -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()
@@ -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) {
+63
View File
@@ -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)
}
@@ -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
// <token>` 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/<method>, 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
}
@@ -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")
}
}
+207
View File
@@ -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)
}
}
@@ -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")
}
})
}
}
+132
View File
@@ -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 <instruction>` 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"
}
+52
View File
@@ -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)
}
+64
View File
@@ -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))
}
}
+302
View File
@@ -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"
}
}
+359
View File
@@ -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
}
+297
View File
@@ -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 ", <name>" if the column is present in the legacy schema
// or ", <fallback> AS <name>" 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, &notnull, &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
}
+250
View File
@@ -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)
}
}
+331
View File
@@ -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
}
+629
View File
@@ -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'")
}
}
@@ -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 {
@@ -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)
@@ -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"
}
+9
View File
@@ -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"
+7 -1
View File
@@ -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() {
@@ -0,0 +1,5 @@
import { SlackIntegrationPage } from "@/components/slack/slack-settings";
export default function IntegrationsSlackPage() {
return <SlackIntegrationPage />;
}
@@ -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 (
<SidebarMenuItem>
@@ -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: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
};
function SecretFields({ form, loading, hasSavedToken, hasSavedCookie, update }: SecretFieldsProps) {
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="slack-token">
Session token (xoxc-…)
{hasSavedToken && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep)
</span>
)}
</Label>
<Input
id="slack-token"
type="password"
placeholder={hasSavedToken ? "••••••••" : "xoxc-..."}
value={form.token}
onChange={(e) => update("token", e.target.value)}
disabled={loading}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slack-cookie">
d cookie value
{hasSavedCookie && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep)
</span>
)}
</Label>
<Input
id="slack-cookie"
type="password"
placeholder={hasSavedCookie ? "••••••••" : "xoxd-..."}
value={form.cookie}
onChange={(e) => update("cookie", e.target.value)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Open Slack in your browser, copy the value of the `d` cookie and the `xoxc-` token from
local storage. Both are required.
</p>
</div>
</div>
);
}
type UtilityAgentPickerProps = {
form: FormState;
loading: boolean;
agents: UtilityAgent[];
loadingAgents: boolean;
update: <K extends keyof FormState>(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 (
<div className="space-y-1.5">
<Label htmlFor="slack-utility-agent">Triage agent</Label>
<Select
value={form.utilityAgentId || ""}
onValueChange={(v) => update("utilityAgentId", v)}
disabled={loading || loadingAgents || agents.length === 0}
>
<SelectTrigger id="slack-utility-agent" className="w-full">
<SelectValue placeholder={utilityAgentPlaceholder(agents, loadingAgents)} />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id} disabled={!isAgentSelectable(a)}>
{utilityAgentLabel(a)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
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{" "}
<Link href="/settings/utility-agents" className="underline cursor-pointer">
Settings → Utility agents
</Link>
.
</p>
<p className="text-xs text-muted-foreground">
Custom prompts can reference <code>{"{{SlackInstruction}}"}</code>,{" "}
<code>{"{{SlackThread}}"}</code>, <code>{"{{SlackPermalink}}"}</code>,{" "}
<code>{"{{SlackUser}}"}</code>, <code>{"{{SlackChannelID}}"}</code>, and{" "}
<code>{"{{SlackTS}}"}</code>. When at least one is used, your template owns the full prompt;
otherwise the default Slack-triage system prompt is prepended automatically.
</p>
</div>
);
}
function PrefixField({
form,
loading,
update,
}: {
form: FormState;
loading: boolean;
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor="slack-prefix">Command prefix</Label>
<Input
id="slack-prefix"
type="text"
placeholder={DEFAULT_PREFIX}
value={form.commandPrefix}
onChange={(e) => update("commandPrefix", e.target.value)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Messages you write in Slack starting with this prefix become Kandev tasks. Default:{" "}
<code>{DEFAULT_PREFIX} &lt;instruction&gt;</code>.
</p>
</div>
);
}
function PollIntervalField({
form,
loading,
update,
}: {
form: FormState;
loading: boolean;
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor="slack-poll-interval">Polling interval (seconds)</Label>
<Input
id="slack-poll-interval"
type="number"
min={MIN_POLL_INTERVAL_SECONDS}
max={MAX_POLL_INTERVAL_SECONDS}
step={1}
value={form.pollIntervalSeconds}
onChange={(e) => {
const n = Number(e.target.value);
update("pollIntervalSeconds", Number.isFinite(n) ? n : DEFAULT_POLL_INTERVAL_SECONDS);
}}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
How often Slack is checked for new <code>{form.commandPrefix || DEFAULT_PREFIX}</code>{" "}
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.
</p>
</div>
);
}
function TestResultAlert({ result }: { result: TestSlackConnectionResult | null }) {
if (!result) return null;
const teamSuffix = result.teamName ? ` (${result.teamName})` : "";
return (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{result.ok
? `Connected as ${result.displayName || result.userId}${teamSuffix}`
: `Failed: ${result.error}`}
</AlertDescription>
</Alert>
);
}
function UnsupportedWarning() {
return (
<Alert>
<AlertDescription className="text-xs">
<strong>Browser session auth (unsupported):</strong> Slack rotates session cookies often, so
you may need to reconnect when authentication expires. Bot installs and user OAuth are on
the roadmap.
</AlertDescription>
</Alert>
);
}
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 (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={onTest}
disabled={testing || loading || disableTest}
className="cursor-pointer"
title={disableTest ? "Paste a token and cookie to test the connection" : undefined}
>
{testing ? "Testing..." : "Test connection"}
</Button>
<Button type="button" onClick={onSave} disabled={disableSave} className="cursor-pointer">
{saveLabel(saving, hasConfig)}
</Button>
{hasConfig && (
<Button
type="button"
variant="destructive"
onClick={onDelete}
disabled={deleting}
className="ml-auto cursor-pointer"
>
{deleting ? "Removing..." : "Remove configuration"}
</Button>
)}
</div>
);
}
function useUtilityAgentsLoader() {
const [agents, setAgents] = useState<UtilityAgent[] | null>(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<SlackConfig | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<TestSlackConnectionResult | null>(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(
<K extends keyof FormState>(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 (
<div className="flex items-center gap-2 rounded-full border bg-muted/30 px-3 py-1">
<Switch
id="slack-enabled"
checked={enabled}
onCheckedChange={setEnabled}
className="cursor-pointer"
/>
<Label htmlFor="slack-enabled" className="text-xs cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
);
}
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 (
<SettingsSection
icon={<IconBrandSlack className="h-5 w-5" />}
title="Slack integration"
description="Capture Slack threads as Kandev tasks. Type !kandev <instruction> in any thread you can see and the configured utility agent picks the workspace/workflow/repo and creates the task."
action={<EnabledPill />}
>
<Card>
<CardContent className="space-y-4 pt-6">
<UnsupportedWarning />
<IntegrationAuthStatusBanner health={s.health} />
<SecretFields
form={s.form}
loading={s.loading}
hasSavedToken={!!s.config?.hasToken}
hasSavedCookie={!!s.config?.hasCookie}
update={s.update}
/>
<Separator />
<UtilityAgentPicker
form={s.form}
loading={s.loading}
agents={s.agents}
loadingAgents={s.loadingAgents}
update={s.update}
/>
<PrefixField form={s.form} loading={s.loading} update={s.update} />
<PollIntervalField form={s.form} loading={s.loading} update={s.update} />
<TestResultAlert result={s.testResult} />
<Separator />
<ActionBar
saving={s.saving}
testing={s.testing}
deleting={s.deleting}
loading={s.loading}
hasConfig={!!s.config}
disableSave={disableSave}
disableTest={disableTest}
onTest={s.handleTest}
onSave={s.handleSave}
onDelete={s.handleDelete}
/>
</CardContent>
</Card>
</SettingsSection>
);
}
export function SlackIntegrationPage() {
return (
<div className="space-y-8">
<SlackConnectionSection />
</div>
);
}
@@ -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<IntegrationConfigStatus | null> {
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,
});
}
@@ -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);
}
+36
View File
@@ -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<SlackConfig | null> {
const res = await fetchJson<SlackConfig | undefined>(`/api/v1/slack/config`, options);
return res ?? null;
}
export async function setSlackConfig(payload: SetSlackConfigRequest, options?: ApiRequestOptions) {
return fetchJson<SlackConfig>(`/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<TestSlackConnectionResult>(`/api/v1/slack/config/test`, {
...options,
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) },
});
}
+41
View File
@@ -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;
}
+1
View File
@@ -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 | — |
+46
View File
@@ -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 <instruction>`, 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 <title>` 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?