@adriangalilea/utils 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +29 -5
  2. package/dist/bot/access-control.d.ts +106 -62
  3. package/dist/bot/access-control.d.ts.map +1 -1
  4. package/dist/bot/access-control.js +255 -146
  5. package/dist/bot/access-control.js.map +1 -1
  6. package/dist/bot/index.d.ts +3 -0
  7. package/dist/bot/index.d.ts.map +1 -1
  8. package/dist/bot/index.js +3 -0
  9. package/dist/bot/index.js.map +1 -1
  10. package/dist/bot/kit.d.ts.map +1 -1
  11. package/dist/bot/kit.js +6 -0
  12. package/dist/bot/kit.js.map +1 -1
  13. package/dist/bot/language.d.ts +305 -0
  14. package/dist/bot/language.d.ts.map +1 -0
  15. package/dist/bot/language.js +250 -0
  16. package/dist/bot/language.js.map +1 -0
  17. package/dist/bot/menu.d.ts +189 -0
  18. package/dist/bot/menu.d.ts.map +1 -0
  19. package/dist/bot/menu.js +331 -0
  20. package/dist/bot/menu.js.map +1 -0
  21. package/dist/bot/message-history.d.ts +259 -0
  22. package/dist/bot/message-history.d.ts.map +1 -0
  23. package/dist/bot/message-history.js +111 -0
  24. package/dist/bot/message-history.js.map +1 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/say/index.d.ts +62 -0
  30. package/dist/say/index.d.ts.map +1 -0
  31. package/dist/say/index.js +59 -0
  32. package/dist/say/index.js.map +1 -0
  33. package/package.json +17 -1
  34. package/dist/currency/crypto-symbols-data.d.ts +0 -10
  35. package/dist/currency/crypto-symbols-data.d.ts.map +0 -1
  36. package/dist/currency/crypto-symbols-data.js +0 -13765
  37. package/dist/currency/crypto-symbols-data.js.map +0 -1
  38. package/dist/currency/crypto-symbols.d.ts +0 -20
  39. package/dist/currency/crypto-symbols.d.ts.map +0 -1
  40. package/dist/currency/crypto-symbols.js +0 -23
  41. package/dist/currency/crypto-symbols.js.map +0 -1
  42. package/dist/currency/download-crypto-list.d.ts +0 -10
  43. package/dist/currency/download-crypto-list.d.ts.map +0 -1
  44. package/dist/currency/download-crypto-list.js +0 -69
  45. package/dist/currency/download-crypto-list.js.map +0 -1
  46. package/dist/currency/index.d.ts +0 -84
  47. package/dist/currency/index.d.ts.map +0 -1
  48. package/dist/currency/index.js +0 -230
  49. package/dist/currency/index.js.map +0 -1
  50. package/dist/dir.d.ts +0 -40
  51. package/dist/dir.d.ts.map +0 -1
  52. package/dist/dir.js +0 -108
  53. package/dist/dir.js.map +0 -1
  54. package/dist/file.d.ts +0 -53
  55. package/dist/file.d.ts.map +0 -1
  56. package/dist/file.js +0 -211
  57. package/dist/file.js.map +0 -1
  58. package/dist/format.d.ts +0 -40
  59. package/dist/format.d.ts.map +0 -1
  60. package/dist/format.js +0 -83
  61. package/dist/format.js.map +0 -1
  62. package/dist/kev.d.ts +0 -149
  63. package/dist/kev.d.ts.map +0 -1
  64. package/dist/kev.js +0 -761
  65. package/dist/kev.js.map +0 -1
  66. package/dist/log.d.ts +0 -91
  67. package/dist/log.d.ts.map +0 -1
  68. package/dist/log.js +0 -300
  69. package/dist/log.js.map +0 -1
  70. package/dist/logger.d.ts +0 -91
  71. package/dist/logger.d.ts.map +0 -1
  72. package/dist/logger.js +0 -269
  73. package/dist/logger.js.map +0 -1
  74. package/dist/path.d.ts +0 -67
  75. package/dist/path.d.ts.map +0 -1
  76. package/dist/path.js +0 -107
  77. package/dist/path.js.map +0 -1
  78. package/dist/project.d.ts +0 -35
  79. package/dist/project.d.ts.map +0 -1
  80. package/dist/project.js +0 -154
  81. package/dist/project.js.map +0 -1
@@ -11,59 +11,77 @@
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
  * │
18
18
  * stranger's session updated · stranger gets DM
19
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.
20
+ * **Storage layout.** This plugin stores its per-user record under
21
+ * the `access` field of the shared session record (see
22
+ * `bot/CLAUDE.md` § "Shared session, one record per user"). All
23
+ * per-user state across our plugins coexists in the same record:
25
24
  *
26
- * storage:
27
- * access:<userId> → AccessRecord (the user's session)
28
- * ac:index → { pending, approved, denied }
25
+ * storage[String(userId)] = {
26
+ * access: { status, approvedAt, … }, // ← this plugin
27
+ * language: 'es', // bot/language
28
+ * history: { items: [...] }, // ← bot/message-history
29
+ * }
29
30
  *
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.
31
+ * Plus one tiny admin-side index so `/access` can list pending /
32
+ * approved / denied without scanning every user:
36
33
  *
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.
34
+ * storage['ac:index'] = { pending: [...ids], approved: [...], denied: [...] }
40
35
  *
41
- * Peer deps: `gramio`, `@gramio/storage`, `@gramio/session`.
36
+ * **Cross-user mutations.** When the admin taps `[✅ Approve]` on
37
+ * Pepe's notification, `ctx` is the admin's, so `ctx.session` is the
38
+ * admin's record — useless for mutating Pepe. We reach for Pepe's
39
+ * record directly via `storage.get(String(pepeId))`, preserve other
40
+ * plugins' fields in it (read-modify-write), and put it back.
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
+ *
49
+ * **Composes with**:
50
+ * - `adminContext` (kit.ts) — required, gives us `ctx.adminId` /
51
+ * `ctx.isAdmin`. Declared as a runtime dependency.
52
+ * - `@gramio/session` — the user creates ONE session at bot level
53
+ * and passes it to this plugin (and the other session-using
54
+ * ones). gramio's runtime dedup ensures the session derive runs
55
+ * exactly once per update.
56
+ *
57
+ * Peer deps: `gramio`, `@gramio/session`, `@gramio/storage`.
42
58
  *
43
59
  * @example
44
60
  * import { Bot } from 'gramio'
61
+ * import { session } from '@gramio/session'
45
62
  * import { redisStorage } from '@gramio/storage-redis'
46
63
  * import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
47
64
  * import { accessControl } from '@adriangalilea/utils/bot/access-control'
48
65
  *
49
66
  * const storage = redisStorage()
67
+ * const userSession = session({ storage, key: 'session', initial: () => ({}) })
50
68
  *
51
69
  * const bot = new Bot(process.env.BOT_TOKEN!)
52
70
  * .extend(adminContext({ adminId: 190202471 }))
53
- * .extend(accessControl({ storage, defaults: [1158734055] }))
54
- * .command('start', (ctx) => ctx.send(`hola, source=${ctx.access.source}`))
71
+ * .extend(userSession)
72
+ * .extend(accessControl({ session: userSession, storage, defaults: [1158734055] }))
73
+ * .command('start', (ctx) => ctx.send(`source=${ctx.access.source ?? 'denied'}`))
55
74
  *
56
75
  * await gracefulStart(bot)
57
76
  */
58
77
  import { CallbackData, InlineKeyboard, Plugin, } from 'gramio';
59
- import { session } from '@gramio/session';
60
- import { inMemoryStorage } from '@gramio/storage';
61
- const SESSION_KEY_PREFIX = 'access:';
78
+ import { say } from '../say/index.js';
62
79
  const INDEX_KEY = 'ac:index';
63
80
  const FIRST_MSG_LIMIT = 200;
64
- const DEFAULT_DENY_MSG = 'Este bot es privado. Tu solicitud se ha enviado al admin.';
65
81
  const DEFAULT_THROTTLE_MS = 6 * 60 * 60 * 1000;
66
- const userSessionKey = (userId) => `${SESSION_KEY_PREFIX}${userId}`;
82
+ const FALLBACK_LANG = 'en';
83
+ /** gramio's `@gramio/session` default `getSessionKey` is `String(senderId)`. */
84
+ const sessionKey = (userId) => String(userId);
67
85
  // ─── callback schemas ──────────────────────────────────────────────
68
86
  //
69
87
  // Short `nameId`s keep callback_data under Telegram's 64-byte cap.
@@ -95,34 +113,27 @@ const fmtAge = (ms) => {
95
113
  return `${h}h`;
96
114
  return `${Math.floor(h / 24)}d`;
97
115
  };
98
- const requestNotificationText = (uid, r, repeat) => {
116
+ const requestNotificationText = (uid, r, repeat, lang) => {
99
117
  const parts = [
100
- 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),
101
121
  '',
102
122
  `👤 ${formatUser(r.user, uid)}`,
103
123
  `🆔 ${uid}`,
104
- `⏰ hace ${fmtAge(Date.now() - (r.requestedAt ?? Date.now()))}`,
124
+ `⏰ ${say({ en: 'ago', es: 'hace' }, lang)} ${fmtAge(Date.now() - (r.requestedAt ?? Date.now()))}`,
105
125
  ];
106
- if (repeat)
107
- parts.push(`🔁 intentos: ${(r.rejectedAttempts ?? 0) + 1}`);
126
+ if (repeat) {
127
+ parts.push(`🔁 ${say({ en: 'attempts', es: 'intentos' }, lang)}: ${(r.rejectedAttempts ?? 0) + 1}`);
128
+ }
108
129
  if (r.firstMessage)
109
130
  parts.push('', `💬 "${r.firstMessage}"`);
110
131
  return parts.join('\n');
111
132
  };
112
- const requestKeyboard = (uid) => new InlineKeyboard()
113
- .text('✅ Aprobar', acApprove.pack({ uid }))
114
- .text('❌ Denegar', acDeny.pack({ uid }));
115
- let warnedMemory = false;
116
- const warnedMemoryStorage = () => {
117
- if (!warnedMemory) {
118
- warnedMemory = true;
119
- console.warn('[access-control] using inMemoryStorage — approvals will not survive restarts. ' +
120
- 'Pass `storage: redisStorage()` (or sqliteStorage / cloudflareStorage) for persistence.');
121
- }
122
- return inMemoryStorage();
123
- };
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 }));
124
136
  // ─── index helpers ─────────────────────────────────────────────────
125
- const emptyIndex = () => ({ pending: [], approved: [], denied: [] });
126
137
  const loadIndex = async (storage) => {
127
138
  const raw = (await storage.get(INDEX_KEY));
128
139
  return {
@@ -157,27 +168,40 @@ const indexMove = async (storage, uid, from, to) => {
157
168
  idx[to].push(uid);
158
169
  await saveIndex(storage, idx);
159
170
  };
160
- // ─── per-user record helpers (cross-user storage access) ───────────
161
- const loadRecord = async (storage, userId) => (await storage.get(userSessionKey(userId)));
162
- const saveRecord = (storage, userId, rec) => storage.set(userSessionKey(userId), rec);
171
+ const loadFullRecord = async (storage, userId) => (await storage.get(sessionKey(userId))) ?? {};
172
+ const saveAccess = async (storage, userId, rec) => {
173
+ const full = await loadFullRecord(storage, userId);
174
+ full.access = rec;
175
+ await storage.set(sessionKey(userId), full);
176
+ };
177
+ const loadAccess = async (storage, userId) => {
178
+ const full = await loadFullRecord(storage, userId);
179
+ return full.access;
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;
163
188
  // ─── plugin ────────────────────────────────────────────────────────
164
- export const accessControl = (opts = {}) => {
165
- const storage = opts.storage ?? warnedMemoryStorage();
189
+ export const accessControl = (opts) => {
190
+ const { session: sessionPlugin, storage } = opts;
166
191
  const defaults = new Set(opts.defaults ?? []);
167
- const denyMessage = opts.denyMessage === false ? null : (opts.denyMessage ?? DEFAULT_DENY_MSG);
192
+ const silentDeny = opts.silentDeny === true;
168
193
  const throttleMs = opts.notifyThrottleMs ?? DEFAULT_THROTTLE_MS;
169
- return (new Plugin('@adriangalilea/utils/bot/access-control', {
194
+ return (
195
+ // Generic declares dependency on adminContext's derives so
196
+ // ctx.adminId / ctx.isAdmin are typed inside our handlers.
197
+ // Session-side types flow through `.extend(opts.session)` below
198
+ // and TypeScript merges them with our generic via the chain.
199
+ new Plugin('@adriangalilea/utils/bot/access-control', {
170
200
  dependencies: ['@adriangalilea/utils/bot/admin'],
171
201
  })
172
- // Per-user record lives in this session (storage key `access:<senderId>`).
173
- // The internal name `_accessSession` is plumbing consumers read
174
- // `ctx.access` (computed below) instead.
175
- .extend(session({
176
- storage,
177
- key: '_accessSession',
178
- getSessionKey: (ctx) => userSessionKey(ctx.senderId ?? 0),
179
- initial: () => ({ status: 'unknown' }),
180
- }))
202
+ // Declare the shared session as a dependency. gramio's runtime
203
+ // dedups against the bot's top-level extension; types flow.
204
+ .extend(sessionPlugin)
181
205
  // Compute the gate decision so handlers can read `ctx.access` ergonomically.
182
206
  .derive((ctx) => {
183
207
  // Only message + callback_query carry a senderId we can gate on.
@@ -191,7 +215,9 @@ export const accessControl = (opts = {}) => {
191
215
  if (defaults.has(senderId)) {
192
216
  return { access: { allowed: true, source: 'default' } };
193
217
  }
194
- const rec = ctx._accessSession;
218
+ // ctx.session.access may be undefined for first-ever interaction
219
+ // (session.initial() returns {} so .access isn't set yet).
220
+ const rec = ctx.session.access ?? { status: 'unknown' };
195
221
  if (rec.status === 'approved') {
196
222
  return { access: { allowed: true, source: 'store', record: rec } };
197
223
  }
@@ -205,37 +231,49 @@ export const accessControl = (opts = {}) => {
205
231
  if (ctx.access.allowed) {
206
232
  // Activity bump (only for store-approved users — admins/defaults
207
233
  // don't have a session record we want to clutter).
208
- if (ctx.access.source === 'store' && ctx.is('message')) {
209
- ctx._accessSession.lastActivityAt = Date.now();
210
- ctx._accessSession.messageCount = (ctx._accessSession.messageCount ?? 0) + 1;
234
+ if (ctx.access.source === 'store' &&
235
+ ctx.is('message') &&
236
+ ctx.session.access) {
237
+ ctx.session.access = {
238
+ ...ctx.session.access,
239
+ lastActivityAt: Date.now(),
240
+ messageCount: (ctx.session.access.messageCount ?? 0) + 1,
241
+ };
211
242
  }
212
243
  return next();
213
244
  }
214
245
  // Acknowledge unauthorized callback queries so the spinner clears.
215
246
  if (ctx.is('callback_query')) {
216
- 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
+ });
217
251
  return;
218
252
  }
219
253
  // Only message-shaped events have .text/.chat for our notification.
220
254
  if (!ctx.is('message'))
221
255
  return;
222
256
  const userId = ctx.from.id;
223
- const rec = ctx._accessSession;
257
+ const existing = ctx.session.access;
224
258
  const now = Date.now();
225
- const isFirstRequest = rec.status === 'unknown';
259
+ const isFirstRequest = !existing || existing.status === 'unknown';
260
+ const rec = isFirstRequest
261
+ ? {
262
+ status: 'pending',
263
+ user: {
264
+ id: userId,
265
+ firstName: ctx.from.firstName,
266
+ lastName: ctx.from.lastName,
267
+ username: ctx.from.username,
268
+ },
269
+ chatId: ctx.chat.id,
270
+ requestedAt: now,
271
+ firstMessage: ctx.text?.slice(0, FIRST_MSG_LIMIT),
272
+ messageCount: 0,
273
+ rejectedAttempts: 0,
274
+ }
275
+ : { ...existing };
226
276
  if (isFirstRequest) {
227
- rec.status = 'pending';
228
- rec.user = {
229
- id: userId,
230
- firstName: ctx.from.firstName,
231
- lastName: ctx.from.lastName,
232
- username: ctx.from.username,
233
- };
234
- rec.chatId = ctx.chat.id;
235
- rec.requestedAt = now;
236
- rec.firstMessage = ctx.text?.slice(0, FIRST_MSG_LIMIT);
237
- rec.messageCount = 0;
238
- rec.rejectedAttempts = 0;
239
277
  await indexAdd(storage, 'pending', userId);
240
278
  }
241
279
  else {
@@ -245,10 +283,11 @@ export const accessControl = (opts = {}) => {
245
283
  if (shouldNotify) {
246
284
  rec.lastNotifiedAt = now;
247
285
  try {
286
+ const adminLang = await langOfUser(storage, ctx.adminId);
248
287
  await ctx.bot.api.sendMessage({
249
288
  chat_id: ctx.adminId,
250
- text: requestNotificationText(userId, rec, !isFirstRequest),
251
- reply_markup: requestKeyboard(userId),
289
+ text: requestNotificationText(userId, rec, !isFirstRequest, adminLang),
290
+ reply_markup: requestKeyboard(userId, adminLang),
252
291
  });
253
292
  }
254
293
  catch (e) {
@@ -256,9 +295,14 @@ export const accessControl = (opts = {}) => {
256
295
  }
257
296
  opts.onAccessRequest?.({ user: rec.user, firstMessage: rec.firstMessage });
258
297
  }
259
- if (denyMessage && isFirstRequest) {
298
+ // Persist the updated record to the user's session.
299
+ ctx.session.access = rec;
300
+ if (!silentDeny && isFirstRequest) {
260
301
  try {
261
- 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)));
262
306
  }
263
307
  catch {
264
308
  // user blocked the bot — irrelevant
@@ -268,12 +312,18 @@ export const accessControl = (opts = {}) => {
268
312
  })
269
313
  // ─── admin actions ────────────────────────────────────────
270
314
  .callbackQuery(acApprove, async (ctx) => {
315
+ const aLang = ctxLang(ctx);
271
316
  if (!ctx.isAdmin)
272
- 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
+ });
273
321
  const uid = ctx.queryData.uid;
274
- const rec = await loadRecord(storage, uid);
322
+ const rec = await loadAccess(storage, uid);
275
323
  if (!rec)
276
- return ctx.answer({ text: 'No encontrado.' });
324
+ return ctx.answer({
325
+ text: say({ en: 'Not found.', es: 'No encontrado.' }, aLang),
326
+ });
277
327
  const wasDenied = rec.status === 'denied';
278
328
  const wasPending = rec.status === 'pending';
279
329
  rec.status = 'approved';
@@ -281,28 +331,37 @@ export const accessControl = (opts = {}) => {
281
331
  rec.approvedBy = ctx.adminId;
282
332
  rec.deniedAt = undefined;
283
333
  rec.deniedBy = undefined;
284
- await saveRecord(storage, uid, rec);
334
+ await saveAccess(storage, uid, rec);
285
335
  await indexMove(storage, uid, wasPending ? 'pending' : wasDenied ? 'denied' : 'any', 'approved');
286
336
  if (rec.chatId !== undefined) {
287
337
  try {
338
+ const sLang = await langOfUser(storage, uid);
288
339
  await ctx.bot.api.sendMessage({
289
340
  chat_id: rec.chatId,
290
- text: wasDenied
291
- ? '✅ El admin reconsideró: ya tienes acceso.'
292
- : '✅ 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),
293
350
  });
294
351
  }
295
352
  catch {
296
353
  // user blocked / chat gone
297
354
  }
298
355
  }
299
- await ctx.answer({ text: '✅ Aprobado' });
356
+ await ctx.answer({
357
+ text: say({ en: '✅ Approved', es: '✅ Aprobado' }, aLang),
358
+ });
300
359
  if (ctx.queryData.v) {
301
- await renderView(ctx, storage, defaults, ctx.queryData.v);
360
+ await renderView(ctx, storage, defaults, ctx.queryData.v, aLang);
302
361
  }
303
362
  else {
304
363
  try {
305
- await ctx.editText(`✅ Aprobado · ${formatUser(rec.user, uid)}`);
364
+ await ctx.editText(`${say({ en: '✅ Approved', es: '✅ Aprobado' }, aLang)} · ${formatUser(rec.user, uid)}`);
306
365
  }
307
366
  catch {
308
367
  // not always editable
@@ -311,36 +370,45 @@ export const accessControl = (opts = {}) => {
311
370
  opts.onApprove?.({ userId: uid, approvedBy: ctx.adminId });
312
371
  })
313
372
  .callbackQuery(acDeny, async (ctx) => {
373
+ const aLang = ctxLang(ctx);
314
374
  if (!ctx.isAdmin)
315
- 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
+ });
316
379
  const uid = ctx.queryData.uid;
317
- const rec = await loadRecord(storage, uid);
380
+ const rec = await loadAccess(storage, uid);
318
381
  if (!rec)
319
- return ctx.answer({ text: 'No encontrado.' });
382
+ return ctx.answer({
383
+ text: say({ en: 'Not found.', es: 'No encontrado.' }, aLang),
384
+ });
320
385
  const wasPending = rec.status === 'pending';
321
386
  rec.status = 'denied';
322
387
  rec.deniedAt = Date.now();
323
388
  rec.deniedBy = ctx.adminId;
324
- await saveRecord(storage, uid, rec);
389
+ await saveAccess(storage, uid, rec);
325
390
  await indexMove(storage, uid, wasPending ? 'pending' : 'any', 'denied');
326
391
  if (rec.chatId !== undefined) {
327
392
  try {
393
+ const sLang = await langOfUser(storage, uid);
328
394
  await ctx.bot.api.sendMessage({
329
395
  chat_id: rec.chatId,
330
- text: '❌ Acceso denegado.',
396
+ text: say({ en: '❌ Access denied.', es: '❌ Acceso denegado.' }, sLang),
331
397
  });
332
398
  }
333
399
  catch {
334
400
  // ignore
335
401
  }
336
402
  }
337
- await ctx.answer({ text: '❌ Denegado' });
403
+ await ctx.answer({
404
+ text: say({ en: '❌ Denied', es: '❌ Denegado' }, aLang),
405
+ });
338
406
  if (ctx.queryData.v) {
339
- await renderView(ctx, storage, defaults, ctx.queryData.v);
407
+ await renderView(ctx, storage, defaults, ctx.queryData.v, aLang);
340
408
  }
341
409
  else {
342
410
  try {
343
- await ctx.editText(`❌ Denegado · ${formatUser(rec.user, uid)}`);
411
+ await ctx.editText(`${say({ en: '❌ Denied', es: '❌ Denegado' }, aLang)} · ${formatUser(rec.user, uid)}`);
344
412
  }
345
413
  catch {
346
414
  // ignore
@@ -349,40 +417,60 @@ export const accessControl = (opts = {}) => {
349
417
  opts.onDeny?.({ userId: uid, deniedBy: ctx.adminId });
350
418
  })
351
419
  .callbackQuery(acRevoke, async (ctx) => {
420
+ const aLang = ctxLang(ctx);
352
421
  if (!ctx.isAdmin)
353
- 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
+ });
354
426
  const uid = ctx.queryData.uid;
355
- const rec = await loadRecord(storage, uid);
427
+ const rec = await loadAccess(storage, uid);
356
428
  if (!rec)
357
- return ctx.answer({ text: 'No encontrado.' });
429
+ return ctx.answer({
430
+ text: say({ en: 'Not found.', es: 'No encontrado.' }, aLang),
431
+ });
358
432
  rec.status = 'denied';
359
433
  rec.deniedAt = Date.now();
360
434
  rec.deniedBy = ctx.adminId;
361
- await saveRecord(storage, uid, rec);
435
+ await saveAccess(storage, uid, rec);
362
436
  await indexMove(storage, uid, 'approved', 'denied');
363
437
  if (rec.chatId !== undefined) {
364
438
  try {
439
+ const sLang = await langOfUser(storage, uid);
365
440
  await ctx.bot.api.sendMessage({
366
441
  chat_id: rec.chatId,
367
- 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),
368
446
  });
369
447
  }
370
448
  catch {
371
449
  // ignore
372
450
  }
373
451
  }
374
- await ctx.answer({ text: '↩️ Revocado' });
375
- 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);
376
456
  })
377
457
  .callbackQuery(acView, async (ctx) => {
458
+ const aLang = ctxLang(ctx);
378
459
  if (!ctx.isAdmin)
379
- 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
+ });
380
464
  await ctx.answer({});
381
- await renderView(ctx, storage, defaults, ctx.queryData.v);
465
+ await renderView(ctx, storage, defaults, ctx.queryData.v, aLang);
382
466
  })
383
467
  .callbackQuery(acClose, async (ctx) => {
468
+ const aLang = ctxLang(ctx);
384
469
  if (!ctx.isAdmin)
385
- 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
+ });
386
474
  await ctx.answer({});
387
475
  try {
388
476
  await ctx.message?.delete();
@@ -391,21 +479,32 @@ export const accessControl = (opts = {}) => {
391
479
  // ignore
392
480
  }
393
481
  })
394
- .command('access', async (ctx) => {
482
+ .command('access', {
483
+ // Admin-only; hidden from Telegram's `/` menu so it doesn't
484
+ // tempt other users to type it. Admin still invokes via /access.
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.
489
+ description: 'Admin: access control menu',
490
+ hide: true,
491
+ }, async (ctx) => {
395
492
  if (!ctx.isAdmin)
396
493
  return;
397
- const v = await mainView(storage, defaults);
494
+ const aLang = ctxLang(ctx);
495
+ const v = mainView(await loadIndex(storage), defaults, aLang);
398
496
  await ctx.send(v.text, { reply_markup: v.keyboard });
399
497
  }));
400
498
  };
401
- const renderView = async (ctx, storage, defaults, view) => {
499
+ const renderView = async (ctx, storage, defaults, view, lang) => {
500
+ const idx = await loadIndex(storage);
402
501
  const v = view === 'approved'
403
- ? await listView(storage, 'approved', defaults)
502
+ ? await listView(storage, idx, 'approved', defaults, lang)
404
503
  : view === 'pending'
405
- ? await listView(storage, 'pending', defaults)
504
+ ? await listView(storage, idx, 'pending', defaults, lang)
406
505
  : view === 'denied'
407
- ? await listView(storage, 'denied', defaults)
408
- : await mainView(storage, defaults);
506
+ ? await listView(storage, idx, 'denied', defaults, lang)
507
+ : mainView(idx, defaults, lang);
409
508
  try {
410
509
  await ctx.editText(v.text, { reply_markup: v.keyboard });
411
510
  }
@@ -413,37 +512,44 @@ const renderView = async (ctx, storage, defaults, view) => {
413
512
  // editText only works while message is recent enough; ignore
414
513
  }
415
514
  };
416
- const mainView = async (storage, defaults) => {
417
- 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);
418
519
  const text = [
419
- '🔐 Access Control',
520
+ say({ en: '🔐 Access Control', es: '🔐 Access Control' }, lang),
420
521
  '',
421
- `✅ Aprobados: ${idx.approved.length}`,
422
- `⏳ Pendientes: ${idx.pending.length}`,
423
- `❌ Denegados: ${idx.denied.length}`,
424
- `👑 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)`,
425
526
  ].join('\n');
426
527
  const keyboard = new InlineKeyboard()
427
- .text(`✅ Aprobados (${idx.approved.length})`, acView.pack({ v: 'approved' }))
428
- .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' }))
429
530
  .row()
430
- .text(`❌ Denegados (${idx.denied.length})`, acView.pack({ v: 'denied' }))
431
- .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' }))
432
533
  .row()
433
- .text('✖️ Cerrar', acClose.pack({}));
534
+ .text(say({ en: '✖️ Close', es: '✖️ Cerrar' }, lang), acClose.pack({}));
434
535
  return { text, keyboard };
435
536
  };
436
- const listView = async (storage, filter, defaults) => {
437
- const idx = await loadIndex(storage);
537
+ const listView = async (storage, idx, filter, defaults, lang) => {
438
538
  const ids = idx[filter];
439
539
  // Cap at 20 to keep callback_data + rendering sane.
440
540
  const shownIds = ids.slice(0, 20);
441
- const records = await Promise.all(shownIds.map(async (id) => ({ id, rec: await loadRecord(storage, id) })));
541
+ const records = await Promise.all(shownIds.map(async (id) => ({ id, rec: await loadAccess(storage, id) })));
442
542
  const headerEmoji = filter === 'approved' ? '✅' : filter === 'pending' ? '⏳' : '❌';
443
- 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);
444
549
  if (ids.length === 0) {
445
- const text = `${headerEmoji} ${headerLabel} (0)\n\n(vacío)`;
446
- 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' }));
447
553
  return { text, keyboard };
448
554
  }
449
555
  const lines = [`${headerEmoji} ${headerLabel} (${ids.length})`, ''];
@@ -452,11 +558,11 @@ const listView = async (storage, filter, defaults) => {
452
558
  const { id, rec } = records[i];
453
559
  if (!rec) {
454
560
  // index referenced a missing record — show as placeholder
455
- lines.push(`${i + 1}. id ${id} (datos perdidos)`);
561
+ lines.push(`${i + 1}. id ${id} ${say({ en: '(data lost)', es: '(datos perdidos)' }, lang)}`);
456
562
  continue;
457
563
  }
458
564
  const ageRef = rec.approvedAt ?? rec.deniedAt ?? rec.requestedAt ?? Date.now();
459
- 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)}` +
460
566
  (rec.messageCount ? ` · ${rec.messageCount} msgs` : ''));
461
567
  if (filter === 'pending') {
462
568
  keyboard
@@ -465,21 +571,23 @@ const listView = async (storage, filter, defaults) => {
465
571
  .row();
466
572
  }
467
573
  else if (filter === 'approved') {
468
- 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();
469
577
  }
470
578
  else if (filter === 'denied') {
471
579
  keyboard
472
- .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' }))
473
581
  .row();
474
582
  }
475
583
  }
476
584
  if (ids.length > shownIds.length) {
477
- 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)})`);
478
586
  }
479
587
  if (filter === 'approved' && defaults.size > 0) {
480
588
  lines.push('', `+ ${defaults.size} hardcoded defaults`);
481
589
  }
482
- keyboard.text('⬅️ Volver', acView.pack({ v: 'main' }));
590
+ keyboard.text(back, acView.pack({ v: 'main' }));
483
591
  return { text: lines.join('\n'), keyboard };
484
592
  };
485
593
  // ─── test helper ───────────────────────────────────────────────────
@@ -488,7 +596,7 @@ const listView = async (storage, filter, defaults) => {
488
596
  * easily spin up a second Telegram account. Writes a `pending` record
489
597
  * to storage at the same key the plugin's session would, updates the
490
598
  * index, then DMs the admin with the real
491
- * `[✅ Aprobar][❌ Denegar]` keyboard. Tapping those buttons exercises
599
+ * `[✅ Approve][❌ Deny]` keyboard. Tapping those buttons exercises
492
600
  * the real callback handlers end-to-end.
493
601
  *
494
602
  * Pass the SAME `storage` instance you passed to `accessControl({ storage })`.
@@ -505,12 +613,13 @@ export const simulateAccessRequest = async (bot, storage, adminId, fakeUser, mes
505
613
  rejectedAttempts: 0,
506
614
  lastNotifiedAt: now,
507
615
  };
508
- await saveRecord(storage, fakeUser.id, rec);
616
+ await saveAccess(storage, fakeUser.id, rec);
509
617
  await indexAdd(storage, 'pending', fakeUser.id);
618
+ const adminLang = await langOfUser(storage, adminId);
510
619
  await bot.api.sendMessage({
511
620
  chat_id: adminId,
512
- text: requestNotificationText(fakeUser.id, rec, false),
513
- reply_markup: requestKeyboard(fakeUser.id),
621
+ text: requestNotificationText(fakeUser.id, rec, false, adminLang),
622
+ reply_markup: requestKeyboard(fakeUser.id, adminLang),
514
623
  });
515
624
  };
516
625
  //# sourceMappingURL=access-control.js.map