@link-assistant/hive-mind 2.13.1 → 2.13.2
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 +6 -0
- package/package.json +1 -1
- package/src/buildUserMention.lib.mjs +30 -3
- package/src/github-url-parser.lib.mjs +26 -1
- package/src/session-monitor.lib.mjs +3 -2
- package/src/telegram-accept-invitations.lib.mjs +5 -3
- package/src/telegram-bot.mjs +18 -9
- package/src/telegram-command-execution.lib.mjs +2 -1
- package/src/telegram-context-safety.lib.mjs +70 -0
- package/src/telegram-fix-command.lib.mjs +68 -4
- package/src/telegram-language-command.lib.mjs +4 -3
- package/src/telegram-log-command.lib.mjs +14 -12
- package/src/telegram-markdown-validator.lib.mjs +192 -0
- package/src/telegram-merge-command.lib.mjs +18 -16
- package/src/telegram-message-filters.lib.mjs +1 -1
- package/src/telegram-safe-reply.lib.mjs +290 -21
- package/src/telegram-solve-queue-command.lib.mjs +2 -1
- package/src/telegram-solve-queue.lib.mjs +16 -7
- package/src/telegram-start-stop-command.lib.mjs +38 -27
- package/src/telegram-subscribers.lib.mjs +6 -4
- package/src/telegram-terminal-watch-command.lib.mjs +8 -7
- package/src/telegram-tokens-command.lib.mjs +2 -1
- package/src/telegram-top-command.lib.mjs +8 -9
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeLocale, t } from './i18n.lib.mjs';
|
|
2
2
|
import { sanitizeForPublication } from './token-sanitization.lib.mjs';
|
|
3
|
+
import { validateTelegramText } from './telegram-markdown-validator.lib.mjs';
|
|
3
4
|
|
|
4
5
|
const FORMATTING_FALLBACK_INSTALLED = Symbol.for('hiveMind.telegramFormattingFallbackInstalled');
|
|
5
6
|
const DEFAULT_FORMATTING_FALLBACK_WARNING = '⚠️ Formatting error detected. Showing plain text fallback.';
|
|
@@ -252,33 +253,140 @@ function logChunking(scope, text, chunks, verbose = false) {
|
|
|
252
253
|
}
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
// Issue #2166: every outgoing Telegram text goes through this module, so this is
|
|
257
|
+
// the one place where a complete audit trail of what the bot tried to send (and
|
|
258
|
+
// what Telegram answered) can be produced. Without it a rejected message is
|
|
259
|
+
// invisible in the logs beyond a stack trace.
|
|
260
|
+
const SEND_LOG_PREVIEW_LIMIT = 300;
|
|
261
|
+
let sendLogSequence = 0;
|
|
262
|
+
|
|
263
|
+
function nextSendId() {
|
|
264
|
+
sendLogSequence += 1;
|
|
265
|
+
return `s${sendLogSequence}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function describeTelegramSendTarget(target) {
|
|
269
|
+
if (!target || typeof target !== 'object') return target === undefined || target === null ? 'chat=unknown' : `chat=${target}`;
|
|
270
|
+
const parts = [];
|
|
271
|
+
if (target.chatId !== undefined && target.chatId !== null) parts.push(`chat=${target.chatId}`);
|
|
272
|
+
if (target.messageId !== undefined && target.messageId !== null) parts.push(`message=${target.messageId}`);
|
|
273
|
+
if (target.inlineMessageId) parts.push(`inline=${target.inlineMessageId}`);
|
|
274
|
+
if (target.threadId !== undefined && target.threadId !== null) parts.push(`thread=${target.threadId}`);
|
|
275
|
+
return parts.length ? parts.join(' ') : 'chat=unknown';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function previewForLog(text, verbose) {
|
|
279
|
+
const source = String(text ?? '');
|
|
280
|
+
if (verbose || source.length <= SEND_LOG_PREVIEW_LIMIT) return JSON.stringify(source);
|
|
281
|
+
return `${JSON.stringify(source.slice(0, SEND_LOG_PREVIEW_LIMIT))}… (+${source.length - SEND_LOG_PREVIEW_LIMIT} chars)`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function describeOptionsForLog(options = {}) {
|
|
285
|
+
const parseMode = options?.parse_mode ?? 'none';
|
|
286
|
+
const parts = [`parse_mode=${parseMode}`];
|
|
287
|
+
if (options?.reply_to_message_id) parts.push(`reply_to=${options.reply_to_message_id}`);
|
|
288
|
+
if (options?.message_thread_id) parts.push(`thread=${options.message_thread_id}`);
|
|
289
|
+
if (options?.reply_markup) parts.push('reply_markup=yes');
|
|
290
|
+
return parts.join(' ');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function logSendAttempt({ scope, target, text, options, verbose }) {
|
|
294
|
+
const id = nextSendId();
|
|
295
|
+
const source = String(text ?? '');
|
|
296
|
+
console.log(`[telegram-send] ${id} ${scope} → ${describeTelegramSendTarget(target)} ${describeOptionsForLog(options)} chars=${source.length} bytes=${Buffer.byteLength(source, 'utf-8')} text=${previewForLog(source, verbose)}`);
|
|
297
|
+
return id;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function logSendSuccess({ id, scope, result }) {
|
|
301
|
+
const messageId = result?.message_id ?? result?.message?.message_id ?? null;
|
|
302
|
+
console.log(`[telegram-send] ${id} ${scope} ✓ delivered${messageId === null ? '' : ` message_id=${messageId}`}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function logSendRejected({ id, scope, error }) {
|
|
306
|
+
console.error(`[telegram-send] ${id} ${scope} ✗ rejected: ${getTelegramErrorMessage(error)}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A Bot API `400 Bad Request` is never a partial delivery: Telegram refused the
|
|
311
|
+
* message outright, so retrying it as plain text cannot duplicate anything.
|
|
312
|
+
* This is the safety net that makes "no silent failures" (issue #2166) hold even
|
|
313
|
+
* for rejections this module does not recognise yet.
|
|
314
|
+
*/
|
|
315
|
+
export function isTelegramBadRequestError(error) {
|
|
316
|
+
if (error?.response?.error_code === 400 || error?.error_code === 400) return true;
|
|
317
|
+
return /^\s*400:/.test(getTelegramErrorMessage(error)) || /bad request/i.test(getTelegramErrorMessage(error));
|
|
318
|
+
}
|
|
319
|
+
|
|
255
320
|
function getPlainTextOptions(telegramOptions) {
|
|
256
321
|
return { ...telegramOptions, parse_mode: undefined, entities: undefined };
|
|
257
322
|
}
|
|
258
323
|
|
|
259
|
-
async function sendPlainTextChunks({ text, telegramOptions, scope, verbose, sendChunk }) {
|
|
324
|
+
async function sendPlainTextChunks({ text, telegramOptions, scope, verbose, sendChunk, target }) {
|
|
260
325
|
const plainOptions = getPlainTextOptions(telegramOptions);
|
|
261
326
|
const chunks = splitTelegramMessageText(text);
|
|
262
327
|
logChunking(`${scope}:plainText`, text, chunks, verbose);
|
|
263
328
|
|
|
264
329
|
let firstResult;
|
|
265
330
|
for (const chunk of chunks) {
|
|
266
|
-
const
|
|
267
|
-
|
|
331
|
+
const id = logSendAttempt({ scope: `${scope}:plainText`, target, text: chunk, options: plainOptions, verbose });
|
|
332
|
+
try {
|
|
333
|
+
const result = await sendChunk(chunk, plainOptions);
|
|
334
|
+
logSendSuccess({ id, scope: `${scope}:plainText`, result });
|
|
335
|
+
if (firstResult === undefined) firstResult = result;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
logSendRejected({ id, scope: `${scope}:plainText`, error });
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
268
340
|
}
|
|
269
341
|
return firstResult;
|
|
270
342
|
}
|
|
271
343
|
|
|
272
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Check a chunk *before* it reaches the Bot API (issue #2166, requirement R2).
|
|
346
|
+
*
|
|
347
|
+
* Telegram answers an unterminated entity with an opaque
|
|
348
|
+
* `Can't find end of the entity starting at byte offset N`, so catching it here
|
|
349
|
+
* both saves a doomed round trip and lets the log name the offending characters.
|
|
350
|
+
*
|
|
351
|
+
* @returns {{valid: boolean, description?: string, byteOffset?: number, context?: string}}
|
|
352
|
+
*/
|
|
353
|
+
function preflightChunk({ scope, chunk, telegramOptions, fallbackLocale, verbose }) {
|
|
354
|
+
const validation = validateTelegramText(chunk, telegramOptions?.parse_mode);
|
|
355
|
+
if (validation.valid) return validation;
|
|
356
|
+
console.error(`[telegram-send] ${scope}: pre-send validation rejected a ${telegramOptions?.parse_mode} message: ${validation.description}`);
|
|
357
|
+
console.error(`[telegram-send] ${scope}: byte offset ${validation.byteOffset} context: ${validation.context}`);
|
|
358
|
+
const fallbackText = buildTelegramFormattingFallbackText(chunk, { fallbackLocale });
|
|
359
|
+
logFormattingFailure(`${scope}:preflight`, { description: validation.description }, chunk, verbose, fallbackText);
|
|
360
|
+
return { ...validation, fallbackText };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function sendTelegramTextChunks({ text, telegramOptions, fallbackLocale, verbose, scope, sendChunk, target }) {
|
|
273
364
|
const chunks = splitTelegramMessageText(text);
|
|
274
365
|
logChunking(scope, text, chunks, verbose);
|
|
275
366
|
|
|
276
367
|
let firstResult;
|
|
277
368
|
for (const chunk of chunks) {
|
|
369
|
+
const preflight = preflightChunk({ scope, chunk, telegramOptions, fallbackLocale, verbose });
|
|
370
|
+
if (!preflight.valid) {
|
|
371
|
+
const result = await sendPlainTextChunks({
|
|
372
|
+
text: preflight.fallbackText,
|
|
373
|
+
telegramOptions,
|
|
374
|
+
scope: `${scope}:preflight`,
|
|
375
|
+
verbose,
|
|
376
|
+
sendChunk,
|
|
377
|
+
target,
|
|
378
|
+
});
|
|
379
|
+
if (firstResult === undefined) firstResult = result;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const id = logSendAttempt({ scope, target, text: chunk, options: telegramOptions, verbose });
|
|
278
384
|
try {
|
|
279
385
|
const result = await sendChunk(chunk, telegramOptions);
|
|
386
|
+
logSendSuccess({ id, scope, result });
|
|
280
387
|
if (firstResult === undefined) firstResult = result;
|
|
281
388
|
} catch (error) {
|
|
389
|
+
logSendRejected({ id, scope, error });
|
|
282
390
|
let fallbackText;
|
|
283
391
|
if (isTelegramFormattingError(error)) {
|
|
284
392
|
fallbackText = buildTelegramFormattingFallbackText(chunk, { fallbackLocale });
|
|
@@ -286,6 +394,11 @@ async function sendTelegramTextChunks({ text, telegramOptions, fallbackLocale, v
|
|
|
286
394
|
} else if (isTelegramMessageTooLongError(error)) {
|
|
287
395
|
fallbackText = stripTelegramMarkdown(chunk);
|
|
288
396
|
logMessageTooLongFailure(scope, error, chunk, verbose, fallbackText);
|
|
397
|
+
} else if (telegramOptions?.parse_mode && isTelegramBadRequestError(error)) {
|
|
398
|
+
// Unrecognised 400: the message was definitely not delivered, so a plain
|
|
399
|
+
// text retry is safe and keeps the failure from being silent.
|
|
400
|
+
fallbackText = buildTelegramFormattingFallbackText(chunk, { fallbackLocale });
|
|
401
|
+
logFormattingFailure(scope, error, chunk, verbose, fallbackText);
|
|
289
402
|
} else {
|
|
290
403
|
throw error;
|
|
291
404
|
}
|
|
@@ -296,6 +409,7 @@ async function sendTelegramTextChunks({ text, telegramOptions, fallbackLocale, v
|
|
|
296
409
|
scope,
|
|
297
410
|
verbose,
|
|
298
411
|
sendChunk,
|
|
412
|
+
target,
|
|
299
413
|
});
|
|
300
414
|
if (firstResult === undefined) firstResult = result;
|
|
301
415
|
}
|
|
@@ -304,7 +418,7 @@ async function sendTelegramTextChunks({ text, telegramOptions, fallbackLocale, v
|
|
|
304
418
|
return firstResult;
|
|
305
419
|
}
|
|
306
420
|
|
|
307
|
-
async function sendRemainingEditChunks({ chunks, telegramOptions, fallbackLocale, verbose, scope, sendFollowUpChunk }) {
|
|
421
|
+
async function sendRemainingEditChunks({ chunks, telegramOptions, fallbackLocale, verbose, scope, sendFollowUpChunk, target }) {
|
|
308
422
|
if (chunks.length === 0) return undefined;
|
|
309
423
|
if (!sendFollowUpChunk) {
|
|
310
424
|
console.error(`[telegram-bot] ${scope}: cannot send ${chunks.length} remaining chunk(s) after edit because chat_id is unavailable.`);
|
|
@@ -318,10 +432,11 @@ async function sendRemainingEditChunks({ chunks, telegramOptions, fallbackLocale
|
|
|
318
432
|
verbose,
|
|
319
433
|
scope: `${scope}:followUp`,
|
|
320
434
|
sendChunk: sendFollowUpChunk,
|
|
435
|
+
target,
|
|
321
436
|
});
|
|
322
437
|
}
|
|
323
438
|
|
|
324
|
-
async function sendPlainRemainingEditChunks({ chunks, telegramOptions, verbose, scope, sendFollowUpChunk }) {
|
|
439
|
+
async function sendPlainRemainingEditChunks({ chunks, telegramOptions, verbose, scope, sendFollowUpChunk, target }) {
|
|
325
440
|
if (chunks.length === 0) return undefined;
|
|
326
441
|
if (!sendFollowUpChunk) {
|
|
327
442
|
console.error(`[telegram-bot] ${scope}: cannot send ${chunks.length} plain-text fallback chunk(s) after edit because chat_id is unavailable.`);
|
|
@@ -334,33 +449,52 @@ async function sendPlainRemainingEditChunks({ chunks, telegramOptions, verbose,
|
|
|
334
449
|
scope: `${scope}:followUp`,
|
|
335
450
|
verbose,
|
|
336
451
|
sendChunk: sendFollowUpChunk,
|
|
452
|
+
target,
|
|
337
453
|
});
|
|
338
454
|
}
|
|
339
455
|
|
|
340
|
-
async function editTelegramTextChunks({ text, telegramOptions, fallbackLocale, verbose, scope, editChunk, sendFollowUpChunk }) {
|
|
456
|
+
async function editTelegramTextChunks({ text, telegramOptions, fallbackLocale, verbose, scope, editChunk, sendFollowUpChunk, target }) {
|
|
341
457
|
const chunks = splitTelegramMessageText(text);
|
|
342
458
|
logChunking(scope, text, chunks, verbose);
|
|
343
459
|
|
|
344
460
|
const [firstChunk, ...remainingChunks] = chunks;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
461
|
+
const preflight = preflightChunk({ scope, chunk: firstChunk, telegramOptions, fallbackLocale, verbose });
|
|
462
|
+
let editError = null;
|
|
463
|
+
if (preflight.valid) {
|
|
464
|
+
const id = logSendAttempt({ scope, target, text: firstChunk, options: telegramOptions, verbose });
|
|
465
|
+
try {
|
|
466
|
+
const result = await editChunk(firstChunk, telegramOptions);
|
|
467
|
+
logSendSuccess({ id, scope, result });
|
|
468
|
+
await sendRemainingEditChunks({
|
|
469
|
+
chunks: remainingChunks,
|
|
470
|
+
telegramOptions,
|
|
471
|
+
fallbackLocale,
|
|
472
|
+
verbose,
|
|
473
|
+
scope,
|
|
474
|
+
sendFollowUpChunk,
|
|
475
|
+
target,
|
|
476
|
+
});
|
|
477
|
+
return result;
|
|
478
|
+
} catch (error) {
|
|
479
|
+
logSendRejected({ id, scope, error });
|
|
480
|
+
editError = error;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
{
|
|
485
|
+
const error = editError;
|
|
357
486
|
let fallbackText;
|
|
358
|
-
if (
|
|
487
|
+
if (error === null) {
|
|
488
|
+
fallbackText = preflight.fallbackText;
|
|
489
|
+
} else if (isTelegramFormattingError(error)) {
|
|
359
490
|
fallbackText = buildTelegramFormattingFallbackText(firstChunk, { fallbackLocale });
|
|
360
491
|
logFormattingFailure(scope, error, firstChunk, verbose, fallbackText);
|
|
361
492
|
} else if (isTelegramMessageTooLongError(error)) {
|
|
362
493
|
fallbackText = stripTelegramMarkdown(firstChunk);
|
|
363
494
|
logMessageTooLongFailure(scope, error, firstChunk, verbose, fallbackText);
|
|
495
|
+
} else if (telegramOptions?.parse_mode && isTelegramBadRequestError(error)) {
|
|
496
|
+
fallbackText = buildTelegramFormattingFallbackText(firstChunk, { fallbackLocale });
|
|
497
|
+
logFormattingFailure(scope, error, firstChunk, verbose, fallbackText);
|
|
364
498
|
} else {
|
|
365
499
|
throw error;
|
|
366
500
|
}
|
|
@@ -369,13 +503,22 @@ async function editTelegramTextChunks({ text, telegramOptions, fallbackLocale, v
|
|
|
369
503
|
const fallbackChunks = splitTelegramMessageText(fallbackText);
|
|
370
504
|
logChunking(`${scope}:plainText`, fallbackText, fallbackChunks, verbose);
|
|
371
505
|
const [firstFallbackChunk, ...remainingFallbackChunks] = fallbackChunks;
|
|
372
|
-
const
|
|
506
|
+
const plainId = logSendAttempt({ scope: `${scope}:plainText`, target, text: firstFallbackChunk, options: plainOptions, verbose });
|
|
507
|
+
let result;
|
|
508
|
+
try {
|
|
509
|
+
result = await editChunk(firstFallbackChunk, plainOptions);
|
|
510
|
+
logSendSuccess({ id: plainId, scope: `${scope}:plainText`, result });
|
|
511
|
+
} catch (plainError) {
|
|
512
|
+
logSendRejected({ id: plainId, scope: `${scope}:plainText`, error: plainError });
|
|
513
|
+
throw plainError;
|
|
514
|
+
}
|
|
373
515
|
await sendPlainRemainingEditChunks({
|
|
374
516
|
chunks: [...remainingFallbackChunks, ...remainingChunks.map(stripTelegramMarkdown)],
|
|
375
517
|
telegramOptions,
|
|
376
518
|
verbose,
|
|
377
519
|
scope,
|
|
378
520
|
sendFollowUpChunk,
|
|
521
|
+
target,
|
|
379
522
|
});
|
|
380
523
|
return result;
|
|
381
524
|
}
|
|
@@ -391,10 +534,36 @@ export async function safeReply(ctx, text, options = {}) {
|
|
|
391
534
|
fallbackLocale,
|
|
392
535
|
verbose,
|
|
393
536
|
scope: 'safeReply',
|
|
537
|
+
target: { chatId: ctx?.chat?.id, threadId: firstOptions.message_thread_id },
|
|
394
538
|
sendChunk: (chunk, chunkOptions) => ctx.reply(chunk, chunkOptions),
|
|
395
539
|
});
|
|
396
540
|
}
|
|
397
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Bot-initiated send (no `ctx`): same funnel as {@link safeReply}.
|
|
544
|
+
*
|
|
545
|
+
* Used by background senders (session monitor, subscriber broadcasts) that hold
|
|
546
|
+
* a `Telegram` client instead of a context (issue #2166).
|
|
547
|
+
*
|
|
548
|
+
* @param {object} telegram - Telegraf `Telegram` client.
|
|
549
|
+
* @param {number|string} chatId - Target chat.
|
|
550
|
+
* @param {string} text - Message text.
|
|
551
|
+
* @param {object} [options] - Telegram options plus `fallbackLocale`/`verbose`.
|
|
552
|
+
*/
|
|
553
|
+
export async function safeSendMessage(telegram, chatId, text, options = {}) {
|
|
554
|
+
const { telegramOptions, fallbackLocale, verbose } = splitOptions(options);
|
|
555
|
+
const firstOptions = { parse_mode: 'Markdown', ...telegramOptions };
|
|
556
|
+
return await sendTelegramTextChunks({
|
|
557
|
+
text: await sanitizeForPublication(text),
|
|
558
|
+
telegramOptions: firstOptions,
|
|
559
|
+
fallbackLocale,
|
|
560
|
+
verbose,
|
|
561
|
+
scope: 'safeSendMessage',
|
|
562
|
+
target: { chatId, threadId: firstOptions.message_thread_id },
|
|
563
|
+
sendChunk: (chunk, chunkOptions) => telegram.sendMessage(chatId, chunk, chunkOptions),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
398
567
|
export async function safeEditMessageText(telegram, chatId, messageId, inlineMessageId, text, options = {}) {
|
|
399
568
|
const { telegramOptions, fallbackLocale, verbose } = splitOptions(options);
|
|
400
569
|
const firstOptions = { parse_mode: 'Markdown', ...telegramOptions };
|
|
@@ -404,11 +573,109 @@ export async function safeEditMessageText(telegram, chatId, messageId, inlineMes
|
|
|
404
573
|
fallbackLocale,
|
|
405
574
|
verbose,
|
|
406
575
|
scope: 'safeEditMessageText',
|
|
576
|
+
target: { chatId, messageId, inlineMessageId },
|
|
407
577
|
editChunk: (chunk, chunkOptions) => telegram.editMessageText(chatId, messageId, inlineMessageId, chunk, chunkOptions),
|
|
408
578
|
sendFollowUpChunk: chatId !== undefined && chatId !== null && typeof telegram.sendMessage === 'function' ? (chunk, chunkOptions) => telegram.sendMessage(chatId, chunk, chunkOptions) : null,
|
|
409
579
|
});
|
|
410
580
|
}
|
|
411
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Telegram truncates media captions at 1024 characters (text messages get 4096).
|
|
584
|
+
*/
|
|
585
|
+
export const TELEGRAM_CAPTION_LIMIT = 1024;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Make media options safe to send (issue #2166).
|
|
589
|
+
*
|
|
590
|
+
* A document/photo caption is parsed with the same entity parser as a message,
|
|
591
|
+
* so `caption: '📁 Log for session `abc`'` with an unbalanced backtick makes the
|
|
592
|
+
* whole upload fail with an opaque 400 — and, because captions were never routed
|
|
593
|
+
* through the send funnel, the failure was invisible. This validates the caption
|
|
594
|
+
* *before* the call and degrades to plain text instead.
|
|
595
|
+
*
|
|
596
|
+
* @param {object} options - Telegram options (may carry `caption`/`parse_mode`).
|
|
597
|
+
* @param {{scope?: string, verbose?: boolean}} [context]
|
|
598
|
+
* @returns {object} Options safe to hand to the Bot API.
|
|
599
|
+
*/
|
|
600
|
+
export function buildSafeCaptionOptions(options = {}, { scope = 'caption', verbose = false } = {}) {
|
|
601
|
+
const { telegramOptions } = splitOptions(options);
|
|
602
|
+
const caption = telegramOptions.caption;
|
|
603
|
+
if (typeof caption !== 'string' || caption.length === 0) return telegramOptions;
|
|
604
|
+
|
|
605
|
+
let text = caption;
|
|
606
|
+
let parseMode = telegramOptions.parse_mode;
|
|
607
|
+
|
|
608
|
+
if (parseMode) {
|
|
609
|
+
const validation = validateTelegramText(text, parseMode);
|
|
610
|
+
if (!validation.valid) {
|
|
611
|
+
console.error(`[telegram-send] ${scope}: pre-send validation rejected a ${parseMode} caption: ${validation.description}`);
|
|
612
|
+
if (verbose) console.error(`[telegram-send] ${scope}: byte offset ${validation.byteOffset} context: ${validation.context}`);
|
|
613
|
+
text = stripTelegramMarkdown(text);
|
|
614
|
+
parseMode = undefined;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (text.length > TELEGRAM_CAPTION_LIMIT) {
|
|
619
|
+
console.warn(`[telegram-send] ${scope}: caption is ${text.length} chars, truncating to ${TELEGRAM_CAPTION_LIMIT} (Telegram limit).`);
|
|
620
|
+
text = `${stripTelegramMarkdown(text).slice(0, TELEGRAM_CAPTION_LIMIT - 1)}…`;
|
|
621
|
+
parseMode = undefined;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return { ...telegramOptions, caption: text, parse_mode: parseMode };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function sendMediaWithCaptionFallback({ scope, target, options, verbose, send }) {
|
|
628
|
+
const safeOptions = buildSafeCaptionOptions(options, { scope, verbose });
|
|
629
|
+
const id = logSendAttempt({ scope, target, text: safeOptions.caption ?? '', options: safeOptions, verbose });
|
|
630
|
+
try {
|
|
631
|
+
const result = await send(safeOptions);
|
|
632
|
+
logSendSuccess({ id, scope, result });
|
|
633
|
+
return result;
|
|
634
|
+
} catch (error) {
|
|
635
|
+
logSendRejected({ id, scope, error });
|
|
636
|
+
if (!safeOptions.parse_mode || !isTelegramBadRequestError(error)) throw error;
|
|
637
|
+
const plainOptions = { ...safeOptions, parse_mode: undefined, caption: typeof safeOptions.caption === 'string' ? stripTelegramMarkdown(safeOptions.caption) : safeOptions.caption };
|
|
638
|
+
logFormattingFailure(scope, error, safeOptions.caption, verbose, plainOptions.caption);
|
|
639
|
+
const retryId = logSendAttempt({ scope: `${scope}:plainText`, target, text: plainOptions.caption ?? '', options: plainOptions, verbose });
|
|
640
|
+
try {
|
|
641
|
+
const result = await send(plainOptions);
|
|
642
|
+
logSendSuccess({ id: retryId, scope: `${scope}:plainText`, result });
|
|
643
|
+
return result;
|
|
644
|
+
} catch (plainError) {
|
|
645
|
+
logSendRejected({ id: retryId, scope: `${scope}:plainText`, error: plainError });
|
|
646
|
+
throw plainError;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* `ctx.replyWithDocument` with caption validation, logging and plain-text fallback.
|
|
653
|
+
*/
|
|
654
|
+
export async function safeReplyWithDocument(ctx, document, options = {}) {
|
|
655
|
+
const { verbose } = splitOptions(options);
|
|
656
|
+
return await sendMediaWithCaptionFallback({
|
|
657
|
+
scope: 'safeReplyWithDocument',
|
|
658
|
+
target: { chatId: ctx?.chat?.id },
|
|
659
|
+
options,
|
|
660
|
+
verbose,
|
|
661
|
+
send: sendOptions => ctx.replyWithDocument(document, sendOptions),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* `telegram.sendDocument` with caption validation, logging and plain-text fallback.
|
|
667
|
+
*/
|
|
668
|
+
export async function safeSendDocument(telegram, chatId, document, options = {}) {
|
|
669
|
+
const { verbose } = splitOptions(options);
|
|
670
|
+
return await sendMediaWithCaptionFallback({
|
|
671
|
+
scope: 'safeSendDocument',
|
|
672
|
+
target: { chatId },
|
|
673
|
+
options,
|
|
674
|
+
verbose,
|
|
675
|
+
send: sendOptions => telegram.sendDocument(chatId, document, sendOptions),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
412
679
|
function wrapTelegramSendMessage(telegram, defaults = {}) {
|
|
413
680
|
const original = telegram?.sendMessage;
|
|
414
681
|
if (typeof original !== 'function') return;
|
|
@@ -428,6 +695,7 @@ function wrapTelegramSendMessage(telegram, defaults = {}) {
|
|
|
428
695
|
fallbackLocale: fallbackLocale || defaults.fallbackLocale,
|
|
429
696
|
verbose: verbose || defaults.verbose,
|
|
430
697
|
scope: 'sendMessage',
|
|
698
|
+
target: { chatId: args[0], threadId: telegramOptions.message_thread_id },
|
|
431
699
|
sendChunk: (chunk, chunkOptions) => {
|
|
432
700
|
const chunkArgs = [...args];
|
|
433
701
|
chunkArgs[1] = chunk;
|
|
@@ -457,6 +725,7 @@ function wrapTelegramEditMessageText(telegram, defaults = {}) {
|
|
|
457
725
|
fallbackLocale: fallbackLocale || defaults.fallbackLocale,
|
|
458
726
|
verbose: verbose || defaults.verbose,
|
|
459
727
|
scope: 'editMessageText',
|
|
728
|
+
target: { chatId: args[0], messageId: args[1], inlineMessageId: args[2] },
|
|
460
729
|
editChunk: (chunk, chunkOptions) => {
|
|
461
730
|
const chunkArgs = [...args];
|
|
462
731
|
chunkArgs[3] = chunk;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { t } from './i18n.lib.mjs';
|
|
17
|
+
import { safeReply as defaultSafeReply } from './telegram-safe-reply.lib.mjs';
|
|
17
18
|
|
|
18
19
|
const GROUP_ONLY_MESSAGE = '❌ The /queue command only works in group chats. Please add this bot to a group and make it an admin.';
|
|
19
20
|
|
|
@@ -50,7 +51,7 @@ export function registerSolveQueueCommand(bot, options) {
|
|
|
50
51
|
data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
|
|
51
52
|
});
|
|
52
53
|
const locale = resolveLocale ? resolveLocale(ctx) : null;
|
|
53
|
-
const replyWithFallback = (text, replyOptions = {}) => (safeReply
|
|
54
|
+
const replyWithFallback = (text, replyOptions = {}) => (safeReply || defaultSafeReply)(ctx, text, replyOptions);
|
|
54
55
|
|
|
55
56
|
// Ignore messages sent before bot started
|
|
56
57
|
if (isOldMessage(ctx)) {
|
|
@@ -16,7 +16,9 @@ export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
|
|
|
16
16
|
import { QUEUE_CONFIG } from './queue-config.lib.mjs';
|
|
17
17
|
import { reserveStartSlotForQueue } from './queue-start-reservation.lib.mjs';
|
|
18
18
|
import { formatExecutingWorkSessionMessage, formatFailedLaunchMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
|
|
19
|
+
import { canonicalizeGitHubUrl as canonicalizeQueueUrl } from './github-url-parser.lib.mjs';
|
|
19
20
|
import { t } from './i18n.lib.mjs';
|
|
21
|
+
import { safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
20
22
|
import { lt } from './limits-i18n.lib.mjs';
|
|
21
23
|
export const QueueItemStatus = {
|
|
22
24
|
QUEUED: 'queued',
|
|
@@ -247,16 +249,23 @@ export class SolveQueue {
|
|
|
247
249
|
* @see https://github.com/link-assistant/hive-mind/issues/1080
|
|
248
250
|
*/
|
|
249
251
|
findByUrl(url) {
|
|
252
|
+
// Issue #2166: `/solve` and `/stop` must agree on what "the same task" means.
|
|
253
|
+
// Comparing raw strings made `…/pull/18` and `…/pull/18#issuecomment-123`
|
|
254
|
+
// two different tasks, so a chat owner could not stop a task they had just
|
|
255
|
+
// started from a copied comment link. Both sides collapse to the canonical
|
|
256
|
+
// URL (no query string, no fragment) before comparing.
|
|
257
|
+
const target = canonicalizeQueueUrl(url);
|
|
258
|
+
const matches = item => canonicalizeQueueUrl(item.url) === target;
|
|
250
259
|
// Check all tool queues
|
|
251
260
|
for (const toolQueue of Object.values(this.queues)) {
|
|
252
|
-
const queuedItem = toolQueue.find(
|
|
261
|
+
const queuedItem = toolQueue.find(matches);
|
|
253
262
|
if (queuedItem) {
|
|
254
263
|
return queuedItem;
|
|
255
264
|
}
|
|
256
265
|
}
|
|
257
266
|
// Check processing items
|
|
258
267
|
for (const item of this.processing.values()) {
|
|
259
|
-
if (item
|
|
268
|
+
if (matches(item)) {
|
|
260
269
|
return item;
|
|
261
270
|
}
|
|
262
271
|
}
|
|
@@ -948,7 +957,7 @@ export class SolveQueue {
|
|
|
948
957
|
if (!item.messageInfo || !item.ctx) return;
|
|
949
958
|
try {
|
|
950
959
|
const { chatId, messageId } = item.messageInfo;
|
|
951
|
-
await item.ctx.telegram
|
|
960
|
+
await safeEditMessageText(item.ctx.telegram, chatId, messageId, undefined, text, { verbose: this.verbose });
|
|
952
961
|
if (trackUpdateTime) {
|
|
953
962
|
item.lastMessageUpdateTime = Date.now();
|
|
954
963
|
}
|
|
@@ -1094,7 +1103,7 @@ export class SolveQueue {
|
|
|
1094
1103
|
if (chatId && messageId) {
|
|
1095
1104
|
try {
|
|
1096
1105
|
if (result.warning) {
|
|
1097
|
-
await item.ctx.telegram
|
|
1106
|
+
await safeEditMessageText(item.ctx.telegram, chatId, messageId, undefined, `⚠️ ${result.warning}\n\n${item.infoBlock}`, { verbose: this.verbose });
|
|
1098
1107
|
} else if (result.success) {
|
|
1099
1108
|
const response = formatExecutingWorkSessionMessage({
|
|
1100
1109
|
sessionName,
|
|
@@ -1102,7 +1111,7 @@ export class SolveQueue {
|
|
|
1102
1111
|
infoBlock: item.infoBlock,
|
|
1103
1112
|
locale: item.locale,
|
|
1104
1113
|
});
|
|
1105
|
-
await item.ctx.telegram
|
|
1114
|
+
await safeEditMessageText(item.ctx.telegram, chatId, messageId, undefined, response, { verbose: this.verbose });
|
|
1106
1115
|
} else {
|
|
1107
1116
|
// Issue #2154: a queued /solve that fails to launch reports the
|
|
1108
1117
|
// same way as a direct one — with its session UUID and a note
|
|
@@ -1115,7 +1124,7 @@ export class SolveQueue {
|
|
|
1115
1124
|
error: result.error || result.output,
|
|
1116
1125
|
locale: item.locale,
|
|
1117
1126
|
});
|
|
1118
|
-
await item.ctx.telegram
|
|
1127
|
+
await safeEditMessageText(item.ctx.telegram, chatId, messageId, undefined, response, { verbose: this.verbose });
|
|
1119
1128
|
}
|
|
1120
1129
|
} catch (error) {
|
|
1121
1130
|
// Log message edit failures for debugging
|
|
@@ -1137,7 +1146,7 @@ export class SolveQueue {
|
|
|
1137
1146
|
if (chatId && messageId && item.ctx) {
|
|
1138
1147
|
try {
|
|
1139
1148
|
const errorText = item.infoBlock ? `❌ Error: ${error.message}\n\n${item.infoBlock}` : `❌ Error: ${error.message}`;
|
|
1140
|
-
await item.ctx.telegram
|
|
1149
|
+
await safeEditMessageText(item.ctx.telegram, chatId, messageId, undefined, errorText, { verbose: this.verbose });
|
|
1141
1150
|
} catch (editError) {
|
|
1142
1151
|
// Log the edit failure for debugging
|
|
1143
1152
|
// See: https://github.com/link-assistant/hive-mind/issues/1062
|