@link-assistant/hive-mind 1.56.18 → 1.57.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/CHANGELOG.md +23 -0
- package/README.hi.md +43 -2
- package/README.md +40 -2
- package/README.ru.md +45 -2
- package/README.zh.md +39 -2
- package/package.json +2 -2
- package/src/isolation-runner.lib.mjs +13 -4
- package/src/session-monitor.lib.mjs +15 -0
- package/src/telegram-bot.mjs +22 -27
- package/src/telegram-log-command.lib.mjs +372 -0
- package/src/telegram-safe-reply.lib.mjs +19 -0
- package/src/telegram-terminal-watch-command.lib.mjs +412 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram /log command implementation
|
|
3
|
+
*
|
|
4
|
+
* Lets a chat owner pull the log of an isolation session that was launched
|
|
5
|
+
* through the `$` (start-command) CLI. The session is identified by its UUID,
|
|
6
|
+
* either passed as `/log <UUID>` or extracted from a message that the
|
|
7
|
+
* `/log` command is replying to.
|
|
8
|
+
*
|
|
9
|
+
* Privacy guarantees:
|
|
10
|
+
* - Only the chat creator (`status === 'creator'`) may invoke `/log`.
|
|
11
|
+
* - Logs from public GitHub repositories may be uploaded into the chat where
|
|
12
|
+
* `/log` was issued.
|
|
13
|
+
* - Logs from private GitHub repositories — and logs whose repository
|
|
14
|
+
* visibility we cannot determine — are sent to the user via direct message
|
|
15
|
+
* only, after forwarding the original message that contained the session id.
|
|
16
|
+
* - Currently only sessions launched with one of the `$` isolation backends
|
|
17
|
+
* (`screen`, `tmux`, `docker`) are supported. Direct (non-isolation) sessions
|
|
18
|
+
* are rejected with a clear message.
|
|
19
|
+
*
|
|
20
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1686
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import fs from 'fs/promises';
|
|
25
|
+
import { constants as fsConstants } from 'fs';
|
|
26
|
+
|
|
27
|
+
const UUID_RE = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
|
|
28
|
+
const ISOLATION_BACKENDS = new Set(['screen', 'tmux', 'docker']);
|
|
29
|
+
// Telegram bots may upload documents up to 50 MB via sendDocument.
|
|
30
|
+
// https://core.telegram.org/bots/api#senddocument
|
|
31
|
+
const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract the first RFC 4122 v4-shaped UUID found in `text`.
|
|
35
|
+
*
|
|
36
|
+
* @param {string|null|undefined} text
|
|
37
|
+
* @returns {string|null}
|
|
38
|
+
*/
|
|
39
|
+
export function extractSessionIdFromText(text) {
|
|
40
|
+
if (!text || typeof text !== 'string') return null;
|
|
41
|
+
const match = text.match(UUID_RE);
|
|
42
|
+
return match ? match[1].toLowerCase() : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decide where the log for a session should be delivered.
|
|
47
|
+
*
|
|
48
|
+
* Inputs:
|
|
49
|
+
* - `statusResult`: parsed result of `$ --status <uuid>` (see
|
|
50
|
+
* `parseSessionStatusOutput` in `isolation-runner.lib.mjs`).
|
|
51
|
+
* - `sessionInfo`: in-memory record from the Telegram session monitor, or null.
|
|
52
|
+
* - `repoVisibility`: result of `detectRepositoryVisibility(owner, repo)`, or
|
|
53
|
+
* null when the repo could not be identified.
|
|
54
|
+
* - `chatType`: Telegram chat type where `/log` was invoked
|
|
55
|
+
* (`'private'` | `'group'` | `'supergroup'` | `'channel'`).
|
|
56
|
+
*
|
|
57
|
+
* Output: `{ destination, reason, isolationBackend }` where `destination` is
|
|
58
|
+
* one of `'chat'` (deliver in the same chat), `'dm'` (deliver in DM),
|
|
59
|
+
* `'reject'` (don't deliver). `reason` is a short, user-facing string.
|
|
60
|
+
*
|
|
61
|
+
* @returns {{destination: 'chat'|'dm'|'reject', reason: string, isolationBackend: string|null}}
|
|
62
|
+
*/
|
|
63
|
+
export function decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType }) {
|
|
64
|
+
if (!statusResult || !statusResult.exists) {
|
|
65
|
+
return { destination: 'reject', reason: 'Unknown session id (start-command does not know about it).', isolationBackend: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Determine isolation backend. Prefer the in-memory record (which knows what
|
|
69
|
+
// we asked `$` to use), fall back to whatever `$ --status` reports.
|
|
70
|
+
const isolationBackend = (sessionInfo?.isolationBackend || statusResult.isolation || '').toLowerCase() || null;
|
|
71
|
+
if (!isolationBackend || !ISOLATION_BACKENDS.has(isolationBackend)) {
|
|
72
|
+
return {
|
|
73
|
+
destination: 'reject',
|
|
74
|
+
reason: 'This command currently supports only sessions launched with `$` isolation (screen / tmux / docker).',
|
|
75
|
+
isolationBackend: isolationBackend || null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Privacy decision — fail closed when in doubt.
|
|
80
|
+
const isPublic = repoVisibility?.isPublic === true;
|
|
81
|
+
const visibilityKnown = !!repoVisibility && repoVisibility.visibility !== null;
|
|
82
|
+
|
|
83
|
+
if (isPublic && visibilityKnown) {
|
|
84
|
+
if (chatType === 'private') {
|
|
85
|
+
// /log was invoked in DM. Deliver in DM regardless of repo visibility.
|
|
86
|
+
return { destination: 'dm', reason: 'Public repository, delivering in DM (command was sent in a private chat).', isolationBackend };
|
|
87
|
+
}
|
|
88
|
+
return { destination: 'chat', reason: 'Public repository, delivering in chat.', isolationBackend };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Private OR unknown visibility — never leak in a public chat.
|
|
92
|
+
return {
|
|
93
|
+
destination: 'dm',
|
|
94
|
+
reason: visibilityKnown ? 'Private repository — delivering via direct message.' : 'Repository visibility could not be determined — delivering via direct message (fail-closed).',
|
|
95
|
+
isolationBackend,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the on-disk log path for a session.
|
|
101
|
+
*
|
|
102
|
+
* Prefers the `logPath` field reported by `$ --status` (always correct when
|
|
103
|
+
* supported). Falls back to start-command's documented layout if the field is
|
|
104
|
+
* missing.
|
|
105
|
+
*
|
|
106
|
+
* @returns {string|null}
|
|
107
|
+
*/
|
|
108
|
+
export function resolveLogPath({ statusResult, isolationBackend }) {
|
|
109
|
+
if (statusResult?.logPath) return statusResult.logPath;
|
|
110
|
+
const uuid = statusResult?.uuid;
|
|
111
|
+
if (!uuid) return null;
|
|
112
|
+
if (isolationBackend && ISOLATION_BACKENDS.has(isolationBackend)) {
|
|
113
|
+
return path.join('/tmp/start-command/logs/isolation', isolationBackend, `${uuid}.log`);
|
|
114
|
+
}
|
|
115
|
+
return path.join('/tmp/start-command/logs/direct', `${uuid}.log`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function fileExists(filePath) {
|
|
119
|
+
try {
|
|
120
|
+
await fs.access(filePath, fsConstants.R_OK);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function fileSize(filePath) {
|
|
128
|
+
try {
|
|
129
|
+
const stat = await fs.stat(filePath);
|
|
130
|
+
return stat.size;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Registers the /log command handler with the bot.
|
|
138
|
+
*
|
|
139
|
+
* Dependencies (`querySessionStatus`, `getTrackedSessionInfo`,
|
|
140
|
+
* `detectRepositoryVisibility`, `parseGitHubUrl`) are lazy-loaded from the
|
|
141
|
+
* existing libraries by default; tests pass mocked versions through `options`.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} bot - Telegraf bot instance
|
|
144
|
+
* @param {Object} options
|
|
145
|
+
* @param {boolean} [options.VERBOSE]
|
|
146
|
+
* @param {Function} options.isOldMessage
|
|
147
|
+
* @param {Function} options.isChatAuthorized
|
|
148
|
+
* @param {Function} [options.isTopicAuthorized]
|
|
149
|
+
* @param {Function} [options.buildAuthErrorMessage]
|
|
150
|
+
* @param {Function} [options.querySessionStatus] - Override for tests
|
|
151
|
+
* @param {Function} [options.getTrackedSessionInfo] - Override for tests
|
|
152
|
+
* @param {Function} [options.detectRepositoryVisibility] - Override for tests
|
|
153
|
+
* @param {Function} [options.parseGitHubUrl] - Override for tests
|
|
154
|
+
*/
|
|
155
|
+
export async function registerLogCommand(bot, options) {
|
|
156
|
+
const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
157
|
+
const querySessionStatus = options.querySessionStatus || (await import('./isolation-runner.lib.mjs')).querySessionStatus;
|
|
158
|
+
const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
|
|
159
|
+
const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
|
|
160
|
+
const parseGitHubUrl = options.parseGitHubUrl || (await import('./github.lib.mjs')).parseGitHubUrl;
|
|
161
|
+
|
|
162
|
+
bot.command('log', async ctx => {
|
|
163
|
+
VERBOSE && console.log('[VERBOSE] /log command received');
|
|
164
|
+
|
|
165
|
+
if (isOldMessage && isOldMessage(ctx)) {
|
|
166
|
+
VERBOSE && console.log('[VERBOSE] /log ignored: old message');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const chat = ctx.chat;
|
|
171
|
+
const message = ctx.message;
|
|
172
|
+
if (!chat || !message) return;
|
|
173
|
+
|
|
174
|
+
const chatType = chat.type;
|
|
175
|
+
const chatId = chat.id;
|
|
176
|
+
|
|
177
|
+
// Extract the session id. Priority: explicit argument, then reply text.
|
|
178
|
+
const directSessionId = extractSessionIdFromText(message.text || '');
|
|
179
|
+
const repliedTo = message.reply_to_message;
|
|
180
|
+
const replySessionId = repliedTo ? extractSessionIdFromText(repliedTo.text || repliedTo.caption || '') : null;
|
|
181
|
+
const sessionId = directSessionId || replySessionId;
|
|
182
|
+
|
|
183
|
+
if (!sessionId) {
|
|
184
|
+
await ctx.reply('❌ /log requires a session id.\n\nUsage:\n• `/log <UUID>` — fetch a specific session log\n• Reply to a session message with `/log` — fetch the session referenced in that message', {
|
|
185
|
+
parse_mode: 'Markdown',
|
|
186
|
+
reply_to_message_id: message.message_id,
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Authorization. /log is only available to chat owners. In private chats
|
|
192
|
+
// there is no "creator" status — the user is implicitly the owner of their
|
|
193
|
+
// own DM, so we allow it. We still apply the optional allowlist used by
|
|
194
|
+
// other commands so a private bot deployment can lock /log to known users.
|
|
195
|
+
if (chatType === 'private') {
|
|
196
|
+
// No further auth required beyond the optional whitelist applied below.
|
|
197
|
+
} else {
|
|
198
|
+
try {
|
|
199
|
+
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
200
|
+
if (!member || member.status !== 'creator') {
|
|
201
|
+
VERBOSE && console.log('[VERBOSE] /log rejected: not chat owner');
|
|
202
|
+
await ctx.reply('❌ /log is only available to the chat owner.', { reply_to_message_id: message.message_id });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error('[ERROR] /log: getChatMember failed:', error);
|
|
207
|
+
await ctx.reply('❌ Failed to verify permissions for /log.', { reply_to_message_id: message.message_id });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (isChatAuthorized && !isChatAuthorized(chatId)) {
|
|
213
|
+
// Topic-aware fallback (used elsewhere in this repo for forum topics).
|
|
214
|
+
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
215
|
+
VERBOSE && console.log('[VERBOSE] /log rejected: chat not authorized');
|
|
216
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized.`;
|
|
217
|
+
await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 1. Validate the session id with $ --status.
|
|
223
|
+
let statusResult;
|
|
224
|
+
try {
|
|
225
|
+
statusResult = await querySessionStatus(sessionId, VERBOSE);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error('[ERROR] /log: querySessionStatus failed:', error);
|
|
228
|
+
await ctx.reply(`❌ Failed to query session status: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!statusResult || !statusResult.exists) {
|
|
233
|
+
await ctx.reply(`❌ Session \`${sessionId}\` is not known to start-command.\n\nUse the session id from a \`📊 Session: <uuid>\` line in one of the bot's status messages.`, {
|
|
234
|
+
parse_mode: 'Markdown',
|
|
235
|
+
reply_to_message_id: message.message_id,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2. Look up tracked metadata (for repo URL and original chat).
|
|
241
|
+
const sessionInfo = getTrackedSessionInfo ? getTrackedSessionInfo(sessionId) : null;
|
|
242
|
+
|
|
243
|
+
// 3. Decide repo visibility — prefer the URL we tracked at launch time.
|
|
244
|
+
let repoVisibility = null;
|
|
245
|
+
let repoUrlDescription = null;
|
|
246
|
+
const trackedUrl = sessionInfo?.url || null;
|
|
247
|
+
if (trackedUrl) {
|
|
248
|
+
const parsed = parseGitHubUrl ? parseGitHubUrl(trackedUrl) : null;
|
|
249
|
+
if (parsed && parsed.valid && parsed.owner && parsed.repo) {
|
|
250
|
+
repoUrlDescription = `${parsed.owner}/${parsed.repo}`;
|
|
251
|
+
try {
|
|
252
|
+
repoVisibility = await detectRepositoryVisibility(parsed.owner, parsed.repo);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.error('[ERROR] /log: detectRepositoryVisibility failed:', error);
|
|
255
|
+
repoVisibility = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 4. Decide the destination.
|
|
261
|
+
const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType });
|
|
262
|
+
if (decision.destination === 'reject') {
|
|
263
|
+
await ctx.reply(`❌ ${decision.reason}`, { reply_to_message_id: message.message_id });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 5. Resolve and validate the on-disk log file.
|
|
268
|
+
const logPath = resolveLogPath({ statusResult, isolationBackend: decision.isolationBackend });
|
|
269
|
+
if (!logPath) {
|
|
270
|
+
await ctx.reply('❌ Could not determine the log file path for this session.', { reply_to_message_id: message.message_id });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!(await fileExists(logPath))) {
|
|
274
|
+
await ctx.reply(`❌ Log file does not exist on disk:\n\`${logPath}\`\n\nThe session may have been cleaned up by the host or the isolation backend.`, {
|
|
275
|
+
parse_mode: 'Markdown',
|
|
276
|
+
reply_to_message_id: message.message_id,
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const size = await fileSize(logPath);
|
|
281
|
+
if (size !== null && size > TELEGRAM_DOCUMENT_MAX_BYTES) {
|
|
282
|
+
await ctx.reply(`❌ Log file is ${(size / (1024 * 1024)).toFixed(1)} MB which exceeds Telegram's 50 MB document upload limit.\n\nFile path on host: \`${logPath}\``, {
|
|
283
|
+
parse_mode: 'Markdown',
|
|
284
|
+
reply_to_message_id: message.message_id,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const filename = path.basename(logPath);
|
|
290
|
+
const captionLines = [`📁 Log for session \`${sessionId}\``];
|
|
291
|
+
if (decision.isolationBackend) captionLines.push(`🔒 Isolation: \`${decision.isolationBackend}\``);
|
|
292
|
+
if (statusResult.status) captionLines.push(`Status: \`${statusResult.status}\``);
|
|
293
|
+
if (repoUrlDescription) captionLines.push(`Repo: \`${repoUrlDescription}\``);
|
|
294
|
+
captionLines.push(`Privacy: ${decision.reason}`);
|
|
295
|
+
const caption = captionLines.join('\n');
|
|
296
|
+
|
|
297
|
+
if (decision.destination === 'chat') {
|
|
298
|
+
// Public repository → reply with the document directly in the chat.
|
|
299
|
+
try {
|
|
300
|
+
await ctx.replyWithDocument({ source: logPath, filename }, { reply_to_message_id: message.message_id, caption, parse_mode: 'Markdown' });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error('[ERROR] /log: replyWithDocument failed:', error);
|
|
303
|
+
await ctx.reply(`❌ Failed to upload log: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// DM flow: forward the originating message into DM (so the audit chain
|
|
309
|
+
// is preserved), then reply to that forwarded message with the log file.
|
|
310
|
+
const userId = ctx.from?.id;
|
|
311
|
+
if (!userId) {
|
|
312
|
+
await ctx.reply('❌ Cannot deliver the log via DM: missing user id.', { reply_to_message_id: message.message_id });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let forwardedMessageId = null;
|
|
317
|
+
try {
|
|
318
|
+
// Forward the message that contains the session id (the reply target if
|
|
319
|
+
// any, otherwise the /log message itself).
|
|
320
|
+
const forwardSource = repliedTo || message;
|
|
321
|
+
const forwardedFromChatId = forwardSource === repliedTo ? chatId : chatId;
|
|
322
|
+
const forwardedSourceMessageId = forwardSource.message_id;
|
|
323
|
+
try {
|
|
324
|
+
const forwarded = await ctx.telegram.forwardMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
|
|
325
|
+
forwardedMessageId = forwarded?.message_id || null;
|
|
326
|
+
} catch (forwardError) {
|
|
327
|
+
// forwardMessage can fail if the user has not opened a DM with the bot
|
|
328
|
+
// yet, or the source chat blocks forwards. Fall back to copyMessage,
|
|
329
|
+
// which works without a forward header.
|
|
330
|
+
try {
|
|
331
|
+
const copied = await ctx.telegram.copyMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
|
|
332
|
+
forwardedMessageId = copied?.message_id || null;
|
|
333
|
+
} catch (copyError) {
|
|
334
|
+
console.error('[ERROR] /log: forward/copyMessage to DM failed:', forwardError, copyError);
|
|
335
|
+
// Fall through — we can still try sendDocument without a reply ref.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
console.error('[ERROR] /log: DM forwarding step failed:', error);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const replyOpts = forwardedMessageId ? { reply_to_message_id: forwardedMessageId, caption, parse_mode: 'Markdown' } : { caption, parse_mode: 'Markdown' };
|
|
344
|
+
await ctx.telegram.sendDocument(userId, { source: logPath, filename }, replyOpts);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error('[ERROR] /log: sendDocument to DM failed:', error);
|
|
347
|
+
// Tell the user, in their original chat, that DM delivery failed
|
|
348
|
+
// (commonly because they have not started a chat with the bot).
|
|
349
|
+
const friendly = error?.code === 403 || /chat not found|bot can't initiate conversation/i.test(error?.message || '') ? 'I could not send you a DM. Please open a private chat with me and send /start, then try again.' : `Failed to send the log via DM: ${error.message || String(error)}`;
|
|
350
|
+
await ctx.reply(`❌ ${friendly}`, { reply_to_message_id: message.message_id });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Acknowledge in the original chat (only if it wasn't already a DM).
|
|
355
|
+
if (chatType !== 'private') {
|
|
356
|
+
try {
|
|
357
|
+
await ctx.reply(`📬 Sent the log for \`${sessionId}\` to your direct messages (private repository).`, {
|
|
358
|
+
parse_mode: 'Markdown',
|
|
359
|
+
reply_to_message_id: message.message_id,
|
|
360
|
+
});
|
|
361
|
+
} catch (error) {
|
|
362
|
+
console.error('[ERROR] /log: failed to acknowledge in chat:', error);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const __INTERNAL_FOR_TESTS__ = {
|
|
369
|
+
UUID_RE,
|
|
370
|
+
TELEGRAM_DOCUMENT_MAX_BYTES,
|
|
371
|
+
ISOLATION_BACKENDS,
|
|
372
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Issue #1460/#1497: safeReply - try Markdown first, fall back to plain text on parsing errors.
|
|
2
|
+
export async function safeReply(ctx, text, options = {}) {
|
|
3
|
+
try {
|
|
4
|
+
return await ctx.reply(text, { parse_mode: 'Markdown', ...options });
|
|
5
|
+
} catch (error) {
|
|
6
|
+
const message = error?.message || '';
|
|
7
|
+
const isParsingError = message.includes("can't parse entities") || message.includes("Can't parse entities") || message.includes("can't find end of") || (message.includes('Bad Request') && message.includes('400'));
|
|
8
|
+
if (!isParsingError) throw error;
|
|
9
|
+
console.error(`[telegram-bot] safeReply: Markdown parsing failed: ${message}`);
|
|
10
|
+
console.error(`[telegram-bot] safeReply: Failing message (${Buffer.byteLength(text, 'utf-8')} bytes): ${text}`);
|
|
11
|
+
const plainText = text
|
|
12
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
|
13
|
+
.replace(/\\_/g, '_')
|
|
14
|
+
.replace(/\\\*/g, '*')
|
|
15
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
16
|
+
.replace(/`([^`]+)`/g, '$1');
|
|
17
|
+
return await ctx.reply(plainText, { ...options, parse_mode: undefined });
|
|
18
|
+
}
|
|
19
|
+
}
|