Skip to content

Commit eab18e8

Browse files
slack app: subscribe to status page
1 parent aeaa751 commit eab18e8

34 files changed

Lines changed: 6291 additions & 31 deletions

File tree

apps/dashboard/src/components/data-table/subscribers/columns.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ export const columns: ColumnDef<Subscriber>[] = [
4040
const flavor =
4141
sub.channelType === "webhook"
4242
? detectFlavorBadge(sub.webhookUrl)
43-
: null;
43+
: sub.channelType === "slack"
44+
? "Slack"
45+
: null;
4446

4547
return (
4648
<div className="flex items-center gap-2">

apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,17 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
6161
statusPage?.pageComponentGroups ?? [],
6262
);
6363

64-
const editDefaults = isVendorAdded
65-
? {
66-
channelType: sub.channelType,
67-
name: sub.name ?? "",
68-
email: sub.email ?? "",
69-
webhookUrl: sub.webhookUrl ?? "",
70-
headers: parseHeaders(sub.channelConfig),
71-
componentIds: sub.components.map((c) => c.id),
72-
}
73-
: undefined;
64+
const editDefaults =
65+
isVendorAdded && sub.channelType !== "slack"
66+
? {
67+
channelType: sub.channelType,
68+
name: sub.name ?? "",
69+
email: sub.email ?? "",
70+
webhookUrl: sub.webhookUrl ?? "",
71+
headers: parseHeaders(sub.channelConfig),
72+
componentIds: sub.components.map((c) => c.id),
73+
}
74+
: undefined;
7475

7576
const actions = [
7677
...(isVendorAdded

apps/server/src/routes/rpc/handlers/status-page/converters.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export type DBPageSubscriber = {
7777
updatedAt: Date | null;
7878
source: "self_signup" | "vendor" | "import";
7979
name: string | null;
80-
channelType: "email" | "webhook";
80+
channelType: "email" | "webhook" | "slack";
8181
webhookUrl: string | null;
8282
channelConfig: string | null;
8383
componentIds?: number[];
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { ForbiddenError } from "@openstatus/services";
2+
import {
3+
createSlackSubscriber,
4+
listSlackSubscribersForChannel,
5+
removeSlackSubscriber,
6+
} from "@openstatus/services/page-subscriber";
7+
import { WebClient } from "@slack/web-api";
8+
import type { Context } from "hono";
9+
import { z } from "zod";
10+
11+
import { resolvePageFromUrl } from "./resolve-page";
12+
import { resolveWorkspace } from "./workspace-resolver";
13+
14+
const slashCommandSchema = z.object({
15+
text: z.string().optional().default(""),
16+
team_id: z.string(),
17+
channel_id: z.string(),
18+
channel_name: z.string().optional(),
19+
});
20+
21+
const HELP = [
22+
"*OpenStatus*",
23+
"• `/openstatus add <status-page-url>` — subscribe this channel to a status page",
24+
"• `/openstatus remove <status-page-url>` — unsubscribe",
25+
"• `/openstatus list` — show this channel's subscriptions",
26+
].join("\n");
27+
28+
function ephemeral(c: Context, text: string) {
29+
return c.json({ response_type: "ephemeral", text });
30+
}
31+
32+
async function joinChannel(teamId: string, channelId: string): Promise<void> {
33+
try {
34+
const resolved = await resolveWorkspace(teamId);
35+
if (!resolved) return;
36+
const client = new WebClient(resolved.botToken);
37+
await client.conversations.join({ channel: channelId });
38+
} catch (error) {
39+
// Private channels can't be self-joined — the bot must be /invited.
40+
if (error instanceof Error) {
41+
console.error(
42+
`slack: conversations.join failed for ${channelId}: ${error.message}`,
43+
);
44+
}
45+
}
46+
}
47+
48+
export async function handleSlackCommand(c: Context) {
49+
const parsed = slashCommandSchema.safeParse(c.get("slackBody"));
50+
if (!parsed.success) {
51+
return ephemeral(c, "Could not read the command.");
52+
}
53+
const { text, team_id, channel_id, channel_name } = parsed.data;
54+
55+
const tokens = text.trim().split(/\s+/).filter(Boolean);
56+
const sub = (tokens[0] ?? "help").toLowerCase();
57+
const arg = tokens[1];
58+
59+
if (sub === "add") {
60+
if (!arg) {
61+
return ephemeral(c, "Usage: `/openstatus add <status-page-url>`");
62+
}
63+
const page = await resolvePageFromUrl(arg);
64+
if (!page) {
65+
return ephemeral(c, `Couldn't find a status page at \`${arg}\`.`);
66+
}
67+
try {
68+
const result = await createSlackSubscriber({
69+
input: {
70+
pageId: page.id,
71+
teamId: team_id,
72+
channelId: channel_id,
73+
channelName: channel_name,
74+
},
75+
});
76+
await joinChannel(team_id, channel_id);
77+
if (result.alreadySubscribed) {
78+
return ephemeral(
79+
c,
80+
`This channel is already subscribed to *${page.title}*.`,
81+
);
82+
}
83+
return ephemeral(
84+
c,
85+
`📡 This channel is now subscribed to *${page.title}*. Incident updates will appear here.`,
86+
);
87+
} catch (error) {
88+
if (error instanceof ForbiddenError) {
89+
return ephemeral(
90+
c,
91+
`*${page.title}* isn't on a plan that supports subscribers.`,
92+
);
93+
}
94+
console.error("slack /openstatus add failed:", error);
95+
return ephemeral(c, "Something went wrong subscribing this channel.");
96+
}
97+
}
98+
99+
if (sub === "remove") {
100+
if (!arg) {
101+
const subs = await listSlackSubscribersForChannel({
102+
input: { channelId: channel_id },
103+
});
104+
if (subs.length === 0) {
105+
return ephemeral(
106+
c,
107+
"This channel isn't subscribed to any status page.",
108+
);
109+
}
110+
if (subs.length === 1) {
111+
await removeSlackSubscriber({
112+
input: { pageId: subs[0].pageId, channelId: channel_id },
113+
});
114+
return ephemeral(c, `Unsubscribed from *${subs[0].pageName}*.`);
115+
}
116+
const list = subs.map((s) => `• ${s.pageName}`).join("\n");
117+
return ephemeral(
118+
c,
119+
`This channel is subscribed to several pages — specify which:\n${list}\n\nUsage: \`/openstatus remove <status-page-url>\``,
120+
);
121+
}
122+
const page = await resolvePageFromUrl(arg);
123+
if (!page) {
124+
return ephemeral(c, `Couldn't find a status page at \`${arg}\`.`);
125+
}
126+
const { removed } = await removeSlackSubscriber({
127+
input: { pageId: page.id, channelId: channel_id },
128+
});
129+
return ephemeral(
130+
c,
131+
removed
132+
? `Unsubscribed from *${page.title}*.`
133+
: `This channel wasn't subscribed to *${page.title}*.`,
134+
);
135+
}
136+
137+
if (sub === "list") {
138+
const subs = await listSlackSubscribersForChannel({
139+
input: { channelId: channel_id },
140+
});
141+
if (subs.length === 0) {
142+
return ephemeral(c, "This channel isn't subscribed to any status page.");
143+
}
144+
const list = subs.map((s) => `• *${s.pageName}*`).join("\n");
145+
return ephemeral(c, `This channel is subscribed to:\n${list}`);
146+
}
147+
148+
return ephemeral(c, HELP);
149+
}

apps/server/src/routes/slack/handler.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getLogger } from "@logtape/logtape";
2-
import { and, db, eq } from "@openstatus/db";
3-
import { integration } from "@openstatus/db/src/schema";
2+
import { and, db, eq, isNull, sql } from "@openstatus/db";
3+
import { integration, pageSubscriber } from "@openstatus/db/src/schema";
44
import { WebClient } from "@slack/web-api";
55
import type { Context } from "hono";
66
import { z } from "zod";
@@ -111,6 +111,16 @@ async function processEvent(body: SlackEvent) {
111111
eq(integration.externalId, teamId),
112112
),
113113
);
114+
await db
115+
.update(pageSubscriber)
116+
.set({ unsubscribedAt: new Date(), updatedAt: new Date() })
117+
.where(
118+
and(
119+
eq(pageSubscriber.channelType, "slack"),
120+
isNull(pageSubscriber.unsubscribedAt),
121+
sql`json_extract(${pageSubscriber.channelConfig}, '$.teamId') = ${teamId}`,
122+
),
123+
);
114124
logger.info("slack integration cleaned up", { teamId });
115125
}
116126
return;

apps/server/src/routes/slack/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Hono } from "hono";
22

33
import { env } from "@/env";
44

5+
import { handleSlackCommand } from "./commands";
56
import { handleSlackEvent } from "./handler";
67
import { handleSlackInteraction } from "./interactions";
78
import { handleSlackInstall, handleSlackOAuthCallback } from "./oauth";
@@ -28,5 +29,6 @@ slack.get("/oauth/callback", handleSlackOAuthCallback);
2829

2930
slack.post("/events", verifySlackSignature, handleSlackEvent);
3031
slack.post("/interactions", verifySlackSignature, handleSlackInteraction);
32+
slack.post("/commands", verifySlackSignature, handleSlackCommand);
3133

3234
export { slack as slackRoute };

apps/server/src/routes/slack/oauth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ const SLACK_TOKEN_URL = "https://slack.com/api/oauth.v2.access";
1919
const BOT_SCOPES = [
2020
"app_mentions:read",
2121
"channels:history",
22+
"channels:join",
2223
"chat:write",
24+
"commands",
2325
"groups:history",
2426
"groups:read",
2527
"groups:write",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { db, eq, or } from "@openstatus/db";
2+
import { page } from "@openstatus/db/src/schema";
3+
4+
const BASE_DOMAIN_SUFFIX = ".openstatus.dev";
5+
6+
export interface ResolvedPage {
7+
id: number;
8+
title: string;
9+
slug: string;
10+
customDomain: string | null;
11+
}
12+
13+
function hostFromInput(raw: string): string | null {
14+
const trimmed = raw.trim();
15+
if (!trimmed) return null;
16+
const withScheme = /^https?:\/\//i.test(trimmed)
17+
? trimmed
18+
: `https://${trimmed}`;
19+
try {
20+
return new URL(withScheme).hostname.toLowerCase();
21+
} catch {
22+
return null;
23+
}
24+
}
25+
26+
/**
27+
* Resolve a public status-page URL (or bare host) to its page. Cross-workspace
28+
* by design — Slack subscriptions are public self-signup, like email.
29+
*/
30+
export async function resolvePageFromUrl(
31+
raw: string,
32+
): Promise<ResolvedPage | null> {
33+
const host = hostFromInput(raw);
34+
if (!host) return null;
35+
36+
if (host.endsWith(BASE_DOMAIN_SUFFIX)) {
37+
const slug = host.slice(0, -BASE_DOMAIN_SUFFIX.length);
38+
if (!slug || slug.includes(".")) return null;
39+
const row = await db
40+
.select({
41+
id: page.id,
42+
title: page.title,
43+
slug: page.slug,
44+
customDomain: page.customDomain,
45+
})
46+
.from(page)
47+
.where(eq(page.slug, slug))
48+
.get();
49+
return row ?? null;
50+
}
51+
52+
const bareHost = host.startsWith("www.") ? host.slice(4) : host;
53+
const row = await db
54+
.select({
55+
id: page.id,
56+
title: page.title,
57+
slug: page.slug,
58+
customDomain: page.customDomain,
59+
})
60+
.from(page)
61+
.where(or(eq(page.customDomain, host), eq(page.customDomain, bareHost)))
62+
.get();
63+
return row ?? null;
64+
}

apps/server/src/routes/slack/verify.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ export const verifySlackSignature = createMiddleware<{
5959
} else if (contentType.includes("application/x-www-form-urlencoded")) {
6060
const params = new URLSearchParams(rawBody);
6161
const payload = params.get("payload");
62-
c.set("slackBody", payload ? JSON.parse(payload) : {});
62+
// Interactions arrive as a `payload` field; slash commands arrive as the
63+
// flat form fields themselves.
64+
c.set(
65+
"slackBody",
66+
payload ? JSON.parse(payload) : Object.fromEntries(params),
67+
);
6368
}
6469

6570
await next();
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
PRAGMA foreign_keys=OFF;--> statement-breakpoint
2+
CREATE TABLE `__new_page_subscriber` (
3+
`id` integer PRIMARY KEY NOT NULL,
4+
`email` text,
5+
`page_id` integer NOT NULL,
6+
`channel_type` text DEFAULT 'email' NOT NULL,
7+
`webhook_url` text,
8+
`channel_config` text,
9+
`slack_channel_id` text,
10+
`source` text DEFAULT 'self_signup' NOT NULL,
11+
`name` text,
12+
`token` text,
13+
`accepted_at` integer,
14+
`expires_at` integer,
15+
`unsubscribed_at` integer,
16+
`created_at` integer DEFAULT (strftime('%s', 'now')),
17+
`updated_at` integer DEFAULT (strftime('%s', 'now')),
18+
FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE cascade,
19+
CONSTRAINT "page_subscriber_channel_check" CHECK(("__new_page_subscriber"."channel_type" = 'email' AND "__new_page_subscriber"."email" IS NOT NULL AND "__new_page_subscriber"."webhook_url" IS NULL) OR ("__new_page_subscriber"."channel_type" = 'webhook' AND "__new_page_subscriber"."webhook_url" IS NOT NULL AND "__new_page_subscriber"."email" IS NULL) OR ("__new_page_subscriber"."channel_type" = 'slack' AND "__new_page_subscriber"."slack_channel_id" IS NOT NULL AND "__new_page_subscriber"."email" IS NULL AND "__new_page_subscriber"."webhook_url" IS NULL))
20+
);
21+
--> statement-breakpoint
22+
INSERT INTO `__new_page_subscriber`("id", "email", "page_id", "channel_type", "webhook_url", "channel_config", "slack_channel_id", "source", "name", "token", "accepted_at", "expires_at", "unsubscribed_at", "created_at", "updated_at") SELECT "id", "email", "page_id", "channel_type", "webhook_url", "channel_config", NULL, "source", "name", "token", "accepted_at", "expires_at", "unsubscribed_at", "created_at", "updated_at" FROM `page_subscriber`;--> statement-breakpoint
23+
DROP TABLE `page_subscriber`;--> statement-breakpoint
24+
ALTER TABLE `__new_page_subscriber` RENAME TO `page_subscriber`;--> statement-breakpoint
25+
PRAGMA foreign_keys=ON;--> statement-breakpoint
26+
CREATE UNIQUE INDEX `idx_page_subscriber_email_page_active` ON `page_subscriber` (`LOWER("email")`,`page_id`) WHERE "page_subscriber"."unsubscribed_at" IS NULL AND "page_subscriber"."channel_type" = 'email';--> statement-breakpoint
27+
CREATE UNIQUE INDEX `idx_page_subscriber_webhook_page_active` ON `page_subscriber` (`LOWER("webhook_url")`,`page_id`) WHERE "page_subscriber"."unsubscribed_at" IS NULL AND "page_subscriber"."channel_type" = 'webhook';--> statement-breakpoint
28+
CREATE UNIQUE INDEX `idx_page_subscriber_slack_channel_page_active` ON `page_subscriber` (`slack_channel_id`,`page_id`) WHERE "page_subscriber"."unsubscribed_at" IS NULL AND "page_subscriber"."channel_type" = 'slack';

0 commit comments

Comments
 (0)