From 4618b451846f5aa01318bfc6e045f1940016df37 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 21 Jun 2026 23:05:00 -0400 Subject: [PATCH] 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. --- examples/http-bot/README.md | 26 ++- examples/http-bot/playground.py | 349 ++++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 examples/http-bot/playground.py diff --git a/examples/http-bot/README.md b/examples/http-bot/README.md index 1b3909b43..36bcf9612 100644 --- a/examples/http-bot/README.md +++ b/examples/http-bot/README.md @@ -11,14 +11,36 @@ Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-op | 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`). | -Both implement the identical HMAC-SHA256 scheme +All three implement the identical HMAC-SHA256 scheme (`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte against the adapter. -## Quickstart +## 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= ./.venv/bin/python examples/http-bot/playground.py +# then open http://: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) diff --git a/examples/http-bot/playground.py b/examples/http-bot/playground.py new file mode 100644 index 000000000..ea77d26b9 --- /dev/null +++ b/examples/http-bot/playground.py @@ -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/, 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/): + 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: so you can open it from the internet. + +Run: ./.venv/bin/python examples/http-bot/playground.py +Then open: http://:/ +""" + +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""" + + +LangBot HTTP Bot · 调试台 + + +
+ + HTTP Bot 调试台examples/http-bot + 连接中… +
+
+ +
+

对话 · 真实发往运行中的 http_bot

+
+
+ + +
+
+ +
+

调试信息

+
入站地址
/bots/<uuid>
+
签名 HMAC-SHA256 · X-LB-Signature
+
+ 会话 + + +
+
+
+
+ +""" + + +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())