@nordbyte/nordrelay 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bot.js CHANGED
@@ -4,15 +4,18 @@ import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { autoRetry } from "@grammyjs/auto-retry";
6
6
  import { Bot, InlineKeyboard, InputFile } from "grammy";
7
- import { hasTelegramPermission, permissionForCallbackData, permissionForCommand, } from "./access-control.js";
7
+ import { ADMIN_GROUP_ID } from "./access-control.js";
8
8
  import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
9
9
  import { collectArtifactReport, collectRecentWorkspaceArtifacts, createArtifactZipBundle, ensureOutDir, formatArtifactSummary, getArtifactTurnReport, isTelegramImagePreview, listRecentArtifactReports, persistWorkspaceArtifactReport, pruneConnectorTurnDirs, removeArtifactTurn, telegramArtifactFilename, totalArtifactSize, } from "./artifacts.js";
10
10
  import { listAgentAdapterDescriptors } from "./agent-adapter.js";
11
+ import { AgentUpdateManager } from "./agent-updates.js";
11
12
  import { AuditLogStore } from "./audit-log.js";
12
13
  import { formatSessionLabel, renderHelpMessage, renderWelcomeFirstTime, renderWelcomeReturning, } from "./bot-ui.js";
13
14
  import { BotPreferencesStore, formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
15
+ import { logTailRequests, parseLogsCommand, renderAgentUpdateJobAction, renderAgentsAction, renderArtifactReportsAction, renderChannelsAction, renderLogTailsAction, renderQueueListAction, renderQueuedPromptDetailAction, } from "./channel-actions.js";
14
16
  import { listChannelDescriptors } from "./channel-adapter.js";
15
- import { CODEX_AGENT_CAPABILITIES, agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
17
+ import { deliverChannelAction } from "./channel-runtime.js";
18
+ import { agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
16
19
  import { getAgentActivityLog, getAgentDiagnostics, getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
17
20
  import { enabledAgents } from "./agent-factory.js";
18
21
  import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
@@ -20,53 +23,34 @@ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout
20
23
  import { formatLaunchProfileBehavior } from "./codex-launch.js";
21
24
  import { contextKeyFromCtx, isTelegramContextKey, isTopicContextKey, parseContextKey } from "./context-key.js";
22
25
  import { friendlyErrorText } from "./error-messages.js";
23
- import { escapeHTML, formatTelegramHTML } from "./format.js";
24
- import { getConnectorHealth, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
26
+ import { escapeHTML } from "./format.js";
27
+ import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, } from "./operations.js";
25
28
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
26
29
  import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
27
30
  import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
28
31
  import { checkPiAuthStatus } from "./pi-auth.js";
29
32
  import { configureRedaction, redactText } from "./redaction.js";
30
33
  import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
31
- import { formatFileSize, renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
34
+ import { renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
32
35
  import { SessionRegistry } from "./session-registry.js";
33
36
  import { getAvailableBackends, transcribeAudio } from "./voice.js";
34
37
  import { getTelegramRateLimitMetrics, telegramRateLimiter } from "./telegram-rate-limit.js";
38
+ import { chatBucket, downloadTelegramFile, isMessageNotModifiedError, renderMarkdownChunkWithinLimit, safeEditMessage, safeEditReplyMarkup, safeReply, sendChatActionSafe, sendTextMessage, splitMarkdownForTelegram, } from "./telegram-output.js";
39
+ import { NOOP_PAGE_CALLBACK_DATA, TelegramBotChannelRuntime, paginateKeyboard, telegramChannelContextFromCtx, } from "./telegram-channel-runtime.js";
40
+ import { createTelegramAccessMiddleware } from "./telegram-access-middleware.js";
41
+ import { registerTelegramAccessCommands } from "./telegram-access-commands.js";
42
+ import { registerTelegramUpdateCommands } from "./telegram-update-commands.js";
43
+ import { appendWithCap, authHelpText, buildArtifactActionsKeyboard, buildStreamingPreview, capabilitiesOf, filterActivityEvents, filterArtifactReports, filterSessions, formatAgentLaunchProfileLabel, formatAgentSettingScope, formatCliPathHTML, formatCliPathPlain, formatDurationSeconds, formatError, formatLocalDateTime, formatLockOwner, formatModelButtonLabel, formatRelativeTime, formatTelegramName, formatToolSummaryLine, formatTurnUsageLine, getWorkspaceShortName, idOf, isEmptyArtifactReport, isPromptEnvelopeLike, isQueuedPromptLike, labelOf, orderPinnedSessions, parseActivityOptions, parseFastModeArgument, parseToggle, renderActivityTimeline, renderAgentDiagnostics, renderAuditEvents, renderDiagnosticsHTML, renderDiagnosticsPlain, renderExternalMirrorEvent, renderExternalMirrorStatus, renderHealthHTML, renderHealthPlain, renderPromptFailure, renderProgressHTML, renderProgressPlain, renderSessionLocks, renderTodoList, renderToolEndMessage, renderToolStartMessage, renderVersionCheckHTML, renderVersionCheckPlain, requiresTurnApproval, trimLine, } from "./bot-rendering.js";
44
+ import { UserStore } from "./user-management.js";
35
45
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces, renderWorkspacePolicyLine, } from "./workspace-policy.js";
36
- const TELEGRAM_MESSAGE_LIMIT = 4000;
46
+ export { formatToolSummaryLine, formatTurnUsageLine, summarizeToolName } from "./bot-rendering.js";
47
+ export { registerCommands } from "./telegram-command-menu.js";
37
48
  const EDIT_DEBOUNCE_MS = 1500;
38
49
  const TYPING_INTERVAL_MS = 4500;
39
50
  const TOOL_OUTPUT_PREVIEW_LIMIT = 500;
40
- const STREAMING_PREVIEW_LIMIT = 3800;
41
- const FORMATTED_CHUNK_TARGET = 3000;
42
51
  const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
43
52
  const MEDIA_GROUP_FLUSH_MS = 1200;
44
- const KEYBOARD_PAGE_SIZE = 6;
45
- const NOOP_PAGE_CALLBACK_DATA = "noop_page";
46
53
  const LAUNCH_PROFILES_COMMAND = "/launch_profiles";
47
- function paginateKeyboard(items, page, prefix) {
48
- const totalPages = Math.max(1, Math.ceil(items.length / KEYBOARD_PAGE_SIZE));
49
- const currentPage = Math.min(Math.max(page, 0), totalPages - 1);
50
- const start = currentPage * KEYBOARD_PAGE_SIZE;
51
- const pageItems = items.slice(start, start + KEYBOARD_PAGE_SIZE);
52
- const keyboard = new InlineKeyboard();
53
- pageItems.forEach((item, index) => {
54
- keyboard.text(item.label, item.callbackData);
55
- if (index < pageItems.length - 1 || totalPages > 1) {
56
- keyboard.row();
57
- }
58
- });
59
- if (totalPages > 1) {
60
- if (currentPage > 0) {
61
- keyboard.text("◀️ Prev", `${prefix}_page_${currentPage - 1}`);
62
- }
63
- keyboard.text(`${currentPage + 1}/${totalPages}`, NOOP_PAGE_CALLBACK_DATA);
64
- if (currentPage < totalPages - 1) {
65
- keyboard.text("Next ▶️", `${prefix}_page_${currentPage + 1}`);
66
- }
67
- }
68
- return keyboard;
69
- }
70
54
  export function createBot(config, registry) {
71
55
  configureRedaction(config.telegramRedactPatterns);
72
56
  telegramRateLimiter.configure({
@@ -76,6 +60,7 @@ export function createBot(config, registry) {
76
60
  });
77
61
  const bot = new Bot(config.telegramBotToken);
78
62
  bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));
63
+ const telegramChannelRuntime = new TelegramBotChannelRuntime(bot);
79
64
  const contextBusy = new Map();
80
65
  const pendingApprovals = new Map();
81
66
  const pendingSessionPicks = new Map();
@@ -94,6 +79,10 @@ export function createBot(config, registry) {
94
79
  const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
95
80
  const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
96
81
  const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
82
+ const userStore = new UserStore();
83
+ const contextUsers = new WeakMap();
84
+ const agentUpdates = new AgentUpdateManager();
85
+ const linkAttempts = new Map();
97
86
  const drainingQueues = new Set();
98
87
  const externalQueueTimers = new Map();
99
88
  const externalMirrors = new Map();
@@ -220,6 +209,41 @@ export function createBot(config, registry) {
220
209
  }
221
210
  return checkAuthStatus(config.codexApiKey);
222
211
  };
212
+ const replyChannelAction = async (ctx, rendered) => {
213
+ const channelContext = telegramChannelContextFromCtx(ctx);
214
+ if (!channelContext) {
215
+ return;
216
+ }
217
+ await deliverChannelAction(telegramChannelRuntime, channelContext, rendered);
218
+ };
219
+ const agentUpdateContext = () => ({
220
+ piCliPath: config.piCliPath,
221
+ hermesCliPath: config.hermesCliPath,
222
+ openClawCliPath: config.openClawCliPath,
223
+ claudeCodeCliPath: config.claudeCodeCliPath,
224
+ });
225
+ const startTelegramAgentUpdate = async (ctx, agentId) => {
226
+ try {
227
+ const job = agentUpdates.start(agentId, agentUpdateContext());
228
+ const contextKey = contextKeyFromCtx(ctx);
229
+ if (contextKey) {
230
+ audit({
231
+ action: "command",
232
+ status: "ok",
233
+ contextKey,
234
+ agentId,
235
+ description: `update ${agentId}`,
236
+ detail: job.summary,
237
+ });
238
+ }
239
+ const rendered = renderAgentUpdateJobAction(job);
240
+ await replyChannelAction(ctx, rendered);
241
+ }
242
+ catch (error) {
243
+ const message = `Failed to start ${agentLabel(agentId)} update: ${friendlyErrorText(error)}`;
244
+ await safeReply(ctx, `<b>Update failed:</b> ${escapeHTML(message)}`, { fallbackText: message });
245
+ }
246
+ };
223
247
  const startAgentLogin = (info) => {
224
248
  const agentId = agentIdForAuth(info);
225
249
  if (agentId === "hermes") {
@@ -324,22 +348,10 @@ export function createBot(config, registry) {
324
348
  const createQueuedPromptCancelKeyboard = (contextKey, queueId, label = "Cancel queued message") => new InlineKeyboard().text(label, queueCancelCallbackData("cancel", contextKey, queueId));
325
349
  const renderQueueList = (contextKey, queue) => {
326
350
  const paused = promptStore.isPaused(contextKey);
351
+ const rendered = renderQueueListAction(queue, paused);
327
352
  if (queue.length === 0) {
328
- return {
329
- plain: paused ? "Queue is empty and paused." : "Queue is empty.",
330
- html: escapeHTML(paused ? "Queue is empty and paused." : "Queue is empty."),
331
- };
353
+ return rendered;
332
354
  }
333
- const lines = queue.map((item, index) => {
334
- const age = formatRelativeTime(new Date(item.createdAt));
335
- const attempts = item.attempts && item.attempts > 0 ? ` · attempts ${item.attempts}` : "";
336
- const error = item.lastError ? ` · last error: ${trimLine(item.lastError, 80)}` : "";
337
- const scheduled = item.notBefore && item.notBefore > Date.now()
338
- ? `scheduled ${formatLocalDateTime(new Date(item.notBefore))}`
339
- : index === 0 ? "next" : `after ${index} queued item${index === 1 ? "" : "s"}`;
340
- const eta = scheduled;
341
- return `${index + 1}. ${item.id} · ${age} · ${eta}${attempts}${error} · ${item.description}`;
342
- });
343
355
  const keyboard = new InlineKeyboard();
344
356
  queue.forEach((item, index) => {
345
357
  keyboard
@@ -352,11 +364,7 @@ export function createBot(config, registry) {
352
364
  .text("Down", queueCancelCallbackData("down", contextKey, item.id))
353
365
  .row();
354
366
  });
355
- return {
356
- plain: [paused ? "Queued prompts (paused):" : "Queued prompts:", ...lines].join("\n"),
357
- html: [paused ? "<b>Queued prompts:</b> <code>paused</code>" : "<b>Queued prompts:</b>", ...lines.map(escapeHTML)].join("\n"),
358
- keyboard,
359
- };
367
+ return { ...rendered, keyboard };
360
368
  };
361
369
  const createSystemContext = (contextKey) => {
362
370
  const parsed = parseContextKey(contextKey);
@@ -367,6 +375,9 @@ export function createBot(config, registry) {
367
375
  };
368
376
  };
369
377
  const updateQueueStatusMessage = async (contextKey, text) => {
378
+ if (!canSendSystemMessagesToContext(contextKey)) {
379
+ return;
380
+ }
370
381
  const parsed = parseContextKey(contextKey);
371
382
  const html = escapeHTML(text);
372
383
  const state = queueStatusMessages.get(contextKey) ?? {};
@@ -409,6 +420,9 @@ export function createBot(config, registry) {
409
420
  if (!isTelegramContextKey(contextKey)) {
410
421
  return;
411
422
  }
423
+ if (!canSendSystemMessagesToContext(contextKey)) {
424
+ return;
425
+ }
412
426
  const session = await registry.getOrCreate(contextKey, { deferThreadStart: true }).catch(() => null);
413
427
  if (!session) {
414
428
  return;
@@ -557,7 +571,20 @@ export function createBot(config, registry) {
557
571
  }
558
572
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
559
573
  };
574
+ const canSendSystemMessagesToContext = (contextKey) => {
575
+ if (!userStore.hasAdminUser()) {
576
+ return false;
577
+ }
578
+ const parsed = parseContextKey(contextKey);
579
+ if (parsed.chatId > 0) {
580
+ return Boolean(userStore.resolveTelegramUser(parsed.chatId));
581
+ }
582
+ return userStore.snapshot().telegramChats.some((chat) => chat.chatId === parsed.chatId && chat.enabled);
583
+ };
560
584
  const deliverCliGeneratedArtifacts = async (contextKey, chatId, session, startedAt, turnId, messageThreadId) => {
585
+ if (!canSendSystemMessagesToContext(contextKey)) {
586
+ return;
587
+ }
561
588
  if (!startedAt || !turnId) {
562
589
  return;
563
590
  }
@@ -609,6 +636,9 @@ export function createBot(config, registry) {
609
636
  if (promptStore.list(contextKey).length === 0) {
610
637
  return;
611
638
  }
639
+ if (!canSendSystemMessagesToContext(contextKey)) {
640
+ return;
641
+ }
612
642
  const busy = getBusyReason(contextKey);
613
643
  if (busy.kind === "external") {
614
644
  const label = busy.activity.agentLabel;
@@ -628,37 +658,12 @@ export function createBot(config, registry) {
628
658
  timer.unref?.();
629
659
  externalQueueTimers.set(contextKey, timer);
630
660
  };
661
+ const getAuthenticatedUser = (ctx) => contextUsers.get(ctx) ?? null;
631
662
  const getUserRole = (ctx) => {
632
- const fromId = ctx.from?.id;
633
- if (fromId !== undefined && config.telegramAdminUserIdSet.has(fromId)) {
634
- return "admin";
635
- }
636
- if (fromId !== undefined && config.telegramReadOnlyUserIdSet.has(fromId)) {
637
- return "readonly";
638
- }
639
- return "operator";
640
- };
641
- const getRequiredPermission = (ctx) => {
642
- if (ctx.callbackQuery?.data) {
643
- return permissionForCallbackData(ctx.callbackQuery.data);
644
- }
645
- if (ctx.message?.voice || ctx.message?.audio || ctx.message?.photo || ctx.message?.document) {
646
- return "files";
647
- }
648
- const text = ctx.message?.text?.trim();
649
- if (!text) {
650
- return "inspect";
651
- }
652
- if (!text.startsWith("/")) {
653
- return "prompt";
654
- }
655
- const command = extractCommandName(text);
656
- if (command === "queue") {
657
- const argument = text.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
658
- return argument ? "prompt" : "inspect";
659
- }
660
- return permissionForCommand(command);
663
+ const authUser = getAuthenticatedUser(ctx);
664
+ return authUser?.groups.map((group) => group.name).join(", ") || "unauthenticated";
661
665
  };
666
+ const isAdminUser = (ctx) => Boolean(getAuthenticatedUser(ctx)?.groups.some((group) => group.id === ADMIN_GROUP_ID));
662
667
  const audit = (event) => {
663
668
  try {
664
669
  auditLog.append(event);
@@ -681,7 +686,7 @@ export function createBot(config, registry) {
681
686
  };
682
687
  const denyIfLocked = async (ctx, contextKey, session) => {
683
688
  const lock = lockStore.get(contextKey);
684
- const isAdmin = getUserRole(ctx) === "admin";
689
+ const isAdmin = isAdminUser(ctx);
685
690
  if (canWriteWithLock(lock, ctx.from?.id, isAdmin)) {
686
691
  return false;
687
692
  }
@@ -752,6 +757,9 @@ export function createBot(config, registry) {
752
757
  }
753
758
  pendingApprovals.delete(approvalId);
754
759
  getBusyState(contextKey).approving = false;
760
+ if (!canSendSystemMessagesToContext(contextKey)) {
761
+ return;
762
+ }
755
763
  const parsed = parseContextKey(contextKey);
756
764
  void sendTextMessage(bot.api, parsed.chatId, `Approval timed out for prompt ${approvalId}.`, {
757
765
  messageThreadId: parsed.messageThreadId,
@@ -782,6 +790,9 @@ export function createBot(config, registry) {
782
790
  await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
783
791
  };
784
792
  const handleUserPrompt = async (ctx, contextKey, chatId, session, prompt, options = {}) => {
793
+ if (!canSendSystemMessagesToContext(contextKey)) {
794
+ return;
795
+ }
785
796
  const parsed = parseContextKey(contextKey);
786
797
  const messageThreadId = parsed.messageThreadId;
787
798
  const envelope = isPromptEnvelopeLike(prompt) ? prompt : toPromptEnvelope(prompt);
@@ -1325,6 +1336,9 @@ export function createBot(config, registry) {
1325
1336
  if (drainingQueues.has(contextKey)) {
1326
1337
  return;
1327
1338
  }
1339
+ if (!canSendSystemMessagesToContext(contextKey)) {
1340
+ return;
1341
+ }
1328
1342
  drainingQueues.add(contextKey);
1329
1343
  try {
1330
1344
  while (true) {
@@ -1487,16 +1501,25 @@ export function createBot(config, registry) {
1487
1501
  clearTimeout(pending.timer);
1488
1502
  pendingMediaGroups.delete(key);
1489
1503
  try {
1504
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1505
+ return;
1506
+ }
1490
1507
  await processMediaGroup(pending);
1491
1508
  }
1492
1509
  catch (error) {
1493
1510
  console.error("Failed to process media group:", error);
1511
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1512
+ return;
1513
+ }
1494
1514
  await safeReply(pending.ctx, `<b>Failed to process media group:</b> ${escapeHTML(friendlyErrorText(error))}`, {
1495
1515
  fallbackText: `Failed to process media group: ${friendlyErrorText(error)}`,
1496
1516
  });
1497
1517
  }
1498
1518
  };
1499
1519
  const processMediaGroup = async (pending) => {
1520
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1521
+ return;
1522
+ }
1500
1523
  const busyState = getBusyState(pending.contextKey);
1501
1524
  busyState.transcribing = true;
1502
1525
  const turnId = randomUUID().slice(0, 12);
@@ -1547,10 +1570,16 @@ export function createBot(config, registry) {
1547
1570
  busyState.transcribing = false;
1548
1571
  }
1549
1572
  if (stagedFiles.length === 0) {
1573
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1574
+ return;
1575
+ }
1550
1576
  const text = skippedCount > 0 ? "No media group files could be staged." : "Media group was empty.";
1551
1577
  await safeReply(pending.ctx, escapeHTML(text), { fallbackText: text });
1552
1578
  return;
1553
1579
  }
1580
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1581
+ return;
1582
+ }
1554
1583
  const receivedText = `Received ${stagedFiles.length} media group file${stagedFiles.length === 1 ? "" : "s"}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}.`;
1555
1584
  await safeReply(pending.ctx, escapeHTML(receivedText), { fallbackText: receivedText });
1556
1585
  await sendChatActionSafe(pending.ctx.api, pending.chatId, "typing", pending.messageThreadId).catch(() => { });
@@ -1572,35 +1601,8 @@ export function createBot(config, registry) {
1572
1601
  await clearReaction(pending.ctx);
1573
1602
  }
1574
1603
  };
1575
- bot.use(async (ctx, next) => {
1576
- const fromId = ctx.from?.id;
1577
- const chatId = ctx.chat?.id;
1578
- const authorized = config.telegramAllowAnyChat ||
1579
- (fromId !== undefined && config.telegramAllowedUserIdSet.has(fromId)) ||
1580
- (chatId !== undefined && config.telegramAllowedChatIdSet.has(chatId));
1581
- if (!authorized) {
1582
- if (ctx.callbackQuery) {
1583
- await ctx.answerCallbackQuery({ text: "Unauthorized" }).catch(() => { });
1584
- }
1585
- else if (ctx.chat) {
1586
- await safeReply(ctx, escapeHTML("Unauthorized"), { fallbackText: "Unauthorized" });
1587
- }
1588
- return;
1589
- }
1590
- const role = getUserRole(ctx);
1591
- const permission = getRequiredPermission(ctx);
1592
- if (!hasTelegramPermission(config.telegramRolePolicies, role, permission)) {
1593
- const message = `Access denied: ${permission} permission required.`;
1594
- if (ctx.callbackQuery) {
1595
- await ctx.answerCallbackQuery({ text: message }).catch(() => { });
1596
- }
1597
- else {
1598
- await safeReply(ctx, escapeHTML(message), { fallbackText: message });
1599
- }
1600
- return;
1601
- }
1602
- await next();
1603
- });
1604
+ bot.use(createTelegramAccessMiddleware({ userStore, contextUsers, audit }));
1605
+ registerTelegramAccessCommands({ bot, userStore, contextUsers, linkAttempts, audit, getUserRole });
1604
1606
  bot.command("start", async (ctx) => {
1605
1607
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1606
1608
  if (!contextSession) {
@@ -1629,60 +1631,12 @@ export function createBot(config, registry) {
1629
1631
  await safeReply(ctx, help.html, { fallbackText: help.plain });
1630
1632
  });
1631
1633
  bot.command("channels", async (ctx) => {
1632
- const descriptors = listChannelDescriptors();
1633
- const lines = descriptors.map((descriptor) => {
1634
- const status = descriptor.status === "available" ? "available" : "planned";
1635
- return `${descriptor.label}: ${status} · ${descriptor.capabilities.join(", ")}`;
1636
- });
1637
- const html = [
1638
- "<b>Channel adapters:</b>",
1639
- ...descriptors.map((descriptor) => {
1640
- const statusIcon = descriptor.status === "available" ? "✅" : "🟡";
1641
- const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1642
- return `${statusIcon} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(descriptor.status)}</code>\n <code>${escapeHTML(descriptor.capabilities.join(", "))}</code>${notes}`;
1643
- }),
1644
- ].join("\n");
1645
- await safeReply(ctx, html, { fallbackText: ["Channel adapters:", ...lines].join("\n") });
1634
+ const rendered = renderChannelsAction(listChannelDescriptors());
1635
+ await replyChannelAction(ctx, rendered);
1646
1636
  });
1647
1637
  bot.command("agents", async (ctx) => {
1648
- const descriptors = listAgentAdapterDescriptors();
1649
- const plain = [
1650
- "Agent adapters:",
1651
- ...descriptors.map((descriptor) => {
1652
- const enabled = descriptor.id === "codex"
1653
- ? config.codexEnabled
1654
- : descriptor.id === "pi"
1655
- ? config.piEnabled
1656
- : descriptor.id === "hermes"
1657
- ? config.hermesEnabled
1658
- : descriptor.id === "openclaw"
1659
- ? config.openClawEnabled
1660
- : descriptor.id === "claude-code"
1661
- ? config.claudeCodeEnabled
1662
- : false;
1663
- return `${descriptor.label}: ${descriptor.status}${descriptor.status === "available" ? ` · ${enabled ? "enabled" : "disabled"}` : ""}`;
1664
- }),
1665
- ].join("\n");
1666
- const html = [
1667
- "<b>Agent adapters:</b>",
1668
- ...descriptors.map((descriptor) => {
1669
- const enabled = descriptor.id === "codex"
1670
- ? config.codexEnabled
1671
- : descriptor.id === "pi"
1672
- ? config.piEnabled
1673
- : descriptor.id === "hermes"
1674
- ? config.hermesEnabled
1675
- : descriptor.id === "openclaw"
1676
- ? config.openClawEnabled
1677
- : descriptor.id === "claude-code"
1678
- ? config.claudeCodeEnabled
1679
- : false;
1680
- const status = descriptor.status === "available" ? `${enabled ? "enabled" : "disabled"}` : "planned";
1681
- const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
1682
- return `${descriptor.status === "available" ? "✅" : "🟡"} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(status)}</code>${notes}`;
1683
- }),
1684
- ].join("\n");
1685
- await safeReply(ctx, html, { fallbackText: plain });
1638
+ const rendered = renderAgentsAction(listAgentAdapterDescriptors(), enabledAgents(config));
1639
+ await replyChannelAction(ctx, rendered);
1686
1640
  });
1687
1641
  bot.command("agent", async (ctx) => {
1688
1642
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
@@ -2139,7 +2093,7 @@ export function createBot(config, registry) {
2139
2093
  }
2140
2094
  const { contextKey, session } = contextSession;
2141
2095
  const existing = lockStore.get(contextKey);
2142
- if (existing && existing.ownerId !== ctx.from.id && getUserRole(ctx) !== "admin") {
2096
+ if (existing && existing.ownerId !== ctx.from.id && !isAdminUser(ctx)) {
2143
2097
  const text = `Session is already locked by ${formatLockOwner(existing)}.`;
2144
2098
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2145
2099
  return;
@@ -2160,7 +2114,7 @@ export function createBot(config, registry) {
2160
2114
  }
2161
2115
  const { contextKey, session } = contextSession;
2162
2116
  const lock = lockStore.get(contextKey);
2163
- if (lock && lock.ownerId !== ctx.from?.id && getUserRole(ctx) !== "admin") {
2117
+ if (lock && lock.ownerId !== ctx.from?.id && !isAdminUser(ctx)) {
2164
2118
  const text = `Only ${formatLockOwner(lock)} or an admin can unlock this session.`;
2165
2119
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2166
2120
  return;
@@ -2244,20 +2198,12 @@ export function createBot(config, registry) {
2244
2198
  const rawText = ctx.message?.text ?? "";
2245
2199
  const argument = rawText.replace(/^\/logs(?:@\w+)?\s*/i, "").trim();
2246
2200
  const logRequest = parseLogsCommand(argument);
2247
- const logs = logRequest.target === "all"
2248
- ? [
2249
- { title: "Connector", tail: await readFormattedLogTail(logRequest.lines) },
2250
- { title: "Update", tail: await readFormattedLogTail(logRequest.lines, getUpdateLogPath()) },
2251
- ]
2252
- : [
2253
- {
2254
- title: logRequest.target === "update" ? "Update" : "Connector",
2255
- tail: await readFormattedLogTail(logRequest.lines, logRequest.target === "update" ? getUpdateLogPath() : undefined),
2256
- },
2257
- ];
2258
- const plain = logs.map(({ title, tail }) => renderLogTailPlain(title, tail)).join("\n\n");
2259
- const html = logs.map(({ title, tail }) => renderLogTailHTML(title, tail)).join("\n\n");
2260
- await safeReply(ctx, html, { fallbackText: plain });
2201
+ const logs = await Promise.all(logTailRequests(logRequest.target).map(async (request) => ({
2202
+ title: request.title,
2203
+ tail: await readFormattedLogTail(logRequest.lines, request.path),
2204
+ })));
2205
+ const rendered = renderLogTailsAction(logs);
2206
+ await replyChannelAction(ctx, rendered);
2261
2207
  });
2262
2208
  bot.command("restart", async (ctx) => {
2263
2209
  await safeReply(ctx, escapeHTML("Restarting connector..."), {
@@ -2267,26 +2213,7 @@ export function createBot(config, registry) {
2267
2213
  spawnConnectorRestart();
2268
2214
  }, 300);
2269
2215
  });
2270
- bot.command("update", async (ctx) => {
2271
- const update = spawnSelfUpdate();
2272
- const plain = [
2273
- "Update started.",
2274
- `Method: ${update.method}`,
2275
- update.summary,
2276
- `Source: ${update.sourceRoot}`,
2277
- `Log: ${update.logPath}`,
2278
- "Use /logs update after the restart or inspect update.log on the host.",
2279
- ].join("\n");
2280
- const html = [
2281
- "<b>Update started.</b>",
2282
- `<b>Method:</b> <code>${escapeHTML(update.method)}</code>`,
2283
- escapeHTML(update.summary),
2284
- `<b>Source:</b> <code>${escapeHTML(update.sourceRoot)}</code>`,
2285
- `<b>Log:</b> <code>${escapeHTML(update.logPath)}</code>`,
2286
- `Use <code>/logs update</code> after the restart or inspect <code>${escapeHTML(getUpdateLogPath())}</code> on the host.`,
2287
- ].join("\n");
2288
- await safeReply(ctx, html, { fallbackText: plain });
2289
- });
2216
+ registerTelegramUpdateCommands({ bot, agentUpdates, replyChannelAction, startTelegramAgentUpdate });
2290
2217
  bot.command("new", async (ctx) => {
2291
2218
  const chatId = ctx.chat?.id;
2292
2219
  if (!chatId) {
@@ -2433,7 +2360,7 @@ export function createBot(config, registry) {
2433
2360
  });
2434
2361
  return;
2435
2362
  }
2436
- const rendered = renderQueuedPromptDetail(item);
2363
+ const rendered = renderQueuedPromptDetailAction(item);
2437
2364
  await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2438
2365
  return;
2439
2366
  }
@@ -2591,7 +2518,7 @@ export function createBot(config, registry) {
2591
2518
  });
2592
2519
  return;
2593
2520
  }
2594
- const rendered = renderArtifactReports(filtered);
2521
+ const rendered = renderArtifactReportsAction(filtered);
2595
2522
  await safeReply(ctx, rendered.html, {
2596
2523
  fallbackText: rendered.plain,
2597
2524
  replyMarkup: buildArtifactActionsKeyboard(filtered),
@@ -2617,7 +2544,7 @@ export function createBot(config, registry) {
2617
2544
  }
2618
2545
  return;
2619
2546
  }
2620
- const { html, plain } = renderArtifactReports(reports);
2547
+ const { html, plain } = renderArtifactReportsAction(reports);
2621
2548
  await safeReply(ctx, html, {
2622
2549
  fallbackText: plain,
2623
2550
  replyMarkup: buildArtifactActionsKeyboard(reports),
@@ -3303,8 +3230,7 @@ export function createBot(config, registry) {
3303
3230
  await ctx.answerCallbackQuery({ text: "Approval expired" });
3304
3231
  return;
3305
3232
  }
3306
- const role = getUserRole(ctx);
3307
- if (pending.requestedBy !== undefined && ctx.from?.id !== pending.requestedBy && role !== "admin") {
3233
+ if (pending.requestedBy !== undefined && ctx.from?.id !== pending.requestedBy && !isAdminUser(ctx)) {
3308
3234
  await ctx.answerCallbackQuery({ text: "Only the requester or an admin can approve" });
3309
3235
  return;
3310
3236
  }
@@ -4020,1170 +3946,3 @@ export function createBot(config, registry) {
4020
3946
  });
4021
3947
  return bot;
4022
3948
  }
4023
- export async function registerCommands(bot) {
4024
- await bot.api.setMyCommands([
4025
- { command: "start", description: "Welcome & status" },
4026
- { command: "help", description: "Command reference" },
4027
- { command: "channels", description: "Messaging adapter status" },
4028
- { command: "agents", description: "Agent adapter status" },
4029
- { command: "agent", description: "Select agent" },
4030
- { command: "new", description: "Start a new thread" },
4031
- { command: "session", description: "Current thread details" },
4032
- { command: "sessions", description: "Browse & switch threads" },
4033
- { command: "sync", description: "Sync active session from CLI state" },
4034
- { command: "pinned", description: "Show pinned threads" },
4035
- { command: "pin", description: "Pin current or given thread" },
4036
- { command: "unpin", description: "Unpin current or given thread" },
4037
- { command: "retry", description: "Resend the last prompt" },
4038
- { command: "queue", description: "Show queued prompts" },
4039
- { command: "cancel", description: "Cancel a queued prompt" },
4040
- { command: "clearqueue", description: "Clear queued prompts" },
4041
- { command: "artifacts", description: "List or resend generated files" },
4042
- { command: "workspaces", description: "List allowed workspaces" },
4043
- { command: "abort", description: "Cancel current operation" },
4044
- { command: "stop", description: "Cancel current operation" },
4045
- { command: "launch_profiles", description: "Select launch profile" },
4046
- { command: "fast", description: "Toggle fast mode" },
4047
- { command: "model", description: "View & change model" },
4048
- { command: "reasoning", description: "Set reasoning effort" },
4049
- { command: "mirror", description: "Control CLI mirroring" },
4050
- { command: "notify", description: "Control notifications" },
4051
- { command: "auth", description: "Check auth status" },
4052
- { command: "login", description: "Start authentication" },
4053
- { command: "logout", description: "Sign out" },
4054
- { command: "voice", description: "Voice transcription status" },
4055
- { command: "tasks", description: "Current turn progress" },
4056
- { command: "progress", description: "Current turn progress" },
4057
- { command: "activity", description: "Thread activity timeline" },
4058
- { command: "audit", description: "Admin: recent audit events" },
4059
- { command: "status", description: "Connector runtime status" },
4060
- { command: "health", description: "Connector health report" },
4061
- { command: "version", description: "Connector version" },
4062
- { command: "logs", description: "Admin: show connector logs" },
4063
- { command: "diagnostics", description: "Admin: connector diagnostics" },
4064
- { command: "lock", description: "Lock session writes to you" },
4065
- { command: "unlock", description: "Release session write lock" },
4066
- { command: "locks", description: "List session write locks" },
4067
- { command: "restart", description: "Admin: restart connector" },
4068
- { command: "update", description: "Admin: update connector" },
4069
- { command: "handback", description: "Hand session back to CLI" },
4070
- { command: "attach", description: "Bind a session to this topic" },
4071
- { command: "switch", description: "Switch to a thread by ID" },
4072
- ]);
4073
- }
4074
- function renderArtifactReports(reports) {
4075
- const lines = reports.slice(0, 5).map((report, index) => {
4076
- const size = formatFileSize(totalArtifactSize(report.artifacts));
4077
- const skipped = report.skippedCount > 0 ? `, ${report.skippedCount} skipped` : "";
4078
- return `${index + 1}. ${report.turnId} · ${formatRelativeTime(report.updatedAt)} · ${report.artifacts.length} file${report.artifacts.length === 1 ? "" : "s"} · ${size}${skipped}`;
4079
- });
4080
- const usage = "Tap an action below, or use /artifacts latest, /artifacts zip latest, /artifacts images, /artifacts docs, /artifacts search <text>, or /artifacts delete <turn-id>.";
4081
- const plain = ["Recent artifacts:", ...lines, "", usage].join("\n");
4082
- const html = ["<b>Recent artifacts:</b>", ...lines.map(escapeHTML), "", escapeHTML(usage)].join("\n");
4083
- return { html, plain };
4084
- }
4085
- function renderVersionCheckPlain(check) {
4086
- const icon = versionStatusIcon(check);
4087
- const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
4088
- return `${label}: ${icon} ${formatVersionCheckDetailPlain(check)}`;
4089
- }
4090
- function renderVersionCheckHTML(check) {
4091
- const icon = versionStatusIcon(check);
4092
- const label = check.label === "NordRelay" ? "NordRelay" : `${check.label} version`;
4093
- return `<b>${escapeHTML(label)}:</b> ${icon} ${formatVersionCheckDetailHTML(check)}`;
4094
- }
4095
- function formatCliPathPlain(label, cliPath, fallback) {
4096
- return cliPath ? `${label} path: ${cliPath}` : `${label}: ${fallback}`;
4097
- }
4098
- function formatCliPathHTML(label, cliPath, fallback) {
4099
- return cliPath
4100
- ? `<b>${escapeHTML(label)} path:</b> <code>${escapeHTML(cliPath)}</code>`
4101
- : `<b>${escapeHTML(label)}:</b> <code>${escapeHTML(fallback)}</code>`;
4102
- }
4103
- function formatVersionCheckDetailPlain(check) {
4104
- if (check.status === "not-installed") {
4105
- return "not installed";
4106
- }
4107
- if (check.status === "outdated") {
4108
- return `${check.installedLabel} (latest ${check.latestVersion ?? "unknown"})`;
4109
- }
4110
- if (check.status === "current") {
4111
- return `${check.installedLabel} (latest)`;
4112
- }
4113
- return `${check.installedLabel} (latest unknown${check.detail ? `: ${check.detail}` : ""})`;
4114
- }
4115
- function formatVersionCheckDetailHTML(check) {
4116
- if (check.status === "not-installed") {
4117
- return "<code>not installed</code>";
4118
- }
4119
- if (check.status === "outdated") {
4120
- return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest ${escapeHTML(check.latestVersion ?? "unknown")})</i>`;
4121
- }
4122
- if (check.status === "current") {
4123
- return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest)</i>`;
4124
- }
4125
- return `<code>${escapeHTML(check.installedLabel)}</code> <i>(latest unknown${check.detail ? `: ${escapeHTML(check.detail)}` : ""})</i>`;
4126
- }
4127
- function versionStatusIcon(check) {
4128
- return check.status === "current" ? "✅" : "⚠️";
4129
- }
4130
- function parseLogsCommand(argument) {
4131
- const tokens = argument.split(/\s+/).filter(Boolean);
4132
- let target = "connector";
4133
- let lines = 80;
4134
- for (const token of tokens) {
4135
- const normalized = token.toLowerCase();
4136
- if (normalized === "connector" || normalized === "main") {
4137
- target = "connector";
4138
- continue;
4139
- }
4140
- if (normalized === "update" || normalized === "updates") {
4141
- target = "update";
4142
- continue;
4143
- }
4144
- if (normalized === "all") {
4145
- target = "all";
4146
- continue;
4147
- }
4148
- const parsedLines = Number.parseInt(token, 10);
4149
- if (!Number.isNaN(parsedLines)) {
4150
- lines = parsedLines;
4151
- }
4152
- }
4153
- return { target, lines };
4154
- }
4155
- function renderLogTailPlain(title, tail) {
4156
- return [
4157
- `${title} log tail`,
4158
- `File: ${tail.filePath}`,
4159
- `Updated: ${tail.updatedAt ? formatLogDate(tail.updatedAt) : "-"}`,
4160
- `Lines: ${tail.lineCount}/${tail.requestedLines}`,
4161
- "",
4162
- tail.plain || "(empty)",
4163
- ].join("\n");
4164
- }
4165
- function renderLogTailHTML(title, tail) {
4166
- const body = tail.plain
4167
- ? tail.plain.split("\n").map(renderLogLineHTML).join("\n")
4168
- : "<code>(empty)</code>";
4169
- return [
4170
- `<b>${escapeHTML(title)} log tail</b>`,
4171
- `<b>File:</b> <code>${escapeHTML(tail.filePath)}</code>`,
4172
- `<b>Updated:</b> <code>${escapeHTML(tail.updatedAt ? formatLogDate(tail.updatedAt) : "-")}</code>`,
4173
- `<b>Lines:</b> <code>${tail.lineCount}/${tail.requestedLines}</code>`,
4174
- "",
4175
- body,
4176
- ].join("\n");
4177
- }
4178
- function formatLogDate(date) {
4179
- return [
4180
- `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
4181
- `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
4182
- ].join(" ");
4183
- }
4184
- function renderLogLineHTML(line) {
4185
- const structured = line.match(/^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|unknown time\s*)\s+(?<level>INFO|WARN|ERROR)\s+(?<message>.*)$/);
4186
- if (structured?.groups) {
4187
- const level = structured.groups.level;
4188
- const levelHtml = level === "INFO" ? escapeHTML(level) : `<b>${escapeHTML(level)}</b>`;
4189
- return [
4190
- `<code>${escapeHTML(structured.groups.timestamp.trim())}</code>`,
4191
- levelHtml,
4192
- escapeHTML(structured.groups.message),
4193
- ].join(" ");
4194
- }
4195
- return escapeHTML(line);
4196
- }
4197
- function renderAuditEvents(events) {
4198
- if (events.length === 0) {
4199
- return {
4200
- plain: "Audit log is empty.",
4201
- html: escapeHTML("Audit log is empty."),
4202
- };
4203
- }
4204
- const lines = events.map((event) => {
4205
- const time = formatLocalDateTime(new Date(event.timestamp));
4206
- const actor = event.actorId ? `user ${event.actorId}` : "system";
4207
- const prompt = event.promptId ? ` · ${event.promptId}` : "";
4208
- const detail = event.detail ? ` · ${trimLine(event.detail, 90)}` : "";
4209
- const description = event.description ? ` · ${trimLine(event.description, 90)}` : "";
4210
- return `${time} · ${event.status.toUpperCase()} · ${event.action} · ${actor}${prompt}${description}${detail}`;
4211
- });
4212
- return {
4213
- plain: ["Audit:", ...lines].join("\n"),
4214
- html: [
4215
- "<b>Audit:</b>",
4216
- ...lines.map((line) => escapeHTML(line)),
4217
- ].join("\n"),
4218
- };
4219
- }
4220
- function renderSessionLocks(locks) {
4221
- if (locks.length === 0) {
4222
- return {
4223
- plain: "No active session locks.",
4224
- html: escapeHTML("No active session locks."),
4225
- };
4226
- }
4227
- const lines = locks.map((lock) => {
4228
- const expires = lock.expiresAt ? ` · expires ${formatLocalDateTime(new Date(lock.expiresAt))}` : "";
4229
- return `${lock.contextKey} · ${formatLockOwner(lock)}${expires}`;
4230
- });
4231
- return {
4232
- plain: ["Session locks:", ...lines].join("\n"),
4233
- html: ["<b>Session locks:</b>", ...lines.map((line) => escapeHTML(line))].join("\n"),
4234
- };
4235
- }
4236
- function renderQueuedPromptDetail(item) {
4237
- const lines = [
4238
- "Queued prompt:",
4239
- `ID: ${item.id}`,
4240
- `Created: ${formatLocalDateTime(new Date(item.createdAt))}`,
4241
- item.notBefore ? `Scheduled: ${formatLocalDateTime(new Date(item.notBefore))}` : undefined,
4242
- `Attempts: ${item.attempts ?? 0}`,
4243
- item.lastError ? `Last error: ${item.lastError}` : undefined,
4244
- `Description: ${item.description}`,
4245
- ].filter((line) => Boolean(line));
4246
- return {
4247
- plain: lines.join("\n"),
4248
- html: [
4249
- "<b>Queued prompt:</b>",
4250
- `<b>ID:</b> <code>${escapeHTML(item.id)}</code>`,
4251
- `<b>Created:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.createdAt)))}</code>`,
4252
- item.notBefore ? `<b>Scheduled:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.notBefore)))}</code>` : undefined,
4253
- `<b>Attempts:</b> <code>${item.attempts ?? 0}</code>`,
4254
- item.lastError ? `<b>Last error:</b> ${escapeHTML(item.lastError)}` : undefined,
4255
- `<b>Description:</b> ${escapeHTML(item.description)}`,
4256
- ].filter((line) => Boolean(line)).join("\n"),
4257
- };
4258
- }
4259
- function formatLockOwner(lock) {
4260
- if (!lock) {
4261
- return "nobody";
4262
- }
4263
- return lock.ownerName ? `${lock.ownerName} (${lock.ownerId})` : `user ${lock.ownerId}`;
4264
- }
4265
- function formatTelegramName(ctx) {
4266
- const firstName = ctx.from?.first_name?.trim();
4267
- const lastName = ctx.from?.last_name?.trim();
4268
- const username = ctx.from?.username?.trim();
4269
- const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
4270
- return fullName || (username ? `@${username}` : undefined);
4271
- }
4272
- function formatLocalDateTime(date) {
4273
- if (Number.isNaN(date.getTime())) {
4274
- return "-";
4275
- }
4276
- return [
4277
- `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
4278
- `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
4279
- ].join(" ");
4280
- }
4281
- function pad2(value) {
4282
- return String(value).padStart(2, "0");
4283
- }
4284
- function buildArtifactActionsKeyboard(reports) {
4285
- const keyboard = new InlineKeyboard();
4286
- for (const [index, report] of reports.slice(0, 5).entries()) {
4287
- const label = `${index + 1}`;
4288
- keyboard
4289
- .text(`${label} Send`, `artifact_send:${report.turnId}`)
4290
- .text(`${label} ZIP`, `artifact_zip:${report.turnId}`)
4291
- .text(`${label} Delete`, `artifact_delete:${report.turnId}`)
4292
- .row();
4293
- }
4294
- return keyboard;
4295
- }
4296
- function filterArtifactReports(reports, argument) {
4297
- const normalized = argument.trim().toLowerCase();
4298
- if (!normalized) {
4299
- return null;
4300
- }
4301
- let predicate = null;
4302
- if (normalized === "images" || normalized === "image" || normalized === "photos") {
4303
- predicate = (artifact) => isTelegramImagePreview(artifact);
4304
- }
4305
- else if (normalized === "docs" || normalized === "documents" || normalized === "files") {
4306
- predicate = (artifact) => !isTelegramImagePreview(artifact);
4307
- }
4308
- else if (normalized.startsWith("search ")) {
4309
- const query = normalized.slice("search ".length).trim();
4310
- if (!query) {
4311
- return [];
4312
- }
4313
- predicate = (artifact) => artifact.name.toLowerCase().includes(query);
4314
- }
4315
- if (!predicate) {
4316
- return null;
4317
- }
4318
- return reports
4319
- .map((report) => ({
4320
- ...report,
4321
- artifacts: report.artifacts.filter(predicate),
4322
- }))
4323
- .filter((report) => report.artifacts.length > 0);
4324
- }
4325
- function renderProgressPlain(progress, queueLength, busyState, info) {
4326
- const busyFlags = formatBusyFlags(busyState);
4327
- if (!progress) {
4328
- return [
4329
- "Progress:",
4330
- "Status: idle",
4331
- `Thread: ${info.threadId ?? "(not started yet)"}`,
4332
- `Queue: ${queueLength}`,
4333
- `Busy: ${busyFlags || "no"}`,
4334
- ].join("\n");
4335
- }
4336
- const lines = [
4337
- "Progress:",
4338
- `Status: ${progress.status}`,
4339
- `Prompt: ${progress.promptDescription}`,
4340
- `Elapsed: ${formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000)}`,
4341
- `Current tool: ${progress.currentTool ?? "-"}`,
4342
- `Last tool: ${progress.lastTool ?? "-"}`,
4343
- `Tools: ${formatToolSummaryLine(progress.toolCounts) || "-"}`,
4344
- `Output chars: ${progress.textCharacters}`,
4345
- `Queue: ${queueLength}`,
4346
- `Busy: ${busyFlags || "no"}`,
4347
- ];
4348
- if (progress.error) {
4349
- lines.push(`Error: ${progress.error}`);
4350
- }
4351
- return lines.join("\n");
4352
- }
4353
- function renderProgressHTML(progress, queueLength, busyState, info) {
4354
- const busyFlags = formatBusyFlags(busyState);
4355
- if (!progress) {
4356
- return [
4357
- "<b>Progress:</b>",
4358
- "<b>Status:</b> <code>idle</code>",
4359
- `<b>Thread:</b> <code>${escapeHTML(info.threadId ?? "(not started yet)")}</code>`,
4360
- `<b>Queue:</b> <code>${queueLength}</code>`,
4361
- `<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
4362
- ].join("\n");
4363
- }
4364
- const lines = [
4365
- "<b>Progress:</b>",
4366
- `<b>Status:</b> <code>${escapeHTML(progress.status)}</code>`,
4367
- `<b>Prompt:</b> <code>${escapeHTML(progress.promptDescription)}</code>`,
4368
- `<b>Elapsed:</b> <code>${escapeHTML(formatDurationSeconds(((progress.completedAt ?? Date.now()) - progress.startedAt) / 1000))}</code>`,
4369
- `<b>Current tool:</b> <code>${escapeHTML(progress.currentTool ?? "-")}</code>`,
4370
- `<b>Last tool:</b> <code>${escapeHTML(progress.lastTool ?? "-")}</code>`,
4371
- `<b>Tools:</b> <code>${escapeHTML(formatToolSummaryLine(progress.toolCounts) || "-")}</code>`,
4372
- `<b>Output chars:</b> <code>${progress.textCharacters}</code>`,
4373
- `<b>Queue:</b> <code>${queueLength}</code>`,
4374
- `<b>Busy:</b> <code>${escapeHTML(busyFlags || "no")}</code>`,
4375
- ];
4376
- if (progress.error) {
4377
- lines.push(`<b>Error:</b> <code>${escapeHTML(progress.error)}</code>`);
4378
- }
4379
- return lines.join("\n");
4380
- }
4381
- function renderExternalMirrorStatus(snapshot, queueLength) {
4382
- const prompt = trimLine(snapshot.latestUserMessage ?? "-", 180);
4383
- const elapsed = snapshot.activity.startedAt
4384
- ? formatDurationSeconds((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
4385
- : "-";
4386
- const lines = [
4387
- `${snapshot.agentLabel} CLI task running.`,
4388
- `Thread: ${snapshot.threadId}`,
4389
- `Elapsed: ${elapsed}`,
4390
- `Prompt: ${prompt}`,
4391
- `Last tool: ${snapshot.latestToolName ?? "-"}`,
4392
- `Queue: ${queueLength}`,
4393
- ];
4394
- return {
4395
- plain: lines.join("\n"),
4396
- html: [
4397
- `<b>${escapeHTML(snapshot.agentLabel)} CLI task running.</b>`,
4398
- `<b>Thread:</b> <code>${escapeHTML(snapshot.threadId)}</code>`,
4399
- `<b>Elapsed:</b> <code>${escapeHTML(elapsed)}</code>`,
4400
- `<b>Prompt:</b> <code>${escapeHTML(prompt)}</code>`,
4401
- `<b>Last tool:</b> <code>${escapeHTML(snapshot.latestToolName ?? "-")}</code>`,
4402
- `<b>Queue:</b> <code>${queueLength}</code>`,
4403
- ].join("\n"),
4404
- };
4405
- }
4406
- function renderExternalMirrorEvent(event) {
4407
- if (event.kind === "task") {
4408
- const status = event.status ?? event.type;
4409
- const plain = `CLI task: ${status}`;
4410
- return {
4411
- plain,
4412
- html: `<b>CLI task:</b> <code>${escapeHTML(status)}</code>`,
4413
- };
4414
- }
4415
- if (event.kind !== "tool") {
4416
- return null;
4417
- }
4418
- const status = event.status ?? event.type;
4419
- const tool = event.toolName ?? "tool";
4420
- const detail = event.text ? `\n${trimLine(event.text.replace(/\s+/g, " "), 180)}` : "";
4421
- const plain = `CLI tool ${status}: ${tool}${detail}`;
4422
- return {
4423
- plain,
4424
- html: `<b>CLI tool ${escapeHTML(status)}:</b> <code>${escapeHTML(tool)}</code>${detail ? `\n<code>${escapeHTML(detail.trim())}</code>` : ""}`,
4425
- };
4426
- }
4427
- function renderActivityTimeline(threadId, events, options = { limit: 16, filter: "all", exportFile: false }) {
4428
- if (events.length === 0) {
4429
- return {
4430
- plain: `Activity:\nThread: ${threadId}\nFilter: ${options.filter}\nNo activity events found.`,
4431
- 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>`,
4432
- };
4433
- }
4434
- const lines = events.map((event) => {
4435
- const time = event.timestamp ? event.timestamp.toISOString().slice(11, 19) : "--:--:--";
4436
- const label = activityEventLabel(event);
4437
- const detail = event.text ? ` · ${trimLine(event.text.replace(/\s+/g, " ").trim(), 120)}` : "";
4438
- const tool = event.toolName ? ` · ${event.toolName}` : "";
4439
- return `${time} · ${label}${tool}${detail}`;
4440
- });
4441
- return {
4442
- plain: ["Activity:", `Thread: ${threadId}`, `Filter: ${options.filter}`, `Events: ${events.length}`, ...lines].join("\n"),
4443
- html: [
4444
- "<b>Activity:</b>",
4445
- `<b>Thread:</b> <code>${escapeHTML(threadId)}</code>`,
4446
- `<b>Filter:</b> <code>${escapeHTML(options.filter)}</code>`,
4447
- `<b>Events:</b> <code>${events.length}</code>`,
4448
- ...lines.map((line) => `<code>${escapeHTML(line)}</code>`),
4449
- ].join("\n"),
4450
- };
4451
- }
4452
- function parseActivityOptions(argument) {
4453
- const options = {
4454
- limit: 16,
4455
- filter: "all",
4456
- exportFile: false,
4457
- };
4458
- const parts = argument.split(/\s+/).filter(Boolean);
4459
- for (let index = 0; index < parts.length; index += 1) {
4460
- const part = parts[index].toLowerCase();
4461
- if (/^\d+$/.test(part)) {
4462
- options.limit = Math.min(200, Math.max(1, Number(part)));
4463
- continue;
4464
- }
4465
- if (part === "export") {
4466
- options.exportFile = true;
4467
- continue;
4468
- }
4469
- if (isActivityFilter(part)) {
4470
- options.filter = part;
4471
- continue;
4472
- }
4473
- if (part === "since" && parts[index + 1]) {
4474
- options.sinceMs = parseDurationToMs(parts[index + 1]);
4475
- index += 1;
4476
- }
4477
- }
4478
- return options;
4479
- }
4480
- function filterActivityEvents(events, options) {
4481
- const cutoff = options.sinceMs ? Date.now() - options.sinceMs : undefined;
4482
- return events
4483
- .filter((event) => {
4484
- if (cutoff && event.timestamp && event.timestamp.getTime() < cutoff) {
4485
- return false;
4486
- }
4487
- switch (options.filter) {
4488
- case "tools":
4489
- return event.kind === "tool";
4490
- case "errors":
4491
- return event.status === "failed" || event.status === "error" || /error|failed/i.test(event.text ?? "");
4492
- case "user":
4493
- return event.kind === "user";
4494
- case "agent":
4495
- return event.kind === "agent";
4496
- case "tasks":
4497
- return event.kind === "task";
4498
- default:
4499
- return true;
4500
- }
4501
- })
4502
- .slice(-options.limit);
4503
- }
4504
- function isActivityFilter(value) {
4505
- return value === "all" || value === "tools" || value === "errors" || value === "user" || value === "agent" || value === "tasks";
4506
- }
4507
- function formatAgentLaunchProfileLabel(profile, selected) {
4508
- const prefix = selected ? "✅" : profile.unsafe ? "⚠️" : "🚀";
4509
- return `${prefix} ${profile.label} · ${trimLine(profile.behavior, 24)}`;
4510
- }
4511
- function formatModelButtonLabel(model, selected) {
4512
- const meta = [
4513
- model.contextWindow ? formatCompactNumber(model.contextWindow) : undefined,
4514
- model.supportsImages === true ? "img" : model.supportsImages === false ? "text" : undefined,
4515
- model.supportsThinking === true ? "think" : undefined,
4516
- ].filter(Boolean).join(" ");
4517
- return trimLine(`${selected ? "✅ " : ""}${model.displayName}${meta ? ` · ${meta}` : ""}`, 58);
4518
- }
4519
- function formatCompactNumber(value) {
4520
- if (value >= 1_000_000_000)
4521
- return `${Math.round(value / 100_000_000) / 10}B`;
4522
- if (value >= 1_000_000)
4523
- return `${Math.round(value / 100_000) / 10}M`;
4524
- if (value >= 1_000)
4525
- return `${Math.round(value / 100) / 10}K`;
4526
- return String(value);
4527
- }
4528
- function renderAgentDiagnostics(diagnostics) {
4529
- return {
4530
- plain: [
4531
- `${diagnostics.agentLabel} state:`,
4532
- ...diagnostics.lines.map((line) => `${line.label}: ${line.value}`),
4533
- ].join("\n"),
4534
- html: [
4535
- `<b>${escapeHTML(diagnostics.agentLabel)} state:</b>`,
4536
- ...diagnostics.lines.map((line) => `<b>${escapeHTML(line.label)}:</b> <code>${escapeHTML(line.value)}</code>`),
4537
- ].join("\n"),
4538
- };
4539
- }
4540
- function activityEventLabel(event) {
4541
- if (event.kind === "task") {
4542
- return `task ${event.status ?? event.type}`;
4543
- }
4544
- if (event.kind === "user") {
4545
- return "user";
4546
- }
4547
- if (event.kind === "agent") {
4548
- return event.phase ? `agent ${event.phase}` : "agent";
4549
- }
4550
- return event.status ? `tool ${event.status}` : "tool";
4551
- }
4552
- function isEmptyArtifactReport(report) {
4553
- return report.artifacts.length === 0 && report.skippedCount === 0 && !(report.omittedCount && report.omittedCount > 0);
4554
- }
4555
- function formatBusyFlags(state) {
4556
- return Object.entries(state)
4557
- .filter(([, enabled]) => enabled)
4558
- .map(([name]) => name)
4559
- .join(", ");
4560
- }
4561
- function renderDiagnosticsPlain(config, registry, health, authenticated, role, queueLength, progress, runtime) {
4562
- const contexts = registry.listContexts();
4563
- return [
4564
- "Diagnostics:",
4565
- `Status: ${health.state.status ?? "unknown"}`,
4566
- `Version: ${health.version}`,
4567
- `Role: ${role}`,
4568
- `Auth: ${authenticated ? "yes" : "no"} (${health.state.authMethod ?? "-"})`,
4569
- `PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
4570
- `App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
4571
- `Workspace: ${config.workspace}`,
4572
- `State backend: ${config.stateBackend}`,
4573
- `Telegram transport: ${config.telegramTransport}`,
4574
- `Codex CLI: ${health.codexCli}`,
4575
- `Pi CLI: ${health.piCli}`,
4576
- `Hermes CLI: ${health.hermesCli}`,
4577
- `OpenClaw CLI: ${health.openClawCli}`,
4578
- `Claude Code CLI: ${health.claudeCodeCli}`,
4579
- `Hermes API: ${config.hermesApiBaseUrl}`,
4580
- `OpenClaw Gateway: ${config.openClawGatewayUrl}`,
4581
- `Enabled agents/default: ${enabledAgents(config).join(", ")} / ${config.defaultAgent}`,
4582
- `State DB: ${health.databasePath ?? "-"}`,
4583
- `Log file: ${health.logFile}`,
4584
- `Log format: ${config.logFormat}`,
4585
- `Tool verbosity: ${config.toolVerbosity}`,
4586
- `Telegram rate limit queued/running/retries/429: ${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}`,
4587
- `Telegram last retry_after: ${runtime.rateLimit.lastRetryAfterSeconds ?? "-"}s`,
4588
- `CLI mirror mode/update: ${runtime.mirrorMode} / ${config.telegramMirrorMinUpdateMs} ms`,
4589
- `Notify/quiet: ${runtime.notifyMode} / ${runtime.quietHours}`,
4590
- `Voice: ${runtime.voiceBackend} / ${runtime.voiceLanguage} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}`,
4591
- `Sync interval: ${config.codexSyncIntervalMs} ms`,
4592
- `External busy check/stale: ${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms`,
4593
- `External mirrors/timers/status messages: ${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}`,
4594
- `Auto-send artifacts: ${config.telegramAutoSendArtifacts ? "yes" : "no"}`,
4595
- `Artifact ignore dirs/globs: ${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}`,
4596
- `Artifact retention: ${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs`,
4597
- `Workspace allowed/warn roots: ${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}`,
4598
- `Allowed users/chats/admins/readonly: ${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}`,
4599
- `Session lock TTL: ${config.sessionLockTtlMs} ms`,
4600
- `Audit max events: ${config.auditMaxEvents}`,
4601
- `Loaded sessions: ${contexts.length}`,
4602
- `Current queue: ${queueLength}`,
4603
- `Current progress: ${progress?.status ?? "idle"}`,
4604
- ].join("\n");
4605
- }
4606
- function renderDiagnosticsHTML(config, registry, health, authenticated, role, queueLength, progress, runtime) {
4607
- const contexts = registry.listContexts();
4608
- return [
4609
- "<b>Diagnostics:</b>",
4610
- `<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
4611
- `<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
4612
- `<b>Role:</b> <code>${escapeHTML(role)}</code>`,
4613
- `<b>Auth:</b> <code>${authenticated ? "yes" : "no"} (${escapeHTML(health.state.authMethod ?? "-")})</code>`,
4614
- `<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
4615
- `<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
4616
- `<b>Workspace:</b> <code>${escapeHTML(config.workspace)}</code>`,
4617
- `<b>State backend:</b> <code>${escapeHTML(config.stateBackend)}</code>`,
4618
- `<b>Telegram transport:</b> <code>${escapeHTML(config.telegramTransport)}</code>`,
4619
- `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4620
- `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4621
- `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
4622
- `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
4623
- `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
4624
- `<b>Hermes API:</b> <code>${escapeHTML(config.hermesApiBaseUrl)}</code>`,
4625
- `<b>OpenClaw Gateway:</b> <code>${escapeHTML(config.openClawGatewayUrl)}</code>`,
4626
- `<b>Enabled agents/default:</b> <code>${escapeHTML(`${enabledAgents(config).join(", ")} / ${config.defaultAgent}`)}</code>`,
4627
- `<b>State DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4628
- `<b>Log file:</b> <code>${escapeHTML(health.logFile)}</code>`,
4629
- `<b>Log format:</b> <code>${escapeHTML(config.logFormat)}</code>`,
4630
- `<b>Tool verbosity:</b> <code>${escapeHTML(config.toolVerbosity)}</code>`,
4631
- `<b>Telegram rate limit queued/running/retries/429:</b> <code>${runtime.rateLimit.queued}/${runtime.rateLimit.running}/${runtime.rateLimit.retries}/${runtime.rateLimit.rateLimitHits}</code>`,
4632
- `<b>Telegram last retry_after:</b> <code>${escapeHTML(String(runtime.rateLimit.lastRetryAfterSeconds ?? "-"))}s</code>`,
4633
- `<b>CLI mirror mode/update:</b> <code>${escapeHTML(runtime.mirrorMode)} / ${config.telegramMirrorMinUpdateMs} ms</code>`,
4634
- `<b>Notify/quiet:</b> <code>${escapeHTML(runtime.notifyMode)} / ${escapeHTML(runtime.quietHours)}</code>`,
4635
- `<b>Voice:</b> <code>${escapeHTML(runtime.voiceBackend)} / ${escapeHTML(runtime.voiceLanguage)} / transcribe-only ${runtime.voiceTranscribeOnly ? "on" : "off"}</code>`,
4636
- `<b>Sync interval:</b> <code>${config.codexSyncIntervalMs} ms</code>`,
4637
- `<b>External busy check/stale:</b> <code>${config.codexExternalBusyCheckMs} ms / ${config.codexExternalBusyStaleMs} ms</code>`,
4638
- `<b>External mirrors/timers/status messages:</b> <code>${runtime.externalMirrors}/${runtime.externalQueueTimers}/${runtime.queueStatusMessages}</code>`,
4639
- `<b>Auto-send artifacts:</b> <code>${config.telegramAutoSendArtifacts ? "yes" : "no"}</code>`,
4640
- `<b>Artifact ignore dirs/globs:</b> <code>${config.artifactIgnoreDirs.length}/${config.artifactIgnoreGlobs.length}</code>`,
4641
- `<b>Artifact retention:</b> <code>${config.artifactRetentionDays}d / ${config.artifactMaxTurnDirs} turns / ${config.artifactMaxInboxDirs} inbox dirs</code>`,
4642
- `<b>Workspace allowed/warn roots:</b> <code>${config.workspaceAllowedRoots.length}/${config.workspaceWarnRoots.length}</code>`,
4643
- `<b>Allowed users/chats/admins/readonly:</b> <code>${config.telegramAllowedUserIds.length}/${config.telegramAllowedChatIds.length}/${config.telegramAdminUserIds.length}/${config.telegramReadOnlyUserIds.length}</code>`,
4644
- `<b>Session lock TTL:</b> <code>${config.sessionLockTtlMs} ms</code>`,
4645
- `<b>Audit max events:</b> <code>${config.auditMaxEvents}</code>`,
4646
- `<b>Loaded sessions:</b> <code>${contexts.length}</code>`,
4647
- `<b>Current queue:</b> <code>${queueLength}</code>`,
4648
- `<b>Current progress:</b> <code>${escapeHTML(progress?.status ?? "idle")}</code>`,
4649
- ].join("\n");
4650
- }
4651
- function renderHealthPlain(health, authenticated, role) {
4652
- return [
4653
- `Status: ${health.state.status ?? "unknown"}`,
4654
- `Version: ${health.version}`,
4655
- `Role: ${role}`,
4656
- `Auth: ${authenticated ? "yes" : "no"}`,
4657
- `PID: ${health.state.pid ?? "-"} (${health.pidRunning ? "running" : "not running"})`,
4658
- `App PID: ${health.state.appPid ?? "-"} (${health.appPidRunning ? "running" : "not running"})`,
4659
- `Uptime: ${formatDuration(health.uptimeSeconds)}`,
4660
- `Workspace: ${health.state.workspace ?? "-"}`,
4661
- `Codex CLI: ${health.codexCli}`,
4662
- `Pi CLI: ${health.piCli}`,
4663
- `Hermes CLI: ${health.hermesCli}`,
4664
- `OpenClaw CLI: ${health.openClawCli}`,
4665
- `Claude Code CLI: ${health.claudeCodeCli}`,
4666
- `Codex state DB: ${health.databasePath ?? "-"}`,
4667
- `Log: ${health.logFile}`,
4668
- ].join("\n");
4669
- }
4670
- function renderHealthHTML(health, authenticated, role) {
4671
- return [
4672
- `<b>Status:</b> <code>${escapeHTML(health.state.status ?? "unknown")}</code>`,
4673
- `<b>Version:</b> <code>${escapeHTML(health.version)}</code>`,
4674
- `<b>Role:</b> <code>${escapeHTML(role)}</code>`,
4675
- `<b>Auth:</b> <code>${authenticated ? "yes" : "no"}</code>`,
4676
- `<b>PID:</b> <code>${escapeHTML(String(health.state.pid ?? "-"))} (${health.pidRunning ? "running" : "not running"})</code>`,
4677
- `<b>App PID:</b> <code>${escapeHTML(String(health.state.appPid ?? "-"))} (${health.appPidRunning ? "running" : "not running"})</code>`,
4678
- `<b>Uptime:</b> <code>${escapeHTML(formatDuration(health.uptimeSeconds))}</code>`,
4679
- `<b>Workspace:</b> <code>${escapeHTML(health.state.workspace ?? "-")}</code>`,
4680
- `<b>Codex CLI:</b> <code>${escapeHTML(health.codexCli)}</code>`,
4681
- `<b>Pi CLI:</b> <code>${escapeHTML(health.piCli)}</code>`,
4682
- `<b>Hermes CLI:</b> <code>${escapeHTML(health.hermesCli)}</code>`,
4683
- `<b>OpenClaw CLI:</b> <code>${escapeHTML(health.openClawCli)}</code>`,
4684
- `<b>Claude Code CLI:</b> <code>${escapeHTML(health.claudeCodeCli)}</code>`,
4685
- `<b>Codex state DB:</b> <code>${escapeHTML(health.databasePath ?? "-")}</code>`,
4686
- `<b>Log:</b> <code>${escapeHTML(health.logFile)}</code>`,
4687
- ].join("\n");
4688
- }
4689
- function parseFastModeArgument(argument, currentValue) {
4690
- if (!argument) {
4691
- return !currentValue;
4692
- }
4693
- const normalized = argument.toLowerCase();
4694
- if (["on", "enable", "enabled", "true", "1"].includes(normalized)) {
4695
- return true;
4696
- }
4697
- if (["off", "disable", "disabled", "false", "0"].includes(normalized)) {
4698
- return false;
4699
- }
4700
- return undefined;
4701
- }
4702
- function parseToggle(argument) {
4703
- const normalized = argument.trim().toLowerCase();
4704
- if (["on", "enable", "enabled", "true", "1", "yes"].includes(normalized)) {
4705
- return true;
4706
- }
4707
- if (["off", "disable", "disabled", "false", "0", "no"].includes(normalized)) {
4708
- return false;
4709
- }
4710
- return undefined;
4711
- }
4712
- function parseDurationToMs(value) {
4713
- const match = value.trim().match(/^(\d+)(s|m|h|d)?$/i);
4714
- if (!match) {
4715
- return undefined;
4716
- }
4717
- const amount = Number(match[1]);
4718
- const unit = (match[2] ?? "m").toLowerCase();
4719
- const multiplier = unit === "s"
4720
- ? 1000
4721
- : unit === "h"
4722
- ? 60 * 60 * 1000
4723
- : unit === "d"
4724
- ? 24 * 60 * 60 * 1000
4725
- : 60 * 1000;
4726
- return amount * multiplier;
4727
- }
4728
- function extractCommandName(text) {
4729
- const match = text.trim().match(/^\/([a-zA-Z0-9_-]+)(?:@\w+)?(?:\s|$)/);
4730
- return match?.[1]?.toLowerCase();
4731
- }
4732
- function isPromptEnvelopeLike(value) {
4733
- return typeof value === "object" && value !== null && "input" in value && "description" in value;
4734
- }
4735
- function isQueuedPromptLike(value) {
4736
- return "id" in value &&
4737
- "contextKey" in value &&
4738
- "createdAt" in value &&
4739
- typeof value.id === "string" &&
4740
- typeof value.contextKey === "string" &&
4741
- typeof value.createdAt === "number";
4742
- }
4743
- function capabilitiesOf(info) {
4744
- return info.capabilities ?? CODEX_AGENT_CAPABILITIES;
4745
- }
4746
- function labelOf(info) {
4747
- return info.agentLabel ?? agentLabel(info.agentId ?? "codex");
4748
- }
4749
- function idOf(info) {
4750
- return info.agentId ?? "codex";
4751
- }
4752
- function authHelpText(info) {
4753
- const agentId = idOf(info);
4754
- if (agentId === "pi") {
4755
- return "Configure the required Pi provider environment variable on the host.";
4756
- }
4757
- if (agentId === "hermes") {
4758
- return "Start the Hermes API Server, configure HERMES_API_KEY when required, or use /login to start Hermes CLI auth.";
4759
- }
4760
- if (agentId === "openclaw") {
4761
- return "Start the OpenClaw Gateway and configure OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD when the gateway requires one.";
4762
- }
4763
- if (agentId === "claude-code") {
4764
- return "Use /login to start Claude Code CLI auth, or run 'claude auth login' on the host.";
4765
- }
4766
- return "Use /login to start authentication, or set CODEX_API_KEY on the host.";
4767
- }
4768
- function formatAgentSettingScope(info, appliedToActiveThread) {
4769
- const agentId = idOf(info);
4770
- if (agentId === "hermes") {
4771
- return appliedToActiveThread
4772
- ? "applies to the next Hermes run in this session"
4773
- : "applies to new Hermes sessions";
4774
- }
4775
- if (agentId === "pi") {
4776
- return appliedToActiveThread
4777
- ? "applied to the current idle Pi session and future turns"
4778
- : "applies to new Pi sessions";
4779
- }
4780
- if (agentId === "openclaw") {
4781
- return appliedToActiveThread
4782
- ? "applies to the next OpenClaw run in this session"
4783
- : "applies to new OpenClaw sessions";
4784
- }
4785
- if (agentId === "claude-code") {
4786
- return appliedToActiveThread
4787
- ? "applies to the next Claude Code run in this session"
4788
- : "applies to new Claude Code sessions";
4789
- }
4790
- return appliedToActiveThread
4791
- ? "applied to the current idle thread and future threads"
4792
- : "applies to new threads";
4793
- }
4794
- function requiresTurnApproval(info) {
4795
- return info.unsafeLaunch || info.approvalPolicy !== "never";
4796
- }
4797
- function formatDuration(totalSeconds) {
4798
- const seconds = Math.max(0, Math.floor(totalSeconds));
4799
- const days = Math.floor(seconds / 86400);
4800
- const hours = Math.floor((seconds % 86400) / 3600);
4801
- const minutes = Math.floor((seconds % 3600) / 60);
4802
- if (days > 0) {
4803
- return `${days}d ${hours}h`;
4804
- }
4805
- if (hours > 0) {
4806
- return `${hours}h ${minutes}m`;
4807
- }
4808
- return `${minutes}m`;
4809
- }
4810
- function formatDurationSeconds(totalSeconds) {
4811
- const seconds = Math.max(0, Math.floor(totalSeconds));
4812
- if (seconds < 60) {
4813
- return `${seconds}s`;
4814
- }
4815
- const minutes = Math.floor(seconds / 60);
4816
- const remainingSeconds = seconds % 60;
4817
- if (minutes < 60) {
4818
- return `${minutes}m ${remainingSeconds}s`;
4819
- }
4820
- const hours = Math.floor(minutes / 60);
4821
- return `${hours}h ${minutes % 60}m`;
4822
- }
4823
- function renderToolStartMessage(toolName) {
4824
- return {
4825
- text: `<b>🔧 Running:</b> <code>${escapeHTML(toolName)}</code>`,
4826
- fallbackText: `🔧 Running: ${toolName}`,
4827
- parseMode: "HTML",
4828
- };
4829
- }
4830
- function renderToolEndMessage(toolName, partialResult, isError) {
4831
- const preview = summarizeToolOutput(partialResult);
4832
- const icon = isError ? "❌" : "✅";
4833
- const htmlLines = [`<b>${icon}</b> <code>${escapeHTML(toolName)}</code>`];
4834
- const plainLines = [`${icon} ${toolName}`];
4835
- if (preview) {
4836
- htmlLines.push(`<pre>${escapeHTML(preview)}</pre>`);
4837
- plainLines.push(preview);
4838
- }
4839
- return {
4840
- text: htmlLines.join("\n"),
4841
- fallbackText: plainLines.join("\n"),
4842
- parseMode: "HTML",
4843
- };
4844
- }
4845
- export function formatToolSummaryLine(toolCounts) {
4846
- if (toolCounts.size === 0) {
4847
- return "";
4848
- }
4849
- const summarizedCounts = new Map();
4850
- for (const [toolName, count] of toolCounts.entries()) {
4851
- const summaryName = summarizeToolName(toolName);
4852
- summarizedCounts.set(summaryName, (summarizedCounts.get(summaryName) ?? 0) + count);
4853
- }
4854
- const entries = [...summarizedCounts.entries()].sort((left, right) => {
4855
- const countDelta = right[1] - left[1];
4856
- return countDelta !== 0 ? countDelta : left[0].localeCompare(right[0]);
4857
- });
4858
- const tools = entries
4859
- .map(([name, count]) => formatSummaryEntry(name, count))
4860
- .join(", ");
4861
- return `Tools used: ${tools}`;
4862
- }
4863
- function renderTodoList(items) {
4864
- const lines = items.map((item) => {
4865
- const icon = item.completed ? "✅" : "⬜";
4866
- return `${icon} ${escapeHTML(item.text)}`;
4867
- });
4868
- return `📋 <b>Plan</b>\n${lines.join("\n")}`;
4869
- }
4870
- export function formatTurnUsageLine(usage) {
4871
- return `🪙 in: ${usage.inputTokens} · cached: ${usage.cachedInputTokens} · out: ${usage.outputTokens}`;
4872
- }
4873
- export function summarizeToolName(toolName) {
4874
- if (toolName.startsWith("🔍 ")) {
4875
- return "web_fetch";
4876
- }
4877
- if (toolName === "file_change") {
4878
- return "file_change";
4879
- }
4880
- if (toolName === "⚠️ error") {
4881
- return "error";
4882
- }
4883
- if (toolName.startsWith("mcp:")) {
4884
- const tool = toolName.split("/").at(-1) ?? toolName;
4885
- if (SUBAGENT_TOOL_NAMES.has(tool)) {
4886
- return "subagent";
4887
- }
4888
- return tool;
4889
- }
4890
- return "bash";
4891
- }
4892
- function formatSummaryEntry(name, count) {
4893
- if (count <= 1) {
4894
- return name;
4895
- }
4896
- const label = name === "subagent" ? "subagents" : name;
4897
- return `${count}x ${label}`;
4898
- }
4899
- const SUBAGENT_TOOL_NAMES = new Set(["spawn_agent", "send_input", "wait_agent", "close_agent", "resume_agent"]);
4900
- async function safeReply(ctx, text, options = {}) {
4901
- const chatId = ctx.chat?.id;
4902
- if (!chatId) {
4903
- return;
4904
- }
4905
- const parseMode = options.parseMode !== undefined ? options.parseMode : "HTML";
4906
- const messageThreadId = options.messageThreadId ?? ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id;
4907
- const chunks = splitTelegramText(redactText(text));
4908
- const fallbackChunks = options.fallbackText ? splitTelegramText(redactText(options.fallbackText)) : [];
4909
- for (const [index, chunk] of chunks.entries()) {
4910
- await sendTextMessage(ctx.api, chatId, chunk, {
4911
- parseMode,
4912
- fallbackText: fallbackChunks[index] ?? chunk,
4913
- replyMarkup: index === 0 ? options.replyMarkup : undefined,
4914
- messageThreadId,
4915
- });
4916
- }
4917
- }
4918
- async function sendTextMessage(api, chatId, text, options = {}) {
4919
- const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
4920
- const safeText = redactText(text);
4921
- const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
4922
- const bucket = chatBucket(chatId);
4923
- try {
4924
- return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeText, {
4925
- ...(parseMode ? { parse_mode: parseMode } : {}),
4926
- ...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
4927
- reply_markup: options.replyMarkup,
4928
- }));
4929
- }
4930
- catch (error) {
4931
- if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
4932
- return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeFallbackText, {
4933
- ...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
4934
- reply_markup: options.replyMarkup,
4935
- }));
4936
- }
4937
- throw error;
4938
- }
4939
- }
4940
- async function safeEditMessage(bot, chatId, messageId, text, options = {}) {
4941
- const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
4942
- const safeText = redactText(text);
4943
- const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
4944
- const bucket = `${chatBucket(chatId)}:${messageId}`;
4945
- try {
4946
- await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeText, {
4947
- ...(parseMode ? { parse_mode: parseMode } : {}),
4948
- reply_markup: options.replyMarkup,
4949
- }));
4950
- }
4951
- catch (error) {
4952
- if (isMessageNotModifiedError(error)) {
4953
- return;
4954
- }
4955
- if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
4956
- await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeFallbackText, {
4957
- reply_markup: options.replyMarkup,
4958
- }));
4959
- return;
4960
- }
4961
- throw error;
4962
- }
4963
- }
4964
- async function safeEditReplyMarkup(bot, chatId, messageId, replyMarkup) {
4965
- try {
4966
- await telegramRateLimiter.run(`${chatBucket(chatId)}:${messageId}`, "editMessageReplyMarkup", () => bot.api.editMessageReplyMarkup(chatId, messageId, {
4967
- reply_markup: replyMarkup ?? new InlineKeyboard(),
4968
- }));
4969
- }
4970
- catch (error) {
4971
- if (!isMessageNotModifiedError(error)) {
4972
- throw error;
4973
- }
4974
- }
4975
- }
4976
- async function sendChatActionSafe(api, chatId, action, messageThreadId) {
4977
- await telegramRateLimiter.run(chatBucket(chatId), "sendChatAction", () => api.sendChatAction(chatId, action, {
4978
- ...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
4979
- }));
4980
- }
4981
- function chatBucket(chatId) {
4982
- return `chat:${String(chatId)}`;
4983
- }
4984
- async function downloadTelegramFile(api, token, fileId, maxBytes = MAX_AUDIO_FILE_SIZE) {
4985
- const file = await api.getFile(fileId);
4986
- if (!file.file_path) {
4987
- throw new Error("Telegram did not return a file path");
4988
- }
4989
- if (file.file_size && file.file_size > maxBytes) {
4990
- throw new Error(`Telegram file too large (${Math.round(file.file_size / 1024 / 1024)} MB, max ${Math.round(maxBytes / 1024 / 1024)} MB)`);
4991
- }
4992
- const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
4993
- const response = await fetch(url);
4994
- if (!response.ok) {
4995
- throw new Error(`Failed to download Telegram file: ${response.status}`);
4996
- }
4997
- const buffer = Buffer.from(await response.arrayBuffer());
4998
- const extension = path.extname(file.file_path) || ".bin";
4999
- const tempPath = path.join(tmpdir(), `nordrelay-file-${randomUUID()}${extension}`);
5000
- await writeFile(tempPath, buffer);
5001
- return tempPath;
5002
- }
5003
- function splitTelegramText(text) {
5004
- if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
5005
- return [text];
5006
- }
5007
- const chunks = [];
5008
- let remaining = text;
5009
- while (remaining.length > TELEGRAM_MESSAGE_LIMIT) {
5010
- let cut = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
5011
- if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
5012
- cut = remaining.lastIndexOf(" ", TELEGRAM_MESSAGE_LIMIT);
5013
- }
5014
- if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
5015
- cut = TELEGRAM_MESSAGE_LIMIT;
5016
- }
5017
- chunks.push(remaining.slice(0, cut).trimEnd());
5018
- remaining = remaining.slice(cut).trimStart();
5019
- }
5020
- if (remaining) {
5021
- chunks.push(remaining);
5022
- }
5023
- return chunks.length > 0 ? chunks : [""];
5024
- }
5025
- function splitMarkdownForTelegram(markdown) {
5026
- if (!markdown) {
5027
- return [];
5028
- }
5029
- const chunks = [];
5030
- let remaining = markdown;
5031
- while (remaining) {
5032
- const maxLength = Math.min(remaining.length, FORMATTED_CHUNK_TARGET);
5033
- const initialCut = findPreferredSplitIndex(remaining, maxLength);
5034
- const candidate = remaining.slice(0, initialCut) || remaining.slice(0, 1);
5035
- const rendered = renderMarkdownChunkWithinLimit(candidate);
5036
- chunks.push(rendered);
5037
- remaining = remaining.slice(rendered.sourceText.length).trimStart();
5038
- }
5039
- return chunks;
5040
- }
5041
- function renderMarkdownChunkWithinLimit(markdown) {
5042
- if (!markdown) {
5043
- return {
5044
- text: "",
5045
- fallbackText: "",
5046
- parseMode: "HTML",
5047
- sourceText: "",
5048
- };
5049
- }
5050
- let sourceText = markdown;
5051
- let rendered = formatMarkdownMessage(sourceText);
5052
- while (rendered.text.length > TELEGRAM_MESSAGE_LIMIT && sourceText.length > 1) {
5053
- const nextLength = Math.max(1, sourceText.length - Math.max(100, Math.ceil(sourceText.length * 0.1)));
5054
- sourceText = sourceText.slice(0, nextLength).trimEnd() || sourceText.slice(0, nextLength);
5055
- rendered = formatMarkdownMessage(sourceText);
5056
- }
5057
- return {
5058
- ...rendered,
5059
- sourceText,
5060
- };
5061
- }
5062
- function formatMarkdownMessage(markdown) {
5063
- try {
5064
- return {
5065
- text: formatTelegramHTML(markdown),
5066
- fallbackText: markdown,
5067
- parseMode: "HTML",
5068
- };
5069
- }
5070
- catch (error) {
5071
- console.error("Failed to format Telegram HTML, falling back to plain text", error);
5072
- return {
5073
- text: markdown,
5074
- fallbackText: markdown,
5075
- parseMode: undefined,
5076
- };
5077
- }
5078
- }
5079
- function findPreferredSplitIndex(text, maxLength) {
5080
- if (text.length <= maxLength) {
5081
- return Math.max(1, text.length);
5082
- }
5083
- const newlineIndex = text.lastIndexOf("\n", maxLength);
5084
- if (newlineIndex >= maxLength * 0.5) {
5085
- return Math.max(1, newlineIndex);
5086
- }
5087
- const spaceIndex = text.lastIndexOf(" ", maxLength);
5088
- if (spaceIndex >= maxLength * 0.5) {
5089
- return Math.max(1, spaceIndex);
5090
- }
5091
- return Math.max(1, maxLength);
5092
- }
5093
- function buildStreamingPreview(text) {
5094
- if (text.length <= STREAMING_PREVIEW_LIMIT) {
5095
- return text;
5096
- }
5097
- return `${text.slice(0, STREAMING_PREVIEW_LIMIT)}\n\n… streaming (preview truncated)`;
5098
- }
5099
- function appendWithCap(base, addition, cap) {
5100
- const combined = `${base}${addition}`;
5101
- return combined.length <= cap ? combined : combined.slice(-cap);
5102
- }
5103
- function summarizeToolOutput(text) {
5104
- const trimmed = text.trim();
5105
- if (!trimmed) {
5106
- return "";
5107
- }
5108
- return trimmed.length <= TOOL_OUTPUT_PREVIEW_LIMIT ? trimmed : `${trimmed.slice(-TOOL_OUTPUT_PREVIEW_LIMIT)}\n…`;
5109
- }
5110
- function trimLine(text, maxLength) {
5111
- const singleLine = text.replace(/\s+/g, " ").trim();
5112
- if (singleLine.length <= maxLength) {
5113
- return singleLine;
5114
- }
5115
- return `${singleLine.slice(0, maxLength - 1)}…`;
5116
- }
5117
- function getWorkspaceShortName(workspace) {
5118
- return workspace.split(/[\\/]/).filter(Boolean).pop() ?? workspace;
5119
- }
5120
- function formatRelativeTime(date) {
5121
- const deltaMs = Date.now() - date.getTime();
5122
- const deltaSeconds = Math.max(0, Math.floor(deltaMs / 1000));
5123
- if (deltaSeconds < 60) {
5124
- return "just now";
5125
- }
5126
- const deltaMinutes = Math.floor(deltaSeconds / 60);
5127
- if (deltaMinutes < 60) {
5128
- return `${deltaMinutes}m ago`;
5129
- }
5130
- const deltaHours = Math.floor(deltaMinutes / 60);
5131
- if (deltaHours < 48) {
5132
- return `${deltaHours}h ago`;
5133
- }
5134
- const deltaDays = Math.floor(deltaHours / 24);
5135
- if (deltaDays < 14) {
5136
- return `${deltaDays}d ago`;
5137
- }
5138
- const deltaWeeks = Math.floor(deltaDays / 7);
5139
- return `${deltaWeeks}w ago`;
5140
- }
5141
- function filterSessions(sessions, query) {
5142
- const normalized = query.trim().toLowerCase();
5143
- if (!normalized) {
5144
- return sessions;
5145
- }
5146
- return sessions.filter((session) => [
5147
- session.id,
5148
- session.title ?? "",
5149
- session.cwd,
5150
- session.model ?? "",
5151
- session.firstUserMessage ?? "",
5152
- ].some((value) => value.toLowerCase().includes(normalized)));
5153
- }
5154
- function orderPinnedSessions(sessions, pinnedThreadIds) {
5155
- const pinnedIndex = new Map(pinnedThreadIds.map((threadId, index) => [threadId, index]));
5156
- return [...sessions].sort((left, right) => {
5157
- const leftPinned = pinnedIndex.get(left.id);
5158
- const rightPinned = pinnedIndex.get(right.id);
5159
- if (leftPinned !== undefined && rightPinned !== undefined) {
5160
- return leftPinned - rightPinned;
5161
- }
5162
- if (leftPinned !== undefined) {
5163
- return -1;
5164
- }
5165
- if (rightPinned !== undefined) {
5166
- return 1;
5167
- }
5168
- return 0;
5169
- });
5170
- }
5171
- function isMessageNotModifiedError(error) {
5172
- const message = error instanceof Error ? error.message : String(error);
5173
- return message.includes("message is not modified");
5174
- }
5175
- function isTelegramParseError(error) {
5176
- const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
5177
- return (message.includes("can't parse entities") ||
5178
- message.includes("unsupported start tag") ||
5179
- message.includes("unexpected end tag") ||
5180
- message.includes("entity name") ||
5181
- message.includes("parse entities"));
5182
- }
5183
- function renderPromptFailure(accumulatedText, error) {
5184
- const message = friendlyErrorText(error);
5185
- return accumulatedText.trim() ? `${accumulatedText.trim()}\n\n⚠️ ${message}` : `⚠️ ${message}`;
5186
- }
5187
- function formatError(error) {
5188
- return error instanceof Error ? error.message : String(error);
5189
- }