@nordbyte/nordrelay 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,88 @@
1
+ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
2
+ import { agentLabel } from "./agent.js";
3
+ import { parseAgentUpdateId, renderAgentUpdateJobAction, renderAgentUpdateJobsAction, renderAgentUpdateLogAction, renderAgentUpdatePickerAction, renderSelfUpdateStartedAction, } from "./channel-actions.js";
4
+ import { escapeHTML } from "./format.js";
5
+ import { spawnSelfUpdate } from "./operations.js";
6
+ import { safeReply } from "./telegram-output.js";
7
+ export function registerTelegramUpdateCommands(deps) {
8
+ const { bot, agentUpdates, replyChannelAction, startTelegramAgentUpdate } = deps;
9
+ bot.command("update", async (ctx) => {
10
+ const rawText = ctx.message?.text ?? "";
11
+ const argument = rawText.replace(/^\/update(?:@\w+)?\s*/i, "").trim();
12
+ const tokens = argument.split(/\s+/).filter(Boolean);
13
+ const subcommand = tokens[0]?.toLowerCase();
14
+ if (subcommand === "agents" || subcommand === "agent") {
15
+ const rendered = renderAgentUpdatePickerAction(listAgentAdapterDescriptors());
16
+ await replyChannelAction(ctx, rendered);
17
+ return;
18
+ }
19
+ if (subcommand === "jobs" || subcommand === "status") {
20
+ const rendered = renderAgentUpdateJobsAction(agentUpdates.list());
21
+ await replyChannelAction(ctx, rendered);
22
+ return;
23
+ }
24
+ if (subcommand === "log" && tokens[1]) {
25
+ const rendered = renderAgentUpdateLogAction(agentUpdates.readLog(tokens[1]));
26
+ await replyChannelAction(ctx, rendered);
27
+ return;
28
+ }
29
+ if (subcommand === "cancel" && tokens[1]) {
30
+ const job = agentUpdates.cancel(tokens[1]);
31
+ const rendered = renderAgentUpdateJobAction(job);
32
+ await replyChannelAction(ctx, rendered);
33
+ return;
34
+ }
35
+ if ((subcommand === "input" || subcommand === "send") && tokens[1] && tokens.slice(2).join(" ").trim()) {
36
+ const job = agentUpdates.sendInput(tokens[1], tokens.slice(2).join(" "));
37
+ const rendered = renderAgentUpdateJobAction(job);
38
+ await replyChannelAction(ctx, rendered);
39
+ return;
40
+ }
41
+ const requestedAgent = parseAgentUpdateId(subcommand);
42
+ if (requestedAgent) {
43
+ await startTelegramAgentUpdate(ctx, requestedAgent);
44
+ return;
45
+ }
46
+ if (subcommand) {
47
+ const usage = "Unknown update target. Use /update, /update agents, /update jobs, /update <agent>, /update log <id>, /update cancel <id>, or /update input <id> <text>.";
48
+ await safeReply(ctx, escapeHTML(usage), { fallbackText: usage });
49
+ return;
50
+ }
51
+ const update = spawnSelfUpdate();
52
+ const rendered = renderSelfUpdateStartedAction(update);
53
+ await replyChannelAction(ctx, rendered);
54
+ });
55
+ bot.callbackQuery("upd_jobs", async (ctx) => {
56
+ await ctx.answerCallbackQuery();
57
+ const rendered = renderAgentUpdateJobsAction(agentUpdates.list());
58
+ await replyChannelAction(ctx, rendered);
59
+ });
60
+ bot.callbackQuery(/^upd_agent:(codex|pi|hermes|openclaw|claude-code)$/, async (ctx) => {
61
+ const agentId = ctx.match?.[1];
62
+ if (!agentId) {
63
+ await ctx.answerCallbackQuery();
64
+ return;
65
+ }
66
+ await ctx.answerCallbackQuery({ text: `Starting ${agentLabel(agentId)} update...` });
67
+ await startTelegramAgentUpdate(ctx, agentId);
68
+ });
69
+ bot.callbackQuery(/^upd_log:(.+)$/, async (ctx) => {
70
+ const id = ctx.match?.[1];
71
+ await ctx.answerCallbackQuery();
72
+ if (!id) {
73
+ return;
74
+ }
75
+ const rendered = renderAgentUpdateLogAction(agentUpdates.readLog(id));
76
+ await replyChannelAction(ctx, rendered);
77
+ });
78
+ bot.callbackQuery(/^upd_cancel:(.+)$/, async (ctx) => {
79
+ const id = ctx.match?.[1];
80
+ await ctx.answerCallbackQuery({ text: "Cancelling update..." });
81
+ if (!id) {
82
+ return;
83
+ }
84
+ const job = agentUpdates.cancel(id);
85
+ const rendered = renderAgentUpdateJobAction(job);
86
+ await replyChannelAction(ctx, rendered);
87
+ });
88
+ }