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

340 lines
9.9 KiB
Go

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
}