@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

terminal
pnpm add @yaebal/panel

quick start

three moving parts: a store, the recorder plugin, and the panel handler.

server.ts
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 screen

the 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.

mounting.ts
// 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

options.ts
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:

sqlite.ts
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:

store.ts
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:

outgoing.ts
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
// 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

exportfromkinddescription
panelHandler(api, store, options)@yaebal/panelfunctionreturns a (Request) => Promise<Response> handler
recorder(store)@yaebal/panelPluginlogs incoming private text/media into the store
recordOutgoing(api, store)@yaebal/panelfunctionlogs outgoing replies sent outside the panel (api after hook)
MemoryPanelStore@yaebal/panelclassin-memory PanelStore with subscribe
SqlitePanelStore@yaebal/panel/sqliteclasspersistent store on node:sqlite
serve(handler, options)@yaebal/panel/servefunctionnative node:http server, zero deps
PanelStore@yaebal/panelinterfacerecord / chats / history (+ optional subscribe)
PanelOptions@yaebal/panelinterfacetoken, basePath, cors, rateLimit, clientKey
HistoryOptions@yaebal/panelinterface{ before?: number; limit?: number }
PanelAttachment@yaebal/panelinterface{ type, fileId, fileName?, mimeType? }
PanelEvent@yaebal/panelinterface{ type: 'record'; chatId: number; direction }
PANEL_HTML@yaebal/panelstringthe raw HTML of the panel UI (exported for custom serving)
the panel HTML is a single self-contained page — no external assets, no CDN. it updates in realtime over SSE and falls back to polling. for a production deployment, put the panel behind a reverse proxy with TLS and restrict the token to a long random value.