Files
kandev/apps/backend/internal/slack/store.go
T
João Salavisa 7e0b46c227 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>
2026-05-05 09:03:42 +01:00

298 lines
9.7 KiB
Go

package slack
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
// Store persists the install-wide Slack configuration. Secret values (xoxc
// token, d cookie) live in the shared encrypted secrets store, not here.
type Store struct {
db *sqlx.DB
ro *sqlx.DB
// migratedFromWorkspace records the workspace_id of the row that was
// promoted into the singleton during initSchema. Provider reads this to
// migrate the per-workspace secrets to the new global keys. Empty when
// no migration ran.
migratedFromWorkspace string
}
// NewStore creates a new Store and initializes the schema if needed.
func NewStore(writer, reader *sqlx.DB) (*Store, error) {
s := &Store{db: writer, ro: reader}
if err := s.initSchema(); err != nil {
return nil, fmt.Errorf("slack schema init: %w", err)
}
return s, nil
}
// MigratedFromWorkspace returns the workspace_id of the row promoted to the
// singleton during the per-workspace → singleton schema migration, or "" when
// no migration ran.
func (s *Store) MigratedFromWorkspace() string {
return s.migratedFromWorkspace
}
const createTablesSQL = `
CREATE TABLE IF NOT EXISTS slack_configs (
id TEXT PRIMARY KEY CHECK(id = 'singleton'),
auth_method TEXT NOT NULL,
command_prefix TEXT NOT NULL DEFAULT '',
utility_agent_id TEXT NOT NULL DEFAULT '',
poll_interval_seconds INTEGER NOT NULL DEFAULT 30,
slack_team_id TEXT NOT NULL DEFAULT '',
slack_user_id TEXT NOT NULL DEFAULT '',
last_seen_ts TEXT NOT NULL DEFAULT '',
last_checked_at DATETIME,
last_ok INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
`
// singletonID is the synthetic primary key of the (only) row in slack_configs.
const singletonID = "singleton"
func (s *Store) initSchema() error {
if err := s.migrateLegacyPerWorkspaceTable(); err != nil {
return err
}
if _, err := s.db.Exec(createTablesSQL); err != nil {
return err
}
return nil
}
// migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where
// slack_configs was keyed by workspace_id) and rewrites it into the singleton
// shape. Picks the most-recently-updated row and records the source
// workspace_id so the provider can migrate the secrets.
func (s *Store) migrateLegacyPerWorkspaceTable() error {
cols, err := s.tableColumns()
if err != nil {
return err
}
if len(cols) == 0 {
return nil
}
if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// Build the SELECT against only columns present in this database — the
// per-workspace shape evolved across patches (utility_agent_id,
// poll_interval_seconds, the slack_team/slack_user/last_seen trio, and
// the auth-health trio were all added incrementally), so a deployment
// upgrading from any earlier intermediate would crash on a SELECT that
// assumed every column. Mirrors the Linear/Jira fix.
selectCols := "workspace_id, auth_method"
selectCols += pickCol(cols, "command_prefix", "''")
selectCols += pickCol(cols, "utility_agent_id", "''")
selectCols += pickCol(cols, "poll_interval_seconds", "30")
selectCols += pickCol(cols, "slack_team_id", "''")
selectCols += pickCol(cols, "slack_user_id", "''")
selectCols += pickCol(cols, "last_seen_ts", "''")
selectCols += pickCol(cols, "last_checked_at", "NULL")
selectCols += pickCol(cols, "last_ok", "0")
selectCols += pickCol(cols, "last_error", "''")
selectCols += ", created_at, updated_at"
var sourceWorkspace, authMethod, commandPrefix, utilityAgentID sql.NullString
var pollIntervalSeconds sql.NullInt64
var slackTeamID, slackUserID, lastSeenTS, lastError sql.NullString
var lastCheckedAt sql.NullTime
var lastOk sql.NullInt64
var createdAt, updatedAt sql.NullTime
row := tx.QueryRow(`SELECT ` + selectCols + ` FROM slack_configs ORDER BY updated_at DESC, workspace_id DESC LIMIT 1`)
switch err := row.Scan(&sourceWorkspace, &authMethod,
&commandPrefix, &utilityAgentID, &pollIntervalSeconds,
&slackTeamID, &slackUserID, &lastSeenTS,
&lastCheckedAt, &lastOk, &lastError,
&createdAt, &updatedAt); {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil {
return err
}
return tx.Commit()
case err != nil:
return err
}
if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil {
return err
}
if _, err := tx.Exec(createTablesSQL); err != nil {
return err
}
pollSeconds := int(pollIntervalSeconds.Int64)
if pollSeconds == 0 {
pollSeconds = DefaultPollIntervalSeconds
}
if _, err := tx.Exec(`
INSERT INTO slack_configs (
id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds,
slack_team_id, slack_user_id, last_seen_ts,
last_checked_at, last_ok, last_error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
singletonID, authMethod.String, commandPrefix.String, utilityAgentID.String, pollSeconds,
slackTeamID.String, slackUserID.String, lastSeenTS.String,
nullableTime(lastCheckedAt), lastOk.Int64, lastError.String,
nullableTime(createdAt), nullableTime(updatedAt)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
s.migratedFromWorkspace = sourceWorkspace.String
return nil
}
// pickCol returns ", <name>" if the column is present in the legacy schema
// or ", <fallback> AS <name>" otherwise. Lets the migration SELECT defaults
// for columns that didn't exist in earlier intermediate schemas.
func pickCol(cols map[string]struct{}, name, fallback string) string {
if _, ok := cols[name]; ok {
return ", " + name
}
return ", " + fallback + " AS " + name
}
func nullableTime(t sql.NullTime) interface{} {
if !t.Valid {
return nil
}
return t.Time
}
// tableColumns returns the column-name set for slack_configs. SQLite doesn't
// support parameterised identifiers in PRAGMA, and the only table this
// migration logic ever inspects is slack_configs, so the table name is
// inlined as a literal.
func (s *Store) tableColumns() (map[string]struct{}, error) {
rows, err := s.db.Query(`PRAGMA table_info(slack_configs)`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
cols := make(map[string]struct{})
for rows.Next() {
var (
cid int
name string
ctype string
notnull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return nil, err
}
cols[name] = struct{}{}
}
return cols, rows.Err()
}
const selectConfigColumns = `auth_method, command_prefix, utility_agent_id,
poll_interval_seconds,
slack_team_id, slack_user_id, last_seen_ts,
last_checked_at, last_ok, last_error, created_at, updated_at`
// GetConfig returns the singleton Slack config, or nil when no row exists.
func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) {
var cfg SlackConfig
err := s.ro.GetContext(ctx, &cfg,
`SELECT `+selectConfigColumns+` FROM slack_configs WHERE id = ?`, singletonID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &cfg, nil
}
// UpsertConfig inserts or updates the singleton row. Health columns
// (last_*) and watermark columns (last_seen_ts, slack_team_id, slack_user_id)
// are owned by the poller / trigger and aren't touched here.
func (s *Store) UpsertConfig(ctx context.Context, cfg *SlackConfig) error {
now := time.Now().UTC()
if cfg.CreatedAt.IsZero() {
cfg.CreatedAt = now
}
cfg.UpdatedAt = now
_, err := s.db.ExecContext(ctx, `
INSERT INTO slack_configs (
id, auth_method,
command_prefix, utility_agent_id, poll_interval_seconds,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
auth_method = excluded.auth_method,
command_prefix = excluded.command_prefix,
utility_agent_id = excluded.utility_agent_id,
poll_interval_seconds = excluded.poll_interval_seconds,
updated_at = excluded.updated_at`,
singletonID, cfg.AuthMethod,
cfg.CommandPrefix, cfg.UtilityAgentID, cfg.PollIntervalSeconds,
cfg.CreatedAt, cfg.UpdatedAt)
return err
}
// DeleteConfig removes the singleton row. Secrets must be cleared separately.
func (s *Store) DeleteConfig(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE id = ?`, singletonID)
return err
}
// HasConfig reports whether the singleton row exists. Used by the auth-health
// poller to decide whether to probe at all.
func (s *Store) HasConfig(ctx context.Context) (bool, error) {
var present int
err := s.ro.GetContext(ctx, &present,
`SELECT COUNT(*) FROM slack_configs WHERE id = ?`, singletonID)
if err != nil {
return false, err
}
return present > 0, nil
}
// UpdateAuthHealth records the result of a credential probe and (when
// supplied) the user/team identifiers captured during the same probe so the
// trigger can scope its searches without an extra round-trip.
func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error {
if teamID != "" && userID != "" {
_, err := s.db.ExecContext(ctx, `
UPDATE slack_configs
SET last_checked_at = ?, last_ok = ?, last_error = ?,
slack_team_id = ?, slack_user_id = ?
WHERE id = ?`,
checkedAt, ok, errMsg, teamID, userID, singletonID)
return err
}
_, err := s.db.ExecContext(ctx, `
UPDATE slack_configs
SET last_checked_at = ?, last_ok = ?, last_error = ?
WHERE id = ?`,
checkedAt, ok, errMsg, singletonID)
return err
}
// UpdateLastSeenTS advances the trigger's watermark.
func (s *Store) UpdateLastSeenTS(ctx context.Context, ts string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE slack_configs SET last_seen_ts = ? WHERE id = ?`,
ts, singletonID)
return err
}