@link-assistant/hive-mind 2.13.0 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/src/buildUserMention.lib.mjs +30 -3
  4. package/src/claude.budget-stats.lib.mjs +4 -2
  5. package/src/claude.lib.mjs +31 -16
  6. package/src/claude.stream-events.lib.mjs +56 -2
  7. package/src/disk-guard.lib.mjs +256 -0
  8. package/src/github-url-parser.lib.mjs +26 -1
  9. package/src/github.batch.lib.mjs +31 -11
  10. package/src/github.lib.mjs +3 -1
  11. package/src/hive.mjs +97 -11
  12. package/src/lib.mjs +23 -3
  13. package/src/list-solution-drafts.lib.mjs +17 -4
  14. package/src/locales/en.lino +1 -0
  15. package/src/locales/hi.lino +1 -0
  16. package/src/locales/ru.lino +1 -0
  17. package/src/locales/zh.lino +1 -0
  18. package/src/session-log-rename.lib.mjs +65 -0
  19. package/src/session-monitor.lib.mjs +3 -2
  20. package/src/solve.mjs +12 -0
  21. package/src/solve.repository.lib.mjs +5 -1
  22. package/src/solve.restart-shared.lib.mjs +18 -9
  23. package/src/solve.results.lib.mjs +6 -3
  24. package/src/solve.validation.lib.mjs +7 -9
  25. package/src/telegram-accept-invitations.lib.mjs +5 -3
  26. package/src/telegram-bot.mjs +18 -9
  27. package/src/telegram-command-execution.lib.mjs +2 -1
  28. package/src/telegram-context-safety.lib.mjs +70 -0
  29. package/src/telegram-fix-command.lib.mjs +68 -4
  30. package/src/telegram-language-command.lib.mjs +4 -3
  31. package/src/telegram-log-command.lib.mjs +14 -12
  32. package/src/telegram-markdown-validator.lib.mjs +192 -0
  33. package/src/telegram-merge-command.lib.mjs +18 -16
  34. package/src/telegram-message-filters.lib.mjs +1 -1
  35. package/src/telegram-safe-reply.lib.mjs +290 -21
  36. package/src/telegram-solve-queue-command.lib.mjs +2 -1
  37. package/src/telegram-solve-queue.lib.mjs +16 -7
  38. package/src/telegram-start-stop-command.lib.mjs +38 -27
  39. package/src/telegram-subscribers.lib.mjs +6 -4
  40. package/src/telegram-terminal-watch-command.lib.mjs +8 -7
  41. package/src/telegram-tokens-command.lib.mjs +2 -1
  42. 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 result = await sendChunk(chunk, plainOptions);
267
- if (firstResult === undefined) firstResult = result;
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
- async function sendTelegramTextChunks({ text, telegramOptions, fallbackLocale, verbose, scope, sendChunk }) {
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
- try {
346
- const result = await editChunk(firstChunk, telegramOptions);
347
- await sendRemainingEditChunks({
348
- chunks: remainingChunks,
349
- telegramOptions,
350
- fallbackLocale,
351
- verbose,
352
- scope,
353
- sendFollowUpChunk,
354
- });
355
- return result;
356
- } catch (error) {
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 (isTelegramFormattingError(error)) {
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 result = await editChunk(firstFallbackChunk, plainOptions);
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 ? safeReply(ctx, text, replyOptions) : ctx.reply(text, { parse_mode: 'Markdown', ...replyOptions }));
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(item => item.url === url);
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.url === url) {
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.editMessageText(chatId, messageId, undefined, text, { parse_mode: 'Markdown' });
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.editMessageText(chatId, messageId, undefined, `⚠️ ${result.warning}\n\n${item.infoBlock}`, { parse_mode: 'Markdown' });
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.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
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.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
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.editMessageText(chatId, messageId, undefined, errorText, { parse_mode: 'Markdown' });
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