Files
kandev/apps/backend/internal/slack/provider.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

65 lines
2.3 KiB
Go

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))
}
}