/** * Access control for personal GramIO bots — a one-stop guard + * approve/deny + revocable allow-list with an inline admin menu. * * **Native alternative** (BotFather → Bot Settings → Access → * "Restrict bot usage"). Toggling that ON makes Telegram itself reject * any non-allowlisted user before the update ever reaches your bot. * It's the right pick when you want a flat "me + a few hand-picked * accounts" allow-list and never need to approve from inside the bot. * * This plugin is preferable when you want: * - In-bot approval flow (admin gets a DM with `[✅ Approve][❌ Deny]` * buttons when a stranger DMs the bot), not a BotFather round-trip * - Dynamic revoke / re-approve from `/access` without leaving Telegram * - Pending / approved / denied lists visible to the admin * - `ctx.access.source` (`admin` / `default` / `approved`) on every * update for downstream logic * * Both can coexist: BotFather's native flag is a hard pre-filter, this * plugin is the dynamic UX on top of whoever gets through. * * **Each surface has a mode.** `dms` and `groups` are independent * questions — the DM gate asks "may this USER talk to the bot", the room * gate asks "may the bot serve this ROOM" — and each is `"allowlist"` * (unknowns go pending, the admin approves) or `"open"` (unknowns pass; * only an explicit deny blocks — open IS the ban-list mode, written via * `/ban ` / the leave button, lifted via `/unban `; a positive id * bans a user, a negative one bans a room and leaves it). Defaults: * `dms: "allowlist"` (the plugin's historic core), `groups: "off"` (no * room machinery at all). The canonical pairings: a private bot runs * allowlist/allowlist; a public bot runs open/open and keeps `/ban`. * * With `groups: "allowlist"`: * * bot added to a group / supergroup / channel * │ * added by an admin or a default id? * yes → approved on the spot, silently * no → PENDING: the bot stays but serves nothing, * admin gets a DM with `[✅ Approve][🚪 Leave]` * │ * approve → the room works — for EVERY member * (`ctx.access.source === 'group'`: approving the * room admits the room; the per-user DM gate does * not apply inside it) * leave → the bot leaves AND remembers: re-added while * denied, it leaves again on sight (throttled DM) * * A group the bot already sits in when the gate turns on has no record; * its first activity seeds a pending request (throttled DM) — existing * rooms surface for review instead of going silently mute forever. In the * `/access` menu STATUS is the only navigation axis: one Approved / one * Pending / one Denied list, each mixing 👤 users and 👥 rooms with the * right actions per row (approve/deny/revoke vs approve/leave/allow). * * With `groups: "open"` every add auto-approves QUIETLY (no DM — the * consumer's own join notifications cover it) and rooms the bot already * sits in self-register on first activity, so the Groups view lists the * whole footprint ready to be banned; a banned room is left on sight. * * Removal from a group clears its record (a fresh add re-asks) UNLESS it * was denied — deny memory survives, that's the anti-re-add-spam. Cost: * one storage read per gated group update. Known edge: a group→supergroup * migration changes the chat id, so the room re-asks under its new id. * * stranger DMs your bot * │ * ▼ * ┌──── plugin gate (this file) ────────────────────┐ * │ ctx.from.id ∈ admin / defaults / approved? │ * │ yes → next() │ * │ no → drop + notify admin (rate-limited) │ * └─────────────────────────────────────────────────┘ * │ * admin gets DM with [✅ Approve] [❌ Deny] * │ * admin taps * │ * stranger's session updated · stranger gets DM * * **Storage layout.** This plugin stores its per-user record under * the `access` field of the shared session record (see * `bot/CLAUDE.md` § "Shared session, one record per user"). All * per-user state across our plugins coexists in the same record: * * storage[String(userId)] = { * access: { status, approvedAt, … }, // ← this plugin * language: 'es', // ← bot/language * llm: { shards: { 'general': [...] } }, // ← bot/llm (history) * } * * Plus one tiny admin-side index so `/access` can list pending / * approved / denied without scanning every user: * * storage['ac:index'] = { pending: [...ids], approved: [...], denied: [...] } * * **Cross-user mutations.** When the admin taps `[✅ Approve]` on * Pepe's notification, `ctx` is the admin's, so `ctx.session` is the * admin's record — useless for mutating Pepe. We reach for Pepe's * record directly via `storage.get(String(pepeId))`, preserve other * plugins' fields in it (read-modify-write), and put it back. * * **i18n.** Every user-facing string is an inline `{ en, es }` * polyglot literal resolved via `say(value, lang)` at the call site * — no message bundle, no override registry. The recipient's stored * `language` field (set by `bot/language`) picks the variant; falls * back to `'en'`. Want a different default? Set `language` on the * relevant session record before this plugin fires. * * **Composes with**: * - `adminContext` (kit.ts) — required, gives us `ctx.adminId` / * `ctx.isAdmin`. Declared as a runtime dependency. * - `@gramio/session` — the user creates ONE session at bot level * and passes it to this plugin (and the other session-using * ones). gramio's runtime dedup ensures the session derive runs * exactly once per update. * * Peer deps: `gramio`, `@gramio/session`, `@gramio/storage`. * * @example * import { Bot } from 'gramio' * import { session } from '@gramio/session' * import { redisStorage } from '@gramio/storage-redis' * import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit' * import { accessControl } from '@adriangalilea/utils/bot/access-control' * * const storage = redisStorage() * const userSession = session({ storage, key: 'session', initial: () => ({}) }) * * const bot = new Bot(process.env.BOT_TOKEN!) * .extend(adminContext(123456789)) * .extend(userSession) * .extend(accessControl({ session: userSession, storage, defaults: [1158734055] })) * .command('start', (ctx) => ctx.send(`source=${ctx.access.source ?? 'denied'}`)) * * await gracefulStart(bot) */ import type { session } from "@gramio/session"; import type { Storage } from "@gramio/storage"; import { type AnyBot, type DeriveDefinitions, Plugin } from "gramio"; import { type LangSession } from "./lang.js"; export type AccessStatus = "unknown" | "pending" | "approved" | "denied"; export type AccessUser = { id: number; firstName?: string; lastName?: string; username?: string; }; /** * What this plugin persists under `ctx.session.access` per user. When * `ctx.session.access` is `undefined`, the user has never interacted * (or has been wiped via /forget). The plugin treats that as * status='unknown' for gating purposes. */ export type AccessRecord = { status: AccessStatus; user?: AccessUser; /** Chat to DM the user back. For private chats this equals user.id. */ chatId?: number; requestedAt?: number; approvedAt?: number; approvedBy?: number; deniedAt?: number; deniedBy?: number; /** First message text from the request (truncated). */ firstMessage?: string; lastActivityAt?: number; messageCount?: number; /** Counts attempts after the initial request — used by the throttle. */ rejectedAttempts?: number; lastNotifiedAt?: number; }; export type AccessIndex = { pending: number[]; approved: number[]; denied: number[]; }; export type GroupAccessStatus = "pending" | "approved" | "denied"; /** * What the group gate persists per room, under the chat id's storage row * (`groupAccess` field — chat ids are negative, so rooms and users share * the `bot-:` keyspace without collision). */ export type GroupAccessRecord = { status: GroupAccessStatus; chat: { id: number; title?: string; type: string; }; /** Who added the bot (or whose message surfaced an ungated room). */ addedBy?: AccessUser; requestedAt?: number; approvedAt?: number; approvedBy?: number; deniedAt?: number; deniedBy?: number; lastNotifiedAt?: number; }; export type AccessSource = "admin" | "default" | "store" | "group" | "open"; /** * A surface's gate mode. `allowlist`: unknowns land in pending and the * admin approves. `open`: unknowns pass; only an explicit deny (`/ban`, * the leave button) blocks — open IS the ban-list mode. */ export type GateMode = "allowlist" | "open"; /** * What handlers downstream see on `ctx.access`. A discriminated union — * use the `allowed` field to narrow. * * **Snapshot semantics.** `ctx.access` is computed by this plugin's * derive at event start and stays static through the handler — same * pattern as `ctx.lang` from `bot/language`. If you mutate the user's * access state mid-handler (rare — usually the admin's tap mutates * the *target's* session record, not their own), re-read from * storage / `ctx.session.access` directly rather than from * `ctx.access`. */ export type AccessInfo = { allowed: true; source: AccessSource; /** The persisted record, when source is 'store'. */ record?: AccessRecord; } | { allowed: false; reason: "denied" | "pending" | "unknown" | "no-sender"; }; /** * Loose session shape — this plugin writes `access`; it READS `language` * to localize messages it sends to the subject. Both are optional. */ type SessionLike = { access?: AccessRecord; } & LangSession; /** @internal — kept unexported so it doesn't clash with peers' refs. */ type AcSessionPluginRef = ReturnType>; export type AccessControlOptions = { /** * Shared session plugin. This plugin extends it for type flow; * gramio's runtime dedup ensures it only runs once per update. * `ctx.session.access` is where the per-user access record lives. */ session: AcSessionPluginRef; /** * Storage backend for cross-user mutations (admin approves Pepe → * write to Pepe's session record from admin's ctx). Must be the * same storage instance passed to `session()`. */ storage: Storage; /** Always-allowed user ids, hardcoded. Bypass the entire flow. */ defaults?: ReadonlyArray; /** Pass `false` to silence the first-attempt reply to denied users. */ silentDeny?: boolean; /** Min ms between repeat admin notifications for the same user. Default 6h. */ notifyThrottleMs?: number; /** * DM surface mode. `"allowlist"` (default — the plugin's historic * core): unknown DM users land in pending and the admin approves. * `"open"`: unknown DM users pass (`ctx.access.source === 'open'`); * an explicit deny (`/ban`, or a leftover deny record) still blocks — * open mode IS the ban-list mode. */ dms?: GateMode; /** * Room surface mode (group / supergroup / channel), see the header. * `"off"` (default): no room machinery at all. `"allowlist"`: adds go * pending, the admin approves or the bot leaves. `"open"`: every add * auto-approves quietly (the room shows in `/access` → Groups, ready * to be banned); a banned room is left on sight. */ groups?: GateMode | "off"; /** Callbacks for your own logging / metrics. */ onAccessRequest?: (info: { user: AccessUser; firstMessage?: string; }) => void; onApprove?: (info: { userId: number; approvedBy: number; }) => void; onDeny?: (info: { userId: number; deniedBy: number; }) => void; /** Group-gate callbacks — e.g. send your welcome card on approve, not on add. */ onGroupRequest?: (info: { chat: GroupAccessRecord["chat"]; addedBy?: AccessUser; }) => void; onGroupApprove?: (info: { chatId: number; approvedBy: number; }) => void; onGroupDeny?: (info: { chatId: number; deniedBy: number; }) => void; }; type AdminDerives = { adminId: number; isAdmin: boolean; }; type AccessDerives = { access: AccessInfo; }; type SessionDerives = { session: SessionLike & { $clear: () => Promise; }; }; export declare const accessControl: (opts: AccessControlOptions) => Plugin, DeriveDefinitions & { global: AdminDerives & AccessDerives & SessionDerives; } & { message: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; channel_post: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; inline_query: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; chosen_inline_result: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; callback_query: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; shipping_query: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; pre_checkout_query: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; poll_answer: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; chat_join_request: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; new_chat_members: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; new_chat_title: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; new_chat_photo: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; delete_chat_photo: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; group_chat_created: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; message_auto_delete_timer_changed: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; migrate_to_chat_id: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; migrate_from_chat_id: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; pinned_message: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; invoice: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; successful_payment: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; chat_shared: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; proximity_alert_triggered: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; video_chat_scheduled: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; video_chat_started: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; video_chat_ended: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; video_chat_participants_invited: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; web_app_data: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; location: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; passport_data: { session: { access?: AccessRecord; } & LangSession & { $clear: () => Promise; }; }; } & { global: { access: { allowed: false; reason: "no-sender"; source?: undefined; record?: undefined; }; } | { access: { allowed: true; source: "admin"; reason?: undefined; record?: undefined; }; } | { access: { allowed: true; source: "default"; reason?: undefined; record?: undefined; }; } | { access: { allowed: true; source: "group"; reason?: undefined; record?: undefined; }; } | { access: { allowed: true; source: "open"; reason?: undefined; record?: undefined; }; } | { access: { allowed: true; source: "store"; record: AccessRecord; reason?: undefined; }; } | { access: { allowed: false; reason: "unknown" | "pending" | "denied"; source?: undefined; record?: undefined; }; }; }, {}>; /** * Inject a synthetic access request — for tests/demos when you can't * easily spin up a second Telegram account. Writes a `pending` record * to storage at the same key the plugin's session would, updates the * index, then DMs the admin with the real * `[✅ Approve][❌ Deny]` keyboard. Tapping those buttons exercises * the real callback handlers end-to-end. * * Pass the SAME `storage` instance you passed to `accessControl({ storage })`. */ export declare const simulateAccessRequest: (bot: AnyBot, storage: Storage, adminId: number, fakeUser: AccessUser, message?: string) => Promise; export {}; //# sourceMappingURL=access-control.d.ts.map