refactor(v2): extract webhook server into standalone module

Aligns with upstream feat/chat-sdk-integration pattern: regex-based
routing (/webhook/{adapterName}), response streaming, cleanup function.
Updates Slack and Teams skill docs to match /webhook/{name} convention
used by all other v2 channel skills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-12 17:36:02 +03:00
parent 5a606a83d4
commit f0e4f07ac2
4 changed files with 142 additions and 94 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, WebhookEntry>();
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<Request> {
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<void> {
const responseHeaders: Record<string, string> = {};
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<string, (req: Request, opts?: any) => Promise<Response>>;
const handler = webhooks[entry.adapterName];
const webRes = await handler(webReq, {
waitUntil: (p: Promise<unknown>) => { 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()] });
});
}
}

134
src/webhook-server.ts Normal file
View File

@@ -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<string, WebhookEntry>();
let server: http.Server | null = null;
/** Convert Node.js IncomingMessage to a Web API Request. */
async function toWebRequest(req: http.IncomingMessage): Promise<Request> {
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<string, string> = {};
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<void> {
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<string, (r: Request, opts?: any) => Promise<Response>>;
const handler = webhooks[entry.adapterName];
const webRes = await handler(webReq, {
waitUntil: (p: Promise<unknown>) => {
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<void> {
if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
routes.clear();
log.info('Webhook server stopped');
}
}