diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md index 294c045..dff3e55 100644 --- a/.claude/skills/add-slack-v2/SKILL.md +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -52,7 +52,7 @@ npm run build ### Event Subscriptions 8. Go to **Event Subscriptions** and toggle **Enable Events** -9. Set the **Request URL** to `https://your-domain/api/webhooks/slack` — Slack will send a verification challenge; it must pass before you can save +9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save 10. Under **Subscribe to bot events**, add: - `message.channels`, `message.groups`, `message.im`, `app_mention` 11. Click **Save Changes** @@ -71,9 +71,9 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Webhook server -The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. -If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/api/webhooks/slack`. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`. ## Next Steps diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index 42384b8..eaed5ce 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -38,7 +38,7 @@ npm run build 3. After creation, go to **Configuration**: - Copy the **Microsoft App ID** - Note the **App Tenant ID** (shown for Single Tenant) - - Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams` + - Set **Messaging endpoint** to `https://your-domain/webhook/teams` 4. Click **Manage Password** > **Certificates & secrets** > **New client secret** — copy the Value immediately (shown only once) 5. Go to **Channels** > add **Microsoft Teams** > Accept terms > Apply @@ -62,9 +62,9 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Webhook server -The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. -If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/api/webhooks/teams`. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/teams`. ## Next Steps diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 0cf0c66..02ddeba 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -20,6 +20,7 @@ import { } from 'chat'; import { log } from '../log.js'; import { SqliteStateAdapter } from '../state-sqlite.js'; +import { registerWebhookAdapter } from '../webhook-server.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; /** Adapter with optional gateway support (e.g., Discord). */ @@ -306,9 +307,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter log.info('Gateway listener started', { adapter: adapter.name }); } else { // Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server - const webhookPath = `/api/webhooks/${adapter.name}`; - registerWebhookAdapter(webhookPath, chat, adapter.name); - log.info('Webhook adapter registered', { adapter: adapter.name, path: webhookPath }); + registerWebhookAdapter(chat, adapter.name); } log.info('Chat SDK bridge initialized', { adapter: adapter.name }); @@ -534,88 +533,3 @@ async function handleForwardedEvent( }); await adapter.handleWebhook(fakeRequest, {}); } - -/** - * Shared webhook server for all webhook-based adapters. - * Each adapter registers a path (e.g. /api/webhooks/slack, /api/webhooks/teams). - * Listens on a single port (default 3000, configurable via WEBHOOK_PORT env var). - * - * Routes incoming requests to the Chat SDK's `chat.webhooks[name]()` handler, - * which auto-initializes the adapter and handles signature verification. - */ -interface WebhookEntry { - chat: Chat; - adapterName: string; -} -const webhookRoutes = new Map(); -let sharedWebhookServer: http.Server | null = null; -/** Placeholder base for URL parsing — Node's http.IncomingMessage only has a path, not a full URL. */ -const URL_BASE = 'http://0.0.0.0'; - -/** Convert a Node http.IncomingMessage into a Web API Request. */ -function nodeToWebRequest(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('error', reject); - req.on('end', () => { - const body = Buffer.concat(chunks); - const headers = new Headers(); - for (const [key, val] of Object.entries(req.headers)) { - if (typeof val === 'string') headers.set(key, val); - else if (Array.isArray(val)) for (const v of val) headers.append(key, v); - } - const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; - resolve( - new Request(`${URL_BASE}${req.url}`, { - method: req.method, - headers, - body: hasBody ? body : undefined, - }), - ); - }); - }); -} - -/** Write a Web API Response back to a Node http.ServerResponse. */ -async function writeWebResponse(webRes: Response, nodeRes: http.ServerResponse): Promise { - const responseHeaders: Record = {}; - webRes.headers.forEach((v, k) => { - responseHeaders[k] = v; - }); - nodeRes.writeHead(webRes.status, responseHeaders); - nodeRes.end(await webRes.text()); -} - -function registerWebhookAdapter(urlPath: string, chat: Chat, adapterName: string): void { - webhookRoutes.set(urlPath, { chat, adapterName }); - if (!sharedWebhookServer) { - const port = parseInt(process.env.WEBHOOK_PORT || '3000', 10); - sharedWebhookServer = http.createServer(async (req, res) => { - const pathname = new URL(req.url || '/', URL_BASE).pathname; - const entry = webhookRoutes.get(pathname); - if (!entry) { - res.writeHead(404); - res.end('Not found'); - return; - } - try { - const webReq = await nodeToWebRequest(req); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const webhooks = entry.chat.webhooks as Record Promise>; - const handler = webhooks[entry.adapterName]; - const webRes = await handler(webReq, { - waitUntil: (p: Promise) => { p.catch(() => {}); }, - }); - await writeWebResponse(webRes, res); - } catch (err) { - log.error('Webhook handler error', { url: req.url, err }); - res.writeHead(500); - res.end('{"error":"internal"}'); - } - }); - sharedWebhookServer.listen(port, '0.0.0.0', () => { - log.info('Shared webhook server started', { port, paths: [...webhookRoutes.keys()] }); - }); - } -} diff --git a/src/webhook-server.ts b/src/webhook-server.ts new file mode 100644 index 0000000..6b26d11 --- /dev/null +++ b/src/webhook-server.ts @@ -0,0 +1,134 @@ +/** + * Minimal HTTP server for Chat SDK adapter webhooks. + * + * Starts lazily on first adapter registration. Routes requests by path: + * /webhook/{adapterName} → chat.webhooks[adapterName](request) + * + * Multiple Chat instances can register adapters — each adapter name maps + * to its owning Chat instance. + */ +import http from 'http'; + +import type { Chat } from 'chat'; + +import { log } from './log.js'; + +const DEFAULT_PORT = 3000; + +interface WebhookEntry { + chat: Chat; + adapterName: string; +} + +const routes = new Map(); +let server: http.Server | null = null; + +/** Convert Node.js IncomingMessage to a Web API Request. */ +async function toWebRequest(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = Buffer.concat(chunks); + + const host = req.headers.host || 'localhost'; + const url = `http://${host}${req.url}`; + + const headers: Record = {}; + for (const [key, val] of Object.entries(req.headers)) { + if (typeof val === 'string') headers[key] = val; + else if (Array.isArray(val)) headers[key] = val.join(', '); + } + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + return new Request(url, { + method: req.method || 'GET', + headers, + body: hasBody ? body : undefined, + }); +} + +/** Write a Web API Response back to a Node.js ServerResponse. */ +async function fromWebResponse(webRes: Response, nodeRes: http.ServerResponse): Promise { + nodeRes.writeHead(webRes.status, Object.fromEntries(webRes.headers.entries())); + if (webRes.body) { + const reader = webRes.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + nodeRes.write(value); + } + } finally { + reader.releaseLock(); + } + } + nodeRes.end(); +} + +/** + * Register a webhook adapter on the shared server. + * Starts the server lazily on first call. + */ +export function registerWebhookAdapter(chat: Chat, adapterName: string): void { + routes.set(adapterName, { chat, adapterName }); + ensureServer(); + log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${adapterName}` }); +} + +function ensureServer(): void { + if (server) return; + + const port = parseInt(process.env.WEBHOOK_PORT || String(DEFAULT_PORT), 10); + + server = http.createServer(async (req, res) => { + const url = req.url || '/'; + + // Route: /webhook/{adapterName} + const match = url.match(/^\/webhook\/([^/?]+)/); + if (!match) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + + const adapterName = match[1]; + const entry = routes.get(adapterName); + if (!entry) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end(`Unknown adapter: ${adapterName}`); + return; + } + + try { + const webReq = await toWebRequest(req); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webhooks = entry.chat.webhooks as Record Promise>; + const handler = webhooks[entry.adapterName]; + const webRes = await handler(webReq, { + waitUntil: (p: Promise) => { + p.catch(() => {}); + }, + }); + await fromWebResponse(webRes, res); + } catch (err) { + log.error('Webhook handler error', { adapter: adapterName, url: req.url, err }); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + } + }); + + server.listen(port, '0.0.0.0', () => { + log.info('Webhook server started', { port, adapters: [...routes.keys()] }); + }); +} + +/** Shut down the webhook server. */ +export async function stopWebhookServer(): Promise { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + server = null; + routes.clear(); + log.info('Webhook server stopped'); + } +}