@link-assistant/hive-mind 1.64.2 → 1.64.4
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/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +12 -1
- package/src/codex.lib.mjs +12 -1
- package/src/github.lib.mjs +2 -2
- package/src/interactive-mode.lib.mjs +104 -8
- package/src/isolation-runner.lib.mjs +49 -0
- package/src/lib.mjs +3 -3
- package/src/post-finish-sanitization-sweep.lib.mjs +201 -0
- package/src/solve.config.lib.mjs +15 -0
- package/src/solve.results.lib.mjs +52 -0
- package/src/telegram-bot.mjs +41 -0
- package/src/telegram-leak-notifier.lib.mjs +79 -0
- package/src/telegram-start-stop-command.lib.mjs +139 -3
- package/src/telegram-tokens-command.lib.mjs +151 -0
- package/src/token-sanitization.lib.mjs +355 -18
- package/src/tool-comments.lib.mjs +6 -2
package/src/telegram-bot.mjs
CHANGED
|
@@ -571,6 +571,7 @@ bot.command('help', async ctx => {
|
|
|
571
571
|
message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
|
|
572
572
|
message += '*/help* - Show this help message\n';
|
|
573
573
|
message += '*/stop* / */start* - Stop or resume accepting new tasks (owner only)\n';
|
|
574
|
+
message += '*/stop* `<uuid>` - Send CTRL+C to an isolated solve/hive session (owner only). Also works as a reply to a message containing the UUID.\n';
|
|
574
575
|
message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n';
|
|
575
576
|
message += '*/terminal\\_watch* - Live-update an isolation session log (owner only). Usage: `/terminal_watch <uuid>` or reply with `/terminal_watch`\n\n';
|
|
576
577
|
message += '🔔 *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\n';
|
|
@@ -1109,6 +1110,46 @@ registerStartStopCommands(bot, sharedCommandOpts);
|
|
|
1109
1110
|
await registerLogCommand(bot, sharedCommandOpts);
|
|
1110
1111
|
await registerTerminalWatchCommand(bot, sharedCommandOpts);
|
|
1111
1112
|
|
|
1113
|
+
// Issue #1745: hidden /tokens command for chat owners (private DMs only,
|
|
1114
|
+
// undocumented, masked output). Lets operators audit which local tokens are
|
|
1115
|
+
// live in the bot's environment so they can search for accidental leaks.
|
|
1116
|
+
const { registerTokensCommand } = await import('./telegram-tokens-command.lib.mjs');
|
|
1117
|
+
registerTokensCommand(bot, { ...sharedCommandOpts, allowedChats });
|
|
1118
|
+
|
|
1119
|
+
// Issue #1745: register the leak-warning DM hook. The interactive bridge
|
|
1120
|
+
// fires reportInteractiveLeak() whenever it has to mask a known-local token
|
|
1121
|
+
// in an outbound PR comment. We DM every operator (chat creator) of every
|
|
1122
|
+
// allowlisted chat so at least one of them sees it quickly.
|
|
1123
|
+
const { registerLeakNotifier } = await import('./telegram-leak-notifier.lib.mjs');
|
|
1124
|
+
registerLeakNotifier(async ({ owner, repo, prNumber, tokenHits = [] }) => {
|
|
1125
|
+
if (!allowedChats || allowedChats.length === 0) return;
|
|
1126
|
+
const where = prNumber ? `${owner}/${repo}#${prNumber}` : `${owner}/${repo}`;
|
|
1127
|
+
const sources = tokenHits.length ? tokenHits.map(h => `${h.name} (${h.source})`).join(', ') : 'unknown';
|
|
1128
|
+
const text = `🚨 *Token-leak event*\n\nA known local token was about to be published in *${where}* and was masked by the sanitizer just in time.\n\nTokens detected: ${sources}\n\nRotate the affected secret(s) now and check public surfaces (GitHub comments, gists, Slack) for any prior copies.`;
|
|
1129
|
+
for (const chatId of allowedChats) {
|
|
1130
|
+
try {
|
|
1131
|
+
const member = await bot.telegram.getChatMember(chatId, chatId).catch(() => null);
|
|
1132
|
+
// For groups, getChatMember(chatId, chatId) returns the chat itself; we
|
|
1133
|
+
// really want the creator. Fall back to getChatAdministrators.
|
|
1134
|
+
let ownerUserId = null;
|
|
1135
|
+
if (member && member.status === 'creator' && member.user?.id) {
|
|
1136
|
+
ownerUserId = member.user.id;
|
|
1137
|
+
} else {
|
|
1138
|
+
const admins = await bot.telegram.getChatAdministrators(chatId).catch(() => []);
|
|
1139
|
+
const creator = (admins || []).find(a => a.status === 'creator');
|
|
1140
|
+
if (creator && creator.user?.id) ownerUserId = creator.user.id;
|
|
1141
|
+
}
|
|
1142
|
+
if (ownerUserId) {
|
|
1143
|
+
await bot.telegram.sendMessage(ownerUserId, text, { parse_mode: 'Markdown' }).catch(err => {
|
|
1144
|
+
console.warn(`[telegram-leak-notifier] DM to user ${ownerUserId} (chat ${chatId}) failed: ${err.message}`);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
console.warn(`[telegram-leak-notifier] could not notify owner of chat ${chatId}: ${err.message}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1112
1153
|
// Add message listener for verbose debugging
|
|
1113
1154
|
if (VERBOSE) {
|
|
1114
1155
|
bot.on('message', (ctx, next) => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Telegram leak-notifier (Issue #1745)
|
|
4
|
+
*
|
|
5
|
+
* The interactive AI bridge calls `reportInteractiveLeak()` whenever it
|
|
6
|
+
* detects that a comment body it was about to publish contained a
|
|
7
|
+
* known-local token. The sanitizer masks the token before it goes out, but
|
|
8
|
+
* we still want the chat owner who started the session to know — quickly,
|
|
9
|
+
* out-of-band — so they can rotate the token immediately.
|
|
10
|
+
*
|
|
11
|
+
* The Telegram bot calls `registerLeakNotifier()` on startup with a callback
|
|
12
|
+
* that knows how to DM the chat owner. We keep this contract intentionally
|
|
13
|
+
* small (callback-based, no direct telegraf import) so:
|
|
14
|
+
*
|
|
15
|
+
* 1. interactive-mode.lib.mjs doesn't have to depend on telegraf at all
|
|
16
|
+
* (avoids a heavy import in the AI subprocess).
|
|
17
|
+
* 2. Tests can register a no-op (or assertion-collecting) notifier.
|
|
18
|
+
* 3. solve.mjs running outside the Telegram bot process degrades gracefully
|
|
19
|
+
* to a console warning.
|
|
20
|
+
*
|
|
21
|
+
* @see docs/case-studies/issue-1745/analysis.md
|
|
22
|
+
* @module telegram-leak-notifier
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
let registeredNotifier = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Telegram bot calls this once during startup so the AI bridge has a way
|
|
29
|
+
* to send out-of-band leak warnings.
|
|
30
|
+
*
|
|
31
|
+
* @param {Function} notifier async ({ owner, repo, prNumber, tokenHits }) => void
|
|
32
|
+
*/
|
|
33
|
+
export const registerLeakNotifier = notifier => {
|
|
34
|
+
registeredNotifier = typeof notifier === 'function' ? notifier : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Test hook — clear the registered notifier between tests. */
|
|
38
|
+
export const clearLeakNotifierForTests = () => {
|
|
39
|
+
registeredNotifier = null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Issue #1745 — fired by interactive-mode.lib.mjs when it had to mask a
|
|
44
|
+
* known-local token in an outbound comment.
|
|
45
|
+
*
|
|
46
|
+
* Always succeeds. If no notifier is registered (we're running outside the
|
|
47
|
+
* Telegram bot process) it falls back to a structured console warning.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} params
|
|
50
|
+
* @param {string} params.owner repo owner
|
|
51
|
+
* @param {string} params.repo repo name
|
|
52
|
+
* @param {number} [params.prNumber] pull-request number, when applicable
|
|
53
|
+
* @param {Array<{name: string, source: string}>} [params.tokenHits]
|
|
54
|
+
* list of token identifiers (NEVER the values) that were detected.
|
|
55
|
+
* @param {Function} [params.log] async logger from interactive-mode
|
|
56
|
+
*/
|
|
57
|
+
export const reportInteractiveLeak = async ({ owner, repo, prNumber, tokenHits = [], log } = {}) => {
|
|
58
|
+
const fallbackLog = log || (async msg => console.warn(msg));
|
|
59
|
+
|
|
60
|
+
const summary = tokenHits.length ? tokenHits.map(h => `${h.name} (${h.source})`).join(', ') : 'unknown';
|
|
61
|
+
|
|
62
|
+
const where = prNumber ? `${owner}/${repo}#${prNumber}` : `${owner}/${repo}`;
|
|
63
|
+
|
|
64
|
+
await fallbackLog(`🚨 Token-leak event: ${summary} found in outbound comment for ${where} (sanitizer masked it).`);
|
|
65
|
+
|
|
66
|
+
if (registeredNotifier) {
|
|
67
|
+
try {
|
|
68
|
+
await registeredNotifier({ owner, repo, prNumber, tokenHits });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
await fallbackLog(`⚠️ Telegram leak notifier threw: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default {
|
|
76
|
+
registerLeakNotifier,
|
|
77
|
+
reportInteractiveLeak,
|
|
78
|
+
clearLeakNotifierForTests,
|
|
79
|
+
};
|
|
@@ -10,10 +10,17 @@
|
|
|
10
10
|
* - Graceful stop: existing queue items continue to process
|
|
11
11
|
* - Read-only commands (/help, /limits, /version) remain available when stopped
|
|
12
12
|
* - Write commands (/solve, /hive) are rejected when stopped
|
|
13
|
+
* - `/stop <UUID>` or reply-to-message-with-UUID forwards CTRL+C to the
|
|
14
|
+
* matching isolated solve/hive session via `$ --stop <UUID>` from
|
|
15
|
+
* link-foundation/start (issue #524).
|
|
13
16
|
*
|
|
14
17
|
* @see https://github.com/link-assistant/hive-mind/issues/1081
|
|
18
|
+
* @see https://github.com/link-assistant/hive-mind/issues/524
|
|
19
|
+
* @see https://github.com/link-foundation/start/issues/112
|
|
15
20
|
*/
|
|
16
21
|
|
|
22
|
+
import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
|
|
23
|
+
|
|
17
24
|
// Store stopped chats: Map<chatId, { stoppedAt: Date, stoppedBy: { id, username, firstName }, reason?: string }>
|
|
18
25
|
const stoppedChats = new Map();
|
|
19
26
|
|
|
@@ -86,6 +93,30 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
|
|
|
86
93
|
return `❌ ${commandName} command rejected.\n\n🚫 Reason: ${reason}\n\nUse /start to resume (chat owner only).`;
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Extract a session UUID for `/stop`. Priority:
|
|
98
|
+
* 1. UUID literal anywhere in the `/stop` message text.
|
|
99
|
+
* 2. UUID in the text/caption of the message being replied to.
|
|
100
|
+
*
|
|
101
|
+
* The `text` argument is the raw `/stop ...` command text. `repliedTo`, when
|
|
102
|
+
* present, is the Telegram message object that the user replied to with `/stop`.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} text
|
|
105
|
+
* @param {Object|null|undefined} repliedTo
|
|
106
|
+
* @returns {{ sessionId: string|null, source: 'argument'|'reply'|null }}
|
|
107
|
+
*/
|
|
108
|
+
export function extractStopSessionId(text, repliedTo) {
|
|
109
|
+
// Strip the leading `/stop` (or `/stop@botname`) before looking for a UUID,
|
|
110
|
+
// so we don't accidentally match digits inside the command name itself.
|
|
111
|
+
const argText = String(text || '').replace(/^\/stop(?:@\w+)?\s*/i, '');
|
|
112
|
+
const direct = extractSessionIdFromText(argText);
|
|
113
|
+
if (direct) return { sessionId: direct, source: 'argument' };
|
|
114
|
+
const replyText = repliedTo ? `${repliedTo.text || ''}\n${repliedTo.caption || ''}` : '';
|
|
115
|
+
const fromReply = extractSessionIdFromText(replyText);
|
|
116
|
+
if (fromReply) return { sessionId: fromReply, source: 'reply' };
|
|
117
|
+
return { sessionId: null, source: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
/**
|
|
90
121
|
* Registers the /start and /stop command handlers with the bot
|
|
91
122
|
* @param {Object} bot - The Telegraf bot instance
|
|
@@ -95,9 +126,13 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
|
|
|
95
126
|
* @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
|
|
96
127
|
* @param {Function} options.isGroupChat - Function to check if chat is a group
|
|
97
128
|
* @param {Function} options.isChatAuthorized - Function to check if chat is authorized
|
|
129
|
+
* @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
|
|
130
|
+
* @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
|
|
131
|
+
* @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
|
|
98
132
|
*/
|
|
99
133
|
export function registerStartStopCommands(bot, options) {
|
|
100
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized } = options;
|
|
134
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
135
|
+
const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
|
|
101
136
|
|
|
102
137
|
/**
|
|
103
138
|
* Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
|
|
@@ -145,9 +180,110 @@ export function registerStartStopCommands(bot, options) {
|
|
|
145
180
|
return { valid: true, chatId };
|
|
146
181
|
}
|
|
147
182
|
|
|
148
|
-
// /stop command
|
|
149
|
-
//
|
|
183
|
+
// /stop command. Two modes:
|
|
184
|
+
// 1. `/stop <UUID>` or reply-to-message-with-UUID — forward CTRL+C to the
|
|
185
|
+
// matching isolated session via `$ --stop <UUID>` (issue #524).
|
|
186
|
+
// 2. bare `/stop` (optionally with a free-text reason) — pause new task
|
|
187
|
+
// acceptance for the chat (issue #1081).
|
|
188
|
+
// Only accessible by chat owner (creator) in both modes.
|
|
150
189
|
bot.command('stop', async ctx => {
|
|
190
|
+
VERBOSE && console.log('[VERBOSE] /stop command received');
|
|
191
|
+
if (isOldMessage(ctx)) {
|
|
192
|
+
VERBOSE && console.log('[VERBOSE] /stop ignored: old message');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Detect UUID modes BEFORE the forwarded/reply rejection used by the
|
|
197
|
+
// chat-level stop, because the UUID-from-reply mode is intentionally a
|
|
198
|
+
// reply (issue #524).
|
|
199
|
+
const message = ctx.message;
|
|
200
|
+
const repliedTo = message?.reply_to_message || null;
|
|
201
|
+
const { sessionId, source } = extractStopSessionId(message?.text || '', repliedTo);
|
|
202
|
+
|
|
203
|
+
if (sessionId) {
|
|
204
|
+
VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${source})`);
|
|
205
|
+
// Reuse the same auth model as /log: must be chat owner in groups; in
|
|
206
|
+
// private DMs the user is implicitly the owner of their own chat.
|
|
207
|
+
const chatId = ctx.chat?.id;
|
|
208
|
+
const chatType = ctx.chat?.type;
|
|
209
|
+
if (chatType !== 'private') {
|
|
210
|
+
if (!isGroupChat(ctx)) {
|
|
211
|
+
await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!isChatAuthorized(chatId)) {
|
|
215
|
+
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
216
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
|
|
217
|
+
await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
223
|
+
if (!member || member.status !== 'creator') {
|
|
224
|
+
VERBOSE && console.log('[VERBOSE] /stop <UUID> ignored: user is not chat owner');
|
|
225
|
+
await ctx.reply('❌ /stop <UUID> is only available to the chat owner.', { reply_to_message_id: message.message_id });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error('[ERROR] /stop <UUID>: getChatMember failed:', error);
|
|
230
|
+
await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
|
|
236
|
+
parse_mode: 'Markdown',
|
|
237
|
+
reply_to_message_id: message.message_id,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
let result;
|
|
241
|
+
try {
|
|
242
|
+
result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.error('[ERROR] /stop <UUID>: stopIsolatedSession threw:', error);
|
|
245
|
+
result = { success: false, output: '', error: error?.message || String(error) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const trimmedOutput = (result.output || '').toString().trim();
|
|
249
|
+
const trimmedError = (result.error || '').toString().trim();
|
|
250
|
+
const lines = [];
|
|
251
|
+
if (result.success) {
|
|
252
|
+
lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
|
|
253
|
+
lines.push('');
|
|
254
|
+
lines.push('The session should terminate shortly.');
|
|
255
|
+
if (trimmedOutput) {
|
|
256
|
+
lines.push('');
|
|
257
|
+
lines.push('```');
|
|
258
|
+
lines.push(trimmedOutput.slice(0, 1000));
|
|
259
|
+
lines.push('```');
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
|
|
263
|
+
if (trimmedError) {
|
|
264
|
+
lines.push('');
|
|
265
|
+
lines.push('```');
|
|
266
|
+
lines.push(trimmedError.slice(0, 1000));
|
|
267
|
+
lines.push('```');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error('[ERROR] /stop <UUID>: editMessageText failed, falling back to reply:', error);
|
|
275
|
+
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// No UUID — fall through to the chat-level pause flow. That flow rejects
|
|
281
|
+
// forwards/replies on purpose (#1081) so a stray reply doesn't pause the chat.
|
|
282
|
+
if (isForwardedOrReply(ctx)) {
|
|
283
|
+
VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
151
287
|
const check = await validateOwnerCommand(ctx, '/stop');
|
|
152
288
|
if (!check.valid) return;
|
|
153
289
|
const chatId = check.chatId;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Telegram /tokens command — hidden, owner-only, private-chat only.
|
|
4
|
+
*
|
|
5
|
+
* Lists every known LOCAL token the bot can see (env vars + GitHub CLI
|
|
6
|
+
* tokens), already masked via `maskToken` (3-char prefix/suffix per
|
|
7
|
+
* issue #1745). Useful for spot-checking which secrets are live in the
|
|
8
|
+
* bot's environment so the operator can search for them in public places
|
|
9
|
+
* before they become a leak.
|
|
10
|
+
*
|
|
11
|
+
* Privacy / safety guarantees:
|
|
12
|
+
*
|
|
13
|
+
* - Hidden command. Not advertised in /help. Not part of the BotFather
|
|
14
|
+
* command list.
|
|
15
|
+
* - Private-chat only. Never echoes tokens (even masked) into a group chat.
|
|
16
|
+
* - Authenticated. The user must own (`status === 'creator'`) at least one
|
|
17
|
+
* chat that is on the allowlist — i.e. they're an actual operator of
|
|
18
|
+
* this bot, not a random DMer.
|
|
19
|
+
* - Output is always masked. We never print raw values.
|
|
20
|
+
*
|
|
21
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1745
|
|
22
|
+
* @module telegram-tokens-command
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { getAllKnownLocalTokens } from './token-sanitization.lib.mjs';
|
|
26
|
+
import { maskToken } from './lib.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve allowed chat IDs into an array of numeric IDs the user could own.
|
|
30
|
+
* Accepts:
|
|
31
|
+
* - Array<number|string>
|
|
32
|
+
* - Function returning Array<number|string>
|
|
33
|
+
* - undefined / null (treated as "any" — useful in private bot deployments)
|
|
34
|
+
*
|
|
35
|
+
* @param {Array|Function|null|undefined} allowedChats
|
|
36
|
+
* @returns {Array<string>} numeric chat IDs as strings
|
|
37
|
+
*/
|
|
38
|
+
const resolveAllowedChatIds = allowedChats => {
|
|
39
|
+
if (!allowedChats) return [];
|
|
40
|
+
const raw = typeof allowedChats === 'function' ? allowedChats() : allowedChats;
|
|
41
|
+
if (!Array.isArray(raw)) return [];
|
|
42
|
+
return raw.map(v => String(v)).filter(Boolean);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns true if `userId` is the creator of any chat in `allowedChatIds`.
|
|
47
|
+
* Returns true unconditionally when `allowedChatIds` is empty (private
|
|
48
|
+
* deployment — no allowlist means any DM is fine).
|
|
49
|
+
*/
|
|
50
|
+
const isOperatorOfAnyAllowedChat = async ({ telegram, userId, allowedChatIds }) => {
|
|
51
|
+
if (!allowedChatIds || allowedChatIds.length === 0) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
for (const chatId of allowedChatIds) {
|
|
55
|
+
try {
|
|
56
|
+
const member = await telegram.getChatMember(chatId, userId);
|
|
57
|
+
if (member && member.status === 'creator') {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Bot may have been removed from the chat; skip and try the next one.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format the token list for display. Each line: `name (source): masked`.
|
|
69
|
+
* The masked form is `first-3 *** last-3` per maskToken's new default.
|
|
70
|
+
*/
|
|
71
|
+
export const formatTokenList = tokens => {
|
|
72
|
+
if (!tokens || tokens.length === 0) {
|
|
73
|
+
return 'No known local tokens found in this bot process.';
|
|
74
|
+
}
|
|
75
|
+
const lines = tokens.map(t => {
|
|
76
|
+
const masked = maskToken(t.value);
|
|
77
|
+
return `• ${t.name} (${t.source}): \`${masked}\``;
|
|
78
|
+
});
|
|
79
|
+
return ['🔐 *Active local tokens (masked):*', '', ...lines, '', '_Use this list to search public places (GitHub, Slack, etc.) for accidentally leaked tokens before they become a problem. Tokens are masked with first 3 + last 3 characters per issue #1745._'].join('\n');
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Registers the hidden /tokens command on the bot.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} bot - Telegraf bot
|
|
86
|
+
* @param {Object} options
|
|
87
|
+
* @param {boolean} [options.VERBOSE]
|
|
88
|
+
* @param {Function} [options.isOldMessage]
|
|
89
|
+
* @param {Array|Function} [options.allowedChats] — used for owner-of-allowed-chat check
|
|
90
|
+
* @param {Function} [options.fetchTokens] — test override for getAllKnownLocalTokens
|
|
91
|
+
*/
|
|
92
|
+
export const registerTokensCommand = (bot, options = {}) => {
|
|
93
|
+
const { VERBOSE = false, isOldMessage, allowedChats } = options;
|
|
94
|
+
const fetchTokens = options.fetchTokens || getAllKnownLocalTokens;
|
|
95
|
+
|
|
96
|
+
bot.command('tokens', async ctx => {
|
|
97
|
+
if (isOldMessage && isOldMessage(ctx)) {
|
|
98
|
+
VERBOSE && console.log('[VERBOSE] /tokens ignored: old message');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const chat = ctx.chat;
|
|
103
|
+
if (!chat || !ctx.from) return;
|
|
104
|
+
|
|
105
|
+
// Step 1: private-chat only. Silently no-op in groups so the command stays
|
|
106
|
+
// truly hidden — a curious group member never gets a hint that it exists.
|
|
107
|
+
if (chat.type !== 'private') {
|
|
108
|
+
VERBOSE && console.log(`[VERBOSE] /tokens ignored: chat type ${chat.type} (private only)`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Step 2: authenticate by ownership of an allowlisted chat.
|
|
113
|
+
const allowedChatIds = resolveAllowedChatIds(allowedChats);
|
|
114
|
+
let isOperator = false;
|
|
115
|
+
try {
|
|
116
|
+
isOperator = await isOperatorOfAnyAllowedChat({
|
|
117
|
+
telegram: ctx.telegram,
|
|
118
|
+
userId: ctx.from.id,
|
|
119
|
+
allowedChatIds,
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
VERBOSE && console.error('[VERBOSE] /tokens auth check failed:', err);
|
|
123
|
+
isOperator = false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!isOperator) {
|
|
127
|
+
VERBOSE && console.log(`[VERBOSE] /tokens denied: user ${ctx.from.id} is not creator of any allowed chat`);
|
|
128
|
+
// Reply with a generic "unknown command"-shaped message so the command
|
|
129
|
+
// stays undiscoverable to non-operators.
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Step 3: gather and emit.
|
|
134
|
+
let tokens;
|
|
135
|
+
try {
|
|
136
|
+
tokens = await fetchTokens();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
VERBOSE && console.error('[VERBOSE] /tokens: fetchTokens failed:', err);
|
|
139
|
+
await ctx.reply('❌ Failed to gather local tokens.');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const message = formatTokenList(tokens);
|
|
144
|
+
await ctx.reply(message, { parse_mode: 'Markdown' });
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export default {
|
|
149
|
+
registerTokensCommand,
|
|
150
|
+
formatTokenList,
|
|
151
|
+
};
|