-
Notifications
You must be signed in to change notification settings - Fork 0
Add pluggable inbound-webhook framework + Granola meeting summarizer #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Miyamura80
wants to merge
2
commits into
main
Choose a base branch
from
claude/webhook-listener-meetings-56Ucc
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+203
−52
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| export async function* streamCodex(prompt: string): AsyncIterable<string> { | ||
| 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 exitCode = await proc.exited; | ||
| if (exitCode !== 0) { | ||
| throw new Error(`codex exited with code ${exitCode}`); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| 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 raw: unknown; | ||
| try { | ||
| raw = await req.json(); | ||
| } catch { | ||
| return new Response("Invalid JSON", { status: 400 }); | ||
| } | ||
| 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<GranolaPayload>; | ||
| 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 }); | ||
| } | ||
|
|
||
| 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 as GranolaPayload); | ||
|
|
||
| return new Response("OK", { status: 200 }); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| async function processGranolaMeeting( | ||
| ctx: WebhookContext, | ||
| channelId: string, | ||
| payload: GranolaPayload, | ||
| ) { | ||
| const channel = ctx.bot.channel(`slack:${channelId}`); | ||
| 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. | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import type { Chat } from "chat"; | ||
|
|
||
| export type WebhookContext = { | ||
| bot: Chat<any>; | ||
| }; | ||
|
|
||
| export type WebhookRoute = { | ||
| path: string; | ||
| handler: (req: Request) => Promise<Response>; | ||
| }; | ||
|
|
||
| export type WebhookFactory = (ctx: WebhookContext) => WebhookRoute; |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.