diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json new file mode 100644 index 00000000..f882dc5f --- /dev/null +++ b/apps/server/slack-manifest.json @@ -0,0 +1,64 @@ +{ + "display_information": { + "name": "openstatus", + "description": "Manage incidents and status pages directly from Slack.", + "background_color": "#000000", + "long_description": "openstatus brings incident management into your team's Slack workspace. Mention the bot or send it a direct message 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." + }, + "features": { + "app_home": { + "home_tab_enabled": true, + "messages_tab_enabled": false, + "messages_tab_read_only_enabled": true + }, + "bot_user": { + "display_name": "openstatus", + "always_online": true + }, + "slash_commands": [ + { + "command": "/openstatus", + "url": "https://api.openstatus.dev/slack/commands", + "description": "Subscribe this channel to a status page", + "usage_hint": "subscribe | unsubscribe | subscriptions ", + "should_escape": false + } + ] + }, + "oauth_config": { + "redirect_urls": ["https://api.openstatus.dev/slack/oauth/callback"], + "scopes": { + "user": ["groups:write"], + "user_optional": ["groups:write"], + "bot": [ + "app_mentions:read", + "channels:history", + "channels:join", + "chat:write", + "commands", + "groups:history", + "groups:read", + "groups:write", + "im:history", + "im:read", + "im:write", + "mpim:history" + ] + }, + "pkce_enabled": false + }, + "settings": { + "event_subscriptions": { + "request_url": "https://api.openstatus.dev/slack/events", + "bot_events": ["app_home_opened", "message.channels", "message.groups"] + }, + "interactivity": { + "is_enabled": true, + "request_url": "https://api.openstatus.dev/slack/interactions" + }, + "org_deploy_enabled": true, + "socket_mode_enabled": false, + "token_rotation_enabled": false, + "is_mcp_enabled": false + } +} diff --git a/packages/subscriptions/src/channels/slack.test.ts b/packages/subscriptions/src/channels/slack.test.ts index a9084853..d4494cf3 100644 --- a/packages/subscriptions/src/channels/slack.test.ts +++ b/packages/subscriptions/src/channels/slack.test.ts @@ -199,6 +199,31 @@ describe("createSlackChannel", () => { expect(unsubscribed).toEqual([7]); }); + test("token error leaves subscribers intact and aborts the team batch", async () => { + const { client, calls } = makeClient({ failPostWith: "invalid_auth" }); + const unsubscribed: number[] = []; + const channel = createSlackChannel({ + store: createMemoryAnchorStore(), + createClient: () => client, + getBotToken: token, + softUnsubscribe: async (id) => { + unsubscribed.push(id); + }, + }); + + await channel.sendNotifications( + [ + makeSub({ id: 1, slackChannelId: "C1" }), + makeSub({ id: 2, slackChannelId: "C2" }), + ], + makeUpdate(), + ); + + // No subscriber unsubscribed, and the second member is never attempted. + expect(unsubscribed).toEqual([]); + expect(calls.filter((c) => c.method === "post").length).toBe(1); + }); + test("maintenance posts once with no thread and no anchor", async () => { const { client, calls } = makeClient(); const store = createMemoryAnchorStore(); diff --git a/packages/subscriptions/src/channels/slack.ts b/packages/subscriptions/src/channels/slack.ts index e469020a..6759e688 100644 --- a/packages/subscriptions/src/channels/slack.ts +++ b/packages/subscriptions/src/channels/slack.ts @@ -26,19 +26,30 @@ export interface SlackChannelDeps { softUnsubscribe: (subscriberId: number) => Promise; } -// Errors that can never succeed on retry — the channel/app is gone, so we -// stop delivering to that subscriber instead of failing on every update. -const TERMINAL_SLACK_ERRORS = new Set([ +// Channel-scoped terminal errors: this subscriber's destination is gone for +// good, so we stop delivering to that one subscriber. +const CHANNEL_TERMINAL_ERRORS = new Set([ "channel_not_found", "is_archived", "channel_is_archived", +]); + +// Token-scoped errors: the team's bot token is invalid. This affects every +// subscriber on the team and is recoverable by reinstalling the app, so we +// abort the team's batch WITHOUT unsubscribing anyone — otherwise a broken +// token silently and permanently drops every subscription on the team. +const TOKEN_TERMINAL_ERRORS = new Set([ + "invalid_auth", "account_inactive", "token_revoked", - "not_in_channel", "not_authed", - "invalid_auth", ]); +// Returned by a delivery when the team token is invalid, signalling the caller +// to abort the remaining members of that team. +const TEAM_TOKEN_INVALID = Symbol("slack_team_token_invalid"); +type DeliveryOutcome = typeof TEAM_TOKEN_INVALID | undefined; + // WebClient throws `WebAPIPlatformError` carrying `data.error`; this is the // only place we reach into that SDK error shape. function slackErrorCode(error: Error): string | undefined { @@ -83,13 +94,16 @@ export function createSlackChannel(deps: SlackChannelDeps) { async function runSlack( subscriberId: number, fn: () => Promise, - ): Promise { + ): Promise { try { return await fn(); } catch (error) { if (error instanceof Error) { const code = slackErrorCode(error); - if (code && TERMINAL_SLACK_ERRORS.has(code)) { + if (code && TOKEN_TERMINAL_ERRORS.has(code)) { + return TEAM_TOKEN_INVALID; + } + if (code && CHANNEL_TERMINAL_ERRORS.has(code)) { await deps.softUnsubscribe(subscriberId); console.error( `slack: terminal error '${code}' for subscriber ${subscriberId} — unsubscribed`, @@ -111,15 +125,16 @@ export function createSlackChannel(deps: SlackChannelDeps) { sub: Subscription, channelId: string, pageUpdate: PageUpdate, - ): Promise { + ): Promise { const root = buildRootMessage(pageUpdate, sub); - await runSlack(sub.id, () => + const res = await runSlack(sub.id, () => client.postMessage({ channel: channelId, text: root.text, attachments: root.attachments, }), ); + if (res === TEAM_TOKEN_INVALID) return TEAM_TOKEN_INVALID; } async function deliverReport( @@ -127,7 +142,7 @@ export function createSlackChannel(deps: SlackChannelDeps) { sub: Subscription, channelId: string, pageUpdate: PageUpdate, - ): Promise { + ): Promise { const reportId = pageUpdate.id; const updateId = pageUpdate.updateId; if (updateId == null) { @@ -150,9 +165,9 @@ export function createSlackChannel(deps: SlackChannelDeps) { attachments: root.attachments, }), ); - if (!res) { + if (!res || res === TEAM_TOKEN_INVALID) { await deps.store.releaseDelivery(reportId, sub.id, updateId); - return; + return res === TEAM_TOKEN_INVALID ? TEAM_TOKEN_INVALID : undefined; } if (res.ts) { await deps.store.setAnchor(reportId, sub.id, { ts: res.ts, channelId }); @@ -169,13 +184,13 @@ export function createSlackChannel(deps: SlackChannelDeps) { blocks: reply.blocks, }), ); - if (!replyRes) { + if (!replyRes || replyRes === TEAM_TOKEN_INVALID) { await deps.store.releaseDelivery(reportId, sub.id, updateId); - return; + return replyRes === TEAM_TOKEN_INVALID ? TEAM_TOKEN_INVALID : undefined; } // Re-render the root so its emoji/status track the latest state. - await runSlack(sub.id, () => + const updateRes = await runSlack(sub.id, () => client.update({ channel: anchor.channelId, ts: anchor.ts, @@ -183,6 +198,7 @@ export function createSlackChannel(deps: SlackChannelDeps) { attachments: root.attachments, }), ); + if (updateRes === TEAM_TOKEN_INVALID) return TEAM_TOKEN_INVALID; } async function sendNotifications( @@ -214,13 +230,20 @@ export function createSlackChannel(deps: SlackChannelDeps) { return; } const client = deps.createClient(token); - await Promise.allSettled( - members.map(({ sub, channelId }) => + // Sequential per team so a token failure aborts the batch before + // hammering Slack with N calls that will all fail identically. + for (const { sub, channelId } of members) { + const outcome = pageUpdate.status === "maintenance" - ? deliverMaintenance(client, sub, channelId, pageUpdate) - : deliverReport(client, sub, channelId, pageUpdate), - ), - ); + ? await deliverMaintenance(client, sub, channelId, pageUpdate) + : await deliverReport(client, sub, channelId, pageUpdate); + if (outcome === TEAM_TOKEN_INVALID) { + console.error( + `slack: team ${teamId} bot token invalid — aborting ${members.length} deliveries; subscribers left intact (reconnect the Slack app)`, + ); + break; + } + } }), ); }