@link-assistant/hive-mind 2.13.1 ā 2.13.3
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 +12 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +19 -5
- package/src/buildUserMention.lib.mjs +30 -3
- package/src/claude.connection.lib.mjs +35 -43
- package/src/claude.lib.mjs +44 -13
- package/src/codex.lib.mjs +33 -10
- package/src/config.lib.mjs +23 -5
- package/src/gemini.lib.mjs +19 -5
- package/src/github-url-parser.lib.mjs +26 -1
- package/src/opencode.lib.mjs +19 -5
- package/src/qwen.lib.mjs +19 -5
- 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
- package/src/tool-retry.lib.mjs +117 -6
package/src/gemini.lib.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { defaultModels, geminiModels, isFormalAiModel } from './models/index.mjs
|
|
|
22
22
|
import { isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
|
|
23
23
|
import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
24
24
|
import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
|
|
25
|
-
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
25
|
+
import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
26
26
|
import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
|
|
27
27
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
28
28
|
import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
|
|
@@ -391,12 +391,15 @@ export const executeGeminiCommand = async params => {
|
|
|
391
391
|
const { tempDir, workspaceTmpDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, geminiPath, $, waitForRetryDelay = waitWithCountdown } = params;
|
|
392
392
|
|
|
393
393
|
let retryCount = 0;
|
|
394
|
+
// Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
|
|
395
|
+
// HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
|
|
396
|
+
const transientRetryBudget = createTransientRetryBudget();
|
|
394
397
|
|
|
395
398
|
const executeWithRetry = async () => {
|
|
396
399
|
if (retryCount === 0) {
|
|
397
400
|
await log(`\n${formatAligned('š¤', 'Executing Gemini:', argv.model.toUpperCase())}`);
|
|
398
401
|
} else {
|
|
399
|
-
await log(`\n${formatAligned('š', 'Retry attempt:', `${retryCount}
|
|
402
|
+
await log(`\n${formatAligned('š', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
|
|
400
403
|
}
|
|
401
404
|
|
|
402
405
|
if (argv.verbose) {
|
|
@@ -515,7 +518,17 @@ export const executeGeminiCommand = async params => {
|
|
|
515
518
|
if (retryableError.isRetryable) {
|
|
516
519
|
const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
|
|
517
520
|
const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
518
|
-
|
|
521
|
+
// Issue #2169: the attempt count is only a runaway backstop ā the 12-hour wall-clock budget
|
|
522
|
+
// (configurable) decides when to stop, and every wait honours the 3-minute minimum.
|
|
523
|
+
const retryDecision = transientRetryBudget.evaluate({
|
|
524
|
+
retryCount,
|
|
525
|
+
maxRetries,
|
|
526
|
+
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
527
|
+
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
528
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
529
|
+
});
|
|
530
|
+
if (retryDecision.allowed) {
|
|
531
|
+
transientRetryBudget.grant();
|
|
519
532
|
// Issue #2037: retry the same model on capacity errors before falling back;
|
|
520
533
|
// after a capacity-driven model switch, retry quickly instead of waiting the
|
|
521
534
|
// full transient backoff ā the new model may be available now.
|
|
@@ -527,16 +540,17 @@ export const executeGeminiCommand = async params => {
|
|
|
527
540
|
retryCount,
|
|
528
541
|
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
529
542
|
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
543
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
530
544
|
});
|
|
531
545
|
const delay = retryPlan.delay;
|
|
532
546
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
533
|
-
await log(`\nā ļø ${retryableError.label} detected. Retry ${retryCount + 1}
|
|
547
|
+
await log(`\nā ļø ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
534
548
|
await waitForRetryDelay(delay, log);
|
|
535
549
|
await log('\nš Retrying now...');
|
|
536
550
|
retryCount++;
|
|
537
551
|
return await executeWithRetry();
|
|
538
552
|
}
|
|
539
|
-
await log(`\n\nā ${retryableError.label} persisted
|
|
553
|
+
await log(`\n\nā ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
|
|
540
554
|
}
|
|
541
555
|
|
|
542
556
|
const limitInfo = detectUsageLimit(errorText);
|
|
@@ -5,7 +5,8 @@ import { reportError } from './sentry.lib.mjs';
|
|
|
5
5
|
* @param {string} url - The GitHub URL to parse
|
|
6
6
|
* @returns {Object} Parsed URL information including:
|
|
7
7
|
* - valid: boolean indicating if the URL is valid
|
|
8
|
-
* - normalized: the normalized URL (https://github.com/...)
|
|
8
|
+
* - normalized: the normalized URL (https://github.com/...), query/fragment kept
|
|
9
|
+
* - canonical: the URL the bot actually interprets (no query string, no #fragment)
|
|
9
10
|
* - type: 'user', 'repo', 'issue', 'pull', 'gist', 'actions', etc.
|
|
10
11
|
* - owner: repository owner/organization
|
|
11
12
|
* - repo: repository name (if applicable)
|
|
@@ -96,6 +97,13 @@ export function parseGitHubUrl(url) {
|
|
|
96
97
|
const result = {
|
|
97
98
|
valid: true,
|
|
98
99
|
normalized: normalizedUrl,
|
|
100
|
+
// Issue #2166: `normalized` keeps whatever query string or fragment the user
|
|
101
|
+
// pasted (`ā¦/pull/18#issuecomment-5370631063`), but nothing downstream reads
|
|
102
|
+
// it ā the bot resolves the target from owner/repo/number alone. Echoing the
|
|
103
|
+
// fragment back therefore claims an interpretation that never happened, so
|
|
104
|
+
// `canonical` is the URL the bot actually acted on and is what gets shown,
|
|
105
|
+
// queued and matched against.
|
|
106
|
+
canonical: `https://github.com${urlObj.pathname.replace(/\/+$/, '')}`,
|
|
99
107
|
hostname: 'github.com',
|
|
100
108
|
protocol: 'https',
|
|
101
109
|
path: urlObj.pathname,
|
|
@@ -242,6 +250,23 @@ export function normalizeGitHubUrl(url) {
|
|
|
242
250
|
return parsed.valid ? parsed.normalized : null;
|
|
243
251
|
}
|
|
244
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Reduce a GitHub URL to the part the tooling actually interprets: no query
|
|
255
|
+
* string, no `#issuecomment-ā¦` fragment, no trailing slash.
|
|
256
|
+
*
|
|
257
|
+
* Used wherever a URL is echoed back to a user, stored as a queue key, or
|
|
258
|
+
* compared against another URL, so that a link copied from a comment and the
|
|
259
|
+
* same link copied from the address bar name one and the same task (issue #2166).
|
|
260
|
+
*
|
|
261
|
+
* @param {string} url
|
|
262
|
+
* @returns {string} The canonical URL, or the trimmed input when it cannot be parsed.
|
|
263
|
+
*/
|
|
264
|
+
export function canonicalizeGitHubUrl(url) {
|
|
265
|
+
if (!url || typeof url !== 'string') return url;
|
|
266
|
+
const parsed = parseGitHubUrl(url);
|
|
267
|
+
return parsed.valid && parsed.canonical ? parsed.canonical : url.trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
245
270
|
/** Build the canonical web URL for a pull request already identified by GitHub. */
|
|
246
271
|
export function buildGitHubPullRequestUrl({ owner, repo, number } = {}) {
|
|
247
272
|
if (!owner || !repo || !Number.isInteger(Number(number)) || Number(number) <= 0) {
|
package/src/opencode.lib.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { checkPlaywrightMcpPackageAvailability, getOpenCodePlaywrightMcpDisableE
|
|
|
25
25
|
import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage as parseOpenCodeTokenUsage } from './agent-token-usage.lib.mjs';
|
|
26
26
|
import { createJsonStreamScanner } from './json-stream.lib.mjs';
|
|
27
27
|
import { calculateAgentPricing } from './agent.lib.mjs';
|
|
28
|
-
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
28
|
+
import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
29
29
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
30
30
|
|
|
31
31
|
export { parseOpenCodeTokenUsage };
|
|
@@ -193,13 +193,16 @@ export const executeOpenCodeCommand = async params => {
|
|
|
193
193
|
|
|
194
194
|
// Retry configuration
|
|
195
195
|
let retryCount = 0;
|
|
196
|
+
// Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
|
|
197
|
+
// HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
|
|
198
|
+
const transientRetryBudget = createTransientRetryBudget();
|
|
196
199
|
|
|
197
200
|
const executeWithRetry = async () => {
|
|
198
201
|
// Execute opencode command from the cloned repository directory
|
|
199
202
|
if (retryCount === 0) {
|
|
200
203
|
await log(`\n${formatAligned('š¤', 'Executing OpenCode:', argv.model.toUpperCase())}`);
|
|
201
204
|
} else {
|
|
202
|
-
await log(`\n${formatAligned('š', 'Retry attempt:', `${retryCount}
|
|
205
|
+
await log(`\n${formatAligned('š', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
|
|
203
206
|
}
|
|
204
207
|
|
|
205
208
|
if (argv.verbose) {
|
|
@@ -483,7 +486,17 @@ export const executeOpenCodeCommand = async params => {
|
|
|
483
486
|
if (retryableError.isRetryable) {
|
|
484
487
|
const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
|
|
485
488
|
const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
486
|
-
|
|
489
|
+
// Issue #2169: the attempt count is only a runaway backstop ā the 12-hour wall-clock budget
|
|
490
|
+
// (configurable) decides when to stop, and every wait honours the 3-minute minimum.
|
|
491
|
+
const retryDecision = transientRetryBudget.evaluate({
|
|
492
|
+
retryCount,
|
|
493
|
+
maxRetries,
|
|
494
|
+
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
495
|
+
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
496
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
497
|
+
});
|
|
498
|
+
if (retryDecision.allowed) {
|
|
499
|
+
transientRetryBudget.grant();
|
|
487
500
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
488
501
|
// Issue #2037: retry the same model on capacity errors before falling back;
|
|
489
502
|
// after a capacity-driven model switch, retry quickly instead of waiting the
|
|
@@ -496,16 +509,17 @@ export const executeOpenCodeCommand = async params => {
|
|
|
496
509
|
retryCount,
|
|
497
510
|
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
498
511
|
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
512
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
499
513
|
});
|
|
500
514
|
const delay = retryPlan.delay;
|
|
501
515
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
502
|
-
await log(`\nā ļø ${retryableError.label} detected. Retry ${retryCount + 1}
|
|
516
|
+
await log(`\nā ļø ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
503
517
|
await waitForRetryDelay(delay, log);
|
|
504
518
|
await log('\nš Retrying now...');
|
|
505
519
|
retryCount++;
|
|
506
520
|
return await executeWithRetry();
|
|
507
521
|
}
|
|
508
|
-
await log(`\n\nā ${retryableError.label} persisted
|
|
522
|
+
await log(`\n\nā ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
|
|
509
523
|
}
|
|
510
524
|
|
|
511
525
|
// Check for usage limit errors first (more specific)
|
package/src/qwen.lib.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { qwenModels, defaultModels, isFormalAiModel } from './models/index.mjs';
|
|
|
22
22
|
import { buildFormalAiEnvExports, isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
|
|
23
23
|
import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
24
24
|
import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
|
|
25
|
-
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
25
|
+
import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
26
26
|
import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
|
|
27
27
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
28
28
|
import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
|
|
@@ -489,6 +489,9 @@ export const executeQwenCommand = async params => {
|
|
|
489
489
|
const { tempDir, branchName, prompt, systemPrompt, argv, log, formatAligned = (_icon, label, value = '') => `${label} ${value}`.trim(), getResourceSnapshot = async () => ({ memory: '\nunknown', load: 'unknown' }), forkedRepo, feedbackLines, qwenPath = 'qwen', $: dollar = $, waitForRetryDelay = waitWithCountdown } = params;
|
|
490
490
|
|
|
491
491
|
let retryCount = 0;
|
|
492
|
+
// Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
|
|
493
|
+
// HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
|
|
494
|
+
const transientRetryBudget = createTransientRetryBudget();
|
|
492
495
|
const promptFile = path.join(os.tmpdir(), `qwen_prompt_${Date.now()}_${process.pid}.txt`);
|
|
493
496
|
const systemPromptFile = path.join(os.tmpdir(), `qwen_system_prompt_${Date.now()}_${process.pid}.txt`);
|
|
494
497
|
|
|
@@ -499,7 +502,7 @@ export const executeQwenCommand = async params => {
|
|
|
499
502
|
if (retryCount === 0) {
|
|
500
503
|
await log(`\n${formatAligned('š¤', 'Executing Qwen Code:', argv.model.toUpperCase())}`);
|
|
501
504
|
} else {
|
|
502
|
-
await log(`\n${formatAligned('š', 'Retry attempt:', `${retryCount}
|
|
505
|
+
await log(`\n${formatAligned('š', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
|
|
503
506
|
}
|
|
504
507
|
|
|
505
508
|
if (argv.verbose) {
|
|
@@ -625,7 +628,17 @@ export const executeQwenCommand = async params => {
|
|
|
625
628
|
if (retryableError.isRetryable) {
|
|
626
629
|
const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
|
|
627
630
|
const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
628
|
-
|
|
631
|
+
// Issue #2169: the attempt count is only a runaway backstop ā the 12-hour wall-clock budget
|
|
632
|
+
// (configurable) decides when to stop, and every wait honours the 3-minute minimum.
|
|
633
|
+
const retryDecision = transientRetryBudget.evaluate({
|
|
634
|
+
retryCount,
|
|
635
|
+
maxRetries,
|
|
636
|
+
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
637
|
+
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
638
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
639
|
+
});
|
|
640
|
+
if (retryDecision.allowed) {
|
|
641
|
+
transientRetryBudget.grant();
|
|
629
642
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
630
643
|
// Issue #2037: retry the same model on capacity errors before falling back;
|
|
631
644
|
// after a capacity-driven model switch, retry quickly instead of waiting the
|
|
@@ -638,16 +651,17 @@ export const executeQwenCommand = async params => {
|
|
|
638
651
|
retryCount,
|
|
639
652
|
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
640
653
|
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
654
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
641
655
|
});
|
|
642
656
|
const delay = retryPlan.delay;
|
|
643
657
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
644
|
-
await log(`\nā ļø ${retryableError.label} detected. Retry ${retryCount + 1}
|
|
658
|
+
await log(`\nā ļø ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
645
659
|
await waitForRetryDelay(delay, log);
|
|
646
660
|
await log('\nš Retrying now...');
|
|
647
661
|
retryCount++;
|
|
648
662
|
return await executeWithRetry();
|
|
649
663
|
}
|
|
650
|
-
await log(`\n\nā ${retryableError.label} persisted
|
|
664
|
+
await log(`\n\nā ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
|
|
651
665
|
} else if (exitCode === 130) {
|
|
652
666
|
await log('\n\nā ļø Qwen Code command interrupted (CTRL+C)');
|
|
653
667
|
} else {
|
|
@@ -23,6 +23,7 @@ import fs from 'fs/promises';
|
|
|
23
23
|
import { promisify } from 'util';
|
|
24
24
|
import { formatSessionCompletionMessage, getSessionCompletionExitCode, classifySessionOutcome } from './work-session-formatting.lib.mjs';
|
|
25
25
|
import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
|
|
26
|
+
import { safeSendMessage, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
26
27
|
import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
|
|
27
28
|
import { readLastSessionIdFromLog, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
|
|
28
29
|
import { resolveFailedSessionPullRequestState } from './github-pr-state.lib.mjs';
|
|
@@ -942,11 +943,11 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
942
943
|
let notifyFromChatId = null;
|
|
943
944
|
let notifyMessageId = null;
|
|
944
945
|
if (sessionInfo.messageId) {
|
|
945
|
-
await bot.telegram
|
|
946
|
+
await safeEditMessageText(bot.telegram, sessionInfo.chatId, sessionInfo.messageId, undefined, message, { verbose });
|
|
946
947
|
notifyFromChatId = sessionInfo.chatId;
|
|
947
948
|
notifyMessageId = sessionInfo.messageId;
|
|
948
949
|
} else {
|
|
949
|
-
const sent = await bot.telegram
|
|
950
|
+
const sent = await safeSendMessage(bot.telegram, sessionInfo.chatId, message, { verbose });
|
|
950
951
|
notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
|
|
951
952
|
notifyMessageId = sent?.message_id || null;
|
|
952
953
|
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { promisify } from 'util';
|
|
20
20
|
import { exec as execCallback } from 'child_process';
|
|
21
21
|
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
22
|
+
import { safeReply, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
22
23
|
|
|
23
24
|
const execRaw = promisify(execCallback);
|
|
24
25
|
// Issue #1726: rate-limit safe gh wrapper.
|
|
@@ -148,9 +149,10 @@ export function registerAcceptInvitesCommand(bot, options) {
|
|
|
148
149
|
return await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
const fetchingMessage = await ctx
|
|
152
|
+
const fetchingMessage = await safeReply(ctx, 'š Fetching pending GitHub invitations\\.\\.\\.', {
|
|
152
153
|
reply_to_message_id: ctx.message.message_id,
|
|
153
154
|
parse_mode: 'MarkdownV2',
|
|
155
|
+
verbose: VERBOSE,
|
|
154
156
|
});
|
|
155
157
|
|
|
156
158
|
// State for tracking progress
|
|
@@ -169,7 +171,7 @@ export function registerAcceptInvitesCommand(bot, options) {
|
|
|
169
171
|
const updateMessage = async () => {
|
|
170
172
|
try {
|
|
171
173
|
const message = buildProgressMessage(state);
|
|
172
|
-
await ctx.telegram
|
|
174
|
+
await safeEditMessageText(ctx.telegram, fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'MarkdownV2', verbose: VERBOSE });
|
|
173
175
|
} catch (err) {
|
|
174
176
|
// Ignore "message not modified" errors
|
|
175
177
|
if (!err.message?.includes('message is not modified')) {
|
|
@@ -238,7 +240,7 @@ export function registerAcceptInvitesCommand(bot, options) {
|
|
|
238
240
|
} catch (error) {
|
|
239
241
|
console.error('Error in /accept_invites:', error);
|
|
240
242
|
const escapedError = escapeMarkdown(error.message);
|
|
241
|
-
await ctx.telegram
|
|
243
|
+
await safeEditMessageText(ctx.telegram, fetchingMessage.chat.id, fetchingMessage.message_id, undefined, `ā Error fetching invitations: ${escapedError}\n\nMake sure \`gh\` CLI is installed and authenticated\\.`, { parse_mode: 'MarkdownV2', verbose: VERBOSE });
|
|
242
244
|
}
|
|
243
245
|
});
|
|
244
246
|
}
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -244,8 +244,8 @@ const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFro
|
|
|
244
244
|
const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
|
|
245
245
|
const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
|
|
246
246
|
const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwarded: _isForwarded, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
|
|
247
|
-
const {
|
|
248
|
-
const {
|
|
247
|
+
const { isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, safeSendMessage, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
|
|
248
|
+
const { installTelegramContextSafety } = await import('./telegram-context-safety.lib.mjs');
|
|
249
249
|
const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
|
|
250
250
|
const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
|
|
251
251
|
const { trackSession, untrackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl, setSessionStore, setSessionLogger, resumeTrackedSessions, getActiveSessionCount } = await import('./session-monitor.lib.mjs');
|
|
@@ -271,8 +271,10 @@ const { Telegraf } = telegrafModule;
|
|
|
271
271
|
const bot = new Telegraf(BOT_TOKEN, {
|
|
272
272
|
handlerTimeout: Infinity, // Remove default 90s timeout; command handlers like /solve spawn long-running processes
|
|
273
273
|
});
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
// Issue #2166: Telegraf hands every update a *new* `Telegram` client, so
|
|
275
|
+
// instrumenting `bot.telegram` alone leaves `ctx.reply()` unprotected. Install
|
|
276
|
+
// on the bot client (for background sends) and on each per-update context.
|
|
277
|
+
installTelegramContextSafety(bot, { verbose: VERBOSE });
|
|
276
278
|
// Track bot startup time (Unix seconds to match Telegram's message.date format)
|
|
277
279
|
const BOT_START_TIME = Math.floor(Date.now() / 1000);
|
|
278
280
|
|
|
@@ -522,7 +524,9 @@ bot.command('version', async ctx => {
|
|
|
522
524
|
const { registerLanguageCommand } = await import('./telegram-language-command.lib.mjs');
|
|
523
525
|
registerLanguageCommand(bot, { VERBOSE, isOldMessage, isForwardedOrReply });
|
|
524
526
|
const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
|
|
525
|
-
|
|
527
|
+
// Issue #2166 (R7): every command module gets the same send helpers, so no
|
|
528
|
+
// command can drift back to a raw, unvalidated, unlogged `ctx.reply`.
|
|
529
|
+
const sharedCommandOpts = { VERBOSE, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage, safeReply, safeEditMessageText };
|
|
526
530
|
registerAcceptInvitesCommand(bot, sharedCommandOpts);
|
|
527
531
|
const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
|
|
528
532
|
registerMergeCommand(bot, sharedCommandOpts);
|
|
@@ -673,6 +677,9 @@ async function handleSolveCommand(ctx) {
|
|
|
673
677
|
return;
|
|
674
678
|
}
|
|
675
679
|
userArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
|
|
680
|
+
// Issue #2166: hand the spawned session the same canonical URL that is shown
|
|
681
|
+
// in the chat, so the echo and the actual work can never disagree.
|
|
682
|
+
if (validation.parsed?.canonical && userArgs[0] && cleanNonPrintableChars(userArgs[0]) === validation.normalizedUrl) userArgs[0] = validation.parsed.canonical;
|
|
676
683
|
const { backend: solvePerCommandIsolation, filteredArgs: userArgsWithoutIsolation } = extractIsolationFromArgs(userArgs); // issue #1534
|
|
677
684
|
if (solvePerCommandIsolation && !isValidPerCommandIsolation(solvePerCommandIsolation)) {
|
|
678
685
|
await safeReply(ctx, t('telegram.invalid_isolation', { value: escapeMarkdown(solvePerCommandIsolation) }, { locale: solveLocale }), { reply_to_message_id: ctx.message.message_id });
|
|
@@ -739,8 +746,10 @@ async function handleSolveCommand(ctx) {
|
|
|
739
746
|
await safeReply(ctx, `ā ${escapeMarkdown(entityCheck.error)}`, { reply_to_message_id: ctx.message.message_id });
|
|
740
747
|
return;
|
|
741
748
|
}
|
|
742
|
-
// Use
|
|
743
|
-
|
|
749
|
+
// Use the canonical URL from validation to ensure consistent duplicate
|
|
750
|
+
// detection (issue #1080) and to echo back only what the bot interpreted ā
|
|
751
|
+
// a `#issuecomment-ā¦` fragment is never used to resolve the target (#2166).
|
|
752
|
+
const normalizedUrl = validation.parsed.canonical || validation.parsed.normalized;
|
|
744
753
|
|
|
745
754
|
const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
|
|
746
755
|
// #1228: only user options; #1460: escape; #1688: 'Issue:' / 'Pull request:' label so completion can append PR link.
|
|
@@ -777,7 +786,7 @@ async function handleSolveCommand(ctx) {
|
|
|
777
786
|
}
|
|
778
787
|
|
|
779
788
|
// Issue #1688: parsed URL context lets the completion message look up linked PRs.
|
|
780
|
-
const solveUrlContext = validation.parsed ? { owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, normalized:
|
|
789
|
+
const solveUrlContext = validation.parsed ? { owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, normalized: normalizedUrl } : null;
|
|
781
790
|
const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
|
|
782
791
|
// Issue #378: propagate user's effective Telegram locale to the spawned solve session.
|
|
783
792
|
const argsWithLocale = injectLanguageIfMissing(args, solveLocale);
|
|
@@ -998,7 +1007,7 @@ registerLeakNotifier(async ({ owner, repo, prNumber, tokenHits = [] }) => {
|
|
|
998
1007
|
if (creator && creator.user?.id) ownerUserId = creator.user.id;
|
|
999
1008
|
}
|
|
1000
1009
|
if (ownerUserId) {
|
|
1001
|
-
await bot.telegram
|
|
1010
|
+
await safeSendMessage(bot.telegram, ownerUserId, text, { verbose: VERBOSE }).catch(err => {
|
|
1002
1011
|
console.warn(`[telegram-leak-notifier] DM to user ${ownerUserId} (chat ${chatId}) failed: ${err.message}`);
|
|
1003
1012
|
});
|
|
1004
1013
|
}
|
|
@@ -3,6 +3,7 @@ import { describeChildExit } from './child-exit.lib.mjs';
|
|
|
3
3
|
import { promisify } from 'util';
|
|
4
4
|
import { exec as execCallback } from 'child_process';
|
|
5
5
|
import { formatFailedLaunchMessage as defaultFormatFailedLaunchMessage } from './work-session-formatting.lib.mjs';
|
|
6
|
+
import { safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
6
7
|
|
|
7
8
|
const exec = promisify(execCallback);
|
|
8
9
|
|
|
@@ -106,7 +107,7 @@ export function buildExecuteAndUpdateMessage(deps) {
|
|
|
106
107
|
const { chat, message_id: msgId } = startingMessage;
|
|
107
108
|
const safeEdit = async text => {
|
|
108
109
|
try {
|
|
109
|
-
await ctx.telegram
|
|
110
|
+
await safeEditMessageText(ctx.telegram, chat.id, msgId, undefined, text, { verbose: VERBOSE });
|
|
110
111
|
} catch (e) {
|
|
111
112
|
console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
|
|
112
113
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-update Telegram instrumentation (issue #2166).
|
|
3
|
+
*
|
|
4
|
+
* Telegraf does **not** reuse `bot.telegram` when handling an update. Every
|
|
5
|
+
* update gets a brand new `Telegram` client so that `webhookReply` can answer on
|
|
6
|
+
* the open HTTP response:
|
|
7
|
+
*
|
|
8
|
+
* ```js
|
|
9
|
+
* // telegraf/lib/telegraf.js ā handleUpdate()
|
|
10
|
+
* const tg = new telegram_1.default(this.token, this.telegram.options, webhookResponse);
|
|
11
|
+
* const ctx = new TelegrafContext(update, tg, this.botInfo);
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Consequently `ctx.telegram !== bot.telegram`, and anything installed on
|
|
15
|
+
* `bot.telegram` (the plain-text formatting fallback, the rate-limit tracker) is
|
|
16
|
+
* invisible to `ctx.reply()`, `ctx.telegram.sendMessage()` and
|
|
17
|
+
* `ctx.telegram.editMessageText()` ā which is how *every* command handler talks
|
|
18
|
+
* to Telegram. That is why the `/stop` confirmation in issue #2166 died with an
|
|
19
|
+
* unhandled `Can't find end of the entity starting at byte offset 65` instead of
|
|
20
|
+
* degrading to plain text.
|
|
21
|
+
*
|
|
22
|
+
* This module re-installs the instrumentation on each freshly created context,
|
|
23
|
+
* so a single send path ā `installTelegramFormattingFallback` ā covers the whole
|
|
24
|
+
* bot regardless of which object a handler happens to call.
|
|
25
|
+
*
|
|
26
|
+
* @module telegram-context-safety.lib
|
|
27
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2166
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { installTelegramFormattingFallback } from './telegram-safe-reply.lib.mjs';
|
|
31
|
+
import { installTelegramRateLimitTracker } from './telegram-rate-limit.lib.mjs';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Install the formatting fallback and rate-limit tracker on one Telegram client.
|
|
35
|
+
* Both installers are idempotent (guarded by a symbol), so calling this on an
|
|
36
|
+
* already-protected client is a no-op.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} telegram - A Telegraf `Telegram` client instance.
|
|
39
|
+
* @param {{verbose?: boolean, fallbackLocale?: string|null, tracker?: object}} [options]
|
|
40
|
+
* @returns {object|null} The same client, or `null` when there is nothing to protect.
|
|
41
|
+
*/
|
|
42
|
+
export function protectTelegramClient(telegram, options = {}) {
|
|
43
|
+
if (!telegram) return null;
|
|
44
|
+
const { verbose = false, fallbackLocale = null, tracker } = options;
|
|
45
|
+
installTelegramFormattingFallback(telegram, { verbose, fallbackLocale });
|
|
46
|
+
installTelegramRateLimitTracker(telegram, tracker ? { verbose, tracker } : { verbose });
|
|
47
|
+
return telegram;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register the per-update protection as the very first middleware of a bot.
|
|
52
|
+
*
|
|
53
|
+
* Must run before any command handler so that handlers which call `ctx.reply()`
|
|
54
|
+
* directly still get the plain-text fallback and the send audit log.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} bot - A Telegraf bot instance.
|
|
57
|
+
* @param {{verbose?: boolean, fallbackLocale?: string|null, tracker?: object}} [options]
|
|
58
|
+
* @returns {object} The bot, for chaining.
|
|
59
|
+
*/
|
|
60
|
+
export function installTelegramContextSafety(bot, options = {}) {
|
|
61
|
+
if (!bot) return bot;
|
|
62
|
+
protectTelegramClient(bot.telegram, options);
|
|
63
|
+
if (typeof bot.use === 'function') {
|
|
64
|
+
bot.use((ctx, next) => {
|
|
65
|
+
protectTelegramClient(ctx?.telegram, options);
|
|
66
|
+
return next();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return bot;
|
|
70
|
+
}
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { buildUserMention } from './buildUserMention.lib.mjs';
|
|
11
|
+
import { calculateLevenshteinDistance } from './option-suggestions.lib.mjs';
|
|
12
|
+
import { getLinoYargsFactory } from './cli-arguments.lib.mjs';
|
|
13
|
+
import { createYargsConfig as createSolveYargsConfig, detectMalformedFlags } from './solve.config.lib.mjs';
|
|
14
|
+
import { parseArgsWithYargs } from './telegram-solve-command.lib.mjs';
|
|
11
15
|
import { validateModelName } from './models/index.mjs';
|
|
12
16
|
import { parseFixRepository } from './fix.ci-cd.lib.mjs';
|
|
13
17
|
import { getModelFromArgs } from './model-args.lib.mjs';
|
|
@@ -15,6 +19,8 @@ import { escapeMarkdown } from './telegram-markdown.lib.mjs';
|
|
|
15
19
|
import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
|
|
16
20
|
import { mergeArgsWithOverrides } from './args-overrides.lib.mjs';
|
|
17
21
|
import { moveArgumentToFront, parseCommandArgs } from './telegram-solve-command.lib.mjs';
|
|
22
|
+
import { safeReply as defaultSafeReply } from './telegram-safe-reply.lib.mjs';
|
|
23
|
+
import { partitionFixArgs } from './fix.ci-cd.lib.mjs';
|
|
18
24
|
import { formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
|
|
19
25
|
|
|
20
26
|
export const FIX_COMMAND_NAMES = Object.freeze(['fix']);
|
|
@@ -68,6 +74,54 @@ export function buildFixCommandArgs(text) {
|
|
|
68
74
|
};
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Options `/fix` consumes itself; everything else is forwarded to `/solve` and
|
|
79
|
+
* must therefore be a valid `solve` option.
|
|
80
|
+
*/
|
|
81
|
+
export const FIX_OWN_OPTIONS = Object.freeze(['--ci-cd', '--isolation', '--dry-run', '--no-solve', '--no-auto-solve', '--solve', '--help', '-h', '--version']);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reject a `/fix` request that contains any option `fix` or `solve` cannot act on.
|
|
85
|
+
*
|
|
86
|
+
* Issue #2166: a typo such as `--ci-de` used to be silently forwarded to
|
|
87
|
+
* `solve.mjs` inside the spawned work session, where the failure was invisible
|
|
88
|
+
* in the chat. `/fix` now fails immediately, in the same chat message, using the
|
|
89
|
+
* very same checks `/solve` runs (`detectMalformedFlags` + solve's strict yargs
|
|
90
|
+
* config), so no typo can slip through.
|
|
91
|
+
*
|
|
92
|
+
* @param {string[]} args - Arguments as produced by `buildFixCommandArgs().args`.
|
|
93
|
+
* @returns {Promise<string|null>} Error message to show the user, or `null` when valid.
|
|
94
|
+
*/
|
|
95
|
+
export async function validateFixCommandOptions(args) {
|
|
96
|
+
const list = Array.isArray(args) ? args : [];
|
|
97
|
+
|
|
98
|
+
const { malformed, errors } = detectMalformedFlags(list);
|
|
99
|
+
if (malformed.length > 0) return errors.join('\n');
|
|
100
|
+
|
|
101
|
+
// `--ci-de` is closer to `/fix`'s own `--ci-cd` than to anything solve knows,
|
|
102
|
+
// so check fix's own vocabulary first ā otherwise the generic suggester points
|
|
103
|
+
// at unrelated solve options.
|
|
104
|
+
const partitioned = partitionFixArgs(list);
|
|
105
|
+
for (const arg of partitioned.passthrough) {
|
|
106
|
+
if (!arg.startsWith('-')) continue;
|
|
107
|
+
const name = arg.split('=')[0];
|
|
108
|
+
const closest = FIX_OWN_OPTIONS.map(option => ({ option, distance: calculateLevenshteinDistance(name, option) }))
|
|
109
|
+
.filter(candidate => candidate.distance > 0 && candidate.distance <= 2)
|
|
110
|
+
.sort((a, b) => a.distance - b.distance)[0];
|
|
111
|
+
if (closest) return `Unknown option "${name}". Did you mean "${closest.option}"?`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// solve requires a positional issue URL; a placeholder keeps the parser happy
|
|
115
|
+
// so that only the *options* are judged here.
|
|
116
|
+
const probeArgs = ['https://github.com/owner/repo/issues/1', ...partitioned.passthrough];
|
|
117
|
+
try {
|
|
118
|
+
await parseArgsWithYargs(probeArgs, getLinoYargsFactory(), createSolveYargsConfig);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return error?.message || String(error);
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
71
125
|
// Issue #378: inject --language LOCALE into spawn args if no language flag is
|
|
72
126
|
// already present, so spawned fix sessions inherit the user's effective locale.
|
|
73
127
|
function injectLanguageIfMissing(args, locale) {
|
|
@@ -81,7 +135,7 @@ function injectLanguageIfMissing(args, locale) {
|
|
|
81
135
|
}
|
|
82
136
|
|
|
83
137
|
export function registerFixCommand(bot, options) {
|
|
84
|
-
const { VERBOSE, fixEnabled, addBreadcrumb, isOldMessage, isForwardedOrReply, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, resolveLocale = null, solveOverrides = [] } = options;
|
|
138
|
+
const { VERBOSE, fixEnabled, addBreadcrumb, isOldMessage, isForwardedOrReply, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply = defaultSafeReply, executeAndUpdateMessage, resolveLocale = null, solveOverrides = [] } = options;
|
|
85
139
|
|
|
86
140
|
async function handleFixCommand(ctx) {
|
|
87
141
|
const commandDisplay = '/fix';
|
|
@@ -95,7 +149,7 @@ export function registerFixCommand(bot, options) {
|
|
|
95
149
|
});
|
|
96
150
|
|
|
97
151
|
if (!fixEnabled) {
|
|
98
|
-
await ctx
|
|
152
|
+
await safeReply(ctx, 'ā The fix command is disabled on this bot instance.');
|
|
99
153
|
return;
|
|
100
154
|
}
|
|
101
155
|
if (isOldMessage(ctx)) return;
|
|
@@ -107,11 +161,11 @@ export function registerFixCommand(bot, options) {
|
|
|
107
161
|
return;
|
|
108
162
|
}
|
|
109
163
|
if (!isGroupChat(ctx)) {
|
|
110
|
-
await ctx
|
|
164
|
+
await safeReply(ctx, `ā The ${commandDisplay} command only works in group chats. Please add this bot to a group and make it an admin.`, { reply_to_message_id: ctx.message.message_id });
|
|
111
165
|
return;
|
|
112
166
|
}
|
|
113
167
|
if (!isTopicAuthorized(ctx)) {
|
|
114
|
-
await ctx
|
|
168
|
+
await safeReply(ctx, buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
|
|
115
169
|
return;
|
|
116
170
|
}
|
|
117
171
|
if (isChatStopped(ctx.chat.id)) {
|
|
@@ -131,6 +185,16 @@ export function registerFixCommand(bot, options) {
|
|
|
131
185
|
return;
|
|
132
186
|
}
|
|
133
187
|
|
|
188
|
+
// Issue #2166: fail immediately on any unsupported option, before a work
|
|
189
|
+
// session is spawned, so a typo can never turn into a silent no-op. Runs
|
|
190
|
+
// after --isolation extraction because that flag is consumed by /fix itself
|
|
191
|
+
// and is not part of the solve vocabulary the probe parser validates.
|
|
192
|
+
const optionsError = await validateFixCommandOptions(filteredArgs);
|
|
193
|
+
if (optionsError) {
|
|
194
|
+
await safeReply(ctx, `ā Invalid options: ${escapeMarkdown(optionsError)}\n\nUse /help to see available options`, { reply_to_message_id: ctx.message.message_id });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
134
198
|
// Issue #2085: /fix hands the generated issue off to /solve, so it must
|
|
135
199
|
// apply the operator's solve overrides (TELEGRAM_SOLVE_OVERRIDES) exactly
|
|
136
200
|
// like the /solve handler does ā otherwise the solve started by /fix runs
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { t, getSupportedLocales, normalizeLocale, setUserLocale, clearUserLocale, resolveLocaleFromTelegramCtx } from './i18n.lib.mjs';
|
|
14
|
+
import { safeReply } from './telegram-safe-reply.lib.mjs';
|
|
14
15
|
|
|
15
16
|
export function registerLanguageCommand(bot, options = {}) {
|
|
16
17
|
const { VERBOSE = false, isOldMessage, isForwardedOrReply } = options;
|
|
@@ -27,14 +28,14 @@ export function registerLanguageCommand(bot, options = {}) {
|
|
|
27
28
|
const arg = parts.length > 1 ? parts[1] : null;
|
|
28
29
|
if (!arg) {
|
|
29
30
|
const langName = t(`language.${locale}`, {}, { locale });
|
|
30
|
-
await ctx
|
|
31
|
+
await safeReply(ctx, t('telegram.language_current', { language: langName, supported: supportedList }, { locale }), { reply_to_message_id: ctx.message.message_id });
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
34
|
if (['default', 'reset', 'clear'].includes(arg.toLowerCase())) {
|
|
34
35
|
clearUserLocale(userId);
|
|
35
36
|
const newLocale = resolveLocaleFromTelegramCtx(ctx);
|
|
36
37
|
const langName = t(`language.${newLocale}`, {}, { locale: newLocale });
|
|
37
|
-
await ctx
|
|
38
|
+
await safeReply(ctx, t('telegram.language_set', { language: langName }, { locale: newLocale }), { reply_to_message_id: ctx.message.message_id });
|
|
38
39
|
return;
|
|
39
40
|
}
|
|
40
41
|
const target = normalizeLocale(arg);
|
|
@@ -44,6 +45,6 @@ export function registerLanguageCommand(bot, options = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
setUserLocale(userId, target);
|
|
46
47
|
const langName = t(`language.${target}`, {}, { locale: target });
|
|
47
|
-
await ctx
|
|
48
|
+
await safeReply(ctx, t('telegram.language_set', { language: langName }, { locale: target }), { reply_to_message_id: ctx.message.message_id });
|
|
48
49
|
});
|
|
49
50
|
}
|