Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

integrations-chat.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

integrations-chat.tsBlame292 lines · 1 contributor
0c3eee5Claude1/**
2 * Chat-bot endpoints — Slack events, Discord interactions, install
3 * callbacks. Mounted on `/api/v2/integrations/{slack,discord}/*`.
4 *
5 * Surface:
6 * POST /slack/events — Slack Events API + slash commands
7 * POST /discord/interactions — Discord interactions (slash commands)
8 * POST /slack/install — OAuth completion (placeholder)
9 * POST /discord/install — OAuth completion (placeholder)
10 *
11 * Verification:
12 * - Slack: X-Slack-Signature HMAC-SHA256 over `v0:<ts>:<body>`. The
13 * signing secret comes from the chat_integrations row that matches the
14 * incoming team_id.
15 * - Discord: X-Signature-Ed25519 + X-Signature-Timestamp. The Application
16 * Public Key is stored in chat_integrations.signing_secret. We look it
17 * up by guild_id when present, otherwise we accept any row of
18 * kind='discord' that verifies (multi-tenancy is exact-match on the
19 * public key, so this is safe).
20 *
21 * Dispatch:
22 * - Both endpoints decode the slash-command, call handleBotCommand(), and
23 * return the formatted blocks/embeds inline. We never await long ops
24 * here — anything that would block returns a deep-link instead (see
25 * cmdSpecShip / cmdChat in chat-bot.ts).
26 */
27
28import { Hono } from "hono";
29import { eq, and } from "drizzle-orm";
30import { db } from "../db";
31import { chatIntegrations } from "../db/schema";
32import {
33 parseSlackSlashCommand,
34 parseDiscordSlashCommand,
35 verifySlackSignature,
36 verifyDiscordSignature,
37 handleBotCommand,
38 type DiscordInteractionLike,
39} from "../lib/chat-bot";
40
41const r = new Hono();
42
43// ---------------------------------------------------------------------------
44// Slack — Events API + slash commands hit the same endpoint.
45// ---------------------------------------------------------------------------
46
47r.post("/api/v2/integrations/slack/events", async (c) => {
48 const body = await c.req.text();
49 const sig = c.req.header("X-Slack-Signature") ?? "";
50 const ts = c.req.header("X-Slack-Request-Timestamp") ?? "";
51
52 // url_verification challenge arrives as JSON BEFORE the integration is
53 // saved, so we can't reach into chat_integrations for a secret. We MAY
54 // accept it without verification because the response (`challenge`) is
55 // exactly what Slack sent us — it carries no privileged data. Slack's
56 // own docs recommend skipping signature on url_verification.
57 if (looksLikeJson(body)) {
58 try {
59 const parsed = JSON.parse(body) as {
60 type?: string;
61 challenge?: string;
62 };
63 if (parsed.type === "url_verification" && parsed.challenge) {
64 return c.json({ challenge: parsed.challenge });
65 }
66 } catch {
67 /* fall through */
68 }
69 }
70
71 // Slash commands and event subscriptions are form-encoded; pull team_id
72 // out to find the right signing secret.
73 const params = new URLSearchParams(body);
74 const teamId = params.get("team_id") ?? "";
75
76 // Find every Slack integration that could possibly own this request — we
77 // need the signing_secret to verify. We try each in turn (typical user
78 // has one Slack workspace).
79 const candidates = await db
80 .select()
81 .from(chatIntegrations)
82 .where(
83 and(
84 eq(chatIntegrations.kind, "slack"),
85 teamId
86 ? eq(chatIntegrations.teamId, teamId)
87 : eq(chatIntegrations.kind, "slack")
88 )
89 );
90
91 let matched = null;
92 for (const integ of candidates) {
93 if (!integ.signingSecret) continue;
94 const ok = await verifySlackSignature({
95 signingSecret: integ.signingSecret,
96 timestamp: ts,
97 signature: sig,
98 body,
99 });
100 if (ok) {
101 matched = integ;
102 break;
103 }
104 }
105 if (!matched) {
106 return c.json({ error: "invalid_signature" }, 401);
107 }
108
109 // Slash command path: dispatch + return blocks inline.
110 const text = params.get("text") ?? "";
111 const parsed = parseSlackSlashCommand(text);
112 const response = await handleBotCommand({
113 kind: "slack",
114 userId: matched.ownerUserId,
115 command: parsed.command,
116 subcommand: parsed.subcommand,
117 args: parsed.args,
118 });
119 return c.json(response);
120});
121
122// ---------------------------------------------------------------------------
123// Discord — interactions endpoint (Ed25519-signed)
124// ---------------------------------------------------------------------------
125
126r.post("/api/v2/integrations/discord/interactions", async (c) => {
127 const body = await c.req.text();
128 const sig = c.req.header("X-Signature-Ed25519") ?? "";
129 const ts = c.req.header("X-Signature-Timestamp") ?? "";
130
131 if (!sig || !ts) {
132 return c.json({ error: "missing_signature" }, 401);
133 }
134
135 // Look up by guild_id if present in body; else scan all discord rows
136 // (typical install volume is small per user — the constant-time verify
137 // ensures attackers can't enumerate).
138 let guildId: string | null = null;
139 try {
140 const parsed = JSON.parse(body) as { guild_id?: string };
141 guildId = parsed.guild_id ?? null;
142 } catch {
143 /* not JSON → fail below */
144 }
145
146 const candidates = guildId
147 ? await db
148 .select()
149 .from(chatIntegrations)
150 .where(
151 and(
152 eq(chatIntegrations.kind, "discord"),
153 eq(chatIntegrations.teamId, guildId)
154 )
155 )
156 : await db
157 .select()
158 .from(chatIntegrations)
159 .where(eq(chatIntegrations.kind, "discord"));
160
161 let matched = null;
162 for (const integ of candidates) {
163 if (!integ.signingSecret) continue;
164 const ok = await verifyDiscordSignature({
165 publicKeyHex: integ.signingSecret,
166 signatureHex: sig,
167 timestamp: ts,
168 body,
169 });
170 if (ok) {
171 matched = integ;
172 break;
173 }
174 }
175 if (!matched) return c.json({ error: "invalid_signature" }, 401);
176
177 let interaction: DiscordInteractionLike;
178 try {
179 interaction = JSON.parse(body) as DiscordInteractionLike;
180 } catch {
181 return c.json({ error: "bad_json" }, 400);
182 }
183
184 // PING (type 1) → PONG (type 1). Discord uses this every few minutes.
185 if (interaction.type === 1) {
186 return c.json({ type: 1 });
187 }
188
189 const parsed = parseDiscordSlashCommand(interaction);
190 const response = await handleBotCommand({
191 kind: "discord",
192 userId: matched.ownerUserId,
193 command: parsed.command,
194 subcommand: parsed.subcommand,
195 args: parsed.args,
196 });
197
198 // CHANNEL_MESSAGE_WITH_SOURCE (type 4).
199 if (response.kind === "discord") {
200 return c.json({
201 type: 4,
202 data: {
203 embeds: response.embeds,
204 content: response.content,
205 flags: response.ephemeral ? 1 << 6 : 0,
206 },
207 });
208 }
209 // Defensive — handleBotCommand should always return the matching kind.
210 return c.json({ type: 4, data: { content: "ok" } });
211});
212
213// ---------------------------------------------------------------------------
214// Install / OAuth completion stubs.
215//
216// These accept either:
217// * a JSON body with { team_id, channel_id, webhook_url, signing_secret }
218// for direct manual installs from /settings/integrations;
219// * an OAuth `code` exchange (when a real Slack/Discord app is wired up,
220// this code path is the one to extend).
221//
222// Auth: requires a Gluecron session cookie or PAT; we use the api-v2
223// surface's auth (apiAuth runs at the basePath). For the stub we trust the
224// caller's `ownerUserId` to be the session user.
225// ---------------------------------------------------------------------------
226
227interface InstallBody {
228 team_id?: string;
229 channel_id?: string;
230 webhook_url?: string;
231 signing_secret?: string;
232}
233
234async function handleInstall(
235 kind: "slack" | "discord",
236 c: import("hono").Context
237) {
238 // Minimal auth — read the session cookie / Bearer via api-v2 middleware
239 // (we're mounted under the same prefix). The shared apiAuth middleware
240 // populates c.get("user"); fall through to 401 if absent.
241 // To avoid pulling in the middleware import cycle here, we check both.
242 const user = (c.get as (k: string) => unknown)("user") as
243 | { id: string }
244 | undefined;
245 if (!user?.id) return c.json({ error: "auth_required" }, 401);
246
247 let body: InstallBody = {};
248 try {
249 body = (await c.req.json()) as InstallBody;
250 } catch {
251 /* allow empty body for placeholder */
252 }
253 const webhookUrl = (body.webhook_url ?? "").trim();
254 if (!webhookUrl) {
255 return c.json(
256 { error: "missing_webhook_url", hint: "POST a JSON body with webhook_url" },
257 400
258 );
259 }
260
261 const [row] = await db
262 .insert(chatIntegrations)
263 .values({
264 ownerUserId: user.id,
265 kind,
266 teamId: body.team_id?.trim() || null,
267 channelId: body.channel_id?.trim() || null,
268 webhookUrl,
269 signingSecret: body.signing_secret?.trim() || null,
270 enabled: true,
271 })
272 .onConflictDoNothing()
273 .returning();
274
275 return c.json({ ok: true, integration: row ?? null }, 201);
276}
277
278r.post("/api/v2/integrations/slack/install", (c) => handleInstall("slack", c));
279r.post("/api/v2/integrations/discord/install", (c) =>
280 handleInstall("discord", c)
281);
282
283// ---------------------------------------------------------------------------
284// Helpers
285// ---------------------------------------------------------------------------
286
287function looksLikeJson(body: string): boolean {
288 const t = body.trimStart();
289 return t.startsWith("{") || t.startsWith("[");
290}
291
292export default r;