@yaebal/filters

composable, type-narrowing update filters (the mtcute idea, made two-phase) for the core composer.filter(...) method. filters are plain predicates — sync or async — that may stage typed data; combine them with and / or / not and the additions flow through, for any number of filters.

install

terminal
pnpm add @yaebal/filters

the filter() method

filter() lives in core. a filter stages extra fields in a bag instead of touching the context; the bag is committed onto ctx only after the whole filter tree matched. a failing and branch or a matching filter inside not can never leak or corrupt anything — and because commit is centralized, filters can be async.

filter.ts
// composer.filter(filter, ...handlers) — runs handlers only when the filter matches.
// additions flow into the handler type, so everything a filter stages is typed:
bot.filter(command("add"), (ctx) => {
  ctx.command; // string
  ctx.args;    // string[]
  ctx.payload; // string — the raw text after the command
});

// any bare predicate is already a filter — async included:
bot.filter(async (ctx) => await isAllowed(ctx.from?.id), handler);

usage

bot.ts
import { and, or, not, command, regex, deeplink, isPrivate, photo, video, fromUser } from "@yaebal/filters";

bot.filter(and(isPrivate, command("buy")), (ctx) => ctx.args);   // ctx.command, ctx.args, ctx.payload
bot.filter(regex(/^\d+$/), (ctx) => ctx.match[0]);              // ctx.match: RegExpMatchArray
bot.filter(deeplink(/^ref_(\d+)$/), (ctx) => ctx.match[1]);     // t.me/bot?start=ref_42
bot.filter(or(photo, video), (ctx) => ctx.message);              // narrowed message
bot.filter(not(fromUser(BANNED_ID)), handler);
playground
import { Composer, createBot, filters } from "yaebal";

const support = new Composer()
  .filter(filters.regex(/^ticket (.+)$/i), (ctx) =>
    ctx.reply(`ticket created: ${ctx.match[1]}`),
  );

const bot = createBot(process.env.BOT_TOKEN!)
  .extend(support)
  .command("help", (ctx) => ctx.reply("send: ticket <subject>"));

bot.start();

commands and deep links

command() follows the same routing rules as composer.command() — fresh messages only (an edited /cmd doesn't re-fire), message text only (a caption is not a command), /cmd@other_bot is skipped when ctx.me is known — and adds multiple names, regex names and custom prefixes on top.

commands.ts
import { command } from "@yaebal/filters";

command("start");                             // /start, /START (case-insensitive)
command(["stop", "halt"]);                    // any of the names
command(/set_(\d+)/);                         // regex name — groups in ctx.match
command("ban", { prefixes: ["!", "."] });     // !ban, .ban
command("exact", { caseSensitive: true });

start matches /start in private chats, startGroup in groups, and deeplink(param) matches the /start payload (t.me/bot?start=…) against a string or regex.

built-in filters

filtermatchesadds to ctx
text
textnon-empty text or captiontext: string
equals(s) / contains(s) / startsWith(s) / endsWith(s)text comparison, optional { ignoreCase: true }text: string
regex(re)text matches rematch: RegExpMatchArray
commands
command(name?, opts?)a /command — string, array or regex namecommand, args, payload
start / startGroup/start in private / groupcommand additions + narrowed chat
deeplink(param)/start payload equals or matchescommand additions, match for regex
who / where
chatType(...t)chat type in t (typed literals)narrows chat.type
isPrivate / isGroup / isChannel / isForumshorthand chat kindsnarrows chat
chatId(...ids) / fromUser(...ids)ids or @usernames — messages, callback/inline queries, member updates, reactions, …narrows chat / from
fromBot / isPremiumsender is a bot / premium usernarrows from
message shape
media / mediaType(...k)any media / given kinds — includes paid_media and storynarrows message
photo, video, audio, voice, sticker, document, animation, videoNote, paidMedia, storymedia shorthandsnarrows message
location, contact, venue, poll, dice, game, invoice, successfulPaymentmessage payloadsnarrows message
reply / forward / forwardOrigin(...t) / viaBot(...ids)replies, forwards, inline-bot messagesnarrows message
hasEntity(type?)entity in text or captionentities: MessageEntity[] (the matching ones)
service, newChatMembers, leftChatMember, pinnedMessageservice messagesnarrows message
other updates
callbackData(trigger)callback data equals / matchesmatch for regex, narrows callbackQuery
inlineQuery(trigger?)inline query, optional text matchinlineQuery, match for regex
editededited message / post
chatMemberStatus({ from?, to? })member status transitionchatMember: ChatMemberUpdated

combinators

  • and(a, b, …) — all must match; additions intersect. later members see what earlier ones staged. typed for any arity.
  • or(a, b, …) — first match wins; additions unite — when every branch stages the same field (e.g. or(regex(a), regex(b))), it stays plainly typed. a failed branch's staged data is discarded.
  • not(a) — inverts; no additions.
and() of nothing matches everything; or() of nothing matches nothing. everything is also under one namespace, mtcute-style: import { filters } from "@yaebal/filters" then filters.command(...).

custom filters

a filter is just a function (ctx, bag) => boolean | Promise<boolean>. a bare predicate already works; use defineFilter to stage typed data — set every declared field on bag before returning true.

custom.ts
import { defineFilter } from "@yaebal/filters";

// stage typed data in the bag; it lands on ctx only if the whole tree matches
const vip = defineFilter<{ profile: Profile }>(async (ctx, bag) => {
  const profile = await db.profile(ctx.from?.id);
  if (!profile?.vip) return false;
  bag.profile = profile;
  return true;
});

bot.filter(and(vip, command("redeem")), (ctx) => {
  ctx.profile; // Profile — from vip
  ctx.args;    // string[] — from command
});
filter queries (on("message:text")) still exist and are great for the common case — filter() adds composition, async predicates, and typed data staging on top.