@nordbyte/nordrelay 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +16 -10
  2. package/dist/access-control.js +2 -0
  3. package/dist/agent-updates.js +43 -8
  4. package/dist/bot-ui.js +1 -0
  5. package/dist/bot.js +108 -1063
  6. package/dist/channel-actions.js +8 -8
  7. package/dist/operations.js +63 -9
  8. package/dist/relay-artifact-service.js +126 -0
  9. package/dist/relay-external-activity-monitor.js +216 -0
  10. package/dist/relay-queue-service.js +66 -0
  11. package/dist/relay-runtime-types.js +1 -0
  12. package/dist/relay-runtime.js +77 -359
  13. package/dist/support-bundle.js +205 -0
  14. package/dist/telegram-agent-commands.js +212 -0
  15. package/dist/telegram-artifact-commands.js +139 -0
  16. package/dist/telegram-command-menu.js +1 -0
  17. package/dist/telegram-command-types.js +1 -0
  18. package/dist/telegram-diagnostics-command.js +102 -0
  19. package/dist/telegram-general-commands.js +52 -0
  20. package/dist/telegram-operational-commands.js +153 -0
  21. package/dist/telegram-preference-commands.js +198 -0
  22. package/dist/telegram-queue-commands.js +278 -0
  23. package/dist/telegram-support-command.js +53 -0
  24. package/dist/telegram-update-commands.js +6 -1
  25. package/dist/web-api-contract.js +79 -31
  26. package/dist/web-api-types.js +1 -0
  27. package/dist/web-dashboard-access-routes.js +163 -0
  28. package/dist/web-dashboard-artifact-routes.js +65 -0
  29. package/dist/web-dashboard-assets.js +2 -0
  30. package/dist/web-dashboard-http.js +143 -0
  31. package/dist/web-dashboard-pages.js +257 -0
  32. package/dist/web-dashboard-runtime-routes.js +92 -0
  33. package/dist/web-dashboard-session-routes.js +209 -0
  34. package/dist/web-dashboard.js +43 -882
  35. package/dist/webui-assets/dashboard.css +74 -4
  36. package/dist/webui-assets/dashboard.js +163 -24
  37. package/dist/zip-writer.js +83 -0
  38. package/package.json +10 -4
@@ -0,0 +1,205 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdir, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { getAgentUpdateLogPath, getConnectorHealth, getConnectorHome, getConnectorLogPath, getConnectorStatePath, getSourceRoot, getUpdateLogPath, getVersionChecks, readFormattedLogTail, resolveNpmSpawnCommand, } from "./operations.js";
6
+ import { redactText } from "./redaction.js";
7
+ import { createZipBuffer } from "./zip-writer.js";
8
+ export async function createSupportBundle(options) {
9
+ const createdAt = new Date();
10
+ const health = options.health ?? await getConnectorHealth(cliPathOptions(options.config));
11
+ const versionChecks = options.versionChecks ?? await getVersionChecks(cliPathOptions(options.config));
12
+ const entries = [];
13
+ addJson(entries, "manifest.json", {
14
+ createdAt: createdAt.toISOString(),
15
+ source: options.source ?? "web",
16
+ package: "@nordbyte/nordrelay",
17
+ includedFiles: [],
18
+ });
19
+ addJson(entries, "config/redacted-config.json", redactValue(options.config));
20
+ addJson(entries, "config/relevant-env.json", redactValue(relevantEnvironment()));
21
+ addJson(entries, "runtime/health.json", redactValue(health));
22
+ addJson(entries, "runtime/version-checks.json", redactValue(versionChecks));
23
+ addJson(entries, "runtime/state-backend.json", {
24
+ stateBackend: options.config.stateBackend,
25
+ stateFile: getConnectorStatePath(),
26
+ connectorHome: getConnectorHome(),
27
+ sourceRoot: getSourceRoot(),
28
+ workspace: options.config.workspace,
29
+ databasePath: health.databasePath,
30
+ });
31
+ addJson(entries, "runtime/agent-paths.json", agentPaths(health));
32
+ addJson(entries, "system/info.json", systemInfo());
33
+ if (options.diagnostics) {
34
+ addJson(entries, "runtime/diagnostics.json", redactValue(options.diagnostics));
35
+ }
36
+ if (options.adapterHealth) {
37
+ addJson(entries, "runtime/adapter-health.json", redactValue(options.adapterHealth));
38
+ }
39
+ if (options.auditEvents) {
40
+ addJson(entries, "audit/recent-events.json", redactValue(options.auditEvents));
41
+ }
42
+ if (options.agentUpdateJobs) {
43
+ addJson(entries, "updates/jobs.json", redactValue(options.agentUpdateJobs));
44
+ }
45
+ await addLog(entries, "logs/connector.log", getConnectorLogPath());
46
+ await addLog(entries, "logs/nordrelay-update.log", getUpdateLogPath());
47
+ await addLog(entries, "logs/agent-updates.log", getAgentUpdateLogPath());
48
+ const includedFiles = entries.map((entry) => entry.name);
49
+ entries[0] = {
50
+ name: "manifest.json",
51
+ data: jsonText({
52
+ createdAt: createdAt.toISOString(),
53
+ source: options.source ?? "web",
54
+ package: "@nordbyte/nordrelay",
55
+ includedFiles,
56
+ }),
57
+ date: createdAt,
58
+ };
59
+ const buffer = createZipBuffer(entries);
60
+ const name = `nordrelay-diagnostics-${formatTimestamp(createdAt)}.zip`;
61
+ const supportDir = path.join(getConnectorHome(), "support");
62
+ await mkdir(supportDir, { recursive: true });
63
+ const bundlePath = path.join(supportDir, name);
64
+ await writeFile(bundlePath, buffer);
65
+ const stats = await stat(bundlePath);
66
+ return {
67
+ path: bundlePath,
68
+ name,
69
+ sizeBytes: stats.size,
70
+ createdAt: createdAt.toISOString(),
71
+ includedFiles,
72
+ };
73
+ }
74
+ function cliPathOptions(config) {
75
+ return {
76
+ piCliPath: config.piCliPath,
77
+ hermesCliPath: config.hermesCliPath,
78
+ openClawCliPath: config.openClawCliPath,
79
+ claudeCodeCliPath: config.claudeCodeCliPath,
80
+ };
81
+ }
82
+ function addJson(entries, name, value) {
83
+ entries.push({ name, data: jsonText(value) });
84
+ }
85
+ async function addLog(entries, name, filePath) {
86
+ const tail = await readFormattedLogTail(300, filePath);
87
+ entries.push({
88
+ name,
89
+ data: [
90
+ `File: ${tail.filePath}`,
91
+ `Updated: ${tail.updatedAt ? tail.updatedAt.toISOString() : "-"}`,
92
+ `Lines: ${tail.lineCount}/${tail.requestedLines}`,
93
+ "",
94
+ tail.plain || "(empty)",
95
+ ].join("\n"),
96
+ });
97
+ }
98
+ function jsonText(value) {
99
+ return `${JSON.stringify(value, null, 2)}\n`;
100
+ }
101
+ function redactValue(value) {
102
+ if (Array.isArray(value)) {
103
+ return value.map((item) => redactValue(item));
104
+ }
105
+ if (!value || typeof value !== "object") {
106
+ return typeof value === "string" ? redactText(value) : value;
107
+ }
108
+ const output = {};
109
+ for (const [key, rawValue] of Object.entries(value)) {
110
+ output[key] = isSecretKey(key) ? "[REDACTED]" : redactValue(rawValue);
111
+ }
112
+ return output;
113
+ }
114
+ function isSecretKey(key) {
115
+ return /(token|secret|password|authorization|api[_-]?key|apikey|botToken|webhookSecret|gatewayPassword)/i.test(key);
116
+ }
117
+ function relevantEnvironment() {
118
+ const prefixes = [
119
+ "NORDRELAY_",
120
+ "TELEGRAM_",
121
+ "CODEX_",
122
+ "PI_",
123
+ "HERMES_",
124
+ "OPENCLAW_",
125
+ "CLAUDE_",
126
+ "WORKSPACE_",
127
+ "ARTIFACT_",
128
+ "VOICE_",
129
+ "FASTER_WHISPER_",
130
+ ];
131
+ const exact = new Set(["MAX_FILE_SIZE", "TOOL_VERBOSITY", "CONNECTOR_LOG_FORMAT", "NODE_ENV"]);
132
+ return Object.fromEntries(Object.entries(process.env)
133
+ .filter(([key, value]) => value !== undefined && (exact.has(key) || prefixes.some((prefix) => key.startsWith(prefix))))
134
+ .sort(([left], [right]) => left.localeCompare(right))
135
+ .map(([key, value]) => [key, value ?? ""]));
136
+ }
137
+ function agentPaths(health) {
138
+ return {
139
+ codex: { label: health.codexCli, path: health.codexCliPath, version: health.codexCliVersion },
140
+ pi: { label: health.piCli, path: health.piCliPath, version: health.piCliVersion },
141
+ hermes: { label: health.hermesCli, path: health.hermesCliPath, version: health.hermesCliVersion },
142
+ openclaw: { label: health.openClawCli, path: health.openClawCliPath, version: health.openClawCliVersion },
143
+ "claude-code": { label: health.claudeCodeCli, path: health.claudeCodeCliPath, version: health.claudeCodeCliVersion },
144
+ };
145
+ }
146
+ function systemInfo() {
147
+ const npm = resolveNpmSpawnCommand();
148
+ return {
149
+ os: {
150
+ platform: os.platform(),
151
+ release: os.release(),
152
+ arch: os.arch(),
153
+ type: os.type(),
154
+ homedir: os.homedir(),
155
+ tmpdir: os.tmpdir(),
156
+ cpus: os.cpus().length,
157
+ totalMemoryBytes: os.totalmem(),
158
+ freeMemoryBytes: os.freemem(),
159
+ uptimeSeconds: os.uptime(),
160
+ },
161
+ node: {
162
+ executable: process.execPath,
163
+ version: process.version,
164
+ versions: process.versions,
165
+ argv: process.argv,
166
+ cwd: process.cwd(),
167
+ pid: process.pid,
168
+ uptimeSeconds: process.uptime(),
169
+ },
170
+ npm: npm ? {
171
+ command: npm.display,
172
+ version: detectNpmVersion(npm),
173
+ } : {
174
+ command: null,
175
+ version: null,
176
+ error: "npm not found",
177
+ },
178
+ };
179
+ }
180
+ function detectNpmVersion(npm) {
181
+ const result = spawnSync(npm.command, [...npm.argsPrefix, "--version"], {
182
+ encoding: "utf8",
183
+ shell: npm.shell,
184
+ timeout: 3000,
185
+ windowsHide: true,
186
+ });
187
+ if (result.error || result.status !== 0) {
188
+ return null;
189
+ }
190
+ return String(result.stdout || "").trim() || null;
191
+ }
192
+ function formatTimestamp(date) {
193
+ return [
194
+ date.getFullYear(),
195
+ pad2(date.getMonth() + 1),
196
+ pad2(date.getDate()),
197
+ "-",
198
+ pad2(date.getHours()),
199
+ pad2(date.getMinutes()),
200
+ pad2(date.getSeconds()),
201
+ ].join("");
202
+ }
203
+ function pad2(value) {
204
+ return String(value).padStart(2, "0");
205
+ }
@@ -0,0 +1,212 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { agentLabel, } from "./agent.js";
3
+ import { enabledAgents } from "./agent-factory.js";
4
+ import { capabilitiesOf, idOf, labelOf, } from "./bot-rendering.js";
5
+ import { checkAuthStatus } from "./codex-auth.js";
6
+ import { contextKeyFromCtx } from "./context-key.js";
7
+ import { friendlyErrorText } from "./error-messages.js";
8
+ import { escapeHTML } from "./format.js";
9
+ import { redactText } from "./redaction.js";
10
+ import { renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
11
+ import { safeEditMessage, safeReply, } from "./telegram-output.js";
12
+ export function registerTelegramAgentCommands(options) {
13
+ options.bot.command("agent", async (ctx) => {
14
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
15
+ if (!contextSession) {
16
+ return;
17
+ }
18
+ const { contextKey, session } = contextSession;
19
+ if (options.isBusy(contextKey)) {
20
+ await safeReply(ctx, escapeHTML("Cannot switch agent while a prompt is running."), {
21
+ fallbackText: "Cannot switch agent while a prompt is running.",
22
+ });
23
+ return;
24
+ }
25
+ const availableAgents = enabledAgents(options.config);
26
+ const currentAgent = idOf(session.getInfo());
27
+ if (availableAgents.length <= 1) {
28
+ const only = agentLabel(availableAgents[0] ?? currentAgent);
29
+ await safeReply(ctx, `<b>Current agent:</b> <code>${escapeHTML(only)}</code>\nNo other agents are enabled.`, {
30
+ fallbackText: `Current agent: ${only}\nNo other agents are enabled.`,
31
+ });
32
+ return;
33
+ }
34
+ options.pendingAgentPicks.set(contextKey, availableAgents);
35
+ const keyboard = new InlineKeyboard();
36
+ for (const availableAgent of availableAgents) {
37
+ keyboard.text(`${agentLabel(availableAgent)}${availableAgent === currentAgent ? " ✓" : ""}`, `agent_${availableAgent}`).row();
38
+ }
39
+ await safeReply(ctx, `<b>Current agent:</b> <code>${escapeHTML(agentLabel(currentAgent))}</code>\nSelect agent for this Telegram context:`, {
40
+ fallbackText: `Current agent: ${agentLabel(currentAgent)}\nSelect agent for this Telegram context:`,
41
+ replyMarkup: keyboard,
42
+ });
43
+ });
44
+ options.bot.command("auth", async (ctx) => {
45
+ if (!ctx.chat) {
46
+ return;
47
+ }
48
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
49
+ const info = contextSession?.session.getInfo();
50
+ if (info && !capabilitiesOf(info).auth) {
51
+ const text = `${labelOf(info)} uses its local CLI authentication. Run its login flow on the host if needed.`;
52
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
53
+ return;
54
+ }
55
+ const authStatus = info ? await options.checkAgentAuthStatus(info) : await checkAuthStatus(options.config.codexApiKey);
56
+ const icon = authStatus.authenticated ? "✅" : "❌";
57
+ const html = [
58
+ `<b>${icon} Auth status:</b> ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
59
+ `<b>Method:</b> <code>${escapeHTML(authStatus.method)}</code>`,
60
+ `<b>Detail:</b> <code>${escapeHTML(authStatus.detail)}</code>`,
61
+ ].join("\n");
62
+ const plain = [
63
+ `${icon} Auth status: ${authStatus.authenticated ? "authenticated" : "not authenticated"}`,
64
+ `Method: ${authStatus.method}`,
65
+ `Detail: ${authStatus.detail}`,
66
+ ].join("\n");
67
+ await safeReply(ctx, html, { fallbackText: plain });
68
+ });
69
+ options.bot.command("login", async (ctx) => {
70
+ if (!ctx.chat) {
71
+ return;
72
+ }
73
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
74
+ const info = contextSession?.session.getInfo();
75
+ if (info && !capabilitiesOf(info).login) {
76
+ const text = `${labelOf(info)} login is not managed by NordRelay. Run the CLI login flow on the host.`;
77
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
78
+ return;
79
+ }
80
+ const authStatus = await options.checkLoginAuthStatus(info);
81
+ if (options.agentIdForAuth(info) !== "hermes" && authStatus.authenticated) {
82
+ await safeReply(ctx, `<b>✅ Already authenticated</b> via <code>${escapeHTML(authStatus.method)}</code>.`, {
83
+ fallbackText: `✅ Already authenticated via ${authStatus.method}.`,
84
+ });
85
+ return;
86
+ }
87
+ if (!options.config.enableTelegramLogin) {
88
+ await safeReply(ctx, [
89
+ "<b>Telegram-initiated login is disabled.</b>",
90
+ "",
91
+ `Run <code>${escapeHTML(options.hostLoginCommand(info))}</code> on the host.`,
92
+ ].join("\n"), {
93
+ fallbackText: [
94
+ "Telegram-initiated login is disabled.",
95
+ "",
96
+ `Run '${options.hostLoginCommand(info)}' on the host.`,
97
+ ].join("\n"),
98
+ });
99
+ return;
100
+ }
101
+ const result = await options.startAgentLogin(info);
102
+ if (result.success) {
103
+ await safeReply(ctx, `<b>🔑 Login initiated.</b>\n\n<code>${escapeHTML(redactText(result.message))}</code>`, {
104
+ fallbackText: `🔑 Login initiated.\n\n${redactText(result.message)}`,
105
+ });
106
+ return;
107
+ }
108
+ await safeReply(ctx, `<b>❌ Login failed.</b>\n\n<code>${escapeHTML(redactText(result.message))}</code>`, {
109
+ fallbackText: `❌ Login failed.\n\n${redactText(result.message)}`,
110
+ });
111
+ });
112
+ options.bot.command("logout", async (ctx) => {
113
+ if (!ctx.chat) {
114
+ return;
115
+ }
116
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
117
+ const info = contextSession?.session.getInfo();
118
+ if (info && !capabilitiesOf(info).logout) {
119
+ const text = `${labelOf(info)} logout is not managed by NordRelay. Run the CLI logout flow on the host.`;
120
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
121
+ return;
122
+ }
123
+ const authStatus = await options.checkLoginAuthStatus(info);
124
+ if (authStatus.method === "api-key") {
125
+ await safeReply(ctx, [
126
+ `<b>Cannot logout via Telegram when ${escapeHTML(options.labelForAuth(info))} uses API-key authentication.</b>`,
127
+ "",
128
+ "Remove the API key from .env to use CLI-based auth instead.",
129
+ ].join("\n"), {
130
+ fallbackText: [
131
+ `Cannot logout via Telegram when ${options.labelForAuth(info)} uses API-key authentication.`,
132
+ "",
133
+ "Remove the API key from .env to use CLI-based auth instead.",
134
+ ].join("\n"),
135
+ });
136
+ return;
137
+ }
138
+ if (!options.config.enableTelegramLogin) {
139
+ await safeReply(ctx, [
140
+ "<b>Telegram-initiated auth management is disabled.</b>",
141
+ "",
142
+ `Run <code>${escapeHTML(options.hostLogoutCommand(info))}</code> on the host.`,
143
+ ].join("\n"), {
144
+ fallbackText: [
145
+ "Telegram-initiated auth management is disabled.",
146
+ "",
147
+ `Run '${options.hostLogoutCommand(info)}' on the host.`,
148
+ ].join("\n"),
149
+ });
150
+ return;
151
+ }
152
+ if (options.agentIdForAuth(info) !== "hermes" && !authStatus.authenticated) {
153
+ await safeReply(ctx, escapeHTML("Not currently authenticated."), {
154
+ fallbackText: "Not currently authenticated.",
155
+ });
156
+ return;
157
+ }
158
+ const result = await options.startAgentLogout(info);
159
+ if (result.success) {
160
+ await safeReply(ctx, `<b>🔓 Logged out.</b>\n\n${escapeHTML(redactText(result.message))}`, {
161
+ fallbackText: `🔓 Logged out.\n\n${redactText(result.message)}`,
162
+ });
163
+ return;
164
+ }
165
+ await safeReply(ctx, `<b>❌ Logout failed.</b>\n\n<code>${escapeHTML(redactText(result.message))}</code>`, {
166
+ fallbackText: `❌ Logout failed.\n\n${redactText(result.message)}`,
167
+ });
168
+ });
169
+ options.bot.callbackQuery(/^agent_(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
170
+ const chatId = ctx.chat?.id;
171
+ const messageId = ctx.callbackQuery.message?.message_id;
172
+ const selectedAgent = ctx.match?.[1];
173
+ const contextKey = contextKeyFromCtx(ctx);
174
+ if (!chatId || !contextKey || !selectedAgent) {
175
+ await ctx.answerCallbackQuery();
176
+ return;
177
+ }
178
+ const picks = options.pendingAgentPicks.get(contextKey);
179
+ if (!picks?.includes(selectedAgent)) {
180
+ await ctx.answerCallbackQuery({ text: "Expired, run /agent again" });
181
+ return;
182
+ }
183
+ if (options.isBusy(contextKey)) {
184
+ await ctx.answerCallbackQuery({ text: "Wait for the current prompt to finish" });
185
+ return;
186
+ }
187
+ await ctx.answerCallbackQuery({ text: `Switching to ${agentLabel(selectedAgent)}...` });
188
+ options.pendingAgentPicks.delete(contextKey);
189
+ try {
190
+ const session = await options.registry.switchAgent(contextKey, selectedAgent);
191
+ const info = session.getInfo();
192
+ const html = [`<b>Agent switched to ${escapeHTML(labelOf(info))}.</b>`, "", renderSessionInfoHTML(info)].join("\n");
193
+ const plain = [`Agent switched to ${labelOf(info)}.`, "", renderSessionInfoPlain(info)].join("\n");
194
+ if (messageId) {
195
+ await safeEditMessage(options.bot, chatId, messageId, html, { fallbackText: plain });
196
+ }
197
+ else {
198
+ await safeReply(ctx, html, { fallbackText: plain });
199
+ }
200
+ }
201
+ catch (error) {
202
+ const html = `<b>Failed:</b> ${escapeHTML(friendlyErrorText(error))}`;
203
+ const plain = `Failed: ${friendlyErrorText(error)}`;
204
+ if (messageId) {
205
+ await safeEditMessage(options.bot, chatId, messageId, html, { fallbackText: plain });
206
+ }
207
+ else {
208
+ await safeReply(ctx, html, { fallbackText: plain });
209
+ }
210
+ }
211
+ });
212
+ }
@@ -0,0 +1,139 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getArtifactTurnReport, listRecentArtifactReports, removeArtifactTurn, } from "./artifacts.js";
3
+ import { buildArtifactActionsKeyboard, filterArtifactReports, } from "./bot-rendering.js";
4
+ import { renderArtifactReportsAction } from "./channel-actions.js";
5
+ import { escapeHTML } from "./format.js";
6
+ import { NOOP_PAGE_CALLBACK_DATA } from "./telegram-channel-runtime.js";
7
+ import { safeEditMessage, safeReply, } from "./telegram-output.js";
8
+ export function registerTelegramArtifactCommands(options) {
9
+ const { bot, config } = options;
10
+ bot.command("artifacts", async (ctx) => {
11
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
12
+ if (!contextSession || !ctx.chat) {
13
+ return;
14
+ }
15
+ const workspace = contextSession.session.getInfo().workspace;
16
+ const rawText = ctx.message?.text ?? "";
17
+ const argument = rawText.replace(/^\/artifacts(?:@\w+)?\s*/i, "").trim();
18
+ const reports = await listRecentArtifactReports(workspace, 10, config.maxFileSize);
19
+ if (reports.length === 0) {
20
+ await safeReply(ctx, escapeHTML("No generated artifacts found for this workspace."), {
21
+ fallbackText: "No generated artifacts found for this workspace.",
22
+ });
23
+ return;
24
+ }
25
+ if (argument) {
26
+ const parts = argument.split(/\s+/).filter(Boolean);
27
+ if (parts[0]?.toLowerCase() === "delete" && parts[1]) {
28
+ const selected = reports.find((report) => report.turnId === parts[1] || report.turnId.startsWith(parts[1]));
29
+ if (!selected) {
30
+ await safeReply(ctx, escapeHTML(`No artifact turn found for "${parts[1]}".`), {
31
+ fallbackText: `No artifact turn found for "${parts[1]}".`,
32
+ });
33
+ return;
34
+ }
35
+ const removed = await removeArtifactTurn(workspace, selected.turnId);
36
+ const text = removed ? `Deleted artifact turn: ${selected.turnId}` : `Artifact turn not found: ${selected.turnId}`;
37
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
38
+ return;
39
+ }
40
+ const filtered = filterArtifactReports(reports, argument);
41
+ if (filtered) {
42
+ if (filtered.length === 0) {
43
+ await safeReply(ctx, escapeHTML(`No artifacts matched "${argument}".`), {
44
+ fallbackText: `No artifacts matched "${argument}".`,
45
+ });
46
+ return;
47
+ }
48
+ const rendered = renderArtifactReportsAction(filtered);
49
+ await safeReply(ctx, rendered.html, {
50
+ fallbackText: rendered.plain,
51
+ replyMarkup: buildArtifactActionsKeyboard(filtered),
52
+ });
53
+ return;
54
+ }
55
+ const shouldZip = parts[0]?.toLowerCase() === "zip";
56
+ const requestedTurn = shouldZip ? parts[1] : parts[0];
57
+ const selected = !requestedTurn || requestedTurn.toLowerCase() === "latest"
58
+ ? reports[0]
59
+ : reports.find((report) => report.turnId === requestedTurn || report.turnId.startsWith(requestedTurn));
60
+ if (!selected) {
61
+ await safeReply(ctx, escapeHTML(`No artifact turn found for "${argument}".`), {
62
+ fallbackText: `No artifact turn found for "${argument}".`,
63
+ });
64
+ return;
65
+ }
66
+ if (shouldZip) {
67
+ await options.deliverArtifactReportZip(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
68
+ }
69
+ else {
70
+ await options.deliverArtifactReport(ctx, ctx.chat.id, selected, ctx.message?.message_thread_id);
71
+ }
72
+ return;
73
+ }
74
+ const { html, plain } = renderArtifactReportsAction(reports);
75
+ await safeReply(ctx, html, {
76
+ fallbackText: plain,
77
+ replyMarkup: buildArtifactActionsKeyboard(reports),
78
+ });
79
+ });
80
+ bot.callbackQuery(/^artifact_(send|zip|delete|delete_confirm):([a-zA-Z0-9._-]+)$/, async (ctx) => {
81
+ const action = ctx.match?.[1];
82
+ const turnId = ctx.match?.[2];
83
+ const chatId = ctx.chat?.id;
84
+ const messageId = ctx.callbackQuery.message?.message_id;
85
+ if (!action || !turnId || !chatId) {
86
+ await ctx.answerCallbackQuery();
87
+ return;
88
+ }
89
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
90
+ if (!contextSession) {
91
+ await ctx.answerCallbackQuery({ text: "No context" });
92
+ return;
93
+ }
94
+ const workspace = contextSession.session.getInfo().workspace;
95
+ if (action === "delete") {
96
+ await ctx.answerCallbackQuery({ text: "Confirm deletion" });
97
+ const keyboard = new InlineKeyboard()
98
+ .text("Delete artifacts", `artifact_delete_confirm:${turnId}`)
99
+ .row()
100
+ .text("Cancel", NOOP_PAGE_CALLBACK_DATA);
101
+ const html = `<b>Delete artifact turn?</b>\n<code>${escapeHTML(turnId)}</code>`;
102
+ const plain = `Delete artifact turn?\n${turnId}`;
103
+ if (messageId) {
104
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain, replyMarkup: keyboard });
105
+ }
106
+ else {
107
+ await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
108
+ }
109
+ return;
110
+ }
111
+ if (action === "delete_confirm") {
112
+ const removed = await removeArtifactTurn(workspace, turnId);
113
+ await ctx.answerCallbackQuery({ text: removed ? "Deleted" : "Already gone" });
114
+ const html = removed
115
+ ? `<b>Deleted artifact turn:</b> <code>${escapeHTML(turnId)}</code>`
116
+ : `<b>Artifact turn not found:</b> <code>${escapeHTML(turnId)}</code>`;
117
+ const plain = removed ? `Deleted artifact turn: ${turnId}` : `Artifact turn not found: ${turnId}`;
118
+ if (messageId) {
119
+ await safeEditMessage(bot, chatId, messageId, html, { fallbackText: plain });
120
+ }
121
+ else {
122
+ await safeReply(ctx, html, { fallbackText: plain });
123
+ }
124
+ return;
125
+ }
126
+ const report = await getArtifactTurnReport(workspace, turnId, config.maxFileSize);
127
+ if (!report) {
128
+ await ctx.answerCallbackQuery({ text: "Artifact turn not found" });
129
+ return;
130
+ }
131
+ await ctx.answerCallbackQuery({ text: action === "zip" ? "Sending ZIP..." : "Sending artifacts..." });
132
+ if (action === "zip") {
133
+ await options.deliverArtifactReportZip(ctx, chatId, report, ctx.callbackQuery.message?.message_thread_id);
134
+ }
135
+ else {
136
+ await options.deliverArtifactReport(ctx, chatId, report, ctx.callbackQuery.message?.message_thread_id);
137
+ }
138
+ });
139
+ }
@@ -42,6 +42,7 @@ export async function registerCommands(bot) {
42
42
  { command: "version", description: "Connector version" },
43
43
  { command: "logs", description: "Admin: show connector logs" },
44
44
  { command: "diagnostics", description: "Admin: connector diagnostics" },
45
+ { command: "support", description: "Admin: export diagnostics bundle" },
45
46
  { command: "lock", description: "Lock session writes to you" },
46
47
  { command: "unlock", description: "Release session write lock" },
47
48
  { command: "locks", description: "List session write locks" },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ import { getAgentDiagnostics } from "./agent-activity.js";
2
+ import { formatQuietHours } from "./bot-preferences.js";
3
+ import { logTailRequests, parseLogsCommand, renderLogTailsAction, } from "./channel-actions.js";
4
+ import { checkAuthStatus } from "./codex-auth.js";
5
+ import { contextKeyFromCtx } from "./context-key.js";
6
+ import { escapeHTML } from "./format.js";
7
+ import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, } from "./operations.js";
8
+ import { formatCliPathHTML, formatCliPathPlain, renderAgentDiagnostics, renderDiagnosticsHTML, renderDiagnosticsPlain, renderHealthHTML, renderHealthPlain, renderVersionCheckHTML, renderVersionCheckPlain, } from "./bot-rendering.js";
9
+ import { getTelegramRateLimitMetrics } from "./telegram-rate-limit.js";
10
+ import { safeReply } from "./telegram-output.js";
11
+ export function registerTelegramDiagnosticsCommands(options) {
12
+ options.bot.command(["status", "health"], async (ctx) => {
13
+ const health = await getConnectorHealth(cliPathOptions(options.config));
14
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
15
+ const authStatus = contextSession
16
+ ? await options.checkAgentAuthStatus(contextSession.session.getInfo())
17
+ : await checkAuthStatus(options.config.codexApiKey);
18
+ const html = renderHealthHTML(health, authStatus.authenticated, options.getUserRole(ctx));
19
+ const plain = renderHealthPlain(health, authStatus.authenticated, options.getUserRole(ctx));
20
+ await safeReply(ctx, html, { fallbackText: plain });
21
+ });
22
+ options.bot.command("version", async (ctx) => {
23
+ const health = await getConnectorHealth(cliPathOptions(options.config));
24
+ const state = await readConnectorState();
25
+ const versions = await getVersionChecks(cliPathOptions(options.config));
26
+ const plain = [
27
+ renderVersionCheckPlain(versions.nordrelay),
28
+ `Runtime status: ${state.status ?? "unknown"}`,
29
+ formatCliPathPlain("Codex CLI", health.codexCliPath, health.codexCli),
30
+ renderVersionCheckPlain(versions.codex),
31
+ formatCliPathPlain("Pi CLI", health.piCliPath, health.piCli),
32
+ renderVersionCheckPlain(versions.pi),
33
+ formatCliPathPlain("Hermes CLI", health.hermesCliPath, health.hermesCli),
34
+ renderVersionCheckPlain(versions.hermes),
35
+ formatCliPathPlain("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
36
+ renderVersionCheckPlain(versions.openclaw),
37
+ formatCliPathPlain("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
38
+ renderVersionCheckPlain(versions.claudeCode),
39
+ ].join("\n");
40
+ const html = [
41
+ renderVersionCheckHTML(versions.nordrelay),
42
+ `<b>Runtime status:</b> <code>${escapeHTML(state.status ?? "unknown")}</code>`,
43
+ formatCliPathHTML("Codex CLI", health.codexCliPath, health.codexCli),
44
+ renderVersionCheckHTML(versions.codex),
45
+ formatCliPathHTML("Pi CLI", health.piCliPath, health.piCli),
46
+ renderVersionCheckHTML(versions.pi),
47
+ formatCliPathHTML("Hermes CLI", health.hermesCliPath, health.hermesCli),
48
+ renderVersionCheckHTML(versions.hermes),
49
+ formatCliPathHTML("OpenClaw CLI", health.openClawCliPath, health.openClawCli),
50
+ renderVersionCheckHTML(versions.openclaw),
51
+ formatCliPathHTML("Claude Code CLI", health.claudeCodeCliPath, health.claudeCodeCli),
52
+ renderVersionCheckHTML(versions.claudeCode),
53
+ ].join("\n");
54
+ await safeReply(ctx, html, { fallbackText: plain });
55
+ });
56
+ options.bot.command("diagnostics", async (ctx) => {
57
+ const health = await getConnectorHealth(cliPathOptions(options.config));
58
+ const contextKey = contextKeyFromCtx(ctx);
59
+ const queueLength = contextKey ? options.promptStore.list(contextKey).length : 0;
60
+ const progress = contextKey ? options.turnProgress.get(contextKey) : undefined;
61
+ const contextSession = contextKey ? await options.getContextSession(ctx, { deferThreadStart: true }) : null;
62
+ const authStatus = contextSession
63
+ ? await options.checkAgentAuthStatus(contextSession.session.getInfo())
64
+ : await checkAuthStatus(options.config.codexApiKey);
65
+ const agentDiagnostics = contextSession
66
+ ? renderAgentDiagnostics(getAgentDiagnostics(contextSession.session, options.config))
67
+ : { plain: "Agent state: no context", html: "<b>Agent state:</b> <code>no context</code>" };
68
+ const runtime = {
69
+ rateLimit: getTelegramRateLimitMetrics(),
70
+ externalMirrors: options.externalMirrors.size,
71
+ externalQueueTimers: options.externalQueueTimers.size,
72
+ queueStatusMessages: options.queueStatusMessages.size,
73
+ mirrorMode: contextKey ? options.getEffectiveMirrorMode(contextKey) : options.config.telegramMirrorMode,
74
+ notifyMode: contextKey ? options.getEffectiveNotifyMode(contextKey) : options.config.telegramNotifyMode,
75
+ quietHours: formatQuietHours(contextKey ? options.getEffectiveQuietHours(contextKey) : options.config.telegramQuietHours),
76
+ voiceBackend: contextKey ? options.getEffectiveVoiceBackend(contextKey) : options.config.voicePreferredBackend,
77
+ voiceLanguage: contextKey ? options.getEffectiveVoiceLanguage(contextKey) ?? "auto" : options.config.voiceDefaultLanguage ?? "auto",
78
+ voiceTranscribeOnly: contextKey ? options.isVoiceTranscribeOnly(contextKey) : options.config.voiceTranscribeOnly,
79
+ };
80
+ const plain = `${renderDiagnosticsPlain(options.config, options.registry, health, authStatus.authenticated, options.getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.plain}`;
81
+ const html = `${renderDiagnosticsHTML(options.config, options.registry, health, authStatus.authenticated, options.getUserRole(ctx), queueLength, progress, runtime)}\n${agentDiagnostics.html}`;
82
+ await safeReply(ctx, html, { fallbackText: plain });
83
+ });
84
+ options.bot.command("logs", async (ctx) => {
85
+ const rawText = ctx.message?.text ?? "";
86
+ const argument = rawText.replace(/^\/logs(?:@\w+)?\s*/i, "").trim();
87
+ const logRequest = parseLogsCommand(argument);
88
+ const logs = await Promise.all(logTailRequests(logRequest.target).map(async (request) => ({
89
+ title: request.title,
90
+ tail: await readFormattedLogTail(logRequest.lines, request.path),
91
+ })));
92
+ await options.replyChannelAction(ctx, renderLogTailsAction(logs));
93
+ });
94
+ }
95
+ function cliPathOptions(config) {
96
+ return {
97
+ piCliPath: config.piCliPath,
98
+ hermesCliPath: config.hermesCliPath,
99
+ openClawCliPath: config.openClawCliPath,
100
+ claudeCodeCliPath: config.claudeCodeCliPath,
101
+ };
102
+ }