/** * Composable settings menu — registers a single slash command, renders * an `InlineKeyboard`, routes callbacks. Features (language, history, * etc.) contribute items; the bot builder adds custom items inline. * * ## Why menu is a separate primitive * * The user-facing menu is just UI — composing items. Features (per-user * state, recording, gates) have their OWN runtime behaviour independent * of any menu. * * ## Privacy & data submenu * * The menu always renders a single `🛡️ Privacy & data` button at root * which navigates to a virtual submenu containing the privacy policy * link plus (when `personalData: { storage }` is passed): * * - 🗑 Forget my data — `storage.delete(sessionKey(userId))` * - 📥 Export my data — `storage.get(sessionKey(userId))` → JSON file * - 📖 Privacy policy — URL from `privacy` (defaults to Telegram's) * * Keeping these one tap away avoids cluttering the root view with * destructive / informational buttons that the user only needs rarely. * * Because all per-user state across our plugins lives in ONE shared * session record (see `bot/language`, `bot/llm`'s `llmHistory`), wiping * or exporting that single key covers everything in one shot. No * registry, no cascade, no per-plugin coordination. * * The `sessionKey` option defaults to `String(userId)` — matching * `@gramio/session`'s default `getSessionKey: (ctx) => `${ctx.senderId}`. * If you customize the session's `getSessionKey`, pass a matching * function here. * * ## Privacy URL * * `privacy` defaults to [Telegram's Standard Bot Privacy Policy](https://telegram.org/privacy-tpa) * which covers everything the plugins in this package retain * (language preference, access state, threaded LLM conversation * history — Telegram designed Threaded Mode explicitly for AI * chatbots with multi-turn memory). Override only if your bot retains * data beyond what the plugins do. * * Peer deps: `gramio`, `@gramio/storage`. * * @example Personal LLM bot — language only, no retention * * import { language } from '@adriangalilea/utils/bot/language' * import { botMenu } from '@adriangalilea/utils/bot/menu' * * const lang = language({ session: userSession, supported: ['en','es'] as const, default: 'en' }) * * const menu = botMenu({ * command: 'settings', * description: 'Open settings', * adminContact: '@adriangalilea', * items: [lang.menuItem], * }) * * bot.extend(userSession).extend(lang.plugin).extend(menu.plugin) * * @example Bot with retention — adds Forget/Export * * const menu = botMenu({ * command: 'settings', * description: 'Open settings', * adminContact: '@adriangalilea', * privacy: 'https://yourbot.com/privacy', * personalData: { storage }, // ← enables Forget/Export * items: [lang.menuItem], * }) * * bot * .extend(userSession) * .extend(history.plugin) * .extend(menu.plugin) */ import type { Storage } from "@gramio/storage"; import { CallbackData, Plugin } from "gramio"; import { type Polyglot } from "../say/index.js"; /** * Shape of the `ctx` an action / label / predicate sees. * * - Static fields come from the dispatching gramio ctx — a * `MessageContext` on the initial `/settings` render, a * `CallbackQueryContext` on every tap. The two SPELL the chat * differently: `chat` exists only on message ctxs; callback ctxs * carry `chatId` and `message.chat` instead, so `ctx.chat` is * `undefined` inside every action. Read the chat through * `chatIdOf` / `isGroupChat` from `bot/groups` (they resolve both * spellings), never `ctx.chat` directly. * - Common reply methods (`send`, `reply`, `answer`, `editText`) are * declared as optional so action callbacks can call them without * `as unknown as` casts. They exist at runtime on every callback ctx * gramio dispatches. * - Plugin-decorated fields (`ctx.llm`, `ctx.say`, `ctx.lang`, …) * live on the real gramio ctx but aren't declared here — narrow * them via a local type assertion where you use them. The menu * stays plugin-agnostic. */ export type MenuCtx = { bot: unknown; from?: { id: number; languageCode?: string; }; chat?: { id: number; type: string; }; chatId?: number; session?: { language?: string; }; threadId?: number; message?: { threadId?: number; chat?: { id?: number; type?: string; }; }; send?: (text: string | { toString(): string; }, params?: object) => Promise; reply?: (text: string | { toString(): string; }, params?: object) => Promise; answer?: (params: object) => Promise; delete?: () => Promise; editText?: (text: string, params?: object) => Promise; }; /** * Anything a button or header can render as. Authoring a polyglot * label is just an inline `{ en, es }` literal — `say()` resolves it * to the recipient's language at render time. * * label: 'Static' * label: { en: 'Settings', es: 'Ajustes' } * label: (ctx) => `Hi ${ctx.from?.firstName}` * label: (ctx) => ({ en: `Hi ${name}`, es: `Hola ${name}` }) * label: async (ctx) => `📊 ${await store.readCount(ctx)} left` * * Resolvers may be ASYNC (labels, headers, styles, visibility): the menu * awaits them at render time, so state can come straight from a database — * never cache render strings into the session to satisfy a sync signature. */ type Label = string | Polyglot | ((ctx: MenuCtx) => string | Polyglot | Promise>); type Predicate = (ctx: MenuCtx) => boolean | Promise; /** * What a menu action's return value means: * * - `undefined` / `void` — menu sends an empty `answerCallbackQuery` * (just clears the loading spinner). * - `string` — menu sends `answerCallbackQuery({ text })`. The string * pops as a toast on top of the chat. * - `Polyglot` — menu resolves at `ctx.session?.language` and * sends as toast. * * DO NOT call `ctx.answer(...)` from inside an action — Telegram * rejects the second answer ("query is too old"), the action throws, * and `refresh: true` never runs. Return the toast instead; the menu * sends the single answer. */ export type ActionResult = undefined | string | Polyglot; type Action = (ctx: MenuCtx) => Promise | ActionResult; /** * Telegram's inline-keyboard-button colour modes * ([Bot API InlineKeyboardButton.style](https://core.telegram.org/bots/api#inlinekeyboardbutton)): * * - `primary` — blue, "selected / active / default action" * - `success` — green, "positive / approve / confirm" * - `danger` — red, "destructive / reject / forget" * * On clients that don't render the style (older Telegram releases), * the button falls back to the app-default look — no breakage. Use * `style` instead of emoji markers (●/○) to mark active state, which * is consistent with how Telegram itself surfaces selection state. */ export type ButtonStyle = "primary" | "success" | "danger"; type StyleResolver = ButtonStyle | ((ctx: MenuCtx) => ButtonStyle | undefined | Promise); /** * An entry in the menu. Three variants: * * - `action` — callback button; taps run `action(ctx)`. If * `refresh: true`, the current menu message * re-renders right after — useful for selections / * toggles whose visible state depends on what the * action mutated (typical: `style` based on session). * - `url` — link button (Telegram opens it externally). * - `submenu` — nested item tree, navigated to via callback. * * `style` (`primary` / `success` / `danger`) maps to Telegram's * coloured inline-button modes; the resolver form `(ctx) => style` * is for state-dependent colouring (e.g. blue on the currently-selected * language). * * **Live state inside resolvers run by `refresh: true`.** Label / * style resolvers fire AFTER the action mutated the session. Read * mutable state from `ctx.session.` directly — *not* from * derives that snapshot at event start (e.g. `ctx.lang` from * `bot/language` is the event-start value, NOT the post-mutation * one). `ctx.say(...)` IS live and safe to use anywhere. * * **`id` must not contain `.`.** Submenu paths are dot-joined into * callback_data (`parent.child.grandchild`), so a dot in an id * collides with the path separator and the route resolver returns * "Item not found" with no logging. Use `_` instead. The plugin * validates this at registration; misformatted ids panic the build * rather than silently mis-routing. */ export type MenuItem = { id: string; label: Label; action: Action; order?: number; visible?: Predicate; style?: StyleResolver; /** Render on the same row as the following item (no row break after this button). */ keepRow?: boolean; /** Render at the bottom of the root menu, below the Privacy & data button. */ rootExtra?: boolean; /** Re-render the menu message after the action runs. */ refresh?: boolean; /** * Renders the button greyed-out and non-tappable (Bot API 10.3 * DisabledButton — a tap does nothing, no callback fires). A disabled * button cannot explain itself, so pair it with header/body copy saying * WHY and how to unlock. The action still needs its own guard: clients * predating the field render a normal tappable button. Clients also do * not DRAW the disabled state yet (checked Aug 2026: Telegram Desktop * renders it identically), so give the locked state a label marker too * (🔒 via a label resolver) — a dead-but-normal-looking button reads as * a lagging bot. */ disabled?: Predicate; /** * Adds a one-step confirmation before the action runs. First tap * edits the menu message in place to show `prompt` + "Confirm" / * "Cancel" buttons; the action only runs on Confirm. * * Use this for destructive actions instead of * `ctx.answer({ show_alert: true })` — Telegram's alert UI is * disruptive and doesn't compose with refresh / toast. * * After Confirm runs the action, the menu navigates back to * root (so the user lands in a known-good state). */ confirm?: { /** Body text rendered above the Confirm/Cancel buttons. */ prompt: Label; /** Override "✅ Confirm" label. Default: polyglot en/es. */ confirmLabel?: Label; /** Override "⬅️ Cancel" label. Default: polyglot en/es. */ cancelLabel?: Label; }; } | { id: string; label: Label; url: string; order?: number; visible?: Predicate; style?: StyleResolver; /** Render on the same row as the following item (no row break after this button). */ keepRow?: boolean; /** Render at the bottom of the root menu, below the Privacy & data button. */ rootExtra?: boolean; } | { id: string; label: Label; submenu: MenuItem[]; order?: number; visible?: Predicate; style?: StyleResolver; /** Render on the same row as the following item (no row break after this button). */ keepRow?: boolean; /** Render at the bottom of the root menu, below the Privacy & data button. */ rootExtra?: boolean; /** * Message text shown while INSIDE this submenu (an explainer, a legend), * replacing the menu's root header there. Falls back to the root header * when unset. Deepest header along the path wins on nested submenus. */ header?: Label; }; export type PersonalDataOptions = { /** * Storage backend where each user's data lives. Must be the SAME * instance you passed to your `session(...)` (or `botSession(...)`) * plugin — that's how /forget and /export reach the right keys. * * The storage key for the calling user is derived as * `bot-:` via `botStorageKey(ctx, userId)`, matching * the namespace `botSession` uses by default. No `sessionKey` * override exists because every plugin in this package shares the * same key shape by construction. */ storage: Storage; /** * Forget must actually forget. The storage delete above only wipes the * SESSION record — if your bot keeps per-user state anywhere else * (message logs, metrics tables, credit rows), wipe it HERE. Runs after * the session delete, inside the same try: a failing onForget reports * "Failed" + your admin contact to the user instead of lying about a * partial erasure. */ onForget?: (ctx: MenuCtx, userId: number) => void | Promise; }; export type BotMenuOptions = { /** Slash command that opens the menu. Default `'settings'`. */ command?: string; /** Description shown in Telegram's command list. */ description?: string; /** Items rendered top-down (sorted by `order`, then registration). */ items?: MenuItem[]; /** * URL to your privacy policy. Defaults to Telegram's Standard Bot * Privacy Policy. Override when you retain content or process data * beyond what the standard covers. */ privacy?: string; /** * Header text rendered above the keyboard. May be an ASYNC resolver — * read your database here instead of caching render strings in the * session. Rendered with `parseMode` when set. */ header?: Label; /** * Contact the user can reach when something fails (export error, * etc.). **Required** — a bot that asks users to trust it with * data must always offer a human to talk to when the automated * paths fail. */ adminContact: string; /** * Enables 🗑 Forget my data and 📥 Export my data buttons inside * the `🛡️ Privacy & data` submenu. Pass the storage instance * backing your `session()`. If omitted, the submenu still appears * but only shows the privacy policy link (use this for bots with * no per-user state beyond what Telegram's standard policy covers). */ personalData?: PersonalDataOptions; /** * Parse mode for the HEADER text (`"HTML"` or `"MarkdownV2"`). Off by * default (plain text). With it set, your header owns escaping — user * content interpolated into an HTML header must be entity-escaped by you. * Button labels are never parsed (Telegram renders them plain). */ parseMode?: "HTML" | "MarkdownV2"; /** * Allow the menu to open and operate in group chats. Off by default: with * `personalData` set the menu is private-only (data controls shouldn't surface * in a group). Turn on when the menu hosts a group-scoped control (e.g. a * per-group toggle) that must be reachable from inside the group. */ allowInGroups?: boolean; /** * Delete the user's `/command` invocation message after the menu opens, when this * predicate answers true (e.g. "in groups where tidy-mode is on and the bot holds * the Delete-messages right"). Best-effort: a failed delete never blocks the menu. */ deleteInvocation?: (ctx: MenuCtx) => boolean | Promise; }; /** * Telegram's own policy for third-party bots: the default behind the * menu's 🔒 Privacy & data button, and behind `bot/payments`' * `legal.privacyUrl`. Both surfaces show the same text, so they read * the same constant. */ export declare const DEFAULT_PRIVACY_URL = "https://telegram.org/privacy-tpa"; /** * Callback data for navigating between menu levels. Exported so peer * plugins (e.g. `bot/payments`'s `require()` upgrade prompt) can pack * a "jump to /settings → " button without duplicating the schema * name/fields. Routes through the handler registered below. * * menuNavCb.pack({ path: 'pay' }) → goes to `pay` submenu * menuNavCb.pack({ path: '_root' }) → goes back to root * menuNavCb.pack({ path: 'pay.history' }) → nested path */ export declare const menuNavCb: CallbackData<{ path: string; }, { path: string; }>; type ResolvedPersonalData = { storage: Storage; onForget: ((ctx: MenuCtx, userId: number) => void | Promise) | null; }; type ResolvedOpts = { command: string; description: string; privacy: string; header: Label; adminContact: string; personalData: ResolvedPersonalData | null; allowInGroups: boolean; deleteInvocation: ((ctx: MenuCtx) => boolean | Promise) | null; parseMode: "HTML" | "MarkdownV2" | null; }; export declare class BotMenu { /** @internal */ readonly _items: MenuItem[]; /** @internal */ readonly _opts: ResolvedOpts; constructor(opts: BotMenuOptions); /** Append a custom item. Mutates the menu. */ add(item: MenuItem): this; /** The gramio plugin: registers the slash command + all callback handlers. */ get plugin(): Plugin<{}, import("gramio").DeriveDefinitions, {}>; } export declare const botMenu: (opts: BotMenuOptions) => BotMenu; export type ToggleMenuItemOptions = { /** Item id within the menu. Must be unique among siblings. */ id: string; /** * Reads the current boolean value. Typically `(ctx) => * ctx.session?.someField ?? false`. Storage-agnostic — return * `false` by default so the toggle starts in the OFF state. */ read: (ctx: MenuCtx) => boolean | Promise; /** * Persists the new value. Typically `(ctx, v) => { * (ctx.session as any).someField = v }`. The menu plugin does NOT * own a session — write through whatever your bot already uses. */ write: (ctx: MenuCtx, value: boolean) => void | Promise; /** * Button labels for each state. Polyglot literals resolve against * `ctx.session?.language` (set by `bot/language`); strings render * as-is. Use functions for runtime composition (e.g. emoji ✓/✗ + * dynamic name). */ label: { off: Label; on: Label; }; /** * Optional toast shown via `ctx.answer({ text })` after a tap. Same * polyglot resolution as `label`. Omit to stay silent. */ toast?: { off?: Label; on?: Label; }; order?: number; visible?: Predicate; }; /** * Convenience factory for a boolean-toggle `MenuItem`. The label * tracks `read(ctx)` and the action flips it through `write(ctx, v)`. * Storage is the caller's concern — pass closures that read/write the * field wherever you keep it (typically `ctx.session.something`). * * The current menu message is NOT auto-re-rendered after a tap — the * new label is visible the next time the user re-opens or navigates. * The optional `toast` gives immediate feedback in the meantime. * * @example * toggleMenuItem({ * id: 'thinking', * read: (ctx) => (ctx.session as { thinking?: boolean }).thinking ?? false, * write: (ctx, v) => { (ctx.session as { thinking?: boolean }).thinking = v }, * label: { * off: { en: '💭 Thinking: OFF', es: '💭 Razonamiento: OFF' }, * on: { en: '💭 Thinking: ON', es: '💭 Razonamiento: ON' }, * }, * toast: { * off: { en: 'Thinking off.', es: 'Razonamiento off.' }, * on: { en: 'Thinking on.', es: 'Razonamiento on.' }, * }, * }) */ export declare const toggleMenuItem: (opts: ToggleMenuItemOptions) => MenuItem; export type RadioMenuItemOptions = { /** Item id within the menu. Must be unique among siblings; choice ids are the values. */ id: string; /** The parent row's label — typically a resolver showing the CURRENT choice. */ label: Label; /** * Explainer/legend shown as the message text while inside the submenu * (the per-submenu header). This is where each choice — or each emoji a * choice implies — gets its one-line explanation. */ header?: Label; choices: ReadonlyArray<{ value: V; label: Label; }>; isActive: (ctx: MenuCtx, value: V) => boolean | Promise; /** Persists the pick and returns the toast (gate refusals inside it — the * menu owns the single answerCallbackQuery, never call ctx.answer). */ pick: (ctx: MenuCtx, value: V) => ActionResult | Promise; /** Grey out a choice per-context (Bot API 10.3 disabled button) — a value whose * prerequisites aren't met. Pair with `header` copy explaining why (a disabled * button never fires, so it cannot explain itself), and STILL refuse the value * inside `pick`: clients predating the field render a normal tappable button. */ disabledWhen?: (ctx: MenuCtx, value: V) => boolean | Promise; order?: number; visible?: Predicate; rootExtra?: boolean; }; /** * ONE setting, N mutually-exclusive values — the control that kills both the * cycling toggle (state you can only discover by tapping) and paired * mutually-exclusive toggles (whack-a-mole). Renders as a submenu with one * button per value, the chosen one wearing Telegram's `success` fill (green = * chosen value; `primary` blue stays the navigation channel), re-rendered in * place on every pick. Storage- and policy-agnostic like the language picker: * what "active" means and what a pick does live in the caller's closures. */ export declare const radioMenuItem: (opts: RadioMenuItemOptions) => MenuItem; export {}; //# sourceMappingURL=menu.d.ts.map