refactor(v2): use Chat SDK webhooks proxy and clean up webhook server
Route webhook requests through chat.webhooks[name]() instead of calling adapter.handleWebhook() directly, getting proper auto-initialization and signature verification. Extract Node↔Web Request/Response conversion into reusable helpers, parse URL pathname properly for query string safety, and support all HTTP methods (not just POST). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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
|
## Next Steps
|
||||||
|
|
||||||
If you're in the middle of `/setup`, return to the setup flow now.
|
If you're in the middle of `/setup`, return to the setup flow now.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Next Steps
|
||||||
|
|
||||||
If you're in the middle of `/setup`, return to the setup flow now.
|
If you're in the middle of `/setup`, return to the setup flow now.
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
} else {
|
} else {
|
||||||
// Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server
|
// Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server
|
||||||
const webhookPath = `/api/webhooks/${adapter.name}`;
|
const webhookPath = `/api/webhooks/${adapter.name}`;
|
||||||
registerWebhookAdapter(webhookPath, adapter);
|
registerWebhookAdapter(webhookPath, chat, adapter.name);
|
||||||
log.info('Webhook adapter registered', { adapter: adapter.name, path: webhookPath });
|
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).
|
* 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<string, Adapter>();
|
interface WebhookEntry {
|
||||||
|
chat: Chat;
|
||||||
|
adapterName: string;
|
||||||
|
}
|
||||||
|
const webhookRoutes = new Map<string, WebhookEntry>();
|
||||||
let sharedWebhookServer: http.Server | null = null;
|
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 {
|
/** Convert a Node http.IncomingMessage into a Web API Request. */
|
||||||
webhookAdapters.set(path, adapter);
|
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) {
|
if (!sharedWebhookServer) {
|
||||||
const port = parseInt(process.env.WEBHOOK_PORT || '3000', 10);
|
const port = parseInt(process.env.WEBHOOK_PORT || '3000', 10);
|
||||||
sharedWebhookServer = http.createServer((req, res) => {
|
sharedWebhookServer = http.createServer(async (req, res) => {
|
||||||
const matchedAdapter = req.url ? webhookAdapters.get(req.url) : undefined;
|
const pathname = new URL(req.url || '/', URL_BASE).pathname;
|
||||||
if (req.method === 'POST' && matchedAdapter) {
|
const entry = webhookRoutes.get(pathname);
|
||||||
const chunks: Buffer[] = [];
|
if (!entry) {
|
||||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
req.on('end', async () => {
|
|
||||||
try {
|
|
||||||
const body = Buffer.concat(chunks).toString();
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
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<unknown>) => { p.catch(() => {}); },
|
|
||||||
});
|
|
||||||
const responseBody = await response.text();
|
|
||||||
const responseHeaders: Record<string, string> = { '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 {
|
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end('Not found');
|
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', () => {
|
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()] });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,17 @@ export function insertRecurrence(
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content)
|
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content)
|
||||||
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?)`,
|
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 {
|
export function clearRecurrence(db: Database.Database, messageId: string): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user