@adriangalilea/utils 1.2.0 → 1.4.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
@@ -359,6 +359,17 @@ await ctx.say.edit({ en: 'Done', es: 'Listo' }) // → ctx.editText (callback
359
359
  await ctx.say.answer({ en: 'OK', es: 'OK' }) // → ctx.answer (callback only)
360
360
  ```
361
361
 
362
+ ### Telegram HTML (`tg-html`)
363
+
364
+ Transform arbitrary (LLM-emitted) HTML into Telegram-compatible `parse_mode=HTML` with opinionated, consistent spacing. Telegram's HTML subset has no headings, lists, or block layout — send it `<h1>`/`<ul>` and the API rejects the whole message. `transform()` accepts the HTML a model naturally writes and renders the structure typographically: `h1` → bold+underline + blank line, `h2…h6` → bold + blank line, `ul/li` → `• ` bullets, blocks get blank lines, blockquotes keep their internal line breaks, allowed tags pass through with attributes filtered, unknown tags drop their markup (content kept), and stray `<`/`>` escape instead of vanishing. Zero dependencies, no DOM, Worker-safe. Successor of the standalone `tghtml` package (jsr, archived).
365
+
366
+ ```typescript
367
+ import { transform } from '@adriangalilea/utils/tg-html'
368
+
369
+ transform('<h1>Title</h1><ul><li>Point one</li><li>Point two</li></ul>')
370
+ // '<b><u>Title</u></b>\n\n• Point one\n• Point two'
371
+ ```
372
+
362
373
  ### Telegram bot plugins (GramIO)
363
374
 
364
375
  Plugins for personal Telegram bots built on [GramIO](https://gramio.dev). Each plugin lives at its own subpath; peer deps (`gramio`, `@gramio/storage`, `@gramio/session`, `@gramio/format`, `marked`) are **all optional** — install only what you import.
@@ -429,12 +440,13 @@ Then `pnpm install`. Every `ctx.send` / `ctx.sendDocument` / `ctx.reply` / etc.
429
440
  | `@adriangalilea/utils/bot/kit` | The Node-only corner. `gracefulStart(bot, opts?)` — SIGINT/SIGTERM → `bot.stop()` → exit; force-kills if shutdown hangs. DMs the admin `@<bot> started.` / `@<bot> shutting down.` by default when `KEV.TELEGRAM_ADMIN_ID` is set (graceful only — crashes don't trigger `onStop`); pass `notifyAdmin: false` to disable or `notifyAdmin: 12345` for an explicit chat id.<br>`adminContext({ adminId? })` — reads `TELEGRAM_ADMIN_ID` from `kev` (with optional hardcoded fallback), decorates `ctx.adminId` + `ctx.isAdmin`.<br>Also re-exports `botSession` / `prefixStorage` from `bot/session` so Node consumers keep one import. |
430
441
  | `@adriangalilea/utils/bot/session` | `botSession(opts)` — **drop-in replacement for `@gramio/session`'s `session()`** that auto-namespaces every key as `bot-<id>:<senderId>` using `ctx.bot.info.id` (populated by `getMe()` at startup). Use this instead of `session()` — full stop. Multiple bots sharing one Redis/D1 stay isolated by construction; every plugin in this package derives the same prefix internally via `botStorageKey(ctx, userId)` / `botSubKey(ctx, sub)` (from `bot/keys`). No regex, no manual prefix argument, no way to forget.<br>`prefixStorage(storage, prefix)` — escape hatch for adding a top-level prefix on top of the bot-id namespace; almost never needed. Worker-safe (used to live in `bot/kit`, which forced Worker bots to hand-roll the keying). |
431
442
  | `@adriangalilea/utils/bot/notify` | Best-effort admin DMs, worker-safe (you pass the ids; no env, no process). `notifyAdmins(bot, adminIds, text, extra?)` — DM each admin, failures logged and swallowed (a notification must never take the bot down). `alertAdminError(bot, adminIds, label, error, throttle?)` — truncated `🚨 label\nName: message`, rate-limited through a caller-owned `alertThrottle(ms?)` so a failure storm sends one alert per window. `gracefulStart`'s start/stop DMs are built on this. |
443
+ | `@adriangalilea/utils/bot/profile` | `syncBotProfile(bot, { name?, description?, about?, commands?, expects?, adminIds? })` — the bot's Telegram-facing identity as CODE, never BotFather: localized name / description / About / public command list, reconciled idempotently on every boot (get → compare → set per field per language; unchanged values cost one read, so cold-start firing is rate-limit-free). `expects: { inline: true }` declares the BotFather-only capabilities the code assumes — a mismatch (inline mode off on an inline-dependent bot) DMs the admins, since the API can only detect it, not fix it. Never throws; failures log and the bot keeps running. |
432
444
  | `@adriangalilea/utils/bot/access-control` | Personal-bot ACL — gates non-admin/non-default users; admin gets DM with `[✅ Aprobar][❌ Denegar]` on first attempt; `/access` opens a persistent menu (revoke / reapprove / list pending). Backed by `@gramio/session` per-user + a small index. **Native alternative**: BotFather → Bot Settings → Access → "Restrict bot usage" — flat allow-list at Telegram. Use this plugin when you want in-bot approval flow instead of a BotFather round-trip; both can coexist. |
433
445
  | `@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. |
434
446
  | `@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. |
435
447
  | `@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). |
436
448
  | `@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. |
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`. |
449
+ | `@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)` / `onRefunded(...)` register fire-and-forget hooks (purchase applied / admin-approved refund — a revenue ledger writes on one, reverses on the other). **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
450
  | `@adriangalilea/utils/bot/create` | `createBot<S>({ token?, storage?, initial?, admins?, language?, menu?, access?, payments?, handlers?, worker? })` — the composer (see "One bot file, ideation → production" above). Returns `{ build, session, poll, isMain, fetch }`: `poll()` long-polls, `export default app` is a complete Worker, `app.session(ctx)` / the `handlers` callback's `session(ctx)` is the TYPED accessor for your `S` fields. 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) — or pass `storage: (env) => Storage` when the choice is env-dependent (e.g. a D1 binding not named `DB`). Boot NARRATES the composition (`session: memory …`, `features: language(en,es) · menu(/settings)`) so every implicit decision is visible where you're looking. Runnable demo: `pnpm demo:bot`. |
439
451
  | `@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
452
  | `@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. |
@@ -0,0 +1,59 @@
1
+ /** One public slash command as Telegram publishes it. */
2
+ export interface BotCommandEntry {
3
+ command: string;
4
+ description: string;
5
+ }
6
+ /** Per-language values; the FIRST key doubles as the unlocalized default. */
7
+ export type Localized<T> = Record<string, T>;
8
+ /** The structural bot surface the sync needs (satisfied by a gramio Bot). */
9
+ export type ProfileBot = {
10
+ info?: {
11
+ username?: string;
12
+ supports_inline_queries?: boolean;
13
+ };
14
+ api: {
15
+ getMyName: (p: object) => Promise<{
16
+ name?: string;
17
+ }>;
18
+ setMyName: (p: object) => Promise<unknown>;
19
+ getMyDescription: (p: object) => Promise<{
20
+ description?: string;
21
+ }>;
22
+ setMyDescription: (p: object) => Promise<unknown>;
23
+ getMyShortDescription: (p: object) => Promise<{
24
+ short_description?: string;
25
+ }>;
26
+ setMyShortDescription: (p: object) => Promise<unknown>;
27
+ getMyCommands: (p: object) => Promise<ReadonlyArray<BotCommandEntry>>;
28
+ setMyCommands: (p: object) => Promise<unknown>;
29
+ sendMessage: (p: {
30
+ chat_id: number;
31
+ text: string;
32
+ }) => Promise<unknown>;
33
+ };
34
+ };
35
+ export interface BotProfileOptions {
36
+ /** Display name (`setMyName`, 64 chars). */
37
+ name?: Localized<string>;
38
+ /** "What can this bot do?" (`setMyDescription`, 512 chars). */
39
+ description?: Localized<string>;
40
+ /** About / short description (`setMyShortDescription`, 120 chars). */
41
+ about?: Localized<string>;
42
+ /** Public command list per language (admin commands stay out — they're behavior, not menu). */
43
+ commands?: Localized<ReadonlyArray<BotCommandEntry>>;
44
+ /**
45
+ * BotFather-only capabilities this bot's features assume. Checked against `getMe` on every
46
+ * sync; a mismatch WARNS the admins (it can't be fixed over the API, only surfaced).
47
+ */
48
+ expects?: {
49
+ inline?: boolean;
50
+ };
51
+ /** Admins to DM on an expectation mismatch: a plain array or a live resolver. */
52
+ adminIds?: readonly number[] | (() => Promise<readonly number[]> | readonly number[]);
53
+ }
54
+ /**
55
+ * Reconcile the declared profile with Telegram, field by field, language by language —
56
+ * writes only genuine diffs. Never throws. Call fire-and-forget after `bot.init()`.
57
+ */
58
+ export declare function syncBotProfile(bot: ProfileBot, opts: BotProfileOptions): Promise<void>;
59
+ //# sourceMappingURL=profile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../../src/bot/profile.ts"],"names":[],"mappings":"AA0CA,yDAAyD;AACzD,MAAM,WAAW,eAAe;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,6EAA6E;AAC7E,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;AAE7C,6EAA6E;AAC7E,MAAM,MAAM,UAAU,GAAG;IACxB,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,uBAAuB,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAChE,GAAG,EAAE;QACJ,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACrD,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;QAC3C,gBAAgB,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;YAAE,WAAW,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACnE,gBAAgB,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;QAClD,qBAAqB,EAAE,CACtB,CAAC,EAAE,MAAM,KACL,OAAO,CAAC;YAAE,iBAAiB,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QAC7C,qBAAqB,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;QACvD,aAAa,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,CAAC;QACtE,aAAa,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;QAC/C,WAAW,EAAE,CAAC,CAAC,EAAE;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;KACxE,CAAC;CACF,CAAC;AAEF,MAAM,WAAW,iBAAiB;IACjC,4CAA4C;IAC5C,IAAI,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IACzB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IAChC,sEAAsE;IACtE,KAAK,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IAC1B,+FAA+F;IAC/F,QAAQ,CAAC,EAAE,SAAS,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,CAAC;IACrD;;;OAGG;IACH,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,EACN,SAAS,MAAM,EAAE,GACjB,CAAC,MAAM,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC;CAC1D;AAqCD;;;GAGG;AACH,wBAAsB,cAAc,CACnC,GAAG,EAAE,UAAU,EACf,IAAI,EAAE,iBAAiB,GACrB,OAAO,CAAC,IAAI,CAAC,CAkEf"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `bot/profile` — the bot's Telegram-facing identity as CODE, synced idempotently.
3
+ *
4
+ * A bot should never depend on values typed into BotFather: name, description,
5
+ * About, and the public command list are all settable over the API, so they
6
+ * belong in the repo — reviewable, localized, and reproducible on a fresh
7
+ * token. `syncBotProfile` declares them once and reconciles on every boot with
8
+ * a get → compare → set diff-guard per field per language: unchanged values
9
+ * cost one read, so firing it on every Worker cold start is free of rate-limit
10
+ * risk. Failures are logged and swallowed — a profile hiccup must never take
11
+ * the bot down.
12
+ *
13
+ * What the API canNOT set, BotFather still owns: the token itself and the
14
+ * inline-mode switch. Those are declared as EXPECTATIONS instead — the sync
15
+ * checks `getMe` and DMs the admins when reality disagrees with what the code
16
+ * assumes (an inline-dependent bot silently losing inline mode is exactly the
17
+ * kind of drift nobody notices until users do).
18
+ *
19
+ * @example
20
+ * import { syncBotProfile } from '@adriangalilea/utils/bot/profile'
21
+ *
22
+ * void syncBotProfile(bot, {
23
+ * description: { en: 'What I do…', es: 'Lo que hago…' }, // first key = the default
24
+ * about: { en: '⚡ short pitch', es: '⚡ pitch corto' },
25
+ * commands: {
26
+ * en: [{ command: 'start', description: 'Set up' }],
27
+ * es: [{ command: 'start', description: 'Configurar' }],
28
+ * },
29
+ * expects: { inline: true },
30
+ * adminIds: () => store.readAdminIds(ctx, []), // live resolver or plain array
31
+ * })
32
+ */
33
+ import { createLogger } from "../universal/log.js";
34
+ import { notifyAdmins } from "./notify.js";
35
+ const log = createLogger("bot/profile");
36
+ // Telegram caps, counted in code points so emoji are one glyph each.
37
+ const NAME_MAX = 64;
38
+ const DESCRIPTION_MAX = 512;
39
+ const ABOUT_MAX = 120;
40
+ /** Count Unicode code points (emoji = 1), for Telegram's limits. */
41
+ function glyphs(s) {
42
+ return [...s].length;
43
+ }
44
+ function commandsEqual(a, b) {
45
+ if (a.length !== b.length)
46
+ return false;
47
+ return a.every((cmd, i) => cmd.command === b[i].command && cmd.description === b[i].description);
48
+ }
49
+ function overLimit(field, values, max) {
50
+ for (const [lang, value] of Object.entries(values)) {
51
+ if (glyphs(value) > max) {
52
+ log.error(`${field} (${lang}) exceeds ${max} chars — skipping ${field} sync`);
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ const langParam = (lang) => lang ? { language_code: lang } : {};
59
+ /**
60
+ * Reconcile the declared profile with Telegram, field by field, language by language —
61
+ * writes only genuine diffs. Never throws. Call fire-and-forget after `bot.init()`.
62
+ */
63
+ export async function syncBotProfile(bot, opts) {
64
+ const jobs = [];
65
+ const eachLang = (values, sync) => {
66
+ const entries = Object.entries(values);
67
+ if (entries.length === 0)
68
+ return;
69
+ jobs.push(sync(undefined, entries[0][1])); // first entry = the unlocalized default
70
+ for (const [lang, value] of entries)
71
+ jobs.push(sync(lang, value));
72
+ };
73
+ if (opts.name && !overLimit("name", opts.name, NAME_MAX)) {
74
+ eachLang(opts.name, async (lang, value) => {
75
+ const current = await bot.api.getMyName(langParam(lang));
76
+ if (current.name === value)
77
+ return;
78
+ await bot.api.setMyName({ name: value, ...langParam(lang) });
79
+ log.info(`name synced${lang ? ` (${lang})` : ""}`);
80
+ });
81
+ }
82
+ if (opts.description &&
83
+ !overLimit("description", opts.description, DESCRIPTION_MAX)) {
84
+ eachLang(opts.description, async (lang, value) => {
85
+ const current = await bot.api.getMyDescription(langParam(lang));
86
+ if (current.description === value)
87
+ return;
88
+ await bot.api.setMyDescription({
89
+ description: value,
90
+ ...langParam(lang),
91
+ });
92
+ log.info(`description synced${lang ? ` (${lang})` : ""}`);
93
+ });
94
+ }
95
+ if (opts.about && !overLimit("about", opts.about, ABOUT_MAX)) {
96
+ eachLang(opts.about, async (lang, value) => {
97
+ const current = await bot.api.getMyShortDescription(langParam(lang));
98
+ if (current.short_description === value)
99
+ return;
100
+ await bot.api.setMyShortDescription({
101
+ short_description: value,
102
+ ...langParam(lang),
103
+ });
104
+ log.info(`about synced${lang ? ` (${lang})` : ""}`);
105
+ });
106
+ }
107
+ if (opts.commands) {
108
+ eachLang(opts.commands, async (lang, list) => {
109
+ const current = await bot.api.getMyCommands(langParam(lang));
110
+ if (commandsEqual(current, list))
111
+ return;
112
+ await bot.api.setMyCommands({ commands: [...list], ...langParam(lang) });
113
+ log.info(`commands synced${lang ? ` (${lang})` : ""} (${list.length})`);
114
+ });
115
+ }
116
+ try {
117
+ await Promise.all(jobs);
118
+ }
119
+ catch (e) {
120
+ log.error(`profile sync failed: ${e instanceof Error ? e.message : e}`);
121
+ }
122
+ await checkExpectations(bot, opts).catch((e) => log.error(`expectation check failed: ${e instanceof Error ? e.message : e}`));
123
+ }
124
+ /** The BotFather-drift check: warn the admins when a declared capability is off. */
125
+ async function checkExpectations(bot, opts) {
126
+ if (!opts.expects?.inline)
127
+ return;
128
+ if (bot.info?.supports_inline_queries !== false)
129
+ return; // true, or unknown (no getMe yet)
130
+ const handle = bot.info?.username ? `@${bot.info.username}` : "this bot";
131
+ log.warn(`inline mode is OFF for ${handle} — the bot's inline features are dead`);
132
+ const ids = typeof opts.adminIds === "function"
133
+ ? await opts.adminIds()
134
+ : (opts.adminIds ?? []);
135
+ if (ids.length === 0)
136
+ return;
137
+ await notifyAdmins(bot, ids, `⚠️ ${handle}: inline mode is OFF, but this bot's features depend on it.\n` +
138
+ "Only BotFather can enable it: @BotFather → /setinline (and /setinlinefeedback).");
139
+ }
140
+ //# sourceMappingURL=profile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile.js","sourceRoot":"","sources":["../../src/bot/profile.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `tg-html` — transform arbitrary (LLM-emitted) HTML into Telegram-compatible
3
+ * `parse_mode=HTML` with opinionated, consistent spacing.
4
+ *
5
+ * Telegram's HTML subset has no headings, lists, or block layout — send it
6
+ * `<h1>`/`<ul>` and the API rejects the whole message. This module accepts the
7
+ * HTML a model naturally writes and renders the structure typographically:
8
+ *
9
+ * h1 → <b><u>…</u></b> + blank line
10
+ * h2…h6 → <b>…</b> + blank line
11
+ * p/div/section/article/… → content + blank line
12
+ * ul/ol/li → `• ` bullets, blank line after the list
13
+ * br → newline
14
+ * blockquote → kept (newlines inside preserved), newline after
15
+ * b/i/u/s/code/pre/a/… → kept, attributes filtered to Telegram's allow-list
16
+ * spoiler / span.tg-spoiler → <tg-spoiler>
17
+ * unknown letter-tags → dropped, content kept (hallucinated markup)
18
+ * stray < > → escaped, never eaten
19
+ *
20
+ * Zero dependencies, no DOM: a single-pass tokenizer over a small recursive
21
+ * renderer, safe everywhere (Workers included). Successor of the standalone
22
+ * `tghtml` package (jsr:@adriangalilea/tghtml, now archived), with its lossy
23
+ * edge cases fixed: `x<5 and y>10` escapes instead of vanishing, and unknown
24
+ * tags drop their markup instead of leaking `small…/small` fragments.
25
+ *
26
+ * @example
27
+ * import { transform } from '@adriangalilea/utils/tg-html'
28
+ *
29
+ * transform('<h1>Title</h1><ul><li>Point</li></ul>')
30
+ * // '<b><u>Title</u></b>\n\n• Point'
31
+ */
32
+ /**
33
+ * Transform HTML into Telegram-compatible `parse_mode=HTML` with consistent
34
+ * spacing. Never throws; pathological input degrades to escaped plain text.
35
+ */
36
+ export declare function transform(html: string): string;
37
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tg-html/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AA4PH;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAa9C"}
@@ -0,0 +1,254 @@
1
+ /**
2
+ * `tg-html` — transform arbitrary (LLM-emitted) HTML into Telegram-compatible
3
+ * `parse_mode=HTML` with opinionated, consistent spacing.
4
+ *
5
+ * Telegram's HTML subset has no headings, lists, or block layout — send it
6
+ * `<h1>`/`<ul>` and the API rejects the whole message. This module accepts the
7
+ * HTML a model naturally writes and renders the structure typographically:
8
+ *
9
+ * h1 → <b><u>…</u></b> + blank line
10
+ * h2…h6 → <b>…</b> + blank line
11
+ * p/div/section/article/… → content + blank line
12
+ * ul/ol/li → `• ` bullets, blank line after the list
13
+ * br → newline
14
+ * blockquote → kept (newlines inside preserved), newline after
15
+ * b/i/u/s/code/pre/a/… → kept, attributes filtered to Telegram's allow-list
16
+ * spoiler / span.tg-spoiler → <tg-spoiler>
17
+ * unknown letter-tags → dropped, content kept (hallucinated markup)
18
+ * stray < > → escaped, never eaten
19
+ *
20
+ * Zero dependencies, no DOM: a single-pass tokenizer over a small recursive
21
+ * renderer, safe everywhere (Workers included). Successor of the standalone
22
+ * `tghtml` package (jsr:@adriangalilea/tghtml, now archived), with its lossy
23
+ * edge cases fixed: `x<5 and y>10` escapes instead of vanishing, and unknown
24
+ * tags drop their markup instead of leaking `small…/small` fragments.
25
+ *
26
+ * @example
27
+ * import { transform } from '@adriangalilea/utils/tg-html'
28
+ *
29
+ * transform('<h1>Title</h1><ul><li>Point</li></ul>')
30
+ * // '<b><u>Title</u></b>\n\n• Point'
31
+ */
32
+ // Tags Telegram's parse_mode=HTML accepts verbatim.
33
+ const TELEGRAM_TAGS = new Set([
34
+ "b",
35
+ "strong",
36
+ "i",
37
+ "em",
38
+ "u",
39
+ "ins",
40
+ "s",
41
+ "strike",
42
+ "del",
43
+ "span",
44
+ "tg-spoiler",
45
+ "a",
46
+ "code",
47
+ "pre",
48
+ "blockquote",
49
+ "tg-emoji",
50
+ ]);
51
+ // Attributes kept per tag; everything else is dropped.
52
+ const TELEGRAM_ATTRS = {
53
+ a: ["href"],
54
+ span: ["class"],
55
+ blockquote: ["expandable"],
56
+ code: ["class"],
57
+ "tg-emoji": ["emoji-id"],
58
+ };
59
+ // Structural tags this module renders typographically (they can't reach Telegram).
60
+ const STRUCTURAL = new Set([
61
+ "h1",
62
+ "h2",
63
+ "h3",
64
+ "h4",
65
+ "h5",
66
+ "h6",
67
+ "p",
68
+ "div",
69
+ "section",
70
+ "article",
71
+ "header",
72
+ "footer",
73
+ "ul",
74
+ "ol",
75
+ "li",
76
+ "br",
77
+ "spoiler",
78
+ // Document wrappers a model sometimes emits around its answer; rendered transparent.
79
+ "html",
80
+ "head",
81
+ "body",
82
+ ]);
83
+ // Void tags: never expect a closing counterpart.
84
+ const VOID_TAGS = new Set(["br", "meta", "hr", "img", "input"]);
85
+ // A tag token must start with a letter (like real HTML): `<1mb>` or `<5` is text, not markup.
86
+ const TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
87
+ const ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
88
+ function parseAttrs(raw) {
89
+ const attrs = {};
90
+ for (const m of raw.matchAll(ATTR_RE)) {
91
+ attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
92
+ }
93
+ return attrs;
94
+ }
95
+ /** Tokenize + tree-build in one pass. Mismatched closers close intervening elements (recovery). */
96
+ function parse(html) {
97
+ const root = {
98
+ kind: "element",
99
+ tag: "#root",
100
+ attrs: {},
101
+ children: [],
102
+ };
103
+ const stack = [root];
104
+ let last = 0;
105
+ const pushText = (text) => {
106
+ if (text)
107
+ stack[stack.length - 1].children.push({ kind: "text", text });
108
+ };
109
+ for (const m of html.matchAll(TAG_RE)) {
110
+ pushText(html.slice(last, m.index));
111
+ last = (m.index ?? 0) + m[0].length;
112
+ const closing = m[1] === "/";
113
+ const tag = m[2].toLowerCase();
114
+ const selfClosing = m[4] === "/" || VOID_TAGS.has(tag);
115
+ const known = TELEGRAM_TAGS.has(tag) || STRUCTURAL.has(tag);
116
+ if (!known)
117
+ continue; // hallucinated markup: drop the tag, keep surrounding content
118
+ if (closing) {
119
+ // Close up to the matching open element, if any (recovery for mismatches).
120
+ for (let at = stack.length - 1; at > 0; at--) {
121
+ if (stack[at].tag === tag) {
122
+ stack.length = at;
123
+ break;
124
+ }
125
+ }
126
+ continue;
127
+ }
128
+ const el = {
129
+ kind: "element",
130
+ tag,
131
+ attrs: parseAttrs(m[3]),
132
+ children: [],
133
+ };
134
+ stack[stack.length - 1].children.push(el);
135
+ if (!selfClosing)
136
+ stack.push(el);
137
+ }
138
+ pushText(html.slice(last));
139
+ return root.children;
140
+ }
141
+ // ─── rendering ──────────────────────────────────────────────────────
142
+ function escapeText(s) {
143
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
144
+ }
145
+ function escapeAttr(s) {
146
+ return escapeText(s).replace(/"/g, "&quot;");
147
+ }
148
+ function renderText(text, mode) {
149
+ const escaped = escapeText(text);
150
+ if (mode === "verbatim")
151
+ return escaped;
152
+ if (mode === "quote") {
153
+ return escaped
154
+ .replace(/[ \t]+/g, " ")
155
+ .replace(/\n[ \t]+/g, "\n")
156
+ .replace(/[ \t]+\n/g, "\n")
157
+ .replace(/\n{3,}/g, "\n\n");
158
+ }
159
+ return escaped.replace(/\s+/g, " ");
160
+ }
161
+ function renderChildren(el, mode) {
162
+ let out = "";
163
+ for (const child of el.children) {
164
+ out +=
165
+ child.kind === "text"
166
+ ? renderText(child.text, mode)
167
+ : renderElement(child, mode);
168
+ }
169
+ return out;
170
+ }
171
+ function keptAttrs(el) {
172
+ let out = "";
173
+ for (const name of TELEGRAM_ATTRS[el.tag] ?? []) {
174
+ if (!(name in el.attrs))
175
+ continue;
176
+ let value = el.attrs[name];
177
+ // Scheme-less links get https; only http(s)/tg pass through untouched.
178
+ if (el.tag === "a" &&
179
+ name === "href" &&
180
+ value &&
181
+ !/^(https?|tg):/i.test(value)) {
182
+ value = `https://${value}`;
183
+ }
184
+ out += ` ${name}="${escapeAttr(value)}"`;
185
+ }
186
+ return out;
187
+ }
188
+ function renderElement(el, mode) {
189
+ const tag = el.tag;
190
+ if (tag === "br")
191
+ return "\n";
192
+ if (tag === "h1")
193
+ return `<b><u>${renderChildren(el, "collapse").trim()}</u></b>\n\n`;
194
+ if (/^h[2-6]$/.test(tag))
195
+ return `<b>${renderChildren(el, "collapse").trim()}</b>\n\n`;
196
+ if (tag === "ul" || tag === "ol") {
197
+ const items = el.children
198
+ .filter((c) => c.kind === "element" && c.tag === "li")
199
+ .map((li) => `• ${renderChildren(li, "collapse").trim()}`);
200
+ return `${items.join("\n")}\n\n`;
201
+ }
202
+ if (tag === "li")
203
+ return `• ${renderChildren(el, "collapse").trim()}\n`;
204
+ if ([
205
+ "p",
206
+ "div",
207
+ "section",
208
+ "article",
209
+ "header",
210
+ "footer",
211
+ "html",
212
+ "head",
213
+ "body",
214
+ ].includes(tag)) {
215
+ return `${renderChildren(el, mode).trim()}\n\n`;
216
+ }
217
+ if (tag === "blockquote") {
218
+ const expandable = "expandable" in el.attrs ? ' expandable=""' : "";
219
+ return `<blockquote${expandable}>${renderChildren(el, "quote").trim()}</blockquote>\n`;
220
+ }
221
+ if (tag === "spoiler")
222
+ return `<tg-spoiler>${renderChildren(el, "collapse").trim()}</tg-spoiler>`;
223
+ if (tag === "span") {
224
+ // Only the spoiler span means anything to Telegram; other spans are transparent.
225
+ if (el.attrs.class !== "tg-spoiler")
226
+ return renderChildren(el, mode);
227
+ return `<span class="tg-spoiler">${renderChildren(el, "collapse").trim()}</span>`;
228
+ }
229
+ if (TELEGRAM_TAGS.has(tag)) {
230
+ // Quote mode applies to a blockquote's DIRECT text only: an inline tag inside it
231
+ // collapses its own whitespace like anywhere else.
232
+ const inner = tag === "pre" || tag === "code" ? "verbatim" : "collapse";
233
+ return `<${tag}${keptAttrs(el)}>${renderChildren(el, inner).trim()}</${tag}>`;
234
+ }
235
+ return renderChildren(el, mode);
236
+ }
237
+ /**
238
+ * Transform HTML into Telegram-compatible `parse_mode=HTML` with consistent
239
+ * spacing. Never throws; pathological input degrades to escaped plain text.
240
+ */
241
+ export function transform(html) {
242
+ if (!html.trim())
243
+ return "";
244
+ return parse(html)
245
+ .map((n) => n.kind === "text"
246
+ ? renderText(n.text, "collapse")
247
+ : renderElement(n, "collapse"))
248
+ .join("")
249
+ .replace(/\n{3,}/g, "\n\n")
250
+ .replace(/[ \t]+\n/g, "\n")
251
+ .replace(/\n +/g, "\n")
252
+ .trim();
253
+ }
254
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/tg-html/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adriangalilea/utils",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "TypeScript utilities - logger, currency, formatter, GramIO bot plugins, and more",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/say/index.d.ts",
41
41
  "default": "./dist/say/index.js"
42
42
  },
43
+ "./tg-html": {
44
+ "types": "./dist/tg-html/index.d.ts",
45
+ "default": "./dist/tg-html/index.js"
46
+ },
43
47
  "./bot": {
44
48
  "types": "./dist/bot/index.d.ts",
45
49
  "default": "./dist/bot/index.js"
@@ -115,6 +119,10 @@
115
119
  "./bot/create": {
116
120
  "types": "./dist/bot/create.d.ts",
117
121
  "default": "./dist/bot/create.js"
122
+ },
123
+ "./bot/profile": {
124
+ "types": "./dist/bot/profile.d.ts",
125
+ "default": "./dist/bot/profile.js"
118
126
  }
119
127
  },
120
128
  "files": [
@@ -137,7 +145,8 @@
137
145
  "ci": "biome ci",
138
146
  "prepublishOnly": "pnpm run build",
139
147
  "test:worker-safe": "pnpm run build && tsx tests/worker-safe-test.ts",
140
- "demo:bot": "tsx tests/create-demo.ts"
148
+ "demo:bot": "tsx tests/create-demo.ts",
149
+ "test:tg-html": "tsx tests/tg-html-test.ts"
141
150
  },
142
151
  "keywords": [
143
152
  "typescript",