@link-assistant/hive-mind 1.69.6 → 1.69.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/telegram-bot.mjs +1 -1
- package/src/telegram-start-stop-command.lib.mjs +393 -79
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.69.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 175eaee: Fix two defects in the Telegram `/stop` command. (1) When `/stop` cancels a queued task by URL or reply, the original "⏳ Waiting (… queue #N)" card is now edited in place to show the task was cancelled (instead of leaving it stale). (2) Allow the user who originally ran `/solve` or `/hive` to `/stop` their own task by UUID or URL in a group chat, mirroring the requester authorization already used by `/terminal_watch` and `/watch` (PR #1779). The chat-creator fallback is preserved, so chat owners can still stop any task.
|
|
8
|
+
|
|
9
|
+
## 1.69.7
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 2ea2bb7: Extend Telegram `/stop` to accept a GitHub issue or pull-request URL (passed as the argument or contained in the replied-to message). The bot looks the URL up in the in-memory solve queue and either cancels the queued item or forwards CTRL+C via `$ --stop <UUID>` to the running isolated session. The UUID flow from #524 and the chat-level pause flow from #1081 are preserved.
|
|
14
|
+
|
|
3
15
|
## 1.69.6
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/telegram-bot.mjs
CHANGED
|
@@ -1057,7 +1057,7 @@ const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
|
|
|
1057
1057
|
const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
|
|
1058
1058
|
const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
|
|
1059
1059
|
registerTopCommand(bot, sharedCommandOpts);
|
|
1060
|
-
registerStartStopCommands(bot, sharedCommandOpts);
|
|
1060
|
+
registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue });
|
|
1061
1061
|
await registerLogCommand(bot, sharedCommandOpts);
|
|
1062
1062
|
await registerTerminalWatchCommand(bot, sharedCommandOpts);
|
|
1063
1063
|
|
|
@@ -13,13 +13,21 @@
|
|
|
13
13
|
* - `/stop <UUID>` or reply-to-message-with-UUID forwards CTRL+C to the
|
|
14
14
|
* matching isolated solve/hive session via `$ --stop <UUID>` from
|
|
15
15
|
* link-foundation/start (issue #524).
|
|
16
|
+
* - `/stop <issue-or-pr-url>` (or reply to a message that contains one) looks
|
|
17
|
+
* the URL up in the in-memory solve queue and either cancels the queued
|
|
18
|
+
* item or forwards CTRL+C to the running isolated session (issue #1780).
|
|
16
19
|
*
|
|
17
20
|
* @see https://github.com/link-assistant/hive-mind/issues/1081
|
|
18
21
|
* @see https://github.com/link-assistant/hive-mind/issues/524
|
|
22
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1780
|
|
19
23
|
* @see https://github.com/link-foundation/start/issues/112
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
|
|
27
|
+
import { parseGitHubUrl } from './github.lib.mjs';
|
|
28
|
+
import { cleanNonPrintableChars } from './telegram-markdown.lib.mjs';
|
|
29
|
+
|
|
30
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
31
|
|
|
24
32
|
// Store stopped chats: Map<chatId, { stoppedAt: Date, stoppedBy: { id, username, firstName }, reason?: string }>
|
|
25
33
|
const stoppedChats = new Map();
|
|
@@ -117,6 +125,122 @@ export function extractStopSessionId(text, repliedTo) {
|
|
|
117
125
|
return { sessionId: null, source: null };
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Walk arbitrary text and return the first GitHub issue or pull-request URL
|
|
130
|
+
* found, or null. Tolerates multiple URLs (returns the first issue/pull URL
|
|
131
|
+
* in source order). Uses the same `parseGitHubUrl` validator as the rest of
|
|
132
|
+
* the bot so the result is always a normalized URL string.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
function findFirstIssueOrPullUrl(text) {
|
|
138
|
+
if (!text || typeof text !== 'string') return null;
|
|
139
|
+
const cleaned = cleanNonPrintableChars(text);
|
|
140
|
+
for (const word of cleaned.split(/\s+/)) {
|
|
141
|
+
if (!word) continue;
|
|
142
|
+
const parsed = parseGitHubUrl(word);
|
|
143
|
+
if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
|
|
144
|
+
return parsed.normalized;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract the target of a `/stop` invocation. Returns the most specific
|
|
152
|
+
* target found among the four possible sources, in this priority order:
|
|
153
|
+
*
|
|
154
|
+
* 1. UUID in the `/stop` argument (kind='uuid', source='argument')
|
|
155
|
+
* 2. UUID in the replied-to message (kind='uuid', source='reply')
|
|
156
|
+
* 3. Issue/PR URL in the `/stop` argument (kind='url', source='argument')
|
|
157
|
+
* 4. Issue/PR URL in the replied-to text (kind='url', source='reply')
|
|
158
|
+
*
|
|
159
|
+
* UUIDs win over URLs because UUIDs are globally unique whereas a single
|
|
160
|
+
* issue URL can map to several in-flight requests if the user enqueued the
|
|
161
|
+
* same issue twice. Argument wins over reply because the argument is the
|
|
162
|
+
* more deliberate signal (the user explicitly typed it).
|
|
163
|
+
*
|
|
164
|
+
* @param {string} text - Raw `/stop ...` command text
|
|
165
|
+
* @param {Object|null|undefined} repliedTo - Telegram message object being replied to
|
|
166
|
+
* @returns {{ kind: 'uuid'|'url'|null, value: string|null, source: 'argument'|'reply'|null }}
|
|
167
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1780
|
|
168
|
+
*/
|
|
169
|
+
export function extractStopTarget(text, repliedTo) {
|
|
170
|
+
const argText = String(text || '').replace(/^\/stop(?:@\w+)?\s*/i, '');
|
|
171
|
+
const replyText = repliedTo ? `${repliedTo.text || ''}\n${repliedTo.caption || ''}` : '';
|
|
172
|
+
|
|
173
|
+
const argUuid = extractSessionIdFromText(argText);
|
|
174
|
+
if (argUuid) return { kind: 'uuid', value: argUuid, source: 'argument' };
|
|
175
|
+
|
|
176
|
+
const replyUuid = extractSessionIdFromText(replyText);
|
|
177
|
+
if (replyUuid) return { kind: 'uuid', value: replyUuid, source: 'reply' };
|
|
178
|
+
|
|
179
|
+
const argUrl = findFirstIssueOrPullUrl(argText);
|
|
180
|
+
if (argUrl) return { kind: 'url', value: argUrl, source: 'argument' };
|
|
181
|
+
|
|
182
|
+
const replyUrl = findFirstIssueOrPullUrl(replyText);
|
|
183
|
+
if (replyUrl) return { kind: 'url', value: replyUrl, source: 'reply' };
|
|
184
|
+
|
|
185
|
+
return { kind: null, value: null, source: null };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Update the queue card message in Telegram to show that the task was
|
|
190
|
+
* cancelled via /stop. Best-effort: silently swallows edit failures (e.g.,
|
|
191
|
+
* the card was already cleared by the consumer loop) — the dispatcher still
|
|
192
|
+
* sends its own ack reply, so the user always gets feedback.
|
|
193
|
+
*
|
|
194
|
+
* @param {Object} item - SolveQueueItem; reads .messageInfo and .ctx
|
|
195
|
+
* @param {string} url - GitHub issue/PR URL of the cancelled task
|
|
196
|
+
* @param {string|null} tool - Per-tool queue name (claude/agent/codex/...)
|
|
197
|
+
* @param {string} stopperName - Display name of the user who ran /stop
|
|
198
|
+
* @returns {Promise<boolean>} true when the card was edited
|
|
199
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1783
|
|
200
|
+
*/
|
|
201
|
+
export async function updateQueueCardForCancellation(item, url, tool, stopperName) {
|
|
202
|
+
if (!item || !item.messageInfo || !item.ctx) return false;
|
|
203
|
+
const toolSuffix = tool ? ` from \`${tool}\` queue` : '';
|
|
204
|
+
const stopperSuffix = stopperName ? ` by ${stopperName}` : '';
|
|
205
|
+
const text = `🗑 *Cancelled*\n\n${url}\n\nRemoved${toolSuffix}${stopperSuffix} via /stop.`;
|
|
206
|
+
try {
|
|
207
|
+
const { chatId, messageId } = item.messageInfo;
|
|
208
|
+
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, text, { parse_mode: 'Markdown' });
|
|
209
|
+
// Match the consumer's contract: once a card reaches a terminal state we
|
|
210
|
+
// forget the message coordinates so nothing else tries to edit it.
|
|
211
|
+
item.messageInfo = null;
|
|
212
|
+
return true;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error('[ERROR] /stop: failed to update queue card for cancellation:', error?.message || error);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Returns true when the user issuing /stop is the same user who originally
|
|
221
|
+
* requested the targeted task. Mirrors `isTerminalWatchSessionRequester` from
|
|
222
|
+
* /terminal_watch (PR #1779) so the two commands behave consistently.
|
|
223
|
+
*
|
|
224
|
+
* Accepts the requester either from a `SolveQueueItem` (URL flow) or from
|
|
225
|
+
* a tracked session info record (UUID flow).
|
|
226
|
+
*
|
|
227
|
+
* @param {Object} args
|
|
228
|
+
* @param {number|string|null|undefined} args.userId - ctx.from.id of the /stop sender
|
|
229
|
+
* @param {Object|null} [args.queueItem] - matched SolveQueueItem, if any
|
|
230
|
+
* @param {Object|null} [args.sessionInfo] - tracked session info, if any
|
|
231
|
+
* @returns {boolean}
|
|
232
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1783
|
|
233
|
+
*/
|
|
234
|
+
export function isStopTargetRequester({ userId, queueItem = null, sessionInfo = null } = {}) {
|
|
235
|
+
if (userId === null || userId === undefined) return false;
|
|
236
|
+
const candidates = [queueItem?.requesterUserId, sessionInfo?.requesterUserId];
|
|
237
|
+
for (const candidate of candidates) {
|
|
238
|
+
if (candidate === null || candidate === undefined) continue;
|
|
239
|
+
if (String(candidate) === String(userId)) return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
120
244
|
/**
|
|
121
245
|
* Registers the /start and /stop command handlers with the bot
|
|
122
246
|
* @param {Object} bot - The Telegraf bot instance
|
|
@@ -129,10 +253,26 @@ export function extractStopSessionId(text, repliedTo) {
|
|
|
129
253
|
* @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
|
|
130
254
|
* @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
|
|
131
255
|
* @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
|
|
256
|
+
* @param {Function} [options.getSolveQueue] - Returns the in-memory SolveQueue (for `/stop <url>`).
|
|
257
|
+
* When omitted, the URL flow degrades gracefully to a "no queue available"
|
|
258
|
+
* message so unit tests for non-URL paths don't need to construct a queue.
|
|
259
|
+
* See https://github.com/link-assistant/hive-mind/issues/1780.
|
|
132
260
|
*/
|
|
133
261
|
export function registerStartStopCommands(bot, options) {
|
|
134
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
262
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
|
|
135
263
|
const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
|
|
264
|
+
// Issue #1783: look the UUID up in the session monitor so /stop can let the
|
|
265
|
+
// user who started the task stop it (mirrors /terminal_watch from PR #1779).
|
|
266
|
+
// Test stubs can inject getTrackedSessionInfo directly via options.
|
|
267
|
+
// The real session-monitor.getTrackedSessionInfo is sync; we wrap it in an
|
|
268
|
+
// async tolerant helper so tests can pass either a sync or async stub.
|
|
269
|
+
async function lookupTrackedSessionInfo(sessionId) {
|
|
270
|
+
if (typeof options.getTrackedSessionInfo === 'function') {
|
|
271
|
+
return await options.getTrackedSessionInfo(sessionId);
|
|
272
|
+
}
|
|
273
|
+
const mod = await import('./session-monitor.lib.mjs');
|
|
274
|
+
return mod.getTrackedSessionInfo(sessionId);
|
|
275
|
+
}
|
|
136
276
|
|
|
137
277
|
/**
|
|
138
278
|
* Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
|
|
@@ -180,12 +320,178 @@ export function registerStartStopCommands(bot, options) {
|
|
|
180
320
|
return { valid: true, chatId };
|
|
181
321
|
}
|
|
182
322
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
323
|
+
/**
|
|
324
|
+
* Authorization check for the /stop UUID and /stop URL flows.
|
|
325
|
+
*
|
|
326
|
+
* In private DMs the user is implicitly authorized. In group chats the user
|
|
327
|
+
* is authorized when EITHER:
|
|
328
|
+
* - They are the chat creator, OR
|
|
329
|
+
* - They are the original requester of the task being stopped (the user
|
|
330
|
+
* who ran the /solve or /hive that produced the queue item / session).
|
|
331
|
+
*
|
|
332
|
+
* This mirrors /terminal_watch and /watch (PR #1779) which already let the
|
|
333
|
+
* task requester act on their own session without requiring chat-owner
|
|
334
|
+
* privileges. See https://github.com/link-assistant/hive-mind/issues/1783.
|
|
335
|
+
*
|
|
336
|
+
* @param {Object} ctx - Telegraf context
|
|
337
|
+
* @param {string} label - Short human-readable label for the variant ('UUID', 'URL')
|
|
338
|
+
* @param {Object} [opts]
|
|
339
|
+
* @param {Object|null} [opts.queueItem] - matched SolveQueueItem, if known
|
|
340
|
+
* @param {Object|null} [opts.sessionInfo] - tracked session info, if known
|
|
341
|
+
* @returns {Promise<boolean>} true when authorized
|
|
342
|
+
*/
|
|
343
|
+
async function authorizeTargetedStop(ctx, label, { queueItem = null, sessionInfo = null } = {}) {
|
|
344
|
+
const message = ctx.message;
|
|
345
|
+
const chatId = ctx.chat?.id;
|
|
346
|
+
const chatType = ctx.chat?.type;
|
|
347
|
+
if (chatType === 'private') return true;
|
|
348
|
+
if (!isGroupChat(ctx)) {
|
|
349
|
+
await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
if (!isChatAuthorized(chatId)) {
|
|
353
|
+
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
354
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
|
|
355
|
+
await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (isStopTargetRequester({ userId: ctx.from?.id, queueItem, sessionInfo })) {
|
|
361
|
+
VERBOSE && console.log(`[VERBOSE] /stop <${label}> allowed for task requester ${ctx.from?.id}`);
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
367
|
+
if (!member || member.status !== 'creator') {
|
|
368
|
+
VERBOSE && console.log(`[VERBOSE] /stop <${label}> ignored: user is not chat owner or task requester`);
|
|
369
|
+
await ctx.reply(`❌ /stop <${label}> is only available to the chat owner or the user who started this task.`, { reply_to_message_id: message.message_id });
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
console.error(`[ERROR] /stop <${label}>: getChatMember failed:`, error);
|
|
374
|
+
await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Forward CTRL+C to a running isolated session via `$ --stop <uuid>`.
|
|
382
|
+
* Posts an ack reply, edits it with the result. Used by both the
|
|
383
|
+
* `/stop <UUID>` path (issue #524) and the `/stop <url>` path when the
|
|
384
|
+
* matched queue item is already executing in an isolated session
|
|
385
|
+
* (issue #1780).
|
|
386
|
+
*
|
|
387
|
+
* @param {Object} ctx - Telegraf context
|
|
388
|
+
* @param {string} sessionId - UUID of the session to stop
|
|
389
|
+
*/
|
|
390
|
+
async function runStopIsolatedSessionFlow(ctx, sessionId) {
|
|
391
|
+
const message = ctx.message;
|
|
392
|
+
const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
|
|
393
|
+
parse_mode: 'Markdown',
|
|
394
|
+
reply_to_message_id: message.message_id,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
let result;
|
|
398
|
+
try {
|
|
399
|
+
result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
console.error('[ERROR] /stop: stopIsolatedSession threw:', error);
|
|
402
|
+
result = { success: false, output: '', error: error?.message || String(error) };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const trimmedOutput = (result.output || '').toString().trim();
|
|
406
|
+
const trimmedError = (result.error || '').toString().trim();
|
|
407
|
+
const lines = [];
|
|
408
|
+
if (result.success) {
|
|
409
|
+
lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
|
|
410
|
+
lines.push('');
|
|
411
|
+
lines.push('The session should terminate shortly.');
|
|
412
|
+
if (trimmedOutput) {
|
|
413
|
+
lines.push('');
|
|
414
|
+
lines.push('```');
|
|
415
|
+
lines.push(trimmedOutput.slice(0, 1000));
|
|
416
|
+
lines.push('```');
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
|
|
420
|
+
if (trimmedError) {
|
|
421
|
+
lines.push('');
|
|
422
|
+
lines.push('```');
|
|
423
|
+
lines.push(trimmedError.slice(0, 1000));
|
|
424
|
+
lines.push('```');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
|
|
430
|
+
} catch (error) {
|
|
431
|
+
console.error('[ERROR] /stop: editMessageText failed, falling back to reply:', error);
|
|
432
|
+
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Find the candidate queue/processing item for a `/stop <url>` request
|
|
438
|
+
* without mutating queue state. Used by the dispatcher to look up the
|
|
439
|
+
* task's requesterUserId for authorization before any cancellation
|
|
440
|
+
* happens (#1783).
|
|
441
|
+
*
|
|
442
|
+
* @param {string} url - Normalized GitHub issue or PR URL
|
|
443
|
+
* @returns {{ action: 'no-queue'|'not-found'|'candidate', item?: Object, queue?: Object }}
|
|
444
|
+
*/
|
|
445
|
+
function findQueueCandidateForUrl(url) {
|
|
446
|
+
if (typeof getSolveQueue !== 'function') {
|
|
447
|
+
return { action: 'no-queue' };
|
|
448
|
+
}
|
|
449
|
+
const queue = getSolveQueue({ verbose: VERBOSE });
|
|
450
|
+
const item = queue?.findByUrl?.(url);
|
|
451
|
+
if (!item) return { action: 'not-found' };
|
|
452
|
+
return { action: 'candidate', item, queue };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Resolve a `/stop <url>` request against the in-memory solve queue.
|
|
457
|
+
* Returns an action descriptor that the dispatcher executes.
|
|
458
|
+
*
|
|
459
|
+
* @param {string} url - Normalized GitHub issue or PR URL
|
|
460
|
+
* @returns {{ action: 'no-queue'|'not-found'|'cancel-queued'|'stop-running'|'running-not-isolated', item?: Object, sessionId?: string|null, tool?: string|null }}
|
|
461
|
+
*/
|
|
462
|
+
function resolveQueueLookupForUrl(url) {
|
|
463
|
+
const candidate = findQueueCandidateForUrl(url);
|
|
464
|
+
if (candidate.action !== 'candidate') return candidate;
|
|
465
|
+
const { item, queue } = candidate;
|
|
466
|
+
|
|
467
|
+
// Queued items have a defined .id and live in one of the per-tool queues.
|
|
468
|
+
// The cancel(id) call walks every per-tool queue and returns true on hit.
|
|
469
|
+
const cancelled = queue.cancel(item.id);
|
|
470
|
+
if (cancelled) {
|
|
471
|
+
return { action: 'cancel-queued', item, tool: item.tool || null };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Not in a per-tool queue → must be in `processing`. If it was started
|
|
475
|
+
// via an isolation backend, item.sessionName is the start-command UUID
|
|
476
|
+
// and we can forward CTRL+C to it. Non-isolated runs have a screen name
|
|
477
|
+
// that is not UUID-shaped — we can't safely interrupt those from here.
|
|
478
|
+
const sessionId = item.sessionName && UUID_RE.test(item.sessionName) ? item.sessionName : null;
|
|
479
|
+
if (sessionId) {
|
|
480
|
+
return { action: 'stop-running', item, sessionId, tool: item.tool || null };
|
|
481
|
+
}
|
|
482
|
+
return { action: 'running-not-isolated', item, tool: item.tool || null };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// /stop command. Three modes (checked in this order, before any reply
|
|
486
|
+
// rejection so the queue-card-reply ergonomics from issue #1780 work):
|
|
487
|
+
// 1. `/stop <UUID>` or reply with UUID — forward CTRL+C via
|
|
488
|
+
// `$ --stop <UUID>` (issue #524).
|
|
489
|
+
// 2. `/stop <issue-or-pr-url>` or reply containing that URL — look up
|
|
490
|
+
// the matching solve queue item; cancel it if queued, forward
|
|
491
|
+
// CTRL+C if running with isolation (issue #1780).
|
|
492
|
+
// 3. bare `/stop` (optionally with a free-text reason) — pause new task
|
|
187
493
|
// acceptance for the chat (issue #1081).
|
|
188
|
-
// Only accessible by chat owner (creator) in
|
|
494
|
+
// Only accessible by chat owner (creator) in modes 1, 2 (in groups).
|
|
189
495
|
bot.command('stop', async ctx => {
|
|
190
496
|
VERBOSE && console.log('[VERBOSE] /stop command received');
|
|
191
497
|
if (isOldMessage(ctx)) {
|
|
@@ -193,92 +499,100 @@ export function registerStartStopCommands(bot, options) {
|
|
|
193
499
|
return;
|
|
194
500
|
}
|
|
195
501
|
|
|
196
|
-
// Detect UUID
|
|
197
|
-
// chat-level stop, because
|
|
198
|
-
//
|
|
502
|
+
// Detect UUID/URL targets BEFORE the forwarded/reply rejection used by
|
|
503
|
+
// the chat-level stop, because both targeted modes are intentionally
|
|
504
|
+
// delivered as replies (issues #524, #1780).
|
|
199
505
|
const message = ctx.message;
|
|
200
506
|
const repliedTo = message?.reply_to_message || null;
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
216
|
-
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
|
|
217
|
-
await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
223
|
-
if (!member || member.status !== 'creator') {
|
|
224
|
-
VERBOSE && console.log('[VERBOSE] /stop <UUID> ignored: user is not chat owner');
|
|
225
|
-
await ctx.reply('❌ /stop <UUID> is only available to the chat owner.', { reply_to_message_id: message.message_id });
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
} catch (error) {
|
|
229
|
-
console.error('[ERROR] /stop <UUID>: getChatMember failed:', error);
|
|
230
|
-
await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
507
|
+
const target = extractStopTarget(message?.text || '', repliedTo);
|
|
508
|
+
|
|
509
|
+
if (target.kind === 'uuid') {
|
|
510
|
+
const sessionId = target.value;
|
|
511
|
+
VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${target.source})`);
|
|
512
|
+
// Look up the session's owner before auth so the original task requester
|
|
513
|
+
// can /stop their own session in a group even if they aren't the chat
|
|
514
|
+
// creator (#1783). Lookup failures are non-fatal: we fall through to the
|
|
515
|
+
// chat-owner-only check.
|
|
516
|
+
let sessionInfo = null;
|
|
517
|
+
try {
|
|
518
|
+
sessionInfo = await lookupTrackedSessionInfo(sessionId);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.error('[ERROR] /stop: getTrackedSessionInfo failed:', error);
|
|
233
521
|
}
|
|
522
|
+
const ok = await authorizeTargetedStop(ctx, 'UUID', { sessionInfo });
|
|
523
|
+
if (!ok) return;
|
|
524
|
+
await runStopIsolatedSessionFlow(ctx, sessionId);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
234
527
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
528
|
+
if (target.kind === 'url') {
|
|
529
|
+
const url = target.value;
|
|
530
|
+
VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
|
|
531
|
+
|
|
532
|
+
// Look up the queue item BEFORE auth so we can allow the original task
|
|
533
|
+
// requester to cancel their own task in a group (#1783). The lookup
|
|
534
|
+
// here does NOT mutate the queue — actual cancel happens below in
|
|
535
|
+
// resolveQueueLookupForUrl after auth has passed.
|
|
536
|
+
const candidate = findQueueCandidateForUrl(url);
|
|
537
|
+
const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null });
|
|
538
|
+
if (!ok) return;
|
|
539
|
+
|
|
540
|
+
const lookup = resolveQueueLookupForUrl(url);
|
|
541
|
+
VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
|
|
542
|
+
|
|
543
|
+
if (lookup.action === 'no-queue') {
|
|
544
|
+
await ctx.reply(`ℹ️ Cannot look up tasks by URL right now (the bot has no solve queue available in this context).\n\nIf you have the session UUID, you can use \`/stop <UUID>\` instead.`, {
|
|
545
|
+
parse_mode: 'Markdown',
|
|
546
|
+
reply_to_message_id: message.message_id,
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
239
550
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
551
|
+
if (lookup.action === 'not-found') {
|
|
552
|
+
await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with \`--isolation screen\`, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
|
|
553
|
+
parse_mode: 'Markdown',
|
|
554
|
+
reply_to_message_id: message.message_id,
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
246
557
|
}
|
|
247
558
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
lines.push('');
|
|
265
|
-
lines.push('```');
|
|
266
|
-
lines.push(trimmedError.slice(0, 1000));
|
|
267
|
-
lines.push('```');
|
|
268
|
-
}
|
|
559
|
+
if (lookup.action === 'cancel-queued') {
|
|
560
|
+
VERBOSE && console.log(`[VERBOSE] /stop: cancelled queued item ${lookup.item?.id} for ${url}`);
|
|
561
|
+
const toolLabel = lookup.tool ? ` from \`${lookup.tool}\` queue` : '';
|
|
562
|
+
|
|
563
|
+
// Update the original queue card ("⏳ Waiting (claude queue #3)") to
|
|
564
|
+
// reflect that the task was cancelled. The queue stores the message
|
|
565
|
+
// coordinates on the item itself when the card was first posted —
|
|
566
|
+
// see telegram-bot.mjs where `item.messageInfo` is wired up.
|
|
567
|
+
const stopperName = ctx.from?.username ? `@${ctx.from.username}` : ctx.from?.first_name || `user ${ctx.from?.id}`;
|
|
568
|
+
await updateQueueCardForCancellation(lookup.item, url, lookup.tool, stopperName);
|
|
569
|
+
|
|
570
|
+
await ctx.reply(`🗑 Removed queued task for ${url}${toolLabel}.`, {
|
|
571
|
+
parse_mode: 'Markdown',
|
|
572
|
+
reply_to_message_id: message.message_id,
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
269
575
|
}
|
|
270
576
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
|
|
577
|
+
if (lookup.action === 'stop-running') {
|
|
578
|
+
VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to running session ${lookup.sessionId} for ${url}`);
|
|
579
|
+
await runStopIsolatedSessionFlow(ctx, lookup.sessionId);
|
|
580
|
+
return;
|
|
276
581
|
}
|
|
582
|
+
|
|
583
|
+
// running-not-isolated: a started, non-isolated screen session. We
|
|
584
|
+
// could shell out to `screen -X -S <name> stuff $'\003'`, but that's
|
|
585
|
+
// brittle and out of scope for #1780. Tell the user how to recover.
|
|
586
|
+
await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
|
|
587
|
+
parse_mode: 'Markdown',
|
|
588
|
+
reply_to_message_id: message.message_id,
|
|
589
|
+
});
|
|
277
590
|
return;
|
|
278
591
|
}
|
|
279
592
|
|
|
280
|
-
// No UUID — fall through to the chat-level pause flow. That flow
|
|
281
|
-
// forwards/replies on purpose (#1081) so a stray reply doesn't
|
|
593
|
+
// No UUID or URL — fall through to the chat-level pause flow. That flow
|
|
594
|
+
// rejects forwards/replies on purpose (#1081) so a stray reply doesn't
|
|
595
|
+
// pause the chat.
|
|
282
596
|
if (isForwardedOrReply(ctx)) {
|
|
283
597
|
VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
|
|
284
598
|
return;
|