@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.
- package/README.md +29 -5
- package/dist/bot/access-control.d.ts +106 -62
- package/dist/bot/access-control.d.ts.map +1 -1
- package/dist/bot/access-control.js +255 -146
- package/dist/bot/access-control.js.map +1 -1
- package/dist/bot/index.d.ts +3 -0
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +3 -0
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/kit.d.ts.map +1 -1
- package/dist/bot/kit.js +6 -0
- package/dist/bot/kit.js.map +1 -1
- package/dist/bot/language.d.ts +305 -0
- package/dist/bot/language.d.ts.map +1 -0
- package/dist/bot/language.js +250 -0
- package/dist/bot/language.js.map +1 -0
- package/dist/bot/menu.d.ts +189 -0
- package/dist/bot/menu.d.ts.map +1 -0
- package/dist/bot/menu.js +331 -0
- package/dist/bot/menu.js.map +1 -0
- package/dist/bot/message-history.d.ts +259 -0
- package/dist/bot/message-history.d.ts.map +1 -0
- package/dist/bot/message-history.js +111 -0
- package/dist/bot/message-history.js.map +1 -0
- 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 +17 -1
- package/dist/currency/crypto-symbols-data.d.ts +0 -10
- package/dist/currency/crypto-symbols-data.d.ts.map +0 -1
- package/dist/currency/crypto-symbols-data.js +0 -13765
- package/dist/currency/crypto-symbols-data.js.map +0 -1
- package/dist/currency/crypto-symbols.d.ts +0 -20
- package/dist/currency/crypto-symbols.d.ts.map +0 -1
- package/dist/currency/crypto-symbols.js +0 -23
- package/dist/currency/crypto-symbols.js.map +0 -1
- package/dist/currency/download-crypto-list.d.ts +0 -10
- package/dist/currency/download-crypto-list.d.ts.map +0 -1
- package/dist/currency/download-crypto-list.js +0 -69
- package/dist/currency/download-crypto-list.js.map +0 -1
- package/dist/currency/index.d.ts +0 -84
- package/dist/currency/index.d.ts.map +0 -1
- package/dist/currency/index.js +0 -230
- package/dist/currency/index.js.map +0 -1
- package/dist/dir.d.ts +0 -40
- package/dist/dir.d.ts.map +0 -1
- package/dist/dir.js +0 -108
- package/dist/dir.js.map +0 -1
- package/dist/file.d.ts +0 -53
- package/dist/file.d.ts.map +0 -1
- package/dist/file.js +0 -211
- package/dist/file.js.map +0 -1
- package/dist/format.d.ts +0 -40
- package/dist/format.d.ts.map +0 -1
- package/dist/format.js +0 -83
- package/dist/format.js.map +0 -1
- package/dist/kev.d.ts +0 -149
- package/dist/kev.d.ts.map +0 -1
- package/dist/kev.js +0 -761
- package/dist/kev.js.map +0 -1
- package/dist/log.d.ts +0 -91
- package/dist/log.d.ts.map +0 -1
- package/dist/log.js +0 -300
- package/dist/log.js.map +0 -1
- package/dist/logger.d.ts +0 -91
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -269
- package/dist/logger.js.map +0 -1
- package/dist/path.d.ts +0 -67
- package/dist/path.d.ts.map +0 -1
- package/dist/path.js +0 -107
- package/dist/path.js.map +0 -1
- package/dist/project.d.ts +0 -35
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js +0 -154
- 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 [✅
|
|
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.**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
28
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
54
|
-
* .
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
161
|
-
const
|
|
162
|
-
const
|
|
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
|
|
189
|
+
export const accessControl = (opts) => {
|
|
190
|
+
const { session: sessionPlugin, storage } = opts;
|
|
166
191
|
const defaults = new Set(opts.defaults ?? []);
|
|
167
|
-
const
|
|
192
|
+
const silentDeny = opts.silentDeny === true;
|
|
168
193
|
const throttleMs = opts.notifyThrottleMs ?? DEFAULT_THROTTLE_MS;
|
|
169
|
-
return (
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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
|
-
|
|
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' &&
|
|
209
|
-
ctx.
|
|
210
|
-
ctx.
|
|
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({
|
|
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
|
|
257
|
+
const existing = ctx.session.access;
|
|
224
258
|
const now = Date.now();
|
|
225
|
-
const isFirstRequest =
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
|
322
|
+
const rec = await loadAccess(storage, uid);
|
|
275
323
|
if (!rec)
|
|
276
|
-
return ctx.answer({
|
|
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
|
|
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
|
-
?
|
|
292
|
-
|
|
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({
|
|
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(
|
|
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({
|
|
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
|
|
380
|
+
const rec = await loadAccess(storage, uid);
|
|
318
381
|
if (!rec)
|
|
319
|
-
return ctx.answer({
|
|
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
|
|
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({
|
|
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(
|
|
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({
|
|
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
|
|
427
|
+
const rec = await loadAccess(storage, uid);
|
|
356
428
|
if (!rec)
|
|
357
|
-
return ctx.answer({
|
|
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
|
|
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:
|
|
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({
|
|
375
|
-
|
|
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({
|
|
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({
|
|
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',
|
|
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
|
|
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
|
-
:
|
|
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 =
|
|
417
|
-
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);
|
|
418
519
|
const text = [
|
|
419
|
-
'🔐 Access Control',
|
|
520
|
+
say({ en: '🔐 Access Control', es: '🔐 Access Control' }, lang),
|
|
420
521
|
'',
|
|
421
|
-
`✅
|
|
422
|
-
`⏳
|
|
423
|
-
`❌
|
|
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(`✅
|
|
428
|
-
.text(`⏳
|
|
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(`❌
|
|
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
|
|
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'
|
|
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
|
|
446
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
* `[✅
|
|
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
|
|
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
|