@link-assistant/hive-mind 1.56.18 → 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 +6 -0
- package/package.json +2 -2
- package/src/isolation-runner.lib.mjs +13 -4
- package/src/session-monitor.lib.mjs +15 -0
- package/src/telegram-bot.mjs +5 -4
- package/src/telegram-log-command.lib.mjs +372 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 1.56.18
|
|
4
10
|
|
|
5
11
|
### 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-1688-subscribe-and-pr-link.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
|
|
|
@@ -89,6 +89,21 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
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
|
+
|
|
92
107
|
/**
|
|
93
108
|
* Get the number of active sessions being tracked
|
|
94
109
|
* @param {boolean} verbose - Whether to log verbose output
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -662,9 +662,9 @@ bot.command('help', async ctx => {
|
|
|
662
662
|
message += "Merges all PRs with 'ready' label sequentially.\n";
|
|
663
663
|
message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
|
|
664
664
|
message += '*/help* - Show this help message\n';
|
|
665
|
-
message += '*/stop* - Stop accepting new tasks (owner only)\n';
|
|
666
|
-
message += '*/
|
|
667
|
-
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';
|
|
668
668
|
if (ISOLATION_BACKEND) message += `🔒 *Isolation Mode:* \`${ISOLATION_BACKEND}\` (experimental)\n`;
|
|
669
669
|
message += '\n';
|
|
670
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';
|
|
@@ -763,7 +763,6 @@ bot.command('version', async ctx => {
|
|
|
763
763
|
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, '🤖 *Version Information*\n\n' + formatVersionMessage(result.versions), { parse_mode: 'Markdown' });
|
|
764
764
|
});
|
|
765
765
|
|
|
766
|
-
// Register external command modules (keeps telegram-bot.mjs under line limit)
|
|
767
766
|
const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
|
|
768
767
|
const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
|
|
769
768
|
registerAcceptInvitesCommand(bot, sharedCommandOpts);
|
|
@@ -1191,8 +1190,10 @@ bot.command(/^hive$/i, handleHiveCommand);
|
|
|
1191
1190
|
|
|
1192
1191
|
const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
|
|
1193
1192
|
const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
|
|
1193
|
+
const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
|
|
1194
1194
|
registerTopCommand(bot, sharedCommandOpts);
|
|
1195
1195
|
registerStartStopCommands(bot, sharedCommandOpts);
|
|
1196
|
+
await registerLogCommand(bot, sharedCommandOpts);
|
|
1196
1197
|
|
|
1197
1198
|
// Add message listener for verbose debugging
|
|
1198
1199
|
if (VERBOSE) {
|
|
@@ -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
|
+
};
|