@link-assistant/hive-mind 1.74.6 → 1.74.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.hi.md +3 -0
- package/README.md +3 -0
- package/README.ru.md +3 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/session-monitor.lib.mjs +72 -1
- package/src/telegram-auth-command.lib.mjs +298 -0
- package/src/telegram-bot.mjs +13 -5
- package/src/telegram-start-stop-command.lib.mjs +70 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.74.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c132ce0: Fix `/stop <issue-or-pr-url>` so it can stop tasks that started immediately
|
|
8
|
+
(empty queue) or were already dispatched to a detached isolation session. The
|
|
9
|
+
URL lookup now also consults the session-monitor registry and forwards CTRL+C
|
|
10
|
+
to the tracked start-command UUID, so all three stop modes (issue URL, PR URL,
|
|
11
|
+
and session UUID) work end-to-end (#1871).
|
|
12
|
+
|
|
13
|
+
## 1.74.7
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 8ea7110: Document the issue #1858 case study and add an experimental private Telegram
|
|
18
|
+
`/auth` command for allowlisted chat owners to check or start GitHub, Claude,
|
|
19
|
+
and Codex auth flows.
|
|
20
|
+
|
|
3
21
|
## 1.74.6
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.hi.md
CHANGED
|
@@ -559,6 +559,7 @@ Shows:
|
|
|
559
559
|
- ✅ **Screen सत्र**: कमांड डिटैच्ड screen सत्रों में चलते हैं
|
|
560
560
|
- ✅ **Live Terminal Watch**: `/terminal_watch` और opt-in auto-start live session logs दिखाते हैं
|
|
561
561
|
- ✅ **चैट प्रतिबंध**: अनुमत चैट ID की वैकल्पिक सफेद सूची
|
|
562
|
+
- ✅ **Private Auth Check**: allowlisted chat owners के लिए experimental `/auth --status <gh|claude|codex>` और `/auth --login <gh|claude|codex>`
|
|
562
563
|
- ✅ **डायग्नोस्टिक टूल**: चैट ID और कॉन्फ़िगरेशन जानकारी प्राप्त करें
|
|
563
564
|
|
|
564
565
|
#### Live Terminal Watch
|
|
@@ -577,6 +578,8 @@ sessions के लिए अपने आप एक अलग live terminal wat
|
|
|
577
578
|
|
|
578
579
|
- केवल उन ग्रुप चैट में काम करता है जहाँ बॉट एडमिन है
|
|
579
580
|
- `TELEGRAM_ALLOWED_CHATS` के माध्यम से वैकल्पिक चैट ID प्रतिबंध
|
|
581
|
+
- private `/auth` तब disabled रहता है जब `TELEGRAM_ALLOWED_CHATS` set नहीं है,
|
|
582
|
+
और इसे केवल listed chats के owners इस्तेमाल कर सकते हैं
|
|
580
583
|
- बॉट चलाने वाले सिस्टम उपयोगकर्ता के रूप में कमांड चलते हैं
|
|
581
584
|
- उचित प्रमाणीकरण सुनिश्चित करें (`gh auth login`, `claude-profiles`)
|
|
582
585
|
|
package/README.md
CHANGED
|
@@ -580,6 +580,7 @@ Shows:
|
|
|
580
580
|
- ✅ **Screen Sessions**: Commands run in detached screen sessions
|
|
581
581
|
- ✅ **Live Terminal Watch**: `/terminal_watch` and opt-in auto-start show live session logs
|
|
582
582
|
- ✅ **Chat Restrictions**: Optional whitelist of allowed chat IDs
|
|
583
|
+
- ✅ **Private Auth Check**: Experimental `/auth --status <gh|claude|codex>` and `/auth --login <gh|claude|codex>` for owners of allowlisted chats
|
|
583
584
|
- ✅ **Diagnostic Tools**: Get chat ID and configuration info
|
|
584
585
|
|
|
585
586
|
#### Live Terminal Watch
|
|
@@ -597,6 +598,8 @@ When enabled with `--auto-start-screen-watch-message`, the bot automatically sta
|
|
|
597
598
|
|
|
598
599
|
- Only works in group chats where the bot is admin
|
|
599
600
|
- Optional chat ID restrictions via `TELEGRAM_ALLOWED_CHATS`
|
|
601
|
+
- Private `/auth` is disabled unless `TELEGRAM_ALLOWED_CHATS` is set and only
|
|
602
|
+
owners of listed chats can use it
|
|
600
603
|
- Commands run as the system user running the bot
|
|
601
604
|
- Ensure proper authentication (`gh auth login`, `claude-profiles`)
|
|
602
605
|
|
package/README.ru.md
CHANGED
|
@@ -561,6 +561,7 @@ Shows:
|
|
|
561
561
|
- ✅ **Screen-сессии**: команды запускаются в отсоединённых screen-сессиях
|
|
562
562
|
- ✅ **Live Terminal Watch**: `/terminal_watch` и opt-in auto-start показывают live session logs
|
|
563
563
|
- ✅ **Ограничения по чатам**: опциональный белый список разрешённых ID чатов
|
|
564
|
+
- ✅ **Приватная проверка auth**: экспериментальные `/auth --status <gh|claude|codex>` и `/auth --login <gh|claude|codex>` для владельцев разрешённых чатов
|
|
564
565
|
- ✅ **Диагностические инструменты**: получение ID чата и информации о конфигурации
|
|
565
566
|
|
|
566
567
|
#### Live Terminal Watch
|
|
@@ -579,6 +580,8 @@ Shows:
|
|
|
579
580
|
|
|
580
581
|
- Работает только в групповых чатах, где бот является администратором
|
|
581
582
|
- Опциональное ограничение по ID чата через `TELEGRAM_ALLOWED_CHATS`
|
|
583
|
+
- Приватная `/auth` отключена, если `TELEGRAM_ALLOWED_CHATS` не задан, и
|
|
584
|
+
доступна только владельцам перечисленных чатов
|
|
582
585
|
- Команды выполняются от имени системного пользователя, запустившего бота
|
|
583
586
|
- Убедитесь в наличии надлежащей аутентификации (`gh auth login`, `claude-profiles`)
|
|
584
587
|
|
package/README.zh.md
CHANGED
|
@@ -555,6 +555,7 @@ Shows:
|
|
|
555
555
|
- ✅ **Screen 会话**:命令在后台 Screen 会话中运行
|
|
556
556
|
- ✅ **Live Terminal Watch**:`/terminal_watch` 和 opt-in auto-start 显示 live session logs
|
|
557
557
|
- ✅ **聊天限制**:可选配置允许的聊天 ID 白名单
|
|
558
|
+
- ✅ **私聊 Auth 检查**:为白名单聊天所有者提供实验性的 `/auth --status <gh|claude|codex>` 和 `/auth --login <gh|claude|codex>`
|
|
558
559
|
- ✅ **诊断工具**:获取聊天 ID 和配置信息
|
|
559
560
|
|
|
560
561
|
#### Live Terminal Watch
|
|
@@ -573,6 +574,7 @@ Shows:
|
|
|
573
574
|
|
|
574
575
|
- 仅在机器人为管理员的群聊中有效
|
|
575
576
|
- 可通过 `TELEGRAM_ALLOWED_CHATS` 配置可选的聊天 ID 限制
|
|
577
|
+
- 如果未设置 `TELEGRAM_ALLOWED_CHATS`,私聊 `/auth` 会被禁用,且只有所列聊天的所有者可以使用
|
|
576
578
|
- 命令以运行机器人的系统用户身份执行
|
|
577
579
|
- 请确保已完成正确的身份验证(`gh auth login`、`claude-profiles`)
|
|
578
580
|
|
package/package.json
CHANGED
|
@@ -150,7 +150,11 @@ function isMessageAlreadyUpdatedError(error) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function normalizeSessionUrl(url) {
|
|
153
|
-
|
|
153
|
+
// Strip the fragment first, then any trailing slashes, so URLs that carry a
|
|
154
|
+
// fragment after a trailing slash (e.g. `.../issues/18/#comment`) normalize to
|
|
155
|
+
// the same value as the bare `.../issues/18`. Doing it in the other order
|
|
156
|
+
// would leave a dangling trailing slash. (Issue #1871.)
|
|
157
|
+
return url.replace(/#.*$/, '').replace(/\/+$/, '').toLowerCase();
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
|
|
@@ -488,6 +492,73 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
488
492
|
return { isActive: false, sessionName: null };
|
|
489
493
|
}
|
|
490
494
|
|
|
495
|
+
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
|
|
499
|
+
* and report whether it can be stopped by forwarding CTRL+C to the
|
|
500
|
+
* start-command session UUID.
|
|
501
|
+
*
|
|
502
|
+
* The `/stop <url>` Telegram flow originally consulted only the in-memory solve
|
|
503
|
+
* queue. But a `/solve` or `/codex` that starts immediately (queue empty)
|
|
504
|
+
* dispatches straight to a detached isolation session and is removed from the
|
|
505
|
+
* queue's `processing` Map the moment it is launched. From that point on the
|
|
506
|
+
* session-monitor's in-memory registry is the only place that still knows the
|
|
507
|
+
* URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
|
|
508
|
+
* even though the task was clearly running. This helper exposes that registry
|
|
509
|
+
* so the stop flow can recover the UUID and interrupt the session.
|
|
510
|
+
*
|
|
511
|
+
* A session is stoppable when it was launched with an isolation backend and its
|
|
512
|
+
* start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
|
|
513
|
+
* non-isolation screen sessions are reported but marked `stoppable: false`
|
|
514
|
+
* because `$ --stop` cannot interrupt them.
|
|
515
|
+
*
|
|
516
|
+
* @param {string} url - GitHub issue or PR URL (any normalization)
|
|
517
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
518
|
+
* @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
|
|
519
|
+
* isolationBackend: string|null, stoppable: boolean }|null} Match or null
|
|
520
|
+
*/
|
|
521
|
+
export function findStoppableSessionByUrl(url, verbose = false) {
|
|
522
|
+
if (!url) return null;
|
|
523
|
+
|
|
524
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
525
|
+
|
|
526
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
527
|
+
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
// Issue #1586: skip expired non-isolation sessions — they are no longer running.
|
|
531
|
+
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// The UUID `$ --stop` expects is the start-command session id. For
|
|
536
|
+
// isolation sessions it is tracked either as sessionInfo.sessionId or as
|
|
537
|
+
// the (UUID-shaped) session key itself.
|
|
538
|
+
const candidateId = sessionInfo.sessionId || sessionName;
|
|
539
|
+
const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
|
|
540
|
+
const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
|
|
541
|
+
|
|
542
|
+
if (verbose) {
|
|
543
|
+
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
|
|
544
|
+
console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
sessionName,
|
|
549
|
+
sessionId,
|
|
550
|
+
sessionInfo,
|
|
551
|
+
isolationBackend: sessionInfo.isolationBackend || null,
|
|
552
|
+
stoppable,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (verbose) {
|
|
557
|
+
console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
491
562
|
/**
|
|
492
563
|
* Async active-session check for command handlers.
|
|
493
564
|
*
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { parseCommandArgs } from './telegram-solve-command.lib.mjs';
|
|
3
|
+
|
|
4
|
+
export const AUTH_PROVIDERS = Object.freeze(['gh', 'claude', 'codex']);
|
|
5
|
+
|
|
6
|
+
const AUTH_PROVIDER_SET = new Set(AUTH_PROVIDERS);
|
|
7
|
+
const AUTH_USAGE = 'Usage: /auth --status <gh|claude|codex> or /auth --login <gh|claude|codex>';
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
10
|
+
const TOKEN_RE = /\b(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-proj-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g;
|
|
11
|
+
const TOKEN_FIELD_RE = /\b(token|access_token|refresh_token|api[_-]?key|authorization)\s*[:=]\s*["']?[^"'\s,}]+/gi;
|
|
12
|
+
|
|
13
|
+
function trimOutput(text, max = 3500) {
|
|
14
|
+
const value = String(text || '').trim();
|
|
15
|
+
if (value.length <= max) return value;
|
|
16
|
+
return value.slice(0, max) + `\n... truncated ${value.length - max} characters`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function escapeCodeFence(text) {
|
|
20
|
+
return String(text || '').replace(/```/g, '` ` `');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeProvider(provider) {
|
|
24
|
+
return String(provider || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readActionValue(arg) {
|
|
30
|
+
if (arg === '--status') return { action: 'status', provider: null, consumesNext: true };
|
|
31
|
+
if (arg === '--login') return { action: 'login', provider: null, consumesNext: true };
|
|
32
|
+
if (arg.startsWith('--status=')) return { action: 'status', provider: arg.slice('--status='.length), consumesNext: false };
|
|
33
|
+
if (arg.startsWith('--login=')) return { action: 'login', provider: arg.slice('--login='.length), consumesNext: false };
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseAuthRequest(text) {
|
|
38
|
+
const args = parseCommandArgs(text || '');
|
|
39
|
+
let action = null;
|
|
40
|
+
let provider = null;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < args.length; i++) {
|
|
43
|
+
const parsed = readActionValue(args[i]);
|
|
44
|
+
if (!parsed) {
|
|
45
|
+
return { action: null, provider: null, error: `Unsupported /auth argument: ${args[i]}\n\n${AUTH_USAGE}` };
|
|
46
|
+
}
|
|
47
|
+
if (action) {
|
|
48
|
+
return { action: null, provider: null, error: `Use exactly one of --status or --login.\n\n${AUTH_USAGE}` };
|
|
49
|
+
}
|
|
50
|
+
action = parsed.action;
|
|
51
|
+
provider = normalizeProvider(parsed.provider);
|
|
52
|
+
if (parsed.consumesNext) {
|
|
53
|
+
const next = args[i + 1];
|
|
54
|
+
if (!next || next.startsWith('--')) {
|
|
55
|
+
return { action: null, provider: null, error: AUTH_USAGE };
|
|
56
|
+
}
|
|
57
|
+
provider = normalizeProvider(next);
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!action || !provider) {
|
|
63
|
+
return { action: null, provider: null, error: AUTH_USAGE };
|
|
64
|
+
}
|
|
65
|
+
if (!AUTH_PROVIDER_SET.has(provider)) {
|
|
66
|
+
return { action, provider: null, error: `Unsupported auth provider: ${provider}\n\n${AUTH_USAGE}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { action, provider, error: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildAuthCommand(action, provider) {
|
|
73
|
+
if (action === 'status') {
|
|
74
|
+
if (provider === 'gh') return { command: 'gh', args: ['auth', 'status', '--hostname', 'github.com'] };
|
|
75
|
+
if (provider === 'claude') return { command: 'claude', args: ['auth', 'status'] };
|
|
76
|
+
if (provider === 'codex') return { command: 'codex', args: ['login', 'status'] };
|
|
77
|
+
}
|
|
78
|
+
if (action === 'login') {
|
|
79
|
+
if (provider === 'gh') return { command: 'gh', args: ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web'] };
|
|
80
|
+
if (provider === 'claude') return { command: 'claude', args: ['auth', 'login', '--claudeai'] };
|
|
81
|
+
if (provider === 'codex') return { command: 'codex', args: ['login', '--device-auth'] };
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Unsupported auth command: ${action} ${provider}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function redactAuthOutput(output) {
|
|
87
|
+
return String(output || '')
|
|
88
|
+
.replace(ANSI_RE, '')
|
|
89
|
+
.replace(TOKEN_RE, '[REDACTED_TOKEN]')
|
|
90
|
+
.replace(TOKEN_FIELD_RE, (_, name) => `${name}: [REDACTED_TOKEN]`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function collectAuthOutput(result) {
|
|
94
|
+
return redactAuthOutput([result?.stdout, result?.stderr].filter(Boolean).join('\n'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function extractAuthStartDetails(output) {
|
|
98
|
+
const text = redactAuthOutput(output);
|
|
99
|
+
const urls = [...new Set([...text.matchAll(/https?:\/\/[^\s<>)"']+/g)].map(match => match[0].replace(/[.,;:!?]+$/, '')))];
|
|
100
|
+
|
|
101
|
+
const codePatterns = [/\bone-time code\s*[:=]\s*([A-Z0-9][A-Z0-9-]{3,})/i, /\b(?:user code|verification code|code)\s*[:=]\s*([A-Z0-9][A-Z0-9-]{3,})/i, /\b([A-Z0-9]{4,}-[A-Z0-9-]{4,})\b/];
|
|
102
|
+
let code = null;
|
|
103
|
+
for (const pattern of codePatterns) {
|
|
104
|
+
const match = text.match(pattern);
|
|
105
|
+
if (match) {
|
|
106
|
+
code = match[1].toUpperCase();
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { urls, code };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatAuthStatusMessage(provider, result) {
|
|
115
|
+
const code = result?.code;
|
|
116
|
+
const ok = code === 0;
|
|
117
|
+
const output = trimOutput(collectAuthOutput(result)) || '(no output)';
|
|
118
|
+
return `${ok ? 'OK' : 'ERROR'} *${provider} auth status*\n\nExit code: ${code ?? 'unknown'}\n\n\`\`\`\n${escapeCodeFence(output)}\n\`\`\``;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatAuthLoginMessage(provider, result) {
|
|
122
|
+
const output = collectAuthOutput(result);
|
|
123
|
+
const details = extractAuthStartDetails(output);
|
|
124
|
+
const lines = [`*${provider} auth login started*`, '', 'The local login command was cancelled locally after capturing the browser step, so this bot command did not replace existing credentials.'];
|
|
125
|
+
|
|
126
|
+
if (details.urls.length > 0) {
|
|
127
|
+
lines.push('', 'Open this URL:');
|
|
128
|
+
for (const url of details.urls) lines.push(url);
|
|
129
|
+
}
|
|
130
|
+
if (details.code) {
|
|
131
|
+
lines.push('', `Code: \`${details.code}\``);
|
|
132
|
+
}
|
|
133
|
+
if (details.urls.length === 0 && !details.code) {
|
|
134
|
+
const shownOutput = trimOutput(output) || '(no output captured)';
|
|
135
|
+
lines.push('', 'Captured output:', '', '```', escapeCodeFence(shownOutput), '```');
|
|
136
|
+
}
|
|
137
|
+
if (result?.cancelled) {
|
|
138
|
+
lines.push('', 'Status: cancelled locally after capture.');
|
|
139
|
+
} else if (typeof result?.code === 'number') {
|
|
140
|
+
lines.push('', `Status: login command exited with code ${result.code}.`);
|
|
141
|
+
}
|
|
142
|
+
lines.push('', 'Continuation by replying with a provider code is not automated yet; this is the first experimental CLI-backed /auth path.');
|
|
143
|
+
return lines.join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const resolveAllowedAuthChatIds = allowedChats => {
|
|
147
|
+
if (!allowedChats) return [];
|
|
148
|
+
const raw = typeof allowedChats === 'function' ? allowedChats() : allowedChats;
|
|
149
|
+
if (!Array.isArray(raw)) return [];
|
|
150
|
+
return raw.map(value => String(value)).filter(Boolean);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export async function isAuthOperator({ telegram, userId, allowedChatIds }) {
|
|
154
|
+
if (!telegram || !userId || !allowedChatIds || allowedChatIds.length === 0) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
for (const chatId of allowedChatIds) {
|
|
158
|
+
if (String(chatId) === String(userId)) return true;
|
|
159
|
+
try {
|
|
160
|
+
const member = await telegram.getChatMember(chatId, userId);
|
|
161
|
+
if (member?.status === 'creator') return true;
|
|
162
|
+
} catch {
|
|
163
|
+
// Try the next configured chat. The bot may no longer be a member.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function runAuthCommand(command, args, options = {}) {
|
|
170
|
+
const { mode = 'status', loginCaptureMs = 15000, outputLimit = 20000, env = process.env } = options;
|
|
171
|
+
return new Promise(resolve => {
|
|
172
|
+
const child = spawn(command, args, {
|
|
173
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
174
|
+
env,
|
|
175
|
+
});
|
|
176
|
+
let stdout = '';
|
|
177
|
+
let stderr = '';
|
|
178
|
+
let settled = false;
|
|
179
|
+
let captureTimer = null;
|
|
180
|
+
|
|
181
|
+
const settle = result => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
if (captureTimer) clearTimeout(captureTimer);
|
|
185
|
+
resolve({
|
|
186
|
+
stdout: stdout.slice(0, outputLimit),
|
|
187
|
+
stderr: stderr.slice(0, outputLimit),
|
|
188
|
+
...result,
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const maybeCancelLogin = () => {
|
|
193
|
+
if (mode !== 'login' || settled) return;
|
|
194
|
+
const details = extractAuthStartDetails(`${stdout}\n${stderr}`);
|
|
195
|
+
if (details.urls.length === 0 && !details.code) return;
|
|
196
|
+
child.kill('SIGTERM');
|
|
197
|
+
settle({ code: null, signal: 'SIGTERM', cancelled: true });
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
child.stdout.on('data', data => {
|
|
201
|
+
stdout += data.toString();
|
|
202
|
+
maybeCancelLogin();
|
|
203
|
+
});
|
|
204
|
+
child.stderr.on('data', data => {
|
|
205
|
+
stderr += data.toString();
|
|
206
|
+
maybeCancelLogin();
|
|
207
|
+
});
|
|
208
|
+
child.on('error', error => {
|
|
209
|
+
settle({ code: null, error: error.message });
|
|
210
|
+
});
|
|
211
|
+
child.on('close', (code, signal) => {
|
|
212
|
+
settle({ code, signal, cancelled: false });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (mode === 'login') {
|
|
216
|
+
captureTimer = setTimeout(() => {
|
|
217
|
+
child.kill('SIGTERM');
|
|
218
|
+
settle({ code: null, signal: 'SIGTERM', cancelled: true });
|
|
219
|
+
}, loginCaptureMs);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function registerAuthCommand(bot, options = {}) {
|
|
225
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, allowedChats, authEnabled = true } = options;
|
|
226
|
+
const execute = options.runCommand || runAuthCommand;
|
|
227
|
+
const reply = options.safeReply || ((ctx, text, replyOptions) => ctx.reply(text, replyOptions));
|
|
228
|
+
|
|
229
|
+
async function handleAuthCommand(ctx) {
|
|
230
|
+
VERBOSE && console.log('[VERBOSE] /auth command received');
|
|
231
|
+
|
|
232
|
+
if (isOldMessage && isOldMessage(ctx)) {
|
|
233
|
+
VERBOSE && console.log('[VERBOSE] /auth ignored: old message');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (isForwardedOrReply && isForwardedOrReply(ctx)) {
|
|
237
|
+
VERBOSE && console.log('[VERBOSE] /auth ignored: forwarded or reply');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!authEnabled) {
|
|
241
|
+
await reply(ctx, 'The /auth command is disabled on this bot instance.', { reply_to_message_id: ctx.message?.message_id });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (!ctx.chat || !ctx.from || !ctx.message) return;
|
|
245
|
+
if (ctx.chat.type !== 'private') {
|
|
246
|
+
await reply(ctx, 'The /auth command is only available in private messages.', { reply_to_message_id: ctx.message.message_id });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const allowedChatIds = resolveAllowedAuthChatIds(allowedChats);
|
|
251
|
+
if (allowedChatIds.length === 0) {
|
|
252
|
+
await reply(ctx, 'The /auth command is disabled because TELEGRAM_ALLOWED_CHATS is not configured.', { reply_to_message_id: ctx.message.message_id });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const authorized = await isAuthOperator({ telegram: ctx.telegram, userId: ctx.from.id, allowedChatIds });
|
|
257
|
+
if (!authorized) {
|
|
258
|
+
VERBOSE && console.log(`[VERBOSE] /auth denied: user ${ctx.from.id} is not creator of any allowed chat`);
|
|
259
|
+
await reply(ctx, 'The /auth command is only available to owners of allowlisted chats.', { reply_to_message_id: ctx.message.message_id });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const request = parseAuthRequest(ctx.message.text || '');
|
|
264
|
+
if (request.error) {
|
|
265
|
+
await reply(ctx, request.error, { reply_to_message_id: ctx.message.message_id });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { command, args } = buildAuthCommand(request.action, request.provider);
|
|
270
|
+
let result;
|
|
271
|
+
try {
|
|
272
|
+
result = await execute(command, args, { mode: request.action, provider: request.provider });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
await reply(ctx, `Failed to run ${request.provider} auth ${request.action}: ${error.message || String(error)}`, { reply_to_message_id: ctx.message.message_id });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const message = request.action === 'status' ? formatAuthStatusMessage(request.provider, result) : formatAuthLoginMessage(request.provider, result);
|
|
279
|
+
await reply(ctx, message, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
bot.command('auth', handleAuthCommand);
|
|
283
|
+
return { handleAuthCommand };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export default {
|
|
287
|
+
AUTH_PROVIDERS,
|
|
288
|
+
buildAuthCommand,
|
|
289
|
+
extractAuthStartDetails,
|
|
290
|
+
formatAuthLoginMessage,
|
|
291
|
+
formatAuthStatusMessage,
|
|
292
|
+
isAuthOperator,
|
|
293
|
+
parseAuthRequest,
|
|
294
|
+
redactAuthOutput,
|
|
295
|
+
registerAuthCommand,
|
|
296
|
+
resolveAllowedAuthChatIds,
|
|
297
|
+
runAuthCommand,
|
|
298
|
+
};
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -43,7 +43,7 @@ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized
|
|
|
43
43
|
const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
|
|
44
44
|
const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
|
|
45
45
|
const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
|
|
46
|
-
const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
|
|
46
|
+
const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl } = await import('./session-monitor.lib.mjs');
|
|
47
47
|
const { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
|
|
48
48
|
const { buildTelegramHelpMessage, buildTelegramInfoBlock, buildSolveQueuedMessage } = await import('./telegram-ui-messages.lib.mjs');
|
|
49
49
|
|
|
@@ -100,6 +100,11 @@ const config = yargs(hideBin(process.argv))
|
|
|
100
100
|
description: 'Enable /task and /split commands (use --no-task to disable)',
|
|
101
101
|
default: getenv('TELEGRAM_TASK', 'true') !== 'false',
|
|
102
102
|
})
|
|
103
|
+
.option('auth', {
|
|
104
|
+
type: 'boolean',
|
|
105
|
+
description: 'Enable experimental private /auth command for allowlisted chat owners (use --no-auth to disable)',
|
|
106
|
+
default: getenv('TELEGRAM_AUTH', 'true') !== 'false',
|
|
107
|
+
})
|
|
103
108
|
.option('dryRun', {
|
|
104
109
|
type: 'boolean',
|
|
105
110
|
description: 'Validate configuration and options without starting the bot',
|
|
@@ -163,6 +168,7 @@ const hiveOverrides = resolvedHiveOverrides
|
|
|
163
168
|
const solveEnabled = config.solve;
|
|
164
169
|
const hiveEnabled = config.hive;
|
|
165
170
|
const taskEnabled = config.task;
|
|
171
|
+
const authEnabled = config.auth;
|
|
166
172
|
// Isolation mode (experimental): uses `$` from start-command with specified backend
|
|
167
173
|
const ISOLATION_BACKEND = (config.isolation || getenv('TELEGRAM_ISOLATION', '')).trim().toLowerCase();
|
|
168
174
|
let isolationRunner = null;
|
|
@@ -283,7 +289,7 @@ if (config.dryRun) {
|
|
|
283
289
|
if (allowedTopics && allowedTopics.length > 0) {
|
|
284
290
|
console.log(' Allowed topics:', lino.formatLinks(allowedTopics));
|
|
285
291
|
}
|
|
286
|
-
console.log(' Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled });
|
|
292
|
+
console.log(' Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled, auth: authEnabled });
|
|
287
293
|
if (solveOverrides.length > 0) {
|
|
288
294
|
console.log(' Solve overrides:', lino.format(solveOverrides));
|
|
289
295
|
}
|
|
@@ -606,6 +612,8 @@ const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.m
|
|
|
606
612
|
registerSubscribeCommands(bot, sharedCommandOpts);
|
|
607
613
|
const { registerTaskCommands } = await import('./telegram-task-command.lib.mjs');
|
|
608
614
|
const { handleTaskCommand, TASK_COMMAND_NAMES } = registerTaskCommands(bot, { ...sharedCommandOpts, taskEnabled, safeReply, executeAndUpdateMessage, resolveLocale: resolveLocaleFromTelegramCtx });
|
|
615
|
+
const { registerAuthCommand } = await import('./telegram-auth-command.lib.mjs');
|
|
616
|
+
const { handleAuthCommand } = registerAuthCommand(bot, { ...sharedCommandOpts, allowedChats, authEnabled, safeReply });
|
|
609
617
|
|
|
610
618
|
// Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
|
|
611
619
|
async function handleSolveCommand(ctx) {
|
|
@@ -1060,7 +1068,7 @@ const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
|
|
|
1060
1068
|
const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
|
|
1061
1069
|
const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
|
|
1062
1070
|
registerTopCommand(bot, sharedCommandOpts);
|
|
1063
|
-
registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue });
|
|
1071
|
+
registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue, findRunningSessionByUrl: (url, verbose) => findStoppableSessionByUrl(url, verbose) });
|
|
1064
1072
|
await registerLogCommand(bot, sharedCommandOpts);
|
|
1065
1073
|
await registerTerminalWatchCommand(bot, sharedCommandOpts);
|
|
1066
1074
|
|
|
@@ -1170,7 +1178,7 @@ bot.on('message', async (ctx, next) => {
|
|
|
1170
1178
|
const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
|
|
1171
1179
|
const taskHandlers = Object.fromEntries(TASK_COMMAND_NAMES.map(command => [command, handleTaskCommand]));
|
|
1172
1180
|
// /queue is the short alias for /solve_queue (issue #1837)
|
|
1173
|
-
const handlers = { ...solveHandlers, ...taskHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand, queue: handleSolveQueueCommand };
|
|
1181
|
+
const handlers = { ...solveHandlers, ...taskHandlers, auth: handleAuthCommand, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand, queue: handleSolveQueueCommand };
|
|
1174
1182
|
|
|
1175
1183
|
const handler = handlers[extracted.command];
|
|
1176
1184
|
if (!handler) return next();
|
|
@@ -1279,7 +1287,7 @@ if (allowedChats && allowedChats.length > 0) {
|
|
|
1279
1287
|
if (allowedTopics && allowedTopics.length > 0) {
|
|
1280
1288
|
console.log('Allowed topics (lino):', lino.formatLinks(allowedTopics));
|
|
1281
1289
|
}
|
|
1282
|
-
console.log('Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled });
|
|
1290
|
+
console.log('Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled, auth: authEnabled });
|
|
1283
1291
|
if (solveOverrides.length > 0) console.log('Solve overrides (lino):', lino.format(solveOverrides));
|
|
1284
1292
|
if (hiveOverrides.length > 0) console.log('Hive overrides (lino):', lino.format(hiveOverrides));
|
|
1285
1293
|
if (VERBOSE) {
|
|
@@ -16,10 +16,15 @@
|
|
|
16
16
|
* - `/stop <issue-or-pr-url>` (or reply to a message that contains one) looks
|
|
17
17
|
* the URL up in the in-memory solve queue and either cancels the queued
|
|
18
18
|
* item or forwards CTRL+C to the running isolated session (issue #1780).
|
|
19
|
+
* - `/stop <issue-or-pr-url>` also consults the session-monitor registry of
|
|
20
|
+
* running detached sessions, so it can interrupt a task that started
|
|
21
|
+
* immediately (queue empty) and was therefore never left in the queue's
|
|
22
|
+
* `processing` Map — the case shown in issue #1871's screenshots.
|
|
19
23
|
*
|
|
20
24
|
* @see https://github.com/link-assistant/hive-mind/issues/1081
|
|
21
25
|
* @see https://github.com/link-assistant/hive-mind/issues/524
|
|
22
26
|
* @see https://github.com/link-assistant/hive-mind/issues/1780
|
|
27
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1871
|
|
23
28
|
* @see https://github.com/link-foundation/start/issues/112
|
|
24
29
|
*/
|
|
25
30
|
|
|
@@ -257,6 +262,12 @@ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo =
|
|
|
257
262
|
* When omitted, the URL flow degrades gracefully to a "no queue available"
|
|
258
263
|
* message so unit tests for non-URL paths don't need to construct a queue.
|
|
259
264
|
* See https://github.com/link-assistant/hive-mind/issues/1780.
|
|
265
|
+
* @param {Function} [options.findRunningSessionByUrl] - Override for tests; looks
|
|
266
|
+
* the URL up in the session-monitor registry of running detached sessions so
|
|
267
|
+
* `/stop <url>` can interrupt tasks that started immediately (queue empty) and
|
|
268
|
+
* were therefore never left in the queue's `processing` Map. When omitted, the
|
|
269
|
+
* real `findStoppableSessionByUrl` from session-monitor is lazy-imported.
|
|
270
|
+
* See https://github.com/link-assistant/hive-mind/issues/1871.
|
|
260
271
|
*/
|
|
261
272
|
export function registerStartStopCommands(bot, options) {
|
|
262
273
|
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
|
|
@@ -274,6 +285,26 @@ export function registerStartStopCommands(bot, options) {
|
|
|
274
285
|
return mod.getTrackedSessionInfo(sessionId);
|
|
275
286
|
}
|
|
276
287
|
|
|
288
|
+
// Issue #1871: look a URL up in the session-monitor registry of running
|
|
289
|
+
// detached sessions. A /solve or /codex that started immediately (queue
|
|
290
|
+
// empty) is dispatched straight to an isolation session and removed from the
|
|
291
|
+
// queue's `processing` Map, so the queue lookup alone reports "no task found"
|
|
292
|
+
// for a task that is clearly running. The session monitor still knows the
|
|
293
|
+
// URL → start-command-UUID mapping, which lets /stop <url> recover and
|
|
294
|
+
// interrupt the session. Test stubs can inject findRunningSessionByUrl.
|
|
295
|
+
async function lookupRunningSessionByUrl(url) {
|
|
296
|
+
try {
|
|
297
|
+
if (typeof options.findRunningSessionByUrl === 'function') {
|
|
298
|
+
return await options.findRunningSessionByUrl(url, VERBOSE);
|
|
299
|
+
}
|
|
300
|
+
const mod = await import('./session-monitor.lib.mjs');
|
|
301
|
+
return mod.findStoppableSessionByUrl(url, VERBOSE);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error('[ERROR] /stop: findStoppableSessionByUrl failed:', error);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
277
308
|
/**
|
|
278
309
|
* Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
|
|
279
310
|
* @param {Object} ctx - Telegraf context
|
|
@@ -529,18 +560,42 @@ export function registerStartStopCommands(bot, options) {
|
|
|
529
560
|
const url = target.value;
|
|
530
561
|
VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
|
|
531
562
|
|
|
532
|
-
// Look up the queue item
|
|
533
|
-
// requester to cancel their own task in a
|
|
534
|
-
//
|
|
535
|
-
//
|
|
563
|
+
// Look up the queue item AND any running detached session BEFORE auth so
|
|
564
|
+
// we can allow the original task requester to cancel their own task in a
|
|
565
|
+
// group (#1783), regardless of whether the task is still queued or already
|
|
566
|
+
// dispatched to an isolation session (#1871). Neither lookup mutates
|
|
567
|
+
// state — actual cancel/stop happens below after auth has passed.
|
|
536
568
|
const candidate = findQueueCandidateForUrl(url);
|
|
537
|
-
const
|
|
569
|
+
const runningSession = await lookupRunningSessionByUrl(url);
|
|
570
|
+
const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null, sessionInfo: runningSession?.sessionInfo || null });
|
|
538
571
|
if (!ok) return;
|
|
539
572
|
|
|
540
573
|
const lookup = resolveQueueLookupForUrl(url);
|
|
541
574
|
VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
|
|
542
575
|
|
|
576
|
+
// Issue #1871: when the queue has no record of the task (it started
|
|
577
|
+
// immediately and was dispatched to a detached session) but the session
|
|
578
|
+
// monitor still tracks a running isolated session for this URL, forward
|
|
579
|
+
// CTRL+C to its start-command UUID. This is the common case for tasks
|
|
580
|
+
// that begin executing right away with `--isolation screen`.
|
|
581
|
+
const queueHasTask = lookup.action === 'cancel-queued' || lookup.action === 'stop-running';
|
|
582
|
+
if (!queueHasTask && runningSession?.stoppable && runningSession.sessionId) {
|
|
583
|
+
VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to tracked session ${runningSession.sessionId} for ${url} (queue action=${lookup.action})`);
|
|
584
|
+
await runStopIsolatedSessionFlow(ctx, runningSession.sessionId);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
543
588
|
if (lookup.action === 'no-queue') {
|
|
589
|
+
// No solve queue in this context. If the session monitor found a
|
|
590
|
+
// running-but-non-stoppable (non-isolation) session, say so; otherwise
|
|
591
|
+
// fall back to the UUID hint.
|
|
592
|
+
if (runningSession) {
|
|
593
|
+
await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
|
|
594
|
+
parse_mode: 'Markdown',
|
|
595
|
+
reply_to_message_id: message.message_id,
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
544
599
|
await ctx.reply(`ℹ️ Cannot look up tasks by URL right now (the bot has no solve queue available in this context).\n\nIf you have the session UUID, you can use \`/stop <UUID>\` instead.`, {
|
|
545
600
|
parse_mode: 'Markdown',
|
|
546
601
|
reply_to_message_id: message.message_id,
|
|
@@ -549,6 +604,16 @@ export function registerStartStopCommands(bot, options) {
|
|
|
549
604
|
}
|
|
550
605
|
|
|
551
606
|
if (lookup.action === 'not-found') {
|
|
607
|
+
// The session monitor also had no stoppable session (otherwise we would
|
|
608
|
+
// have forwarded CTRL+C above). If it tracked a non-isolation session,
|
|
609
|
+
// explain why it can't be stopped; otherwise report not found.
|
|
610
|
+
if (runningSession) {
|
|
611
|
+
await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
|
|
612
|
+
parse_mode: 'Markdown',
|
|
613
|
+
reply_to_message_id: message.message_id,
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
552
617
|
await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with \`--isolation screen\`, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
|
|
553
618
|
parse_mode: 'Markdown',
|
|
554
619
|
reply_to_message_id: message.message_id,
|