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

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.

chat-bot.tsBlame893 lines · 1 contributor
0c3eee5Claude1/**
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
23import { eq, and, desc } from "drizzle-orm";
24import { db } from "../db";
25import {
26 users,
27 repositories,
28 issues,
29 pullRequests,
30} from "../db/schema";
31import type { PullRequest, Issue } from "../db/schema";
32
33// ---------------------------------------------------------------------------
34// Types
35// ---------------------------------------------------------------------------
36
37export type ChatKind = "slack" | "discord" | "teams";
38
39export 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
50export 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
58export 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
68export 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
76export 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
84export 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 */
96export 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 */
135export 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
180const SUBCOMMAND_VERBS = new Set<string>([
181 "pr list",
182 "pr open",
183 "issue list",
184 "issue create",
185 "spec ship",
186]);
187
188function 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 */
209export 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 */
252export 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",
1d4ff60Claude266 pubBytes.buffer as ArrayBuffer,
0c3eee5Claude267 { name: "Ed25519" } as AlgorithmIdentifier,
268 false,
269 ["verify"]
270 );
271 return await crypto.subtle.verify(
272 { name: "Ed25519" } as AlgorithmIdentifier,
273 key,
1d4ff60Claude274 sigBytes.buffer as ArrayBuffer,
275 msg.buffer as ArrayBuffer
0c3eee5Claude276 );
277 } catch {
278 return false;
279 }
280}
281
282function 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
294function 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
307export 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 */
323export 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
369async 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
394async 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
421async 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
447async 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
482async 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
504async 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
524interface 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 */
537async 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
596export 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
639export 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
682export 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
722export 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
744function 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 */
783function escapeSlack(s: string): string {
784 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
785}
786
787// ---------------------------------------------------------------------------
788// Discord interaction surface — minimal shape so we don't pull the SDK.
789// ---------------------------------------------------------------------------
790
791export interface DiscordOptionLike {
792 name?: string;
793 type?: number;
794 value?: unknown;
795 options?: DiscordOptionLike[];
796}
797
798export 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
814export 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 */
828export 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
870function 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
878function 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
886export const __test = {
887 stripWrappingQuotes,
888 hexToBytes,
889 constantTimeEqual,
890 colorForEvent,
891 truncate,
892 escapeSlack,
893};