@nordbyte/nordrelay 0.4.1 → 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,7 +4,7 @@ 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";
@@ -12,9 +12,10 @@ import { AgentUpdateManager } from "./agent-updates.js";
12
12
  import { AuditLogStore } from "./audit-log.js";
13
13
  import { formatSessionLabel, renderHelpMessage, renderWelcomeFirstTime, renderWelcomeReturning, } from "./bot-ui.js";
14
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";
15
+ import { logTailRequests, parseLogsCommand, renderAgentUpdateJobAction, renderAgentsAction, renderArtifactReportsAction, renderChannelsAction, renderLogTailsAction, renderQueueListAction, renderQueuedPromptDetailAction, } from "./channel-actions.js";
16
16
  import { listChannelDescriptors } from "./channel-adapter.js";
17
- 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";
18
19
  import { getAgentActivityLog, getAgentDiagnostics, getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
19
20
  import { enabledAgents } from "./agent-factory.js";
20
21
  import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
@@ -22,8 +23,8 @@ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout
22
23
  import { formatLaunchProfileBehavior } from "./codex-launch.js";
23
24
  import { contextKeyFromCtx, isTelegramContextKey, isTopicContextKey, parseContextKey } from "./context-key.js";
24
25
  import { friendlyErrorText } from "./error-messages.js";
25
- import { escapeHTML, formatTelegramHTML } from "./format.js";
26
- import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate, } from "./operations.js";
26
+ import { escapeHTML } from "./format.js";
27
+ import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, } from "./operations.js";
27
28
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
28
29
  import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
29
30
  import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
@@ -34,72 +35,22 @@ import { renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTM
34
35
  import { SessionRegistry } from "./session-registry.js";
35
36
  import { getAvailableBackends, transcribeAudio } from "./voice.js";
36
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";
37
45
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces, renderWorkspacePolicyLine, } from "./workspace-policy.js";
38
- const TELEGRAM_MESSAGE_LIMIT = 4000;
46
+ export { formatToolSummaryLine, formatTurnUsageLine, summarizeToolName } from "./bot-rendering.js";
47
+ export { registerCommands } from "./telegram-command-menu.js";
39
48
  const EDIT_DEBOUNCE_MS = 1500;
40
49
  const TYPING_INTERVAL_MS = 4500;
41
50
  const TOOL_OUTPUT_PREVIEW_LIMIT = 500;
42
- const STREAMING_PREVIEW_LIMIT = 3800;
43
- const FORMATTED_CHUNK_TARGET = 3000;
44
51
  const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
45
52
  const MEDIA_GROUP_FLUSH_MS = 1200;
46
- const KEYBOARD_PAGE_SIZE = 6;
47
- const NOOP_PAGE_CALLBACK_DATA = "noop_page";
48
53
  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
54
  export function createBot(config, registry) {
104
55
  configureRedaction(config.telegramRedactPatterns);
105
56
  telegramRateLimiter.configure({
@@ -109,6 +60,7 @@ export function createBot(config, registry) {
109
60
  });
110
61
  const bot = new Bot(config.telegramBotToken);
111
62
  bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));
63
+ const telegramChannelRuntime = new TelegramBotChannelRuntime(bot);
112
64
  const contextBusy = new Map();
113
65
  const pendingApprovals = new Map();
114
66
  const pendingSessionPicks = new Map();
@@ -127,7 +79,10 @@ export function createBot(config, registry) {
127
79
  const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
128
80
  const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
129
81
  const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
82
+ const userStore = new UserStore();
83
+ const contextUsers = new WeakMap();
130
84
  const agentUpdates = new AgentUpdateManager();
85
+ const linkAttempts = new Map();
131
86
  const drainingQueues = new Set();
132
87
  const externalQueueTimers = new Map();
133
88
  const externalMirrors = new Map();
@@ -254,6 +209,13 @@ export function createBot(config, registry) {
254
209
  }
255
210
  return checkAuthStatus(config.codexApiKey);
256
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
+ };
257
219
  const agentUpdateContext = () => ({
258
220
  piCliPath: config.piCliPath,
259
221
  hermesCliPath: config.hermesCliPath,
@@ -275,10 +237,7 @@ export function createBot(config, registry) {
275
237
  });
276
238
  }
277
239
  const rendered = renderAgentUpdateJobAction(job);
278
- await safeReply(ctx, rendered.html, {
279
- fallbackText: rendered.plain,
280
- replyMarkup: actionKeyboard(rendered.buttons),
281
- });
240
+ await replyChannelAction(ctx, rendered);
282
241
  }
283
242
  catch (error) {
284
243
  const message = `Failed to start ${agentLabel(agentId)} update: ${friendlyErrorText(error)}`;
@@ -416,6 +375,9 @@ export function createBot(config, registry) {
416
375
  };
417
376
  };
418
377
  const updateQueueStatusMessage = async (contextKey, text) => {
378
+ if (!canSendSystemMessagesToContext(contextKey)) {
379
+ return;
380
+ }
419
381
  const parsed = parseContextKey(contextKey);
420
382
  const html = escapeHTML(text);
421
383
  const state = queueStatusMessages.get(contextKey) ?? {};
@@ -458,6 +420,9 @@ export function createBot(config, registry) {
458
420
  if (!isTelegramContextKey(contextKey)) {
459
421
  return;
460
422
  }
423
+ if (!canSendSystemMessagesToContext(contextKey)) {
424
+ return;
425
+ }
461
426
  const session = await registry.getOrCreate(contextKey, { deferThreadStart: true }).catch(() => null);
462
427
  if (!session) {
463
428
  return;
@@ -606,7 +571,20 @@ export function createBot(config, registry) {
606
571
  }
607
572
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
608
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
+ };
609
584
  const deliverCliGeneratedArtifacts = async (contextKey, chatId, session, startedAt, turnId, messageThreadId) => {
585
+ if (!canSendSystemMessagesToContext(contextKey)) {
586
+ return;
587
+ }
610
588
  if (!startedAt || !turnId) {
611
589
  return;
612
590
  }
@@ -658,6 +636,9 @@ export function createBot(config, registry) {
658
636
  if (promptStore.list(contextKey).length === 0) {
659
637
  return;
660
638
  }
639
+ if (!canSendSystemMessagesToContext(contextKey)) {
640
+ return;
641
+ }
661
642
  const busy = getBusyReason(contextKey);
662
643
  if (busy.kind === "external") {
663
644
  const label = busy.activity.agentLabel;
@@ -677,37 +658,12 @@ export function createBot(config, registry) {
677
658
  timer.unref?.();
678
659
  externalQueueTimers.set(contextKey, timer);
679
660
  };
661
+ const getAuthenticatedUser = (ctx) => contextUsers.get(ctx) ?? null;
680
662
  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);
663
+ const authUser = getAuthenticatedUser(ctx);
664
+ return authUser?.groups.map((group) => group.name).join(", ") || "unauthenticated";
710
665
  };
666
+ const isAdminUser = (ctx) => Boolean(getAuthenticatedUser(ctx)?.groups.some((group) => group.id === ADMIN_GROUP_ID));
711
667
  const audit = (event) => {
712
668
  try {
713
669
  auditLog.append(event);
@@ -730,7 +686,7 @@ export function createBot(config, registry) {
730
686
  };
731
687
  const denyIfLocked = async (ctx, contextKey, session) => {
732
688
  const lock = lockStore.get(contextKey);
733
- const isAdmin = getUserRole(ctx) === "admin";
689
+ const isAdmin = isAdminUser(ctx);
734
690
  if (canWriteWithLock(lock, ctx.from?.id, isAdmin)) {
735
691
  return false;
736
692
  }
@@ -801,6 +757,9 @@ export function createBot(config, registry) {
801
757
  }
802
758
  pendingApprovals.delete(approvalId);
803
759
  getBusyState(contextKey).approving = false;
760
+ if (!canSendSystemMessagesToContext(contextKey)) {
761
+ return;
762
+ }
804
763
  const parsed = parseContextKey(contextKey);
805
764
  void sendTextMessage(bot.api, parsed.chatId, `Approval timed out for prompt ${approvalId}.`, {
806
765
  messageThreadId: parsed.messageThreadId,
@@ -831,6 +790,9 @@ export function createBot(config, registry) {
831
790
  await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
832
791
  };
833
792
  const handleUserPrompt = async (ctx, contextKey, chatId, session, prompt, options = {}) => {
793
+ if (!canSendSystemMessagesToContext(contextKey)) {
794
+ return;
795
+ }
834
796
  const parsed = parseContextKey(contextKey);
835
797
  const messageThreadId = parsed.messageThreadId;
836
798
  const envelope = isPromptEnvelopeLike(prompt) ? prompt : toPromptEnvelope(prompt);
@@ -1374,6 +1336,9 @@ export function createBot(config, registry) {
1374
1336
  if (drainingQueues.has(contextKey)) {
1375
1337
  return;
1376
1338
  }
1339
+ if (!canSendSystemMessagesToContext(contextKey)) {
1340
+ return;
1341
+ }
1377
1342
  drainingQueues.add(contextKey);
1378
1343
  try {
1379
1344
  while (true) {
@@ -1536,16 +1501,25 @@ export function createBot(config, registry) {
1536
1501
  clearTimeout(pending.timer);
1537
1502
  pendingMediaGroups.delete(key);
1538
1503
  try {
1504
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1505
+ return;
1506
+ }
1539
1507
  await processMediaGroup(pending);
1540
1508
  }
1541
1509
  catch (error) {
1542
1510
  console.error("Failed to process media group:", error);
1511
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1512
+ return;
1513
+ }
1543
1514
  await safeReply(pending.ctx, `<b>Failed to process media group:</b> ${escapeHTML(friendlyErrorText(error))}`, {
1544
1515
  fallbackText: `Failed to process media group: ${friendlyErrorText(error)}`,
1545
1516
  });
1546
1517
  }
1547
1518
  };
1548
1519
  const processMediaGroup = async (pending) => {
1520
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1521
+ return;
1522
+ }
1549
1523
  const busyState = getBusyState(pending.contextKey);
1550
1524
  busyState.transcribing = true;
1551
1525
  const turnId = randomUUID().slice(0, 12);
@@ -1596,10 +1570,16 @@ export function createBot(config, registry) {
1596
1570
  busyState.transcribing = false;
1597
1571
  }
1598
1572
  if (stagedFiles.length === 0) {
1573
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1574
+ return;
1575
+ }
1599
1576
  const text = skippedCount > 0 ? "No media group files could be staged." : "Media group was empty.";
1600
1577
  await safeReply(pending.ctx, escapeHTML(text), { fallbackText: text });
1601
1578
  return;
1602
1579
  }
1580
+ if (!canSendSystemMessagesToContext(pending.contextKey)) {
1581
+ return;
1582
+ }
1603
1583
  const receivedText = `Received ${stagedFiles.length} media group file${stagedFiles.length === 1 ? "" : "s"}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}.`;
1604
1584
  await safeReply(pending.ctx, escapeHTML(receivedText), { fallbackText: receivedText });
1605
1585
  await sendChatActionSafe(pending.ctx.api, pending.chatId, "typing", pending.messageThreadId).catch(() => { });
@@ -1621,35 +1601,8 @@ export function createBot(config, registry) {
1621
1601
  await clearReaction(pending.ctx);
1622
1602
  }
1623
1603
  };
1624
- bot.use(async (ctx, next) => {
1625
- const fromId = ctx.from?.id;
1626
- 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
- }
1649
- return;
1650
- }
1651
- await next();
1652
- });
1604
+ bot.use(createTelegramAccessMiddleware({ userStore, contextUsers, audit }));
1605
+ registerTelegramAccessCommands({ bot, userStore, contextUsers, linkAttempts, audit, getUserRole });
1653
1606
  bot.command("start", async (ctx) => {
1654
1607
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
1655
1608
  if (!contextSession) {
@@ -1679,11 +1632,11 @@ export function createBot(config, registry) {
1679
1632
  });
1680
1633
  bot.command("channels", async (ctx) => {
1681
1634
  const rendered = renderChannelsAction(listChannelDescriptors());
1682
- await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
1635
+ await replyChannelAction(ctx, rendered);
1683
1636
  });
1684
1637
  bot.command("agents", async (ctx) => {
1685
1638
  const rendered = renderAgentsAction(listAgentAdapterDescriptors(), enabledAgents(config));
1686
- await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
1639
+ await replyChannelAction(ctx, rendered);
1687
1640
  });
1688
1641
  bot.command("agent", async (ctx) => {
1689
1642
  const contextSession = await getContextSession(ctx, { deferThreadStart: true });
@@ -2140,7 +2093,7 @@ export function createBot(config, registry) {
2140
2093
  }
2141
2094
  const { contextKey, session } = contextSession;
2142
2095
  const existing = lockStore.get(contextKey);
2143
- if (existing && existing.ownerId !== ctx.from.id && getUserRole(ctx) !== "admin") {
2096
+ if (existing && existing.ownerId !== ctx.from.id && !isAdminUser(ctx)) {
2144
2097
  const text = `Session is already locked by ${formatLockOwner(existing)}.`;
2145
2098
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2146
2099
  return;
@@ -2161,7 +2114,7 @@ export function createBot(config, registry) {
2161
2114
  }
2162
2115
  const { contextKey, session } = contextSession;
2163
2116
  const lock = lockStore.get(contextKey);
2164
- if (lock && lock.ownerId !== ctx.from?.id && getUserRole(ctx) !== "admin") {
2117
+ if (lock && lock.ownerId !== ctx.from?.id && !isAdminUser(ctx)) {
2165
2118
  const text = `Only ${formatLockOwner(lock)} or an admin can unlock this session.`;
2166
2119
  await safeReply(ctx, escapeHTML(text), { fallbackText: text });
2167
2120
  return;
@@ -2250,7 +2203,7 @@ export function createBot(config, registry) {
2250
2203
  tail: await readFormattedLogTail(logRequest.lines, request.path),
2251
2204
  })));
2252
2205
  const rendered = renderLogTailsAction(logs);
2253
- await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
2206
+ await replyChannelAction(ctx, rendered);
2254
2207
  });
2255
2208
  bot.command("restart", async (ctx) => {
2256
2209
  await safeReply(ctx, escapeHTML("Restarting connector..."), {
@@ -2260,94 +2213,7 @@ export function createBot(config, registry) {
2260
2213
  spawnConnectorRestart();
2261
2214
  }, 300);
2262
2215
  });
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
- });
2216
+ registerTelegramUpdateCommands({ bot, agentUpdates, replyChannelAction, startTelegramAgentUpdate });
2351
2217
  bot.command("new", async (ctx) => {
2352
2218
  const chatId = ctx.chat?.id;
2353
2219
  if (!chatId) {
@@ -3364,8 +3230,7 @@ export function createBot(config, registry) {
3364
3230
  await ctx.answerCallbackQuery({ text: "Approval expired" });
3365
3231
  return;
3366
3232
  }
3367
- const role = getUserRole(ctx);
3368
- if (pending.requestedBy !== undefined && ctx.from?.id !== pending.requestedBy && role !== "admin") {
3233
+ if (pending.requestedBy !== undefined && ctx.from?.id !== pending.requestedBy && !isAdminUser(ctx)) {
3369
3234
  await ctx.answerCallbackQuery({ text: "Only the requester or an admin can approve" });
3370
3235
  return;
3371
3236
  }
@@ -4081,1069 +3946,3 @@ export function createBot(config, registry) {
4081
3946
  });
4082
3947
  return bot;
4083
3948
  }
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
- }