@link-assistant/hive-mind 1.56.17 ā 1.56.19
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 +2 -2
- package/src/isolation-runner.lib.mjs +13 -4
- package/src/session-monitor.lib.mjs +107 -1
- package/src/telegram-bot.mjs +23 -18
- package/src/telegram-isolation.lib.mjs +21 -1
- package/src/telegram-log-command.lib.mjs +372 -0
- package/src/telegram-solve-queue.lib.mjs +19 -1
- package/src/telegram-subscribers.lib.mjs +220 -0
- package/src/work-session-formatting.lib.mjs +36 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.56.19
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0da8eba: Add a `/log` Telegram command that lets a chat owner pull the on-disk log of a `$` isolation session (`screen`, `tmux`, `docker`). The command accepts `/log <UUID>` directly or `/log` as a reply to any session message that contains a session UUID, validates the id with `$ --status`, derives the log path from start-command's `logPath` field, and uploads the file as a reply to the user. Logs from public GitHub repositories are uploaded to the same chat; logs from private (or unknown-visibility) repositories are sent via direct message after forwarding the originating session message into the DM, so private logs never leak into public chats. Access is restricted to the chat owner (Telegram `creator` status), matching the existing `/start`, `/stop`, and `/top` policy.
|
|
8
|
+
|
|
9
|
+
## 1.56.18
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 47810ae: Telegram bot: add experimental `/subscribe` + `/unsubscribe` commands so users can opt in to receive a private DM forward of the `/solve` work-session completion message (commands work in both private and group chats; subscriptions are kept in memory and reset on bot restart). The completion message now includes both an `Issue:` line (the original URL passed to `/solve`) and, when the agent created a pull request for that issue, a follow-up `Pull request:` line so reviewers see both links without leaving the chat. (#1688)
|
|
14
|
+
|
|
3
15
|
## 1.56.17
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.56.
|
|
3
|
+
"version": "1.56.19",
|
|
4
4
|
"description": "AI-powered issue solver and hive mind for collaborative problem solving",
|
|
5
5
|
"main": "src/hive.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"hive-telegram-bot": "./src/telegram-bot.mjs"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
18
|
+
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1686-log-command.mjs && node tests/test-issue-1688-subscribe-and-pr-link.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
19
19
|
"test:queue": "node tests/solve-queue.test.mjs",
|
|
20
20
|
"test:limits-display": "node tests/limits-display.test.mjs",
|
|
21
21
|
"test:usage-limit": "node tests/test-usage-limit.mjs",
|
|
@@ -41,17 +41,18 @@ export function generateSessionId() {
|
|
|
41
41
|
* Keep the parser tolerant so completion monitoring survives either format.
|
|
42
42
|
*
|
|
43
43
|
* @param {string} output - Raw stdout from `$ --status`
|
|
44
|
-
* @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, raw: string}}
|
|
44
|
+
* @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, raw: string}}
|
|
45
45
|
*/
|
|
46
46
|
export function parseSessionStatusOutput(output) {
|
|
47
47
|
const raw = (output || '').trim();
|
|
48
48
|
if (!raw) {
|
|
49
|
-
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
|
|
49
|
+
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
53
|
const parsed = JSON.parse(raw);
|
|
54
54
|
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
55
|
+
const isolationFromOptions = typeof data?.options?.isolation === 'string' ? data.options.isolation.toLowerCase() : null;
|
|
55
56
|
return {
|
|
56
57
|
exists: true,
|
|
57
58
|
uuid: data?.uuid || null,
|
|
@@ -60,6 +61,10 @@ export function parseSessionStatusOutput(output) {
|
|
|
60
61
|
startTime: data?.startTime || null,
|
|
61
62
|
endTime: data?.endTime || null,
|
|
62
63
|
currentTime: data?.currentTime || null,
|
|
64
|
+
logPath: data?.logPath || null,
|
|
65
|
+
command: data?.command || null,
|
|
66
|
+
isolation: typeof data?.isolation === 'string' ? data.isolation.toLowerCase() : isolationFromOptions,
|
|
67
|
+
workingDirectory: data?.workingDirectory || null,
|
|
63
68
|
raw,
|
|
64
69
|
};
|
|
65
70
|
} catch {
|
|
@@ -87,6 +92,10 @@ export function parseSessionStatusOutput(output) {
|
|
|
87
92
|
startTime: readField('startTime'),
|
|
88
93
|
endTime: readField('endTime'),
|
|
89
94
|
currentTime: readField('currentTime'),
|
|
95
|
+
logPath: readField('logPath'),
|
|
96
|
+
command: readField('command'),
|
|
97
|
+
isolation: readField('isolation')?.toLowerCase() || null,
|
|
98
|
+
workingDirectory: readField('workingDirectory'),
|
|
90
99
|
raw,
|
|
91
100
|
};
|
|
92
101
|
}
|
|
@@ -213,7 +222,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
213
222
|
if (verbose) {
|
|
214
223
|
console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
|
|
215
224
|
}
|
|
216
|
-
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
|
|
225
|
+
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
|
|
217
226
|
}
|
|
218
227
|
|
|
219
228
|
try {
|
|
@@ -230,7 +239,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
230
239
|
if (verbose) {
|
|
231
240
|
console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
|
|
232
241
|
}
|
|
233
|
-
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
|
|
242
|
+
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
|
|
234
243
|
}
|
|
235
244
|
}
|
|
236
245
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { promisify } from 'util';
|
|
19
19
|
import { exec as execCallback } from 'child_process';
|
|
20
20
|
import { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
21
|
+
import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
|
|
21
22
|
|
|
22
23
|
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
23
24
|
|
|
@@ -88,6 +89,21 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Look up the in-memory record for a session id (UUID for isolation sessions
|
|
94
|
+
* or the screen session name for non-isolation sessions). Returns null when no
|
|
95
|
+
* record exists ā for example, after a process restart or for sessions that
|
|
96
|
+
* were never tracked through the Telegram bot. Used by `/log` to discover the
|
|
97
|
+
* originating chat id and the GitHub URL associated with a session.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} sessionName
|
|
100
|
+
* @returns {Object|null}
|
|
101
|
+
*/
|
|
102
|
+
export function getTrackedSessionInfo(sessionName) {
|
|
103
|
+
if (!sessionName) return null;
|
|
104
|
+
return activeSessions.get(sessionName) || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
/**
|
|
92
108
|
* Get the number of active sessions being tracked
|
|
93
109
|
* @param {boolean} verbose - Whether to log verbose output
|
|
@@ -251,6 +267,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
251
267
|
|
|
252
268
|
try {
|
|
253
269
|
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
270
|
+
|
|
271
|
+
// Issue #1688: When the original /solve URL was an issue, look up the
|
|
272
|
+
// linked PR so the completion message can include both an `Issue:` and
|
|
273
|
+
// a `Pull request:` line. Failures are logged and ignored ā the
|
|
274
|
+
// notification still goes out without the PR line.
|
|
275
|
+
let pullRequestUrl = null;
|
|
276
|
+
try {
|
|
277
|
+
pullRequestUrl = await resolvePullRequestUrlForSession(sessionInfo, { verbose, lookupLinkedPullRequest: options.lookupLinkedPullRequest });
|
|
278
|
+
} catch (lookupError) {
|
|
279
|
+
if (verbose) {
|
|
280
|
+
console.log(`[VERBOSE] Pull request lookup failed for ${sessionName}: ${lookupError?.message || lookupError}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
254
284
|
const message = formatSessionCompletionMessage({
|
|
255
285
|
sessionName,
|
|
256
286
|
sessionInfo,
|
|
@@ -258,13 +288,44 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
258
288
|
observedEndTime: new Date(),
|
|
259
289
|
exitCode: finalExitCode,
|
|
260
290
|
infoBlock: sessionInfo?.infoBlock || '',
|
|
291
|
+
pullRequestUrl,
|
|
261
292
|
});
|
|
262
293
|
|
|
263
294
|
// Update the original reply message if messageId is available, otherwise send new message
|
|
295
|
+
let notifyFromChatId = null;
|
|
296
|
+
let notifyMessageId = null;
|
|
264
297
|
if (sessionInfo.messageId) {
|
|
265
298
|
await bot.telegram.editMessageText(sessionInfo.chatId, sessionInfo.messageId, undefined, message, { parse_mode: 'Markdown' });
|
|
299
|
+
notifyFromChatId = sessionInfo.chatId;
|
|
300
|
+
notifyMessageId = sessionInfo.messageId;
|
|
266
301
|
} else {
|
|
267
|
-
await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
|
|
302
|
+
const sent = await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
|
|
303
|
+
notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
|
|
304
|
+
notifyMessageId = sent?.message_id || null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Issue #1688: forward the same completion message to every /subscribe-d user
|
|
308
|
+
// in their private chat with the bot. Failures are logged but don't block
|
|
309
|
+
// completion of the parent session.
|
|
310
|
+
if (getSubscriberCount() > 0 && notifyFromChatId && notifyMessageId) {
|
|
311
|
+
try {
|
|
312
|
+
const skipUserIds = new Set();
|
|
313
|
+
if (sessionInfo?.requesterUserId) skipUserIds.add(sessionInfo.requesterUserId);
|
|
314
|
+
const summary = await notifySubscribers({
|
|
315
|
+
bot,
|
|
316
|
+
fromChatId: notifyFromChatId,
|
|
317
|
+
messageId: notifyMessageId,
|
|
318
|
+
fallbackText: message,
|
|
319
|
+
fallbackOptions: { parse_mode: 'Markdown' },
|
|
320
|
+
skipUserIds,
|
|
321
|
+
verbose,
|
|
322
|
+
});
|
|
323
|
+
if (verbose) {
|
|
324
|
+
console.log(`[VERBOSE] Subscribe notify summary for ${sessionName}: forwarded=${summary.forwarded}, sent=${summary.sent}, skipped=${summary.skipped}, failures=${summary.failures.length}`);
|
|
325
|
+
}
|
|
326
|
+
} catch (notifyError) {
|
|
327
|
+
console.error(`[session-monitor] notifySubscribers failed for ${sessionName}:`, notifyError);
|
|
328
|
+
}
|
|
268
329
|
}
|
|
269
330
|
|
|
270
331
|
completeSession(sessionName, finalExitCode || 0, verbose);
|
|
@@ -285,6 +346,51 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
285
346
|
}
|
|
286
347
|
}
|
|
287
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Look up the URL of a pull request linked to the issue this session worked on.
|
|
351
|
+
* Returns null when the session was already operating on a PR, the URL context
|
|
352
|
+
* is missing, or no linked PR exists.
|
|
353
|
+
*
|
|
354
|
+
* Lazy-loads the GitHub batch helper so unrelated tests/imports don't pull
|
|
355
|
+
* GitHub deps. Tests can override the lookup via `options.lookupLinkedPullRequest`.
|
|
356
|
+
*
|
|
357
|
+
* @param {Object} sessionInfo
|
|
358
|
+
* @param {Object} [options]
|
|
359
|
+
* @param {boolean} [options.verbose]
|
|
360
|
+
* @param {Function} [options.lookupLinkedPullRequest] - Optional override `(ctx) => Promise<string|null>`
|
|
361
|
+
* @returns {Promise<string|null>} PR URL or null
|
|
362
|
+
*
|
|
363
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1688
|
|
364
|
+
*/
|
|
365
|
+
async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, lookupLinkedPullRequest = null } = {}) {
|
|
366
|
+
const ctx = sessionInfo?.urlContext;
|
|
367
|
+
if (!ctx || ctx.type !== 'issue' || !ctx.owner || !ctx.repo || !ctx.number) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (typeof lookupLinkedPullRequest === 'function') {
|
|
372
|
+
return await lookupLinkedPullRequest(ctx);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const { batchCheckPullRequestsForIssues } = await import('./github.lib.mjs');
|
|
377
|
+
const result = await batchCheckPullRequestsForIssues(ctx.owner, ctx.repo, [ctx.number]);
|
|
378
|
+
const linkedPRs = result?.[ctx.number]?.linkedPRs || [];
|
|
379
|
+
if (linkedPRs.length > 0 && linkedPRs[0].url) {
|
|
380
|
+
if (verbose) {
|
|
381
|
+
console.log(`[VERBOSE] Found linked PR ${linkedPRs[0].url} for issue ${ctx.owner}/${ctx.repo}#${ctx.number}`);
|
|
382
|
+
}
|
|
383
|
+
return linkedPRs[0].url;
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (verbose) {
|
|
387
|
+
console.log(`[VERBOSE] batchCheckPullRequestsForIssues failed for ${ctx.owner}/${ctx.repo}#${ctx.number}: ${error?.message || error}`);
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
288
394
|
/**
|
|
289
395
|
* Start the session monitoring interval
|
|
290
396
|
* @param {Object} bot - Telegraf bot instance for sending messages
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -551,7 +551,7 @@ async function safeReply(ctx, text, options = {}) {
|
|
|
551
551
|
}
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
-
async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude') {
|
|
554
|
+
async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null) {
|
|
555
555
|
const { chat, message_id: msgId } = startingMessage;
|
|
556
556
|
const safeEdit = async text => {
|
|
557
557
|
try {
|
|
@@ -560,22 +560,20 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
|
|
|
560
560
|
console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
|
|
561
561
|
}
|
|
562
562
|
};
|
|
563
|
+
const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
|
|
563
564
|
const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
|
|
564
565
|
let result, session;
|
|
565
566
|
if (iso) {
|
|
566
567
|
session = iso.runner.generateSessionId();
|
|
567
568
|
VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
|
|
568
569
|
result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
|
|
569
|
-
if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock }, VERBOSE);
|
|
570
|
+
if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
|
|
570
571
|
} else {
|
|
571
572
|
result = await executeStartScreen(commandName, args);
|
|
572
573
|
const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
573
574
|
session = match ? match[1] : 'unknown';
|
|
574
|
-
// Issue #1586:
|
|
575
|
-
|
|
576
|
-
// `exec bash`), so active URL checks auto-expire them after 10 min.
|
|
577
|
-
// This prevents accidental duplicate commands within the timeout window.
|
|
578
|
-
if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock }, VERBOSE);
|
|
575
|
+
// Issue #1586: Non-isolation sessions auto-expire after 10 min ā screen stays alive via `exec bash` so completion can't be detected reliably; this still blocks duplicate commands in the timeout window.
|
|
576
|
+
if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
|
|
579
577
|
}
|
|
580
578
|
if (result.warning) return safeEdit(`ā ļø ${result.warning}`);
|
|
581
579
|
if (result.success) {
|
|
@@ -662,13 +660,14 @@ bot.command('help', async ctx => {
|
|
|
662
660
|
message += '*/merge* - Merge queue (experimental)\n';
|
|
663
661
|
message += 'Usage: `/merge <github-repo-url>`\n';
|
|
664
662
|
message += "Merges all PRs with 'ready' label sequentially.\n";
|
|
663
|
+
message += '*/subscribe* / */unsubscribe* - š Get private DM forward of /solve completion (experimental, #1688)\n';
|
|
665
664
|
message += '*/help* - Show this help message\n';
|
|
666
|
-
message += '*/stop* - Stop accepting new tasks (owner only)\n';
|
|
667
|
-
message += '*/
|
|
668
|
-
message += 'š *Session Notifications:*
|
|
665
|
+
message += '*/stop* / */start* - Stop or resume accepting new tasks (owner only)\n';
|
|
666
|
+
message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n\n';
|
|
667
|
+
message += 'š *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\n';
|
|
669
668
|
if (ISOLATION_BACKEND) message += `š *Isolation Mode:* \`${ISOLATION_BACKEND}\` (experimental)\n`;
|
|
670
669
|
message += '\n';
|
|
671
|
-
message += 'ā ļø *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats.\n\n';
|
|
670
|
+
message += 'ā ļø *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /subscribe and /unsubscribe work in private and group chats.\n\n';
|
|
672
671
|
message += 'š§ *Common Options:*\n';
|
|
673
672
|
message += `⢠\`--model <model>\` or \`-m\` - ${buildModelOptionDescription()}\n`;
|
|
674
673
|
message += '⢠`--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)\n';
|
|
@@ -764,7 +763,6 @@ bot.command('version', async ctx => {
|
|
|
764
763
|
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, 'š¤ *Version Information*\n\n' + formatVersionMessage(result.versions), { parse_mode: 'Markdown' });
|
|
765
764
|
});
|
|
766
765
|
|
|
767
|
-
// Register external command modules (keeps telegram-bot.mjs under line limit)
|
|
768
766
|
const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
|
|
769
767
|
const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
|
|
770
768
|
registerAcceptInvitesCommand(bot, sharedCommandOpts);
|
|
@@ -772,6 +770,8 @@ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs'
|
|
|
772
770
|
registerMergeCommand(bot, sharedCommandOpts);
|
|
773
771
|
const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
|
|
774
772
|
const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue });
|
|
773
|
+
const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.mjs'); // #1688
|
|
774
|
+
registerSubscribeCommands(bot, sharedCommandOpts);
|
|
775
775
|
|
|
776
776
|
// Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
|
|
777
777
|
async function handleSolveCommand(ctx) {
|
|
@@ -983,10 +983,10 @@ async function handleSolveCommand(ctx) {
|
|
|
983
983
|
const normalizedUrl = validation.parsed.normalized;
|
|
984
984
|
|
|
985
985
|
const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
|
|
986
|
-
//
|
|
987
|
-
// Issue #1460: Escape options text to prevent Markdown parsing errors
|
|
986
|
+
// #1228: only user options; #1460: escape; #1688: 'Issue:' / 'Pull request:' label so completion can append PR link.
|
|
988
987
|
const userOptionsRaw = userArgs.slice(1).join(' ');
|
|
989
|
-
|
|
988
|
+
const urlLabel = validation.parsed?.type === 'pull' ? 'Pull request' : 'Issue';
|
|
989
|
+
let infoBlock = `Requested by: ${requester}\n${urlLabel}: ${escapeMarkdown(normalizedUrl)}`;
|
|
990
990
|
if (userOptionsRaw) infoBlock += `\n\nš Options: ${escapeMarkdown(userOptionsRaw)}`;
|
|
991
991
|
if (solveOverrides.length > 0) infoBlock += `${userOptionsRaw ? '\n' : '\n\n'}š Locked options: ${escapeMarkdown(solveOverrides.join(' '))}`;
|
|
992
992
|
const solveQueue = getSolveQueue({ verbose: VERBOSE });
|
|
@@ -1012,12 +1012,15 @@ async function handleSolveCommand(ctx) {
|
|
|
1012
1012
|
return;
|
|
1013
1013
|
}
|
|
1014
1014
|
|
|
1015
|
+
// Issue #1688: parsed URL context lets the completion message look up linked PRs.
|
|
1016
|
+
const solveUrlContext = validation.parsed ? { owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, normalized: validation.parsed.normalized || normalizedUrl } : null;
|
|
1017
|
+
|
|
1015
1018
|
const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
|
|
1016
1019
|
if (check.canStart && toolQueuedCount === 0) {
|
|
1017
1020
|
const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock }), { reply_to_message_id: ctx.message.message_id });
|
|
1018
|
-
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool);
|
|
1021
|
+
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext);
|
|
1019
1022
|
} else {
|
|
1020
|
-
const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
|
|
1023
|
+
const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext });
|
|
1021
1024
|
let queueMessage = `š Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
|
|
1022
1025
|
if (check.reason) queueMessage += `\n\nā³ Waiting: ${escapeMarkdown(check.reason)}`;
|
|
1023
1026
|
const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
|
|
@@ -1187,8 +1190,10 @@ bot.command(/^hive$/i, handleHiveCommand);
|
|
|
1187
1190
|
|
|
1188
1191
|
const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
|
|
1189
1192
|
const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
|
|
1193
|
+
const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
|
|
1190
1194
|
registerTopCommand(bot, sharedCommandOpts);
|
|
1191
1195
|
registerStartStopCommands(bot, sharedCommandOpts);
|
|
1196
|
+
await registerLogCommand(bot, sharedCommandOpts);
|
|
1192
1197
|
|
|
1193
1198
|
// Add message listener for verbose debugging
|
|
1194
1199
|
if (VERBOSE) {
|
|
@@ -1252,7 +1257,7 @@ bot.on('message', async (ctx, next) => {
|
|
|
1252
1257
|
}
|
|
1253
1258
|
}
|
|
1254
1259
|
|
|
1255
|
-
//
|
|
1260
|
+
// /subscribe + /unsubscribe (#1688) are intentionally not in the text fallback ā Telegraf's bot.command() is sufficient.
|
|
1256
1261
|
const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
|
|
1257
1262
|
const handlers = { ...solveHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand };
|
|
1258
1263
|
|
|
@@ -80,7 +80,27 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
|
|
|
80
80
|
if (iso) {
|
|
81
81
|
const sid = iso.runner.generateSessionId();
|
|
82
82
|
const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
|
|
83
|
-
if (r.success)
|
|
83
|
+
if (r.success)
|
|
84
|
+
trackSession(
|
|
85
|
+
sid,
|
|
86
|
+
{
|
|
87
|
+
chatId: item.ctx?.chat?.id,
|
|
88
|
+
messageId: item.messageInfo?.messageId,
|
|
89
|
+
startTime: new Date(),
|
|
90
|
+
url: item.url,
|
|
91
|
+
command: item.command || 'solve',
|
|
92
|
+
isolationBackend: iso.backend,
|
|
93
|
+
sessionId: sid,
|
|
94
|
+
tool: item.tool || 'claude',
|
|
95
|
+
infoBlock: item.infoBlock,
|
|
96
|
+
// Issue #1688: propagate URL context + requester through the queue so the
|
|
97
|
+
// completion notification can append a 'Pull request:' line and skip
|
|
98
|
+
// notifying the requester twice via /subscribe.
|
|
99
|
+
urlContext: item.urlContext || null,
|
|
100
|
+
requesterUserId: item.requesterUserId ?? null,
|
|
101
|
+
},
|
|
102
|
+
verbose
|
|
103
|
+
);
|
|
84
104
|
return { ...r, sessionId: sid, isolationBackend: iso.backend, output: r.output || `session: ${sid}` };
|
|
85
105
|
}
|
|
86
106
|
return fallbackCallback(item);
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram /log command implementation
|
|
3
|
+
*
|
|
4
|
+
* Lets a chat owner pull the log of an isolation session that was launched
|
|
5
|
+
* through the `$` (start-command) CLI. The session is identified by its UUID,
|
|
6
|
+
* either passed as `/log <UUID>` or extracted from a message that the
|
|
7
|
+
* `/log` command is replying to.
|
|
8
|
+
*
|
|
9
|
+
* Privacy guarantees:
|
|
10
|
+
* - Only the chat creator (`status === 'creator'`) may invoke `/log`.
|
|
11
|
+
* - Logs from public GitHub repositories may be uploaded into the chat where
|
|
12
|
+
* `/log` was issued.
|
|
13
|
+
* - Logs from private GitHub repositories ā and logs whose repository
|
|
14
|
+
* visibility we cannot determine ā are sent to the user via direct message
|
|
15
|
+
* only, after forwarding the original message that contained the session id.
|
|
16
|
+
* - Currently only sessions launched with one of the `$` isolation backends
|
|
17
|
+
* (`screen`, `tmux`, `docker`) are supported. Direct (non-isolation) sessions
|
|
18
|
+
* are rejected with a clear message.
|
|
19
|
+
*
|
|
20
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1686
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import fs from 'fs/promises';
|
|
25
|
+
import { constants as fsConstants } from 'fs';
|
|
26
|
+
|
|
27
|
+
const UUID_RE = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
|
|
28
|
+
const ISOLATION_BACKENDS = new Set(['screen', 'tmux', 'docker']);
|
|
29
|
+
// Telegram bots may upload documents up to 50 MB via sendDocument.
|
|
30
|
+
// https://core.telegram.org/bots/api#senddocument
|
|
31
|
+
const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract the first RFC 4122 v4-shaped UUID found in `text`.
|
|
35
|
+
*
|
|
36
|
+
* @param {string|null|undefined} text
|
|
37
|
+
* @returns {string|null}
|
|
38
|
+
*/
|
|
39
|
+
export function extractSessionIdFromText(text) {
|
|
40
|
+
if (!text || typeof text !== 'string') return null;
|
|
41
|
+
const match = text.match(UUID_RE);
|
|
42
|
+
return match ? match[1].toLowerCase() : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decide where the log for a session should be delivered.
|
|
47
|
+
*
|
|
48
|
+
* Inputs:
|
|
49
|
+
* - `statusResult`: parsed result of `$ --status <uuid>` (see
|
|
50
|
+
* `parseSessionStatusOutput` in `isolation-runner.lib.mjs`).
|
|
51
|
+
* - `sessionInfo`: in-memory record from the Telegram session monitor, or null.
|
|
52
|
+
* - `repoVisibility`: result of `detectRepositoryVisibility(owner, repo)`, or
|
|
53
|
+
* null when the repo could not be identified.
|
|
54
|
+
* - `chatType`: Telegram chat type where `/log` was invoked
|
|
55
|
+
* (`'private'` | `'group'` | `'supergroup'` | `'channel'`).
|
|
56
|
+
*
|
|
57
|
+
* Output: `{ destination, reason, isolationBackend }` where `destination` is
|
|
58
|
+
* one of `'chat'` (deliver in the same chat), `'dm'` (deliver in DM),
|
|
59
|
+
* `'reject'` (don't deliver). `reason` is a short, user-facing string.
|
|
60
|
+
*
|
|
61
|
+
* @returns {{destination: 'chat'|'dm'|'reject', reason: string, isolationBackend: string|null}}
|
|
62
|
+
*/
|
|
63
|
+
export function decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType }) {
|
|
64
|
+
if (!statusResult || !statusResult.exists) {
|
|
65
|
+
return { destination: 'reject', reason: 'Unknown session id (start-command does not know about it).', isolationBackend: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Determine isolation backend. Prefer the in-memory record (which knows what
|
|
69
|
+
// we asked `$` to use), fall back to whatever `$ --status` reports.
|
|
70
|
+
const isolationBackend = (sessionInfo?.isolationBackend || statusResult.isolation || '').toLowerCase() || null;
|
|
71
|
+
if (!isolationBackend || !ISOLATION_BACKENDS.has(isolationBackend)) {
|
|
72
|
+
return {
|
|
73
|
+
destination: 'reject',
|
|
74
|
+
reason: 'This command currently supports only sessions launched with `$` isolation (screen / tmux / docker).',
|
|
75
|
+
isolationBackend: isolationBackend || null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Privacy decision ā fail closed when in doubt.
|
|
80
|
+
const isPublic = repoVisibility?.isPublic === true;
|
|
81
|
+
const visibilityKnown = !!repoVisibility && repoVisibility.visibility !== null;
|
|
82
|
+
|
|
83
|
+
if (isPublic && visibilityKnown) {
|
|
84
|
+
if (chatType === 'private') {
|
|
85
|
+
// /log was invoked in DM. Deliver in DM regardless of repo visibility.
|
|
86
|
+
return { destination: 'dm', reason: 'Public repository, delivering in DM (command was sent in a private chat).', isolationBackend };
|
|
87
|
+
}
|
|
88
|
+
return { destination: 'chat', reason: 'Public repository, delivering in chat.', isolationBackend };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Private OR unknown visibility ā never leak in a public chat.
|
|
92
|
+
return {
|
|
93
|
+
destination: 'dm',
|
|
94
|
+
reason: visibilityKnown ? 'Private repository ā delivering via direct message.' : 'Repository visibility could not be determined ā delivering via direct message (fail-closed).',
|
|
95
|
+
isolationBackend,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the on-disk log path for a session.
|
|
101
|
+
*
|
|
102
|
+
* Prefers the `logPath` field reported by `$ --status` (always correct when
|
|
103
|
+
* supported). Falls back to start-command's documented layout if the field is
|
|
104
|
+
* missing.
|
|
105
|
+
*
|
|
106
|
+
* @returns {string|null}
|
|
107
|
+
*/
|
|
108
|
+
export function resolveLogPath({ statusResult, isolationBackend }) {
|
|
109
|
+
if (statusResult?.logPath) return statusResult.logPath;
|
|
110
|
+
const uuid = statusResult?.uuid;
|
|
111
|
+
if (!uuid) return null;
|
|
112
|
+
if (isolationBackend && ISOLATION_BACKENDS.has(isolationBackend)) {
|
|
113
|
+
return path.join('/tmp/start-command/logs/isolation', isolationBackend, `${uuid}.log`);
|
|
114
|
+
}
|
|
115
|
+
return path.join('/tmp/start-command/logs/direct', `${uuid}.log`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function fileExists(filePath) {
|
|
119
|
+
try {
|
|
120
|
+
await fs.access(filePath, fsConstants.R_OK);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function fileSize(filePath) {
|
|
128
|
+
try {
|
|
129
|
+
const stat = await fs.stat(filePath);
|
|
130
|
+
return stat.size;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Registers the /log command handler with the bot.
|
|
138
|
+
*
|
|
139
|
+
* Dependencies (`querySessionStatus`, `getTrackedSessionInfo`,
|
|
140
|
+
* `detectRepositoryVisibility`, `parseGitHubUrl`) are lazy-loaded from the
|
|
141
|
+
* existing libraries by default; tests pass mocked versions through `options`.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} bot - Telegraf bot instance
|
|
144
|
+
* @param {Object} options
|
|
145
|
+
* @param {boolean} [options.VERBOSE]
|
|
146
|
+
* @param {Function} options.isOldMessage
|
|
147
|
+
* @param {Function} options.isChatAuthorized
|
|
148
|
+
* @param {Function} [options.isTopicAuthorized]
|
|
149
|
+
* @param {Function} [options.buildAuthErrorMessage]
|
|
150
|
+
* @param {Function} [options.querySessionStatus] - Override for tests
|
|
151
|
+
* @param {Function} [options.getTrackedSessionInfo] - Override for tests
|
|
152
|
+
* @param {Function} [options.detectRepositoryVisibility] - Override for tests
|
|
153
|
+
* @param {Function} [options.parseGitHubUrl] - Override for tests
|
|
154
|
+
*/
|
|
155
|
+
export async function registerLogCommand(bot, options) {
|
|
156
|
+
const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
157
|
+
const querySessionStatus = options.querySessionStatus || (await import('./isolation-runner.lib.mjs')).querySessionStatus;
|
|
158
|
+
const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
|
|
159
|
+
const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
|
|
160
|
+
const parseGitHubUrl = options.parseGitHubUrl || (await import('./github.lib.mjs')).parseGitHubUrl;
|
|
161
|
+
|
|
162
|
+
bot.command('log', async ctx => {
|
|
163
|
+
VERBOSE && console.log('[VERBOSE] /log command received');
|
|
164
|
+
|
|
165
|
+
if (isOldMessage && isOldMessage(ctx)) {
|
|
166
|
+
VERBOSE && console.log('[VERBOSE] /log ignored: old message');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const chat = ctx.chat;
|
|
171
|
+
const message = ctx.message;
|
|
172
|
+
if (!chat || !message) return;
|
|
173
|
+
|
|
174
|
+
const chatType = chat.type;
|
|
175
|
+
const chatId = chat.id;
|
|
176
|
+
|
|
177
|
+
// Extract the session id. Priority: explicit argument, then reply text.
|
|
178
|
+
const directSessionId = extractSessionIdFromText(message.text || '');
|
|
179
|
+
const repliedTo = message.reply_to_message;
|
|
180
|
+
const replySessionId = repliedTo ? extractSessionIdFromText(repliedTo.text || repliedTo.caption || '') : null;
|
|
181
|
+
const sessionId = directSessionId || replySessionId;
|
|
182
|
+
|
|
183
|
+
if (!sessionId) {
|
|
184
|
+
await ctx.reply('ā /log requires a session id.\n\nUsage:\n⢠`/log <UUID>` ā fetch a specific session log\n⢠Reply to a session message with `/log` ā fetch the session referenced in that message', {
|
|
185
|
+
parse_mode: 'Markdown',
|
|
186
|
+
reply_to_message_id: message.message_id,
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Authorization. /log is only available to chat owners. In private chats
|
|
192
|
+
// there is no "creator" status ā the user is implicitly the owner of their
|
|
193
|
+
// own DM, so we allow it. We still apply the optional allowlist used by
|
|
194
|
+
// other commands so a private bot deployment can lock /log to known users.
|
|
195
|
+
if (chatType === 'private') {
|
|
196
|
+
// No further auth required beyond the optional whitelist applied below.
|
|
197
|
+
} else {
|
|
198
|
+
try {
|
|
199
|
+
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
200
|
+
if (!member || member.status !== 'creator') {
|
|
201
|
+
VERBOSE && console.log('[VERBOSE] /log rejected: not chat owner');
|
|
202
|
+
await ctx.reply('ā /log is only available to the chat owner.', { reply_to_message_id: message.message_id });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error('[ERROR] /log: getChatMember failed:', error);
|
|
207
|
+
await ctx.reply('ā Failed to verify permissions for /log.', { reply_to_message_id: message.message_id });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (isChatAuthorized && !isChatAuthorized(chatId)) {
|
|
213
|
+
// Topic-aware fallback (used elsewhere in this repo for forum topics).
|
|
214
|
+
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
215
|
+
VERBOSE && console.log('[VERBOSE] /log rejected: chat not authorized');
|
|
216
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `ā This chat (ID: ${chatId}) is not authorized.`;
|
|
217
|
+
await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 1. Validate the session id with $ --status.
|
|
223
|
+
let statusResult;
|
|
224
|
+
try {
|
|
225
|
+
statusResult = await querySessionStatus(sessionId, VERBOSE);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error('[ERROR] /log: querySessionStatus failed:', error);
|
|
228
|
+
await ctx.reply(`ā Failed to query session status: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!statusResult || !statusResult.exists) {
|
|
233
|
+
await ctx.reply(`ā Session \`${sessionId}\` is not known to start-command.\n\nUse the session id from a \`š Session: <uuid>\` line in one of the bot's status messages.`, {
|
|
234
|
+
parse_mode: 'Markdown',
|
|
235
|
+
reply_to_message_id: message.message_id,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2. Look up tracked metadata (for repo URL and original chat).
|
|
241
|
+
const sessionInfo = getTrackedSessionInfo ? getTrackedSessionInfo(sessionId) : null;
|
|
242
|
+
|
|
243
|
+
// 3. Decide repo visibility ā prefer the URL we tracked at launch time.
|
|
244
|
+
let repoVisibility = null;
|
|
245
|
+
let repoUrlDescription = null;
|
|
246
|
+
const trackedUrl = sessionInfo?.url || null;
|
|
247
|
+
if (trackedUrl) {
|
|
248
|
+
const parsed = parseGitHubUrl ? parseGitHubUrl(trackedUrl) : null;
|
|
249
|
+
if (parsed && parsed.valid && parsed.owner && parsed.repo) {
|
|
250
|
+
repoUrlDescription = `${parsed.owner}/${parsed.repo}`;
|
|
251
|
+
try {
|
|
252
|
+
repoVisibility = await detectRepositoryVisibility(parsed.owner, parsed.repo);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.error('[ERROR] /log: detectRepositoryVisibility failed:', error);
|
|
255
|
+
repoVisibility = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 4. Decide the destination.
|
|
261
|
+
const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType });
|
|
262
|
+
if (decision.destination === 'reject') {
|
|
263
|
+
await ctx.reply(`ā ${decision.reason}`, { reply_to_message_id: message.message_id });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 5. Resolve and validate the on-disk log file.
|
|
268
|
+
const logPath = resolveLogPath({ statusResult, isolationBackend: decision.isolationBackend });
|
|
269
|
+
if (!logPath) {
|
|
270
|
+
await ctx.reply('ā Could not determine the log file path for this session.', { reply_to_message_id: message.message_id });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!(await fileExists(logPath))) {
|
|
274
|
+
await ctx.reply(`ā Log file does not exist on disk:\n\`${logPath}\`\n\nThe session may have been cleaned up by the host or the isolation backend.`, {
|
|
275
|
+
parse_mode: 'Markdown',
|
|
276
|
+
reply_to_message_id: message.message_id,
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const size = await fileSize(logPath);
|
|
281
|
+
if (size !== null && size > TELEGRAM_DOCUMENT_MAX_BYTES) {
|
|
282
|
+
await ctx.reply(`ā Log file is ${(size / (1024 * 1024)).toFixed(1)} MB which exceeds Telegram's 50 MB document upload limit.\n\nFile path on host: \`${logPath}\``, {
|
|
283
|
+
parse_mode: 'Markdown',
|
|
284
|
+
reply_to_message_id: message.message_id,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const filename = path.basename(logPath);
|
|
290
|
+
const captionLines = [`š Log for session \`${sessionId}\``];
|
|
291
|
+
if (decision.isolationBackend) captionLines.push(`š Isolation: \`${decision.isolationBackend}\``);
|
|
292
|
+
if (statusResult.status) captionLines.push(`Status: \`${statusResult.status}\``);
|
|
293
|
+
if (repoUrlDescription) captionLines.push(`Repo: \`${repoUrlDescription}\``);
|
|
294
|
+
captionLines.push(`Privacy: ${decision.reason}`);
|
|
295
|
+
const caption = captionLines.join('\n');
|
|
296
|
+
|
|
297
|
+
if (decision.destination === 'chat') {
|
|
298
|
+
// Public repository ā reply with the document directly in the chat.
|
|
299
|
+
try {
|
|
300
|
+
await ctx.replyWithDocument({ source: logPath, filename }, { reply_to_message_id: message.message_id, caption, parse_mode: 'Markdown' });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error('[ERROR] /log: replyWithDocument failed:', error);
|
|
303
|
+
await ctx.reply(`ā Failed to upload log: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// DM flow: forward the originating message into DM (so the audit chain
|
|
309
|
+
// is preserved), then reply to that forwarded message with the log file.
|
|
310
|
+
const userId = ctx.from?.id;
|
|
311
|
+
if (!userId) {
|
|
312
|
+
await ctx.reply('ā Cannot deliver the log via DM: missing user id.', { reply_to_message_id: message.message_id });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let forwardedMessageId = null;
|
|
317
|
+
try {
|
|
318
|
+
// Forward the message that contains the session id (the reply target if
|
|
319
|
+
// any, otherwise the /log message itself).
|
|
320
|
+
const forwardSource = repliedTo || message;
|
|
321
|
+
const forwardedFromChatId = forwardSource === repliedTo ? chatId : chatId;
|
|
322
|
+
const forwardedSourceMessageId = forwardSource.message_id;
|
|
323
|
+
try {
|
|
324
|
+
const forwarded = await ctx.telegram.forwardMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
|
|
325
|
+
forwardedMessageId = forwarded?.message_id || null;
|
|
326
|
+
} catch (forwardError) {
|
|
327
|
+
// forwardMessage can fail if the user has not opened a DM with the bot
|
|
328
|
+
// yet, or the source chat blocks forwards. Fall back to copyMessage,
|
|
329
|
+
// which works without a forward header.
|
|
330
|
+
try {
|
|
331
|
+
const copied = await ctx.telegram.copyMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
|
|
332
|
+
forwardedMessageId = copied?.message_id || null;
|
|
333
|
+
} catch (copyError) {
|
|
334
|
+
console.error('[ERROR] /log: forward/copyMessage to DM failed:', forwardError, copyError);
|
|
335
|
+
// Fall through ā we can still try sendDocument without a reply ref.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
console.error('[ERROR] /log: DM forwarding step failed:', error);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const replyOpts = forwardedMessageId ? { reply_to_message_id: forwardedMessageId, caption, parse_mode: 'Markdown' } : { caption, parse_mode: 'Markdown' };
|
|
344
|
+
await ctx.telegram.sendDocument(userId, { source: logPath, filename }, replyOpts);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error('[ERROR] /log: sendDocument to DM failed:', error);
|
|
347
|
+
// Tell the user, in their original chat, that DM delivery failed
|
|
348
|
+
// (commonly because they have not started a chat with the bot).
|
|
349
|
+
const friendly = error?.code === 403 || /chat not found|bot can't initiate conversation/i.test(error?.message || '') ? 'I could not send you a DM. Please open a private chat with me and send /start, then try again.' : `Failed to send the log via DM: ${error.message || String(error)}`;
|
|
350
|
+
await ctx.reply(`ā ${friendly}`, { reply_to_message_id: message.message_id });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Acknowledge in the original chat (only if it wasn't already a DM).
|
|
355
|
+
if (chatType !== 'private') {
|
|
356
|
+
try {
|
|
357
|
+
await ctx.reply(`š¬ Sent the log for \`${sessionId}\` to your direct messages (private repository).`, {
|
|
358
|
+
parse_mode: 'Markdown',
|
|
359
|
+
reply_to_message_id: message.message_id,
|
|
360
|
+
});
|
|
361
|
+
} catch (error) {
|
|
362
|
+
console.error('[ERROR] /log: failed to acknowledge in chat:', error);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const __INTERNAL_FOR_TESTS__ = {
|
|
369
|
+
UUID_RE,
|
|
370
|
+
TELEGRAM_DOCUMENT_MAX_BYTES,
|
|
371
|
+
ISOLATION_BACKENDS,
|
|
372
|
+
};
|
|
@@ -43,6 +43,11 @@ class SolveQueueItem {
|
|
|
43
43
|
this.requester = options.requester;
|
|
44
44
|
this.infoBlock = options.infoBlock;
|
|
45
45
|
this.tool = options.tool || 'claude';
|
|
46
|
+
// Issue #1688: keep parsed URL context (owner/repo/number/type) so completion
|
|
47
|
+
// notifications can look up linked PRs for issue URLs.
|
|
48
|
+
this.urlContext = options.urlContext || null;
|
|
49
|
+
// Issue #1688: requester user ID for /subscribe duplicate-suppression.
|
|
50
|
+
this.requesterUserId = options.ctx?.from?.id ?? null;
|
|
46
51
|
this.createdAt = new Date();
|
|
47
52
|
this.startedAt = null;
|
|
48
53
|
this.status = QueueItemStatus.QUEUED;
|
|
@@ -1361,7 +1366,20 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
|
|
|
1361
1366
|
const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
1362
1367
|
const session = match ? match[1] : null;
|
|
1363
1368
|
if (session) {
|
|
1364
|
-
trackSessionFn(session, {
|
|
1369
|
+
trackSessionFn(session, {
|
|
1370
|
+
chatId: item.ctx?.chat?.id,
|
|
1371
|
+
messageId: item.messageInfo?.messageId,
|
|
1372
|
+
startTime: new Date(),
|
|
1373
|
+
url: item.url,
|
|
1374
|
+
command: 'solve',
|
|
1375
|
+
tool: item.tool || 'claude',
|
|
1376
|
+
infoBlock: item.infoBlock,
|
|
1377
|
+
// Issue #1688: propagate URL context + requester so the completion
|
|
1378
|
+
// notification can append a 'Pull request:' line and skip
|
|
1379
|
+
// notifying the requester twice via /subscribe.
|
|
1380
|
+
urlContext: item.urlContext || null,
|
|
1381
|
+
requesterUserId: item.requesterUserId ?? null,
|
|
1382
|
+
});
|
|
1365
1383
|
}
|
|
1366
1384
|
}
|
|
1367
1385
|
return result;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram /subscribe and /unsubscribe command implementation (experimental).
|
|
3
|
+
*
|
|
4
|
+
* In-memory store of users who want to receive a private notification when
|
|
5
|
+
* a /solve (or alias) work session completes. Storage is intentionally
|
|
6
|
+
* volatile: subscriptions are cleared on bot restart since we do not yet
|
|
7
|
+
* have a database (issue #1688).
|
|
8
|
+
*
|
|
9
|
+
* /subscribe and /unsubscribe work in both private chats and public group
|
|
10
|
+
* chats. They store the Telegram user ID (not the chat ID) so a single
|
|
11
|
+
* user only ever receives one private notification, regardless of which
|
|
12
|
+
* chat they ran the command in.
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1688
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Map<userId, { username, firstName, subscribedAt, sourceChatId }>
|
|
18
|
+
const subscribers = new Map();
|
|
19
|
+
|
|
20
|
+
export function isSubscribed(userId) {
|
|
21
|
+
if (userId === null || userId === undefined) return false;
|
|
22
|
+
return subscribers.has(userId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function addSubscriber(userId, info = {}) {
|
|
26
|
+
if (userId === null || userId === undefined) return false;
|
|
27
|
+
const existed = subscribers.has(userId);
|
|
28
|
+
subscribers.set(userId, {
|
|
29
|
+
username: info.username || null,
|
|
30
|
+
firstName: info.firstName || null,
|
|
31
|
+
subscribedAt: existed ? subscribers.get(userId).subscribedAt : new Date(),
|
|
32
|
+
sourceChatId: info.sourceChatId ?? null,
|
|
33
|
+
});
|
|
34
|
+
return !existed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function removeSubscriber(userId) {
|
|
38
|
+
if (userId === null || userId === undefined) return false;
|
|
39
|
+
return subscribers.delete(userId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getSubscribers() {
|
|
43
|
+
return Array.from(subscribers.entries()).map(([userId, info]) => ({ userId, ...info }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getSubscriberCount() {
|
|
47
|
+
return subscribers.size;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resetSubscribersForTests() {
|
|
51
|
+
subscribers.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Forward (or send) a session-completion notification to every subscribed user
|
|
56
|
+
* in their private chat with the bot.
|
|
57
|
+
*
|
|
58
|
+
* Strategy:
|
|
59
|
+
* 1. Try forwardMessage(userId, sourceChatId, messageId) ā preserves the
|
|
60
|
+
* original visual style of the reply Telegram users already see in chat.
|
|
61
|
+
* 2. If that fails (e.g. the user has never started a private chat with
|
|
62
|
+
* the bot, or the message can't be forwarded), fall back to
|
|
63
|
+
* sendMessage(userId, fallbackText) so the notification still arrives
|
|
64
|
+
* when possible.
|
|
65
|
+
*
|
|
66
|
+
* Returns a summary so callers can log delivery results.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} params
|
|
69
|
+
* @param {Object} params.bot - Telegraf bot instance
|
|
70
|
+
* @param {number} params.fromChatId - Chat ID of the original /solve reply
|
|
71
|
+
* @param {number} params.messageId - Message ID of the (now-edited) reply
|
|
72
|
+
* @param {string} [params.fallbackText] - Plain-text body to send when forwardMessage is rejected
|
|
73
|
+
* @param {Object} [params.fallbackOptions] - Telegram sendMessage options for fallback
|
|
74
|
+
* @param {Set<number>} [params.skipUserIds] - Users that should not receive the notification (e.g. requester is already in the chat)
|
|
75
|
+
* @param {boolean} [params.verbose]
|
|
76
|
+
* @returns {Promise<{forwarded: number, sent: number, skipped: number, failures: Array}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function notifySubscribers({ bot, fromChatId, messageId, fallbackText = '', fallbackOptions = {}, skipUserIds = null, verbose = false } = {}) {
|
|
79
|
+
const summary = { forwarded: 0, sent: 0, skipped: 0, failures: [] };
|
|
80
|
+
if (!bot || !bot.telegram) {
|
|
81
|
+
if (verbose) console.log('[VERBOSE] notifySubscribers: missing bot/telegram, skipping');
|
|
82
|
+
return summary;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const [userId, info] of subscribers.entries()) {
|
|
86
|
+
if (skipUserIds && skipUserIds.has(userId)) {
|
|
87
|
+
summary.skipped += 1;
|
|
88
|
+
if (verbose) console.log(`[VERBOSE] notifySubscribers: skipping user ${userId} (in skip set)`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let forwarded = false;
|
|
93
|
+
if (fromChatId !== null && fromChatId !== undefined && messageId !== null && messageId !== undefined) {
|
|
94
|
+
try {
|
|
95
|
+
await bot.telegram.forwardMessage(userId, fromChatId, messageId);
|
|
96
|
+
summary.forwarded += 1;
|
|
97
|
+
forwarded = true;
|
|
98
|
+
if (verbose) {
|
|
99
|
+
console.log(`[VERBOSE] notifySubscribers: forwarded to user ${userId} (${info.username || info.firstName || 'unknown'})`);
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (verbose) {
|
|
103
|
+
console.log(`[VERBOSE] notifySubscribers: forwardMessage to ${userId} failed: ${error?.message || error}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (forwarded) continue;
|
|
109
|
+
|
|
110
|
+
if (!fallbackText) {
|
|
111
|
+
summary.failures.push({ userId, reason: 'forwardMessage failed and no fallback text supplied' });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await bot.telegram.sendMessage(userId, fallbackText, fallbackOptions);
|
|
117
|
+
summary.sent += 1;
|
|
118
|
+
if (verbose) {
|
|
119
|
+
console.log(`[VERBOSE] notifySubscribers: sent fallback message to user ${userId}`);
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
summary.failures.push({ userId, reason: error?.message || String(error) });
|
|
123
|
+
if (verbose) {
|
|
124
|
+
console.log(`[VERBOSE] notifySubscribers: sendMessage to ${userId} failed: ${error?.message || error}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return summary;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SUBSCRIBE_CONFIRMATION = 'š *Subscribed* (experimental)\n\n' + 'You will receive a private notification each time a /solve command finishes (in any chat where this bot runs).\n\n' + 'ā ļø Subscriptions are kept in memory and are cleared whenever the bot restarts.\n\n' + 'š” If notifications never arrive, open a private chat with the bot and send /start so Telegram lets the bot DM you.\n\n' + 'Use /unsubscribe to stop receiving these notifications.';
|
|
133
|
+
|
|
134
|
+
const UNSUBSCRIBE_CONFIRMATION = 'š *Unsubscribed*\n\n' + 'You will no longer receive private notifications when /solve commands finish.\n\n' + 'Use /subscribe to resume notifications.';
|
|
135
|
+
|
|
136
|
+
const NOT_SUBSCRIBED_MESSAGE = 'ā¹ļø You are not subscribed.\n\nUse /subscribe to start receiving private notifications when /solve commands finish.';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Register /subscribe and /unsubscribe handlers.
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} bot - Telegraf bot instance
|
|
142
|
+
* @param {Object} options - Shared command options (VERBOSE, isOldMessage, ...)
|
|
143
|
+
*/
|
|
144
|
+
export function registerSubscribeCommands(bot, options = {}) {
|
|
145
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb } = options;
|
|
146
|
+
|
|
147
|
+
async function shouldHandle(ctx, cmdName) {
|
|
148
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} command received`);
|
|
149
|
+
if (addBreadcrumb) {
|
|
150
|
+
await addBreadcrumb({
|
|
151
|
+
category: 'telegram.command',
|
|
152
|
+
message: `${cmdName} command received`,
|
|
153
|
+
level: 'info',
|
|
154
|
+
data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (isOldMessage && isOldMessage(ctx)) {
|
|
158
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: old message`);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (isForwardedOrReply && isForwardedOrReply(ctx)) {
|
|
162
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: forwarded or reply`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// Issue #1688: /subscribe and /unsubscribe work in both private and group chats.
|
|
166
|
+
// In group chats we still require chat/topic authorization so unauthorized
|
|
167
|
+
// chats cannot use the bot to spam.
|
|
168
|
+
const chatType = ctx.chat?.type;
|
|
169
|
+
const isPrivateChat = chatType === 'private';
|
|
170
|
+
if (!isPrivateChat && isTopicAuthorized && !isTopicAuthorized(ctx)) {
|
|
171
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: not authorized`);
|
|
172
|
+
if (buildAuthErrorMessage) {
|
|
173
|
+
await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message?.message_id });
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (!ctx.from?.id) {
|
|
178
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: no user id on update`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
bot.command('subscribe', async ctx => {
|
|
185
|
+
if (!(await shouldHandle(ctx, '/subscribe'))) return;
|
|
186
|
+
|
|
187
|
+
const userId = ctx.from.id;
|
|
188
|
+
const wasNew = addSubscriber(userId, {
|
|
189
|
+
username: ctx.from.username,
|
|
190
|
+
firstName: ctx.from.first_name,
|
|
191
|
+
sourceChatId: ctx.chat?.id ?? null,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
let message = SUBSCRIBE_CONFIRMATION;
|
|
195
|
+
if (!wasNew) {
|
|
196
|
+
message = 'ā¹ļø You are already subscribed.\n\nUse /unsubscribe to stop receiving private notifications when /solve commands finish.';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await ctx.reply(message, {
|
|
200
|
+
parse_mode: 'Markdown',
|
|
201
|
+
reply_to_message_id: ctx.message?.message_id,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) added (new=${wasNew}); total=${getSubscriberCount()}`);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
bot.command('unsubscribe', async ctx => {
|
|
208
|
+
if (!(await shouldHandle(ctx, '/unsubscribe'))) return;
|
|
209
|
+
|
|
210
|
+
const userId = ctx.from.id;
|
|
211
|
+
const wasRemoved = removeSubscriber(userId);
|
|
212
|
+
|
|
213
|
+
await ctx.reply(wasRemoved ? UNSUBSCRIBE_CONFIRMATION : NOT_SUBSCRIBED_MESSAGE, {
|
|
214
|
+
parse_mode: 'Markdown',
|
|
215
|
+
reply_to_message_id: ctx.message?.message_id,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) removed=${wasRemoved}; total=${getSubscriberCount()}`);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -52,7 +52,38 @@ export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', iso
|
|
|
52
52
|
return `ā³ Executing...\n\nš Session: \`${sessionName}\`${isolationInfo}${details}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Append an extra "Pull request:" line to an existing infoBlock when an issue's
|
|
57
|
+
* /solve session has produced a PR. Idempotent ā already present URLs are not
|
|
58
|
+
* duplicated.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} infoBlock - Existing infoBlock (already contains an Issue: line)
|
|
61
|
+
* @param {string|null} pullRequestUrl - PR URL discovered after the session completed
|
|
62
|
+
* @returns {string} New infoBlock
|
|
63
|
+
*
|
|
64
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1688
|
|
65
|
+
*/
|
|
66
|
+
export function appendPullRequestLine(infoBlock, pullRequestUrl) {
|
|
67
|
+
if (!pullRequestUrl || !infoBlock) return infoBlock || '';
|
|
68
|
+
if (infoBlock.includes(pullRequestUrl)) return infoBlock;
|
|
69
|
+
|
|
70
|
+
const lines = infoBlock.split('\n');
|
|
71
|
+
let lastUrlLineIdx = -1;
|
|
72
|
+
for (let i = 0; i < lines.length; i++) {
|
|
73
|
+
if (/^(Issue|Pull request|URL):\s/.test(lines[i])) {
|
|
74
|
+
lastUrlLineIdx = i;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const prLine = `Pull request: ${pullRequestUrl}`;
|
|
78
|
+
if (lastUrlLineIdx === -1) {
|
|
79
|
+
return `${infoBlock}\n${prLine}`;
|
|
80
|
+
}
|
|
81
|
+
const before = lines.slice(0, lastUrlLineIdx + 1);
|
|
82
|
+
const after = lines.slice(lastUrlLineIdx + 1);
|
|
83
|
+
return [...before, prLine, ...after].join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null, infoBlock = '', pullRequestUrl = null } = {}) {
|
|
56
87
|
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
57
88
|
const failed = finalExitCode !== null && finalExitCode !== 0;
|
|
58
89
|
const statusEmoji = failed ? 'ā' : 'ā
';
|
|
@@ -61,7 +92,10 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
|
|
|
61
92
|
const startTime = parseDateValue(statusResult?.startTime) || parseDateValue(sessionInfo?.startTime) || observedEndTime;
|
|
62
93
|
const endTime = parseDateValue(statusResult?.endTime) || observedEndTime;
|
|
63
94
|
const durationSeconds = Math.max(0, (endTime.getTime() - startTime.getTime()) / 1000);
|
|
64
|
-
|
|
95
|
+
let resolvedInfoBlock = infoBlock || sessionInfo?.infoBlock || '';
|
|
96
|
+
// Issue #1688: When the agent created a PR for an issue-driven /solve, append
|
|
97
|
+
// a 'Pull request:' line so the completion message includes both Issue and PR links.
|
|
98
|
+
if (pullRequestUrl) resolvedInfoBlock = appendPullRequestLine(resolvedInfoBlock, pullRequestUrl);
|
|
65
99
|
const details = resolvedInfoBlock ? `\n\n${resolvedInfoBlock}` : '';
|
|
66
100
|
|
|
67
101
|
let message = `${statusEmoji} *${statusText}*\n\n`;
|