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-notifier.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-notifier.tsBlame148 lines · 1 contributor
0c3eee5Claude1/**
2 * Outbound chat notifications — pipes PR / issue / AI-review events into
3 * Slack, Discord, and Teams via the existing `webhook_deliveries` retry
4 * queue (NO parallel queue).
5 *
6 * How it reuses the existing queue:
7 * - The retry queue (`src/lib/webhook-delivery.ts`) requires a `webhooks`
8 * row to point at. We therefore lazily create one synthetic "shadow"
9 * webhook per (repo, integration) on first use, with
10 * `events='chat-bridge'` so the user-facing /settings/webhooks UI can
11 * filter it out (`routes/webhooks.tsx` already does).
12 * - Subsequent events for the same (repo, integration) reuse the same
13 * shadow row. `enqueueWebhookDelivery` then schedules retries with
14 * the standard exponential backoff (30s → 6h → dead after 6 attempts).
15 *
16 * Why a shadow row rather than a separate table:
17 * - One source of truth for retries, signatures, and the worker.
18 * - Slack/Discord don't actually consume the `X-Gluecron-Signature`
19 * header, but having it set costs nothing and is harmless.
20 *
21 * Public API:
22 * - notifyChatChannels(ownerUserId, repositoryId, repoLabel, event)
23 * - Fire-and-forget; errors are swallowed/logged so a notification
24 * outage can never block a PR merge or issue create.
25 */
26
27import { and, eq } from "drizzle-orm";
28import { db } from "../db";
29import {
30 chatIntegrations,
31 webhooks,
32 type ChatIntegration,
33} from "../db/schema";
34import {
35 enqueueWebhookDelivery,
36 drainPendingDeliveries,
37} from "./webhook-delivery";
38import { formatOutboundEvent, type ChatKind, type OutboundEvent } from "./chat-bot";
39
40const SHADOW_EVENT = "chat-bridge";
41
42/**
43 * Fan out a single event to every enabled chat integration owned by
44 * `ownerUserId`. The event is rendered per-kind (Slack blocks vs Discord
45 * embeds) and queued via the shared `webhook_deliveries` table.
46 *
47 * Never throws — failures are logged but swallowed.
48 */
49export async function notifyChatChannels(opts: {
50 ownerUserId: string;
51 repositoryId: string;
52 event: OutboundEvent;
53}): Promise<void> {
54 try {
55 const integrations = await db
56 .select()
57 .from(chatIntegrations)
58 .where(
59 and(
60 eq(chatIntegrations.ownerUserId, opts.ownerUserId),
61 eq(chatIntegrations.enabled, true)
62 )
63 );
64
65 if (integrations.length === 0) return;
66
67 let enqueued = 0;
68 for (const integ of integrations) {
69 if (!integ.webhookUrl) continue;
70 const kind = integ.kind as ChatKind;
71 if (kind !== "slack" && kind !== "discord" && kind !== "teams") continue;
72
73 const payload = formatOutboundEvent(kind, opts.event);
74 const shadowId = await ensureShadowWebhook(
75 opts.repositoryId,
76 integ
77 );
78 if (!shadowId) continue;
79
80 const id = await enqueueWebhookDelivery({
81 webhookId: shadowId,
82 secret: integ.signingSecret,
83 event: opts.event.event,
84 payload,
85 });
86 if (id) {
87 enqueued++;
88 // Touch last_used_at — best-effort.
89 db.update(chatIntegrations)
90 .set({ lastUsedAt: new Date() })
91 .where(eq(chatIntegrations.id, integ.id))
92 .catch(() => {});
93 }
94 }
95
96 if (enqueued > 0) {
97 void drainPendingDeliveries().catch((err) => {
98 console.error("[chat-notifier] kick drain failed:", err);
99 });
100 }
101 } catch (err) {
102 console.error("[chat-notifier] notify failed:", err);
103 }
104}
105
106/**
107 * Find-or-create the synthetic webhook row that pipes events for a given
108 * (repo, integration) pair through the retry queue. Returns the row id, or
109 * null on insert failure.
110 *
111 * We key on URL+repository — if a user re-installs the bot with the same
112 * webhook URL the existing shadow row is reused so retry stats don't reset.
113 */
114async function ensureShadowWebhook(
115 repositoryId: string,
116 integ: ChatIntegration
117): Promise<string | null> {
118 if (!integ.webhookUrl) return null;
119 try {
120 const existing = await db
121 .select({ id: webhooks.id })
122 .from(webhooks)
123 .where(
124 and(
125 eq(webhooks.repositoryId, repositoryId),
126 eq(webhooks.url, integ.webhookUrl),
127 eq(webhooks.events, SHADOW_EVENT)
128 )
129 )
130 .limit(1);
131 if (existing[0]) return existing[0].id;
132
133 const [row] = await db
134 .insert(webhooks)
135 .values({
136 repositoryId,
137 url: integ.webhookUrl,
138 secret: integ.signingSecret,
139 events: SHADOW_EVENT,
140 isActive: true,
141 })
142 .returning({ id: webhooks.id });
143 return row?.id ?? null;
144 } catch (err) {
145 console.error("[chat-notifier] ensure shadow webhook failed:", err);
146 return null;
147 }
148}