/** * Per-user language preference for GramIO bots. * * Follows gramio's canonical "shared infrastructure" pattern (see * [Composer docs — Production Architecture](https://gramio.dev/extend/middleware.html#production-architecture)): * the bot's session is extended once at the top level by the user, * and each feature plugin declares it as a required dependency. * gramio's runtime deduplication ensures the session derive runs * exactly once per update; TypeScript flows the session's data shape * into every plugin that `.extend()`s it. * * ## What this plugin owns * * - Validates the supported BCP-47 language tags via `Intl.getCanonicalLocales` * - Resolves `ctx.lang` AND `ctx.say` on every event (stored pick → Telegram * hint → default), all READ-TIME: the hint is never persisted, so a user who * switches their Telegram client language moves with it until they pick * - Persists a language as `ctx.session.language` ONLY on an explicit pick * (the menuItem action) — consumers must not auto-write inferred values * - Provides a `menuItem` for a `botMenu`'s language picker (highlight shows * the EFFECTIVE language, hint included) * * ## What this plugin does NOT own * * - The session itself. The user creates it (`session(...)`) and * extends it at bot level before this plugin. * - GDPR machinery. A language preference is trivially covered by * [Telegram's Standard Bot Privacy Policy](https://telegram.org/privacy-tpa) * under "data necessary to function". * * ## Resolution priority for `ctx.lang` * * 1. `ctx.session.language` — stored override * 2. `ctx.from.languageCode` — Telegram-detected user lang, only in * user-scoped resolution (in groups it would flicker per-speaker) * 3. `default` — the fallback passed at construction * * Peer deps: `gramio`, `@gramio/session`. * * @example * import { Bot } from 'gramio' * import { session } from '@gramio/session' * import { redisStorage } from '@gramio/storage-redis' * import { language } from '@adriangalilea/utils/bot/language' * * const userSession = session({ * storage: redisStorage(), * key: 'session', * initial: () => ({}), // plugins add their fields by convention * }) * * const lang = language({ * session: userSession, * supported: ['en','es'] as const, * default: 'en', * }) * * const bot = new Bot(process.env.BOT_TOKEN!) * .extend(userSession) // ← FIRST: ctx.session lands on the real ctx * .extend(lang.plugin) // declares userSession as dep; runtime dedup * .command('hello', (ctx) => ctx.send({ en: 'Hello', es: 'Hola' }[ctx.lang])) */ import type { session } from "@gramio/session"; import { type DeriveDefinitions, type InlineKeyboard, Plugin } from "gramio"; import { type Polyglot } from "../say/index.js"; import type { ActionResult, MenuCtx, MenuItem } from "./menu.js"; /** Branded BCP-47 language tag — obtainable only via the validators below. */ export type LangCode = string & { readonly __langCode: unique symbol; }; /** * Validate + canonicalize a BCP-47 tag using the standard * `Intl.getCanonicalLocales`. Throws `RangeError` on invalid input. * Canonicalizes casing: `'en-us'` → `'en-US'`. */ export declare const parseLangCode: (s: string) => LangCode; /** * Primary subtag of a Telegram client language hint (`"pt-BR"` → `"pt"`), * or undefined when absent/unusable. The shared normalizer behind every * read-time hint fallback (`ctx.lang`, `ctx.say`, menu chrome): the hint is * resolved live per event and NEVER persisted — only an explicit user pick * writes `session.language`. */ export declare const langHintOf: (code: string | undefined) => string | undefined; export type LanguageScopeStrategy = "user" | "chat"; export type LanguageScope = LanguageScopeStrategy | { private?: LanguageScopeStrategy; group?: LanguageScopeStrategy; supergroup?: LanguageScopeStrategy; channel?: LanguageScopeStrategy; }; /** Loose session shape — the plugin only touches the `language` field. */ type SessionLike = { language?: string; }; /** @internal — kept unexported so it doesn't clash with peers' refs. */ type LangSessionPluginRef = ReturnType>; export type LanguageOptions = { /** * The session plugin to read/write `ctx.session.language` from. * Must be extended on the bot before this plugin (gramio's runtime * dedup ensures the session derive only runs once per update). */ session: LangSessionPluginRef; /** Tuple of BCP-47 tags. Each validated via `Intl.getCanonicalLocales`. */ supported: Langs; /** Must be a member of `supported`. */ default: Langs[number]; /** See module docstring. Per chat-type override possible. */ scope?: LanguageScope; /** * Override per-language menu label. Default uses an emoji flag prefix * derived from the language code. */ labels?: Partial>; /** * Header text for the language sub-menu. Accepts a plain string or a * polyglot literal. Default: `{ en: '🌐 Language', es: '🌐 Idioma' }`. */ menuLabel?: string | Polyglot; }; export type LanguageFeature = { plugin: ReturnType>; menuItem: MenuItem; }; /** * Callable namespace attached to `ctx.say`. * * ctx.say({ en, es }) — resolves to a string at ctx.lang * ctx.say.send({ en, es }, p?) — ctx.send with the resolved string * ctx.say.edit({ en, es }, p?) — ctx.editText (callback ctx only) * ctx.say.answer({ en, es }, p?)— ctx.answer (callback ctx only) * * `.send` is valid wherever `ctx.send` exists; `.edit` / `.answer` * require a callback_query ctx. Calling the wrong one for the event * type raises a clear TypeError at runtime — the type-level declares * them uniformly to keep the surface flat. */ export type Sayer = { >(value: V): string; send>(value: V, params?: object): Promise; edit>(value: V, params?: object): Promise; answer>(value: V, params?: object): Promise; }; /** * What this plugin decorates onto `ctx`. Two surfaces: * * - `ctx.lang` — the user's current language, **resolved once at * event start and frozen** for the rest of the handler. Cheap to * read repeatedly, but goes stale if you mutate * `ctx.session.language` mid-handler (typical inside a * `MenuItem.action` that flips the user's selection). For * post-mutation freshness use `ctx.session.language` directly, * or call `ctx.say(...)` which is live. * * - `ctx.say(value)` — callable + namespace, **resolves the lang * on every call** by re-reading `ctx.session.language`. Safe to * use both before and after a mid-handler mutation. Plus * `.send / .edit / .answer` that forward to gramio's * `ctx.send / .editText / .answer` with the resolved string. */ type LanguageDerives = { lang: Lang; say: Sayer; }; /** A representative flag for a language tag: the region's flag when the tag carries one * (`pt-BR` → 🇧🇷), a curated flag for common regionless tags (`es` → 🇪🇸), 🌐 otherwise. */ export declare const flagFor: (lang: string) => string; /** The language's name in itself (`es` → "Español", `ja` → "日本語") — what its own * speakers scan a picker for. Falls back to the tag when Intl doesn't know it. */ export declare const autonym: (lang: string) => string; /** The canonical picker label: flag + autonym (`es` → "🇪🇸 Español"). The autonym is * title-cased for the label position — Intl returns "español" (correct in running * Spanish prose, wrong on a button); caseless scripts pass through untouched. */ export declare const languageLabel: (lang: string) => string; /** * The language-picker MenuItem, storage- and policy-agnostic: one submenu entry per * code, packed two-up, the active code wearing Telegram's `primary` fill, re-rendered * in place after a tap. What "active" means and what a tap DOES live in your closures — * the plugin's own session-writing `menuItem`, a group-scoped admin-gated picker, and a * tier-gated one are all this one factory. * * `pick` returns the toast (or a refusal toast — gate inside it); the menu owns the * single answerCallbackQuery, so never call `ctx.answer` from `pick`. */ export type LanguagePickerSpec = { /** MenuItem id (default "lang"); submenu entry ids are the codes. */ id?: string; /** The submenu button's label. */ label: string | Polyglot; codes: readonly string[]; /** Button label per code; default {@link languageLabel}. */ labelFor?: (code: string) => string; isActive: (ctx: MenuCtx, code: string) => boolean | Promise; pick: (ctx: MenuCtx, code: string) => ActionResult | Promise; }; export declare function languagePickerItem(spec: LanguagePickerSpec): MenuItem; /** * Append flag-labeled language rows to an InlineKeyboard — the raw-surface twin of * {@link languagePickerItem} for keyboards outside `botMenu` (an onboarding /start, a * group welcome). The caller owns the callback schema: `pack(code)` returns the * callback_data. Returns the same keyboard, so lead rows go before and trailing rows * chain after. */ export declare function addLanguageRows(kb: InlineKeyboard, opts: { codes: readonly string[]; pack: (code: string) => string; labelFor?: (code: string) => string; /** The code that wears the active fill (the current setting), if any. */ active?: string; /** The active code's fill (default `primary`). Pass `success` when blue already * means something else on the same keyboard (e.g. an active nav tab). */ activeStyle?: "primary" | "success" | "danger"; perRow?: number; }): InlineKeyboard; export declare const language: (opts: LanguageOptions) => LanguageFeature; declare const buildLanguagePlugin: (args: { sessionPlugin: LangSessionPluginRef; canonicalSet: ReadonlySet; defaultLanguage: Lang; matchSupported: (s: string | undefined) => Lang | undefined; scopeOpt: LanguageScope | undefined; }) => Plugin, DeriveDefinitions & { global: LanguageDerives; } & { message: { session: SessionLike & { $clear: () => Promise; }; }; channel_post: { session: SessionLike & { $clear: () => Promise; }; }; inline_query: { session: SessionLike & { $clear: () => Promise; }; }; chosen_inline_result: { session: SessionLike & { $clear: () => Promise; }; }; callback_query: { session: SessionLike & { $clear: () => Promise; }; }; shipping_query: { session: SessionLike & { $clear: () => Promise; }; }; pre_checkout_query: { session: SessionLike & { $clear: () => Promise; }; }; poll_answer: { session: SessionLike & { $clear: () => Promise; }; }; chat_join_request: { session: SessionLike & { $clear: () => Promise; }; }; new_chat_members: { session: SessionLike & { $clear: () => Promise; }; }; new_chat_title: { session: SessionLike & { $clear: () => Promise; }; }; new_chat_photo: { session: SessionLike & { $clear: () => Promise; }; }; delete_chat_photo: { session: SessionLike & { $clear: () => Promise; }; }; group_chat_created: { session: SessionLike & { $clear: () => Promise; }; }; message_auto_delete_timer_changed: { session: SessionLike & { $clear: () => Promise; }; }; migrate_to_chat_id: { session: SessionLike & { $clear: () => Promise; }; }; migrate_from_chat_id: { session: SessionLike & { $clear: () => Promise; }; }; pinned_message: { session: SessionLike & { $clear: () => Promise; }; }; invoice: { session: SessionLike & { $clear: () => Promise; }; }; successful_payment: { session: SessionLike & { $clear: () => Promise; }; }; chat_shared: { session: SessionLike & { $clear: () => Promise; }; }; proximity_alert_triggered: { session: SessionLike & { $clear: () => Promise; }; }; video_chat_scheduled: { session: SessionLike & { $clear: () => Promise; }; }; video_chat_started: { session: SessionLike & { $clear: () => Promise; }; }; video_chat_ended: { session: SessionLike & { $clear: () => Promise; }; }; video_chat_participants_invited: { session: SessionLike & { $clear: () => Promise; }; }; web_app_data: { session: SessionLike & { $clear: () => Promise; }; }; location: { session: SessionLike & { $clear: () => Promise; }; }; passport_data: { session: SessionLike & { $clear: () => Promise; }; }; } & { message: { lang: Lang; say: Sayer; }; inline_query: { lang: Lang; say: Sayer; }; chosen_inline_result: { lang: Lang; say: Sayer; }; callback_query: { lang: Lang; say: Sayer; }; }, {}>; export {}; //# sourceMappingURL=language.d.ts.map