mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 13:34:24 +00:00
feat(platform): standalone HTTP Bot adapter (server-to-server) (#2274)
* docs(platform): add HTTP Bot adapter design (RFC)
Standalone server-to-server HTTP adapter for driving a pipeline from external
systems (LangBot Space ticketing et al). Inbound via the existing unified
webhook route; outbound via signed callback POSTs. Preserves pipeline-native
N->1 aggregation and 1->M multi-reply without a long-lived WebSocket.
No core changes required (router/aggregator/pipeline untouched).
* 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).
* docs(examples): add interactive HTTP Bot playground (browser debug console)
A single-file aiohttp web app (examples/http-bot/playground.py) that lets you
chat with a RUNNING http_bot bot from the browser and watch the protocol live:
signed inbound POST -> 202 ack -> 1->M signed callbacks streamed back via SSE,
with a debug panel showing the signature, HTTP status, and per-callback
sequence/verification. Light LangBot-styled UI.
On startup it reads the API key + http_bot bot from data/langbot.db and points
the bot's callback_url + secrets back at itself via the LangBot API (live
reload, no restart). README updated with a playground section.
* docs(examples): add Chinese README for http-bot reference clients
* style(platform): use </> code icon for http_bot adapter logo
* docs(examples): point http-bot guide links to docs.langbot.app
* style(platform): make http_bot icon a transparent monochrome </> so WebUI tints it like other adapters
* Revert to colorful </> badge for http_bot icon (WebUI renders it as-is)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# HTTP Bot Adapter — Reference Clients
|
||||
|
||||
> English | [中文](./README.zh.md)
|
||||
|
||||
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.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
|
||||
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `playground.py` | **Interactive browser debug console** — a single-file web app you open in a browser to chat with a running `http_bot` bot and watch signing / 202 / callbacks live. Zero extra deps. |
|
||||
| `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`). |
|
||||
|
||||
All three implement the identical HMAC-SHA256 scheme
|
||||
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte
|
||||
against the adapter.
|
||||
|
||||
## Interactive playground (recommended first run)
|
||||
|
||||
A self-contained web console: type a message in your browser, it is signed and
|
||||
POSTed to a **running** `http_bot` bot, and the bot's replies stream back into
|
||||
the page — with a debug panel showing the signature, the `202` ack, and each
|
||||
callback's `sequence` / signature-verification.
|
||||
|
||||
```bash
|
||||
# From the LangBot repo root, with the backend already running:
|
||||
PUBLIC_IP=<your-host-ip> ./.venv/bin/python examples/http-bot/playground.py
|
||||
# then open http://<your-host-ip>:8920/
|
||||
```
|
||||
|
||||
On startup it reads the LangBot API key + the `http_bot` bot from
|
||||
`data/langbot.db`, and configures that bot (inbound/outbound secret +
|
||||
`callback_url`) to point back at itself via the LangBot API — the bot reloads
|
||||
live, no restart needed. Requirements: an enabled `http_bot` bot bound to a
|
||||
working pipeline, and port `8920` reachable from your browser.
|
||||
|
||||
Env knobs: `PUBLIC_IP` (default `127.0.0.1`), `PLAYGROUND_PORT` (default `8920`).
|
||||
|
||||
## Headless clients
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# HTTP Bot 适配器 —— 参考客户端
|
||||
|
||||
> [English](./README.md) | 中文
|
||||
|
||||
面向 LangBot **HTTP Bot** 平台适配器的极简、低依赖客户端示例。
|
||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||
1→M 的多段回复。
|
||||
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
||||
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 是什么 |
|
||||
|---|---|
|
||||
| `playground.py` | **浏览器交互式调试台** —— 单文件 Web 应用,在浏览器里和一个运行中的 `http_bot` bot 对话,实时观察签名 / 202 / 回调。零额外依赖。 |
|
||||
| `client.py` | Python 客户端 + Flask 回调接收端(`pip install flask requests`)。 |
|
||||
| `client.ts` | TypeScript/Node 18+ 客户端 + 回调接收端,**零依赖**(`npx tsx client.ts`)。 |
|
||||
|
||||
三者实现完全一致的 HMAC-SHA256 签名方案
|
||||
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`)—— 已与适配器逐字节比对验证。
|
||||
|
||||
## 交互式 playground(推荐先跑这个)
|
||||
|
||||
一个自包含的 Web 控制台:在浏览器里输入消息,它会被签名并 POST 给一个
|
||||
**运行中**的 `http_bot` bot,bot 的回复会流式回到页面上 —— 调试面板会显示
|
||||
签名、`202` 确认,以及每条回调的 `sequence` / 签名验证结果。
|
||||
|
||||
```bash
|
||||
# 在 LangBot 仓库根目录、后端已启动的前提下:
|
||||
PUBLIC_IP=<你的主机IP> ./.venv/bin/python examples/http-bot/playground.py
|
||||
# 然后打开 http://<你的主机IP>:8920/
|
||||
```
|
||||
|
||||
启动时它会从 `data/langbot.db` 读取 LangBot API key 和 `http_bot` bot,
|
||||
并通过 LangBot API 把该 bot 配好(入站/出站密钥 + `callback_url`)指回自己 ——
|
||||
bot 会热加载,无需重启。前提:有一个已启用、绑定了可用 pipeline 的
|
||||
`http_bot` bot,且端口 `8920` 能从你的浏览器访问到。
|
||||
|
||||
可调环境变量:`PUBLIC_IP`(默认 `127.0.0.1`)、`PLAYGROUND_PORT`(默认 `8920`)。
|
||||
|
||||
## 无头客户端
|
||||
|
||||
```bash
|
||||
# Python —— 终端 1:回调接收端(你的 callback_url 指向它)
|
||||
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||
|
||||
# Python —— 终端 2:推送一条消息
|
||||
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||
|
||||
# 阻塞式同步模式
|
||||
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
|
||||
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||
|
||||
# 重置一个会话
|
||||
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"
|
||||
```
|
||||
|
||||
当 bot 回复时,接收端会逐条打印,带上各自的 `sequence`,并在最后一条标记
|
||||
`[FINAL]` —— 这就是 1→M 多段回复模型的实际效果。
|
||||
|
||||
> bot 的 `callback_url` 必须能从 LangBot 访问到。本地测试时,可用隧道
|
||||
> (cloudflared / ngrok)把你的接收端暴露出去,并把那个 URL 填进 bot 配置。
|
||||
@@ -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())
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LangBot HTTP Bot — interactive playground (public, browser-based).
|
||||
|
||||
This is a REAL end-to-end demo against the RUNNING LangBot instance on this
|
||||
host. It is NOT a mock and NOT an in-process import: every message you type in
|
||||
the browser is signed and POSTed to the live `http_bot` bot at
|
||||
http://127.0.0.1:5300/bots/<uuid>, and the bot's replies come back to this
|
||||
server's /callback endpoint over real HTTP, then stream to your browser via SSE.
|
||||
|
||||
What it does on startup:
|
||||
1. Reads the LangBot API key + the http_bot bot from data/langbot.db.
|
||||
2. Configures the bot via the LangBot API (PUT /api/v1/platform/bots/<uuid>):
|
||||
sets inbound_secret + outbound_secret + callback_url to point back here.
|
||||
(LangBot reloads the bot live — no server restart needed.)
|
||||
3. Serves a chat page on 0.0.0.0:<PORT> so you can open it from the internet.
|
||||
|
||||
Run: ./.venv/bin/python examples/http-bot/playground.py
|
||||
Then open: http://<this-host-public-ip>:<PORT>/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
sys.path.insert(0, os.path.join(REPO, 'src'))
|
||||
|
||||
from aiohttp import web # noqa: E402
|
||||
import aiohttp # noqa: E402
|
||||
|
||||
from langbot.pkg.platform.sources import http_bot_signing as sg # noqa: E402
|
||||
|
||||
# ---- config -----------------------------------------------------------------
|
||||
LANGBOT_BASE = 'http://127.0.0.1:5300'
|
||||
DB_PATH = os.path.join(REPO, 'data', 'langbot.db')
|
||||
PUBLIC_IP = os.environ.get('PUBLIC_IP', '127.0.0.1')
|
||||
PORT = int(os.environ.get('PLAYGROUND_PORT', '8920'))
|
||||
SECRET = 'playground-shared-secret'
|
||||
|
||||
# SSE subscribers: list of asyncio.Queue
|
||||
subscribers: list[asyncio.Queue] = []
|
||||
|
||||
|
||||
def db_lookup() -> tuple[str, str]:
|
||||
"""Return (api_key, http_bot_uuid) from the LangBot DB."""
|
||||
db = sqlite3.connect(DB_PATH)
|
||||
db.row_factory = sqlite3.Row
|
||||
api_key = db.execute('SELECT key FROM api_keys LIMIT 1').fetchone()['key']
|
||||
bot = db.execute("SELECT uuid FROM bots WHERE adapter='http_bot' LIMIT 1").fetchone()
|
||||
if not bot:
|
||||
raise SystemExit('No http_bot bot found. Create one in the WebUI first.')
|
||||
return api_key, bot['uuid']
|
||||
|
||||
|
||||
async def configure_bot(api_key: str, bot_uuid: str, callback_url: str):
|
||||
"""Point the live bot at this playground via the LangBot API.
|
||||
|
||||
update_bot() runs a raw SQL UPDATE with whatever keys we send, so we send a
|
||||
MINIMAL payload: only adapter_config (built from scratch, not read back —
|
||||
the GET masks secrets). LangBot reloads + reruns the bot live.
|
||||
"""
|
||||
cfg = {
|
||||
'inbound_secret': SECRET,
|
||||
'outbound_secret': SECRET,
|
||||
'callback_url': callback_url,
|
||||
'signature_required': True,
|
||||
'default_session_type': 'person',
|
||||
'callback_timeout': 15,
|
||||
'callback_max_retries': 3,
|
||||
}
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.put(
|
||||
f'{LANGBOT_BASE}/api/v1/platform/bots/{bot_uuid}',
|
||||
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
|
||||
json={'adapter_config': cfg},
|
||||
) as r:
|
||||
txt = await r.text()
|
||||
print(f'[configure] PUT adapter_config -> {r.status} {txt[:200]}')
|
||||
return r.status < 400
|
||||
|
||||
|
||||
async def broadcast(event: dict):
|
||||
for q in list(subscribers):
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- HTTP handlers ----------------------------------------------------------
|
||||
async def index(request: web.Request):
|
||||
return web.Response(text=PAGE, content_type='text/html')
|
||||
|
||||
|
||||
async def send(request: web.Request):
|
||||
"""Browser -> here -> signed POST -> live LangBot bot."""
|
||||
body_in = await request.json()
|
||||
session_id = body_in.get('session_id') or 'playground-1'
|
||||
text = body_in.get('text', '')
|
||||
bot_uuid = request.app['bot_uuid']
|
||||
|
||||
payload = {
|
||||
'session_id': session_id,
|
||||
'sender': {'id': 'browser-user', 'name': 'You'},
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
raw = json.dumps(payload, ensure_ascii=False).encode()
|
||||
ts, sig = sg.sign(SECRET, raw)
|
||||
url = f'{LANGBOT_BASE}/bots/{bot_uuid}'
|
||||
|
||||
# echo what we send to the browser timeline
|
||||
await broadcast(
|
||||
{'dir': 'out', 'kind': 'request', 'session_id': session_id, 'text': text, 'url': url, 'sig': sig[:24] + '…'}
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.post(
|
||||
url,
|
||||
data=raw,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
sg.HEADER_TIMESTAMP: ts,
|
||||
sg.HEADER_SIGNATURE: sig,
|
||||
},
|
||||
) as r:
|
||||
status = r.status
|
||||
try:
|
||||
jr = await r.json()
|
||||
except Exception:
|
||||
jr = {'raw': await r.text()}
|
||||
await broadcast({'dir': 'in', 'kind': 'ack', 'status': status, 'data': jr})
|
||||
return web.json_response({'status': status, 'data': jr})
|
||||
|
||||
|
||||
async def callback(request: web.Request):
|
||||
"""Live LangBot bot -> here. Verify signature, stream to browser."""
|
||||
raw = await request.read()
|
||||
ok, why = sg.verify(SECRET, raw, request.headers.get(sg.HEADER_TIMESTAMP), request.headers.get(sg.HEADER_SIGNATURE))
|
||||
data = json.loads(raw)
|
||||
text = ' '.join(c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain')
|
||||
await broadcast(
|
||||
{
|
||||
'dir': 'in',
|
||||
'kind': 'reply',
|
||||
'session_id': data.get('session_id'),
|
||||
'sequence': data.get('sequence'),
|
||||
'is_final': data.get('is_final'),
|
||||
'sig_ok': ok,
|
||||
'sig_why': why,
|
||||
'text': text,
|
||||
}
|
||||
)
|
||||
return web.json_response({'ok': True})
|
||||
|
||||
|
||||
async def events(request: web.Request):
|
||||
"""SSE stream to the browser."""
|
||||
resp = web.StreamResponse(
|
||||
headers={
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}
|
||||
)
|
||||
await resp.prepare(request)
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
subscribers.append(q)
|
||||
try:
|
||||
await resp.write(b': connected\n\n')
|
||||
while True:
|
||||
try:
|
||||
ev = await asyncio.wait_for(q.get(), timeout=15)
|
||||
await resp.write(f'data: {json.dumps(ev, ensure_ascii=False)}\n\n'.encode())
|
||||
except asyncio.TimeoutError:
|
||||
await resp.write(b': ping\n\n')
|
||||
except (asyncio.CancelledError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
if q in subscribers:
|
||||
subscribers.remove(q)
|
||||
return resp
|
||||
|
||||
|
||||
PAGE = r"""<!doctype html>
|
||||
<html lang="zh"><head><meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>LangBot HTTP Bot · 调试台</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f7f8fa; --panel:#ffffff; --line:#e8eaed; --ink:#1f2329; --mut:#8a909a;
|
||||
--brand:#2563eb; --brand-soft:#eef3ff; --ok:#16a34a; --bad:#dc2626; --code:#f3f4f6;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||
.top{height:52px;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
display:flex;align-items:center;gap:10px;padding:0 18px}
|
||||
.logo{width:26px;height:26px;border-radius:7px;background:var(--brand);display:grid;place-items:center;color:#fff;font-weight:700;font-size:14px}
|
||||
.top b{font-size:15px} .top .ver{font-size:12px;color:var(--mut)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:#cbd2dc;display:inline-block;margin-right:5px;vertical-align:middle}
|
||||
.dot.on{background:var(--ok)} .dot.off{background:var(--bad)}
|
||||
.conn{margin-left:auto;font-size:12px;color:var(--mut)}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:18px;display:grid;grid-template-columns:1fr 360px;gap:16px}
|
||||
@media(max-width:880px){.wrap{grid-template-columns:1fr}}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;min-height:0}
|
||||
.card h3{margin:0;padding:12px 16px;font-size:13px;font-weight:600;color:#4b5563;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
|
||||
.chat{height:62vh}
|
||||
.msgs{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
|
||||
.row{display:flex;flex-direction:column;gap:4px;max-width:82%}
|
||||
.row.me{align-self:flex-end;align-items:flex-end}
|
||||
.row.bot{align-self:flex-start}
|
||||
.bub{padding:9px 13px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
|
||||
.me .bub{background:var(--brand);color:#fff;border-bottom-right-radius:3px}
|
||||
.bot .bub{background:#f1f3f6;color:var(--ink);border-bottom-left-radius:3px}
|
||||
.meta{font-size:11px;color:var(--mut)}
|
||||
.meta .ok{color:var(--ok)} .meta .bad{color:var(--bad)}
|
||||
.sys{align-self:center;font-size:12px;color:var(--mut);background:#f1f3f6;border-radius:8px;padding:4px 12px}
|
||||
.bar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
|
||||
.bar input{flex:1;border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:14px;outline:none}
|
||||
.bar input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-soft)}
|
||||
.bar button{background:var(--brand);color:#fff;border:0;border-radius:9px;padding:0 18px;font-size:14px;font-weight:500;cursor:pointer}
|
||||
.bar button:disabled{opacity:.5;cursor:default}
|
||||
.side{height:62vh}
|
||||
.kv{padding:12px 16px;border-bottom:1px solid var(--line);font-size:12px}
|
||||
.kv .k{color:var(--mut)} .kv .v{color:var(--ink);word-break:break-all}
|
||||
.kv code{background:var(--code);border-radius:5px;padding:1px 5px;font-size:11px}
|
||||
.sessrow{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line);font-size:12px}
|
||||
.sessrow input{flex:1;border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
|
||||
.sessrow button{border:1px solid var(--line);background:#fff;border-radius:7px;padding:5px 9px;font-size:12px;cursor:pointer;color:#4b5563}
|
||||
.trace{flex:1;overflow:auto;padding:10px 12px;font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.ev{padding:6px 8px;border-radius:7px;margin-bottom:6px;border:1px solid var(--line)}
|
||||
.ev .t{font-weight:600;font-size:10px;letter-spacing:.3px;text-transform:uppercase}
|
||||
.ev.out{background:#f5f8ff;border-color:#dbe6ff}.ev.out .t{color:var(--brand)}
|
||||
.ev.ack{background:#f4f6f8}.ev.ack .t{color:#6b7280}
|
||||
.ev.reply{background:#f1faf3;border-color:#cdeed6}.ev.reply .t{color:var(--ok)}
|
||||
.ev pre{margin:3px 0 0;white-space:pre-wrap;word-break:break-all;color:#374151}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="logo">L</div>
|
||||
<b>HTTP Bot 调试台</b><span class="ver">examples/http-bot</span>
|
||||
<span class="conn"><span class="dot off" id="cdot"></span><span id="conn">连接中…</span></span>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<!-- chat -->
|
||||
<div class="card chat">
|
||||
<h3>对话 · 真实发往运行中的 http_bot</h3>
|
||||
<div class="msgs" id="msgs"></div>
|
||||
<div class="bar">
|
||||
<input id="msg" placeholder="输入消息,回车发送…" autofocus/>
|
||||
<button id="send">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- debug -->
|
||||
<div class="card side">
|
||||
<h3>调试信息</h3>
|
||||
<div class="kv"><span class="k">入站地址</span><br><span class="v"><code id="endpoint">/bots/<uuid></code></span></div>
|
||||
<div class="kv"><span class="k">签名</span> <span class="v">HMAC-SHA256 · <code>X-LB-Signature</code></span></div>
|
||||
<div class="sessrow">
|
||||
<span class="k">会话</span>
|
||||
<input id="sid" value="playground-1"/>
|
||||
<button id="reset">新会话</button>
|
||||
</div>
|
||||
<div class="trace" id="trace"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
const msgs=$('#msgs'),trace=$('#trace'),inp=$('#msg'),btn=$('#send'),
|
||||
conn=$('#conn'),cdot=$('#cdot'),sidIn=$('#sid');
|
||||
function el(c){const d=document.createElement('div');d.className=c;return d}
|
||||
function atBottom(n){n.scrollTop=n.scrollHeight}
|
||||
function bubble(side,text,metaHtml){
|
||||
const r=el('row '+side),b=el('bub');b.textContent=text;r.appendChild(b);
|
||||
if(metaHtml){const m=el('meta');m.innerHTML=metaHtml;r.appendChild(m)}
|
||||
msgs.appendChild(r);atBottom(msgs)}
|
||||
function sys(t){const d=el('sys');d.textContent=t;msgs.appendChild(d);atBottom(msgs)}
|
||||
function logEv(kind,title,obj){
|
||||
const e=el('ev '+kind),t=el('t');t.textContent=title;e.appendChild(t);
|
||||
if(obj!==undefined){const p=document.createElement('pre');
|
||||
p.textContent=typeof obj==='string'?obj:JSON.stringify(obj,null,2);e.appendChild(p)}
|
||||
trace.appendChild(e);atBottom(trace)}
|
||||
|
||||
const es=new EventSource('/events');
|
||||
es.onopen=()=>{conn.textContent='SSE 已连接';cdot.className='dot on'};
|
||||
es.onerror=()=>{conn.textContent='SSE 断开,重连…';cdot.className='dot off'};
|
||||
es.onmessage=e=>{const ev=JSON.parse(e.data);
|
||||
if(ev.kind==='request'){
|
||||
if(ev.endpoint)$('#endpoint').textContent=ev.url||ev.endpoint;
|
||||
logEv('out','出站 · 已签名 POST',{url:ev.url,session_id:ev.session_id,'X-LB-Signature':ev.sig});
|
||||
}else if(ev.kind==='ack'){
|
||||
const id=ev.data&&ev.data.data&&ev.data.data.accepted_message_id;
|
||||
sys(`LangBot 已接收 · HTTP ${ev.status}`);
|
||||
logEv('ack','入站确认 202',{status:ev.status,accepted_message_id:id||'-'});
|
||||
}else if(ev.kind==='reply'){
|
||||
const sig=ev.sig_ok?'<span class=ok>验签通过</span>':'<span class=bad>验签失败</span>';
|
||||
bubble('bot',ev.text,`seq=${ev.sequence} · ${ev.is_final?'<b>FINAL</b>':'中间段'} · ${sig}`);
|
||||
logEv('reply',`回调 · seq ${ev.sequence}${ev.is_final?' · FINAL':''}`,
|
||||
{session_id:ev.session_id,sequence:ev.sequence,is_final:ev.is_final,sig_ok:ev.sig_ok,text:ev.text});
|
||||
}};
|
||||
|
||||
async function send(){
|
||||
const t=inp.value.trim();if(!t)return;inp.value='';btn.disabled=true;
|
||||
bubble('me',t,'已签名 → POST /bots/<uuid>');
|
||||
try{await fetch('/send',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({session_id:sidIn.value.trim()||'playground-1',text:t})});}
|
||||
catch(e){sys('发送失败:'+e)}
|
||||
btn.disabled=false;inp.focus();}
|
||||
btn.onclick=send;inp.addEventListener('keydown',e=>{if(e.key==='Enter')send()});
|
||||
$('#reset').onclick=()=>{sidIn.value='playground-'+Math.random().toString(36).slice(2,7);
|
||||
sys('已切换到新会话 '+sidIn.value);};
|
||||
sys('调试台就绪 · 每条消息都会真实发往运行中的 http_bot,右侧可观察签名 / 202 / 回调全过程。');
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
async def main():
|
||||
api_key, bot_uuid = db_lookup()
|
||||
callback_url = f'http://{PUBLIC_IP}:{PORT}/callback'
|
||||
print(f'[init] http_bot uuid = {bot_uuid}')
|
||||
print(f'[init] callback_url = {callback_url}')
|
||||
ok = await configure_bot(api_key, bot_uuid, callback_url)
|
||||
if not ok:
|
||||
print('[warn] bot config update failed; check the API key / payload shape')
|
||||
|
||||
app = web.Application()
|
||||
app['bot_uuid'] = bot_uuid
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_post('/send', send)
|
||||
app.router.add_post('/callback', callback)
|
||||
app.router.add_get('/events', events)
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, '0.0.0.0', PORT)
|
||||
await site.start()
|
||||
print(f'\n ▶ 打开: http://{PUBLIC_IP}:{PORT}/\n')
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user