Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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`).
59 changes: 9 additions & 50 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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<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 bot = new Chat({
userName: "edison-bot",
adapters: {
Expand All @@ -76,6 +30,8 @@ bot.onNewMention(async (thread, message) => {

await bot.initialize();

const webhooks: WebhookRoute[] = [granolaWebhook({ bot })];

const port = 3123;

Bun.serve({
Expand All @@ -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}`);
53 changes: 53 additions & 0 deletions lib/codex.ts
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()) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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}`);
}
}
106 changes: 106 additions & 0 deletions webhooks/granola.ts
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);
}
}
}
12 changes: 12 additions & 0 deletions webhooks/types.ts
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;