@adriangalilea/utils 0.8.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.
- package/README.md +28 -0
- package/dist/bot/access-control.d.ts +17 -6
- package/dist/bot/access-control.d.ts.map +1 -1
- package/dist/bot/access-control.js +155 -76
- package/dist/bot/access-control.js.map +1 -1
- package/dist/bot/language.d.ts +28 -2
- package/dist/bot/language.d.ts.map +1 -1
- package/dist/bot/language.js +46 -18
- package/dist/bot/language.js.map +1 -1
- package/dist/bot/menu.d.ts +15 -1
- package/dist/bot/menu.d.ts.map +1 -1
- package/dist/bot/menu.js +71 -24
- package/dist/bot/menu.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/say/index.d.ts +62 -0
- package/dist/say/index.d.ts.map +1 -0
- package/dist/say/index.js +59 -0
- package/dist/say/index.js.map +1 -0
- package/package.json +5 -1
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.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* │ no → drop + notify admin (rate-limited) │
|
|
12
12
|
* └─────────────────────────────────────────────────┘
|
|
13
13
|
* │
|
|
14
|
-
* admin gets DM with [✅
|
|
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 `[✅
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
145
|
-
|
|
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
|
-
* `[✅
|
|
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":"AAAA
|
|
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 [✅
|
|
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 `[✅
|
|
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
|
-
|
|
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
|
|
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
|
|
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({
|
|
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 (
|
|
300
|
+
if (!silentDeny && isFirstRequest) {
|
|
284
301
|
try {
|
|
285
|
-
await ctx.send(
|
|
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({
|
|
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({
|
|
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
|
-
?
|
|
316
|
-
|
|
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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:
|
|
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({
|
|
399
|
-
|
|
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({
|
|
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({
|
|
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
|
|
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
|
-
:
|
|
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 =
|
|
447
|
-
const
|
|
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
|
-
`✅
|
|
452
|
-
`⏳
|
|
453
|
-
`❌
|
|
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(`✅
|
|
458
|
-
.text(`⏳
|
|
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(`❌
|
|
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'
|
|
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
|
|
476
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
* `[✅
|
|
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
|