@link-assistant/hive-mind 1.14.2 → 1.15.1
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 +31 -0
- package/package.json +2 -2
- package/src/agent.prompts.lib.mjs +1 -0
- package/src/codex.prompts.lib.mjs +1 -0
- package/src/opencode.prompts.lib.mjs +1 -0
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.mjs +51 -0
- package/src/solve.results.lib.mjs +133 -3
- package/src/telegram-bot.mjs +61 -88
- package/src/telegram-message-filters.lib.mjs +173 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.15.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- docs: Expand auto-cleanup case study with 9 additional solutions (Issue #912)
|
|
8
|
+
|
|
9
|
+
Expanded the case study analysis from 6 to 15 solutions covering:
|
|
10
|
+
- OOM protection (earlyoom, systemd-oomd, OOM score tuning)
|
|
11
|
+
- Resource isolation (cgroups via systemd)
|
|
12
|
+
- Log management (logrotate)
|
|
13
|
+
- Process monitoring (Monit, Supervisord)
|
|
14
|
+
- Event-driven cleanup (incron)
|
|
15
|
+
- Resource watchdog scripts
|
|
16
|
+
- Kubernetes liveness probes and resource limits
|
|
17
|
+
|
|
18
|
+
Added tiered recommendation system (Essential, Recommended, Advanced) and updated implementation guide with steps for earlyoom, OOM score tuning, cgroup limits, and logrotate configuration.
|
|
19
|
+
|
|
20
|
+
Extract message filter functions to testable module with 34 unit tests for message recognition pipeline (issue #1207)
|
|
21
|
+
|
|
22
|
+
## 1.15.0
|
|
23
|
+
|
|
24
|
+
### Minor Changes
|
|
25
|
+
|
|
26
|
+
- c5dad3c: feat: add --auto-restart-on-non-updated-pull-request-description option (Issue #1162)
|
|
27
|
+
|
|
28
|
+
When using `--tool agent` mode, the pull request title and description could remain
|
|
29
|
+
in their initial WIP placeholder state. This adds an opt-in `--auto-restart-on-non-updated-pull-request-description`
|
|
30
|
+
flag that detects placeholder content after agent execution and auto-restarts with a
|
|
31
|
+
short factual hint. Also adds gentle checklist suggestions to agent/opencode/codex prompts
|
|
32
|
+
(excluding Claude, which handles PR updates naturally).
|
|
33
|
+
|
|
3
34
|
## 1.14.2
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.1",
|
|
4
4
|
"description": "AI-powered issue solver and hive mind for collaborative problem solving",
|
|
5
5
|
"main": "src/hive.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"hive-telegram-bot": "./src/telegram-bot.mjs"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs",
|
|
16
|
+
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-telegram-message-filters.mjs",
|
|
17
17
|
"test:queue": "node tests/solve-queue.test.mjs",
|
|
18
18
|
"test:limits-display": "node tests/limits-display.test.mjs",
|
|
19
19
|
"test:usage-limit": "node tests/test-usage-limit.mjs",
|
|
@@ -184,6 +184,7 @@ Preparing pull request.
|
|
|
184
184
|
- When there is a package with version and GitHub Actions workflows for automatic release, update the version in your pull request to prepare for next release.
|
|
185
185
|
- When you update existing pr ${prNumber}, use gh pr edit to modify title and description.
|
|
186
186
|
- When you finalize the pull request:
|
|
187
|
+
check that pull request title and description are updated (the PR may start with a [WIP] prefix and placeholder description that should be replaced with actual title and description of the changes),
|
|
187
188
|
follow style from merged prs for code, title, and description,
|
|
188
189
|
make sure no uncommitted changes corresponding to the original requirements are left behind,
|
|
189
190
|
make sure the default branch is merged to the pull request's branch,
|
|
@@ -194,6 +194,7 @@ Preparing pull request.
|
|
|
194
194
|
- When you update existing pr ${prNumber}, use gh pr edit to modify title and description.
|
|
195
195
|
- When you are about to commit or push code, ALWAYS run local CI checks first if they are available in contributing guidelines (like ruff check, mypy, eslint, etc.) to catch errors before pushing.
|
|
196
196
|
- When you finalize the pull request:
|
|
197
|
+
check that pull request title and description are updated (the PR may start with a [WIP] prefix and placeholder description that should be replaced with actual title and description of the changes),
|
|
197
198
|
follow style from merged prs for code, title, and description,
|
|
198
199
|
make sure no uncommitted changes corresponding to the original requirements are left behind,
|
|
199
200
|
make sure the default branch is merged to the pull request's branch,
|
|
@@ -187,6 +187,7 @@ Preparing pull request.
|
|
|
187
187
|
- When there is a package with version and GitHub Actions workflows for automatic release, update the version in your pull request to prepare for next release.
|
|
188
188
|
- When you update existing pr ${prNumber}, use gh pr edit to modify title and description.
|
|
189
189
|
- When you finalize the pull request:
|
|
190
|
+
check that pull request title and description are updated (the PR may start with a [WIP] prefix and placeholder description that should be replaced with actual title and description of the changes),
|
|
190
191
|
follow style from merged prs for code, title, and description,
|
|
191
192
|
make sure no uncommitted changes corresponding to the original requirements are left behind,
|
|
192
193
|
make sure the default branch is merged to the pull request's branch,
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -210,6 +210,11 @@ export const createYargsConfig = yargsInstance => {
|
|
|
210
210
|
description: 'Auto-restart until PR becomes mergeable (no iteration limit). Restarts on new comments from non-bot users, CI failures, merge conflicts, or other issues. Does NOT auto-merge.',
|
|
211
211
|
default: false,
|
|
212
212
|
})
|
|
213
|
+
.option('auto-restart-on-non-updated-pull-request-description', {
|
|
214
|
+
type: 'boolean',
|
|
215
|
+
description: 'Automatically restart if PR title or description still contains auto-generated placeholder text after agent execution. Restarts with a hint about what was not updated.',
|
|
216
|
+
default: false,
|
|
217
|
+
})
|
|
213
218
|
.option('continue-only-on-feedback', {
|
|
214
219
|
type: 'boolean',
|
|
215
220
|
description: 'Only continue if feedback is detected (works only with pull request link or issue link with --auto-continue)',
|
package/src/solve.mjs
CHANGED
|
@@ -1192,6 +1192,57 @@ try {
|
|
|
1192
1192
|
const verifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType);
|
|
1193
1193
|
const logsAlreadyUploaded = verifyResult?.logUploadSuccess || false;
|
|
1194
1194
|
|
|
1195
|
+
// Issue #1162: Auto-restart when PR title/description still has placeholder content
|
|
1196
|
+
if (argv.autoRestartOnNonUpdatedPullRequestDescription && (verifyResult?.prTitleHasPlaceholder || verifyResult?.prBodyHasPlaceholder)) {
|
|
1197
|
+
const { buildPRNotUpdatedHint } = results;
|
|
1198
|
+
const hintLines = buildPRNotUpdatedHint(verifyResult.prTitleHasPlaceholder, verifyResult.prBodyHasPlaceholder);
|
|
1199
|
+
|
|
1200
|
+
await log('');
|
|
1201
|
+
await log('🔄 AUTO-RESTART: PR title/description not updated by agent');
|
|
1202
|
+
hintLines.forEach(async line => await log(` ${line}`));
|
|
1203
|
+
await log(' Restarting tool to give agent another chance to update...');
|
|
1204
|
+
await log('');
|
|
1205
|
+
|
|
1206
|
+
// Import executeToolIteration for re-execution
|
|
1207
|
+
const { executeToolIteration } = await import('./solve.restart-shared.lib.mjs');
|
|
1208
|
+
|
|
1209
|
+
// Re-execute tool with hint as feedback lines
|
|
1210
|
+
const restartResult = await executeToolIteration({
|
|
1211
|
+
issueUrl,
|
|
1212
|
+
owner,
|
|
1213
|
+
repo,
|
|
1214
|
+
issueNumber,
|
|
1215
|
+
prNumber,
|
|
1216
|
+
branchName,
|
|
1217
|
+
tempDir,
|
|
1218
|
+
mergeStateStatus,
|
|
1219
|
+
feedbackLines: hintLines,
|
|
1220
|
+
argv: {
|
|
1221
|
+
...argv,
|
|
1222
|
+
// Disable auto-restart for this iteration to prevent infinite loops
|
|
1223
|
+
autoRestartOnNonUpdatedPullRequestDescription: false,
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// Update session data from restart
|
|
1228
|
+
if (restartResult) {
|
|
1229
|
+
if (restartResult.sessionId) sessionId = restartResult.sessionId;
|
|
1230
|
+
if (restartResult.anthropicTotalCostUSD) anthropicTotalCostUSD = restartResult.anthropicTotalCostUSD;
|
|
1231
|
+
if (restartResult.publicPricingEstimate) publicPricingEstimate = restartResult.publicPricingEstimate;
|
|
1232
|
+
if (restartResult.pricingInfo) pricingInfo = restartResult.pricingInfo;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Clean up CLAUDE.md/.gitkeep again after restart
|
|
1236
|
+
await cleanupClaudeFile(tempDir, branchName, null, argv);
|
|
1237
|
+
|
|
1238
|
+
// Re-verify results after restart (without auto-restart flag to prevent recursion)
|
|
1239
|
+
const reVerifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, { ...argv, autoRestartOnNonUpdatedPullRequestDescription: false }, shouldAttachLogs, false, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType);
|
|
1240
|
+
|
|
1241
|
+
if (reVerifyResult?.prTitleHasPlaceholder || reVerifyResult?.prBodyHasPlaceholder) {
|
|
1242
|
+
await log('⚠️ PR title/description still not updated after restart');
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1195
1246
|
// Start watch mode if enabled OR if we need to handle uncommitted changes
|
|
1196
1247
|
if (argv.verbose) {
|
|
1197
1248
|
await log('');
|
|
@@ -47,6 +47,51 @@ const { reportError } = sentryLib;
|
|
|
47
47
|
const githubLinking = await import('./github-linking.lib.mjs');
|
|
48
48
|
const { hasGitHubLinkingKeyword } = githubLinking;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Placeholder patterns used to detect auto-generated PR content that was not updated by the agent.
|
|
52
|
+
* These patterns match the initial WIP PR created by solve.auto-pr.lib.mjs.
|
|
53
|
+
*/
|
|
54
|
+
export const PR_TITLE_PLACEHOLDER_PREFIX = '[WIP]';
|
|
55
|
+
|
|
56
|
+
export const PR_BODY_PLACEHOLDER_PATTERNS = ['_Details will be added as the solution draft is developed..._', '**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.', '### 🚧 Status'];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if PR title still contains auto-generated placeholder content
|
|
60
|
+
* @param {string} title - PR title
|
|
61
|
+
* @returns {boolean} - true if title has placeholder content
|
|
62
|
+
*/
|
|
63
|
+
export const hasPRTitlePlaceholder = title => {
|
|
64
|
+
return title && title.startsWith(PR_TITLE_PLACEHOLDER_PREFIX);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if PR body still contains auto-generated placeholder content
|
|
69
|
+
* @param {string} body - PR body
|
|
70
|
+
* @returns {boolean} - true if body has placeholder content
|
|
71
|
+
*/
|
|
72
|
+
export const hasPRBodyPlaceholder = body => {
|
|
73
|
+
return body && PR_BODY_PLACEHOLDER_PATTERNS.some(pattern => body.includes(pattern));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a short factual hint for auto-restart when PR title/description was not updated.
|
|
78
|
+
* Uses neutral, fact-stating language (no forcing words).
|
|
79
|
+
* @param {boolean} titleNotUpdated - Whether the PR title still has placeholder
|
|
80
|
+
* @param {boolean} descriptionNotUpdated - Whether the PR description still has placeholder
|
|
81
|
+
* @returns {string[]} - Array of feedback lines to pass as hint to the restarted session
|
|
82
|
+
*/
|
|
83
|
+
export const buildPRNotUpdatedHint = (titleNotUpdated, descriptionNotUpdated) => {
|
|
84
|
+
const lines = [];
|
|
85
|
+
if (titleNotUpdated && descriptionNotUpdated) {
|
|
86
|
+
lines.push('Pull request title and description were not updated.');
|
|
87
|
+
} else if (titleNotUpdated) {
|
|
88
|
+
lines.push('Pull request title was not updated.');
|
|
89
|
+
} else if (descriptionNotUpdated) {
|
|
90
|
+
lines.push('Pull request description was not updated.');
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
};
|
|
94
|
+
|
|
50
95
|
/**
|
|
51
96
|
* Detect the CLAUDE.md or .gitkeep commit hash from branch structure when not available in session
|
|
52
97
|
* This handles continue mode where the commit hash was lost between sessions
|
|
@@ -465,12 +510,17 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
465
510
|
await log(` ℹ️ PR #${pr.number} was merged during the session`);
|
|
466
511
|
}
|
|
467
512
|
|
|
513
|
+
// Declare placeholder detection variables outside block scopes for use in return value
|
|
514
|
+
let prTitleHasPlaceholder = false;
|
|
515
|
+
let prBodyHasPlaceholder = false;
|
|
516
|
+
|
|
468
517
|
// Skip PR body update and ready conversion for merged PRs (they can't be edited)
|
|
469
518
|
if (!isPrMerged) {
|
|
470
519
|
// Check if PR body has proper issue linking keywords
|
|
520
|
+
let prBody = '';
|
|
471
521
|
const prBodyResult = await $`gh pr view ${pr.number} --repo ${owner}/${repo} --json body --jq .body`;
|
|
472
522
|
if (prBodyResult.code === 0) {
|
|
473
|
-
|
|
523
|
+
prBody = prBodyResult.stdout.toString();
|
|
474
524
|
const issueRef = argv.fork ? `${owner}/${repo}#${issueNumber}` : `#${issueNumber}`;
|
|
475
525
|
|
|
476
526
|
// Use the new GitHub linking detection library to check for valid keywords
|
|
@@ -513,6 +563,80 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
513
563
|
}
|
|
514
564
|
}
|
|
515
565
|
|
|
566
|
+
// Issue #1162: Detect if PR title/description still have auto-generated placeholder content
|
|
567
|
+
// Track this before cleanup for --auto-restart-on-non-updated-pull-request-description
|
|
568
|
+
prTitleHasPlaceholder = hasPRTitlePlaceholder(pr.title);
|
|
569
|
+
prBodyHasPlaceholder = hasPRBodyPlaceholder(prBody);
|
|
570
|
+
|
|
571
|
+
// Issue #1162: Remove [WIP] prefix from title if still present
|
|
572
|
+
// Skip cleanup if auto-restart-on-non-updated-pull-request-description is enabled
|
|
573
|
+
// (let the agent handle it on restart instead)
|
|
574
|
+
if (prTitleHasPlaceholder && !argv.autoRestartOnNonUpdatedPullRequestDescription) {
|
|
575
|
+
const updatedTitle = pr.title.replace(/^\[WIP\]\s*/, '');
|
|
576
|
+
await log(` 📝 Removing [WIP] prefix from PR title...`);
|
|
577
|
+
const titleResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --title "${updatedTitle}"`;
|
|
578
|
+
if (titleResult.code === 0) {
|
|
579
|
+
await log(` ✅ Updated PR title to: "${updatedTitle}"`);
|
|
580
|
+
} else {
|
|
581
|
+
await log(` ⚠️ Could not update PR title: ${titleResult.stderr ? titleResult.stderr.toString().trim() : 'Unknown error'}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Issue #1162: Update PR description if still contains placeholder text
|
|
586
|
+
// Skip cleanup if auto-restart-on-non-updated-pull-request-description is enabled
|
|
587
|
+
const hasPlaceholder = prBodyHasPlaceholder;
|
|
588
|
+
if (hasPlaceholder && !argv.autoRestartOnNonUpdatedPullRequestDescription) {
|
|
589
|
+
await log(` 📝 Updating PR description to remove placeholder text...`);
|
|
590
|
+
|
|
591
|
+
// Build a summary of the changes from the PR diff
|
|
592
|
+
const diffResult = await $`gh pr diff ${pr.number} --repo ${owner}/${repo} 2>&1`;
|
|
593
|
+
const diffOutput = diffResult.code === 0 ? diffResult.stdout.toString() : '';
|
|
594
|
+
|
|
595
|
+
// Count files changed
|
|
596
|
+
const filesChanged = (diffOutput.match(/^diff --git/gm) || []).length;
|
|
597
|
+
const additions = (diffOutput.match(/^\+[^+]/gm) || []).length;
|
|
598
|
+
const deletions = (diffOutput.match(/^-[^-]/gm) || []).length;
|
|
599
|
+
|
|
600
|
+
// Get the issue title for context
|
|
601
|
+
const issueTitleResult = await $`gh issue view ${issueNumber} --repo ${owner}/${repo} --json title --jq .title 2>&1`;
|
|
602
|
+
const issueTitle = issueTitleResult.code === 0 ? issueTitleResult.stdout.toString().trim() : 'the issue';
|
|
603
|
+
|
|
604
|
+
// Build new description
|
|
605
|
+
const fs = (await use('fs')).promises;
|
|
606
|
+
const issueRef = argv.fork ? `${owner}/${repo}#${issueNumber}` : `#${issueNumber}`;
|
|
607
|
+
const newDescription = `## Summary
|
|
608
|
+
|
|
609
|
+
This pull request implements a solution for ${issueRef}: ${issueTitle}
|
|
610
|
+
|
|
611
|
+
### Changes
|
|
612
|
+
- ${filesChanged} file(s) modified
|
|
613
|
+
- ${additions} line(s) added
|
|
614
|
+
- ${deletions} line(s) removed
|
|
615
|
+
|
|
616
|
+
### Issue Reference
|
|
617
|
+
Fixes ${issueRef}
|
|
618
|
+
|
|
619
|
+
---
|
|
620
|
+
*This PR was created automatically by the AI issue solver*`;
|
|
621
|
+
|
|
622
|
+
const tempBodyFile = `/tmp/pr-body-finalize-${pr.number}-${Date.now()}.md`;
|
|
623
|
+
await fs.writeFile(tempBodyFile, newDescription);
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
const descResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --body-file "${tempBodyFile}"`;
|
|
627
|
+
await fs.unlink(tempBodyFile).catch(() => {});
|
|
628
|
+
|
|
629
|
+
if (descResult.code === 0) {
|
|
630
|
+
await log(` ✅ Updated PR description with solution summary`);
|
|
631
|
+
} else {
|
|
632
|
+
await log(` ⚠️ Could not update PR description: ${descResult.stderr ? descResult.stderr.toString().trim() : 'Unknown error'}`);
|
|
633
|
+
}
|
|
634
|
+
} catch (descError) {
|
|
635
|
+
await fs.unlink(tempBodyFile).catch(() => {});
|
|
636
|
+
await log(` ⚠️ Error updating PR description: ${descError.message}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
516
640
|
// Check if PR is ready for review (convert from draft if necessary)
|
|
517
641
|
if (pr.isDraft) {
|
|
518
642
|
await log(' 🔄 Converting PR from draft to ready for review...');
|
|
@@ -563,11 +687,17 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
563
687
|
}
|
|
564
688
|
await log('\n✨ Please review the pull request for the proposed solution draft.');
|
|
565
689
|
// Don't exit if watch mode is enabled OR if auto-restart is needed for uncommitted changes
|
|
566
|
-
if
|
|
690
|
+
// Also don't exit if auto-restart-on-non-updated-pull-request-description detected placeholders
|
|
691
|
+
const shouldAutoRestartForPlaceholder = argv.autoRestartOnNonUpdatedPullRequestDescription && (prTitleHasPlaceholder || prBodyHasPlaceholder);
|
|
692
|
+
if (shouldAutoRestartForPlaceholder) {
|
|
693
|
+
await log('\n🔄 Placeholder detected in PR title/description - auto-restart will be triggered');
|
|
694
|
+
}
|
|
695
|
+
if (!argv.watch && !shouldRestart && !shouldAutoRestartForPlaceholder) {
|
|
567
696
|
await safeExit(0, 'Process completed successfully');
|
|
568
697
|
}
|
|
569
698
|
// Issue #1154: Return logUploadSuccess to prevent duplicate log uploads
|
|
570
|
-
|
|
699
|
+
// Issue #1162: Return placeholder detection status for auto-restart
|
|
700
|
+
return { logUploadSuccess, prTitleHasPlaceholder, prBodyHasPlaceholder }; // Return for watch mode or auto-restart
|
|
571
701
|
} else {
|
|
572
702
|
await log(` ℹ️ Found pull request #${pr.number} but it appears to be from a different session`);
|
|
573
703
|
}
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -43,6 +43,8 @@ const { formatUsageMessage, getAllCachedLimits } = await import('./limits.lib.mj
|
|
|
43
43
|
const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
|
|
44
44
|
const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
|
|
45
45
|
const { getSolveQueue, getRunningClaudeProcesses, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
|
|
46
|
+
// Import extracted message filter functions for testability (issue #1207)
|
|
47
|
+
const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText } = await import('./telegram-message-filters.lib.mjs');
|
|
46
48
|
|
|
47
49
|
const config = yargs(hideBin(process.argv))
|
|
48
50
|
.usage('Usage: hive-telegram-bot [options]')
|
|
@@ -291,100 +293,22 @@ const bot = new Telegraf(BOT_TOKEN, {
|
|
|
291
293
|
// Using Unix timestamp (seconds since epoch) to match Telegram's message.date format
|
|
292
294
|
const BOT_START_TIME = Math.floor(Date.now() / 1000);
|
|
293
295
|
|
|
296
|
+
// Wrapper functions that bind extracted filter functions to bot-specific state
|
|
297
|
+
// The actual logic is in telegram-message-filters.lib.mjs for testability (issue #1207)
|
|
294
298
|
function isChatAuthorized(chatId) {
|
|
295
|
-
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
return allowedChats.includes(chatId);
|
|
299
|
+
return _isChatAuthorized(chatId, allowedChats);
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
function isOldMessage(ctx) {
|
|
302
|
-
|
|
303
|
-
// This prevents processing old/pending messages from before current bot instance startup
|
|
304
|
-
const messageDate = ctx.message?.date;
|
|
305
|
-
if (!messageDate) {
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
return messageDate < BOT_START_TIME;
|
|
303
|
+
return _isOldMessage(ctx, BOT_START_TIME, { verbose: VERBOSE });
|
|
309
304
|
}
|
|
310
305
|
|
|
311
306
|
function isGroupChat(ctx) {
|
|
312
|
-
|
|
313
|
-
return chatType === 'group' || chatType === 'supergroup';
|
|
307
|
+
return _isGroupChat(ctx);
|
|
314
308
|
}
|
|
315
309
|
|
|
316
310
|
function isForwardedOrReply(ctx) {
|
|
317
|
-
|
|
318
|
-
if (!message) {
|
|
319
|
-
if (VERBOSE) {
|
|
320
|
-
console.log('[VERBOSE] isForwardedOrReply: No message object');
|
|
321
|
-
}
|
|
322
|
-
return false;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if (VERBOSE) {
|
|
326
|
-
console.log('[VERBOSE] isForwardedOrReply: Checking message fields...');
|
|
327
|
-
console.log('[VERBOSE] message.forward_origin:', JSON.stringify(message.forward_origin));
|
|
328
|
-
console.log('[VERBOSE] message.forward_origin?.type:', message.forward_origin?.type);
|
|
329
|
-
console.log('[VERBOSE] message.forward_from:', JSON.stringify(message.forward_from));
|
|
330
|
-
console.log('[VERBOSE] message.forward_from_chat:', JSON.stringify(message.forward_from_chat));
|
|
331
|
-
console.log('[VERBOSE] message.forward_from_message_id:', message.forward_from_message_id);
|
|
332
|
-
console.log('[VERBOSE] message.forward_signature:', message.forward_signature);
|
|
333
|
-
console.log('[VERBOSE] message.forward_sender_name:', message.forward_sender_name);
|
|
334
|
-
console.log('[VERBOSE] message.forward_date:', message.forward_date);
|
|
335
|
-
console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
|
|
336
|
-
console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Check if message is forwarded (has forward_origin field with actual content)
|
|
340
|
-
// Note: We check for .type because Telegram might send empty objects {}
|
|
341
|
-
// which are truthy in JavaScript but don't indicate a forwarded message
|
|
342
|
-
if (message.forward_origin && message.forward_origin.type) {
|
|
343
|
-
if (VERBOSE) {
|
|
344
|
-
console.log('[VERBOSE] isForwardedOrReply: TRUE - forward_origin.type exists:', message.forward_origin.type);
|
|
345
|
-
}
|
|
346
|
-
return true;
|
|
347
|
-
}
|
|
348
|
-
// Also check old forwarding API fields for backward compatibility
|
|
349
|
-
if (message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date) {
|
|
350
|
-
if (VERBOSE) {
|
|
351
|
-
console.log('[VERBOSE] isForwardedOrReply: TRUE - old forwarding API field detected');
|
|
352
|
-
if (message.forward_from) console.log('[VERBOSE] Triggered by: forward_from');
|
|
353
|
-
if (message.forward_from_chat) console.log('[VERBOSE] Triggered by: forward_from_chat');
|
|
354
|
-
if (message.forward_from_message_id) console.log('[VERBOSE] Triggered by: forward_from_message_id');
|
|
355
|
-
if (message.forward_signature) console.log('[VERBOSE] Triggered by: forward_signature');
|
|
356
|
-
if (message.forward_sender_name) console.log('[VERBOSE] Triggered by: forward_sender_name');
|
|
357
|
-
if (message.forward_date) console.log('[VERBOSE] Triggered by: forward_date');
|
|
358
|
-
}
|
|
359
|
-
return true;
|
|
360
|
-
}
|
|
361
|
-
// Check if message is a reply (has reply_to_message field with actual content)
|
|
362
|
-
// Note: We check for .message_id because Telegram might send empty objects {}
|
|
363
|
-
// IMPORTANT: In forum groups, messages in topics have reply_to_message pointing to the topic's
|
|
364
|
-
// first message (with forum_topic_created). These are NOT user replies, just part of the thread.
|
|
365
|
-
// We must exclude these to allow commands in forum topics.
|
|
366
|
-
if (message.reply_to_message && message.reply_to_message.message_id) {
|
|
367
|
-
// If the reply_to_message is a forum topic creation message, this is NOT a user reply
|
|
368
|
-
if (message.reply_to_message.forum_topic_created) {
|
|
369
|
-
if (VERBOSE) {
|
|
370
|
-
console.log('[VERBOSE] isForwardedOrReply: FALSE - reply is to forum topic creation, not user reply');
|
|
371
|
-
console.log('[VERBOSE] Forum topic:', message.reply_to_message.forum_topic_created);
|
|
372
|
-
}
|
|
373
|
-
// This is just a message in a forum topic, not a reply to another user
|
|
374
|
-
// Allow the message to proceed
|
|
375
|
-
} else {
|
|
376
|
-
// This is an actual reply to another user's message
|
|
377
|
-
if (VERBOSE) {
|
|
378
|
-
console.log('[VERBOSE] isForwardedOrReply: TRUE - reply_to_message.message_id exists:', message.reply_to_message.message_id);
|
|
379
|
-
}
|
|
380
|
-
return true;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (VERBOSE) {
|
|
385
|
-
console.log('[VERBOSE] isForwardedOrReply: FALSE - no forwarding or reply detected');
|
|
386
|
-
}
|
|
387
|
-
return false;
|
|
311
|
+
return _isForwardedOrReply(ctx, { verbose: VERBOSE });
|
|
388
312
|
}
|
|
389
313
|
|
|
390
314
|
async function findStartScreenCommand() {
|
|
@@ -915,7 +839,8 @@ registerMergeCommand(bot, {
|
|
|
915
839
|
addBreadcrumb,
|
|
916
840
|
});
|
|
917
841
|
|
|
918
|
-
|
|
842
|
+
// Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
|
|
843
|
+
async function handleSolveCommand(ctx) {
|
|
919
844
|
if (VERBOSE) {
|
|
920
845
|
console.log('[VERBOSE] /solve command received');
|
|
921
846
|
}
|
|
@@ -1115,9 +1040,12 @@ bot.command(/^solve$/i, async ctx => {
|
|
|
1115
1040
|
queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
|
|
1116
1041
|
if (!solveQueue.executeCallback) solveQueue.executeCallback = createQueueExecuteCallback(executeStartScreen);
|
|
1117
1042
|
}
|
|
1118
|
-
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
bot.command(/^solve$/i, handleSolveCommand);
|
|
1119
1046
|
|
|
1120
|
-
|
|
1047
|
+
// Named handler for /hive command - extracted for reuse by text-based fallback (issue #1207)
|
|
1048
|
+
async function handleHiveCommand(ctx) {
|
|
1121
1049
|
if (VERBOSE) {
|
|
1122
1050
|
console.log('[VERBOSE] /hive command received');
|
|
1123
1051
|
}
|
|
@@ -1252,7 +1180,9 @@ bot.command(/^hive$/i, async ctx => {
|
|
|
1252
1180
|
|
|
1253
1181
|
const startingMessage = await ctx.reply(`🚀 Starting hive command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
|
|
1254
1182
|
await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock);
|
|
1255
|
-
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
bot.command(/^hive$/i, handleHiveCommand);
|
|
1256
1186
|
|
|
1257
1187
|
// Register /top command from separate module
|
|
1258
1188
|
// This keeps telegram-bot.mjs under the 1500 line limit
|
|
@@ -1285,6 +1215,10 @@ if (VERBOSE) {
|
|
|
1285
1215
|
});
|
|
1286
1216
|
if (msg) {
|
|
1287
1217
|
console.log('[VERBOSE] Msg fields:', Object.keys(msg));
|
|
1218
|
+
// Log entities for command matching diagnostics (issue #1207)
|
|
1219
|
+
if (msg.entities) {
|
|
1220
|
+
console.log('[VERBOSE] Entities:', JSON.stringify(msg.entities));
|
|
1221
|
+
}
|
|
1288
1222
|
console.log('[VERBOSE] Forward/reply:', {
|
|
1289
1223
|
forward_origin: msg.forward_origin,
|
|
1290
1224
|
forward_from: msg.forward_from,
|
|
@@ -1299,6 +1233,45 @@ if (VERBOSE) {
|
|
|
1299
1233
|
});
|
|
1300
1234
|
}
|
|
1301
1235
|
|
|
1236
|
+
// Text-based fallback for command matching (issue #1207)
|
|
1237
|
+
// Telegraf's bot.command() relies on Telegram's bot_command entities. In rare cases,
|
|
1238
|
+
// messages may not have the expected entity at offset 0 (e.g., certain clients, edge cases
|
|
1239
|
+
// with message formatting, or entity ordering), causing bot.command() to silently skip
|
|
1240
|
+
// the message. This fallback uses text pattern matching to catch those missed commands.
|
|
1241
|
+
// It runs AFTER bot.command() handlers, so it only fires when entity-based matching fails.
|
|
1242
|
+
bot.on('message', async (ctx, next) => {
|
|
1243
|
+
const text = ctx.message?.text;
|
|
1244
|
+
if (!text) return next();
|
|
1245
|
+
|
|
1246
|
+
// Extract command from text using the testable filter function
|
|
1247
|
+
// Note: We pass null for botUsername here and check it separately with ctx.me
|
|
1248
|
+
// which is set by Telegraf after bot initialization
|
|
1249
|
+
const extracted = extractCommandFromText(text);
|
|
1250
|
+
if (!extracted) return next();
|
|
1251
|
+
|
|
1252
|
+
// If command mentions a specific bot, verify it's us
|
|
1253
|
+
if (extracted.botMention) {
|
|
1254
|
+
const myUsername = ctx.me; // Telegraf sets this from getMe()
|
|
1255
|
+
if (!myUsername || extracted.botMention.toLowerCase() !== myUsername.toLowerCase()) {
|
|
1256
|
+
return next(); // Command is for a different bot or we can't verify
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Check if this is a command we handle
|
|
1261
|
+
const handlers = {
|
|
1262
|
+
solve: handleSolveCommand,
|
|
1263
|
+
hive: handleHiveCommand,
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
const handler = handlers[extracted.command];
|
|
1267
|
+
if (!handler) return next();
|
|
1268
|
+
|
|
1269
|
+
// Log that fallback was triggered - this indicates bot.command() entity matching failed
|
|
1270
|
+
console.warn(`[WARNING] Command /${extracted.command} matched by text fallback, not by entity-based bot.command(). ` + `Entities: ${JSON.stringify(ctx.message.entities || [])}. ` + `User: ${ctx.from?.username || ctx.from?.id}. ` + `This may indicate a Telegram client entity issue (issue #1207).`);
|
|
1271
|
+
|
|
1272
|
+
await handler(ctx);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1302
1275
|
// Add global error handler for uncaught errors in middleware
|
|
1303
1276
|
bot.catch((error, ctx) => {
|
|
1304
1277
|
console.error('Unhandled error while processing update', ctx.update.update_id);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message filtering functions for Telegram bot.
|
|
3
|
+
* Extracted from telegram-bot.mjs for testability and reuse.
|
|
4
|
+
*
|
|
5
|
+
* These filters determine whether incoming messages should be processed
|
|
6
|
+
* or silently ignored by the bot's command handlers.
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1207
|
|
9
|
+
* @see https://core.telegram.org/bots/features#privacy-mode
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if a message was sent before the bot started.
|
|
14
|
+
* Prevents processing old/pending messages from before the current bot instance startup.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} ctx - Telegraf context object
|
|
17
|
+
* @param {number} botStartTime - Unix timestamp (seconds) of when bot started
|
|
18
|
+
* @param {Object} [options] - Options
|
|
19
|
+
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
20
|
+
* @returns {boolean} true if message is old and should be ignored
|
|
21
|
+
*/
|
|
22
|
+
export function isOldMessage(ctx, botStartTime, options = {}) {
|
|
23
|
+
const messageDate = ctx.message?.date;
|
|
24
|
+
if (!messageDate) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const isOld = messageDate < botStartTime;
|
|
28
|
+
if (options.verbose && isOld) {
|
|
29
|
+
console.log(`[VERBOSE] isOldMessage: TRUE - message date ${messageDate} < bot start ${botStartTime}`);
|
|
30
|
+
}
|
|
31
|
+
return isOld;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the chat is a group or supergroup.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} ctx - Telegraf context object
|
|
38
|
+
* @returns {boolean} true if chat is a group or supergroup
|
|
39
|
+
*/
|
|
40
|
+
export function isGroupChat(ctx) {
|
|
41
|
+
const chatType = ctx.chat?.type;
|
|
42
|
+
return chatType === 'group' || chatType === 'supergroup';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a chat ID is in the allowed chats whitelist.
|
|
47
|
+
*
|
|
48
|
+
* @param {number} chatId - The chat ID to check
|
|
49
|
+
* @param {number[]|null} allowedChats - Array of allowed chat IDs, or null for no restrictions
|
|
50
|
+
* @returns {boolean} true if chat is authorized
|
|
51
|
+
*/
|
|
52
|
+
export function isChatAuthorized(chatId, allowedChats) {
|
|
53
|
+
if (!allowedChats) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return allowedChats.includes(chatId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a message is forwarded or a reply to another user's message.
|
|
61
|
+
*
|
|
62
|
+
* This function distinguishes between:
|
|
63
|
+
* 1. Forwarded messages (should be ignored)
|
|
64
|
+
* 2. User replies to other messages (should be ignored, except for /solve reply feature)
|
|
65
|
+
* 3. Forum topic messages (should NOT be ignored - they have reply_to_message pointing
|
|
66
|
+
* to the topic's first message with forum_topic_created)
|
|
67
|
+
* 4. Normal messages (should NOT be ignored)
|
|
68
|
+
*
|
|
69
|
+
* @param {Object} ctx - Telegraf context object
|
|
70
|
+
* @param {Object} [options] - Options
|
|
71
|
+
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
72
|
+
* @returns {boolean} true if message is forwarded or a reply (and should be filtered)
|
|
73
|
+
*/
|
|
74
|
+
export function isForwardedOrReply(ctx, options = {}) {
|
|
75
|
+
const message = ctx.message;
|
|
76
|
+
if (!message) {
|
|
77
|
+
if (options.verbose) {
|
|
78
|
+
console.log('[VERBOSE] isForwardedOrReply: No message object');
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.verbose) {
|
|
84
|
+
console.log('[VERBOSE] isForwardedOrReply: Checking message fields...');
|
|
85
|
+
console.log('[VERBOSE] message.forward_origin:', JSON.stringify(message.forward_origin));
|
|
86
|
+
console.log('[VERBOSE] message.forward_origin?.type:', message.forward_origin?.type);
|
|
87
|
+
console.log('[VERBOSE] message.forward_from:', JSON.stringify(message.forward_from));
|
|
88
|
+
console.log('[VERBOSE] message.forward_from_chat:', JSON.stringify(message.forward_from_chat));
|
|
89
|
+
console.log('[VERBOSE] message.forward_from_message_id:', message.forward_from_message_id);
|
|
90
|
+
console.log('[VERBOSE] message.forward_signature:', message.forward_signature);
|
|
91
|
+
console.log('[VERBOSE] message.forward_sender_name:', message.forward_sender_name);
|
|
92
|
+
console.log('[VERBOSE] message.forward_date:', message.forward_date);
|
|
93
|
+
console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
|
|
94
|
+
console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check if message is forwarded (has forward_origin field with actual content)
|
|
98
|
+
// Note: We check for .type because Telegram might send empty objects {}
|
|
99
|
+
// which are truthy in JavaScript but don't indicate a forwarded message
|
|
100
|
+
if (message.forward_origin && message.forward_origin.type) {
|
|
101
|
+
if (options.verbose) {
|
|
102
|
+
console.log('[VERBOSE] isForwardedOrReply: TRUE - forward_origin.type exists:', message.forward_origin.type);
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
// Also check old forwarding API fields for backward compatibility
|
|
107
|
+
if (message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date) {
|
|
108
|
+
if (options.verbose) {
|
|
109
|
+
console.log('[VERBOSE] isForwardedOrReply: TRUE - old forwarding API field detected');
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
// Check if message is a reply (has reply_to_message field with actual content)
|
|
114
|
+
// Note: We check for .message_id because Telegram might send empty objects {}
|
|
115
|
+
// IMPORTANT: In forum groups, messages in topics have reply_to_message pointing to the topic's
|
|
116
|
+
// first message (with forum_topic_created). These are NOT user replies, just part of the thread.
|
|
117
|
+
// We must exclude these to allow commands in forum topics.
|
|
118
|
+
if (message.reply_to_message && message.reply_to_message.message_id) {
|
|
119
|
+
// If the reply_to_message is a forum topic creation message, this is NOT a user reply
|
|
120
|
+
if (message.reply_to_message.forum_topic_created) {
|
|
121
|
+
if (options.verbose) {
|
|
122
|
+
console.log('[VERBOSE] isForwardedOrReply: FALSE - reply is to forum topic creation, not user reply');
|
|
123
|
+
console.log('[VERBOSE] Forum topic:', message.reply_to_message.forum_topic_created);
|
|
124
|
+
}
|
|
125
|
+
// This is just a message in a forum topic, not a reply to another user
|
|
126
|
+
// Allow the message to proceed
|
|
127
|
+
} else {
|
|
128
|
+
// This is an actual reply to another user's message
|
|
129
|
+
if (options.verbose) {
|
|
130
|
+
console.log('[VERBOSE] isForwardedOrReply: TRUE - reply_to_message.message_id exists:', message.reply_to_message.message_id);
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (options.verbose) {
|
|
137
|
+
console.log('[VERBOSE] isForwardedOrReply: FALSE - no forwarding or reply detected');
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extract a bot command from message text using text-based pattern matching.
|
|
144
|
+
* This is a fallback for when Telegraf's entity-based bot.command() fails
|
|
145
|
+
* to match due to missing or malformed bot_command entities.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} text - Message text
|
|
148
|
+
* @param {string|null} [botUsername] - Bot's username for @mention validation (case-insensitive)
|
|
149
|
+
* @returns {{ command: string, botMention: string|null } | null} Extracted command info, or null if no command found
|
|
150
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1207
|
|
151
|
+
*/
|
|
152
|
+
export function extractCommandFromText(text, botUsername = null) {
|
|
153
|
+
if (!text || typeof text !== 'string') {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const match = text.match(/^\/(\w+)(?:@(\S+))?\s*/);
|
|
158
|
+
if (!match) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const command = match[1].toLowerCase();
|
|
163
|
+
const botMention = match[2] || null;
|
|
164
|
+
|
|
165
|
+
// If command mentions a specific bot, verify it matches ours
|
|
166
|
+
if (botMention && botUsername) {
|
|
167
|
+
if (botMention.toLowerCase() !== botUsername.toLowerCase()) {
|
|
168
|
+
return null; // Command is for a different bot
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { command, botMention };
|
|
173
|
+
}
|