@yaebal/guards
reusable bot.guard() predicates so "is this user allowed" checks aren't hand-rolled
per project: isPrivate/isGroup reuse @yaebal/filters' chat-type filters, isAdmin/hasMembership/ hasPermission do a live getChatMember lookup against the Bot API, membership() caches that lookup with event-driven invalidation, and guardOr answers a denied check instead of silently dropping the update.
install
pnpm add @yaebal/guardsusage
a membership-based guard calls getChatMember — put it behind something narrower
than "every update" so it isn't called on every message in the chat:
import { and, command } from "@yaebal/filters";
import { isAdmin } from "@yaebal/guards";
// getChatMember only runs for an actual /ban, not for every message that passes through
bot.filter(and(command("ban"), isAdmin), banHandler);import { createBot, type Context } from "yaebal";
import { and } from "@yaebal/filters";
import { isPrivate } from "@yaebal/guards";
const bot = createBot(process.env.BOT_TOKEN!);
bot.guard(isPrivate).command("whoami", (ctx) =>
ctx.reply(`private chat — chat.type is narrowed to "${ctx.chat.type}"`),
);
// and()/or() infer a bare predicate's ctx from the tuple element type, not the composer —
// annotate it explicitly so it isn't inferred as `never`
bot.filter(and(isPrivate, (ctx: Context) => (ctx.text?.length ?? 0) > 0), (ctx) =>
ctx.reply(`hey ${ctx.from?.first_name}, guards compose with @yaebal/filters' and/or/not`),
);
bot.start();composes with @yaebal/filters
every guard here fits Filter<Context>'s call shape
((ctx) => boolean | Promise<boolean>), so it works on bot.guard() and on bot.filter() combinators the same way.
import { and } from "@yaebal/filters";
import { isAdmin, isGroup } from "@yaebal/guards";
// every guard here also fits Filter<Context>'s shape, so it composes with and/or/not
bot.filter(and(isGroup, isAdmin), (ctx) => ctx.reply("welcome, admin"));caching: membership()
every guard here also works without any setup, falling back to a direct getChatMember call. install membership() to cache that lookup per (chat, user), so a burst of commands from the same admin doesn't cost a burst of
api calls. cached entries are also dropped the instant telegram reports a real membership
change (chat_member/my_chat_member) — a fresh promotion or demotion
is never served stale, which a plain ttl alone can't guarantee.
import { and, command } from "@yaebal/filters";
import { isAdmin, membership } from "@yaebal/guards";
bot.install(membership({ ttl: 60_000 }));
bot.filter(and(command("ban"), isAdmin), banHandler); // cached for 60s per (chat, user)pass an existing @yaebal/cache client via { cache } to share one cache across plugins, or read membership().cache to pre-warm/inspect it directly.
answering a denial: guardOr
bot.guard() drops a failing update silently — right for background filtering,
wrong for a user-facing command, where /ban from a non-admin should get a reply,
not silence.
import { guardOr, isAdmin } from "@yaebal/guards";
// bot.guard() drops a failing update silently — guardOr answers it instead
bot.use(guardOr(isAdmin, (ctx) => ctx.reply("admins only"))).command("ban", banHandler);anonymous admins & linked channels
an admin/owner posting with "hide my identity" on arrives with from set to GroupAnonymousBot and no user id bots can look up — a plain getChatMember on that id would just 400. every guard here already handles it: isAdmin treats an anonymous poster as at least an administrator (no api call
needed — telegram guarantees that much), isOwner denies them (telegram never
says whether they're the owner or "just" an administrator), hasMembership(...) grants them only when it would be correct either way, and the permission-checking guards deny
them by default (their actual flags are unknowable) unless you opt in.
import { hasMembership, hasPermission, isAdmin, isOwner } from "@yaebal/guards";
// an anonymous admin/owner ("hide my identity") is guaranteed to be at least an
// administrator, without an api call — isAdmin passes, isOwner denies (telegram never
// says which one they are), hasMembership needs both statuses accepted to pass them:
hasMembership("administrator"); // denies an anonymous poster
hasMembership("creator", "administrator"); // passes one — same rule isAdmin/isOwner use
// hasPermission can't check an anonymous poster's actual flags — deny by default, or opt in:
hasPermission("can_restrict_members", { allowAnonymous: true });isAnonymousAdmin(ctx) and fromLinkedChannel(ctx) (an automatic
forward from a channel linked to this group — a different, unrelated case that also sets ctx.senderChat) are exported directly if you need to branch on either yourself.
checking the bot's own permissions
botIsAdmin/botHasPermission(permission) mirror isAdmin/hasPermission, but for the bot itself (ctx.me)
— check before an action that needs standing. ctx.me is only known once the bot
has resolved its own identity (long polling fills it in after getMe); until then
these deny.
import { and, command } from "@yaebal/filters";
import { botHasPermission } from "@yaebal/guards";
bot.filter(and(command("pin"), botHasPermission("can_pin_messages")), (ctx) =>
ctx.reply("I don't have permission to pin messages here."),
);asGuard — adapt a filter
adapt any synchronous, non-staging @yaebal/filters "who/where" predicate
(chatType(...), fromUser(...), isChannel, isForum, …) into a narrowing bot.guard() predicate. isPrivate and isGroup here are just asGuard(...) applied to @yaebal/filters' own isPrivate/isGroup. narrowing (and
any properties enrichment already added upstream) survives no matter how enriched the context
already is.
import { isChannel } from "@yaebal/filters";
import { asGuard } from "@yaebal/guards";
// adapt any synchronous, non-staging @yaebal/filters "who/where" predicate
bot.guard(asGuard(isChannel)).on("channel_post", onlyChannelPosts);api
| export | signature | description |
|---|---|---|
isPrivate | <C extends Context>(ctx: C) => ctx is C & { chat: Chat & { type: "private" } } | chat is a private (1:1) chat — narrows ctx.chat |
isGroup | <C extends Context>(ctx: C) => ctx is C & { chat: Chat & { type: "group" | "supergroup" } } | chat is a group or supergroup — narrows ctx.chat |
isAdmin | (ctx: Context) => Promise<boolean> | chat owner, administrator, or an anonymous poster |
isOwner | (ctx: Context) => Promise<boolean> | specifically the chat's owner (an anonymous poster is denied) |
hasMembership | (...statuses: ChatMemberStatus[]) => (ctx: Context) => Promise<boolean> | current status is one of the given ChatMemberStatus values |
hasPermission | (permission: ChatPermission, opts?: PermissionOptions) => (ctx: Context) => Promise<boolean> | owner always passes; administrator passes when the flag is set |
hasAnyPermission | (permissions: ChatPermission[], opts?: PermissionOptions) => (ctx: Context) => Promise<boolean> | owner always passes; administrator passes when any listed flag is set |
hasAllPermissions | (permissions: ChatPermission[], opts?: PermissionOptions) => (ctx: Context) => Promise<boolean> | owner always passes; administrator passes when every listed flag is set |
botIsAdmin | (ctx: Context) => Promise<boolean> | the bot itself (ctx.me) is owner/administrator |
botHasPermission | (permission: ChatPermission) => (ctx: Context) => Promise<boolean> | the bot itself has the flag set |
isAnonymousAdmin | (ctx: Context) => boolean | update is an anonymous admin/owner post |
fromLinkedChannel | (ctx: Context) => boolean | update is an automatic forward from a linked channel |
resolveMember | (ctx: Context, userId?: number) => Promise<MemberResolution> | the low-level lookup every predicate above is built on |
membership | (options?: MembershipOptions) => MembershipPlugin | cache getChatMember lookups, invalidated by chat_member/my_chat_member |
guardOr | <C extends Context>(predicate, onDeny) => Middleware<C> | gate like guard(), but answer a denial instead of dropping it |
asGuard | (filter: Filter<Context, Add>) => <C extends Context>(ctx: C) => ctx is C & Add | adapt a sync, non-staging @yaebal/filters predicate into a guard |
ChatPermission
derived from ChatMemberAdministrator's can_* flags (minus can_be_edited and is_anonymous), so it always matches the current Bot
API schema: can_manage_chat, can_delete_messages, can_manage_video_chats, can_restrict_members, can_promote_members, can_change_info, can_invite_users, can_post_stories, can_edit_stories, can_delete_stories, can_post_messages, can_edit_messages, can_pin_messages, can_manage_topics, can_manage_direct_messages, can_manage_tags.
behavior
a getChatMember lookup that fails as telegram saying "no" (user not found, bot
isn't in the chat, …) denies access. anything else — a network failure, a malformed response —
throws through the predicate instead of masquerading as a deny; a permission check that fails
silently and looks like a permissions bug forever is worse than one that's loud about an actual
outage. an update's user is never treated as privileged by default.
asGuard only accepts synchronous, non-staging filters — passing an async filter or
one that stages bag data (command(), regex(), …) throws immediately
rather than silently misbehaving. reach for bot.filter() for those instead.
testing
@yaebal/test stubs every api call, so seed getChatMember directly, and simulate the promotion/demotion events membership() listens for with chatMemberUpdate/ myChatMemberUpdate:
import { chatMemberUpdate, createTestEnv } from "@yaebal/test";
const env = createTestEnv(bot);
env.onApi("getChatMember", {
status: "administrator",
user: { id: 1, is_bot: false, first_name: "admin" },
can_restrict_members: true,
});
// simulate telegram reporting a promotion/demotion — membership() invalidates on this
await env.dispatch(chatMemberUpdate({ chatId: -1, userId: 1, newStatus: "administrator" }));isPrivate/isGroup are the same
checks as @yaebal/filters' isPrivate/ isGroup, just adapted for bot.guard() — reach for filter() instead when you also want the staged data (e.g. command()'s ctx.args).