diff --git a/.claude/skills/add-slack-v2/SKILL.md b/.claude/skills/add-slack-v2/SKILL.md index a7c8e39..294c045 100644 --- a/.claude/skills/add-slack-v2/SKILL.md +++ b/.claude/skills/add-slack-v2/SKILL.md @@ -73,6 +73,8 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` 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. +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`. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. diff --git a/.claude/skills/add-teams-v2/SKILL.md b/.claude/skills/add-teams-v2/SKILL.md index 8f91aa1..42384b8 100644 --- a/.claude/skills/add-teams-v2/SKILL.md +++ b/.claude/skills/add-teams-v2/SKILL.md @@ -64,6 +64,8 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env` 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. +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`. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 0ed32fd..0cf0c66 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -307,7 +307,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } else { // Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server const webhookPath = `/api/webhooks/${adapter.name}`; - registerWebhookAdapter(webhookPath, adapter); + registerWebhookAdapter(webhookPath, chat, adapter.name); log.info('Webhook adapter registered', { adapter: adapter.name, path: webhookPath }); } @@ -536,55 +536,86 @@ async function handleForwardedEvent( } /** - * Shared public webhook server for all webhook-based adapters. + * Shared webhook server for all webhook-based adapters. * Each adapter registers a path (e.g. /api/webhooks/slack, /api/webhooks/teams). - * The server listens on a single port (default 3000, configurable via WEBHOOK_PORT env var). + * 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. */ -const webhookAdapters = new Map(); +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'; -function registerWebhookAdapter(path: string, adapter: Adapter): void { - webhookAdapters.set(path, adapter); +/** 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((req, res) => { - const matchedAdapter = req.url ? webhookAdapters.get(req.url) : undefined; - if (req.method === 'POST' && matchedAdapter) { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', async () => { - try { - const body = Buffer.concat(chunks).toString(); - const headers: Record = {}; - for (const [key, val] of Object.entries(req.headers)) { - if (typeof val === 'string') headers[key] = val; - } - const request = new Request(`http://localhost${req.url}`, { - method: 'POST', - headers, - body, - }); - const response = await matchedAdapter.handleWebhook!(request, { - waitUntil: (p: Promise) => { p.catch(() => {}); }, - }); - const responseBody = await response.text(); - const responseHeaders: Record = { 'Content-Type': 'application/json' }; - response.headers.forEach((v, k) => { responseHeaders[k] = v; }); - res.writeHead(response.status, responseHeaders); - res.end(responseBody); - } catch (err) { - log.error('Webhook handler error', { url: req.url, err }); - res.writeHead(500); - res.end('{"error":"internal"}'); - } - }); - } else { + 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: [...webhookAdapters.keys()] }); + log.info('Shared webhook server started', { port, paths: [...webhookRoutes.keys()] }); }); } } diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 73dc139..32cd8f4 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -197,7 +197,17 @@ export function insertRecurrence( db.prepare( `INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content) VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`, - ).run(newId, nextEvenSeq(db), msg.kind, nextRun, msg.recurrence, msg.platform_id, msg.channel_type, msg.thread_id, msg.content); + ).run( + newId, + nextEvenSeq(db), + msg.kind, + nextRun, + msg.recurrence, + msg.platform_id, + msg.channel_type, + msg.thread_id, + msg.content, + ); } export function clearRecurrence(db: Database.Database, messageId: string): void {