From 7fca34758be76adaf52c2ca7f7161be586f51318 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 18:10:56 +0000 Subject: [PATCH 1/2] Add pluggable inbound-webhook framework with Granola meeting summarizer Introduces webhooks/ as a place to mount custom webhook listeners on the same Bun.serve instance. First source is Granola: when a meeting wraps, the emitter POSTs to /api/webhooks/granola and a Codex-generated summary is streamed into the configured Slack channel. - Shared X-Webhook-Secret auth and Redis NX/EX dedup per source - Handler returns 200 immediately and processes in the background - Failures post a :warning: notice to the same channel so issues are visible - Extracted streamCodex into lib/codex.ts so mentions and webhooks share it --- .env.example | 10 ++++- CLAUDE.md | 15 +++++++- index.ts | 59 +++++----------------------- lib/codex.ts | 48 +++++++++++++++++++++++ webhooks/granola.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++ webhooks/types.ts | 12 ++++++ 6 files changed, 186 insertions(+), 52 deletions(-) create mode 100644 lib/codex.ts create mode 100644 webhooks/granola.ts create mode 100644 webhooks/types.ts diff --git a/.env.example b/.env.example index 4f077f3..ef174b2 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,13 @@ SLACK_BOT_TOKEN=xoxb-your-token-here # Must have connections:write scope SLACK_APP_TOKEN=xapp-your-token-here -# Redis URL for state management +# Redis URL for state management (and inbound-webhook dedup) REDIS_URL=redis://localhost:6379 + +# Shared secret for inbound custom webhooks. Senders must include it as the +# `X-Webhook-Secret` header. Pick something long and random. +WEBHOOK_SECRET=change-me + +# Slack channel ID for #edison-os-updates (the target for meeting wrap-ups). +# Find it in Slack: channel name > "View channel details" > scroll to bottom > "Channel ID". +GRANOLA_SLACK_CHANNEL_ID= diff --git a/CLAUDE.md b/CLAUDE.md index 52ceeaa..4933dcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,5 +4,18 @@ Slack bot using [chat-sdk](https://chat-sdk.dev) with `@chat-adapter/slack` (web - Runtime: Bun (auto-loads `.env`, no dotenv needed) - HTTP server: `Bun.serve()` on port 3123 -- State: local Redis on `localhost:6379` +- State: local Redis on `localhost:6379` (also used for inbound-webhook dedup via `Bun.redis`) - Slack setup and known issues: `docs/SLACK_INTEGRATION.md` + +## Inbound webhooks (custom, non-Slack) + +Pluggable webhook handlers live in `webhooks/`. Each module exports a factory `(ctx: { bot }) => { path, handler }`; `index.ts` mounts them under `Bun.serve({ routes })`. Add a source by creating `webhooks/.ts` and pushing the factory into the `webhooks` array in `index.ts`. + +Shared conventions: +- Auth: senders must include `X-Webhook-Secret: $WEBHOOK_SECRET`. +- Dedup: handlers `SET key NX EX 604800` in Redis; duplicates return 200 without re-processing. +- Async: handlers return 200 immediately and process in the background. Failures post a `:warning:` notice to the same Slack channel. +- Codex: reuse `lib/codex.ts#streamCodex` and pipe it into `bot.channel("slack:Cxxx").post(...)` for a streamed Slack reply. + +Current sources: +- `webhooks/granola.ts` — `POST /api/webhooks/granola`. Expects `{ meeting_id, title, transcript, notes?, attendees? }`. Posts a Codex summary to `GRANOLA_SLACK_CHANNEL_ID` (intended: `#edison-os-updates`). diff --git a/index.ts b/index.ts index 913fa0c..e1b9adf 100644 --- a/index.ts +++ b/index.ts @@ -1,58 +1,12 @@ import { Chat, toAiMessages } from "chat"; import { createSlackAdapter } from "@chat-adapter/slack"; import { createRedisState } from "@chat-adapter/state-redis"; +import { streamCodex } from "./lib/codex"; +import { granolaWebhook } from "./webhooks/granola"; +import type { WebhookRoute } from "./webhooks/types"; const slackAdapter = createSlackAdapter(); -async function* streamCodex(prompt: string): AsyncIterable { - const proc = Bun.spawn( - ["codex", "exec", "--ephemeral", "-s", "read-only", "--json", "-"], - { stdin: "pipe", stdout: "pipe", stderr: "ignore" }, - ); - proc.stdin.write(prompt); - proc.stdin.end(); - - const decoder = new TextDecoder(); - let buffer = ""; - let messageCount = 0; - - for await (const chunk of proc.stdout) { - buffer += decoder.decode(chunk, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const event = JSON.parse(line); - if ( - event.type === "item.completed" && - event.item?.type === "agent_message" && - event.item.text - ) { - if (messageCount > 0) yield "\n\n---\n\n"; - yield event.item.text; - messageCount++; - } - } catch {} - } - } - - if (buffer.trim()) { - try { - const event = JSON.parse(buffer); - if ( - event.type === "item.completed" && - event.item?.type === "agent_message" && - event.item.text - ) { - if (messageCount > 0) yield "\n\n---\n\n"; - yield event.item.text; - } - } catch {} - } -} - const bot = new Chat({ userName: "edison-bot", adapters: { @@ -76,6 +30,8 @@ bot.onNewMention(async (thread, message) => { await bot.initialize(); +const webhooks: WebhookRoute[] = [granolaWebhook({ bot })]; + const port = 3123; Bun.serve({ @@ -86,10 +42,13 @@ Bun.serve({ return slackAdapter.handleWebhook(req); }, }, + ...Object.fromEntries(webhooks.map((w) => [w.path, { POST: w.handler }])), }, fetch(req) { return new Response("Not found", { status: 404 }); }, }); -console.log("Bot is running! Webhook listening on http://localhost:3123/api/webhooks/slack"); +console.log(`Bot is running on http://localhost:${port}`); +console.log(` POST /api/webhooks/slack`); +for (const w of webhooks) console.log(` POST ${w.path}`); diff --git a/lib/codex.ts b/lib/codex.ts new file mode 100644 index 0000000..8692fb3 --- /dev/null +++ b/lib/codex.ts @@ -0,0 +1,48 @@ +export async function* streamCodex(prompt: string): AsyncIterable { + const proc = Bun.spawn( + ["codex", "exec", "--ephemeral", "-s", "read-only", "--json", "-"], + { stdin: "pipe", stdout: "pipe", stderr: "ignore" }, + ); + proc.stdin.write(prompt); + proc.stdin.end(); + + const decoder = new TextDecoder(); + let buffer = ""; + let messageCount = 0; + + for await (const chunk of proc.stdout) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + if ( + event.type === "item.completed" && + event.item?.type === "agent_message" && + event.item.text + ) { + if (messageCount > 0) yield "\n\n---\n\n"; + yield event.item.text; + messageCount++; + } + } catch {} + } + } + + if (buffer.trim()) { + try { + const event = JSON.parse(buffer); + if ( + event.type === "item.completed" && + event.item?.type === "agent_message" && + event.item.text + ) { + if (messageCount > 0) yield "\n\n---\n\n"; + yield event.item.text; + } + } catch {} + } +} diff --git a/webhooks/granola.ts b/webhooks/granola.ts new file mode 100644 index 0000000..9f798e5 --- /dev/null +++ b/webhooks/granola.ts @@ -0,0 +1,94 @@ +import { redis } from "bun"; +import { streamCodex } from "../lib/codex"; +import type { WebhookContext, WebhookRoute } from "./types"; + +type GranolaPayload = { + meeting_id: string; + title: string; + transcript: string; + notes?: string; + attendees?: string[]; +}; + +const DEDUP_TTL_SECONDS = 60 * 60 * 24 * 7; + +export function granolaWebhook(ctx: WebhookContext): WebhookRoute { + const channelId = process.env.GRANOLA_SLACK_CHANNEL_ID; + const secret = process.env.WEBHOOK_SECRET; + + if (!channelId) { + console.warn("[granola webhook] GRANOLA_SLACK_CHANNEL_ID is not set; requests will 500"); + } + if (!secret) { + console.warn("[granola webhook] WEBHOOK_SECRET is not set; all requests will be rejected"); + } + + return { + path: "/api/webhooks/granola", + handler: async (req) => { + if (!secret || req.headers.get("x-webhook-secret") !== secret) { + return new Response("Unauthorized", { status: 401 }); + } + if (!channelId) { + return new Response("GRANOLA_SLACK_CHANNEL_ID not configured", { status: 500 }); + } + + let payload: GranolaPayload; + try { + payload = (await req.json()) as GranolaPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + if (!payload.meeting_id || !payload.title || !payload.transcript) { + return new Response("Missing required fields: meeting_id, title, transcript", { status: 400 }); + } + + const dedupKey = `webhook:granola:${payload.meeting_id}`; + const claimed = await redis.send("SET", [dedupKey, "1", "NX", "EX", String(DEDUP_TTL_SECONDS)]); + if (claimed !== "OK") { + return new Response("Duplicate (already processed)", { status: 200 }); + } + + void processGranolaMeeting(ctx, channelId, payload); + + return new Response("OK", { status: 200 }); + }, + }; +} + +async function processGranolaMeeting( + ctx: WebhookContext, + channelId: string, + payload: GranolaPayload, +) { + const channel = ctx.bot.channel(`slack:${channelId}`); + const attendeesLine = payload.attendees?.length + ? `\nAttendees: ${payload.attendees.join(", ")}` + : ""; + + const prompt = `Write a Slack message summarizing the following meeting. + +Format: +- First line: "*Meeting wrap-up — ${payload.title}*" (Slack-style bold with single asterisks). +- Then 3-5 bullets starting with "• " covering what was discussed. +- If concrete action items were mentioned, add a blank line, then "*Action items:*", then bullets starting with "• ". + +No preamble, no markdown headers, no "Here is the summary" — emit only the formatted Slack message.${attendeesLine} + +Transcript: +${payload.transcript}${payload.notes ? `\n\n--- Notes ---\n${payload.notes}` : ""}`; + + try { + await channel.post(streamCodex(prompt)); + } catch (err) { + console.error("[granola webhook] processing failed:", err); + const msg = err instanceof Error ? err.message : String(err); + try { + await channel.post( + `:warning: Failed to summarize meeting "${payload.title}" (\`${payload.meeting_id}\`): ${msg}`, + ); + } catch (notifyErr) { + console.error("[granola webhook] failure notify also failed:", notifyErr); + } + } +} diff --git a/webhooks/types.ts b/webhooks/types.ts new file mode 100644 index 0000000..bf2775f --- /dev/null +++ b/webhooks/types.ts @@ -0,0 +1,12 @@ +import type { Chat } from "chat"; + +export type WebhookContext = { + bot: Chat; +}; + +export type WebhookRoute = { + path: string; + handler: (req: Request) => Promise; +}; + +export type WebhookFactory = (ctx: WebhookContext) => WebhookRoute; From dc6d91c3d82388ca74f05361a70a51208b71de28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 16:55:58 +0000 Subject: [PATCH 2/2] Harden webhook input validation and codex exit handling Addresses three review issues from cubic on PR #4: - webhooks/granola.ts: reject non-object JSON bodies (null, arrays, primitives) before field access, instead of letting a TypeError bubble up as a 500. - webhooks/granola.ts: normalize `attendees` with Array.isArray before calling .join, so a malformed (non-array, non-empty) value can no longer throw outside the surrounding try/catch in the background task. - lib/codex.ts: await the subprocess and throw on non-zero exit so silent codex failures surface as errors instead of empty summaries. Callers (webhook handler, onNewMention) now see the failure and can react. --- lib/codex.ts | 5 +++++ webhooks/granola.ts | 24 ++++++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/codex.ts b/lib/codex.ts index 8692fb3..c262328 100644 --- a/lib/codex.ts +++ b/lib/codex.ts @@ -45,4 +45,9 @@ export async function* streamCodex(prompt: string): AsyncIterable { } } catch {} } + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`codex exited with code ${exitCode}`); + } } diff --git a/webhooks/granola.ts b/webhooks/granola.ts index 9f798e5..da54372 100644 --- a/webhooks/granola.ts +++ b/webhooks/granola.ts @@ -33,13 +33,24 @@ export function granolaWebhook(ctx: WebhookContext): WebhookRoute { return new Response("GRANOLA_SLACK_CHANNEL_ID not configured", { status: 500 }); } - let payload: GranolaPayload; + let raw: unknown; try { - payload = (await req.json()) as GranolaPayload; + raw = await req.json(); } catch { return new Response("Invalid JSON", { status: 400 }); } - if (!payload.meeting_id || !payload.title || !payload.transcript) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return new Response("Invalid payload: expected a JSON object", { status: 400 }); + } + const payload = raw as Partial; + if ( + typeof payload.meeting_id !== "string" || + typeof payload.title !== "string" || + typeof payload.transcript !== "string" || + !payload.meeting_id || + !payload.title || + !payload.transcript + ) { return new Response("Missing required fields: meeting_id, title, transcript", { status: 400 }); } @@ -49,7 +60,7 @@ export function granolaWebhook(ctx: WebhookContext): WebhookRoute { return new Response("Duplicate (already processed)", { status: 200 }); } - void processGranolaMeeting(ctx, channelId, payload); + void processGranolaMeeting(ctx, channelId, payload as GranolaPayload); return new Response("OK", { status: 200 }); }, @@ -62,8 +73,9 @@ async function processGranolaMeeting( payload: GranolaPayload, ) { const channel = ctx.bot.channel(`slack:${channelId}`); - const attendeesLine = payload.attendees?.length - ? `\nAttendees: ${payload.attendees.join(", ")}` + const attendees = Array.isArray(payload.attendees) ? payload.attendees : []; + const attendeesLine = attendees.length + ? `\nAttendees: ${attendees.join(", ")}` : ""; const prompt = `Write a Slack message summarizing the following meeting.