7e0b46c227
* 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>
303 lines
11 KiB
Go
303 lines
11 KiB
Go
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"
|
|
}
|
|
}
|