@adriangalilea/utils 0.30.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -350,7 +350,7 @@ const notify = (msg: Polyglot<'en' | 'es'>, lang: 'en' | 'es') =>
350
350
  transport.send(say(msg, lang))
351
351
  ```
352
352
 
353
- In a bot, `bot/language` adds `ctx.say` — a callable namespace bound to `ctx.lang`:
353
+ In a bot, `bot/language` adds `ctx.lang` + `ctx.say`. Both resolve stored explicit pick → Telegram client hint → configured default, at READ time — the hint is never persisted, and `session.language` is written only by an explicit pick (the plugin's menuItem action):
354
354
 
355
355
  ```typescript
356
356
  ctx.say({ en: 'Continue', es: 'Continuar' }) // → string
@@ -367,6 +367,47 @@ Plugins for personal Telegram bots built on [GramIO](https://gramio.dev). Each p
367
367
  pnpm add @adriangalilea/utils gramio @gramio/storage @gramio/session
368
368
  ```
369
369
 
370
+ #### One bot file, ideation → production (`bot/create`)
371
+
372
+ `createBot` is the composer: it constructs the storage + session pair ONCE and threads it into every feature, so "must be the SAME instance you passed to session()" is unrepresentable instead of a doc warning. The same file runs in every stage — **storage and transport are environment decisions, never code shape**:
373
+
374
+ | stage | run | session | transport |
375
+ |---|---|---|---|
376
+ | ideation | `BOT_TOKEN=… tsx bot.ts` | memory (ephemeral, announced) | long-poll |
377
+ | experiment | `BOT_PERSIST=./bot.sqlite tsx bot.ts` | sqlite | long-poll |
378
+ | prod · your own hardware | systemd/launchd unit running the same command | sqlite / `BOT_PERSIST=redis://…` | long-poll (a legitimate prod mode: dials out, no inbound port/TLS) |
379
+ | prod · Cloudflare Worker | `wrangler deploy` (D1 binding `DB`) | D1 (`bot/storage-d1`) | webhook (`bot/worker`) |
380
+ | prod · Node/Bun server | webhook behind your HTTP server | sqlite/redis | fetch-shaped handler |
381
+
382
+ ```typescript
383
+ import { createBot } from '@adriangalilea/utils/bot/create'
384
+
385
+ const app = createBot({
386
+ admins: 190202471,
387
+ language: { supported: ['en', 'es'] as const, default: 'en' },
388
+ menu: {
389
+ adminContact: '@you',
390
+ header: async (ctx) => `⚙️ hi ${ctx.from?.firstName}`, // async — read your db here
391
+ items: [/* … */],
392
+ personalData: { onForget: async (ctx, userId) => {/* wipe YOUR tables */} },
393
+ },
394
+ handlers: (bot) => bot.command('start', (ctx) => ctx.say({ en: 'hi', es: 'hola' })),
395
+ })
396
+
397
+ export default app // Worker: webhook + /setup + /pause + deploy DMs
398
+ if (app.isMain(import.meta)) app.poll() // Node: `tsx bot.ts` long-polls
399
+ ```
400
+
401
+ Cloudflare is one adapter, not the architecture: `bot/worker` and `bot/storage-d1` are the workerd cap (~each 100 lines, both optional); `bot/kit`'s `gracefulStart` is the Node twin (signals + start/stop DMs) and stays the one deliberately Node-only corner. The worker-safe tripwire (`pnpm test:worker-safe`) guarantees the core never grows a Node dependency.
402
+
403
+ #### Design rules the bot plugins hold to (each learned the hard way)
404
+
405
+ - **Resolution is read-time.** Language resolves stored pick → live Telegram hint → default on every surface (`ctx.lang`, `ctx.say`, menu chrome, picker highlight). Inferred values are NEVER persisted — a user who switches their client language moves with it until they pick.
406
+ - **Only explicit user signals are stored.** The session holds picks, consent, state the user created — never derived values, never render caches. If a sync signature ever tempts you to cache a rendered string into the session, the signature is the bug (menu resolvers are async for exactly this reason).
407
+ - **Derives cover every event the user can speak through** — message, callback, AND inline. A feature that skips inline forces consumers to fork shadow helpers that read the session directly; those forks then read as design.
408
+ - **Forget actually forgets.** `personalData.onForget` runs inside the same try as the session delete: your message logs/metrics/credit rows get wiped too, or the user is told it failed — never a partial erasure reading as success.
409
+ - **The composer owns instance wiring.** Features still compose manually (below) when you need full control, but every "same instance" contract has one home.
410
+
370
411
  #### Threaded Mode — pin the `@gramio/contexts` fork
371
412
 
372
413
  Telegram added [Threaded Mode](https://telegram.org/blog/threaded-conversations) for private chats (BotFather → Bot Settings → Threaded Mode). gramio's `SendMixin` skips auto-threading there, and `CallbackQueryContext` doesn't expose `threadId` at all. Fixes [PR'd upstream](https://github.com/gramiojs/contexts/pull/4); until merged, pin the fork in **your bot project's** workspace root (pnpm only honors overrides at the root, not transitively — and pnpm ≥11 reads them from `pnpm-workspace.yaml`, not `package.json`):
@@ -392,8 +433,11 @@ Then `pnpm install`. Every `ctx.send` / `ctx.sendDocument` / `ctx.reply` / etc.
392
433
  | `@adriangalilea/utils/bot/allow-list` | Static allow-list by **id and/or @username** — stateless, no session/storage. `allowList({ ids?, usernames? })` is a plugin that decorates `ctx.allowed` (boolean); gate in your handlers (`if (!ctx.allowed) return`). `makeAllowList(...)` is the pure framework-free predicate. The light counterpart to `access-control` (which adds an approve/deny flow + revocable store, needing session+storage). Username caveat: a `@username` is optional and mutable, and the Bot API can't resolve username→id ahead of time — prefer `ids` when known, `usernames` is the pragmatic fallback. |
393
434
  | `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
394
435
  | `@adriangalilea/utils/bot/llm` | The full LLM-chatbot pipeline in one module. **Input:** `streamChat(response)` parses OpenAI-compatible SSE (OpenAI, vllm, mlx-lm, llama.cpp, Together, Groq, …) into a typed `AsyncGenerator<{type: 'content' \| 'reasoning', text}>`. **Output:** `ctx.startStream()` (low-level: debounced markdown to Telegram, 4000-char split, exposes `wasPartial` after `.end()`). `ctx.startChatStream(response)` (high-level: consumes the stream, renders reasoning as a Telegram `expandable_blockquote` entity + content as streamed markdown — both go through `markdownToFormattable` with graceful degradation — returns `{ content, reasoning }`). **History:** `llmHistory({...})` returns `.plugin` (decorates `ctx.llm` with `.add() / .get() / .clear() / .all() / .clearAll()`, per-(user, thread) OpenAI `ChatMessage` shape, persisted in the shared session record so the menu's 🗑 Forget wipes it automatically) AND `.menuItem` (drop-in "🗑 Delete this thread" for `botMenu` — wipes the LLM history AND calls `deleteForumTopic` so the Telegram thread + all its messages disappear from the chat; falls back to Redis-only clear when no `threadId` is present). |
395
- | `@adriangalilea/utils/bot/menu` | `botMenu({ command, description, items, privacy?, personalData?, adminContact })` — `/settings` command + InlineKeyboard router. Root view always renders a `🛡️ Privacy & data` submenu button that wraps the privacy policy link plus (if `personalData: { storage }`) 🗑 Forget + 📥 Export buttons. Items take `keepRow` (render on the same row as the next item — e.g. a two-per-row language picker) and `rootExtra` (render at the bottom of the root menu, below Privacy & data). `toggleMenuItem({ id, read, write, label: { off, on }, toast? })` — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via `read`/`write` closures. |
436
+ | `@adriangalilea/utils/bot/menu` | `botMenu({ command, description, items, privacy?, personalData?, adminContact })` — `/settings` command + InlineKeyboard router. Root view always renders a `🛡️ Privacy & data` submenu button that wraps the privacy policy link plus (if `personalData: { storage }`) 🗑 Forget + 📥 Export buttons. Items take `keepRow` (render on the same row as the next item — e.g. a two-per-row language picker) and `rootExtra` (render at the bottom of the root menu, below Privacy & data). `label` / `header` / `style` / `visible` resolvers may be **async** (read your db at render time — never cache render strings in the session); `parseMode: 'HTML'` renders the header formatted (you own escaping); `personalData.onForget(ctx, userId)` wipes YOUR tables inside the same try as the session delete, so Forget either forgets everything or reports failure. `toggleMenuItem({ id, read, write, label: { off, on }, toast? })` — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via `read`/`write` closures. |
396
437
  | `@adriangalilea/utils/bot/payments` | `botPayments({ session, storage, paysupport, paysupportHint?, legal, waiver, vip?, credits?, perks? })` — Telegram Stars monetization in one drop-in plugin. **Three axes, all optional:** `vip` (positional tier ladder — single rung in v1 is just `vip: [{...}]`, ladder is `vip: [{...}, {...}]`; ids are `vip.1`, `vip.2`, …), `credits` (consumable balance + top-up packs `credits.1`, `credits.2`, …), `perks` (orthogonal one-shot unlocks `perks.<key>`). **Surface:** `ctx.payments.atLeast('vip')` / `atLeast('vip.2')` (typed rank check), `ctx.payments.tier()` / `.tier.level()` / `.tier.label()`, `ctx.payments.credits.{balance, consume, tryConsume}` (throws `InsufficientCredits`), `ctx.payments.has(perkId)`, `await ctx.payments.require('vip', { feature? })` (gate that sends a localized upgrade prompt deep-linked to `/settings → 💎 VIP`), `await ctx.payments.invoice(productKey)` (threads Art. 103(m) TRLGDCU consent inline before `sendInvoice`). **Owns:** waiver consent flow (versioned text → forces re-consent on bump, snapshotted on every charge for audit), `/paysupport` slash command (Telegram ToS §6.5; `paysupportHint` overrides the where-to-manage-charges line when your menu isn't `/settings`), idempotent `successful_payment` fulfillment via `pay:idempotency:{chargeId}` sentinel, lazy subscription expiry (no cron needed), tier upgrade auto-cancel of the lower rung's renewal, and admin-DM refund approval (mirror of `accessControl`'s [✅ Aprobar][❌ Denegar] pattern). **Returns:** `{ plugin, menuItem, payouts, onFulfilled }` — `menuItem` is the drop-in `💎 VIP` entry for `botMenu`; `payouts.{record, list, export, exportForUsers}` is the Fragment payout ledger (you receive TON, log the EUR conversion, export time-windowed CSV/JSON for your gestor); `onFulfilled(productKey \| '*', handler)` registers fire-and-forget hooks. **Stars-only by design** — Telegram ToS §6.2 forbids third-party payment providers for digital goods. Crypto Pay deferred (MiCA risk); Stripe-outside-Telegram is a future v2 channel. Full compliance memo (Spanish-autónomo seller-of-record analysis, Verifactu vs Crea y Crece, MiCA, Art. 103(m) waiver text, GDPR retention) in `src/bot/payments/CLAUDE.md`. |
438
+ | `@adriangalilea/utils/bot/create` | `createBot({ token?, storage?, admins?, language?, menu?, access?, payments?, handlers?, worker? })` — the composer (see "One bot file, ideation → production" above). Returns `{ build, poll, isMain, fetch }`: `poll()` long-polls, `export default app` is a complete Worker. Owns storage+session wiring; resolves storage per environment (D1 binding → `bot/storage-d1`; `BOT_PERSIST` path → sqlite, `redis://` → redis, lazily-imported optional peers; else announced-ephemeral memory). |
439
+ | `@adriangalilea/utils/bot/worker` | `botWorkerFetch(resolve)` — the Cloudflare Worker cap: secret-checked webhook (ack fast, work + storage flush ride `ctx.waitUntil`, errors DM admins throttled), `POST /setup` (webhook registration with handler-derived `allowed_updates` + a commit-narrating 🚀 deploy DM from the request body), `POST /deploy-started` (the 🛳 "what is shipping" DM — curl it from your deploy script on the still-live version, body `{sha, author, message, etaSeconds?}`), operator-authed `/pause` `/resume` `/webhook-status` (+ `statusExtra`), and a `routes` escape hatch tried before the built-ins. Structural bot type — no workers-types dependency. |
440
+ | `@adriangalilea/utils/bot/storage-d1` | `d1Storage({ db, table? })` — `@gramio/storage` adapter over a D1 `session` table (schema in the module doc). `flush()` matters: the session plugin writes un-awaited and workerd freezes the isolate the instant `fetch()` returns — hand `flush` to `bot/worker` (automatic via `createBot`) so writes survive. |
397
441
 
398
442
  Standard wiring:
399
443
 
@@ -507,7 +551,7 @@ Field summary:
507
551
  - `action` returns `void | string | Polyglot<string>`; the menu plugin sends a single `answerCallbackQuery` with that text. **Never call `ctx.answer(...)` from inside an action** — Telegram rejects the second answer, the action throws, and `refresh` never runs.
508
552
  - `confirm: { prompt }` adds a one-step confirmation overlay before the action runs. Cancel returns to root. Use this for destructive actions instead of `ctx.answer({ show_alert: true })` — Telegram's alert UI doesn't compose with refresh / toast.
509
553
 
510
- **Live state inside resolvers**: `label` / `style` resolvers fire AFTER the action mutated the session. Read mutable state from `ctx.session.<field>` directly. `ctx.lang` from `bot/language` is a snapshot at event start and goes stale within the same callback; `ctx.say(...)` IS live and safe to use anywhere.
554
+ **Live state inside resolvers**: `label` / `style` / `header` / `visible` resolvers fire AFTER the action mutated the session, and they may be **async** — read your database (or `ctx.session.<field>`) directly at render time; never cache render strings into the session to satisfy a signature. `ctx.lang` from `bot/language` is a snapshot at event start and goes stale within the same callback; `ctx.say(...)` IS live and safe to use anywhere.
511
555
 
512
556
  See `src/bot/CLAUDE.md` for storage layout, design decisions, and gotchas.
513
557
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `createBot` — one bot file from ideation to production.
3
+ *
4
+ * The composer owns the WIRING that used to be documented foot-guns: it
5
+ * constructs the storage + session pair ONCE and threads it into every
6
+ * feature (menu, language, access, payments), so "must be the SAME instance
7
+ * you passed to session()" is unrepresentable instead of a doc warning.
8
+ *
9
+ * The same file runs in every stage — storage and transport are environment
10
+ * decisions, never code shape:
11
+ *
12
+ * ideation BOT_TOKEN=… tsx bot.ts memory session, long-poll
13
+ * experiment BOT_PERSIST=./bot.sqlite tsx bot.ts sqlite session, long-poll
14
+ * prod worker wrangler deploy (D1 binding `DB`) D1 session, webhook (bot/worker)
15
+ * prod server systemd/launchd unit running poll() sqlite/redis, long-poll
16
+ *
17
+ * @example
18
+ * const app = createBot({
19
+ * language: { supported: ["en", "es"] as const, default: "en" },
20
+ * menu: { adminContact: "@you", items: [...] },
21
+ * handlers: (bot) => bot.command("start", (ctx) => ctx.say({ en: "hi", es: "hola" })),
22
+ * })
23
+ * export default app // Worker: webhook + /setup + /pause + deploy DMs
24
+ * if (app.isMain(import.meta)) app.poll() // Node: `tsx bot.ts` long-polls
25
+ *
26
+ * Worker-safe by construction: the Node-only conveniences (sqlite/redis
27
+ * adapters) load lazily on the poll path only; `bot/kit` stays out of the
28
+ * graph entirely (wrap `app.poll` with `gracefulStart` yourself if you want
29
+ * signal-handled start/stop DMs on a server).
30
+ */
31
+ import { Bot } from "gramio";
32
+ import { type Storage } from "@gramio/storage";
33
+ import { type Admins } from "./admin.js";
34
+ import { type AccessControlOptions } from "./access-control.js";
35
+ import { type LanguageOptions } from "./language.js";
36
+ import { type BotMenuOptions, type PersonalDataOptions } from "./menu.js";
37
+ import { type BotPaymentsConfig } from "./payments/index.js";
38
+ import { type BotWorkerRuntime } from "./worker.js";
39
+ type EnvLike = Record<string, unknown>;
40
+ export type CreateBotOptions = {
41
+ /** Display name for startup logs. Default: the bot's username once known. */
42
+ name?: string;
43
+ /**
44
+ * Bot token. Default: the `BOT_TOKEN` environment value (process env on
45
+ * Node, the Worker env in workerd). Missing token throws at build — a bot
46
+ * with no token must scream, not idle.
47
+ */
48
+ token?: string | ((env: EnvLike) => string | undefined);
49
+ /**
50
+ * Session storage OVERRIDE. Default resolution (the lifecycle story):
51
+ * workerd → the `DB` D1 binding; Node → `BOT_PERSIST` (a path = sqlite via
52
+ * `@gramio/storage-sqlite`, `redis://…` = `@gramio/storage-redis`), else
53
+ * in-memory (ephemeral, announced at startup).
54
+ */
55
+ storage?: Storage;
56
+ /** Admin user ids (static or a LIVE resolver) — enables access/payments admin flows + lifecycle DMs. */
57
+ admins?: Admins;
58
+ /** UI language feature (`ctx.lang`/`ctx.say`, read-time stored→hint→default). */
59
+ language?: Omit<LanguageOptions<readonly string[]>, "session">;
60
+ /**
61
+ * /settings menu. `personalData` is auto-wired to the composer's storage —
62
+ * pass `personalData: { onForget }` to also wipe your own tables, or
63
+ * `personalData: false` to drop Forget/Export entirely. When `language` is
64
+ * configured its picker item is appended automatically (opt out with
65
+ * `languagePicker: false`).
66
+ */
67
+ menu?: Omit<BotMenuOptions, "personalData"> & {
68
+ personalData?: Pick<PersonalDataOptions, "onForget"> | false;
69
+ languagePicker?: boolean;
70
+ };
71
+ /** Gate the bot to admins + approved users (requires `admins`). */
72
+ access?: Omit<AccessControlOptions, "session" | "storage">;
73
+ /** Telegram Stars monetization (requires `admins` — the refund approver). */
74
+ payments?: Omit<BotPaymentsConfig<string>, "session" | "storage">;
75
+ /** Your commands/handlers — runs LAST, after every feature is wired. */
76
+ handlers?: (bot: Bot) => void;
77
+ /** Extra options forwarded to `bot/worker` in the Worker cap (routes, statusExtra, mode…). */
78
+ worker?: (env: EnvLike) => Partial<BotWorkerRuntime>;
79
+ };
80
+ export type BotApp = {
81
+ /** Build (memoized) — exposed for tests and custom runtimes. */
82
+ build(env?: EnvLike): Promise<{
83
+ bot: Bot;
84
+ storage: Storage;
85
+ flush?: () => Promise<void>;
86
+ }>;
87
+ /** Long-poll (Node/Bun/Deno). The ideation loop AND a legitimate prod mode on your own hardware. */
88
+ poll(): Promise<void>;
89
+ /** True when this module is the process entrypoint (`tsx bot.ts`). Always false on workerd. */
90
+ isMain(meta: ImportMeta): boolean;
91
+ /** The Worker fetch handler — `export default app` is a complete workerd bot. */
92
+ fetch(request: Request, env: EnvLike, ctx: {
93
+ waitUntil(p: Promise<unknown>): void;
94
+ }): Promise<Response>;
95
+ };
96
+ export declare function createBot(opts?: CreateBotOptions): BotApp;
97
+ export {};
98
+ //# sourceMappingURL=create.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/bot/create.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAC7B,OAAO,EAAE,KAAK,OAAO,EAAmB,MAAM,iBAAiB,CAAC;AAGhE,OAAO,EAAE,KAAK,MAAM,EAAgB,MAAM,YAAY,CAAC;AACvD,OAAO,EAAE,KAAK,oBAAoB,EAAiB,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,KAAK,eAAe,EAAY,MAAM,eAAe,CAAC;AAC/D,OAAO,EAAE,KAAK,cAAc,EAAiB,KAAK,mBAAmB,EAAW,MAAM,WAAW,CAAC;AAClG,OAAO,EAAE,KAAK,iBAAiB,EAAe,MAAM,qBAAqB,CAAC;AAG1E,OAAO,EAAE,KAAK,gBAAgB,EAAkB,MAAM,aAAa,CAAC;AAIpE,KAAK,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEvC,MAAM,MAAM,gBAAgB,GAAG;IAC9B,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;IACxD;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wGAAwG;IACxG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,QAAQ,CAAC,EAAE,IAAI,CAAC,eAAe,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,SAAS,CAAC,CAAC;IAC/D;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,cAAc,CAAC,GAAG;QAC7C,YAAY,CAAC,EAAE,IAAI,CAAC,mBAAmB,EAAE,UAAU,CAAC,GAAG,KAAK,CAAC;QAC7D,cAAc,CAAC,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,mEAAmE;IACnE,MAAM,CAAC,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;IAC3D,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;IAClE,wEAAwE;IACxE,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAC;IAC9B,8FAA8F;IAC9F,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;CACrD,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IACpB,gEAAgE;IAChE,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,GAAG,CAAC;QAAC,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC,CAAC;IAC3F,oGAAoG;IACpG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,+FAA+F;IAC/F,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC;IAClC,iFAAiF;IACjF,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE;QAAE,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACxG,CAAC;AAgBF,wBAAgB,SAAS,CAAC,IAAI,GAAE,gBAAqB,GAAG,MAAM,CA0H7D"}
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `createBot` — one bot file from ideation to production.
3
+ *
4
+ * The composer owns the WIRING that used to be documented foot-guns: it
5
+ * constructs the storage + session pair ONCE and threads it into every
6
+ * feature (menu, language, access, payments), so "must be the SAME instance
7
+ * you passed to session()" is unrepresentable instead of a doc warning.
8
+ *
9
+ * The same file runs in every stage — storage and transport are environment
10
+ * decisions, never code shape:
11
+ *
12
+ * ideation BOT_TOKEN=… tsx bot.ts memory session, long-poll
13
+ * experiment BOT_PERSIST=./bot.sqlite tsx bot.ts sqlite session, long-poll
14
+ * prod worker wrangler deploy (D1 binding `DB`) D1 session, webhook (bot/worker)
15
+ * prod server systemd/launchd unit running poll() sqlite/redis, long-poll
16
+ *
17
+ * @example
18
+ * const app = createBot({
19
+ * language: { supported: ["en", "es"] as const, default: "en" },
20
+ * menu: { adminContact: "@you", items: [...] },
21
+ * handlers: (bot) => bot.command("start", (ctx) => ctx.say({ en: "hi", es: "hola" })),
22
+ * })
23
+ * export default app // Worker: webhook + /setup + /pause + deploy DMs
24
+ * if (app.isMain(import.meta)) app.poll() // Node: `tsx bot.ts` long-polls
25
+ *
26
+ * Worker-safe by construction: the Node-only conveniences (sqlite/redis
27
+ * adapters) load lazily on the poll path only; `bot/kit` stays out of the
28
+ * graph entirely (wrap `app.poll` with `gracefulStart` yourself if you want
29
+ * signal-handled start/stop DMs on a server).
30
+ */
31
+ import { Bot } from "gramio";
32
+ import { inMemoryStorage } from "@gramio/storage";
33
+ import { createLogger } from "../universal/log.js";
34
+ import { runtime } from "../runtime.js";
35
+ import { adminContext } from "./admin.js";
36
+ import { accessControl } from "./access-control.js";
37
+ import { language } from "./language.js";
38
+ import { botMenu } from "./menu.js";
39
+ import { botPayments } from "./payments/index.js";
40
+ import { botSession } from "./session.js";
41
+ import { d1Storage } from "./storage-d1.js";
42
+ import { botWorkerFetch } from "./worker.js";
43
+ const log = createLogger("bot");
44
+ // Optional Node-only peers, loaded lazily on the poll path. The specifier goes
45
+ // through a variable so TS doesn't demand types for an optional dependency.
46
+ async function importPeer(name, why) {
47
+ try {
48
+ const specifier = name;
49
+ return await import(specifier);
50
+ }
51
+ catch {
52
+ throw new Error(`createBot: install ${name} for ${why}`);
53
+ }
54
+ }
55
+ const idsOf = (v) => (typeof v === "number" ? [v] : [...v]).filter((n) => Number.isFinite(n) && n > 0);
56
+ export function createBot(opts = {}) {
57
+ let built = null;
58
+ const resolveToken = (env) => {
59
+ const token = typeof opts.token === "string" ? opts.token : (opts.token?.(env) ?? env.BOT_TOKEN);
60
+ if (!token)
61
+ throw new Error("createBot: no token — set BOT_TOKEN or pass `token`");
62
+ return token;
63
+ };
64
+ const resolveStorage = async (env) => {
65
+ if (opts.storage)
66
+ return { storage: opts.storage, kind: "custom" };
67
+ const db = env.DB;
68
+ if (db && typeof db === "object" && "prepare" in db) {
69
+ const storage = d1Storage({ db });
70
+ return { storage, kind: "d1", flush: storage.flush };
71
+ }
72
+ const persist = env.BOT_PERSIST;
73
+ if (persist?.startsWith("redis://") || persist?.startsWith("rediss://")) {
74
+ const mod = await importPeer("@gramio/storage-redis", "BOT_PERSIST=redis://…");
75
+ const factory = mod.redisStorage;
76
+ if (!factory)
77
+ throw new Error("createBot: @gramio/storage-redis has no redisStorage export");
78
+ return { storage: factory(persist), kind: "redis" };
79
+ }
80
+ if (persist) {
81
+ const mod = (await importPeer("@gramio/storage-sqlite", "a BOT_PERSIST path"));
82
+ const factory = mod.sqliteStorage ?? mod.default;
83
+ if (!factory)
84
+ throw new Error("createBot: @gramio/storage-sqlite has no storage export");
85
+ return { storage: factory(persist), kind: `sqlite ${persist}` };
86
+ }
87
+ return { storage: inMemoryStorage(), kind: "memory (ephemeral — set BOT_PERSIST to keep state)" };
88
+ };
89
+ const build = (env) => {
90
+ built ??= (async () => {
91
+ const e = env ?? (runtime.isNode ? process.env : {});
92
+ const bot = new Bot(resolveToken(e));
93
+ const { storage, kind, flush } = await resolveStorage(e);
94
+ log.info(`session: ${kind}`);
95
+ // ONE session, threaded everywhere — the composer's whole job.
96
+ const session = botSession({ storage, initial: () => ({}) });
97
+ bot.extend(session);
98
+ const lang = opts.language ? language({ ...opts.language, session }) : null;
99
+ if (lang)
100
+ bot.extend(lang.plugin);
101
+ if (opts.admins)
102
+ bot.extend(adminContext(opts.admins));
103
+ if (opts.access) {
104
+ if (!opts.admins)
105
+ throw new Error("createBot: `access` needs `admins` (the approver)");
106
+ bot.extend(accessControl({ ...opts.access, session, storage }));
107
+ }
108
+ const payments = opts.payments ? botPayments({ ...opts.payments, session, storage }) : null;
109
+ if (payments) {
110
+ if (!opts.admins)
111
+ throw new Error("createBot: `payments` needs `admins` (the refund approver)");
112
+ bot.extend(payments.plugin);
113
+ }
114
+ if (opts.menu) {
115
+ const { personalData, languagePicker, items, ...menuOpts } = opts.menu;
116
+ const composed = [...(items ?? [])];
117
+ if (lang && languagePicker !== false)
118
+ composed.push(lang.menuItem);
119
+ if (payments)
120
+ composed.push(payments.menuItem);
121
+ bot.extend(botMenu({
122
+ ...menuOpts,
123
+ items: composed,
124
+ ...(personalData === false
125
+ ? {}
126
+ : { personalData: { storage, onForget: personalData?.onForget } }),
127
+ }).plugin);
128
+ }
129
+ opts.handlers?.(bot);
130
+ return { bot, storage, flush };
131
+ })();
132
+ return built;
133
+ };
134
+ const adminIds = () => {
135
+ const a = opts.admins;
136
+ if (a === undefined)
137
+ return undefined;
138
+ return typeof a === "function" ? async () => idsOf(await a()) : async () => idsOf(a);
139
+ };
140
+ const workerFetch = botWorkerFetch(async (env) => {
141
+ const { bot, flush } = await build(env);
142
+ return {
143
+ bot: bot,
144
+ webhookSecret: env.WEBHOOK_SECRET,
145
+ operatorSecret: env.OPERATOR_SECRET,
146
+ mode: env.MODE ?? opts.name,
147
+ adminIds: adminIds(),
148
+ flush,
149
+ ...opts.worker?.(env),
150
+ };
151
+ });
152
+ return {
153
+ build,
154
+ async poll() {
155
+ const { bot } = await build();
156
+ await bot.start();
157
+ log.ready(`${opts.name ?? "bot"} polling as @${bot.info?.username}`);
158
+ },
159
+ isMain(meta) {
160
+ const main = meta.main;
161
+ if (main !== undefined)
162
+ return main;
163
+ if (!runtime.isNode)
164
+ return false;
165
+ // Fallback for runtimes without import.meta.main: entrypoint filename match.
166
+ const argv1 = process.argv[1];
167
+ if (!argv1)
168
+ return false;
169
+ const tail = argv1.split("/").pop();
170
+ return !!tail && meta.url.endsWith(`/${tail}`);
171
+ },
172
+ fetch: workerFetch,
173
+ };
174
+ }
175
+ //# sourceMappingURL=create.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create.js","sourceRoot":"","sources":["../../src/bot/create.ts"],"names":[],"mappings":"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"}
@@ -12,9 +12,13 @@
12
12
  * ## What this plugin owns
13
13
  *
14
14
  * - Validates the supported BCP-47 language tags via `Intl.getCanonicalLocales`
15
- * - Resolves `ctx.lang` on every event (override → Telegram hint → default)
16
- * - Persists the user's chosen language as `ctx.session.language`
17
- * - Provides a `menuItem` for a `botMenu`'s language picker
15
+ * - Resolves `ctx.lang` AND `ctx.say` on every event (stored pick → Telegram
16
+ * hint → default), all READ-TIME: the hint is never persisted, so a user who
17
+ * switches their Telegram client language moves with it until they pick
18
+ * - Persists a language as `ctx.session.language` ONLY on an explicit pick
19
+ * (the menuItem action) — consumers must not auto-write inferred values
20
+ * - Provides a `menuItem` for a `botMenu`'s language picker (highlight shows
21
+ * the EFFECTIVE language, hint included)
18
22
  *
19
23
  * ## What this plugin does NOT own
20
24
  *
@@ -72,6 +76,14 @@ export type LangCode = string & {
72
76
  export declare const parseLangCode: (s: string) => LangCode;
73
77
  /** Non-throwing type guard. Same parser path as `parseLangCode`. */
74
78
  export declare const isLangCode: (s: unknown) => s is LangCode;
79
+ /**
80
+ * Primary subtag of a Telegram client language hint (`"pt-BR"` → `"pt"`),
81
+ * or undefined when absent/unusable. The shared normalizer behind every
82
+ * read-time hint fallback (`ctx.lang`, `ctx.say`, menu chrome): the hint is
83
+ * resolved live per event and NEVER persisted — only an explicit user pick
84
+ * writes `session.language`.
85
+ */
86
+ export declare const langHintOf: (code: string | undefined) => string | undefined;
75
87
  export type LanguageScopeStrategy = "user" | "chat";
76
88
  export type LanguageScope = LanguageScopeStrategy | {
77
89
  private?: LanguageScopeStrategy;
@@ -313,6 +325,14 @@ declare const buildLanguagePlugin: <Lang extends string>(args: {
313
325
  lang: Lang;
314
326
  say: Sayer<Lang>;
315
327
  };
328
+ inline_query: {
329
+ lang: Lang;
330
+ say: Sayer<Lang>;
331
+ };
332
+ chosen_inline_result: {
333
+ lang: Lang;
334
+ say: Sayer<Lang>;
335
+ };
316
336
  callback_query: {
317
337
  lang: Lang;
318
338
  say: Sayer<Lang>;
@@ -1 +1 @@
1
- {"version":3,"file":"language.d.ts","sourceRoot":"","sources":["../../src/bot/language.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"language.d.ts","sourceRoot":"","sources":["../../src/bot/language.ts"],"names":[],"mappings":"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"}
@@ -12,9 +12,13 @@
12
12
  * ## What this plugin owns
13
13
  *
14
14
  * - Validates the supported BCP-47 language tags via `Intl.getCanonicalLocales`
15
- * - Resolves `ctx.lang` on every event (override → Telegram hint → default)
16
- * - Persists the user's chosen language as `ctx.session.language`
17
- * - Provides a `menuItem` for a `botMenu`'s language picker
15
+ * - Resolves `ctx.lang` AND `ctx.say` on every event (stored pick → Telegram
16
+ * hint → default), all READ-TIME: the hint is never persisted, so a user who
17
+ * switches their Telegram client language moves with it until they pick
18
+ * - Persists a language as `ctx.session.language` ONLY on an explicit pick
19
+ * (the menuItem action) — consumers must not auto-write inferred values
20
+ * - Provides a `menuItem` for a `botMenu`'s language picker (highlight shows
21
+ * the EFFECTIVE language, hint included)
18
22
  *
19
23
  * ## What this plugin does NOT own
20
24
  *
@@ -76,6 +80,17 @@ export const isLangCode = (s) => {
76
80
  return false;
77
81
  }
78
82
  };
83
+ /**
84
+ * Primary subtag of a Telegram client language hint (`"pt-BR"` → `"pt"`),
85
+ * or undefined when absent/unusable. The shared normalizer behind every
86
+ * read-time hint fallback (`ctx.lang`, `ctx.say`, menu chrome): the hint is
87
+ * resolved live per event and NEVER persisted — only an explicit user pick
88
+ * writes `session.language`.
89
+ */
90
+ export const langHintOf = (code) => {
91
+ const primary = code?.toLowerCase().split("-")[0] ?? "";
92
+ return /^[a-z]{2,3}$/.test(primary) ? primary : undefined;
93
+ };
79
94
  /**
80
95
  * Builds `ctx.say` with LIVE lang resolution — the lang is re-read
81
96
  * from `ctx.session.language` on every `say(value)` / `.send` / `.edit`
@@ -89,11 +104,16 @@ export const isLangCode = (s) => {
89
104
  * directly (or call `ctx.say(...)`) when you need post-mutation
90
105
  * freshness.
91
106
  */
92
- const buildSayer = (ctx, canonicalSet, defaultLang) => {
107
+ const buildSayer = (ctx, canonicalSet, defaultLang,
108
+ /** Scope-resolved Telegram hint, precomputed by the derive (it can't change
109
+ * mid-handler); the read-time fallback between the stored pick and the default. */
110
+ hintLang) => {
93
111
  const c = ctx;
94
112
  const currentLang = () => {
95
113
  const stored = c.session?.language;
96
- return stored && canonicalSet.has(stored) ? stored : defaultLang;
114
+ if (stored && canonicalSet.has(stored))
115
+ return stored;
116
+ return hintLang ?? defaultLang;
97
117
  };
98
118
  const resolve = (value) => {
99
119
  const lang = currentLang();
@@ -217,6 +237,18 @@ export const language = (opts) => {
217
237
  }
218
238
  return undefined;
219
239
  };
240
+ // The user's EFFECTIVE language for chrome that renders outside the derive
241
+ // (the picker highlight): stored pick → Telegram hint (full tag, then primary
242
+ // subtag) → default. Read-time, mirrors the derive's chain.
243
+ const effectiveLang = (ctx) => {
244
+ const c = ctx;
245
+ const stored = c.session?.language;
246
+ if (stored && canonicalSet.has(stored))
247
+ return stored;
248
+ return (matchSupported(c.from?.languageCode) ??
249
+ matchSupported(langHintOf(c.from?.languageCode)) ??
250
+ defaultLanguage);
251
+ };
220
252
  const plugin = buildLanguagePlugin({
221
253
  sessionPlugin: opts.session,
222
254
  canonicalSet,
@@ -232,18 +264,15 @@ export const language = (opts) => {
232
264
  label: labels[l] ?? defaultLabel(l),
233
265
  // Pack the picker two languages per row (break after each odd index).
234
266
  keepRow: i % 2 === 0 && i < arr.length - 1,
235
- // The currently-selected language renders blue (Telegram's
236
- // `primary` style); the rest stay at app default. Replaces the
237
- // old `●` / `○` markers — same signal, native Telegram styling.
267
+ // The user's EFFECTIVE language renders blue (Telegram's `primary`
268
+ // style); the rest stay at app default. Replaces the old `●` / `○`
269
+ // markers — same signal, native Telegram styling.
238
270
  //
239
- // Reads `ctx.session.language` (the live store), NOT `ctx.lang`
240
- // (the event-start snapshot from the language derive — would
241
- // be stale within the same callback after the sibling action
242
- // mutated the session).
243
- style: (ctx) => ctx.session
244
- ?.language === l
245
- ? "primary"
246
- : undefined,
271
+ // Resolves stored → hint → default LIVE (not `ctx.lang`, the
272
+ // event-start snapshot — stale within the same callback after the
273
+ // sibling action mutated the session), so the highlight tracks
274
+ // reality even before any explicit pick exists.
275
+ style: (ctx) => (effectiveLang(ctx) === l ? "primary" : undefined),
247
276
  // Re-render the submenu in place after the tap so the colour
248
277
  // moves to the newly-selected language without the user having
249
278
  // to re-open the menu.
@@ -275,33 +304,40 @@ const buildLanguagePlugin = (args) => {
275
304
  // session derive runs exactly once per update — but the types
276
305
  // (ctx.session: SessionLike) flow into our handlers below.
277
306
  .extend(sessionPlugin)
278
- .derive(["message", "callback_query"], (ctx) => {
307
+ .derive(
308
+ // Inline events included: ctx.lang/ctx.say must exist wherever the
309
+ // user speaks to the bot, or consumers fork their own resolvers.
310
+ ["message", "callback_query", "inline_query", "chosen_inline_result"], (ctx) => {
311
+ // The Telegram hint, scope-resolved ONCE per event (it can't change
312
+ // mid-handler): honored only in user-scoped resolution — in groups it
313
+ // would flicker per-speaker. Full tag first ("pt-BR"), then its
314
+ // primary subtag ("pt"). Never persisted: the read-time middle rung
315
+ // between a stored explicit pick and the configured default.
316
+ // Inline events carry no chat — they resolve as "private" (user-scoped),
317
+ // which is what an inline interaction is. Structural read: `message`
318
+ // only exists on callback ctxs in the widened event union.
319
+ const chatType = ctx.is("message")
320
+ ? ctx.chat.type
321
+ : (ctx.message?.chat
322
+ .type ?? "private");
323
+ const strategy = resolveScope(scopeOpt, chatType);
324
+ const hintLang = strategy === "user"
325
+ ? (matchSupported(ctx.from.languageCode) ??
326
+ matchSupported(langHintOf(ctx.from.languageCode)))
327
+ : undefined;
279
328
  // Snapshot resolved at event start. Stays static through the
280
329
  // handler — see the JSDoc on `LanguageDerives.lang` below for
281
330
  // the staleness gotcha and why `ctx.say` is the live escape.
282
- const resolveLang = () => {
283
- // 1) stored override
284
- const stored = ctx.session.language;
285
- if (stored && canonicalSet.has(stored))
286
- return stored;
287
- // 2) Telegram hint — only when in user-scoped resolution
288
- const chatType = ctx.is("message")
289
- ? ctx.chat.type
290
- : (ctx.message?.chat.type ?? "private");
291
- const strategy = resolveScope(scopeOpt, chatType);
292
- if (strategy === "user") {
293
- const hint = matchSupported(ctx.from.languageCode);
294
- if (hint)
295
- return hint;
296
- }
297
- // 3) configured default
298
- return defaultLanguage;
299
- };
331
+ const stored = ctx.session.language;
332
+ const lang = stored && canonicalSet.has(stored)
333
+ ? stored
334
+ : (hintLang ?? defaultLanguage);
300
335
  return {
301
- lang: resolveLang(),
336
+ lang,
302
337
  // LIVE: re-resolves ctx.session.language on every call so
303
- // post-mutation reads inside a handler get the new value.
304
- say: buildSayer(ctx, canonicalSet, defaultLanguage),
338
+ // post-mutation reads inside a handler get the new value;
339
+ // falls to the hint, then the default, when nothing is stored.
340
+ say: buildSayer(ctx, canonicalSet, defaultLanguage, hintLang),
305
341
  };
306
342
  }));
307
343
  };