import { HttpError } from "../errors"; import { all, run } from "../db"; import { googleChatDigest, googleChatMessage, genericPayload, slackDigest, slackMessage } from "./format"; import { isSuccess, outboundFetch } from "./outbound"; import type { OutboundResult } from "./outbound"; import { signPayload } from "./signatures"; import { parseConfig, openCredentials } from "./store"; import type { IntegrationRow } from "./store"; import type { EventSummary } from "./summarize"; import { handleLinearEvent } from "./linear"; import { handleGithubEvent } from "./github-sync"; export type DeliverInput = { deliveryId: string; repoId: string | null; event: string; payload: Record; summary: EventSummary | null; digest?: EventSummary[]; digestLabel?: string; }; const HTTP_SERVER_ERROR = 500; const SLACK_RETRYABLE = new Set([ "ratelimited", "internal_error", "fatal_error", "service_unavailable", "request_timeout", ]); const SLACK_POST_MESSAGE = "https://slack.com/api/chat.postMessage"; async function signedPost( url: string, body: Record, signingSecret: string, input: DeliverInput, extra: Record = {}, ): Promise { const text = JSON.stringify(body); const headers: Record = { "content-type": "application/json; charset=utf-8", "x-gitfor-event": input.event, "x-gitfor-delivery": input.deliveryId, ...extra, }; if (signingSecret) headers["x-gitfor-signature-256"] = await signPayload(signingSecret, text); return outboundFetch(url, { method: "POST", headers, body: text }); } /** Slack's Web API answers 200 with `{ ok: false }` on failure, so success needs the body too. */ function slackApiFailure(result: OutboundResult): OutboundResult { if (!isSuccess(result)) return result; try { const parsed = JSON.parse(result.body) as { ok?: boolean; error?: string }; if (parsed.ok === false) { const code = parsed.error ?? "unknown_error"; return { ...result, error: `Slack: ${code}`, permanent: !SLACK_RETRYABLE.has(code) }; } } catch { return result; } return result; } /** Channels that already received this delivery, so a retry only resends the ones that failed. */ export async function deliveredTargets(deliveryId: string): Promise> { const rows = await all<{ target: string }>( "SELECT target FROM integration_delivery_targets WHERE delivery_id = ? LIMIT 100", deliveryId, ); return new Set(rows.map((row) => row.target)); } async function markTargetDelivered(deliveryId: string, target: string, status: number | null): Promise { await run( "INSERT OR IGNORE INTO integration_delivery_targets (delivery_id, target, status_code, created_at) VALUES (?, ?, ?, ?)", deliveryId, target, status, Date.now(), ); } /** * Sends to each target not yet delivered, recording successes so a retry only resends failed targets. The * result is the most actionable failure: a retryable one wins over a permanent one, so retries still happen. */ export async function postToTargets( targets: string[], done: Set, post: (target: string) => Promise, markDone: (target: string, result: OutboundResult) => Promise, ): Promise { let last: OutboundResult = { status: 200, body: "", error: null, durationMs: 0 }; let failure: OutboundResult | null = null; for (const target of targets) { if (done.has(target)) continue; last = await post(target); if (isSuccess(last)) await markDone(target, last); else if (!failure || (failure.permanent && !last.permanent)) failure = last; } return failure ?? last; } async function deliverSlack(row: IntegrationRow, input: DeliverInput): Promise { const credentials = await openCredentials(row); const config = parseConfig(row); const message = input.digest && input.digest.length > 0 ? slackDigest(input.digest, input.digestLabel ?? row.name) : input.summary ? slackMessage(input.summary) : null; if (!message) return { status: 200, body: "", error: null, durationMs: 0 }; if (credentials.webhookUrl) return signedPost(credentials.webhookUrl, message, credentials.signingSecret, input); if (!credentials.botToken) return { status: null, body: "", error: "Slack is not connected", durationMs: 0, permanent: true }; const category = input.summary?.category; const channels = (config.slackChannels ?? []).filter( (route) => input.digest || (category !== undefined && route.events.includes(category)), ); const token = credentials.botToken; return postToTargets( channels.map((route) => route.channel), await deliveredTargets(input.deliveryId), async (channel) => slackApiFailure( await signedPost(SLACK_POST_MESSAGE, { ...message, channel }, "", input, { authorization: `Bearer ${token}` }), ), (channel, result) => markTargetDelivered(input.deliveryId, channel, result.status), ); } async function deliverGoogleChat(row: IntegrationRow, input: DeliverInput): Promise { const credentials = await openCredentials(row); if (!credentials.webhookUrl) return { status: null, body: "", error: "No webhook URL", durationMs: 0, permanent: true }; const cardId = `gitfor-${input.deliveryId}`; const message = input.digest && input.digest.length > 0 ? googleChatDigest(input.digest, input.digestLabel ?? row.name, cardId) : input.summary ? googleChatMessage(input.summary, cardId) : null; if (!message) return { status: 200, body: "", error: null, durationMs: 0 }; return signedPost(credentials.webhookUrl, message, credentials.signingSecret, input); } async function deliverGeneric(row: IntegrationRow, input: DeliverInput): Promise { const credentials = await openCredentials(row); if (!credentials.webhookUrl) return { status: null, body: "", error: "No webhook URL", durationMs: 0, permanent: true }; const body = input.digest ? { event: "digest", items: input.digest, delivered_at: new Date().toISOString() } : genericPayload(input.summary, input.event, input.payload); return signedPost(credentials.webhookUrl, body, credentials.signingSecret, input); } /** Sends one queued delivery through the integration's transport. Never throws; failures come back as results. */ export async function deliverToIntegration(row: IntegrationRow, input: DeliverInput): Promise { try { switch (row.kind) { case "slack": return await deliverSlack(row, input); case "google_chat": return await deliverGoogleChat(row, input); case "webhook": return await deliverGeneric(row, input); case "linear": return await handleLinearEvent(row, input); case "github": return await handleGithubEvent(row, input); } } catch (caught) { const error = caught instanceof Error ? caught.message : String(caught); return { status: null, body: "", error, durationMs: 0, permanent: caught instanceof HttpError && caught.status < HTTP_SERVER_ERROR, }; } }