Files
2026-07-13 12:38:34 +08:00

53 lines
47 KiB
JSON

[
{
"path": "standup.config.ts",
"contents": "// ─────────────────────────────────────────────────────────────────────────────\n// Standup bot setup. Edit this file to configure your team and tools.\n// Secrets (API keys, IDs) live in .env.local, never here.\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type Member = {\n slackEmail: string; // used to look up their Slack user + Composio connections\n githubUsername: string;\n standupTime?: string; // \"HH:MM\" 24h in THEIR timezone; defaults below\n standupTimezone?: string; // IANA tz, e.g. \"Europe/Rome\"; defaults below\n};\n\nexport type Toolkit = {\n slug: string; // Composio toolkit slug\n label: string; // shown on Connect buttons\n emoji: string; // leads the bullet in the draft\n draftInstruction: string; // injected into the draft prompt when connected\n};\n\n// Model used to write the draft, via the Vercel AI Gateway.\nexport const MODEL = \"anthropic/claude-sonnet-4-5\" as const;\n\n// ─── Schedule ────────────────────────────────────────────────────────────────\n// The cron runs every 30 min on weekdays; each member is reminded in the slot\n// containing their standupTime, evaluated in their own timezone.\nexport const DEFAULT_STANDUP_TIMEZONE = \"America/Los_Angeles\";\nexport const DEFAULT_STANDUP_TIME = \"10:00\";\nexport const CRON_SLOT_MINUTES = 30;\n// When true, every member is \"due\" on every cron run, ignoring standupTime.\n// Set false in production so per-person times are honored.\nexport const DEMO_MODE = false;\n\n// ─── Team ────────────────────────────────────────────────────────────────────\nexport const standupChannel = \"#standup\";\n\nexport const team: Member[] = [\n { slackEmail: \"you@example.com\", githubUsername: \"your-handle\" },\n { slackEmail: \"teammate@example.com\", githubUsername: \"teammate-handle\" },\n // A per-person time and timezone override the defaults below:\n { slackEmail: \"alberto@example.com\", githubUsername: \"alberto-handle\", standupTimezone: \"Europe/Rome\" },\n];\n\n// GitHub org that standup drafts are scoped to. Activity in other orgs and\n// personal repos is ignored. Change this to your org.\nexport const GITHUB_ORG = \"your-github-org\";\n\n// ─── Toolkit catalogue ───────────────────────────────────────────────────────\n// Every toolkit a member can connect. The draft agent gets a Composio tool-router\n// session scoped to whichever of these each member has connected. Delete what you\n// don't want; edit draftInstruction to change how each source is summarized.\nexport const TOOLKITS: Toolkit[] = [\n {\n slug: \"github\",\n label: \"GitHub\",\n emoji: \"🔧\",\n draftInstruction:\n `GitHub: find PRs they opened or merged in the time range, SCOPED TO THE ${GITHUB_ORG} ORG ONLY. Always include \\`org:${GITHUB_ORG}\\` in the search query. Ignore other orgs and personal repos. For each meaningful PR write a one-liner explaining its PURPOSE, prefixed with a status emoji: 🟣 merged, 🟢 open, ⚪️ draft, 🔴 closed. Include the *#number*.`,\n },\n {\n slug: \"linear\",\n label: \"Linear\",\n emoji: \"📋\",\n draftInstruction:\n \"Linear: find issues they created, updated, or completed in the time range. Summarize the meaningful ones (📋), focusing on what moved forward.\",\n },\n {\n slug: \"notion\",\n label: \"Notion\",\n emoji: \"📝\",\n draftInstruction:\n \"Notion: find pages/docs they created or edited in the time range. Summarize the doc work as a one-liner (📝) describing what the doc is for.\",\n },\n {\n slug: \"googlecalendar\",\n label: \"Google Calendar\",\n emoji: \"📅\",\n draftInstruction:\n \"Google Calendar: find their meetings/events in the time range. Only mention notable ones that reflect real work (📅), not routine blocks.\",\n },\n {\n slug: \"googlemeet\",\n label: \"Google Meet\",\n emoji: \"🎥\",\n draftInstruction:\n \"Google Meet (context only): optionally read meeting details/transcripts for context. Do not list meetings just for existing.\",\n },\n {\n slug: \"zoom\",\n label: \"Zoom\",\n emoji: \"🎥\",\n draftInstruction:\n \"Zoom (context only): optionally read meeting info/recordings in the time range for extra context.\",\n },\n {\n slug: \"granola_mcp\",\n label: \"Granola\",\n emoji: \"🎙️\",\n draftInstruction:\n \"Granola (context only): optionally read meeting transcripts in the time range for additional context.\",\n },\n {\n slug: \"slack\",\n label: \"Slack\",\n emoji: \"💬\",\n draftInstruction:\n \"Slack (context only): optionally look at messages they sent in public channels for context. Do not quote verbatim.\",\n },\n];\n\nexport const TOOLKIT_SLUGS = TOOLKITS.map((t) => t.slug);\n",
"composio": false
},
{
"path": "api/cron/standup.ts",
"contents": "import type { VercelRequest, VercelResponse } from \"@vercel/node\";\nimport { team, standupChannel, TOOLKITS } from \"../../standup.config\";\nimport { formatToday, isMemberDue } from \"../_utils/schedule\";\nimport { getConnectedIntegrations } from \"../_utils/composio\";\nimport { buildReminderMenu } from \"../_utils/blocks\";\nimport {\n findChannelId,\n getRecentMessages,\n postMessage,\n getPermalink,\n findUserIdByEmail,\n sendDmBlocks,\n} from \"../_utils/slack\";\nimport { env } from \"../_utils/env\";\n\nexport const maxDuration = 300;\n\nexport default async function handler(req: VercelRequest, res: VercelResponse) {\n if (req.headers.authorization !== `Bearer ${env.CRON_SECRET}`) {\n return res.status(401).json({ error: \"Unauthorized\" });\n }\n\n const results: { member: string; status: string }[] = [];\n // One shared thread per calendar day, keyed to the team timezone on purpose:\n // everyone posts into the same thread even though reminders fire in each\n // member's local time. (Per-member timezone here would fork the thread.)\n const today = formatToday();\n\n // Only members whose configured time falls in this 30-minute slot.\n const dueMembers = team.filter(isMemberDue);\n if (dueMembers.length === 0) {\n return res.status(200).json({ status: \"no members due this slot\" });\n }\n\n try {\n // Resolve the channel and find or create today's thread.\n const channelId = await findChannelId(standupChannel);\n if (!channelId) {\n return res.status(500).json({\n error: `Channel ${standupChannel} not found. Is the bot a member?`,\n });\n }\n\n const parentText = `📅 *Daily Standup: ${today}*\\n_Standups are posted in this thread as folks confirm._`;\n // Scan a generous window for today's parent. A high-volume channel could push\n // it past this; a production bot would paginate or persist the thread ts.\n const recent = await getRecentMessages(channelId, 200);\n const threadTs =\n recent.find((m) => m.text.includes(\"Daily Standup\") && m.text.includes(today))\n ?.ts ?? (await postMessage(channelId, parentText))?.ts;\n if (!threadTs) {\n return res.status(500).json({ error: \"Failed to create daily thread\" });\n }\n // Narrowed const so the per-member closure below sees a plain string.\n const thread = threadTs;\n const threadUrl = (await getPermalink(channelId, thread)) ?? \"\";\n\n // DM each due member the reminder menu, in parallel.\n async function processMember(\n member: (typeof team)[number]\n ): Promise<{ member: string; status: string }> {\n try {\n const [connected, userId] = await Promise.all([\n getConnectedIntegrations(member.slackEmail),\n findUserIdByEmail(member.slackEmail),\n ]);\n const showConnect = connected.length < TOOLKITS.length;\n\n if (!userId) {\n return { member: member.slackEmail, status: \"error: no slack user\" };\n }\n\n const menu = buildReminderMenu(\n threadUrl,\n { memberEmail: member.slackEmail, threadTs: thread, channelId: channelId! },\n showConnect\n );\n const dm = await sendDmBlocks(userId, menu.text, menu.blocks);\n return {\n member: member.slackEmail,\n status: dm ? \"reminded\" : \"error: dm failed\",\n };\n } catch (error) {\n const errMsg = error instanceof Error ? error.message : String(error);\n return { member: member.slackEmail, status: `error: ${errMsg}` };\n }\n }\n\n results.push(...(await Promise.all(dueMembers.map(processMember))));\n return res.status(200).json({ threadTs, threadUrl, results });\n } catch (err) {\n return res.status(500).json({\n error: \"Internal server error\",\n message: err instanceof Error ? err.message : String(err),\n results,\n });\n }\n}\n",
"composio": true
},
{
"path": "api/interactivity.ts",
"contents": "import type { VercelRequest, VercelResponse } from \"@vercel/node\";\nimport { waitUntil } from \"@vercel/functions\";\nimport crypto from \"crypto\";\nimport { team, TOOLKITS } from \"../standup.config\";\nimport { memberName } from \"./_utils/schedule\";\nimport {\n ACTION_DRAFT,\n ACTION_CONNECT,\n ACTION_CONFIRM,\n ACTION_EDIT,\n MODAL_EDIT_SUBMIT,\n EDIT_INPUT_BLOCK,\n EDIT_INPUT_ACTION,\n buildLoadingMessage,\n buildDraftMessage,\n buildPostedMessage,\n buildCopyMessage,\n buildConnectMenu,\n buildEditModal,\n extractDraft,\n StandupContext,\n} from \"./_utils/blocks\";\nimport { getConnectedIntegrations, getConnectLink } from \"./_utils/composio\";\nimport { generateDraftText } from \"./_utils/agent\";\nimport {\n openModal,\n postMessage,\n updateMessage,\n postAsUser,\n getPermalink,\n} from \"./_utils/slack\";\nimport { env } from \"./_utils/env\";\n\nexport const maxDuration = 300;\n\n// Disable Vercel's body parsing: Slack signs the EXACT raw bytes.\nexport const config = { api: { bodyParser: false } };\n\n// The subset of a Slack interaction payload this handler reads.\ntype SlackAction = { action_id?: string; value?: string };\ntype SlackInteraction = {\n type?: string;\n trigger_id?: string;\n actions?: SlackAction[];\n message?: { ts?: string; blocks?: Array<{ block_id?: string; text?: { text?: string } }> };\n container?: { channel_id?: string };\n channel?: { id?: string };\n view?: {\n callback_id?: string;\n private_metadata?: string;\n state?: { values?: Record<string, Record<string, { value?: string }>> };\n };\n};\n// What buildEditModal stuffs into the modal's private_metadata.\ntype StoredMeta = StandupContext & { dmChannel?: string; dmTs?: string };\n\nfunction readRawBody(req: VercelRequest): Promise<string> {\n return new Promise((resolve) => {\n if (typeof req.body === \"string\") return resolve(req.body);\n if (req.body && Buffer.isBuffer(req.body)) return resolve(req.body.toString(\"utf8\"));\n let data = \"\";\n req.setEncoding?.(\"utf8\");\n req.on(\"data\", (c) => (data += c));\n req.on(\"end\", () => resolve(data));\n req.on(\"error\", () => resolve(data));\n });\n}\n\n// Implements Slack's documented request-verification algorithm (v0 basestring,\n// 5-minute replay window, constant-time HMAC-SHA256 compare):\n// https://docs.slack.dev/authentication/verifying-requests-from-slack\nfunction verifySlackSignature(\n rawBody: string,\n signature: string | undefined,\n timestamp: string | undefined,\n signingSecret: string\n): boolean {\n if (!signature || !timestamp) return false;\n const age = Math.abs(Date.now() / 1000 - Number(timestamp));\n if (!Number.isFinite(age) || age > 300) return false;\n const hmac =\n \"v0=\" +\n crypto\n .createHmac(\"sha256\", signingSecret)\n .update(`v0:${timestamp}:${rawBody}`)\n .digest(\"hex\");\n try {\n return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(signature));\n } catch {\n return false;\n }\n}\n\nexport default async function handler(req: VercelRequest, res: VercelResponse) {\n if (req.method !== \"POST\") {\n return res.status(405).json({ error: \"Method not allowed\" });\n }\n\n const rawBody = await readRawBody(req);\n if (\n !verifySlackSignature(\n rawBody,\n req.headers[\"x-slack-signature\"] as string,\n req.headers[\"x-slack-request-timestamp\"] as string,\n env.SLACK_SIGNING_SECRET\n )\n ) {\n return res.status(401).json({ error: \"Invalid signature\" });\n }\n\n const payload: SlackInteraction = JSON.parse(\n new URLSearchParams(rawBody).get(\"payload\") || \"{}\"\n );\n\n // Modal submit (edits): fast, respond inline.\n if (payload.type === \"view_submission\") {\n if (payload.view?.callback_id === MODAL_EDIT_SUBMIT) {\n const meta: StoredMeta = JSON.parse(payload.view.private_metadata || \"{}\");\n const ctx: StandupContext = {\n memberEmail: meta.memberEmail,\n threadTs: meta.threadTs,\n channelId: meta.channelId,\n };\n const edited =\n payload.view.state?.values?.[EDIT_INPUT_BLOCK]?.[EDIT_INPUT_ACTION]\n ?.value ?? \"\";\n const msg = buildDraftMessage(edited, ctx);\n if (meta.dmChannel && meta.dmTs) {\n await updateMessage(meta.dmChannel, meta.dmTs, msg.text, msg.blocks);\n }\n }\n return res.status(200).send(\"\");\n }\n\n if (payload.type !== \"block_actions\") return res.status(200).send(\"\");\n\n const action = payload.actions?.[0];\n const ctx: StandupContext = JSON.parse(action?.value || \"{}\");\n const dmChannel = payload.container?.channel_id ?? payload.channel?.id;\n const dmTs = payload.message?.ts;\n if (!dmChannel || !dmTs) return res.status(200).send(\"\");\n\n // Edit opens a modal: must use trigger_id within ~3s, so handle inline.\n if (action?.action_id === ACTION_EDIT) {\n const draft = extractDraft(payload.message ?? {});\n await openModal(\n payload.trigger_id ?? \"\",\n buildEditModal(draft, ctx, { channel: dmChannel, ts: dmTs })\n );\n return res.status(200).send(\"\");\n }\n\n // Slow work (AI draft, OAuth links, posting): ack within Slack's 3s window,\n // then run via waitUntil, which keeps the function alive after the response is\n // sent. A plain post-send await would be frozen by Vercel.\n waitUntil(\n processSlowAction(action, ctx, dmChannel, dmTs, payload).catch(async (err) => {\n // Don't leave the member stuck on the loading message: log and tell them.\n console.error(\"standup interaction failed:\", err);\n await postMessage(\n dmChannel,\n \"⚠️ Something went wrong handling that. Please try again.\"\n ).catch(() => {});\n })\n );\n return res.status(200).send(\"\");\n}\n\nasync function processSlowAction(\n action: SlackAction | undefined,\n ctx: StandupContext,\n dmChannel: string,\n dmTs: string,\n payload: SlackInteraction\n) {\n const member = team.find((m) => m.slackEmail === ctx.memberEmail);\n\n if (action?.action_id === ACTION_DRAFT) {\n const loading = buildLoadingMessage();\n await updateMessage(dmChannel, dmTs, loading.text, loading.blocks);\n\n const connected = await getConnectedIntegrations(ctx.memberEmail);\n const draft =\n member && connected.length\n ? await generateDraftText(member, connected)\n : \"\";\n const msg = buildDraftMessage(draft, ctx);\n await updateMessage(dmChannel, dmTs, msg.text, msg.blocks);\n return;\n }\n\n if (action?.action_id === ACTION_CONNECT) {\n const connected = await getConnectedIntegrations(ctx.memberEmail);\n const unconnected = TOOLKITS.filter((t) => !connected.includes(t.slug));\n const links = (\n await Promise.all(\n unconnected.map(async (t) => {\n const url = await getConnectLink(ctx.memberEmail, t.slug);\n return url ? { label: t.label, url } : null;\n })\n )\n ).filter(Boolean) as { label: string; url: string }[];\n\n if (unconnected.length === 0) {\n await postMessage(dmChannel, \"You're already connected to everything! 🎉\");\n } else if (links.length === 0) {\n // Toolkits remain, but every connect link failed to generate.\n await postMessage(\n dmChannel,\n \"⚠️ Couldn't generate connect links right now. Please try again in a moment.\"\n );\n } else {\n const menu = buildConnectMenu(links);\n await postMessage(dmChannel, menu.text, { blocks: menu.blocks });\n }\n return;\n }\n\n if (action?.action_id === ACTION_CONFIRM) {\n const draft = extractDraft(payload.message ?? {});\n // Swap the buttons out immediately so a double-click can't post twice.\n const posting = buildLoadingMessage(\"Posting your standup…\");\n await updateMessage(dmChannel, dmTs, posting.text, posting.blocks);\n\n const name = member ? memberName(member) : ctx.memberEmail;\n const connected = await getConnectedIntegrations(ctx.memberEmail);\n\n let posted = false;\n if (connected.includes(\"slack\")) {\n posted = await postAsUser(\n ctx.memberEmail,\n ctx.channelId,\n `*${name}'s standup*\\n${draft}`,\n ctx.threadTs\n );\n }\n\n if (posted) {\n const m = buildPostedMessage(draft);\n await updateMessage(dmChannel, dmTs, m.text, m.blocks);\n } else {\n // No personal Slack (or post failed): give them copyable text.\n const url = (await getPermalink(ctx.channelId, ctx.threadTs)) ?? \"\";\n const m = buildCopyMessage(draft, url);\n await updateMessage(dmChannel, dmTs, m.text, m.blocks);\n }\n return;\n }\n}\n",
"composio": true
},
{
"path": "api/_utils/env.ts",
"contents": "import { config } from \"dotenv\";\n\n// Load .env.local for local runs (scripts, `vercel dev`). Harmless on Vercel,\n// where the file is absent and real environment variables are already set.\nconfig({ path: \".env.local\" });\n\n// Validate every required var once at import time, so a misconfigured deploy\n// fails fast with a clear message instead of a vague error mid-request.\nconst REQUIRED = [\n \"COMPOSIO_API_KEY\",\n \"COMPOSIO_SLACKBOT_AUTH_CONFIG_ID\", // the \"slackbot\" auth config (ac_...)\n \"SLACK_SIGNING_SECRET\", // verifies Slack interactivity requests\n \"CRON_SECRET\", // shared secret the Vercel cron presents\n] as const;\n\ntype EnvKey = (typeof REQUIRED)[number];\n\nfunction loadEnv(): Record<EnvKey, string> {\n const missing = REQUIRED.filter((key) => !process.env[key]);\n if (missing.length > 0) {\n throw new Error(\n `Missing required environment variable(s): ${missing.join(\", \")}.\\n` +\n `Copy .env.local.example to .env.local and fill them in.`\n );\n }\n return Object.fromEntries(\n REQUIRED.map((key) => [key, process.env[key]!])\n ) as Record<EnvKey, string>;\n}\n\nexport const env = loadEnv();\n",
"composio": false
},
{
"path": "api/_utils/schedule.ts",
"contents": "import dayjs from \"dayjs\";\nimport utc from \"dayjs/plugin/utc\";\nimport timezone from \"dayjs/plugin/timezone\";\nimport {\n Member,\n DEFAULT_STANDUP_TIME,\n DEFAULT_STANDUP_TIMEZONE,\n CRON_SLOT_MINUTES,\n DEMO_MODE,\n} from \"../../standup.config\";\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\n\n/** \"soham.basu@…\" → \"Soham Basu\". Keeps raw emails out of the public channel. */\nexport function memberName(member: Member): string {\n return member.slackEmail\n .split(\"@\")[0]\n .split(/[._]/)\n .map((p) => p.charAt(0).toUpperCase() + p.slice(1))\n .join(\" \");\n}\n\nexport function formatToday(): string {\n return dayjs().tz(DEFAULT_STANDUP_TIMEZONE).format(\"dddd, MMMM D, YYYY\");\n}\n\n/** Draft lookback: Monday reaches back to Friday, else the previous day.\n * Computed in the member's own timezone so the window matches their day. */\nexport function lookbackWindow(\n timezone: string = DEFAULT_STANDUP_TIMEZONE\n): { date: string; label: string } {\n const now = dayjs().tz(timezone);\n const days = now.day() === 1 ? 3 : 1;\n return {\n date: now.subtract(days, \"day\").format(\"YYYY-MM-DD\"),\n label: days === 1 ? \"since yesterday\" : \"since your last standup\",\n };\n}\n\nconst memberStandupTime = (m: Member) => m.standupTime ?? DEFAULT_STANDUP_TIME;\nconst memberTimezone = (m: Member) => m.standupTimezone ?? DEFAULT_STANDUP_TIMEZONE;\n\n/** Is this member due in the current cron slot, evaluated in their timezone? */\nexport function isMemberDue(member: Member): boolean {\n if (DEMO_MODE) return true;\n const now = dayjs().tz(memberTimezone(member));\n const nowMin = now.hour() * 60 + now.minute();\n const slotStart = Math.floor(nowMin / CRON_SLOT_MINUTES) * CRON_SLOT_MINUTES;\n const [h, m] = memberStandupTime(member).split(\":\").map(Number);\n const memberMin = h * 60 + m;\n return memberMin >= slotStart && memberMin < slotStart + CRON_SLOT_MINUTES;\n}\n",
"composio": false
},
{
"path": "api/_utils/composio.ts",
"contents": "import { Composio } from \"@composio/core\";\nimport { VercelProvider } from \"@composio/vercel\";\nimport { TOOLKIT_SLUGS } from \"../../standup.config\";\nimport { env } from \"./env\";\n\nexport const composio = new Composio({\n apiKey: env.COMPOSIO_API_KEY,\n provider: new VercelProvider(),\n});\n\n// The bot's own Composio user. The slackbot OAuth connection lives here, so all\n// bot Slack actions (posting to the channel, DMing members) execute as the bot.\nexport const BOT_USER = \"default\";\n\n/** Active connected accounts for a user, optionally filtered to one toolkit.\n * Filters server-side via `statuses` so we never see stale/expired ones. */\nexport async function listActiveAccounts(userId: string, toolkitSlug?: string) {\n const res = await composio.connectedAccounts.list({\n userIds: [userId],\n statuses: [\"ACTIVE\"],\n ...(toolkitSlug ? { toolkitSlugs: [toolkitSlug] } : {}),\n });\n return res.items;\n}\n\n// The bot's slackbot connected-account ID. proxyExecute needs it so the call\n// authenticates as the bot (the project has several connections, so we can't\n// rely on a default-account fallback). Resolved once and memoized.\nlet botAccountIdPromise: Promise<string | undefined> | undefined;\nfunction getBotAccountId(): Promise<string | undefined> {\n botAccountIdPromise ??= listActiveAccounts(BOT_USER, \"slackbot\")\n .then((accounts) => accounts[0]?.id)\n .then((id) => {\n // Don't cache a miss: if the bot isn't connected yet, retry next call so a\n // later setup/OAuth is picked up without waiting for a cold start.\n if (!id) botAccountIdPromise = undefined;\n return id;\n });\n return botAccountIdPromise;\n}\n\n// Wraps Composio's proxyExecute to call any Slack Web API endpoint as the bot\n// (or a member, via accountId). Covers what the named SLACKBOT_* tools don't,\n// like views.open for modals.\nexport async function slackApi<T = unknown>(\n endpoint: string,\n method: \"GET\" | \"POST\",\n body?: Record<string, unknown>,\n accountId?: string // override the bot account (e.g. post as a member)\n): Promise<T> {\n const connectedAccountId = accountId ?? (await getBotAccountId());\n const res = await composio.tools.proxyExecute({\n endpoint,\n method,\n ...(body ? { body } : {}),\n ...(connectedAccountId ? { connectedAccountId } : {}),\n });\n // proxyExecute wraps Slack's JSON response in `data`.\n return ((res as { data?: unknown }).data ?? res) as T;\n}\n\n/** Run a named Composio tool as a user (the bot by default). This is the normal\n * path for actions a SLACKBOT or SLACK tool covers (sending a message, even\n * one with Block Kit buttons, updating one, posting as a member). Returns the\n * tool's `data`, which is the Slack API response. */\nexport async function slackTool<T = Record<string, unknown>>(\n slug: string,\n args: Record<string, unknown>,\n userId: string = BOT_USER\n): Promise<T> {\n const res = await composio.tools.execute(slug, { userId, arguments: args });\n return (res.data ?? {}) as T;\n}\n\n/** Which catalogue toolkits the member has actively connected. */\nexport async function getConnectedIntegrations(\n memberEmail: string\n): Promise<string[]> {\n const active = await listActiveAccounts(memberEmail);\n const slugs = new Set(active.map((a) => a.toolkit.slug));\n return TOOLKIT_SLUGS.filter((s) => slugs.has(s));\n}\n\n/** Generate an OAuth connect link for one toolkit, for one member. */\nexport async function getConnectLink(\n memberEmail: string,\n toolkitSlug: string\n): Promise<string | null> {\n try {\n const conn = await composio.toolkits.authorize(memberEmail, toolkitSlug);\n return conn.redirectUrl ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * A tool-router session scoped to the given toolkits. `session.tools()` returns\n * Composio's research meta-tools (search/execute/workbench) limited to these\n * toolkits. `manageConnections: false` strips the connection meta-tools so the\n * draft agent can research but never initiate an OAuth flow mid-draft.\n */\nexport async function createToolRouterSession(\n memberEmail: string,\n toolkitSlugs: string[]\n) {\n return composio.create(memberEmail, {\n toolkits: toolkitSlugs,\n manageConnections: false,\n });\n}\n",
"composio": true
},
{
"path": "api/_utils/slack.ts",
"contents": "import { slackApi, slackTool, listActiveAccounts } from \"./composio\";\n\n// The subset of Slack Web API response fields this bot reads. proxyExecute\n// returns the raw Slack JSON, so one shared shape keeps call sites typed.\ntype SlackResponse = {\n ok?: boolean;\n error?: string;\n ts?: string;\n channel?: string;\n permalink?: string;\n user?: { id?: string };\n channels?: Array<{ id: string; name: string }>;\n messages?: Array<{ ts: string; text?: string }>;\n response_metadata?: { next_cursor?: string };\n};\n\nexport async function findUserIdByEmail(email: string): Promise<string | null> {\n const data = await slackApi<SlackResponse>(\n `/users.lookupByEmail?email=${encodeURIComponent(email)}`,\n \"GET\"\n );\n return data.user?.id ?? null;\n}\n\n/** Resolve a channel ID from a \"#channel-name\" (or bare name). */\nexport async function findChannelId(channelName: string): Promise<string | null> {\n const name = channelName.replace(/^#/, \"\");\n let cursor: string | undefined;\n for (let i = 0; i < 10; i++) {\n const qs = new URLSearchParams({\n types: \"public_channel,private_channel\",\n limit: \"200\",\n ...(cursor ? { cursor } : {}),\n });\n const data = await slackApi<SlackResponse>(`/conversations.list?${qs.toString()}`, \"GET\");\n const match = (data.channels ?? []).find((c) => c.name === name);\n if (match) return match.id;\n cursor = data.response_metadata?.next_cursor || undefined;\n if (!cursor) break;\n }\n return null;\n}\n\n/** Fetch recent top-level messages in a channel (newest first; threaded replies excluded). */\nexport async function getRecentMessages(\n channelId: string,\n limit = 50\n): Promise<Array<{ ts: string; text: string }>> {\n const data = await slackApi<SlackResponse>(\n `/conversations.history?channel=${encodeURIComponent(channelId)}&limit=${limit}`,\n \"GET\"\n );\n return (data.messages ?? []).map((m) => ({ ts: m.ts, text: m.text ?? \"\" }));\n}\n\nexport async function postMessage(\n channel: string,\n text: string,\n opts?: { thread_ts?: string; blocks?: unknown[] }\n): Promise<{ ts: string } | null> {\n // SLACKBOT_SEND_MESSAGE is the named tool: markdown_text for prose, or Block\n // Kit `blocks` (buttons included). channel must be a channel/DM id, not a user id.\n const data = await slackTool<SlackResponse>(\"SLACKBOT_SEND_MESSAGE\", {\n channel,\n ...(opts?.blocks ? { blocks: opts.blocks } : { markdown_text: text }),\n ...(opts?.thread_ts ? { thread_ts: opts.thread_ts } : {}),\n });\n return data.ts ? { ts: data.ts } : null;\n}\n\n// Stays on the proxy: this opens a DM by passing a user id as the channel, which\n// the raw chat.postMessage allows but SLACKBOT_SEND_MESSAGE rejects (it wants a\n// pre-opened D… channel id). The proxy lets us DM the member in one call.\nexport async function sendDmBlocks(\n userId: string,\n text: string,\n blocks: unknown[]\n): Promise<{ channel: string; ts: string } | null> {\n const data = await slackApi<SlackResponse>(\"/chat.postMessage\", \"POST\", {\n channel: userId,\n text,\n blocks,\n });\n if (!data.ok || !data.channel || !data.ts) return null;\n return { channel: data.channel, ts: data.ts };\n}\n\n/** Update an existing message (e.g. swap the draft after an edit). The named\n * tool SLACKBOT_UPDATES_A_MESSAGE takes Block Kit blocks just like sending. */\nexport async function updateMessage(\n channel: string,\n ts: string,\n _text: string,\n blocks: unknown[]\n): Promise<boolean> {\n const data = await slackTool<SlackResponse>(\"SLACKBOT_UPDATES_A_MESSAGE\", { channel, ts, blocks });\n return data.ok !== false;\n}\n\n/** Permalink to a message (used to link members to today's thread). */\nexport async function getPermalink(\n channel: string,\n ts: string\n): Promise<string | null> {\n const data = await slackApi<SlackResponse>(\n `/chat.getPermalink?channel=${encodeURIComponent(channel)}&message_ts=${ts}`,\n \"GET\"\n );\n return data.permalink ?? null;\n}\n\n/** The member's OWN active Slack connected-account id (for posting as them). */\nasync function getMemberSlackAccountId(\n memberEmail: string\n): Promise<string | undefined> {\n const [active] = await listActiveAccounts(memberEmail, \"slack\");\n return active?.id;\n}\n\n/**\n * Post into the thread AS the member, using their personal Slack connection.\n * SLACK_SEND_MESSAGE (the slack user toolkit) run under their email as the\n * userId, so it posts under their name. Returns false if they have no active\n * personal Slack account.\n */\nexport async function postAsUser(\n memberEmail: string,\n channel: string,\n text: string,\n threadTs: string\n): Promise<boolean> {\n const accountId = await getMemberSlackAccountId(memberEmail);\n if (!accountId) return false;\n const data = await slackTool<SlackResponse>(\n \"SLACK_SEND_MESSAGE\",\n { channel, markdown_text: text, thread_ts: threadTs },\n memberEmail\n );\n return data.ok !== false;\n}\n\n/** Open a modal in response to a button click (needs trigger_id, <3s). */\nexport async function openModal(triggerId: string, view: unknown): Promise<boolean> {\n const data = await slackApi<SlackResponse>(\"/views.open\", \"POST\", { trigger_id: triggerId, view });\n return !!data.ok;\n}\n",
"composio": true
},
{
"path": "api/_utils/agent.ts",
"contents": "import { generateText, stepCountIs } from \"ai\";\nimport { Member, MODEL, TOOLKITS, GITHUB_ORG } from \"../../standup.config\";\nimport { lookbackWindow } from \"./schedule\";\nimport { createToolRouterSession } from \"./composio\";\n\n/** The standup draft prompt, adapted to whichever toolkits the member connected. */\nfunction buildDraftPrompt(\n member: Member,\n window: { date: string; label: string },\n connectedSlugs: string[]\n): string {\n const connected = TOOLKITS.filter((t) => connectedSlugs.includes(t.slug));\n const sources = connected.map((t) => ` - ${t.draftInstruction}`).join(\"\\n\");\n const emojiLegend = connected.map((t) => `${t.emoji} ${t.label}`).join(\", \");\n\n return `You are writing @${member.githubUsername}'s daily standup, covering\n${window.label} (everything since ${window.date}).\n\nYou have a Composio tool-router session with these tools connected:\n${connected.map((t) => t.label).join(\", \")}.\nUse the meta-tools to research yourself: search for the right tool per source,\nthen execute it. Do NOT guess activity. Investigate first, then write.\n\nIMPORTANT: All tools above are ALREADY connected. Do NOT attempt to connect,\nauthenticate, or generate OAuth links. If a tool call fails, skip that source.\nNever ask the user to connect anything.\n\nWhat to look at (ONLY these connected sources):\n${sources}\n\nKeep tool requests small (filter by date, ~10 items, no full transcripts).\n\nOutput rules:\n - 2 to 5 atomic, one-line updates. Prune noise.\n - Start each line with the right source emoji: ${emojiLegend}. GitHub PRs use\n 🟣 merged, 🟢 open, ⚪️ draft, 🔴 closed and include the *#number*.\n - State WHAT they did and WHY it matters, plainly. Facts only, no speculation.\n - NEVER use em dashes. Use colons, commas, or separate sentences.\n - LINKS: this is posted to Slack, which does NOT support Markdown links. NEVER\n write [text](url). Use Slack's link syntax <url|text> instead, e.g.\n <https://github.com/${GITHUB_ORG}/repo/pull/893|#893>. A bare URL is also fine.\n - CRITICAL: your reply is posted to Slack VERBATIM. It must contain ONLY the\n bullet lines, each starting with its source emoji. No preamble (\"Here's the\n standup\"), no narration (\"I found 3 PRs\"), no headings, no closing remarks,\n no sign-off. The very first character of your reply is the first bullet's emoji.\n - If no activity in range, your entire reply is exactly: \"📭 No tracked activity ${window.label}.\"`;\n}\n\n/**\n * Generate a member's standup draft by researching their CONNECTED tools. Spins\n * up a Composio tool-router session scoped to those toolkits and lets the agent\n * drive the meta-tools to gather activity. Returns the draft text only.\n */\nexport async function generateDraftText(\n member: Member,\n connectedSlugs: string[]\n): Promise<string> {\n if (connectedSlugs.length === 0) return \"\";\n\n const session = await createToolRouterSession(member.slackEmail, connectedSlugs);\n const tools = await session.tools();\n\n const { text } = await generateText({\n model: MODEL,\n system: buildDraftPrompt(member, lookbackWindow(member.standupTimezone), connectedSlugs),\n prompt: `Research and write @${member.githubUsername}'s standup update.`,\n tools,\n stopWhen: stepCountIs(40),\n });\n\n return text.trim();\n}\n",
"composio": true
},
{
"path": "api/_utils/blocks.ts",
"contents": "// Block Kit builders for the standup menu / confirm / edit / connect flow.\n// Plain data (no agent involvement) keeps the interactive messages deterministic.\n\nexport const ACTION_DRAFT = \"standup_draft\";\nexport const ACTION_CONNECT = \"standup_connect\";\nexport const ACTION_CONFIRM = \"standup_confirm\";\nexport const ACTION_EDIT = \"standup_edit\";\nexport const MODAL_EDIT_SUBMIT = \"standup_edit_submit\";\nexport const EDIT_INPUT_BLOCK = \"standup_edit_block\";\nexport const EDIT_INPUT_ACTION = \"standup_edit_input\";\n// Stable id for the draft-body section, so the handler can recover the draft\n// text by block_id instead of a fragile positional index.\nexport const DRAFT_BODY_BLOCK = \"standup_draft_body\";\n\n// Context threaded through buttons/modals so the handler knows who/where without\n// re-deriving it. Kept small (button `value` is capped at 2000 chars).\nexport type StandupContext = {\n memberEmail: string;\n threadTs: string; // parent daily-thread message ts\n channelId: string; // the standup channel's ID\n};\n\n/** The daily reminder \"menu\": a nudge + Draft button (+ Connect if applicable). */\nexport function buildReminderMenu(\n threadUrl: string,\n ctx: StandupContext,\n showConnect: boolean\n) {\n const value = JSON.stringify(ctx);\n const buttons: Record<string, unknown>[] = [\n {\n type: \"button\",\n style: \"primary\",\n text: { type: \"plain_text\", text: \"📝 Draft\", emoji: true },\n action_id: ACTION_DRAFT,\n value,\n },\n ];\n if (showConnect) {\n buttons.push({\n type: \"button\",\n text: { type: \"plain_text\", text: \"🔌 Connect more tools\", emoji: true },\n action_id: ACTION_CONNECT,\n value,\n });\n }\n return {\n text: \"Daily standup reminder\",\n blocks: [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `📅 Time for your standup. Post your update in <${threadUrl}|today's thread>.\\nTap *Draft* and I'll put together a draft from your connected tools.`,\n },\n },\n { type: \"actions\", elements: buttons },\n ],\n };\n}\n\n/** \"Researching…\" placeholder shown while the draft is generated. */\nexport function buildLoadingMessage(label = \"Researching your activity and drafting your standup…\") {\n return {\n text: label,\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: `⏳ ${label}` } }],\n };\n}\n\n/** The draft DM: the draft text + Confirm/Edit buttons. */\nexport function buildDraftMessage(draft: string, ctx: StandupContext) {\n const value = JSON.stringify(ctx);\n const body = draft.trim()\n ? draft\n : \"_No draft yet. Tap ✏️ Edit to write your update._\";\n return {\n text: \"Your standup draft is ready for review.\",\n blocks: [\n {\n type: \"section\",\n text: { type: \"mrkdwn\", text: \"*Your standup draft.* Review and post 👇\" },\n },\n { type: \"section\", block_id: DRAFT_BODY_BLOCK, text: { type: \"mrkdwn\", text: body } },\n { type: \"divider\" },\n {\n type: \"actions\",\n elements: [\n {\n type: \"button\",\n style: \"primary\",\n text: { type: \"plain_text\", text: \"✅ Confirm & Post\", emoji: true },\n action_id: ACTION_CONFIRM,\n value,\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"✏️ Edit\", emoji: true },\n action_id: ACTION_EDIT,\n value,\n },\n ],\n },\n ],\n };\n}\n\ntype DraftMessage = {\n blocks?: Array<{ block_id?: string; text?: { text?: string } }>;\n};\n\n/** Recover the draft text from a posted draft message, by stable block_id. */\nexport function extractDraft(message: DraftMessage): string {\n const block = (message.blocks ?? []).find(\n (b) => b.block_id === DRAFT_BODY_BLOCK\n );\n return block?.text?.text ?? \"\";\n}\n\n/** Shown after posting from the user's own Slack account. */\nexport function buildPostedMessage(draft: string) {\n return {\n text: \"Standup posted.\",\n blocks: [\n { type: \"section\", text: { type: \"mrkdwn\", text: \"✅ *Posted to the standup thread.*\" } },\n { type: \"section\", text: { type: \"mrkdwn\", text: draft } },\n ],\n };\n}\n\n/** Fallback when the member has no personal Slack: copyable text + thread link. */\nexport function buildCopyMessage(draft: string, threadUrl: string) {\n return {\n text: \"Copy your standup into the thread.\",\n blocks: [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `Here's your standup. Copy it and paste it into <${threadUrl}|today's thread>:`,\n },\n },\n { type: \"section\", text: { type: \"mrkdwn\", text: \"```\" + draft + \"```\" } },\n {\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \"_Connect your Slack (tap Connect on the reminder) to have me post it for you next time._\",\n },\n ],\n },\n ],\n };\n}\n\n/** Connect menu: one URL button per unconnected tool (opens its OAuth link). */\nexport function buildConnectMenu(links: { label: string; url: string }[]) {\n // Slack actions blocks allow max 5 elements; chunk into rows of 5.\n const rows: Record<string, unknown>[] = [];\n for (let i = 0; i < links.length; i += 5) {\n rows.push({\n type: \"actions\",\n elements: links.slice(i, i + 5).map((l) => ({\n type: \"button\",\n text: { type: \"plain_text\", text: `Connect ${l.label}`, emoji: true },\n url: l.url,\n })),\n });\n }\n return {\n text: \"Connect tools to enrich your standup drafts.\",\n blocks: [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: \"*Connect your tools* so I can draft from your real activity. All optional:\",\n },\n },\n ...rows,\n ],\n };\n}\n\n/** Edit modal: multiline input prefilled with the current draft. */\nexport function buildEditModal(\n draft: string,\n ctx: StandupContext,\n dm: { channel: string; ts: string }\n) {\n return {\n type: \"modal\",\n callback_id: MODAL_EDIT_SUBMIT,\n private_metadata: JSON.stringify({ ...ctx, dmChannel: dm.channel, dmTs: dm.ts }),\n title: { type: \"plain_text\", text: \"Edit standup\" },\n submit: { type: \"plain_text\", text: \"Save\" },\n close: { type: \"plain_text\", text: \"Cancel\" },\n blocks: [\n {\n type: \"input\",\n block_id: EDIT_INPUT_BLOCK,\n label: { type: \"plain_text\", text: \"Your standup update\" },\n element: {\n type: \"plain_text_input\",\n action_id: EDIT_INPUT_ACTION,\n multiline: true,\n initial_value: draft,\n },\n },\n ],\n };\n}\n",
"composio": false
},
{
"path": "scripts/setup.ts",
"contents": "import { Composio } from \"@composio/core\";\nimport { env } from \"../api/_utils/env\";\n\n// One-time setup: connect the bot's Slack (\"slackbot\") account so it can post to\n// the channel and DM members. Run with `npx tsx scripts/setup.ts`, or\n// `--reconnect` to clear and re-do the OAuth (needed after changing Slack scopes).\nconst COMPOSIO_BASE_URL = \"https://backend.composio.dev\";\nconst TOOLKIT_SLUG = \"slackbot\";\n\n// Scopes the bot's OAuth *user* token must carry. Composio freezes the scopes at\n// connect time, so we check them before connecting and tell the user what to add.\nconst REQUIRED_USER_SCOPES = [\"team:read\"];\n\nconst log = {\n ok: (msg: string) => console.log(` ✅ ${msg}`),\n info: (msg: string) => console.log(` · ${msg}`),\n warn: (msg: string) => console.log(` ⚠️ ${msg}`),\n};\n\nfunction errMessage(err: unknown): string {\n if (err && typeof err === \"object\") {\n const e = err as {\n error?: { error?: { message?: string }; message?: string };\n message?: string;\n };\n return e.error?.error?.message ?? e.error?.message ?? e.message ?? String(err);\n }\n return String(err);\n}\n\nasync function main() {\n const RECONNECT = process.argv.includes(\"--reconnect\");\n\n console.log(\"\\n╭─────────────────────────────────────────────────────────╮\");\n console.log(\"│ Daily Standup Bot: One-Time Setup │\");\n console.log(\"╰─────────────────────────────────────────────────────────╯\");\n if (RECONNECT) {\n console.log(\" (--reconnect: will clear and re-do the bot's Slack auth)\");\n }\n\n const composio = new Composio({ apiKey: env.COMPOSIO_API_KEY });\n const authConfigId = env.COMPOSIO_SLACKBOT_AUTH_CONFIG_ID;\n\n // Check the auth config requests the scopes the bot needs.\n // READ-ONLY on purpose. GET returns secrets MASKED, and a PATCH that echoes\n // those masked values back overwrites the real client_secret with the mask.\n // So we never write credentials here; we only check and warn.\n const cfgRes = await fetch(`${COMPOSIO_BASE_URL}/api/v3/auth_configs/${authConfigId}`, {\n headers: { \"x-api-key\": env.COMPOSIO_API_KEY },\n });\n if (cfgRes.ok) {\n const cfg = (await cfgRes.json().catch(() => ({}))) as {\n credentials?: { user_scopes?: string[] };\n };\n const userScopes = cfg.credentials?.user_scopes ?? [];\n const missing = REQUIRED_USER_SCOPES.filter((s) => !userScopes.includes(s));\n if (missing.length > 0) {\n log.warn(`Auth config is missing required user scope(s): ${missing.join(\", \")}`);\n console.log(\n \" Add them under the auth config's User Token Scopes in the Composio\\n\" +\n \" dashboard (Auth Configs → slackbot), then re-run with --reconnect.\"\n );\n } else {\n log.ok(\"Auth config has the required user scopes.\");\n }\n }\n\n // Ensure the bot's Slack connection is active.\n const session = await composio.create(\"default\", {\n authConfigs: { slackbot: authConfigId },\n });\n\n async function listSlackbotAccounts() {\n try {\n const res = await composio.connectedAccounts.list({\n userIds: [\"default\"],\n toolkitSlugs: [TOOLKIT_SLUG],\n });\n return res.items;\n } catch {\n return [];\n }\n }\n\n if (RECONNECT) {\n const accounts = await listSlackbotAccounts();\n if (accounts.length === 0) {\n log.info(\"No existing connections to clear.\");\n } else {\n for (const acc of accounts) {\n try {\n await composio.connectedAccounts.delete(acc.id);\n log.ok(`Removed old connection ${acc.id}.`);\n } catch (err) {\n log.warn(`Could not remove ${acc.id}: ${errMessage(err)}`);\n }\n }\n }\n }\n\n let connected = false;\n if (!RECONNECT) {\n const toolkits = await session.toolkits({ toolkits: [TOOLKIT_SLUG] });\n connected = !!toolkits.items.find((t) => t.slug === TOOLKIT_SLUG)?.connection\n ?.isActive;\n }\n\n if (connected) {\n log.ok(\"Bot is already connected to Slack.\");\n } else {\n log.info(\"Bot is not connected yet. Generating an authorization link…\");\n // Manual authentication: authorize() returns a Connect Link, then\n // waitForConnection() resolves once the bot finishes OAuth in the browser.\n // https://docs.composio.dev/docs/manually-authenticating\n const connectionRequest = await session.authorize(TOOLKIT_SLUG);\n if (!connectionRequest.redirectUrl) {\n throw new Error(\"Composio did not return an authorization link.\");\n }\n\n console.log(\"\\n Open this URL in your browser to authorize the bot:\\n\");\n console.log(` ${connectionRequest.redirectUrl}\\n`);\n console.log(\" Waiting for you to complete the OAuth flow (Ctrl+C to abort)…\");\n\n const TIMEOUT_MS = 5 * 60 * 1000;\n const account = await connectionRequest.waitForConnection(TIMEOUT_MS);\n log.ok(`Bot connected to Slack (${account.id}).`);\n }\n\n console.log(`\\n${\"─\".repeat(70)}`);\n console.log(\" 🎉 Setup complete. Invite the bot to your standup channel and\");\n console.log(\" point your Slack app's Interactivity Request URL at\");\n console.log(\" https://<your-deployment>/api/interactivity\");\n console.log(`${\"─\".repeat(70)}\\n`);\n}\n\nmain().catch((err) => {\n console.error(`\\n ❌ ${errMessage(err)}\\n`);\n process.exit(1);\n});\n",
"composio": true
}
]