@adriangalilea/utils 2.6.0 → 2.8.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
@@ -462,9 +462,11 @@ Then `pnpm install`. Every `ctx.send` / `ctx.sendDocument` / `ctx.reply` / etc.
462
462
  | `@adriangalilea/utils/bot/groups` | Group-chat identity as plain functions: `chatIdOf(ctx)` (the chat id on any ctx flavour), `isGroupChat(ctx)` / `isPrivateChat(ctx)`, and `isGroupAdmin(ctx, { chatId?, userId? }?)` — the `getChatMember` creator/administrator check behind every admin-gated group setting (auto-summary toggles, "dismiss" buttons on group welcomes), chat/user defaulted from the ctx. Every reader resolves both gramio spellings — message ctxs carry `chat`, callback ctxs (inline-button taps) only `chatId`/`message.chat` — so the same gate works in handlers and taps; read chat ids via `chatIdOf`, never `ctx.chat?.id`. Takes real gramio ctxs and `MenuCtx` with no cast; a miswired ctx panics, an API rejection fails closed (`false`). Composition over policy: bot-owner override stays at your call site (`ctx.isAdmin \|\| await isGroupAdmin(ctx)`). |
463
463
  | `@adriangalilea/utils/bot/language` (picker surface) | The language-picker vocabulary and surfaces, so bots don't fork label lists: `flagFor` / `autonym` / `languageLabel` ("🇪🇸 Español"), `languagePickerItem({ label, codes, isActive, pick })` (the 2-up primary-highlighted MenuItem factory — storage and policy live in your closures), and `addLanguageRows(kb, { codes, pack, active?, activeStyle? })` (the raw-InlineKeyboard twin for onboarding / group-welcome keyboards; `activeStyle: "success"` when primary already marks something else on the keyboard). |
464
464
  | `@adriangalilea/utils/bot/user` | `userLabel(u)` — the conditional "[name] [@username] [id]" line every bot re-rolls for admin DMs and logs: `Ada Lovelace (@ada · 42)` / `Ada (42)` / `@ada (42)` / `id 42`, missing pieces drop instead of padding. Reads both gramio spellings (`firstName` / `first_name`); plain text by design. |
465
+ | `@adriangalilea/utils/bot/inline-feedback` | Behavioral tripwire for BotFather's inline feedback probability (gates `chosen_inline_result`; defaults 0%, silently resets on bot transfer and inline-mode toggling, invisible to `getMe`): served-but-never-chosen crosses a threshold → throttled admin DM naming the exact BotFather switch. Storage injected as three ops; never throws into the answer path. |
465
466
  | `@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. |
466
467
  | `@adriangalilea/utils/llm` | Multi-provider LLM caller on the Vercel AI SDK — the policy layer the SDK doesn't ship. `createLlm({ providers, health? })` → `.stream(req)` (typed events: `delta` / `reasoning` / `reset` / `tool-call` / `end`) and `.complete(req)`. **Failover:** priority-ordered across OpenAI-compatible, Anthropic-compatible, and OpenRouter endpoints; multiple keys per provider (health tracked per key fingerprint); per-key circuit breaker with exponential cool-down persisted in any KV-shaped `HealthStore`; an attempt that dies after emitting yields `reset` (discard, next candidate regenerates); empty completions count as failures. **Accounting:** tokens summed across billed attempts; `costUsd` is the ACTUAL charge (OpenRouter usage accounting via `providerMetadata`, or a `cost` field on the raw usage frame) — never a price-table estimate. **Knobs:** per-model temperature, maxTokens caps, `disableThinking` mapped to each dialect. **Tools:** AI SDK `tool()` + `toolChoice` pass through (re-exported). Worker-safe; peers: `ai`, `@ai-sdk/openai-compatible`, `@ai-sdk/anthropic`, `@openrouter/ai-sdk-provider`. |
467
468
  | `@adriangalilea/utils/bot/llm` | The Telegram side of an LLM chatbot; the model side is `@adriangalilea/utils/llm`. **Output:** `streamChatReply(ctx, events, opts?)` consumes an `AsyncIterable<LlmStreamEvent>` and paints it with Telegram's native message-draft streaming (`sendMessageDraft` full-frame repaints, throttled ~1/s, keepalive under the ~30s draft TTL), then persists the finished markdown via `ctx.send`, entity-split across 4096 by `@gramio/split`. Reasoning models get a thinking phase — streams into the ephemeral draft and evaporates (`reasoning: 'preview'`, default), persists as an expandable blockquote (`'message'`), or never renders at all (`'hidden'`). Upstream `reset` (provider failover) repaints the draft from scratch. Drafts are private-chat-only (+ BotFather forum-topic mode); elsewhere the preview phase is skipped and only the final send happens. Returns `{ content, reasoning, toolCalls, usage, messages }`. The painter underneath is `@adriangalilea/utils/bot/draft` (`createDraftPreview(ctx, { render, throttleMs? })`): dependency-light (no markdown machinery), for producers that push deltas or own their per-frame rendering (plain or rich-message frames) and persist path, with `streamForensics` (render-regression + reset warnings) built in and exported standalone. **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 history-only clear when no `threadId` is present). |
469
+ | `@adriangalilea/utils/bot/metrics` | Self-serve product measurement, the flags pattern applied to counting: `defineMetrics(spec, { write, funnels? })` — declare a metric once (counter / timing, `perUser: true` for uniques·repeat·retention·funnels), `bump()`/`record()` at the call site (fire-and-forget, never throws), `describe()` is the schema panels render generically so a new metric reaches every panel with zero panel edits. Storage = one injected atomic day-bucket upsert (aggregate row + per-user row when `user` given); per-user rows are personal data — wipe on forget, prune by age. Money stays in ledgers. |
468
470
  | `@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. |
469
471
  | `@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`. |
470
472
  | `@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`. |
@@ -18,11 +18,13 @@ export * from "./coalesce.js";
18
18
  export * from "./ctx.js";
19
19
  export * from "./draft.js";
20
20
  export * from "./groups.js";
21
+ export * from "./inline-feedback.js";
21
22
  export * from "./keys.js";
22
23
  export * from "./kit.js";
23
24
  export * from "./language.js";
24
25
  export * from "./llm.js";
25
26
  export * from "./menu.js";
27
+ export * from "./metrics.js";
26
28
  export * from "./notify.js";
27
29
  export * from "./payments/index.js";
28
30
  export * from "./storage.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
package/dist/bot/index.js CHANGED
@@ -18,11 +18,13 @@ export * from "./coalesce.js";
18
18
  export * from "./ctx.js";
19
19
  export * from "./draft.js";
20
20
  export * from "./groups.js";
21
+ export * from "./inline-feedback.js";
21
22
  export * from "./keys.js";
22
23
  export * from "./kit.js";
23
24
  export * from "./language.js";
24
25
  export * from "./llm.js";
25
26
  export * from "./menu.js";
27
+ export * from "./metrics.js";
26
28
  export * from "./notify.js";
27
29
  export * from "./payments/index.js";
28
30
  export * from "./storage.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Inline-feedback tripwire — behavioral detection for a BotFather setting the
3
+ * Bot API cannot see.
4
+ *
5
+ * `chosen_inline_result` delivery is gated by BotFather's inline FEEDBACK
6
+ * probability, which defaults to 0% and silently RESETS on bot transfer and
7
+ * on inline-mode toggling. At 0%, taps post the placeholder client-side but
8
+ * the bot never hears about them, so nothing ever fills in — and `getMe`
9
+ * reports nothing (the profile `expects` drift-check is blind here). The
10
+ * asymmetry IS measurable though: results keep getting served while a chosen
11
+ * event never arrives.
12
+ *
13
+ * Storage is injected as three atomic-ish ops (wire to D1/Redis/anything);
14
+ * this module owns the thresholds, the throttle, and the operator message.
15
+ * Thresholds are deliberately lazy — this is a drift alarm, not a metric.
16
+ *
17
+ * @example
18
+ * const probe = inlineFeedbackProbe({
19
+ * bump: () => store.bumpInlineServed(ctx),
20
+ * reset: () => store.recordInlineChosen(ctx, Date.now()),
21
+ * markAlerted: () => store.markInlineAlerted(ctx, Date.now()),
22
+ * adminIds: () => deps.adminIds(),
23
+ * })
24
+ * bot.inlineQuery(async (ctx) => { …answer with results…; await probe.onServed(ctx.bot) })
25
+ * bot.chosenInlineResult(() => true, async (ctx) => { await probe.onChosen(); … })
26
+ */
27
+ import type { Bot } from "gramio";
28
+ export interface InlineFeedbackProbeOptions {
29
+ /** Increment the served counter, returning the new count + when the last alert fired. */
30
+ bump: () => Promise<{
31
+ served: number;
32
+ lastAlertAt: number | null;
33
+ }>;
34
+ /** A chosen event arrived: feedback works, the counter starts over. */
35
+ reset: () => Promise<void>;
36
+ /** Persist that the alert fired (also zeroes the counter downstream). */
37
+ markAlerted: () => Promise<void>;
38
+ adminIds: () => Promise<readonly number[]> | readonly number[];
39
+ /** Served results (WITH content — empty answers prove nothing) before the alarm. Default 25. */
40
+ threshold?: number;
41
+ /** Minimum ms between alerts. Default 24h. */
42
+ alertEveryMs?: number;
43
+ /** Called with the alarm line before the DM goes out (wire your logger). */
44
+ onTrip?: (message: string) => void;
45
+ }
46
+ export interface InlineFeedbackProbe {
47
+ /** Call after answering an inline query WITH results. Never throws. */
48
+ onServed(bot: Bot): Promise<void>;
49
+ /** Call from the chosen_inline_result handler. Never throws. */
50
+ onChosen(): Promise<void>;
51
+ }
52
+ export declare function inlineFeedbackProbe(opts: InlineFeedbackProbeOptions): InlineFeedbackProbe;
53
+ //# sourceMappingURL=inline-feedback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inline-feedback.d.ts","sourceRoot":"","sources":["../../src/bot/inline-feedback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAGlC,MAAM,WAAW,0BAA0B;IAC1C,yFAAyF;IACzF,IAAI,EAAE,MAAM,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;IACpE,uEAAuE;IACvE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,yEAAyE;IACzE,WAAW,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,QAAQ,EAAE,MAAM,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC;IAC/D,gGAAgG;IAChG,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,mBAAmB;IACnC,uEAAuE;IACvE,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClC,gEAAgE;IAChE,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAKD,wBAAgB,mBAAmB,CAClC,IAAI,EAAE,0BAA0B,GAC9B,mBAAmB,CA6BrB"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Inline-feedback tripwire — behavioral detection for a BotFather setting the
3
+ * Bot API cannot see.
4
+ *
5
+ * `chosen_inline_result` delivery is gated by BotFather's inline FEEDBACK
6
+ * probability, which defaults to 0% and silently RESETS on bot transfer and
7
+ * on inline-mode toggling. At 0%, taps post the placeholder client-side but
8
+ * the bot never hears about them, so nothing ever fills in — and `getMe`
9
+ * reports nothing (the profile `expects` drift-check is blind here). The
10
+ * asymmetry IS measurable though: results keep getting served while a chosen
11
+ * event never arrives.
12
+ *
13
+ * Storage is injected as three atomic-ish ops (wire to D1/Redis/anything);
14
+ * this module owns the thresholds, the throttle, and the operator message.
15
+ * Thresholds are deliberately lazy — this is a drift alarm, not a metric.
16
+ *
17
+ * @example
18
+ * const probe = inlineFeedbackProbe({
19
+ * bump: () => store.bumpInlineServed(ctx),
20
+ * reset: () => store.recordInlineChosen(ctx, Date.now()),
21
+ * markAlerted: () => store.markInlineAlerted(ctx, Date.now()),
22
+ * adminIds: () => deps.adminIds(),
23
+ * })
24
+ * bot.inlineQuery(async (ctx) => { …answer with results…; await probe.onServed(ctx.bot) })
25
+ * bot.chosenInlineResult(() => true, async (ctx) => { await probe.onChosen(); … })
26
+ */
27
+ import { notifyAdmins } from "./notify.js";
28
+ const DEFAULT_THRESHOLD = 25;
29
+ const DEFAULT_ALERT_EVERY_MS = 24 * 60 * 60 * 1000;
30
+ export function inlineFeedbackProbe(opts) {
31
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
32
+ const alertEveryMs = opts.alertEveryMs ?? DEFAULT_ALERT_EVERY_MS;
33
+ return {
34
+ async onServed(bot) {
35
+ try {
36
+ const { served, lastAlertAt } = await opts.bump();
37
+ if (served < threshold)
38
+ return;
39
+ const now = Date.now();
40
+ if (lastAlertAt !== null && now - lastAlertAt < alertEveryMs)
41
+ return;
42
+ await opts.markAlerted();
43
+ const line = `inline feedback looks DISABLED: ${served} inline results served since the last chosen_inline_result`;
44
+ opts.onTrip?.(line);
45
+ const ids = await opts.adminIds();
46
+ await notifyAdmins(bot, ids, `⚠️ ${line}.\n` +
47
+ "Taps post the placeholder but the bot never hears about them, so nothing ever fills in.\n" +
48
+ "@BotFather → /setinlinefeedback → this bot → 100%. (It resets to 0% on bot TRANSFER and on inline-mode toggling.)");
49
+ }
50
+ catch {
51
+ // best-effort: the probe must never break the inline answer path
52
+ }
53
+ },
54
+ async onChosen() {
55
+ await opts.reset().catch(() => { });
56
+ },
57
+ };
58
+ }
59
+ //# sourceMappingURL=inline-feedback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inline-feedback.js","sourceRoot":"","sources":["../../src/bot/inline-feedback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAyB3C,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAC7B,MAAM,sBAAsB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEnD,MAAM,UAAU,mBAAmB,CAClC,IAAgC;IAEhC,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,iBAAiB,CAAC;IACtD,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,IAAI,sBAAsB,CAAC;IACjE,OAAO;QACN,KAAK,CAAC,QAAQ,CAAC,GAAG;YACjB,IAAI,CAAC;gBACJ,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;gBAClD,IAAI,MAAM,GAAG,SAAS;oBAAE,OAAO;gBAC/B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;gBACvB,IAAI,WAAW,KAAK,IAAI,IAAI,GAAG,GAAG,WAAW,GAAG,YAAY;oBAAE,OAAO;gBACrE,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;gBACzB,MAAM,IAAI,GAAG,mCAAmC,MAAM,4DAA4D,CAAC;gBACnH,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,CAAC;gBACpB,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAClC,MAAM,YAAY,CACjB,GAAyC,EACzC,GAAG,EACH,MAAM,IAAI,KAAK;oBACd,2FAA2F;oBAC3F,mHAAmH,CACpH,CAAC;YACH,CAAC;YAAC,MAAM,CAAC;gBACR,iEAAiE;YAClE,CAAC;QACF,CAAC;QACD,KAAK,CAAC,QAAQ;YACb,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QACpC,CAAC;KACD,CAAC;AACH,CAAC"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Metric registry — self-serve product measurement, the `bot/flags` pattern
3
+ * applied to counting. Declare a metric ONCE in code and it exists
4
+ * everywhere: `metrics.<key>.bump()` / `.record(value)` at the call site,
5
+ * generic day-bucketed storage underneath (no migration per metric), and
6
+ * `metrics.describe()` as the schema admin panels render generically — a new
7
+ * metric reaches every panel with zero panel edits.
8
+ *
9
+ * Built to answer PRODUCT questions, not just count:
10
+ * volume how many times → counter
11
+ * averages how long / how big → timing (count + sum → avg)
12
+ * uniques how many distinct users → perUser: true
13
+ * repeat used it again same day? → per-user day rows with count > 1
14
+ * retention came back another day? → same user on ≥ 2 distinct days
15
+ * funnels of those who did A, who did B → declared pairs over the
16
+ * shared user space; panels render conversion % generically
17
+ * depth how far do they go → per-user daily counts distribute
18
+ *
19
+ * Storage is ONE injected write op; the atomic upserts live with the
20
+ * consumer's database. Contract: always add (count, sum) to the day bucket
21
+ * for `key`; when `user` is present, ALSO to the per-user day bucket, e.g.:
22
+ *
23
+ * INSERT INTO metric (key, day, count, sum) VALUES (?1, date('now'), ?2, ?3)
24
+ * ON CONFLICT (key, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
25
+ * -- and when user is present:
26
+ * INSERT INTO metric_user (key, user, day, count, sum) VALUES (?1, ?4, date('now'), ?2, ?3)
27
+ * ON CONFLICT (key, user, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
28
+ *
29
+ * Per-user rows are personal data: wipe them in your forget-user path, and
30
+ * prune by age (growth is users × metrics × days). Writes are FIRE-AND-FORGET
31
+ * and never throw — measurement must never break the product. The registry
32
+ * IS the cardinality cap: keys are declared identifiers, never free-form
33
+ * strings. Money stays in ledgers; this is operational/product counting.
34
+ *
35
+ * @example
36
+ * const metrics = defineMetrics({
37
+ * chapterOpens: { kind: "counter", label: "chapter opens", perUser: true },
38
+ * chapterPages: { kind: "counter", label: "chapter pages", perUser: true, help: "Back/Next taps" },
39
+ * chapterLatency: { kind: "timing", label: "chapter latency", unit: "ms" },
40
+ * }, {
41
+ * write: (key, addCount, addSum, user) => store.metricBump(key, addCount, addSum, user),
42
+ * funnels: [["chapterOpens", "chapterPages"]],
43
+ * })
44
+ *
45
+ * void metrics.chapterOpens.bump({ user: ctx.from.id })
46
+ * void metrics.chapterLatency.record(Date.now() - startedAt)
47
+ * metrics.describe() // → { metrics: [...], funnels: [...] } — panels iterate this
48
+ */
49
+ /** One metric declaration. `perUser: true` unlocks uniques/repeat/retention/funnels for it. */
50
+ export type MetricSpec = {
51
+ kind: "counter";
52
+ label: string;
53
+ help?: string;
54
+ perUser?: boolean;
55
+ } | {
56
+ kind: "timing";
57
+ label: string;
58
+ help?: string;
59
+ unit?: string;
60
+ perUser?: boolean;
61
+ };
62
+ export interface MetricsOptions<Keys extends string = string> {
63
+ /**
64
+ * The atomic day-bucket upsert(s): add `addCount`/`addSum` to today's row
65
+ * for `key`, and — when `user` is present — to the per-user day row too
66
+ * (one batch). Failures are swallowed by the registry; make the op itself
67
+ * atomic.
68
+ */
69
+ write: (key: string, addCount: number, addSum: number, user?: number) => Promise<unknown>;
70
+ /**
71
+ * Declared conversion pairs over perUser metrics ("of users who did FROM,
72
+ * how many did TO"). Panels render these generically; they are queries
73
+ * over the shared user space, not extra storage.
74
+ */
75
+ funnels?: ReadonlyArray<readonly [Keys, Keys]>;
76
+ }
77
+ export interface CounterHandle {
78
+ /** Count `n` events (default 1); pass `user` on perUser metrics. Fire-and-forget, never throws. */
79
+ bump(opts?: {
80
+ user?: number;
81
+ n?: number;
82
+ }): Promise<void>;
83
+ }
84
+ export interface TimingHandle {
85
+ /** Record one sample; pass `user` on perUser metrics. Fire-and-forget, never throws. */
86
+ record(value: number, opts?: {
87
+ user?: number;
88
+ }): Promise<void>;
89
+ }
90
+ export interface MetricsSchema {
91
+ metrics: Array<{
92
+ key: string;
93
+ } & MetricSpec>;
94
+ funnels: Array<{
95
+ from: string;
96
+ to: string;
97
+ }>;
98
+ }
99
+ export type Metrics<Spec extends Record<string, MetricSpec>> = {
100
+ [K in keyof Spec]: Spec[K]["kind"] extends "counter" ? CounterHandle : TimingHandle;
101
+ } & {
102
+ /** The registry schema, for panels to render generically. */
103
+ describe(): MetricsSchema;
104
+ };
105
+ export declare function defineMetrics<const Spec extends Record<string, MetricSpec>>(spec: Spec, opts: MetricsOptions<Extract<keyof Spec, string>>): Metrics<Spec>;
106
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/bot/metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,+FAA+F;AAC/F,MAAM,MAAM,UAAU,GACnB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GACpE;IACA,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEL,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IAC3D;;;;;OAKG;IACH,KAAK,EAAE,CACN,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,KACT,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;CAC/C;AAED,MAAM,WAAW,aAAa;IAC7B,mGAAmG;IACnG,IAAI,CAAC,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1D;AACD,MAAM,WAAW,YAAY;IAC5B,wFAAwF;IACxF,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAED,MAAM,WAAW,aAAa;IAC7B,OAAO,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,CAAC,CAAC;IAC7C,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC7C;AAED,MAAM,MAAM,OAAO,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,IAAI;KAC7D,CAAC,IAAI,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,SAAS,GACjD,aAAa,GACb,YAAY;CACf,GAAG;IACH,6DAA6D;IAC7D,QAAQ,IAAI,aAAa,CAAC;CAC1B,CAAC;AAEF,wBAAgB,aAAa,CAAC,KAAK,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,EAC1E,IAAI,EAAE,IAAI,EACV,IAAI,EAAE,cAAc,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,MAAM,CAAC,CAAC,GAC/C,OAAO,CAAC,IAAI,CAAC,CA2Cf"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Metric registry — self-serve product measurement, the `bot/flags` pattern
3
+ * applied to counting. Declare a metric ONCE in code and it exists
4
+ * everywhere: `metrics.<key>.bump()` / `.record(value)` at the call site,
5
+ * generic day-bucketed storage underneath (no migration per metric), and
6
+ * `metrics.describe()` as the schema admin panels render generically — a new
7
+ * metric reaches every panel with zero panel edits.
8
+ *
9
+ * Built to answer PRODUCT questions, not just count:
10
+ * volume how many times → counter
11
+ * averages how long / how big → timing (count + sum → avg)
12
+ * uniques how many distinct users → perUser: true
13
+ * repeat used it again same day? → per-user day rows with count > 1
14
+ * retention came back another day? → same user on ≥ 2 distinct days
15
+ * funnels of those who did A, who did B → declared pairs over the
16
+ * shared user space; panels render conversion % generically
17
+ * depth how far do they go → per-user daily counts distribute
18
+ *
19
+ * Storage is ONE injected write op; the atomic upserts live with the
20
+ * consumer's database. Contract: always add (count, sum) to the day bucket
21
+ * for `key`; when `user` is present, ALSO to the per-user day bucket, e.g.:
22
+ *
23
+ * INSERT INTO metric (key, day, count, sum) VALUES (?1, date('now'), ?2, ?3)
24
+ * ON CONFLICT (key, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
25
+ * -- and when user is present:
26
+ * INSERT INTO metric_user (key, user, day, count, sum) VALUES (?1, ?4, date('now'), ?2, ?3)
27
+ * ON CONFLICT (key, user, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
28
+ *
29
+ * Per-user rows are personal data: wipe them in your forget-user path, and
30
+ * prune by age (growth is users × metrics × days). Writes are FIRE-AND-FORGET
31
+ * and never throw — measurement must never break the product. The registry
32
+ * IS the cardinality cap: keys are declared identifiers, never free-form
33
+ * strings. Money stays in ledgers; this is operational/product counting.
34
+ *
35
+ * @example
36
+ * const metrics = defineMetrics({
37
+ * chapterOpens: { kind: "counter", label: "chapter opens", perUser: true },
38
+ * chapterPages: { kind: "counter", label: "chapter pages", perUser: true, help: "Back/Next taps" },
39
+ * chapterLatency: { kind: "timing", label: "chapter latency", unit: "ms" },
40
+ * }, {
41
+ * write: (key, addCount, addSum, user) => store.metricBump(key, addCount, addSum, user),
42
+ * funnels: [["chapterOpens", "chapterPages"]],
43
+ * })
44
+ *
45
+ * void metrics.chapterOpens.bump({ user: ctx.from.id })
46
+ * void metrics.chapterLatency.record(Date.now() - startedAt)
47
+ * metrics.describe() // → { metrics: [...], funnels: [...] } — panels iterate this
48
+ */
49
+ export function defineMetrics(spec, opts) {
50
+ for (const [key, s] of Object.entries(spec)) {
51
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key))
52
+ throw new Error(`metrics: invalid key "${key}"`);
53
+ if (!s.label)
54
+ throw new Error(`metrics: "${key}" needs a label`);
55
+ }
56
+ for (const [from, to] of opts.funnels ?? []) {
57
+ for (const k of [from, to]) {
58
+ if (!spec[k])
59
+ throw new Error(`metrics: funnel references unknown metric "${k}"`);
60
+ if (!spec[k].perUser)
61
+ throw new Error(`metrics: funnel metric "${k}" must be perUser (funnels join on user)`);
62
+ }
63
+ }
64
+ const out = {};
65
+ for (const [key, s] of Object.entries(spec)) {
66
+ out[key] =
67
+ s.kind === "counter"
68
+ ? {
69
+ async bump(o) {
70
+ await opts
71
+ .write(key, o?.n ?? 1, 0, s.perUser ? o?.user : undefined)
72
+ .catch(() => { });
73
+ },
74
+ }
75
+ : {
76
+ async record(value, o) {
77
+ await opts
78
+ .write(key, 1, value, s.perUser ? o?.user : undefined)
79
+ .catch(() => { });
80
+ },
81
+ };
82
+ }
83
+ return {
84
+ ...out,
85
+ describe: () => ({
86
+ metrics: Object.entries(spec).map(([key, s]) => ({ key, ...s })),
87
+ funnels: (opts.funnels ?? []).map(([from, to]) => ({ from, to })),
88
+ }),
89
+ };
90
+ }
91
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.js","sourceRoot":"","sources":["../../src/bot/metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAyDH,MAAM,UAAU,aAAa,CAC5B,IAAU,EACV,IAAiD;IAEjD,KAAK,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC7C,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,GAAG,CAAC;YACvC,MAAM,IAAI,KAAK,CAAC,yBAAyB,GAAG,GAAG,CAAC,CAAC;QAClD,IAAI,CAAC,CAAC,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,aAAa,GAAG,iBAAiB,CAAC,CAAC;IAClE,CAAC;IACD,KAAK,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC;QAC7C,KAAK,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC;YAC5B,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;gBACX,MAAM,IAAI,KAAK,CAAC,8CAA8C,CAAC,GAAG,CAAC,CAAC;YACrE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO;gBACnB,MAAM,IAAI,KAAK,CACd,2BAA2B,CAAC,0CAA0C,CACtE,CAAC;QACJ,CAAC;IACF,CAAC;IAED,MAAM,GAAG,GAAiD,EAAE,CAAC;IAC7D,KAAK,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC7C,GAAG,CAAC,GAAG,CAAC;YACP,CAAC,CAAC,IAAI,KAAK,SAAS;gBACnB,CAAC,CAAC;oBACA,KAAK,CAAC,IAAI,CAAC,CAAiC;wBAC3C,MAAM,IAAI;6BACR,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;6BACzD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;oBACnB,CAAC;iBACD;gBACF,CAAC,CAAC;oBACA,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,CAAqB;wBAChD,MAAM,IAAI;6BACR,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;6BACrD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;oBACnB,CAAC;iBACD,CAAC;IACN,CAAC;IACD,OAAO;QACN,GAAG,GAAG;QACN,QAAQ,EAAE,GAAkB,EAAE,CAAC,CAAC;YAC/B,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;YAChE,OAAO,EAAE,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;SACjE,CAAC;KACe,CAAC;AACpB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adriangalilea/utils",
3
- "version": "2.6.0",
3
+ "version": "2.8.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",
@@ -147,6 +147,14 @@
147
147
  "./llm": {
148
148
  "types": "./dist/llm/index.d.ts",
149
149
  "default": "./dist/llm/index.js"
150
+ },
151
+ "./bot/inline-feedback": {
152
+ "types": "./dist/bot/inline-feedback.d.ts",
153
+ "default": "./dist/bot/inline-feedback.js"
154
+ },
155
+ "./bot/metrics": {
156
+ "types": "./dist/bot/metrics.d.ts",
157
+ "default": "./dist/bot/metrics.js"
150
158
  }
151
159
  },
152
160
  "files": [