@adriangalilea/utils 0.18.0 → 0.19.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.
Files changed (97) hide show
  1. package/README.md +2 -1
  2. package/dist/bot/access-control.d.ts.map +1 -1
  3. package/dist/bot/access-control.js +17 -1
  4. package/dist/bot/access-control.js.map +1 -1
  5. package/dist/bot/callbacks.d.ts +89 -0
  6. package/dist/bot/callbacks.d.ts.map +1 -0
  7. package/dist/bot/callbacks.js +128 -0
  8. package/dist/bot/callbacks.js.map +1 -0
  9. package/dist/bot/ctx.d.ts +219 -0
  10. package/dist/bot/ctx.d.ts.map +1 -0
  11. package/dist/bot/ctx.js +52 -0
  12. package/dist/bot/ctx.js.map +1 -0
  13. package/dist/bot/index.d.ts +4 -0
  14. package/dist/bot/index.d.ts.map +1 -1
  15. package/dist/bot/index.js +4 -0
  16. package/dist/bot/index.js.map +1 -1
  17. package/dist/bot/kit.d.ts +1 -1
  18. package/dist/bot/kit.d.ts.map +1 -1
  19. package/dist/bot/kit.js +4 -2
  20. package/dist/bot/kit.js.map +1 -1
  21. package/dist/bot/menu.d.ts +23 -1
  22. package/dist/bot/menu.d.ts.map +1 -1
  23. package/dist/bot/menu.js +30 -1
  24. package/dist/bot/menu.js.map +1 -1
  25. package/dist/bot/payments/callbacks.d.ts +28 -0
  26. package/dist/bot/payments/callbacks.d.ts.map +1 -0
  27. package/dist/bot/payments/callbacks.js +102 -0
  28. package/dist/bot/payments/callbacks.js.map +1 -0
  29. package/dist/bot/payments/commands.d.ts +26 -0
  30. package/dist/bot/payments/commands.d.ts.map +1 -0
  31. package/dist/bot/payments/commands.js +41 -0
  32. package/dist/bot/payments/commands.js.map +1 -0
  33. package/dist/bot/payments/config.d.ts +30 -0
  34. package/dist/bot/payments/config.d.ts.map +1 -0
  35. package/dist/bot/payments/config.js +166 -0
  36. package/dist/bot/payments/config.js.map +1 -0
  37. package/dist/bot/payments/derive.d.ts +75 -0
  38. package/dist/bot/payments/derive.d.ts.map +1 -0
  39. package/dist/bot/payments/derive.js +151 -0
  40. package/dist/bot/payments/derive.js.map +1 -0
  41. package/dist/bot/payments/handlers.d.ts +113 -0
  42. package/dist/bot/payments/handlers.d.ts.map +1 -0
  43. package/dist/bot/payments/handlers.js +284 -0
  44. package/dist/bot/payments/handlers.js.map +1 -0
  45. package/dist/bot/payments/index.d.ts +17 -0
  46. package/dist/bot/payments/index.d.ts.map +1 -0
  47. package/dist/bot/payments/index.js +14 -0
  48. package/dist/bot/payments/index.js.map +1 -0
  49. package/dist/bot/payments/invoice.d.ts +101 -0
  50. package/dist/bot/payments/invoice.d.ts.map +1 -0
  51. package/dist/bot/payments/invoice.js +213 -0
  52. package/dist/bot/payments/invoice.js.map +1 -0
  53. package/dist/bot/payments/menu-item.d.ts +41 -0
  54. package/dist/bot/payments/menu-item.d.ts.map +1 -0
  55. package/dist/bot/payments/menu-item.js +259 -0
  56. package/dist/bot/payments/menu-item.js.map +1 -0
  57. package/dist/bot/payments/payload.d.ts +42 -0
  58. package/dist/bot/payments/payload.d.ts.map +1 -0
  59. package/dist/bot/payments/payload.js +69 -0
  60. package/dist/bot/payments/payload.js.map +1 -0
  61. package/dist/bot/payments/payouts.d.ts +88 -0
  62. package/dist/bot/payments/payouts.d.ts.map +1 -0
  63. package/dist/bot/payments/payouts.js +133 -0
  64. package/dist/bot/payments/payouts.js.map +1 -0
  65. package/dist/bot/payments/plugin.d.ts +271 -0
  66. package/dist/bot/payments/plugin.d.ts.map +1 -0
  67. package/dist/bot/payments/plugin.js +141 -0
  68. package/dist/bot/payments/plugin.js.map +1 -0
  69. package/dist/bot/payments/refund.d.ts +124 -0
  70. package/dist/bot/payments/refund.d.ts.map +1 -0
  71. package/dist/bot/payments/refund.js +308 -0
  72. package/dist/bot/payments/refund.js.map +1 -0
  73. package/dist/bot/payments/schemas.d.ts +58 -0
  74. package/dist/bot/payments/schemas.d.ts.map +1 -0
  75. package/dist/bot/payments/schemas.js +55 -0
  76. package/dist/bot/payments/schemas.js.map +1 -0
  77. package/dist/bot/payments/state.d.ts +90 -0
  78. package/dist/bot/payments/state.d.ts.map +1 -0
  79. package/dist/bot/payments/state.js +149 -0
  80. package/dist/bot/payments/state.js.map +1 -0
  81. package/dist/bot/payments/stores.d.ts +44 -0
  82. package/dist/bot/payments/stores.d.ts.map +1 -0
  83. package/dist/bot/payments/stores.js +39 -0
  84. package/dist/bot/payments/stores.js.map +1 -0
  85. package/dist/bot/payments/types.d.ts +299 -0
  86. package/dist/bot/payments/types.d.ts.map +1 -0
  87. package/dist/bot/payments/types.js +39 -0
  88. package/dist/bot/payments/types.js.map +1 -0
  89. package/dist/bot/payments/waiver.d.ts +81 -0
  90. package/dist/bot/payments/waiver.d.ts.map +1 -0
  91. package/dist/bot/payments/waiver.js +145 -0
  92. package/dist/bot/payments/waiver.js.map +1 -0
  93. package/dist/bot/storage.d.ts +154 -0
  94. package/dist/bot/storage.d.ts.map +1 -0
  95. package/dist/bot/storage.js +222 -0
  96. package/dist/bot/storage.js.map +1 -0
  97. package/package.json +24 -3
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Type surface for `bot/payments`. Two layers:
3
+ *
4
+ * 1. **Config shapes** the consumer authors (`BotPaymentsConfig`).
5
+ * 2. **Derived types** the consumer reads through `ctx.payments`
6
+ * (`TierKey`, `AtLeastKey`, `ProductKey`) — narrowed from their
7
+ * config so e.g. `atLeast('vip.5')` is a TS error when only 2
8
+ * rungs are declared.
9
+ *
10
+ * Internal types (the ledger records, the catalog) live below and aren't
11
+ * exported through the package entrypoint.
12
+ *
13
+ * See `CLAUDE.md` in this folder for the design rationale.
14
+ */
15
+ /** Internal value Telegram expects on the wire (seconds). */
16
+ export const SUBSCRIPTION_PERIOD_SECONDS = {
17
+ "30d": 2_592_000,
18
+ };
19
+ /**
20
+ * Fallback when `legal.privacyUrl` is omitted. Identical to the menu
21
+ * plugin's default — both surface the same policy text.
22
+ */
23
+ export const DEFAULT_PRIVACY_URL = "https://telegram.org/privacy-tpa";
24
+ // ─── error sentinel ────────────────────────────────────────────────
25
+ /**
26
+ * Thrown by `credits.consume(n)` when the balance is below `n`.
27
+ * `tryConsume(n)` returns `false` instead.
28
+ */
29
+ export class InsufficientCredits extends Error {
30
+ requested;
31
+ available;
32
+ constructor(requested, available) {
33
+ super(`InsufficientCredits: requested ${requested}, available ${available}`);
34
+ this.name = "InsufficientCredits";
35
+ this.requested = requested;
36
+ this.available = available;
37
+ }
38
+ }
39
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/bot/payments/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAcH,6DAA6D;AAC7D,MAAM,CAAC,MAAM,2BAA2B,GAEpC;IACH,KAAK,EAAE,SAAS;CAChB,CAAC;AAwFF;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,kCAAkC,CAAC;AAoQtE,sEAAsE;AAEtE;;;GAGG;AACH,MAAM,OAAO,mBAAoB,SAAQ,KAAK;IACpC,SAAS,CAAS;IAClB,SAAS,CAAS;IAC3B,YAAY,SAAiB,EAAE,SAAiB;QAC/C,KAAK,CACJ,kCAAkC,SAAS,eAAe,SAAS,EAAE,CACrE,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,qBAAqB,CAAC;QAClC,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC5B,CAAC;CACD"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Art. 103(m) TRLGDCU waiver flow.
3
+ *
4
+ * Three things have to happen together (see CLAUDE.md §5):
5
+ *
6
+ * 1. The user expresses *prior express consent* to begin execution.
7
+ * 2. They acknowledge they thereby lose the 14-day right of withdrawal.
8
+ * 3. The contract is confirmed on a durable medium.
9
+ *
10
+ * This module owns #1 and #2 via a separate, unbundled consent prompt
11
+ * — one inline message with the waiver text plus a single affirmative
12
+ * button. Bundling this into ToS acceptance voids the waiver under
13
+ * Spanish consumer law. #3 is satisfied implicitly by Telegram: every
14
+ * outgoing message the bot sends is preserved in the chat (durable).
15
+ *
16
+ * The bot author supplies the wording (Polyglot text + version string).
17
+ * Bumping `waiver.version` forces re-consent on the next purchase
18
+ * attempt; old consents stay attached to old charges for audit (see
19
+ * `ChargeRecord.waiverSnapshot`).
20
+ */
21
+ import type { Storage } from "@gramio/storage";
22
+ import { InlineKeyboard } from "gramio";
23
+ import { type BotPaymentsConfig, type PaymentsSession, type WaiverRecord } from "./types.js";
24
+ /**
25
+ * Callback fired when the user taps "✅ Consiento y comprendo" in the
26
+ * waiver consent prompt. Carries the productKey of the in-flight
27
+ * purchase so we can resume after persisting consent.
28
+ *
29
+ * `pk` (productKey) follows the shape validated by `payload.ts` —
30
+ * `vip.N` / `credits.N` / `perks.X` — all ASCII-safe.
31
+ */
32
+ export declare const waiverConsentCb: import("gramio").CallbackData<Record<never, never>, Record<never, never>>;
33
+ /** Fired when the user taps "❌ Cancelar" in the consent prompt. */
34
+ export declare const waiverCancelCb: import("gramio").CallbackData<Record<never, never>, Record<never, never>>;
35
+ /**
36
+ * Returns true when the stored consent matches the current waiver
37
+ * version — i.e. no re-consent needed before a purchase.
38
+ */
39
+ export declare const isWaiverFresh: (stored: WaiverRecord | undefined, currentVersion: string) => boolean;
40
+ /**
41
+ * Build the inline keyboard that accompanies the consent prompt. Three
42
+ * buttons:
43
+ *
44
+ * 📖 Términos — URL to `legal.termsUrl` (informational, not the
45
+ * affirmative action)
46
+ * 🔒 Privacidad — URL to `legal.privacyUrl` (same)
47
+ * ✅ Consiento y comprendo — the single affirmative action
48
+ * ❌ Cancelar — back out without consenting
49
+ *
50
+ * The "✅" button carries the productKey the user was about to buy, so
51
+ * the consent handler can resume the invoice flow on tap.
52
+ */
53
+ export declare const waiverKeyboard: (cfg: BotPaymentsConfig<string>, productKey: string, lang: string) => InlineKeyboard;
54
+ /**
55
+ * Render the consent body the user sees. Includes a short, neutral
56
+ * header above the wording supplied by the bot author. The header is
57
+ * separated by a blank line so reading order matches what consumer
58
+ * authorities expect (waiver text is unambiguous and prominent).
59
+ */
60
+ export declare const renderConsentBody: (cfg: BotPaymentsConfig<string>, lang: string) => string;
61
+ /**
62
+ * Persist a fresh waiver record onto the current ctx's session. Callers
63
+ * own the session shape via a structural narrow — this avoids a generic
64
+ * type bloom across the plugin.
65
+ */
66
+ export declare const persistWaiverOnSession: (ctx: {
67
+ session: {
68
+ pay?: PaymentsSession;
69
+ };
70
+ }, version: string, locale: string) => WaiverRecord;
71
+ /**
72
+ * Persist a waiver record onto a SPECIFIC user's stored session (used
73
+ * when the consent tap arrives on a different ctx than the original
74
+ * `invoice()` call — rare, but possible if the user wedges multiple
75
+ * flows). The cross-user write is via storage directly, mirroring the
76
+ * pattern in `access-control.ts`.
77
+ */
78
+ export declare const persistWaiverForUser: (storage: Storage, ctx: {
79
+ bot: unknown;
80
+ }, userId: number, version: string, locale: string) => Promise<WaiverRecord>;
81
+ //# sourceMappingURL=waiver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waiver.d.ts","sourceRoot":"","sources":["../../../src/bot/payments/waiver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAMxC,OAAO,EACN,KAAK,iBAAiB,EAEtB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,MAAM,YAAY,CAAC;AAOpB;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,2EAA8C,CAAC;AAE3E,mEAAmE;AACnE,eAAO,MAAM,cAAc,2EAA+B,CAAC;AAI3D;;;GAGG;AACH,eAAO,MAAM,aAAa,GACzB,QAAQ,YAAY,GAAG,SAAS,EAChC,gBAAgB,MAAM,KACpB,OAGF,CAAC;AAiBF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GAC1B,KAAK,iBAAiB,CAAC,MAAM,CAAC,EAC9B,YAAY,MAAM,EAClB,MAAM,MAAM,KACV,cA8BF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAC7B,KAAK,iBAAiB,CAAC,MAAM,CAAC,EAC9B,MAAM,MAAM,KACV,MAWU,CAAC;AAId;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,GAClC,KAAK;IAAE,OAAO,EAAE;QAAE,GAAG,CAAC,EAAE,eAAe,CAAA;KAAE,CAAA;CAAE,EAC3C,SAAS,MAAM,EACf,QAAQ,MAAM,KACZ,YAUF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,OAAO,EAChB,KAAK;IAAE,GAAG,EAAE,OAAO,CAAA;CAAE,EACrB,QAAQ,MAAM,EACd,SAAS,MAAM,EACf,QAAQ,MAAM,KACZ,OAAO,CAAC,YAAY,CAatB,CAAC"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Art. 103(m) TRLGDCU waiver flow.
3
+ *
4
+ * Three things have to happen together (see CLAUDE.md §5):
5
+ *
6
+ * 1. The user expresses *prior express consent* to begin execution.
7
+ * 2. They acknowledge they thereby lose the 14-day right of withdrawal.
8
+ * 3. The contract is confirmed on a durable medium.
9
+ *
10
+ * This module owns #1 and #2 via a separate, unbundled consent prompt
11
+ * — one inline message with the waiver text plus a single affirmative
12
+ * button. Bundling this into ToS acceptance voids the waiver under
13
+ * Spanish consumer law. #3 is satisfied implicitly by Telegram: every
14
+ * outgoing message the bot sends is preserved in the chat (durable).
15
+ *
16
+ * The bot author supplies the wording (Polyglot text + version string).
17
+ * Bumping `waiver.version` forces re-consent on the next purchase
18
+ * attempt; old consents stay attached to old charges for audit (see
19
+ * `ChargeRecord.waiverSnapshot`).
20
+ */
21
+ import { InlineKeyboard } from "gramio";
22
+ import { say } from "../../say/index.js";
23
+ import { createLogger } from "../../universal/log.js";
24
+ import { callbackNs } from "../callbacks.js";
25
+ import { botStorageKey } from "../kit.js";
26
+ import { DEFAULT_PRIVACY_URL, } from "./types.js";
27
+ const FALLBACK_LANG = "en";
28
+ const log = createLogger("bot/payments");
29
+ const cb = callbackNs("pay");
30
+ /**
31
+ * Callback fired when the user taps "✅ Consiento y comprendo" in the
32
+ * waiver consent prompt. Carries the productKey of the in-flight
33
+ * purchase so we can resume after persisting consent.
34
+ *
35
+ * `pk` (productKey) follows the shape validated by `payload.ts` —
36
+ * `vip.N` / `credits.N` / `perks.X` — all ASCII-safe.
37
+ */
38
+ export const waiverConsentCb = cb.data("waiver:consent", { pk: "string" });
39
+ /** Fired when the user taps "❌ Cancelar" in the consent prompt. */
40
+ export const waiverCancelCb = cb.data("waiver:cancel", {});
41
+ // ─── pure helpers ──────────────────────────────────────────────────
42
+ /**
43
+ * Returns true when the stored consent matches the current waiver
44
+ * version — i.e. no re-consent needed before a purchase.
45
+ */
46
+ export const isWaiverFresh = (stored, currentVersion) => {
47
+ if (!stored)
48
+ return false;
49
+ return stored.version === currentVersion;
50
+ };
51
+ /**
52
+ * Resolve the waiver text Polyglot to a single string for the recipient's
53
+ * locale, with `'en'` fallback. Matches `say()`'s semantics for the
54
+ * dynamic-string escape hatch.
55
+ */
56
+ const resolveWaiverText = (cfg, lang) => {
57
+ const text = cfg.waiver.text;
58
+ return text[lang] ?? text[FALLBACK_LANG] ?? Object.values(text)[0] ?? "";
59
+ };
60
+ // ─── consent prompt rendering ──────────────────────────────────────
61
+ /**
62
+ * Build the inline keyboard that accompanies the consent prompt. Three
63
+ * buttons:
64
+ *
65
+ * 📖 Términos — URL to `legal.termsUrl` (informational, not the
66
+ * affirmative action)
67
+ * 🔒 Privacidad — URL to `legal.privacyUrl` (same)
68
+ * ✅ Consiento y comprendo — the single affirmative action
69
+ * ❌ Cancelar — back out without consenting
70
+ *
71
+ * The "✅" button carries the productKey the user was about to buy, so
72
+ * the consent handler can resume the invoice flow on tap.
73
+ */
74
+ export const waiverKeyboard = (cfg, productKey, lang) => {
75
+ const kb = new InlineKeyboard();
76
+ const termsUrl = cfg.legal.termsUrl;
77
+ const privacyUrl = cfg.legal.privacyUrl ?? DEFAULT_PRIVACY_URL;
78
+ // Terms button only renders if the bot configured a termsUrl —
79
+ // there's no Telegram-side standard ToS to default to. Privacy
80
+ // always renders; it falls back to Telegram's Standard Bot Privacy
81
+ // Policy when unset (covers what this library retains by default).
82
+ if (termsUrl) {
83
+ kb.url(say({ en: "📖 Terms", es: "📖 Términos" }, lang), termsUrl);
84
+ }
85
+ kb.url(say({ en: "🔒 Privacy", es: "🔒 Privacidad" }, lang), privacyUrl);
86
+ kb.row();
87
+ kb.text(say({
88
+ en: "✅ I consent and understand",
89
+ es: "✅ Consiento y comprendo",
90
+ }, lang), waiverConsentCb.pack({ pk: productKey }), { style: "primary" });
91
+ kb.row();
92
+ kb.text(say({ en: "❌ Cancel", es: "❌ Cancelar" }, lang), waiverCancelCb.pack({}));
93
+ return kb;
94
+ };
95
+ /**
96
+ * Render the consent body the user sees. Includes a short, neutral
97
+ * header above the wording supplied by the bot author. The header is
98
+ * separated by a blank line so reading order matches what consumer
99
+ * authorities expect (waiver text is unambiguous and prominent).
100
+ */
101
+ export const renderConsentBody = (cfg, lang) => [
102
+ say({
103
+ en: "Before continuing, please read and consent:",
104
+ es: "Antes de continuar, lee y consiente:",
105
+ }, lang),
106
+ "",
107
+ resolveWaiverText(cfg, lang),
108
+ ].join("\n");
109
+ // ─── persistence ───────────────────────────────────────────────────
110
+ /**
111
+ * Persist a fresh waiver record onto the current ctx's session. Callers
112
+ * own the session shape via a structural narrow — this avoids a generic
113
+ * type bloom across the plugin.
114
+ */
115
+ export const persistWaiverOnSession = (ctx, version, locale) => {
116
+ const record = {
117
+ at: Date.now(),
118
+ version,
119
+ locale,
120
+ };
121
+ ctx.session.pay ??= {};
122
+ ctx.session.pay.waiver = record;
123
+ log.success(`waiver consent persisted: version=${version} locale=${locale}`);
124
+ return record;
125
+ };
126
+ /**
127
+ * Persist a waiver record onto a SPECIFIC user's stored session (used
128
+ * when the consent tap arrives on a different ctx than the original
129
+ * `invoice()` call — rare, but possible if the user wedges multiple
130
+ * flows). The cross-user write is via storage directly, mirroring the
131
+ * pattern in `access-control.ts`.
132
+ */
133
+ export const persistWaiverForUser = async (storage, ctx, userId, version, locale) => {
134
+ const key = botStorageKey(ctx, userId);
135
+ const full = ((await storage.get(key)) ?? {});
136
+ const record = {
137
+ at: Date.now(),
138
+ version,
139
+ locale,
140
+ };
141
+ full.pay = { ...(full.pay ?? {}), waiver: record };
142
+ await storage.set(key, full);
143
+ return record;
144
+ };
145
+ //# sourceMappingURL=waiver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waiver.js","sourceRoot":"","sources":["../../../src/bot/payments/waiver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Typed storage abstraction for `bot/*` plugins.
3
+ *
4
+ * Replaces the per-plugin `botSubKey + storage.get/set` boilerplate
5
+ * with three small primitives that automatically:
6
+ *
7
+ * - **Namespace by bot id.** Every key gets `bot-<id>:` prefixed via
8
+ * `ctx.bot.info.id`, so multiple bots sharing one Redis stay
9
+ * isolated by construction (see `bot/CLAUDE.md` § Multi-bot
10
+ * isolation). No plugin has to remember to wrap.
11
+ *
12
+ * - **Validate on read** (optional). Pass any `{ parse(data:
13
+ * unknown): T }` validator (zod / valibot / arktype / yours) and a
14
+ * mis-shaped record from storage throws a clear `SourcedError`
15
+ * instead of NaN-ing downstream. Omit the validator and you get
16
+ * the same untyped read as before.
17
+ *
18
+ * - **Stay typed end-to-end.** `botRecord<ChargeRecord>(...)` returns
19
+ * a `BotRecord<ChargeRecord>` and `.get()` is `Promise<ChargeRecord
20
+ * | undefined>`. No casts at the call site.
21
+ *
22
+ * ## Three primitives
23
+ *
24
+ * - `botRecord<T>(storage, prefix, validator?)` — get/set/delete a
25
+ * single typed value keyed by an id (`pay:charge:<chargeId>`,
26
+ * `ac:user:<userId>`, …). Most plugin state.
27
+ *
28
+ * - `botIndex(storage, prefix, opts?)` — a capped, prepend-friendly
29
+ * `string[]` stored under a single key. Use for "recent N
30
+ * charges", "pending access requests", etc.
31
+ *
32
+ * - `botSentinel(storage, prefix)` — a presence flag (`"1"` / absent)
33
+ * for idempotency. `claim(ctx, id)` returns `true` only on first
34
+ * call; subsequent calls return `false` without setting. Note:
35
+ * read-then-write is NOT atomic under concurrent load; for
36
+ * production we'd want a storage backend with `setNX` semantics.
37
+ *
38
+ * ## Why not couple to zod?
39
+ *
40
+ * The validator parameter is typed as `{ parse(data: unknown): T }` —
41
+ * a structural shape that zod schemas, valibot, ArkType, and hand-rolled
42
+ * type guards all satisfy. The library has zero zod imports; consumers
43
+ * bring whichever validator they prefer.
44
+ */
45
+ import type { Storage } from "@gramio/storage";
46
+ import type { BotIdCtx } from "./ctx.js";
47
+ /**
48
+ * Anything with a synchronous `parse(unknown): T`. zod schemas satisfy
49
+ * this natively (`.parse` throws on invalid input). Custom guards work
50
+ * the same way: `{ parse: (d) => { if (!ok(d)) throw new Error(...); return d as T } }`.
51
+ */
52
+ export type RecordValidator<T> = {
53
+ parse: (data: unknown) => T;
54
+ };
55
+ export type BotRecord<T> = {
56
+ /** Storage key for `id` under this record's prefix. */
57
+ keyFor: (ctx: BotIdCtx, id: string) => string;
58
+ /** `undefined` on miss; throws `SourcedError` on validator failure. */
59
+ get: (ctx: BotIdCtx, id: string) => Promise<T | undefined>;
60
+ /** Overwrites unconditionally. Use `botSentinel` if you need set-if-absent. */
61
+ set: (ctx: BotIdCtx, id: string, value: T) => Promise<void>;
62
+ /** Hard delete. No-op if absent. */
63
+ delete: (ctx: BotIdCtx, id: string) => Promise<void>;
64
+ /** `true` if a record exists at `id` (no read, no validation). */
65
+ has: (ctx: BotIdCtx, id: string) => Promise<boolean>;
66
+ };
67
+ /**
68
+ * Typed record store, auto-namespaced by bot id.
69
+ *
70
+ * @example per-charge record with zod validation
71
+ *
72
+ * const charges = botRecord<ChargeRecord>(
73
+ * storage,
74
+ * 'pay:charge',
75
+ * ChargeRecordSchema, // any zod.ZodType<ChargeRecord>
76
+ * )
77
+ * await charges.set(ctx, chargeId, charge)
78
+ * const c = await charges.get(ctx, chargeId)
79
+ *
80
+ * @example without a validator (backwards-compatible behaviour)
81
+ *
82
+ * const charges = botRecord<ChargeRecord>(storage, 'pay:charge')
83
+ * const c = await charges.get(ctx, chargeId) // typed but unchecked
84
+ */
85
+ export declare const botRecord: <T>(storage: Storage, prefix: string, validator?: RecordValidator<T>) => BotRecord<T>;
86
+ export type BotIndexOptions = {
87
+ /**
88
+ * Maximum number of ids retained. New `prepend` calls past this
89
+ * limit drop the OLDEST id. Omit for an unbounded index (a
90
+ * footgun for high-volume bots — set a cap).
91
+ */
92
+ capacity?: number;
93
+ };
94
+ export type BotIndex = {
95
+ keyFor: (ctx: BotIdCtx) => string;
96
+ /** Newest-first list of ids, capped to `capacity` if configured. */
97
+ list: (ctx: BotIdCtx) => Promise<string[]>;
98
+ /** Insert `id` at the front. Idempotent: re-prepending an existing
99
+ * id moves it to the front. Returns the new length post-cap. */
100
+ prepend: (ctx: BotIdCtx, id: string) => Promise<number>;
101
+ /** Remove `id`. No-op if absent. Returns whether anything was removed. */
102
+ remove: (ctx: BotIdCtx, id: string) => Promise<boolean>;
103
+ /** Wipe the whole index. */
104
+ clear: (ctx: BotIdCtx) => Promise<void>;
105
+ };
106
+ /**
107
+ * Newest-first capped list of ids stored under a single key. Designed
108
+ * for "user's last N charges", "pending access requests", "recent
109
+ * payouts" — small, ordered, bounded.
110
+ *
111
+ * `prepend(id)` is the only mutator. Re-prepending an existing id moves
112
+ * it to the front (de-duplication). At-cap inserts drop the tail.
113
+ */
114
+ export declare const botIndex: (storage: Storage, prefix: string, opts?: BotIndexOptions) => BotIndex;
115
+ export type BotSentinel = {
116
+ keyFor: (ctx: BotIdCtx, id: string) => string;
117
+ /**
118
+ * Attempt to claim `id`. Returns `true` on first claim (and
119
+ * persists), `false` if already claimed (no write).
120
+ *
121
+ * NOT atomic under concurrent load — the read and write happen as
122
+ * two ops. For at-most-once semantics under contention, swap the
123
+ * storage backend for one with `setNX` (e.g. `@gramio/storage-redis`
124
+ * with a `setIfNotExists` helper) and override this method. For
125
+ * single-process polling bots (the v1 target), the race window is
126
+ * bounded by Telegram's retry delay and acceptable.
127
+ */
128
+ claim: (ctx: BotIdCtx, id: string) => Promise<boolean>;
129
+ /** `true` if `id` has been claimed. No write. */
130
+ check: (ctx: BotIdCtx, id: string) => Promise<boolean>;
131
+ /** Release a claim. Returns whether anything was released. */
132
+ release: (ctx: BotIdCtx, id: string) => Promise<boolean>;
133
+ };
134
+ /**
135
+ * Presence-flag store for idempotency. Each id is either claimed
136
+ * (storage has the key) or not (storage doesn't). The stored value is
137
+ * an opaque sentinel string — readers only care about presence.
138
+ *
139
+ * @example idempotent successful_payment fulfillment
140
+ *
141
+ * const idem = botSentinel(storage, 'pay:idem')
142
+ * if (!(await idem.claim(ctx, chargeId))) return // duplicate, no-op
143
+ * await fulfill(charge)
144
+ */
145
+ export declare const botSentinel: (storage: Storage, prefix: string) => BotSentinel;
146
+ /**
147
+ * Wrap any `BotRecord` with trace-level logging for every read/write.
148
+ * Useful when debugging a specific plugin's storage flow without
149
+ * polluting every other plugin's logs. Defaults off.
150
+ *
151
+ * const charges = withTracing(botRecord<ChargeRecord>(...), 'pay:charge')
152
+ */
153
+ export declare const withTracing: <T>(record: BotRecord<T>, label: string) => BotRecord<T>;
154
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/bot/storage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Typed storage abstraction for `bot/*` plugins.
3
+ *
4
+ * Replaces the per-plugin `botSubKey + storage.get/set` boilerplate
5
+ * with three small primitives that automatically:
6
+ *
7
+ * - **Namespace by bot id.** Every key gets `bot-<id>:` prefixed via
8
+ * `ctx.bot.info.id`, so multiple bots sharing one Redis stay
9
+ * isolated by construction (see `bot/CLAUDE.md` § Multi-bot
10
+ * isolation). No plugin has to remember to wrap.
11
+ *
12
+ * - **Validate on read** (optional). Pass any `{ parse(data:
13
+ * unknown): T }` validator (zod / valibot / arktype / yours) and a
14
+ * mis-shaped record from storage throws a clear `SourcedError`
15
+ * instead of NaN-ing downstream. Omit the validator and you get
16
+ * the same untyped read as before.
17
+ *
18
+ * - **Stay typed end-to-end.** `botRecord<ChargeRecord>(...)` returns
19
+ * a `BotRecord<ChargeRecord>` and `.get()` is `Promise<ChargeRecord
20
+ * | undefined>`. No casts at the call site.
21
+ *
22
+ * ## Three primitives
23
+ *
24
+ * - `botRecord<T>(storage, prefix, validator?)` — get/set/delete a
25
+ * single typed value keyed by an id (`pay:charge:<chargeId>`,
26
+ * `ac:user:<userId>`, …). Most plugin state.
27
+ *
28
+ * - `botIndex(storage, prefix, opts?)` — a capped, prepend-friendly
29
+ * `string[]` stored under a single key. Use for "recent N
30
+ * charges", "pending access requests", etc.
31
+ *
32
+ * - `botSentinel(storage, prefix)` — a presence flag (`"1"` / absent)
33
+ * for idempotency. `claim(ctx, id)` returns `true` only on first
34
+ * call; subsequent calls return `false` without setting. Note:
35
+ * read-then-write is NOT atomic under concurrent load; for
36
+ * production we'd want a storage backend with `setNX` semantics.
37
+ *
38
+ * ## Why not couple to zod?
39
+ *
40
+ * The validator parameter is typed as `{ parse(data: unknown): T }` —
41
+ * a structural shape that zod schemas, valibot, ArkType, and hand-rolled
42
+ * type guards all satisfy. The library has zero zod imports; consumers
43
+ * bring whichever validator they prefer.
44
+ */
45
+ import { SourcedError } from "../offensive.js";
46
+ import { createLogger } from "../universal/log.js";
47
+ const log = createLogger("bot/storage");
48
+ const botIdOf = (ctx) => {
49
+ const id = ctx.bot?.info?.id;
50
+ if (typeof id !== "number") {
51
+ throw new SourcedError({
52
+ source: "storage",
53
+ operation: "key",
54
+ message: "ctx.bot.info.id is missing — called before bot.start()/bot.init()?",
55
+ });
56
+ }
57
+ return id;
58
+ };
59
+ /**
60
+ * Build the namespaced key for a record / index / sentinel. Matches
61
+ * the format `botSubKey` / `botStorageKey` in `kit.ts` use, so the
62
+ * abstractions compose with anything that still consumes those.
63
+ */
64
+ const buildKey = (ctx, prefix, id) => {
65
+ const base = `bot-${botIdOf(ctx)}:${prefix}`;
66
+ return id !== undefined ? `${base}:${id}` : base;
67
+ };
68
+ const parseOr = (raw, validator, key) => {
69
+ if (!validator)
70
+ return raw;
71
+ try {
72
+ return validator.parse(raw);
73
+ }
74
+ catch (cause) {
75
+ throw new SourcedError({
76
+ source: "storage",
77
+ operation: "validate",
78
+ message: cause instanceof Error
79
+ ? cause.message
80
+ : "validator threw a non-Error value",
81
+ cause,
82
+ context: { key },
83
+ });
84
+ }
85
+ };
86
+ /**
87
+ * Typed record store, auto-namespaced by bot id.
88
+ *
89
+ * @example per-charge record with zod validation
90
+ *
91
+ * const charges = botRecord<ChargeRecord>(
92
+ * storage,
93
+ * 'pay:charge',
94
+ * ChargeRecordSchema, // any zod.ZodType<ChargeRecord>
95
+ * )
96
+ * await charges.set(ctx, chargeId, charge)
97
+ * const c = await charges.get(ctx, chargeId)
98
+ *
99
+ * @example without a validator (backwards-compatible behaviour)
100
+ *
101
+ * const charges = botRecord<ChargeRecord>(storage, 'pay:charge')
102
+ * const c = await charges.get(ctx, chargeId) // typed but unchecked
103
+ */
104
+ export const botRecord = (storage, prefix, validator) => ({
105
+ keyFor: (ctx, id) => buildKey(ctx, prefix, id),
106
+ get: async (ctx, id) => {
107
+ const key = buildKey(ctx, prefix, id);
108
+ const raw = await storage.get(key);
109
+ if (raw === undefined)
110
+ return undefined;
111
+ return parseOr(raw, validator, key);
112
+ },
113
+ // `@gramio/storage`'s methods return `MaybePromise<T>` (sync- or
114
+ // async-storage agnostic). We normalize to `Promise<T>` via
115
+ // `Promise.resolve` so callers don't have to care which backend
116
+ // they're on.
117
+ set: async (ctx, id, value) => void (await storage.set(buildKey(ctx, prefix, id), value)),
118
+ delete: async (ctx, id) => void (await storage.delete(buildKey(ctx, prefix, id))),
119
+ has: async (ctx, id) => (await storage.has(buildKey(ctx, prefix, id))) === true,
120
+ });
121
+ /**
122
+ * Newest-first capped list of ids stored under a single key. Designed
123
+ * for "user's last N charges", "pending access requests", "recent
124
+ * payouts" — small, ordered, bounded.
125
+ *
126
+ * `prepend(id)` is the only mutator. Re-prepending an existing id moves
127
+ * it to the front (de-duplication). At-cap inserts drop the tail.
128
+ */
129
+ export const botIndex = (storage, prefix, opts = {}) => {
130
+ const cap = opts.capacity;
131
+ return {
132
+ keyFor: (ctx) => buildKey(ctx, prefix),
133
+ list: async (ctx) => {
134
+ const key = buildKey(ctx, prefix);
135
+ return (await storage.get(key)) ?? [];
136
+ },
137
+ prepend: async (ctx, id) => {
138
+ const key = buildKey(ctx, prefix);
139
+ const existing = (await storage.get(key)) ?? [];
140
+ // De-dup: remove if present, then unshift. Cap at the end.
141
+ const filtered = existing.filter((x) => x !== id);
142
+ filtered.unshift(id);
143
+ const trimmed = cap !== undefined && filtered.length > cap
144
+ ? filtered.slice(0, cap)
145
+ : filtered;
146
+ await storage.set(key, trimmed);
147
+ return trimmed.length;
148
+ },
149
+ remove: async (ctx, id) => {
150
+ const key = buildKey(ctx, prefix);
151
+ const existing = (await storage.get(key)) ?? [];
152
+ const filtered = existing.filter((x) => x !== id);
153
+ if (filtered.length === existing.length)
154
+ return false;
155
+ await storage.set(key, filtered);
156
+ return true;
157
+ },
158
+ clear: async (ctx) => void (await storage.delete(buildKey(ctx, prefix))),
159
+ };
160
+ };
161
+ /**
162
+ * Presence-flag store for idempotency. Each id is either claimed
163
+ * (storage has the key) or not (storage doesn't). The stored value is
164
+ * an opaque sentinel string — readers only care about presence.
165
+ *
166
+ * @example idempotent successful_payment fulfillment
167
+ *
168
+ * const idem = botSentinel(storage, 'pay:idem')
169
+ * if (!(await idem.claim(ctx, chargeId))) return // duplicate, no-op
170
+ * await fulfill(charge)
171
+ */
172
+ export const botSentinel = (storage, prefix) => {
173
+ const SENTINEL = "1";
174
+ return {
175
+ keyFor: (ctx, id) => buildKey(ctx, prefix, id),
176
+ claim: async (ctx, id) => {
177
+ const key = buildKey(ctx, prefix, id);
178
+ const existing = await storage.get(key);
179
+ if (existing !== undefined)
180
+ return false;
181
+ await storage.set(key, SENTINEL);
182
+ return true;
183
+ },
184
+ check: async (ctx, id) => {
185
+ const key = buildKey(ctx, prefix, id);
186
+ return (await storage.get(key)) !== undefined;
187
+ },
188
+ release: async (ctx, id) => {
189
+ const key = buildKey(ctx, prefix, id);
190
+ if ((await storage.get(key)) === undefined)
191
+ return false;
192
+ await storage.delete(key);
193
+ return true;
194
+ },
195
+ };
196
+ };
197
+ // ─── observability hook (opt-in) ──────────────────────────────────
198
+ /**
199
+ * Wrap any `BotRecord` with trace-level logging for every read/write.
200
+ * Useful when debugging a specific plugin's storage flow without
201
+ * polluting every other plugin's logs. Defaults off.
202
+ *
203
+ * const charges = withTracing(botRecord<ChargeRecord>(...), 'pay:charge')
204
+ */
205
+ export const withTracing = (record, label) => ({
206
+ keyFor: record.keyFor,
207
+ get: async (ctx, id) => {
208
+ const result = await record.get(ctx, id);
209
+ log.trace(`${label}.get(${id}) → ${result === undefined ? "miss" : "hit"}`);
210
+ return result;
211
+ },
212
+ set: async (ctx, id, value) => {
213
+ await record.set(ctx, id, value);
214
+ log.trace(`${label}.set(${id})`);
215
+ },
216
+ delete: async (ctx, id) => {
217
+ await record.delete(ctx, id);
218
+ log.trace(`${label}.delete(${id})`);
219
+ },
220
+ has: (ctx, id) => record.has(ctx, id),
221
+ });
222
+ //# sourceMappingURL=storage.js.map