@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.
Files changed (57) hide show
  1. package/.env.example +155 -64
  2. package/README.md +81 -65
  3. package/dist/access-control.js +126 -115
  4. package/dist/agent-updates.js +62 -9
  5. package/dist/bot-rendering.js +838 -0
  6. package/dist/bot-ui.js +1 -0
  7. package/dist/bot.js +342 -2498
  8. package/dist/channel-actions.js +8 -8
  9. package/dist/channel-runtime.js +89 -0
  10. package/dist/config-metadata.js +238 -0
  11. package/dist/config.js +0 -58
  12. package/dist/index.js +8 -0
  13. package/dist/operations.js +63 -9
  14. package/dist/relay-artifact-service.js +126 -0
  15. package/dist/relay-external-activity-monitor.js +216 -0
  16. package/dist/relay-queue-service.js +66 -0
  17. package/dist/relay-runtime-types.js +1 -0
  18. package/dist/relay-runtime.js +96 -354
  19. package/dist/settings-service.js +2 -117
  20. package/dist/support-bundle.js +205 -0
  21. package/dist/telegram-access-commands.js +123 -0
  22. package/dist/telegram-access-middleware.js +129 -0
  23. package/dist/telegram-agent-commands.js +212 -0
  24. package/dist/telegram-artifact-commands.js +139 -0
  25. package/dist/telegram-channel-runtime.js +132 -0
  26. package/dist/telegram-command-menu.js +55 -0
  27. package/dist/telegram-command-types.js +1 -0
  28. package/dist/telegram-diagnostics-command.js +102 -0
  29. package/dist/telegram-general-commands.js +52 -0
  30. package/dist/telegram-operational-commands.js +153 -0
  31. package/dist/telegram-output.js +216 -0
  32. package/dist/telegram-preference-commands.js +198 -0
  33. package/dist/telegram-queue-commands.js +278 -0
  34. package/dist/telegram-support-command.js +53 -0
  35. package/dist/telegram-update-commands.js +93 -0
  36. package/dist/user-management.js +708 -0
  37. package/dist/web-api-contract.js +104 -0
  38. package/dist/web-api-types.js +1 -0
  39. package/dist/web-dashboard-access-routes.js +163 -0
  40. package/dist/web-dashboard-artifact-routes.js +65 -0
  41. package/dist/web-dashboard-assets.js +35 -2
  42. package/dist/web-dashboard-http.js +143 -0
  43. package/dist/web-dashboard-pages.js +257 -0
  44. package/dist/web-dashboard-runtime-routes.js +92 -0
  45. package/dist/web-dashboard-session-routes.js +209 -0
  46. package/dist/web-dashboard-ui.js +14 -14
  47. package/dist/web-dashboard.js +330 -707
  48. package/dist/webui-assets/dashboard.css +989 -0
  49. package/dist/webui-assets/dashboard.js +1750 -0
  50. package/dist/zip-writer.js +83 -0
  51. package/package.json +13 -4
  52. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  53. package/plugins/nordrelay/commands/remote.md +1 -1
  54. package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
  55. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
  56. package/dist/web-dashboard-client.js +0 -275
  57. 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, writeFile } from "node:fs/promises";
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 { hasTelegramPermission, permissionForCallbackData, permissionForCommand, } from "./access-control.js";
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, getArtifactTurnReport, isTelegramImagePreview, listRecentArtifactReports, persistWorkspaceArtifactReport, pruneConnectorTurnDirs, removeArtifactTurn, telegramArtifactFilename, totalArtifactSize, } from "./artifacts.js";
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, renderHelpMessage, renderWelcomeFirstTime, renderWelcomeReturning, } from "./bot-ui.js";
14
- import { BotPreferencesStore, formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
15
- import { logTailRequests, parseAgentUpdateId, parseLogsCommand, renderAgentUpdateJobAction, renderAgentUpdateJobsAction, renderAgentUpdateLogAction, renderAgentUpdatePickerAction, renderAgentsAction, renderArtifactReportsAction, renderChannelsAction, renderLogTailsAction, renderQueueListAction, renderQueuedPromptDetailAction, renderSelfUpdateStartedAction, } from "./channel-actions.js";
16
- import { listChannelDescriptors } from "./channel-adapter.js";
17
- import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
18
- import { getAgentActivityLog, getAgentDiagnostics, getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
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, formatTelegramHTML } from "./format.js";
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 { renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
29
+ import { renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
34
30
  import { SessionRegistry } from "./session-registry.js";
35
- import { getAvailableBackends, transcribeAudio } from "./voice.js";
36
- import { getTelegramRateLimitMetrics, telegramRateLimiter } from "./telegram-rate-limit.js";
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
- const TELEGRAM_MESSAGE_LIMIT = 4000;
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: `update ${agentId}`,
238
+ description: `${operation} ${agentId}`,
274
239
  detail: job.summary,
275
240
  });
276
241
  }
277
242
  const rendered = renderAgentUpdateJobAction(job);
278
- await safeReply(ctx, rendered.html, {
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)} update: ${friendlyErrorText(error)}`;
285
- await safeReply(ctx, `<b>Update failed:</b> ${escapeHTML(message)}`, { fallbackText: message });
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 fromId = ctx.from?.id;
682
- if (fromId !== undefined && config.telegramAdminUserIdSet.has(fromId)) {
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 = getUserRole(ctx) === "admin";
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(async (ctx, next) => {
1625
- const fromId = ctx.from?.id;
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
- const authorized = config.telegramAllowAnyChat ||
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
- const info = session.getInfo();
1660
- const authStatus = capabilitiesOf(info).auth ? await checkAgentAuthStatus(info) : null;
1661
- const authWarning = authStatus && !authStatus.authenticated
1662
- ? [`${labelOf(info)} is not authenticated.`, authStatus.detail, authHelpText(info)].filter(Boolean).join(" ")
1663
- : undefined;
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
- else {
1670
- const welcome = renderWelcomeFirstTime(authWarning);
1671
- await safeReply(ctx, [welcome.html, "", renderLaunchSummaryHTML(info)].join("\n"), {
1672
- fallbackText: [welcome.plain, "", renderLaunchSummaryPlain(info)].join("\n"),
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("help", async (ctx) => {
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
- if (isBusy(contextKey)) {
1695
- await safeReply(ctx, escapeHTML("Cannot switch agent while a prompt is running."), {
1696
- fallbackText: "Cannot switch agent while a prompt is running.",
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
- const availableAgents = enabledAgents(config);
1701
- const currentAgent = idOf(session.getInfo());
1702
- if (availableAgents.length <= 1) {
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("auth", async (ctx) => {
1720
- if (!ctx.chat) {
1721
- return;
1722
- }
1739
+ bot.command("retry", async (ctx) => {
1723
1740
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1724
- const info = contextSession?.session.getInfo();
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 authStatus = info ? await checkAgentAuthStatus(info) : await checkAuthStatus(config.codexApiKey);
1731
- const icon = authStatus.authenticated ? "✅" : "❌";
1732
- const html = [
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
- const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1749
- const info = contextSession?.session.getInfo();
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 authStatus = await checkLoginAuthStatus(info);
1756
- if (agentIdForAuth(info) !== "hermes" && authStatus.authenticated) {
1757
- await safeReply(ctx, `<b>✅ Already authenticated</b> via <code>${escapeHTML(authStatus.method)}</code>.`, {
1758
- fallbackText: `✅ Already authenticated via ${authStatus.method}.`,
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
- if (!config.enableTelegramLogin) {
1763
- await safeReply(ctx, [
1764
- "<b>Telegram-initiated login is disabled.</b>",
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
- const result = await startAgentLogin(info);
1777
- if (result.success) {
1778
- await safeReply(ctx, `<b>🔑 Login initiated.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1779
- fallbackText: `🔑 Login initiated.\n\n${result.message}`,
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
- await safeReply(ctx, `<b>❌ Login failed.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1784
- fallbackText: `❌ Login failed.\n\n${result.message}`,
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
- bot.command("logout", async (ctx) => {
1788
- if (!ctx.chat) {
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
- const info = contextSession?.session.getInfo();
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 authStatus = await checkLoginAuthStatus(info);
1799
- if (authStatus.method === "api-key") {
1800
- await safeReply(ctx, [
1801
- `<b>Cannot logout via Telegram when ${escapeHTML(labelForAuth(info))} uses API-key authentication.</b>`,
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 (!config.enableTelegramLogin) {
1814
- await safeReply(ctx, [
1815
- "<b>Telegram-initiated auth management is disabled.</b>",
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
- if (agentIdForAuth(info) !== "hermes" && !authStatus.authenticated) {
1828
- await safeReply(ctx, escapeHTML("Not currently authenticated."), {
1829
- fallbackText: "Not currently authenticated.",
1830
- });
1831
- return;
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
- const result = await startAgentLogout(info);
1834
- if (result.success) {
1835
- await safeReply(ctx, `<b>🔓 Logged out.</b>\n\n${escapeHTML(result.message)}`, {
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, `<b>❌ Logout failed.</b>\n\n<code>${escapeHTML(result.message)}</code>`, {
1841
- fallbackText: `❌ Logout failed.\n\n${result.message}`,
1853
+ await safeReply(ctx, htmlLines.join("\n"), {
1854
+ fallbackText: plainLines.join("\n"),
1855
+ replyMarkup: keyboard,
1842
1856
  });
1843
- });
1844
- bot.command("mirror", async (ctx) => {
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 (!capabilitiesOf(session.getInfo()).cliMirror) {
1851
- const text = `CLI mirroring is not supported for ${labelOf(session.getInfo())} yet.`;
1852
- await safeReply(ctx, escapeHTML(text), { fallbackText: text });
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
- const argument = (ctx.message?.text ?? "").replace(/^\/mirror(?:@\w+)?\s*/i, "").trim();
1856
- if (argument) {
1857
- const mode = parseMirrorMode(argument, getEffectiveMirrorMode(contextKey));
1858
- if (!["off", "status", "final", "full"].includes(argument.toLowerCase())) {
1859
- await safeReply(ctx, escapeHTML("Usage: /mirror [off|status|final|full]"), {
1860
- fallbackText: "Usage: /mirror [off|status|final|full]",
1861
- });
1862
- return;
1863
- }
1864
- preferencesStore.update(contextKey, { mirrorMode: mode });
1865
- }
1866
- const mode = getEffectiveMirrorMode(contextKey);
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
- const role = getUserRole(ctx);
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
- }