@yaebal/callback-data

typed callback_data — pack and unpack button payloads with a field schema and full type inference.

install

terminal
pnpm add @yaebal/callback-data

usage

call callbackData(prefix, schema) once per namespace. the schema maps field names to one of three codecs: Number, String, or Boolean. types are inferred — pack and unpack both carry the exact shape.

bot.ts
import { Bot } from "@yaebal/core";
import { InlineKeyboard } from "@yaebal/keyboard";
import { callbackData } from "@yaebal/callback-data";

// define a namespace: prefix + field→codec schema
const userAction = callbackData("user", {
  id: Number,
  action: String,
  admin: Boolean,
});

const bot = new Bot(process.env.BOT_TOKEN!);

bot.command("manage", (ctx) => {
  const kb = new InlineKeyboard()
    .text("ban", userAction.pack({ id: 42, action: "ban", admin: false }))
    .text("promote", userAction.pack({ id: 42, action: "promote", admin: true }))
    .build();
  return ctx.reply("choose:", { reply_markup: kb });
});

// use the pattern to route only this namespace
bot.callbackQuery(userAction.pattern, (ctx) => {
  const payload = userAction.unpack(ctx.callbackQuery.data ?? "");
  if (!payload) return; // wrong namespace — shouldn't happen with pattern routing
  ctx.reply(`id=${payload.id} action=${payload.action} admin=${payload.admin}`);
});

routing with filter

use .filter() as a manual guard when you can't use .pattern:

filter.ts
// manual guard without pattern routing
bot.on("callback_query:data", (ctx) => {
  if (!userAction.filter(ctx.callbackQuery.data ?? "")) return;
  const payload = userAction.unpack(ctx.callbackQuery.data ?? "")!;
  // payload.id is number, payload.action is string, payload.admin is boolean
});

prefix-only schema

pass an empty schema {} for buttons that carry no payload:

prefix-only.ts
// a namespace with no fields — useful for simple action buttons
const ping = callbackData("ping", {});
ping.pack({});    // => "ping"
ping.unpack("ping"); // => {}
ping.filter("ping"); // => true

api

exportsignaturedescription
callbackData(prefix: string, schema: S) => CallbackData<S>creates a typed callback_data namespace
CallbackDatainterfacethe object returned by callbackData()

CallbackData<S>

membertypedescription
pack(data: Infer<S>) => stringserialize a payload into a callback_data string
unpack(raw: string) => Infer<S> | undefinedparse a raw string; returns undefined if prefix or arity doesn't match
filter(raw: string | undefined) => booleantrue if raw belongs to this namespace; safe to pass undefined
patternRegExpregex anchored to the prefix — pass to bot.callbackQuery()

codecs

codecTypeScript typedecode behaviour
Numbernumberparsed with Number(); non-numeric input decodes to NaN
StringstringURL-decoded verbatim
Booleanbooleanexact string "true"true; anything else → false
Telegram caps callback_data at 64 bytes. values are URL-encoded so the : separator is safe inside field values, but encoding expands size — keep prefixes and values short.

the prefix must not contain :callbackData throws immediately if it does.

codecs are not validated on unpack. this is intentional: you only ever unpack data you packed yourself, so malformed values are not a concern in normal use.