@nordbyte/nordrelay 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +155 -64
- package/README.md +81 -65
- package/dist/access-control.js +126 -115
- package/dist/agent-updates.js +62 -9
- package/dist/bot-rendering.js +838 -0
- package/dist/bot-ui.js +1 -0
- package/dist/bot.js +342 -2498
- package/dist/channel-actions.js +8 -8
- package/dist/channel-runtime.js +89 -0
- package/dist/config-metadata.js +238 -0
- package/dist/config.js +0 -58
- package/dist/index.js +8 -0
- package/dist/operations.js +63 -9
- package/dist/relay-artifact-service.js +126 -0
- package/dist/relay-external-activity-monitor.js +216 -0
- package/dist/relay-queue-service.js +66 -0
- package/dist/relay-runtime-types.js +1 -0
- package/dist/relay-runtime.js +96 -354
- package/dist/settings-service.js +2 -117
- package/dist/support-bundle.js +205 -0
- package/dist/telegram-access-commands.js +123 -0
- package/dist/telegram-access-middleware.js +129 -0
- package/dist/telegram-agent-commands.js +212 -0
- package/dist/telegram-artifact-commands.js +139 -0
- package/dist/telegram-channel-runtime.js +132 -0
- package/dist/telegram-command-menu.js +55 -0
- package/dist/telegram-command-types.js +1 -0
- package/dist/telegram-diagnostics-command.js +102 -0
- package/dist/telegram-general-commands.js +52 -0
- package/dist/telegram-operational-commands.js +153 -0
- package/dist/telegram-output.js +216 -0
- package/dist/telegram-preference-commands.js +198 -0
- package/dist/telegram-queue-commands.js +278 -0
- package/dist/telegram-support-command.js +53 -0
- package/dist/telegram-update-commands.js +93 -0
- package/dist/user-management.js +708 -0
- package/dist/web-api-contract.js +104 -0
- package/dist/web-api-types.js +1 -0
- package/dist/web-dashboard-access-routes.js +163 -0
- package/dist/web-dashboard-artifact-routes.js +65 -0
- package/dist/web-dashboard-assets.js +35 -2
- package/dist/web-dashboard-http.js +143 -0
- package/dist/web-dashboard-pages.js +257 -0
- package/dist/web-dashboard-runtime-routes.js +92 -0
- package/dist/web-dashboard-session-routes.js +209 -0
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +330 -707
- package/dist/webui-assets/dashboard.css +989 -0
- package/dist/webui-assets/dashboard.js +1750 -0
- package/dist/zip-writer.js +83 -0
- package/package.json +13 -4
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/commands/remote.md +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
- package/dist/web-dashboard-client.js +0 -275
- package/dist/web-dashboard-style.js +0 -9
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Bot, InlineKeyboard, InputFile } from "grammy";
|
|
2
|
+
import { TelegramChannelAdapter, } from "./channel-adapter.js";
|
|
3
|
+
import { redactText } from "./redaction.js";
|
|
4
|
+
import { telegramRateLimiter } from "./telegram-rate-limit.js";
|
|
5
|
+
import { chatBucket, safeEditMessage, sendChatActionSafe, sendTextMessage, } from "./telegram-output.js";
|
|
6
|
+
const KEYBOARD_PAGE_SIZE = 6;
|
|
7
|
+
export const NOOP_PAGE_CALLBACK_DATA = "noop_page";
|
|
8
|
+
export function paginateKeyboard(items, page, prefix) {
|
|
9
|
+
const totalPages = Math.max(1, Math.ceil(items.length / KEYBOARD_PAGE_SIZE));
|
|
10
|
+
const currentPage = Math.min(Math.max(page, 0), totalPages - 1);
|
|
11
|
+
const start = currentPage * KEYBOARD_PAGE_SIZE;
|
|
12
|
+
const pageItems = items.slice(start, start + KEYBOARD_PAGE_SIZE);
|
|
13
|
+
const keyboard = new InlineKeyboard();
|
|
14
|
+
pageItems.forEach((item, index) => {
|
|
15
|
+
keyboard.text(item.label, item.callbackData);
|
|
16
|
+
if (index < pageItems.length - 1 || totalPages > 1) {
|
|
17
|
+
keyboard.row();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
if (totalPages > 1) {
|
|
21
|
+
if (currentPage > 0) {
|
|
22
|
+
keyboard.text("◀️ Prev", `${prefix}_page_${currentPage - 1}`);
|
|
23
|
+
}
|
|
24
|
+
keyboard.text(`${currentPage + 1}/${totalPages}`, NOOP_PAGE_CALLBACK_DATA);
|
|
25
|
+
if (currentPage < totalPages - 1) {
|
|
26
|
+
keyboard.text("Next ▶️", `${prefix}_page_${currentPage + 1}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return keyboard;
|
|
30
|
+
}
|
|
31
|
+
export function actionKeyboard(rows) {
|
|
32
|
+
if (!rows || rows.length === 0) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const keyboard = new InlineKeyboard();
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
for (const button of row) {
|
|
38
|
+
keyboard.text(button.label, telegramActionData(button.action));
|
|
39
|
+
}
|
|
40
|
+
keyboard.row();
|
|
41
|
+
}
|
|
42
|
+
return keyboard;
|
|
43
|
+
}
|
|
44
|
+
export function telegramActionData(action) {
|
|
45
|
+
if (action === "agent-update:jobs") {
|
|
46
|
+
return "upd_jobs";
|
|
47
|
+
}
|
|
48
|
+
const agentUpdateStart = action.match(/^agent-update:start:(.+)$/);
|
|
49
|
+
if (agentUpdateStart?.[1]) {
|
|
50
|
+
return `upd_agent:${agentUpdateStart[1]}`;
|
|
51
|
+
}
|
|
52
|
+
const agentUpdateLog = action.match(/^agent-update:log:(.+)$/);
|
|
53
|
+
if (agentUpdateLog?.[1]) {
|
|
54
|
+
return `upd_log:${agentUpdateLog[1]}`;
|
|
55
|
+
}
|
|
56
|
+
const agentUpdateCancel = action.match(/^agent-update:cancel:(.+)$/);
|
|
57
|
+
if (agentUpdateCancel?.[1]) {
|
|
58
|
+
return `upd_cancel:${agentUpdateCancel[1]}`;
|
|
59
|
+
}
|
|
60
|
+
return action;
|
|
61
|
+
}
|
|
62
|
+
export class TelegramBotChannelRuntime {
|
|
63
|
+
bot;
|
|
64
|
+
id = "telegram";
|
|
65
|
+
label = "Telegram";
|
|
66
|
+
capabilities = new TelegramChannelAdapter().capabilities;
|
|
67
|
+
constructor(bot) {
|
|
68
|
+
this.bot = bot;
|
|
69
|
+
}
|
|
70
|
+
describe() {
|
|
71
|
+
return new TelegramChannelAdapter().describe();
|
|
72
|
+
}
|
|
73
|
+
async sendMessage(context, message) {
|
|
74
|
+
const sent = await sendTextMessage(this.bot.api, telegramChatIdFromChannelContext(context), message.text, {
|
|
75
|
+
parseMode: telegramParseMode(message.parseMode),
|
|
76
|
+
fallbackText: message.fallbackText,
|
|
77
|
+
replyMarkup: actionKeyboard(message.buttons),
|
|
78
|
+
messageThreadId: telegramThreadIdFromChannelContext(context, message.threadId),
|
|
79
|
+
});
|
|
80
|
+
return { messageId: String(sent.message_id) };
|
|
81
|
+
}
|
|
82
|
+
async editMessage(context, messageId, message) {
|
|
83
|
+
const parsedMessageId = Number.parseInt(messageId, 10);
|
|
84
|
+
if (!Number.isFinite(parsedMessageId)) {
|
|
85
|
+
throw new Error(`Invalid Telegram message id: ${messageId}`);
|
|
86
|
+
}
|
|
87
|
+
await safeEditMessage(this.bot, telegramChatIdFromChannelContext(context), parsedMessageId, message.text, {
|
|
88
|
+
parseMode: telegramParseMode(message.parseMode),
|
|
89
|
+
fallbackText: message.fallbackText,
|
|
90
|
+
replyMarkup: actionKeyboard(message.buttons),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async sendTyping(context) {
|
|
94
|
+
await sendChatActionSafe(this.bot.api, telegramChatIdFromChannelContext(context), "typing", telegramThreadIdFromChannelContext(context));
|
|
95
|
+
}
|
|
96
|
+
async sendFile(context, file) {
|
|
97
|
+
const chatId = telegramChatIdFromChannelContext(context);
|
|
98
|
+
const sent = await telegramRateLimiter.run(chatBucket(chatId), "sendDocument", () => this.bot.api.sendDocument(chatId, new InputFile(file.localPath, file.name), {
|
|
99
|
+
caption: file.caption ? redactText(file.caption) : undefined,
|
|
100
|
+
message_thread_id: telegramThreadIdFromChannelContext(context, file.threadId),
|
|
101
|
+
}));
|
|
102
|
+
return { messageId: String(sent.message_id) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function telegramChannelContextFromCtx(ctx) {
|
|
106
|
+
if (!ctx.chat?.id) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const topicId = ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id;
|
|
110
|
+
return {
|
|
111
|
+
channelId: "telegram",
|
|
112
|
+
chatId: String(ctx.chat.id),
|
|
113
|
+
...(topicId ? { topicId: String(topicId) } : {}),
|
|
114
|
+
...(ctx.from?.id ? { userId: String(ctx.from.id) } : {}),
|
|
115
|
+
...(ctx.from?.username ? { username: ctx.from.username } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export function telegramChatIdFromChannelContext(context) {
|
|
119
|
+
const numeric = Number(context.chatId);
|
|
120
|
+
return Number.isSafeInteger(numeric) ? numeric : context.chatId;
|
|
121
|
+
}
|
|
122
|
+
export function telegramThreadIdFromChannelContext(context, override) {
|
|
123
|
+
const value = override ?? context.topicId;
|
|
124
|
+
if (!value) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const numeric = Number(value);
|
|
128
|
+
return Number.isSafeInteger(numeric) ? numeric : undefined;
|
|
129
|
+
}
|
|
130
|
+
export function telegramParseMode(parseMode) {
|
|
131
|
+
return parseMode === "html" ? "HTML" : undefined;
|
|
132
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export async function registerCommands(bot) {
|
|
2
|
+
await bot.api.setMyCommands([
|
|
3
|
+
{ command: "start", description: "Welcome & status" },
|
|
4
|
+
{ command: "help", description: "Command reference" },
|
|
5
|
+
{ command: "link", description: "Link Telegram to NordRelay user" },
|
|
6
|
+
{ command: "whoami", description: "Show your NordRelay user" },
|
|
7
|
+
{ command: "register_chat", description: "Admin: enable this group chat" },
|
|
8
|
+
{ command: "channels", description: "Messaging adapter status" },
|
|
9
|
+
{ command: "agents", description: "Agent adapter status" },
|
|
10
|
+
{ command: "agent", description: "Select agent" },
|
|
11
|
+
{ command: "new", description: "Start a new thread" },
|
|
12
|
+
{ command: "session", description: "Current thread details" },
|
|
13
|
+
{ command: "sessions", description: "Browse & switch threads" },
|
|
14
|
+
{ command: "sync", description: "Sync active session from CLI state" },
|
|
15
|
+
{ command: "pinned", description: "Show pinned threads" },
|
|
16
|
+
{ command: "pin", description: "Pin current or given thread" },
|
|
17
|
+
{ command: "unpin", description: "Unpin current or given thread" },
|
|
18
|
+
{ command: "retry", description: "Resend the last prompt" },
|
|
19
|
+
{ command: "queue", description: "Show queued prompts" },
|
|
20
|
+
{ command: "cancel", description: "Cancel a queued prompt" },
|
|
21
|
+
{ command: "clearqueue", description: "Clear queued prompts" },
|
|
22
|
+
{ command: "artifacts", description: "List or resend generated files" },
|
|
23
|
+
{ command: "workspaces", description: "List allowed workspaces" },
|
|
24
|
+
{ command: "abort", description: "Cancel current operation" },
|
|
25
|
+
{ command: "stop", description: "Cancel current operation" },
|
|
26
|
+
{ command: "launch_profiles", description: "Select launch profile" },
|
|
27
|
+
{ command: "fast", description: "Toggle fast mode" },
|
|
28
|
+
{ command: "model", description: "View & change model" },
|
|
29
|
+
{ command: "reasoning", description: "Set reasoning effort" },
|
|
30
|
+
{ command: "mirror", description: "Control CLI mirroring" },
|
|
31
|
+
{ command: "notify", description: "Control notifications" },
|
|
32
|
+
{ command: "auth", description: "Check auth status" },
|
|
33
|
+
{ command: "login", description: "Start authentication" },
|
|
34
|
+
{ command: "logout", description: "Sign out" },
|
|
35
|
+
{ command: "voice", description: "Voice transcription status" },
|
|
36
|
+
{ command: "tasks", description: "Current turn progress" },
|
|
37
|
+
{ command: "progress", description: "Current turn progress" },
|
|
38
|
+
{ command: "activity", description: "Thread activity timeline" },
|
|
39
|
+
{ command: "audit", description: "Admin: recent audit events" },
|
|
40
|
+
{ command: "status", description: "Connector runtime status" },
|
|
41
|
+
{ command: "health", description: "Connector health report" },
|
|
42
|
+
{ command: "version", description: "Connector version" },
|
|
43
|
+
{ command: "logs", description: "Admin: show connector logs" },
|
|
44
|
+
{ command: "diagnostics", description: "Admin: connector diagnostics" },
|
|
45
|
+
{ command: "support", description: "Admin: export diagnostics bundle" },
|
|
46
|
+
{ command: "lock", description: "Lock session writes to you" },
|
|
47
|
+
{ command: "unlock", description: "Release session write lock" },
|
|
48
|
+
{ command: "locks", description: "List session write locks" },
|
|
49
|
+
{ command: "restart", description: "Admin: restart connector" },
|
|
50
|
+
{ command: "update", description: "Admin: update connector or agents" },
|
|
51
|
+
{ command: "handback", description: "Hand session back to CLI" },
|
|
52
|
+
{ command: "attach", description: "Bind a session to this topic" },
|
|
53
|
+
{ command: "switch", description: "Switch to a thread by ID" },
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|