@yaebal/panel
framework-agnostic operator panel for Telegram bots. it records private-chat updates, shows a
polished browser inbox, and lets operators hand a conversation off from the bot, reply, edit or
delete a message, search across every chat, and export a conversation. the panel is just a fetch handler, so it mounts on any HTTP runtime or framework.
- cookie-session auth, multi-operator, with an audit trail on every panel-sent message
- handoff:
handoff(store)mutes the bot's own handlers once an operator marks a chat handled - reply / edit / delete any message, full-text search, one-click export
- chat status, unread counts, assign, pin, canned responses, typing indicator
- runtime-neutral
panelHandlerplus Nodeserve,sqliteandskladstore entries
installation
pnpm add @yaebal/panelyaebal setup
use the yaebal plugin recorder when the bot itself is yaebal. add recordOutgoing if
you want replies sent by normal handlers to appear in the panel too. open the panel and enter PANEL_TOKEN — the browser exchanges it once for an HttpOnly session
cookie, so the token itself never appears in a url.
import { Bot } from "@yaebal/core";
import { MemoryPanelStore, panelHandler, recordOutgoing, recorder } from "@yaebal/panel";
import { serve } from "@yaebal/panel/serve";
const bot = new Bot(process.env.BOT_TOKEN!);
const store = new MemoryPanelStore();
bot.install(recorder(store));
recordOutgoing(bot.api, store);
const handler = panelHandler(bot.api, store, {
token: process.env.PANEL_TOKEN!,
recordSends: false,
});
serve(handler, { port: 8080 });
bot.start();multiple operators + audit trail
pass operators instead of a single token to give each operator their
own login. every panel-sent message is recorded with PanelMessage.operator set to
whoever sent it, and assign(chatId, operator) tracks who owns a conversation.
const handler = panelHandler(bot.api, store, {
operators: [
{ name: "alice", token: process.env.ALICE_TOKEN! },
{ name: "bob", token: process.env.BOB_TOKEN! },
],
});handoff: let an operator take over from the bot
install handoff(store) before your reply handlers. once an operator marks a chat "handled" in the panel, the guard short-circuits the chain and the bot goes quiet
for that chat until it's released back to "open" (or archived).
import { handoff, recorder } from "@yaebal/panel";
bot.install(recorder(store));
bot.install(handoff(store)); // must come before your reply handlers
bot.on("message:text", (ctx) => ctx.reply("..."));framework-agnostic setup
for grammY, GramIO, puregram or anything else, keep your existing bot. pass raw Telegram
updates to recordTelegramUpdate, and use createPanelApi(token) for panel
sends, media proxying and operator uploads.
import {
MemoryPanelStore,
createPanelApi,
panelHandler,
recordTelegramUpdate,
} from "@yaebal/panel";
import { serve } from "@yaebal/panel/serve";
const store = new MemoryPanelStore();
const panelApi = createPanelApi(process.env.BOT_TOKEN!);
// call this from your framework middleware for every raw Telegram update
await recordTelegramUpdate(store, rawTelegramUpdate);
const handler = panelHandler(panelApi, store, { token: process.env.PANEL_TOKEN! });
serve(handler, { port: 8080 });grammY
import { Bot } from "grammy";
import { MemoryPanelStore, createPanelApi, panelHandler, recordTelegramUpdate } from "@yaebal/panel";
const bot = new Bot(process.env.BOT_TOKEN!);
const store = new MemoryPanelStore();
bot.use(async (ctx, next) => {
await recordTelegramUpdate(store, ctx.update);
await next();
});
const handler = panelHandler(createPanelApi(process.env.BOT_TOKEN!), store, {
token: process.env.PANEL_TOKEN!,
});GramIO
import { Bot } from "gramio";
import { MemoryPanelStore, createPanelApi, panelHandler, recordTelegramUpdate } from "@yaebal/panel";
const bot = new Bot(process.env.BOT_TOKEN!);
const store = new MemoryPanelStore();
bot.use(async (ctx, next) => {
await recordTelegramUpdate(store, ctx.update);
return next();
});
const handler = panelHandler(createPanelApi(process.env.BOT_TOKEN!), store, {
token: process.env.PANEL_TOKEN!,
});puregram
import { Telegram } from "puregram";
import { MemoryPanelStore, createPanelApi, panelHandler, recordTelegramUpdate } from "@yaebal/panel";
const telegram = new Telegram({ token: process.env.BOT_TOKEN! });
const store = new MemoryPanelStore();
telegram.updates.use(async (ctx, next) => {
await recordTelegramUpdate(store, ctx.update);
return next();
});
const handler = panelHandler(createPanelApi(process.env.BOT_TOKEN!), store, {
token: process.env.PANEL_TOKEN!,
});Other frameworks follow the same rule: if you can access the raw Telegram update, call recordTelegramUpdate(store, update). if you cannot, write directly to store.record() with PanelChatRecord and PanelMessage.
recording group/channel chats too
recorder/recordTelegramUpdate only log private chats
by default — the panel is an operator inbox for support-style conversations. pass chats to widen that.
bot.install(recorder(store, { chats: "all" }));
// or a predicate:
bot.install(recorder(store, { chats: (chat) => chat.type !== "channel" }));mounting
panelHandler returns (Request) => Promise<Response>. it binds no port,
so the same handler works on Node, Bun, Deno, edge runtimes and fetch-compatible frameworks. if
you mount on something other than @yaebal/panel/serve, make sure it streams Response bodies chunk-by-chunk — SSE requires it.
// node 20+, native node:http helper (streams SSE properly)
import { serve } from "@yaebal/panel/serve";
serve(handler, { port: 8080 });
// 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
export default { fetch: handler };options
panelHandler(api, store, {
token: process.env.PANEL_TOKEN!, // or `operators: [...]`
basePath: "/panel",
cors: "https://ops.example", // "*" disables session cookies in browsers
rateLimit: { max: 10, windowMs: 60_000 },
clientKey: (req) => req.headers.get("x-real-ip") ?? "shared",
trustProxy: false, // only set true behind a proxy you control
recordSends: true,
maxUploadBytes: 50 * 1024 * 1024,
sessionTtlMs: 12 * 60 * 60 * 1000,
cannedResponses: [{ label: "Hours", text: "We're open 9-5 UTC." }],
notifyChatId: process.env.ADMIN_CHAT_ID, // telegram DM'd when idle
onError: (error, context) => console.error(`panel:${context}`, error),
});basePath makes the SPA build API URLs under a prefix. rateLimit throttles
failed auth *guesses*; a request presenting no credential at all (e.g. an expired session's EventSource reconnecting) never counts against it, so a stale session can't lock an
operator out of logging back in. trustProxy defaults to false — only
trust x-forwarded-for/x-forwarded-proto behind a proxy you control. recordSends controls whether panel-originated sends are written to the store by the
handler itself.
persistence
MemoryPanelStore keeps up to 1000 messages per chat and is lost on restart. SqlitePanelStore uses Node's built-in node:sqlite, persists identity,
status, assignment, edits and deletions, and backs search() with FTS5 when available.
import { SqlitePanelStore } from "@yaebal/panel/sqlite";
const store = new SqlitePanelStore({ path: "./panel.db" }); // FTS5 search, Node 22.5+skladPanelStore bridges any @yaebal/sklad StorageAdapter —
Redis, Cloudflare KV, a flat file, or MemoryStorage — for edge runtimes or anywhere node:sqlite isn't available. it read-modifies-writes on every record, correct for a
single bot process, same as every other yaebal plugin's sklad integration.
import { MemoryStorage } from "@yaebal/sklad"; // or redisStorage/kvStorage/sqliteStorage
import { skladPanelStore } from "@yaebal/panel/sklad";
const store = skladPanelStore(new MemoryStorage()); // edge runtimes, no node:sqlite neededor implement PanelStore against your own database:
import type { PanelStore, PanelChat, PanelChatRecord, PanelMessage, HistoryOptions, PanelEvent } from "@yaebal/panel";
class MyPersistentStore implements PanelStore {
async record(chat: PanelChatRecord, message: PanelMessage) {
await db.messages.insert({ chatId: chat.id, ...chat, ...message });
}
async chats(options?: { status?: string; limit?: number; offset?: number }) {
return db.chats.findAll({ orderBy: "lastDate desc", ...options });
}
async history(chatId: number, opts?: HistoryOptions): Promise<PanelMessage[]> {
return db.messages.page({ chatId, before: opts?.before, beforeSeq: opts?.beforeSeq, limit: opts?.limit });
}
async status(chatId: number) { return (await db.chats.find(chatId))?.status; }
async setStatus(chatId: number, status: PanelChatStatus) { await db.chats.update(chatId, { status }); }
async assign(chatId: number, operator: string | null) { await db.chats.update(chatId, { assignedTo: operator }); }
async pin(chatId: number, pinned: boolean) { await db.chats.update(chatId, { pinned }); }
async markRead(chatId: number) { await db.chats.update(chatId, { unread: 0 }); }
async updateMessage(chatId: number, messageId: number, patch: MessagePatch) { await db.messages.patch(chatId, messageId, patch); }
async deleteChat(chatId: number) { await db.chats.delete(chatId); }
subscribe(listener: (e: PanelEvent) => void) {
return bus.on("panel-event", listener);
}
}what gets recorded
- private message text and captions (widen with
recorder(store, { chats })) - photos, videos, animations, audio, voice, video notes, documents, stickers and albums
- inline and reply keyboards attached to messages
- callback queries, message reactions, reaction counts, poll answers, member-status changes
- edits (
edited_message) patch the existing row instead of duplicating it - outgoing
send*results whenrecordOutgoingis installed, and every reply, edit or delete made from the panel, stamped with the sending operator's name
import { recordOutgoing } from "@yaebal/panel";
recordOutgoing(bot.api, store);
const handler = panelHandler(bot.api, store, {
token: process.env.PANEL_TOKEN!,
recordSends: false,
});media and UI
media bytes are proxied through GET /api/file?id=... (cookie-authenticated, no token
in the url), so the bot token never reaches the browser. operator uploads use sendPhoto, sendVideo, sendVoice, sendAudio or sendDocument based on MIME type, capped at maxUploadBytes. the UI renders
media previews in the sidebar, opens photos/videos in a viewer dialog, groups albums, and updates
the open conversation incrementally — new messages, edits and deletions patch in place without
ever wiping out an operator's unsent draft.
security model
- the shared secret is exchanged once at
POST /api/loginfor anHttpOnly,SameSite=Strictsession cookie — never placed in a url, including the SSE stream and media loads. X-Frame-Options: DENYandframe-ancestors 'none'block clickjacking; keyboard button urls are restricted tohttp(s):/tg:schemes, sincePanelStore.recordis a public interface any adapter can write to.- the inline
<script>still runs underscript-src 'unsafe-inline'— the single-file, zero-build-step UI is the point of this package. put the panel behind its own network-level auth (a VPN, an IP allowlist) if that tradeoff doesn't fit your threat model.
api routes
GET / -> login + chat SPA (public)
POST {base}/api/login -> { token } -> Set-Cookie session; { operator }
POST {base}/api/logout -> clear the session
GET {base}/api/session -> { operator } or 401
GET {base}/api/chats -> PanelChat[] (?status=&limit=&offset=)
GET {base}/api/chats/:id -> PanelMessage[] (?before=&beforeSeq=&limit=); marks read
GET {base}/api/chats/:id/export -> conversation dump (?format=json|text)
DELETE {base}/api/chats/:id -> delete the chat + its history
POST {base}/api/chats/:id/status -> { status: "open"|"handled"|"archived" }
POST {base}/api/chats/:id/assign -> { operator: string | null }
POST {base}/api/chats/:id/pin -> { pinned: boolean }
POST {base}/api/chats/:id/typing -> best-effort sendChatAction("typing")
POST {base}/api/chats/:id/send -> json { text, replyToId?, reply_markup?, ... } or multipart { file, caption?, type? }
POST {base}/api/chats/:id/messages/:msgId/edit -> { text } -> editMessageText
POST {base}/api/chats/:id/messages/:msgId/delete -> deleteMessage (soft-deletes in the store)
GET {base}/api/search -> ?q=&chatId=&limit= -> PanelSearchResult[]
GET {base}/api/canned -> configured canned-response templates
GET {base}/api/stream -> text/event-stream of store events
GET {base}/api/file?id=<file_id> -> proxied file bytesbreaking changes from 0.0.x
- auth: the panel authenticates via session cookie from
POST /api/login, not a?token=query parameter.tokenstill works for a single operator;operatorsis new. PanelStoregained required methods:status,setStatus,assign,pin,markRead,updateMessage,deleteChat, plus optionalsearch.chats()now takes options.HistoryOptionsgainedbeforeSeq— pass it alongsidebeforewhen paginating, or same-second messages can be dropped.PanelEventis now a discriminated union (record|status|read|chat|deleted).recordTelegramUpdate/recordertake an options bag ({ chats }).
api
| export | from | kind | description |
|---|---|---|---|
panelHandler(api, store, options) | @yaebal/panel | function | returns a (Request) => Promise<Response> handler |
createPanelApi(token) | @yaebal/panel | function | Bot API client satisfying PanelApi, including uploads and file URLs |
recordTelegramUpdate(store, update, options?) | @yaebal/panel | function | framework-neutral raw update recorder; { chats } scopes which chats to log |
recorder(store, options?) | @yaebal/panel | Plugin | YAEBAL middleware wrapper around recordTelegramUpdate |
handoff(store, options?) | @yaebal/panel | Plugin | suppresses the bot's own handlers on chats marked "handled" |
recordOutgoing(api, store, options?) | @yaebal/panel | function | logs successful outgoing send* results |
MemoryPanelStore | @yaebal/panel | class | in-memory PanelStore with SSE subscriptions |
SqlitePanelStore | @yaebal/panel/sqlite | class | persistent store on node:sqlite with FTS5 search; requires Node >= 22.5 |
skladPanelStore(adapter, options?) | @yaebal/panel/sklad | function | persistent store on any @yaebal/sklad StorageAdapter |
serve(handler, options) | @yaebal/panel/serve | function | native node:http server helper, streams SSE correctly |
PanelApi | @yaebal/panel | interface | sendMessage, optional call and fileUrl |
PanelStore | @yaebal/panel | interface | record, chats, history, status, setStatus, assign, pin, markRead, updateMessage, deleteChat, optional search/subscribe |
PanelChatRecord | @yaebal/panel | interface | chat id plus optional name, first name, last name and username |
PanelChat | @yaebal/panel | interface | identity, preview, status, unread, assignedTo, pinned |
PanelMessage | @yaebal/panel | interface | text, date, direction, seq, id, replyToId, operator, edited, deleted, attachments, media group, keyboard and event metadata |
PanelKeyboard | @yaebal/panel | interface | inline/reply keyboard preview rows |
PanelMessageEvent | @yaebal/panel | interface | callback, reaction, poll and member event metadata |
PanelEvent | @yaebal/panel | type | discriminated union of realtime store events |
node:sqlite, so @yaebal/panel/sqlite requires Node >= 22.5.