@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

terminal
pnpm add @yaebal/guards

usage

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:

bot.ts
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);
playground
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.

bot.ts
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.

bot.ts
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.

bot.ts
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.

bot.ts
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.

bot.ts
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.

bot.ts
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

exportsignaturedescription
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) => booleanupdate is an anonymous admin/owner post
fromLinkedChannel(ctx: Context) => booleanupdate 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) => MembershipPlugincache 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 & Addadapt 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:

guards.test.ts
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" }));
pairs with filters. 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).