From 5214d006d3c3f1ca396529af5685cd08b98a3e11 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 24 Sep 2026 18:30:25 +0200 Subject: [PATCH] slack: improve agents (#2767) * improve agents * fix types * fix pr --- apps/dashboard/README.md | 4 +- apps/dashboard/next-env.d.ts | 4 +- apps/dashboard/package.json | 2 +- .../data-table/billing/data-table.tsx | 2 +- apps/dashboard/src/lib/trpc/shared.ts | 3 +- apps/server/.gitignore | 1 + apps/server/package.json | 2 + apps/server/scripts/slack-manifest-dev.ts | 32 ++ apps/server/slack-manifest.json | 37 +- .../src/libs/test/doubles/page-urls.mock.ts | 9 + .../src/libs/test/doubles/slack-test-state.ts | 2 + .../libs/test/doubles/slack-web-api.mock.ts | 21 + apps/server/src/routes/mcp/evals/run.ts | 6 +- apps/server/src/routes/slack/agent.ts | 7 +- apps/server/src/routes/slack/assistant.ts | 70 +++ apps/server/src/routes/slack/blocks.test.ts | 133 +++++- apps/server/src/routes/slack/blocks.ts | 143 +++++-- apps/server/src/routes/slack/config.ts | 2 +- apps/server/src/routes/slack/handler.test.ts | 318 +++++++++++++- apps/server/src/routes/slack/handler.ts | 399 ++++++++++++++---- apps/server/src/routes/slack/home.ts | 4 +- apps/server/src/routes/slack/oauth.test.ts | 2 + apps/server/src/routes/slack/oauth.ts | 2 + apps/server/src/routes/slack/page-urls.ts | 31 +- .../src/routes/slack/registry-runner.test.ts | 18 +- .../src/routes/slack/system-prompt.test.ts | 10 + apps/server/src/routes/slack/system-prompt.ts | 46 +- apps/server/src/routes/slack/verify.ts | 21 + .../src/app/api/callback/pagerduty/route.ts | 2 +- 29 files changed, 1154 insertions(+), 179 deletions(-) create mode 100644 apps/server/scripts/slack-manifest-dev.ts create mode 100644 apps/server/src/routes/slack/assistant.ts diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 58c11c02..097643e8 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -63,7 +63,7 @@ pnpm -w dev:dashboard Turbo runs the dashboard (`apps/dashboard`) and `@openstatus/db` together. -6. Open [http://localhost:3000](http://localhost:3000) +6. Open [http://localhost:3001](http://localhost:3001) ## Logging in @@ -73,7 +73,7 @@ In `NODE_ENV=development` or `SELF_HOST=true`, `src/lib/auth/providers.ts` confi To log in: -1. Open [http://localhost:3000/login](http://localhost:3000/login) +1. Open [http://localhost:3001/login](http://localhost:3001/login) 2. Enter `ping@openstatus.dev` (the seeded user, bound to workspace 1) in the magic-link form 3. Watch the dashboard terminal — the magic link is logged there. Open it in your browser. diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts index ce4e94a6..a419cbe4 100644 --- a/apps/dashboard/next-env.d.ts +++ b/apps/dashboard/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 0b2fb9b7..b640d621 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "PORT=3001 next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx index 28b7e2aa..a8d82e2d 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -37,7 +37,7 @@ import { cn } from "@/lib/utils"; const BASE_URL = process.env.NODE_ENV === "production" ? "https://app.openstatus.dev" - : "http://localhost:3000"; + : "http://localhost:3001"; function getPeriodSuffix(interval: BillingInterval) { return interval === "yearly" ? "/yr." : "/mo."; diff --git a/apps/dashboard/src/lib/trpc/shared.ts b/apps/dashboard/src/lib/trpc/shared.ts index 01f8f361..501d3afa 100644 --- a/apps/dashboard/src/lib/trpc/shared.ts +++ b/apps/dashboard/src/lib/trpc/shared.ts @@ -51,7 +51,8 @@ const getBaseUrl = () => { if (typeof window !== "undefined") return ""; // Note: dashboard has its own tRPC API routes if (process.env.VERCEL_URL) return "https://app.openstatus.dev"; // Vercel - return "http://localhost:3000"; // Local dev and Docker (internal calls) + // Dev runs on 3001 (`PORT` in the dev script), Docker on 3000. + return `http://localhost:${process.env.PORT || 3000}`; // Local dev and Docker (internal calls) }; // The whole tRPC surface is served from a single Node.js endpoint — there is diff --git a/apps/server/.gitignore b/apps/server/.gitignore index c1fbf070..12a21cd5 100644 --- a/apps/server/.gitignore +++ b/apps/server/.gitignore @@ -3,3 +3,4 @@ app src/_serve.bundle.mjs .deno_compile_bundle_*.mjs app.tmp-* +slack-manifest.dev.json diff --git a/apps/server/package.json b/apps/server/package.json index da2e3a93..b4f6f30e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,6 +10,8 @@ "start": "NODE_ENV=production deno run -A --sloppy-imports src/serve.ts", "test": "deno test --parallel -A --no-check --sloppy-imports --env-file=.env.test --import-map=test.importmap.json", "check": "deno check --sloppy-imports src/serve.ts", + "slack:manifest:prod": "cat slack-manifest.json", + "slack:manifest:dev": "deno run --env-file=.env --allow-env --allow-write=slack-manifest.dev.json scripts/slack-manifest-dev.ts", "eval:mcp": "deno run -A --sloppy-imports src/routes/mcp/evals/run.ts", "openapi:json": "deno run -A scripts/build-openapi-json.ts && pnpm exec oxfmt static/openapi.json static/openapi-yaml.ts", "openapi:v1": "deno run -A --no-check --sloppy-imports --env-file=.env.test --import-map=test.importmap.json scripts/build-openapi-v1-json.ts && pnpm exec oxfmt static/openapi-v1.json" diff --git a/apps/server/scripts/slack-manifest-dev.ts b/apps/server/scripts/slack-manifest-dev.ts new file mode 100644 index 00000000..89b336ec --- /dev/null +++ b/apps/server/scripts/slack-manifest-dev.ts @@ -0,0 +1,32 @@ +// Writes slack-manifest.dev.json (gitignored) for the local `openstatus-dev` +// app: the production manifest renamed, with every api.openstatus.dev URL +// pointed at your tunnel. The tunnel origin comes from SLACK_REDIRECT_URI in +// .env, so the manifest and the OAuth callback the server sends can't disagree. +// Also prints it, so it can be piped straight to the clipboard. +// +// pnpm slack:manifest:dev | pbcopy # then App Manifest → JSON → paste +import manifest from "../slack-manifest.json" with { type: "json" }; + +const PROD_ORIGIN = "https://api.openstatus.dev"; +const DEV_NAME = "openstatus-dev"; + +const redirectUri = Deno.env.get("SLACK_REDIRECT_URI"); +if (!redirectUri) { + console.error( + "SLACK_REDIRECT_URI is not set — add https:///slack/oauth/callback to apps/server/.env", + ); + Deno.exit(1); +} +const devOrigin = new URL(redirectUri).origin; + +const dev = structuredClone(manifest); +dev.display_information.name = DEV_NAME; +dev.features.bot_user.display_name = DEV_NAME; +dev.oauth_config.redirect_urls = [redirectUri]; + +const output = `${JSON.stringify(dev, null, 2).replaceAll(PROD_ORIGIN, devOrigin)}\n`; +await Deno.writeTextFile( + new URL("../slack-manifest.dev.json", import.meta.url), + output, +); +console.log(output); diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json index b6574141..49dd6c72 100644 --- a/apps/server/slack-manifest.json +++ b/apps/server/slack-manifest.json @@ -1,15 +1,36 @@ { "display_information": { "name": "openstatus", - "description": "Manage incidents and status pages directly from Slack.", + "description": "The incident communication agent for your status pages.", "background_color": "#000000", - "long_description": "openstatus brings incident management into your team's Slack workspace. Mention the bot in any channel to create, update, and resolve status reports — no context switching required.\n\n How it works:\n :one: Describe the issue by mentioning @openstatus in any channel or thread\n :two: The assistant reads the conversation, queries your status pages, and drafts a status update\n :three: Review the proposed action and click Approve, Approve & Notify, or Cancel\n\n What you can do:\n :white_check_mark: Create status reports from natural conversation\n :white_check_mark: Post progress updates as incidents evolve\n :white_check_mark: Resolve incidents when the issue is fixed\n :white_check_mark: Notify your status page subscribers with one click\n :white_check_mark: Edit report titles and affected components\n\n The assistant understands context — tag it in an ongoing thread and it will synthesize the discussion into a clear, public-facing status update. It infers incident status automatically: \"we found the root cause\" becomes Identified, \"it's fixed\" becomes Resolved.\n\n AI disclaimer: openstatus uses large language models (LLMs) to summarize conversations, draft status updates, and infer incident status. AI-generated content may sometimes be inaccurate, incomplete, or misleading. Always review every draft before approving it — nothing is posted to your status page until you explicitly confirm. Only the person who triggered the action can approve it, and you can edit or cancel any draft." + "long_description": "openstatus is an agent for incident communication. Open it from the Slack top bar, or mention @openstatus in any channel, and it turns the conversation into status page updates — no context switching required.\n\n How it works:\n :one: Open openstatus in the agent pane and describe the issue, or mention @openstatus in an incident channel or thread\n :two: The agent reads the conversation, queries your status pages, and drafts a status update\n :three: Review the proposed action and click Approve, Approve & Notify, or Cancel\n\n What you can do:\n :white_check_mark: Create status reports from natural conversation\n :white_check_mark: Post progress updates as incidents evolve\n :white_check_mark: Resolve incidents when the issue is fixed\n :white_check_mark: Schedule maintenance windows in plain language\n :white_check_mark: Notify your status page subscribers with one click\n\n The agent understands context — tag it in an ongoing thread and it will synthesize the discussion into a clear, public-facing status update. It infers incident status automatically: \"we found the root cause\" becomes Identified, \"it's fixed\" becomes Resolved.\n\n AI disclaimer: openstatus uses large language models (LLMs) to summarize conversations, draft status updates, and infer incident status. AI-generated content may sometimes be inaccurate, incomplete, or misleading. Always review every draft before approving it — nothing is posted to your status page until you explicitly confirm. Only the person who triggered the action can approve it, and you can edit or cancel any draft." }, "features": { + "assistant_view": { + "assistant_description": "Drafts, updates and resolves status reports and maintenance windows. Nothing is published until you approve it.", + "suggested_prompts": [ + { + "title": "Create a status report", + "message": "We're seeing elevated errors on the API — create a status report." + }, + { + "title": "Open status reports", + "message": "Which status reports are currently open?" + }, + { + "title": "Schedule maintenance", + "message": "Schedule a maintenance window next Tuesday from 2-3 PM UTC." + }, + { + "title": "Upcoming maintenance", + "message": "What maintenance windows are coming up?" + } + ] + }, "app_home": { "home_tab_enabled": true, - "messages_tab_enabled": false, - "messages_tab_read_only_enabled": true + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false }, "bot_user": { "display_name": "openstatus", @@ -32,13 +53,15 @@ "user_optional": ["groups:write"], "bot": [ "app_mentions:read", + "assistant:write", "channels:history", "channels:join", "chat:write", "commands", "groups:history", "groups:read", - "groups:write" + "groups:write", + "im:history" ] }, "pkce_enabled": false @@ -49,8 +72,10 @@ "bot_events": [ "app_home_opened", "app_mention", + "assistant_thread_started", "message.channels", - "message.groups" + "message.groups", + "message.im" ] }, "interactivity": { diff --git a/apps/server/src/libs/test/doubles/page-urls.mock.ts b/apps/server/src/libs/test/doubles/page-urls.mock.ts index 424e115b..b0dae007 100644 --- a/apps/server/src/libs/test/doubles/page-urls.mock.ts +++ b/apps/server/src/libs/test/doubles/page-urls.mock.ts @@ -23,3 +23,12 @@ export const getComponentNames = ( ids: number[], ): Promise> => Promise.resolve(new Map(ids.map((id) => [id, `Component ${id}`]))); + +export const getStatusReportLink = ( + _workspaceId: number, + statusReportId: number, +): Promise<{ title: string; url: string | null } | null> => + Promise.resolve({ + title: `Report ${statusReportId}`, + url: `https://example.openstatus.dev/events/report/${statusReportId}`, + }); diff --git a/apps/server/src/libs/test/doubles/slack-test-state.ts b/apps/server/src/libs/test/doubles/slack-test-state.ts index 9d19743e..faec30f2 100644 --- a/apps/server/src/libs/test/doubles/slack-test-state.ts +++ b/apps/server/src/libs/test/doubles/slack-test-state.ts @@ -10,6 +10,7 @@ export interface SlackTestState { postMessageOverride: Override; updateOverride: Override; postEphemeralOverride: Override; + sessionStatusOverride: Override; runAgentOverride: (() => Promise) | null; repliesImpl: () => Promise; } @@ -22,6 +23,7 @@ if (!g.__slackTestState) { postMessageOverride: null, updateOverride: null, postEphemeralOverride: null, + sessionStatusOverride: null, runAgentOverride: null, repliesImpl: () => Promise.resolve({ diff --git a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts index c4479e9e..a9e5b221 100644 --- a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts +++ b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts @@ -23,6 +23,27 @@ export class WebClient { conversations = { replies: () => s.repliesImpl(), }; + agents = { + sessions: { + setStatus: (args: Record) => { + if (s.sessionStatusOverride) return s.sessionStatusOverride(args); + s.calls.push({ method: "agents.sessions.setStatus", args }); + return Promise.resolve({ ok: true }); + }, + }, + }; + assistant = { + threads: { + setStatus: (args: Record) => { + s.calls.push({ method: "assistant.threads.setStatus", args }); + return Promise.resolve({ ok: true }); + }, + setSuggestedPrompts: (args: Record) => { + s.calls.push({ method: "assistant.threads.setSuggestedPrompts", args }); + return Promise.resolve({ ok: true }); + }, + }, + }; views = { publish: (args: Record) => { s.calls.push({ method: "views.publish", args }); diff --git a/apps/server/src/routes/mcp/evals/run.ts b/apps/server/src/routes/mcp/evals/run.ts index f7296010..f6a912b1 100644 --- a/apps/server/src/routes/mcp/evals/run.ts +++ b/apps/server/src/routes/mcp/evals/run.ts @@ -1,12 +1,12 @@ /** * MCP tool-selection eval. Standalone bun script — `pnpm eval:mcp`. * - * Runs each case in `cases.ts` against Claude Haiku 4.5 (via the AI + * Runs each case in `cases.ts` against Claude Opus 5 (via the AI * Gateway), asserting the model picks the expected tool and includes * the required args. Fails the run if fewer than `PASS_THRESHOLD` of * `cases.length` succeed. * - * Not in default CI. Cost: a handful of cents per run. + * Not in default CI — every run bills Opus tokens. * * -------------------------------------------------------------------- * TODO: deduplicate tool catalogue. @@ -37,7 +37,7 @@ import { type EvalCase, cases } from "./cases"; // Resolved through the AI Gateway (`AI_GATEWAY_API_KEY` env). Using // `gateway(...)` instead of a bare string makes the routing path // explicit and gives a clearer error if the gateway is unconfigured. -const MODEL = gateway("anthropic/claude-haiku-4-5"); +const MODEL = gateway("anthropic/claude-opus-5"); // Lenient bar (10/12) accommodates model non-determinism even at // `temperature: 0` — a single flaky tool selection shouldn't tank // the run. Tighten if descriptions stabilize and runs trend toward diff --git a/apps/server/src/routes/slack/agent.ts b/apps/server/src/routes/slack/agent.ts index 00678aff..da99037e 100644 --- a/apps/server/src/routes/slack/agent.ts +++ b/apps/server/src/routes/slack/agent.ts @@ -8,10 +8,9 @@ import { tb } from "@/libs/clients"; import { buildSlackTools } from "./registry-runner"; import { buildSystemPrompt } from "./system-prompt"; -// Vercel AI Gateway model id. Override via SLACK_AGENT_MODEL when rolling -// out a new Sonnet version. Dotted format (`4.6`, not `4-6`) is what the -// gateway accepts — see `apps/dashboard/src/app/api/chat/route.ts`. -const DEFAULT_MODEL = "anthropic/claude-sonnet-4.6"; +// Vercel AI Gateway model id (`anthropic/`). Override via +// SLACK_AGENT_MODEL when rolling out a new model version. +const DEFAULT_MODEL = "anthropic/claude-opus-5"; // `||` (not `??`) so empty / whitespace-only env values fall back to the // default rather than being passed through to `generateText`. const MODEL = process.env.SLACK_AGENT_MODEL?.trim() || DEFAULT_MODEL; diff --git a/apps/server/src/routes/slack/assistant.ts b/apps/server/src/routes/slack/assistant.ts new file mode 100644 index 00000000..c58db42b --- /dev/null +++ b/apps/server/src/routes/slack/assistant.ts @@ -0,0 +1,70 @@ +import type { WebClient } from "@slack/web-api"; + +// Shown when a user opens a new thread in the agent pane. Slack caps a thread +// at four prompts; titles render as chips, `message` is what gets sent. +export const SUGGESTED_PROMPTS = [ + { + title: "Create a status report", + message: + "We're seeing elevated errors on the API — create a status report.", + }, + { + title: "Open status reports", + message: "Which status reports are currently open?", + }, + { + title: "Schedule maintenance", + message: "Schedule a maintenance window next Tuesday from 2-3 PM UTC.", + }, + { + title: "Upcoming maintenance", + message: "What maintenance windows are coming up?", + }, +]; + +export async function startAssistantThread( + slack: WebClient, + channel: string, + threadTs: string, +): Promise { + await slack.assistant.threads.setSuggestedPrompts({ + channel_id: channel, + thread_ts: threadTs, + title: "What do you need to communicate?", + prompts: SUGGESTED_PROMPTS, + }); +} + +// Slack clears the status as soon as the app posts in the thread, so there is +// no matching "clear" call on the success path. +export async function setAssistantStatus( + slack: WebClient, + channel: string, + threadTs: string, + status: string, +): Promise { + await slack.assistant.threads.setStatus({ + channel_id: channel, + thread_ts: threadTs, + status, + }); +} + +// Agent session lifecycle: `processing` shows the agent as working on the +// thread, `active` hands the turn back to the user. Works on thread-based +// sessions in channels and DMs; throws `feature_disabled` on workspaces where +// agent sessions aren't enabled. +export async function setSessionStatus( + slack: WebClient, + channel: string, + threadTs: string, + status: "processing" | "active", + initiatorUserId?: string, +): Promise { + await slack.agents.sessions.setStatus({ + channel_id: channel, + thread_ts: threadTs, + status, + initiator_user_id: initiatorUserId, + }); +} diff --git a/apps/server/src/routes/slack/blocks.test.ts b/apps/server/src/routes/slack/blocks.test.ts index eeb1b2a3..b5a7170d 100644 --- a/apps/server/src/routes/slack/blocks.test.ts +++ b/apps/server/src/routes/slack/blocks.test.ts @@ -73,7 +73,7 @@ describe("buildConfirmationBlocks", () => { expect(text).not.toContain("Page ID"); }); - test("create_status_report falls back to page id when the page can't be resolved", async () => { + test("create_status_report hides the page line when the page can't be resolved", async () => { const tool = agentTools.create_status_report; const blocks = await buildConfirmationBlocks({ actionId: "link2", @@ -90,7 +90,8 @@ describe("buildConfirmationBlocks", () => { const text = ( blocks.find((b) => b.type === "section") as { text: { text: string } } ).text.text; - expect(text).toContain("*Page ID:* 2705"); + expect(text).not.toContain("Page"); + expect(text).not.toContain("2705"); }); test("create_status_report shows component names when resolvers resolve", async () => { @@ -115,7 +116,7 @@ describe("buildConfirmationBlocks", () => { expect(text).toContain("*Impacts:* Svc 101 → major_outage"); }); - test("component line falls back to raw id when a name is missing", async () => { + test("component line shows unknown when a name is missing", async () => { const tool = agentTools.create_status_report; const blocks = await buildConfirmationBlocks({ actionId: "cn2", @@ -135,7 +136,8 @@ describe("buildConfirmationBlocks", () => { const text = ( blocks.find((b) => b.type === "section") as { text: { text: string } } ).text.text; - expect(text).toContain("*Components:* Svc 101, 999"); + expect(text).toContain("*Components:* Svc 101, _unknown_"); + expect(text).not.toContain("999"); }); test("degrades to raw page id (card intact) when the page resolver rejects", async () => { @@ -158,7 +160,8 @@ describe("buildConfirmationBlocks", () => { const text = ( blocks.find((b) => b.type === "section") as { text: { text: string } } ).text.text; - expect(text).toContain("*Page ID:* 2705"); + expect(text).not.toContain("Page"); + expect(text).not.toContain("2705"); // The rest of the card must still build — a flaky lookup degrades one line. const actions = blocks.find((b) => b.type === "actions") as { elements: unknown[]; @@ -166,7 +169,7 @@ describe("buildConfirmationBlocks", () => { expect(actions.elements).toHaveLength(3); }); - test("degrades to raw component ids when the component resolver rejects", async () => { + test("drops component lines when the component resolver rejects", async () => { const tool = agentTools.create_status_report; const blocks = await buildConfirmationBlocks({ actionId: "rej2", @@ -187,8 +190,9 @@ describe("buildConfirmationBlocks", () => { const text = ( blocks.find((b) => b.type === "section") as { text: { text: string } } ).text.text; - expect(text).toContain("*Components:* 101, 102"); - expect(text).toContain("*Impacts:* 101 → major_outage"); + expect(text).not.toContain("Components"); + expect(text).not.toContain("Impacts"); + expect(text).not.toContain("101"); }); test("escapes mrkdwn-significant chars in the page link text", async () => { @@ -346,11 +350,12 @@ describe("buildConfirmationBlocks", () => { pageId: 1, pageComponentIds: [101, 102], }, + resolvers: stubResolvers, }); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; - expect(section.text.text).toContain("101, 102"); + expect(section.text.text).toContain("Svc 101, Svc 102"); }); test("create_status_report shows impacts when provided", async () => { @@ -369,13 +374,14 @@ describe("buildConfirmationBlocks", () => { { pageComponentId: 102, impact: "degraded_performance" }, ], }, + resolvers: stubResolvers, }); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; expect(section.text.text).toContain("Impacts"); - expect(section.text.text).toContain("101 → major_outage"); - expect(section.text.text).toContain("102 → degraded_performance"); + expect(section.text.text).toContain("Svc 101 → major_outage"); + expect(section.text.text).toContain("Svc 102 → degraded_performance"); }); test("add_status_report_update shows impacts when provided", async () => { @@ -389,12 +395,13 @@ describe("buildConfirmationBlocks", () => { message: "recovering", componentImpacts: [{ pageComponentId: 7, impact: "partial_outage" }], }, + resolvers: stubResolvers, }); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; expect(section.text.text).toContain("Impacts"); - expect(section.text.text).toContain("7 → partial_outage"); + expect(section.text.text).toContain("Svc 7 → partial_outage"); }); test("add_status_report_update has 3 buttons", async () => { @@ -407,12 +414,13 @@ describe("buildConfirmationBlocks", () => { status: "identified", message: "Root cause found", }, + resolvers: stubResolvers, }); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; - expect(section.text.text).toContain("42"); + expect(section.text.text).not.toContain("Report ID"); expect(section.text.text).toContain("identified"); const actions = blocks.find((b) => b.type === "actions") as { @@ -429,6 +437,7 @@ describe("buildConfirmationBlocks", () => { actionId: "u1", tool, input: { statusReportId: 10, title: "X" }, + resolvers: stubResolvers, }); const noChangeText = ( noChange.find((b) => b.type === "section") as { @@ -442,6 +451,7 @@ describe("buildConfirmationBlocks", () => { actionId: "u2", tool, input: { statusReportId: 10, pageComponentIds: [] }, + resolvers: stubResolvers, }); const clearAllText = ( clearAll.find((b) => b.type === "section") as { @@ -455,13 +465,14 @@ describe("buildConfirmationBlocks", () => { actionId: "u3", tool, input: { statusReportId: 10, pageComponentIds: [1, 2] }, + resolvers: stubResolvers, }); const withIdsText = ( withIds.find((b) => b.type === "section") as { text: { text: string }; } ).text.text; - expect(withIdsText).toContain("1, 2"); + expect(withIdsText).toContain("Svc 1, Svc 2"); }); test("update_status_report has 2 buttons (no notify flag)", async () => { @@ -485,7 +496,7 @@ describe("buildConfirmationBlocks", () => { expect(actions.elements[1].action_id).toBe("cancel_xyz"); }); - test("create_maintenance card shows pageId", async () => { + test("create_maintenance card hides the page id without resolvers", async () => { const tool = agentTools.create_maintenance; const blocks = await buildConfirmationBlocks({ actionId: "m1", @@ -504,8 +515,8 @@ describe("buildConfirmationBlocks", () => { text: { text: string }; } ).text.text; - expect(text).toContain("Page ID"); - expect(text).toContain("7"); + // No resolvers: the page line is dropped rather than showing the id. + expect(text).not.toContain("Page"); }); test("resolve_status_report has 3 buttons", async () => { @@ -514,12 +525,14 @@ describe("buildConfirmationBlocks", () => { actionId: "res1", tool, input: { statusReportId: 5, message: "Issue has been resolved" }, + resolvers: stubResolvers, }); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; - expect(section.text.text).toContain("5"); + expect(section.text.text).not.toContain("Report ID"); + expect(section.text.text).not.toContain("5"); expect(section.text.text).toContain("Issue has been resolved"); const actions = blocks.find((b) => b.type === "actions") as { @@ -636,3 +649,87 @@ describe("parseActionId", () => { expect(parseActionId("foo_abc")).toBeUndefined(); }); }); + +describe("buildConfirmationBlocks report line", () => { + const withReport: RefResolvers = { + page: () => Promise.resolve(null), + statusReport: (id) => + Promise.resolve( + id === 42 + ? { + title: "API ", + url: "https://acme.openstatus.dev/events/report/42", + } + : null, + ), + componentNames: () => Promise.resolve(new Map()), + }; + + async function card(input: unknown, resolvers: RefResolvers = withReport) { + const blocks = await buildConfirmationBlocks({ + actionId: "rep", + tool: agentTools.add_status_report_update, + input, + resolvers, + }); + const section = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + const context = blocks.find((b) => b.type === "context") as + | { elements: { text: string }[] } + | undefined; + return { blocks, section, context }; + } + + test("shows the report title and a link to view it", async () => { + const { blocks, section, context } = await card({ + statusReportId: 42, + status: "monitoring", + message: "Watching", + }); + expect(section).toContain("*Report:* API <outage>"); + expect(section).not.toContain("42"); + expect(context?.elements[0].text).toBe( + "", + ); + // The link sits between the details and the buttons. + expect(blocks.map((b) => b.type)).toEqual([ + "section", + "context", + "divider", + "actions", + ]); + }); + + test("omits the report line and link when the report isn't found", async () => { + const { section, context } = await card({ + statusReportId: 9031, + status: "monitoring", + message: "Watching", + }); + expect(section).not.toContain("*Report:*"); + expect(section).not.toContain("9031"); + expect(context).toBeUndefined(); + }); + + test("shows the title without a link when the report has no page", async () => { + const { section, context } = await card( + { statusReportId: 42, status: "monitoring", message: "Watching" }, + { + ...withReport, + statusReport: () => Promise.resolve({ title: "Orphan", url: null }), + }, + ); + expect(section).toContain("*Report:* Orphan"); + expect(context).toBeUndefined(); + }); + + test("keeps the card when the report lookup fails", async () => { + const { blocks, section } = await card( + { statusReportId: 42, status: "monitoring", message: "Watching" }, + { ...withReport, statusReport: () => Promise.reject(new Error("down")) }, + ); + expect(section).not.toContain("*Report:*"); + expect(blocks.some((b) => b.type === "actions")).toBe(true); + }); +}); diff --git a/apps/server/src/routes/slack/blocks.ts b/apps/server/src/routes/slack/blocks.ts index 3e8dd0a7..e39c8905 100644 --- a/apps/server/src/routes/slack/blocks.ts +++ b/apps/server/src/routes/slack/blocks.ts @@ -24,6 +24,11 @@ interface DividerBlock { type: "divider"; } +interface ContextBlock { + type: "context"; + elements: TextObject[]; +} + interface ButtonElement { type: "button"; text: TextObject; @@ -32,7 +37,7 @@ interface ButtonElement { style?: "primary" | "danger"; } -export type Block = SectionBlock | ActionsBlock | DividerBlock; +export type Block = SectionBlock | ActionsBlock | DividerBlock | ContextBlock; /** * Action-id encoding. We need to round-trip both the pending action's id @@ -104,6 +109,11 @@ function escapeLinkText(text: string): string { return escapeText(text).replace(/\|/g, "❘"); } +/** Escape a URL for use as the target of a Slack mrkdwn link (``). */ +function escapeLinkUrl(url: string): string { + return escapeText(url).replace(/\|/g, "%7C"); +} + /** * Data resolvers the Slack surface injects so `buildConfirmationBlocks` can * turn `SummaryLineRef` descriptors into names. Resolution needs DB access, @@ -112,53 +122,92 @@ function escapeLinkText(text: string): string { export interface RefResolvers { /** Page id → dashboard link, or null when the page no longer exists. */ page: (pageId: number) => Promise<{ title: string; url: string } | null>; + /** + * Status report id → its title and public URL (url null when its page is + * gone), or null when the report doesn't exist in the workspace. + */ + statusReport?: ( + statusReportId: number, + ) => Promise<{ title: string; url: string | null } | null>; /** Page-component ids → their names (missing ids simply absent). */ componentNames: (ids: number[]) => Promise>; } +// Internal ids mean nothing to the person approving, so the card never shows +// one: id-only lines (e.g. "Report ID") are dropped, a page that can't be +// named is dropped, and a component that can't be named shows as unknown. +const ID_LABEL = /\bID$/i; +const UNKNOWN = "_unknown_"; + async function renderLine( line: SummaryLine, resolvers?: RefResolvers, -): Promise { +): Promise { const ref = line.ref; - if (ref && resolvers) { - try { - switch (ref.kind) { - case "page": { - const link = await resolvers.page(ref.pageId); - if (link) { - return `*Page:* <${link.url}|${escapeLinkText(link.title)}>`; - } - break; - } - case "components": { - const names = await resolvers.componentNames(ref.componentIds); - const value = ref.componentIds - .map((id) => nameOrId(names, id)) - .join(", "); - return `*${line.label}:* ${value}`; - } - case "componentImpacts": { - const names = await resolvers.componentNames( - ref.impacts.map((i) => i.pageComponentId), - ); - const value = ref.impacts - .map((i) => `${nameOrId(names, i.pageComponentId)} → ${i.impact}`) - .join(", "); - return `*${line.label}:* ${value}`; - } + if (!ref) { + return ID_LABEL.test(line.label) + ? null + : `*${line.label}:* ${escapeText(line.value)}`; + } + if (!resolvers) return null; + try { + switch (ref.kind) { + case "page": { + const link = await resolvers.page(ref.pageId); + return link + ? `*Page:* <${link.url}|${escapeLinkText(link.title)}>` + : null; + } + case "components": { + const names = await resolvers.componentNames(ref.componentIds); + const value = ref.componentIds + .map((id) => nameOrUnknown(names, id)) + .join(", "); + return `*${line.label}:* ${value}`; + } + case "componentImpacts": { + const names = await resolvers.componentNames( + ref.impacts.map((i) => i.pageComponentId), + ); + const value = ref.impacts + .map( + (i) => `${nameOrUnknown(names, i.pageComponentId)} → ${i.impact}`, + ) + .join(", "); + return `*${line.label}:* ${value}`; } - } catch { - // A transient name/link lookup failure degrades just this line to its - // raw id value below, rather than aborting the whole confirmation card. } + } catch { + // A transient lookup failure drops just this line, rather than aborting + // the whole confirmation card. } - return `*${line.label}:* ${escapeText(line.value)}`; + return null; } -function nameOrId(names: Map, id: number): string { +function nameOrUnknown(names: Map, id: number): string { const name = names.get(id); - return name ? escapeText(name) : String(id); + return name ? escapeText(name) : UNKNOWN; +} + +/** + * The report a draft acts on (add update / update / resolve), found by its + * `statusReportId` input. The services summary only carries the id — which + * the card never shows — so Slack looks the title up itself. + */ +async function resolveReport( + input: unknown, + resolvers?: RefResolvers, +): Promise<{ title: string; url: string | null } | null> { + if (!resolvers?.statusReport) return null; + if (typeof input !== "object" || input === null) return null; + const id = (input as { statusReportId?: unknown }).statusReportId; + if (typeof id !== "number") return null; + try { + return await resolvers.statusReport(id); + } catch { + // Same as a failed ref lookup: drop the line, keep the card. + return null; + } } /** @@ -166,7 +215,7 @@ function nameOrId(names: Map, id: number): string { * Two affirmative buttons when an extraFlag exists; one otherwise. When a * summary line carries a `ref` and `resolvers` are supplied, raw ids are * replaced by entity names (a dashboard link for pages, component names for - * component ids). + * component ids). The card never shows a raw id. */ export async function buildConfirmationBlocks(args: { actionId: string; @@ -183,9 +232,13 @@ export async function buildConfirmationBlocks(args: { const summary = tool.approval.summarize(input); const flag: ExtraFlag | undefined = tool.approval.extraFlags?.[0]; - const lines = ( - await Promise.all(summary.lines.map((l) => renderLine(l, resolvers))) - ).join("\n"); + const report = await resolveReport(input, resolvers); + const lines = [ + report ? `*Report:* ${escapeText(report.title)}` : null, + ...(await Promise.all(summary.lines.map((l) => renderLine(l, resolvers)))), + ] + .filter((l) => l !== null) + .join("\n"); const buttons: ButtonElement[] = [ { @@ -214,7 +267,7 @@ export async function buildConfirmationBlocks(args: { style: "danger", }); - return [ + const blocks: Block[] = [ { type: "section", text: { @@ -222,9 +275,17 @@ export async function buildConfirmationBlocks(args: { text: `*${escapeText(summary.title)}*\n\n${lines}`, }, }, - { type: "divider" }, - { type: "actions", elements: buttons }, ]; + if (report?.url) { + blocks.push({ + type: "context", + elements: [ + { type: "mrkdwn", text: `<${escapeLinkUrl(report.url)}|View report>` }, + ], + }); + } + blocks.push({ type: "divider" }, { type: "actions", elements: buttons }); + return blocks; } export function getConfirmationText(args: { diff --git a/apps/server/src/routes/slack/config.ts b/apps/server/src/routes/slack/config.ts index d5a68aca..39fa615a 100644 --- a/apps/server/src/routes/slack/config.ts +++ b/apps/server/src/routes/slack/config.ts @@ -36,6 +36,6 @@ export function slackConfigFromEnv(): SlackConfig { dashboardUrl: env.NODE_ENV === "production" ? "https://app.openstatus.dev" - : "http://localhost:3000", + : "http://localhost:3001", }; } diff --git a/apps/server/src/routes/slack/handler.test.ts b/apps/server/src/routes/slack/handler.test.ts index fe3489c0..a8eb1801 100644 --- a/apps/server/src/routes/slack/handler.test.ts +++ b/apps/server/src/routes/slack/handler.test.ts @@ -12,7 +12,11 @@ import { } from "@/libs/test/slack-config"; import type { SlackEnv } from "./config"; -import { handleSlackEvent, looksLikeUncardedDraft } from "./handler"; +import { + handleSlackEvent, + isAnswerToAgent, + looksLikeUncardedDraft, +} from "./handler"; import { verifySlackSignature } from "./verify"; function createTestApp() { @@ -52,6 +56,20 @@ describe("handleSlackEvent", () => { slackTestState.postMessageOverride = null; slackTestState.updateOverride = null; slackTestState.runAgentOverride = null; + slackTestState.repliesImpl = () => + Promise.resolve({ + messages: [{ user: "U1", text: "test message", ts: "1.1" }], + }); + // Default to a workspace without agent sessions so the tests below cover + // the fallback indicators; the session path opts back in explicitly. + slackTestState.sessionStatusOverride = () => { + const err = new Error("An API error occurred: feature_disabled"); + Object.assign(err, { + code: "slack_webapi_platform_error", + data: { ok: false, error: "feature_disabled" }, + }); + return Promise.reject(err); + }; slackTestState.resolveWorkspace = (teamId: string) => { if (teamId === "T_KNOWN") { return Promise.resolve({ @@ -292,26 +310,265 @@ describe("handleSlackEvent", () => { expect(slackTestState.calls.length).toBe(0); }); - test("ignores DM messages", async () => { + test("marks the agent session as processing instead of posting Thinking", async () => { + slackTestState.sessionStatusOverride = null; + const ts = `${Date.now()}.60`; + + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_session_${ts}`, + event: { + type: "app_mention", + text: "<@UBOT> which reports are open?", + user: "U1", + channel: "C1", + ts, + }, + }); + await new Promise((r) => setTimeout(r, 100)); + + const statuses = slackTestState.calls + .filter((m) => m.method === "agents.sessions.setStatus") + .map((m) => m.args); + expect(statuses).toEqual([ + { + channel_id: "C1", + thread_ts: ts, + status: "processing", + initiator_user_id: "U1", + }, + { channel_id: "C1", thread_ts: ts, status: "active" }, + ]); + + const posts = slackTestState.calls.filter( + (m) => m.method === "postMessage", + ); + expect(posts.length).toBe(1); + expect(posts[0].args).toMatchObject({ + channel: "C1", + thread_ts: ts, + text: "Here is my response", + }); + expect(slackTestState.calls.some((m) => m.method === "update")).toBe(false); + }); + + test("hands the agent session back as active when runAgent throws", async () => { + slackTestState.sessionStatusOverride = null; + slackTestState.runAgentOverride = () => + Promise.reject(new Error("agent exploded")); + const ts = `${Date.now()}.61`; + + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_session_err_${ts}`, + event: { + type: "app_mention", + text: "<@UBOT> hello", + user: "U1", + channel: "C1", + ts, + }, + }); + await new Promise((r) => setTimeout(r, 100)); + + const errorPost = slackTestState.calls.find( + (m) => + m.method === "postMessage" && + typeof m.args.text === "string" && + m.args.text.includes("Something went wrong"), + ); + expect(errorPost).toBeDefined(); + const last = slackTestState.calls + .filter((m) => m.method === "agents.sessions.setStatus") + .at(-1); + expect(last?.args.status).toBe("active"); + }); + + test("answers an untagged reply to its own question", async () => { + const ts = `${Date.now()}.70`; + slackTestState.repliesImpl = () => + Promise.resolve({ + messages: [ + { user: "U1", text: "<@UBOT> update my status page", ts: "5.1" }, + { user: "UBOT", bot_id: "B1", text: "Which page?", ts: "5.2" }, + { user: "U1", text: "acme, id 1", ts }, + ], + }); + + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_untagged_${ts}`, + event: { + type: "message", + text: "acme, id 1", + user: "U1", + channel: "C1", + channel_type: "channel", + ts, + thread_ts: "5.1", + }, + }); + await new Promise((r) => setTimeout(r, 100)); + + const answer = slackTestState.calls.find( + (m) => + (m.method === "update" || m.method === "postMessage") && + m.args.text === "Here is my response", + ); + expect(answer).toBeDefined(); + }); + + test("ignores an untagged thread reply that isn't answering the agent", async () => { + const ts = `${Date.now()}.71`; + slackTestState.repliesImpl = () => + Promise.resolve({ + messages: [ + { user: "U1", text: "<@UBOT> update my status page", ts: "6.1" }, + { user: "UBOT", bot_id: "B1", text: "Which page?", ts: "6.2" }, + { user: "U2", text: "I'll check the logs", ts }, + ], + }); + + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_untagged_other_${ts}`, + event: { + type: "message", + text: "I'll check the logs", + user: "U2", + channel: "C1", + channel_type: "channel", + ts, + thread_ts: "6.1", + }, + }); + await new Promise((r) => setTimeout(r, 100)); + + expect(slackTestState.calls.length).toBe(0); + }); + + test("replies in the agent pane without a mention", async () => { + const ts = `${Date.now()}.5`; const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", - event_id: `evt_dm_${Date.now()}`, + event_id: `evt_dm_${ts}`, event: { type: "message", - text: "hello in DM", + text: "which reports are open?", user: "U1", channel: "D1", channel_type: "im", - ts: `${Date.now()}.5`, + ts, + thread_ts: "1.1", }, }); expect(res.status).toBe(200); + await new Promise((r) => setTimeout(r, 100)); + + const status = slackTestState.calls.find( + (m) => m.method === "assistant.threads.setStatus", + ); + expect(status?.args).toMatchObject({ channel_id: "D1", thread_ts: "1.1" }); + + // Native status replaces the "Thinking..." placeholder: the answer is + // a single fresh message in the thread, never an update. + const posts = slackTestState.calls.filter( + (m) => m.method === "postMessage", + ); + expect(posts.length).toBe(1); + expect(posts[0].args).toMatchObject({ + channel: "D1", + thread_ts: "1.1", + text: "Here is my response", + }); + expect(slackTestState.calls.some((m) => m.method === "update")).toBe(false); + }); + + test("ignores the agent pane thread root and edits", async () => { + for (const subtype of ["assistant_app_thread", "message_changed"]) { + const res = await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_dm_${subtype}_${Date.now()}`, + event: { + type: "message", + subtype, + text: "hello", + user: "U1", + channel: "D1", + channel_type: "im", + ts: `${Date.now()}.6`, + }, + }); + expect(res.status).toBe(200); + } + await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); }); + test("posts the error in the agent pane when runAgent throws", async () => { + slackTestState.runAgentOverride = () => + Promise.reject(new Error("agent exploded")); + + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_dm_err_${Date.now()}`, + event: { + type: "message", + text: "hello", + user: "U1", + channel: "D1", + channel_type: "im", + ts: `${Date.now()}.7`, + thread_ts: "1.1", + }, + }); + await new Promise((r) => setTimeout(r, 100)); + + const errorPost = slackTestState.calls.find( + (m) => + m.method === "postMessage" && + typeof m.args.text === "string" && + m.args.text.includes("Something went wrong"), + ); + expect(errorPost).toBeDefined(); + }); + + test("sets suggested prompts on assistant_thread_started", async () => { + const res = await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_thread_started_${Date.now()}`, + event: { + type: "assistant_thread_started", + assistant_thread: { + user_id: "U1", + channel_id: "D1", + thread_ts: "2.2", + context: {}, + }, + }, + }); + + expect(res.status).toBe(200); + await new Promise((r) => setTimeout(r, 50)); + + const prompts = slackTestState.calls.find( + (m) => m.method === "assistant.threads.setSuggestedPrompts", + ); + expect(prompts?.args).toMatchObject({ channel_id: "D1", thread_ts: "2.2" }); + expect(prompts?.args.prompts).toBeDefined(); + expect((prompts?.args.prompts as unknown[]).length).toBeGreaterThan(0); + }); + test("ignores events without channel", async () => { const res = await signAndPost(app, { type: "event_callback", @@ -608,3 +865,54 @@ Shall I go ahead and publish this, or would you like to adjust anything?`, expect(looksLikeUncardedDraft("")).toBe(false); }); }); + +describe("isAnswerToAgent", () => { + const starter = { user: "U1", text: "<@UBOT> open an incident", ts: "1" }; + const question = { user: "UBOT", bot_id: "B1", text: "Which page?", ts: "2" }; + + test("answers the session starter replying right after the agent", () => { + const thread = [starter, question, { user: "U1", text: "API", ts: "3" }]; + expect(isAnswerToAgent(thread, { ts: "3", user: "U1" }, "UBOT")).toBe(true); + }); + + test("ignores someone other than the session starter", () => { + const thread = [starter, question, { user: "U2", text: "API", ts: "3" }]; + expect(isAnswerToAgent(thread, { ts: "3", user: "U2" }, "UBOT")).toBe( + false, + ); + }); + + test("ignores the starter once a human spoke after the agent", () => { + const thread = [ + starter, + question, + { user: "U2", text: "it's the API", ts: "3" }, + { user: "U1", text: "yes, the API", ts: "4" }, + ]; + expect(isAnswerToAgent(thread, { ts: "4", user: "U1" }, "UBOT")).toBe( + false, + ); + }); + + test("ignores threads where the agent was never mentioned", () => { + const thread = [ + { user: "U1", text: "anyone seeing errors?", ts: "1" }, + question, + { user: "U1", text: "API", ts: "3" }, + ]; + expect(isAnswerToAgent(thread, { ts: "3", user: "U1" }, "UBOT")).toBe( + false, + ); + }); + + test("ignores other bots' messages as the previous message", () => { + const thread = [ + starter, + { user: "UOTHER", bot_id: "B2", text: "Deploy finished", ts: "2" }, + { user: "U1", text: "API", ts: "3" }, + ]; + expect(isAnswerToAgent(thread, { ts: "3", user: "U1" }, "UBOT")).toBe( + false, + ); + }); +}); diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index 9b52f879..af266dc0 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -7,6 +7,12 @@ import { z } from "zod"; import { runAgent } from "./agent"; import { + setAssistantStatus, + setSessionStatus, + startAssistantThread, +} from "./assistant"; +import { + type Block, buildConfirmationBlocks, getConfirmationText, type RefResolvers, @@ -15,13 +21,19 @@ import { findByThread, replace, store } from "./confirmation-store"; import type { PendingPayload } from "./confirmation-store"; import { publishHomeView } from "./home"; import { toMrkdwn } from "./mrkdwn"; -import { getComponentNames, getPageDashboardLink } from "./page-urls"; +import { + getComponentNames, + getPageDashboardLink, + getStatusReportLink, +} from "./page-urls"; import { getRegistryTool, isSlackToolDraft } from "./registry-runner"; import { resolveWorkspace } from "./workspace-resolver"; function makeRefResolvers(workspaceId: number): RefResolvers { return { page: (pageId) => getPageDashboardLink(workspaceId, pageId), + statusReport: (statusReportId) => + getStatusReportLink(workspaceId, statusReportId), componentNames: (ids) => getComponentNames(workspaceId, ids), }; } @@ -54,6 +66,12 @@ const slackEventSchema = z.object({ thread_ts: z.string().optional(), bot_id: z.string().optional(), tab: z.string().optional(), + assistant_thread: z + .object({ + channel_id: z.string(), + thread_ts: z.string(), + }) + .optional(), }) .optional(), event_id: z.string().optional(), @@ -103,6 +121,57 @@ export function looksLikeUncardedDraft(text: string): boolean { return (text.match(PROSE_DRAFT_FIELDS) ?? []).length >= 2; } +// Bounds the Slack calls (and the agent's context) on very long threads. +const MAX_THREAD_PAGES = 5; + +// Replies come oldest first, so a single page would miss the latest messages +// of a long thread — the ones the agent is being asked about. +async function fetchThread( + slack: WebClient, + channel: string, + threadTs: string, +): Promise { + const messages: ThreadMessage[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_THREAD_PAGES; page++) { + const replies = await slack.conversations.replies({ + channel, + ts: threadTs, + limit: 100, + cursor, + }); + messages.push(...((replies.messages ?? []) as ThreadMessage[])); + cursor = replies.response_metadata?.next_cursor || undefined; + if (!replies.has_more || !cursor) break; + } + return messages; +} + +/** + * Whether an untagged channel-thread message is the user answering the agent + * (e.g. "API" after "Which status page — API or Marketing?"). True only when + * the agent posted the message right before it and its author is whoever first + * mentioned the agent in the thread. Anything else still needs a mention, so + * the agent stays out of the humans' side of an incident thread. + */ +export function isAnswerToAgent( + thread: ThreadMessage[], + message: { ts: string; user?: string }, + botUserId: string, +): boolean { + if (!message.user || !botUserId) return false; + const index = thread.findIndex((m) => m.ts === message.ts); + const earlier = index === -1 ? thread : thread.slice(0, index); + + const previous = earlier.at(-1); + if (previous?.user !== botUserId) return false; + + const starter = earlier.find( + (m) => m.user !== botUserId && m.text?.includes(`<@${botUserId}>`), + ); + return starter?.user === message.user; +} + export async function handleSlackEvent(c: Context) { const body = c.get("slackBody") as SlackEvent; @@ -175,9 +244,34 @@ async function processEvent(body: SlackEvent) { return; } + if (event.type === "assistant_thread_started") { + const teamId = body.team_id; + const thread = event.assistant_thread; + if (!teamId || !thread) return; + const resolved = await resolveWorkspace(teamId); + if (!resolved) return; + try { + await startAssistantThread( + new WebClient(resolved.botToken), + thread.channel_id, + thread.thread_ts, + ); + } catch (err) { + logger.error("slack failed to start assistant thread", { + error: err, + teamId, + }); + } + return; + } + if (event.type !== "app_mention" && event.type !== "message") return; if (event.type === "message" && event.bot_id) return; + // The agent pane is the app's DM: every message there is addressed to us, + // so no mention is required. + const isAgentThread = event.channel_type === "im"; + const ignoredSubtypes = [ "channel_join", "channel_leave", @@ -186,6 +280,9 @@ async function processEvent(body: SlackEvent) { "channel_name", ]; if (event.subtype && ignoredSubtypes.includes(event.subtype)) return; + // In the agent pane, subtypes are the thread root (`assistant_app_thread`), + // edits and deletions — only plain user messages start a turn. + if (isAgentThread && event.subtype) return; const teamId = body.team_id; if (!teamId || !event.channel || !event.ts) return; @@ -206,8 +303,38 @@ async function processEvent(body: SlackEvent) { const botUserId = resolved.botUserId; const threadTs = event.thread_ts ?? event.ts; - if (event.type === "message" && !event.text?.includes(`<@${botUserId}>`)) { - return; + // Fetched early only when needed to decide whether to answer; reused below + // so the agent sees the same thread. + let prefetchedThread: ThreadMessage[] | undefined; + if ( + !isAgentThread && + event.type === "message" && + !event.text?.includes(`<@${botUserId}>`) + ) { + if (!event.thread_ts) return; + try { + prefetchedThread = await fetchThread( + slack, + event.channel, + event.thread_ts, + ); + } catch (err) { + logger.warn("slack failed to fetch thread for untagged reply", { + error: err, + channel: event.channel, + teamId, + }); + return; + } + if ( + !isAnswerToAgent( + prefetchedThread, + { ts: event.ts, user: event.user }, + botUserId, + ) + ) { + return; + } } logger.info("slack event received", { @@ -216,67 +343,30 @@ async function processEvent(body: SlackEvent) { eventType: event.type, threadTs, user: event.user, + agentThread: isAgentThread, }); - let thinkingTs: string | undefined; - try { - const thinkingMsg = await slack.chat.postMessage({ - channel: event.channel, - thread_ts: threadTs, - text: ":hourglass_flowing_sand: Thinking...", - }); - thinkingTs = thinkingMsg.ts; - } catch (err) { - if (isSlackPlatformError(err, "cannot_reply_to_message")) { - logger.warn("slack cannot reply to message, falling back to top-level", { - channel: event.channel, - teamId, - threadTs, - }); - try { - const fallbackMsg = await slack.chat.postMessage({ - channel: event.channel, - text: ":hourglass_flowing_sand: Thinking...", - }); - thinkingTs = fallbackMsg.ts; - } catch (fallbackErr) { - logger.error("slack failed to post fallback thinking message", { - error: fallbackErr, - channel: event.channel, - teamId, - }); - return; - } - } else { - logger.error("slack failed to post thinking message", { - error: err, - channel: event.channel, - teamId, - threadTs, - }); - return; - } - } - - if (!thinkingTs) { - logger.error("slack thinking message returned no ts", { - channel: event.channel, + const reply = + (await acknowledgeWithSession( + slack, + event.channel, + threadTs, teamId, - }); - return; - } + event.user, + )) ?? + (isAgentThread + ? await acknowledgeInAgentThread(slack, event.channel, threadTs, teamId) + : await acknowledgeInChannel(slack, event.channel, threadTs, teamId)); + if (!reply) return; try { let thread: ThreadMessage[] = []; - if (event.thread_ts) { - const replies = await slack.conversations.replies({ - channel: event.channel, - ts: event.thread_ts, - limit: 100, - }); - thread = ((replies.messages ?? []) as ThreadMessage[]).filter( - (msg) => msg.ts !== thinkingTs, - ); + if (prefetchedThread) { + thread = prefetchedThread; + } else if (event.thread_ts) { + thread = ( + await fetchThread(slack, event.channel, event.thread_ts) + ).filter((msg) => msg.ts !== reply.placeholderTs); } else { thread = [{ user: event.user, text: event.text, ts: event.ts }]; } @@ -324,9 +414,9 @@ async function processEvent(body: SlackEvent) { }); await handleConfirmation( slack, + reply, event.channel, threadTs, - thinkingTs, event.user ?? "", resolved.workspace.id, resolved.botToken, @@ -347,9 +437,7 @@ async function processEvent(body: SlackEvent) { readToolCalls: result.toolResults.map((tr) => tr.toolName), }); } - await slack.chat.update({ - channel: event.channel, - ts: thinkingTs, + await reply.send({ text: result.text ? toMrkdwn(result.text) : "Done!", }); logger.info("slack response sent", { @@ -365,29 +453,174 @@ async function processEvent(body: SlackEvent) { teamId, threadTs, }); - if (thinkingTs) { - await slack.chat - .update({ + await reply + .send({ text: ":x: Something went wrong. Please try again." }) + .catch((sendErr: unknown) => { + logger.error("slack failed to send error message", { + error: sendErr, channel: event.channel, - ts: thinkingTs, - text: ":x: Something went wrong. Please try again.", - }) - .catch((updateErr: unknown) => { - logger.error("slack failed to update error message", { - error: updateErr, - channel: event.channel, - thinkingTs, - }); + threadTs, + }); + }); + } finally { + await reply.finish?.(); + } +} + +/** + * Where the agent's answer goes. In a channel we post a "Thinking..." message + * up front and overwrite it; in the agent pane Slack renders a native status + * instead, so the answer is a fresh message in the thread. + */ +interface Reply { + /** Writes the answer and returns the ts of the message holding it. */ + send(message: { text: string; blocks?: Block[] }): Promise; + /** Our own "Thinking..." message, to keep it out of the agent's context. */ + placeholderTs?: string; + /** Runs once the turn is over, whether it succeeded or not. */ + finish?: () => Promise; +} + +function postInThread( + slack: WebClient, + channel: string, + threadTs: string, +): Reply["send"] { + return async ({ text, blocks }) => { + const res = await slack.chat.postMessage({ + channel, + thread_ts: threadTs, + text, + blocks, + }); + if (!res.ts) throw new Error("chat.postMessage returned no ts"); + return res.ts; + }; +} + +/** + * Preferred acknowledgement: mark the thread's agent session as `processing` + * so Slack shows the agent working — no placeholder message — and hand it + * back as `active` once we've answered. Returns undefined when the workspace + * doesn't support agent sessions, so the caller falls back to the older + * indicators. + */ +async function acknowledgeWithSession( + slack: WebClient, + channel: string, + threadTs: string, + teamId: string, + userId: string | undefined, +): Promise { + try { + await setSessionStatus(slack, channel, threadTs, "processing", userId); + } catch (err) { + logger.info("slack agent session unavailable, falling back", { + error: err, + channel, + teamId, + }); + return; + } + return { + send: postInThread(slack, channel, threadTs), + async finish() { + await setSessionStatus(slack, channel, threadTs, "active").catch( + (err: unknown) => + logger.warn("slack failed to reset agent session status", { + error: err, + channel, + teamId, + }), + ); + }, + }; +} + +async function acknowledgeInAgentThread( + slack: WebClient, + channel: string, + threadTs: string, + teamId: string, +): Promise { + // Best-effort: a missing status only loses the loading indicator. + await setAssistantStatus(slack, channel, threadTs, "is thinking...").catch( + (err: unknown) => + logger.warn("slack failed to set assistant status", { + error: err, + channel, + teamId, + }), + ); + return { send: postInThread(slack, channel, threadTs) }; +} + +async function acknowledgeInChannel( + slack: WebClient, + channel: string, + threadTs: string, + teamId: string, +): Promise { + let thinkingTs: string | undefined; + try { + const thinkingMsg = await slack.chat.postMessage({ + channel, + thread_ts: threadTs, + text: ":hourglass_flowing_sand: Thinking...", + }); + thinkingTs = thinkingMsg.ts; + } catch (err) { + if (isSlackPlatformError(err, "cannot_reply_to_message")) { + logger.warn("slack cannot reply to message, falling back to top-level", { + channel, + teamId, + threadTs, + }); + try { + const fallbackMsg = await slack.chat.postMessage({ + channel, + text: ":hourglass_flowing_sand: Thinking...", }); + thinkingTs = fallbackMsg.ts; + } catch (fallbackErr) { + logger.error("slack failed to post fallback thinking message", { + error: fallbackErr, + channel, + teamId, + }); + return; + } + } else { + logger.error("slack failed to post thinking message", { + error: err, + channel, + teamId, + threadTs, + }); + return; } } + + if (!thinkingTs) { + logger.error("slack thinking message returned no ts", { channel, teamId }); + return; + } + + const ts = thinkingTs; + return { + placeholderTs: ts, + async send({ text, blocks }) { + await slack.chat.update({ channel, ts, text, blocks }); + return ts; + }, + }; } async function handleConfirmation( slack: WebClient, + reply: Reply, channel: string, threadTs: string, - thinkingTs: string, userId: string, workspaceId: number, botToken: string, @@ -400,11 +633,7 @@ async function handleConfirmation( logger.error("slack: registry tool not found", { toolName: draft.toolName, }); - await slack.chat.update({ - channel, - ts: thinkingTs, - text: ":x: Something went wrong. Please try again.", - }); + await reply.send({ text: ":x: Something went wrong. Please try again." }); return; } @@ -430,7 +659,7 @@ async function handleConfirmation( input: draft.displayInput, resolvers: makeRefResolvers(workspaceId), }); - await slack.chat.update({ channel, ts: thinkingTs, text, blocks }); + await reply.send({ text, blocks }); await slack.chat.update({ channel, ts: existing.messageTs, @@ -438,12 +667,16 @@ async function handleConfirmation( blocks, }); } else { + // The card's buttons carry the action id, and the stored action carries + // the card's ts — so write the text first to learn the ts, then attach + // the buttons. + const messageTs = await reply.send({ text }); const actionId = await store({ workspaceId, botToken, channelId: channel, threadTs, - messageTs: thinkingTs, + messageTs, userId, payload, }); @@ -454,6 +687,6 @@ async function handleConfirmation( input: draft.displayInput, resolvers: makeRefResolvers(workspaceId), }); - await slack.chat.update({ channel, ts: thinkingTs, text, blocks }); + await slack.chat.update({ channel, ts: messageTs, text, blocks }); } } diff --git a/apps/server/src/routes/slack/home.ts b/apps/server/src/routes/slack/home.ts index a7030af6..8caad375 100644 --- a/apps/server/src/routes/slack/home.ts +++ b/apps/server/src/routes/slack/home.ts @@ -13,7 +13,7 @@ export function buildHomeBlocks(): KnownBlock[] { type: "section", text: { type: "mrkdwn", - text: "Manage incidents and status pages without leaving Slack. Mention *@openstatus* in any channel or thread and it drafts a status update from the conversation — nothing is published until you approve it.", + text: "Your incident communication agent. Open *openstatus* from the Slack top bar to chat with it, or mention *@openstatus* in any channel or thread — it drafts status updates from the conversation, and nothing is published until you approve it.", }, }, { type: "divider" }, @@ -21,7 +21,7 @@ export function buildHomeBlocks(): KnownBlock[] { type: "section", text: { type: "mrkdwn", - text: '*Create & update incidents*\nMention `@openstatus` describing the issue. It reads the thread, drafts a report, and you click *Approve*, *Approve & Notify*, or *Cancel*. Say _"we found the cause"_ or _"it\'s fixed"_ and it moves the incident to Identified or Resolved.', + text: '*Create & update incidents*\nDescribe the issue in the agent pane, or mention `@openstatus` in any channel or thread. It reads the thread, drafts a report, and you click *Approve*, *Approve & Notify*, or *Cancel*. Say _"we found the cause"_ or _"it\'s fixed"_ and it moves the incident to Identified or Resolved.', }, }, { diff --git a/apps/server/src/routes/slack/oauth.test.ts b/apps/server/src/routes/slack/oauth.test.ts index 1e3f7872..bc338015 100644 --- a/apps/server/src/routes/slack/oauth.test.ts +++ b/apps/server/src/routes/slack/oauth.test.ts @@ -91,11 +91,13 @@ describe("handleSlackInstall", () => { const expectedScopes = [ "app_mentions:read", + "assistant:write", "channels:history", "chat:write", "groups:history", "groups:read", "groups:write", + "im:history", ]; for (const s of expectedScopes) { diff --git a/apps/server/src/routes/slack/oauth.ts b/apps/server/src/routes/slack/oauth.ts index 407848c1..354a421b 100644 --- a/apps/server/src/routes/slack/oauth.ts +++ b/apps/server/src/routes/slack/oauth.ts @@ -18,6 +18,7 @@ const SLACK_TOKEN_URL = "https://slack.com/api/oauth.v2.access"; const BOT_SCOPES = [ "app_mentions:read", + "assistant:write", "channels:history", "channels:join", "chat:write", @@ -25,6 +26,7 @@ const BOT_SCOPES = [ "groups:history", "groups:read", "groups:write", + "im:history", ].join(","); interface OAuthState { diff --git a/apps/server/src/routes/slack/page-urls.ts b/apps/server/src/routes/slack/page-urls.ts index 8d210c91..02ce1f95 100644 --- a/apps/server/src/routes/slack/page-urls.ts +++ b/apps/server/src/routes/slack/page-urls.ts @@ -1,5 +1,5 @@ import { and, db, eq, inArray } from "@openstatus/db"; -import { page, pageComponent } from "@openstatus/db/src/schema"; +import { page, pageComponent, statusReport } from "@openstatus/db/src/schema"; import { env } from "@/env"; @@ -26,7 +26,7 @@ export async function getPageUrl(pageId: number): Promise { function getDashboardBaseUrl(): string { return env.NODE_ENV === "production" ? "https://app.openstatus.dev" - : "http://localhost:3000"; + : "http://localhost:3001"; } /** @@ -98,3 +98,30 @@ export async function getReportUrl( : `https://${statusPage.slug}.openstatus.dev`; return `${baseUrl}/events/report/${reportId}`; } + +/** + * Resolve a status report id to its title and public URL for the approval + * card, scoped to the workspace so a spoofed id from another workspace never + * leaks its title. Returns null when the report doesn't exist in the workspace. + */ +export async function getStatusReportLink( + workspaceId: number, + statusReportId: number, +): Promise<{ title: string; url: string | null } | null> { + const report = await db + .select({ title: statusReport.title, pageId: statusReport.pageId }) + .from(statusReport) + .where( + and( + eq(statusReport.workspaceId, workspaceId), + eq(statusReport.id, statusReportId), + ), + ) + .get(); + + if (!report) return null; + const url = report.pageId + ? await getReportUrl(report.pageId, statusReportId) + : null; + return { title: report.title, url }; +} diff --git a/apps/server/src/routes/slack/registry-runner.test.ts b/apps/server/src/routes/slack/registry-runner.test.ts index a0523800..d0854fad 100644 --- a/apps/server/src/routes/slack/registry-runner.test.ts +++ b/apps/server/src/routes/slack/registry-runner.test.ts @@ -14,6 +14,7 @@ import { getRegistryTool, isSlackToolDraft, } from "./registry-runner"; +import { buildSystemPrompt } from "./system-prompt"; const fakeCtx = { workspace: { id: 1 }, @@ -346,7 +347,10 @@ describe("buildTool draft split", () => { async function runExecute(t: AnyAgentTool) { const built = buildTool(t, fakeCtx); if (!built.execute) throw new Error("expected an execute fn"); - return built.execute({ value: 7 }, { toolCallId: "t", messages: [] }); + return built.execute( + { value: 7 }, + { toolCallId: "t", messages: [], context: {} }, + ); } test("persists raw input but enriches a separate displayInput", async () => { @@ -370,3 +374,15 @@ describe("buildTool draft split", () => { expect(result.displayInput).toEqual({ value: 7 }); }); }); + +describe("buildSystemPrompt coverage", () => { + // buildSlackTools hands the model every registry tool; a tool the prompt + // never mentions is one the model won't reach for (or will misuse). + test("mentions every tool the Slack agent is given", () => { + const prompt = buildSystemPrompt("Acme Corp"); + const missing = Object.keys(agentTools).filter( + (name) => !new RegExp(`\\b${name}\\b`).test(prompt), + ); + expect(missing).toEqual([]); + }); +}); diff --git a/apps/server/src/routes/slack/system-prompt.test.ts b/apps/server/src/routes/slack/system-prompt.test.ts index 6d431cd8..5d6d4d3d 100644 --- a/apps/server/src/routes/slack/system-prompt.test.ts +++ b/apps/server/src/routes/slack/system-prompt.test.ts @@ -45,4 +45,14 @@ describe("buildSystemPrompt", () => { // partial recovery before resolve IS reported manually, as operational. expect(prompt).toContain("Recovery counts as a change"); }); + + test("forbids guessing monitor and notification ids", () => { + const prompt = buildSystemPrompt("Acme Corp"); + expect(prompt).toContain("call list_monitors FIRST"); + expect(prompt).toContain("call list_notifications FIRST"); + }); + + test("tells the model not to show internal ids", () => { + expect(buildSystemPrompt("Acme Corp")).toContain("NEVER show internal ids"); + }); }); diff --git a/apps/server/src/routes/slack/system-prompt.ts b/apps/server/src/routes/slack/system-prompt.ts index 8f32655b..296f869e 100644 --- a/apps/server/src/routes/slack/system-prompt.ts +++ b/apps/server/src/routes/slack/system-prompt.ts @@ -10,7 +10,10 @@ export function buildSystemPrompt(workspaceName: string): string { const now = new Date().toISOString(); return `You are the OpenStatus assistant for workspace "${workspaceName}". The current date and time is: ${now} (UTC). -You help teams create and manage status reports and maintenance windows through Slack. +You help teams through Slack with three kinds of work: +- Incident communication: create and manage status reports and maintenance windows on their status pages. +- SRE / on-call questions ("what's broken right now?", "is the checkout monitor healthy?") answered from monitors, response logs, notification channels, private locations, and audit logs. +- Product / how-to questions about openstatus itself, answered from the official docs. HOW APPROVAL WORKS HERE — read this before any write tool: Calling a write tool (create_status_report, add_status_report_update, update_status_report, resolve_status_report, create_maintenance) does NOT execute it. It renders an approval card in Slack with Approve/Cancel buttons, and nothing is created, published, or notified until the user clicks Approve. The card IS how you ask. @@ -20,10 +23,15 @@ Calling a write tool (create_status_report, add_status_report_update, update_sta - NEVER ask whether to notify subscribers. That choice is a button on the card, not yours. - Only ask a question in text when you genuinely cannot build the call: an ambiguous status page, an unclear component impact, a missing date. Ask that, get the answer, then call the tool. -IMPORTANT: You have NO knowledge of this workspace's data. NEVER guess or make up IDs (page IDs, component IDs, report IDs). You MUST call the appropriate tool first to get real data. +IMPORTANT: You have NO knowledge of this workspace's data. NEVER guess or make up IDs (page, component, report, maintenance, monitor, notification, response log, audit log IDs). You MUST call the appropriate tool first to get real data. - Questions about pages or components -> call list_status_pages FIRST - Questions about reports -> call list_status_reports FIRST - Questions about maintenances -> call list_maintenances FIRST +- Questions about a monitor, including by name ("the API monitor") -> call list_monitors FIRST to get its id +- Questions about a notification channel, including by name ("PagerDuty") -> call list_notifications FIRST +- Questions about a private location / on-prem checker -> call list_private_locations FIRST +- Questions about who changed what -> call list_audit_logs FIRST; use get_audit_log only with an id it returned +- Details of a single check -> get_response_log only with an id returned by list_response_logs - Creating a report -> you MUST call list_status_pages first to get the real pageId, then call create_status_report with that pageId - Scheduling maintenance -> you MUST call list_status_pages first to get the real pageId, then call create_maintenance with that pageId - Components live on a specific page — call list_page_components({ pageId }) to discover pageComponentIds. @@ -31,13 +39,25 @@ IMPORTANT: You have NO knowledge of this workspace's data. NEVER guess or make u - NEVER pass a pageId you did not receive from list_status_pages. Guessing a pageId WILL cause an error. Capabilities: +Status pages and incidents (write tools render an approval card): - Create status reports on status pages (create_status_report) - Publish progress updates to existing reports (add_status_report_update) - Edit report metadata like title or components (update_status_report) - Resolve active reports (resolve_status_report) -- List active status reports and status pages - Schedule maintenance windows (create_maintenance) -- List upcoming maintenance windows (list_maintenances) +- List status pages, page components, status reports, and maintenance windows (list_status_pages, list_page_components, list_status_reports, list_maintenances) +Monitoring (read-only): +- List monitors and read one monitor's config (list_monitors, get_monitor) +- Per-region health right now (get_monitor_status) +- Uptime and latency percentiles over a window (get_monitor_summary) +- Individual check results, e.g. recent failures (list_response_logs, get_response_log) +- Notification channels and which monitors they're wired to (list_notifications) +- Private locations and whether their checkers are reporting (list_private_locations) +- Audit trail of workspace changes (list_audit_logs, get_audit_log) +openstatus knowledge (read-only): +- Product docs (search_docs, get_doc_page) +- Pricing, comparisons, use cases, customer stories, blog (search_content, get_content_page) +You CANNOT create, edit, pause, or delete monitors or notification channels, and you cannot send an alert. Say so plainly and point the user to the dashboard. Lifecycle: create_status_report once -> add_status_report_update repeatedly -> resolve_status_report. - "provide an update", "we found the cause" -> add_status_report_update @@ -59,11 +79,27 @@ Guidelines: "we're watching it" -> monitoring "it's fixed" -> resolved - Draft professional status page updates. Don't repeat the user verbatim. -- When tagged in a thread, synthesize the full thread into a status report draft. +- When tagged in a channel thread, synthesize the full thread into a status report draft. +- In a direct conversation (the agent pane), the user is talking to you directly — answer their request; there is no channel discussion to summarize. - Status progression: investigating -> identified -> monitoring -> resolved - Be concise. Use Slack mrkdwn formatting (*bold*, _italic_). +- NEVER show internal ids (page, component, report, maintenance, monitor ids) in your replies — they mean nothing to the reader. Refer to things by name; when two share a name, tell them apart by slug, URL, or another visible detail. Ids are only for tool calls. - Every mutation goes through a tool call — see "HOW APPROVAL WORKS HERE" above. A drafted change you did not call a tool for is a change the user cannot approve. +Monitor diagnostics: +- get_monitor_status returns one row per configured region (active/degraded/error). Report at the worst region's level: "Healthy in 5/7 regions; failing in gru, fra." Don't invent a composite "overall: degraded" label — the per-region facts ARE the answer. +- Default to the last 1 day for get_monitor_summary and list_response_logs; use 7d or 14d only if the user asks for a longer window. +- Before drafting a status report that names a monitor as degraded or down, call get_monitor_status to confirm the per-region state — don't rely on the user's framing alone. +- list_notifications shows which monitors each channel is wired to (by id — resolve names with list_monitors). Use it to advise ("PagerDuty is attached to the API monitor, so on-call will be paged"). + +Docs and product questions: +- For questions about how openstatus works (features, configuration, CLI, API, plans), call search_docs BEFORE answering — never answer product questions from memory. +- Search with keyword queries. If the first search misses, retry once with different terms or type: "guides". "When did X ship?" -> type: "changelog". +- Read the best 1-2 hits with get_doc_page, ground your answer in that content, and ALWAYS cite the page URL(s) as links. +- Pricing, plan fit, comparisons with other tools, use cases, customer stories, blog posts -> search_content, then get_content_page on the best hits. +- If nothing relevant is found, say so plainly instead of guessing. +- Don't use search_docs or search_content for workspace data — the list/get tools are the source of truth there. + Maintenance scheduling: - Parse natural language dates into ISO 8601 format. Convert relative dates like "next Friday from 2-3 PM" into proper ISO 8601 timestamps. - If the user doesn't specify a timezone, default to UTC and mention that in your response. diff --git a/apps/server/src/routes/slack/verify.ts b/apps/server/src/routes/slack/verify.ts index 6fd0045f..ce673f0a 100644 --- a/apps/server/src/routes/slack/verify.ts +++ b/apps/server/src/routes/slack/verify.ts @@ -1,12 +1,27 @@ +import { getLogger } from "@logtape/logtape"; import { createMiddleware } from "hono/factory"; import type { SlackEnv } from "./config"; +const logger = getLogger("api-server"); + +// Slack retries on a 401 and then disables the subscription, so a secret that +// doesn't match the app looks like "the bot never answers" — make it loud. +function logInvalidSignature(path: string) { + logger.warn( + "slack request rejected: invalid signature — does SLACK_SIGNING_SECRET belong to this Slack app?", + { path }, + ); +} + export const verifySlackSignature = createMiddleware( async (c, next) => { const signingSecret = c.get("slackConfig")?.signingSecret; if (!signingSecret) { + logger.error("slack request rejected: signing secret not configured", { + path: c.req.path, + }); return c.json({ error: "Slack not configured" }, 503); } @@ -19,6 +34,10 @@ export const verifySlackSignature = createMiddleware( const now = Math.floor(Date.now() / 1000); if (Math.abs(now - Number(timestamp)) > 300) { + logger.warn("slack request rejected: stale timestamp", { + path: c.req.path, + skewSeconds: now - Number(timestamp), + }); return c.json({ error: "Request too old" }, 401); } @@ -43,6 +62,7 @@ export const verifySlackSignature = createMiddleware( .join("")}`; if (computed.length !== signature.length) { + logInvalidSignature(c.req.path); return c.json({ error: "Invalid signature" }, 401); } @@ -53,6 +73,7 @@ export const verifySlackSignature = createMiddleware( mismatch |= a[i] ^ b[i]; } if (mismatch !== 0) { + logInvalidSignature(c.req.path); return c.json({ error: "Invalid signature" }, 401); } diff --git a/apps/web/src/app/api/callback/pagerduty/route.ts b/apps/web/src/app/api/callback/pagerduty/route.ts index b020ab21..292d8573 100644 --- a/apps/web/src/app/api/callback/pagerduty/route.ts +++ b/apps/web/src/app/api/callback/pagerduty/route.ts @@ -8,7 +8,7 @@ export async function GET(request: Request) { const APP_URL = `${ process.env.NODE_ENV === "development" // FIXME: This sucks - ? "http://localhost:3000" + ? "http://localhost:3001" : "https://app.openstatus.dev" }/notifications?${searchParams}&channel=pagerduty`; -- 2.51.2