@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.43.0",
3
+ "version": "1.45.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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
 
@@ -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 chatId = ctx.chat.id;
138
- if (!isChatAuthorized(chatId))
139
- return await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, {
140
- reply_to_message_id: ctx.message.message_id,
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,
@@ -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
- message += '\n🔒 *Restricted Mode:* This bot only accepts commands from authorized chats.\n';
690
- message += `Authorized: ${isChatAuthorized(chatId) ? '✅ Yes' : '❌ No'}`;
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
- const chatId = ctx.chat.id;
732
- if (!isChatAuthorized(chatId)) {
733
- VERBOSE && console.log('[VERBOSE] /limits ignored: chat not authorized');
734
- 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 });
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
- const chatId = ctx.chat.id;
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 /accept_invites command from separate module
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
- registerAcceptInvitesCommand(bot, {
785
- VERBOSE,
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
- if (VERBOSE) {
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
- const chatId = ctx.chat.id;
875
- if (!isChatAuthorized(chatId)) {
875
+ if (!isTopicAuthorized(ctx)) {
876
876
  if (VERBOSE) {
877
- console.log('[VERBOSE] /solve ignored: chat not authorized');
877
+ console.log('[VERBOSE] /solve ignored: not authorized');
878
878
  }
879
- 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 });
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 /solve\\_queue to check the queue status.`, { reply_to_message_id: ctx.message.message_id });
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
- const chatId = ctx.chat.id;
1103
- if (!isChatAuthorized(chatId)) {
1103
+ if (!isTopicAuthorized(ctx)) {
1104
1104
  if (VERBOSE) {
1105
- console.log('[VERBOSE] /hive ignored: chat not authorized');
1105
+ console.log('[VERBOSE] /hive ignored: not authorized');
1106
1106
  }
1107
- 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 });
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
- const commandOptions = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized };
1208
- registerTopCommand(bot, commandOptions);
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 chatId = ctx.chat.id;
158
- if (!isChatAuthorized(chatId)) {
159
- return await ctx.reply(`This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, {
160
- reply_to_message_id: ctx.message.message_id,
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 chatId = ctx.chat.id;
63
- if (!isChatAuthorized(chatId)) {
64
- VERBOSE && console.log('[VERBOSE] /solve_queue ignored: chat not authorized');
65
- await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, {
66
- reply_to_message_id: ctx.message.message_id,
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 chatId = ctx.chat.id;
93
- if (!isChatAuthorized(chatId)) {
94
+ const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
95
+ if (!authorize(ctx)) {
94
96
  if (VERBOSE) {
95
- console.log('[VERBOSE] /top ignored: chat not authorized');
97
+ console.log('[VERBOSE] /top ignored: not authorized');
96
98
  }
97
- await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot.`, {
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);