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.
| 0c3eee5 | 1 | /** |
| 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 | ||
| 28 | import { Hono } from "hono"; | |
| 29 | import { eq, and } from "drizzle-orm"; | |
| 30 | import { db } from "../db"; | |
| 31 | import { chatIntegrations } from "../db/schema"; | |
| 32 | import { | |
| 33 | parseSlackSlashCommand, | |
| 34 | parseDiscordSlashCommand, | |
| 35 | verifySlackSignature, | |
| 36 | verifyDiscordSignature, | |
| 37 | handleBotCommand, | |
| 38 | type DiscordInteractionLike, | |
| 39 | } from "../lib/chat-bot"; | |
| 40 | ||
| 41 | const r = new Hono(); | |
| 42 | ||
| 43 | // --------------------------------------------------------------------------- | |
| 44 | // Slack — Events API + slash commands hit the same endpoint. | |
| 45 | // --------------------------------------------------------------------------- | |
| 46 | ||
| 47 | r.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 | ||
| 126 | r.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 | ||
| 227 | interface InstallBody { | |
| 228 | team_id?: string; | |
| 229 | channel_id?: string; | |
| 230 | webhook_url?: string; | |
| 231 | signing_secret?: string; | |
| 232 | } | |
| 233 | ||
| 234 | async 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 | ||
| 278 | r.post("/api/v2/integrations/slack/install", (c) => handleInstall("slack", c)); | |
| 279 | r.post("/api/v2/integrations/discord/install", (c) => | |
| 280 | handleInstall("discord", c) | |
| 281 | ); | |
| 282 | ||
| 283 | // --------------------------------------------------------------------------- | |
| 284 | // Helpers | |
| 285 | // --------------------------------------------------------------------------- | |
| 286 | ||
| 287 | function looksLikeJson(body: string): boolean { | |
| 288 | const t = body.trimStart(); | |
| 289 | return t.startsWith("{") || t.startsWith("["); | |
| 290 | } | |
| 291 | ||
| 292 | export default r; |