@yaebal/panel
an operator panel: view incoming private-chat messages and reply from the browser, live. ships as
a self-contained fetch handler — mount it on any http framework. the recorder() plugin feeds a store; panelHandler() serves the login + chat UI and a small REST API.
- login page on the panel root — paste your token, no secrets in the url
- realtime updates over server-sent events, with a polling safety net
- media: photos, docs, voice, video and albums — both directions, in the browser
- persistence via a pluggable
PanelStore(in-memory + sqlite included) - CORS, basePath mounting and failed-auth rate limiting
installation
pnpm add @yaebal/panelquick start
three moving parts: a store, the recorder plugin, and the panel handler.
import { Bot } from "@yaebal/core";
import { MemoryPanelStore, recorder, panelHandler } from "@yaebal/panel";
import { serve } from "@yaebal/panel/serve";
const bot = new Bot(token);
const store = new MemoryPanelStore();
// 1. attach the recorder plugin — logs incoming private messages to the store
bot.install(recorder(store));
bot.start();
// 2. serve the panel — a fetch handler: (Request) => Promise<Response>
const handler = panelHandler(bot.api, store, { token: process.env.PANEL_TOKEN! });
serve(handler, { port: 8080 });
// open http://localhost:8080 and paste your token on the login screenthe panel root serves a small SPA: a centered login (token input + authorize button), then the live chat view. the token is kept in sessionStorage and sent as an Authorization: Bearer header — it never rides in the page url.
mounting
panelHandler returns a plain (Request) => Promise<Response> — it
binds no port of its own. serve is a separate entry (@yaebal/panel/serve)
so the main module stays free of node: imports for edge bundles.
// node 20+ — `serve` ships in the box (native node:http, no deps)
import { serve } from "@yaebal/panel/serve";
serve(handler, { port: 8080, onListen: ({ port }) => console.log(`panel on :${port}`) });
// bun
Bun.serve({ port: 8080, fetch: handler });
// deno
Deno.serve({ port: 8080 }, handler);
// hono / any fetch framework — pair with basePath: "/panel"
app.all("/panel/*", (c) => handler(c.req.raw));
// cloudflare workers / deno deploy / vercel edge — same handler, no port
export default { fetch: handler };options
panelHandler(bot.api, store, {
token: process.env.PANEL_TOKEN!, // required shared secret
basePath: "/panel", // mount under a sub-path (default: root)
cors: "https://ops.example", // allow a browser origin (or a list, or "*")
rateLimit: { max: 10, windowMs: 60_000 }, // throttle failed auth (default); false to disable
});basePath — the UI builds its api urls from this, so no extra rewriting is needed under
a prefix. rateLimit — after max bad tokens within windowMs a
client gets 429 with Retry-After; keyed by x-forwarded-for / x-real-ip by default, override with clientKey.
persistence
MemoryPanelStore is the default — up to 1000 messages per chat, lost on restart. a
sqlite-backed store built on node's native node:sqlite ships in the box:
import { SqlitePanelStore } from "@yaebal/panel/sqlite";
// persistent store on node's native node:sqlite — zero extra deps
const store = new SqlitePanelStore({ path: "./panel.db" }); // or ":memory:"
bot.install(recorder(store));
const handler = panelHandler(bot.api, store, { token: process.env.PANEL_TOKEN! });or implement PanelStore against your own database:
import type { PanelStore, PanelChat, PanelMessage, HistoryOptions, PanelEvent } from "@yaebal/panel";
// implement PanelStore for persistence (redis, postgres, …)
class MyPersistentStore implements PanelStore {
async record(chat: { id: number; name?: string }, message: PanelMessage) {
await db.messages.insert({ chatId: chat.id, ...message });
}
async chats(): Promise<PanelChat[]> {
return db.chats.findAll({ orderBy: "lastDate desc" });
}
async history(chatId: number, opts?: HistoryOptions): Promise<PanelMessage[]> {
return db.messages.page({ chatId, before: opts?.before, limit: opts?.limit });
}
// optional — enables the realtime SSE stream; omit it and the UI just polls
subscribe(listener: (e: PanelEvent) => void) {
return bus.on("record", listener); // returns an unsubscribe fn
}
}subscribe is what powers the SSE stream — a store without it still works, the UI just
falls back to polling every few seconds.
what the recorder captures
incoming private-chat messages only. text and captions are stored verbatim; a message
with no text is logged as a [photo] / [document] / [voice] /
… placeholder so the conversation stays readable. group messages are passed through unchanged.
replies sent from the panel are recorded with direction: "out" and accept text plus optional parse_mode, reply_to_message_id and reply_parameters, forwarded to sendMessage.
to also capture replies the bot sends outside the panel, hook the api with recordOutgoing and disable the panel's own send-recording to avoid duplicates:
import { recordOutgoing } from "@yaebal/panel";
// recorder only sees incoming updates. to also log replies the bot sends elsewhere
// (e.g. ctx.reply(...) in your handlers), hook the api — and stop the panel from
// recording its own sends so they aren't logged twice:
recordOutgoing(bot.api, store);
const handler = panelHandler(bot.api, store, {
token: process.env.PANEL_TOKEN!,
recordSends: false,
});media
photos, documents, voice notes, video and albums flow both ways. the recorder
stores each attachment's file_id (and album id); the browser renders them inline —
images, <video>, <audio>, or a download link for documents —
and consecutive messages sharing a media_group_id show as one album. the 📎 button in the composer uploads a file, and the panel picks sendPhoto / sendVideo / sendVoice / sendDocument from its mime type.
media bytes are proxied through GET /api/file?id=… (the panel calls getFile and streams the result) so the bot token never reaches the browser. this needs
an api with call() / fileUrl() — the real @yaebal/core Api
has both; without them, media routes answer 501 and text still works.
api routes
// routes are relative to basePath (default root). all but the page require the token.
GET / → login + chat SPA (public)
GET /api/chats → PanelChat[] (sorted by lastDate desc)
GET /api/chats/:id → PanelMessage[] (?before=&limit= to page)
GET /api/stream → text/event-stream of record events
GET /api/file?id=<file_id> → proxied file bytes (getFile + stream)
POST /api/chats/:id/send → json { text, … } → sendMessage
multipart { file, caption? } → sendPhoto / sendDocument / …api
| export | from | kind | description |
|---|---|---|---|
panelHandler(api, store, options) | @yaebal/panel | function | returns a (Request) => Promise<Response> handler |
recorder(store) | @yaebal/panel | Plugin | logs incoming private text/media into the store |
recordOutgoing(api, store) | @yaebal/panel | function | logs outgoing replies sent outside the panel (api after hook) |
MemoryPanelStore | @yaebal/panel | class | in-memory PanelStore with subscribe |
SqlitePanelStore | @yaebal/panel/sqlite | class | persistent store on node:sqlite |
serve(handler, options) | @yaebal/panel/serve | function | native node:http server, zero deps |
PanelStore | @yaebal/panel | interface | record / chats / history (+ optional subscribe) |
PanelOptions | @yaebal/panel | interface | token, basePath, cors, rateLimit, clientKey |
HistoryOptions | @yaebal/panel | interface | { before?: number; limit?: number } |
PanelAttachment | @yaebal/panel | interface | { type, fileId, fileName?, mimeType? } |
PanelEvent | @yaebal/panel | interface | { type: 'record'; chatId: number; direction } |
PANEL_HTML | @yaebal/panel | string | the raw HTML of the panel UI (exported for custom serving) |
token to a long random value.