@adriangalilea/utils 0.8.0 → 0.10.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
@@ -248,6 +248,34 @@ newMessages = [{ id: '2', from: 'bob', text: 'hey' }]
248
248
 
249
249
  Saves state to: `$XDG_STATE_HOME/unseen/{name}.json`
250
250
 
251
+ ### Polyglot strings (`say`)
252
+
253
+ A typed multi-language string is just an object literal `{ en, es, … }` — the keys are the source of truth, the TS compiler enforces completeness, there's no JSON file / extraction tool / registry.
254
+
255
+ ```typescript
256
+ import { say, type Polyglot } from '@adriangalilea/utils/say'
257
+
258
+ say({ en: 'Hello', es: 'Hola' }, 'es') // → 'Hola'
259
+ say({ en: 'Hello', es: 'Hola' }, 'fr') // TS error: '"fr"' not in '"en" | "es"'
260
+
261
+ // parametric — closures, no wrapper:
262
+ const greeting = (name: string) => ({ en: `Hi ${name}`, es: `Hola ${name}` })
263
+ say(greeting('Adrian'), 'es') // → 'Hola Adrian'
264
+
265
+ // type your own adapter:
266
+ const notify = (msg: Polyglot<'en' | 'es'>, lang: 'en' | 'es') =>
267
+ transport.send(say(msg, lang))
268
+ ```
269
+
270
+ In a bot, `bot/language` adds `ctx.say` — a callable namespace bound to `ctx.lang`:
271
+
272
+ ```typescript
273
+ ctx.say({ en: 'Continue', es: 'Continuar' }) // → string
274
+ await ctx.say.send({ en: 'Hi', es: 'Hola' }) // → ctx.send(resolved)
275
+ await ctx.say.edit({ en: 'Done', es: 'Listo' }) // → ctx.editText (callback only)
276
+ await ctx.say.answer({ en: 'OK', es: 'OK' }) // → ctx.answer (callback only)
277
+ ```
278
+
251
279
  ### Telegram bot plugins (GramIO)
252
280
 
253
281
  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.
@@ -258,7 +286,7 @@ pnpm add @adriangalilea/utils gramio @gramio/storage @gramio/session
258
286
 
259
287
  | Subpath | What it does |
260
288
  |---|---|
261
- | `@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`. |
289
+ | `@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`.<br>`inThread(ctx)` — returns `{ message_thread_id }` (or `{}`) so outgoing methods route to the same thread. Closes [gramio's auto-thread gap](https://gramio.dev) under BotFather Threaded Mode, where `isTopicMessage()` is false. |
262
290
  | `@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. |
263
291
  | `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
264
292
  | `@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. |
@@ -11,7 +11,7 @@
11
11
  * │ no → drop + notify admin (rate-limited) │
12
12
  * └─────────────────────────────────────────────────┘
13
13
  * │
14
- * admin gets DM with [✅ Aprobar] [❌ Denegar]
14
+ * admin gets DM with [✅ Approve] [❌ Deny]
15
15
  * │
16
16
  * admin taps
17
17
  * │
@@ -33,12 +33,19 @@
33
33
  *
34
34
  * storage['ac:index'] = { pending: [...ids], approved: [...], denied: [...] }
35
35
  *
36
- * **Cross-user mutations.** When the admin taps `[✅ Aprobar]` on
36
+ * **Cross-user mutations.** When the admin taps `[✅ Approve]` on
37
37
  * Pepe's notification, `ctx` is the admin's, so `ctx.session` is the
38
38
  * admin's record — useless for mutating Pepe. We reach for Pepe's
39
39
  * record directly via `storage.get(String(pepeId))`, preserve other
40
40
  * plugins' fields in it (read-modify-write), and put it back.
41
41
  *
42
+ * **i18n.** Every user-facing string is an inline `{ en, es }`
43
+ * polyglot literal resolved via `say(value, lang)` at the call site
44
+ * — no message bundle, no override registry. The recipient's stored
45
+ * `language` field (set by `bot/language`) picks the variant; falls
46
+ * back to `'en'`. Want a different default? Set `language` on the
47
+ * relevant session record before this plugin fires.
48
+ *
42
49
  * **Composes with**:
43
50
  * - `adminContext` (kit.ts) — required, gives us `ctx.adminId` /
44
51
  * `ctx.isAdmin`. Declared as a runtime dependency.
@@ -120,9 +127,13 @@ export type AccessInfo = {
120
127
  allowed: false;
121
128
  reason: 'denied' | 'pending' | 'unknown' | 'no-sender';
122
129
  };
123
- /** Loose session shape — this plugin only touches the `access` field. */
130
+ /**
131
+ * Loose session shape — this plugin writes `access`; it READS `language`
132
+ * to localize messages it sends to the subject. Both are optional.
133
+ */
124
134
  type SessionLike = {
125
135
  access?: AccessRecord;
136
+ language?: string;
126
137
  };
127
138
  /** @internal — kept unexported so it doesn't clash with peers' refs. */
128
139
  type AcSessionPluginRef = ReturnType<typeof session<SessionLike, 'session'>>;
@@ -141,8 +152,8 @@ export type AccessControlOptions = {
141
152
  storage: Storage;
142
153
  /** Always-allowed user ids, hardcoded. Bypass the entire flow. */
143
154
  defaults?: ReadonlyArray<number>;
144
- /** Reply sent to denied users on first attempt. `false` to silence. */
145
- denyMessage?: string | false;
155
+ /** Pass `false` to silence the first-attempt reply to denied users. */
156
+ silentDeny?: boolean;
146
157
  /** Min ms between repeat admin notifications for the same user. Default 6h. */
147
158
  notifyThrottleMs?: number;
148
159
  /** Callbacks for your own logging / metrics. */
@@ -362,7 +373,7 @@ export declare const accessControl: (opts: AccessControlOptions) => Plugin<{}, D
362
373
  * easily spin up a second Telegram account. Writes a `pending` record
363
374
  * to storage at the same key the plugin's session would, updates the
364
375
  * index, then DMs the admin with the real
365
- * `[✅ Aprobar][❌ Denegar]` keyboard. Tapping those buttons exercises
376
+ * `[✅ Approve][❌ Deny]` keyboard. Tapping those buttons exercises
366
377
  * the real callback handlers end-to-end.
367
378
  *
368
379
  * Pass the SAME `storage` instance you passed to `accessControl({ storage })`.
@@ -1 +1 @@
1
- {"version":3,"file":"access-control.d.ts","sourceRoot":"","sources":["../../src/bot/access-control.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"access-control.d.ts","sourceRoot":"","sources":["../../src/bot/access-control.ts"],"names":[],"mappings":"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"}
@@ -11,7 +11,7 @@
11
11
  * │ no → drop + notify admin (rate-limited) │
12
12
  * └─────────────────────────────────────────────────┘
13
13
  * │
14
- * admin gets DM with [✅ Aprobar] [❌ Denegar]
14
+ * admin gets DM with [✅ Approve] [❌ Deny]
15
15
  * │
16
16
  * admin taps
17
17
  * │
@@ -33,12 +33,19 @@
33
33
  *
34
34
  * storage['ac:index'] = { pending: [...ids], approved: [...], denied: [...] }
35
35
  *
36
- * **Cross-user mutations.** When the admin taps `[✅ Aprobar]` on
36
+ * **Cross-user mutations.** When the admin taps `[✅ Approve]` on
37
37
  * Pepe's notification, `ctx` is the admin's, so `ctx.session` is the
38
38
  * admin's record — useless for mutating Pepe. We reach for Pepe's
39
39
  * record directly via `storage.get(String(pepeId))`, preserve other
40
40
  * plugins' fields in it (read-modify-write), and put it back.
41
41
  *
42
+ * **i18n.** Every user-facing string is an inline `{ en, es }`
43
+ * polyglot literal resolved via `say(value, lang)` at the call site
44
+ * — no message bundle, no override registry. The recipient's stored
45
+ * `language` field (set by `bot/language`) picks the variant; falls
46
+ * back to `'en'`. Want a different default? Set `language` on the
47
+ * relevant session record before this plugin fires.
48
+ *
42
49
  * **Composes with**:
43
50
  * - `adminContext` (kit.ts) — required, gives us `ctx.adminId` /
44
51
  * `ctx.isAdmin`. Declared as a runtime dependency.
@@ -68,14 +75,11 @@
68
75
  * await gracefulStart(bot)
69
76
  */
70
77
  import { CallbackData, InlineKeyboard, Plugin, } from 'gramio';
71
- // Cross-user mutations (admin approves Pepe → write to Pepe's session)
72
- // hit `storage` directly using the same key format `@gramio/session`
73
- // uses by default: `String(userId)`. We preserve other plugins' fields
74
- // in the same session record by read-modify-write.
78
+ import { say } from '../say/index.js';
75
79
  const INDEX_KEY = 'ac:index';
76
80
  const FIRST_MSG_LIMIT = 200;
77
- const DEFAULT_DENY_MSG = 'Este bot es privado. Tu solicitud se ha enviado al admin.';
78
81
  const DEFAULT_THROTTLE_MS = 6 * 60 * 60 * 1000;
82
+ const FALLBACK_LANG = 'en';
79
83
  /** gramio's `@gramio/session` default `getSessionKey` is `String(senderId)`. */
80
84
  const sessionKey = (userId) => String(userId);
81
85
  // ─── callback schemas ──────────────────────────────────────────────
@@ -109,25 +113,27 @@ const fmtAge = (ms) => {
109
113
  return `${h}h`;
110
114
  return `${Math.floor(h / 24)}d`;
111
115
  };
112
- const requestNotificationText = (uid, r, repeat) => {
116
+ const requestNotificationText = (uid, r, repeat, lang) => {
113
117
  const parts = [
114
- repeat ? '🔁 Acceso re-solicitado' : '🔔 Acceso solicitado',
118
+ say(repeat
119
+ ? { en: '🔁 Access re-requested', es: '🔁 Acceso re-solicitado' }
120
+ : { en: '🔔 Access requested', es: '🔔 Acceso solicitado' }, lang),
115
121
  '',
116
122
  `👤 ${formatUser(r.user, uid)}`,
117
123
  `🆔 ${uid}`,
118
- `⏰ hace ${fmtAge(Date.now() - (r.requestedAt ?? Date.now()))}`,
124
+ `⏰ ${say({ en: 'ago', es: 'hace' }, lang)} ${fmtAge(Date.now() - (r.requestedAt ?? Date.now()))}`,
119
125
  ];
120
- if (repeat)
121
- parts.push(`🔁 intentos: ${(r.rejectedAttempts ?? 0) + 1}`);
126
+ if (repeat) {
127
+ parts.push(`🔁 ${say({ en: 'attempts', es: 'intentos' }, lang)}: ${(r.rejectedAttempts ?? 0) + 1}`);
128
+ }
122
129
  if (r.firstMessage)
123
130
  parts.push('', `💬 "${r.firstMessage}"`);
124
131
  return parts.join('\n');
125
132
  };
126
- const requestKeyboard = (uid) => new InlineKeyboard()
127
- .text('✅ Aprobar', acApprove.pack({ uid }))
128
- .text('❌ Denegar', acDeny.pack({ uid }));
133
+ const requestKeyboard = (uid, lang) => new InlineKeyboard()
134
+ .text(say({ en: '✅ Approve', es: '✅ Aprobar' }, lang), acApprove.pack({ uid }))
135
+ .text(say({ en: '❌ Deny', es: '❌ Denegar' }, lang), acDeny.pack({ uid }));
129
136
  // ─── index helpers ─────────────────────────────────────────────────
130
- const emptyIndex = () => ({ pending: [], approved: [], denied: [] });
131
137
  const loadIndex = async (storage) => {
132
138
  const raw = (await storage.get(INDEX_KEY));
133
139
  return {
@@ -172,11 +178,18 @@ const loadAccess = async (storage, userId) => {
172
178
  const full = await loadFullRecord(storage, userId);
173
179
  return full.access;
174
180
  };
181
+ /** Read recipient's stored language (set by bot/language); fallback to en. */
182
+ const langOfUser = async (storage, userId) => {
183
+ const full = await loadFullRecord(storage, userId);
184
+ return full.language ?? FALLBACK_LANG;
185
+ };
186
+ /** Read current ctx's lang. */
187
+ const ctxLang = (ctx) => ctx.session.language ?? FALLBACK_LANG;
175
188
  // ─── plugin ────────────────────────────────────────────────────────
176
189
  export const accessControl = (opts) => {
177
190
  const { session: sessionPlugin, storage } = opts;
178
191
  const defaults = new Set(opts.defaults ?? []);
179
- const denyMessage = opts.denyMessage === false ? null : (opts.denyMessage ?? DEFAULT_DENY_MSG);
192
+ const silentDeny = opts.silentDeny === true;
180
193
  const throttleMs = opts.notifyThrottleMs ?? DEFAULT_THROTTLE_MS;
181
194
  return (
182
195
  // Generic declares dependency on adminContext's derives so
@@ -231,7 +244,10 @@ export const accessControl = (opts) => {
231
244
  }
232
245
  // Acknowledge unauthorized callback queries so the spinner clears.
233
246
  if (ctx.is('callback_query')) {
234
- await ctx.answer({ text: 'Sin acceso.', show_alert: false });
247
+ await ctx.answer({
248
+ text: say({ en: 'No access.', es: 'Sin acceso.' }, ctxLang(ctx)),
249
+ show_alert: false,
250
+ });
235
251
  return;
236
252
  }
237
253
  // Only message-shaped events have .text/.chat for our notification.
@@ -267,10 +283,11 @@ export const accessControl = (opts) => {
267
283
  if (shouldNotify) {
268
284
  rec.lastNotifiedAt = now;
269
285
  try {
286
+ const adminLang = await langOfUser(storage, ctx.adminId);
270
287
  await ctx.bot.api.sendMessage({
271
288
  chat_id: ctx.adminId,
272
- text: requestNotificationText(userId, rec, !isFirstRequest),
273
- reply_markup: requestKeyboard(userId),
289
+ text: requestNotificationText(userId, rec, !isFirstRequest, adminLang),
290
+ reply_markup: requestKeyboard(userId, adminLang),
274
291
  });
275
292
  }
276
293
  catch (e) {
@@ -280,9 +297,12 @@ export const accessControl = (opts) => {
280
297
  }
281
298
  // Persist the updated record to the user's session.
282
299
  ctx.session.access = rec;
283
- if (denyMessage && isFirstRequest) {
300
+ if (!silentDeny && isFirstRequest) {
284
301
  try {
285
- await ctx.send(denyMessage);
302
+ await ctx.send(say({
303
+ en: 'This bot is private. Your request has been sent to the admin.',
304
+ es: 'Este bot es privado. Tu solicitud se ha enviado al admin.',
305
+ }, ctxLang(ctx)));
286
306
  }
287
307
  catch {
288
308
  // user blocked the bot — irrelevant
@@ -292,12 +312,18 @@ export const accessControl = (opts) => {
292
312
  })
293
313
  // ─── admin actions ────────────────────────────────────────
294
314
  .callbackQuery(acApprove, async (ctx) => {
315
+ const aLang = ctxLang(ctx);
295
316
  if (!ctx.isAdmin)
296
- return ctx.answer({ text: 'Solo admin.', show_alert: true });
317
+ return ctx.answer({
318
+ text: say({ en: 'Admin only.', es: 'Solo admin.' }, aLang),
319
+ show_alert: true,
320
+ });
297
321
  const uid = ctx.queryData.uid;
298
322
  const rec = await loadAccess(storage, uid);
299
323
  if (!rec)
300
- return ctx.answer({ text: 'No encontrado.' });
324
+ return ctx.answer({
325
+ text: say({ en: 'Not found.', es: 'No encontrado.' }, aLang),
326
+ });
301
327
  const wasDenied = rec.status === 'denied';
302
328
  const wasPending = rec.status === 'pending';
303
329
  rec.status = 'approved';
@@ -309,24 +335,33 @@ export const accessControl = (opts) => {
309
335
  await indexMove(storage, uid, wasPending ? 'pending' : wasDenied ? 'denied' : 'any', 'approved');
310
336
  if (rec.chatId !== undefined) {
311
337
  try {
338
+ const sLang = await langOfUser(storage, uid);
312
339
  await ctx.bot.api.sendMessage({
313
340
  chat_id: rec.chatId,
314
- text: wasDenied
315
- ? '✅ El admin reconsideró: ya tienes acceso.'
316
- : '✅ Acceso concedido. Ya puedes usar el bot.',
341
+ text: say(wasDenied
342
+ ? {
343
+ en: '✅ The admin reconsidered: you have access.',
344
+ es: '✅ El admin reconsideró: ya tienes acceso.',
345
+ }
346
+ : {
347
+ en: '✅ Access granted. You can use the bot now.',
348
+ es: '✅ Acceso concedido. Ya puedes usar el bot.',
349
+ }, sLang),
317
350
  });
318
351
  }
319
352
  catch {
320
353
  // user blocked / chat gone
321
354
  }
322
355
  }
323
- await ctx.answer({ text: '✅ Aprobado' });
356
+ await ctx.answer({
357
+ text: say({ en: '✅ Approved', es: '✅ Aprobado' }, aLang),
358
+ });
324
359
  if (ctx.queryData.v) {
325
- await renderView(ctx, storage, defaults, ctx.queryData.v);
360
+ await renderView(ctx, storage, defaults, ctx.queryData.v, aLang);
326
361
  }
327
362
  else {
328
363
  try {
329
- await ctx.editText(`✅ Aprobado · ${formatUser(rec.user, uid)}`);
364
+ await ctx.editText(`${say({ en: '✅ Approved', es: '✅ Aprobado' }, aLang)} · ${formatUser(rec.user, uid)}`);
330
365
  }
331
366
  catch {
332
367
  // not always editable
@@ -335,12 +370,18 @@ export const accessControl = (opts) => {
335
370
  opts.onApprove?.({ userId: uid, approvedBy: ctx.adminId });
336
371
  })
337
372
  .callbackQuery(acDeny, async (ctx) => {
373
+ const aLang = ctxLang(ctx);
338
374
  if (!ctx.isAdmin)
339
- return ctx.answer({ text: 'Solo admin.', show_alert: true });
375
+ return ctx.answer({
376
+ text: say({ en: 'Admin only.', es: 'Solo admin.' }, aLang),
377
+ show_alert: true,
378
+ });
340
379
  const uid = ctx.queryData.uid;
341
380
  const rec = await loadAccess(storage, uid);
342
381
  if (!rec)
343
- return ctx.answer({ text: 'No encontrado.' });
382
+ return ctx.answer({
383
+ text: say({ en: 'Not found.', es: 'No encontrado.' }, aLang),
384
+ });
344
385
  const wasPending = rec.status === 'pending';
345
386
  rec.status = 'denied';
346
387
  rec.deniedAt = Date.now();
@@ -349,22 +390,25 @@ export const accessControl = (opts) => {
349
390
  await indexMove(storage, uid, wasPending ? 'pending' : 'any', 'denied');
350
391
  if (rec.chatId !== undefined) {
351
392
  try {
393
+ const sLang = await langOfUser(storage, uid);
352
394
  await ctx.bot.api.sendMessage({
353
395
  chat_id: rec.chatId,
354
- text: '❌ Acceso denegado.',
396
+ text: say({ en: '❌ Access denied.', es: '❌ Acceso denegado.' }, sLang),
355
397
  });
356
398
  }
357
399
  catch {
358
400
  // ignore
359
401
  }
360
402
  }
361
- await ctx.answer({ text: '❌ Denegado' });
403
+ await ctx.answer({
404
+ text: say({ en: '❌ Denied', es: '❌ Denegado' }, aLang),
405
+ });
362
406
  if (ctx.queryData.v) {
363
- await renderView(ctx, storage, defaults, ctx.queryData.v);
407
+ await renderView(ctx, storage, defaults, ctx.queryData.v, aLang);
364
408
  }
365
409
  else {
366
410
  try {
367
- await ctx.editText(`❌ Denegado · ${formatUser(rec.user, uid)}`);
411
+ await ctx.editText(`${say({ en: '❌ Denied', es: '❌ Denegado' }, aLang)} · ${formatUser(rec.user, uid)}`);
368
412
  }
369
413
  catch {
370
414
  // ignore
@@ -373,12 +417,18 @@ export const accessControl = (opts) => {
373
417
  opts.onDeny?.({ userId: uid, deniedBy: ctx.adminId });
374
418
  })
375
419
  .callbackQuery(acRevoke, async (ctx) => {
420
+ const aLang = ctxLang(ctx);
376
421
  if (!ctx.isAdmin)
377
- return ctx.answer({ text: 'Solo admin.', show_alert: true });
422
+ return ctx.answer({
423
+ text: say({ en: 'Admin only.', es: 'Solo admin.' }, aLang),
424
+ show_alert: true,
425
+ });
378
426
  const uid = ctx.queryData.uid;
379
427
  const rec = await loadAccess(storage, uid);
380
428
  if (!rec)
381
- return ctx.answer({ text: 'No encontrado.' });
429
+ return ctx.answer({
430
+ text: say({ en: 'Not found.', es: 'No encontrado.' }, aLang),
431
+ });
382
432
  rec.status = 'denied';
383
433
  rec.deniedAt = Date.now();
384
434
  rec.deniedBy = ctx.adminId;
@@ -386,27 +436,41 @@ export const accessControl = (opts) => {
386
436
  await indexMove(storage, uid, 'approved', 'denied');
387
437
  if (rec.chatId !== undefined) {
388
438
  try {
439
+ const sLang = await langOfUser(storage, uid);
389
440
  await ctx.bot.api.sendMessage({
390
441
  chat_id: rec.chatId,
391
- text: '↩️ Tu acceso al bot ha sido revocado.',
442
+ text: say({
443
+ en: '↩️ Your bot access has been revoked.',
444
+ es: '↩️ Tu acceso al bot ha sido revocado.',
445
+ }, sLang),
392
446
  });
393
447
  }
394
448
  catch {
395
449
  // ignore
396
450
  }
397
451
  }
398
- await ctx.answer({ text: '↩️ Revocado' });
399
- await renderView(ctx, storage, defaults, 'approved');
452
+ await ctx.answer({
453
+ text: say({ en: '↩️ Revoked', es: '↩️ Revocado' }, aLang),
454
+ });
455
+ await renderView(ctx, storage, defaults, 'approved', aLang);
400
456
  })
401
457
  .callbackQuery(acView, async (ctx) => {
458
+ const aLang = ctxLang(ctx);
402
459
  if (!ctx.isAdmin)
403
- return ctx.answer({ text: 'Solo admin.', show_alert: true });
460
+ return ctx.answer({
461
+ text: say({ en: 'Admin only.', es: 'Solo admin.' }, aLang),
462
+ show_alert: true,
463
+ });
404
464
  await ctx.answer({});
405
- await renderView(ctx, storage, defaults, ctx.queryData.v);
465
+ await renderView(ctx, storage, defaults, ctx.queryData.v, aLang);
406
466
  })
407
467
  .callbackQuery(acClose, async (ctx) => {
468
+ const aLang = ctxLang(ctx);
408
469
  if (!ctx.isAdmin)
409
- return ctx.answer({ text: 'Solo admin.', show_alert: true });
470
+ return ctx.answer({
471
+ text: say({ en: 'Admin only.', es: 'Solo admin.' }, aLang),
472
+ show_alert: true,
473
+ });
410
474
  await ctx.answer({});
411
475
  try {
412
476
  await ctx.message?.delete();
@@ -419,23 +483,28 @@ export const accessControl = (opts) => {
419
483
  // Admin-only; hidden from Telegram's `/` menu so it doesn't
420
484
  // tempt other users to type it. Admin still invokes via /access.
421
485
  // See https://gramio.dev/triggers/command.html#commandmeta-fields
486
+ //
487
+ // Note: gramio's setMyCommands publishes ONE description per
488
+ // bot, not per language. English form used as the canonical.
422
489
  description: 'Admin: access control menu',
423
490
  hide: true,
424
491
  }, async (ctx) => {
425
492
  if (!ctx.isAdmin)
426
493
  return;
427
- const v = await mainView(storage, defaults);
494
+ const aLang = ctxLang(ctx);
495
+ const v = mainView(await loadIndex(storage), defaults, aLang);
428
496
  await ctx.send(v.text, { reply_markup: v.keyboard });
429
497
  }));
430
498
  };
431
- const renderView = async (ctx, storage, defaults, view) => {
499
+ const renderView = async (ctx, storage, defaults, view, lang) => {
500
+ const idx = await loadIndex(storage);
432
501
  const v = view === 'approved'
433
- ? await listView(storage, 'approved', defaults)
502
+ ? await listView(storage, idx, 'approved', defaults, lang)
434
503
  : view === 'pending'
435
- ? await listView(storage, 'pending', defaults)
504
+ ? await listView(storage, idx, 'pending', defaults, lang)
436
505
  : view === 'denied'
437
- ? await listView(storage, 'denied', defaults)
438
- : await mainView(storage, defaults);
506
+ ? await listView(storage, idx, 'denied', defaults, lang)
507
+ : mainView(idx, defaults, lang);
439
508
  try {
440
509
  await ctx.editText(v.text, { reply_markup: v.keyboard });
441
510
  }
@@ -443,37 +512,44 @@ const renderView = async (ctx, storage, defaults, view) => {
443
512
  // editText only works while message is recent enough; ignore
444
513
  }
445
514
  };
446
- const mainView = async (storage, defaults) => {
447
- const idx = await loadIndex(storage);
515
+ const mainView = (idx, defaults, lang) => {
516
+ const approved = say({ en: 'Approved', es: 'Aprobados' }, lang);
517
+ const pending = say({ en: 'Pending', es: 'Pendientes' }, lang);
518
+ const denied = say({ en: 'Denied', es: 'Denegados' }, lang);
448
519
  const text = [
449
- '🔐 Access Control',
520
+ say({ en: '🔐 Access Control', es: '🔐 Access Control' }, lang),
450
521
  '',
451
- `✅ Aprobados: ${idx.approved.length}`,
452
- `⏳ Pendientes: ${idx.pending.length}`,
453
- `❌ Denegados: ${idx.denied.length}`,
454
- `👑 Defaults: ${defaults.size} (hardcoded)`,
522
+ `✅ ${approved}: ${idx.approved.length}`,
523
+ `⏳ ${pending}: ${idx.pending.length}`,
524
+ `❌ ${denied}: ${idx.denied.length}`,
525
+ `👑 ${say({ en: 'Defaults', es: 'Defaults' }, lang)}: ${defaults.size} (hardcoded)`,
455
526
  ].join('\n');
456
527
  const keyboard = new InlineKeyboard()
457
- .text(`✅ Aprobados (${idx.approved.length})`, acView.pack({ v: 'approved' }))
458
- .text(`⏳ Pendientes (${idx.pending.length})`, acView.pack({ v: 'pending' }))
528
+ .text(`✅ ${approved} (${idx.approved.length})`, acView.pack({ v: 'approved' }))
529
+ .text(`⏳ ${pending} (${idx.pending.length})`, acView.pack({ v: 'pending' }))
459
530
  .row()
460
- .text(`❌ Denegados (${idx.denied.length})`, acView.pack({ v: 'denied' }))
461
- .text('🔄 Refresh', acView.pack({ v: 'main' }))
531
+ .text(`❌ ${denied} (${idx.denied.length})`, acView.pack({ v: 'denied' }))
532
+ .text(say({ en: '🔄 Refresh', es: '🔄 Refresh' }, lang), acView.pack({ v: 'main' }))
462
533
  .row()
463
- .text('✖️ Cerrar', acClose.pack({}));
534
+ .text(say({ en: '✖️ Close', es: '✖️ Cerrar' }, lang), acClose.pack({}));
464
535
  return { text, keyboard };
465
536
  };
466
- const listView = async (storage, filter, defaults) => {
467
- const idx = await loadIndex(storage);
537
+ const listView = async (storage, idx, filter, defaults, lang) => {
468
538
  const ids = idx[filter];
469
539
  // Cap at 20 to keep callback_data + rendering sane.
470
540
  const shownIds = ids.slice(0, 20);
471
541
  const records = await Promise.all(shownIds.map(async (id) => ({ id, rec: await loadAccess(storage, id) })));
472
542
  const headerEmoji = filter === 'approved' ? '✅' : filter === 'pending' ? '⏳' : '❌';
473
- const headerLabel = filter === 'approved' ? 'Aprobados' : filter === 'pending' ? 'Pendientes' : 'Denegados';
543
+ const headerLabel = filter === 'approved'
544
+ ? say({ en: 'Approved', es: 'Aprobados' }, lang)
545
+ : filter === 'pending'
546
+ ? say({ en: 'Pending', es: 'Pendientes' }, lang)
547
+ : say({ en: 'Denied', es: 'Denegados' }, lang);
548
+ const back = say({ en: '⬅️ Back', es: '⬅️ Volver' }, lang);
474
549
  if (ids.length === 0) {
475
- const text = `${headerEmoji} ${headerLabel} (0)\n\n(vacío)`;
476
- const keyboard = new InlineKeyboard().text('⬅️ Volver', acView.pack({ v: 'main' }));
550
+ const text = `${headerEmoji} ${headerLabel} (0)\n\n` +
551
+ say({ en: '(empty)', es: '(vacío)' }, lang);
552
+ const keyboard = new InlineKeyboard().text(back, acView.pack({ v: 'main' }));
477
553
  return { text, keyboard };
478
554
  }
479
555
  const lines = [`${headerEmoji} ${headerLabel} (${ids.length})`, ''];
@@ -482,11 +558,11 @@ const listView = async (storage, filter, defaults) => {
482
558
  const { id, rec } = records[i];
483
559
  if (!rec) {
484
560
  // index referenced a missing record — show as placeholder
485
- lines.push(`${i + 1}. id ${id} (datos perdidos)`);
561
+ lines.push(`${i + 1}. id ${id} ${say({ en: '(data lost)', es: '(datos perdidos)' }, lang)}`);
486
562
  continue;
487
563
  }
488
564
  const ageRef = rec.approvedAt ?? rec.deniedAt ?? rec.requestedAt ?? Date.now();
489
- lines.push(`${i + 1}. ${formatUser(rec.user, id)} · hace ${fmtAge(Date.now() - ageRef)}` +
565
+ lines.push(`${i + 1}. ${formatUser(rec.user, id)} · ${say({ en: 'ago', es: 'hace' }, lang)} ${fmtAge(Date.now() - ageRef)}` +
490
566
  (rec.messageCount ? ` · ${rec.messageCount} msgs` : ''));
491
567
  if (filter === 'pending') {
492
568
  keyboard
@@ -495,21 +571,23 @@ const listView = async (storage, filter, defaults) => {
495
571
  .row();
496
572
  }
497
573
  else if (filter === 'approved') {
498
- keyboard.text(`↩️ Revocar #${i + 1}`, acRevoke.pack({ uid: id })).row();
574
+ keyboard
575
+ .text(`${say({ en: '↩️ Revoke', es: '↩️ Revocar' }, lang)} #${i + 1}`, acRevoke.pack({ uid: id }))
576
+ .row();
499
577
  }
500
578
  else if (filter === 'denied') {
501
579
  keyboard
502
- .text(`✅ Reaprobar #${i + 1}`, acApprove.pack({ uid: id, v: 'denied' }))
580
+ .text(`${say({ en: '✅ Reapprove', es: '✅ Reaprobar' }, lang)} #${i + 1}`, acApprove.pack({ uid: id, v: 'denied' }))
503
581
  .row();
504
582
  }
505
583
  }
506
584
  if (ids.length > shownIds.length) {
507
- lines.push('', `(+${ids.length - shownIds.length} más, no mostrados)`);
585
+ lines.push('', `(+${ids.length - shownIds.length} ${say({ en: 'more, not shown', es: 'más, no mostrados' }, lang)})`);
508
586
  }
509
587
  if (filter === 'approved' && defaults.size > 0) {
510
588
  lines.push('', `+ ${defaults.size} hardcoded defaults`);
511
589
  }
512
- keyboard.text('⬅️ Volver', acView.pack({ v: 'main' }));
590
+ keyboard.text(back, acView.pack({ v: 'main' }));
513
591
  return { text: lines.join('\n'), keyboard };
514
592
  };
515
593
  // ─── test helper ───────────────────────────────────────────────────
@@ -518,7 +596,7 @@ const listView = async (storage, filter, defaults) => {
518
596
  * easily spin up a second Telegram account. Writes a `pending` record
519
597
  * to storage at the same key the plugin's session would, updates the
520
598
  * index, then DMs the admin with the real
521
- * `[✅ Aprobar][❌ Denegar]` keyboard. Tapping those buttons exercises
599
+ * `[✅ Approve][❌ Deny]` keyboard. Tapping those buttons exercises
522
600
  * the real callback handlers end-to-end.
523
601
  *
524
602
  * Pass the SAME `storage` instance you passed to `accessControl({ storage })`.
@@ -537,10 +615,11 @@ export const simulateAccessRequest = async (bot, storage, adminId, fakeUser, mes
537
615
  };
538
616
  await saveAccess(storage, fakeUser.id, rec);
539
617
  await indexAdd(storage, 'pending', fakeUser.id);
618
+ const adminLang = await langOfUser(storage, adminId);
540
619
  await bot.api.sendMessage({
541
620
  chat_id: adminId,
542
- text: requestNotificationText(fakeUser.id, rec, false),
543
- reply_markup: requestKeyboard(fakeUser.id),
621
+ text: requestNotificationText(fakeUser.id, rec, false, adminLang),
622
+ reply_markup: requestKeyboard(fakeUser.id, adminLang),
544
623
  });
545
624
  };
546
625
  //# sourceMappingURL=access-control.js.map