@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
pnpm add @yaebal/filtersthe 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.
// 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
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);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.
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
| filter | matches | adds to ctx |
|---|---|---|
| text | ||
text | non-empty text or caption | text: string |
equals(s) / contains(s) / startsWith(s) / endsWith(s) | text comparison, optional { ignoreCase: true } | text: string |
regex(re) | text matches re | match: RegExpMatchArray |
| commands | ||
command(name?, opts?) | a /command — string, array or regex name | command, args, payload |
start / startGroup | /start in private / group | command additions + narrowed chat |
deeplink(param) | /start payload equals or matches | command additions, match for regex |
| who / where | ||
chatType(...t) | chat type in t (typed literals) | narrows chat.type |
isPrivate / isGroup / isChannel / isForum | shorthand chat kinds | narrows chat |
chatId(...ids) / fromUser(...ids) | ids or @usernames — messages, callback/inline queries, member updates, reactions, … | narrows chat / from |
fromBot / isPremium | sender is a bot / premium user | narrows from |
| message shape | ||
media / mediaType(...k) | any media / given kinds — includes paid_media and story | narrows message |
photo, video, audio, voice, sticker, document, animation, videoNote, paidMedia, story | media shorthands | narrows message |
location, contact, venue, poll, dice, game, invoice, successfulPayment | message payloads | narrows message |
reply / forward / forwardOrigin(...t) / viaBot(...ids) | replies, forwards, inline-bot messages | narrows message |
hasEntity(type?) | entity in text or caption | entities: MessageEntity[] (the matching ones) |
service, newChatMembers, leftChatMember, pinnedMessage | service messages | narrows message |
| other updates | ||
callbackData(trigger) | callback data equals / matches | match for regex, narrows callbackQuery |
inlineQuery(trigger?) | inline query, optional text match | inlineQuery, match for regex |
edited | edited message / post | — |
chatMemberStatus({ from?, to? }) | member status transition | chatMember: 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.
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
});on("message:text")) still exist and are great for the common case — filter() adds composition, async predicates, and typed data staging on top.