diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md index 65f349110..b7ef15e73 100644 --- a/docs/AUTOMATIONS.md +++ b/docs/AUTOMATIONS.md @@ -6,13 +6,14 @@ session whenever the trigger fires. Trigger types: -| Trigger Type | Description | Availability | -| ------------------- | ----------------------------------------- | ------------ | -| **Schedule** | Run on a cron schedule | Available | -| **Inbound Webhook** | Trigger from any system with an HTTP POST | Available | -| **Sentry Alert** | Trigger from a Sentry Custom Integration | Available | -| **GitHub Event** | Trigger on GitHub activity | Planned | -| **Linear Event** | Trigger on Linear activity | Planned | +| Trigger Type | Description | Availability | +| ------------------- | ----------------------------------------- | ------------------ | +| **Schedule** | Run on a cron schedule | Available | +| **Inbound Webhook** | Trigger from any system with an HTTP POST | Available | +| **Sentry Alert** | Trigger from a Sentry Custom Integration | Available | +| **Slack Message** | Trigger on messages in watched channels | Available (opt-in) | +| **GitHub Event** | Trigger on GitHub activity | Planned | +| **Linear Event** | Trigger on Linear activity | Planned | Common use cases include nightly dependency updates, reacting to deploy or incident events, triaging new Sentry issues, and recurring report generation. @@ -45,11 +46,12 @@ Start by choosing a **Trigger Type**. The rest of the form adjusts based on that ### Trigger-Specific Fields -| Trigger Type | Additional Fields | -| ------------------- | ------------------------------------------- | -| **Schedule** | **Schedule** and **Timezone** | -| **Inbound Webhook** | No extra required fields | -| **Sentry Alert** | **Event Type** and **Sentry Client Secret** | +| Trigger Type | Additional Fields | +| ------------------- | -------------------------------------------------------------------------------------------- | +| **Schedule** | **Schedule** and **Timezone** | +| **Inbound Webhook** | No extra required fields | +| **Sentry Alert** | **Event Type** and **Sentry Client Secret** | +| **Slack Message** | **Conditions** (a Slack Channel condition is required; a Message Text condition is optional) | For non-schedule automations, schedule fields are not used. @@ -192,6 +194,57 @@ concurrency protection. --- +## Slack Message Triggers + +A **Slack Message** automation starts a session when someone posts a matching message in a watched +Slack channel. Unlike `@mention` sessions (which are explicit, interactive requests), these triggers +fire on ambient channel messages that match the conditions you define. + +This source is opt-in per deployment and ships **disabled by default**. Enabling it requires the +operator to set the `SLACK_TRIGGERS_ENABLED` flag and configure the Slack app — see +[the Slack integration guide](integrations/SLACK.md#channel-message-triggers) for setup and the +threat model. The web form and these conditions are always available to author; messages are only +ingested once the flag is on. + +### Conditions + +A Slack automation must define at least a **Slack Channel** condition; the rest are optional +filters. + +- **Slack Channel** (required) — the channels to watch. Pick channels by name in the web form; + channel IDs (for example `C0123ABCD`) also work as a fallback when channel listing is unavailable. + Only messages in these channels are considered, and the bot must be a member of each. +- **Message Text** (optional) — filter on the message text. Without it, every message in the watched + channels triggers the automation. Pick a mode: + - **contains** — the message contains the substring (optionally case-insensitive). + - **exact** — the message equals the text. + - **regex** — the message matches a regular expression. Patterns are capped in length and limited + to the `i` and `m` flags; an invalid pattern is rejected when you save. +- **Slack User** (optional) — include or exclude specific Slack user IDs (an allowlist is the + recommended way to limit who can trigger a run). + +A message runs the automation only when **every** condition passes. The bot-mention token is +stripped before matching, and messages that `@mention` the bot are handled by the interactive +`@mention` flow instead — they never double-fire as triggers. + +### Run feedback + +A triggering message is marked with the 👀 reaction while its run is in flight. When the run +finishes, the agent's final response is posted as a reply in that message's thread — with links to +any pull requests it opened and to the full web session — and the reaction is cleared. A failed run +posts a short failure notice in the thread instead. + +Every reply in a thread **continues the same session** — during the run and after it finishes — for +up to 24 hours after the thread's first trigger, exactly like replying in an `@mention` thread. The +reply is enqueued as a follow-up turn on that session (re-spawning it from a snapshot if it had gone +idle), and the agent posts its response in-thread when the turn finishes. A follow-up does not need +to match the trigger condition — conditions gate new runs, not replies that continue an existing +thread. If a reply races the very first trigger before its session exists, it falls back to an +ephemeral "a run is already active" notice (reason `concurrent_run_active`); a reply more than 24 +hours after the first trigger starts a fresh run. + +--- + ## Schedule Options The schedule picker offers four presets and a custom mode: @@ -304,6 +357,11 @@ Event-driven automations use concurrency keys instead. For inbound webhooks, ret `idempotencyKey` are treated as the same event, but separate deliveries without a shared `idempotencyKey` can overlap. +Slack Message triggers key concurrency by thread. Replies in a thread are not skipped — for 24 hours +after the thread's first trigger they continue the same session (during the run and after it +finishes), routed to that session as follow-up prompts (see the **Run feedback** note under +[Slack Message Triggers](#slack-message-triggers)). + This prevents overlapping sessions from interfering with each other on the same repository. --- diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index bc961d1fd..05370b720 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -942,7 +942,9 @@ If the bot doesn't see the original message when tagged in a thread reply: private channels). These are required by the `conversations.replies` API to fetch thread messages. 2. Verify the bot has `channels:read` and `groups:read` scopes. These are required by - `conversations.info` to fetch channel name and description for context. + `conversations.info` to fetch channel name and description for context, and by + `conversations.list` to populate the automation channel picker. If the picker shows no channels, + check these scopes and that the bot is invited to the target channel. 3. If you added missing scopes, **reinstall the app** to your workspace for the new permissions to take effect. diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md index 1cd904f1e..9c00e6bec 100644 --- a/docs/integrations/SLACK.md +++ b/docs/integrations/SLACK.md @@ -37,9 +37,13 @@ notification controls and safety notes are covered near the end. | Set personal defaults | Use the Slack app's **Home** tab for model, reasoning effort, and branch | | Follow the result | Read the completion reply or open the full session with **View Session** | | Ask the agent to post Slack | Enable agent notifications, then explicitly ask the agent to post to Slack | +| Auto-trigger from a channel | Opt-in: watch a channel so matching messages start an automation | -Open-Inspect does not use slash commands today. In channels, it responds to `@mentions`, not every -message posted in the channel. +Open-Inspect does not use slash commands today. In channels, it normally responds only to +`@mentions`, not to every message. The optional +[channel-message triggers](#channel-message-triggers) feature can additionally start an +**automation** from non-mention messages that match conditions you configure; it is disabled by +default and must be enabled by an operator. --- @@ -181,9 +185,9 @@ Slack thread continue the existing session. ## Optional Agent Notifications -Slack-started sessions always get their normal thread replies and completion messages. Agent -notifications are separate: they let an agent post an extra message to a Slack channel when you -explicitly ask for it: +Interactive Slack sessions (DMs and `@mentions`) always get their normal thread replies and +completion messages. Agent notifications are separate: they let an agent post an extra message to a +Slack channel when you explicitly ask for it: ```text When you finish, post a short summary to #eng-updates. @@ -220,6 +224,67 @@ always stripped from agent notification messages. --- +## Channel Message Triggers + +Channel message triggers let an **automation** start a session when someone posts a matching message +in a watched channel — without `@mentioning` the bot. This is distinct from the interactive +`@mention` flow: it is driven by [automations](../AUTOMATIONS.md#slack-message-triggers) with +keyword, substring, or regex conditions. + +The feature is **disabled by default** and gated by the `SLACK_TRIGGERS_ENABLED` deployment flag. +When the flag is off, the bot ignores channel messages and forwards nothing; authoring a Slack +automation in the web app is still allowed, but it will not run until the flag is enabled. + +### Slack app setup + +In addition to the standard event subscription the bot already uses, enable the bot to receive +ordinary channel messages: + +- **Event subscriptions**: subscribe to `message.channels` (public channels). Add `message.groups` + if you also want to watch private channels. +- **Bot token scopes**: `channels:history` (public) and, for private channels, `groups:history`. +- Invite the bot to every channel you intend to watch. The bot only sees messages in channels it is + a member of. + +Then, in the web app, create a **Slack Message** automation and add a **Slack Channel** condition +(pick channels by name; channel IDs also work as a fallback). Optionally add a **Message Text** +condition to filter by content. See +[Slack Message Triggers](../AUTOMATIONS.md#slack-message-triggers) for the full field reference. + +### Run feedback + +- A triggering message gets a 👀 reaction while its run is in flight. +- When the run finishes, the agent's final response is posted into the triggering message's thread + (with links to any pull requests and the full session), and the reaction is cleared. A failed run + posts a short failure notice instead. +- Every reply in a thread continues the same session — during the run and after it finishes — for up + to 24 hours after the thread's first trigger, like replying in an `@mention` thread. The reply is + routed to that session as a follow-up prompt (re-spawned from a snapshot if it had gone idle), + gets its own 👀 reaction and in-thread response, and does **not** need to match the trigger's text + condition — conditions gate new runs, not replies that continue a thread. A reply more than 24 + hours after the first trigger starts a fresh run. + +### Threat model + +Channel triggers widen who can start a coding session, so weigh the following before enabling them: + +- **Any member of a watched channel can trigger a run** simply by posting a matching message. Treat + every watched channel as a list of people authorized to start sessions against the automation's + repository. +- **Prefer an allowlist.** Add a **Slack User** condition (`include`) so only specific people can + trigger the automation, and keep watched channels small and trusted. +- **Message text reaches the agent.** The triggering message becomes part of the prompt. Scope the + automation's instructions defensively and rely on the deployment's repository access boundary — + the same GitHub App installation limits used elsewhere apply here too. +- **Regex conditions run untimed.** Conditions are evaluated with the native regex engine and no + per-match timeout; a pathological pattern is an operator-authored risk. Patterns are length-capped + and validated at save time, and the `SLACK_TRIGGERS_ENABLED` flag is the kill switch if a bad + pattern degrades automation dispatch. +- **The kill switch is immediate.** Setting `SLACK_TRIGGERS_ENABLED` back to `false` stops the bot + from ingesting or forwarding channel messages right away. + +--- + ## Admin and Safety Notes These notes are most useful for workspace admins deciding where the Slack bot should be available. diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts index 6b89b72e8..07dbb7c13 100644 --- a/packages/control-plane/src/db/automation-store.test.ts +++ b/packages/control-plane/src/db/automation-store.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi } from "vitest"; import { AutomationStore, + isDuplicateKeyError, toAutomation, toAutomationRun, type AutomationRow, @@ -431,3 +432,27 @@ describe("AutomationStore", () => { }); }); }); + +describe("isDuplicateKeyError", () => { + it("matches the trigger-key dedup index violation", () => { + expect( + isDuplicateKeyError( + new Error( + "D1_ERROR: UNIQUE constraint failed: automation_runs.automation_id, automation_runs.trigger_key" + ) + ) + ).toBe(true); + }); + + it("ignores unrelated UNIQUE violations so they surface as real errors", () => { + expect(isDuplicateKeyError(new Error("UNIQUE constraint failed: automation_runs.id"))).toBe( + false + ); + expect(isDuplicateKeyError(new Error("some other write failure"))).toBe(false); + }); + + it("handles non-Error throwables", () => { + expect(isDuplicateKeyError("UNIQUE constraint failed: x.trigger_key")).toBe(true); + expect(isDuplicateKeyError(null)).toBe(false); + }); +}); diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index b83d452d2..87e8a7a96 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -5,7 +5,12 @@ * snake_case rows in the database, camelCase types at the API boundary. */ -import type { Automation, AutomationRun, AutomationRunStatus } from "@open-inspect/shared"; +import type { + Automation, + AutomationRun, + AutomationRunStatus, + TriggerConfig, +} from "@open-inspect/shared"; // ─── Internal row types ────────────────────────────────────────────────────── @@ -48,6 +53,9 @@ export interface AutomationRunRow { created_at: number; trigger_key: string | null; concurrency_key: string | null; + // Source-specific run metadata as JSON (slack-origin runs only today; absent + // otherwise). Opaque to the store — interpreted by the owning source's layer. + trigger_run_metadata?: string | null; } export interface EnrichedRunRow extends AutomationRunRow { @@ -58,6 +66,9 @@ export interface EnrichedRunRow extends AutomationRunRow { // ─── Mappers ───────────────────────────────────────────────────────────────── export function toAutomation(row: AutomationRow): Automation { + const triggerConfig: TriggerConfig | null = row.trigger_config + ? JSON.parse(row.trigger_config) + : null; return { id: row.id, name: row.name, @@ -79,7 +90,7 @@ export function toAutomation(row: AutomationRow): Automation { updatedAt: row.updated_at, deletedAt: row.deleted_at, eventType: row.event_type ?? null, - triggerConfig: row.trigger_config ? JSON.parse(row.trigger_config) : null, + triggerConfig, }; } @@ -109,8 +120,12 @@ export class AutomationStore { // --- Automation CRUD --- - async create(row: AutomationRow): Promise { - await this.db + /** + * Prepared INSERT for an automation row. Public so a route can compose it with + * `SlackChannelStore.bindChannelStatements` into one atomic `db.batch`. + */ + bindAutomationInsert(row: AutomationRow): D1PreparedStatement { + return this.db .prepare( `INSERT INTO automations (id, name, repo_owner, repo_name, base_branch, repo_id, instructions, @@ -143,8 +158,11 @@ export class AutomationStore { row.event_type, row.trigger_config, row.trigger_auth_data - ) - .run(); + ); + } + + async create(row: AutomationRow): Promise { + await this.bindAutomationInsert(row).run(); } async getById(id: string): Promise { @@ -180,7 +198,12 @@ export class AutomationStore { return { automations, total: automations.length }; } - async update(id: string, fields: Partial): Promise { + /** + * Build the dynamic UPDATE statement for the allowed automation fields, or + * null when `fields` carries nothing to write. Public so a route can compose it + * with `SlackChannelStore.bindChannelStatements` into one atomic `db.batch`. + */ + bindAutomationUpdate(id: string, fields: Partial): D1PreparedStatement | null { const setClauses: string[] = []; const params: unknown[] = []; @@ -207,19 +230,22 @@ export class AutomationStore { } } - if (setClauses.length === 0) return this.getById(id); + if (setClauses.length === 0) return null; setClauses.push("updated_at = ?"); params.push(Date.now()); params.push(id); - await this.db + return this.db .prepare( `UPDATE automations SET ${setClauses.join(", ")} WHERE id = ? AND deleted_at IS NULL` ) - .bind(...params) - .run(); + .bind(...params); + } + async update(id: string, fields: Partial): Promise { + const statement = this.bindAutomationUpdate(id, fields); + if (statement) await statement.run(); return this.getById(id); } @@ -291,8 +317,9 @@ export class AutomationStore { .prepare( `INSERT INTO automation_runs (id, automation_id, session_id, status, skip_reason, failure_reason, - scheduled_at, started_at, completed_at, created_at, trigger_key, concurrency_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + scheduled_at, started_at, completed_at, created_at, trigger_key, concurrency_key, + trigger_run_metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( run.id, @@ -306,7 +333,8 @@ export class AutomationStore { run.completed_at, run.created_at, run.trigger_key ?? null, - run.concurrency_key ?? null + run.concurrency_key ?? null, + run.trigger_run_metadata ?? null ); } @@ -477,6 +505,47 @@ export class AutomationStore { return result.results || []; } + /** + * Record a `skipped` run for observability (concurrency skips). + * Leaves trigger_key null so repeated skips never collide on the dedup index + * (NULLs are distinct under the unique automation_id/trigger_key index). + * `runMetadata` carries the run's source-specific metadata as a unit — the same + * value a materialized run is inserted with — so there is no re-mapping. + */ + async recordSkippedRun(params: { + id: string; + automationId: string; + skipReason: string; + concurrencyKey?: string | null; + runMetadata?: Pick; + }): Promise { + const now = Date.now(); + try { + await this.insertRun({ + id: params.id, + automation_id: params.automationId, + session_id: null, + status: "skipped", + skip_reason: params.skipReason, + failure_reason: null, + scheduled_at: now, + started_at: null, + completed_at: now, + created_at: now, + trigger_key: null, + concurrency_key: params.concurrencyKey ?? null, + ...params.runMetadata, + }); + } catch (e) { + // Defensive only: the insert uses a fresh unique id and a null trigger_key + // (NULLs are distinct under the unique automation_id/trigger_key index), so + // a collision is not expected. Swallow one anyway so best-effort skip + // bookkeeping never throws into the dispatch path. + if (isDuplicateKeyError(e)) return; + throw e; + } + } + async getActiveRunForKey( automationId: string, concurrencyKey: string | null @@ -494,6 +563,30 @@ export class AutomationStore { .first(); } + /** + * The most recent materialized run (any status, with a session) for a thread's + * concurrency key, created at/after `sinceMs`. Powers Slack thread-session + * continuity: a reply continues this run's session regardless of run status. + * Excludes skipped rows and not-yet-started runs (session_id NULL). Backed by + * idx_runs_thread_continuity (migration 0028). + */ + async getLatestSteerableRunForThread( + automationId: string, + concurrencyKey: string | null, + sinceMs: number + ): Promise { + if (concurrencyKey === null) return null; + return this.db + .prepare( + `SELECT * FROM automation_runs + WHERE automation_id = ? AND concurrency_key = ? + AND session_id IS NOT NULL AND created_at >= ? + ORDER BY created_at DESC LIMIT 1` + ) + .bind(automationId, concurrencyKey, sinceMs) + .first(); + } + // --- Recovery sweep queries --- // Backed by partial indexes (migration 0024); `status` must stay a literal, not // a bound param, or the planner skips the index and full-scans automation_runs. @@ -560,3 +653,16 @@ export class AutomationStore { .run(); } } + +/** + * True when a D1 write failed because it violated the trigger-key dedup index + * (`idx_runs_trigger_key` on `automation_id, trigger_key` — the only UNIQUE on + * `automation_runs`). Scoping to the `trigger_key` column keeps unrelated UNIQUE + * violations surfacing as real errors instead of being silently swallowed as + * duplicates, while matching the column name (not D1's full + * `table.col, table.col` string) stays robust to exact message formatting. + */ +export function isDuplicateKeyError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("UNIQUE constraint failed") && message.includes("trigger_key"); +} diff --git a/packages/control-plane/src/db/slack-channel-store.ts b/packages/control-plane/src/db/slack-channel-store.ts new file mode 100644 index 000000000..9ede3d783 --- /dev/null +++ b/packages/control-plane/src/db/slack-channel-store.ts @@ -0,0 +1,83 @@ +/** + * SlackChannelStore — D1 persistence for the slack_event watched-channel index. + * + * `automation_slack_channels` maps each slack_event automation to the Slack + * channels it watches. It backs scheduler candidate selection (which automations + * fire for an incoming channel message) and the watched-channels endpoint the + * slack-bot polls to pre-filter messages before forwarding them. + * + * Kept out of AutomationStore so trigger-source-specific persistence doesn't leak + * into the generic automation store — slack is the only source that needs a + * dedicated index. The index is a denormalized copy of each automation's + * `slack_channel` condition (held in trigger_config); a route writes the + * automation row and the channel rows in one `db.batch` (via bindChannelStatements) + * so the two can't drift apart on a partial failure. + */ + +import type { AutomationRow } from "./automation-store"; + +export class SlackChannelStore { + constructor(private readonly db: D1Database) {} + + /** Enabled, non-deleted slack_event automations watching a channel (indexed by channel_id). */ + async getSlackAutomationsForChannel(channelId: string): Promise { + const result = await this.db + .prepare( + `SELECT a.* FROM automations a + JOIN automation_slack_channels c ON c.automation_id = a.id + WHERE c.channel_id = ? AND a.enabled = 1 AND a.deleted_at IS NULL + AND a.trigger_type = 'slack_event'` + ) + .bind(channelId) + .all(); + return result.results || []; + } + + /** Distinct channel IDs watched by any enabled slack_event automation. */ + async getWatchedSlackChannels(): Promise { + const result = await this.db + .prepare( + `SELECT DISTINCT c.channel_id FROM automation_slack_channels c + JOIN automations a ON a.id = c.automation_id + WHERE a.enabled = 1 AND a.deleted_at IS NULL AND a.trigger_type = 'slack_event'` + ) + .all<{ channel_id: string }>(); + return (result.results || []).map((r) => r.channel_id); + } + + /** + * Statements that replace an automation's watched-channel set (DELETE + re-INSERT). + * Public so a route can compose them with the automation insert/update into one + * `db.batch`, keeping the canonical trigger_config and this index atomic. + */ + bindChannelStatements(automationId: string, channelIds: string[]): D1PreparedStatement[] { + const statements: D1PreparedStatement[] = [ + this.db + .prepare("DELETE FROM automation_slack_channels WHERE automation_id = ?") + .bind(automationId), + ]; + for (const channelId of channelIds) { + statements.push( + this.db + .prepare( + "INSERT OR IGNORE INTO automation_slack_channels (automation_id, channel_id) VALUES (?, ?)" + ) + .bind(automationId, channelId) + ); + } + return statements; + } + + /** + * Replace an automation's watched-channel set atomically. Test-support only — + * production writes compose `bindChannelStatements` into the same `db.batch` as + * the automation row so the index stays coupled to the canonical trigger_config. + * A standalone write here would let the two drift, so it is kept off the + * production path. + * + * @internal + */ + async setSlackChannels(automationId: string, channelIds: string[]): Promise { + await this.db.batch(this.bindChannelStatements(automationId, channelIds)); + } +} diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index aac83958a..5d7f1e11a 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -11,12 +11,20 @@ import { getValidModelOrDefault, validateConditions, conditionRegistry, + listChannels, TRIGGER_TYPE_TO_SOURCE, type CreateAutomationRequest, type UpdateAutomationRequest, type AutomationTriggerType, + type TriggerConfig, } from "@open-inspect/shared"; -import { AutomationStore, toAutomation, toAutomationRun } from "../db/automation-store"; +import { + AutomationStore, + toAutomation, + toAutomationRun, + type AutomationRow, +} from "../db/automation-store"; +import { SlackChannelStore } from "../db/slack-channel-store"; import { UserStore } from "../db/user-store"; import { resolveProviderIdentity, type SessionIdentityFields } from "../session/identity"; import { generateId } from "../auth/crypto"; @@ -67,6 +75,38 @@ function isValidTimezone(tz: string): boolean { } } +/** Extract the watched channel IDs from a slack automation's `slack_channel` condition. */ +function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): string[] { + for (const condition of triggerConfig?.conditions ?? []) { + if (condition.type === "slack_channel") return condition.value; + } + return []; +} + +/** + * Validate a slack_event trigger config before persistence. It must be scoped to + * an explicit channel set (net-new validation; the engine otherwise skips + * condition validation entirely when none are present). A text_match is optional + * — without one the automation fires on every message in the watched channel. + * Returns an error message, or null when valid. + */ +function validateSlackTriggerConfig( + triggerConfig: TriggerConfig | null | undefined +): string | null { + // Guard the shape here too: this runs before the generic array-shape check in + // the update path, so a non-array `conditions` would otherwise throw on + // `.some()` and surface as a 500 instead of a 400. + const rawConditions = triggerConfig?.conditions; + if (rawConditions !== undefined && !Array.isArray(rawConditions)) { + return "triggerConfig.conditions must be an array"; + } + const conditions = rawConditions ?? []; + if (!conditions.some((c) => c.type === "slack_channel")) { + return "slack_event triggers require a slack_channel condition"; + } + return null; +} + // ─── Handlers ──────────────────────────────────────────────────────────────── async function handleListAutomations( @@ -126,6 +166,7 @@ async function handleCreateAutomation( "webhook", "github_event", "linear_event", + "slack_event", ]; if (!validTriggerTypes.includes(triggerType)) { return error(`triggerType must be one of: ${validTriggerTypes.join(", ")}`, 400); @@ -175,6 +216,12 @@ async function handleCreateAutomation( } } + // Slack triggers require explicit scoping (at least one watched channel). + if (triggerType === "slack_event") { + const slackError = validateSlackTriggerConfig(body.triggerConfig); + if (slackError) return error(slackError, 400); + } + // Validate model const model = getValidModelOrDefault(body.model); const reasoningEffort = resolveReasoningEffort(model, body.reasoningEffort); @@ -240,7 +287,7 @@ async function handleCreateAutomation( } const store = new AutomationStore(env.DB); - await store.create({ + const row: AutomationRow = { id, name: body.name.trim(), repo_owner: repoOwner, @@ -264,7 +311,21 @@ async function handleCreateAutomation( event_type: body.eventType ?? null, trigger_config: body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, trigger_auth_data: triggerAuthData, - }); + }; + + // Persist the automation and (for slack_event) its watched-channel index in a + // single atomic write, so the canonical trigger_config and the channel index + // that drives scheduler candidate selection can never drift apart on a partial + // failure. The batch composes the two single-table stores' prepared statements. + if (triggerType === "slack_event") { + const slackStore = new SlackChannelStore(env.DB); + await env.DB.batch([ + store.bindAutomationInsert(row), + ...slackStore.bindChannelStatements(row.id, extractSlackChannels(body.triggerConfig)), + ]); + } else { + await store.create(row); + } const automation = toAutomation((await store.getById(id))!); @@ -408,14 +469,28 @@ async function handleUpdateAutomation( updateFields.event_type = body.eventType; } - // Update trigger config (conditions) — only for non-schedule types + // Validate trigger config (conditions) — only for non-schedule types if (body.triggerConfig !== undefined) { if (existing.trigger_type === "schedule") { return error("Cannot set triggerConfig on schedule automations", 400); } if (body.triggerConfig === null) { - updateFields.trigger_config = null; + // A slack_event's trigger_config holds its required scoping (channel + + // text_match) and the watched-channel index is derived from it. Clearing + // it would leave the automation enabled but untriggerable, so reject null + // — pause or delete instead. (Other sources may clear conditions to a + // match-all, so null stays allowed for them.) + if (existing.trigger_type === "slack_event") { + return error( + "Cannot clear triggerConfig on slack_event automations; pause or delete instead", + 400 + ); + } } else { + if (existing.trigger_type === "slack_event") { + const slackError = validateSlackTriggerConfig(body.triggerConfig); + if (slackError) return error(slackError, 400); + } if (body.triggerConfig.conditions) { if (!Array.isArray(body.triggerConfig.conditions)) { return error("triggerConfig.conditions must be an array", 400); @@ -432,10 +507,18 @@ async function handleUpdateAutomation( } } } - updateFields.trigger_config = JSON.stringify(body.triggerConfig); } } + // trigger_config is a single source-interpreted JSON blob (the conditions), + // so a PUT replaces it wholesale (null clears it). The caller owns the full + // blob; the web form always re-submits the conditions within triggerConfig. + if (body.triggerConfig === null) { + updateFields.trigger_config = null; + } else if (body.triggerConfig !== undefined) { + updateFields.trigger_config = JSON.stringify(body.triggerConfig); + } + // Recompute next_run_at if schedule changed (only for schedule types) if ( existing.trigger_type === "schedule" && @@ -449,7 +532,28 @@ async function handleUpdateAutomation( updateFields.next_run_at = nextCronOccurrence(cron, tz).getTime(); } - const updated = await store.update(id, updateFields); + // Apply the update and, when a slack_event automation's conditions changed, + // re-sync its watched-channel index in the same atomic write so trigger_config + // and the channel index can never drift apart on a partial failure. The batch + // composes the two single-table stores' prepared statements, tolerating a null + // update statement (no automation fields changed, channels-only re-sync). + const resyncSlackChannels = + existing.trigger_type === "slack_event" && body.triggerConfig !== undefined; + let updated: AutomationRow | null; + if (resyncSlackChannels) { + const slackStore = new SlackChannelStore(env.DB); + const updateStatement = store.bindAutomationUpdate(id, updateFields); + const channelStatements = slackStore.bindChannelStatements( + id, + extractSlackChannels(body.triggerConfig) + ); + await env.DB.batch( + updateStatement ? [updateStatement, ...channelStatements] : channelStatements + ); + updated = await store.getById(id); + } else { + updated = await store.update(id, updateFields); + } if (!updated) return error("Automation not found", 404); logger.info("automation.updated", { @@ -715,9 +819,70 @@ async function handleRegenerateKey( }); } +/** + * GET /integration-settings/slack/watched-channels + * + * Returns the distinct set of Slack channel IDs referenced by enabled + * `slack_event` automations. The slack-bot polls this (cached) to pre-filter + * channel messages before normalizing and forwarding them — only messages in a + * watched channel are worth forwarding to the scheduler. + * + * Grouped under the `/integration-settings/slack` prefix the bot already uses + * for its runtime config (routing rules), even though the data is sourced from + * the automations store. Internal-auth gated by the router (non-public route). + */ +async function handleGetWatchedSlackChannels( + _request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext +): Promise { + const channels = await new SlackChannelStore(env.DB).getWatchedSlackChannels(); + return json({ channels }); +} + +/** + * GET /integration-settings/slack/channels + * + * Lists the workspace's channels (public + private the bot can see) so the + * automation form can offer a channel picker instead of a raw channel ID. Sourced + * live from Slack via `conversations.list` using the bot token. + * + * Returns `{ channels }` on success, or `{ channels: [], error }` when the token + * is unset or Slack rejects the call (e.g. missing `channels:read`/`groups:read` + * scope) — the form then degrades to manual channel-ID entry. Internal-auth gated + * by the router (non-public route). + */ +async function handleGetSlackChannels( + _request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext +): Promise { + if (!env.SLACK_BOT_TOKEN) { + return json({ channels: [], error: "not_configured" }); + } + const result = await listChannels(env.SLACK_BOT_TOKEN); + if (!result.ok) { + logger.warn("slack.channels.list_failed", { slack_error: result.error }); + return json({ channels: [], error: result.error }); + } + return json({ channels: result.channels }); +} + // ─── Route exports ─────────────────────────────────────────────────────────── export const automationRoutes: Route[] = [ + { + method: "GET", + pattern: parsePattern("/integration-settings/slack/watched-channels"), + handler: handleGetWatchedSlackChannels, + }, + { + method: "GET", + pattern: parsePattern("/integration-settings/slack/channels"), + handler: handleGetSlackChannels, + }, { method: "GET", pattern: parsePattern("/automations"), diff --git a/packages/control-plane/src/scheduler/durable-object.test.ts b/packages/control-plane/src/scheduler/durable-object.test.ts index 611856d0d..95a8d66a7 100644 --- a/packages/control-plane/src/scheduler/durable-object.test.ts +++ b/packages/control-plane/src/scheduler/durable-object.test.ts @@ -31,6 +31,9 @@ function createMockStore() { return { getOverdueAutomations: vi.fn().mockResolvedValue([]), getActiveRunForAutomation: vi.fn().mockResolvedValue(null), + getActiveRunForKey: vi.fn().mockResolvedValue(null), + getLatestSteerableRunForThread: vi.fn().mockResolvedValue(null), + recordSkippedRun: vi.fn().mockResolvedValue(undefined), createRunAndAdvanceSchedule: vi.fn().mockResolvedValue(undefined), insertRun: vi.fn().mockResolvedValue(undefined), updateRun: vi.fn().mockResolvedValue(undefined), @@ -77,6 +80,15 @@ vi.mock("../db/user-store", () => ({ }), })); +const mockGetSlackAutomationsForChannel = vi.fn().mockResolvedValue([]); +vi.mock("../db/slack-channel-store", () => ({ + SlackChannelStore: vi.fn().mockImplementation(function () { + return { + getSlackAutomationsForChannel: mockGetSlackAutomationsForChannel, + }; + }), +})); + vi.mock("../auth/crypto", () => ({ generateId: vi.fn(() => `id-${Math.random().toString(36).slice(2, 8)}`), })); @@ -154,6 +166,33 @@ async function getInitBody(fetchMock: ReturnType): Promise; } +async function getPromptBody( + fetchMock: ReturnType +): Promise> { + const promptCall = fetchMock.mock.calls.find((call) => { + const input = call[0]; + const url = + typeof input === "string" ? input : input instanceof Request ? input.url : String(input); + return new URL(url).pathname === "/internal/prompt"; + }); + + expect(promptCall).toBeDefined(); + const [input, init] = promptCall!; + if (input instanceof Request) { + return (await input.json()) as Record; + } + return JSON.parse(String(init?.body)) as Record; +} + +function promptCallCount(fetchMock: ReturnType): number { + return fetchMock.mock.calls.filter((call) => { + const input = call[0]; + const url = + typeof input === "string" ? input : input instanceof Request ? input.url : String(input); + return new URL(url).pathname === "/internal/prompt"; + }).length; +} + function createEnv(overrides?: Partial): Env { const sessionStub = createMockSessionStub(); return { @@ -200,12 +239,55 @@ const sampleAutomation = { deleted_at: null, }; +const sampleSlackAutomation = { + ...sampleAutomation, + id: "auto-slack", + name: "Slack triage", + trigger_type: "slack_event", + schedule_cron: null, + next_run_at: null, + event_type: "message.posted", + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), +}; + +function makeSlackEvent(overrides?: Record) { + const ts = "1700000000.000200"; + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:C1:${ts}`, + concurrencyKey: "slack:C1:thread-root", + contextBlock: "A message was posted in #ops.", + meta: {}, + channelId: "C1", + threadTs: "1700000000.000100", + ts, + actorUserId: "U1", + text: "please deploy the api", + ...overrides, + }; +} + +function slackEventRequest(overrides?: Record): Request { + return new Request("http://internal/internal/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(makeSlackEvent(overrides)), + }); +} + // ─── Tests ─────────────────────────────────────────────────────────────────── describe("SchedulerDO", () => { beforeEach(() => { vi.clearAllMocks(); mockStore = createMockStore(); + mockGetSlackAutomationsForChannel.mockResolvedValue([]); }); describe("/internal/health", () => { @@ -1042,6 +1124,187 @@ describe("SchedulerDO", () => { }); }); + describe("/internal/event — slack thread continuity", () => { + it("steers the thread session even when the follow-up fails trigger conditions", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ + id: "active-run", + status: "running", + session_id: "sess-running", + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + // A natural follow-up reply won't repeat the "deploy" trigger keyword, yet + // it must still steer the thread's session — conditions gate new runs only. + const res = await scheduler.fetch( + slackEventRequest({ text: "thanks — also update the changelog" }) + ); + + expect(res.status).toBe(200); + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + // The continuity lookup is scoped to the thread's concurrency key and a time + // window measured from now (24h back). + expect(mockStore.getLatestSteerableRunForThread).toHaveBeenCalledWith( + "auto-slack", + "slack:C1:thread-root", + expect.any(Number) + ); + + // The follow-up was enqueued onto the existing session as a slack-sourced + // turn, so its reply posts back in-thread via /callbacks/complete. + const promptBody = await getPromptBody(fetchMock); + expect(promptBody.source).toBe("slack"); + expect(promptBody.content).toBe("thanks — also update the changelog"); + expect(promptBody.authorId).toBe("slack:U1"); + expect(promptBody.callbackContext).toMatchObject({ + source: "slack", + channel: "C1", + threadTs: "1700000000.000100", + reactionMessageTs: "1700000000.000200", + repoFullName: "acme/web-app", + }); + + // A steer is not a new trigger and not a skip. + expect(mockStore.insertRun).not.toHaveBeenCalled(); + expect(mockStore.recordSkippedRun).not.toHaveBeenCalled(); + }); + + it("continues the same session on a reply after the run has completed", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + // The thread's run finished, but its session is still steerable within the + // window — like replying after an @mention turn ends. + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ + id: "done-run", + status: "completed", + session_id: "sess-done", + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + const res = await scheduler.fetch( + slackEventRequest({ text: "actually, can you also bump the version?" }) + ); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + const promptBody = await getPromptBody(fetchMock); + expect(promptBody.source).toBe("slack"); + expect(promptBody.content).toBe("actually, can you also bump the version?"); + // Routed to the completed run's session — no new run, and the concurrency + // guard is never consulted (the steer short-circuits the loop). + expect(mockStore.insertRun).not.toHaveBeenCalled(); + expect(mockStore.getActiveRunForKey).not.toHaveBeenCalled(); + }); + + it("anchors the thread to the message ts for a top-level (non-reply) follow-up", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ + id: "active-run", + status: "running", + session_id: "sess-running", + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + // No threadTs → the follow-up should anchor to its own ts. + await scheduler.fetch(slackEventRequest({ threadTs: undefined })); + + const promptBody = await getPromptBody(fetchMock); + expect(promptBody.callbackContext).toMatchObject({ + threadTs: "1700000000.000200", + reactionMessageTs: "1700000000.000200", + }); + }); + + it("starts a fresh run when no steerable session exists (outside the window)", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + // Outside the continuity window → no steerable run, and no active run. + mockStore.getLatestSteerableRunForThread.mockResolvedValue(null); + mockStore.getActiveRunForKey.mockResolvedValue(null); + + const scheduler = createSchedulerDO(); + // Matching text so the trigger conditions pass. + const res = await scheduler.fetch(slackEventRequest()); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 1, skipped: 0, steered: 0 }); + expect(mockStore.insertRun).toHaveBeenCalledWith( + expect.objectContaining({ automation_id: "auto-slack", status: "starting" }) + ); + }); + + it("posts the already-active notice for a reply racing the initial trigger (no session yet)", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + // Run is still starting → not yet steerable, but it blocks a second run. + mockStore.getLatestSteerableRunForThread.mockResolvedValue(null); + mockStore.getActiveRunForKey.mockResolvedValue({ + id: "starting-run", + status: "starting", + session_id: null, + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + const res = await scheduler.fetch(slackEventRequest()); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 0, skipped: 1, steered: 0 }); + expect(mockStore.recordSkippedRun).toHaveBeenCalled(); + // No prompt reached any session. + expect(promptCallCount(fetchMock)).toBe(0); + }); + + it("falls through to a new trigger when steering the session fails", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + // A completed run is steerable, but the enqueue will fail; with the run no + // longer active, the reply is re-evaluated as a new trigger (it matches), + // mirroring the @mention path's stale-session → new-session recovery. + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ + id: "done-run", + status: "completed", + session_id: "sess-done", + }); + mockStore.getActiveRunForKey.mockResolvedValue(null); + + // Session DO rejects every fetch → steerSession fails AND the fresh run's + // session init fails, so the run is created then marked failed. + const failingStub = { + fetch: vi.fn().mockResolvedValue(new Response("boom", { status: 500 })), + } as never; + const env = createEnv(); + vi.mocked(env.SESSION.get).mockReturnValue(failingStub); + + const scheduler = createSchedulerDO(env); + const res = await scheduler.fetch(slackEventRequest()); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + // Steer failed → fell through → matched conditions → fresh run created + // (insertRun) but its session init failed, so triggered stays 0. + expect(body).toEqual({ triggered: 0, skipped: 0, steered: 0 }); + expect(mockStore.insertRun).toHaveBeenCalledWith( + expect.objectContaining({ automation_id: "auto-slack", status: "starting" }) + ); + // Not treated as a concurrency skip. + expect(mockStore.recordSkippedRun).not.toHaveBeenCalled(); + }); + }); + it("returns 404 for unknown routes", async () => { const scheduler = createSchedulerDO(); const res = await scheduler.fetch(new Request("http://internal/unknown", { method: "GET" })); diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 9666e8e66..5e4d5b7ad 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -13,16 +13,28 @@ import { nextCronOccurrence, matchesConditions, conditionRegistry, + computeHmacHex, type AutomationCallbackContext, type AutomationEvent, + type SlackAutomationEvent, + type SlackCallbackContext, type TriggerConfig, } from "@open-inspect/shared"; import { AutomationStore, toAutomationRun, + isDuplicateKeyError, type AutomationRow, type AutomationRunRow, } from "../db/automation-store"; +import { SlackChannelStore } from "../db/slack-channel-store"; +import { + buildSlackCompletionNotification, + buildSlackSkipNotification, + getSlackRunMetadata, + type SlackRunMetadata, + type SlackCompletionContext, +} from "./slack-completion"; import { UserStore } from "../db/user-store"; import { createRequestMetrics } from "../db/instrumented-d1"; import { generateId } from "../auth/crypto"; @@ -50,6 +62,14 @@ const AUTO_PAUSE_THRESHOLD = 3; /** Max runs to recover per sweep type per tick (backpressure). */ const RECOVERY_SWEEP_LIMIT = 50; +/** + * How long after a slack run's first trigger that thread replies keep continuing + * the same session (matches the interactive thread→session KV TTL of 24h). Steering + * does not create new runs, so this is measured from the root run's `created_at` and + * does not slide — a reply after the window forks a fresh run. + */ +const SLACK_THREAD_CONTINUITY_WINDOW_MS = 24 * 60 * 60 * 1000; + export class SchedulerDO extends DurableObject { private readonly log: Logger; @@ -416,13 +436,51 @@ export class SchedulerDO extends DurableObject { event.eventType ); break; + case "slack": + candidates = await new SlackChannelStore(this.env.DB).getSlackAutomationsForChannel( + event.channelId + ); + break; } let triggered = 0; let skipped = 0; + // Follow-ups routed into an already-active thread's session (slack steering). + let steered = 0; + // Surface at most one concurrency-skip ephemeral per event, even when + // several automations watch the same thread and all skip. + let concurrencySkipped = false; for (const automation of candidates) { - // 2. Evaluate conditions + const now = Date.now(); + + // Slack thread continuity — mirrors the interactive @mention path: any reply + // in a thread that already has a session (any run status, within the + // continuity window) continues that session, regardless of trigger + // conditions. A reply is a steer, not a new trigger: a natural reply ("also + // do X") won't contain the keyword that started the run, and a reply after + // the run finished should still land in the same session. Its reply posts + // back in-thread via the slack completion callback, exactly like an + // interactive follow-up. + if (event.source === "slack") { + const steerable = await store.getLatestSteerableRunForThread( + automation.id, + event.concurrencyKey, + now - SLACK_THREAD_CONTINUITY_WINDOW_MS + ); + if ( + steerable?.session_id && + (await this.steerSession(steerable.session_id, automation, event)) + ) { + steered++; + continue; + } + // No steerable session (outside the window, no session yet, or a rare + // enqueue error) → fall through. Like the @mention path's stale-session + // recovery, the reply is re-evaluated as a potential new trigger below. + } + + // Trigger conditions gate starting a NEW run. const config: TriggerConfig = automation.trigger_config ? JSON.parse(automation.trigger_config) : { conditions: [] }; @@ -430,16 +488,22 @@ export class SchedulerDO extends DurableObject { continue; } - // 3. Concurrency check (per-event-instance) + // Concurrency guard: never start a second run while one is active for this + // key. For slack this also covers the brief window before a run has created + // its session (no steerable row yet), so a reply racing the initial trigger + // gets the "already active" notice instead of a second session. const activeRun = await store.getActiveRunForKey(automation.id, event.concurrencyKey); if (activeRun) { + if (event.source === "slack") { + await this.recordSlackSkip(store, automation.id, event, "concurrent_run_active"); + concurrencySkipped = true; + } skipped++; continue; } - // 4. Create run (dedup via unique index on trigger_key) + // Create run (dedup via unique index on trigger_key) const runId = generateId(); - const now = Date.now(); try { await store.insertRun({ id: runId, @@ -454,6 +518,7 @@ export class SchedulerDO extends DurableObject { created_at: now, trigger_key: event.triggerKey, concurrency_key: event.concurrencyKey, + ...(event.source === "slack" ? slackRunMetadata(event) : {}), }); } catch (e) { if (isDuplicateKeyError(e)) { @@ -463,7 +528,7 @@ export class SchedulerDO extends DurableObject { throw e; } - // 5. Create session + send prompt (with event context prepended) + // Create session + send prompt (with event context prepended) try { const instructions = `${event.contextBlock}\n---\n\n${automation.instructions}`; const { sessionId } = await this.createSessionForAutomation(automation, runId); @@ -482,6 +547,10 @@ export class SchedulerDO extends DurableObject { } } + if (event.source === "slack" && concurrencySkipped) { + await this.notifySlackConcurrencySkip(event); + } + this.log.info("Event processed", { event: "scheduler.event_processed", source: event.source, @@ -489,10 +558,11 @@ export class SchedulerDO extends DurableObject { trigger_key: event.triggerKey, triggered, skipped, + steered, candidates: candidates.length, }); - return new Response(JSON.stringify({ triggered, skipped }), { + return new Response(JSON.stringify({ triggered, skipped, steered }), { headers: { "Content-Type": "application/json" }, }); } @@ -597,6 +667,8 @@ export class SchedulerDO extends DurableObject { automationId: string; runId: string; sessionId: string; + /** Optional for resilience to version skew; the bot falls back to a reaction clear. */ + messageId?: string; success: boolean; error?: string; }; @@ -648,11 +720,136 @@ export class SchedulerDO extends DurableObject { }); } + // Slack-triggered runs post the agent's result into the triggering message's + // thread and clear the `eyes` reaction when they finish. The scheduler owns + // this fan-out (not the session callback path) because the message + // coordinates live on the run row. Best-effort. + const slackMeta = getSlackRunMetadata(run); + if (slackMeta) { + const automation = await store.getById(body.automationId); + await this.notifySlackCompletion(run, slackMeta, { + sessionId: body.sessionId, + messageId: body.messageId ?? "", + success: body.success, + error: body.error, + repoFullName: automation ? `${automation.repo_owner}/${automation.repo_name}` : "", + model: automation?.model ?? "", + reasoningEffort: automation?.reasoning_effort ?? undefined, + }); + } + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, }); } + /** + * Persist a `skipped` run for a slack event dropped by the per-thread + * concurrency guard, carrying the same message coordinates a materialized run + * would. Best-effort observability; `recordSkippedRun` swallows the unexpected + * duplicate-key case internally. + */ + private async recordSlackSkip( + store: AutomationStore, + automationId: string, + event: SlackAutomationEvent, + reason: string + ): Promise { + await store.recordSkippedRun({ + id: generateId(), + automationId, + skipReason: reason, + concurrencyKey: event.concurrencyKey, + runMetadata: slackRunMetadata(event), + }); + } + + /** + * Tell the slack-bot to post a slack-triggered run's result into the triggering + * message's thread and clear the `eyes` reaction, via its + * `/callbacks/automation-complete` endpoint. Signs the body with + * `INTERNAL_CALLBACK_SECRET` (in-body HMAC, matching the bot's other callbacks). + * No-ops when the run has no triggering message, when `SLACK_BOT` is unbound, or + * when the secret is unset — all best-effort. + */ + private async notifySlackCompletion( + run: AutomationRunRow, + meta: SlackRunMetadata, + ctx: SlackCompletionContext + ): Promise { + const binding = this.env.SLACK_BOT; + const secret = this.env.INTERNAL_CALLBACK_SECRET; + if (!binding || !secret) return; + + const body = buildSlackCompletionNotification(meta, ctx); + if (!body) return; + + try { + const signature = await computeHmacHex(JSON.stringify(body), secret); + const response = await binding.fetch("https://internal/callbacks/automation-complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, signature }), + }); + if (!response.ok) { + this.log.warn("Slack completion callback failed", { + event: "scheduler.slack_complete_failed", + automation_id: run.automation_id, + run_id: run.id, + http_status: response.status, + }); + } + } catch (e) { + this.log.warn("Slack completion callback errored", { + event: "scheduler.slack_complete_failed", + automation_id: run.automation_id, + run_id: run.id, + error: e instanceof Error ? e : new Error(String(e)), + }); + } + } + + /** + * Post a best-effort ephemeral "a run is already active for this thread" + * notice to the message author when a slack event is dropped by the + * per-thread concurrency guard. No-ops without a binding/secret/actor. + */ + private async notifySlackConcurrencySkip(event: SlackAutomationEvent): Promise { + const binding = this.env.SLACK_BOT; + const secret = this.env.INTERNAL_CALLBACK_SECRET; + if (!binding || !secret) return; + + const body = buildSlackSkipNotification({ + channelId: event.channelId, + actorUserId: event.actorUserId, + threadTs: event.threadTs, + ts: event.ts, + }); + if (!body) return; + + try { + const signature = await computeHmacHex(JSON.stringify(body), secret); + const response = await binding.fetch("https://internal/callbacks/automation-skip", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, signature }), + }); + if (!response.ok) { + this.log.warn("Slack skip callback failed", { + event: "scheduler.slack_skip_failed", + channel: event.channelId, + http_status: response.status, + }); + } + } catch (e) { + this.log.warn("Slack skip callback errored", { + event: "scheduler.slack_skip_failed", + channel: event.channelId, + error: e instanceof Error ? e : new Error(String(e)), + }); + } + } + // ─── Health check ──────────────────────────────────────────────────────── private async handleHealth(): Promise { @@ -739,9 +936,6 @@ export class SchedulerDO extends DurableObject { runId: string, instructionsOverride?: string ): Promise { - const doId = this.env.SESSION.idFromName(sessionId); - const stub = this.env.SESSION.get(doId); - const callbackContext: AutomationCallbackContext = { source: "automation", automationId: automation.id, @@ -749,15 +943,81 @@ export class SchedulerDO extends DurableObject { automationName: automation.name, }; + await this.enqueueSessionPrompt(sessionId, { + content: instructionsOverride ?? automation.instructions, + authorId: automation.created_by, + source: "automation", + callbackContext, + }); + } + + /** + * Route a follow-up slack message in a thread to its run's existing session as + * the next turn — whether that run is still in flight, completed, or failed — + * so every reply in the thread continues the same session, like the interactive + * @mention path. If the session has gone idle the prompt re-spawns/restores it + * in the background; its reply posts back in-thread via the slack completion + * callback (source "slack"), exactly like an interactive follow-up. Returns + * false when the enqueue fails, so the caller can fall through to the trigger + * path (stale-session recovery). + */ + private async steerSession( + sessionId: string, + automation: AutomationRow, + event: SlackAutomationEvent + ): Promise { + const callbackContext: SlackCallbackContext = { + source: "slack", + channel: event.channelId, + // Post in the existing thread; for a reply, threadTs is the thread root. + threadTs: event.threadTs ?? event.ts, + // React on (and later clear) the follow-up message itself. + reactionMessageTs: event.ts, + repoFullName: `${automation.repo_owner}/${automation.repo_name}`, + model: automation.model, + reasoningEffort: automation.reasoning_effort ?? undefined, + }; + + try { + await this.enqueueSessionPrompt(sessionId, { + content: event.text, + authorId: `slack:${event.actorUserId}`, + source: "slack", + callbackContext, + }); + this.log.info("Steered thread session with slack follow-up", { + event: "scheduler.slack_steer", + automation_id: automation.id, + session_id: sessionId, + channel: event.channelId, + }); + return true; + } catch (e) { + this.log.warn("Failed to steer thread session; falling through to trigger path", { + event: "scheduler.slack_steer_failed", + automation_id: automation.id, + session_id: sessionId, + error: e instanceof Error ? e : new Error(String(e)), + }); + return false; + } + } + + /** Enqueue a prompt onto a session's queue via its DO `/internal/prompt` route. */ + private async enqueueSessionPrompt( + sessionId: string, + body: { + content: string; + authorId: string; + source: string; + callbackContext: AutomationCallbackContext | SlackCallbackContext; + } + ): Promise { + const stub = this.env.SESSION.get(this.env.SESSION.idFromName(sessionId)); const promptResponse = await stub.fetch("http://internal/internal/prompt", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: instructionsOverride ?? automation.instructions, - authorId: automation.created_by, - source: "automation", - callbackContext, - }), + body: JSON.stringify(body), }); if (!promptResponse.ok) { @@ -766,7 +1026,13 @@ export class SchedulerDO extends DurableObject { } } -function isDuplicateKeyError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("UNIQUE constraint failed"); +/** Serialized slack run metadata for a slack-origin event — shared by insertRun and recordSkippedRun. */ +function slackRunMetadata( + event: SlackAutomationEvent +): Pick { + const metadata: SlackRunMetadata = { + channel: event.channelId, + messageTs: event.ts, + }; + return { trigger_run_metadata: JSON.stringify(metadata) }; } diff --git a/packages/control-plane/src/scheduler/slack-completion.test.ts b/packages/control-plane/src/scheduler/slack-completion.test.ts new file mode 100644 index 000000000..0978ed9fe --- /dev/null +++ b/packages/control-plane/src/scheduler/slack-completion.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { + buildSlackCompletionNotification, + buildSlackSkipNotification, + type SlackRunMetadata, + type SlackCompletionContext, +} from "./slack-completion"; + +function meta(overrides?: Partial): SlackRunMetadata { + return { + channel: "C1", + messageTs: "1700000000.000100", + ...overrides, + }; +} + +function ctx(overrides?: Partial): SlackCompletionContext { + return { + sessionId: "sess-1", + messageId: "msg-1", + success: true, + repoFullName: "acme/web", + model: "anthropic/claude-sonnet-4-6", + ...overrides, + }; +} + +describe("buildSlackCompletionNotification", () => { + it("returns null for a non-slack run (no metadata)", () => { + expect(buildSlackCompletionNotification(null, ctx())).toBeNull(); + }); + + it("returns null when there is no triggering message to anchor to", () => { + expect(buildSlackCompletionNotification(meta({ messageTs: "" }), ctx())).toBeNull(); + }); + + it("carries the run result plus the triggering message coordinates", () => { + expect(buildSlackCompletionNotification(meta(), ctx({ error: undefined }))).toEqual({ + channel: "C1", + reactionMessageTs: "1700000000.000100", + sessionId: "sess-1", + messageId: "msg-1", + success: true, + repoFullName: "acme/web", + model: "anthropic/claude-sonnet-4-6", + }); + }); + + it("includes the failure detail when the run errored", () => { + const result = buildSlackCompletionNotification(meta(), ctx({ success: false, error: "boom" })); + expect(result).toMatchObject({ success: false, error: "boom" }); + }); +}); + +describe("buildSlackSkipNotification", () => { + it("returns null when the actor is unknown", () => { + expect(buildSlackSkipNotification({ channelId: "C1", ts: "1700000000.000100" })).toBeNull(); + }); + + it("targets the actor and anchors to the thread ts when present", () => { + expect( + buildSlackSkipNotification({ + channelId: "C1", + actorUserId: "U9", + threadTs: "1699999999.000001", + ts: "1700000000.000100", + }) + ).toEqual({ channel: "C1", user: "U9", threadTs: "1699999999.000001" }); + }); + + it("falls back to the message ts as the thread anchor", () => { + expect( + buildSlackSkipNotification({ channelId: "C1", actorUserId: "U9", ts: "1700000000.000100" }) + ).toEqual({ channel: "C1", user: "U9", threadTs: "1700000000.000100" }); + }); +}); diff --git a/packages/control-plane/src/scheduler/slack-completion.ts b/packages/control-plane/src/scheduler/slack-completion.ts new file mode 100644 index 000000000..087ed9e2f --- /dev/null +++ b/packages/control-plane/src/scheduler/slack-completion.ts @@ -0,0 +1,93 @@ +/** + * Pure builders for the scheduler → slack-bot notifications (run completion and + * concurrency-skip). Kept free of Durable Object state so they can be unit + * tested directly; the SchedulerDO method signs the result (HMAC over the JSON + * body) and POSTs it via the optional `SLACK_BOT` Fetcher. + * + * Returning `null` from either builder is the explicit signal to skip the bot + * call — for a non-slack run, a slack run with no triggering message, or a skip + * with no actor to address. + */ + +import type { AutomationRunRow } from "../db/automation-store"; + +/** + * Slack run coordinates captured at trigger time, serialized into the run's + * generic `trigger_run_metadata` column (slack-origin runs only). `messageTs` is + * the triggering message, used to clear the `eyes` reaction on completion. + */ +export interface SlackRunMetadata { + channel: string; + messageTs: string; +} + +/** + * Parse a run row's `trigger_run_metadata` as slack coordinates — null when + * absent (a non-slack run) or malformed. Completion is best-effort, so a parse + * failure silently no-ops rather than throwing into the dispatch path. + */ +export function getSlackRunMetadata( + row: Pick +): SlackRunMetadata | null { + if (!row.trigger_run_metadata) return null; + try { + return JSON.parse(row.trigger_run_metadata) as SlackRunMetadata; + } catch { + return null; + } +} + +/** + * Run result fields the bot needs to post the agent's final response into the + * triggering message's thread. The SchedulerDO sources `sessionId`/`messageId` + * (and success/error) from the run-complete callback and repo/model from the + * automation row. + */ +export interface SlackCompletionContext { + sessionId: string; + messageId: string; + success: boolean; + error?: string; + repoFullName: string; + model: string; + reasoningEffort?: string; +} + +/** + * The scheduler → bot payload for a completed slack-triggered run. Carries the + * triggering message (the thread anchor and the `eyes` reaction target) plus the + * run result the bot uses to fetch and post the agent's response in-thread. + */ +export interface SlackCompletionNotification extends SlackCompletionContext { + channel: string; + /** The triggering message: the thread anchor and the `eyes` reaction target. */ + reactionMessageTs: string; +} + +export function buildSlackCompletionNotification( + meta: SlackRunMetadata | null, + ctx: SlackCompletionContext +): SlackCompletionNotification | null { + if (!meta?.messageTs) return null; + return { channel: meta.channel, reactionMessageTs: meta.messageTs, ...ctx }; +} + +export interface SlackSkipNotification { + channel: string; + user: string; + threadTs: string; +} + +export function buildSlackSkipNotification(params: { + channelId: string; + actorUserId?: string; + threadTs?: string; + ts: string; +}): SlackSkipNotification | null { + if (!params.actorUserId) return null; + return { + channel: params.channelId, + user: params.actorUserId, + threadTs: params.threadTs ?? params.ts, + }; +} diff --git a/packages/control-plane/src/session/callback-notification-service.test.ts b/packages/control-plane/src/session/callback-notification-service.test.ts index a8b12157b..105df93ac 100644 --- a/packages/control-plane/src/session/callback-notification-service.test.ts +++ b/packages/control-plane/src/session/callback-notification-service.test.ts @@ -271,6 +271,31 @@ describe("CallbackNotificationService", () => { expect(body.signature).toEqual(expect.any(String)); }); + it("skips automation source — the SchedulerDO has no tool-call consumer", async () => { + vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ + callback_context: JSON.stringify({ automationId: "a1", runId: "r1" }), + source: "automation", + }); + + await harness.service.notifyToolCall("msg-1", { + type: "tool_call", + tool: "glob", + callId: "call-1", + }); + + // No forward at all — previously this 404'd against the SchedulerDO. + const slackFetch = (harness.slackBot as unknown as { fetch: ReturnType }).fetch; + expect(slackFetch).not.toHaveBeenCalled(); + expect(harness.log.debug).toHaveBeenCalledWith( + "callback.tool_call", + expect.objectContaining({ + source: "automation", + outcome: "skipped", + skip_reason: "automation_no_consumer", + }) + ); + }); + it("skips when no callback context", async () => { vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue(null); diff --git a/packages/control-plane/src/session/callback-notification-service.ts b/packages/control-plane/src/session/callback-notification-service.ts index 8608eb36c..17f8f01d2 100644 --- a/packages/control-plane/src/session/callback-notification-service.ts +++ b/packages/control-plane/src/session/callback-notification-service.ts @@ -115,7 +115,7 @@ export class CallbackNotificationService { // Route automation callbacks to SchedulerDO (different URL + payload) if (context.source === "automation") { - return this.notifyAutomationComplete(context, success, error); + return this.notifyAutomationComplete(context, success, error, messageId); } if (!this.env.INTERNAL_CALLBACK_SECRET) { @@ -201,7 +201,8 @@ export class CallbackNotificationService { private async notifyAutomationComplete( context: { automationId: string; runId: string; automationName: string }, success: boolean, - error?: string + error: string | undefined, + messageId: string ): Promise { const binding = this.env.SCHEDULER_CALLBACK; if (!binding) { @@ -213,6 +214,8 @@ export class CallbackNotificationService { automationId: context.automationId, runId: context.runId, sessionId: this.getSessionId(), + // The message whose agent response the bot fetches to post the run result. + messageId, success, error, automationName: context.automationName, @@ -308,6 +311,21 @@ export class CallbackNotificationService { } const source = message.source ?? null; + + // Automation runs have no tool-call progress consumer: getBinding routes them + // to the SchedulerDO, which only implements /internal/run-complete — every + // /callbacks/tool_call forward 404s. Skip rather than spam best-effort calls. + if (source === "automation") { + this.log.debug("callback.tool_call", { + message_id: messageId, + source, + tool, + outcome: "skipped", + skip_reason: "automation_no_consumer", + }); + return; + } + const binding = this.getBinding(source); if (!binding) { this.log.debug("callback.tool_call", { diff --git a/packages/control-plane/src/webhooks/automation-event.ts b/packages/control-plane/src/webhooks/automation-event.ts new file mode 100644 index 000000000..47216052a --- /dev/null +++ b/packages/control-plane/src/webhooks/automation-event.ts @@ -0,0 +1,95 @@ +/** + * Shared handler for the internal "normalized automation event" endpoints + * (e.g. `/internal/github-event`, `/internal/slack-event`). Each bot + * pre-normalizes its source's events and POSTs them here; this layer + * authenticates, validates the event envelope, and forwards to the singleton + * SchedulerDO for matching and dispatch. The only per-source difference is the + * required-field check, supplied via `validate`. + */ + +import type { AutomationEventSource } from "@open-inspect/shared"; +import { verifyInternalToken } from "../auth/internal"; +import type { Route, RequestContext } from "../routes/shared"; +import { parsePattern, json, error } from "../routes/shared"; +import type { Env } from "../types"; + +export function createAutomationEventRoute(opts: { + path: string; + source: AutomationEventSource; + /** Validate source-specific required fields. Returns an error message, or null when valid. */ + validate: (event: Record) => string | null; +}): Route { + async function handler( + request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext + ): Promise { + // 0. Authenticate — fail closed if secret is unconfigured or token is invalid. + // The router-level gate already enforces this; repeat it here for + // defense-in-depth. + if (!env.INTERNAL_CALLBACK_SECRET) { + return error("Internal authentication not configured", 500); + } + const isValid = await verifyInternalToken( + request.headers.get("Authorization"), + env.INTERNAL_CALLBACK_SECRET + ); + if (!isValid) { + return error("Unauthorized", 401); + } + + // 1. Parse body + let body: unknown; + try { + body = await request.json(); + } catch { + return error("Invalid JSON", 400); + } + + // 2. Validate envelope — source, then source-specific fields, then the + // common dispatch keys every event must carry. + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return error("Invalid event: body must be a JSON object", 400); + } + const event = body as Record; + if (event.source !== opts.source) { + return error(`Invalid event: source must be '${opts.source}'`, 400); + } + const fieldError = opts.validate(event); + if (fieldError) { + return error(fieldError, 400); + } + if (!event.eventType || !event.triggerKey || !event.concurrencyKey) { + return error("Invalid event: eventType, triggerKey, and concurrencyKey are required", 400); + } + + // 3. Forward to SchedulerDO + if (!env.SCHEDULER) { + return error("Scheduler not configured", 503); + } + const stub = env.SCHEDULER.get(env.SCHEDULER.idFromName("global-scheduler")); + + let response: Response; + try { + response = await stub.fetch("http://internal/internal/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }); + } catch { + return json({ ok: false, error: "Failed to reach scheduler" }, 502); + } + + let result: { triggered: number; skipped: number; steered?: number }; + try { + result = await response.json<{ triggered: number; skipped: number; steered?: number }>(); + } catch { + return json({ ok: false, error: "Invalid response from scheduler" }, 502); + } + + return json({ ok: true, ...result }, response.status); + } + + return { method: "POST", pattern: parsePattern(opts.path), handler }; +} diff --git a/packages/control-plane/src/webhooks/github.ts b/packages/control-plane/src/webhooks/github.ts index c648515e6..f840ec1fc 100644 --- a/packages/control-plane/src/webhooks/github.ts +++ b/packages/control-plane/src/webhooks/github.ts @@ -4,81 +4,13 @@ * them to the SchedulerDO for automation matching and session dispatch. */ -import type { GitHubAutomationEvent } from "@open-inspect/shared"; -import { verifyInternalToken } from "../auth/internal"; -import type { Route, RequestContext } from "../routes/shared"; -import { parsePattern, json, error } from "../routes/shared"; -import type { Env } from "../types"; - -async function handleGitHubAutomationEvent( - request: Request, - env: Env, - _match: RegExpMatchArray, - _ctx: RequestContext -): Promise { - // 0. Authenticate — fail closed if secret is unconfigured or token is invalid - if (!env.INTERNAL_CALLBACK_SECRET) { - return error("Internal authentication not configured", 500); - } - const isValid = await verifyInternalToken( - request.headers.get("Authorization"), - env.INTERNAL_CALLBACK_SECRET - ); - if (!isValid) { - return error("Unauthorized", 401); - } - - // 1. Parse body - let body: unknown; - try { - body = await request.json(); - } catch { - return error("Invalid JSON", 400); - } - - // 2. Validate required fields - const event = body as Partial; - if (event.source !== "github") { - return error("Invalid event: source must be 'github'", 400); - } - if (!event.repoOwner || !event.repoName) { - return error("Invalid event: repoOwner and repoName are required", 400); - } - if (!event.eventType || !event.triggerKey || !event.concurrencyKey) { - return error("Invalid event: eventType, triggerKey, and concurrencyKey are required", 400); - } - - // 3. Forward to SchedulerDO - if (!env.SCHEDULER) { - return error("Scheduler not configured", 503); - } - - const doId = env.SCHEDULER.idFromName("global-scheduler"); - const stub = env.SCHEDULER.get(doId); - - let response: Response; - try { - response = await stub.fetch("http://internal/internal/event", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(event), - }); - } catch (_e) { - return json({ ok: false, error: "Failed to reach scheduler" }, 502); - } - - let result: { triggered: number; skipped: number }; - try { - result = await response.json<{ triggered: number; skipped: number }>(); - } catch { - return json({ ok: false, error: "Invalid response from scheduler" }, 502); - } - - return json({ ok: true, ...result }, response.status); -} - -export const githubAutomationEventRoute: Route = { - method: "POST", - pattern: parsePattern("/internal/github-event"), - handler: handleGitHubAutomationEvent, -}; +import { createAutomationEventRoute } from "./automation-event"; + +export const githubAutomationEventRoute = createAutomationEventRoute({ + path: "/internal/github-event", + source: "github", + validate: (event) => + !event.repoOwner || !event.repoName + ? "Invalid event: repoOwner and repoName are required" + : null, +}); diff --git a/packages/control-plane/src/webhooks/index.ts b/packages/control-plane/src/webhooks/index.ts index 073d7c697..102ab316b 100644 --- a/packages/control-plane/src/webhooks/index.ts +++ b/packages/control-plane/src/webhooks/index.ts @@ -6,9 +6,11 @@ import type { Route } from "../routes/shared"; import { sentryWebhookRoute } from "./sentry"; import { automationWebhookRoute } from "./automation-webhook"; import { githubAutomationEventRoute } from "./github"; +import { slackAutomationEventRoute } from "./slack"; export const webhookRoutes: Route[] = [ sentryWebhookRoute, automationWebhookRoute, githubAutomationEventRoute, + slackAutomationEventRoute, ]; diff --git a/packages/control-plane/src/webhooks/slack.ts b/packages/control-plane/src/webhooks/slack.ts new file mode 100644 index 000000000..c4b47538f --- /dev/null +++ b/packages/control-plane/src/webhooks/slack.ts @@ -0,0 +1,19 @@ +/** + * Slack automation event webhook route — internal endpoint that receives + * pre-normalized SlackAutomationEvents from the slack-bot and proxies them + * to the SchedulerDO for automation matching and session dispatch. + * + * The slack-bot is responsible for ingress filtering (watched channels, + * mention suppression) and normalization; this endpoint only authenticates, + * validates the event envelope, and forwards. Channel-keyed candidate + * selection, condition evaluation, and dedup all happen in the scheduler. + */ + +import { createAutomationEventRoute } from "./automation-event"; + +export const slackAutomationEventRoute = createAutomationEventRoute({ + path: "/internal/slack-event", + source: "slack", + validate: (event) => + !event.channelId || !event.ts ? "Invalid event: channelId and ts are required" : null, +}); diff --git a/packages/control-plane/test/integration/automation-store.test.ts b/packages/control-plane/test/integration/automation-store.test.ts index 02321b9cf..cdd6f82be 100644 --- a/packages/control-plane/test/integration/automation-store.test.ts +++ b/packages/control-plane/test/integration/automation-store.test.ts @@ -767,6 +767,100 @@ describe("AutomationStore (D1 integration)", () => { expect(active!.id).toBe("run-ck3"); }); + it("getLatestSteerableRunForThread finds a completed run with a session (any status)", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer1" })); + + await store.insertRun( + makeRun("auto-steer1", { + id: "run-steer1", + status: "completed", + session_id: "sess-1", + concurrency_key: "slack:C1:t1", + completed_at: Date.now(), + }) + ); + + const run = await store.getLatestSteerableRunForThread("auto-steer1", "slack:C1:t1", 0); + expect(run).not.toBeNull(); + expect(run!.id).toBe("run-steer1"); + }); + + it("getLatestSteerableRunForThread excludes runs without a session", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer2" })); + + // A skip/starting row (session_id null) must not shadow the thread session. + await store.insertRun( + makeRun("auto-steer2", { + id: "run-steer2-skip", + status: "skipped", + session_id: null, + concurrency_key: "slack:C1:t2", + }) + ); + + const run = await store.getLatestSteerableRunForThread("auto-steer2", "slack:C1:t2", 0); + expect(run).toBeNull(); + }); + + it("getLatestSteerableRunForThread excludes runs created before the window", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer3" })); + + await store.insertRun( + makeRun("auto-steer3", { + id: "run-steer3-old", + status: "completed", + session_id: "sess-old", + concurrency_key: "slack:C1:t3", + created_at: Date.now() - 48 * 60 * 60 * 1000, + }) + ); + + const sinceMs = Date.now() - 24 * 60 * 60 * 1000; + const run = await store.getLatestSteerableRunForThread("auto-steer3", "slack:C1:t3", sinceMs); + expect(run).toBeNull(); + }); + + it("getLatestSteerableRunForThread returns the most recent session run for the key", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer4" })); + + // Distinct scheduled_at: automation_runs has a UNIQUE(automation_id, + // scheduled_at), and these two share an automation. + await store.insertRun( + makeRun("auto-steer4", { + id: "run-steer4-older", + status: "completed", + session_id: "sess-older", + concurrency_key: "slack:C1:t4", + scheduled_at: Date.now() - 60000, + created_at: Date.now() - 60000, + }) + ); + await store.insertRun( + makeRun("auto-steer4", { + id: "run-steer4-newer", + status: "running", + session_id: "sess-newer", + concurrency_key: "slack:C1:t4", + scheduled_at: Date.now(), + created_at: Date.now(), + }) + ); + + const run = await store.getLatestSteerableRunForThread("auto-steer4", "slack:C1:t4", 0); + expect(run!.id).toBe("run-steer4-newer"); + }); + + it("getLatestSteerableRunForThread returns null for a null concurrency key", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer5" })); + const run = await store.getLatestSteerableRunForThread("auto-steer5", null, 0); + expect(run).toBeNull(); + }); + it("trigger_key unique index prevents duplicate runs", async () => { const store = new AutomationStore(env.DB); await store.create(makeAutomation({ id: "auto-dedup1" })); @@ -803,4 +897,67 @@ describe("AutomationStore (D1 integration)", () => { expect(runs.total).toBe(2); }); }); + + // ─── Slack triggers ────────────────────────────────────────────────── + + describe("slack triggers", () => { + const makeSlackAutomation = (overrides?: Partial) => + makeAutomation({ + trigger_type: "slack_event", + event_type: "message.posted", + ...overrides, + }); + + it("recordSkippedRun persists a skip with run metadata and no trigger_key", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s9" })); + + await store.recordSkippedRun({ + id: "r-skip", + automationId: "auto-s9", + skipReason: "concurrent_run_active", + concurrencyKey: "slack:C1:111", + runMetadata: { + trigger_run_metadata: JSON.stringify({ + channel: "C1", + messageTs: "222", + }), + }, + }); + + const run = await store.getRunById("auto-s9", "r-skip"); + expect(run).not.toBeNull(); + expect(run!.status).toBe("skipped"); + expect(run!.skip_reason).toBe("concurrent_run_active"); + expect(JSON.parse(run!.trigger_run_metadata!)).toMatchObject({ + channel: "C1", + messageTs: "222", + }); + expect(run!.trigger_key).toBeNull(); + }); + + it("insertRun persists trigger_run_metadata on a materialized run", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s10" })); + + await store.insertRun( + makeRun("auto-s10", { + id: "r-mat", + status: "starting", + trigger_key: "slack:msg:C1:222", + concurrency_key: "slack:C1:111", + trigger_run_metadata: JSON.stringify({ + channel: "C1", + messageTs: "222", + }), + }) + ); + + const run = await store.getRunById("auto-s10", "r-mat"); + expect(JSON.parse(run!.trigger_run_metadata!)).toEqual({ + channel: "C1", + messageTs: "222", + }); + }); + }); }); diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts new file mode 100644 index 000000000..e89abc0d3 --- /dev/null +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { SELF, env } from "cloudflare:test"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; +import { generateInternalToken } from "../../src/auth/internal"; +import { cleanD1Tables } from "./cleanup"; +import type { TriggerConfig } from "@open-inspect/shared"; + +async function authHeaders(): Promise> { + const token = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET!); + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +function makeSlackAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-${Math.random().toString(36).slice(2, 8)}`, + name: "Slack triage", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Investigate and fix", + trigger_type: "slack_event", + schedule_cron: null, + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: "message.posted", + trigger_config: null, + trigger_auth_data: null, + ...overrides, + }; +} + +function createBody(overrides: Record) { + return { + name: "Slack triage", + instructions: "Investigate the report", + repoOwner: "acme", + repoName: "web-app", + triggerType: "slack_event", + ...overrides, + }; +} + +async function postAutomation(body: Record): Promise { + return SELF.fetch("https://test.local/automations", { + method: "POST", + headers: await authHeaders(), + body: JSON.stringify(body), + }); +} + +describe("POST /automations — slack_event validation (integration)", () => { + beforeEach(cleanD1Tables); + + it("rejects a slack_event without a slack_channel condition (400)", async () => { + const res = await postAutomation(createBody({ triggerConfig: { conditions: [] } })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("slack_channel"); + }); + + it("accepts a slack_event with only a slack_channel condition (no text_match)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + }, + }) + ); + // text_match is optional; the channel-only config passes validation and only + // later fails at repo resolution in the test env. + expect(res.status).not.toBe(400); + }); + + it("rejects a slack_channel value that is not an array of strings (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: "C1" }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("slack_channel"); + }); + + it("rejects an invalid regex text_match at save time (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "regex", value: { pattern: "(" } }, + ], + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Invalid regex"); + }); + + it("rejects a disallowed regex flag at save time (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "regex", value: { pattern: "deploy", flags: "g" } }, + ], + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Unsupported regex flag"); + }); + + it("accepts slack_event past the trigger-type allowlist (no unknown-trigger 400)", async () => { + // Valid scoping passes validation; the request then fails later at repository + // resolution (no GitHub App in the test env). The point is that slack_event is + // NOT rejected as an unknown trigger type before reaching that stage. + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }) + ); + expect(await res.text()).not.toContain("triggerType must be one of"); + }); +}); + +describe("PUT /automations/:id — slack_event validation (integration)", () => { + beforeEach(cleanD1Tables); + + async function putAutomation(id: string, body: Record): Promise { + return SELF.fetch(`https://test.local/automations/${id}`, { + method: "PUT", + headers: await authHeaders(), + body: JSON.stringify(body), + }); + } + + it("rejects a non-array conditions on update with 400, not 500", async () => { + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation(); + await store.create(auto); + + const res = await putAutomation(auto.id, { triggerConfig: { conditions: "not-an-array" } }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("must be an array"); + }); + + it("atomically updates conditions and re-syncs the watched-channel index", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + const auto = makeSlackAutomation(); + await store.create(auto); + await channels.setSlackChannels(auto.id, ["C1"]); + + const res = await putAutomation(auto.id, { + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C2", "C3"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }); + expect(res.status).toBe(200); + + const watched = await channels.getWatchedSlackChannels(); + expect([...watched].sort()).toEqual(["C2", "C3"]); + }); + + it("replaces trigger_config wholesale on update", async () => { + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation({ + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "old" } }, + ], + }), + }); + await store.create(auto); + + // A PUT replaces the whole blob. The client owns the full trigger_config. + const res = await putAutomation(auto.id, { + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C9"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }); + expect(res.status).toBe(200); + + const config = JSON.parse((await store.getById(auto.id))!.trigger_config!) as TriggerConfig; + expect(config.conditions.find((c) => c.type === "slack_channel")?.value).toEqual(["C9"]); + }); + + it("rejects clearing trigger_config to null on a slack_event automation (400)", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + const auto = makeSlackAutomation({ + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), + }); + await store.create(auto); + await channels.setSlackChannels(auto.id, ["C1"]); + + const res = await putAutomation(auto.id, { triggerConfig: null }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Cannot clear triggerConfig"); + + // The rejected request left both the config and the derived index intact — + // an enabled-but-untriggerable state never materializes. + expect((await store.getById(auto.id))!.trigger_config).not.toBeNull(); + expect([...(await channels.getWatchedSlackChannels())]).toEqual(["C1"]); + }); +}); + +describe("GET /integration-settings/slack/watched-channels (integration)", () => { + beforeEach(cleanD1Tables); + + async function getWatchedChannels(): Promise { + return SELF.fetch("https://test.local/integration-settings/slack/watched-channels", { + method: "GET", + headers: await authHeaders(), + }); + } + + it("returns 401 without an internal token", async () => { + const res = await SELF.fetch("https://test.local/integration-settings/slack/watched-channels", { + method: "GET", + }); + expect(res.status).toBe(401); + }); + + it("returns the distinct watched channels for enabled slack automations", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + const a = makeSlackAutomation(); + const b = makeSlackAutomation(); + await store.create(a); + await store.create(b); + await channels.setSlackChannels(a.id, ["C1", "C2"]); + await channels.setSlackChannels(b.id, ["C2", "C3"]); + + const res = await getWatchedChannels(); + expect(res.status).toBe(200); + const body = await res.json<{ channels: string[] }>(); + expect([...body.channels].sort()).toEqual(["C1", "C2", "C3"]); + }); + + it("excludes channels of disabled automations and returns an empty list when none", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + const disabled = makeSlackAutomation({ enabled: 0 }); + await store.create(disabled); + await channels.setSlackChannels(disabled.id, ["C9"]); + + const res = await getWatchedChannels(); + expect(res.status).toBe(200); + const body = await res.json<{ channels: string[] }>(); + expect(body.channels).toEqual([]); + }); +}); + +describe("GET /integration-settings/slack/channels (integration)", () => { + beforeEach(cleanD1Tables); + + async function getSlackChannels(auth = true): Promise { + return SELF.fetch("https://test.local/integration-settings/slack/channels", { + method: "GET", + headers: auth ? await authHeaders() : undefined, + }); + } + + it("returns 401 without an internal token", async () => { + const res = await getSlackChannels(false); + expect(res.status).toBe(401); + }); + + it("degrades to an empty channel list (never a 500) when listing is unavailable", async () => { + // The integration env has no usable bot token, so the route returns an empty + // list with an error — `not_configured` when unset, or a Slack error such as + // `invalid_auth` when a placeholder token is present — rather than throwing. + const res = await getSlackChannels(); + expect(res.status).toBe(200); + const body = await res.json<{ channels: string[]; error?: string }>(); + expect(body.channels).toEqual([]); + expect(typeof body.error).toBe("string"); + expect(body.error).toBeTruthy(); + }); +}); diff --git a/packages/control-plane/test/integration/cleanup.ts b/packages/control-plane/test/integration/cleanup.ts index 5b3eff2a5..af705d610 100644 --- a/packages/control-plane/test/integration/cleanup.ts +++ b/packages/control-plane/test/integration/cleanup.ts @@ -6,6 +6,6 @@ import { env } from "cloudflare:test"; */ export async function cleanD1Tables(): Promise { await env.DB.exec( - "DELETE FROM automation_runs; DELETE FROM automations; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM repo_images; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users;" + "DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automations; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM repo_images; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users;" ); } diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts new file mode 100644 index 000000000..dbd818dfb --- /dev/null +++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { env } from "cloudflare:test"; +import { + AutomationStore, + type AutomationRow, + type AutomationRunRow, +} from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; +import type { SlackAutomationEvent } from "@open-inspect/shared"; +import { cleanD1Tables } from "./cleanup"; + +function getSchedulerStub() { + const id = env.SCHEDULER.idFromName("global-scheduler"); + return env.SCHEDULER.get(id); +} + +function makeAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-${Math.random().toString(36).slice(2, 8)}`, + name: "Slack triage", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Investigate and fix", + trigger_type: "slack_event", + schedule_cron: null, + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: "message.posted", + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), + trigger_auth_data: null, + ...overrides, + }; +} + +function makeRun(automationId: string, overrides?: Partial): AutomationRunRow { + const now = Date.now(); + return { + id: `run-${Math.random().toString(36).slice(2, 8)}`, + automation_id: automationId, + session_id: null, + status: "starting", + skip_reason: null, + failure_reason: null, + scheduled_at: now, + started_at: null, + completed_at: null, + created_at: now, + trigger_key: null, + concurrency_key: null, + ...overrides, + }; +} + +function makeSlackEvent(overrides?: Partial): SlackAutomationEvent { + const ts = `${Date.now()}.${Math.floor(Math.random() * 1e6)}`; + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:C1:${ts}`, + concurrencyKey: `slack:C1:${ts}`, + contextBlock: "A message was posted in Slack channel #ops by user U1.", + meta: {}, + channelId: "C1", + ts, + actorUserId: "U1", + text: "please deploy the api", + ...overrides, + }; +} + +async function sendEvent(event: SlackAutomationEvent): Promise { + const opts = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }; + try { + return await getSchedulerStub().fetch("http://internal/internal/event", opts); + } catch (e) { + if (e instanceof Error && e.message.includes("invalidating this Durable Object")) { + return getSchedulerStub().fetch("http://internal/internal/event", opts); + } + throw e; + } +} + +/** Create a watched slack_event automation (channel C1, text_match contains "deploy"). */ +async function seedSlackAutomation( + store: AutomationStore, + overrides?: Partial +): Promise { + const id = `auto-slack-${Math.random().toString(36).slice(2, 8)}`; + await store.create(makeAutomation({ id, ...overrides })); + await new SlackChannelStore(env.DB).setSlackChannels(id, ["C1"]); + return id; +} + +describe("SchedulerDO /internal/event — slack (integration)", () => { + beforeEach(cleanD1Tables); + + it("triggers a matching slack automation and records thread coordinates", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const event = makeSlackEvent({ text: "please deploy the api" }); + const res = await sendEvent(event); + expect(res.status).toBe(200); + + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.total).toBeGreaterThanOrEqual(1); + const run = runs.runs.find((r) => r.trigger_key === event.triggerKey)!; + expect(run).toBeDefined(); + const metadata = JSON.parse(run.trigger_run_metadata!); + expect(metadata.channel).toBe("C1"); + expect(metadata.messageTs).toBe(event.ts); + }); + + it("does not trigger when the text_match condition fails", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const res = await sendEvent(makeSlackEvent({ text: "good morning team" })); + const body = await res.json<{ triggered: number; skipped: number }>(); + expect(body.triggered).toBe(0); + expect(body.skipped).toBe(0); + + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.total).toBe(0); + }); + + it("does not trigger when the channel is not watched (no candidate)", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + // Event in an unwatched channel — the join table returns no candidate. + const res = await sendEvent( + makeSlackEvent({ + channelId: "C2", + text: "please deploy", + triggerKey: "slack:msg:C2:1", + concurrencyKey: "slack:C2:1", + }) + ); + const body = await res.json<{ triggered: number; skipped: number }>(); + expect(body.triggered).toBe(0); + expect(body.skipped).toBe(0); + + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.total).toBe(0); + }); + + it("falls back to a concurrency skip when the active run has no session to steer", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + // A run still in "starting" has not created its session yet, so a follow-up + // has nothing to steer and is dropped with the "already active" notice. + // (The steering path — where the active run has a session_id — is covered in + // the SchedulerDO unit tests with a mocked session, so it doesn't attempt a + // real sandbox spawn here.) + const concurrencyKey = "slack:C1:thread-1"; + await store.insertRun( + makeRun(id, { + id: "active-1", + status: "starting", + session_id: null, + concurrency_key: concurrencyKey, + trigger_key: "slack:msg:C1:first", + }) + ); + + const res = await sendEvent( + makeSlackEvent({ text: "deploy", concurrencyKey, triggerKey: "slack:msg:C1:second" }) + ); + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body.skipped).toBe(1); + expect(body.triggered).toBe(0); + expect(body.steered).toBe(0); + + const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + const skip = runs.runs.find((r) => r.skip_reason === "concurrent_run_active"); + expect(skip).toBeDefined(); + expect(JSON.parse(skip!.trigger_run_metadata!).channel).toBe("C1"); + }); + + it("steers the running session on a follow-up reply instead of dropping it", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const concurrencyKey = "slack:C1:thread-steer"; + // Root message triggers the run and creates its session. + const rootRes = await sendEvent( + makeSlackEvent({ text: "deploy the api", concurrencyKey, triggerKey: "slack:msg:C1:root" }) + ); + const rootBody = await rootRes.json<{ triggered: number }>(); + expect(rootBody.triggered).toBe(1); + + // A follow-up reply in the same thread (same concurrency key, new message) + // is routed to the running session as a steering turn — not skipped. + const followRes = await sendEvent( + makeSlackEvent({ + text: "also update the changelog", + concurrencyKey, + triggerKey: "slack:msg:C1:reply", + }) + ); + const followBody = await followRes.json<{ + triggered: number; + skipped: number; + steered: number; + }>(); + expect(followBody).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + // No concurrency-skip row recorded — the follow-up was steered, not dropped. + const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + expect(runs.runs.find((r) => r.skip_reason === "concurrent_run_active")).toBeUndefined(); + }); + + it("continues the same session on a reply after the run has completed", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const concurrencyKey = "slack:C1:thread-done"; + // Root message triggers the run and creates its session. + const rootRes = await sendEvent( + makeSlackEvent({ + text: "deploy the api", + concurrencyKey, + triggerKey: "slack:msg:C1:root-done", + }) + ); + expect((await rootRes.json<{ triggered: number }>()).triggered).toBe(1); + + // Simulate the run finishing. Its session stays steerable within the window, + // just like an @mention thread after a turn completes. + const afterRoot = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + const rootRun = afterRoot.runs.find((r) => r.trigger_key === "slack:msg:C1:root-done")!; + expect(rootRun.session_id).toBeTruthy(); + await store.updateRun(rootRun.id, { status: "completed", completed_at: Date.now() }); + + // A reply after completion — with text that does NOT match the trigger + // conditions — still continues the same session, proving the steer bypasses + // both condition matching and the run-status filter. + const followRes = await sendEvent( + makeSlackEvent({ + text: "thanks! can you also bump the version?", + concurrencyKey, + triggerKey: "slack:msg:C1:reply-done", + }) + ); + const followBody = await followRes.json<{ + triggered: number; + skipped: number; + steered: number; + }>(); + expect(followBody).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + // The reply created no new run and recorded no skip — it reused the completed + // run's session. Exactly one materialized run remains (the completed root). + const afterReply = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + expect( + afterReply.runs.find((r) => r.trigger_key === "slack:msg:C1:reply-done") + ).toBeUndefined(); + expect(afterReply.runs.find((r) => r.skip_reason === "concurrent_run_active")).toBeUndefined(); + expect(afterReply.runs.filter((r) => r.trigger_key !== null)).toHaveLength(1); + }); +}); diff --git a/packages/control-plane/test/integration/slack-channel-store.test.ts b/packages/control-plane/test/integration/slack-channel-store.test.ts new file mode 100644 index 000000000..5424139f5 --- /dev/null +++ b/packages/control-plane/test/integration/slack-channel-store.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { env } from "cloudflare:test"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; +import { cleanD1Tables } from "./cleanup"; + +function makeAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-${Math.random().toString(36).slice(2, 8)}`, + name: "Test Automation", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Run tests", + trigger_type: "schedule", + schedule_cron: "0 9 * * *", + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: now + 86400000, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: null, + trigger_config: null, + trigger_auth_data: null, + ...overrides, + }; +} + +const makeSlackAutomation = (overrides?: Partial) => + makeAutomation({ + trigger_type: "slack_event", + event_type: "message.posted", + ...overrides, + }); + +describe("SlackChannelStore (D1 integration)", () => { + beforeEach(cleanD1Tables); + + it("setSlackChannels writes and replaces the channel set", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s1" })); + + await channels.setSlackChannels("auto-s1", ["C1", "C2"]); + expect((await channels.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2"]); + + await channels.setSlackChannels("auto-s1", ["C2", "C3"]); + expect((await channels.getWatchedSlackChannels()).sort()).toEqual(["C2", "C3"]); + }); + + it("getSlackAutomationsForChannel returns only enabled, non-deleted slack automations", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s2" })); + await store.create(makeSlackAutomation({ id: "auto-s3", enabled: 0 })); + await store.create( + makeAutomation({ + id: "auto-s4", + trigger_type: "github_event", + event_type: "pull_request.opened", + }) + ); + + await channels.setSlackChannels("auto-s2", ["C1"]); + await channels.setSlackChannels("auto-s3", ["C1"]); // disabled → excluded + await channels.setSlackChannels("auto-s4", ["C1"]); // wrong trigger_type → excluded + + const matches = await channels.getSlackAutomationsForChannel("C1"); + expect(matches.map((m) => m.id)).toEqual(["auto-s2"]); + }); + + it("getWatchedSlackChannels dedups and excludes disabled automations", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s5" })); + await store.create(makeSlackAutomation({ id: "auto-s6" })); + await store.create(makeSlackAutomation({ id: "auto-s7", enabled: 0 })); + + await channels.setSlackChannels("auto-s5", ["C1", "C2"]); + await channels.setSlackChannels("auto-s6", ["C2", "C3"]); // C2 duplicated across automations + await channels.setSlackChannels("auto-s7", ["C9"]); // disabled → excluded + + expect((await channels.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2", "C3"]); + }); +}); diff --git a/packages/control-plane/test/integration/webhooks-slack.test.ts b/packages/control-plane/test/integration/webhooks-slack.test.ts new file mode 100644 index 000000000..5993b1226 --- /dev/null +++ b/packages/control-plane/test/integration/webhooks-slack.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { SELF, env } from "cloudflare:test"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; +import { generateInternalToken } from "../../src/auth/internal"; +import { cleanD1Tables } from "./cleanup"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function authHeaders(): Promise> { + const token = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET!); + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +function makeSlackEventBody(overrides?: Record): Record { + const ts = `${Date.now()}.${Math.floor(Math.random() * 1e6)}`; + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:C1:${ts}`, + concurrencyKey: `slack:C1:${ts}`, + contextBlock: "A message was posted in Slack channel #ops by user U1.", + meta: {}, + channelId: "C1", + ts, + actorUserId: "U1", + text: "please deploy the api", + ...overrides, + }; +} + +function makeSlackAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-slack-${Math.random().toString(36).slice(2, 8)}`, + name: "Slack triage", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Investigate and fix", + trigger_type: "slack_event", + schedule_cron: null, + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: "message.posted", + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), + trigger_auth_data: null, + ...overrides, + }; +} + +async function seedSlackAutomation(): Promise { + const store = new AutomationStore(env.DB); + const automation = makeSlackAutomation(); + await store.create(automation); + await new SlackChannelStore(env.DB).setSlackChannels(automation.id, ["C1"]); + return automation.id; +} + +async function postEvent( + body: Record, + headers?: Record +): Promise { + return SELF.fetch("https://test.local/internal/slack-event", { + method: "POST", + headers: headers ?? (await authHeaders()), + body: JSON.stringify(body), + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("POST /internal/slack-event (integration)", () => { + beforeEach(cleanD1Tables); + + it("returns 401 without an internal token", async () => { + const res = await postEvent(makeSlackEventBody(), { "Content-Type": "application/json" }); + expect(res.status).toBe(401); + }); + + it("returns 401 with an invalid internal token", async () => { + const res = await postEvent(makeSlackEventBody(), { + Authorization: "Bearer not-a-valid-token", + "Content-Type": "application/json", + }); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid JSON", async () => { + const res = await SELF.fetch("https://test.local/internal/slack-event", { + method: "POST", + headers: await authHeaders(), + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it.each(["null", "[]", "42"])( + "returns 400 when the JSON body is not an object (%s)", + async (raw) => { + const res = await SELF.fetch("https://test.local/internal/slack-event", { + method: "POST", + headers: await authHeaders(), + body: raw, + }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("must be a JSON object"); + } + ); + + it("returns 400 when source is not 'slack'", async () => { + const res = await postEvent(makeSlackEventBody({ source: "github" })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("source"); + }); + + it("returns 400 when channelId is missing", async () => { + const res = await postEvent(makeSlackEventBody({ channelId: undefined })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("channelId"); + }); + + it("returns 400 when ts is missing", async () => { + const res = await postEvent(makeSlackEventBody({ ts: undefined })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("ts"); + }); + + it("returns 400 when eventType/triggerKey/concurrencyKey are missing", async () => { + const res = await postEvent(makeSlackEventBody({ triggerKey: undefined })); + expect(res.status).toBe(400); + }); + + it("forwards a valid event to the scheduler and returns trigger counts", async () => { + const id = await seedSlackAutomation(); + const body = makeSlackEventBody({ text: "please deploy the api" }); + + // The DO can transiently throw an invalidation error in the test runtime, + // which the handler surfaces as a 502. Retry once to absorb that. + let res = await postEvent(body); + if (res.status === 502) { + res = await postEvent(body); + } + + expect(res.status).toBe(200); + const result = await res.json<{ ok: boolean; triggered: number; skipped: number }>(); + expect(result.ok).toBe(true); + expect(result.triggered).toBe(1); + expect(result.skipped).toBe(0); + + // The forward actually materialized a run for the seeded automation. + const store = new AutomationStore(env.DB); + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.runs.some((r) => r.trigger_key === body.triggerKey)).toBe(true); + }); +}); diff --git a/packages/shared/src/slack/client.test.ts b/packages/shared/src/slack/client.test.ts index bb60dd427..d7a32ad88 100644 --- a/packages/shared/src/slack/client.test.ts +++ b/packages/shared/src/slack/client.test.ts @@ -6,6 +6,7 @@ import { getPermalink, getThreadMessages, getUserInfo, + listChannels, openView, postMessage, publishView, @@ -478,3 +479,77 @@ describe("openView", () => { } }); }); + +describe("listChannels", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("normalizes a single page and requests public + private, non-archived", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + ok: true, + channels: [ + { id: "C1", name: "general", is_private: false, is_member: true }, + { id: "C2", name: "secret", is_private: true, is_member: false }, + ], + response_metadata: { next_cursor: "" }, + }) + ); + + const result = await listChannels("xoxb-token"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.channels).toEqual([ + { id: "C1", name: "general", isPrivate: false, isMember: true }, + { id: "C2", name: "secret", isPrivate: true, isMember: false }, + ]); + } + const url = String(fetchSpy.mock.calls[0]![0]); + expect(url).toContain("conversations.list"); + expect(url).toContain("types=public_channel%2Cprivate_channel"); + expect(url).toContain("exclude_archived=true"); + expect(url).toContain("limit=1000"); + }); + + it("follows next_cursor pagination and concatenates pages", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + jsonResponse({ + ok: true, + channels: [{ id: "C1", name: "a" }], + response_metadata: { next_cursor: "cur-2" }, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + ok: true, + channels: [{ id: "C2", name: "b" }], + response_metadata: { next_cursor: "" }, + }) + ); + + const result = await listChannels("xoxb-token"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.channels.map((c) => c.id)).toEqual(["C1", "C2"]); + } + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(String(fetchSpy.mock.calls[1]![0])).toContain("cursor=cur-2"); + }); + + it("returns the Slack failure envelope when a page errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ ok: false, error: "missing_scope" }) + ); + + const result = await listChannels("xoxb-token"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("missing_scope"); + } + }); +}); diff --git a/packages/shared/src/slack/client.ts b/packages/shared/src/slack/client.ts index e134b126a..ff0e0de18 100644 --- a/packages/shared/src/slack/client.ts +++ b/packages/shared/src/slack/client.ts @@ -140,6 +140,27 @@ export function getPermalink( return slackGet(token, "chat.getPermalink", { channel, message_ts: messageTs }); } +/** + * Post an ephemeral message visible only to `user` in `channel` (optionally + * threaded). Used to surface best-effort notices — e.g. "a run is already + * active for this thread" — without adding noise for everyone else. + */ +export function postEphemeral( + token: string, + channel: string, + user: string, + text: string, + options?: { thread_ts?: string; blocks?: unknown[] } +): Promise> { + return slackPost(token, "chat.postEphemeral", { + channel, + user, + text, + thread_ts: options?.thread_ts, + blocks: options?.blocks, + }); +} + export function updateMessage( token: string, channel: string, @@ -173,6 +194,23 @@ export function removeReaction( return slackPost(token, "reactions.remove", { channel, timestamp: messageTs, name }); } +/** Subset of the `auth.test` response the bot uses to learn its own identity. */ +export interface SlackAuthTestResult { + user_id: string; + user?: string; + team_id?: string; + team?: string; + bot_id?: string; +} + +/** + * Call `auth.test` to resolve the identity of the token's bot user. The + * slack-bot uses the returned `user_id` to strip and suppress its own mentions. + */ +export function authTest(token: string): Promise> { + return slackPost(token, "auth.test"); +} + export interface SlackChannelInfo { id: string; name: string; @@ -187,6 +225,64 @@ export function getChannelInfo( return slackGet(token, "conversations.info", { channel: channelId }); } +/** Raw `conversations.list` channel shape (subset the picker consumes). */ +interface SlackConversation { + id: string; + name: string; + is_private?: boolean; + is_member?: boolean; +} + +/** Normalized channel for the automation channel picker. */ +export interface SlackChannelListing { + id: string; + name: string; + isPrivate: boolean; + /** Whether the bot is a member — only member channels deliver messages. */ + isMember: boolean; +} + +/** + * List the workspace's public + private channels via `conversations.list`, + * following `response_metadata.next_cursor` pagination and excluding archived + * channels. Requires the bot token's `channels:read` (public) and `groups:read` + * (private) scopes. Returns the SlackEnvelope failure arm on any page's error. + */ +export async function listChannels( + token: string +): Promise> { + const channels: SlackChannelListing[] = []; + let cursor: string | undefined; + // Bound the loop defensively: 1000/page × 20 pages caps at 20k channels. + for (let page = 0; page < 20; page++) { + const query: Record = { + types: "public_channel,private_channel", + exclude_archived: "true", + limit: "1000", + }; + if (cursor) query.cursor = cursor; + + const res = await slackGet<{ + channels: SlackConversation[]; + response_metadata?: { next_cursor?: string }; + }>(token, "conversations.list", query); + if (!res.ok) return res; + + for (const c of res.channels) { + channels.push({ + id: c.id, + name: c.name, + isPrivate: Boolean(c.is_private), + isMember: Boolean(c.is_member), + }); + } + + cursor = res.response_metadata?.next_cursor || undefined; + if (!cursor) break; + } + return { ok: true, channels }; +} + export interface SlackThreadMessage { ts: string; text: string; diff --git a/packages/shared/src/slack/index.ts b/packages/shared/src/slack/index.ts index 090eccae1..7384d5860 100644 --- a/packages/shared/src/slack/index.ts +++ b/packages/shared/src/slack/index.ts @@ -1,17 +1,27 @@ export { addReaction, + authTest, getChannelInfo, getPermalink, getThreadMessages, getUserInfo, + listChannels, openView, + postEphemeral, postMessage, publishView, removeReaction, updateMessage, verifySlackSignature, } from "./client"; -export type { SlackChannelInfo, SlackEnvelope, SlackThreadMessage, SlackUser } from "./client"; +export type { + SlackAuthTestResult, + SlackChannelInfo, + SlackChannelListing, + SlackEnvelope, + SlackThreadMessage, + SlackUser, +} from "./client"; export { applyMentionPolicy, sanitizeAgentText, diff --git a/packages/shared/src/slack/mrkdwn.test.ts b/packages/shared/src/slack/mrkdwn.test.ts index d5133777e..68a8c1e29 100644 --- a/packages/shared/src/slack/mrkdwn.test.ts +++ b/packages/shared/src/slack/mrkdwn.test.ts @@ -115,6 +115,18 @@ describe("applyMentionPolicy", () => { it("preserves channel refs", () => { expect(applyMentionPolicy("<#C123|ops> <@U1>", "strip")).toBe("<#C123|ops> "); }); + + it("escape converts the piped <@ID|label> form to literal @ID", () => { + expect(applyMentionPolicy("hi <@U123|cole>", "escape")).toBe("hi @U123"); + }); + + it("strip removes the piped <@ID|label> form", () => { + expect(applyMentionPolicy("hi <@U123|cole>", "strip")).toBe("hi "); + }); + + it("handles a mix of bare and piped mentions", () => { + expect(applyMentionPolicy("<@U1> and <@U2|two>", "escape")).toBe("@U1 and @U2"); + }); }); describe("truncateForSlack", () => { diff --git a/packages/shared/src/slack/mrkdwn.ts b/packages/shared/src/slack/mrkdwn.ts index b44d30d0b..d8fb2418f 100644 --- a/packages/shared/src/slack/mrkdwn.ts +++ b/packages/shared/src/slack/mrkdwn.ts @@ -22,7 +22,7 @@ const TRUNCATION_MARKER = "… (truncated)"; const BROADCAST_MENTION_RE = /]*)?)>/g; const URL_LINK_RE = /<(https?:\/\/[^|>\s]+|mailto:[^|>\s]+)(?:\|[^>]*)?>/g; -const USER_MENTION_RE = /<@([A-Z0-9]+)>/g; +const USER_MENTION_RE = /<@([A-Z0-9]+)(?:\|[^>]*)?>/g; export function stripBroadcastMentions(text: string): string { return text.replace(BROADCAST_MENTION_RE, ""); diff --git a/packages/shared/src/triggers/conditions.ts b/packages/shared/src/triggers/conditions.ts index a04c73254..9c7067e1a 100644 --- a/packages/shared/src/triggers/conditions.ts +++ b/packages/shared/src/triggers/conditions.ts @@ -20,6 +20,9 @@ export interface ConditionConfigMap { sentry_project: { operator: "any_of"; value: string[] }; sentry_level: { operator: "any_of"; value: string[] }; jsonpath: { operator: "all_match"; value: JsonPathFilter[] }; + text_match: { operator: "contains" | "exact" | "regex"; value: TextMatchValue }; + slack_channel: { operator: "any_of"; value: string[] }; + slack_actor: { operator: "include" | "exclude"; value: string[] }; } export interface JsonPathFilter { @@ -28,6 +31,14 @@ export interface JsonPathFilter { value?: string | number | boolean; } +/** Value shape for the `text_match` condition (keyword / substring / regex). */ +export interface TextMatchValue { + /** Keyword/substring (contains/exact) or regular-expression source (regex). */ + pattern: string; + /** Case/regex flags; only an allowlisted subset is accepted (see ALLOWED_REGEX_FLAGS). */ + flags?: string; +} + // ─── 2. Derived discriminated union ────────────────────────────────────────── export type TriggerCondition = { diff --git a/packages/shared/src/triggers/index.ts b/packages/shared/src/triggers/index.ts index 22094c596..1d82e64a0 100644 --- a/packages/shared/src/triggers/index.ts +++ b/packages/shared/src/triggers/index.ts @@ -10,6 +10,7 @@ export type { LinearAutomationEvent, SentryAutomationEvent, WebhookAutomationEvent, + SlackAutomationEvent, TriggerSourceDefinition, } from "./types"; export { TRIGGER_TYPE_TO_SOURCE } from "./types"; @@ -22,6 +23,7 @@ export type { ConditionHandler, ConditionRegistry, JsonPathFilter, + TextMatchValue, TriggerConfig, } from "./conditions"; export { matchesConditions, validateConditions } from "./conditions"; @@ -53,3 +55,13 @@ export { evaluateJsonPathFilter, buildWebhookContextBlock, } from "./webhook"; + +// Slack source module +export { + slackSource, + normalizeSlackEvent, + SLACK_TEXT_MAX_LENGTH, + REGEX_PATTERN_MAX_LENGTH, + ALLOWED_REGEX_FLAGS, +} from "./slack"; +export type { SlackMessageInput, SlackChannelMeta } from "./slack"; diff --git a/packages/shared/src/triggers/registry.ts b/packages/shared/src/triggers/registry.ts index e0796a78c..64ceceb6e 100644 --- a/packages/shared/src/triggers/registry.ts +++ b/packages/shared/src/triggers/registry.ts @@ -7,6 +7,7 @@ import type { TriggerSourceDefinition } from "./types"; import { sentrySource, sentryConditions } from "./sentry"; import { webhookSource, webhookConditions } from "./webhook"; import { githubSource } from "./github"; +import { slackSource, slackConditions } from "./slack"; // GitHub and Linear condition handlers (stubs for Phase 2c). // These need to exist so that the ConditionRegistry is complete. @@ -114,6 +115,7 @@ export const conditionRegistry: ConditionRegistry = { ...sharedConditions, ...sentryConditions, ...webhookConditions, + ...slackConditions, }; /** @@ -124,4 +126,5 @@ export const triggerSources: TriggerSourceDefinition[] = [ sentrySource, webhookSource, githubSource, + slackSource, ]; diff --git a/packages/shared/src/triggers/slack/conditions.test.ts b/packages/shared/src/triggers/slack/conditions.test.ts new file mode 100644 index 000000000..0f29e61bd --- /dev/null +++ b/packages/shared/src/triggers/slack/conditions.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from "vitest"; +import { matchesConditions, validateConditions } from "../conditions"; +import type { TriggerCondition } from "../conditions"; +import { conditionRegistry } from "../registry"; +import { buildMockEvent } from "../testing"; +import { SLACK_TEXT_MAX_LENGTH } from "./normalizer"; + +const slackEvent = (overrides: { text?: string; channelId?: string; actorUserId?: string }) => + buildMockEvent("slack", overrides); + +const match = (condition: TriggerCondition, event = slackEvent({ text: "please deploy now" })) => + matchesConditions([condition], event, conditionRegistry); + +describe("text_match condition", () => { + it("contains: case-sensitive match", () => { + expect(match({ type: "text_match", operator: "contains", value: { pattern: "deploy" } })).toBe( + true + ); + }); + + it("contains: case-sensitive miss", () => { + expect(match({ type: "text_match", operator: "contains", value: { pattern: "Deploy" } })).toBe( + false + ); + }); + + it("contains: case-insensitive with flags i", () => { + expect( + match({ type: "text_match", operator: "contains", value: { pattern: "DEPLOY", flags: "i" } }) + ).toBe(true); + }); + + it("exact: matches the whole text", () => { + expect( + match( + { type: "text_match", operator: "exact", value: { pattern: "deploy" } }, + slackEvent({ text: "deploy" }) + ) + ).toBe(true); + }); + + it("exact: rejects a substring", () => { + expect( + match( + { type: "text_match", operator: "exact", value: { pattern: "deploy" } }, + slackEvent({ text: "please deploy" }) + ) + ).toBe(false); + }); + + it("exact: case-insensitive with flags i", () => { + expect( + match( + { type: "text_match", operator: "exact", value: { pattern: "DEPLOY", flags: "i" } }, + slackEvent({ text: "deploy" }) + ) + ).toBe(true); + }); + + it("regex: matches", () => { + expect( + match({ type: "text_match", operator: "regex", value: { pattern: "deploy\\s+\\w+" } }) + ).toBe(true); + }); + + it("regex: no match", () => { + expect(match({ type: "text_match", operator: "regex", value: { pattern: "^urgent" } })).toBe( + false + ); + }); + + it("regex: a malformed pattern is a non-match, not a throw", () => { + expect(match({ type: "text_match", operator: "regex", value: { pattern: "(" } })).toBe(false); + }); + + it("regex: a disallowed flag is a non-match", () => { + expect( + match({ type: "text_match", operator: "regex", value: { pattern: "deploy", flags: "g" } }) + ).toBe(false); + }); + + it("does not match input over the length cap", () => { + const big = "deploy " + "x".repeat(SLACK_TEXT_MAX_LENGTH); + expect( + match( + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + slackEvent({ text: big }) + ) + ).toBe(false); + }); + + it("passes through (true) for non-slack events", () => { + expect( + matchesConditions( + [{ type: "text_match", operator: "contains", value: { pattern: "deploy" } }], + buildMockEvent("github"), + conditionRegistry + ) + ).toBe(true); + }); +}); + +describe("slack_channel condition", () => { + it("any_of matches when the channel is in the list", () => { + expect( + match( + { type: "slack_channel", operator: "any_of", value: ["C123", "C999"] }, + slackEvent({ channelId: "C123" }) + ) + ).toBe(true); + }); + + it("any_of misses when the channel is not in the list", () => { + expect( + match( + { type: "slack_channel", operator: "any_of", value: ["C999"] }, + slackEvent({ channelId: "C123" }) + ) + ).toBe(false); + }); +}); + +describe("slack_actor condition", () => { + it("include matches the poster", () => { + expect( + match( + { type: "slack_actor", operator: "include", value: ["U1"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(true); + }); + + it("include rejects a non-listed poster", () => { + expect( + match( + { type: "slack_actor", operator: "include", value: ["U2"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(false); + }); + + it("exclude rejects a listed poster", () => { + expect( + match( + { type: "slack_actor", operator: "exclude", value: ["U1"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(false); + }); + + it("exclude allows a non-listed poster", () => { + expect( + match( + { type: "slack_actor", operator: "exclude", value: ["U2"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(true); + }); +}); + +describe("validateConditions (slack)", () => { + it("rejects an empty text_match pattern", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "contains", value: { pattern: "" } }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a disallowed regex flag", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "regex", value: { pattern: "x", flags: "g" } }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects an invalid regex pattern at save time", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "regex", value: { pattern: "(" } }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("accepts a valid regex with an allowed flag", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "regex", value: { pattern: "deploy", flags: "i" } }], + "slack", + conditionRegistry + ); + expect(errors).toHaveLength(0); + }); + + it("rejects an empty slack_channel list", () => { + const errors = validateConditions( + [{ type: "slack_channel", operator: "any_of", value: [] }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a slack_channel value that is a string, not an array", () => { + // A bare string passes the TS type's `.length` check but would be iterated + // character-by-character when the watched-channel index is built. + const errors = validateConditions( + [{ type: "slack_channel", operator: "any_of", value: "C123" } as unknown as TriggerCondition], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a slack_channel array with a non-string element", () => { + const errors = validateConditions( + [ + { + type: "slack_channel", + operator: "any_of", + value: ["C1", 123], + } as unknown as TriggerCondition, + ], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a text_match value that is not an object (no throw)", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "contains", value: null } as unknown as TriggerCondition], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a text_match pattern that is not a string", () => { + const errors = validateConditions( + [ + { + type: "text_match", + operator: "contains", + value: { pattern: 123 }, + } as unknown as TriggerCondition, + ], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports a slack condition used on a github trigger", () => { + const errors = validateConditions( + [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + "github", + conditionRegistry + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("does not apply to github"); + }); +}); diff --git a/packages/shared/src/triggers/slack/conditions.ts b/packages/shared/src/triggers/slack/conditions.ts new file mode 100644 index 000000000..a3c705e6f --- /dev/null +++ b/packages/shared/src/triggers/slack/conditions.ts @@ -0,0 +1,125 @@ +/** + * Slack condition handlers (text_match / slack_channel / slack_actor). + * + * Co-located with the rest of the slack trigger source (source definition, + * normalizer) and assembled into the central registry by `../registry`. Kept + * here — rather than inline in the registry — so the source owns its conditions, + * matching the sentry/webhook modules. + */ + +import type { ConditionRegistry, TextMatchValue } from "../conditions"; +import type { AutomationEvent } from "../types"; +import { SLACK_TEXT_MAX_LENGTH } from "./normalizer"; + +/** Max length of a user-supplied `text_match` regex pattern (characters). */ +export const REGEX_PATTERN_MAX_LENGTH = 200; + +/** Regex flags accepted for the `text_match` `regex` operator. */ +export const ALLOWED_REGEX_FLAGS = new Set(["i", "m"]); + +/** True when every flag character is on the allowlist (empty/undefined is allowed). */ +function flagsAllowed(flags: string | undefined): boolean { + if (!flags) return true; + for (const flag of flags) { + if (!ALLOWED_REGEX_FLAGS.has(flag)) return false; + } + return true; +} + +/** + * True when `value` is an array of one or more non-empty strings. The condition + * value arrives as untrusted JSON typed only by assertion, so the handlers below + * verify the runtime shape before it is persisted — a bare string would satisfy + * the TypeScript type's `.length` check and then be iterated character-by-character + * when the watched-channel index is built. + */ +function isNonEmptyStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((v) => typeof v === "string" && v !== "") + ); +} + +/** + * Slack condition handlers. `slack_actor` is a distinct slack-only handler — the + * github/linear `actor` handler passes through for slack, so it cannot be reused. + */ +export const slackConditions = { + text_match: { + appliesTo: ["slack"] as const, + validate(c: { operator: "contains" | "exact" | "regex"; value: TextMatchValue }) { + const value = c.value as unknown; + if (typeof value !== "object" || value === null) { + return "text_match value must be an object with a pattern"; + } + const { pattern, flags } = value as { pattern?: unknown; flags?: unknown }; + if (typeof pattern !== "string" || pattern === "") return "Text match pattern is required"; + if (pattern.length > REGEX_PATTERN_MAX_LENGTH) { + return `Pattern exceeds the ${REGEX_PATTERN_MAX_LENGTH}-character limit`; + } + if (flags !== undefined && typeof flags !== "string") { + return "text_match flags must be a string"; + } + if (!flagsAllowed(flags)) return "Unsupported regex flag"; + if (c.operator === "regex") { + try { + new RegExp(pattern, flags ?? ""); + } catch { + return "Invalid regex pattern"; + } + } + return null; + }, + evaluate( + c: { operator: "contains" | "exact" | "regex"; value: TextMatchValue }, + event: AutomationEvent + ) { + if (event.source !== "slack") return true; + const { text } = event; + // Defensive input bound — the normalizer already caps text at this length. + if (text.length > SLACK_TEXT_MAX_LENGTH) return false; + const { pattern, flags } = c.value; + const caseInsensitive = flags?.includes("i") ?? false; + if (c.operator === "contains") { + return caseInsensitive + ? text.toLowerCase().includes(pattern.toLowerCase()) + : text.includes(pattern); + } + if (c.operator === "exact") { + return caseInsensitive ? text.toLowerCase() === pattern.toLowerCase() : text === pattern; + } + // regex + if (pattern.length > REGEX_PATTERN_MAX_LENGTH) return false; + if (!flagsAllowed(flags)) return false; + try { + return new RegExp(pattern, flags ?? "").test(text); + } catch { + return false; + } + }, + }, + slack_channel: { + appliesTo: ["slack"] as const, + validate(c: { value: string[] }) { + return isNonEmptyStringArray(c.value) + ? null + : "slack_channel requires at least one channel ID"; + }, + evaluate(c: { value: string[] }, event: AutomationEvent) { + if (event.source !== "slack") return true; + return c.value.includes(event.channelId); + }, + }, + slack_actor: { + appliesTo: ["slack"] as const, + validate(c: { value: string[] }) { + return isNonEmptyStringArray(c.value) ? null : "slack_actor requires at least one user ID"; + }, + evaluate(c: { operator: "include" | "exclude"; value: string[] }, event: AutomationEvent) { + if (event.source !== "slack") return true; + const inList = c.value.includes(event.actorUserId); + return c.operator === "include" ? inList : !inList; + }, + }, +} satisfies Partial; diff --git a/packages/shared/src/triggers/slack/index.ts b/packages/shared/src/triggers/slack/index.ts new file mode 100644 index 000000000..4717f0ffd --- /dev/null +++ b/packages/shared/src/triggers/slack/index.ts @@ -0,0 +1,26 @@ +/** + * Slack trigger source module. + */ + +import type { TriggerSourceDefinition } from "../types"; + +export type { SlackAutomationEvent } from "../types"; +export { normalizeSlackEvent, SLACK_TEXT_MAX_LENGTH } from "./normalizer"; +export type { SlackMessageInput, SlackChannelMeta } from "./normalizer"; +export { slackConditions, REGEX_PATTERN_MAX_LENGTH, ALLOWED_REGEX_FLAGS } from "./conditions"; + +export const slackSource: TriggerSourceDefinition = { + source: "slack", + triggerType: "slack_event", + displayName: "Slack Message", + description: "Trigger when a message is posted in a watched Slack channel", + supportsEventTypes: false, + eventTypes: [ + { + eventType: "message.posted", + displayName: "Message posted", + description: "A message is posted in a watched channel", + }, + ], + supportedConditions: ["text_match", "slack_channel", "slack_actor"], +}; diff --git a/packages/shared/src/triggers/slack/normalizer.test.ts b/packages/shared/src/triggers/slack/normalizer.test.ts new file mode 100644 index 000000000..9903b41af --- /dev/null +++ b/packages/shared/src/triggers/slack/normalizer.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSlackEvent, SLACK_TEXT_MAX_LENGTH } from "./normalizer"; + +const baseInput = { + channel: "C123", + ts: "1700000000.000100", + user: "U999", + text: "please deploy the api", +}; + +describe("normalizeSlackEvent", () => { + it("normalizes a valid channel message", () => { + const event = normalizeSlackEvent(baseInput, "UBOT", { + channelName: "ops", + permalink: "https://example.slack.com/archives/C123/p1700000000000100", + }); + expect(event).not.toBeNull(); + expect(event!.source).toBe("slack"); + expect(event!.eventType).toBe("message.posted"); + expect(event!.channelId).toBe("C123"); + expect(event!.channelName).toBe("ops"); + expect(event!.actorUserId).toBe("U999"); + expect(event!.ts).toBe("1700000000.000100"); + expect(event!.text).toBe("please deploy the api"); + expect(event!.triggerKey).toBe("slack:msg:C123:1700000000.000100"); + expect(event!.concurrencyKey).toBe("slack:C123:1700000000.000100"); + expect(event!.contextBlock).toContain("please deploy the api"); + expect(event!.contextBlock).toContain("ops"); + }); + + it("uses thread_ts for the concurrency key on a thread reply", () => { + const event = normalizeSlackEvent({ ...baseInput, thread_ts: "1699999999.000001" }, "UBOT"); + expect(event!.threadTs).toBe("1699999999.000001"); + expect(event!.concurrencyKey).toBe("slack:C123:1699999999.000001"); + // triggerKey stays keyed on the message ts (one run per message). + expect(event!.triggerKey).toBe("slack:msg:C123:1700000000.000100"); + }); + + it("returns null for empty/whitespace text", () => { + expect(normalizeSlackEvent({ ...baseInput, text: " " }, "UBOT")).toBeNull(); + expect(normalizeSlackEvent({ ...baseInput, text: "" }, "UBOT")).toBeNull(); + }); + + it("strips the bot's own bare mention <@BOT>", () => { + const event = normalizeSlackEvent({ ...baseInput, text: "<@UBOT> please deploy" }, "UBOT"); + expect(event!.text).toBe("please deploy"); + expect(event!.text).not.toContain("UBOT"); + }); + + it("strips the bot's piped mention <@BOT|name>", () => { + const event = normalizeSlackEvent( + { ...baseInput, text: "<@UBOT|open-inspect> please deploy" }, + "UBOT" + ); + expect(event!.text).toBe("please deploy"); + expect(event!.text).not.toContain("UBOT"); + }); + + it("returns null when the message is only the bot mention", () => { + expect(normalizeSlackEvent({ ...baseInput, text: "<@UBOT>" }, "UBOT")).toBeNull(); + expect(normalizeSlackEvent({ ...baseInput, text: "<@UBOT|open-inspect>" }, "UBOT")).toBeNull(); + }); + + it("does not strip other users' mentions", () => { + const event = normalizeSlackEvent({ ...baseInput, text: "<@UBOT> ping <@U777>" }, "UBOT"); + expect(event!.text).toContain("<@U777>"); + }); + + it("caps text at SLACK_TEXT_MAX_LENGTH", () => { + const event = normalizeSlackEvent( + { ...baseInput, text: "x".repeat(SLACK_TEXT_MAX_LENGTH + 500) }, + "UBOT" + ); + expect(event!.text.length).toBe(SLACK_TEXT_MAX_LENGTH); + }); + + it("falls back to the channel id when no channel name is supplied", () => { + const event = normalizeSlackEvent(baseInput, "UBOT"); + expect(event!.channelName).toBeUndefined(); + expect(event!.contextBlock).toContain("C123"); + }); +}); diff --git a/packages/shared/src/triggers/slack/normalizer.ts b/packages/shared/src/triggers/slack/normalizer.ts new file mode 100644 index 000000000..cc9ab5c08 --- /dev/null +++ b/packages/shared/src/triggers/slack/normalizer.ts @@ -0,0 +1,87 @@ +/** + * Normalize raw Slack channel messages into SlackAutomationEvent objects. + */ + +import type { SlackAutomationEvent } from "../types"; + +/** Max length of message text retained for matching + context (characters). */ +export const SLACK_TEXT_MAX_LENGTH = 8 * 1024; + +/** Minimal shape of the Slack `message` event fields the normalizer consumes. */ +export interface SlackMessageInput { + channel: string; + ts: string; + thread_ts?: string; + user: string; + text: string; +} + +/** Bot-fetched channel metadata the shared normalizer cannot resolve itself. */ +export interface SlackChannelMeta { + channelName?: string; + permalink?: string; +} + +/** Matches both the bare `<@U…>` and piped `<@U…|display-name>` renderings. */ +function botMentionPattern(botUserId: string): RegExp { + return new RegExp(`<@${botUserId}(?:\\|[^>]*)?>`, "g"); +} + +function buildContextBlock(params: { + channelLabel: string; + actorUserId: string; + permalink?: string; + text: string; +}): string { + const lines = [ + `A message was posted in Slack channel ${params.channelLabel} by user ${params.actorUserId}.`, + ]; + if (params.permalink) lines.push(`Permalink: ${params.permalink}`); + lines.push("", "", params.text, ""); + return lines.join("\n"); +} + +/** + * Normalize a Slack channel message into a SlackAutomationEvent. + * Returns null when the message has no usable text (e.g. it is only the bot mention). + * + * The caller (slack-bot) supplies `botUserId` so the bot's own mention token is + * stripped, and `channelMeta` for the human-readable name + permalink the shared + * package cannot fetch (it has no Slack token). + */ +export function normalizeSlackEvent( + input: SlackMessageInput, + botUserId: string, + channelMeta?: SlackChannelMeta +): SlackAutomationEvent | null { + const stripped = (input.text ?? "").replace(botMentionPattern(botUserId), "").trim(); + if (!stripped) return null; + + const text = stripped.slice(0, SLACK_TEXT_MAX_LENGTH); + const channelLabel = channelMeta?.channelName ? `#${channelMeta.channelName}` : input.channel; + + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:${input.channel}:${input.ts}`, + concurrencyKey: `slack:${input.channel}:${input.thread_ts ?? input.ts}`, + channelId: input.channel, + channelName: channelMeta?.channelName, + threadTs: input.thread_ts, + ts: input.ts, + actorUserId: input.user, + text, + contextBlock: buildContextBlock({ + channelLabel, + actorUserId: input.user, + permalink: channelMeta?.permalink, + text, + }), + meta: { + channelId: input.channel, + ts: input.ts, + threadTs: input.thread_ts, + permalink: channelMeta?.permalink, + }, + }; +} diff --git a/packages/shared/src/triggers/testing.ts b/packages/shared/src/triggers/testing.ts index 975aeefa9..84cd13737 100644 --- a/packages/shared/src/triggers/testing.ts +++ b/packages/shared/src/triggers/testing.ts @@ -9,6 +9,7 @@ import type { WebhookAutomationEvent, GitHubAutomationEvent, LinearAutomationEvent, + SlackAutomationEvent, } from "./types"; import type { TriggerCondition } from "./conditions"; import { matchesConditions } from "./conditions"; @@ -63,6 +64,19 @@ const defaults: Record AutomationEvent> = { automationId: "auto-1", body: {}, }) as WebhookAutomationEvent, + slack: () => + ({ + source: "slack", + eventType: "message.posted", + triggerKey: "slack:msg:C123:1700000000.000100", + concurrencyKey: "slack:C123:1700000000.000100", + contextBlock: "Test Slack context", + meta: {}, + channelId: "C123", + ts: "1700000000.000100", + actorUserId: "U999", + text: "test slack message", + }) satisfies SlackAutomationEvent, }; /** diff --git a/packages/shared/src/triggers/types.ts b/packages/shared/src/triggers/types.ts index b3cda7b7d..7a4a60058 100644 --- a/packages/shared/src/triggers/types.ts +++ b/packages/shared/src/triggers/types.ts @@ -7,7 +7,7 @@ import type { ConditionType } from "./conditions"; // ─── Event Sources ──────────────────────────────────────────────────────────── -export type AutomationEventSource = "github" | "linear" | "sentry" | "webhook"; +export type AutomationEventSource = "github" | "linear" | "sentry" | "webhook" | "slack"; /** * Maps AutomationTriggerType → AutomationEventSource. @@ -19,6 +19,7 @@ export const TRIGGER_TYPE_TO_SOURCE: Partial ({ mockAuthTest: vi.fn() })); + +vi.mock("@open-inspect/shared", async () => { + const actual = await vi.importActual("@open-inspect/shared"); + return { ...actual, authTest: mockAuthTest }; +}); + +import { getBotUserId, clearBotUserIdCache } from "./bot-identity"; + +function makeEnv(kv: { get: ReturnType; put: ReturnType }): Env { + return { + SLACK_KV: kv, + SLACK_BOT_TOKEN: "xoxb-test", + } as unknown as Env; +} + +function emptyKv() { + return { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("getBotUserId", () => { + beforeEach(() => { + clearBotUserIdCache(); + vi.clearAllMocks(); + }); + + it("resolves the user id via auth.test and persists it to KV", async () => { + mockAuthTest.mockResolvedValue({ ok: true, user_id: "UBOT123" }); + const kv = emptyKv(); + + expect(await getBotUserId(makeEnv(kv))).toBe("UBOT123"); + expect(kv.put).toHaveBeenCalledWith("slack:bot-user-id", "UBOT123"); + }); + + it("caches in-process and does not call auth.test twice", async () => { + mockAuthTest.mockResolvedValue({ ok: true, user_id: "UBOT123" }); + const env = makeEnv(emptyKv()); + + await getBotUserId(env); + await getBotUserId(env); + + expect(mockAuthTest).toHaveBeenCalledTimes(1); + }); + + it("serves the KV last-known-good id when auth.test fails", async () => { + mockAuthTest.mockResolvedValue({ ok: false, error: "ratelimited" }); + const kv = { + get: vi.fn().mockResolvedValue("UCACHED"), + put: vi.fn().mockResolvedValue(undefined), + }; + + expect(await getBotUserId(makeEnv(kv))).toBe("UCACHED"); + expect(kv.get).toHaveBeenCalledWith("slack:bot-user-id"); + }); + + it("fails closed (null) when auth.test fails and KV is empty", async () => { + mockAuthTest.mockResolvedValue({ ok: false, error: "invalid_auth" }); + expect(await getBotUserId(makeEnv(emptyKv()))).toBeNull(); + }); + + it("fails closed (null) when auth.test succeeds without a user_id", async () => { + mockAuthTest.mockResolvedValue({ ok: true }); + expect(await getBotUserId(makeEnv(emptyKv()))).toBeNull(); + }); +}); diff --git a/packages/slack-bot/src/bot-identity.ts b/packages/slack-bot/src/bot-identity.ts new file mode 100644 index 000000000..99ada0205 --- /dev/null +++ b/packages/slack-bot/src/bot-identity.ts @@ -0,0 +1,80 @@ +/** + * Bot identity resolution. + * + * Channel-trigger processing needs the bot's own Slack user id to strip and + * suppress its own mentions. We resolve it once via `auth.test` and cache it — + * the id is effectively static for the lifetime of the Slack app. + */ + +import { authTest, createKvCacheStore } from "@open-inspect/shared"; +import type { Env } from "./types"; +import { createLogger } from "./logger"; + +const log = createLogger("bot-identity"); + +const BOT_USER_ID_KV_KEY = "slack:bot-user-id"; + +/** Bot identity is effectively static; cache it for an hour in-process. */ +const BOT_USER_ID_TTL_MS = 60 * 60 * 1000; + +let botUserIdCache: { + id: string; + timestamp: number; +} | null = null; + +/** + * Resolve the bot's own Slack user id via `auth.test`, cached in-process and in + * KV as last-known-good. + * + * Returns `null` when the id cannot be determined (auth.test failed and no KV + * copy exists). The caller fails **closed** and skips channel-trigger + * processing in that case — mention suppression and self-mention stripping both + * require a known id, so it is never safe to proceed without one. + */ +export async function getBotUserId(env: Env, traceId?: string): Promise { + if (botUserIdCache && Date.now() - botUserIdCache.timestamp < BOT_USER_ID_TTL_MS) { + return botUserIdCache.id; + } + + const result = await authTest(env.SLACK_BOT_TOKEN); + if (result.ok && result.user_id) { + botUserIdCache = { id: result.user_id, timestamp: Date.now() }; + try { + await createKvCacheStore(env.SLACK_KV).put(BOT_USER_ID_KV_KEY, result.user_id); + } catch (e) { + log.warn("kv.put", { + key_prefix: "bot_user_id", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + return result.user_id; + } + + log.warn("slack.auth_test", { + trace_id: traceId, + outcome: "error", + error: result.ok ? "missing_user_id" : result.error, + }); + + // Last-known-good from KV — the id never really changes, so a stale copy is + // safe to serve through a transient auth.test failure. + try { + const cached = await createKvCacheStore(env.SLACK_KV).get(BOT_USER_ID_KV_KEY); + if (cached) { + botUserIdCache = { id: cached, timestamp: Date.now() }; + return cached; + } + } catch (e) { + log.warn("kv.get", { + key_prefix: "bot_user_id", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + + return null; +} + +/** Clear the in-memory bot-id cache (for tests and forced refresh). */ +export function clearBotUserIdCache(): void { + botUserIdCache = null; +} diff --git a/packages/slack-bot/src/callbacks.test.ts b/packages/slack-bot/src/callbacks.test.ts index 3d9bad893..26e3e3f38 100644 --- a/packages/slack-bot/src/callbacks.test.ts +++ b/packages/slack-bot/src/callbacks.test.ts @@ -2,8 +2,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Hono } from "hono"; import { computeHmacHex } from "@open-inspect/shared"; import { callbacksRouter } from "./callbacks"; +import { extractAgentResponse } from "./completion/extractor"; +import type * as ExtractorModule from "./completion/extractor"; import type { Env } from "./types"; +// The automation-complete post path reuses the interactive completion path, which +// fetches the agent's response from the control-plane. Stub that extraction so the +// handler test asserts the in-thread post + reaction clear without reconstructing +// the event-fetch protocol. +vi.mock("./completion/extractor", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, extractAgentResponse: vi.fn() }; +}); + function makeEnv(overrides: Partial = {}): Env { return { SLACK_KV: {} as KVNamespace, @@ -244,3 +255,171 @@ describe("POST /callbacks/tool_call", () => { await flushWaitUntil(ctx); }); }); + +function okFetchMock() { + return vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); +} + +function slackCall( + fetchMock: ReturnType, + endpoint: string +): { url: string; body: Record } | undefined { + const call = fetchMock.mock.calls.find(([url]) => String(url).includes(endpoint)); + if (!call) return undefined; + return { + url: String(call[0]), + body: JSON.parse(String((call[1] as RequestInit).body)) as Record, + }; +} + +async function postCallback(path: string, payload: unknown, env = makeEnv(), ctx = makeCtx()) { + const response = await makeApp().fetch( + new Request(`http://localhost${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-trace-id": "trace-1" }, + body: JSON.stringify(payload), + }), + env, + ctx + ); + return { response, env, ctx }; +} + +describe("POST /callbacks/automation-complete", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function completeData(overrides: Record = {}) { + return { + channel: "C123", + reactionMessageTs: "111.222", + ...overrides, + }; + } + + it("rejects an invalid payload", async () => { + const { response, ctx } = await postCallback("/callbacks/automation-complete", { + channel: "C123", + }); + expect(response.status).toBe(400); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("rejects a bad signature", async () => { + const payload = await signPayload(completeData(), "wrong-secret"); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + expect(response.status).toBe(401); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("clears the reaction without posting when the run carries no session coordinates", async () => { + // No sessionId/messageId (e.g. version skew) — falls back to a reaction clear. + const fetchMock = okFetchMock(); + const payload = await signPayload(completeData()); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + + expect(slackCall(fetchMock, "chat.postMessage")).toBeUndefined(); + const reaction = slackCall(fetchMock, "reactions.remove"); + expect(reaction).toBeDefined(); + expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); + }); + + it("posts the agent result in-thread and clears the reaction", async () => { + vi.mocked(extractAgentResponse).mockResolvedValue({ + textContent: "Fixed the bug and opened a PR.", + toolCalls: [], + artifacts: [], + success: true, + }); + const fetchMock = okFetchMock(); + const payload = await signPayload( + completeData({ + sessionId: "session-9", + messageId: "msg-9", + success: true, + repoFullName: "acme/app", + model: "anthropic/claude-haiku-4-5", + }) + ); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + + // The agent's response is posted into the triggering message's thread. + const post = slackCall(fetchMock, "chat.postMessage"); + expect(post).toBeDefined(); + expect(post!.body).toMatchObject({ channel: "C123", thread_ts: "111.222" }); + expect(JSON.stringify(post!.body)).toContain("Fixed the bug"); + + // ...and the eyes reaction is still cleared. + const reaction = slackCall(fetchMock, "reactions.remove"); + expect(reaction).toBeDefined(); + expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); + }); + + it("does not crash when clearing the reaction throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + const payload = await signPayload(completeData()); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + + expect(response.status).toBe(200); + // The fire-and-forget handler must swallow the throw, not reject in waitUntil. + await expect(flushWaitUntil(ctx)).resolves.toBeUndefined(); + }); +}); + +describe("POST /callbacks/automation-skip", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function skipData(overrides: Record = {}) { + return { channel: "C123", user: "U9", threadTs: "111.222", ...overrides }; + } + + it("rejects an invalid payload", async () => { + const { response, ctx } = await postCallback("/callbacks/automation-skip", { channel: "C123" }); + expect(response.status).toBe(400); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("rejects a bad signature", async () => { + const payload = await signPayload(skipData(), "wrong-secret"); + const { response, ctx } = await postCallback("/callbacks/automation-skip", payload); + expect(response.status).toBe(401); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("posts an ephemeral notice to the message author", async () => { + const fetchMock = okFetchMock(); + const payload = await signPayload(skipData()); + const { response, ctx } = await postCallback("/callbacks/automation-skip", payload); + + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + + const ephemeral = slackCall(fetchMock, "chat.postEphemeral"); + expect(ephemeral).toBeDefined(); + expect(ephemeral!.body).toMatchObject({ channel: "C123", user: "U9", thread_ts: "111.222" }); + }); + + it("does not crash when the ephemeral post throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + const payload = await signPayload(skipData()); + const { response, ctx } = await postCallback("/callbacks/automation-skip", payload); + + expect(response.status).toBe(200); + // The fire-and-forget handler must swallow the throw, not reject in waitUntil. + await expect(flushWaitUntil(ctx)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/slack-bot/src/callbacks.ts b/packages/slack-bot/src/callbacks.ts index 941a6aec5..efc62c699 100644 --- a/packages/slack-bot/src/callbacks.ts +++ b/packages/slack-bot/src/callbacks.ts @@ -2,8 +2,14 @@ * Callback handlers for control-plane notifications. */ -import { computeHmacHex, postMessage, removeReaction, timingSafeEqual } from "@open-inspect/shared"; -import { Hono } from "hono"; +import { + computeHmacHex, + postEphemeral, + postMessage, + removeReaction, + timingSafeEqual, +} from "@open-inspect/shared"; +import { Hono, type Context } from "hono"; import type { Env, CompletionCallback, ToolCallCallback } from "./types"; import { extractAgentResponse } from "./completion/extractor"; import { buildCompletionBlocks, getFallbackText, truncateError } from "./completion/blocks"; @@ -97,23 +103,79 @@ function isValidToolCallPayload(payload: unknown): payload is ToolCallCallback { ); } -export const callbacksRouter = new Hono<{ Bindings: Env }>(); - /** - * Callback endpoint for session completion notifications. + * Payload for a scheduler-owned automation completion (Slack-triggered run). The + * SchedulerDO posts this when the run finishes. The bot posts the agent's final + * response into the triggering message's thread and clears the `eyes` reaction. + * + * The run-result fields (`sessionId`/`messageId` and the presentation fields) are + * optional so a control-plane/bot version skew degrades to a reaction clear only. */ -callbacksRouter.post("/complete", async (c) => { - const startTime = Date.now(); - // Use trace_id from control-plane if present, otherwise generate one - const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); - const payload = await c.req.json(); +interface AutomationCompletePayload { + channel: string; + /** The triggering message: thread anchor for the result and the `eyes` reaction target. */ + reactionMessageTs: string; + sessionId?: string; + messageId?: string; + success?: boolean; + error?: string; + repoFullName?: string; + model?: string; + reasoningEffort?: string; + signature: string; +} + +function isValidAutomationCompletePayload(payload: unknown): payload is AutomationCompletePayload { + if (!isPlainRecord(payload)) return false; + const p = payload; + return ( + typeof p.channel === "string" && + typeof p.reactionMessageTs === "string" && + typeof p.signature === "string" && + (p.sessionId === undefined || typeof p.sessionId === "string") && + (p.messageId === undefined || typeof p.messageId === "string") + ); +} + +/** Payload for a concurrency-skip ephemeral notice. */ +interface AutomationSkipPayload { + channel: string; + user: string; + threadTs: string; + signature: string; +} + +function isValidAutomationSkipPayload(payload: unknown): payload is AutomationSkipPayload { + if (!isPlainRecord(payload)) return false; + const p = payload; + return ( + typeof p.channel === "string" && + typeof p.user === "string" && + typeof p.threadTs === "string" && + typeof p.signature === "string" + ); +} - // Validate payload shape - if (!isValidPayload(payload)) { +/** + * Shared rejection guard for signed callback routes: validate the payload shape, + * require the signing secret, then verify the in-body HMAC signature. Returns a + * Response to short-circuit on any failure, or null when the request is + * authentic and the caller may proceed. Parsing stays in each route so the + * caller controls how a malformed body is surfaced. + */ +async function rejectInvalidCallback( + c: Context<{ Bindings: Env }>, + payload: unknown, + isValid: (p: unknown) => boolean, + opts: { path: string; traceId: string; startTime: number } +): Promise { + const { path, traceId, startTime } = opts; + + if (!isValid(payload)) { log.warn("http.request", { trace_id: traceId, http_method: "POST", - http_path: "/callbacks/complete", + http_path: path, http_status: 400, outcome: "rejected", reject_reason: "invalid_payload", @@ -122,12 +184,11 @@ callbacksRouter.post("/complete", async (c) => { return c.json({ error: "invalid payload" }, 400); } - // Verify signature (prevents external forgery) if (!c.env.INTERNAL_CALLBACK_SECRET) { log.error("http.request", { trace_id: traceId, http_method: "POST", - http_path: "/callbacks/complete", + http_path: path, http_status: 500, outcome: "error", reject_reason: "secret_not_configured", @@ -136,31 +197,56 @@ callbacksRouter.post("/complete", async (c) => { return c.json({ error: "not configured" }, 500); } - const isValid = await verifyCallbackSignature(payload, c.env.INTERNAL_CALLBACK_SECRET); - if (!isValid) { + const authentic = await verifyCallbackSignature( + payload as { signature: string }, + c.env.INTERNAL_CALLBACK_SECRET + ); + if (!authentic) { log.warn("http.request", { trace_id: traceId, http_method: "POST", - http_path: "/callbacks/complete", + http_path: path, http_status: 401, outcome: "rejected", reject_reason: "invalid_signature", - session_id: payload.sessionId, + session_id: (payload as { sessionId?: string }).sessionId, duration_ms: Date.now() - startTime, }); return c.json({ error: "unauthorized" }, 401); } + return null; +} + +export const callbacksRouter = new Hono<{ Bindings: Env }>(); + +/** + * Callback endpoint for session completion notifications. + */ +callbacksRouter.post("/complete", async (c) => { + const startTime = Date.now(); + // Use trace_id from control-plane if present, otherwise generate one + const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); + const payload = await c.req.json(); + + const rejection = await rejectInvalidCallback(c, payload, isValidPayload, { + path: "/callbacks/complete", + traceId, + startTime, + }); + if (rejection) return rejection; + const valid = payload as CompletionCallback; + // Process in background - c.executionCtx.waitUntil(handleCompletionCallback(payload, c.env, traceId)); + c.executionCtx.waitUntil(handleCompletionCallback(valid, c.env, traceId)); log.info("http.request", { trace_id: traceId, http_method: "POST", http_path: "/callbacks/complete", http_status: 200, - session_id: payload.sessionId, - message_id: payload.messageId, + session_id: valid.sessionId, + message_id: valid.messageId, duration_ms: Date.now() - startTime, }); @@ -190,63 +276,88 @@ callbacksRouter.post("/tool_call", async (c) => { return c.json({ error: "invalid payload" }, 400); } - if (!isValidToolCallPayload(payload)) { - log.warn("http.request", { - trace_id: traceId, - http_method: "POST", - http_path: "/callbacks/tool_call", - http_status: 400, - outcome: "rejected", - reject_reason: "invalid_payload", - duration_ms: Date.now() - startTime, - }); - return c.json({ error: "invalid payload" }, 400); - } - - if (!c.env.INTERNAL_CALLBACK_SECRET) { - log.error("http.request", { - trace_id: traceId, - http_method: "POST", - http_path: "/callbacks/tool_call", - http_status: 500, - outcome: "error", - reject_reason: "secret_not_configured", - duration_ms: Date.now() - startTime, - }); - return c.json({ error: "not configured" }, 500); - } - - const isValid = await verifyCallbackSignature(payload, c.env.INTERNAL_CALLBACK_SECRET); - if (!isValid) { - log.warn("http.request", { - trace_id: traceId, - http_method: "POST", - http_path: "/callbacks/tool_call", - http_status: 401, - outcome: "rejected", - reject_reason: "invalid_signature", - session_id: payload.sessionId, - duration_ms: Date.now() - startTime, - }); - return c.json({ error: "unauthorized" }, 401); - } + const rejection = await rejectInvalidCallback(c, payload, isValidToolCallPayload, { + path: "/callbacks/tool_call", + traceId, + startTime, + }); + if (rejection) return rejection; + const valid = payload as ToolCallCallback; - c.executionCtx.waitUntil(handleToolCallCallback(payload, c.env, traceId)); + c.executionCtx.waitUntil(handleToolCallCallback(valid, c.env, traceId)); log.info("http.request", { trace_id: traceId, http_method: "POST", http_path: "/callbacks/tool_call", http_status: 200, - session_id: payload.sessionId, - tool: payload.tool, - call_id: payload.callId, + session_id: valid.sessionId, + tool: valid.tool, + call_id: valid.callId, duration_ms: Date.now() - startTime, }); return c.json({ ok: true }); }); +/** + * Callback endpoint for Slack-triggered automation completion. Posts the agent's + * final response into the triggering message's thread and clears the `eyes` + * reaction. The SchedulerDO owns this fan-out (it holds the message coordinates). + */ +callbacksRouter.post("/automation-complete", async (c) => { + const startTime = Date.now(); + const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); + + let payload: unknown; + try { + payload = await c.req.json(); + } catch { + return c.json({ error: "invalid payload" }, 400); + } + + const rejection = await rejectInvalidCallback(c, payload, isValidAutomationCompletePayload, { + path: "/callbacks/automation-complete", + traceId, + startTime, + }); + if (rejection) return rejection; + + c.executionCtx.waitUntil( + handleAutomationComplete(payload as AutomationCompletePayload, c.env, traceId) + ); + + return c.json({ ok: true }); +}); + +/** + * Callback endpoint for a concurrency-skip notice. Posts a best-effort + * ephemeral reply to the message author when their message was dropped because + * a run is already active for the thread. + */ +callbacksRouter.post("/automation-skip", async (c) => { + const startTime = Date.now(); + const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); + + let payload: unknown; + try { + payload = await c.req.json(); + } catch { + return c.json({ error: "invalid payload" }, 400); + } + + const rejection = await rejectInvalidCallback(c, payload, isValidAutomationSkipPayload, { + path: "/callbacks/automation-skip", + traceId, + startTime, + }); + if (rejection) return rejection; + + c.executionCtx.waitUntil(handleAutomationSkip(payload as AutomationSkipPayload, c.env, traceId)); + + return c.json({ ok: true }); +}); + async function handleToolCallCallback( payload: ToolCallCallback, env: Env, @@ -375,3 +486,106 @@ async function handleCompletionCallback( // Don't throw - this is fire-and-forget } } + +/** + * Post a Slack-triggered run's result into the triggering message's thread and + * clear the `eyes` reaction. Reuses the interactive completion path + * (`handleCompletionCallback`) — which fetches the agent's response, posts it + * in-thread, and clears the reaction. Falls back to a reaction clear only when + * the run carries no session coordinates (control-plane/bot version skew). + * Fire-and-forget. + */ +async function handleAutomationComplete( + payload: AutomationCompletePayload, + env: Env, + traceId?: string +): Promise { + if (payload.sessionId && payload.messageId) { + await handleCompletionCallback( + { + sessionId: payload.sessionId, + messageId: payload.messageId, + success: payload.success ?? true, + error: payload.error, + timestamp: Date.now(), + signature: payload.signature, + context: { + source: "slack", + channel: payload.channel, + threadTs: payload.reactionMessageTs, + reactionMessageTs: payload.reactionMessageTs, + repoFullName: payload.repoFullName ?? "", + model: payload.model ?? "", + reasoningEffort: payload.reasoningEffort, + }, + }, + env, + traceId + ); + return; + } + + // No session coordinates — clear the reaction only. + const startTime = Date.now(); + const base = { + trace_id: traceId, + channel: payload.channel, + message_ts: payload.reactionMessageTs, + }; + try { + await clearThinkingReaction(env, payload.channel, payload.reactionMessageTs, traceId); + + log.info("callback.automation_complete", { + ...base, + outcome: "success", + duration_ms: Date.now() - startTime, + }); + } catch (error) { + log.error("callback.automation_complete", { + ...base, + outcome: "error", + error: error instanceof Error ? error : new Error(String(error)), + duration_ms: Date.now() - startTime, + }); + } +} + +/** + * Post a best-effort ephemeral "a run is already active" notice to the author + * whose message was dropped by the per-thread concurrency guard. + */ +async function handleAutomationSkip( + payload: AutomationSkipPayload, + env: Env, + traceId?: string +): Promise { + // Runs in waitUntil — postEphemeral can throw (network/runtime), so catch here + // or the background task rejects without route-level logging. + try { + const result = await postEphemeral( + env.SLACK_BOT_TOKEN, + payload.channel, + payload.user, + ":hourglass_flowing_sand: A run is already active for this thread — skipping the new trigger.", + { thread_ts: payload.threadTs } + ); + + if (!result.ok) { + log.warn("callback.automation_skip", { + trace_id: traceId, + channel: payload.channel, + user: payload.user, + outcome: "error", + slack_error: result.error, + }); + } + } catch (error) { + log.warn("callback.automation_skip", { + trace_id: traceId, + channel: payload.channel, + user: payload.user, + outcome: "error", + error: error instanceof Error ? error : new Error(String(error)), + }); + } +} diff --git a/packages/slack-bot/src/channel-trigger.test.ts b/packages/slack-bot/src/channel-trigger.test.ts new file mode 100644 index 000000000..5a17120bb --- /dev/null +++ b/packages/slack-bot/src/channel-trigger.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type * as SharedModule from "@open-inspect/shared"; +import type { Env } from "./types"; + +const { + mockVerifySlackSignature, + mockAuthTest, + mockGetChannelInfo, + mockGetPermalink, + mockAddReaction, +} = vi.hoisted(() => ({ + mockVerifySlackSignature: vi.fn(), + mockAuthTest: vi.fn(), + mockGetChannelInfo: vi.fn(), + mockGetPermalink: vi.fn(), + mockAddReaction: vi.fn(), +})); + +vi.mock("@open-inspect/shared", async () => { + const actual = await vi.importActual("@open-inspect/shared"); + return { + ...actual, // keep the real normalizeSlackEvent, internal-auth helpers, cache store + verifySlackSignature: mockVerifySlackSignature, + authTest: mockAuthTest, + getChannelInfo: mockGetChannelInfo, + getPermalink: mockGetPermalink, + addReaction: mockAddReaction, + }; +}); + +import app from "./index"; +import { clearLocalCache } from "./classifier/repos"; +import { clearBotUserIdCache } from "./bot-identity"; + +const BOT_USER_ID = "UBOT123"; + +function createMockKV() { + const store = new Map(); + return { + get: vi.fn(async (key: string, type?: string) => { + const value = store.get(key); + if (!value) return null; + return type === "json" ? JSON.parse(value) : value; + }), + put: vi.fn(async (key: string, value: string) => { + store.set(key, value); + }), + delete: vi.fn(async (key: string) => { + store.delete(key); + }), + }; +} + +/** Control-plane fetch mock: serves the watched-channel set and records forwards. */ +function makeControlPlaneFetch(watched: string[], triggered: number, steered: number) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/integration-settings/slack/watched-channels")) { + return new Response(JSON.stringify({ channels: watched }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.includes("/internal/slack-event")) { + const skipped = triggered === 0 && steered === 0 ? 1 : 0; + return new Response(JSON.stringify({ ok: true, triggered, skipped, steered }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }); +} + +function makeEnv( + opts: { triggersEnabled?: boolean; watched?: string[]; triggered?: number; steered?: number } = {} +): Env { + return { + SLACK_KV: createMockKV() as unknown as KVNamespace, + CONTROL_PLANE: { + fetch: makeControlPlaneFetch( + opts.watched ?? ["C123"], + opts.triggered ?? 1, + opts.steered ?? 0 + ), + } as unknown as Fetcher, + DEPLOYMENT_NAME: "test", + CONTROL_PLANE_URL: "https://control-plane.test", + WEB_APP_URL: "https://app.test", + DEFAULT_MODEL: "anthropic/claude-haiku-4-5", + CLASSIFICATION_MODEL: "anthropic/claude-haiku-4-5", + SLACK_BOT_TOKEN: "xoxb-test", + SLACK_SIGNING_SECRET: "secret", + INTERNAL_CALLBACK_SECRET: "internal-secret", + SLACK_TRIGGERS_ENABLED: opts.triggersEnabled ? "true" : undefined, + } as unknown as Env; +} + +function makeCtx() { + return { + props: {}, + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + } as unknown as ExecutionContext & { waitUntil: ReturnType }; +} + +async function flushWaitUntil(ctx: ReturnType, callIndex = 0): Promise { + await ctx.waitUntil.mock.calls[callIndex]?.[0]; +} + +function channelMessageRequest(event: Record): Request { + return new Request("http://localhost/events", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-slack-signature": "v0=test", + "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, + }, + body: JSON.stringify({ + type: "event_callback", + event_id: crypto.randomUUID(), + event_time: Math.floor(Date.now() / 1000), + team_id: "T123", + event: { + type: "message", + channel_type: "channel", + channel: "C123", + ts: "1700000000.000100", + user: "U999", + text: "the deploy job keeps failing", + ...event, + }, + }), + }); +} + +function forwardedSlackEvents(fetchMock: { mock: { calls: readonly (readonly unknown[])[] } }) { + return fetchMock.mock.calls + .filter(([input]) => String(input).includes("/internal/slack-event")) + .map(([, init]) => JSON.parse(String((init as RequestInit).body)) as Record); +} + +describe("channel-message automation triggers (POST /events)", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearLocalCache(); + clearBotUserIdCache(); + mockVerifySlackSignature.mockResolvedValue(true); + mockAuthTest.mockResolvedValue({ ok: true, user_id: BOT_USER_ID }); + mockGetChannelInfo.mockResolvedValue({ ok: true, channel: { id: "C123", name: "ops" } }); + mockGetPermalink.mockResolvedValue({ + ok: true, + permalink: "https://slack.com/archives/C123/p1700000000000100", + }); + mockAddReaction.mockResolvedValue({ ok: true }); + }); + + it("forwards a normalized event for a candidate message in a watched channel", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"] }); + const ctx = makeCtx(); + + const res = await app.fetch(channelMessageRequest({}), env, ctx); + expect(res.status).toBe(200); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(1); + expect(forwarded[0]).toMatchObject({ + source: "slack", + channelId: "C123", + channelName: "ops", + actorUserId: "U999", + text: "the deploy job keeps failing", + triggerKey: "slack:msg:C123:1700000000.000100", + }); + + // A run materialized → mark the triggering message with 👀. + expect(mockAddReaction).toHaveBeenCalledWith("xoxb-test", "C123", "1700000000.000100", "eyes"); + }); + + it("does not react when the forward matches no automation (triggered: 0)", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"], triggered: 0 }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({}), env, ctx); + await flushWaitUntil(ctx); + + expect(mockAddReaction).not.toHaveBeenCalled(); + }); + + it("reacts when a follow-up steers an active run (triggered: 0, steered: 1)", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"], triggered: 0, steered: 1 }); + const ctx = makeCtx(); + + // A reply in an active thread is forwarded and steers the running session; + // the bot still marks the follow-up message with 👀. + const res = await app.fetch( + channelMessageRequest({ ts: "1700000000.000200", thread_ts: "1700000000.000100" }), + env, + ctx + ); + expect(res.status).toBe(200); + await flushWaitUntil(ctx); + + expect(mockAddReaction).toHaveBeenCalledWith("xoxb-test", "C123", "1700000000.000200", "eyes"); + }); + + it("does not forward when the kill switch is off (default)", async () => { + const env = makeEnv({ triggersEnabled: false, watched: ["C123"] }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({}), env, ctx); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(0); + // auth.test isn't even reached when the feature is dark. + expect(mockAuthTest).not.toHaveBeenCalled(); + }); + + it("does not forward a message in an unwatched channel", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C-other"] }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({}), env, ctx); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(0); + }); + + it("suppresses a message that mentions the bot (handled by app_mention)", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"] }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({ text: `<@${BOT_USER_ID}> please deploy` }), env, ctx); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(0); + }); +}); diff --git a/packages/slack-bot/src/channel-trigger.ts b/packages/slack-bot/src/channel-trigger.ts new file mode 100644 index 000000000..a552f94ab --- /dev/null +++ b/packages/slack-bot/src/channel-trigger.ts @@ -0,0 +1,181 @@ +/** + * Channel-message automation trigger ingress. + * + * Ambient (non-mention) messages in watched Slack channels are normalized and + * forwarded to the control plane, which owns candidate selection, condition + * evaluation, and dedup. All ingress filtering lives here so the Slack event ack + * path stays cheap. + */ + +import { + addReaction, + getChannelInfo, + getPermalink, + normalizeSlackEvent, + type SlackAutomationEvent, + type SlackChannelMeta, +} from "@open-inspect/shared"; +import type { Env } from "./types"; +import { isChannelTriggerCandidate } from "./dm-utils"; +import { getWatchedChannels } from "./classifier/repos"; +import { getBotUserId } from "./bot-identity"; +import { getAuthHeaders } from "./internal-auth"; +import { createLogger } from "./logger"; + +const log = createLogger("channel-trigger"); + +/** + * Ingest an ambient channel message and, if it is a trigger candidate in a + * watched channel, normalize and forward it to the control plane's + * `/internal/slack-event` endpoint for automation matching. + * + * All filtering happens here so the Slack event ack path stays cheap: + * 1. Kill switch (`SLACK_TRIGGERS_ENABLED`) — dark by default. + * 2. Bot identity (fail closed — no id ⇒ skip, since mention suppression needs it). + * 3. Structural candidacy + mention suppression (`isChannelTriggerCandidate`). + * 4. Watched-channel pre-filter (cached) — avoids forwarding every channel message. + * 5. Normalize (+ best-effort channel name/permalink) and forward. + */ +export async function handleChannelTrigger( + event: { + type: string; + subtype?: string; + channel_type?: string; + text?: string; + channel?: string; + ts?: string; + user?: string; + thread_ts?: string; + bot_id?: string; + }, + env: Env, + traceId: string | undefined +): Promise { + if (env.SLACK_TRIGGERS_ENABLED !== "true") { + return; + } + + const botUserId = await getBotUserId(env, traceId); + if (!botUserId) { + log.warn("slack_trigger.skip", { trace_id: traceId, reason: "no_bot_user_id" }); + return; + } + + if (!isChannelTriggerCandidate(event, botUserId)) { + return; + } + + const channel = event.channel!; + const watched = await getWatchedChannels(env, traceId); + if (!watched.has(channel)) { + return; + } + + const channelMeta = await fetchChannelMeta(env, channel, event.ts!); + const normalized = normalizeSlackEvent( + { + channel, + ts: event.ts!, + thread_ts: event.thread_ts, + user: event.user!, + text: event.text!, + }, + botUserId, + channelMeta + ); + // Null when the message was only the bot mention (no usable text after strip). + if (!normalized) { + return; + } + + await forwardSlackEvent(env, normalized, traceId); +} + +/** + * Best-effort fetch of the channel name + message permalink used to enrich the + * agent's context block. Both are optional: on any Slack API failure the + * corresponding field is left undefined and the normalizer falls back to the + * raw channel id. + */ +async function fetchChannelMeta(env: Env, channel: string, ts: string): Promise { + const [info, link] = await Promise.all([ + getChannelInfo(env.SLACK_BOT_TOKEN, channel), + getPermalink(env.SLACK_BOT_TOKEN, channel, ts), + ]); + return { + channelName: info.ok ? info.channel.name : undefined, + permalink: link.ok ? link.permalink : undefined, + }; +} + +/** + * Forward a normalized Slack automation event to the control plane. The + * control plane owns candidate selection, condition evaluation, and dedup; the + * bot's job ends at delivery. + */ +async function forwardSlackEvent( + env: Env, + event: SlackAutomationEvent, + traceId: string | undefined +): Promise { + const startTime = Date.now(); + try { + const headers = await getAuthHeaders(env, traceId); + const response = await env.CONTROL_PLANE.fetch("https://internal/internal/slack-event", { + method: "POST", + headers, + body: JSON.stringify(event), + }); + + if (!response.ok) { + log.error("slack_trigger.forward", { + trace_id: traceId, + outcome: "error", + http_status: response.status, + channel_id: event.channelId, + duration_ms: Date.now() - startTime, + }); + return; + } + + const result = (await response.json()) as { + triggered?: number; + skipped?: number; + steered?: number; + }; + log.info("slack_trigger.forward", { + trace_id: traceId, + outcome: "success", + channel_id: event.channelId, + triggered: result.triggered ?? 0, + skipped: result.skipped ?? 0, + steered: result.steered ?? 0, + duration_ms: Date.now() - startTime, + }); + + // React 👀 on the triggering message when a new run materializes or a + // follow-up steers an already-active run's session, so unmatched channel + // chatter stays unmarked. The reaction clears when the work finishes — via + // /callbacks/automation-complete for a new run, or /callbacks/complete for a + // steered follow-up turn. + if ((result.triggered ?? 0) >= 1 || (result.steered ?? 0) >= 1) { + const reaction = await addReaction(env.SLACK_BOT_TOKEN, event.channelId, event.ts, "eyes"); + if (!reaction.ok && reaction.error !== "already_reacted") { + log.warn("slack_trigger.react", { + trace_id: traceId, + channel_id: event.channelId, + message_ts: event.ts, + slack_error: reaction.error, + }); + } + } + } catch (e) { + log.error("slack_trigger.forward", { + trace_id: traceId, + outcome: "error", + channel_id: event.channelId, + error: e instanceof Error ? e : new Error(String(e)), + duration_ms: Date.now() - startTime, + }); + } +} diff --git a/packages/slack-bot/src/classifier/repos.test.ts b/packages/slack-bot/src/classifier/repos.test.ts index 2b242f663..5e61415ac 100644 --- a/packages/slack-bot/src/classifier/repos.test.ts +++ b/packages/slack-bot/src/classifier/repos.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Env } from "../types"; -import { clearLocalCache, getAvailableRepos, getRoutingRules } from "./repos"; +import { clearLocalCache, getAvailableRepos, getRoutingRules, getWatchedChannels } from "./repos"; function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -201,3 +201,77 @@ describe("getAvailableRepos", () => { expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); }); }); + +describe("getWatchedChannels", () => { + beforeEach(() => { + clearLocalCache(); + vi.clearAllMocks(); + }); + + it("returns the watched channel set from the control plane and stores it in KV", async () => { + const env = makeEnv(jsonResponse({ channels: ["C1", "C2"] })); + + const channels = await getWatchedChannels(env, "trace"); + + expect(channels).toEqual(new Set(["C1", "C2"])); + expect(env.SLACK_KV.put).toHaveBeenCalledWith( + "slack:watched-channels", + JSON.stringify(["C1", "C2"]), + { expirationTtl: 300 } + ); + }); + + it("returns an empty set when the response has no channels", async () => { + const env = makeEnv(jsonResponse({})); + expect(await getWatchedChannels(env)).toEqual(new Set()); + }); + + it("fails closed to an empty set on a non-OK response with no cache", async () => { + const env = makeEnv(new Response("error", { status: 500 })); + expect(await getWatchedChannels(env)).toEqual(new Set()); + }); + + it("fails closed to an empty set when the fetch throws and no cache exists", async () => { + const env = makeEnv(new Error("control plane unreachable")); + expect(await getWatchedChannels(env)).toEqual(new Set()); + }); + + it("serves the watch-list from the KV cache without hitting the control plane", async () => { + const env = { + SLACK_KV: { + get: vi.fn().mockResolvedValue(["C7", "C8"]), + put: vi.fn().mockResolvedValue(undefined), + }, + CONTROL_PLANE: { + fetch: vi.fn().mockResolvedValue(new Response("error", { status: 503 })), + }, + } as unknown as Env; + + expect(await getWatchedChannels(env, "trace")).toEqual(new Set(["C7", "C8"])); + expect(env.SLACK_KV.get).toHaveBeenCalledWith("slack:watched-channels", "json"); + // KV is the cache: a hit short-circuits before the control plane is consulted. + expect(env.CONTROL_PLANE.fetch).not.toHaveBeenCalled(); + }); + + it("reads through the KV cache on a subsequent call (no in-memory tier)", async () => { + let stored: unknown = null; + const env = { + SLACK_KV: { + get: vi.fn().mockImplementation(async () => stored), + put: vi.fn().mockImplementation(async (_key: string, value: string) => { + stored = JSON.parse(value); + }), + }, + CONTROL_PLANE: { + fetch: vi.fn().mockResolvedValue(jsonResponse({ channels: ["C1"] })), + }, + } as unknown as Env; + + expect(await getWatchedChannels(env)).toEqual(new Set(["C1"])); + expect(await getWatchedChannels(env)).toEqual(new Set(["C1"])); + + // First call misses KV and hits the control plane; the second is served from KV. + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); + expect(env.SLACK_KV.get).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/slack-bot/src/classifier/repos.ts b/packages/slack-bot/src/classifier/repos.ts index a42fd9a70..760defd80 100644 --- a/packages/slack-bot/src/classifier/repos.ts +++ b/packages/slack-bot/src/classifier/repos.ts @@ -33,8 +33,8 @@ const FALLBACK_REPOS: RepoConfig[] = []; const LOCAL_CACHE_TTL_MS = 60 * 1000; /** - * Expiration for the shared KV caches (repos + routing rules), in seconds — - * the unit Cloudflare KV's `expirationTtl` expects. + * Expiration for the shared KV caches (repos, routing rules, watched channels), + * in seconds — the unit Cloudflare KV's `expirationTtl` expects. */ const KV_CACHE_TTL_SECONDS = 300; @@ -57,6 +57,8 @@ let routingRulesLocalCache: { const ROUTING_RULES_CACHE_KEY = "slack:routing-rules"; +const WATCHED_CHANNELS_CACHE_KEY = "slack:watched-channels"; + /** * Convert a control plane repo to a RepoConfig. * Normalizes identifiers to lowercase for consistent comparison. @@ -277,6 +279,73 @@ async function getRoutingRulesFromCache(env: Env): Promise { return []; } +/** + * Channel IDs watched by enabled `slack_event` automations, used to pre-filter + * inbound channel messages. KV-backed with no in-memory tier: served from the + * KV last-known-good copy and refreshed from the control plane on a miss. + * **Fails closed** to an empty set — an unknown watch-list forwards no channel + * messages, so an outage pauses triggers rather than forwarding every message. + */ +export async function getWatchedChannels(env: Env, traceId?: string): Promise> { + const kv = createKvCacheStore(env.SLACK_KV); + + try { + const cached = await kv.get(WATCHED_CHANNELS_CACHE_KEY, "json"); + if (Array.isArray(cached)) { + return new Set(cached as string[]); + } + } catch (e) { + log.warn("kv.get", { + key_prefix: "watched_channels_cache", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + + const startTime = Date.now(); + try { + const response = await controlPlaneFetch( + env, + "/integration-settings/slack/watched-channels", + traceId + ); + + if (!response.ok) { + log.warn("control_plane.fetch_watched_channels", { + trace_id: traceId, + outcome: "error", + http_status: response.status, + duration_ms: Date.now() - startTime, + }); + return new Set(); + } + + const data = (await response.json()) as { channels?: string[] }; + const channels = Array.isArray(data.channels) ? data.channels : []; + + try { + await kv.put(WATCHED_CHANNELS_CACHE_KEY, JSON.stringify(channels), { + expirationTtl: KV_CACHE_TTL_SECONDS, + }); + } catch (e) { + log.warn("kv.put", { + trace_id: traceId, + key_prefix: "watched_channels_cache", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + + return new Set(channels); + } catch (e) { + log.warn("control_plane.fetch_watched_channels", { + trace_id: traceId, + outcome: "error", + error: e instanceof Error ? e : new Error(String(e)), + duration_ms: Date.now() - startTime, + }); + return new Set(); + } +} + /** * Filter repos by a free-text query against their full name (case-insensitive). * Returns all repos when the query is empty — the canonical filter shared by the diff --git a/packages/slack-bot/src/dm-utils.test.ts b/packages/slack-bot/src/dm-utils.test.ts index 646f29e12..dece94ff7 100644 --- a/packages/slack-bot/src/dm-utils.test.ts +++ b/packages/slack-bot/src/dm-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { stripMentions, isDmDispatchable } from "./dm-utils"; +import { stripMentions, isDmDispatchable, isChannelTriggerCandidate } from "./dm-utils"; describe("stripMentions", () => { it("removes a single mention", () => { @@ -67,3 +67,70 @@ describe("isDmDispatchable", () => { expect(isDmDispatchable({ ...baseEvent, type: "app_mention" })).toBe(false); }); }); + +describe("isChannelTriggerCandidate", () => { + const BOT = "UBOT123"; + const baseEvent = { + type: "message", + channel_type: "channel", + text: "the deploy job is failing again", + channel: "C12345", + ts: "1234567890.123456", + user: "U99999", + }; + + it("returns true for a plain public-channel message", () => { + expect(isChannelTriggerCandidate(baseEvent, BOT)).toBe(true); + }); + + it("returns true for a private-channel (group) message", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, channel_type: "group" }, BOT)).toBe(true); + }); + + it("returns false for DM and group-DM channel types", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, channel_type: "im" }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, channel_type: "mpim" }, BOT)).toBe(false); + }); + + it("returns false when a subtype is present (edits, joins, bot posts)", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, subtype: "message_changed" }, BOT)).toBe( + false + ); + expect(isChannelTriggerCandidate({ ...baseEvent, subtype: "channel_join" }, BOT)).toBe(false); + }); + + it("returns false when bot_id is set", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, bot_id: "B1" }, BOT)).toBe(false); + }); + + it("returns false when required fields are missing", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, text: undefined }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, channel: undefined }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, ts: undefined }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, user: undefined }, BOT)).toBe(false); + }); + + it("returns false for the bot's own messages", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, user: BOT }, BOT)).toBe(false); + }); + + it("suppresses messages that mention the bot (handled by app_mention)", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, text: `<@${BOT}> please deploy` }, BOT)).toBe( + false + ); + // piped rendering <@UBOT123|assistant> + expect( + isChannelTriggerCandidate({ ...baseEvent, text: `<@${BOT}|assistant> deploy` }, BOT) + ).toBe(false); + }); + + it("still triggers when a different user is mentioned", () => { + expect( + isChannelTriggerCandidate({ ...baseEvent, text: "<@U55555> can you deploy?" }, BOT) + ).toBe(true); + }); + + it("returns false for app_mention event type", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, type: "app_mention" }, BOT)).toBe(false); + }); +}); diff --git a/packages/slack-bot/src/dm-utils.ts b/packages/slack-bot/src/dm-utils.ts index f6ca361bd..ee091a0ee 100644 --- a/packages/slack-bot/src/dm-utils.ts +++ b/packages/slack-bot/src/dm-utils.ts @@ -33,3 +33,51 @@ export function isDmDispatchable(event: { !!event.user ); } + +/** + * Returns true when `text` contains a mention of `userId`, matching both the + * bare `<@U…>` and piped `<@U…|display-name>` renderings. Slack user IDs are + * `[A-Z0-9]+`, so the id is safe to splice into the pattern verbatim. + */ +function mentionsUser(text: string, userId: string): boolean { + return new RegExp(`<@${userId}(?:\\|[^>]*)?>`).test(text); +} + +/** + * Returns true if a Slack channel message should be considered an automation + * trigger candidate (an ambient message that may match a watched automation). + * + * This is the structural pre-filter the bot applies before normalizing and + * forwarding to the control plane. It drops: + * - non-`message` events and any subtype (edits, joins, bot posts, …) + * - DM (`im`) and group-DM (`mpim`) channels — handled by the DM path + * - messages from the bot itself + * - messages that @mention the bot — those are explicit requests dispatched by + * the `app_mention` path; processing them here too would double-handle. + * + * `botUserId` must be the bot's own Slack user id (from `auth.test`); the caller + * resolves it and fails closed when it cannot, so mention suppression is always + * applied against a known id. + */ +export function isChannelTriggerCandidate( + event: { + type: string; + subtype?: string; + channel_type?: string; + text?: string; + channel?: string; + ts?: string; + user?: string; + bot_id?: string; + }, + botUserId: string +): boolean { + if (event.type !== "message") return false; + if (event.subtype) return false; + if (event.bot_id) return false; + if (event.channel_type !== "channel" && event.channel_type !== "group") return false; + if (!event.text || !event.channel || !event.ts || !event.user) return false; + if (event.user === botUserId) return false; + if (mentionsUser(event.text, botUserId)) return false; + return true; +} diff --git a/packages/slack-bot/src/index.ts b/packages/slack-bot/src/index.ts index ad026aaf5..71661a9c7 100644 --- a/packages/slack-bot/src/index.ts +++ b/packages/slack-bot/src/index.ts @@ -26,8 +26,9 @@ import { import { resolveUserNames } from "@open-inspect/shared"; import { createClassifier } from "./classifier"; import { getAvailableRepos } from "./classifier/repos"; +import { handleChannelTrigger } from "./channel-trigger"; +import { getAuthHeaders } from "./internal-auth"; import { callbacksRouter } from "./callbacks"; -import { buildInternalAuthHeaders } from "@open-inspect/shared"; import { createLogger } from "./logger"; import { createKvCacheStore } from "@open-inspect/shared"; import { getUserRepoBranchPreference } from "./branch-preferences"; @@ -46,16 +47,6 @@ const log = createLogger("handler"); type BackgroundTaskScheduler = (promise: Promise) => void; -/** - * Build authenticated headers for control plane requests. - */ -async function getAuthHeaders(env: Env, traceId?: string): Promise> { - return { - "Content-Type": "application/json", - ...(await buildInternalAuthHeaders(env.INTERNAL_CALLBACK_SECRET, traceId)), - }; -} - /** * Create a session via the control plane. */ @@ -744,6 +735,14 @@ async function handleSlackEvent( // Handle app_mention events if (event.type === "app_mention" && event.text && event.channel && event.ts) { await handleAppMention(event as Required, env, traceId, scheduleBackground); + return; + } + + // Handle ambient channel messages as potential automation triggers. + // `handleChannelTrigger` applies the kill switch, candidacy, and watched-channel + // gates; non-candidates (DMs already handled above, mentions, bot posts) are dropped. + if (event.type === "message") { + await handleChannelTrigger(event, env, traceId); } } diff --git a/packages/slack-bot/src/internal-auth.ts b/packages/slack-bot/src/internal-auth.ts new file mode 100644 index 000000000..74d42feb0 --- /dev/null +++ b/packages/slack-bot/src/internal-auth.ts @@ -0,0 +1,14 @@ +/** + * Build authenticated headers for internal control-plane requests (HMAC auth). + * Shared by every slack-bot → control-plane POST. + */ + +import { buildInternalAuthHeaders } from "@open-inspect/shared"; +import type { Env } from "./types"; + +export async function getAuthHeaders(env: Env, traceId?: string): Promise> { + return { + "Content-Type": "application/json", + ...(await buildInternalAuthHeaders(env.INTERNAL_CALLBACK_SECRET, traceId)), + }; +} diff --git a/packages/slack-bot/src/types/index.ts b/packages/slack-bot/src/types/index.ts index 122eabfde..62a9bac7e 100644 --- a/packages/slack-bot/src/types/index.ts +++ b/packages/slack-bot/src/types/index.ts @@ -19,6 +19,12 @@ export interface Env { DEFAULT_MODEL: string; CLASSIFICATION_MODEL: string; APP_NAME?: string; + /** + * Kill switch for Slack channel-message automation triggers. The bot only + * ingests/forwards channel messages when this is exactly "true". Dark by + * default — any other value (or unset) disables the feature entirely. + */ + SLACK_TRIGGERS_ENABLED?: string; // Secrets SLACK_BOT_TOKEN: string; diff --git a/packages/web/src/app/(app)/automations/[id]/page.tsx b/packages/web/src/app/(app)/automations/[id]/page.tsx index 6ff4f2246..31569e12a 100644 --- a/packages/web/src/app/(app)/automations/[id]/page.tsx +++ b/packages/web/src/app/(app)/automations/[id]/page.tsx @@ -8,6 +8,7 @@ import { useSidebarContext } from "@/components/sidebar-layout"; import { useAutomation, useAutomationRuns } from "@/hooks/use-automations"; import { RunHistory } from "@/components/automations/run-history"; import { AutomationStatusBadge } from "@/components/automations/automation-status-badge"; +import { ConditionSummary } from "@/components/automations/condition-summary"; import { Button } from "@/components/ui/button"; import { ErrorBanner } from "@/components/ui/error-banner"; import { SidebarIcon, BackIcon, PencilIcon } from "@/components/ui/icons"; @@ -216,6 +217,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: webhook: "Inbound Webhook", github_event: "GitHub Event", linear_event: "Linear Event", + slack_event: "Slack Message", }[automation.triggerType] || automation.triggerType} {automation.eventType && ( ({automation.eventType}) @@ -246,20 +248,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: )} {automation.triggerConfig?.conditions && automation.triggerConfig.conditions.length > 0 && ( -
-
Conditions
-
- {automation.triggerConfig.conditions.map((c, i) => ( - - {c.type}: {c.operator}{" "} - {Array.isArray(c.value) ? c.value.join(", ") : String(c.value)} - - ))} -
-
+ )}
Model
diff --git a/packages/web/src/app/api/integrations/slack/channels/route.ts b/packages/web/src/app/api/integrations/slack/channels/route.ts new file mode 100644 index 000000000..ece24a5a3 --- /dev/null +++ b/packages/web/src/app/api/integrations/slack/channels/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { controlPlaneFetch } from "@/lib/control-plane"; +import type { SlackChannelListing } from "@open-inspect/shared"; + +interface ControlPlaneChannelsResponse { + channels: SlackChannelListing[]; + error?: string; +} + +/** + * List Slack channels for the automation channel picker. Proxies to the control + * plane (which holds the bot token) and always responds 200 with a `channels` + * array so the picker degrades to manual channel-ID entry on any failure. + */ +export async function GET() { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const response = await controlPlaneFetch("/integration-settings/slack/channels"); + if (!response.ok) { + const error = await response.text(); + console.error("Control plane slack channels error:", error); + return NextResponse.json({ channels: [], error: "fetch_failed" }); + } + const data: ControlPlaneChannelsResponse = await response.json(); + return NextResponse.json({ channels: data.channels ?? [], error: data.error }); + } catch (error) { + console.error("Error fetching slack channels:", error); + return NextResponse.json({ channels: [], error: "fetch_failed" }); + } +} diff --git a/packages/web/src/components/automations/automation-form.test.tsx b/packages/web/src/components/automations/automation-form.test.tsx index 80a0d3b32..ba9272b62 100644 --- a/packages/web/src/components/automations/automation-form.test.tsx +++ b/packages/web/src/components/automations/automation-form.test.tsx @@ -58,6 +58,13 @@ vi.mock("@/hooks/use-enabled-models", () => ({ }), })); +// The SlackChannelPicker (rendered for slack_channel conditions) lists channels via +// useSession-backed SWR. The form tests don't exercise channel listing, so stub it out +// to avoid needing a SessionProvider. +vi.mock("@/hooks/use-slack-channels", () => ({ + useSlackChannels: () => ({ channels: [], loading: false }), +})); + vi.mock("@/components/ui/combobox", () => ({ Combobox: ({ children }: { children: ReactNode }) =>
{children}
, })); @@ -165,6 +172,82 @@ describe("automation cron submission", () => { }); }); +describe("slack_event automation", () => { + const slackBase = { + name: "Triage Slack reports", + repoOwner: "open-inspect", + repoName: "background-agents", + baseBranch: "main", + model: "openai/gpt-5.4", + instructions: "Triage the reported issue.", + triggerType: "slack_event" as const, + }; + + const validConditions = { + conditions: [ + { type: "slack_channel" as const, operator: "any_of" as const, value: ["C1"] }, + { type: "text_match" as const, operator: "contains" as const, value: { pattern: "deploy" } }, + ], + }; + + it("blocks submit until a slack_channel condition exists", () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText(/require at least one Slack Channel/)).toBeInTheDocument(); + }); + + it("submits a valid slack_event", () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + + fireEvent.submit(container.querySelector("form")!); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + triggerType: "slack_event", + triggerConfig: validConditions, + }); + }); + + it("submits a slack_event with only a slack_channel condition (no text_match)", () => { + const onSubmit = vi.fn(); + const channelOnly = { + conditions: [{ type: "slack_channel" as const, operator: "any_of" as const, value: ["C1"] }], + }; + const { container } = render( + + ); + + fireEvent.submit(container.querySelector("form")!); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ triggerConfig: channelOnly }); + }); +}); + describe("instructions character counter", () => { const baseInitialValues = { name: "Daily review", diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index 44eadabea..c49188248 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -11,6 +11,7 @@ import { type AutomationTriggerType, type AutomationEventSource, type TriggerCondition, + type TriggerConfig, } from "@open-inspect/shared"; import { useRepos } from "@/hooks/use-repos"; import { useBranches } from "@/hooks/use-branches"; @@ -91,7 +92,7 @@ export interface AutomationFormValues { instructions: string; triggerType: AutomationTriggerType; eventType?: string; - triggerConfig?: { conditions: TriggerCondition[] }; + triggerConfig?: TriggerConfig; sentryClientSecret?: string; } @@ -134,7 +135,11 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au const [sentryClientSecret, setSentryClientSecret] = useState(""); const isSchedule = triggerType === "schedule"; + const isSlack = triggerType === "slack_event"; const isScheduleValid = !isSchedule || isValidCron(scheduleCron); + // Mirror the server rule: a slack_event needs a slack_channel. A text_match is + // optional — without one it fires on every message in the watched channel. + const slackConditionsValid = !isSlack || conditions.some((c) => c.type === "slack_channel"); // The model we display and submit. The selector only lists enabled models, so // a disabled default (blank create), a disabled saved model (edit), or a @@ -186,6 +191,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au if (loadingModels) return; if (!name.trim() || !selectedRepo || !instructions.trim() || !isScheduleValid) return; if (triggerType === "sentry" && mode === "create" && !sentryClientSecret.trim()) return; + if (!slackConditionsValid) return; if (showEventTypeSelector && !eventType) { setEventTypeError("Event type is required."); return; @@ -258,6 +264,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au webhook: "Inbound Webhook", github_event: "GitHub Event", linear_event: "Linear Event", + slack_event: "Slack Message", }[triggerType] || triggerType} (cannot be changed)
@@ -511,6 +518,11 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au Optional filters on incoming events. When you add conditions, every condition must pass before a run starts. + {isSlack && !slackConditionsValid && ( +

+ Slack triggers require at least one Slack Channel condition. +

+ )} )} @@ -568,6 +580,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au !selectedRepo || !instructions.trim() || !isScheduleValid || + !slackConditionsValid || (showEventTypeSelector && !eventType) || (triggerType === "sentry" && mode === "create" && !sentryClientSecret.trim()) } diff --git a/packages/web/src/components/automations/condition-builder.test.tsx b/packages/web/src/components/automations/condition-builder.test.tsx new file mode 100644 index 000000000..b90074ff3 --- /dev/null +++ b/packages/web/src/components/automations/condition-builder.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +/// + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import type { TriggerCondition } from "@open-inspect/shared"; +import { ConditionBuilder } from "./condition-builder"; + +type ChannelListing = { id: string; name: string; isPrivate: boolean; isMember: boolean }; +// Mutable per-test channel listing; the hoisted use-slack-channels mock closes over it. +let slackChannelsMock: { channels: ChannelListing[]; loading: boolean; error?: string }; +vi.mock("@/hooks/use-slack-channels", () => ({ + useSlackChannels: () => slackChannelsMock, +})); + +expect.extend(matchers); +afterEach(cleanup); +beforeEach(() => { + slackChannelsMock = { channels: [], loading: false }; + // jsdom doesn't implement scrollIntoView, which the Combobox calls when opened. + Element.prototype.scrollIntoView = vi.fn(); +}); + +function renderBuilder(conditions: TriggerCondition[]) { + const onChange = vi.fn(); + render(); + return onChange; +} + +describe("ConditionBuilder — slack editors", () => { + it("edits a text_match pattern and toggles case-insensitivity", () => { + const onChange = renderBuilder([ + { type: "text_match", operator: "contains", value: { pattern: "" } }, + ]); + + fireEvent.change(screen.getByPlaceholderText(/Substring to look for/), { + target: { value: "deploy" }, + }); + expect(onChange).toHaveBeenLastCalledWith([ + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ]); + + fireEvent.click(screen.getByLabelText("Case-insensitive")); + expect(onChange).toHaveBeenLastCalledWith([ + { type: "text_match", operator: "contains", value: { pattern: "", flags: "i" } }, + ]); + }); + + it("falls back to manual channel-ID entry when channels can't be listed", () => { + slackChannelsMock = { channels: [], loading: false, error: "not_configured" }; + const onChange = renderBuilder([{ type: "slack_channel", operator: "any_of", value: [] }]); + + const input = screen.getByPlaceholderText(/Add channel ID/); + fireEvent.change(input, { target: { value: "C0123ABCD" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onChange).toHaveBeenLastCalledWith([ + { type: "slack_channel", operator: "any_of", value: ["C0123ABCD"] }, + ]); + }); + + it("picks a channel by name and stores its ID", () => { + slackChannelsMock = { + channels: [ + { id: "C0123ABCD", name: "general", isPrivate: false, isMember: true }, + { id: "C9999", name: "random", isPrivate: false, isMember: true }, + ], + loading: false, + }; + const onChange = renderBuilder([{ type: "slack_channel", operator: "any_of", value: [] }]); + + fireEvent.click(screen.getByText("Add channel...")); + fireEvent.click(screen.getByText("#general")); + + expect(onChange).toHaveBeenLastCalledWith([ + { type: "slack_channel", operator: "any_of", value: ["C0123ABCD"] }, + ]); + }); + + it("resolves selected channel IDs to #name chips", () => { + slackChannelsMock = { + channels: [{ id: "C0123ABCD", name: "general", isPrivate: false, isMember: true }], + loading: false, + }; + renderBuilder([{ type: "slack_channel", operator: "any_of", value: ["C0123ABCD"] }]); + + expect(screen.getByText("#general")).toBeInTheDocument(); + }); + + it("renders the slack_actor include/exclude control and user input", () => { + renderBuilder([{ type: "slack_actor", operator: "include", value: [] }]); + expect(screen.getByText("Slack User")).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Add Slack user ID/)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/automations/condition-builder.tsx b/packages/web/src/components/automations/condition-builder.tsx index dadd68827..8b619d267 100644 --- a/packages/web/src/components/automations/condition-builder.tsx +++ b/packages/web/src/components/automations/condition-builder.tsx @@ -1,10 +1,12 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { TriggerCondition, AutomationEventSource, JsonPathFilter } from "@open-inspect/shared"; import { conditionRegistry } from "@open-inspect/shared"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { ChevronDownIcon } from "@/components/ui/icons"; import { Select, SelectContent, @@ -12,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useSlackChannels } from "@/hooks/use-slack-channels"; interface ConditionBuilderProps { conditions: TriggerCondition[]; @@ -30,8 +33,13 @@ const CONDITION_LABELS: Record = { actor: "Actor", check_conclusion: "Check Conclusion", linear_status: "Linear Status", + text_match: "Message Text", + slack_channel: "Slack Channel", + slack_actor: "Slack User", }; +const TEXT_MATCH_MODES = ["contains", "exact", "regex"] as const; + const SENTRY_LEVELS = ["warning", "error", "fatal"]; export const CHECK_CONCLUSION_OPTIONS = [ "success", @@ -85,6 +93,15 @@ export function ConditionBuilder({ conditions, onChange, triggerSource }: Condit value: CHECK_CONCLUSION_OPTIONS[0], }; break; + case "text_match": + newCondition = { type: "text_match", operator: "contains", value: { pattern: "" } }; + break; + case "slack_channel": + newCondition = { type: "slack_channel", operator: "any_of", value: [] }; + break; + case "slack_actor": + newCondition = { type: "slack_actor", operator: "include", value: [] }; + break; default: return; } @@ -242,11 +259,199 @@ function ConditionEditor({ ); + case "text_match": { + const flags = condition.value.flags ?? ""; + const caseInsensitive = flags.includes("i"); + return ( +
+ + + onChange({ ...condition, value: { ...condition.value, pattern: e.target.value } }) + } + placeholder={ + condition.operator === "regex" + ? "Regular expression, e.g. \\b(deploy|release)\\b" + : condition.operator === "exact" + ? "Exact message text to match" + : "Substring to look for, e.g. deploy" + } + className="text-xs" + /> + +
+ ); + } + case "slack_channel": + return ( + onChange({ ...condition, value })} + /> + ); + case "slack_actor": + return ( +
+ + onChange({ ...condition, value })} + placeholder="Add Slack user ID (e.g. U0123ABCD)..." + /> +
+ ); default: return
Configuration not available
; } } +/** + * Channel selector for the `slack_channel` condition. Resolves channel names + * from the workspace listing and stores channel IDs. Falls back to manual ID + * entry when the listing is unavailable (no bot token, missing scopes, or a + * Slack API failure), so the condition is always editable. + */ +function SlackChannelPicker({ + values, + onChange, +}: { + values: string[]; + onChange: (values: string[]) => void; +}) { + const { channels, loading } = useSlackChannels(); + const byId = useMemo(() => new Map(channels.map((c) => [c.id, c])), [channels]); + + const add = (id: string) => { + const trimmed = id.trim(); + if (trimmed && !values.includes(trimmed)) onChange([...values, trimmed]); + }; + const remove = (id: string) => onChange(values.filter((v) => v !== id)); + + // Degraded mode: no channel list (no bot token, missing scopes, or empty + // workspace). Fall back to manual channel-ID entry so the trigger still works. + if (!loading && channels.length === 0) { + return ( +
+ +

+ Couldn't list Slack channels — add channel IDs manually. Check that the Slack app has + the channels:read / groups:read scopes and that the bot is + invited to the channel. +

+
+ ); + } + + // Unselected channels, bot-member first, then alphabetical. + const options: ComboboxOption[] = channels + .filter((c) => !values.includes(c.id)) + .sort((a, b) => Number(b.isMember) - Number(a.isMember) || a.name.localeCompare(b.name)) + .map((c) => ({ + value: c.id, + label: `#${c.name}`, + description: !c.isMember ? "bot not in channel" : c.isPrivate ? "private" : undefined, + })); + const someNotMember = values.some((id) => byId.get(id)?.isMember === false); + + return ( +
+ {values.length > 0 && ( +
+ {values.map((id) => { + const ch = byId.get(id); + return ( + + {ch ? `#${ch.name}` : id} + + + ); + })} +
+ )} + + + {loading ? "Loading channels..." : "Add channel..."} + + + + {someNotMember && ( +

+ The bot isn't a member of some selected channels and won't receive their + messages until it's invited. +

+ )} +
+ ); +} + function TagInput({ values, onChange, diff --git a/packages/web/src/components/automations/condition-summary.test.tsx b/packages/web/src/components/automations/condition-summary.test.tsx new file mode 100644 index 000000000..e8337dd78 --- /dev/null +++ b/packages/web/src/components/automations/condition-summary.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +/// + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import type { TriggerCondition } from "@open-inspect/shared"; +import { ConditionSummary } from "./condition-summary"; + +type ChannelListing = { id: string; name: string; isPrivate: boolean; isMember: boolean }; +// Mutable per-test channel listing; the hoisted use-slack-channels mock closes over it. +let slackChannelsMock: { channels: ChannelListing[]; loading: boolean; error?: string }; +vi.mock("@/hooks/use-slack-channels", () => ({ + useSlackChannels: () => slackChannelsMock, +})); + +expect.extend(matchers); +afterEach(cleanup); +beforeEach(() => { + slackChannelsMock = { channels: [], loading: false }; +}); + +function renderSummary(conditions: TriggerCondition[]) { + render(); +} + +describe("ConditionSummary", () => { + it("resolves slack_channel IDs to #names and falls back to the ID when unknown", () => { + slackChannelsMock = { + channels: [{ id: "C0AV0V949D0", name: "general", isPrivate: false, isMember: true }], + loading: false, + }; + renderSummary([ + { type: "slack_channel", operator: "any_of", value: ["C0AV0V949D0", "C_UNKNOWN"] }, + ]); + + expect(screen.getByText(/#general, C_UNKNOWN/)).toBeInTheDocument(); + }); + + it("renders a text_match pattern instead of [object Object]", () => { + renderSummary([{ type: "text_match", operator: "contains", value: { pattern: "deploy" } }]); + + expect(screen.getByText(/deploy/)).toBeInTheDocument(); + expect(screen.queryByText(/object Object/)).not.toBeInTheDocument(); + }); + + it("appends regex flags to a text_match pattern", () => { + renderSummary([ + { type: "text_match", operator: "regex", value: { pattern: "rollback", flags: "i" } }, + ]); + + expect(screen.getByText(/rollback \(i\)/)).toBeInTheDocument(); + }); + + it("renders jsonpath filters readably instead of [object Object]", () => { + renderSummary([ + { + type: "jsonpath", + operator: "all_match", + value: [ + { path: "$.level", comparison: "eq", value: "error" }, + { path: "$.tags", comparison: "exists" }, + ], + }, + ]); + + expect(screen.getByText(/\$\.level eq error/)).toBeInTheDocument(); + expect(screen.getByText(/\$\.tags exists/)).toBeInTheDocument(); + expect(screen.queryByText(/object Object/)).not.toBeInTheDocument(); + }); + + it("joins plain string-array values", () => { + renderSummary([{ type: "label", operator: "any_of", value: ["bug", "urgent"] }]); + + expect(screen.getByText(/bug, urgent/)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/automations/condition-summary.tsx b/packages/web/src/components/automations/condition-summary.tsx new file mode 100644 index 000000000..70e8b1b89 --- /dev/null +++ b/packages/web/src/components/automations/condition-summary.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useMemo } from "react"; +import type { TriggerCondition } from "@open-inspect/shared"; +import { useSlackChannels } from "@/hooks/use-slack-channels"; + +/** + * Render a human-readable value for one trigger condition. + * + * Slack channel IDs resolve to `#name` (falling back to the raw ID when the + * channel can't be listed), and structured values (`text_match`, `jsonpath`) + * are formatted explicitly instead of stringifying to "[object Object]". + */ +function formatConditionValue( + condition: TriggerCondition, + channelNameById: Map +): string { + switch (condition.type) { + case "slack_channel": + return condition.value + .map((id) => { + const name = channelNameById.get(id); + return name ? `#${name}` : id; + }) + .join(", "); + case "text_match": { + const { pattern, flags } = condition.value; + return flags ? `${pattern} (${flags})` : pattern; + } + case "jsonpath": + return condition.value + .map((f) => `${f.path} ${f.comparison}${f.value === undefined ? "" : ` ${f.value}`}`) + .join(", "); + default: + return Array.isArray(condition.value) ? condition.value.join(", ") : String(condition.value); + } +} + +/** + * Read-only summary of an automation's trigger conditions, shown on the detail + * page. Resolves Slack channel names lazily — only when a `slack_channel` + * condition is present. + */ +export function ConditionSummary({ conditions }: { conditions: TriggerCondition[] }) { + const hasSlackChannel = conditions.some((c) => c.type === "slack_channel"); + const { channels } = useSlackChannels(hasSlackChannel); + const channelNameById = useMemo(() => new Map(channels.map((c) => [c.id, c.name])), [channels]); + + return ( +
+
Conditions
+
+ {conditions.map((c, i) => ( + + {c.type}: {c.operator} {formatConditionValue(c, channelNameById)} + + ))} +
+
+ ); +} diff --git a/packages/web/src/components/automations/trigger-type-selector.tsx b/packages/web/src/components/automations/trigger-type-selector.tsx index b9d32d69d..3194b6390 100644 --- a/packages/web/src/components/automations/trigger-type-selector.tsx +++ b/packages/web/src/components/automations/trigger-type-selector.tsx @@ -30,6 +30,11 @@ const TRIGGER_OPTIONS: TriggerOption[] = [ label: "GitHub Event", description: "Trigger on PR, issue, or CI events", }, + { + type: "slack_event", + label: "Slack Message", + description: "Trigger on messages in watched channels", + }, { type: "linear_event", label: "Linear Event", diff --git a/packages/web/src/hooks/use-slack-channels.ts b/packages/web/src/hooks/use-slack-channels.ts new file mode 100644 index 000000000..e9f17434b --- /dev/null +++ b/packages/web/src/hooks/use-slack-channels.ts @@ -0,0 +1,31 @@ +import useSWR from "swr"; +import { useSession } from "next-auth/react"; +import type { SlackChannelListing } from "@open-inspect/shared"; + +interface SlackChannelsResponse { + channels: SlackChannelListing[]; + error?: string; +} + +/** + * Fetch the workspace's Slack channels for the automation channel picker. + * `error` is set (and `channels` empty) when listing is unavailable — no bot + * token, missing scopes, or a Slack API failure — so callers can fall back to + * manual channel-ID entry. + * + * Pass `enabled: false` to skip the request entirely — e.g. when there is no + * Slack channel to resolve — without violating the rules of hooks. + */ +export function useSlackChannels(enabled = true) { + const { data: session } = useSession(); + + const { data, isLoading } = useSWR( + enabled && session ? "/api/integrations/slack/channels" : null + ); + + return { + channels: data?.channels ?? [], + error: data?.error, + loading: isLoading, + }; +} diff --git a/packages/web/src/lib/automation-templates.test.ts b/packages/web/src/lib/automation-templates.test.ts index de4a9f6a9..549b41cc2 100644 --- a/packages/web/src/lib/automation-templates.test.ts +++ b/packages/web/src/lib/automation-templates.test.ts @@ -108,9 +108,13 @@ describe("automation templates catalog", () => { } if (t.primaryOutput === "slack") { - it("names a Slack channel in its instructions and carries a setup note", () => { - expect(t.prefill.instructions).toMatch(/#[a-z0-9-]+/i); + it("carries a setup note, and names a Slack channel unless it replies in-thread", () => { expect(t.setupNote).toBeTruthy(); + // slack_event automations deliver the result to the originating thread, so + // they don't name a destination channel. slack-notify templates must. + if (triggerType !== "slack_event") { + expect(t.prefill.instructions).toMatch(/#[a-z0-9-]+/i); + } }); } diff --git a/packages/web/src/lib/automation-templates.ts b/packages/web/src/lib/automation-templates.ts index 9283596a4..1e08750af 100644 --- a/packages/web/src/lib/automation-templates.ts +++ b/packages/web/src/lib/automation-templates.ts @@ -231,6 +231,42 @@ export const automationTemplates: AutomationTemplate[] = [ "open a pull request.", }, }, + { + id: "auto-triage-slack-reports", + title: "Auto-triage Slack reports", + description: + "When a message in a watched Slack channel reports a bug or incident, investigate it and reply in the thread.", + categories: ["incidents"], + primaryOutput: "slack", + setupNote: + "Requires Slack triggers enabled, the bot invited to the channel, and a channel ID filled into the Slack Channel condition.", + prefill: { + name: "Auto-triage Slack reports", + triggerType: "slack_event", + eventType: "message.posted", + // The text_match keeps the trigger from firing on every channel message. + // The Slack Channel condition is intentionally omitted — add your own + // channel ID(s) on the form (a slack_event needs at least one). + triggerConfig: { + conditions: [ + { + type: "text_match", + operator: "regex", + value: { pattern: "\\b(bug|broken|error|failing|down|incident)\\b", flags: "i" }, + }, + ], + }, + instructions: + "A message was posted in a watched Slack channel (the message is shown above). Treat it as a " + + "bug or incident report and triage it against this repository.\n\n" + + "Investigate the most likely cause from the codebase: trace the described symptom to the " + + "responsible code, check recent related changes, and determine whether it is a real defect, a " + + "configuration problem, or expected behavior. When you have a concise, well-supported " + + "assessment — what is wrong, the relevant file references, and a recommended next step — that " + + "becomes the run result posted back into the thread. Do not open a pull request unless the fix " + + "is small and clearly correct.", + }, + }, { id: "dependency-digest", title: "Weekly dependency digest", diff --git a/terraform/d1/migrations/0026_slack_triggers.sql b/terraform/d1/migrations/0026_slack_triggers.sql new file mode 100644 index 000000000..a0dbbf29d --- /dev/null +++ b/terraform/d1/migrations/0026_slack_triggers.sql @@ -0,0 +1,30 @@ +-- Slack message triggers: a new `slack_event` automation source. +-- Additive only. No DO storage-schema change, so no two-phase DO-binding deploy. + +-- Channels watched by each slack_event automation. This join table is the +-- candidate / watched-channel key for channel-keyed selection, replacing a +-- full-scan + JSON parse of every automation's trigger_config. +CREATE TABLE IF NOT EXISTS automation_slack_channels ( + automation_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + PRIMARY KEY (automation_id, channel_id) +); + +CREATE INDEX IF NOT EXISTS idx_slack_channels_channel + ON automation_slack_channels (channel_id); + +-- Per-automation rate limit (generic; consumed by slack_event today). A fixed +-- 1-hour windowed cap; NULL means use the app default. reply_in_thread is NOT a +-- column — it lives in the automation's trigger_config JSON, keyed by trigger_type. +ALTER TABLE automations ADD COLUMN max_runs_per_hour INTEGER; + +-- Source-specific run metadata as JSON; only slack-origin runs populate it +-- today. For slack: {channel, threadTs?, messageTs} — threadTs is the reply +-- target (falls back to messageTs), messageTs is the triggering message used to +-- clear the eyes reaction on completion. Read back only to post the result into +-- the originating thread; never queried, so no index. +ALTER TABLE automation_runs ADD COLUMN trigger_run_metadata TEXT; + +-- The rate-limit window query (automation_id = ? AND created_at >= ?) is already +-- served by idx_runs_automation_created (automation_id, created_at DESC) from +-- migration 0013, so no new automation_runs index is added here. diff --git a/terraform/d1/migrations/0027_drop_max_runs_per_hour.sql b/terraform/d1/migrations/0027_drop_max_runs_per_hour.sql new file mode 100644 index 000000000..fc6f69737 --- /dev/null +++ b/terraform/d1/migrations/0027_drop_max_runs_per_hour.sql @@ -0,0 +1,8 @@ +-- Drop the per-automation hourly run cap. The max-runs-per-hour feature was +-- removed: the scheduler no longer rate-limits slack_event triggers, leaving the +-- per-thread concurrency guard as the backstop. The column is unindexed, so the +-- drop is clean. +-- +-- 0026 (already applied) added this column; never edit it — the drop must be its +-- own migration. +ALTER TABLE automations DROP COLUMN max_runs_per_hour; diff --git a/terraform/d1/migrations/0028_slack_thread_continuity_index.sql b/terraform/d1/migrations/0028_slack_thread_continuity_index.sql new file mode 100644 index 000000000..b5c98c491 --- /dev/null +++ b/terraform/d1/migrations/0028_slack_thread_continuity_index.sql @@ -0,0 +1,7 @@ +-- Backs getLatestSteerableRunForThread: the most recent run with a session for a +-- thread's concurrency key, any status, within a time window. Powers Slack +-- thread-session continuity (a reply continues the same session after the run +-- completes). Partial on session_id so skipped rows (session_id NULL) are excluded. +CREATE INDEX IF NOT EXISTS idx_runs_thread_continuity + ON automation_runs (automation_id, concurrency_key, created_at DESC) + WHERE concurrency_key IS NOT NULL AND session_id IS NOT NULL; diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 727a36146..c150c6ec9 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -199,6 +199,12 @@ variable "enable_slack_bot" { } } +variable "slack_triggers_enabled" { + description = "Kill switch for Slack channel-message automation triggers. When false (default), the slack-bot ignores channel messages and forwards nothing — the feature ships dark. Flip to true only after completing the rollout verification." + type = bool + default = false +} + variable "slack_bot_token" { description = "Slack Bot OAuth token (xoxb-...)" type = string diff --git a/terraform/environments/production/workers-slack.tf b/terraform/environments/production/workers-slack.tf index 0433bbed1..9605e2d8a 100644 --- a/terraform/environments/production/workers-slack.tf +++ b/terraform/environments/production/workers-slack.tf @@ -50,6 +50,9 @@ module "slack_bot_worker" { { name = "APP_NAME", value = var.app_name }, { name = "DEFAULT_MODEL", value = "claude-haiku-4-5" }, { name = "CLASSIFICATION_MODEL", value = "claude-haiku-4-5" }, + # Kill switch for Slack channel-message triggers; the bot only ingests/ + # forwards channel messages when this is exactly "true" (dark by default). + { name = "SLACK_TRIGGERS_ENABLED", value = var.slack_triggers_enabled ? "true" : "false" }, ] secrets = [