CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
chat-bot.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 command dispatcher — backs the Slack and Discord slash-command | |
| 3 | * endpoints (`/api/v2/integrations/slack/events`, | |
| 4 | * `/api/v2/integrations/discord/interactions`) and the outbound notification | |
| 5 | * formatter consumed by `src/lib/chat-notifier.ts`. | |
| 6 | * | |
| 7 | * Design rules: | |
| 8 | * - This file only does parsing + dispatch + presentation. The actual ops | |
| 9 | * (listing PRs, creating issues, etc.) reuse the same DB queries the REST | |
| 10 | * surface in `src/routes/api-v2.ts` uses. We pull from the same drizzle | |
| 11 | * tables so a future change to the schema doesn't drift between Slack | |
| 12 | * and the web. | |
| 13 | * - Slash commands map 1:1 to a small command set: `pr list`, `pr open`, | |
| 14 | * `issue list`, `issue create`, `spec ship`, `chat`, `help`. Anything | |
| 15 | * unrecognised falls through to a help block. | |
| 16 | * - All formatters return Slack `blocks` or Discord `embeds`, never raw | |
| 17 | * strings — chat surfaces strip plain text aggressively and we want | |
| 18 | * consistent rendering. | |
| 19 | * - Signature verification for both providers lives here so the endpoint | |
| 20 | * handlers stay thin and the test fixtures can exercise the core. | |
| 21 | */ | |
| 22 | ||
| 23 | import { eq, and, desc } from "drizzle-orm"; | |
| 24 | import { db } from "../db"; | |
| 25 | import { | |
| 26 | users, | |
| 27 | repositories, | |
| 28 | issues, | |
| 29 | pullRequests, | |
| 30 | } from "../db/schema"; | |
| 31 | import type { PullRequest, Issue } from "../db/schema"; | |
| 32 | ||
| 33 | // --------------------------------------------------------------------------- | |
| 34 | // Types | |
| 35 | // --------------------------------------------------------------------------- | |
| 36 | ||
| 37 | export type ChatKind = "slack" | "discord" | "teams"; | |
| 38 | ||
| 39 | export interface ParsedCommand { | |
| 40 | /** Bare command verb, lowercased — e.g. "pr", "issue", "spec", "chat", "help". */ | |
| 41 | command: string; | |
| 42 | /** Subcommand or argv[1] — e.g. "list", "open", "create", "ship". May be "". */ | |
| 43 | subcommand: string; | |
| 44 | /** Whitespace-trimmed rest of the input. May be "". */ | |
| 45 | args: string; | |
| 46 | /** Original raw text (post-trim). Kept for echo / debug. */ | |
| 47 | raw: string; | |
| 48 | } | |
| 49 | ||
| 50 | export interface SlackBlock { | |
| 51 | type: string; | |
| 52 | text?: { type: string; text: string; [k: string]: unknown }; | |
| 53 | fields?: Array<{ type: string; text: string }>; | |
| 54 | elements?: Array<Record<string, unknown>>; | |
| 55 | [k: string]: unknown; | |
| 56 | } | |
| 57 | ||
| 58 | export interface DiscordEmbed { | |
| 59 | title?: string; | |
| 60 | description?: string; | |
| 61 | color?: number; | |
| 62 | url?: string; | |
| 63 | fields?: Array<{ name: string; value: string; inline?: boolean }>; | |
| 64 | footer?: { text: string }; | |
| 65 | timestamp?: string; | |
| 66 | } | |
| 67 | ||
| 68 | export interface BotResponseSlack { | |
| 69 | kind: "slack"; | |
| 70 | blocks: SlackBlock[]; | |
| 71 | /** Slack expects `response_type: in_channel` for public posts. */ | |
| 72 | response_type?: "in_channel" | "ephemeral"; | |
| 73 | text?: string; | |
| 74 | } | |
| 75 | ||
| 76 | export interface BotResponseDiscord { | |
| 77 | kind: "discord"; | |
| 78 | embeds: DiscordEmbed[]; | |
| 79 | /** When set, restricts visibility to the invoking user. */ | |
| 80 | ephemeral?: boolean; | |
| 81 | content?: string; | |
| 82 | } | |
| 83 | ||
| 84 | export type BotResponse = BotResponseSlack | BotResponseDiscord; | |
| 85 | ||
| 86 | // --------------------------------------------------------------------------- | |
| 87 | // Parsers | |
| 88 | // --------------------------------------------------------------------------- | |
| 89 | ||
| 90 | /** | |
| 91 | * Slack slash commands arrive as `application/x-www-form-urlencoded` with the | |
| 92 | * whole user input squashed into one `text` field. We split on whitespace and | |
| 93 | * synthesise the {command, subcommand, args} triple. Quotes in `args` are | |
| 94 | * preserved verbatim — downstream code strips them if it needs to. | |
| 95 | */ | |
| 96 | export function parseSlackSlashCommand(text: string): ParsedCommand { | |
| 97 | const raw = (text ?? "").trim(); | |
| 98 | if (!raw) return { command: "help", subcommand: "", args: "", raw }; | |
| 99 | ||
| 100 | const parts = raw.split(/\s+/); | |
| 101 | const command = (parts[0] ?? "").toLowerCase(); | |
| 102 | const subcommand = (parts[1] ?? "").toLowerCase(); | |
| 103 | ||
| 104 | // For `pr open <title>` / `issue create <title>` / `spec ship <desc>` we | |
| 105 | // want the rest verbatim, including quotes — the model that consumes it | |
| 106 | // (spec→PR, ai-chat) handles its own escaping. | |
| 107 | const rest = raw.slice(parts[0]!.length).trimStart(); | |
| 108 | // If the second token looks like a subcommand, strip it off; otherwise the | |
| 109 | // first token IS the only thing and args is whatever followed it. | |
| 110 | const hasSubcommand = | |
| 111 | subcommand !== "" && | |
| 112 | /^[a-z][a-z0-9-]*$/.test(subcommand) && | |
| 113 | SUBCOMMAND_VERBS.has(`${command} ${subcommand}`); | |
| 114 | ||
| 115 | const args = hasSubcommand | |
| 116 | ? rest.slice(parts[1]!.length).trimStart() | |
| 117 | : rest; | |
| 118 | ||
| 119 | return { | |
| 120 | command, | |
| 121 | subcommand: hasSubcommand ? subcommand : "", | |
| 122 | args: stripWrappingQuotes(args), | |
| 123 | raw, | |
| 124 | }; | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Discord delivers slash commands as a structured interaction body. We expect | |
| 129 | * the upstream registration to declare one top-level command (`gluecron`) | |
| 130 | * with a subcommand group ("pr", "issue", "spec", "chat") and a single | |
| 131 | * string option carrying the free-text payload. This parser is lenient: if | |
| 132 | * the caller passed only `data.name` and the literal text fell into an | |
| 133 | * option we'll still find it. | |
| 134 | */ | |
| 135 | export function parseDiscordSlashCommand( | |
| 136 | interaction: DiscordInteractionLike | |
| 137 | ): ParsedCommand { | |
| 138 | const root = interaction?.data?.name?.toLowerCase() ?? ""; | |
| 139 | const opts = interaction?.data?.options ?? []; | |
| 140 | ||
| 141 | // The Discord SDK nests subcommands: data.options[0] = { name: 'pr', | |
| 142 | // type: 1, options: [{ name: 'list', type: 1, options: [...] }] } or | |
| 143 | // similar. We walk one level deep to find the verb + payload. | |
| 144 | let command = root === "gluecron" ? "" : root; | |
| 145 | let subcommand = ""; | |
| 146 | let args = ""; | |
| 147 | ||
| 148 | if (root === "gluecron" && opts.length > 0) { | |
| 149 | const first = opts[0]!; | |
| 150 | command = (first.name ?? "").toLowerCase(); | |
| 151 | if (Array.isArray(first.options) && first.options.length > 0) { | |
| 152 | const sub = first.options[0]!; | |
| 153 | if (sub.type === 1 || sub.type === 2) { | |
| 154 | // subcommand / subcommand_group | |
| 155 | subcommand = (sub.name ?? "").toLowerCase(); | |
| 156 | const payload = (sub.options ?? []).find( | |
| 157 | (o: DiscordOptionLike) => typeof o.value === "string" | |
| 158 | ); | |
| 159 | if (payload) args = String(payload.value ?? "").trim(); | |
| 160 | } else if (typeof sub.value === "string") { | |
| 161 | // First-level string option (no subcommand layer). | |
| 162 | args = String(sub.value ?? "").trim(); | |
| 163 | } | |
| 164 | } | |
| 165 | } else { | |
| 166 | // Bare command — pull args from the first string option. | |
| 167 | const payload = opts.find((o) => typeof o.value === "string"); | |
| 168 | if (payload) args = String(payload.value ?? "").trim(); | |
| 169 | } | |
| 170 | ||
| 171 | const raw = [command, subcommand, args].filter(Boolean).join(" ").trim(); | |
| 172 | return { | |
| 173 | command: command || "help", | |
| 174 | subcommand, | |
| 175 | args: stripWrappingQuotes(args), | |
| 176 | raw, | |
| 177 | }; | |
| 178 | } | |
| 179 | ||
| 180 | const SUBCOMMAND_VERBS = new Set<string>([ | |
| 181 | "pr list", | |
| 182 | "pr open", | |
| 183 | "issue list", | |
| 184 | "issue create", | |
| 185 | "spec ship", | |
| 186 | ]); | |
| 187 | ||
| 188 | function stripWrappingQuotes(s: string): string { | |
| 189 | if (!s) return s; | |
| 190 | if ( | |
| 191 | (s.startsWith('"') && s.endsWith('"')) || | |
| 192 | (s.startsWith("'") && s.endsWith("'")) || | |
| 193 | (s.startsWith("“") && s.endsWith("”")) | |
| 194 | ) { | |
| 195 | return s.slice(1, -1); | |
| 196 | } | |
| 197 | return s; | |
| 198 | } | |
| 199 | ||
| 200 | // --------------------------------------------------------------------------- | |
| 201 | // Signature verification | |
| 202 | // --------------------------------------------------------------------------- | |
| 203 | ||
| 204 | /** | |
| 205 | * Slack signs every request with `v0=hex(hmac-sha256(secret, "v0:" + ts + | |
| 206 | * ":" + body))` in `X-Slack-Signature`. Timestamp is in `X-Slack-Request- | |
| 207 | * Timestamp` — we reject anything older than 5 minutes to deflect replay. | |
| 208 | */ | |
| 209 | export async function verifySlackSignature(opts: { | |
| 210 | signingSecret: string; | |
| 211 | timestamp: string; | |
| 212 | signature: string; | |
| 213 | body: string; | |
| 214 | /** Inject Date.now()/1000 in tests; defaults to real time. */ | |
| 215 | now?: number; | |
| 216 | }): Promise<boolean> { | |
| 217 | if (!opts.signingSecret || !opts.timestamp || !opts.signature) return false; | |
| 218 | ||
| 219 | const tsNum = parseInt(opts.timestamp, 10); | |
| 220 | if (!Number.isFinite(tsNum)) return false; | |
| 221 | const nowSec = opts.now ?? Math.floor(Date.now() / 1000); | |
| 222 | if (Math.abs(nowSec - tsNum) > 300) return false; | |
| 223 | ||
| 224 | const base = `v0:${opts.timestamp}:${opts.body}`; | |
| 225 | const key = await crypto.subtle.importKey( | |
| 226 | "raw", | |
| 227 | new TextEncoder().encode(opts.signingSecret), | |
| 228 | { name: "HMAC", hash: "SHA-256" }, | |
| 229 | false, | |
| 230 | ["sign"] | |
| 231 | ); | |
| 232 | const sigBytes = await crypto.subtle.sign( | |
| 233 | "HMAC", | |
| 234 | key, | |
| 235 | new TextEncoder().encode(base) | |
| 236 | ); | |
| 237 | const expected = | |
| 238 | "v0=" + | |
| 239 | Array.from(new Uint8Array(sigBytes)) | |
| 240 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 241 | .join(""); | |
| 242 | return constantTimeEqual(expected, opts.signature); | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Discord signs every interaction with Ed25519. The signature is hex; the | |
| 247 | * message is `timestamp + body`. The public key (the bot's | |
| 248 | * "Application Public Key") goes into `signing_secret` at install time. | |
| 249 | * | |
| 250 | * Bun's WebCrypto supports Ed25519 natively. | |
| 251 | */ | |
| 252 | export async function verifyDiscordSignature(opts: { | |
| 253 | publicKeyHex: string; | |
| 254 | signatureHex: string; | |
| 255 | timestamp: string; | |
| 256 | body: string; | |
| 257 | }): Promise<boolean> { | |
| 258 | if (!opts.publicKeyHex || !opts.signatureHex || !opts.timestamp) return false; | |
| 259 | try { | |
| 260 | const pubBytes = hexToBytes(opts.publicKeyHex); | |
| 261 | const sigBytes = hexToBytes(opts.signatureHex); | |
| 262 | if (pubBytes.length !== 32 || sigBytes.length !== 64) return false; | |
| 263 | const msg = new TextEncoder().encode(opts.timestamp + opts.body); | |
| 264 | const key = await crypto.subtle.importKey( | |
| 265 | "raw", | |
| 1d4ff60 | 266 | pubBytes.buffer as ArrayBuffer, |
| 0c3eee5 | 267 | { name: "Ed25519" } as AlgorithmIdentifier, |
| 268 | false, | |
| 269 | ["verify"] | |
| 270 | ); | |
| 271 | return await crypto.subtle.verify( | |
| 272 | { name: "Ed25519" } as AlgorithmIdentifier, | |
| 273 | key, | |
| 1d4ff60 | 274 | sigBytes.buffer as ArrayBuffer, |
| 275 | msg.buffer as ArrayBuffer | |
| 0c3eee5 | 276 | ); |
| 277 | } catch { | |
| 278 | return false; | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | function hexToBytes(hex: string): Uint8Array { | |
| 283 | const clean = hex.trim(); | |
| 284 | if (clean.length % 2 !== 0) throw new Error("odd hex length"); | |
| 285 | const out = new Uint8Array(clean.length / 2); | |
| 286 | for (let i = 0; i < out.length; i++) { | |
| 287 | const byte = parseInt(clean.slice(i * 2, i * 2 + 2), 16); | |
| 288 | if (Number.isNaN(byte)) throw new Error("bad hex"); | |
| 289 | out[i] = byte; | |
| 290 | } | |
| 291 | return out; | |
| 292 | } | |
| 293 | ||
| 294 | function constantTimeEqual(a: string, b: string): boolean { | |
| 295 | if (a.length !== b.length) return false; | |
| 296 | let r = 0; | |
| 297 | for (let i = 0; i < a.length; i++) { | |
| 298 | r |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 299 | } | |
| 300 | return r === 0; | |
| 301 | } | |
| 302 | ||
| 303 | // --------------------------------------------------------------------------- | |
| 304 | // Command handler | |
| 305 | // --------------------------------------------------------------------------- | |
| 306 | ||
| 307 | export interface HandleBotCommandInput { | |
| 308 | kind: ChatKind; | |
| 309 | /** Gluecron user id (resolved from the install record). May be null when | |
| 310 | * the workspace has no linked user — we fall back to "help" with an install | |
| 311 | * nudge in that case. */ | |
| 312 | userId: string | null; | |
| 313 | command: string; | |
| 314 | subcommand: string; | |
| 315 | args: string; | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * Dispatch a parsed slash command. Always returns a BotResponse — errors | |
| 320 | * become user-visible blocks/embeds rather than HTTP failures (Slack/Discord | |
| 321 | * surface non-2xx as "service unavailable" which is uglier than a bot reply). | |
| 322 | */ | |
| 323 | export async function handleBotCommand( | |
| 324 | input: HandleBotCommandInput | |
| 325 | ): Promise<BotResponse> { | |
| 326 | const verb = `${input.command} ${input.subcommand}`.trim(); | |
| 327 | ||
| 328 | if (!input.userId && verb !== "help" && input.command !== "help") { | |
| 329 | return renderHelp(input.kind, { | |
| 330 | note: | |
| 331 | "This workspace isn't linked to a Gluecron account yet. Visit /settings/integrations on Gluecron to finish setup.", | |
| 332 | }); | |
| 333 | } | |
| 334 | ||
| 335 | try { | |
| 336 | switch (verb) { | |
| 337 | case "help": | |
| 338 | case "": | |
| 339 | return renderHelp(input.kind); | |
| 340 | case "pr list": | |
| 341 | return await cmdPrList(input); | |
| 342 | case "pr open": | |
| 343 | return await cmdPrOpen(input); | |
| 344 | case "issue list": | |
| 345 | return await cmdIssueList(input); | |
| 346 | case "issue create": | |
| 347 | return await cmdIssueCreate(input); | |
| 348 | case "spec ship": | |
| 349 | return await cmdSpecShip(input); | |
| 350 | default: | |
| 351 | // Single-verb commands. | |
| 352 | if (input.command === "chat") return await cmdChat(input); | |
| 353 | if (input.command === "help") return renderHelp(input.kind); | |
| 354 | return renderHelp(input.kind, { | |
| 355 | note: `Unknown command: \`${verb || input.command}\`.`, | |
| 356 | }); | |
| 357 | } | |
| 358 | } catch (err) { | |
| 359 | const msg = err instanceof Error ? err.message : String(err); | |
| 360 | return renderError(input.kind, msg); | |
| 361 | } | |
| 362 | } | |
| 363 | ||
| 364 | // --------------------------------------------------------------------------- | |
| 365 | // Command implementations — thin wrappers around the same drizzle queries | |
| 366 | // the REST API uses. Heavy logic stays in lib/ai-chat / lib/autopilot. | |
| 367 | // --------------------------------------------------------------------------- | |
| 368 | ||
| 369 | async function cmdPrList(input: HandleBotCommandInput): Promise<BotResponse> { | |
| 370 | // `args` is "owner/repo" (or empty → most-recent across the user's repos). | |
| 371 | const target = await resolveTargetRepo(input.userId!, input.args); | |
| 372 | if (!target) { | |
| 373 | return renderError( | |
| 374 | input.kind, | |
| 375 | "No repo found. Usage: `pr list owner/repo`." | |
| 376 | ); | |
| 377 | } | |
| 378 | ||
| 379 | const rows = await db | |
| 380 | .select() | |
| 381 | .from(pullRequests) | |
| 382 | .where( | |
| 383 | and( | |
| 384 | eq(pullRequests.repositoryId, target.repo.id), | |
| 385 | eq(pullRequests.state, "open") | |
| 386 | ) | |
| 387 | ) | |
| 388 | .orderBy(desc(pullRequests.createdAt)) | |
| 389 | .limit(10); | |
| 390 | ||
| 391 | return formatPrList(input.kind, target.label, rows); | |
| 392 | } | |
| 393 | ||
| 394 | async function cmdPrOpen(input: HandleBotCommandInput): Promise<BotResponse> { | |
| 395 | // Format: `pr open owner/repo title…` — but to keep slash-command UX | |
| 396 | // simple we also accept just `pr open title…` and use the user's default | |
| 397 | // repo (most-recent push). | |
| 398 | const target = await resolveTargetRepo(input.userId!, input.args); | |
| 399 | const title = target | |
| 400 | ? input.args.slice(target.matchedSegment.length).trim() | |
| 401 | : input.args; | |
| 402 | if (!title) { | |
| 403 | return renderError(input.kind, "Usage: `pr open owner/repo Add dark mode`."); | |
| 404 | } | |
| 405 | if (!target) { | |
| 406 | return renderError(input.kind, "Couldn't resolve a repo. Add `owner/repo`."); | |
| 407 | } | |
| 408 | ||
| 409 | // We don't open the PR straight from chat (head/base branches need | |
| 410 | // disambiguation); instead we return a deep-link to the compose page on | |
| 411 | // the website with the title pre-filled. | |
| 412 | const url = `/${target.owner.username}/${target.repo.name}/compare?title=${encodeURIComponent(title)}`; | |
| 413 | return formatLinkCard( | |
| 414 | input.kind, | |
| 415 | `Open PR: ${title}`, | |
| 416 | `Continue in Gluecron — head/base branch picker is on the next screen.`, | |
| 417 | url | |
| 418 | ); | |
| 419 | } | |
| 420 | ||
| 421 | async function cmdIssueList( | |
| 422 | input: HandleBotCommandInput | |
| 423 | ): Promise<BotResponse> { | |
| 424 | const target = await resolveTargetRepo(input.userId!, input.args); | |
| 425 | if (!target) { | |
| 426 | return renderError( | |
| 427 | input.kind, | |
| 428 | "No repo found. Usage: `issue list owner/repo`." | |
| 429 | ); | |
| 430 | } | |
| 431 | ||
| 432 | const rows = await db | |
| 433 | .select() | |
| 434 | .from(issues) | |
| 435 | .where( | |
| 436 | and( | |
| 437 | eq(issues.repositoryId, target.repo.id), | |
| 438 | eq(issues.state, "open") | |
| 439 | ) | |
| 440 | ) | |
| 441 | .orderBy(desc(issues.createdAt)) | |
| 442 | .limit(10); | |
| 443 | ||
| 444 | return formatIssueList(input.kind, target.label, rows); | |
| 445 | } | |
| 446 | ||
| 447 | async function cmdIssueCreate( | |
| 448 | input: HandleBotCommandInput | |
| 449 | ): Promise<BotResponse> { | |
| 450 | const target = await resolveTargetRepo(input.userId!, input.args); | |
| 451 | const title = target | |
| 452 | ? input.args.slice(target.matchedSegment.length).trim() | |
| 453 | : input.args; | |
| 454 | if (!title) { | |
| 455 | return renderError( | |
| 456 | input.kind, | |
| 457 | "Usage: `issue create owner/repo Bug in foo()`." | |
| 458 | ); | |
| 459 | } | |
| 460 | if (!target) { | |
| 461 | return renderError(input.kind, "Couldn't resolve a repo. Add `owner/repo`."); | |
| 462 | } | |
| 463 | ||
| 464 | const [row] = await db | |
| 465 | .insert(issues) | |
| 466 | .values({ | |
| 467 | repositoryId: target.repo.id, | |
| 468 | authorId: input.userId!, | |
| 469 | title, | |
| 470 | }) | |
| 471 | .returning(); | |
| 472 | ||
| 473 | const num = row?.number ?? 0; | |
| 474 | return formatLinkCard( | |
| 475 | input.kind, | |
| 476 | `Issue #${num} opened — ${title}`, | |
| 477 | `${target.label}`, | |
| 478 | `/${target.owner.username}/${target.repo.name}/issues/${num}` | |
| 479 | ); | |
| 480 | } | |
| 481 | ||
| 482 | async function cmdSpecShip( | |
| 483 | input: HandleBotCommandInput | |
| 484 | ): Promise<BotResponse> { | |
| 485 | // spec ship is a deep-link into the autopilot spec-to-PR flow — the | |
| 486 | // actual PR-authoring happens server-side via lib/autopilot-spec-to-pr, | |
| 487 | // and we don't want to block the slash command on it. | |
| 488 | const description = input.args.trim(); | |
| 489 | if (!description) { | |
| 490 | return renderError( | |
| 491 | input.kind, | |
| 492 | 'Usage: `spec ship "add dark mode to the dashboard"`.' | |
| 493 | ); | |
| 494 | } | |
| 495 | const url = `/specs/new?description=${encodeURIComponent(description)}`; | |
| 496 | return formatLinkCard( | |
| 497 | input.kind, | |
| 498 | "Spec → PR", | |
| 499 | `Autopilot will draft a plan, open branches, and ship a PR for: ${description}`, | |
| 500 | url | |
| 501 | ); | |
| 502 | } | |
| 503 | ||
| 504 | async function cmdChat(input: HandleBotCommandInput): Promise<BotResponse> { | |
| 505 | const message = input.args.trim(); | |
| 506 | if (!message) { | |
| 507 | return renderError(input.kind, "Usage: `chat How do I run the tests?`."); | |
| 508 | } | |
| 509 | // We return a deep-link rather than running the LLM inline — chat surfaces | |
| 510 | // expect a sub-3s response and Anthropic latency blows past that. | |
| 511 | const url = `/ask?q=${encodeURIComponent(message)}`; | |
| 512 | return formatLinkCard( | |
| 513 | input.kind, | |
| 514 | "Ask Gluecron", | |
| 515 | message, | |
| 516 | url | |
| 517 | ); | |
| 518 | } | |
| 519 | ||
| 520 | // --------------------------------------------------------------------------- | |
| 521 | // Repo resolution | |
| 522 | // --------------------------------------------------------------------------- | |
| 523 | ||
| 524 | interface ResolvedTarget { | |
| 525 | owner: { id: string; username: string }; | |
| 526 | repo: { id: string; name: string }; | |
| 527 | /** Substring of `args` that matched "owner/repo" — used to trim title. */ | |
| 528 | matchedSegment: string; | |
| 529 | /** Display label: "owner/repo". */ | |
| 530 | label: string; | |
| 531 | } | |
| 532 | ||
| 533 | /** | |
| 534 | * Pull "owner/repo" out of the args head. If absent, fall back to the | |
| 535 | * caller's most-recently-updated repo (handy for ad-hoc Slack usage). | |
| 536 | */ | |
| 537 | async function resolveTargetRepo( | |
| 538 | userId: string, | |
| 539 | args: string | |
| 540 | ): Promise<ResolvedTarget | null> { | |
| 541 | const head = args.split(/\s+/, 1)[0] ?? ""; | |
| 542 | const m = head.match(/^([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)$/); | |
| 543 | if (m) { | |
| 544 | const [ownerName, repoName] = [m[1]!, m[2]!]; | |
| 545 | const [owner] = await db | |
| 546 | .select({ id: users.id, username: users.username }) | |
| 547 | .from(users) | |
| 548 | .where(eq(users.username, ownerName)) | |
| 549 | .limit(1); | |
| 550 | if (!owner) return null; | |
| 551 | const [repo] = await db | |
| 552 | .select({ id: repositories.id, name: repositories.name }) | |
| 553 | .from(repositories) | |
| 554 | .where( | |
| 555 | and( | |
| 556 | eq(repositories.ownerId, owner.id), | |
| 557 | eq(repositories.name, repoName) | |
| 558 | ) | |
| 559 | ) | |
| 560 | .limit(1); | |
| 561 | if (!repo) return null; | |
| 562 | return { | |
| 563 | owner, | |
| 564 | repo, | |
| 565 | matchedSegment: head, | |
| 566 | label: `${owner.username}/${repo.name}`, | |
| 567 | }; | |
| 568 | } | |
| 569 | ||
| 570 | // Fallback — most-recent repo owned by this user. | |
| 571 | const [owner] = await db | |
| 572 | .select({ id: users.id, username: users.username }) | |
| 573 | .from(users) | |
| 574 | .where(eq(users.id, userId)) | |
| 575 | .limit(1); | |
| 576 | if (!owner) return null; | |
| 577 | const [repo] = await db | |
| 578 | .select({ id: repositories.id, name: repositories.name }) | |
| 579 | .from(repositories) | |
| 580 | .where(eq(repositories.ownerId, owner.id)) | |
| 581 | .orderBy(desc(repositories.updatedAt)) | |
| 582 | .limit(1); | |
| 583 | if (!repo) return null; | |
| 584 | return { | |
| 585 | owner, | |
| 586 | repo, | |
| 587 | matchedSegment: "", | |
| 588 | label: `${owner.username}/${repo.name}`, | |
| 589 | }; | |
| 590 | } | |
| 591 | ||
| 592 | // --------------------------------------------------------------------------- | |
| 593 | // Formatters | |
| 594 | // --------------------------------------------------------------------------- | |
| 595 | ||
| 596 | export function formatPrList( | |
| 597 | kind: ChatKind, | |
| 598 | repoLabel: string, | |
| 599 | rows: PullRequest[] | |
| 600 | ): BotResponse { | |
| 601 | if (kind === "slack" || kind === "teams") { | |
| 602 | const blocks: SlackBlock[] = [ | |
| 603 | { | |
| 604 | type: "header", | |
| 605 | text: { type: "plain_text", text: `Open PRs — ${repoLabel}` }, | |
| 606 | }, | |
| 607 | ]; | |
| 608 | if (rows.length === 0) { | |
| 609 | blocks.push({ | |
| 610 | type: "section", | |
| 611 | text: { type: "mrkdwn", text: "_No open pull requests._" }, | |
| 612 | }); | |
| 613 | } else { | |
| 614 | for (const pr of rows) { | |
| 615 | blocks.push({ | |
| 616 | type: "section", | |
| 617 | text: { | |
| 618 | type: "mrkdwn", | |
| 619 | text: `*#${pr.number}* — ${escapeSlack(pr.title)}\n_${pr.headBranch} → ${pr.baseBranch}_${pr.isDraft ? " · draft" : ""}`, | |
| 620 | }, | |
| 621 | }); | |
| 622 | } | |
| 623 | } | |
| 624 | return { kind: "slack", blocks, response_type: "in_channel" }; | |
| 625 | } | |
| 626 | const embed: DiscordEmbed = { | |
| 627 | title: `Open PRs — ${repoLabel}`, | |
| 628 | color: 0x8c6dff, | |
| 629 | fields: rows.length | |
| 630 | ? rows.map((pr) => ({ | |
| 631 | name: `#${pr.number} ${pr.title}`, | |
| 632 | value: `${pr.headBranch} → ${pr.baseBranch}${pr.isDraft ? " · draft" : ""}`, | |
| 633 | })) | |
| 634 | : [{ name: "No open pull requests", value: "—" }], | |
| 635 | }; | |
| 636 | return { kind: "discord", embeds: [embed] }; | |
| 637 | } | |
| 638 | ||
| 639 | export function formatIssueList( | |
| 640 | kind: ChatKind, | |
| 641 | repoLabel: string, | |
| 642 | rows: Issue[] | |
| 643 | ): BotResponse { | |
| 644 | if (kind === "slack" || kind === "teams") { | |
| 645 | const blocks: SlackBlock[] = [ | |
| 646 | { | |
| 647 | type: "header", | |
| 648 | text: { type: "plain_text", text: `Open issues — ${repoLabel}` }, | |
| 649 | }, | |
| 650 | ]; | |
| 651 | if (rows.length === 0) { | |
| 652 | blocks.push({ | |
| 653 | type: "section", | |
| 654 | text: { type: "mrkdwn", text: "_No open issues._" }, | |
| 655 | }); | |
| 656 | } else { | |
| 657 | for (const it of rows) { | |
| 658 | blocks.push({ | |
| 659 | type: "section", | |
| 660 | text: { | |
| 661 | type: "mrkdwn", | |
| 662 | text: `*#${it.number}* — ${escapeSlack(it.title)}`, | |
| 663 | }, | |
| 664 | }); | |
| 665 | } | |
| 666 | } | |
| 667 | return { kind: "slack", blocks, response_type: "in_channel" }; | |
| 668 | } | |
| 669 | const embed: DiscordEmbed = { | |
| 670 | title: `Open issues — ${repoLabel}`, | |
| 671 | color: 0x36c5d6, | |
| 672 | fields: rows.length | |
| 673 | ? rows.map((it) => ({ | |
| 674 | name: `#${it.number}`, | |
| 675 | value: it.title, | |
| 676 | })) | |
| 677 | : [{ name: "No open issues", value: "—" }], | |
| 678 | }; | |
| 679 | return { kind: "discord", embeds: [embed] }; | |
| 680 | } | |
| 681 | ||
| 682 | export function renderHelp( | |
| 683 | kind: ChatKind, | |
| 684 | opts?: { note?: string } | |
| 685 | ): BotResponse { | |
| 686 | const lines = [ | |
| 687 | "`/gluecron pr list owner/repo` — open PRs", | |
| 688 | '`/gluecron pr open owner/repo "title"` — deep-link to compose a PR', | |
| 689 | "`/gluecron issue list owner/repo` — open issues", | |
| 690 | '`/gluecron issue create owner/repo "title"` — file a new issue', | |
| 691 | '`/gluecron spec ship "description"` — autopilot drafts a PR', | |
| 692 | '`/gluecron chat "question"` — ask Gluecron AI', | |
| 693 | "`/gluecron help` — this message", | |
| 694 | ]; | |
| 695 | if (kind === "slack" || kind === "teams") { | |
| 696 | const blocks: SlackBlock[] = [ | |
| 697 | { | |
| 698 | type: "header", | |
| 699 | text: { type: "plain_text", text: "Gluecron slash commands" }, | |
| 700 | }, | |
| 701 | ]; | |
| 702 | if (opts?.note) { | |
| 703 | blocks.push({ | |
| 704 | type: "section", | |
| 705 | text: { type: "mrkdwn", text: `:warning: ${opts.note}` }, | |
| 706 | }); | |
| 707 | } | |
| 708 | blocks.push({ | |
| 709 | type: "section", | |
| 710 | text: { type: "mrkdwn", text: lines.join("\n") }, | |
| 711 | }); | |
| 712 | return { kind: "slack", blocks, response_type: "ephemeral" }; | |
| 713 | } | |
| 714 | const embed: DiscordEmbed = { | |
| 715 | title: "Gluecron slash commands", | |
| 716 | description: (opts?.note ? `${opts.note}\n\n` : "") + lines.join("\n"), | |
| 717 | color: 0x8c6dff, | |
| 718 | }; | |
| 719 | return { kind: "discord", embeds: [embed], ephemeral: true }; | |
| 720 | } | |
| 721 | ||
| 722 | export function renderError(kind: ChatKind, message: string): BotResponse { | |
| 723 | if (kind === "slack" || kind === "teams") { | |
| 724 | return { | |
| 725 | kind: "slack", | |
| 726 | response_type: "ephemeral", | |
| 727 | blocks: [ | |
| 728 | { | |
| 729 | type: "section", | |
| 730 | text: { type: "mrkdwn", text: `:x: ${escapeSlack(message)}` }, | |
| 731 | }, | |
| 732 | ], | |
| 733 | }; | |
| 734 | } | |
| 735 | return { | |
| 736 | kind: "discord", | |
| 737 | ephemeral: true, | |
| 738 | embeds: [ | |
| 739 | { title: "Error", description: message, color: 0xf87171 }, | |
| 740 | ], | |
| 741 | }; | |
| 742 | } | |
| 743 | ||
| 744 | function formatLinkCard( | |
| 745 | kind: ChatKind, | |
| 746 | title: string, | |
| 747 | description: string, | |
| 748 | url: string | |
| 749 | ): BotResponse { | |
| 750 | if (kind === "slack" || kind === "teams") { | |
| 751 | return { | |
| 752 | kind: "slack", | |
| 753 | blocks: [ | |
| 754 | { | |
| 755 | type: "section", | |
| 756 | text: { | |
| 757 | type: "mrkdwn", | |
| 758 | text: `*${escapeSlack(title)}*\n${escapeSlack(description)}\n<${url}|Open in Gluecron>`, | |
| 759 | }, | |
| 760 | }, | |
| 761 | ], | |
| 762 | response_type: "in_channel", | |
| 763 | }; | |
| 764 | } | |
| 765 | return { | |
| 766 | kind: "discord", | |
| 767 | embeds: [ | |
| 768 | { | |
| 769 | title, | |
| 770 | description, | |
| 771 | url, | |
| 772 | color: 0x8c6dff, | |
| 773 | }, | |
| 774 | ], | |
| 775 | }; | |
| 776 | } | |
| 777 | ||
| 778 | /** | |
| 779 | * Slack uses a tiny markdown subset — only `*bold*`, `_italic_`, and | |
| 780 | * `<url|text>` links matter. Escape the three reserved characters so user | |
| 781 | * titles don't accidentally hijack the formatter. | |
| 782 | */ | |
| 783 | function escapeSlack(s: string): string { | |
| 784 | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 785 | } | |
| 786 | ||
| 787 | // --------------------------------------------------------------------------- | |
| 788 | // Discord interaction surface — minimal shape so we don't pull the SDK. | |
| 789 | // --------------------------------------------------------------------------- | |
| 790 | ||
| 791 | export interface DiscordOptionLike { | |
| 792 | name?: string; | |
| 793 | type?: number; | |
| 794 | value?: unknown; | |
| 795 | options?: DiscordOptionLike[]; | |
| 796 | } | |
| 797 | ||
| 798 | export interface DiscordInteractionLike { | |
| 799 | type?: number; | |
| 800 | data?: { | |
| 801 | name?: string; | |
| 802 | options?: DiscordOptionLike[]; | |
| 803 | }; | |
| 804 | guild_id?: string; | |
| 805 | channel_id?: string; | |
| 806 | member?: { user?: { id?: string } }; | |
| 807 | user?: { id?: string }; | |
| 808 | } | |
| 809 | ||
| 810 | // --------------------------------------------------------------------------- | |
| 811 | // Outbound notification formatter — used by chat-notifier.ts. | |
| 812 | // --------------------------------------------------------------------------- | |
| 813 | ||
| 814 | export interface OutboundEvent { | |
| 815 | /** "pr.opened" | "pr.merged" | "issue.opened" | "ai.review" | … */ | |
| 816 | event: string; | |
| 817 | repo: string; // "owner/repo" | |
| 818 | title: string; | |
| 819 | url: string; // absolute URL into Gluecron | |
| 820 | body?: string; // optional summary / AI digest | |
| 821 | actor?: string; | |
| 822 | } | |
| 823 | ||
| 824 | /** | |
| 825 | * Render an outbound event as either Slack blocks or Discord embeds. The | |
| 826 | * caller posts the returned payload to the integration's webhook_url. | |
| 827 | */ | |
| 828 | export function formatOutboundEvent( | |
| 829 | kind: ChatKind, | |
| 830 | evt: OutboundEvent | |
| 831 | ): Record<string, unknown> { | |
| 832 | const headline = `[${evt.repo}] ${evt.title}`; | |
| 833 | if (kind === "slack" || kind === "teams") { | |
| 834 | const blocks: SlackBlock[] = [ | |
| 835 | { | |
| 836 | type: "section", | |
| 837 | text: { | |
| 838 | type: "mrkdwn", | |
| 839 | text: `*${escapeSlack(evt.event)}* — <${evt.url}|${escapeSlack(headline)}>`, | |
| 840 | }, | |
| 841 | }, | |
| 842 | ]; | |
| 843 | if (evt.body) { | |
| 844 | blocks.push({ | |
| 845 | type: "section", | |
| 846 | text: { type: "mrkdwn", text: escapeSlack(truncate(evt.body, 1500)) }, | |
| 847 | }); | |
| 848 | } | |
| 849 | if (evt.actor) { | |
| 850 | blocks.push({ | |
| 851 | type: "context", | |
| 852 | elements: [ | |
| 853 | { type: "mrkdwn", text: `by ${escapeSlack(evt.actor)}` }, | |
| 854 | ], | |
| 855 | }); | |
| 856 | } | |
| 857 | return { blocks }; | |
| 858 | } | |
| 859 | const embed: DiscordEmbed = { | |
| 860 | title: headline, | |
| 861 | url: evt.url, | |
| 862 | description: evt.body ? truncate(evt.body, 1800) : undefined, | |
| 863 | color: colorForEvent(evt.event), | |
| 864 | footer: evt.actor ? { text: `by ${evt.actor}` } : undefined, | |
| 865 | timestamp: new Date().toISOString(), | |
| 866 | }; | |
| 867 | return { embeds: [embed] }; | |
| 868 | } | |
| 869 | ||
| 870 | function colorForEvent(event: string): number { | |
| 871 | if (event.startsWith("pr.merge")) return 0x34d399; | |
| 872 | if (event.startsWith("pr.")) return 0x8c6dff; | |
| 873 | if (event.startsWith("issue.")) return 0x36c5d6; | |
| 874 | if (event.startsWith("ai.")) return 0xfacc15; | |
| 875 | return 0x9ca3af; | |
| 876 | } | |
| 877 | ||
| 878 | function truncate(s: string, n: number): string { | |
| 879 | return s.length <= n ? s : s.slice(0, n - 1) + "…"; | |
| 880 | } | |
| 881 | ||
| 882 | // --------------------------------------------------------------------------- | |
| 883 | // Test exports | |
| 884 | // --------------------------------------------------------------------------- | |
| 885 | ||
| 886 | export const __test = { | |
| 887 | stripWrappingQuotes, | |
| 888 | hexToBytes, | |
| 889 | constantTimeEqual, | |
| 890 | colorForEvent, | |
| 891 | truncate, | |
| 892 | escapeSlack, | |
| 893 | }; |