@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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Faithful port of TDLib's legacy-Markdown parser used by the Telegram Bot API
3
+ * (`parse_mode: 'Markdown'`), so the bot can tell *before* sending whether
4
+ * Telegram will reject a message with
5
+ * `Bad Request: can't parse entities: Can't find end of the entity starting at byte offset N`.
6
+ *
7
+ * Reference implementation: td/telegram/MessageEntity.cpp — `parse_markdown()`
8
+ * https://github.com/tdlib/td/blob/master/td/telegram/MessageEntity.cpp
9
+ *
10
+ * The two properties that matter for this repository (issue #2166):
11
+ *
12
+ * 1. Only `\_`, `\*`, `` \` `` and `\[` are recognised as escape sequences, and
13
+ * **only at top level**. Inside an entity (for example the label of a
14
+ * `[label](url)` link) every byte is copied verbatim, so `\_` there is shown
15
+ * to the user as a literal backslash + underscore.
16
+ * 2. An unterminated `_`, `*`, `` ` `` or `[` makes the whole message fail with
17
+ * HTTP 400 and a *byte* offset pointing at the opening character.
18
+ *
19
+ * @module telegram-markdown-validator.lib
20
+ * @see https://github.com/link-assistant/hive-mind/issues/2166
21
+ */
22
+
23
+ const UNDERSCORE = 0x5f; // _
24
+ const ASTERISK = 0x2a; // *
25
+ const BACKTICK = 0x60; // `
26
+ const OPEN_BRACKET = 0x5b; // [
27
+ const CLOSE_BRACKET = 0x5d; // ]
28
+ const BACKSLASH = 0x5c; // \
29
+ const OPEN_PAREN = 0x28; // (
30
+ const CLOSE_PAREN = 0x29; // )
31
+ const LF = 0x0a;
32
+ const CR = 0x0d;
33
+
34
+ /** Parse modes this module knows how to validate. */
35
+ export const VALIDATABLE_PARSE_MODES = Object.freeze(['Markdown']);
36
+
37
+ function isUtf8CharacterFirstCodeUnit(byte) {
38
+ return (byte & 0xc0) !== 0x80;
39
+ }
40
+
41
+ function isSpaceByte(byte) {
42
+ return byte === 0x20 || byte === 0x09 || byte === LF || byte === 0x0b || byte === 0x0c || byte === CR;
43
+ }
44
+
45
+ /**
46
+ * Parse `text` exactly like TDLib's `parse_markdown()`.
47
+ *
48
+ * @param {string} text - Message text as it would be sent with `parse_mode: 'Markdown'`.
49
+ * @returns {{ok: true, text: string, entities: Array<{type: string, offset: number, length: number, url?: string}>}
50
+ * |{ok: false, byteOffset: number, description: string}}
51
+ * On success, the rendered plain text (escapes removed, markup consumed) plus
52
+ * the entities Telegram would create. On failure, the byte offset of the
53
+ * unterminated entity and the exact Bot API error description.
54
+ */
55
+ export function parseTelegramLegacyMarkdown(text) {
56
+ const source = Buffer.from(String(text ?? ''), 'utf-8');
57
+ const size = source.length;
58
+ // TDLib relies on std::string's NUL terminator when it peeks past the end.
59
+ const at = index => (index >= 0 && index < size ? source[index] : 0);
60
+
61
+ const out = Buffer.alloc(size);
62
+ let resultSize = 0;
63
+ let utf16Offset = 0;
64
+ const entities = [];
65
+
66
+ for (let i = 0; i < size; i++) {
67
+ const c = source[i];
68
+ if (c === BACKSLASH && (at(i + 1) === UNDERSCORE || at(i + 1) === ASTERISK || at(i + 1) === BACKTICK || at(i + 1) === OPEN_BRACKET)) {
69
+ i++;
70
+ out[resultSize++] = source[i];
71
+ utf16Offset++;
72
+ continue;
73
+ }
74
+ if (c !== UNDERSCORE && c !== ASTERISK && c !== BACKTICK && c !== OPEN_BRACKET) {
75
+ if (isUtf8CharacterFirstCodeUnit(c)) utf16Offset += 1 + (c >= 0xf0 ? 1 : 0);
76
+ out[resultSize++] = source[i];
77
+ continue;
78
+ }
79
+
80
+ // We are at the beginning of an entity.
81
+ const beginPos = i;
82
+ let endCharacter = c;
83
+ let isPre = false;
84
+ if (c === OPEN_BRACKET) endCharacter = CLOSE_BRACKET;
85
+
86
+ i++;
87
+
88
+ let language = '';
89
+ if (c === BACKTICK && at(i) === BACKTICK && at(i + 1) === BACKTICK) {
90
+ i += 2;
91
+ isPre = true;
92
+ let languageEnd = i;
93
+ while (!isSpaceByte(at(languageEnd)) && at(languageEnd) !== BACKTICK) languageEnd++;
94
+ if (i !== languageEnd && languageEnd < size && at(languageEnd) !== BACKTICK) {
95
+ language = source.slice(i, languageEnd).toString('utf-8');
96
+ i = languageEnd;
97
+ }
98
+ // Skip one newline at the beginning of the text.
99
+ if (at(i) === LF || at(i) === CR) {
100
+ if ((at(i + 1) === LF || at(i + 1) === CR) && at(i) !== at(i + 1)) i += 2;
101
+ else i++;
102
+ }
103
+ }
104
+
105
+ const entityOffset = utf16Offset;
106
+ while (i < size && (source[i] !== endCharacter || (isPre && !(at(i + 1) === BACKTICK && at(i + 2) === BACKTICK)))) {
107
+ const cur = source[i];
108
+ if (isUtf8CharacterFirstCodeUnit(cur)) utf16Offset += 1 + (cur >= 0xf0 ? 1 : 0);
109
+ out[resultSize++] = source[i++];
110
+ }
111
+ if (i === size) {
112
+ return {
113
+ ok: false,
114
+ byteOffset: beginPos,
115
+ description: `Bad Request: can't parse entities: Can't find end of the entity starting at byte offset ${beginPos}`,
116
+ };
117
+ }
118
+
119
+ if (entityOffset !== utf16Offset) {
120
+ const entityLength = utf16Offset - entityOffset;
121
+ if (c === UNDERSCORE) entities.push({ type: 'italic', offset: entityOffset, length: entityLength });
122
+ else if (c === ASTERISK) entities.push({ type: 'bold', offset: entityOffset, length: entityLength });
123
+ else if (c === OPEN_BRACKET) {
124
+ let url;
125
+ if (at(i + 1) !== OPEN_PAREN) {
126
+ url = source.slice(beginPos + 1, i).toString('utf-8');
127
+ } else {
128
+ i += 2;
129
+ const urlStart = i;
130
+ while (i < size && source[i] !== CLOSE_PAREN) i++;
131
+ url = source.slice(urlStart, i).toString('utf-8');
132
+ }
133
+ if (url) entities.push({ type: 'text_link', offset: entityOffset, length: entityLength, url });
134
+ } else if (c === BACKTICK) {
135
+ if (isPre) entities.push({ type: 'pre', offset: entityOffset, length: entityLength, ...(language ? { language } : {}) });
136
+ else entities.push({ type: 'code', offset: entityOffset, length: entityLength });
137
+ }
138
+ }
139
+ if (isPre) i += 2;
140
+ }
141
+
142
+ return { ok: true, text: out.slice(0, resultSize).toString('utf-8'), entities };
143
+ }
144
+
145
+ /**
146
+ * Render a small window of `text` around a byte offset, for logs.
147
+ *
148
+ * @param {string} text
149
+ * @param {number} byteOffset
150
+ * @param {number} [radius=32]
151
+ * @returns {string}
152
+ */
153
+ export function describeByteOffsetContext(text, byteOffset, radius = 32) {
154
+ const buffer = Buffer.from(String(text ?? ''), 'utf-8');
155
+ const start = Math.max(0, byteOffset - radius);
156
+ const end = Math.min(buffer.length, byteOffset + radius);
157
+ return JSON.stringify(buffer.slice(start, end).toString('utf-8'));
158
+ }
159
+
160
+ /**
161
+ * Check whether Telegram would accept `text` under `parseMode`.
162
+ *
163
+ * Parse modes other than legacy `Markdown` (and plain text) are reported as
164
+ * valid — this module deliberately only models the parser the bot uses.
165
+ *
166
+ * @param {string} text
167
+ * @param {string|undefined|null} parseMode
168
+ * @returns {{valid: boolean, description?: string, byteOffset?: number, context?: string}}
169
+ */
170
+ export function validateTelegramText(text, parseMode) {
171
+ if (!parseMode || String(parseMode) !== 'Markdown') return { valid: true };
172
+ if (typeof text !== 'string' || text.length === 0) return { valid: true };
173
+ const parsed = parseTelegramLegacyMarkdown(text);
174
+ if (parsed.ok) return { valid: true };
175
+ return {
176
+ valid: false,
177
+ description: parsed.description,
178
+ byteOffset: parsed.byteOffset,
179
+ context: describeByteOffsetContext(text, parsed.byteOffset),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Convenience predicate used by tests and by pre-send checks.
185
+ *
186
+ * @param {string} text
187
+ * @param {string} [parseMode='Markdown']
188
+ * @returns {boolean}
189
+ */
190
+ export function isValidTelegramMarkdown(text, parseMode = 'Markdown') {
191
+ return validateTelegramText(text, parseMode).valid;
192
+ }
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
22
+ import { safeReply, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
22
23
  import { extractMergeTargetUrlFromText, parseMergeTargetUrl } from './github-merge-targets.lib.mjs';
23
24
  import { createMergeQueueProcessor, MergeStatus, MERGE_QUEUE_CONFIG } from './telegram-merge-queue.lib.mjs';
24
25
  import { executeStartScreen } from './telegram-command-execution.lib.mjs';
@@ -331,12 +332,12 @@ export function registerMergeCommand(bot, options) {
331
332
  const targetResult = resolveMergeCommandTarget(positionals, ctx.message);
332
333
 
333
334
  if (!targetResult.target && !targetResult.error) {
334
- return await ctx.reply(getMergeUsageMessage(), { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
335
+ return await safeReply(ctx, getMergeUsageMessage(), { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
335
336
  }
336
337
 
337
338
  if (!targetResult.target || !targetResult.target.valid) {
338
339
  const invalidPrefix = targetResult.fromReply ? 'Invalid merge target in replied message' : 'Invalid merge target';
339
- return await ctx.reply(`${invalidPrefix}: ${escapeMarkdownV2(targetResult.error || targetResult.target?.error || 'Unknown target error')}\n\nPlease provide a valid GitHub repository, issue, or pull request URL\\.`, {
340
+ return await safeReply(ctx, `${invalidPrefix}: ${escapeMarkdownV2(targetResult.error || targetResult.target?.error || 'Unknown target error')}\n\nPlease provide a valid GitHub repository, issue, or pull request URL\\.`, {
340
341
  parse_mode: 'MarkdownV2',
341
342
  reply_to_message_id: ctx.message.message_id,
342
343
  });
@@ -352,7 +353,7 @@ export function registerMergeCommand(bot, options) {
352
353
  if (activeMergeOperations.has(repoKey)) {
353
354
  const existingOp = activeMergeOperations.get(repoKey);
354
355
  if (existingOp.processor.status === MergeStatus.RUNNING) {
355
- return await ctx.reply(`A merge operation is already running for ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\n\nPlease wait for it to complete or cancel it\\.`, {
356
+ return await safeReply(ctx, `A merge operation is already running for ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\n\nPlease wait for it to complete or cancel it\\.`, {
356
357
  parse_mode: 'MarkdownV2',
357
358
  reply_to_message_id: ctx.message.message_id,
358
359
  });
@@ -360,7 +361,7 @@ export function registerMergeCommand(bot, options) {
360
361
  }
361
362
 
362
363
  // Send initial status message (reply to the /merge command)
363
- const statusMessage = await ctx.reply(`Initializing merge queue for ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\\.\\.\n\nThis may take a moment\\.`, {
364
+ const statusMessage = await safeReply(ctx, `Initializing merge queue for ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\\.\\.\n\nThis may take a moment\\.`, {
364
365
  parse_mode: 'MarkdownV2',
365
366
  reply_to_message_id: ctx.message.message_id,
366
367
  });
@@ -369,14 +370,14 @@ export function registerMergeCommand(bot, options) {
369
370
  // Check permissions
370
371
  const permCheck = await checkLabelPermissions(owner, repo, VERBOSE);
371
372
  if (!permCheck.canManageLabels) {
372
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `No permission to manage repository ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\n\nPlease ensure you have write access to this repository\\.`, { parse_mode: 'MarkdownV2' });
373
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `No permission to manage repository ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\n\nPlease ensure you have write access to this repository\\.`, { parse_mode: 'MarkdownV2' });
373
374
  return;
374
375
  }
375
376
 
376
377
  // Ensure ready label exists
377
378
  const labelResult = await ensureReadyLabel(owner, repo, VERBOSE);
378
379
  if (!labelResult.success) {
379
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `Failed to setup 'ready' label: ${escapeMarkdownV2(labelResult.error)}`, {
380
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `Failed to setup 'ready' label: ${escapeMarkdownV2(labelResult.error)}`, {
380
381
  parse_mode: 'MarkdownV2',
381
382
  });
382
383
  return;
@@ -401,7 +402,7 @@ export function registerMergeCommand(bot, options) {
401
402
  // Without this check, progress updates from CI wait loops would re-add
402
403
  // the cancel button after the cancel handler had already removed it.
403
404
  const replyMarkup = processor.isCancelled ? undefined : { inline_keyboard: [[{ text: 'šŸ›‘ Cancel', callback_data: `merge_cancel_${repoKey}` }]] };
404
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, message, {
405
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, message, {
405
406
  parse_mode: 'MarkdownV2',
406
407
  ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
407
408
  });
@@ -416,7 +417,7 @@ export function registerMergeCommand(bot, options) {
416
417
  try {
417
418
  const message = processor.formatFinalMessage();
418
419
  // Remove cancel button on completion
419
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, message, {
420
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, message, {
420
421
  parse_mode: 'MarkdownV2',
421
422
  });
422
423
  VERBOSE && console.log(`[VERBOSE] /merge: Merge queue completed successfully for ${repoKey}`);
@@ -435,7 +436,7 @@ export function registerMergeCommand(bot, options) {
435
436
  const finalReport = processor.formatFinalMessage();
436
437
  // Issue #1269: Show error in the reply message with immediate feedback
437
438
  // Keep a button so users know the error was displayed and can dismiss
438
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `āŒ *Merge queue failed*\n\nāš ļø *Error:* ${escapeMarkdownV2(userMessage)}\n\n${finalReport}`, {
439
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `āŒ *Merge queue failed*\n\nāš ļø *Error:* ${escapeMarkdownV2(userMessage)}\n\n${finalReport}`, {
439
440
  parse_mode: 'MarkdownV2',
440
441
  reply_markup: {
441
442
  inline_keyboard: [[{ text: 'āŒ Failed - Click to dismiss', callback_data: `merge_dismiss_${repoKey}` }]],
@@ -454,7 +455,7 @@ export function registerMergeCommand(bot, options) {
454
455
 
455
456
  if (!initResult.success) {
456
457
  const userMessage = formatUserError(new Error(initResult.error), VERBOSE);
457
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `Failed to initialize merge queue: ${escapeMarkdownV2(userMessage)}`, {
458
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `Failed to initialize merge queue: ${escapeMarkdownV2(userMessage)}`, {
458
459
  parse_mode: 'MarkdownV2',
459
460
  });
460
461
  return;
@@ -462,14 +463,14 @@ export function registerMergeCommand(bot, options) {
462
463
 
463
464
  if (initResult.message) {
464
465
  // No PRs to merge
465
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${escapeMarkdownV2(initResult.message)}\n\nTo use the merge queue:\n1\\. Add the \`ready\` label to repository PRs, or ensure the issue has a linked open PR\n2\\. Run \`/merge ${escapeMarkdownV2(targetUrl)}\` again`, { parse_mode: 'MarkdownV2' });
466
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${escapeMarkdownV2(initResult.message)}\n\nTo use the merge queue:\n1\\. Add the \`ready\` label to repository PRs, or ensure the issue has a linked open PR\n2\\. Run \`/merge ${escapeMarkdownV2(targetUrl)}\` again`, { parse_mode: 'MarkdownV2' });
466
467
  return;
467
468
  }
468
469
 
469
470
  // Update message with PR list and cancel button, start processing
470
471
  const truncatedMsg = initResult.truncated ? `\n\n_Note: Only processing first ${MERGE_QUEUE_CONFIG.MAX_PRS_PER_SESSION} PRs_` : '';
471
472
 
472
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${getTargetFoundText(target, initResult.count)}${escapeMarkdownV2(truncatedMsg)}\n\nStarting merge process\\.\\.\\.`, {
473
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${getTargetFoundText(target, initResult.count)}${escapeMarkdownV2(truncatedMsg)}\n\nStarting merge process\\.\\.\\.`, {
473
474
  parse_mode: 'MarkdownV2',
474
475
  reply_markup: {
475
476
  inline_keyboard: [[{ text: 'šŸ›‘ Cancel', callback_data: `merge_cancel_${repoKey}` }]],
@@ -501,7 +502,7 @@ export function registerMergeCommand(bot, options) {
501
502
  // Show error in the reply message with immediate feedback
502
503
  try {
503
504
  const userMessage = formatUserError(error, VERBOSE);
504
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `āŒ *Merge queue failed unexpectedly*\n\nāš ļø *Error:* ${escapeMarkdownV2(userMessage)}\n\n_The queue processing has stopped\\. Please try again or check server logs\\._`, {
505
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `āŒ *Merge queue failed unexpectedly*\n\nāš ļø *Error:* ${escapeMarkdownV2(userMessage)}\n\n_The queue processing has stopped\\. Please try again or check server logs\\._`, {
505
506
  parse_mode: 'MarkdownV2',
506
507
  reply_markup: {
507
508
  inline_keyboard: [[{ text: 'āŒ Failed - Click to dismiss', callback_data: `merge_dismiss_${repoKey}` }]],
@@ -519,7 +520,7 @@ export function registerMergeCommand(bot, options) {
519
520
 
520
521
  try {
521
522
  const userMessage = formatUserError(error, VERBOSE);
522
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `Error processing merge queue: ${escapeMarkdownV2(userMessage)}\n\nPlease check the repository URL and try again\\.`, { parse_mode: 'MarkdownV2' });
523
+ await safeEditMessageText(ctx.telegram, statusMessage.chat.id, statusMessage.message_id, undefined, `Error processing merge queue: ${escapeMarkdownV2(userMessage)}\n\nPlease check the repository URL and try again\\.`, { parse_mode: 'MarkdownV2' });
523
524
  } catch (editError) {
524
525
  VERBOSE && console.error('[VERBOSE] /merge: Failed to edit error message:', editError);
525
526
  }
@@ -547,9 +548,10 @@ export function registerMergeCommand(bot, options) {
547
548
  // the current PR finishes processing (which can take hours if waiting for CI).
548
549
  try {
549
550
  const cancellingMessage = operation.processor.formatProgressMessage();
550
- await ctx.editMessageText(cancellingMessage, {
551
+ // No reply_markup = cancel button is removed immediately
552
+ await safeEditMessageText(ctx.telegram, ctx.chat?.id, ctx.callbackQuery?.message?.message_id, undefined, cancellingMessage, {
551
553
  parse_mode: 'MarkdownV2',
552
- // No reply_markup = cancel button is removed immediately
554
+ verbose: VERBOSE,
553
555
  });
554
556
  } catch (err) {
555
557
  // If the full message edit fails, fall back to just removing the button
@@ -243,7 +243,7 @@ export function extractGitHubUrl(text, { parseGitHubUrl, cleanNonPrintableChars
243
243
 
244
244
  // Accept issue or PR URLs
245
245
  if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
246
- foundUrls.push(parsed.normalized);
246
+ foundUrls.push(parsed.canonical || parsed.normalized);
247
247
  }
248
248
  }
249
249