@adriangalilea/utils 0.5.0 → 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
@@ -200,6 +200,7 @@ Every `SourcedError` carries `source`, `operation`, `status`, `context`, and the
200
200
  - **XDG**: XDG Base Directory paths — reads env vars set by [xdg-dirs](https://github.com/adriangalilea/xdg-dirs), falls back to spec defaults
201
201
  - **Unseen**: Persistent dedup filter — "what's new since last time?" for cron/monitoring workflows
202
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)
203
204
 
204
205
  ### XDG Base Directories
205
206
 
@@ -249,6 +250,50 @@ newMessages = [{ id: '2', from: 'bob', text: 'hey' }]
249
250
 
250
251
  Saves state to: `$XDG_STATE_HOME/unseen/{name}.json`
251
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
+
252
297
  ## Release
253
298
 
254
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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"}