@link-assistant/hive-mind 1.43.0 → 1.45.0
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 +39 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +4 -4
- package/src/lino.lib.mjs +42 -0
- package/src/solve.config.lib.mjs +19 -0
- package/src/solve.progress-monitoring.lib.mjs +436 -0
- package/src/telegram-accept-invitations.lib.mjs +8 -6
- package/src/telegram-bot.mjs +61 -59
- package/src/telegram-merge-command.lib.mjs +11 -6
- package/src/telegram-solve-queue-command.lib.mjs +8 -7
- package/src/telegram-top-command.lib.mjs +10 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.45.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c308660: Add experimental live progress monitoring for work sessions
|
|
8
|
+
- Implement `--working-session-live-progress [comment|pr]` CLI flag for both solve and hive commands
|
|
9
|
+
- `comment` mode (default): Creates a per-session PR comment with updatable progress section
|
|
10
|
+
- `pr` mode: Updates PR description with live progress section
|
|
11
|
+
- Plain `--working-session-live-progress` defaults to `comment` mode
|
|
12
|
+
- Create progress monitoring module (`solve.progress-monitoring.lib.mjs`) with:
|
|
13
|
+
- Live TODO list tracking from TodoWrite tool calls
|
|
14
|
+
- Progress bar visualization (percentage complete)
|
|
15
|
+
- Comment mode: creates/edits a dedicated PR comment per work session
|
|
16
|
+
- PR mode: updates PR description with progress section
|
|
17
|
+
- Task list is always shown expanded (never collapsible)
|
|
18
|
+
- Rate limiting to avoid GitHub API throttling
|
|
19
|
+
- Integrate progress monitoring into claude.lib.mjs event stream processing
|
|
20
|
+
- Detects TodoWrite tool_use events (assistant) and tool_use_result events (user)
|
|
21
|
+
- Updates progress when TodoWrite tool is invoked
|
|
22
|
+
- Displays task completion stats and progress bar
|
|
23
|
+
- Supports work session identification
|
|
24
|
+
- Works with or without `--interactive-mode` (independent feature)
|
|
25
|
+
- Auto-registered in hive via SOLVE_OPTION_DEFINITIONS (no manual forwarding needed)
|
|
26
|
+
- Add comprehensive test suite (89 tests) covering:
|
|
27
|
+
- Progress calculation and formatting
|
|
28
|
+
- Display mode normalization
|
|
29
|
+
- CLI configuration in solve and hive
|
|
30
|
+
- Auto-registration and forwarding via getSolvePassthroughOptionNames
|
|
31
|
+
- Claude integration for TodoWrite detection
|
|
32
|
+
- Comment and PR display modes
|
|
33
|
+
- Feature is experimental, opt-in via `--working-session-live-progress`
|
|
34
|
+
- Implements issue #936
|
|
35
|
+
|
|
36
|
+
## 1.44.0
|
|
37
|
+
|
|
38
|
+
### Minor Changes
|
|
39
|
+
|
|
40
|
+
- e7ce2dd: Add TELEGRAM_ALLOWED_TOPICS for forum topic filtering (issue #1100)
|
|
41
|
+
|
|
3
42
|
## 1.43.0
|
|
4
43
|
|
|
5
44
|
### Minor Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { reportError } from './sentry.lib.mjs';
|
|
|
11
11
|
import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET, getMaxOutputTokensForModel } from './config.lib.mjs';
|
|
12
12
|
import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
|
|
13
13
|
import { createInteractiveHandler } from './interactive-mode.lib.mjs';
|
|
14
|
+
import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
|
|
14
15
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
15
16
|
import { displayBudgetStats, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison, mergeResultModelUsage } from './claude.budget-stats.lib.mjs';
|
|
16
17
|
import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
|
|
@@ -668,10 +669,8 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
|
|
|
668
669
|
export const isStderrError = message => {
|
|
669
670
|
const trimmed = message.trim();
|
|
670
671
|
if (!trimmed) return false;
|
|
671
|
-
|
|
672
672
|
// Detection 1: Emoji-prefixed warnings (Issue #477)
|
|
673
673
|
let isWarning = trimmed.startsWith('⚠️') || trimmed.startsWith('⚠');
|
|
674
|
-
|
|
675
674
|
// Detection 2: JSON-structured log messages (Issue #1337)
|
|
676
675
|
if (!isWarning && trimmed.startsWith('{')) {
|
|
677
676
|
try {
|
|
@@ -687,13 +686,11 @@ export const isStderrError = message => {
|
|
|
687
686
|
// Not valid JSON — fall through to keyword matching
|
|
688
687
|
}
|
|
689
688
|
}
|
|
690
|
-
|
|
691
689
|
if (!isWarning && (trimmed.includes('Error:') || trimmed.includes('error') || trimmed.includes('failed') || trimmed.includes('not found'))) {
|
|
692
690
|
return true;
|
|
693
691
|
}
|
|
694
692
|
return false;
|
|
695
693
|
};
|
|
696
|
-
|
|
697
694
|
export const executeClaudeCommand = async params => {
|
|
698
695
|
const {
|
|
699
696
|
tempDir,
|
|
@@ -796,6 +793,7 @@ export const executeClaudeCommand = async params => {
|
|
|
796
793
|
} else if (argv.interactiveMode) {
|
|
797
794
|
await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
|
|
798
795
|
}
|
|
796
|
+
const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
|
|
799
797
|
let execCommand;
|
|
800
798
|
const mappedModel = mapModelToId(argv.model);
|
|
801
799
|
const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
|
|
@@ -962,6 +960,7 @@ export const executeClaudeCommand = async params => {
|
|
|
962
960
|
}
|
|
963
961
|
if (data.type === 'message') messageCount++;
|
|
964
962
|
else if (data.type === 'tool_use') toolUseCount++;
|
|
963
|
+
if (progressMonitor) await progressMonitor.processStreamEvent(data).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
|
|
965
964
|
if (data.type === 'result') {
|
|
966
965
|
if (!resultEventReceived) {
|
|
967
966
|
resultEventReceived = true;
|
|
@@ -1121,6 +1120,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1121
1120
|
await log(`⚠️ Interactive mode error (remaining buffer): ${interactiveError.message}`, { verbose: true });
|
|
1122
1121
|
}
|
|
1123
1122
|
}
|
|
1123
|
+
if (progressMonitor) await progressMonitor.processStreamEvent(data, true).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
|
|
1124
1124
|
} catch {
|
|
1125
1125
|
if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'raw' });
|
|
1126
1126
|
}
|
package/src/lino.lib.mjs
CHANGED
|
@@ -96,6 +96,48 @@ export class LinksNotationManager {
|
|
|
96
96
|
return [];
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
parseLinks(input) {
|
|
100
|
+
if (!input) return [];
|
|
101
|
+
|
|
102
|
+
const parsed = this.parser.parse(input);
|
|
103
|
+
if (!parsed || parsed.length === 0) return [];
|
|
104
|
+
|
|
105
|
+
const link = parsed[0];
|
|
106
|
+
const pairs = [];
|
|
107
|
+
|
|
108
|
+
if (link.values && link.values.length > 0) {
|
|
109
|
+
const flatNumbers = [];
|
|
110
|
+
|
|
111
|
+
for (const value of link.values) {
|
|
112
|
+
if (value.id === null && value.values && value.values.length >= 2) {
|
|
113
|
+
const source = parseInt(value.values[0]?.id || value.values[0], 10);
|
|
114
|
+
const target = parseInt(value.values[1]?.id || value.values[1], 10);
|
|
115
|
+
if (!isNaN(source) && !isNaN(target)) {
|
|
116
|
+
pairs.push({ source, target });
|
|
117
|
+
}
|
|
118
|
+
} else if (value.id) {
|
|
119
|
+
const num = parseInt(value.id, 10);
|
|
120
|
+
if (!isNaN(num)) {
|
|
121
|
+
flatNumbers.push(num);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < flatNumbers.length - 1; i += 2) {
|
|
127
|
+
pairs.push({ source: flatNumbers[i], target: flatNumbers[i + 1] });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return pairs;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
formatLinks(pairs) {
|
|
135
|
+
if (!pairs || pairs.length === 0) return '()';
|
|
136
|
+
|
|
137
|
+
const formattedValues = pairs.map(pair => ` ${pair.source} ${pair.target}`).join('\n');
|
|
138
|
+
return `(\n${formattedValues}\n)`;
|
|
139
|
+
}
|
|
140
|
+
|
|
99
141
|
format(values) {
|
|
100
142
|
if (!values || values.length === 0) return '()';
|
|
101
143
|
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -429,6 +429,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
429
429
|
description: '[EXPERIMENTAL] Model to use for --finalize iterations. Defaults to the same model as --model.',
|
|
430
430
|
default: undefined,
|
|
431
431
|
},
|
|
432
|
+
'working-session-live-progress': {
|
|
433
|
+
type: 'string',
|
|
434
|
+
description: '[EXPERIMENTAL] Enable live progress monitoring. Accepts "comment" (default, updates a per-session PR comment) or "pr" (updates PR description). Plain --working-session-live-progress means "comment". Works with or without --interactive-mode.',
|
|
435
|
+
default: false,
|
|
436
|
+
},
|
|
432
437
|
};
|
|
433
438
|
|
|
434
439
|
// Function to create yargs configuration - avoids duplication
|
|
@@ -607,6 +612,20 @@ export const parseArguments = async (yargs, hideBin) => {
|
|
|
607
612
|
}
|
|
608
613
|
}
|
|
609
614
|
|
|
615
|
+
// --working-session-live-progress normalization
|
|
616
|
+
// When passed as --working-session-live-progress (no value), yargs gives true for string type
|
|
617
|
+
// Normalize: true → "comment", validate known values
|
|
618
|
+
if (argv && argv.workingSessionLiveProgress) {
|
|
619
|
+
const val = argv.workingSessionLiveProgress;
|
|
620
|
+
if (val === true || val === 'true') {
|
|
621
|
+
argv.workingSessionLiveProgress = 'comment';
|
|
622
|
+
} else if (typeof val === 'string' && !['comment', 'pr'].includes(val.toLowerCase())) {
|
|
623
|
+
throw new Error(`Invalid --working-session-live-progress value: "${val}". Expected "comment" or "pr".`);
|
|
624
|
+
} else if (typeof val === 'string') {
|
|
625
|
+
argv.workingSessionLiveProgress = val.toLowerCase();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
610
629
|
// Validate --base-branch value (issue #1482: reject URLs and invalid git branch names)
|
|
611
630
|
if (argv.baseBranch) {
|
|
612
631
|
const branchValidation = validateBranchName(argv.baseBranch);
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Progress Monitoring Library
|
|
4
|
+
*
|
|
5
|
+
* [EXPERIMENTAL] This module provides live progress monitoring for work sessions
|
|
6
|
+
* by tracking TODO list updates and reflecting them in PR comments or descriptions.
|
|
7
|
+
*
|
|
8
|
+
* Display modes:
|
|
9
|
+
* - "comment" (default): Creates a per-session PR comment with updatable progress section
|
|
10
|
+
* - "pr": Updates the PR description with a live progress section
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - Tracks TODO list state from TodoWrite tool calls
|
|
14
|
+
* - Calculates progress percentage (completed/total)
|
|
15
|
+
* - Generates progress bar visualization
|
|
16
|
+
* - Task list is always shown expanded (never collapsible)
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* const { createProgressMonitor } = await import('./solve.progress-monitoring.lib.mjs');
|
|
20
|
+
* const monitor = createProgressMonitor({ owner, repo, prNumber, $, log, displayMode: 'comment' });
|
|
21
|
+
* await monitor.updateProgress(todos);
|
|
22
|
+
*
|
|
23
|
+
* @module solve.progress-monitoring.lib.mjs
|
|
24
|
+
* @experimental
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration constants for progress monitoring
|
|
29
|
+
*/
|
|
30
|
+
const CONFIG = {
|
|
31
|
+
// Progress bar width in characters
|
|
32
|
+
PROGRESS_BAR_WIDTH: 30,
|
|
33
|
+
// Progress bar characters
|
|
34
|
+
PROGRESS_CHAR_FILLED: '█',
|
|
35
|
+
PROGRESS_CHAR_EMPTY: '░',
|
|
36
|
+
// Marker comments for identifying the progress section
|
|
37
|
+
PROGRESS_SECTION_START: '<!-- LIVE-PROGRESS-START -->',
|
|
38
|
+
PROGRESS_SECTION_END: '<!-- LIVE-PROGRESS-END -->',
|
|
39
|
+
// Minimum interval between PR description updates (in ms)
|
|
40
|
+
MIN_UPDATE_INTERVAL: 10000, // 10 seconds to avoid rate limiting
|
|
41
|
+
// Valid display modes
|
|
42
|
+
DISPLAY_MODES: ['comment', 'pr'],
|
|
43
|
+
// Default display mode when enabled without explicit value
|
|
44
|
+
DEFAULT_DISPLAY_MODE: 'comment',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a progress bar visualization
|
|
49
|
+
*
|
|
50
|
+
* @param {number} percentage - Progress percentage (0-100)
|
|
51
|
+
* @param {number} width - Width of the progress bar in characters
|
|
52
|
+
* @returns {string} Progress bar string
|
|
53
|
+
*/
|
|
54
|
+
const generateProgressBar = (percentage, width = CONFIG.PROGRESS_BAR_WIDTH) => {
|
|
55
|
+
const clamped = Math.max(0, Math.min(100, percentage));
|
|
56
|
+
const filled = Math.round((clamped / 100) * width);
|
|
57
|
+
const empty = width - filled;
|
|
58
|
+
return CONFIG.PROGRESS_CHAR_FILLED.repeat(filled) + CONFIG.PROGRESS_CHAR_EMPTY.repeat(empty);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Calculate progress statistics from TODO list
|
|
63
|
+
*
|
|
64
|
+
* @param {Array<Object>} todos - Array of TODO items with status property
|
|
65
|
+
* @returns {Object} Progress statistics
|
|
66
|
+
*/
|
|
67
|
+
const calculateProgress = todos => {
|
|
68
|
+
if (!todos || !Array.isArray(todos) || todos.length === 0) {
|
|
69
|
+
return {
|
|
70
|
+
total: 0,
|
|
71
|
+
completed: 0,
|
|
72
|
+
inProgress: 0,
|
|
73
|
+
pending: 0,
|
|
74
|
+
percentage: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const total = todos.length;
|
|
79
|
+
const completed = todos.filter(t => t.status === 'completed').length;
|
|
80
|
+
const inProgress = todos.filter(t => t.status === 'in_progress').length;
|
|
81
|
+
const pending = todos.filter(t => t.status === 'pending').length;
|
|
82
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
83
|
+
|
|
84
|
+
return { total, completed, inProgress, pending, percentage };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Format TODO list for display
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<Object>} todos - Array of TODO items
|
|
91
|
+
* @param {number} maxDisplay - Maximum number of TODOs to show (0 for all)
|
|
92
|
+
* @returns {string} Formatted TODO list
|
|
93
|
+
*/
|
|
94
|
+
const formatTodoList = (todos, maxDisplay = 0) => {
|
|
95
|
+
if (!todos || todos.length === 0) {
|
|
96
|
+
return '_No tasks yet_';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const getStatusIcon = status => {
|
|
100
|
+
switch (status) {
|
|
101
|
+
case 'completed':
|
|
102
|
+
return '[x]';
|
|
103
|
+
case 'in_progress':
|
|
104
|
+
return '[~]';
|
|
105
|
+
case 'pending':
|
|
106
|
+
return '[ ]';
|
|
107
|
+
default:
|
|
108
|
+
return '[ ]';
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
let todosToShow = todos;
|
|
113
|
+
if (maxDisplay > 0 && todos.length > maxDisplay) {
|
|
114
|
+
const half = Math.floor(maxDisplay / 2);
|
|
115
|
+
const firstHalf = todos.slice(0, half);
|
|
116
|
+
const secondHalf = todos.slice(-half);
|
|
117
|
+
const skipped = todos.length - maxDisplay;
|
|
118
|
+
|
|
119
|
+
todosToShow = [...firstHalf, { content: `_...and ${skipped} more tasks_`, status: 'info' }, ...secondHalf];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return todosToShow
|
|
123
|
+
.map(todo => {
|
|
124
|
+
if (todo.status === 'info') {
|
|
125
|
+
return `- ${todo.content}`;
|
|
126
|
+
}
|
|
127
|
+
return `- ${getStatusIcon(todo.status)} ${todo.content}`;
|
|
128
|
+
})
|
|
129
|
+
.join('\n');
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generate the progress section markdown (never collapsible)
|
|
134
|
+
*
|
|
135
|
+
* @param {Array<Object>} todos - Array of TODO items
|
|
136
|
+
* @param {string} sessionId - Work session identifier (optional)
|
|
137
|
+
* @returns {string} Progress section markdown
|
|
138
|
+
*/
|
|
139
|
+
const generateProgressSection = (todos, sessionId = null) => {
|
|
140
|
+
const stats = calculateProgress(todos);
|
|
141
|
+
const progressBar = generateProgressBar(stats.percentage);
|
|
142
|
+
const timestamp = new Date().toISOString();
|
|
143
|
+
|
|
144
|
+
return `${CONFIG.PROGRESS_SECTION_START}
|
|
145
|
+
## 📊 Live Progress Monitor
|
|
146
|
+
|
|
147
|
+
**Session:** ${sessionId || 'Current'}
|
|
148
|
+
**Last Updated:** ${timestamp}
|
|
149
|
+
|
|
150
|
+
### Progress: ${stats.percentage}% Complete
|
|
151
|
+
|
|
152
|
+
\`\`\`
|
|
153
|
+
${progressBar} ${stats.percentage}%
|
|
154
|
+
\`\`\`
|
|
155
|
+
|
|
156
|
+
**Tasks:** ${stats.completed}/${stats.total} completed · ${stats.inProgress} in progress · ${stats.pending} pending
|
|
157
|
+
|
|
158
|
+
📋 **Task List** (${stats.total} total)
|
|
159
|
+
|
|
160
|
+
${formatTodoList(todos)}
|
|
161
|
+
|
|
162
|
+
${CONFIG.PROGRESS_SECTION_END}`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Normalize the display mode value.
|
|
167
|
+
* - false/falsy → null (disabled)
|
|
168
|
+
* - true or "true" → default mode ("comment")
|
|
169
|
+
* - "comment" or "pr" → that mode
|
|
170
|
+
*
|
|
171
|
+
* @param {*} value - Raw option value
|
|
172
|
+
* @returns {string|null} Normalized display mode or null if disabled
|
|
173
|
+
*/
|
|
174
|
+
export const normalizeDisplayMode = value => {
|
|
175
|
+
if (!value || value === 'false') return null;
|
|
176
|
+
if (value === true || value === 'true') return CONFIG.DEFAULT_DISPLAY_MODE;
|
|
177
|
+
const mode = String(value).toLowerCase();
|
|
178
|
+
if (CONFIG.DISPLAY_MODES.includes(mode)) return mode;
|
|
179
|
+
// Unknown value falls back to default
|
|
180
|
+
return CONFIG.DEFAULT_DISPLAY_MODE;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create a progress monitor instance
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} options - Configuration options
|
|
187
|
+
* @param {string} options.owner - Repository owner
|
|
188
|
+
* @param {string} options.repo - Repository name
|
|
189
|
+
* @param {number} options.prNumber - Pull request number
|
|
190
|
+
* @param {Function} options.$ - Zx executor function
|
|
191
|
+
* @param {Function} options.log - Logging function
|
|
192
|
+
* @param {boolean} options.verbose - Enable verbose logging
|
|
193
|
+
* @param {string} options.sessionId - Work session identifier
|
|
194
|
+
* @param {string} options.displayMode - Display mode: "comment" or "pr"
|
|
195
|
+
* @returns {Object} Progress monitor instance
|
|
196
|
+
*/
|
|
197
|
+
export const createProgressMonitor = ({ owner, repo, prNumber, $, log, verbose = false, sessionId = null, displayMode = 'comment' }) => {
|
|
198
|
+
const state = {
|
|
199
|
+
lastUpdate: 0,
|
|
200
|
+
currentTodos: null,
|
|
201
|
+
sessionId: sessionId || `session-${Date.now()}`,
|
|
202
|
+
commentId: null, // For comment mode: the ID of the progress comment to update
|
|
203
|
+
displayMode: displayMode || CONFIG.DEFAULT_DISPLAY_MODE,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Update progress via PR comment (comment mode)
|
|
208
|
+
* Creates a new comment on first call, then edits it on subsequent calls.
|
|
209
|
+
*
|
|
210
|
+
* @param {Array<Object>} todos - Array of TODO items
|
|
211
|
+
* @returns {Promise<boolean>} True if update was successful
|
|
212
|
+
*/
|
|
213
|
+
const updateProgressComment = async todos => {
|
|
214
|
+
try {
|
|
215
|
+
state.currentTodos = todos;
|
|
216
|
+
const progressSection = generateProgressSection(todos, state.sessionId);
|
|
217
|
+
|
|
218
|
+
if (state.commentId) {
|
|
219
|
+
// Edit existing comment
|
|
220
|
+
const fs = (await import('fs')).promises;
|
|
221
|
+
const tempFile = `/tmp/pr-progress-comment-${prNumber}-${Date.now()}.md`;
|
|
222
|
+
await fs.writeFile(tempFile, progressSection);
|
|
223
|
+
await $`gh api repos/${owner}/${repo}/issues/comments/${state.commentId} --method PATCH --field body=@${tempFile}`;
|
|
224
|
+
await fs.unlink(tempFile).catch(() => {});
|
|
225
|
+
} else {
|
|
226
|
+
// Create new comment
|
|
227
|
+
const fs = (await import('fs')).promises;
|
|
228
|
+
const tempFile = `/tmp/pr-progress-comment-${prNumber}-${Date.now()}.md`;
|
|
229
|
+
await fs.writeFile(tempFile, progressSection);
|
|
230
|
+
const result = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body-file ${tempFile}`;
|
|
231
|
+
await fs.unlink(tempFile).catch(() => {});
|
|
232
|
+
|
|
233
|
+
// Extract comment ID from the created comment URL for future edits
|
|
234
|
+
// gh pr comment outputs the URL of the created comment
|
|
235
|
+
const output = result.stdout?.toString?.() || '';
|
|
236
|
+
const urlMatch = output.match(/\/comments\/(\d+)/);
|
|
237
|
+
if (urlMatch) {
|
|
238
|
+
state.commentId = urlMatch[1];
|
|
239
|
+
} else {
|
|
240
|
+
// Fallback: find the comment we just created by looking for our marker
|
|
241
|
+
const commentsResult = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --jq ${`[.[] | select(.body | contains("${CONFIG.PROGRESS_SECTION_START}")) | .id] | last`}`;
|
|
242
|
+
const commentId = commentsResult.stdout?.toString?.().trim();
|
|
243
|
+
if (commentId && commentId !== 'null') {
|
|
244
|
+
state.commentId = commentId;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const stats = calculateProgress(todos);
|
|
250
|
+
await log(`📊 Updated progress comment: ${stats.percentage}% (${stats.completed}/${stats.total} tasks completed)`);
|
|
251
|
+
return true;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
await log(`⚠️ Failed to update progress comment: ${error.message}`);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Update progress via PR description (pr mode)
|
|
260
|
+
*
|
|
261
|
+
* @param {Array<Object>} todos - Array of TODO items
|
|
262
|
+
* @returns {Promise<boolean>} True if update was successful
|
|
263
|
+
*/
|
|
264
|
+
const updateProgressPrDescription = async todos => {
|
|
265
|
+
try {
|
|
266
|
+
state.currentTodos = todos;
|
|
267
|
+
|
|
268
|
+
// Fetch current PR description
|
|
269
|
+
const prData = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json body`;
|
|
270
|
+
const prInfo = JSON.parse(prData.stdout);
|
|
271
|
+
let currentBody = prInfo.body || '';
|
|
272
|
+
|
|
273
|
+
// Generate new progress section
|
|
274
|
+
const progressSection = generateProgressSection(todos, state.sessionId);
|
|
275
|
+
|
|
276
|
+
// Check if progress section already exists
|
|
277
|
+
const hasProgressSection = currentBody.includes(CONFIG.PROGRESS_SECTION_START);
|
|
278
|
+
|
|
279
|
+
let updatedBody;
|
|
280
|
+
if (hasProgressSection) {
|
|
281
|
+
// Replace existing progress section
|
|
282
|
+
const startIdx = currentBody.indexOf(CONFIG.PROGRESS_SECTION_START);
|
|
283
|
+
const endIdx = currentBody.indexOf(CONFIG.PROGRESS_SECTION_END);
|
|
284
|
+
|
|
285
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
286
|
+
updatedBody = currentBody.substring(0, startIdx) + progressSection + currentBody.substring(endIdx + CONFIG.PROGRESS_SECTION_END.length);
|
|
287
|
+
} else {
|
|
288
|
+
updatedBody = currentBody + '\n\n' + progressSection;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
updatedBody = currentBody + '\n\n' + progressSection;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Write to temp file and update PR
|
|
295
|
+
const fs = (await import('fs')).promises;
|
|
296
|
+
const tempBodyFile = `/tmp/pr-progress-${prNumber}-${Date.now()}.md`;
|
|
297
|
+
await fs.writeFile(tempBodyFile, updatedBody);
|
|
298
|
+
await $`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
|
|
299
|
+
await fs.unlink(tempBodyFile).catch(() => {});
|
|
300
|
+
|
|
301
|
+
const stats = calculateProgress(todos);
|
|
302
|
+
await log(`📊 Updated PR progress: ${stats.percentage}% (${stats.completed}/${stats.total} tasks completed)`);
|
|
303
|
+
return true;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
await log(`⚠️ Failed to update PR progress: ${error.message}`);
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Update progress using the configured display mode
|
|
312
|
+
*
|
|
313
|
+
* @param {Array<Object>} todos - Array of TODO items
|
|
314
|
+
* @param {boolean} force - Force update even if within rate limit interval
|
|
315
|
+
* @returns {Promise<boolean>} True if update was successful
|
|
316
|
+
*/
|
|
317
|
+
const updateProgress = async (todos, force = false) => {
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
|
|
320
|
+
// Rate limiting: don't update too frequently unless forced
|
|
321
|
+
if (!force && now - state.lastUpdate < CONFIG.MIN_UPDATE_INTERVAL) {
|
|
322
|
+
if (verbose) {
|
|
323
|
+
await log(`⏭️ Skipping progress update (rate limited, ${Math.round((CONFIG.MIN_UPDATE_INTERVAL - (now - state.lastUpdate)) / 1000)}s remaining)`, { verbose: true });
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let result;
|
|
329
|
+
if (state.displayMode === 'pr') {
|
|
330
|
+
result = await updateProgressPrDescription(todos);
|
|
331
|
+
} else {
|
|
332
|
+
result = await updateProgressComment(todos);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (result) {
|
|
336
|
+
state.lastUpdate = now;
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get current progress statistics
|
|
343
|
+
*
|
|
344
|
+
* @returns {Object} Current progress statistics
|
|
345
|
+
*/
|
|
346
|
+
const getStats = () => {
|
|
347
|
+
return calculateProgress(state.currentTodos);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Generate progress section without updating
|
|
352
|
+
*
|
|
353
|
+
* @param {Array<Object>} todos - Array of TODO items
|
|
354
|
+
* @returns {string} Progress section markdown
|
|
355
|
+
*/
|
|
356
|
+
const generateSection = todos => {
|
|
357
|
+
return generateProgressSection(todos, state.sessionId);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Process a Claude CLI stream event, detecting TodoWrite tool calls
|
|
362
|
+
* and updating progress automatically. Call this for each parsed NDJSON event.
|
|
363
|
+
*
|
|
364
|
+
* @param {Object} data - Parsed JSON event from Claude CLI stream
|
|
365
|
+
* @param {boolean} force - Force update even if within rate limit interval
|
|
366
|
+
* @returns {Promise<boolean>} True if a progress update was triggered
|
|
367
|
+
*/
|
|
368
|
+
const processStreamEvent = async (data, force = false) => {
|
|
369
|
+
if (!data || typeof data !== 'object') return false;
|
|
370
|
+
let updated = false;
|
|
371
|
+
// Pattern 1: assistant event with tool_use containing TodoWrite input
|
|
372
|
+
if (data.type === 'assistant' && data.message?.content) {
|
|
373
|
+
const contentItems = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
|
|
374
|
+
for (const item of contentItems) {
|
|
375
|
+
if (item.type === 'tool_use' && item.name === 'TodoWrite' && item.input?.todos) {
|
|
376
|
+
updated = await updateProgress(item.input.todos, force);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Pattern 2: user event with tool_use_result containing newTodos (confirmation)
|
|
381
|
+
if (data.type === 'user' && data.tool_use_result?.newTodos) {
|
|
382
|
+
updated = await updateProgress(data.tool_use_result.newTodos, force);
|
|
383
|
+
}
|
|
384
|
+
return updated;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
updateProgress,
|
|
389
|
+
processStreamEvent,
|
|
390
|
+
getStats,
|
|
391
|
+
generateSection,
|
|
392
|
+
get currentTodos() {
|
|
393
|
+
return state.currentTodos;
|
|
394
|
+
},
|
|
395
|
+
get sessionId() {
|
|
396
|
+
return state.sessionId;
|
|
397
|
+
},
|
|
398
|
+
get displayMode() {
|
|
399
|
+
return state.displayMode;
|
|
400
|
+
},
|
|
401
|
+
get commentId() {
|
|
402
|
+
return state.commentId;
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Initialize progress monitoring if enabled. Returns null if disabled or missing PR info.
|
|
409
|
+
* Logs status to the provided log function. Designed to minimize integration lines in claude.lib.mjs.
|
|
410
|
+
*
|
|
411
|
+
* @param {Object} argv - Parsed CLI arguments (needs workingSessionLiveProgress)
|
|
412
|
+
* @param {Object} context - { owner, repo, prNumber, $, log }
|
|
413
|
+
* @returns {Promise<Object|null>} Progress monitor instance or null
|
|
414
|
+
*/
|
|
415
|
+
export const initProgressMonitoring = async (argv, { owner, repo, prNumber, $, log }) => {
|
|
416
|
+
const displayMode = normalizeDisplayMode(argv.workingSessionLiveProgress);
|
|
417
|
+
if (!displayMode) return null;
|
|
418
|
+
if (!owner || !repo || !prNumber) {
|
|
419
|
+
await log('⚠️ Live progress monitoring: Disabled - missing PR info', { verbose: true });
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const monitor = createProgressMonitor({ owner, repo, prNumber, $, log, verbose: argv.verbose, sessionId: `session-${Date.now()}`, displayMode });
|
|
423
|
+
await log(`📊 Live progress monitoring: ENABLED (mode: ${displayMode}, session: ${monitor.sessionId})`, { verbose: true });
|
|
424
|
+
return monitor;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Export utility functions for testing and standalone use
|
|
429
|
+
*/
|
|
430
|
+
export const utils = {
|
|
431
|
+
generateProgressBar,
|
|
432
|
+
calculateProgress,
|
|
433
|
+
formatTodoList,
|
|
434
|
+
generateProgressSection,
|
|
435
|
+
CONFIG,
|
|
436
|
+
};
|
|
@@ -116,10 +116,12 @@ function buildProgressMessage(state) {
|
|
|
116
116
|
* @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
|
|
117
117
|
* @param {Function} options.isGroupChat - Function to check if chat is a group
|
|
118
118
|
* @param {Function} options.isChatAuthorized - Function to check if chat is authorized
|
|
119
|
+
* @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
|
|
120
|
+
* @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
|
|
119
121
|
* @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
|
|
120
122
|
*/
|
|
121
123
|
export function registerAcceptInvitesCommand(bot, options) {
|
|
122
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb } = options;
|
|
124
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb } = options;
|
|
123
125
|
|
|
124
126
|
bot.command(/^accept[_-]?invites$/i, async ctx => {
|
|
125
127
|
VERBOSE && console.log('[VERBOSE] /accept_invites command received');
|
|
@@ -134,11 +136,11 @@ export function registerAcceptInvitesCommand(bot, options) {
|
|
|
134
136
|
return await ctx.reply('❌ The /accept_invites command only works in group chats. Please add this bot to a group and make it an admin.', {
|
|
135
137
|
reply_to_message_id: ctx.message.message_id,
|
|
136
138
|
});
|
|
137
|
-
const
|
|
138
|
-
if (!
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
|
|
140
|
+
if (!authorize(ctx)) {
|
|
141
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`;
|
|
142
|
+
return await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
|
|
143
|
+
}
|
|
142
144
|
|
|
143
145
|
const fetchingMessage = await ctx.reply('🔄 Fetching pending GitHub invitations\\.\\.\\.', {
|
|
144
146
|
reply_to_message_id: ctx.message.message_id,
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -78,6 +78,12 @@ const config = yargs(hideBin(process.argv))
|
|
|
78
78
|
alias: 'allowed-chats',
|
|
79
79
|
default: getenv('TELEGRAM_ALLOWED_CHATS', ''),
|
|
80
80
|
})
|
|
81
|
+
.option('allowedTopics', {
|
|
82
|
+
type: 'string',
|
|
83
|
+
description: 'Allowed topic IDs in Links Notation format "chatId topicId" pairs',
|
|
84
|
+
alias: 'allowed-topics',
|
|
85
|
+
default: getenv('TELEGRAM_ALLOWED_TOPICS', ''),
|
|
86
|
+
})
|
|
81
87
|
.option('solveOverrides', {
|
|
82
88
|
type: 'string',
|
|
83
89
|
description: 'Override options for /solve command in lino notation, e.g., "(\n --auto-continue\n --attach-logs\n)"',
|
|
@@ -154,6 +160,10 @@ if (!BOT_TOKEN) {
|
|
|
154
160
|
const resolvedAllowedChats = config.allowedChats || getenv('TELEGRAM_ALLOWED_CHATS', '');
|
|
155
161
|
const allowedChats = resolvedAllowedChats ? lino.parseNumericIds(resolvedAllowedChats) : null;
|
|
156
162
|
|
|
163
|
+
// Parse allowed topics (chatId:topicId pairs in Links Notation)
|
|
164
|
+
const resolvedAllowedTopics = config.allowedTopics || getenv('TELEGRAM_ALLOWED_TOPICS', '');
|
|
165
|
+
const allowedTopics = resolvedAllowedTopics ? lino.parseLinks(resolvedAllowedTopics) : null;
|
|
166
|
+
|
|
157
167
|
// Parse override options
|
|
158
168
|
const resolvedSolveOverrides = config.solveOverrides || getenv('TELEGRAM_SOLVE_OVERRIDES', '');
|
|
159
169
|
const solveOverrides = resolvedSolveOverrides
|
|
@@ -277,6 +287,9 @@ if (config.dryRun) {
|
|
|
277
287
|
} else {
|
|
278
288
|
console.log(' Allowed chats: All (no restrictions)');
|
|
279
289
|
}
|
|
290
|
+
if (allowedTopics && allowedTopics.length > 0) {
|
|
291
|
+
console.log(' Allowed topics:', lino.formatLinks(allowedTopics));
|
|
292
|
+
}
|
|
280
293
|
console.log(' Commands enabled:', { solve: solveEnabled, hive: hiveEnabled });
|
|
281
294
|
if (solveOverrides.length > 0) {
|
|
282
295
|
console.log(' Solve overrides:', lino.format(solveOverrides));
|
|
@@ -319,6 +332,22 @@ function isChatAuthorized(chatId) {
|
|
|
319
332
|
return _isChatAuthorized(chatId, allowedChats);
|
|
320
333
|
}
|
|
321
334
|
|
|
335
|
+
// Topic-level authorization (issue #1100): chat-level auth overrides topic-level
|
|
336
|
+
function isTopicAuthorized(ctx) {
|
|
337
|
+
if (isChatAuthorized(ctx.chat?.id)) return true;
|
|
338
|
+
if (!allowedTopics || allowedTopics.length === 0) return false;
|
|
339
|
+
const chatId = ctx.chat?.id;
|
|
340
|
+
const topicId = ctx.message?.message_thread_id;
|
|
341
|
+
return allowedTopics.some(pair => pair.source === chatId && pair.target === topicId);
|
|
342
|
+
}
|
|
343
|
+
function buildAuthErrorMessage(ctx) {
|
|
344
|
+
const chatId = ctx.chat?.id;
|
|
345
|
+
const topicId = ctx.message?.message_thread_id;
|
|
346
|
+
let msg = `❌ This chat (ID: ${chatId})`;
|
|
347
|
+
if (topicId) msg += ` and topic (ID: ${topicId})`;
|
|
348
|
+
return msg + ' is not authorized.\n\nUse /help to see your chat and topic IDs.';
|
|
349
|
+
}
|
|
350
|
+
|
|
322
351
|
function isOldMessage(ctx) {
|
|
323
352
|
return _isOldMessage(ctx, BOT_START_TIME, { verbose: VERBOSE });
|
|
324
353
|
}
|
|
@@ -621,7 +650,7 @@ bot.command('help', async ctx => {
|
|
|
621
650
|
const chatId = ctx.chat.id;
|
|
622
651
|
const chatType = ctx.chat.type;
|
|
623
652
|
const chatTitle = ctx.chat.title || 'Private Chat';
|
|
624
|
-
|
|
653
|
+
const topicId = ctx.message?.message_thread_id; // Forum topic ID (issue #1100)
|
|
625
654
|
let message = '🤖 *SwarmMindBot Help*\n\n';
|
|
626
655
|
|
|
627
656
|
// Show stopped status if chat is stopped (issue #1081)
|
|
@@ -638,6 +667,7 @@ bot.command('help', async ctx => {
|
|
|
638
667
|
|
|
639
668
|
message += '📋 *Diagnostic Information:*\n';
|
|
640
669
|
message += `• Chat ID: \`${chatId}\`\n`;
|
|
670
|
+
if (topicId) message += `• Topic ID: \`${topicId}\`\n`;
|
|
641
671
|
message += `• Chat Type: ${chatType}\n`;
|
|
642
672
|
message += `• Chat Title: ${chatTitle}\n\n`;
|
|
643
673
|
message += '📝 *Available Commands:*\n\n';
|
|
@@ -685,9 +715,10 @@ bot.command('help', async ctx => {
|
|
|
685
715
|
message += '• `--verbose` or `-v` - Verbose output | `--attach-logs` - Attach logs to PR\n';
|
|
686
716
|
message += '\n💡 *Tip:* Many more options available. See full documentation for complete list.\n';
|
|
687
717
|
|
|
688
|
-
if (allowedChats) {
|
|
689
|
-
|
|
690
|
-
message +=
|
|
718
|
+
if (allowedChats || allowedTopics) {
|
|
719
|
+
const authorized = isTopicAuthorized(ctx);
|
|
720
|
+
message += `\n🔒 *Restricted Mode:* Authorized: ${authorized ? '✅ Yes' : '❌ No'}`;
|
|
721
|
+
if (!authorized && topicId) message += `\n💡 To allow this topic: \`TELEGRAM_ALLOWED_TOPICS="(${chatId} ${topicId})"\``;
|
|
691
722
|
}
|
|
692
723
|
|
|
693
724
|
message += '\n\n🔧 *Troubleshooting:*\n';
|
|
@@ -728,10 +759,11 @@ bot.command('limits', async ctx => {
|
|
|
728
759
|
return;
|
|
729
760
|
}
|
|
730
761
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
762
|
+
if (!isTopicAuthorized(ctx)) {
|
|
763
|
+
if (VERBOSE) {
|
|
764
|
+
console.log('[VERBOSE] /limits ignored: not authorized');
|
|
765
|
+
}
|
|
766
|
+
await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
|
|
735
767
|
return;
|
|
736
768
|
}
|
|
737
769
|
|
|
@@ -768,8 +800,7 @@ bot.command('version', async ctx => {
|
|
|
768
800
|
});
|
|
769
801
|
if (isOldMessage(ctx) || isForwardedOrReply(ctx)) return;
|
|
770
802
|
if (!_isGroupChat(ctx)) return await ctx.reply('❌ The /version 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 });
|
|
771
|
-
|
|
772
|
-
if (!isChatAuthorized(chatId)) return await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, { reply_to_message_id: ctx.message.message_id });
|
|
803
|
+
if (!isTopicAuthorized(ctx)) return await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
|
|
773
804
|
const fetchingMessage = await ctx.reply('🔄 Gathering version information...', {
|
|
774
805
|
reply_to_message_id: ctx.message.message_id,
|
|
775
806
|
});
|
|
@@ -778,48 +809,18 @@ bot.command('version', async ctx => {
|
|
|
778
809
|
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, '🤖 *Version Information*\n\n' + formatVersionMessage(result.versions), { parse_mode: 'Markdown' });
|
|
779
810
|
});
|
|
780
811
|
|
|
781
|
-
// Register
|
|
782
|
-
// This keeps telegram-bot.mjs under the 1500 line limit
|
|
812
|
+
// Register external command modules (keeps telegram-bot.mjs under line limit)
|
|
783
813
|
const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
isOldMessage,
|
|
787
|
-
isForwardedOrReply,
|
|
788
|
-
isGroupChat: _isGroupChat,
|
|
789
|
-
isChatAuthorized,
|
|
790
|
-
addBreadcrumb,
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
// Register /merge command from separate module (experimental, see issue #1143)
|
|
814
|
+
const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
|
|
815
|
+
registerAcceptInvitesCommand(bot, sharedCommandOpts);
|
|
794
816
|
const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
|
|
795
|
-
registerMergeCommand(bot,
|
|
796
|
-
VERBOSE,
|
|
797
|
-
isOldMessage,
|
|
798
|
-
isForwardedOrReply,
|
|
799
|
-
isGroupChat: _isGroupChat,
|
|
800
|
-
isChatAuthorized,
|
|
801
|
-
addBreadcrumb,
|
|
802
|
-
isChatStopped,
|
|
803
|
-
getStoppedChatRejectMessage,
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
// Register /solve_queue command from separate module (issue #1232)
|
|
817
|
+
registerMergeCommand(bot, sharedCommandOpts);
|
|
807
818
|
const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
|
|
808
|
-
const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, {
|
|
809
|
-
VERBOSE,
|
|
810
|
-
isOldMessage,
|
|
811
|
-
isForwardedOrReply,
|
|
812
|
-
isGroupChat: _isGroupChat,
|
|
813
|
-
isChatAuthorized,
|
|
814
|
-
addBreadcrumb,
|
|
815
|
-
getSolveQueue,
|
|
816
|
-
});
|
|
819
|
+
const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue });
|
|
817
820
|
|
|
818
821
|
// Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
|
|
819
822
|
async function handleSolveCommand(ctx) {
|
|
820
|
-
|
|
821
|
-
console.log('[VERBOSE] /solve command received');
|
|
822
|
-
}
|
|
823
|
+
VERBOSE && console.log('[VERBOSE] /solve command received');
|
|
823
824
|
|
|
824
825
|
// Add breadcrumb for error tracking
|
|
825
826
|
await addBreadcrumb({
|
|
@@ -871,16 +872,16 @@ async function handleSolveCommand(ctx) {
|
|
|
871
872
|
return;
|
|
872
873
|
}
|
|
873
874
|
|
|
874
|
-
|
|
875
|
-
if (!isChatAuthorized(chatId)) {
|
|
875
|
+
if (!isTopicAuthorized(ctx)) {
|
|
876
876
|
if (VERBOSE) {
|
|
877
|
-
console.log('[VERBOSE] /solve ignored:
|
|
877
|
+
console.log('[VERBOSE] /solve ignored: not authorized');
|
|
878
878
|
}
|
|
879
|
-
await ctx.reply(
|
|
879
|
+
await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
|
|
880
880
|
return;
|
|
881
881
|
}
|
|
882
882
|
|
|
883
883
|
// Check if chat is stopped (issue #1081) - reject with same style as queue rejected mode
|
|
884
|
+
const chatId = ctx.chat.id;
|
|
884
885
|
if (isChatStopped(chatId)) {
|
|
885
886
|
VERBOSE && console.log('[VERBOSE] /solve rejected: chat is stopped');
|
|
886
887
|
await safeReply(ctx, getStoppedChatRejectMessage(chatId, 'Solve'), { reply_to_message_id: ctx.message.message_id });
|
|
@@ -1017,7 +1018,7 @@ async function handleSolveCommand(ctx) {
|
|
|
1017
1018
|
const existingItem = solveQueue.findByUrl(normalizedUrl);
|
|
1018
1019
|
if (existingItem) {
|
|
1019
1020
|
const statusText = existingItem.status === 'starting' || existingItem.status === 'started' ? 'being processed' : 'already in the queue';
|
|
1020
|
-
await safeReply(ctx, `❌ This URL is ${statusText}.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nStatus: ${existingItem.status}\n\n💡 Use /
|
|
1021
|
+
await safeReply(ctx, `❌ This URL is ${statusText}.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nStatus: ${existingItem.status}\n\n💡 Use /solve_queue to check the queue status.`, { reply_to_message_id: ctx.message.message_id });
|
|
1021
1022
|
return;
|
|
1022
1023
|
}
|
|
1023
1024
|
|
|
@@ -1099,16 +1100,16 @@ async function handleHiveCommand(ctx) {
|
|
|
1099
1100
|
return;
|
|
1100
1101
|
}
|
|
1101
1102
|
|
|
1102
|
-
|
|
1103
|
-
if (!isChatAuthorized(chatId)) {
|
|
1103
|
+
if (!isTopicAuthorized(ctx)) {
|
|
1104
1104
|
if (VERBOSE) {
|
|
1105
|
-
console.log('[VERBOSE] /hive ignored:
|
|
1105
|
+
console.log('[VERBOSE] /hive ignored: not authorized');
|
|
1106
1106
|
}
|
|
1107
|
-
await ctx.reply(
|
|
1107
|
+
await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
|
|
1108
1108
|
return;
|
|
1109
1109
|
}
|
|
1110
1110
|
|
|
1111
1111
|
// Check if chat is stopped (issue #1081) - reject with same style as queue rejected mode
|
|
1112
|
+
const chatId = ctx.chat.id;
|
|
1112
1113
|
if (isChatStopped(chatId)) {
|
|
1113
1114
|
VERBOSE && console.log('[VERBOSE] /hive rejected: chat is stopped');
|
|
1114
1115
|
await safeReply(ctx, getStoppedChatRejectMessage(chatId, 'Hive'), { reply_to_message_id: ctx.message.message_id });
|
|
@@ -1201,12 +1202,10 @@ async function handleHiveCommand(ctx) {
|
|
|
1201
1202
|
|
|
1202
1203
|
bot.command(/^hive$/i, handleHiveCommand);
|
|
1203
1204
|
|
|
1204
|
-
// Register commands from separate modules (keeps telegram-bot.mjs under line limit)
|
|
1205
1205
|
const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
|
|
1206
1206
|
const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
registerStartStopCommands(bot, commandOptions); // issue #1081
|
|
1207
|
+
registerTopCommand(bot, sharedCommandOpts);
|
|
1208
|
+
registerStartStopCommands(bot, sharedCommandOpts);
|
|
1210
1209
|
|
|
1211
1210
|
// Add message listener for verbose debugging
|
|
1212
1211
|
if (VERBOSE) {
|
|
@@ -1389,6 +1388,9 @@ if (allowedChats && allowedChats.length > 0) {
|
|
|
1389
1388
|
} else {
|
|
1390
1389
|
console.log('Allowed chats: All (no restrictions)');
|
|
1391
1390
|
}
|
|
1391
|
+
if (allowedTopics && allowedTopics.length > 0) {
|
|
1392
|
+
console.log('Allowed topics (lino):', lino.formatLinks(allowedTopics));
|
|
1393
|
+
}
|
|
1392
1394
|
console.log('Commands enabled:', { solve: solveEnabled, hive: hiveEnabled });
|
|
1393
1395
|
if (solveOverrides.length > 0) console.log('Solve overrides (lino):', lino.format(solveOverrides));
|
|
1394
1396
|
if (hiveOverrides.length > 0) console.log('Hive overrides (lino):', lino.format(hiveOverrides));
|
|
@@ -130,10 +130,14 @@ function formatUserError(error, verbose) {
|
|
|
130
130
|
* @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
|
|
131
131
|
* @param {Function} options.isGroupChat - Function to check if chat is a group
|
|
132
132
|
* @param {Function} options.isChatAuthorized - Function to check if chat is authorized
|
|
133
|
+
* @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
|
|
134
|
+
* @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
|
|
133
135
|
* @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
|
|
136
|
+
* @param {Function} [options.isChatStopped] - Function to check if chat is stopped (issue #1081)
|
|
137
|
+
* @param {Function} [options.getStoppedChatRejectMessage] - Function to get stopped chat rejection message
|
|
134
138
|
*/
|
|
135
139
|
export function registerMergeCommand(bot, options) {
|
|
136
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
|
|
140
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
|
|
137
141
|
|
|
138
142
|
bot.command(/^merge$/i, async ctx => {
|
|
139
143
|
VERBOSE && console.log('[VERBOSE] /merge command received');
|
|
@@ -154,13 +158,14 @@ export function registerMergeCommand(bot, options) {
|
|
|
154
158
|
});
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
const
|
|
158
|
-
if (!
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
});
|
|
161
|
+
const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
|
|
162
|
+
if (!authorize(ctx)) {
|
|
163
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `This chat (ID: ${ctx.chat.id}) is not authorized.`;
|
|
164
|
+
return await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
const chatId = ctx.chat.id;
|
|
168
|
+
|
|
164
169
|
// Check if chat is stopped (issue #1081) - reject with same style as queue rejected mode
|
|
165
170
|
if (isChatStopped && isChatStopped(chatId)) {
|
|
166
171
|
VERBOSE && console.log('[VERBOSE] /merge rejected: chat is stopped');
|
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
* @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
|
|
23
23
|
* @param {Function} options.isGroupChat - Function to check if chat is a group
|
|
24
24
|
* @param {Function} options.isChatAuthorized - Function to check if chat is authorized
|
|
25
|
+
* @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
|
|
26
|
+
* @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
|
|
25
27
|
* @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
|
|
26
28
|
* @param {Function} options.getSolveQueue - Function to get the solve queue instance
|
|
27
29
|
* @returns {{ handleSolveQueueCommand: Function }} The command handler for use in text fallback
|
|
28
30
|
*/
|
|
29
31
|
export function registerSolveQueueCommand(bot, options) {
|
|
30
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb, getSolveQueue } = options;
|
|
32
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, getSolveQueue } = options;
|
|
31
33
|
|
|
32
34
|
async function handleSolveQueueCommand(ctx) {
|
|
33
35
|
VERBOSE && console.log('[VERBOSE] /solve_queue command received');
|
|
@@ -59,12 +61,11 @@ export function registerSolveQueueCommand(bot, options) {
|
|
|
59
61
|
return;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
64
|
-
VERBOSE && console.log('[VERBOSE] /solve_queue ignored:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
64
|
+
const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
|
|
65
|
+
if (!authorize(ctx)) {
|
|
66
|
+
VERBOSE && console.log('[VERBOSE] /solve_queue ignored: not authorized');
|
|
67
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`;
|
|
68
|
+
await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -51,9 +51,11 @@ async function captureTopOutput(chatId) {
|
|
|
51
51
|
* @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
|
|
52
52
|
* @param {Function} options.isGroupChat - Function to check if chat is a group
|
|
53
53
|
* @param {Function} options.isChatAuthorized - Function to check if chat is authorized
|
|
54
|
+
* @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
|
|
55
|
+
* @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
|
|
54
56
|
*/
|
|
55
57
|
export function registerTopCommand(bot, options) {
|
|
56
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized } = options;
|
|
58
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
57
59
|
|
|
58
60
|
// /top command - show system top output in an auto-updating message (EXPERIMENTAL)
|
|
59
61
|
// Only accessible by chat owner
|
|
@@ -89,17 +91,20 @@ export function registerTopCommand(bot, options) {
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
94
|
+
const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
|
|
95
|
+
if (!authorize(ctx)) {
|
|
94
96
|
if (VERBOSE) {
|
|
95
|
-
console.log('[VERBOSE] /top ignored:
|
|
97
|
+
console.log('[VERBOSE] /top ignored: not authorized');
|
|
96
98
|
}
|
|
97
|
-
|
|
99
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`;
|
|
100
|
+
await ctx.reply(errMsg, {
|
|
98
101
|
reply_to_message_id: ctx.message.message_id,
|
|
99
102
|
});
|
|
100
103
|
return;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
const chatId = ctx.chat.id;
|
|
107
|
+
|
|
103
108
|
// Check if user is chat owner
|
|
104
109
|
try {
|
|
105
110
|
const chatMember = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|