mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 13:34:24 +00:00
c85b9401b8
A first-class, vendor-neutral message-platform adapter (http_bot) for
server-to-server integrations (LangBot Space ticketing et al). Drives a
pipeline over plain HTTP with no long-lived connection:
- Inbound: signed POST to the existing unified webhook route /bots/<uuid>,
carrying a caller-defined session_id mapped to the LangBot launcher id via
get_launcher_id -> per-session isolation. Preserves pipeline-native N->1
aggregation for free.
- Outbound: each reply_message / reply_message_chunk becomes one signed
callback POST to the config-only callback_url, delivered in per-session
sequence order with retry/backoff -> 1->M multi-reply.
- Sub-paths: /reset (drop a session) and /sync (block for the collapsed reply).
- Auth: symmetric HMAC-SHA256 both directions (timestamp + replay window),
no JWT/Turnstile, no socket.
Decisions: callback URL is config-only (SSRF closed); reset + sync shipped;
Python + TS reference clients shipped (signing verified byte-identical 3-way).
No core changes: the unified webhook router, aggregator, query pool and
pipeline are untouched. Adapter is auto-discovered from platform/sources/.
Adds:
src/langbot/pkg/platform/sources/http_bot.{py,yaml,svg}
src/langbot/pkg/platform/sources/http_bot_signing.py
docs/platforms/http-bot.md, docs/http-bot-openapi.json
examples/http-bot/{client.py,client.ts,README.md}
Updates docs/HTTP_BOT_ADAPTER_DESIGN.md (status: implemented).
124 lines
5.4 KiB
TypeScript
124 lines
5.4 KiB
TypeScript
/**
|
|
* LangBot HTTP Bot adapter — reference client (TypeScript / Node 18+).
|
|
*
|
|
* Zero runtime dependencies (uses global `fetch`, `crypto`, and `http`).
|
|
*
|
|
* - `push()` : fire-and-collect; reply lands on your callback URL.
|
|
* - `pushSync()` : POST /sync and await the collapsed reply.
|
|
* - `reset()` : reset a session's conversation.
|
|
* - `startReceiver()` : a callback server that verifies signatures and logs
|
|
* replies, so you can watch N->1 and 1->M live.
|
|
*
|
|
* Run the demos:
|
|
* npx tsx client.ts serve 8900 SHARED_SECRET
|
|
* npx tsx client.ts push https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
|
|
* npx tsx client.ts sync https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
|
|
* npx tsx client.ts reset https://host/bots/<UUID> SHARED_SECRET ticket-1
|
|
*/
|
|
|
|
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
import { createServer } from 'node:http';
|
|
|
|
const HEADER_TIMESTAMP = 'X-LB-Timestamp';
|
|
const HEADER_SIGNATURE = 'X-LB-Signature';
|
|
const HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key';
|
|
const REPLAY_WINDOW = 300;
|
|
|
|
/** Compute the `sha256=<hex>` signature over `"{ts}." + body`. */
|
|
export function sign(secret: string, body: Buffer | string, timestamp?: number): [string, string] {
|
|
const ts = String(timestamp ?? Math.floor(Date.now() / 1000));
|
|
const buf = typeof body === 'string' ? Buffer.from(body) : body;
|
|
const mac = createHmac('sha256', secret).update(Buffer.concat([Buffer.from(`${ts}.`), buf])).digest('hex');
|
|
return [ts, `sha256=${mac}`];
|
|
}
|
|
|
|
/** Verify an inbound signature (used by the callback receiver). */
|
|
export function verify(secret: string, body: Buffer, timestamp?: string, signature?: string): boolean {
|
|
if (!timestamp || !signature) return false;
|
|
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > REPLAY_WINDOW) return false;
|
|
const [, expected] = sign(secret, body, Number(timestamp));
|
|
const a = Buffer.from(expected);
|
|
const b = Buffer.from(signature);
|
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
}
|
|
|
|
interface Segment { type: string; text?: string; url?: string; [k: string]: unknown }
|
|
|
|
async function post(url: string, secret: string, payload: object, idempotency = true) {
|
|
const body = Buffer.from(JSON.stringify(payload));
|
|
const [ts, sig] = sign(secret, body);
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
[HEADER_TIMESTAMP]: ts,
|
|
[HEADER_SIGNATURE]: sig,
|
|
};
|
|
if (idempotency) headers[HEADER_IDEMPOTENCY] = randomUUID();
|
|
const resp = await fetch(url, { method: 'POST', headers, body });
|
|
const text = await resp.text();
|
|
console.log(`-> ${resp.status} ${text}`);
|
|
return { status: resp.status, text };
|
|
}
|
|
|
|
/** Fire-and-collect: 202 now, reply later on your callback URL. */
|
|
export function push(url: string, secret: string, session: string, text: string, sessionType = 'person') {
|
|
return post(url.replace(/\/$/, ''), secret, {
|
|
session_id: session,
|
|
session_type: sessionType,
|
|
message: [{ type: 'Plain', text }] as Segment[],
|
|
});
|
|
}
|
|
|
|
/** Blocking convenience: POST /sync, get the collapsed reply. */
|
|
export function pushSync(url: string, secret: string, session: string, text: string, sessionType = 'person') {
|
|
return post(`${url.replace(/\/$/, '')}/sync`, secret, {
|
|
session_id: session,
|
|
session_type: sessionType,
|
|
message: [{ type: 'Plain', text }] as Segment[],
|
|
}, false);
|
|
}
|
|
|
|
/** Reset a session's conversation. */
|
|
export function reset(url: string, secret: string, session: string, sessionType = 'person') {
|
|
return post(`${url.replace(/\/$/, '')}/reset`, secret, { session_id: session, session_type: sessionType }, false);
|
|
}
|
|
|
|
/** Run a callback receiver that verifies signatures and prints replies. */
|
|
export function startReceiver(port: number, secret: string) {
|
|
const server = createServer((req, res) => {
|
|
if (req.method !== 'POST') { res.writeHead(405).end(); return; }
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
const raw = Buffer.concat(chunks);
|
|
const ok = verify(secret, raw, req.headers[HEADER_TIMESTAMP.toLowerCase()] as string,
|
|
req.headers[HEADER_SIGNATURE.toLowerCase()] as string);
|
|
if (!ok) {
|
|
console.log('!! signature verification FAILED — rejecting');
|
|
res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'bad signature' }));
|
|
return;
|
|
}
|
|
const data = JSON.parse(raw.toString());
|
|
const parts = (data.message as Segment[]).filter((c) => c.type === 'Plain').map((c) => c.text).join(' ');
|
|
const marker = data.is_final ? 'FINAL' : 'part ';
|
|
console.log(`[${marker}] session=${data.session_id} seq=${data.sequence} reply_to=${data.reply_to}: ${parts}`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
|
});
|
|
});
|
|
server.listen(port, () => console.log(`callback receiver listening on http://0.0.0.0:${port}/ (Ctrl-C to stop)`));
|
|
}
|
|
|
|
// --- CLI ---
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
if (cmd === 'serve') {
|
|
startReceiver(Number(rest[0] ?? 8900), rest[1] ?? 'SHARED_SECRET');
|
|
} else if (cmd === 'push') {
|
|
push(rest[0], rest[1], rest[2], rest[3]);
|
|
} else if (cmd === 'sync') {
|
|
pushSync(rest[0], rest[1], rest[2], rest[3]);
|
|
} else if (cmd === 'reset') {
|
|
reset(rest[0], rest[1], rest[2]);
|
|
} else if (cmd) {
|
|
console.error(`unknown command: ${cmd}`);
|
|
process.exit(1);
|
|
}
|