@nordbyte/nordrelay 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.env.example +155 -64
  2. package/README.md +81 -65
  3. package/dist/access-control.js +126 -115
  4. package/dist/agent-updates.js +62 -9
  5. package/dist/bot-rendering.js +838 -0
  6. package/dist/bot-ui.js +1 -0
  7. package/dist/bot.js +342 -2498
  8. package/dist/channel-actions.js +8 -8
  9. package/dist/channel-runtime.js +89 -0
  10. package/dist/config-metadata.js +238 -0
  11. package/dist/config.js +0 -58
  12. package/dist/index.js +8 -0
  13. package/dist/operations.js +63 -9
  14. package/dist/relay-artifact-service.js +126 -0
  15. package/dist/relay-external-activity-monitor.js +216 -0
  16. package/dist/relay-queue-service.js +66 -0
  17. package/dist/relay-runtime-types.js +1 -0
  18. package/dist/relay-runtime.js +96 -354
  19. package/dist/settings-service.js +2 -117
  20. package/dist/support-bundle.js +205 -0
  21. package/dist/telegram-access-commands.js +123 -0
  22. package/dist/telegram-access-middleware.js +129 -0
  23. package/dist/telegram-agent-commands.js +212 -0
  24. package/dist/telegram-artifact-commands.js +139 -0
  25. package/dist/telegram-channel-runtime.js +132 -0
  26. package/dist/telegram-command-menu.js +55 -0
  27. package/dist/telegram-command-types.js +1 -0
  28. package/dist/telegram-diagnostics-command.js +102 -0
  29. package/dist/telegram-general-commands.js +52 -0
  30. package/dist/telegram-operational-commands.js +153 -0
  31. package/dist/telegram-output.js +216 -0
  32. package/dist/telegram-preference-commands.js +198 -0
  33. package/dist/telegram-queue-commands.js +278 -0
  34. package/dist/telegram-support-command.js +53 -0
  35. package/dist/telegram-update-commands.js +93 -0
  36. package/dist/user-management.js +708 -0
  37. package/dist/web-api-contract.js +104 -0
  38. package/dist/web-api-types.js +1 -0
  39. package/dist/web-dashboard-access-routes.js +163 -0
  40. package/dist/web-dashboard-artifact-routes.js +65 -0
  41. package/dist/web-dashboard-assets.js +35 -2
  42. package/dist/web-dashboard-http.js +143 -0
  43. package/dist/web-dashboard-pages.js +257 -0
  44. package/dist/web-dashboard-runtime-routes.js +92 -0
  45. package/dist/web-dashboard-session-routes.js +209 -0
  46. package/dist/web-dashboard-ui.js +14 -14
  47. package/dist/web-dashboard.js +330 -707
  48. package/dist/webui-assets/dashboard.css +989 -0
  49. package/dist/webui-assets/dashboard.js +1750 -0
  50. package/dist/zip-writer.js +83 -0
  51. package/package.json +13 -4
  52. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  53. package/plugins/nordrelay/commands/remote.md +1 -1
  54. package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
  55. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
  56. package/dist/web-dashboard-client.js +0 -275
  57. package/dist/web-dashboard-style.js +0 -9
@@ -0,0 +1,52 @@
1
+ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
2
+ import { enabledAgents } from "./agent-factory.js";
3
+ import { renderWelcomeFirstTime, renderWelcomeReturning, renderHelpMessage, } from "./bot-ui.js";
4
+ import { authHelpText, capabilitiesOf, labelOf, } from "./bot-rendering.js";
5
+ import { renderAgentsAction, renderChannelsAction, } from "./channel-actions.js";
6
+ import { listChannelDescriptors } from "./channel-adapter.js";
7
+ import { escapeHTML } from "./format.js";
8
+ import { spawnConnectorRestart } from "./operations.js";
9
+ import { renderLaunchSummaryHTML, renderLaunchSummaryPlain, renderSessionInfoHTML, renderSessionInfoPlain, } from "./session-format.js";
10
+ import { safeReply } from "./telegram-output.js";
11
+ export function registerTelegramGeneralCommands(options) {
12
+ options.bot.command("start", async (ctx) => {
13
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
14
+ if (!contextSession) {
15
+ return;
16
+ }
17
+ const { contextKey, session } = contextSession;
18
+ const info = session.getInfo();
19
+ const authStatus = capabilitiesOf(info).auth ? await options.checkAgentAuthStatus(info) : null;
20
+ const authWarning = authStatus && !authStatus.authenticated
21
+ ? [`${labelOf(info)} is not authenticated.`, authStatus.detail, authHelpText(info)].filter(Boolean).join(" ")
22
+ : undefined;
23
+ const isReturning = options.registry.hasMetadata(contextKey);
24
+ if (isReturning) {
25
+ const welcome = renderWelcomeReturning(renderSessionInfoHTML(info), renderSessionInfoPlain(info), options.isTopicContext(contextKey), authWarning);
26
+ await safeReply(ctx, welcome.html, { fallbackText: welcome.plain });
27
+ return;
28
+ }
29
+ const welcome = renderWelcomeFirstTime(authWarning);
30
+ await safeReply(ctx, [welcome.html, "", renderLaunchSummaryHTML(info)].join("\n"), {
31
+ fallbackText: [welcome.plain, "", renderLaunchSummaryPlain(info)].join("\n"),
32
+ });
33
+ });
34
+ options.bot.command("help", async (ctx) => {
35
+ const help = renderHelpMessage();
36
+ await safeReply(ctx, help.html, { fallbackText: help.plain });
37
+ });
38
+ options.bot.command("channels", async (ctx) => {
39
+ await options.replyChannelAction(ctx, renderChannelsAction(listChannelDescriptors()));
40
+ });
41
+ options.bot.command("agents", async (ctx) => {
42
+ await options.replyChannelAction(ctx, renderAgentsAction(listAgentAdapterDescriptors(), enabledAgents(options.config)));
43
+ });
44
+ options.bot.command("restart", async (ctx) => {
45
+ await safeReply(ctx, escapeHTML("Restarting connector..."), {
46
+ fallbackText: "Restarting connector...",
47
+ });
48
+ setTimeout(() => {
49
+ spawnConnectorRestart();
50
+ }, 300);
51
+ });
52
+ }
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { unlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { InputFile } from "grammy";
6
+ import { getAgentActivityLog } from "./agent-activity.js";
7
+ import { capabilitiesOf, filterActivityEvents, formatLocalDateTime, formatLockOwner, formatTelegramName, labelOf, parseActivityOptions, renderActivityTimeline, renderAuditEvents, renderProgressHTML, renderProgressPlain, renderSessionLocks, } from "./bot-rendering.js";
8
+ import { escapeHTML } from "./format.js";
9
+ import { renderSessionInfoHTML, renderSessionInfoPlain } from "./session-format.js";
10
+ import { chatBucket, safeReply } from "./telegram-output.js";
11
+ import { telegramRateLimiter } from "./telegram-rate-limit.js";
12
+ export function registerTelegramOperationalCommands(options) {
13
+ const { bot, config, promptStore, auditLog, lockStore, turnProgress, } = options;
14
+ bot.command(["tasks", "progress"], async (ctx) => {
15
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
16
+ if (!contextSession) {
17
+ return;
18
+ }
19
+ const progress = turnProgress.get(contextSession.contextKey);
20
+ const queue = promptStore.list(contextSession.contextKey);
21
+ const externalActivity = options.getExternalActivity(contextSession.session);
22
+ const busyState = {
23
+ ...options.getBusyState(contextSession.contextKey),
24
+ external: Boolean(externalActivity?.active),
25
+ };
26
+ const info = contextSession.session.getInfo();
27
+ const plain = renderProgressPlain(progress, queue.length, busyState, info);
28
+ const html = renderProgressHTML(progress, queue.length, busyState, info);
29
+ await safeReply(ctx, html, { fallbackText: plain });
30
+ });
31
+ bot.command("activity", async (ctx) => {
32
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
33
+ if (!contextSession) {
34
+ return;
35
+ }
36
+ const info = contextSession.session.getInfo();
37
+ if (!capabilitiesOf(info).activityLog) {
38
+ const text = `${labelOf(info)} activity timelines are not available yet.`;
39
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
40
+ return;
41
+ }
42
+ const threadId = contextSession.session.getActiveThreadId();
43
+ if (!threadId) {
44
+ await safeReply(ctx, escapeHTML("No active thread yet."), { fallbackText: "No active thread yet." });
45
+ return;
46
+ }
47
+ const activityOptions = parseActivityOptions((ctx.message?.text ?? "").replace(/^\/activity(?:@\w+)?\s*/i, "").trim());
48
+ const events = filterActivityEvents(getAgentActivityLog(contextSession.session, config, activityOptions.exportFile ? 200 : activityOptions.limit), activityOptions);
49
+ const rendered = renderActivityTimeline(threadId, events, activityOptions);
50
+ if (activityOptions.exportFile && ctx.chat) {
51
+ const exportPath = path.join(tmpdir(), `nordrelay-activity-${threadId}-${randomUUID().slice(0, 8)}.txt`);
52
+ await writeFile(exportPath, rendered.plain, "utf8");
53
+ try {
54
+ await telegramRateLimiter.run(chatBucket(ctx.chat.id), "sendDocument", () => ctx.api.sendDocument(ctx.chat.id, new InputFile(exportPath, path.basename(exportPath)), {
55
+ ...(ctx.message?.message_thread_id ? { message_thread_id: ctx.message.message_thread_id } : {}),
56
+ }));
57
+ }
58
+ finally {
59
+ await unlink(exportPath).catch(() => { });
60
+ }
61
+ return;
62
+ }
63
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
64
+ });
65
+ bot.command("audit", async (ctx) => {
66
+ const rawText = ctx.message?.text ?? "";
67
+ const limitArg = rawText.replace(/^\/audit(?:@\w+)?\s*/i, "").trim();
68
+ const limit = /^\d+$/.test(limitArg) ? Number(limitArg) : 20;
69
+ const events = auditLog.list(limit);
70
+ const rendered = renderAuditEvents(events);
71
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
72
+ });
73
+ bot.command("lock", async (ctx) => {
74
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
75
+ if (!contextSession || !ctx.from) {
76
+ return;
77
+ }
78
+ const { contextKey, session } = contextSession;
79
+ const existing = lockStore.get(contextKey);
80
+ if (existing && existing.ownerId !== ctx.from.id && !options.isAdminUser(ctx)) {
81
+ const text = `Session is already locked by ${formatLockOwner(existing)}.`;
82
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
83
+ return;
84
+ }
85
+ const lock = lockStore.set(contextKey, ctx.from.id, formatTelegramName(ctx), config.sessionLockTtlMs);
86
+ options.auditContext(ctx, contextKey, session, {
87
+ action: "lock_updated",
88
+ status: "ok",
89
+ detail: `locked by ${lock.ownerId}`,
90
+ });
91
+ const text = `Session locked by ${formatLockOwner(lock)}${lock.expiresAt ? ` until ${formatLocalDateTime(new Date(lock.expiresAt))}` : ""}.`;
92
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
93
+ });
94
+ bot.command("unlock", async (ctx) => {
95
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
96
+ if (!contextSession) {
97
+ return;
98
+ }
99
+ const { contextKey, session } = contextSession;
100
+ const lock = lockStore.get(contextKey);
101
+ if (lock && lock.ownerId !== ctx.from?.id && !options.isAdminUser(ctx)) {
102
+ const text = `Only ${formatLockOwner(lock)} or an admin can unlock this session.`;
103
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
104
+ return;
105
+ }
106
+ const removed = lockStore.clear(contextKey);
107
+ options.auditContext(ctx, contextKey, session, {
108
+ action: "lock_updated",
109
+ status: "ok",
110
+ detail: removed ? "unlocked" : "no lock",
111
+ });
112
+ const text = removed ? "Session lock released." : "No active lock for this session.";
113
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
114
+ });
115
+ bot.command("locks", async (ctx) => {
116
+ const locks = lockStore.list();
117
+ const rendered = renderSessionLocks(locks);
118
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
119
+ });
120
+ bot.command("sync", async (ctx) => {
121
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
122
+ if (!contextSession) {
123
+ return;
124
+ }
125
+ const sessionInfo = contextSession.session.getInfo();
126
+ if (!capabilitiesOf(sessionInfo).externalActivity) {
127
+ const plain = [`${labelOf(sessionInfo)} has no external CLI state watcher to sync.`, "", renderSessionInfoPlain(sessionInfo)].join("\n");
128
+ const html = [`<b>${escapeHTML(labelOf(sessionInfo))} has no external CLI state watcher to sync.</b>`, "", renderSessionInfoHTML(sessionInfo)].join("\n");
129
+ await safeReply(ctx, html, { fallbackText: plain });
130
+ return;
131
+ }
132
+ const result = contextSession.session.syncFromAgentState({ reattach: true });
133
+ if (result.changed) {
134
+ options.updateSessionMetadata(contextSession.contextKey, contextSession.session);
135
+ }
136
+ const fields = result.changedFields.length > 0 ? result.changedFields.join(", ") : "none";
137
+ const plain = [
138
+ result.changed ? `Synced from ${labelOf(sessionInfo)} state.` : "Already in sync.",
139
+ `Changed: ${fields}`,
140
+ `Reattached: ${result.reattached ? "yes" : "no"}`,
141
+ "",
142
+ renderSessionInfoPlain(result.info),
143
+ ].join("\n");
144
+ const html = [
145
+ result.changed ? `<b>Synced from ${escapeHTML(labelOf(sessionInfo))} state.</b>` : "<b>Already in sync.</b>",
146
+ `<b>Changed:</b> <code>${escapeHTML(fields)}</code>`,
147
+ `<b>Reattached:</b> <code>${result.reattached ? "yes" : "no"}</code>`,
148
+ "",
149
+ renderSessionInfoHTML(result.info),
150
+ ].join("\n");
151
+ await safeReply(ctx, html, { fallbackText: plain });
152
+ });
153
+ }
@@ -0,0 +1,216 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { Bot, InlineKeyboard } from "grammy";
6
+ import { formatTelegramHTML } from "./format.js";
7
+ import { redactText } from "./redaction.js";
8
+ import { telegramRateLimiter } from "./telegram-rate-limit.js";
9
+ const TELEGRAM_MESSAGE_LIMIT = 4000;
10
+ const FORMATTED_CHUNK_TARGET = 3000;
11
+ const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
12
+ export async function safeReply(ctx, text, options = {}) {
13
+ const chatId = ctx.chat?.id;
14
+ if (!chatId) {
15
+ return;
16
+ }
17
+ const parseMode = options.parseMode !== undefined ? options.parseMode : "HTML";
18
+ const messageThreadId = options.messageThreadId ?? ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id;
19
+ const chunks = splitTelegramText(redactText(text));
20
+ const fallbackChunks = options.fallbackText ? splitTelegramText(redactText(options.fallbackText)) : [];
21
+ for (const [index, chunk] of chunks.entries()) {
22
+ await sendTextMessage(ctx.api, chatId, chunk, {
23
+ parseMode,
24
+ fallbackText: fallbackChunks[index] ?? chunk,
25
+ replyMarkup: index === 0 ? options.replyMarkup : undefined,
26
+ messageThreadId,
27
+ });
28
+ }
29
+ }
30
+ export async function sendTextMessage(api, chatId, text, options = {}) {
31
+ const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
32
+ const safeText = redactText(text);
33
+ const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
34
+ const bucket = chatBucket(chatId);
35
+ try {
36
+ return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeText, {
37
+ ...(parseMode ? { parse_mode: parseMode } : {}),
38
+ ...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
39
+ reply_markup: options.replyMarkup,
40
+ }));
41
+ }
42
+ catch (error) {
43
+ if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
44
+ return await telegramRateLimiter.run(bucket, "sendMessage", () => api.sendMessage(chatId, safeFallbackText, {
45
+ ...(options.messageThreadId ? { message_thread_id: options.messageThreadId } : {}),
46
+ reply_markup: options.replyMarkup,
47
+ }));
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+ export async function safeEditMessage(bot, chatId, messageId, text, options = {}) {
53
+ const parseMode = Object.prototype.hasOwnProperty.call(options, "parseMode") ? options.parseMode : "HTML";
54
+ const safeText = redactText(text);
55
+ const safeFallbackText = options.fallbackText === undefined ? undefined : redactText(options.fallbackText);
56
+ const bucket = `${chatBucket(chatId)}:${messageId}`;
57
+ try {
58
+ await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeText, {
59
+ ...(parseMode ? { parse_mode: parseMode } : {}),
60
+ reply_markup: options.replyMarkup,
61
+ }));
62
+ }
63
+ catch (error) {
64
+ if (isMessageNotModifiedError(error)) {
65
+ return;
66
+ }
67
+ if (parseMode && safeFallbackText !== undefined && isTelegramParseError(error)) {
68
+ await telegramRateLimiter.run(bucket, "editMessageText", () => bot.api.editMessageText(chatId, messageId, safeFallbackText, {
69
+ reply_markup: options.replyMarkup,
70
+ }));
71
+ return;
72
+ }
73
+ throw error;
74
+ }
75
+ }
76
+ export async function safeEditReplyMarkup(bot, chatId, messageId, replyMarkup) {
77
+ try {
78
+ await telegramRateLimiter.run(`${chatBucket(chatId)}:${messageId}`, "editMessageReplyMarkup", () => bot.api.editMessageReplyMarkup(chatId, messageId, {
79
+ reply_markup: replyMarkup ?? new InlineKeyboard(),
80
+ }));
81
+ }
82
+ catch (error) {
83
+ if (!isMessageNotModifiedError(error)) {
84
+ throw error;
85
+ }
86
+ }
87
+ }
88
+ export async function sendChatActionSafe(api, chatId, action, messageThreadId) {
89
+ await telegramRateLimiter.run(chatBucket(chatId), "sendChatAction", () => api.sendChatAction(chatId, action, {
90
+ ...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
91
+ }));
92
+ }
93
+ export function chatBucket(chatId) {
94
+ return `chat:${String(chatId)}`;
95
+ }
96
+ export async function downloadTelegramFile(api, token, fileId, maxBytes = MAX_AUDIO_FILE_SIZE) {
97
+ const file = await api.getFile(fileId);
98
+ if (!file.file_path) {
99
+ throw new Error("Telegram did not return a file path");
100
+ }
101
+ if (file.file_size && file.file_size > maxBytes) {
102
+ throw new Error(`Telegram file too large (${Math.round(file.file_size / 1024 / 1024)} MB, max ${Math.round(maxBytes / 1024 / 1024)} MB)`);
103
+ }
104
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
105
+ const response = await fetch(url);
106
+ if (!response.ok) {
107
+ throw new Error(`Failed to download Telegram file: ${response.status}`);
108
+ }
109
+ const buffer = Buffer.from(await response.arrayBuffer());
110
+ const extension = path.extname(file.file_path) || ".bin";
111
+ const tempPath = path.join(tmpdir(), `nordrelay-file-${randomUUID()}${extension}`);
112
+ await writeFile(tempPath, buffer);
113
+ return tempPath;
114
+ }
115
+ export function splitMarkdownForTelegram(markdown) {
116
+ if (!markdown) {
117
+ return [];
118
+ }
119
+ const chunks = [];
120
+ let remaining = markdown;
121
+ while (remaining) {
122
+ const maxLength = Math.min(remaining.length, FORMATTED_CHUNK_TARGET);
123
+ const initialCut = findPreferredSplitIndex(remaining, maxLength);
124
+ const candidate = remaining.slice(0, initialCut) || remaining.slice(0, 1);
125
+ const rendered = renderMarkdownChunkWithinLimit(candidate);
126
+ chunks.push(rendered);
127
+ remaining = remaining.slice(rendered.sourceText.length).trimStart();
128
+ }
129
+ return chunks;
130
+ }
131
+ export function renderMarkdownChunkWithinLimit(markdown) {
132
+ if (!markdown) {
133
+ return {
134
+ text: "",
135
+ fallbackText: "",
136
+ parseMode: "HTML",
137
+ sourceText: "",
138
+ };
139
+ }
140
+ let sourceText = markdown;
141
+ let rendered = formatMarkdownMessage(sourceText);
142
+ while (rendered.text.length > TELEGRAM_MESSAGE_LIMIT && sourceText.length > 1) {
143
+ const nextLength = Math.max(1, sourceText.length - Math.max(100, Math.ceil(sourceText.length * 0.1)));
144
+ sourceText = sourceText.slice(0, nextLength).trimEnd() || sourceText.slice(0, nextLength);
145
+ rendered = formatMarkdownMessage(sourceText);
146
+ }
147
+ return {
148
+ ...rendered,
149
+ sourceText,
150
+ };
151
+ }
152
+ export function isMessageNotModifiedError(error) {
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ return message.includes("message is not modified");
155
+ }
156
+ function splitTelegramText(text) {
157
+ if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
158
+ return [text];
159
+ }
160
+ const chunks = [];
161
+ let remaining = text;
162
+ while (remaining.length > TELEGRAM_MESSAGE_LIMIT) {
163
+ let cut = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
164
+ if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
165
+ cut = remaining.lastIndexOf(" ", TELEGRAM_MESSAGE_LIMIT);
166
+ }
167
+ if (cut < TELEGRAM_MESSAGE_LIMIT * 0.5) {
168
+ cut = TELEGRAM_MESSAGE_LIMIT;
169
+ }
170
+ chunks.push(remaining.slice(0, cut).trimEnd());
171
+ remaining = remaining.slice(cut).trimStart();
172
+ }
173
+ if (remaining) {
174
+ chunks.push(remaining);
175
+ }
176
+ return chunks.length > 0 ? chunks : [""];
177
+ }
178
+ function formatMarkdownMessage(markdown) {
179
+ try {
180
+ return {
181
+ text: formatTelegramHTML(markdown),
182
+ fallbackText: markdown,
183
+ parseMode: "HTML",
184
+ };
185
+ }
186
+ catch (error) {
187
+ console.error("Failed to format Telegram HTML, falling back to plain text", error);
188
+ return {
189
+ text: markdown,
190
+ fallbackText: markdown,
191
+ parseMode: undefined,
192
+ };
193
+ }
194
+ }
195
+ function findPreferredSplitIndex(text, maxLength) {
196
+ if (text.length <= maxLength) {
197
+ return Math.max(1, text.length);
198
+ }
199
+ const newlineIndex = text.lastIndexOf("\n", maxLength);
200
+ if (newlineIndex >= maxLength * 0.5) {
201
+ return Math.max(1, newlineIndex);
202
+ }
203
+ const spaceIndex = text.lastIndexOf(" ", maxLength);
204
+ if (spaceIndex >= maxLength * 0.5) {
205
+ return Math.max(1, spaceIndex);
206
+ }
207
+ return Math.max(1, maxLength);
208
+ }
209
+ function isTelegramParseError(error) {
210
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
211
+ return (message.includes("can't parse entities") ||
212
+ message.includes("unsupported start tag") ||
213
+ message.includes("unexpected end tag") ||
214
+ message.includes("entity name") ||
215
+ message.includes("parse entities"));
216
+ }
@@ -0,0 +1,198 @@
1
+ import { formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
2
+ import { capabilitiesOf, idOf, labelOf, parseToggle, } from "./bot-rendering.js";
3
+ import { friendlyErrorText } from "./error-messages.js";
4
+ import { escapeHTML } from "./format.js";
5
+ import { getAvailableBackends } from "./voice.js";
6
+ import { evaluateWorkspacePolicy, filterAllowedWorkspaces, renderWorkspacePolicyLine, } from "./workspace-policy.js";
7
+ import { safeReply } from "./telegram-output.js";
8
+ export function registerTelegramPreferenceCommands(options) {
9
+ options.bot.command("mirror", async (ctx) => {
10
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
11
+ if (!contextSession) {
12
+ return;
13
+ }
14
+ const { contextKey, session } = contextSession;
15
+ if (!capabilitiesOf(session.getInfo()).cliMirror) {
16
+ const text = `CLI mirroring is not supported for ${labelOf(session.getInfo())} yet.`;
17
+ await safeReply(ctx, escapeHTML(text), { fallbackText: text });
18
+ return;
19
+ }
20
+ const argument = (ctx.message?.text ?? "").replace(/^\/mirror(?:@\w+)?\s*/i, "").trim();
21
+ if (argument) {
22
+ const mode = parseMirrorMode(argument, options.getEffectiveMirrorMode(contextKey));
23
+ if (!["off", "status", "final", "full"].includes(argument.toLowerCase())) {
24
+ await safeReply(ctx, escapeHTML("Usage: /mirror [off|status|final|full]"), {
25
+ fallbackText: "Usage: /mirror [off|status|final|full]",
26
+ });
27
+ return;
28
+ }
29
+ options.preferencesStore.update(contextKey, { mirrorMode: mode });
30
+ }
31
+ const mode = options.getEffectiveMirrorMode(contextKey);
32
+ const plain = [
33
+ `CLI mirroring: ${mode}`,
34
+ `Minimum update interval: ${options.config.telegramMirrorMinUpdateMs} ms`,
35
+ "Modes: off, status, final, full",
36
+ ].join("\n");
37
+ const html = [
38
+ `<b>CLI mirroring:</b> <code>${escapeHTML(mode)}</code>`,
39
+ `<b>Minimum update interval:</b> <code>${options.config.telegramMirrorMinUpdateMs} ms</code>`,
40
+ "<b>Modes:</b> <code>off</code>, <code>status</code>, <code>final</code>, <code>full</code>",
41
+ ].join("\n");
42
+ await safeReply(ctx, html, { fallbackText: plain });
43
+ });
44
+ options.bot.command("notify", async (ctx) => {
45
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
46
+ if (!contextSession) {
47
+ return;
48
+ }
49
+ const { contextKey } = contextSession;
50
+ const argument = (ctx.message?.text ?? "").replace(/^\/notify(?:@\w+)?\s*/i, "").trim();
51
+ if (argument) {
52
+ const quietMatch = argument.match(/^quiet\s+(.+)$/i);
53
+ if (quietMatch) {
54
+ let quietHours;
55
+ try {
56
+ quietHours = quietMatch[1].toLowerCase() === "off" ? null : parseQuietHours(quietMatch[1]);
57
+ }
58
+ catch (error) {
59
+ await safeReply(ctx, escapeHTML(`Invalid quiet hours: ${friendlyErrorText(error)}`), {
60
+ fallbackText: `Invalid quiet hours: ${friendlyErrorText(error)}`,
61
+ });
62
+ return;
63
+ }
64
+ options.preferencesStore.update(contextKey, { quietHours });
65
+ }
66
+ else {
67
+ const mode = parseNotifyMode(argument, options.getEffectiveNotifyMode(contextKey));
68
+ if (!["off", "minimal", "all"].includes(argument.toLowerCase())) {
69
+ await safeReply(ctx, escapeHTML("Usage: /notify [off|minimal|all] or /notify quiet HH-HH"), {
70
+ fallbackText: "Usage: /notify [off|minimal|all] or /notify quiet HH-HH",
71
+ });
72
+ return;
73
+ }
74
+ options.preferencesStore.update(contextKey, { notifyMode: mode });
75
+ }
76
+ }
77
+ const mode = options.getEffectiveNotifyMode(contextKey);
78
+ const quietHours = options.getEffectiveQuietHours(contextKey);
79
+ const plain = [
80
+ `Notifications: ${mode}`,
81
+ `Quiet hours: ${formatQuietHours(quietHours)}`,
82
+ `Currently quiet: ${isQuietNow(quietHours) ? "yes" : "no"}`,
83
+ ].join("\n");
84
+ const html = [
85
+ `<b>Notifications:</b> <code>${escapeHTML(mode)}</code>`,
86
+ `<b>Quiet hours:</b> <code>${escapeHTML(formatQuietHours(quietHours))}</code>`,
87
+ `<b>Currently quiet:</b> <code>${isQuietNow(quietHours) ? "yes" : "no"}</code>`,
88
+ ].join("\n");
89
+ await safeReply(ctx, html, { fallbackText: plain });
90
+ });
91
+ options.bot.command("workspaces", async (ctx) => {
92
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
93
+ if (!contextSession) {
94
+ return;
95
+ }
96
+ const { session } = contextSession;
97
+ const agentName = labelOf(session.getInfo());
98
+ const workspaces = filterAllowedWorkspaces(session.listWorkspaces(), options.config);
99
+ const currentWorkspace = session.getInfo().workspace;
100
+ const lines = workspaces.slice(0, 20).map((workspace, index) => {
101
+ const prefix = workspace === currentWorkspace ? "*" : `${index + 1}.`;
102
+ const policy = renderWorkspacePolicyLine(workspace, options.config);
103
+ return `${prefix} ${workspace}${policy ? ` (${policy})` : ""}`;
104
+ });
105
+ const currentPolicy = evaluateWorkspacePolicy(currentWorkspace, options.config);
106
+ const header = [
107
+ "Workspaces:",
108
+ `Current: ${currentWorkspace}`,
109
+ currentPolicy.warning ? `Current warning: ${currentPolicy.warning}` : undefined,
110
+ options.config.workspaceAllowedRoots.length > 0 ? `Allowed roots: ${options.config.workspaceAllowedRoots.join(", ")}` : "Allowed roots: unrestricted",
111
+ "",
112
+ ].filter((line) => Boolean(line));
113
+ const plain = [...header, ...(lines.length > 0 ? lines : [`No workspaces found in ${agentName} state.`])].join("\n");
114
+ const html = [
115
+ "<b>Workspaces:</b>",
116
+ `<b>Current:</b> <code>${escapeHTML(currentWorkspace)}</code>`,
117
+ currentPolicy.warning ? `<b>Current warning:</b> <code>${escapeHTML(currentPolicy.warning)}</code>` : undefined,
118
+ `<b>Allowed roots:</b> <code>${escapeHTML(options.config.workspaceAllowedRoots.length > 0 ? options.config.workspaceAllowedRoots.join(", ") : "unrestricted")}</code>`,
119
+ "",
120
+ ...(lines.length > 0 ? lines.map((line) => `<code>${escapeHTML(line)}</code>`) : [`<code>No workspaces found in ${escapeHTML(agentName)} state.</code>`]),
121
+ ].filter((line) => Boolean(line)).join("\n");
122
+ await safeReply(ctx, html, { fallbackText: plain });
123
+ });
124
+ options.bot.command("voice", async (ctx) => {
125
+ if (!ctx.chat) {
126
+ return;
127
+ }
128
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
129
+ if (!contextSession) {
130
+ return;
131
+ }
132
+ const { contextKey } = contextSession;
133
+ const argument = (ctx.message?.text ?? "").replace(/^\/voice(?:@\w+)?\s*/i, "").trim();
134
+ if (argument) {
135
+ const parts = argument.split(/\s+/);
136
+ const key = parts[0]?.toLowerCase();
137
+ const value = parts.slice(1).join(" ").trim();
138
+ if (key === "backend" && value) {
139
+ options.preferencesStore.update(contextKey, { voiceBackend: parseVoiceBackendPreference(value) });
140
+ }
141
+ else if (key === "language") {
142
+ options.preferencesStore.update(contextKey, { voiceLanguage: value && value.toLowerCase() !== "auto" ? value : null });
143
+ }
144
+ else if (key === "transcribe_only" || key === "transcribe-only") {
145
+ const enabled = parseToggle(value);
146
+ if (enabled === undefined) {
147
+ await safeReply(ctx, escapeHTML("Usage: /voice transcribe_only on|off"), {
148
+ fallbackText: "Usage: /voice transcribe_only on|off",
149
+ });
150
+ return;
151
+ }
152
+ options.preferencesStore.update(contextKey, { voiceTranscribeOnly: enabled });
153
+ }
154
+ else {
155
+ await safeReply(ctx, escapeHTML("Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off"), {
156
+ fallbackText: "Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off",
157
+ });
158
+ return;
159
+ }
160
+ }
161
+ const backends = await getAvailableBackends().catch(() => []);
162
+ if (backends.length === 0) {
163
+ await safeReply(ctx, [
164
+ "<b>Voice transcription is not available.</b>",
165
+ "",
166
+ "Install <code>faster-whisper</code> + ffmpeg, install <code>parakeet-coreml</code> on macOS Apple Silicon, or set <code>OPENAI_API_KEY</code>.",
167
+ "<i>Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.</i>",
168
+ ].join("\n"), {
169
+ fallbackText: [
170
+ "Voice transcription is not available.",
171
+ "",
172
+ "Install faster-whisper + ffmpeg, install parakeet-coreml on macOS Apple Silicon, or set OPENAI_API_KEY.",
173
+ "Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.",
174
+ ].join("\n"),
175
+ });
176
+ return;
177
+ }
178
+ const joined = backends.join(" + ");
179
+ const backendPreference = options.getEffectiveVoiceBackend(contextKey);
180
+ const language = options.getEffectiveVoiceLanguage(contextKey);
181
+ const transcribeOnly = options.isVoiceTranscribeOnly(contextKey);
182
+ const plain = [
183
+ `Voice backends: ${joined}`,
184
+ `Preferred backend: ${backendPreference}`,
185
+ `Language: ${language ?? "auto"}`,
186
+ `Transcribe only: ${transcribeOnly ? "on" : "off"}`,
187
+ ].join("\n");
188
+ const html = [
189
+ `<b>Voice backends:</b> <code>${escapeHTML(joined)}</code>`,
190
+ `<b>Preferred backend:</b> <code>${escapeHTML(backendPreference)}</code>`,
191
+ `<b>Language:</b> <code>${escapeHTML(language ?? "auto")}</code>`,
192
+ `<b>Transcribe only:</b> <code>${transcribeOnly ? "on" : "off"}</code>`,
193
+ ].join("\n");
194
+ await safeReply(ctx, html, {
195
+ fallbackText: plain,
196
+ });
197
+ });
198
+ }