feat(platform): add standalone HTTP Bot adapter

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).
This commit is contained in:
RockChinQ
2026-06-21 21:45:37 -04:00
parent 87f4b9c838
commit c85b9401b8
10 changed files with 1615 additions and 22 deletions
+51
View File
@@ -0,0 +1,51 @@
# HTTP Bot Adapter — Reference Clients
Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
They show the whole loop: signing a request, pushing a message, and receiving
multi-part replies on a callback endpoint.
Full guide: [`docs/platforms/http-bot.md`](../../docs/platforms/http-bot.md).
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
## Files
| File | What it is |
|---|---|
| `client.py` | Python client + Flask callback receiver (`pip install flask requests`). |
| `client.ts` | TypeScript/Node 18+ client + callback receiver, **zero deps** (`npx tsx client.ts`). |
Both implement the identical HMAC-SHA256 scheme
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte
against the adapter.
## Quickstart
```bash
# Python — Terminal 1: callback receiver (your callback_url target)
python client.py serve --port 8900 --secret SHARED_SECRET
# Python — Terminal 2: push a message
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1 --text "hello"
# blocking sync mode
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1 --text "hello"
# reset a session
python client.py reset --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1
```
```bash
# TypeScript (Node 18+)
npx tsx client.ts serve 8900 SHARED_SECRET
npx tsx client.ts push https://your-langbot/bots/<BOT_UUID> SHARED_SECRET ticket-1 "hello"
```
When the bot replies, the receiver prints each part with its `sequence` and an
`[FINAL]` marker on the last one — that's the 1→M multi-reply model in action.
> The bot's `callback_url` must be reachable from LangBot. For local testing,
> expose your receiver with a tunnel (cloudflared / ngrok) and set that URL in
> the bot config.
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""LangBot HTTP Bot adapter — reference client (Python).
Two things in one file:
1. ``push()`` / ``push_sync()`` — send a message into a LangBot ``http_bot`` bot.
2. A tiny Flask callback receiver that verifies signatures and prints replies,
so you can watch N->1 aggregation and 1->M multi-reply working live.
Usage
-----
pip install flask requests
# Terminal 1 — start the callback receiver (this is your callback_url):
python client.py serve --port 8900 --secret SHARED_SECRET
# Terminal 2 — push a message (async; reply lands on the receiver):
python client.py push \
--url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET \
--session ticket-10293 \
--text "Export keeps failing on the dashboard."
# Or push and block for the collapsed reply (sync convenience mode):
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-10293 --text "hi"
The signing scheme is HMAC-SHA256 over ``"{timestamp}." + raw_body``; see
``sign()`` below — it is intentionally tiny and easy to port.
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import json
import sys
import time
import uuid
HEADER_TIMESTAMP = 'X-LB-Timestamp'
HEADER_SIGNATURE = 'X-LB-Signature'
HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key'
REPLAY_WINDOW = 300
def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]:
"""Return (timestamp, signature) for *body*."""
ts = str(timestamp if timestamp is not None else int(time.time()))
mac = hmac.new(secret.encode(), f'{ts}.'.encode() + body, hashlib.sha256)
return ts, 'sha256=' + mac.hexdigest()
def verify(secret: str, body: bytes, timestamp: str | None, signature: str | None) -> bool:
"""Verify an inbound signature (used by the callback receiver)."""
if not timestamp or not signature:
return False
try:
if abs(int(time.time()) - int(float(timestamp))) > REPLAY_WINDOW:
return False
except ValueError:
return False
_, expected = sign(secret, body, int(float(timestamp)))
return hmac.compare_digest(expected, signature)
def _post(url: str, secret: str, payload: dict, idempotency: bool = True):
import requests
body = json.dumps(payload, ensure_ascii=False).encode()
ts, sig = sign(secret, body)
headers = {
'Content-Type': 'application/json',
HEADER_TIMESTAMP: ts,
HEADER_SIGNATURE: sig,
}
if idempotency:
headers[HEADER_IDEMPOTENCY] = uuid.uuid4().hex
resp = requests.post(url, data=body, headers=headers, timeout=30)
print(f'-> {resp.status_code} {resp.text}')
return resp
def push(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
"""Fire-and-collect: returns 202 immediately; reply arrives on your callback."""
payload = {
'session_id': session,
'session_type': session_type,
'message': [{'type': 'Plain', 'text': text}],
}
return _post(url.rstrip('/'), secret, payload)
def push_sync(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
"""Blocking convenience: POST to /sync and get the collapsed reply back."""
payload = {
'session_id': session,
'session_type': session_type,
'message': [{'type': 'Plain', 'text': text}],
}
resp = _post(url.rstrip('/') + '/sync', secret, payload, idempotency=False)
return resp
def reset(url: str, secret: str, session: str, session_type: str = 'person'):
"""Reset a session's conversation (next message starts fresh)."""
payload = {'session_id': session, 'session_type': session_type}
return _post(url.rstrip('/') + '/reset', secret, payload, idempotency=False)
def serve(port: int, secret: str):
"""Run a callback receiver that verifies signatures and prints replies."""
from flask import Flask, request
app = Flask(__name__)
@app.route('/', methods=['POST'])
def recv():
raw = request.get_data()
ok = verify(secret, raw, request.headers.get(HEADER_TIMESTAMP), request.headers.get(HEADER_SIGNATURE))
if not ok:
print('!! signature verification FAILED — rejecting')
return {'error': 'bad signature'}, 401
data = json.loads(raw)
text_parts = [c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain']
marker = 'FINAL' if data.get('is_final') else 'part '
print(
f'[{marker}] session={data["session_id"]} seq={data["sequence"]} '
f'reply_to={data.get("reply_to")}: {" ".join(text_parts)}'
)
return {'ok': True}
print(f'callback receiver listening on http://0.0.0.0:{port}/ (Ctrl-C to stop)')
app.run(host='0.0.0.0', port=port)
def main(argv=None):
p = argparse.ArgumentParser(description='LangBot HTTP Bot reference client')
sub = p.add_subparsers(dest='cmd', required=True)
sp = sub.add_parser('serve', help='run the callback receiver')
sp.add_argument('--port', type=int, default=8900)
sp.add_argument('--secret', required=True)
for name in ('push', 'sync', 'reset'):
c = sub.add_parser(name)
c.add_argument('--url', required=True, help='https://host/bots/<BOT_UUID>')
c.add_argument('--secret', required=True)
c.add_argument('--session', required=True)
c.add_argument('--session-type', default='person', choices=['person', 'group'])
if name != 'reset':
c.add_argument('--text', required=True)
args = p.parse_args(argv)
if args.cmd == 'serve':
serve(args.port, args.secret)
elif args.cmd == 'push':
push(args.url, args.secret, args.session, args.text, args.session_type)
elif args.cmd == 'sync':
push_sync(args.url, args.secret, args.session, args.text, args.session_type)
elif args.cmd == 'reset':
reset(args.url, args.secret, args.session, args.session_type)
if __name__ == '__main__':
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
/**
* 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);
}