@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 panelHandler plus Node serve, sqlite and sklad store entries
upgrading from 0.0.x? this release breaks the api — see breaking changes below.

installation

terminal
pnpm add @yaebal/panel

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

yaebal-panel.ts
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.

multi-operator.ts
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).

handoff.ts
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.

panel.ts
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

grammy.ts
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

gramio.ts
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

puregram.ts
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.

scoping.ts
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.

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

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

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

sklad-store.ts
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 needed

or implement PanelStore against your own database:

store.ts
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 when recordOutgoing is installed, and every reply, edit or delete made from the panel, stamped with the sending operator's name
outgoing.ts
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/login for an HttpOnly, SameSite=Strict session cookie — never placed in a url, including the SSE stream and media loads.
  • X-Frame-Options: DENY and frame-ancestors 'none' block clickjacking; keyboard button urls are restricted to http(s):/tg: schemes, since PanelStore.record is a public interface any adapter can write to.
  • the inline <script> still runs under script-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

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 bytes

breaking changes from 0.0.x

  • auth: the panel authenticates via session cookie from POST /api/login, not a ?token= query parameter. token still works for a single operator; operators is new.
  • PanelStore gained required methods: status, setStatus, assign, pin, markRead, updateMessage, deleteChat, plus optional search. chats() now takes options.
  • HistoryOptions gained beforeSeq — pass it alongside before when paginating, or same-second messages can be dropped.
  • PanelEvent is now a discriminated union (record | status | read | chat | deleted).
  • recordTelegramUpdate/recorder take an options bag ({ chats }).

api

exportfromkinddescription
panelHandler(api, store, options)@yaebal/panelfunctionreturns a (Request) => Promise<Response> handler
createPanelApi(token)@yaebal/panelfunctionBot API client satisfying PanelApi, including uploads and file URLs
recordTelegramUpdate(store, update, options?)@yaebal/panelfunctionframework-neutral raw update recorder; { chats } scopes which chats to log
recorder(store, options?)@yaebal/panelPluginYAEBAL middleware wrapper around recordTelegramUpdate
handoff(store, options?)@yaebal/panelPluginsuppresses the bot's own handlers on chats marked "handled"
recordOutgoing(api, store, options?)@yaebal/panelfunctionlogs successful outgoing send* results
MemoryPanelStore@yaebal/panelclassin-memory PanelStore with SSE subscriptions
SqlitePanelStore@yaebal/panel/sqliteclasspersistent store on node:sqlite with FTS5 search; requires Node >= 22.5
skladPanelStore(adapter, options?)@yaebal/panel/skladfunctionpersistent store on any @yaebal/sklad StorageAdapter
serve(handler, options)@yaebal/panel/servefunctionnative node:http server helper, streams SSE correctly
PanelApi@yaebal/panelinterfacesendMessage, optional call and fileUrl
PanelStore@yaebal/panelinterfacerecord, chats, history, status, setStatus, assign, pin, markRead, updateMessage, deleteChat, optional search/subscribe
PanelChatRecord@yaebal/panelinterfacechat id plus optional name, first name, last name and username
PanelChat@yaebal/panelinterfaceidentity, preview, status, unread, assignedTo, pinned
PanelMessage@yaebal/panelinterfacetext, date, direction, seq, id, replyToId, operator, edited, deleted, attachments, media group, keyboard and event metadata
PanelKeyboard@yaebal/panelinterfaceinline/reply keyboard preview rows
PanelMessageEvent@yaebal/panelinterfacecallback, reaction, poll and member event metadata
PanelEvent@yaebal/paneltypediscriminated union of realtime store events
the panel HTML is a single self-contained page with inline SVG icons and no external assets. for production, serve it behind TLS and use long random operator tokens. SQLite uses Node's built-in node:sqlite, so @yaebal/panel/sqlite requires Node >= 22.5.