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:
@@ -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)")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols[name] = struct{}{}
|
||||
}
|
||||
return cols, rows.Err()
|
||||
}
|
||||
|
||||
const selectConfigColumns = `auth_method, command_prefix, utility_agent_id,
|
||||
poll_interval_seconds,
|
||||
slack_team_id, slack_user_id, last_seen_ts,
|
||||
last_checked_at, last_ok, last_error, created_at, updated_at`
|
||||
|
||||
// GetConfig returns the singleton Slack config, or nil when no row exists.
|
||||
func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) {
|
||||
var cfg SlackConfig
|
||||
err := s.ro.GetContext(ctx, &cfg,
|
||||
`SELECT `+selectConfigColumns+` FROM slack_configs WHERE id = ?`, singletonID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpsertConfig inserts or updates the singleton row. Health columns
|
||||
// (last_*) and watermark columns (last_seen_ts, slack_team_id, slack_user_id)
|
||||
// are owned by the poller / trigger and aren't touched here.
|
||||
func (s *Store) UpsertConfig(ctx context.Context, cfg *SlackConfig) error {
|
||||
now := time.Now().UTC()
|
||||
if cfg.CreatedAt.IsZero() {
|
||||
cfg.CreatedAt = now
|
||||
}
|
||||
cfg.UpdatedAt = now
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO slack_configs (
|
||||
id, auth_method,
|
||||
command_prefix, utility_agent_id, poll_interval_seconds,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
auth_method = excluded.auth_method,
|
||||
command_prefix = excluded.command_prefix,
|
||||
utility_agent_id = excluded.utility_agent_id,
|
||||
poll_interval_seconds = excluded.poll_interval_seconds,
|
||||
updated_at = excluded.updated_at`,
|
||||
singletonID, cfg.AuthMethod,
|
||||
cfg.CommandPrefix, cfg.UtilityAgentID, cfg.PollIntervalSeconds,
|
||||
cfg.CreatedAt, cfg.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteConfig removes the singleton row. Secrets must be cleared separately.
|
||||
func (s *Store) DeleteConfig(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE id = ?`, singletonID)
|
||||
return err
|
||||
}
|
||||
|
||||
// HasConfig reports whether the singleton row exists. Used by the auth-health
|
||||
// poller to decide whether to probe at all.
|
||||
func (s *Store) HasConfig(ctx context.Context) (bool, error) {
|
||||
var present int
|
||||
err := s.ro.GetContext(ctx, &present,
|
||||
`SELECT COUNT(*) FROM slack_configs WHERE id = ?`, singletonID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return present > 0, nil
|
||||
}
|
||||
|
||||
// UpdateAuthHealth records the result of a credential probe and (when
|
||||
// supplied) the user/team identifiers captured during the same probe so the
|
||||
// trigger can scope its searches without an extra round-trip.
|
||||
func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error {
|
||||
if teamID != "" && userID != "" {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE slack_configs
|
||||
SET last_checked_at = ?, last_ok = ?, last_error = ?,
|
||||
slack_team_id = ?, slack_user_id = ?
|
||||
WHERE id = ?`,
|
||||
checkedAt, ok, errMsg, teamID, userID, singletonID)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE slack_configs
|
||||
SET last_checked_at = ?, last_ok = ?, last_error = ?
|
||||
WHERE id = ?`,
|
||||
checkedAt, ok, errMsg, singletonID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateLastSeenTS advances the trigger's watermark.
|
||||
func (s *Store) UpdateLastSeenTS(ctx context.Context, ts string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE slack_configs SET last_seen_ts = ? WHERE id = ?`,
|
||||
ts, singletonID)
|
||||
return err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} <instruction></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);
|
||||
}
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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?
|
||||
Reference in New Issue
Block a user