@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.
- package/README.md +2 -1
- package/dist/bot/access-control.d.ts.map +1 -1
- package/dist/bot/access-control.js +17 -1
- package/dist/bot/access-control.js.map +1 -1
- package/dist/bot/callbacks.d.ts +89 -0
- package/dist/bot/callbacks.d.ts.map +1 -0
- package/dist/bot/callbacks.js +128 -0
- package/dist/bot/callbacks.js.map +1 -0
- package/dist/bot/ctx.d.ts +219 -0
- package/dist/bot/ctx.d.ts.map +1 -0
- package/dist/bot/ctx.js +52 -0
- package/dist/bot/ctx.js.map +1 -0
- package/dist/bot/index.d.ts +4 -0
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +4 -0
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/kit.d.ts +1 -1
- package/dist/bot/kit.d.ts.map +1 -1
- package/dist/bot/kit.js +4 -2
- package/dist/bot/kit.js.map +1 -1
- package/dist/bot/menu.d.ts +23 -1
- package/dist/bot/menu.d.ts.map +1 -1
- package/dist/bot/menu.js +30 -1
- package/dist/bot/menu.js.map +1 -1
- package/dist/bot/payments/callbacks.d.ts +28 -0
- package/dist/bot/payments/callbacks.d.ts.map +1 -0
- package/dist/bot/payments/callbacks.js +102 -0
- package/dist/bot/payments/callbacks.js.map +1 -0
- package/dist/bot/payments/commands.d.ts +26 -0
- package/dist/bot/payments/commands.d.ts.map +1 -0
- package/dist/bot/payments/commands.js +41 -0
- package/dist/bot/payments/commands.js.map +1 -0
- package/dist/bot/payments/config.d.ts +30 -0
- package/dist/bot/payments/config.d.ts.map +1 -0
- package/dist/bot/payments/config.js +166 -0
- package/dist/bot/payments/config.js.map +1 -0
- package/dist/bot/payments/derive.d.ts +75 -0
- package/dist/bot/payments/derive.d.ts.map +1 -0
- package/dist/bot/payments/derive.js +151 -0
- package/dist/bot/payments/derive.js.map +1 -0
- package/dist/bot/payments/handlers.d.ts +113 -0
- package/dist/bot/payments/handlers.d.ts.map +1 -0
- package/dist/bot/payments/handlers.js +284 -0
- package/dist/bot/payments/handlers.js.map +1 -0
- package/dist/bot/payments/index.d.ts +17 -0
- package/dist/bot/payments/index.d.ts.map +1 -0
- package/dist/bot/payments/index.js +14 -0
- package/dist/bot/payments/index.js.map +1 -0
- package/dist/bot/payments/invoice.d.ts +101 -0
- package/dist/bot/payments/invoice.d.ts.map +1 -0
- package/dist/bot/payments/invoice.js +213 -0
- package/dist/bot/payments/invoice.js.map +1 -0
- package/dist/bot/payments/menu-item.d.ts +41 -0
- package/dist/bot/payments/menu-item.d.ts.map +1 -0
- package/dist/bot/payments/menu-item.js +259 -0
- package/dist/bot/payments/menu-item.js.map +1 -0
- package/dist/bot/payments/payload.d.ts +42 -0
- package/dist/bot/payments/payload.d.ts.map +1 -0
- package/dist/bot/payments/payload.js +69 -0
- package/dist/bot/payments/payload.js.map +1 -0
- package/dist/bot/payments/payouts.d.ts +88 -0
- package/dist/bot/payments/payouts.d.ts.map +1 -0
- package/dist/bot/payments/payouts.js +133 -0
- package/dist/bot/payments/payouts.js.map +1 -0
- package/dist/bot/payments/plugin.d.ts +271 -0
- package/dist/bot/payments/plugin.d.ts.map +1 -0
- package/dist/bot/payments/plugin.js +141 -0
- package/dist/bot/payments/plugin.js.map +1 -0
- package/dist/bot/payments/refund.d.ts +124 -0
- package/dist/bot/payments/refund.d.ts.map +1 -0
- package/dist/bot/payments/refund.js +308 -0
- package/dist/bot/payments/refund.js.map +1 -0
- package/dist/bot/payments/schemas.d.ts +58 -0
- package/dist/bot/payments/schemas.d.ts.map +1 -0
- package/dist/bot/payments/schemas.js +55 -0
- package/dist/bot/payments/schemas.js.map +1 -0
- package/dist/bot/payments/state.d.ts +90 -0
- package/dist/bot/payments/state.d.ts.map +1 -0
- package/dist/bot/payments/state.js +149 -0
- package/dist/bot/payments/state.js.map +1 -0
- package/dist/bot/payments/stores.d.ts +44 -0
- package/dist/bot/payments/stores.d.ts.map +1 -0
- package/dist/bot/payments/stores.js +39 -0
- package/dist/bot/payments/stores.js.map +1 -0
- package/dist/bot/payments/types.d.ts +299 -0
- package/dist/bot/payments/types.d.ts.map +1 -0
- package/dist/bot/payments/types.js +39 -0
- package/dist/bot/payments/types.js.map +1 -0
- package/dist/bot/payments/waiver.d.ts +81 -0
- package/dist/bot/payments/waiver.d.ts.map +1 -0
- package/dist/bot/payments/waiver.js +145 -0
- package/dist/bot/payments/waiver.js.map +1 -0
- package/dist/bot/storage.d.ts +154 -0
- package/dist/bot/storage.d.ts.map +1 -0
- package/dist/bot/storage.js +222 -0
- package/dist/bot/storage.js.map +1 -0
- 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
|