@nordbyte/nordrelay 0.4.1 → 0.5.1
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/.env.example +155 -64
- package/README.md +81 -65
- package/dist/access-control.js +126 -115
- package/dist/agent-updates.js +62 -9
- package/dist/bot-rendering.js +838 -0
- package/dist/bot-ui.js +1 -0
- package/dist/bot.js +342 -2498
- package/dist/channel-actions.js +8 -8
- package/dist/channel-runtime.js +89 -0
- package/dist/config-metadata.js +238 -0
- package/dist/config.js +0 -58
- package/dist/index.js +8 -0
- package/dist/operations.js +63 -9
- package/dist/relay-artifact-service.js +126 -0
- package/dist/relay-external-activity-monitor.js +216 -0
- package/dist/relay-queue-service.js +66 -0
- package/dist/relay-runtime-types.js +1 -0
- package/dist/relay-runtime.js +96 -354
- package/dist/settings-service.js +2 -117
- package/dist/support-bundle.js +205 -0
- package/dist/telegram-access-commands.js +123 -0
- package/dist/telegram-access-middleware.js +129 -0
- package/dist/telegram-agent-commands.js +212 -0
- package/dist/telegram-artifact-commands.js +139 -0
- package/dist/telegram-channel-runtime.js +132 -0
- package/dist/telegram-command-menu.js +55 -0
- package/dist/telegram-command-types.js +1 -0
- package/dist/telegram-diagnostics-command.js +102 -0
- package/dist/telegram-general-commands.js +52 -0
- package/dist/telegram-operational-commands.js +153 -0
- package/dist/telegram-output.js +216 -0
- package/dist/telegram-preference-commands.js +198 -0
- package/dist/telegram-queue-commands.js +278 -0
- package/dist/telegram-support-command.js +53 -0
- package/dist/telegram-update-commands.js +93 -0
- package/dist/user-management.js +708 -0
- package/dist/web-api-contract.js +104 -0
- package/dist/web-api-types.js +1 -0
- package/dist/web-dashboard-access-routes.js +163 -0
- package/dist/web-dashboard-artifact-routes.js +65 -0
- package/dist/web-dashboard-assets.js +35 -2
- package/dist/web-dashboard-http.js +143 -0
- package/dist/web-dashboard-pages.js +257 -0
- package/dist/web-dashboard-runtime-routes.js +92 -0
- package/dist/web-dashboard-session-routes.js +209 -0
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +330 -707
- package/dist/webui-assets/dashboard.css +989 -0
- package/dist/webui-assets/dashboard.js +1750 -0
- package/dist/zip-writer.js +83 -0
- package/package.json +13 -4
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/commands/remote.md +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
- package/dist/web-dashboard-client.js +0 -275
- package/dist/web-dashboard-style.js +0 -9
package/dist/bot.js
CHANGED
|
@@ -1,105 +1,59 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile, unlink
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { autoRetry } from "@grammyjs/auto-retry";
|
|
6
5
|
import { Bot, InlineKeyboard, InputFile } from "grammy";
|
|
7
|
-
import {
|
|
6
|
+
import { ADMIN_GROUP_ID } from "./access-control.js";
|
|
8
7
|
import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
|
|
9
|
-
import { collectArtifactReport, collectRecentWorkspaceArtifacts, createArtifactZipBundle, ensureOutDir, formatArtifactSummary,
|
|
10
|
-
import { listAgentAdapterDescriptors } from "./agent-adapter.js";
|
|
8
|
+
import { collectArtifactReport, collectRecentWorkspaceArtifacts, createArtifactZipBundle, ensureOutDir, formatArtifactSummary, isTelegramImagePreview, persistWorkspaceArtifactReport, pruneConnectorTurnDirs, telegramArtifactFilename, totalArtifactSize, } from "./artifacts.js";
|
|
11
9
|
import { AgentUpdateManager } from "./agent-updates.js";
|
|
12
10
|
import { AuditLogStore } from "./audit-log.js";
|
|
13
|
-
import { formatSessionLabel
|
|
14
|
-
import { BotPreferencesStore,
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { enabledAgents } from "./agent-factory.js";
|
|
11
|
+
import { formatSessionLabel } from "./bot-ui.js";
|
|
12
|
+
import { BotPreferencesStore, isQuietNow, } from "./bot-preferences.js";
|
|
13
|
+
import { renderAgentUpdateJobAction } from "./channel-actions.js";
|
|
14
|
+
import { deliverChannelAction } from "./channel-runtime.js";
|
|
15
|
+
import { agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
|
|
16
|
+
import { getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
|
|
20
17
|
import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
|
|
21
18
|
import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
|
|
22
19
|
import { formatLaunchProfileBehavior } from "./codex-launch.js";
|
|
23
20
|
import { contextKeyFromCtx, isTelegramContextKey, isTopicContextKey, parseContextKey } from "./context-key.js";
|
|
24
21
|
import { friendlyErrorText } from "./error-messages.js";
|
|
25
|
-
import { escapeHTML
|
|
26
|
-
import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
|
|
22
|
+
import { escapeHTML } from "./format.js";
|
|
27
23
|
import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
|
|
28
24
|
import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
|
|
29
25
|
import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
|
|
30
26
|
import { checkPiAuthStatus } from "./pi-auth.js";
|
|
31
27
|
import { configureRedaction, redactText } from "./redaction.js";
|
|
32
28
|
import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
|
|
33
|
-
import {
|
|
29
|
+
import { renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
|
|
34
30
|
import { SessionRegistry } from "./session-registry.js";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
31
|
+
import { transcribeAudio } from "./voice.js";
|
|
32
|
+
import { telegramRateLimiter } from "./telegram-rate-limit.js";
|
|
33
|
+
import { chatBucket, downloadTelegramFile, isMessageNotModifiedError, renderMarkdownChunkWithinLimit, safeEditMessage, safeEditReplyMarkup, safeReply, sendChatActionSafe, sendTextMessage, splitMarkdownForTelegram, } from "./telegram-output.js";
|
|
34
|
+
import { NOOP_PAGE_CALLBACK_DATA, TelegramBotChannelRuntime, paginateKeyboard, telegramChannelContextFromCtx, } from "./telegram-channel-runtime.js";
|
|
35
|
+
import { createTelegramAccessMiddleware } from "./telegram-access-middleware.js";
|
|
36
|
+
import { registerTelegramAccessCommands } from "./telegram-access-commands.js";
|
|
37
|
+
import { registerTelegramAgentCommands } from "./telegram-agent-commands.js";
|
|
38
|
+
import { registerTelegramArtifactCommands } from "./telegram-artifact-commands.js";
|
|
39
|
+
import { registerTelegramDiagnosticsCommands } from "./telegram-diagnostics-command.js";
|
|
40
|
+
import { registerTelegramGeneralCommands } from "./telegram-general-commands.js";
|
|
41
|
+
import { registerTelegramOperationalCommands } from "./telegram-operational-commands.js";
|
|
42
|
+
import { registerTelegramPreferenceCommands } from "./telegram-preference-commands.js";
|
|
43
|
+
import { createQueuedPromptCancelKeyboard, registerTelegramQueueCommands, } from "./telegram-queue-commands.js";
|
|
44
|
+
import { registerTelegramSupportCommands } from "./telegram-support-command.js";
|
|
45
|
+
import { registerTelegramUpdateCommands } from "./telegram-update-commands.js";
|
|
46
|
+
import { appendWithCap, authHelpText, buildStreamingPreview, capabilitiesOf, filterSessions, formatAgentLaunchProfileLabel, formatAgentSettingScope, formatDurationSeconds, formatError, formatLocalDateTime, formatLockOwner, formatModelButtonLabel, formatRelativeTime, formatTelegramName, formatToolSummaryLine, formatTurnUsageLine, getWorkspaceShortName, idOf, isEmptyArtifactReport, isPromptEnvelopeLike, isQueuedPromptLike, labelOf, orderPinnedSessions, parseFastModeArgument, renderExternalMirrorEvent, renderExternalMirrorStatus, renderPromptFailure, renderTodoList, renderToolEndMessage, renderToolStartMessage, requiresTurnApproval, trimLine, } from "./bot-rendering.js";
|
|
47
|
+
import { UserStore } from "./user-management.js";
|
|
37
48
|
import { evaluateWorkspacePolicy, filterAllowedWorkspaces, renderWorkspacePolicyLine, } from "./workspace-policy.js";
|
|
38
|
-
|
|
49
|
+
export { formatToolSummaryLine, formatTurnUsageLine, summarizeToolName } from "./bot-rendering.js";
|
|
50
|
+
export { registerCommands } from "./telegram-command-menu.js";
|
|
39
51
|
const EDIT_DEBOUNCE_MS = 1500;
|
|
40
52
|
const TYPING_INTERVAL_MS = 4500;
|
|
41
53
|
const TOOL_OUTPUT_PREVIEW_LIMIT = 500;
|
|
42
|
-
const STREAMING_PREVIEW_LIMIT = 3800;
|
|
43
|
-
const FORMATTED_CHUNK_TARGET = 3000;
|
|
44
54
|
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
|
|
45
55
|
const MEDIA_GROUP_FLUSH_MS = 1200;
|
|
46
|
-
const KEYBOARD_PAGE_SIZE = 6;
|
|
47
|
-
const NOOP_PAGE_CALLBACK_DATA = "noop_page";
|
|
48
56
|
const LAUNCH_PROFILES_COMMAND = "/launch_profiles";
|
|
49
|
-
function paginateKeyboard(items, page, prefix) {
|
|
50
|
-
const totalPages = Math.max(1, Math.ceil(items.length / KEYBOARD_PAGE_SIZE));
|
|
51
|
-
const currentPage = Math.min(Math.max(page, 0), totalPages - 1);
|
|
52
|
-
const start = currentPage * KEYBOARD_PAGE_SIZE;
|
|
53
|
-
const pageItems = items.slice(start, start + KEYBOARD_PAGE_SIZE);
|
|
54
|
-
const keyboard = new InlineKeyboard();
|
|
55
|
-
pageItems.forEach((item, index) => {
|
|
56
|
-
keyboard.text(item.label, item.callbackData);
|
|
57
|
-
if (index < pageItems.length - 1 || totalPages > 1) {
|
|
58
|
-
keyboard.row();
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
if (totalPages > 1) {
|
|
62
|
-
if (currentPage > 0) {
|
|
63
|
-
keyboard.text("◀️ Prev", `${prefix}_page_${currentPage - 1}`);
|
|
64
|
-
}
|
|
65
|
-
keyboard.text(`${currentPage + 1}/${totalPages}`, NOOP_PAGE_CALLBACK_DATA);
|
|
66
|
-
if (currentPage < totalPages - 1) {
|
|
67
|
-
keyboard.text("Next ▶️", `${prefix}_page_${currentPage + 1}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return keyboard;
|
|
71
|
-
}
|
|
72
|
-
function actionKeyboard(rows) {
|
|
73
|
-
if (!rows || rows.length === 0) {
|
|
74
|
-
return undefined;
|
|
75
|
-
}
|
|
76
|
-
const keyboard = new InlineKeyboard();
|
|
77
|
-
for (const row of rows) {
|
|
78
|
-
for (const button of row) {
|
|
79
|
-
keyboard.text(button.label, telegramActionData(button.action));
|
|
80
|
-
}
|
|
81
|
-
keyboard.row();
|
|
82
|
-
}
|
|
83
|
-
return keyboard;
|
|
84
|
-
}
|
|
85
|
-
function telegramActionData(action) {
|
|
86
|
-
if (action === "agent-update:jobs") {
|
|
87
|
-
return "upd_jobs";
|
|
88
|
-
}
|
|
89
|
-
const agentUpdateStart = action.match(/^agent-update:start:(.+)$/);
|
|
90
|
-
if (agentUpdateStart?.[1]) {
|
|
91
|
-
return `upd_agent:${agentUpdateStart[1]}`;
|
|
92
|
-
}
|
|
93
|
-
const agentUpdateLog = action.match(/^agent-update:log:(.+)$/);
|
|
94
|
-
if (agentUpdateLog?.[1]) {
|
|
95
|
-
return `upd_log:${agentUpdateLog[1]}`;
|
|
96
|
-
}
|
|
97
|
-
const agentUpdateCancel = action.match(/^agent-update:cancel:(.+)$/);
|
|
98
|
-
if (agentUpdateCancel?.[1]) {
|
|
99
|
-
return `upd_cancel:${agentUpdateCancel[1]}`;
|
|
100
|
-
}
|
|
101
|
-
return action;
|
|
102
|
-
}
|
|
103
57
|
export function createBot(config, registry) {
|
|
104
58
|
configureRedaction(config.telegramRedactPatterns);
|
|
105
59
|
telegramRateLimiter.configure({
|
|
@@ -109,6 +63,7 @@ export function createBot(config, registry) {
|
|
|
109
63
|
});
|
|
110
64
|
const bot = new Bot(config.telegramBotToken);
|
|
111
65
|
bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));
|
|
66
|
+
const telegramChannelRuntime = new TelegramBotChannelRuntime(bot);
|
|
112
67
|
const contextBusy = new Map();
|
|
113
68
|
const pendingApprovals = new Map();
|
|
114
69
|
const pendingSessionPicks = new Map();
|
|
@@ -127,7 +82,10 @@ export function createBot(config, registry) {
|
|
|
127
82
|
const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
|
|
128
83
|
const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
129
84
|
const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
|
|
85
|
+
const userStore = new UserStore();
|
|
86
|
+
const contextUsers = new WeakMap();
|
|
130
87
|
const agentUpdates = new AgentUpdateManager();
|
|
88
|
+
const linkAttempts = new Map();
|
|
131
89
|
const drainingQueues = new Set();
|
|
132
90
|
const externalQueueTimers = new Map();
|
|
133
91
|
const externalMirrors = new Map();
|
|
@@ -254,15 +212,22 @@ export function createBot(config, registry) {
|
|
|
254
212
|
}
|
|
255
213
|
return checkAuthStatus(config.codexApiKey);
|
|
256
214
|
};
|
|
215
|
+
const replyChannelAction = async (ctx, rendered) => {
|
|
216
|
+
const channelContext = telegramChannelContextFromCtx(ctx);
|
|
217
|
+
if (!channelContext) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await deliverChannelAction(telegramChannelRuntime, channelContext, rendered);
|
|
221
|
+
};
|
|
257
222
|
const agentUpdateContext = () => ({
|
|
258
223
|
piCliPath: config.piCliPath,
|
|
259
224
|
hermesCliPath: config.hermesCliPath,
|
|
260
225
|
openClawCliPath: config.openClawCliPath,
|
|
261
226
|
claudeCodeCliPath: config.claudeCodeCliPath,
|
|
262
227
|
});
|
|
263
|
-
const startTelegramAgentUpdate = async (ctx, agentId) => {
|
|
228
|
+
const startTelegramAgentUpdate = async (ctx, agentId, operation = "update") => {
|
|
264
229
|
try {
|
|
265
|
-
const job = agentUpdates.start(agentId, agentUpdateContext());
|
|
230
|
+
const job = agentUpdates.start(agentId, agentUpdateContext(), operation);
|
|
266
231
|
const contextKey = contextKeyFromCtx(ctx);
|
|
267
232
|
if (contextKey) {
|
|
268
233
|
audit({
|
|
@@ -270,19 +235,17 @@ export function createBot(config, registry) {
|
|
|
270
235
|
status: "ok",
|
|
271
236
|
contextKey,
|
|
272
237
|
agentId,
|
|
273
|
-
description:
|
|
238
|
+
description: `${operation} ${agentId}`,
|
|
274
239
|
detail: job.summary,
|
|
275
240
|
});
|
|
276
241
|
}
|
|
277
242
|
const rendered = renderAgentUpdateJobAction(job);
|
|
278
|
-
await
|
|
279
|
-
fallbackText: rendered.plain,
|
|
280
|
-
replyMarkup: actionKeyboard(rendered.buttons),
|
|
281
|
-
});
|
|
243
|
+
await replyChannelAction(ctx, rendered);
|
|
282
244
|
}
|
|
283
245
|
catch (error) {
|
|
284
|
-
const message = `Failed to start ${agentLabel(agentId)}
|
|
285
|
-
|
|
246
|
+
const message = `Failed to start ${agentLabel(agentId)} ${operation}: ${friendlyErrorText(error)}`;
|
|
247
|
+
const label = operation === "install" ? "Install" : "Update";
|
|
248
|
+
await safeReply(ctx, `<b>${label} failed:</b> ${escapeHTML(message)}`, { fallbackText: message });
|
|
286
249
|
}
|
|
287
250
|
};
|
|
288
251
|
const startAgentLogin = (info) => {
|
|
@@ -385,28 +348,6 @@ export function createBot(config, registry) {
|
|
|
385
348
|
fallbackText: "Still working on previous message...",
|
|
386
349
|
});
|
|
387
350
|
};
|
|
388
|
-
const queueCancelCallbackData = (action, contextKey, queueId) => `queue_${action}:${contextKey}:${queueId}`;
|
|
389
|
-
const createQueuedPromptCancelKeyboard = (contextKey, queueId, label = "Cancel queued message") => new InlineKeyboard().text(label, queueCancelCallbackData("cancel", contextKey, queueId));
|
|
390
|
-
const renderQueueList = (contextKey, queue) => {
|
|
391
|
-
const paused = promptStore.isPaused(contextKey);
|
|
392
|
-
const rendered = renderQueueListAction(queue, paused);
|
|
393
|
-
if (queue.length === 0) {
|
|
394
|
-
return rendered;
|
|
395
|
-
}
|
|
396
|
-
const keyboard = new InlineKeyboard();
|
|
397
|
-
queue.forEach((item, index) => {
|
|
398
|
-
keyboard
|
|
399
|
-
.text(`Run ${index + 1}`, queueCancelCallbackData("run", contextKey, item.id))
|
|
400
|
-
.text("Top", queueCancelCallbackData("top", contextKey, item.id))
|
|
401
|
-
.text("Cancel", queueCancelCallbackData("remove", contextKey, item.id))
|
|
402
|
-
.row();
|
|
403
|
-
keyboard
|
|
404
|
-
.text("Up", queueCancelCallbackData("up", contextKey, item.id))
|
|
405
|
-
.text("Down", queueCancelCallbackData("down", contextKey, item.id))
|
|
406
|
-
.row();
|
|
407
|
-
});
|
|
408
|
-
return { ...rendered, keyboard };
|
|
409
|
-
};
|
|
410
351
|
const createSystemContext = (contextKey) => {
|
|
411
352
|
const parsed = parseContextKey(contextKey);
|
|
412
353
|
return {
|
|
@@ -416,6 +357,9 @@ export function createBot(config, registry) {
|
|
|
416
357
|
};
|
|
417
358
|
};
|
|
418
359
|
const updateQueueStatusMessage = async (contextKey, text) => {
|
|
360
|
+
if (!canSendSystemMessagesToContext(contextKey)) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
419
363
|
const parsed = parseContextKey(contextKey);
|
|
420
364
|
const html = escapeHTML(text);
|
|
421
365
|
const state = queueStatusMessages.get(contextKey) ?? {};
|
|
@@ -458,6 +402,9 @@ export function createBot(config, registry) {
|
|
|
458
402
|
if (!isTelegramContextKey(contextKey)) {
|
|
459
403
|
return;
|
|
460
404
|
}
|
|
405
|
+
if (!canSendSystemMessagesToContext(contextKey)) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
461
408
|
const session = await registry.getOrCreate(contextKey, { deferThreadStart: true }).catch(() => null);
|
|
462
409
|
if (!session) {
|
|
463
410
|
return;
|
|
@@ -606,7 +553,20 @@ export function createBot(config, registry) {
|
|
|
606
553
|
}
|
|
607
554
|
state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
|
|
608
555
|
};
|
|
556
|
+
const canSendSystemMessagesToContext = (contextKey) => {
|
|
557
|
+
if (!userStore.hasAdminUser()) {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
const parsed = parseContextKey(contextKey);
|
|
561
|
+
if (parsed.chatId > 0) {
|
|
562
|
+
return Boolean(userStore.resolveTelegramUser(parsed.chatId));
|
|
563
|
+
}
|
|
564
|
+
return userStore.snapshot().telegramChats.some((chat) => chat.chatId === parsed.chatId && chat.enabled);
|
|
565
|
+
};
|
|
609
566
|
const deliverCliGeneratedArtifacts = async (contextKey, chatId, session, startedAt, turnId, messageThreadId) => {
|
|
567
|
+
if (!canSendSystemMessagesToContext(contextKey)) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
610
570
|
if (!startedAt || !turnId) {
|
|
611
571
|
return;
|
|
612
572
|
}
|
|
@@ -658,6 +618,9 @@ export function createBot(config, registry) {
|
|
|
658
618
|
if (promptStore.list(contextKey).length === 0) {
|
|
659
619
|
return;
|
|
660
620
|
}
|
|
621
|
+
if (!canSendSystemMessagesToContext(contextKey)) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
661
624
|
const busy = getBusyReason(contextKey);
|
|
662
625
|
if (busy.kind === "external") {
|
|
663
626
|
const label = busy.activity.agentLabel;
|
|
@@ -677,37 +640,12 @@ export function createBot(config, registry) {
|
|
|
677
640
|
timer.unref?.();
|
|
678
641
|
externalQueueTimers.set(contextKey, timer);
|
|
679
642
|
};
|
|
643
|
+
const getAuthenticatedUser = (ctx) => contextUsers.get(ctx) ?? null;
|
|
680
644
|
const getUserRole = (ctx) => {
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
return "admin";
|
|
684
|
-
}
|
|
685
|
-
if (fromId !== undefined && config.telegramReadOnlyUserIdSet.has(fromId)) {
|
|
686
|
-
return "readonly";
|
|
687
|
-
}
|
|
688
|
-
return "operator";
|
|
689
|
-
};
|
|
690
|
-
const getRequiredPermission = (ctx) => {
|
|
691
|
-
if (ctx.callbackQuery?.data) {
|
|
692
|
-
return permissionForCallbackData(ctx.callbackQuery.data);
|
|
693
|
-
}
|
|
694
|
-
if (ctx.message?.voice || ctx.message?.audio || ctx.message?.photo || ctx.message?.document) {
|
|
695
|
-
return "files";
|
|
696
|
-
}
|
|
697
|
-
const text = ctx.message?.text?.trim();
|
|
698
|
-
if (!text) {
|
|
699
|
-
return "inspect";
|
|
700
|
-
}
|
|
701
|
-
if (!text.startsWith("/")) {
|
|
702
|
-
return "prompt";
|
|
703
|
-
}
|
|
704
|
-
const command = extractCommandName(text);
|
|
705
|
-
if (command === "queue") {
|
|
706
|
-
const argument = text.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
|
|
707
|
-
return argument ? "prompt" : "inspect";
|
|
708
|
-
}
|
|
709
|
-
return permissionForCommand(command);
|
|
645
|
+
const authUser = getAuthenticatedUser(ctx);
|
|
646
|
+
return authUser?.groups.map((group) => group.name).join(", ") || "unauthenticated";
|
|
710
647
|
};
|
|
648
|
+
const isAdminUser = (ctx) => Boolean(getAuthenticatedUser(ctx)?.groups.some((group) => group.id === ADMIN_GROUP_ID));
|
|
711
649
|
const audit = (event) => {
|
|
712
650
|
try {
|
|
713
651
|
auditLog.append(event);
|
|
@@ -730,7 +668,7 @@ export function createBot(config, registry) {
|
|
|
730
668
|
};
|
|
731
669
|
const denyIfLocked = async (ctx, contextKey, session) => {
|
|
732
670
|
const lock = lockStore.get(contextKey);
|
|
733
|
-
const isAdmin =
|
|
671
|
+
const isAdmin = isAdminUser(ctx);
|
|
734
672
|
if (canWriteWithLock(lock, ctx.from?.id, isAdmin)) {
|
|
735
673
|
return false;
|
|
736
674
|
}
|
|
@@ -801,6 +739,9 @@ export function createBot(config, registry) {
|
|
|
801
739
|
}
|
|
802
740
|
pendingApprovals.delete(approvalId);
|
|
803
741
|
getBusyState(contextKey).approving = false;
|
|
742
|
+
if (!canSendSystemMessagesToContext(contextKey)) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
804
745
|
const parsed = parseContextKey(contextKey);
|
|
805
746
|
void sendTextMessage(bot.api, parsed.chatId, `Approval timed out for prompt ${approvalId}.`, {
|
|
806
747
|
messageThreadId: parsed.messageThreadId,
|
|
@@ -831,6 +772,9 @@ export function createBot(config, registry) {
|
|
|
831
772
|
await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
|
|
832
773
|
};
|
|
833
774
|
const handleUserPrompt = async (ctx, contextKey, chatId, session, prompt, options = {}) => {
|
|
775
|
+
if (!canSendSystemMessagesToContext(contextKey)) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
834
778
|
const parsed = parseContextKey(contextKey);
|
|
835
779
|
const messageThreadId = parsed.messageThreadId;
|
|
836
780
|
const envelope = isPromptEnvelopeLike(prompt) ? prompt : toPromptEnvelope(prompt);
|
|
@@ -1374,6 +1318,9 @@ export function createBot(config, registry) {
|
|
|
1374
1318
|
if (drainingQueues.has(contextKey)) {
|
|
1375
1319
|
return;
|
|
1376
1320
|
}
|
|
1321
|
+
if (!canSendSystemMessagesToContext(contextKey)) {
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1377
1324
|
drainingQueues.add(contextKey);
|
|
1378
1325
|
try {
|
|
1379
1326
|
while (true) {
|
|
@@ -1536,16 +1483,25 @@ export function createBot(config, registry) {
|
|
|
1536
1483
|
clearTimeout(pending.timer);
|
|
1537
1484
|
pendingMediaGroups.delete(key);
|
|
1538
1485
|
try {
|
|
1486
|
+
if (!canSendSystemMessagesToContext(pending.contextKey)) {
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1539
1489
|
await processMediaGroup(pending);
|
|
1540
1490
|
}
|
|
1541
1491
|
catch (error) {
|
|
1542
1492
|
console.error("Failed to process media group:", error);
|
|
1493
|
+
if (!canSendSystemMessagesToContext(pending.contextKey)) {
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1543
1496
|
await safeReply(pending.ctx, `<b>Failed to process media group:</b> ${escapeHTML(friendlyErrorText(error))}`, {
|
|
1544
1497
|
fallbackText: `Failed to process media group: ${friendlyErrorText(error)}`,
|
|
1545
1498
|
});
|
|
1546
1499
|
}
|
|
1547
1500
|
};
|
|
1548
1501
|
const processMediaGroup = async (pending) => {
|
|
1502
|
+
if (!canSendSystemMessagesToContext(pending.contextKey)) {
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1549
1505
|
const busyState = getBusyState(pending.contextKey);
|
|
1550
1506
|
busyState.transcribing = true;
|
|
1551
1507
|
const turnId = randomUUID().slice(0, 12);
|
|
@@ -1596,10 +1552,16 @@ export function createBot(config, registry) {
|
|
|
1596
1552
|
busyState.transcribing = false;
|
|
1597
1553
|
}
|
|
1598
1554
|
if (stagedFiles.length === 0) {
|
|
1555
|
+
if (!canSendSystemMessagesToContext(pending.contextKey)) {
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1599
1558
|
const text = skippedCount > 0 ? "No media group files could be staged." : "Media group was empty.";
|
|
1600
1559
|
await safeReply(pending.ctx, escapeHTML(text), { fallbackText: text });
|
|
1601
1560
|
return;
|
|
1602
1561
|
}
|
|
1562
|
+
if (!canSendSystemMessagesToContext(pending.contextKey)) {
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1603
1565
|
const receivedText = `Received ${stagedFiles.length} media group file${stagedFiles.length === 1 ? "" : "s"}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}.`;
|
|
1604
1566
|
await safeReply(pending.ctx, escapeHTML(receivedText), { fallbackText: receivedText });
|
|
1605
1567
|
await sendChatActionSafe(pending.ctx.api, pending.chatId, "typing", pending.messageThreadId).catch(() => { });
|
|
@@ -1621,1165 +1583,304 @@ export function createBot(config, registry) {
|
|
|
1621
1583
|
await clearReaction(pending.ctx);
|
|
1622
1584
|
}
|
|
1623
1585
|
};
|
|
1624
|
-
bot.use(
|
|
1625
|
-
|
|
1586
|
+
bot.use(createTelegramAccessMiddleware({ userStore, contextUsers, audit }));
|
|
1587
|
+
registerTelegramAccessCommands({ bot, userStore, contextUsers, linkAttempts, audit, getUserRole });
|
|
1588
|
+
registerTelegramGeneralCommands({
|
|
1589
|
+
bot,
|
|
1590
|
+
config,
|
|
1591
|
+
registry,
|
|
1592
|
+
getContextSession,
|
|
1593
|
+
checkAgentAuthStatus,
|
|
1594
|
+
isTopicContext,
|
|
1595
|
+
replyChannelAction,
|
|
1596
|
+
});
|
|
1597
|
+
registerTelegramAgentCommands({
|
|
1598
|
+
bot,
|
|
1599
|
+
config,
|
|
1600
|
+
registry,
|
|
1601
|
+
pendingAgentPicks,
|
|
1602
|
+
getContextSession,
|
|
1603
|
+
isBusy,
|
|
1604
|
+
checkAgentAuthStatus,
|
|
1605
|
+
checkLoginAuthStatus,
|
|
1606
|
+
agentIdForAuth,
|
|
1607
|
+
labelForAuth,
|
|
1608
|
+
startAgentLogin,
|
|
1609
|
+
startAgentLogout,
|
|
1610
|
+
hostLoginCommand,
|
|
1611
|
+
hostLogoutCommand,
|
|
1612
|
+
});
|
|
1613
|
+
registerTelegramPreferenceCommands({
|
|
1614
|
+
bot,
|
|
1615
|
+
config,
|
|
1616
|
+
preferencesStore,
|
|
1617
|
+
getContextSession,
|
|
1618
|
+
getEffectiveMirrorMode,
|
|
1619
|
+
getEffectiveNotifyMode,
|
|
1620
|
+
getEffectiveQuietHours,
|
|
1621
|
+
getEffectiveVoiceBackend,
|
|
1622
|
+
getEffectiveVoiceLanguage,
|
|
1623
|
+
isVoiceTranscribeOnly,
|
|
1624
|
+
});
|
|
1625
|
+
registerTelegramDiagnosticsCommands({
|
|
1626
|
+
bot,
|
|
1627
|
+
config,
|
|
1628
|
+
registry,
|
|
1629
|
+
promptStore,
|
|
1630
|
+
turnProgress,
|
|
1631
|
+
externalMirrors,
|
|
1632
|
+
externalQueueTimers,
|
|
1633
|
+
queueStatusMessages,
|
|
1634
|
+
getContextSession,
|
|
1635
|
+
checkAgentAuthStatus,
|
|
1636
|
+
getUserRole,
|
|
1637
|
+
getEffectiveMirrorMode,
|
|
1638
|
+
getEffectiveNotifyMode,
|
|
1639
|
+
getEffectiveQuietHours,
|
|
1640
|
+
getEffectiveVoiceBackend,
|
|
1641
|
+
getEffectiveVoiceLanguage,
|
|
1642
|
+
isVoiceTranscribeOnly,
|
|
1643
|
+
replyChannelAction,
|
|
1644
|
+
});
|
|
1645
|
+
registerTelegramOperationalCommands({
|
|
1646
|
+
bot,
|
|
1647
|
+
config,
|
|
1648
|
+
promptStore,
|
|
1649
|
+
auditLog,
|
|
1650
|
+
lockStore,
|
|
1651
|
+
turnProgress,
|
|
1652
|
+
getContextSession,
|
|
1653
|
+
getBusyState,
|
|
1654
|
+
getExternalActivity,
|
|
1655
|
+
isAdminUser,
|
|
1656
|
+
auditContext,
|
|
1657
|
+
updateSessionMetadata,
|
|
1658
|
+
});
|
|
1659
|
+
registerTelegramSupportCommands({ bot, config, auditLog, agentUpdates, getUserRole, audit });
|
|
1660
|
+
registerTelegramUpdateCommands({ bot, agentUpdates, replyChannelAction, startTelegramAgentUpdate });
|
|
1661
|
+
bot.command("new", async (ctx) => {
|
|
1626
1662
|
const chatId = ctx.chat?.id;
|
|
1627
|
-
|
|
1628
|
-
(fromId !== undefined && config.telegramAllowedUserIdSet.has(fromId)) ||
|
|
1629
|
-
(chatId !== undefined && config.telegramAllowedChatIdSet.has(chatId));
|
|
1630
|
-
if (!authorized) {
|
|
1631
|
-
if (ctx.callbackQuery) {
|
|
1632
|
-
await ctx.answerCallbackQuery({ text: "Unauthorized" }).catch(() => { });
|
|
1633
|
-
}
|
|
1634
|
-
else if (ctx.chat) {
|
|
1635
|
-
await safeReply(ctx, escapeHTML("Unauthorized"), { fallbackText: "Unauthorized" });
|
|
1636
|
-
}
|
|
1637
|
-
return;
|
|
1638
|
-
}
|
|
1639
|
-
const role = getUserRole(ctx);
|
|
1640
|
-
const permission = getRequiredPermission(ctx);
|
|
1641
|
-
if (!hasTelegramPermission(config.telegramRolePolicies, role, permission)) {
|
|
1642
|
-
const message = `Access denied: ${permission} permission required.`;
|
|
1643
|
-
if (ctx.callbackQuery) {
|
|
1644
|
-
await ctx.answerCallbackQuery({ text: message }).catch(() => { });
|
|
1645
|
-
}
|
|
1646
|
-
else {
|
|
1647
|
-
await safeReply(ctx, escapeHTML(message), { fallbackText: message });
|
|
1648
|
-
}
|
|
1663
|
+
if (!chatId) {
|
|
1649
1664
|
return;
|
|
1650
1665
|
}
|
|
1651
|
-
await next();
|
|
1652
|
-
});
|
|
1653
|
-
bot.command("start", async (ctx) => {
|
|
1654
1666
|
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1655
1667
|
if (!contextSession) {
|
|
1656
1668
|
return;
|
|
1657
1669
|
}
|
|
1658
1670
|
const { contextKey, session } = contextSession;
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
const isReturning = registry.hasMetadata(contextKey);
|
|
1665
|
-
if (isReturning) {
|
|
1666
|
-
const welcome = renderWelcomeReturning(renderSessionInfoHTML(info), renderSessionInfoPlain(info), isTopicContext(contextKey), authWarning);
|
|
1667
|
-
await safeReply(ctx, welcome.html, { fallbackText: welcome.plain });
|
|
1671
|
+
if (isBusy(contextKey)) {
|
|
1672
|
+
await safeReply(ctx, escapeHTML("Cannot create a new thread while a prompt is running."), {
|
|
1673
|
+
fallbackText: "Cannot create a new thread while a prompt is running.",
|
|
1674
|
+
});
|
|
1675
|
+
return;
|
|
1668
1676
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
await safeReply(ctx,
|
|
1672
|
-
fallbackText:
|
|
1677
|
+
const currentPolicy = evaluateWorkspacePolicy(session.getCurrentWorkspace(), config);
|
|
1678
|
+
if (!currentPolicy.allowed) {
|
|
1679
|
+
await safeReply(ctx, escapeHTML(currentPolicy.warning ?? "Current workspace is blocked by workspace policy."), {
|
|
1680
|
+
fallbackText: currentPolicy.warning ?? "Current workspace is blocked by workspace policy.",
|
|
1673
1681
|
});
|
|
1682
|
+
return;
|
|
1674
1683
|
}
|
|
1684
|
+
const workspaces = filterAllowedWorkspaces(session.listWorkspaces(), config);
|
|
1685
|
+
if (workspaces.length <= 1) {
|
|
1686
|
+
try {
|
|
1687
|
+
const info = await session.newThread();
|
|
1688
|
+
updateSessionMetadata(contextKey, session);
|
|
1689
|
+
const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
|
|
1690
|
+
const policyLine = renderWorkspacePolicyLine(info.workspace, config);
|
|
1691
|
+
const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
|
|
1692
|
+
const html = [`<b>${escapeHTML(label)}</b>`, policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
|
|
1693
|
+
await safeReply(ctx, html, { fallbackText: plainText });
|
|
1694
|
+
}
|
|
1695
|
+
catch (error) {
|
|
1696
|
+
await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
|
|
1697
|
+
fallbackText: `Failed: ${friendlyErrorText(error)}`,
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
pendingWorkspacePicks.set(contextKey, workspaces);
|
|
1703
|
+
const currentWorkspace = session.getCurrentWorkspace();
|
|
1704
|
+
const workspaceButtons = workspaces.map((workspace, index) => ({
|
|
1705
|
+
label: `${workspace === currentWorkspace ? "📂" : "📁"} ${getWorkspaceShortName(workspace)}`,
|
|
1706
|
+
callbackData: `ws_${index}`,
|
|
1707
|
+
}));
|
|
1708
|
+
pendingWorkspaceButtons.set(contextKey, workspaceButtons);
|
|
1709
|
+
const keyboard = paginateKeyboard(workspaceButtons, 0, "ws");
|
|
1710
|
+
await safeReply(ctx, "<b>Select workspace for new thread:</b>", {
|
|
1711
|
+
fallbackText: "Select workspace for new thread:",
|
|
1712
|
+
replyMarkup: keyboard,
|
|
1713
|
+
});
|
|
1675
1714
|
});
|
|
1676
|
-
bot.command("
|
|
1677
|
-
const help = renderHelpMessage();
|
|
1678
|
-
await safeReply(ctx, help.html, { fallbackText: help.plain });
|
|
1679
|
-
});
|
|
1680
|
-
bot.command("channels", async (ctx) => {
|
|
1681
|
-
const rendered = renderChannelsAction(listChannelDescriptors());
|
|
1682
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
1683
|
-
});
|
|
1684
|
-
bot.command("agents", async (ctx) => {
|
|
1685
|
-
const rendered = renderAgentsAction(listAgentAdapterDescriptors(), enabledAgents(config));
|
|
1686
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
1687
|
-
});
|
|
1688
|
-
bot.command("agent", async (ctx) => {
|
|
1715
|
+
bot.command(["abort", "stop"], async (ctx) => {
|
|
1689
1716
|
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1690
1717
|
if (!contextSession) {
|
|
1691
1718
|
return;
|
|
1692
1719
|
}
|
|
1693
1720
|
const { contextKey, session } = contextSession;
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1721
|
+
try {
|
|
1722
|
+
const busy = getBusyReason(contextKey);
|
|
1723
|
+
if (busy.kind === "external") {
|
|
1724
|
+
const text = `Cannot abort the external ${busy.activity.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running; queued Telegram messages will wait.`;
|
|
1725
|
+
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
await session.abort();
|
|
1729
|
+
await safeReply(ctx, escapeHTML("Aborted current operation"), {
|
|
1730
|
+
fallbackText: "Aborted current operation",
|
|
1697
1731
|
});
|
|
1698
|
-
return;
|
|
1699
1732
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
const only = agentLabel(availableAgents[0] ?? currentAgent);
|
|
1704
|
-
await safeReply(ctx, `<b>Current agent:</b> <code>${escapeHTML(only)}</code>\nNo other agents are enabled.`, {
|
|
1705
|
-
fallbackText: `Current agent: ${only}\nNo other agents are enabled.`,
|
|
1733
|
+
catch (error) {
|
|
1734
|
+
await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
|
|
1735
|
+
fallbackText: `Failed: ${friendlyErrorText(error)}`,
|
|
1706
1736
|
});
|
|
1707
|
-
return;
|
|
1708
|
-
}
|
|
1709
|
-
pendingAgentPicks.set(contextKey, availableAgents);
|
|
1710
|
-
const keyboard = new InlineKeyboard();
|
|
1711
|
-
for (const availableAgent of availableAgents) {
|
|
1712
|
-
keyboard.text(`${agentLabel(availableAgent)}${availableAgent === currentAgent ? " ✓" : ""}`, `agent_${availableAgent}`).row();
|
|
1713
1737
|
}
|
|
1714
|
-
await safeReply(ctx, `<b>Current agent:</b> <code>${escapeHTML(agentLabel(currentAgent))}</code>\nSelect agent for this Telegram context:`, {
|
|
1715
|
-
fallbackText: `Current agent: ${agentLabel(currentAgent)}\nSelect agent for this Telegram context:`,
|
|
1716
|
-
replyMarkup: keyboard,
|
|
1717
|
-
});
|
|
1718
1738
|
});
|
|
1719
|
-
bot.command("
|
|
1720
|
-
if (!ctx.chat) {
|
|
1721
|
-
return;
|
|
1722
|
-
}
|
|
1739
|
+
bot.command("retry", async (ctx) => {
|
|
1723
1740
|
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1724
|
-
|
|
1725
|
-
if (info && !capabilitiesOf(info).auth) {
|
|
1726
|
-
const text = `${labelOf(info)} uses its local CLI authentication. Run its login flow on the host if needed.`;
|
|
1727
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
1741
|
+
if (!contextSession) {
|
|
1728
1742
|
return;
|
|
1729
1743
|
}
|
|
1730
|
-
const
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1733
|
-
`<b>${icon} Auth status:</b> ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
|
|
1734
|
-
`<b>Method:</b> <code>${escapeHTML(authStatus.method)}</code>`,
|
|
1735
|
-
`<b>Detail:</b> <code>${escapeHTML(authStatus.detail)}</code>`,
|
|
1736
|
-
].join("\n");
|
|
1737
|
-
const plain = [
|
|
1738
|
-
`${icon} Auth status: ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
|
|
1739
|
-
`Method: ${authStatus.method}`,
|
|
1740
|
-
`Detail: ${authStatus.detail}`,
|
|
1741
|
-
].join("\n");
|
|
1742
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
1743
|
-
});
|
|
1744
|
-
bot.command("login", async (ctx) => {
|
|
1745
|
-
if (!ctx.chat) {
|
|
1744
|
+
const { contextKey, session } = contextSession;
|
|
1745
|
+
const chatId = ctx.chat?.id;
|
|
1746
|
+
if (!chatId) {
|
|
1746
1747
|
return;
|
|
1747
1748
|
}
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
if (info && !capabilitiesOf(info).login) {
|
|
1751
|
-
const text = `${labelOf(info)} login is not managed by NordRelay. Run the CLI login flow on the host.`;
|
|
1752
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
1749
|
+
if (isBusy(contextKey)) {
|
|
1750
|
+
await sendBusyReply(ctx);
|
|
1753
1751
|
return;
|
|
1754
1752
|
}
|
|
1755
|
-
const
|
|
1756
|
-
if (
|
|
1757
|
-
await safeReply(ctx,
|
|
1758
|
-
fallbackText:
|
|
1753
|
+
const cached = promptStore.getLastPrompt(contextKey);
|
|
1754
|
+
if (!cached) {
|
|
1755
|
+
await safeReply(ctx, escapeHTML("Nothing to retry. Send a message first."), {
|
|
1756
|
+
fallbackText: "Nothing to retry. Send a message first.",
|
|
1759
1757
|
});
|
|
1760
1758
|
return;
|
|
1761
1759
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
`Run <code>${escapeHTML(hostLoginCommand(info))}</code> on the host.`,
|
|
1767
|
-
].join("\n"), {
|
|
1768
|
-
fallbackText: [
|
|
1769
|
-
"Telegram-initiated login is disabled.",
|
|
1770
|
-
"",
|
|
1771
|
-
`Run '${hostLoginCommand(info)}' on the host.`,
|
|
1772
|
-
].join("\n"),
|
|
1773
|
-
});
|
|
1774
|
-
return;
|
|
1760
|
+
await setReaction(ctx, "👀");
|
|
1761
|
+
try {
|
|
1762
|
+
await handleUserPrompt(ctx, contextKey, chatId, session, cached);
|
|
1763
|
+
await setReaction(ctx, "👍");
|
|
1775
1764
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1765
|
+
catch {
|
|
1766
|
+
await clearReaction(ctx);
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
registerTelegramQueueCommands({
|
|
1770
|
+
bot,
|
|
1771
|
+
promptStore,
|
|
1772
|
+
getContextSession,
|
|
1773
|
+
getBusyReason,
|
|
1774
|
+
getSession: (contextKey) => registry.get(contextKey),
|
|
1775
|
+
updateQueueStatusMessage,
|
|
1776
|
+
scheduleExternalQueueDrain,
|
|
1777
|
+
drainQueuedPrompts,
|
|
1778
|
+
handleUserPrompt,
|
|
1779
|
+
auditContext,
|
|
1780
|
+
});
|
|
1781
|
+
registerTelegramArtifactCommands({
|
|
1782
|
+
bot,
|
|
1783
|
+
config,
|
|
1784
|
+
getContextSession,
|
|
1785
|
+
deliverArtifactReport,
|
|
1786
|
+
deliverArtifactReportZip,
|
|
1787
|
+
});
|
|
1788
|
+
bot.command("session", async (ctx) => {
|
|
1789
|
+
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1790
|
+
if (!contextSession) {
|
|
1781
1791
|
return;
|
|
1782
1792
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1793
|
+
const { contextKey, session } = contextSession;
|
|
1794
|
+
const info = session.getInfo();
|
|
1795
|
+
const contextLabel = isTopicContext(contextKey) ? "Topic session" : "Chat session";
|
|
1796
|
+
const policyLine = renderWorkspacePolicyLine(info.workspace, config);
|
|
1797
|
+
const plainLines = [`${contextLabel}:`, policyLine, renderSessionInfoPlain(info)].filter((line) => line !== undefined);
|
|
1798
|
+
const htmlLines = [`<b>${escapeHTML(contextLabel)}:</b>`, policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, renderSessionInfoHTML(info)].filter((line) => line !== undefined);
|
|
1799
|
+
await safeReply(ctx, htmlLines.join("\n"), { fallbackText: plainLines.join("\n") });
|
|
1786
1800
|
});
|
|
1787
|
-
|
|
1788
|
-
|
|
1801
|
+
const openLaunchProfilesPicker = async (ctx) => {
|
|
1802
|
+
const chatId = ctx.chat?.id;
|
|
1803
|
+
if (!chatId) {
|
|
1789
1804
|
return;
|
|
1790
1805
|
}
|
|
1791
1806
|
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1792
|
-
|
|
1793
|
-
if (info && !capabilitiesOf(info).logout) {
|
|
1794
|
-
const text = `${labelOf(info)} logout is not managed by NordRelay. Run the CLI logout flow on the host.`;
|
|
1795
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
1807
|
+
if (!contextSession) {
|
|
1796
1808
|
return;
|
|
1797
1809
|
}
|
|
1798
|
-
const
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
"Remove the API key from .env to use CLI-based auth instead.",
|
|
1804
|
-
].join("\n"), {
|
|
1805
|
-
fallbackText: [
|
|
1806
|
-
`Cannot logout via Telegram when ${labelForAuth(info)} uses API-key authentication.`,
|
|
1807
|
-
"",
|
|
1808
|
-
"Remove the API key from .env to use CLI-based auth instead.",
|
|
1809
|
-
].join("\n"),
|
|
1810
|
-
});
|
|
1810
|
+
const { contextKey, session } = contextSession;
|
|
1811
|
+
const info = session.getInfo();
|
|
1812
|
+
if (!capabilitiesOf(info).launchProfiles) {
|
|
1813
|
+
const text = `Launch profiles are not supported for ${labelOf(info)}.`;
|
|
1814
|
+
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
1811
1815
|
return;
|
|
1812
1816
|
}
|
|
1813
|
-
if (
|
|
1814
|
-
await safeReply(ctx,
|
|
1815
|
-
"
|
|
1816
|
-
"",
|
|
1817
|
-
`Run <code>${escapeHTML(hostLogoutCommand(info))}</code> on the host.`,
|
|
1818
|
-
].join("\n"), {
|
|
1819
|
-
fallbackText: [
|
|
1820
|
-
"Telegram-initiated auth management is disabled.",
|
|
1821
|
-
"",
|
|
1822
|
-
`Run '${hostLogoutCommand(info)}' on the host.`,
|
|
1823
|
-
].join("\n"),
|
|
1817
|
+
if (isBusy(contextKey)) {
|
|
1818
|
+
await safeReply(ctx, escapeHTML("Cannot change launch profile while a prompt is running."), {
|
|
1819
|
+
fallbackText: "Cannot change launch profile while a prompt is running.",
|
|
1824
1820
|
});
|
|
1825
1821
|
return;
|
|
1826
1822
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1823
|
+
const profiles = session.listLaunchProfiles();
|
|
1824
|
+
const selectedLaunchProfile = session.getInfo();
|
|
1825
|
+
const launchButtons = profiles.map((profile, index) => ({
|
|
1826
|
+
label: formatAgentLaunchProfileLabel(profile, profile.id === selectedLaunchProfile.launchProfileId),
|
|
1827
|
+
callbackData: `launch_${index}`,
|
|
1828
|
+
}));
|
|
1829
|
+
pendingLaunchPicks.set(contextKey, profiles.map((profile) => profile.id));
|
|
1830
|
+
pendingLaunchButtons.set(contextKey, launchButtons);
|
|
1831
|
+
pendingUnsafeLaunchConfirmations.delete(contextKey);
|
|
1832
|
+
const keyboard = paginateKeyboard(launchButtons, 0, "launch");
|
|
1833
|
+
const htmlLines = [
|
|
1834
|
+
`<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileLabel)}</code>`,
|
|
1835
|
+
`<b>Behavior:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileBehavior)}</code>`,
|
|
1836
|
+
"",
|
|
1837
|
+
"Select a profile for new or reattached threads:",
|
|
1838
|
+
];
|
|
1839
|
+
const plainLines = [
|
|
1840
|
+
`Selected launch profile: ${selectedLaunchProfile.launchProfileLabel}`,
|
|
1841
|
+
`Behavior: ${selectedLaunchProfile.launchProfileBehavior}`,
|
|
1842
|
+
"",
|
|
1843
|
+
"Select a profile for new or reattached threads:",
|
|
1844
|
+
];
|
|
1845
|
+
if (selectedLaunchProfile.unsafeLaunch) {
|
|
1846
|
+
htmlLines.splice(2, 0, "⚠️ <i>Selected profile uses danger-full-access.</i>");
|
|
1847
|
+
plainLines.splice(2, 0, "⚠️ Selected profile uses danger-full-access.");
|
|
1832
1848
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
fallbackText: `🔓 Logged out.\n\n${result.message}`,
|
|
1837
|
-
});
|
|
1838
|
-
return;
|
|
1849
|
+
if (info.nextLaunchProfileId) {
|
|
1850
|
+
htmlLines.splice(2, 0, `<b>Active thread still uses:</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`);
|
|
1851
|
+
plainLines.splice(2, 0, `Active thread still uses: ${info.launchProfileLabel}`);
|
|
1839
1852
|
}
|
|
1840
|
-
await safeReply(ctx,
|
|
1841
|
-
fallbackText:
|
|
1853
|
+
await safeReply(ctx, htmlLines.join("\n"), {
|
|
1854
|
+
fallbackText: plainLines.join("\n"),
|
|
1855
|
+
replyMarkup: keyboard,
|
|
1842
1856
|
});
|
|
1843
|
-
}
|
|
1844
|
-
bot.command("
|
|
1857
|
+
};
|
|
1858
|
+
bot.command(["launch", "launch_profiles"], openLaunchProfilesPicker);
|
|
1859
|
+
bot.hears(/^\/launch-profiles(?:@\w+)?$/i, openLaunchProfilesPicker);
|
|
1860
|
+
bot.command("handback", async (ctx) => {
|
|
1845
1861
|
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1846
1862
|
if (!contextSession) {
|
|
1847
1863
|
return;
|
|
1848
1864
|
}
|
|
1849
1865
|
const { contextKey, session } = contextSession;
|
|
1850
|
-
if (
|
|
1851
|
-
|
|
1852
|
-
|
|
1866
|
+
if (isBusy(contextKey)) {
|
|
1867
|
+
await safeReply(ctx, escapeHTML("Cannot hand back while a prompt is running. Use /abort first."), {
|
|
1868
|
+
fallbackText: "Cannot hand back while a prompt is running. Use /abort first.",
|
|
1869
|
+
});
|
|
1853
1870
|
return;
|
|
1854
1871
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
const plain = [
|
|
1868
|
-
`CLI mirroring: ${mode}`,
|
|
1869
|
-
`Minimum update interval: ${config.telegramMirrorMinUpdateMs} ms`,
|
|
1870
|
-
"Modes: off, status, final, full",
|
|
1871
|
-
].join("\n");
|
|
1872
|
-
const html = [
|
|
1873
|
-
`<b>CLI mirroring:</b> <code>${escapeHTML(mode)}</code>`,
|
|
1874
|
-
`<b>Minimum update interval:</b> <code>${config.telegramMirrorMinUpdateMs} ms</code>`,
|
|
1875
|
-
"<b>Modes:</b> <code>off</code>, <code>status</code>, <code>final</code>, <code>full</code>",
|
|
1876
|
-
].join("\n");
|
|
1877
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
1878
|
-
});
|
|
1879
|
-
bot.command("notify", async (ctx) => {
|
|
1880
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1881
|
-
if (!contextSession) {
|
|
1882
|
-
return;
|
|
1883
|
-
}
|
|
1884
|
-
const { contextKey } = contextSession;
|
|
1885
|
-
const argument = (ctx.message?.text ?? "").replace(/^\/notify(?:@\w+)?\s*/i, "").trim();
|
|
1886
|
-
if (argument) {
|
|
1887
|
-
const quietMatch = argument.match(/^quiet\s+(.+)$/i);
|
|
1888
|
-
if (quietMatch) {
|
|
1889
|
-
let quietHours;
|
|
1890
|
-
try {
|
|
1891
|
-
quietHours = quietMatch[1].toLowerCase() === "off" ? null : parseQuietHours(quietMatch[1]);
|
|
1892
|
-
}
|
|
1893
|
-
catch (error) {
|
|
1894
|
-
await safeReply(ctx, escapeHTML(`Invalid quiet hours: ${friendlyErrorText(error)}`), {
|
|
1895
|
-
fallbackText: `Invalid quiet hours: ${friendlyErrorText(error)}`,
|
|
1896
|
-
});
|
|
1897
|
-
return;
|
|
1898
|
-
}
|
|
1899
|
-
preferencesStore.update(contextKey, { quietHours });
|
|
1900
|
-
}
|
|
1901
|
-
else {
|
|
1902
|
-
const mode = parseNotifyMode(argument, getEffectiveNotifyMode(contextKey));
|
|
1903
|
-
if (!["off", "minimal", "all"].includes(argument.toLowerCase())) {
|
|
1904
|
-
await safeReply(ctx, escapeHTML("Usage: /notify [off|minimal|all] or /notify quiet HH-HH"), {
|
|
1905
|
-
fallbackText: "Usage: /notify [off|minimal|all] or /notify quiet HH-HH",
|
|
1906
|
-
});
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
|
-
preferencesStore.update(contextKey, { notifyMode: mode });
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
const mode = getEffectiveNotifyMode(contextKey);
|
|
1913
|
-
const quietHours = getEffectiveQuietHours(contextKey);
|
|
1914
|
-
const plain = [
|
|
1915
|
-
`Notifications: ${mode}`,
|
|
1916
|
-
`Quiet hours: ${formatQuietHours(quietHours)}`,
|
|
1917
|
-
`Currently quiet: ${isQuietNow(quietHours) ? "yes" : "no"}`,
|
|
1918
|
-
].join("\n");
|
|
1919
|
-
const html = [
|
|
1920
|
-
`<b>Notifications:</b> <code>${escapeHTML(mode)}</code>`,
|
|
1921
|
-
`<b>Quiet hours:</b> <code>${escapeHTML(formatQuietHours(quietHours))}</code>`,
|
|
1922
|
-
`<b>Currently quiet:</b> <code>${isQuietNow(quietHours) ? "yes" : "no"}</code>`,
|
|
1923
|
-
].join("\n");
|
|
1924
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
1925
|
-
});
|
|
1926
|
-
bot.command("workspaces", async (ctx) => {
|
|
1927
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1928
|
-
if (!contextSession) {
|
|
1929
|
-
return;
|
|
1930
|
-
}
|
|
1931
|
-
const { session } = contextSession;
|
|
1932
|
-
const agentName = labelOf(session.getInfo());
|
|
1933
|
-
const workspaces = filterAllowedWorkspaces(session.listWorkspaces(), config);
|
|
1934
|
-
const currentWorkspace = session.getInfo().workspace;
|
|
1935
|
-
const lines = workspaces.slice(0, 20).map((workspace, index) => {
|
|
1936
|
-
const prefix = workspace === currentWorkspace ? "*" : `${index + 1}.`;
|
|
1937
|
-
const policy = renderWorkspacePolicyLine(workspace, config);
|
|
1938
|
-
return `${prefix} ${workspace}${policy ? ` (${policy})` : ""}`;
|
|
1939
|
-
});
|
|
1940
|
-
const currentPolicy = evaluateWorkspacePolicy(currentWorkspace, config);
|
|
1941
|
-
const header = [
|
|
1942
|
-
"Workspaces:",
|
|
1943
|
-
`Current: ${currentWorkspace}`,
|
|
1944
|
-
currentPolicy.warning ? `Current warning: ${currentPolicy.warning}` : undefined,
|
|
1945
|
-
config.workspaceAllowedRoots.length > 0 ? `Allowed roots: ${config.workspaceAllowedRoots.join(", ")}` : "Allowed roots: unrestricted",
|
|
1946
|
-
"",
|
|
1947
|
-
].filter((line) => Boolean(line));
|
|
1948
|
-
const plain = [...header, ...(lines.length > 0 ? lines : [`No workspaces found in ${agentName} state.`])].join("\n");
|
|
1949
|
-
const html = [
|
|
1950
|
-
"<b>Workspaces:</b>",
|
|
1951
|
-
`<b>Current:</b> <code>${escapeHTML(currentWorkspace)}</code>`,
|
|
1952
|
-
currentPolicy.warning ? `<b>Current warning:</b> <code>${escapeHTML(currentPolicy.warning)}</code>` : undefined,
|
|
1953
|
-
`<b>Allowed roots:</b> <code>${escapeHTML(config.workspaceAllowedRoots.length > 0 ? config.workspaceAllowedRoots.join(", ") : "unrestricted")}</code>`,
|
|
1954
|
-
"",
|
|
1955
|
-
...(lines.length > 0 ? lines.map((line) => `<code>${escapeHTML(line)}</code>`) : [`<code>No workspaces found in ${escapeHTML(agentName)} state.</code>`]),
|
|
1956
|
-
].filter((line) => Boolean(line)).join("\n");
|
|
1957
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
1958
|
-
});
|
|
1959
|
-
bot.command("voice", async (ctx) => {
|
|
1960
|
-
if (!ctx.chat) {
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
1964
|
-
if (!contextSession) {
|
|
1965
|
-
return;
|
|
1966
|
-
}
|
|
1967
|
-
const { contextKey } = contextSession;
|
|
1968
|
-
const argument = (ctx.message?.text ?? "").replace(/^\/voice(?:@\w+)?\s*/i, "").trim();
|
|
1969
|
-
if (argument) {
|
|
1970
|
-
const parts = argument.split(/\s+/);
|
|
1971
|
-
const key = parts[0]?.toLowerCase();
|
|
1972
|
-
const value = parts.slice(1).join(" ").trim();
|
|
1973
|
-
if (key === "backend" && value) {
|
|
1974
|
-
preferencesStore.update(contextKey, { voiceBackend: parseVoiceBackendPreference(value) });
|
|
1975
|
-
}
|
|
1976
|
-
else if (key === "language") {
|
|
1977
|
-
preferencesStore.update(contextKey, { voiceLanguage: value && value.toLowerCase() !== "auto" ? value : null });
|
|
1978
|
-
}
|
|
1979
|
-
else if (key === "transcribe_only" || key === "transcribe-only") {
|
|
1980
|
-
const enabled = parseToggle(value);
|
|
1981
|
-
if (enabled === undefined) {
|
|
1982
|
-
await safeReply(ctx, escapeHTML("Usage: /voice transcribe_only on|off"), {
|
|
1983
|
-
fallbackText: "Usage: /voice transcribe_only on|off",
|
|
1984
|
-
});
|
|
1985
|
-
return;
|
|
1986
|
-
}
|
|
1987
|
-
preferencesStore.update(contextKey, { voiceTranscribeOnly: enabled });
|
|
1988
|
-
}
|
|
1989
|
-
else {
|
|
1990
|
-
await safeReply(ctx, escapeHTML("Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off"), {
|
|
1991
|
-
fallbackText: "Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off",
|
|
1992
|
-
});
|
|
1993
|
-
return;
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
const backends = await getAvailableBackends().catch(() => []);
|
|
1997
|
-
if (backends.length === 0) {
|
|
1998
|
-
await safeReply(ctx, [
|
|
1999
|
-
"<b>Voice transcription is not available.</b>",
|
|
2000
|
-
"",
|
|
2001
|
-
"Install <code>faster-whisper</code> + ffmpeg, install <code>parakeet-coreml</code> on macOS Apple Silicon, or set <code>OPENAI_API_KEY</code>.",
|
|
2002
|
-
"<i>Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.</i>",
|
|
2003
|
-
].join("\n"), {
|
|
2004
|
-
fallbackText: [
|
|
2005
|
-
"Voice transcription is not available.",
|
|
2006
|
-
"",
|
|
2007
|
-
"Install faster-whisper + ffmpeg, install parakeet-coreml on macOS Apple Silicon, or set OPENAI_API_KEY.",
|
|
2008
|
-
"Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.",
|
|
2009
|
-
].join("\n"),
|
|
2010
|
-
});
|
|
2011
|
-
return;
|
|
2012
|
-
}
|
|
2013
|
-
const joined = backends.join(" + ");
|
|
2014
|
-
const backendPreference = getEffectiveVoiceBackend(contextKey);
|
|
2015
|
-
const language = getEffectiveVoiceLanguage(contextKey);
|
|
2016
|
-
const transcribeOnly = isVoiceTranscribeOnly(contextKey);
|
|
2017
|
-
const plain = [
|
|
2018
|
-
`Voice backends: ${joined}`,
|
|
2019
|
-
`Preferred backend: ${backendPreference}`,
|
|
2020
|
-
`Language: ${language ?? "auto"}`,
|
|
2021
|
-
`Transcribe only: ${transcribeOnly ? "on" : "off"}`,
|
|
2022
|
-
].join("\n");
|
|
2023
|
-
const html = [
|
|
2024
|
-
`<b>Voice backends:</b> <code>${escapeHTML(joined)}</code>`,
|
|
2025
|
-
`<b>Preferred backend:</b> <code>${escapeHTML(backendPreference)}</code>`,
|
|
2026
|
-
`<b>Language:</b> <code>${escapeHTML(language ?? "auto")}</code>`,
|
|
2027
|
-
`<b>Transcribe only:</b> <code>${transcribeOnly ? "on" : "off"}</code>`,
|
|
2028
|
-
].join("\n");
|
|
2029
|
-
await safeReply(ctx, html, {
|
|
2030
|
-
fallbackText: plain,
|
|
2031
|
-
});
|
|
2032
|
-
});
|
|
2033
|
-
bot.command(["status", "health"], async (ctx) => {
|
|
2034
|
-
const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
|
|
2035
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2036
|
-
const authStatus = contextSession
|
|
2037
|
-
? await checkAgentAuthStatus(contextSession.session.getInfo())
|
|
2038
|
-
: await checkAuthStatus(config.codexApiKey);
|
|
2039
|
-
const html = renderHealthHTML(health, authStatus.authenticated, getUserRole(ctx));
|
|
2040
|
-
const plain = renderHealthPlain(health, authStatus.authenticated, getUserRole(ctx));
|
|
2041
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
2042
|
-
});
|
|
2043
|
-
bot.command("version", async (ctx) => {
|
|
2044
|
-
const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
|
|
2045
|
-
const state = await readConnectorState();
|
|
2046
|
-
const versions = await getVersionChecks({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
|
|
2047
|
-
const plain = [
|
|
2048
|
-
renderVersionCheckPlain(versions.nordrelay),
|
|
2049
|
-
`Runtime status: ${state.status ?? "unknown"}`,
|
|
2050
|
-
formatCliPathPlain("Codex CLI", health.codexCliPath, health.codexCli),
|
|
2051
|
-
renderVersionCheckPlain(versions.codex),
|
|
2052
|
-
formatCliPathPlain("Pi CLI", health.piCliPath, health.piCli),
|
|
2053
|
-
renderVersionCheckPlain(versions.pi),
|
|
2054
|
-
formatCliPathPlain("Hermes CLI", health.hermesCliPath, health.hermesCli),
|
|
2055
|
-
renderVersionCheckPlain(versions.hermes),
|
|
2056
|
-
formatCliPathPlain("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
|
|
2057
|
-
renderVersionCheckPlain(versions.openclaw),
|
|
2058
|
-
formatCliPathPlain("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
|
|
2059
|
-
renderVersionCheckPlain(versions.claudeCode),
|
|
2060
|
-
].join("\n");
|
|
2061
|
-
const html = [
|
|
2062
|
-
renderVersionCheckHTML(versions.nordrelay),
|
|
2063
|
-
`<b>Runtime status:</b> <code>${escapeHTML(state.status ?? "unknown")}</code>`,
|
|
2064
|
-
formatCliPathHTML("Codex CLI", health.codexCliPath, health.codexCli),
|
|
2065
|
-
renderVersionCheckHTML(versions.codex),
|
|
2066
|
-
formatCliPathHTML("Pi CLI", health.piCliPath, health.piCli),
|
|
2067
|
-
renderVersionCheckHTML(versions.pi),
|
|
2068
|
-
formatCliPathHTML("Hermes CLI", health.hermesCliPath, health.hermesCli),
|
|
2069
|
-
renderVersionCheckHTML(versions.hermes),
|
|
2070
|
-
formatCliPathHTML("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
|
|
2071
|
-
renderVersionCheckHTML(versions.openclaw),
|
|
2072
|
-
formatCliPathHTML("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
|
|
2073
|
-
renderVersionCheckHTML(versions.claudeCode),
|
|
2074
|
-
].join("\n");
|
|
2075
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
2076
|
-
});
|
|
2077
|
-
bot.command(["tasks", "progress"], async (ctx) => {
|
|
2078
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2079
|
-
if (!contextSession) {
|
|
2080
|
-
return;
|
|
2081
|
-
}
|
|
2082
|
-
const progress = turnProgress.get(contextSession.contextKey);
|
|
2083
|
-
const queue = promptStore.list(contextSession.contextKey);
|
|
2084
|
-
const externalActivity = getExternalActivity(contextSession.session);
|
|
2085
|
-
const busyState = {
|
|
2086
|
-
...getBusyState(contextSession.contextKey),
|
|
2087
|
-
external: Boolean(externalActivity?.active),
|
|
2088
|
-
};
|
|
2089
|
-
const info = contextSession.session.getInfo();
|
|
2090
|
-
const plain = renderProgressPlain(progress, queue.length, busyState, info);
|
|
2091
|
-
const html = renderProgressHTML(progress, queue.length, busyState, info);
|
|
2092
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
2093
|
-
});
|
|
2094
|
-
bot.command("activity", async (ctx) => {
|
|
2095
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2096
|
-
if (!contextSession) {
|
|
2097
|
-
return;
|
|
2098
|
-
}
|
|
2099
|
-
const info = contextSession.session.getInfo();
|
|
2100
|
-
if (!capabilitiesOf(info).activityLog) {
|
|
2101
|
-
const text = `${labelOf(info)} activity timelines are not available yet.`;
|
|
2102
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2103
|
-
return;
|
|
2104
|
-
}
|
|
2105
|
-
const threadId = contextSession.session.getActiveThreadId();
|
|
2106
|
-
if (!threadId) {
|
|
2107
|
-
await safeReply(ctx, escapeHTML("No active thread yet."), { fallbackText: "No active thread yet." });
|
|
2108
|
-
return;
|
|
2109
|
-
}
|
|
2110
|
-
const options = parseActivityOptions((ctx.message?.text ?? "").replace(/^\/activity(?:@\w+)?\s*/i, "").trim());
|
|
2111
|
-
const events = filterActivityEvents(getAgentActivityLog(contextSession.session, config, options.exportFile ? 200 : options.limit), options);
|
|
2112
|
-
const rendered = renderActivityTimeline(threadId, events, options);
|
|
2113
|
-
if (options.exportFile && ctx.chat) {
|
|
2114
|
-
const exportPath = path.join(tmpdir(), `nordrelay-activity-${threadId}-${randomUUID().slice(0, 8)}.txt`);
|
|
2115
|
-
await writeFile(exportPath, rendered.plain, "utf8");
|
|
2116
|
-
try {
|
|
2117
|
-
await telegramRateLimiter.run(chatBucket(ctx.chat.id), "sendDocument", () => ctx.api.sendDocument(ctx.chat.id, new InputFile(exportPath, path.basename(exportPath)), {
|
|
2118
|
-
...(ctx.message?.message_thread_id ? { message_thread_id: ctx.message.message_thread_id } : {}),
|
|
2119
|
-
}));
|
|
2120
|
-
}
|
|
2121
|
-
finally {
|
|
2122
|
-
await unlink(exportPath).catch(() => { });
|
|
2123
|
-
}
|
|
2124
|
-
return;
|
|
2125
|
-
}
|
|
2126
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2127
|
-
});
|
|
2128
|
-
bot.command("audit", async (ctx) => {
|
|
2129
|
-
const rawText = ctx.message?.text ?? "";
|
|
2130
|
-
const limitArg = rawText.replace(/^\/audit(?:@\w+)?\s*/i, "").trim();
|
|
2131
|
-
const limit = /^\d+$/.test(limitArg) ? Number(limitArg) : 20;
|
|
2132
|
-
const events = auditLog.list(limit);
|
|
2133
|
-
const rendered = renderAuditEvents(events);
|
|
2134
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2135
|
-
});
|
|
2136
|
-
bot.command("lock", async (ctx) => {
|
|
2137
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2138
|
-
if (!contextSession || !ctx.from) {
|
|
2139
|
-
return;
|
|
2140
|
-
}
|
|
2141
|
-
const { contextKey, session } = contextSession;
|
|
2142
|
-
const existing = lockStore.get(contextKey);
|
|
2143
|
-
if (existing && existing.ownerId !== ctx.from.id && getUserRole(ctx) !== "admin") {
|
|
2144
|
-
const text = `Session is already locked by ${formatLockOwner(existing)}.`;
|
|
2145
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2146
|
-
return;
|
|
2147
|
-
}
|
|
2148
|
-
const lock = lockStore.set(contextKey, ctx.from.id, formatTelegramName(ctx), config.sessionLockTtlMs);
|
|
2149
|
-
auditContext(ctx, contextKey, session, {
|
|
2150
|
-
action: "lock_updated",
|
|
2151
|
-
status: "ok",
|
|
2152
|
-
detail: `locked by ${lock.ownerId}`,
|
|
2153
|
-
});
|
|
2154
|
-
const text = `Session locked by ${formatLockOwner(lock)}${lock.expiresAt ? ` until ${formatLocalDateTime(new Date(lock.expiresAt))}` : ""}.`;
|
|
2155
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2156
|
-
});
|
|
2157
|
-
bot.command("unlock", async (ctx) => {
|
|
2158
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2159
|
-
if (!contextSession) {
|
|
2160
|
-
return;
|
|
2161
|
-
}
|
|
2162
|
-
const { contextKey, session } = contextSession;
|
|
2163
|
-
const lock = lockStore.get(contextKey);
|
|
2164
|
-
if (lock && lock.ownerId !== ctx.from?.id && getUserRole(ctx) !== "admin") {
|
|
2165
|
-
const text = `Only ${formatLockOwner(lock)} or an admin can unlock this session.`;
|
|
2166
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2167
|
-
return;
|
|
2168
|
-
}
|
|
2169
|
-
const removed = lockStore.clear(contextKey);
|
|
2170
|
-
auditContext(ctx, contextKey, session, {
|
|
2171
|
-
action: "lock_updated",
|
|
2172
|
-
status: "ok",
|
|
2173
|
-
detail: removed ? "unlocked" : "no lock",
|
|
2174
|
-
});
|
|
2175
|
-
const text = removed ? "Session lock released." : "No active lock for this session.";
|
|
2176
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2177
|
-
});
|
|
2178
|
-
bot.command("locks", async (ctx) => {
|
|
2179
|
-
const locks = lockStore.list();
|
|
2180
|
-
const rendered = renderSessionLocks(locks);
|
|
2181
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2182
|
-
});
|
|
2183
|
-
bot.command("diagnostics", async (ctx) => {
|
|
2184
|
-
const health = await getConnectorHealth({ piCliPath: config.piCliPath, hermesCliPath: config.hermesCliPath, openClawCliPath: config.openClawCliPath, claudeCodeCliPath: config.claudeCodeCliPath });
|
|
2185
|
-
const contextKey = contextKeyFromCtx(ctx);
|
|
2186
|
-
const queueLength = contextKey ? promptStore.list(contextKey).length : 0;
|
|
2187
|
-
const progress = contextKey ? turnProgress.get(contextKey) : undefined;
|
|
2188
|
-
const contextSession = contextKey ? await getContextSession(ctx, { deferThreadStart: true }) : null;
|
|
2189
|
-
const authStatus = contextSession
|
|
2190
|
-
? await checkAgentAuthStatus(contextSession.session.getInfo())
|
|
2191
|
-
: await checkAuthStatus(config.codexApiKey);
|
|
2192
|
-
const agentDiagnostics = contextSession
|
|
2193
|
-
? renderAgentDiagnostics(getAgentDiagnostics(contextSession.session, config))
|
|
2194
|
-
: { plain: "Agent state: no context", html: "<b>Agent state:</b> <code>no context</code>" };
|
|
2195
|
-
const runtime = {
|
|
2196
|
-
rateLimit: getTelegramRateLimitMetrics(),
|
|
2197
|
-
externalMirrors: externalMirrors.size,
|
|
2198
|
-
externalQueueTimers: externalQueueTimers.size,
|
|
2199
|
-
queueStatusMessages: queueStatusMessages.size,
|
|
2200
|
-
mirrorMode: contextKey ? getEffectiveMirrorMode(contextKey) : config.telegramMirrorMode,
|
|
2201
|
-
notifyMode: contextKey ? getEffectiveNotifyMode(contextKey) : config.telegramNotifyMode,
|
|
2202
|
-
quietHours: formatQuietHours(contextKey ? getEffectiveQuietHours(contextKey) : config.telegramQuietHours),
|
|
2203
|
-
voiceBackend: contextKey ? getEffectiveVoiceBackend(contextKey) : config.voicePreferredBackend,
|
|
2204
|
-
voiceLanguage: contextKey ? getEffectiveVoiceLanguage(contextKey) ?? "auto" : config.voiceDefaultLanguage ?? "auto",
|
|
2205
|
-
voiceTranscribeOnly: contextKey ? isVoiceTranscribeOnly(contextKey) : config.voiceTranscribeOnly,
|
|
2206
|
-
};
|
|
2207
|
-
const plain = `${renderDiagnosticsPlain(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.plain}`;
|
|
2208
|
-
const html = `${renderDiagnosticsHTML(config, registry, health, authStatus.authenticated, getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.html}`;
|
|
2209
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
2210
|
-
});
|
|
2211
|
-
bot.command("sync", async (ctx) => {
|
|
2212
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2213
|
-
if (!contextSession) {
|
|
2214
|
-
return;
|
|
2215
|
-
}
|
|
2216
|
-
const sessionInfo = contextSession.session.getInfo();
|
|
2217
|
-
if (!capabilitiesOf(sessionInfo).externalActivity) {
|
|
2218
|
-
const plain = [`${labelOf(sessionInfo)} has no external CLI state watcher to sync.`, "", renderSessionInfoPlain(sessionInfo)].join("\n");
|
|
2219
|
-
const html = [`<b>${escapeHTML(labelOf(sessionInfo))} has no external CLI state watcher to sync.</b>`, "", renderSessionInfoHTML(sessionInfo)].join("\n");
|
|
2220
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
2221
|
-
return;
|
|
2222
|
-
}
|
|
2223
|
-
const result = contextSession.session.syncFromAgentState({ reattach: true });
|
|
2224
|
-
if (result.changed) {
|
|
2225
|
-
updateSessionMetadata(contextSession.contextKey, contextSession.session);
|
|
2226
|
-
}
|
|
2227
|
-
const fields = result.changedFields.length > 0 ? result.changedFields.join(", ") : "none";
|
|
2228
|
-
const plain = [
|
|
2229
|
-
result.changed ? `Synced from ${labelOf(sessionInfo)} state.` : "Already in sync.",
|
|
2230
|
-
`Changed: ${fields}`,
|
|
2231
|
-
`Reattached: ${result.reattached ? "yes" : "no"}`,
|
|
2232
|
-
"",
|
|
2233
|
-
renderSessionInfoPlain(result.info),
|
|
2234
|
-
].join("\n");
|
|
2235
|
-
const html = [
|
|
2236
|
-
result.changed ? `<b>Synced from ${escapeHTML(labelOf(sessionInfo))} state.</b>` : "<b>Already in sync.</b>",
|
|
2237
|
-
`<b>Changed:</b> <code>${escapeHTML(fields)}</code>`,
|
|
2238
|
-
`<b>Reattached:</b> <code>${result.reattached ? "yes" : "no"}</code>`,
|
|
2239
|
-
"",
|
|
2240
|
-
renderSessionInfoHTML(result.info),
|
|
2241
|
-
].join("\n");
|
|
2242
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
2243
|
-
});
|
|
2244
|
-
bot.command("logs", async (ctx) => {
|
|
2245
|
-
const rawText = ctx.message?.text ?? "";
|
|
2246
|
-
const argument = rawText.replace(/^\/logs(?:@\w+)?\s*/i, "").trim();
|
|
2247
|
-
const logRequest = parseLogsCommand(argument);
|
|
2248
|
-
const logs = await Promise.all(logTailRequests(logRequest.target).map(async (request) => ({
|
|
2249
|
-
title: request.title,
|
|
2250
|
-
tail: await readFormattedLogTail(logRequest.lines, request.path),
|
|
2251
|
-
})));
|
|
2252
|
-
const rendered = renderLogTailsAction(logs);
|
|
2253
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2254
|
-
});
|
|
2255
|
-
bot.command("restart", async (ctx) => {
|
|
2256
|
-
await safeReply(ctx, escapeHTML("Restarting connector..."), {
|
|
2257
|
-
fallbackText: "Restarting connector...",
|
|
2258
|
-
});
|
|
2259
|
-
setTimeout(() => {
|
|
2260
|
-
spawnConnectorRestart();
|
|
2261
|
-
}, 300);
|
|
2262
|
-
});
|
|
2263
|
-
bot.command("update", async (ctx) => {
|
|
2264
|
-
const rawText = ctx.message?.text ?? "";
|
|
2265
|
-
const argument = rawText.replace(/^\/update(?:@\w+)?\s*/i, "").trim();
|
|
2266
|
-
const tokens = argument.split(/\s+/).filter(Boolean);
|
|
2267
|
-
const subcommand = tokens[0]?.toLowerCase();
|
|
2268
|
-
if (subcommand === "agents" || subcommand === "agent") {
|
|
2269
|
-
const rendered = renderAgentUpdatePickerAction(listAgentAdapterDescriptors());
|
|
2270
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain, replyMarkup: actionKeyboard(rendered.buttons) });
|
|
2271
|
-
return;
|
|
2272
|
-
}
|
|
2273
|
-
if (subcommand === "jobs" || subcommand === "status") {
|
|
2274
|
-
const rendered = renderAgentUpdateJobsAction(agentUpdates.list());
|
|
2275
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2276
|
-
return;
|
|
2277
|
-
}
|
|
2278
|
-
if (subcommand === "log" && tokens[1]) {
|
|
2279
|
-
const rendered = renderAgentUpdateLogAction(agentUpdates.readLog(tokens[1]));
|
|
2280
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2281
|
-
return;
|
|
2282
|
-
}
|
|
2283
|
-
if (subcommand === "cancel" && tokens[1]) {
|
|
2284
|
-
const job = agentUpdates.cancel(tokens[1]);
|
|
2285
|
-
const rendered = renderAgentUpdateJobAction(job);
|
|
2286
|
-
await safeReply(ctx, rendered.html, {
|
|
2287
|
-
fallbackText: rendered.plain,
|
|
2288
|
-
replyMarkup: actionKeyboard(rendered.buttons),
|
|
2289
|
-
});
|
|
2290
|
-
return;
|
|
2291
|
-
}
|
|
2292
|
-
if ((subcommand === "input" || subcommand === "send") && tokens[1] && tokens.slice(2).join(" ").trim()) {
|
|
2293
|
-
const job = agentUpdates.sendInput(tokens[1], tokens.slice(2).join(" "));
|
|
2294
|
-
const rendered = renderAgentUpdateJobAction(job);
|
|
2295
|
-
await safeReply(ctx, rendered.html, {
|
|
2296
|
-
fallbackText: rendered.plain,
|
|
2297
|
-
replyMarkup: actionKeyboard(rendered.buttons),
|
|
2298
|
-
});
|
|
2299
|
-
return;
|
|
2300
|
-
}
|
|
2301
|
-
const requestedAgent = parseAgentUpdateId(subcommand);
|
|
2302
|
-
if (requestedAgent) {
|
|
2303
|
-
await startTelegramAgentUpdate(ctx, requestedAgent);
|
|
2304
|
-
return;
|
|
2305
|
-
}
|
|
2306
|
-
if (subcommand) {
|
|
2307
|
-
const usage = "Unknown update target. Use /update, /update agents, /update jobs, /update <agent>, /update log <id>, /update cancel <id>, or /update input <id> <text>.";
|
|
2308
|
-
await safeReply(ctx, escapeHTML(usage), { fallbackText: usage });
|
|
2309
|
-
return;
|
|
2310
|
-
}
|
|
2311
|
-
const update = spawnSelfUpdate();
|
|
2312
|
-
const rendered = renderSelfUpdateStartedAction(update);
|
|
2313
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2314
|
-
});
|
|
2315
|
-
bot.callbackQuery("upd_jobs", async (ctx) => {
|
|
2316
|
-
await ctx.answerCallbackQuery();
|
|
2317
|
-
const rendered = renderAgentUpdateJobsAction(agentUpdates.list());
|
|
2318
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2319
|
-
});
|
|
2320
|
-
bot.callbackQuery(/^upd_agent:(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
|
|
2321
|
-
const agentId = ctx.match?.[1];
|
|
2322
|
-
if (!agentId) {
|
|
2323
|
-
await ctx.answerCallbackQuery();
|
|
2324
|
-
return;
|
|
2325
|
-
}
|
|
2326
|
-
await ctx.answerCallbackQuery({ text: `Starting ${agentLabel(agentId)} update...` });
|
|
2327
|
-
await startTelegramAgentUpdate(ctx, agentId);
|
|
2328
|
-
});
|
|
2329
|
-
bot.callbackQuery(/^upd_log:(.+)$/, async (ctx) => {
|
|
2330
|
-
const id = ctx.match?.[1];
|
|
2331
|
-
await ctx.answerCallbackQuery();
|
|
2332
|
-
if (!id) {
|
|
2333
|
-
return;
|
|
2334
|
-
}
|
|
2335
|
-
const rendered = renderAgentUpdateLogAction(agentUpdates.readLog(id));
|
|
2336
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2337
|
-
});
|
|
2338
|
-
bot.callbackQuery(/^upd_cancel:(.+)$/, async (ctx) => {
|
|
2339
|
-
const id = ctx.match?.[1];
|
|
2340
|
-
await ctx.answerCallbackQuery({ text: "Cancelling update..." });
|
|
2341
|
-
if (!id) {
|
|
2342
|
-
return;
|
|
2343
|
-
}
|
|
2344
|
-
const job = agentUpdates.cancel(id);
|
|
2345
|
-
const rendered = renderAgentUpdateJobAction(job);
|
|
2346
|
-
await safeReply(ctx, rendered.html, {
|
|
2347
|
-
fallbackText: rendered.plain,
|
|
2348
|
-
replyMarkup: actionKeyboard(rendered.buttons),
|
|
2349
|
-
});
|
|
2350
|
-
});
|
|
2351
|
-
bot.command("new", async (ctx) => {
|
|
2352
|
-
const chatId = ctx.chat?.id;
|
|
2353
|
-
if (!chatId) {
|
|
2354
|
-
return;
|
|
2355
|
-
}
|
|
2356
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2357
|
-
if (!contextSession) {
|
|
2358
|
-
return;
|
|
2359
|
-
}
|
|
2360
|
-
const { contextKey, session } = contextSession;
|
|
2361
|
-
if (isBusy(contextKey)) {
|
|
2362
|
-
await safeReply(ctx, escapeHTML("Cannot create a new thread while a prompt is running."), {
|
|
2363
|
-
fallbackText: "Cannot create a new thread while a prompt is running.",
|
|
2364
|
-
});
|
|
2365
|
-
return;
|
|
2366
|
-
}
|
|
2367
|
-
const currentPolicy = evaluateWorkspacePolicy(session.getCurrentWorkspace(), config);
|
|
2368
|
-
if (!currentPolicy.allowed) {
|
|
2369
|
-
await safeReply(ctx, escapeHTML(currentPolicy.warning ?? "Current workspace is blocked by workspace policy."), {
|
|
2370
|
-
fallbackText: currentPolicy.warning ?? "Current workspace is blocked by workspace policy.",
|
|
2371
|
-
});
|
|
2372
|
-
return;
|
|
2373
|
-
}
|
|
2374
|
-
const workspaces = filterAllowedWorkspaces(session.listWorkspaces(), config);
|
|
2375
|
-
if (workspaces.length <= 1) {
|
|
2376
|
-
try {
|
|
2377
|
-
const info = await session.newThread();
|
|
2378
|
-
updateSessionMetadata(contextKey, session);
|
|
2379
|
-
const label = isTopicContext(contextKey) ? "New thread created for this topic." : "New thread created.";
|
|
2380
|
-
const policyLine = renderWorkspacePolicyLine(info.workspace, config);
|
|
2381
|
-
const plainText = [label, policyLine, "", renderSessionInfoPlain(info)].filter((line) => line !== undefined).join("\n");
|
|
2382
|
-
const html = [`<b>${escapeHTML(label)}</b>`, policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, "", renderSessionInfoHTML(info)].filter((line) => line !== undefined).join("\n");
|
|
2383
|
-
await safeReply(ctx, html, { fallbackText: plainText });
|
|
2384
|
-
}
|
|
2385
|
-
catch (error) {
|
|
2386
|
-
await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
|
|
2387
|
-
fallbackText: `Failed: ${friendlyErrorText(error)}`,
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
return;
|
|
2391
|
-
}
|
|
2392
|
-
pendingWorkspacePicks.set(contextKey, workspaces);
|
|
2393
|
-
const currentWorkspace = session.getCurrentWorkspace();
|
|
2394
|
-
const workspaceButtons = workspaces.map((workspace, index) => ({
|
|
2395
|
-
label: `${workspace === currentWorkspace ? "📂" : "📁"} ${getWorkspaceShortName(workspace)}`,
|
|
2396
|
-
callbackData: `ws_${index}`,
|
|
2397
|
-
}));
|
|
2398
|
-
pendingWorkspaceButtons.set(contextKey, workspaceButtons);
|
|
2399
|
-
const keyboard = paginateKeyboard(workspaceButtons, 0, "ws");
|
|
2400
|
-
await safeReply(ctx, "<b>Select workspace for new thread:</b>", {
|
|
2401
|
-
fallbackText: "Select workspace for new thread:",
|
|
2402
|
-
replyMarkup: keyboard,
|
|
2403
|
-
});
|
|
2404
|
-
});
|
|
2405
|
-
bot.command(["abort", "stop"], async (ctx) => {
|
|
2406
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2407
|
-
if (!contextSession) {
|
|
2408
|
-
return;
|
|
2409
|
-
}
|
|
2410
|
-
const { contextKey, session } = contextSession;
|
|
2411
|
-
try {
|
|
2412
|
-
const busy = getBusyReason(contextKey);
|
|
2413
|
-
if (busy.kind === "external") {
|
|
2414
|
-
const text = `Cannot abort the external ${busy.activity.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running; queued Telegram messages will wait.`;
|
|
2415
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2416
|
-
return;
|
|
2417
|
-
}
|
|
2418
|
-
await session.abort();
|
|
2419
|
-
await safeReply(ctx, escapeHTML("Aborted current operation"), {
|
|
2420
|
-
fallbackText: "Aborted current operation",
|
|
2421
|
-
});
|
|
2422
|
-
}
|
|
2423
|
-
catch (error) {
|
|
2424
|
-
await safeReply(ctx, `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`, {
|
|
2425
|
-
fallbackText: `Failed: ${friendlyErrorText(error)}`,
|
|
2426
|
-
});
|
|
2427
|
-
}
|
|
2428
|
-
});
|
|
2429
|
-
bot.command("retry", async (ctx) => {
|
|
2430
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2431
|
-
if (!contextSession) {
|
|
2432
|
-
return;
|
|
2433
|
-
}
|
|
2434
|
-
const { contextKey, session } = contextSession;
|
|
2435
|
-
const chatId = ctx.chat?.id;
|
|
2436
|
-
if (!chatId) {
|
|
2437
|
-
return;
|
|
2438
|
-
}
|
|
2439
|
-
if (isBusy(contextKey)) {
|
|
2440
|
-
await sendBusyReply(ctx);
|
|
2441
|
-
return;
|
|
2442
|
-
}
|
|
2443
|
-
const cached = promptStore.getLastPrompt(contextKey);
|
|
2444
|
-
if (!cached) {
|
|
2445
|
-
await safeReply(ctx, escapeHTML("Nothing to retry. Send a message first."), {
|
|
2446
|
-
fallbackText: "Nothing to retry. Send a message first.",
|
|
2447
|
-
});
|
|
2448
|
-
return;
|
|
2449
|
-
}
|
|
2450
|
-
await setReaction(ctx, "👀");
|
|
2451
|
-
try {
|
|
2452
|
-
await handleUserPrompt(ctx, contextKey, chatId, session, cached);
|
|
2453
|
-
await setReaction(ctx, "👍");
|
|
2454
|
-
}
|
|
2455
|
-
catch {
|
|
2456
|
-
await clearReaction(ctx);
|
|
2457
|
-
}
|
|
2458
|
-
});
|
|
2459
|
-
bot.command("queue", async (ctx) => {
|
|
2460
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2461
|
-
if (!contextSession) {
|
|
2462
|
-
return;
|
|
2463
|
-
}
|
|
2464
|
-
const chatId = ctx.chat?.id;
|
|
2465
|
-
const { contextKey, session } = contextSession;
|
|
2466
|
-
const rawText = ctx.message?.text ?? "";
|
|
2467
|
-
const argument = rawText.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
|
|
2468
|
-
const laterMatch = argument.match(/^later\s+(\d+)(?:m|min|minutes?)?\s+([\s\S]+)$/i);
|
|
2469
|
-
if (laterMatch) {
|
|
2470
|
-
const minutes = Math.min(7 * 24 * 60, Math.max(1, Number(laterMatch[1])));
|
|
2471
|
-
const text = laterMatch[2].trim();
|
|
2472
|
-
const notBefore = Date.now() + minutes * 60 * 1000;
|
|
2473
|
-
const item = promptStore.enqueue(contextKey, toPromptEnvelope(text), { notBefore });
|
|
2474
|
-
const message = `Queued prompt ${item.id} for ${formatLocalDateTime(new Date(notBefore))}.`;
|
|
2475
|
-
await safeReply(ctx, escapeHTML(message), {
|
|
2476
|
-
fallbackText: message,
|
|
2477
|
-
replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
|
|
2478
|
-
});
|
|
2479
|
-
auditContext(ctx, contextKey, session, {
|
|
2480
|
-
action: "prompt_queued",
|
|
2481
|
-
status: "ok",
|
|
2482
|
-
promptId: item.id,
|
|
2483
|
-
description: item.description,
|
|
2484
|
-
detail: "scheduled",
|
|
2485
|
-
});
|
|
2486
|
-
return;
|
|
2487
|
-
}
|
|
2488
|
-
const inspectMatch = argument.match(/^inspect\s+([a-z0-9]+)$/i);
|
|
2489
|
-
if (inspectMatch) {
|
|
2490
|
-
const item = promptStore.get(contextKey, inspectMatch[1]);
|
|
2491
|
-
if (!item) {
|
|
2492
|
-
await safeReply(ctx, escapeHTML(`No queued prompt found with id ${inspectMatch[1]}.`), {
|
|
2493
|
-
fallbackText: `No queued prompt found with id ${inspectMatch[1]}.`,
|
|
2494
|
-
});
|
|
2495
|
-
return;
|
|
2496
|
-
}
|
|
2497
|
-
const rendered = renderQueuedPromptDetailAction(item);
|
|
2498
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2499
|
-
return;
|
|
2500
|
-
}
|
|
2501
|
-
if (/^pause$/i.test(argument)) {
|
|
2502
|
-
promptStore.pause(contextKey);
|
|
2503
|
-
const message = `Queue paused. ${promptStore.list(contextKey).length} queued.`;
|
|
2504
|
-
await safeReply(ctx, escapeHTML(message), { fallbackText: message });
|
|
2505
|
-
await updateQueueStatusMessage(contextKey, message);
|
|
2506
|
-
return;
|
|
2507
|
-
}
|
|
2508
|
-
if (/^resume$/i.test(argument)) {
|
|
2509
|
-
promptStore.resume(contextKey);
|
|
2510
|
-
const message = `Queue resumed. ${promptStore.list(contextKey).length} queued.`;
|
|
2511
|
-
await safeReply(ctx, escapeHTML(message), { fallbackText: message });
|
|
2512
|
-
if (chatId) {
|
|
2513
|
-
void drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
|
|
2514
|
-
console.error("Failed to drain queue after resume:", error);
|
|
2515
|
-
});
|
|
2516
|
-
}
|
|
2517
|
-
return;
|
|
2518
|
-
}
|
|
2519
|
-
const moveMatch = argument.match(/^move\s+([a-z0-9]+)\s+(top|up|down)$/i);
|
|
2520
|
-
if (moveMatch) {
|
|
2521
|
-
const direction = moveMatch[2].toLowerCase();
|
|
2522
|
-
const item = direction === "top"
|
|
2523
|
-
? promptStore.moveToTop(contextKey, moveMatch[1])
|
|
2524
|
-
: direction === "up"
|
|
2525
|
-
? promptStore.moveUp(contextKey, moveMatch[1])
|
|
2526
|
-
: promptStore.moveDown(contextKey, moveMatch[1]);
|
|
2527
|
-
if (!item) {
|
|
2528
|
-
await safeReply(ctx, escapeHTML(`No queued prompt found with id ${moveMatch[1]}.`), {
|
|
2529
|
-
fallbackText: `No queued prompt found with id ${moveMatch[1]}.`,
|
|
2530
|
-
});
|
|
2531
|
-
return;
|
|
2532
|
-
}
|
|
2533
|
-
const message = `Moved queued prompt ${item.id} ${direction}.`;
|
|
2534
|
-
await safeReply(ctx, escapeHTML(message), { fallbackText: message });
|
|
2535
|
-
return;
|
|
2536
|
-
}
|
|
2537
|
-
const runMatch = argument.match(/^run\s+([a-z0-9]+)$/i);
|
|
2538
|
-
if (runMatch) {
|
|
2539
|
-
const item = promptStore.remove(contextKey, runMatch[1]);
|
|
2540
|
-
if (!item) {
|
|
2541
|
-
await safeReply(ctx, escapeHTML(`No queued prompt found with id ${runMatch[1]}.`), {
|
|
2542
|
-
fallbackText: `No queued prompt found with id ${runMatch[1]}.`,
|
|
2543
|
-
});
|
|
2544
|
-
return;
|
|
2545
|
-
}
|
|
2546
|
-
promptStore.enqueueFront(contextKey, item);
|
|
2547
|
-
promptStore.resume(contextKey);
|
|
2548
|
-
if (!chatId) {
|
|
2549
|
-
return;
|
|
2550
|
-
}
|
|
2551
|
-
const busy = getBusyReason(contextKey);
|
|
2552
|
-
if (busy.busy) {
|
|
2553
|
-
const message = `Queued prompt ${item.id} moved to top and will run when the current task finishes.`;
|
|
2554
|
-
await safeReply(ctx, escapeHTML(message), { fallbackText: message });
|
|
2555
|
-
if (busy.kind === "external") {
|
|
2556
|
-
scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
|
|
2557
|
-
}
|
|
2558
|
-
return;
|
|
2559
|
-
}
|
|
2560
|
-
const next = promptStore.dequeue(contextKey);
|
|
2561
|
-
if (next) {
|
|
2562
|
-
await handleUserPrompt(ctx, contextKey, chatId, session, next, { fromQueue: true });
|
|
2563
|
-
}
|
|
2564
|
-
return;
|
|
2565
|
-
}
|
|
2566
|
-
if (argument) {
|
|
2567
|
-
await safeReply(ctx, escapeHTML("Usage: /queue, /queue pause, /queue resume, /queue later <minutes> <prompt>, /queue inspect <id>, /queue move <id> top|up|down, /queue run <id>"), {
|
|
2568
|
-
fallbackText: "Usage: /queue, /queue pause, /queue resume, /queue later <minutes> <prompt>, /queue inspect <id>, /queue move <id> top|up|down, /queue run <id>",
|
|
2569
|
-
});
|
|
2570
|
-
return;
|
|
2571
|
-
}
|
|
2572
|
-
const queue = promptStore.list(contextKey);
|
|
2573
|
-
if (queue.length === 0) {
|
|
2574
|
-
const rendered = renderQueueList(contextKey, queue);
|
|
2575
|
-
await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
|
|
2576
|
-
return;
|
|
2577
|
-
}
|
|
2578
|
-
const rendered = renderQueueList(contextKey, queue);
|
|
2579
|
-
await safeReply(ctx, rendered.html, {
|
|
2580
|
-
fallbackText: rendered.plain,
|
|
2581
|
-
replyMarkup: rendered.keyboard,
|
|
2582
|
-
});
|
|
2583
|
-
});
|
|
2584
|
-
bot.command("clearqueue", async (ctx) => {
|
|
2585
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2586
|
-
if (!contextSession) {
|
|
2587
|
-
return;
|
|
2588
|
-
}
|
|
2589
|
-
const count = promptStore.clear(contextSession.contextKey);
|
|
2590
|
-
const message = `Cleared ${count} queued prompt${count === 1 ? "" : "s"}.`;
|
|
2591
|
-
await safeReply(ctx, escapeHTML(message), { fallbackText: message });
|
|
2592
|
-
});
|
|
2593
|
-
bot.command("cancel", async (ctx) => {
|
|
2594
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2595
|
-
if (!contextSession) {
|
|
2596
|
-
return;
|
|
2597
|
-
}
|
|
2598
|
-
const rawText = ctx.message?.text ?? "";
|
|
2599
|
-
const id = rawText.replace(/^\/cancel(?:@\w+)?\s*/i, "").trim();
|
|
2600
|
-
if (!id) {
|
|
2601
|
-
await safeReply(ctx, escapeHTML("Usage: /cancel <queue-id>"), {
|
|
2602
|
-
fallbackText: "Usage: /cancel <queue-id>",
|
|
2603
|
-
});
|
|
2604
|
-
return;
|
|
2605
|
-
}
|
|
2606
|
-
const removed = promptStore.remove(contextSession.contextKey, id);
|
|
2607
|
-
if (!removed) {
|
|
2608
|
-
await safeReply(ctx, escapeHTML(`No queued prompt found with id ${id}.`), {
|
|
2609
|
-
fallbackText: `No queued prompt found with id ${id}.`,
|
|
2610
|
-
});
|
|
2611
|
-
return;
|
|
2612
|
-
}
|
|
2613
|
-
await safeReply(ctx, escapeHTML(`Cancelled queued prompt ${removed.id}.`), {
|
|
2614
|
-
fallbackText: `Cancelled queued prompt ${removed.id}.`,
|
|
2615
|
-
});
|
|
2616
|
-
});
|
|
2617
|
-
bot.command("artifacts", async (ctx) => {
|
|
2618
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2619
|
-
if (!contextSession || !ctx.chat) {
|
|
2620
|
-
return;
|
|
2621
|
-
}
|
|
2622
|
-
const workspace = contextSession.session.getInfo().workspace;
|
|
2623
|
-
const rawText = ctx.message?.text ?? "";
|
|
2624
|
-
const argument = rawText.replace(/^\/artifacts(?:@\w+)?\s*/i, "").trim();
|
|
2625
|
-
const reports = await listRecentArtifactReports(workspace, 10, config.maxFileSize);
|
|
2626
|
-
if (reports.length === 0) {
|
|
2627
|
-
await safeReply(ctx, escapeHTML("No generated artifacts found for this workspace."), {
|
|
2628
|
-
fallbackText: "No generated artifacts found for this workspace.",
|
|
2629
|
-
});
|
|
2630
|
-
return;
|
|
2631
|
-
}
|
|
2632
|
-
if (argument) {
|
|
2633
|
-
const parts = argument.split(/\s+/).filter(Boolean);
|
|
2634
|
-
if (parts[0]?.toLowerCase() === "delete" && parts[1]) {
|
|
2635
|
-
const selected = reports.find((report) => report.turnId === parts[1] || report.turnId.startsWith(parts[1]));
|
|
2636
|
-
if (!selected) {
|
|
2637
|
-
await safeReply(ctx, escapeHTML(`No artifact turn found for "${parts[1]}".`), {
|
|
2638
|
-
fallbackText: `No artifact turn found for "${parts[1]}".`,
|
|
2639
|
-
});
|
|
2640
|
-
return;
|
|
2641
|
-
}
|
|
2642
|
-
const removed = await removeArtifactTurn(workspace, selected.turnId);
|
|
2643
|
-
const text = removed ? `Deleted artifact turn: ${selected.turnId}` : `Artifact turn not found: ${selected.turnId}`;
|
|
2644
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2645
|
-
return;
|
|
2646
|
-
}
|
|
2647
|
-
const filtered = filterArtifactReports(reports, argument);
|
|
2648
|
-
if (filtered) {
|
|
2649
|
-
if (filtered.length === 0) {
|
|
2650
|
-
await safeReply(ctx, escapeHTML(`No artifacts matched "${argument}".`), {
|
|
2651
|
-
fallbackText: `No artifacts matched "${argument}".`,
|
|
2652
|
-
});
|
|
2653
|
-
return;
|
|
2654
|
-
}
|
|
2655
|
-
const rendered = renderArtifactReportsAction(filtered);
|
|
2656
|
-
await safeReply(ctx, rendered.html, {
|
|
2657
|
-
fallbackText: rendered.plain,
|
|
2658
|
-
replyMarkup: buildArtifactActionsKeyboard(filtered),
|
|
2659
|
-
});
|
|
2660
|
-
return;
|
|
2661
|
-
}
|
|
2662
|
-
const shouldZip = parts[0]?.toLowerCase() === "zip";
|
|
2663
|
-
const requestedTurn = shouldZip ? parts[1] : parts[0];
|
|
2664
|
-
const selected = !requestedTurn || requestedTurn.toLowerCase() === "latest"
|
|
2665
|
-
? reports[0]
|
|
2666
|
-
: reports.find((report) => report.turnId === requestedTurn || report.turnId.startsWith(requestedTurn));
|
|
2667
|
-
if (!selected) {
|
|
2668
|
-
await safeReply(ctx, escapeHTML(`No artifact turn found for "${argument}".`), {
|
|
2669
|
-
fallbackText: `No artifact turn found for "${argument}".`,
|
|
2670
|
-
});
|
|
2671
|
-
return;
|
|
2672
|
-
}
|
|
2673
|
-
if (shouldZip) {
|
|
2674
|
-
await deliverArtifactReportZip(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
|
|
2675
|
-
}
|
|
2676
|
-
else {
|
|
2677
|
-
await deliverArtifactReport(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
|
|
2678
|
-
}
|
|
2679
|
-
return;
|
|
2680
|
-
}
|
|
2681
|
-
const { html, plain } = renderArtifactReportsAction(reports);
|
|
2682
|
-
await safeReply(ctx, html, {
|
|
2683
|
-
fallbackText: plain,
|
|
2684
|
-
replyMarkup: buildArtifactActionsKeyboard(reports),
|
|
2685
|
-
});
|
|
2686
|
-
});
|
|
2687
|
-
bot.command("session", async (ctx) => {
|
|
2688
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2689
|
-
if (!contextSession) {
|
|
2690
|
-
return;
|
|
2691
|
-
}
|
|
2692
|
-
const { contextKey, session } = contextSession;
|
|
2693
|
-
const info = session.getInfo();
|
|
2694
|
-
const contextLabel = isTopicContext(contextKey) ? "Topic session" : "Chat session";
|
|
2695
|
-
const policyLine = renderWorkspacePolicyLine(info.workspace, config);
|
|
2696
|
-
const plainLines = [`${contextLabel}:`, policyLine, renderSessionInfoPlain(info)].filter((line) => line !== undefined);
|
|
2697
|
-
const htmlLines = [`<b>${escapeHTML(contextLabel)}:</b>`, policyLine ? `<i>${escapeHTML(policyLine)}</i>` : undefined, renderSessionInfoHTML(info)].filter((line) => line !== undefined);
|
|
2698
|
-
await safeReply(ctx, htmlLines.join("\n"), { fallbackText: plainLines.join("\n") });
|
|
2699
|
-
});
|
|
2700
|
-
const openLaunchProfilesPicker = async (ctx) => {
|
|
2701
|
-
const chatId = ctx.chat?.id;
|
|
2702
|
-
if (!chatId) {
|
|
2703
|
-
return;
|
|
2704
|
-
}
|
|
2705
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2706
|
-
if (!contextSession) {
|
|
2707
|
-
return;
|
|
2708
|
-
}
|
|
2709
|
-
const { contextKey, session } = contextSession;
|
|
2710
|
-
const info = session.getInfo();
|
|
2711
|
-
if (!capabilitiesOf(info).launchProfiles) {
|
|
2712
|
-
const text = `Launch profiles are not supported for ${labelOf(info)}.`;
|
|
2713
|
-
await safeReply(ctx, escapeHTML(text), { fallbackText: text });
|
|
2714
|
-
return;
|
|
2715
|
-
}
|
|
2716
|
-
if (isBusy(contextKey)) {
|
|
2717
|
-
await safeReply(ctx, escapeHTML("Cannot change launch profile while a prompt is running."), {
|
|
2718
|
-
fallbackText: "Cannot change launch profile while a prompt is running.",
|
|
2719
|
-
});
|
|
2720
|
-
return;
|
|
2721
|
-
}
|
|
2722
|
-
const profiles = session.listLaunchProfiles();
|
|
2723
|
-
const selectedLaunchProfile = session.getInfo();
|
|
2724
|
-
const launchButtons = profiles.map((profile, index) => ({
|
|
2725
|
-
label: formatAgentLaunchProfileLabel(profile, profile.id === selectedLaunchProfile.launchProfileId),
|
|
2726
|
-
callbackData: `launch_${index}`,
|
|
2727
|
-
}));
|
|
2728
|
-
pendingLaunchPicks.set(contextKey, profiles.map((profile) => profile.id));
|
|
2729
|
-
pendingLaunchButtons.set(contextKey, launchButtons);
|
|
2730
|
-
pendingUnsafeLaunchConfirmations.delete(contextKey);
|
|
2731
|
-
const keyboard = paginateKeyboard(launchButtons, 0, "launch");
|
|
2732
|
-
const htmlLines = [
|
|
2733
|
-
`<b>Selected launch profile:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileLabel)}</code>`,
|
|
2734
|
-
`<b>Behavior:</b> <code>${escapeHTML(selectedLaunchProfile.launchProfileBehavior)}</code>`,
|
|
2735
|
-
"",
|
|
2736
|
-
"Select a profile for new or reattached threads:",
|
|
2737
|
-
];
|
|
2738
|
-
const plainLines = [
|
|
2739
|
-
`Selected launch profile: ${selectedLaunchProfile.launchProfileLabel}`,
|
|
2740
|
-
`Behavior: ${selectedLaunchProfile.launchProfileBehavior}`,
|
|
2741
|
-
"",
|
|
2742
|
-
"Select a profile for new or reattached threads:",
|
|
2743
|
-
];
|
|
2744
|
-
if (selectedLaunchProfile.unsafeLaunch) {
|
|
2745
|
-
htmlLines.splice(2, 0, "⚠️ <i>Selected profile uses danger-full-access.</i>");
|
|
2746
|
-
plainLines.splice(2, 0, "⚠️ Selected profile uses danger-full-access.");
|
|
2747
|
-
}
|
|
2748
|
-
if (info.nextLaunchProfileId) {
|
|
2749
|
-
htmlLines.splice(2, 0, `<b>Active thread still uses:</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`);
|
|
2750
|
-
plainLines.splice(2, 0, `Active thread still uses: ${info.launchProfileLabel}`);
|
|
2751
|
-
}
|
|
2752
|
-
await safeReply(ctx, htmlLines.join("\n"), {
|
|
2753
|
-
fallbackText: plainLines.join("\n"),
|
|
2754
|
-
replyMarkup: keyboard,
|
|
2755
|
-
});
|
|
2756
|
-
};
|
|
2757
|
-
bot.command(["launch", "launch_profiles"], openLaunchProfilesPicker);
|
|
2758
|
-
bot.hears(/^\/launch-profiles(?:@\w+)?$/i, openLaunchProfilesPicker);
|
|
2759
|
-
bot.command("handback", async (ctx) => {
|
|
2760
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
2761
|
-
if (!contextSession) {
|
|
2762
|
-
return;
|
|
2763
|
-
}
|
|
2764
|
-
const { contextKey, session } = contextSession;
|
|
2765
|
-
if (isBusy(contextKey)) {
|
|
2766
|
-
await safeReply(ctx, escapeHTML("Cannot hand back while a prompt is running. Use /abort first."), {
|
|
2767
|
-
fallbackText: "Cannot hand back while a prompt is running. Use /abort first.",
|
|
2768
|
-
});
|
|
2769
|
-
return;
|
|
2770
|
-
}
|
|
2771
|
-
if (!session.hasActiveThread()) {
|
|
2772
|
-
await safeReply(ctx, escapeHTML("No active thread to hand back."), {
|
|
2773
|
-
fallbackText: "No active thread to hand back.",
|
|
2774
|
-
});
|
|
2775
|
-
return;
|
|
2776
|
-
}
|
|
2777
|
-
try {
|
|
2778
|
-
const info = session.handback();
|
|
2779
|
-
updateSessionMetadata(contextKey, session);
|
|
2780
|
-
if (!info.threadId) {
|
|
2781
|
-
await safeReply(ctx, escapeHTML("This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh."), {
|
|
2782
|
-
fallbackText: "This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh.",
|
|
1872
|
+
if (!session.hasActiveThread()) {
|
|
1873
|
+
await safeReply(ctx, escapeHTML("No active thread to hand back."), {
|
|
1874
|
+
fallbackText: "No active thread to hand back.",
|
|
1875
|
+
});
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
try {
|
|
1879
|
+
const info = session.handback();
|
|
1880
|
+
updateSessionMetadata(contextKey, session);
|
|
1881
|
+
if (!info.threadId) {
|
|
1882
|
+
await safeReply(ctx, escapeHTML("This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh."), {
|
|
1883
|
+
fallbackText: "This thread has not started yet, so there is no resumable thread ID. Send a message to create one, or use /new to start fresh.",
|
|
2783
1884
|
});
|
|
2784
1885
|
return;
|
|
2785
1886
|
}
|
|
@@ -3199,49 +2300,6 @@ export function createBot(config, registry) {
|
|
|
3199
2300
|
});
|
|
3200
2301
|
};
|
|
3201
2302
|
bot.command(["effort", "reasoning"], openReasoningPicker);
|
|
3202
|
-
bot.callbackQuery(/^agent_(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
|
|
3203
|
-
const chatId = ctx.chat?.id;
|
|
3204
|
-
const messageId = ctx.callbackQuery.message?.message_id;
|
|
3205
|
-
const selectedAgent = ctx.match?.[1];
|
|
3206
|
-
const contextKey = contextKeyFromCtx(ctx);
|
|
3207
|
-
if (!chatId || !contextKey || !selectedAgent) {
|
|
3208
|
-
await ctx.answerCallbackQuery();
|
|
3209
|
-
return;
|
|
3210
|
-
}
|
|
3211
|
-
const picks = pendingAgentPicks.get(contextKey);
|
|
3212
|
-
if (!picks?.includes(selectedAgent)) {
|
|
3213
|
-
await ctx.answerCallbackQuery({ text: "Expired, run /agent again" });
|
|
3214
|
-
return;
|
|
3215
|
-
}
|
|
3216
|
-
if (isBusy(contextKey)) {
|
|
3217
|
-
await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
|
|
3218
|
-
return;
|
|
3219
|
-
}
|
|
3220
|
-
await ctx.answerCallbackQuery({ text: `Switching to ${agentLabel(selectedAgent)}...` });
|
|
3221
|
-
pendingAgentPicks.delete(contextKey);
|
|
3222
|
-
try {
|
|
3223
|
-
const session = await registry.switchAgent(contextKey, selectedAgent);
|
|
3224
|
-
const info = session.getInfo();
|
|
3225
|
-
const html = [`<b>Agent switched to ${escapeHTML(labelOf(info))}.</b>`, "", renderSessionInfoHTML(info)].join("\n");
|
|
3226
|
-
const plain = [`Agent switched to ${labelOf(info)}.`, "", renderSessionInfoPlain(info)].join("\n");
|
|
3227
|
-
if (messageId) {
|
|
3228
|
-
await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
|
|
3229
|
-
}
|
|
3230
|
-
else {
|
|
3231
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
catch (error) {
|
|
3235
|
-
const html = `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`;
|
|
3236
|
-
const plain = `Failed: ${friendlyErrorText(error)}`;
|
|
3237
|
-
if (messageId) {
|
|
3238
|
-
await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
|
|
3239
|
-
}
|
|
3240
|
-
else {
|
|
3241
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3244
|
-
});
|
|
3245
2303
|
bot.callbackQuery(NOOP_PAGE_CALLBACK_DATA, async (ctx) => {
|
|
3246
2304
|
await ctx.answerCallbackQuery();
|
|
3247
2305
|
});
|
|
@@ -3264,94 +2322,6 @@ export function createBot(config, registry) {
|
|
|
3264
2322
|
await ctx.answerCallbackQuery({ text: "Aborting..." });
|
|
3265
2323
|
await session.abort();
|
|
3266
2324
|
});
|
|
3267
|
-
bot.callbackQuery(/^queue_(cancel|remove|top|up|down|run):(-?\d+(?::\d+)?):([a-z0-9]+)$/, async (ctx) => {
|
|
3268
|
-
const action = ctx.match?.[1];
|
|
3269
|
-
const contextKey = ctx.match?.[2];
|
|
3270
|
-
const queueId = ctx.match?.[3];
|
|
3271
|
-
if (!action || !contextKey || !queueId) {
|
|
3272
|
-
await ctx.answerCallbackQuery();
|
|
3273
|
-
return;
|
|
3274
|
-
}
|
|
3275
|
-
const currentContextKey = contextKeyFromCtx(ctx);
|
|
3276
|
-
if (currentContextKey && currentContextKey !== contextKey) {
|
|
3277
|
-
await ctx.answerCallbackQuery({ text: "This queue button belongs to another chat or topic." });
|
|
3278
|
-
return;
|
|
3279
|
-
}
|
|
3280
|
-
const chatId = ctx.chat?.id;
|
|
3281
|
-
const messageId = ctx.callbackQuery.message?.message_id;
|
|
3282
|
-
if (action === "top" || action === "up" || action === "down") {
|
|
3283
|
-
const item = action === "top"
|
|
3284
|
-
? promptStore.moveToTop(contextKey, queueId)
|
|
3285
|
-
: action === "up"
|
|
3286
|
-
? promptStore.moveUp(contextKey, queueId)
|
|
3287
|
-
: promptStore.moveDown(contextKey, queueId);
|
|
3288
|
-
await ctx.answerCallbackQuery({ text: item ? `Moved ${queueId} ${action}.` : "Queued prompt not found." });
|
|
3289
|
-
if (chatId && messageId) {
|
|
3290
|
-
const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
|
|
3291
|
-
await safeEditMessage(bot, chatId, messageId, rendered.html, {
|
|
3292
|
-
fallbackText: rendered.plain,
|
|
3293
|
-
replyMarkup: rendered.keyboard,
|
|
3294
|
-
});
|
|
3295
|
-
}
|
|
3296
|
-
return;
|
|
3297
|
-
}
|
|
3298
|
-
if (action === "run") {
|
|
3299
|
-
const item = promptStore.remove(contextKey, queueId);
|
|
3300
|
-
if (!item) {
|
|
3301
|
-
await ctx.answerCallbackQuery({ text: "Queued prompt already started or was cancelled." });
|
|
3302
|
-
return;
|
|
3303
|
-
}
|
|
3304
|
-
promptStore.enqueueFront(contextKey, item);
|
|
3305
|
-
promptStore.resume(contextKey);
|
|
3306
|
-
await ctx.answerCallbackQuery({ text: `Queued prompt ${queueId} moved to next.` });
|
|
3307
|
-
if (chatId && messageId) {
|
|
3308
|
-
const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
|
|
3309
|
-
await safeEditMessage(bot, chatId, messageId, rendered.html, {
|
|
3310
|
-
fallbackText: rendered.plain,
|
|
3311
|
-
replyMarkup: rendered.keyboard,
|
|
3312
|
-
});
|
|
3313
|
-
}
|
|
3314
|
-
const session = registry.get(contextKey);
|
|
3315
|
-
if (chatId && session && !getBusyReason(contextKey).busy) {
|
|
3316
|
-
void drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
|
|
3317
|
-
console.error("Failed to drain queue after run-now callback:", error);
|
|
3318
|
-
});
|
|
3319
|
-
}
|
|
3320
|
-
return;
|
|
3321
|
-
}
|
|
3322
|
-
const removed = promptStore.remove(contextKey, queueId);
|
|
3323
|
-
if (!removed) {
|
|
3324
|
-
await ctx.answerCallbackQuery({ text: "Queued prompt already started or was cancelled." });
|
|
3325
|
-
if (chatId && messageId) {
|
|
3326
|
-
if (action === "remove") {
|
|
3327
|
-
const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
|
|
3328
|
-
await safeEditMessage(bot, chatId, messageId, rendered.html, {
|
|
3329
|
-
fallbackText: rendered.plain,
|
|
3330
|
-
replyMarkup: rendered.keyboard,
|
|
3331
|
-
});
|
|
3332
|
-
}
|
|
3333
|
-
else {
|
|
3334
|
-
const message = `Queued prompt ${queueId} is no longer queued.`;
|
|
3335
|
-
await safeEditMessage(bot, chatId, messageId, escapeHTML(message), { fallbackText: message });
|
|
3336
|
-
}
|
|
3337
|
-
}
|
|
3338
|
-
return;
|
|
3339
|
-
}
|
|
3340
|
-
const message = `Cancelled queued prompt ${removed.id}.`;
|
|
3341
|
-
await ctx.answerCallbackQuery({ text: message });
|
|
3342
|
-
if (!chatId || !messageId) {
|
|
3343
|
-
return;
|
|
3344
|
-
}
|
|
3345
|
-
if (action === "remove") {
|
|
3346
|
-
const rendered = renderQueueList(contextKey, promptStore.list(contextKey));
|
|
3347
|
-
await safeEditMessage(bot, chatId, messageId, rendered.html, {
|
|
3348
|
-
fallbackText: rendered.plain,
|
|
3349
|
-
replyMarkup: rendered.keyboard,
|
|
3350
|
-
});
|
|
3351
|
-
return;
|
|
3352
|
-
}
|
|
3353
|
-
await safeEditMessage(bot, chatId, messageId, escapeHTML(message), { fallbackText: message });
|
|
3354
|
-
});
|
|
3355
2325
|
bot.callbackQuery(/^approval_(yes|no):([a-z0-9]+)$/, async (ctx) => {
|
|
3356
2326
|
const action = ctx.match?.[1];
|
|
3357
2327
|
const approvalId = ctx.match?.[2];
|
|
@@ -3364,8 +2334,7 @@ export function createBot(config, registry) {
|
|
|
3364
2334
|
await ctx.answerCallbackQuery({ text: "Approval expired" });
|
|
3365
2335
|
return;
|
|
3366
2336
|
}
|
|
3367
|
-
|
|
3368
|
-
if (pending.requestedBy !== undefined && ctx.from?.id !== pending.requestedBy && role !== "admin") {
|
|
2337
|
+
if (pending.requestedBy !== undefined && ctx.from?.id !== pending.requestedBy && !isAdminUser(ctx)) {
|
|
3369
2338
|
await ctx.answerCallbackQuery({ text: "Only the requester or an admin can approve" });
|
|
3370
2339
|
return;
|
|
3371
2340
|
}
|
|
@@ -3762,65 +2731,6 @@ export function createBot(config, registry) {
|
|
|
3762
2731
|
fallbackText: `⚡ ${label} set to ${effort} — ${scope}.`,
|
|
3763
2732
|
});
|
|
3764
2733
|
});
|
|
3765
|
-
bot.callbackQuery(/^artifact_(send|zip|delete|delete_confirm):([a-zA-Z0-9._-]+)$/, async (ctx) => {
|
|
3766
|
-
const action = ctx.match?.[1];
|
|
3767
|
-
const turnId = ctx.match?.[2];
|
|
3768
|
-
const chatId = ctx.chat?.id;
|
|
3769
|
-
const messageId = ctx.callbackQuery.message?.message_id;
|
|
3770
|
-
if (!action || !turnId || !chatId) {
|
|
3771
|
-
await ctx.answerCallbackQuery();
|
|
3772
|
-
return;
|
|
3773
|
-
}
|
|
3774
|
-
const contextSession = await getContextSession(ctx, { deferThreadStart: true });
|
|
3775
|
-
if (!contextSession) {
|
|
3776
|
-
await ctx.answerCallbackQuery({ text: "No context" });
|
|
3777
|
-
return;
|
|
3778
|
-
}
|
|
3779
|
-
const workspace = contextSession.session.getInfo().workspace;
|
|
3780
|
-
if (action === "delete") {
|
|
3781
|
-
await ctx.answerCallbackQuery({ text: "Confirm deletion" });
|
|
3782
|
-
const keyboard = new InlineKeyboard()
|
|
3783
|
-
.text("Delete artifacts", `artifact_delete_confirm:${turnId}`)
|
|
3784
|
-
.row()
|
|
3785
|
-
.text("Cancel", NOOP_PAGE_CALLBACK_DATA);
|
|
3786
|
-
const html = `<b>Delete artifact turn?</b>\n<code>${escapeHTML(turnId)}</code>`;
|
|
3787
|
-
const plain = `Delete artifact turn?\n${turnId}`;
|
|
3788
|
-
if (messageId) {
|
|
3789
|
-
await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain, replyMarkup: keyboard });
|
|
3790
|
-
}
|
|
3791
|
-
else {
|
|
3792
|
-
await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
|
|
3793
|
-
}
|
|
3794
|
-
return;
|
|
3795
|
-
}
|
|
3796
|
-
if (action === "delete_confirm") {
|
|
3797
|
-
const removed = await removeArtifactTurn(workspace, turnId);
|
|
3798
|
-
await ctx.answerCallbackQuery({ text: removed ? "Deleted" : "Already gone" });
|
|
3799
|
-
const html = removed
|
|
3800
|
-
? `<b>Deleted artifact turn:</b> <code>${escapeHTML(turnId)}</code>`
|
|
3801
|
-
: `<b>Artifact turn not found:</b> <code>${escapeHTML(turnId)}</code>`;
|
|
3802
|
-
const plain = removed ? `Deleted artifact turn: ${turnId}` : `Artifact turn not found: ${turnId}`;
|
|
3803
|
-
if (messageId) {
|
|
3804
|
-
await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
|
|
3805
|
-
}
|
|
3806
|
-
else {
|
|
3807
|
-
await safeReply(ctx, html, { fallbackText: plain });
|
|
3808
|
-
}
|
|
3809
|
-
return;
|
|
3810
|
-
}
|
|
3811
|
-
const report = await getArtifactTurnReport(workspace, turnId, config.maxFileSize);
|
|
3812
|
-
if (!report) {
|
|
3813
|
-
await ctx.answerCallbackQuery({ text: "Artifact turn not found" });
|
|
3814
|
-
return;
|
|
3815
|
-
}
|
|
3816
|
-
await ctx.answerCallbackQuery({ text: action === "zip" ? "Sending ZIP..." : "Sending artifacts..." });
|
|
3817
|
-
if (action === "zip") {
|
|
3818
|
-
await deliverArtifactReportZip(ctx, chatId, report, ctx.callbackQuery.message?.message_thread_id);
|
|
3819
|
-
}
|
|
3820
|
-
else {
|
|
3821
|
-
await deliverArtifactReport(ctx, chatId, report, ctx.callbackQuery.message?.message_thread_id);
|
|
3822
|
-
}
|
|
3823
|
-
});
|
|
3824
2734
|
bot.on("message:text", async (ctx) => {
|
|
3825
2735
|
const contextSession = await getContextSession(ctx);
|
|
3826
2736
|
if (!contextSession) {
|
|
@@ -4081,1069 +2991,3 @@ export function createBot(config, registry) {
|
|
|
4081
2991
|
});
|
|
4082
2992
|
return bot;
|
|
4083
2993
|
}
|
|
4084
|
-
export async function registerCommands(bot) {
|
|
4085
|
-
await bot.api.setMyCommands([
|
|
4086
|
-
{ command: "start", description: "Welcome & status" },
|
|
4087
|
-
{ command: "help", description: "Command reference" },
|
|
4088
|
-
{ command: "channels", description: "Messaging adapter status" },
|
|
4089
|
-
{ command: "agents", description: "Agent adapter status" },
|
|
4090
|
-
{ command: "agent", description: "Select agent" },
|
|
4091
|
-
{ command: "new", description: "Start a new thread" },
|
|
4092
|
-
{ command: "session", description: "Current thread details" },
|
|
4093
|
-
{ command: "sessions", description: "Browse & switch threads" },
|
|
4094
|
-
{ command: "sync", description: "Sync active session from CLI state" },
|
|
4095
|
-
{ command: "pinned", description: "Show pinned threads" },
|
|
4096
|
-
{ command: "pin", description: "Pin current or given thread" },
|
|
4097
|
-
{ command: "unpin", description: "Unpin current or given thread" },
|
|
4098
|
-
{ command: "retry", description: "Resend the last prompt" },
|
|
4099
|
-
{ command: "queue", description: "Show queued prompts" },
|
|
4100
|
-
{ command: "cancel", description: "Cancel a queued prompt" },
|
|
4101
|
-
{ command: "clearqueue", description: "Clear queued prompts" },
|
|
4102
|
-
{ command: "artifacts", description: "List or resend generated files" },
|
|
4103
|
-
{ command: "workspaces", description: "List allowed workspaces" },
|
|
4104
|
-
{ command: "abort", description: "Cancel current operation" },
|
|
4105
|
-
{ command: "stop", description: "Cancel current operation" },
|
|
4106
|
-
{ command: "launch_profiles", description: "Select launch profile" },
|
|
4107
|
-
{ command: "fast", description: "Toggle fast mode" },
|
|
4108
|
-
{ command: "model", description: "View & change model" },
|
|
4109
|
-
{ command: "reasoning", description: "Set reasoning effort" },
|
|
4110
|
-
{ command: "mirror", description: "Control CLI mirroring" },
|
|
4111
|
-
{ command: "notify", description: "Control notifications" },
|
|
4112
|
-
{ command: "auth", description: "Check auth status" },
|
|
4113
|
-
{ command: "login", description: "Start authentication" },
|
|
4114
|
-
{ command: "logout", description: "Sign out" },
|
|
4115
|
-
{ command: "voice", description: "Voice transcription status" },
|
|
4116
|
-
{ command: "tasks", description: "Current turn progress" },
|
|
4117
|
-
{ command: "progress", description: "Current turn progress" },
|
|
4118
|
-
{ command: "activity", description: "Thread activity timeline" },
|
|
4119
|
-
{ command: "audit", description: "Admin: recent audit events" },
|
|
4120
|
-
{ command: "status", description: "Connector runtime status" },
|
|
4121
|
-
{ command: "health", description: "Connector health report" },
|
|
4122
|
-
{ command: "version", description: "Connector version" },
|
|
4123
|
-
{ command: "logs", description: "Admin: show connector logs" },
|
|
4124
|
-
{ command: "diagnostics", description: "Admin: connector diagnostics" },
|
|
4125
|
-
{ command: "lock", description: "Lock session writes to you" },
|
|
4126
|
-
{ command: "unlock", description: "Release session write lock" },
|
|
4127
|
-
{ command: "locks", description: "List session write locks" },
|
|
4128
|
-
{ command: "restart", description: "Admin: restart connector" },
|
|
4129
|
-
{ command: "update", description: "Admin: update connector or agents" },
|
|
4130
|
-
{ command: "handback", description: "Hand session back to CLI" },
|
|
4131
|
-
{ command: "attach", description: "Bind a session to this topic" },
|
|
4132
|
-
{ command: "switch", description: "Switch to a thread by ID" },
|
|
4133
|
-
]);
|
|
4134
|
-
}
|
|
4135
|
-
function renderVersionCheckPlain(check) {
|
|
4136
|
-
const icon = versionStatusIcon(check);
|
|
4137
|
-
const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
|
|
4138
|
-
return `${label}: ${icon} ${formatVersionCheckDetailPlain(check)}`;
|
|
4139
|
-
}
|
|
4140
|
-
function renderVersionCheckHTML(check) {
|
|
4141
|
-
const icon = versionStatusIcon(check);
|
|
4142
|
-
const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
|
|
4143
|
-
return `<b>${escapeHTML(label)}:</b> ${icon} ${formatVersionCheckDetailHTML(check)}`;
|
|
4144
|
-
}
|
|
4145
|
-
function formatCliPathPlain(label, cliPath, fallback) {
|
|
4146
|
-
return cliPath ? `${label} path: ${cliPath}` : `${label}: ${fallback}`;
|
|
4147
|
-
}
|
|
4148
|
-
function formatCliPathHTML(label, cliPath, fallback) {
|
|
4149
|
-
return cliPath
|
|
4150
|
-
? `<b>${escapeHTML(label)} path:</b> <code>${escapeHTML(cliPath)}</code>`
|
|
4151
|
-
: `<b>${escapeHTML(label)}:</b> <code>${escapeHTML(fallback)}</code>`;
|
|
4152
|
-
}
|
|
4153
|
-
function formatVersionCheckDetailPlain(check) {
|
|
4154
|
-
if (check.status === "not-installed") {
|
|
4155
|
-
return "not installed";
|
|
4156
|
-
}
|
|
4157
|
-
if (check.status === "outdated") {
|
|
4158
|
-
return `${check.installedLabel} (latest ${check.latestVersion ?? "unknown"})`;
|
|
4159
|
-
}
|
|
4160
|
-
if (check.status === "current") {
|
|
4161
|
-
return `${check.installedLabel} (latest)`;
|
|
4162
|
-
}
|
|
4163
|
-
return `${check.installedLabel} (latest unknown${check.detail ? `: ${check.detail}` : ""})`;
|
|
4164
|
-
}
|
|
4165
|
-
function formatVersionCheckDetailHTML(check) {
|
|
4166
|
-
if (check.status === "not-installed") {
|
|
4167
|
-
return "<code>not installed</code>";
|
|
4168
|
-
}
|
|
4169
|
-
if (check.status === "outdated") {
|
|
4170
|
-
return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest ${escapeHTML(check.latestVersion ?? "unknown")})</i>`;
|
|
4171
|
-
}
|
|
4172
|
-
if (check.status === "current") {
|
|
4173
|
-
return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest)</i>`;
|
|
4174
|
-
}
|
|
4175
|
-
return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest unknown${check.detail ? `: ${escapeHTML(check.detail)}` : ""})</i>`;
|
|
4176
|
-
}
|
|
4177
|
-
function versionStatusIcon(check) {
|
|
4178
|
-
return check.status === "current" ? "✅" : "⚠️";
|
|
4179
|
-
}
|
|
4180
|
-
function renderAuditEvents(events) {
|
|
4181
|
-
if (events.length === 0) {
|
|
4182
|
-
return {
|
|
4183
|
-
plain: "Audit log is empty.",
|
|
4184
|
-
html: escapeHTML("Audit log is empty."),
|
|
4185
|
-
};
|
|
4186
|
-
}
|
|
4187
|
-
const lines = events.map((event) => {
|
|
4188
|
-
const time = formatLocalDateTime(new Date(event.timestamp));
|
|
4189
|
-
const actor = event.actorId ? `user ${event.actorId}` : "system";
|
|
4190
|
-
const prompt = event.promptId ? ` · ${event.promptId}` : "";
|
|
4191
|
-
const detail = event.detail ? ` · ${trimLine(event.detail, 90)}` : "";
|
|
4192
|
-
const description = event.description ? ` · ${trimLine(event.description, 90)}` : "";
|
|
4193
|
-
return `${time} · ${event.status.toUpperCase()} · ${event.action} · ${actor}${prompt}${description}${detail}`;
|
|
4194
|
-
});
|
|
4195
|
-
return {
|
|
4196
|
-
plain: ["Audit:", ...lines].join("\n"),
|
|
4197
|
-
html: [
|
|
4198
|
-
"<b>Audit:</b>",
|
|
4199
|
-
...lines.map((line) => escapeHTML(line)),
|
|
4200
|
-
].join("\n"),
|
|
4201
|
-
};
|
|
4202
|
-
}
|
|
4203
|
-
function renderSessionLocks(locks) {
|
|
4204
|
-
if (locks.length === 0) {
|
|
4205
|
-
return {
|
|
4206
|
-
plain: "No active session locks.",
|
|
4207
|
-
html: escapeHTML("No active session locks."),
|
|
4208
|
-
};
|
|
4209
|
-
}
|
|
4210
|
-
const lines = locks.map((lock) => {
|
|
4211
|
-
const expires = lock.expiresAt ? ` · expires ${formatLocalDateTime(new Date(lock.expiresAt))}` : "";
|
|
4212
|
-
return `${lock.contextKey} · ${formatLockOwner(lock)}${expires}`;
|
|
4213
|
-
});
|
|
4214
|
-
return {
|
|
4215
|
-
plain: ["Session locks:", ...lines].join("\n"),
|
|
4216
|
-
html: ["<b>Session locks:</b>", ...lines.map((line) => escapeHTML(line))].join("\n"),
|
|
4217
|
-
};
|
|
4218
|
-
}
|
|
4219
|
-
function formatLockOwner(lock) {
|
|
4220
|
-
if (!lock) {
|
|
4221
|
-
return "nobody";
|
|
4222
|
-
}
|
|
4223
|
-
return lock.ownerName ? `${lock.ownerName} (${lock.ownerId})` : `user ${lock.ownerId}`;
|
|
4224
|
-
}
|
|
4225
|
-
function formatTelegramName(ctx) {
|
|
4226
|
-
const firstName = ctx.from?.first_name?.trim();
|
|
4227
|
-
const lastName = ctx.from?.last_name?.trim();
|
|
4228
|
-
const username = ctx.from?.username?.trim();
|
|
4229
|
-
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
|
4230
|
-
return fullName || (username ? `@${username}` : undefined);
|
|
4231
|
-
}
|
|
4232
|
-
function formatLocalDateTime(date) {
|
|
4233
|
-
if (Number.isNaN(date.getTime())) {
|
|
4234
|
-
return "-";
|
|
4235
|
-
}
|
|
4236
|
-
return [
|
|
4237
|
-
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
|
|
4238
|
-
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
|
|
4239
|
-
].join(" ");
|
|
4240
|
-
}
|
|
4241
|
-
function pad2(value) {
|
|
4242
|
-
return String(value).padStart(2, "0");
|
|
4243
|
-
}
|
|
4244
|
-
function buildArtifactActionsKeyboard(reports) {
|
|
4245
|
-
const keyboard = new InlineKeyboard();
|
|
4246
|
-
for (const [index, report] of reports.slice(0, 5).entries()) {
|
|
4247
|
-
const label = `${index + 1}`;
|
|
4248
|
-
keyboard
|
|
4249
|
-
.text(`${label} Send`, `artifact_send:${report.turnId}`)
|
|
4250
|
-
.text(`${label} ZIP`, `artifact_zip:${report.turnId}`)
|
|
4251
|
-
.text(`${label} Delete`, `artifact_delete:${report.turnId}`)
|
|
4252
|
-
.row();
|
|
4253
|
-
}
|
|
4254
|
-
return keyboard;
|
|
4255
|
-
}
|
|
4256
|
-
function filterArtifactReports(reports, argument) {
|
|
4257
|
-
const normalized = argument.trim().toLowerCase();
|
|
4258
|
-
if (!normalized) {
|
|
4259
|
-
return null;
|
|
4260
|
-
}
|
|
4261
|
-
let predicate = null;
|
|
4262
|
-
if (normalized === "images" || normalized === "image" || normalized === "photos") {
|
|
4263
|
-
predicate = (artifact) => isTelegramImagePreview(artifact);
|
|
4264
|
-
}
|
|
4265
|
-
else if (normalized === "docs" || normalized === "documents" || normalized === "files") {
|
|
4266
|
-
predicate = (artifact) => !isTelegramImagePreview(artifact);
|
|
4267
|
-
}
|
|
4268
|
-
else if (normalized.startsWith("search ")) {
|
|
4269
|
-
const query = normalized.slice("search ".length).trim();
|
|
4270
|
-
if (!query) {
|
|
4271
|
-
return [];
|
|
4272
|
-
}
|
|
4273
|
-
predicate = (artifact) => artifact.name.toLowerCase().includes(query);
|
|
4274
|
-
}
|
|
4275
|
-
if (!predicate) {
|
|
4276
|
-
return null;
|
|
4277
|
-
}
|
|
4278
|
-
return reports
|
|
4279
|
-
.map((report) => ({
|
|
4280
|
-
...report,
|
|
4281
|
-
artifacts: report.artifacts.filter(predicate),
|
|
4282
|
-
}))
|
|
4283
|
-
.filter((report) => report.artifacts.length > 0);
|
|
4284
|
-
}
|
|
4285
|
-
function renderProgressPlain(progress, queueLength, busyState, info) {
|
|
4286
|
-
const busyFlags = formatBusyFlags(busyState);
|
|
4287
|
-
if (!progress) {
|
|
4288
|
-
return [
|
|
4289
|
-
"Progress:",
|
|
4290
|
-
"Status: idle",
|
|
4291
|
-
`Thread: ${info.threadId ?? "(not started yet)"}`,
|
|
4292
|
-
`Queue: ${queueLength}`,
|
|
4293
|
-
`Busy: ${busyFlags || "no"}`,
|
|
4294
|
-
].join("\n");
|
|
4295
|
-
}
|
|
4296
|
-
const lines = [
|
|
4297
|
-
"Progress:",
|
|
4298
|
-
`Status: ${progress.status}`,
|
|
4299
|
-
`Prompt: ${progress.promptDescription}`,
|
|
4300
|
-
`Elapsed: ${formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000)}`,
|
|
4301
|
-
`Current tool: ${progress.currentTool ?? "-"}`,
|
|
4302
|
-
`Last tool: ${progress.lastTool ?? "-"}`,
|
|
4303
|
-
`Tools: ${formatToolSummaryLine(progress.toolCounts) || "-"}`,
|
|
4304
|
-
`Output chars: ${progress.textCharacters}`,
|
|
4305
|
-
`Queue: ${queueLength}`,
|
|
4306
|
-
`Busy: ${busyFlags || "no"}`,
|
|
4307
|
-
];
|
|
4308
|
-
if (progress.error) {
|
|
4309
|
-
lines.push(`Error: ${progress.error}`);
|
|
4310
|
-
}
|
|
4311
|
-
return lines.join("\n");
|
|
4312
|
-
}
|
|
4313
|
-
function renderProgressHTML(progress, queueLength, busyState, info) {
|
|
4314
|
-
const busyFlags = formatBusyFlags(busyState);
|
|
4315
|
-
if (!progress) {
|
|
4316
|
-
return [
|
|
4317
|
-
"<b>Progress:</b>",
|
|
4318
|
-
"<b>Status:</b> <code>idle</code>",
|
|
4319
|
-
`<b>Thread:</b> <code>${escapeHTML(info.threadId ?? "(not started yet)")}</code>`,
|
|
4320
|
-
`<b>Queue:</b> <code>${queueLength}</code>`,
|
|
4321
|
-
`<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
|
|
4322
|
-
].join("\n");
|
|
4323
|
-
}
|
|
4324
|
-
const lines = [
|
|
4325
|
-
"<b>Progress:</b>",
|
|
4326
|
-
`<b>Status:</b> <code>${escapeHTML(progress.status)}</code>`,
|
|
4327
|
-
`<b>Prompt:</b> <code>${escapeHTML(progress.promptDescription)}</code>`,
|
|
4328
|
-
`<b>Elapsed:</b> <code>${escapeHTML(formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000))}</code>`,
|
|
4329
|
-
`<b>Current tool:</b> <code>${escapeHTML(progress.currentTool ?? "-")}</code>`,
|
|
4330
|
-
`<b>Last tool:</b> <code>${escapeHTML(progress.lastTool ?? "-")}</code>`,
|
|
4331
|
-
`<b>Tools:</b> <code>${escapeHTML(formatToolSummaryLine(progress.toolCounts) || "-")}</code>`,
|
|
4332
|
-
`<b>Output chars:</b> <code>${progress.textCharacters}</code>`,
|
|
4333
|
-
`<b>Queue:</b> <code>${queueLength}</code>`,
|
|
4334
|
-
`<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
|
|
4335
|
-
];
|
|
4336
|
-
if (progress.error) {
|
|
4337
|
-
lines.push(`<b>Error:</b> <code>${escapeHTML(progress.error)}</code>`);
|
|
4338
|
-
}
|
|
4339
|
-
return lines.join("\n");
|
|
4340
|
-
}
|
|
4341
|
-
function renderExternalMirrorStatus(snapshot, queueLength) {
|
|
4342
|
-
const prompt = trimLine(snapshot.latestUserMessage ?? "-", 180);
|
|
4343
|
-
const elapsed = snapshot.activity.startedAt
|
|
4344
|
-
? formatDurationSeconds((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
|
|
4345
|
-
: "-";
|
|
4346
|
-
const lines = [
|
|
4347
|
-
`${snapshot.agentLabel} CLI task running.`,
|
|
4348
|
-
`Thread: ${snapshot.threadId}`,
|
|
4349
|
-
`Elapsed: ${elapsed}`,
|
|
4350
|
-
`Prompt: ${prompt}`,
|
|
4351
|
-
`Last tool: ${snapshot.latestToolName ?? "-"}`,
|
|
4352
|
-
`Queue: ${queueLength}`,
|
|
4353
|
-
];
|
|
4354
|
-
return {
|
|
4355
|
-
plain: lines.join("\n"),
|
|
4356
|
-
html: [
|
|
4357
|
-
`<b>${escapeHTML(snapshot.agentLabel)} CLI task running.</b>`,
|
|
4358
|
-
`<b>Thread:</b> <code>${escapeHTML(snapshot.threadId)}</code>`,
|
|
4359
|
-
`<b>Elapsed:</b> <code>${escapeHTML(elapsed)}</code>`,
|
|
4360
|
-
`<b>Prompt:</b> <code>${escapeHTML(prompt)}</code>`,
|
|
4361
|
-
`<b>Last tool:</b> <code>${escapeHTML(snapshot.latestToolName ?? "-")}</code>`,
|
|
4362
|
-
`<b>Queue:</b> <code>${queueLength}</code>`,
|
|
4363
|
-
].join("\n"),
|
|
4364
|
-
};
|
|
4365
|
-
}
|
|
4366
|
-
function renderExternalMirrorEvent(event) {
|
|
4367
|
-
if (event.kind === "task") {
|
|
4368
|
-
const status = event.status ?? event.type;
|
|
4369
|
-
const plain = `CLI task: ${status}`;
|
|
4370
|
-
return {
|
|
4371
|
-
plain,
|
|
4372
|
-
html: `<b>CLI task:</b> <code>${escapeHTML(status)}</code>`,
|
|
4373
|
-
};
|
|
4374
|
-
}
|
|
4375
|
-
if (event.kind !== "tool") {
|
|
4376
|
-
return null;
|
|
4377
|
-
}
|
|
4378
|
-
const status = event.status ?? event.type;
|
|
4379
|
-
const tool = event.toolName ?? "tool";
|
|
4380
|
-
const detail = event.text ? `\n${trimLine(event.text.replace(/\s+/g, " "), 180)}` : "";
|
|
4381
|
-
const plain = `CLI tool ${status}: ${tool}${detail}`;
|
|
4382
|
-
return {
|
|
4383
|
-
plain,
|
|
4384
|
-
html: `<b>CLI tool ${escapeHTML(status)}:</b> <code>${escapeHTML(tool)}</code>${detail ? `\n<code>${escapeHTML(detail.trim())}</code>` : ""}`,
|
|
4385
|
-
};
|
|
4386
|
-
}
|
|
4387
|
-
function renderActivityTimeline(threadId, events, options = { limit: 16, filter: "all", exportFile: false }) {
|
|
4388
|
-
if (events.length === 0) {
|
|
4389
|
-
return {
|
|
4390
|
-
plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo activity events found.`,
|
|
4391
|
-
html: `<b>Activity:</b>\n<b>Thread:</b> <code>${escapeHTML(threadId)}</code>\n<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>\n<code>No activity events found.</code>`,
|
|
4392
|
-
};
|
|
4393
|
-
}
|
|
4394
|
-
const lines = events.map((event) => {
|
|
4395
|
-
const time = event.timestamp ? event.timestamp.toISOString().slice(11, 19) : "--:--:--";
|
|
4396
|
-
const label = activityEventLabel(event);
|
|
4397
|
-
const detail = event.text ? ` · ${trimLine(event.text.replace(/\s+/g, " ").trim(), 120)}` : "";
|
|
4398
|
-
const tool = event.toolName ? ` · ${event.toolName}` : "";
|
|
4399
|
-
return `${time} · ${label}${tool}${detail}`;
|
|
4400
|
-
});
|
|
4401
|
-
return {
|
|
4402
|
-
plain: ["Activity:", `Thread: ${threadId}`, `Filter: ${options.filter}`, `Events: ${events.length}`, ...lines].join("\n"),
|
|
4403
|
-
html: [
|
|
4404
|
-
"<b>Activity:</b>",
|
|
4405
|
-
`<b>Thread:</b> <code>${escapeHTML(threadId)}</code>`,
|
|
4406
|
-
`<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>`,
|
|
4407
|
-
`<b>Events:</b> <code>${events.length}</code>`,
|
|
4408
|
-
...lines.map((line) => `<code>${escapeHTML(line)}</code>`),
|
|
4409
|
-
].join("\n"),
|
|
4410
|
-
};
|
|
4411
|
-
}
|
|
4412
|
-
function parseActivityOptions(argument) {
|
|
4413
|
-
const options = {
|
|
4414
|
-
limit: 16,
|
|
4415
|
-
filter: "all",
|
|
4416
|
-
exportFile: false,
|
|
4417
|
-
};
|
|
4418
|
-
const parts = argument.split(/\s+/).filter(Boolean);
|
|
4419
|
-
for (let index = 0; index < parts.length; index += 1) {
|
|
4420
|
-
const part = parts[index].toLowerCase();
|
|
4421
|
-
if (/^\d+$/.test(part)) {
|
|
4422
|
-
options.limit = Math.min(200, Math.max(1, Number(part)));
|
|
4423
|
-
continue;
|
|
4424
|
-
}
|
|
4425
|
-
if (part === "export") {
|
|
4426
|
-
options.exportFile = true;
|
|
4427
|
-
continue;
|
|
4428
|
-
}
|
|
4429
|
-
if (isActivityFilter(part)) {
|
|
4430
|
-
options.filter = part;
|
|
4431
|
-
continue;
|
|
4432
|
-
}
|
|
4433
|
-
if (part === "since" && parts[index + 1]) {
|
|
4434
|
-
options.sinceMs = parseDurationToMs(parts[index + 1]);
|
|
4435
|
-
index += 1;
|
|
4436
|
-
}
|
|
4437
|
-
}
|
|
4438
|
-
return options;
|
|
4439
|
-
}
|
|
4440
|
-
function filterActivityEvents(events, options) {
|
|
4441
|
-
const cutoff = options.sinceMs ? Date.now() - options.sinceMs : undefined;
|
|
4442
|
-
return events
|
|
4443
|
-
.filter((event) => {
|
|
4444
|
-
if (cutoff && event.timestamp && event.timestamp.getTime() < cutoff) {
|
|
4445
|
-
return false;
|
|
4446
|
-
}
|
|
4447
|
-
switch (options.filter) {
|
|
4448
|
-
case "tools":
|
|
4449
|
-
return event.kind === "tool";
|
|
4450
|
-
case "errors":
|
|
4451
|
-
return event.status === "failed" || event.status === "error" || /error|failed/i.test(event.text ?? "");
|
|
4452
|
-
case "user":
|
|
4453
|
-
return event.kind === "user";
|
|
4454
|
-
case "agent":
|
|
4455
|
-
return event.kind === "agent";
|
|
4456
|
-
case "tasks":
|
|
4457
|
-
return event.kind === "task";
|
|
4458
|
-
default:
|
|
4459
|
-
return true;
|
|
4460
|
-
}
|
|
4461
|
-
})
|
|
4462
|
-
.slice(-options.limit);
|
|
4463
|
-
}
|
|
4464
|
-
function isActivityFilter(value) {
|
|
4465
|
-
return value === "all" || value === "tools" || value === "errors" || value === "user" || value === "agent" || value === "tasks";
|
|
4466
|
-
}
|
|
4467
|
-
function formatAgentLaunchProfileLabel(profile, selected) {
|
|
4468
|
-
const prefix = selected ? "✅" : profile.unsafe ? "⚠️" : "🚀";
|
|
4469
|
-
return `${prefix} ${profile.label} · ${trimLine(profile.behavior, 24)}`;
|
|
4470
|
-
}
|
|
4471
|
-
function formatModelButtonLabel(model, selected) {
|
|
4472
|
-
const meta = [
|
|
4473
|
-
model.contextWindow ? formatCompactNumber(model.contextWindow) : undefined,
|
|
4474
|
-
model.supportsImages === true ? "img" : model.supportsImages === false ? "text" : undefined,
|
|
4475
|
-
model.supportsThinking === true ? "think" : undefined,
|
|
4476
|
-
].filter(Boolean).join(" ");
|
|
4477
|
-
return trimLine(`${selected ? "✅ " : ""}${model.displayName}${meta ? ` · ${meta}` : ""}`, 58);
|
|
4478
|
-
}
|
|
4479
|
-
function formatCompactNumber(value) {
|
|
4480
|
-
if (value >= 1_000_000_000)
|
|
4481
|
-
return `${Math.round(value / 100_000_000) / 10}B`;
|
|
4482
|
-
if (value >= 1_000_000)
|
|
4483
|
-
return `${Math.round(value / 100_000) / 10}M`;
|
|
4484
|
-
if (value >= 1_000)
|
|
4485
|
-
return `${Math.round(value / 100) / 10}K`;
|
|
4486
|
-
return String(value);
|
|
4487
|
-
}
|
|
4488
|
-
function renderAgentDiagnostics(diagnostics) {
|
|
4489
|
-
return {
|
|
4490
|
-
plain: [
|
|
4491
|
-
`${diagnostics.agentLabel} state:`,
|
|
4492
|
-
...diagnostics.lines.map((line) => `${line.label}: ${line.value}`),
|
|
4493
|
-
].join("\n"),
|
|
4494
|
-
html: [
|
|
4495
|
-
`<b>${escapeHTML(diagnostics.agentLabel)} state:</b>`,
|
|
4496
|
-
...diagnostics.lines.map((line) => `<b>${escapeHTML(line.label)}:</b> <code>${escapeHTML(line.value)}</code>`),
|
|
4497
|
-
].join("\n"),
|
|
4498
|
-
};
|
|
4499
|
-
}
|
|
4500
|
-
function activityEventLabel(event) {
|
|
4501
|
-
if (event.kind === "task") {
|
|
4502
|
-
return `task ${event.status ?? event.type}`;
|
|
4503
|
-
}
|
|
4504
|
-
if (event.kind === "user") {
|
|
4505
|
-
return "user";
|
|
4506
|
-
}
|
|
4507
|
-
if (event.kind === "agent") {
|
|
4508
|
-
return event.phase ? `agent ${event.phase}` : "agent";
|
|
4509
|
-
}
|
|
4510
|
-
return event.status ? `tool ${event.status}` : "tool";
|
|
4511
|
-
}
|
|
4512
|
-
function isEmptyArtifactReport(report) {
|
|
4513
|
-
return report.artifacts.length === 0 && report.skippedCount === 0 && !(report.omittedCount && report.omittedCount > 0);
|
|
4514
|
-
}
|
|
4515
|
-
function formatBusyFlags(state) {
|
|
4516
|
-
return Object.entries(state)
|
|
4517
|
-
.filter(([, enabled]) => enabled)
|
|
4518
|
-
.map(([name]) => name)
|
|
4519
|
-
.join(", ");
|
|
4520
|
-
}
|
|
4521
|
-
function renderDiagnosticsPlain(config, registry, health, authenticated, role, queueLength, progress, runtime) {
|
|
4522
|
-
const contexts = registry.listContexts();
|
|
4523
|
-
return [
|
|
4524
|
-
"Diagnostics:",
|
|
4525
|
-
`Status: ${health.state.status ?? "unknown"}`,
|
|
4526
|
-
`Version: ${health.version}`,
|
|
4527
|
-
`Role: ${role}`,
|
|
4528
|
-
`Auth: ${authenticated ? "yes" : "no"} (${health.state.authMethod ?? "-"})`,
|
|
4529
|
-
`PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
|
|
4530
|
-
`App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
|
|
4531
|
-
`Workspace: ${config.workspace}`,
|
|
4532
|
-
`State backend: ${config.stateBackend}`,
|
|
4533
|
-
`Telegram transport: ${config.telegramTransport}`,
|
|
4534
|
-
`Codex CLI: ${health.codexCli}`,
|
|
4535
|
-
`Pi CLI: ${health.piCli}`,
|
|
4536
|
-
`Hermes CLI: ${health.hermesCli}`,
|
|
4537
|
-
`OpenClaw CLI: ${health.openClawCli}`,
|
|
4538
|
-
`Claude Code CLI: ${health.claudeCodeCli}`,
|
|
4539
|
-
`Hermes API: ${config.hermesApiBaseUrl}`,
|
|
4540
|
-
`OpenClaw Gateway: ${config.openClawGatewayUrl}`,
|
|
4541
|
-
`Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
|
|
4542
|
-
`State DB: ${health.databasePath ?? "-"}`,
|
|
4543
|
-
`Log file: ${health.logFile}`,
|
|
4544
|
-
`Log format: ${config.logFormat}`,
|
|
4545
|
-
`Tool verbosity: ${config.toolVerbosity}`,
|
|
4546
|
-
`Telegram rate limit queued/running/retries/429: ${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}`,
|
|
4547
|
-
`Telegram last retry_after: ${runtime.rateLimit.lastRetryAfterSeconds ?? "-"}s`,
|
|
4548
|
-
`CLI mirror mode/update: ${runtime.mirrorMode} / ${config.telegramMirrorMinUpdateMs} ms`,
|
|
4549
|
-
`Notify/quiet: ${runtime.notifyMode} / ${runtime.quietHours}`,
|
|
4550
|
-
`Voice: ${runtime.voiceBackend} / ${runtime.voiceLanguage} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}`,
|
|
4551
|
-
`Sync interval: ${config.codexSyncIntervalMs} ms`,
|
|
4552
|
-
`External busy check/stale: ${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms`,
|
|
4553
|
-
`External mirrors/timers/status messages: ${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}`,
|
|
4554
|
-
`Auto-send artifacts: ${config.telegramAutoSendArtifacts ? "yes" : "no"}`,
|
|
4555
|
-
`Artifact ignore dirs/globs: ${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}`,
|
|
4556
|
-
`Artifact retention: ${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs`,
|
|
4557
|
-
`Workspace allowed/warn roots: ${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}`,
|
|
4558
|
-
`Allowed users/chats/admins/readonly: ${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}`,
|
|
4559
|
-
`Session lock TTL: ${config.sessionLockTtlMs} ms`,
|
|
4560
|
-
`Audit max events: ${config.auditMaxEvents}`,
|
|
4561
|
-
`Loaded sessions: ${contexts.length}`,
|
|
4562
|
-
`Current queue: ${queueLength}`,
|
|
4563
|
-
`Current progress: ${progress?.status ?? "idle"}`,
|
|
4564
|
-
].join("\n");
|
|
4565
|
-
}
|
|
4566
|
-
function renderDiagnosticsHTML(config, registry, health, authenticated, role, queueLength, progress, runtime) {
|
|
4567
|
-
const contexts = registry.listContexts();
|
|
4568
|
-
return [
|
|
4569
|
-
"<b>Diagnostics:</b>",
|
|
4570
|
-
`<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
|
|
4571
|
-
`<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
|
|
4572
|
-
`<b>Role:</b> <code>${escapeHTML(role)}</code>`,
|
|
4573
|
-
`<b>Auth:</b> <code>${authenticated ? "yes" : "no"} (${escapeHTML(health.state.authMethod ?? "-")})</code>`,
|
|
4574
|
-
`<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
|
|
4575
|
-
`<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
|
|
4576
|
-
`<b>Workspace:</b> <code>${escapeHTML(config.workspace)}</code>`,
|
|
4577
|
-
`<b>State backend:</b> <code>${escapeHTML(config.stateBackend)}</code>`,
|
|
4578
|
-
`<b>Telegram transport:</b> <code>${escapeHTML(config.telegramTransport)}</code>`,
|
|
4579
|
-
`<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
|
|
4580
|
-
`<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
|
|
4581
|
-
`<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
|
|
4582
|
-
`<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
|
|
4583
|
-
`<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
|
|
4584
|
-
`<b>Hermes API:</b> <code>${escapeHTML(config.hermesApiBaseUrl)}</code>`,
|
|
4585
|
-
`<b>OpenClaw Gateway:</b> <code>${escapeHTML(config.openClawGatewayUrl)}</code>`,
|
|
4586
|
-
`<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
|
|
4587
|
-
`<b>State DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
|
|
4588
|
-
`<b>Log file:</b> <code>${escapeHTML(health.logFile)}</code>`,
|
|
4589
|
-
`<b>Log format:</b> <code>${escapeHTML(config.logFormat)}</code>`,
|
|
4590
|
-
`<b>Tool verbosity:</b> <code>${escapeHTML(config.toolVerbosity)}</code>`,
|
|
4591
|
-
`<b>Telegram rate limit queued/running/retries/429:</b> <code>${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}</code>`,
|
|
4592
|
-
`<b>Telegram last retry_after:</b> <code>${escapeHTML(String(runtime.rateLimit.lastRetryAfterSeconds ?? "-"))}s</code>`,
|
|
4593
|
-
`<b>CLI mirror mode/update:</b> <code>${escapeHTML(runtime.mirrorMode)} / ${config.telegramMirrorMinUpdateMs} ms</code>`,
|
|
4594
|
-
`<b>Notify/quiet:</b> <code>${escapeHTML(runtime.notifyMode)} / ${escapeHTML(runtime.quietHours)}</code>`,
|
|
4595
|
-
`<b>Voice:</b> <code>${escapeHTML(runtime.voiceBackend)} / ${escapeHTML(runtime.voiceLanguage)} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}</code>`,
|
|
4596
|
-
`<b>Sync interval:</b> <code>${config.codexSyncIntervalMs} ms</code>`,
|
|
4597
|
-
`<b>External busy check/stale:</b> <code>${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms</code>`,
|
|
4598
|
-
`<b>External mirrors/timers/status messages:</b> <code>${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}</code>`,
|
|
4599
|
-
`<b>Auto-send artifacts:</b> <code>${config.telegramAutoSendArtifacts ? "yes" : "no"}</code>`,
|
|
4600
|
-
`<b>Artifact ignore dirs/globs:</b> <code>${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}</code>`,
|
|
4601
|
-
`<b>Artifact retention:</b> <code>${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs</code>`,
|
|
4602
|
-
`<b>Workspace allowed/warn roots:</b> <code>${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}</code>`,
|
|
4603
|
-
`<b>Allowed users/chats/admins/readonly:</b> <code>${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}</code>`,
|
|
4604
|
-
`<b>Session lock TTL:</b> <code>${config.sessionLockTtlMs} ms</code>`,
|
|
4605
|
-
`<b>Audit max events:</b> <code>${config.auditMaxEvents}</code>`,
|
|
4606
|
-
`<b>Loaded sessions:</b> <code>${contexts.length}</code>`,
|
|
4607
|
-
`<b>Current queue:</b> <code>${queueLength}</code>`,
|
|
4608
|
-
`<b>Current progress:</b> <code>${escapeHTML(progress?.status ?? "idle")}</code>`,
|
|
4609
|
-
].join("\n");
|
|
4610
|
-
}
|
|
4611
|
-
function renderHealthPlain(health, authenticated, role) {
|
|
4612
|
-
return [
|
|
4613
|
-
`Status: ${health.state.status ?? "unknown"}`,
|
|
4614
|
-
`Version: ${health.version}`,
|
|
4615
|
-
`Role: ${role}`,
|
|
4616
|
-
`Auth: ${authenticated ? "yes" : "no"}`,
|
|
4617
|
-
`PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
|
|
4618
|
-
`App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
|
|
4619
|
-
`Uptime: ${formatDuration(health.uptimeSeconds)}`,
|
|
4620
|
-
`Workspace: ${health.state.workspace ?? "-"}`,
|
|
4621
|
-
`Codex CLI: ${health.codexCli}`,
|
|
4622
|
-
`Pi CLI: ${health.piCli}`,
|
|
4623
|
-
`Hermes CLI: ${health.hermesCli}`,
|
|
4624
|
-
`OpenClaw CLI: ${health.openClawCli}`,
|
|
4625
|
-
`Claude Code CLI: ${health.claudeCodeCli}`,
|
|
4626
|
-
`Codex state DB: ${health.databasePath ?? "-"}`,
|
|
4627
|
-
`Log: ${health.logFile}`,
|
|
4628
|
-
].join("\n");
|
|
4629
|
-
}
|
|
4630
|
-
function renderHealthHTML(health, authenticated, role) {
|
|
4631
|
-
return [
|
|
4632
|
-
`<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
|
|
4633
|
-
`<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
|
|
4634
|
-
`<b>Role:</b> <code>${escapeHTML(role)}</code>`,
|
|
4635
|
-
`<b>Auth:</b> <code>${authenticated ? "yes" : "no"}</code>`,
|
|
4636
|
-
`<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
|
|
4637
|
-
`<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
|
|
4638
|
-
`<b>Uptime:</b> <code>${escapeHTML(formatDuration(health.uptimeSeconds))}</code>`,
|
|
4639
|
-
`<b>Workspace:</b> <code>${escapeHTML(health.state.workspace ?? "-")}</code>`,
|
|
4640
|
-
`<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
|
|
4641
|
-
`<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
|
|
4642
|
-
`<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
|
|
4643
|
-
`<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
|
|
4644
|
-
`<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
|
|
4645
|
-
`<b>Codex state DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
|
|
4646
|
-
`<b>Log:</b> <code>${escapeHTML(health.logFile)}</code>`,
|
|
4647
|
-
].join("\n");
|
|
4648
|
-
}
|
|
4649
|
-
function parseFastModeArgument(argument, currentValue) {
|
|
4650
|
-
if (!argument) {
|
|
4651
|
-
return !currentValue;
|
|
4652
|
-
}
|
|
4653
|
-
const normalized = argument.toLowerCase();
|
|
4654
|
-
if (["on", "enable", "enabled", "true", "1"].includes(normalized)) {
|
|
4655
|
-
return true;
|
|
4656
|
-
}
|
|
4657
|
-
if (["off", "disable", "disabled", "false", "0"].includes(normalized)) {
|
|
4658
|
-
return false;
|
|
4659
|
-
}
|
|
4660
|
-
return undefined;
|
|
4661
|
-
}
|
|
4662
|
-
function parseToggle(argument) {
|
|
4663
|
-
const normalized = argument.trim().toLowerCase();
|
|
4664
|
-
if (["on", "enable", "enabled", "true", "1", "yes"].includes(normalized)) {
|
|
4665
|
-
return true;
|
|
4666
|
-
}
|
|
4667
|
-
if (["off", "disable", "disabled", "false", "0", "no"].includes(normalized)) {
|
|
4668
|
-
return false;
|
|
4669
|
-
}
|
|
4670
|
-
return undefined;
|
|
4671
|
-
}
|
|
4672
|
-
function parseDurationToMs(value) {
|
|
4673
|
-
const match = value.trim().match(/^(\d+)(s|m|h|d)?$/i);
|
|
4674
|
-
if (!match) {
|
|
4675
|
-
return undefined;
|
|
4676
|
-
}
|
|
4677
|
-
const amount = Number(match[1]);
|
|
4678
|
-
const unit = (match[2] ?? "m").toLowerCase();
|
|
4679
|
-
const multiplier = unit === "s"
|
|
4680
|
-
? 1000
|
|
4681
|
-
: unit === "h"
|
|
4682
|
-
? 60 * 60 * 1000
|
|
4683
|
-
: unit === "d"
|
|
4684
|
-
? 24 * 60 * 60 * 1000
|
|
4685
|
-
: 60 * 1000;
|
|
4686
|
-
return amount * multiplier;
|
|
4687
|
-
}
|
|
4688
|
-
function extractCommandName(text) {
|
|
4689
|
-
const match = text.trim().match(/^\/([a-zA-Z0-9_-]+)(?:@\w+)?(?:\s|$)/);
|
|
4690
|
-
return match?.[1]?.toLowerCase();
|
|
4691
|
-
}
|
|
4692
|
-
function isPromptEnvelopeLike(value) {
|
|
4693
|
-
return typeof value === "object" && value !== null && "input" in value && "description" in value;
|
|
4694
|
-
}
|
|
4695
|
-
function isQueuedPromptLike(value) {
|
|
4696
|
-
return "id" in value &&
|
|
4697
|
-
"contextKey" in value &&
|
|
4698
|
-
"createdAt" in value &&
|
|
4699
|
-
typeof value.id === "string" &&
|
|
4700
|
-
typeof value.contextKey === "string" &&
|
|
4701
|
-
typeof value.createdAt === "number";
|
|
4702
|
-
}
|
|
4703
|
-
function capabilitiesOf(info) {
|
|
4704
|
-
return info.capabilities ?? CODEX_AGENT_CAPABILITIES;
|
|
4705
|
-
}
|
|
4706
|
-
function labelOf(info) {
|
|
4707
|
-
return info.agentLabel ?? agentLabel(info.agentId ?? "codex");
|
|
4708
|
-
}
|
|
4709
|
-
function idOf(info) {
|
|
4710
|
-
return info.agentId ?? "codex";
|
|
4711
|
-
}
|
|
4712
|
-
function authHelpText(info) {
|
|
4713
|
-
const agentId = idOf(info);
|
|
4714
|
-
if (agentId === "pi") {
|
|
4715
|
-
return "Configure the required Pi provider environment variable on the host.";
|
|
4716
|
-
}
|
|
4717
|
-
if (agentId === "hermes") {
|
|
4718
|
-
return "Start the Hermes API Server, configure HERMES_API_KEY when required, or use /login to start Hermes CLI auth.";
|
|
4719
|
-
}
|
|
4720
|
-
if (agentId === "openclaw") {
|
|
4721
|
-
return "Start the OpenClaw Gateway and configure OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD when the gateway requires one.";
|
|
4722
|
-
}
|
|
4723
|
-
if (agentId === "claude-code") {
|
|
4724
|
-
return "Use /login to start Claude Code CLI auth, or run 'claude auth login' on the host.";
|
|
4725
|
-
}
|
|
4726
|
-
return "Use /login to start authentication, or set CODEX_API_KEY on the host.";
|
|
4727
|
-
}
|
|
4728
|
-
function formatAgentSettingScope(info, appliedToActiveThread) {
|
|
4729
|
-
const agentId = idOf(info);
|
|
4730
|
-
if (agentId === "hermes") {
|
|
4731
|
-
return appliedToActiveThread
|
|
4732
|
-
? "applies to the next Hermes run in this session"
|
|
4733
|
-
: "applies to new Hermes sessions";
|
|
4734
|
-
}
|
|
4735
|
-
if (agentId === "pi") {
|
|
4736
|
-
return appliedToActiveThread
|
|
4737
|
-
? "applied to the current idle Pi session and future turns"
|
|
4738
|
-
: "applies to new Pi sessions";
|
|
4739
|
-
}
|
|
4740
|
-
if (agentId === "openclaw") {
|
|
4741
|
-
return appliedToActiveThread
|
|
4742
|
-
? "applies to the next OpenClaw run in this session"
|
|
4743
|
-
: "applies to new OpenClaw sessions";
|
|
4744
|
-
}
|
|
4745
|
-
if (agentId === "claude-code") {
|
|
4746
|
-
return appliedToActiveThread
|
|
4747
|
-
? "applies to the next Claude Code run in this session"
|
|
4748
|
-
: "applies to new Claude Code sessions";
|
|
4749
|
-
}
|
|
4750
|
-
return appliedToActiveThread
|
|
4751
|
-
? "applied to the current idle thread and future threads"
|
|
4752
|
-
: "applies to new threads";
|
|
4753
|
-
}
|
|
4754
|
-
function requiresTurnApproval(info) {
|
|
4755
|
-
return info.unsafeLaunch || info.approvalPolicy !== "never";
|
|
4756
|
-
}
|
|
4757
|
-
function formatDuration(totalSeconds) {
|
|
4758
|
-
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
4759
|
-
const days = Math.floor(seconds / 86400);
|
|
4760
|
-
const hours = Math.floor((seconds % 86400) / 3600);
|
|
4761
|
-
const minutes = Math.floor((seconds % 3600) / 60);
|
|
4762
|
-
if (days > 0) {
|
|
4763
|
-
return `${days}d ${hours}h`;
|
|
4764
|
-
}
|
|
4765
|
-
if (hours > 0) {
|
|
4766
|
-
return `${hours}h ${minutes}m`;
|
|
4767
|
-
}
|
|
4768
|
-
return `${minutes}m`;
|
|
4769
|
-
}
|
|
4770
|
-
function formatDurationSeconds(totalSeconds) {
|
|
4771
|
-
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
4772
|
-
if (seconds < 60) {
|
|
4773
|
-
return `${seconds}s`;
|
|
4774
|
-
}
|
|
4775
|
-
const minutes = Math.floor(seconds / 60);
|
|
4776
|
-
const remainingSeconds = seconds % 60;
|
|
4777
|
-
if (minutes < 60) {
|
|
4778
|
-
return `${minutes}m ${remainingSeconds}s`;
|
|
4779
|
-
}
|
|
4780
|
-
const hours = Math.floor(minutes / 60);
|
|
4781
|
-
return `${hours}h ${minutes % 60}m`;
|
|
4782
|
-
}
|
|
4783
|
-
function renderToolStartMessage(toolName) {
|
|
4784
|
-
return {
|
|
4785
|
-
text: `<b>🔧 Running:</b> <code>${escapeHTML(toolName)}</code>`,
|
|
4786
|
-
fallbackText: `🔧 Running: ${toolName}`,
|
|
4787
|
-
parseMode: "HTML",
|
|
4788
|
-
};
|
|
4789
|
-
}
|
|
4790
|
-
function renderToolEndMessage(toolName, partialResult, isError) {
|
|
4791
|
-
const preview = summarizeToolOutput(partialResult);
|
|
4792
|
-
const icon = isError ? "❌" : "✅";
|
|
4793
|
-
const htmlLines = [`<b>${icon}</b> <code>${escapeHTML(toolName)}</code>`];
|
|
4794
|
-
const plainLines = [`${icon} ${toolName}`];
|
|
4795
|
-
if (preview) {
|
|
4796
|
-
htmlLines.push(`<pre>${escapeHTML(preview)}</pre>`);
|
|
4797
|
-
plainLines.push(preview);
|
|
4798
|
-
}
|
|
4799
|
-
return {
|
|
4800
|
-
text: htmlLines.join("\n"),
|
|
4801
|
-
fallbackText: plainLines.join("\n"),
|
|
4802
|
-
parseMode: "HTML",
|
|
4803
|
-
};
|
|
4804
|
-
}
|
|
4805
|
-
export function formatToolSummaryLine(toolCounts) {
|
|
4806
|
-
if (toolCounts.size === 0) {
|
|
4807
|
-
return "";
|
|
4808
|
-
}
|
|
4809
|
-
const summarizedCounts = new Map();
|
|
4810
|
-
for (const [toolName, count] of toolCounts.entries()) {
|
|
4811
|
-
const summaryName = summarizeToolName(toolName);
|
|
4812
|
-
summarizedCounts.set(summaryName, (summarizedCounts.get(summaryName) ?? 0) + count);
|
|
4813
|
-
}
|
|
4814
|
-
const entries = [...summarizedCounts.entries()].sort((left, right) => {
|
|
4815
|
-
const countDelta = right[1] - left[1];
|
|
4816
|
-
return countDelta !== 0 ? countDelta : left[0].localeCompare(right[0]);
|
|
4817
|
-
});
|
|
4818
|
-
const tools = entries
|
|
4819
|
-
.map(([name, count]) => formatSummaryEntry(name, count))
|
|
4820
|
-
.join(", ");
|
|
4821
|
-
return `Tools used: ${tools}`;
|
|
4822
|
-
}
|
|
4823
|
-
function renderTodoList(items) {
|
|
4824
|
-
const lines = items.map((item) => {
|
|
4825
|
-
const icon = item.completed ? "✅" : "⬜";
|
|
4826
|
-
return `${icon} ${escapeHTML(item.text)}`;
|
|
4827
|
-
});
|
|
4828
|
-
return `📋 <b>Plan</b>\n${lines.join("\n")}`;
|
|
4829
|
-
}
|
|
4830
|
-
export function formatTurnUsageLine(usage) {
|
|
4831
|
-
return `🪙 in: ${usage.inputTokens} · cached: ${usage.cachedInputTokens} · out: ${usage.outputTokens}`;
|
|
4832
|
-
}
|
|
4833
|
-
export function summarizeToolName(toolName) {
|
|
4834
|
-
if (toolName.startsWith("🔍 ")) {
|
|
4835
|
-
return "web_fetch";
|
|
4836
|
-
}
|
|
4837
|
-
if (toolName === "file_change") {
|
|
4838
|
-
return "file_change";
|
|
4839
|
-
}
|
|
4840
|
-
if (toolName === "⚠️ error") {
|
|
4841
|
-
return "error";
|
|
4842
|
-
}
|
|
4843
|
-
if (toolName.startsWith("mcp:")) {
|
|
4844
|
-
const tool = toolName.split("/").at(-1) ?? toolName;
|
|
4845
|
-
if (SUBAGENT_TOOL_NAMES.has(tool)) {
|
|
4846
|
-
return "subagent";
|
|
4847
|
-
}
|
|
4848
|
-
return tool;
|
|
4849
|
-
}
|
|
4850
|
-
return "bash";
|
|
4851
|
-
}
|
|
4852
|
-
function formatSummaryEntry(name, count) {
|
|
4853
|
-
if (count <= 1) {
|
|
4854
|
-
return name;
|
|
4855
|
-
}
|
|
4856
|
-
const label = name === "subagent" ? "subagents" : name;
|
|
4857
|
-
return `${count}x ${label}`;
|
|
4858
|
-
}
|
|
4859
|
-
const SUBAGENT_TOOL_NAMES = new Set(["spawn_agent", "send_input", "wait_agent", "close_agent", "resume_agent"]);
|
|
4860
|
-
async function safeReply(ctx, text, options = {}) {
|
|
4861
|
-
const chatId = ctx.chat?.id;
|
|
4862
|
-
if (!chatId) {
|
|
4863
|
-
return;
|
|
4864
|
-
}
|
|
4865
|
-
const parseMode = options.parseMode !== undefined ? options.parseMode : "HTML";
|
|
4866
|
-
const messageThreadId = options.messageThreadId ?? ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id;
|
|
4867
|
-
const chunks = splitTelegramText(redactText(text));
|
|
4868
|
-
const fallbackChunks = options.fallbackText ? splitTelegramText(redactText(options.fallbackText)) : [];
|
|
4869
|
-
for (const [index, chunk] of chunks.entries()) {
|
|
4870
|
-
await sendTextMessage(ctx.api, chatId, chunk, {
|
|
4871
|
-
parseMode,
|
|
4872
|
-
fallbackText: fallbackChunks[index] ?? chunk,
|
|
4873
|
-
replyMarkup: index === 0 ? options.replyMarkup : undefined,
|
|
4874
|
-
messageThreadId,
|
|
4875
|
-
});
|
|
4876
|
-
}
|
|
4877
|
-
}
|
|
4878
|
-
async function sendTextMessage(api, chatId, text, options = {}) {
|
|
4879
|
-
const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
|
|
4880
|
-
const safeText = redactText(text);
|
|
4881
|
-
const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
|
|
4882
|
-
const bucket = chatBucket(chatId);
|
|
4883
|
-
try {
|
|
4884
|
-
return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeText, {
|
|
4885
|
-
...(parseMode ? { parse_mode: parseMode } : {}),
|
|
4886
|
-
...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
|
|
4887
|
-
reply_markup: options.replyMarkup,
|
|
4888
|
-
}));
|
|
4889
|
-
}
|
|
4890
|
-
catch (error) {
|
|
4891
|
-
if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
|
|
4892
|
-
return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeFallbackText, {
|
|
4893
|
-
...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
|
|
4894
|
-
reply_markup: options.replyMarkup,
|
|
4895
|
-
}));
|
|
4896
|
-
}
|
|
4897
|
-
throw error;
|
|
4898
|
-
}
|
|
4899
|
-
}
|
|
4900
|
-
async function safeEditMessage(bot, chatId, messageId, text, options = {}) {
|
|
4901
|
-
const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
|
|
4902
|
-
const safeText = redactText(text);
|
|
4903
|
-
const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
|
|
4904
|
-
const bucket = `${chatBucket(chatId)}:${messageId}`;
|
|
4905
|
-
try {
|
|
4906
|
-
await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeText, {
|
|
4907
|
-
...(parseMode ? { parse_mode: parseMode } : {}),
|
|
4908
|
-
reply_markup: options.replyMarkup,
|
|
4909
|
-
}));
|
|
4910
|
-
}
|
|
4911
|
-
catch (error) {
|
|
4912
|
-
if (isMessageNotModifiedError(error)) {
|
|
4913
|
-
return;
|
|
4914
|
-
}
|
|
4915
|
-
if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
|
|
4916
|
-
await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeFallbackText, {
|
|
4917
|
-
reply_markup: options.replyMarkup,
|
|
4918
|
-
}));
|
|
4919
|
-
return;
|
|
4920
|
-
}
|
|
4921
|
-
throw error;
|
|
4922
|
-
}
|
|
4923
|
-
}
|
|
4924
|
-
async function safeEditReplyMarkup(bot, chatId, messageId, replyMarkup) {
|
|
4925
|
-
try {
|
|
4926
|
-
await telegramRateLimiter.run(`${chatBucket(chatId)}:${messageId}`, "editMessageReplyMarkup", () => bot.api.editMessageReplyMarkup(chatId, messageId, {
|
|
4927
|
-
reply_markup: replyMarkup ?? new InlineKeyboard(),
|
|
4928
|
-
}));
|
|
4929
|
-
}
|
|
4930
|
-
catch (error) {
|
|
4931
|
-
if (!isMessageNotModifiedError(error)) {
|
|
4932
|
-
throw error;
|
|
4933
|
-
}
|
|
4934
|
-
}
|
|
4935
|
-
}
|
|
4936
|
-
async function sendChatActionSafe(api, chatId, action, messageThreadId) {
|
|
4937
|
-
await telegramRateLimiter.run(chatBucket(chatId), "sendChatAction", () => api.sendChatAction(chatId, action, {
|
|
4938
|
-
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
|
|
4939
|
-
}));
|
|
4940
|
-
}
|
|
4941
|
-
function chatBucket(chatId) {
|
|
4942
|
-
return `chat:${String(chatId)}`;
|
|
4943
|
-
}
|
|
4944
|
-
async function downloadTelegramFile(api, token, fileId, maxBytes = MAX_AUDIO_FILE_SIZE) {
|
|
4945
|
-
const file = await api.getFile(fileId);
|
|
4946
|
-
if (!file.file_path) {
|
|
4947
|
-
throw new Error("Telegram did not return a file path");
|
|
4948
|
-
}
|
|
4949
|
-
if (file.file_size && file.file_size > maxBytes) {
|
|
4950
|
-
throw new Error(`Telegram file too large (${Math.round(file.file_size / 1024 / 1024)} MB, max ${Math.round(maxBytes / 1024 / 1024)} MB)`);
|
|
4951
|
-
}
|
|
4952
|
-
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
|
4953
|
-
const response = await fetch(url);
|
|
4954
|
-
if (!response.ok) {
|
|
4955
|
-
throw new Error(`Failed to download Telegram file: ${response.status}`);
|
|
4956
|
-
}
|
|
4957
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
4958
|
-
const extension = path.extname(file.file_path) || ".bin";
|
|
4959
|
-
const tempPath = path.join(tmpdir(), `nordrelay-file-${randomUUID()}${extension}`);
|
|
4960
|
-
await writeFile(tempPath, buffer);
|
|
4961
|
-
return tempPath;
|
|
4962
|
-
}
|
|
4963
|
-
function splitTelegramText(text) {
|
|
4964
|
-
if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
|
|
4965
|
-
return [text];
|
|
4966
|
-
}
|
|
4967
|
-
const chunks = [];
|
|
4968
|
-
let remaining = text;
|
|
4969
|
-
while (remaining.length > TELEGRAM_MESSAGE_LIMIT) {
|
|
4970
|
-
let cut = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
|
|
4971
|
-
if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
|
|
4972
|
-
cut = remaining.lastIndexOf(" ", TELEGRAM_MESSAGE_LIMIT);
|
|
4973
|
-
}
|
|
4974
|
-
if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
|
|
4975
|
-
cut = TELEGRAM_MESSAGE_LIMIT;
|
|
4976
|
-
}
|
|
4977
|
-
chunks.push(remaining.slice(0, cut).trimEnd());
|
|
4978
|
-
remaining = remaining.slice(cut).trimStart();
|
|
4979
|
-
}
|
|
4980
|
-
if (remaining) {
|
|
4981
|
-
chunks.push(remaining);
|
|
4982
|
-
}
|
|
4983
|
-
return chunks.length > 0 ? chunks : [""];
|
|
4984
|
-
}
|
|
4985
|
-
function splitMarkdownForTelegram(markdown) {
|
|
4986
|
-
if (!markdown) {
|
|
4987
|
-
return [];
|
|
4988
|
-
}
|
|
4989
|
-
const chunks = [];
|
|
4990
|
-
let remaining = markdown;
|
|
4991
|
-
while (remaining) {
|
|
4992
|
-
const maxLength = Math.min(remaining.length, FORMATTED_CHUNK_TARGET);
|
|
4993
|
-
const initialCut = findPreferredSplitIndex(remaining, maxLength);
|
|
4994
|
-
const candidate = remaining.slice(0, initialCut) || remaining.slice(0, 1);
|
|
4995
|
-
const rendered = renderMarkdownChunkWithinLimit(candidate);
|
|
4996
|
-
chunks.push(rendered);
|
|
4997
|
-
remaining = remaining.slice(rendered.sourceText.length).trimStart();
|
|
4998
|
-
}
|
|
4999
|
-
return chunks;
|
|
5000
|
-
}
|
|
5001
|
-
function renderMarkdownChunkWithinLimit(markdown) {
|
|
5002
|
-
if (!markdown) {
|
|
5003
|
-
return {
|
|
5004
|
-
text: "",
|
|
5005
|
-
fallbackText: "",
|
|
5006
|
-
parseMode: "HTML",
|
|
5007
|
-
sourceText: "",
|
|
5008
|
-
};
|
|
5009
|
-
}
|
|
5010
|
-
let sourceText = markdown;
|
|
5011
|
-
let rendered = formatMarkdownMessage(sourceText);
|
|
5012
|
-
while (rendered.text.length > TELEGRAM_MESSAGE_LIMIT && sourceText.length > 1) {
|
|
5013
|
-
const nextLength = Math.max(1, sourceText.length - Math.max(100, Math.ceil(sourceText.length * 0.1)));
|
|
5014
|
-
sourceText = sourceText.slice(0, nextLength).trimEnd() || sourceText.slice(0, nextLength);
|
|
5015
|
-
rendered = formatMarkdownMessage(sourceText);
|
|
5016
|
-
}
|
|
5017
|
-
return {
|
|
5018
|
-
...rendered,
|
|
5019
|
-
sourceText,
|
|
5020
|
-
};
|
|
5021
|
-
}
|
|
5022
|
-
function formatMarkdownMessage(markdown) {
|
|
5023
|
-
try {
|
|
5024
|
-
return {
|
|
5025
|
-
text: formatTelegramHTML(markdown),
|
|
5026
|
-
fallbackText: markdown,
|
|
5027
|
-
parseMode: "HTML",
|
|
5028
|
-
};
|
|
5029
|
-
}
|
|
5030
|
-
catch (error) {
|
|
5031
|
-
console.error("Failed to format Telegram HTML, falling back to plain text", error);
|
|
5032
|
-
return {
|
|
5033
|
-
text: markdown,
|
|
5034
|
-
fallbackText: markdown,
|
|
5035
|
-
parseMode: undefined,
|
|
5036
|
-
};
|
|
5037
|
-
}
|
|
5038
|
-
}
|
|
5039
|
-
function findPreferredSplitIndex(text, maxLength) {
|
|
5040
|
-
if (text.length <= maxLength) {
|
|
5041
|
-
return Math.max(1, text.length);
|
|
5042
|
-
}
|
|
5043
|
-
const newlineIndex = text.lastIndexOf("\n", maxLength);
|
|
5044
|
-
if (newlineIndex >= maxLength * 0.5) {
|
|
5045
|
-
return Math.max(1, newlineIndex);
|
|
5046
|
-
}
|
|
5047
|
-
const spaceIndex = text.lastIndexOf(" ", maxLength);
|
|
5048
|
-
if (spaceIndex >= maxLength * 0.5) {
|
|
5049
|
-
return Math.max(1, spaceIndex);
|
|
5050
|
-
}
|
|
5051
|
-
return Math.max(1, maxLength);
|
|
5052
|
-
}
|
|
5053
|
-
function buildStreamingPreview(text) {
|
|
5054
|
-
if (text.length <= STREAMING_PREVIEW_LIMIT) {
|
|
5055
|
-
return text;
|
|
5056
|
-
}
|
|
5057
|
-
return `${text.slice(0, STREAMING_PREVIEW_LIMIT)}\n\n… streaming (preview truncated)`;
|
|
5058
|
-
}
|
|
5059
|
-
function appendWithCap(base, addition, cap) {
|
|
5060
|
-
const combined = `${base}${addition}`;
|
|
5061
|
-
return combined.length <= cap ? combined : combined.slice(-cap);
|
|
5062
|
-
}
|
|
5063
|
-
function summarizeToolOutput(text) {
|
|
5064
|
-
const trimmed = text.trim();
|
|
5065
|
-
if (!trimmed) {
|
|
5066
|
-
return "";
|
|
5067
|
-
}
|
|
5068
|
-
return trimmed.length <= TOOL_OUTPUT_PREVIEW_LIMIT ? trimmed : `${trimmed.slice(-TOOL_OUTPUT_PREVIEW_LIMIT)}\n…`;
|
|
5069
|
-
}
|
|
5070
|
-
function trimLine(text, maxLength) {
|
|
5071
|
-
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
5072
|
-
if (singleLine.length <= maxLength) {
|
|
5073
|
-
return singleLine;
|
|
5074
|
-
}
|
|
5075
|
-
return `${singleLine.slice(0, maxLength - 1)}…`;
|
|
5076
|
-
}
|
|
5077
|
-
function getWorkspaceShortName(workspace) {
|
|
5078
|
-
return workspace.split(/[\\/]/).filter(Boolean).pop() ?? workspace;
|
|
5079
|
-
}
|
|
5080
|
-
function formatRelativeTime(date) {
|
|
5081
|
-
const deltaMs = Date.now() - date.getTime();
|
|
5082
|
-
const deltaSeconds = Math.max(0, Math.floor(deltaMs / 1000));
|
|
5083
|
-
if (deltaSeconds < 60) {
|
|
5084
|
-
return "just now";
|
|
5085
|
-
}
|
|
5086
|
-
const deltaMinutes = Math.floor(deltaSeconds / 60);
|
|
5087
|
-
if (deltaMinutes < 60) {
|
|
5088
|
-
return `${deltaMinutes}m ago`;
|
|
5089
|
-
}
|
|
5090
|
-
const deltaHours = Math.floor(deltaMinutes / 60);
|
|
5091
|
-
if (deltaHours < 48) {
|
|
5092
|
-
return `${deltaHours}h ago`;
|
|
5093
|
-
}
|
|
5094
|
-
const deltaDays = Math.floor(deltaHours / 24);
|
|
5095
|
-
if (deltaDays < 14) {
|
|
5096
|
-
return `${deltaDays}d ago`;
|
|
5097
|
-
}
|
|
5098
|
-
const deltaWeeks = Math.floor(deltaDays / 7);
|
|
5099
|
-
return `${deltaWeeks}w ago`;
|
|
5100
|
-
}
|
|
5101
|
-
function filterSessions(sessions, query) {
|
|
5102
|
-
const normalized = query.trim().toLowerCase();
|
|
5103
|
-
if (!normalized) {
|
|
5104
|
-
return sessions;
|
|
5105
|
-
}
|
|
5106
|
-
return sessions.filter((session) => [
|
|
5107
|
-
session.id,
|
|
5108
|
-
session.title ?? "",
|
|
5109
|
-
session.cwd,
|
|
5110
|
-
session.model ?? "",
|
|
5111
|
-
session.firstUserMessage ?? "",
|
|
5112
|
-
].some((value) => value.toLowerCase().includes(normalized)));
|
|
5113
|
-
}
|
|
5114
|
-
function orderPinnedSessions(sessions, pinnedThreadIds) {
|
|
5115
|
-
const pinnedIndex = new Map(pinnedThreadIds.map((threadId, index) => [threadId, index]));
|
|
5116
|
-
return [...sessions].sort((left, right) => {
|
|
5117
|
-
const leftPinned = pinnedIndex.get(left.id);
|
|
5118
|
-
const rightPinned = pinnedIndex.get(right.id);
|
|
5119
|
-
if (leftPinned !== undefined && rightPinned !== undefined) {
|
|
5120
|
-
return leftPinned - rightPinned;
|
|
5121
|
-
}
|
|
5122
|
-
if (leftPinned !== undefined) {
|
|
5123
|
-
return -1;
|
|
5124
|
-
}
|
|
5125
|
-
if (rightPinned !== undefined) {
|
|
5126
|
-
return 1;
|
|
5127
|
-
}
|
|
5128
|
-
return 0;
|
|
5129
|
-
});
|
|
5130
|
-
}
|
|
5131
|
-
function isMessageNotModifiedError(error) {
|
|
5132
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
5133
|
-
return message.includes("message is not modified");
|
|
5134
|
-
}
|
|
5135
|
-
function isTelegramParseError(error) {
|
|
5136
|
-
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
5137
|
-
return (message.includes("can't parse entities") ||
|
|
5138
|
-
message.includes("unsupported start tag") ||
|
|
5139
|
-
message.includes("unexpected end tag") ||
|
|
5140
|
-
message.includes("entity name") ||
|
|
5141
|
-
message.includes("parse entities"));
|
|
5142
|
-
}
|
|
5143
|
-
function renderPromptFailure(accumulatedText, error) {
|
|
5144
|
-
const message = friendlyErrorText(error);
|
|
5145
|
-
return accumulatedText.trim() ? `${accumulatedText.trim()}\n\n⚠️ ${message}` : `⚠️ ${message}`;
|
|
5146
|
-
}
|
|
5147
|
-
function formatError(error) {
|
|
5148
|
-
return error instanceof Error ? error.message : String(error);
|
|
5149
|
-
}
|