@adriangalilea/utils 0.4.1 → 0.6.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
@@ -107,12 +107,14 @@ format.percentage(123.456) // "123%"
107
107
 
108
108
  ### Offensive Programming
109
109
 
110
- Fail loud, fail fast. All primitives throw `Panic` — an uncaught `Panic` crashes the process with a full stack trace. Zero dependencies, works identically in Node, Deno, Bun, and browsers.
110
+ Fail loud, fail fast. Zero dependencies, works in Node, Deno, Bun, and browsers.
111
+
112
+ Two kinds of errors, kept separate: **`Panic`** (bugs in us — crash the process) and **`SourcedError`** (boundary failures — handle per-source).
111
113
 
112
114
  ```typescript
113
- import { assert, panic, must, unwrap, Panic } from '@adriangalilea/utils'
115
+ import { assert, panic, assertNever, must, unwrap, Panic, SourcedError, isSourcedError } from '@adriangalilea/utils'
114
116
 
115
- // Assert invariants — narrows types
117
+ // Assert invariants — narrows types via `asserts condition`
116
118
  assert(port > 0 && port < 65536, 'invalid port:', port)
117
119
 
118
120
  // Impossible state
@@ -121,33 +123,66 @@ switch (state) {
121
123
  default: panic('impossible state:', state)
122
124
  }
123
125
 
126
+ // Exhaustiveness check — TS compile error if you miss a case
127
+ type Event = { kind: 'click' } | { kind: 'hover' } | { kind: 'scroll' }
128
+ function handle(e: Event) {
129
+ switch (e.kind) {
130
+ case 'click': return handleClick()
131
+ case 'hover': return handleHover()
132
+ // forgot 'scroll' → TS error: Argument of type '{ kind: "scroll" }' not assignable to 'never'
133
+ default: return assertNever(e)
134
+ }
135
+ }
136
+ // Add a new variant to Event → every assertNever site lights up at compile time.
137
+
124
138
  // Unwrap operations that shouldn't fail (sync + async)
125
139
  const data = must(() => JSON.parse(staticJsonString))
126
140
  const file = must(() => readFileSync(path))
127
141
  const resp = await must(() => fetch(url))
128
142
 
129
- // Unwrap nullable values — type narrows T | null | undefined → T in one expression
130
- // (assert needs two statements, unwrap does it inline)
143
+ // Unwrap nullable values — T | null | undefined → T in one expression
131
144
  const user = unwrap(db.findUser(id), 'user not found:', id)
132
145
  const el = unwrap(document.getElementById('app'))
146
+ ```
133
147
 
134
- // must() replaces try/catch boilerplate:
135
- // try { return readFileSync(path, 'utf-8') }
136
- // catch (err) { check(err) }
137
- // becomes:
138
- return must(() => readFileSync(path, 'utf-8'))
148
+ #### Typed boundary errors — `SourcedError`
139
149
 
140
- // Panic is a distinct error class distinguishes bugs from runtime errors
141
- // In a server: let Panics crash, handle everything else
142
- app.use((err, req, res, next) => {
143
- if (err instanceof Panic) throw err // bug, re-throw, let it crash
144
- res.status(500).json({ error: 'internal error' })
145
- })
150
+ Every external system call should wear its source. When it fails, carry forensics:
146
151
 
147
- // In tests: assert that code panics
148
- expect(() => assert(false, 'boom')).toThrow(Panic)
152
+ ```typescript
153
+ import { SourcedError, isSourcedError, Panic } from '@adriangalilea/utils'
154
+
155
+ try {
156
+ return await stripe.charges.create({ customer, amount })
157
+ } catch (e) {
158
+ throw new SourcedError({
159
+ source: 'stripe',
160
+ operation: 'charge_customer',
161
+ message: e instanceof Error ? e.message : String(e),
162
+ status: (e as any)?.statusCode,
163
+ cause: e,
164
+ context: { customer, amount },
165
+ })
166
+ }
167
+
168
+ // At catch boundaries — keep Panics and SourcedErrors separate:
169
+ try { await doWork() }
170
+ catch (e) {
171
+ if (e instanceof Panic) throw e // bug in us — crash
172
+ if (isSourcedError(e, 'stripe') && e.status === 402) {
173
+ // TS knows e.source === 'stripe' here (generic narrows)
174
+ return { error: 'card declined' }
175
+ }
176
+ if (isSourcedError(e)) {
177
+ logger.error(`[${e.source}:${e.operation}]`, e.toJSON()) // structured forensics
178
+ throw e
179
+ }
180
+ throw e // unknown — re-throw
181
+ }
149
182
  ```
150
183
 
184
+ Every `SourcedError` carries `source`, `operation`, `status`, `context`, and the original exception via `cause`. Call `.toJSON()` for serialization across process boundaries.
185
+
151
186
  ## Features
152
187
 
153
188
  - **Logger**: Next.js-style colored console output with symbols
@@ -158,13 +193,14 @@ expect(() => assert(false, 'boom')).toThrow(Panic)
158
193
  - Percentage and basis point utilities
159
194
  - Fiat and stablecoin detection
160
195
  - **Format**: Number and currency formatting with compact notation
161
- - **Offensive Programming**: assert, panic, must, unwrap — all throw `Panic` with full stack traces
196
+ - **Offensive Programming**: assert, panic, assertNever, must, unwrap (throw `Panic`) + SourcedError for typed boundary failures
162
197
  - **File Operations**: Read, write with automatic path resolution
163
198
  - **Directory Operations**: Create, list, walk directories
164
199
  - **KEV**: Redis-style environment variable management with monorepo support
165
200
  - **XDG**: XDG Base Directory paths — reads env vars set by [xdg-dirs](https://github.com/adriangalilea/xdg-dirs), falls back to spec defaults
166
201
  - **Unseen**: Persistent dedup filter — "what's new since last time?" for cron/monitoring workflows
167
202
  - **Project Discovery**: Find project/monorepo roots, detect JS/TS projects
203
+ - **Bot plugins (GramIO)**: `kit` (graceful shutdown + admin context), `access-control` (gate + approve/deny menu, backed by sessions), `llm-stream` (streaming LLM markdown to Telegram with graceful degradation)
168
204
 
169
205
  ### XDG Base Directories
170
206
 
@@ -214,6 +250,50 @@ newMessages = [{ id: '2', from: 'bob', text: 'hey' }]
214
250
 
215
251
  Saves state to: `$XDG_STATE_HOME/unseen/{name}.json`
216
252
 
253
+ ### Telegram bot plugins (GramIO)
254
+
255
+ Plugins for personal Telegram bots built on [GramIO](https://gramio.dev). Each plugin lives at its own subpath; peer deps (`gramio`, `@gramio/storage`, `@gramio/session`, `@gramio/format`, `marked`) are **all optional** — install only what you import.
256
+
257
+ ```bash
258
+ pnpm add @adriangalilea/utils gramio @gramio/storage @gramio/session
259
+ ```
260
+
261
+ | Subpath | What it does |
262
+ |---|---|
263
+ | `@adriangalilea/utils/bot/kit` | `gracefulStart(bot)` — SIGINT/SIGTERM → `bot.stop()` → exit; force-kills if shutdown hangs.<br>`adminContext({ adminId? })` — reads `TELEGRAM_ADMIN_ID` from `kev` (with optional hardcoded fallback), decorates `ctx.adminId` + `ctx.isAdmin`. |
264
+ | `@adriangalilea/utils/bot/access-control` | Personal-bot ACL — gates non-admin/non-default users; admin gets DM with `[✅ Aprobar][❌ Denegar]` on first attempt; `/access` opens a persistent menu (revoke / reapprove / list pending). Backed by `@gramio/session` per-user + a small index. |
265
+ | `@adriangalilea/utils/bot/llm-stream` | `ctx.startStream()` for LLM token streams. Debounced `editMessageText`, splits at 4000 chars on paragraph/line/word boundary, parses Markdown locally so malformed mid-stream markup degrades to plain text instead of failing. |
266
+
267
+ Standard wiring:
268
+
269
+ ```typescript
270
+ import { Bot } from 'gramio'
271
+ import { redisStorage } from '@gramio/storage-redis'
272
+ import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
273
+ import { accessControl } from '@adriangalilea/utils/bot/access-control'
274
+ import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
275
+
276
+ const storage = redisStorage() // ONE instance, shared
277
+
278
+ const bot = new Bot(process.env.BOT_TOKEN!)
279
+ .extend(adminContext({ adminId: 190202471 })) // KEV.TELEGRAM_ADMIN_ID overrides
280
+ .extend(accessControl({ storage, defaults: [] })) // gate; depends on adminContext
281
+ .extend(llmStream())
282
+ .command('chat', async (ctx) => {
283
+ const stream = ctx.startStream()
284
+ for await (const chunk of yourLLM()) await stream.append(chunk.text)
285
+ await stream.end()
286
+ })
287
+
288
+ await gracefulStart(bot)
289
+ ```
290
+
291
+ Inside handlers, `ctx.access` is a typed discriminated union — `{ allowed: true, source: 'admin' | 'default' | 'store', record? }` or `{ allowed: false, reason }`. `ctx.adminId` and `ctx.isAdmin` are available on every event from `adminContext`.
292
+
293
+ For tests/demos without a second Telegram account, `simulateAccessRequest(bot, storage, adminId, fakeUser, msg)` injects a synthetic pending request so admin can exercise the approve/deny flow.
294
+
295
+ See `src/bot/CLAUDE.md` for storage layout, design decisions, and gotchas.
296
+
217
297
  ## Release
218
298
 
219
299
  Bump version in `package.json` (and `jsr.json`), push to `main`. CI handles everything:
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Access control for personal GramIO bots — a one-stop guard +
3
+ * approve/deny + revocable allow-list with an inline admin menu.
4
+ *
5
+ * stranger DMs your bot
6
+ * │
7
+ * ▼
8
+ * ┌──── plugin gate (this file) ────────────────────┐
9
+ * │ ctx.from.id ∈ admin / defaults / approved? │
10
+ * │ yes → next() │
11
+ * │ no → drop + notify admin (rate-limited) │
12
+ * └─────────────────────────────────────────────────┘
13
+ * │
14
+ * admin gets DM with [✅ Aprobar] [❌ Denegar]
15
+ * │
16
+ * admin taps
17
+ * │
18
+ * stranger's session updated · stranger gets DM
19
+ *
20
+ * **Storage layout.** Per-user state lives in its own key, written
21
+ * through `@gramio/session` so the gate read on the hot path costs
22
+ * nothing extra (session is loaded for the user already). A single
23
+ * tiny index key keeps track of who's pending / approved / denied so
24
+ * the `/access` admin menu can list without scanning the whole DB.
25
+ *
26
+ * storage:
27
+ * access:<userId> → AccessRecord (the user's session)
28
+ * ac:index → { pending, approved, denied }
29
+ *
30
+ * **Cross-user mutations.** When you tap `[✅ Aprobar]` on Pepe's
31
+ * notification, ctx is *yours* (the admin), so `ctx.access` is your
32
+ * own record. To mutate Pepe's record we hit the storage at the same
33
+ * key format we registered the session with (`access:<id>`) and
34
+ * update the index. This isn't a hack — it's our own module
35
+ * coordinating with itself.
36
+ *
37
+ * **Composes with `adminContext`** (kit.ts) — that plugin must be
38
+ * extended first or `bot.start()` throws. Inside this plugin,
39
+ * `ctx.adminId` and `ctx.isAdmin` are typed.
40
+ *
41
+ * Peer deps: `gramio`, `@gramio/storage`, `@gramio/session`.
42
+ *
43
+ * @example
44
+ * import { Bot } from 'gramio'
45
+ * import { redisStorage } from '@gramio/storage-redis'
46
+ * import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
47
+ * import { accessControl } from '@adriangalilea/utils/bot/access-control'
48
+ *
49
+ * const storage = redisStorage()
50
+ *
51
+ * const bot = new Bot(process.env.BOT_TOKEN!)
52
+ * .extend(adminContext({ adminId: 190202471 }))
53
+ * .extend(accessControl({ storage, defaults: [1158734055] }))
54
+ * .command('start', (ctx) => ctx.send(`hola, source=${ctx.access.source}`))
55
+ *
56
+ * await gracefulStart(bot)
57
+ */
58
+ import { type AnyBot, type DeriveDefinitions, Plugin } from 'gramio';
59
+ import { type Storage } from '@gramio/storage';
60
+ export type AccessStatus = 'unknown' | 'pending' | 'approved' | 'denied';
61
+ export type AccessUser = {
62
+ id: number;
63
+ firstName?: string;
64
+ lastName?: string;
65
+ username?: string;
66
+ };
67
+ /**
68
+ * The shape persisted per-user via session at `access:<userId>`.
69
+ * `unknown` is the initial state (session never seen this user).
70
+ */
71
+ export type AccessRecord = {
72
+ status: AccessStatus;
73
+ user?: AccessUser;
74
+ /** Chat to DM the user back. For private chats this equals user.id. */
75
+ chatId?: number;
76
+ requestedAt?: number;
77
+ approvedAt?: number;
78
+ approvedBy?: number;
79
+ deniedAt?: number;
80
+ deniedBy?: number;
81
+ /** First message text from the request (truncated). */
82
+ firstMessage?: string;
83
+ lastActivityAt?: number;
84
+ messageCount?: number;
85
+ /** Counts attempts after the initial request — used by the throttle. */
86
+ rejectedAttempts?: number;
87
+ lastNotifiedAt?: number;
88
+ };
89
+ export type AccessIndex = {
90
+ pending: number[];
91
+ approved: number[];
92
+ denied: number[];
93
+ };
94
+ export type AccessSource = 'admin' | 'default' | 'store';
95
+ /**
96
+ * What handlers downstream see on `ctx.access`. A discriminated union —
97
+ * use the `allowed` field to narrow.
98
+ */
99
+ export type AccessInfo = {
100
+ allowed: true;
101
+ source: AccessSource;
102
+ /** The persisted record, when source is 'store'. */
103
+ record?: AccessRecord;
104
+ } | {
105
+ allowed: false;
106
+ reason: 'denied' | 'pending' | 'unknown' | 'no-sender';
107
+ };
108
+ export type AccessControlOptions = {
109
+ /** Persistence. Default `inMemoryStorage()` (data lost on restart — warns once). */
110
+ storage?: Storage;
111
+ /** Always-allowed user ids, hardcoded. Bypass the entire flow. */
112
+ defaults?: ReadonlyArray<number>;
113
+ /** Reply sent to denied users on first attempt. `false` to silence. */
114
+ denyMessage?: string | false;
115
+ /** Min ms between repeat admin notifications for the same user. Default 6h. */
116
+ notifyThrottleMs?: number;
117
+ /** Callbacks for your own logging / metrics. */
118
+ onAccessRequest?: (info: {
119
+ user: AccessUser;
120
+ firstMessage?: string;
121
+ }) => void;
122
+ onApprove?: (info: {
123
+ userId: number;
124
+ approvedBy: number;
125
+ }) => void;
126
+ onDeny?: (info: {
127
+ userId: number;
128
+ deniedBy: number;
129
+ }) => void;
130
+ };
131
+ type AdminDerives = {
132
+ adminId: number;
133
+ isAdmin: boolean;
134
+ };
135
+ type AccessSessionDerives = {
136
+ _accessSession: AccessRecord;
137
+ };
138
+ type AccessDerives = {
139
+ access: AccessInfo;
140
+ };
141
+ export declare const accessControl: (opts?: AccessControlOptions) => Plugin<{}, DeriveDefinitions & {
142
+ global: AdminDerives & AccessSessionDerives & AccessDerives;
143
+ } & {
144
+ message: {
145
+ _accessSession: AccessRecord & {
146
+ $clear: () => Promise<void>;
147
+ };
148
+ };
149
+ channel_post: {
150
+ _accessSession: AccessRecord & {
151
+ $clear: () => Promise<void>;
152
+ };
153
+ };
154
+ inline_query: {
155
+ _accessSession: AccessRecord & {
156
+ $clear: () => Promise<void>;
157
+ };
158
+ };
159
+ chosen_inline_result: {
160
+ _accessSession: AccessRecord & {
161
+ $clear: () => Promise<void>;
162
+ };
163
+ };
164
+ callback_query: {
165
+ _accessSession: AccessRecord & {
166
+ $clear: () => Promise<void>;
167
+ };
168
+ };
169
+ shipping_query: {
170
+ _accessSession: AccessRecord & {
171
+ $clear: () => Promise<void>;
172
+ };
173
+ };
174
+ pre_checkout_query: {
175
+ _accessSession: AccessRecord & {
176
+ $clear: () => Promise<void>;
177
+ };
178
+ };
179
+ poll_answer: {
180
+ _accessSession: AccessRecord & {
181
+ $clear: () => Promise<void>;
182
+ };
183
+ };
184
+ chat_join_request: {
185
+ _accessSession: AccessRecord & {
186
+ $clear: () => Promise<void>;
187
+ };
188
+ };
189
+ new_chat_members: {
190
+ _accessSession: AccessRecord & {
191
+ $clear: () => Promise<void>;
192
+ };
193
+ };
194
+ new_chat_title: {
195
+ _accessSession: AccessRecord & {
196
+ $clear: () => Promise<void>;
197
+ };
198
+ };
199
+ new_chat_photo: {
200
+ _accessSession: AccessRecord & {
201
+ $clear: () => Promise<void>;
202
+ };
203
+ };
204
+ delete_chat_photo: {
205
+ _accessSession: AccessRecord & {
206
+ $clear: () => Promise<void>;
207
+ };
208
+ };
209
+ group_chat_created: {
210
+ _accessSession: AccessRecord & {
211
+ $clear: () => Promise<void>;
212
+ };
213
+ };
214
+ message_auto_delete_timer_changed: {
215
+ _accessSession: AccessRecord & {
216
+ $clear: () => Promise<void>;
217
+ };
218
+ };
219
+ migrate_to_chat_id: {
220
+ _accessSession: AccessRecord & {
221
+ $clear: () => Promise<void>;
222
+ };
223
+ };
224
+ migrate_from_chat_id: {
225
+ _accessSession: AccessRecord & {
226
+ $clear: () => Promise<void>;
227
+ };
228
+ };
229
+ pinned_message: {
230
+ _accessSession: AccessRecord & {
231
+ $clear: () => Promise<void>;
232
+ };
233
+ };
234
+ invoice: {
235
+ _accessSession: AccessRecord & {
236
+ $clear: () => Promise<void>;
237
+ };
238
+ };
239
+ successful_payment: {
240
+ _accessSession: AccessRecord & {
241
+ $clear: () => Promise<void>;
242
+ };
243
+ };
244
+ chat_shared: {
245
+ _accessSession: AccessRecord & {
246
+ $clear: () => Promise<void>;
247
+ };
248
+ };
249
+ proximity_alert_triggered: {
250
+ _accessSession: AccessRecord & {
251
+ $clear: () => Promise<void>;
252
+ };
253
+ };
254
+ video_chat_scheduled: {
255
+ _accessSession: AccessRecord & {
256
+ $clear: () => Promise<void>;
257
+ };
258
+ };
259
+ video_chat_started: {
260
+ _accessSession: AccessRecord & {
261
+ $clear: () => Promise<void>;
262
+ };
263
+ };
264
+ video_chat_ended: {
265
+ _accessSession: AccessRecord & {
266
+ $clear: () => Promise<void>;
267
+ };
268
+ };
269
+ video_chat_participants_invited: {
270
+ _accessSession: AccessRecord & {
271
+ $clear: () => Promise<void>;
272
+ };
273
+ };
274
+ web_app_data: {
275
+ _accessSession: AccessRecord & {
276
+ $clear: () => Promise<void>;
277
+ };
278
+ };
279
+ location: {
280
+ _accessSession: AccessRecord & {
281
+ $clear: () => Promise<void>;
282
+ };
283
+ };
284
+ passport_data: {
285
+ _accessSession: AccessRecord & {
286
+ $clear: () => Promise<void>;
287
+ };
288
+ };
289
+ } & {
290
+ global: {
291
+ access: {
292
+ allowed: false;
293
+ reason: "no-sender";
294
+ source?: undefined;
295
+ record?: undefined;
296
+ };
297
+ } | {
298
+ access: {
299
+ allowed: true;
300
+ source: "admin";
301
+ reason?: undefined;
302
+ record?: undefined;
303
+ };
304
+ } | {
305
+ access: {
306
+ allowed: true;
307
+ source: "default";
308
+ reason?: undefined;
309
+ record?: undefined;
310
+ };
311
+ } | {
312
+ access: {
313
+ allowed: true;
314
+ source: "store";
315
+ record: AccessRecord;
316
+ reason?: undefined;
317
+ };
318
+ } | {
319
+ access: {
320
+ allowed: false;
321
+ reason: "unknown" | "pending" | "denied";
322
+ source?: undefined;
323
+ record?: undefined;
324
+ };
325
+ };
326
+ }, {}>;
327
+ /**
328
+ * Inject a synthetic access request — for tests/demos when you can't
329
+ * easily spin up a second Telegram account. Writes a `pending` record
330
+ * to storage at the same key the plugin's session would, updates the
331
+ * index, then DMs the admin with the real
332
+ * `[✅ Aprobar][❌ Denegar]` keyboard. Tapping those buttons exercises
333
+ * the real callback handlers end-to-end.
334
+ *
335
+ * Pass the SAME `storage` instance you passed to `accessControl({ storage })`.
336
+ */
337
+ export declare const simulateAccessRequest: (bot: AnyBot, storage: Storage, adminId: number, fakeUser: AccessUser, message?: string) => Promise<void>;
338
+ export {};
339
+ //# sourceMappingURL=access-control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-control.d.ts","sourceRoot":"","sources":["../../src/bot/access-control.ts"],"names":[],"mappings":"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"}