@nordbyte/nordrelay 0.4.0 → 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,123 @@
1
+ import { consumeRateLimit, resetRateLimit } from "./bot-rendering.js";
2
+ import { friendlyErrorText } from "./error-messages.js";
3
+ import { escapeHTML } from "./format.js";
4
+ import { safeReply } from "./telegram-output.js";
5
+ export function registerTelegramAccessCommands(deps) {
6
+ const { bot, userStore, contextUsers, linkAttempts, audit, getUserRole } = deps;
7
+ bot.command("link", async (ctx) => {
8
+ if (ctx.chat?.type !== "private") {
9
+ await safeReply(ctx, escapeHTML("Use /link in a private chat with the bot."), {
10
+ fallbackText: "Use /link in a private chat with the bot.",
11
+ });
12
+ return;
13
+ }
14
+ const code = (ctx.message?.text ?? "").replace(/^\/link(?:@\w+)?\s*/i, "").trim();
15
+ if (!code) {
16
+ await safeReply(ctx, escapeHTML("Send /link <code> after creating a Telegram link code in the WebUI or CLI."), {
17
+ fallbackText: "Send /link <code> after creating a Telegram link code in the WebUI or CLI.",
18
+ });
19
+ return;
20
+ }
21
+ if (!ctx.from?.id) {
22
+ return;
23
+ }
24
+ const limitKey = String(ctx.from.id);
25
+ const limited = consumeRateLimit(linkAttempts, limitKey, 5, 15 * 60 * 1000, 15 * 60 * 1000);
26
+ if (limited.limited) {
27
+ const seconds = Math.ceil((limited.retryAfterMs ?? 0) / 1000);
28
+ audit({
29
+ action: "auth_login_failed",
30
+ status: "denied",
31
+ contextKey: String(ctx.chat.id),
32
+ actorId: ctx.from.id,
33
+ description: "Telegram link rate limited",
34
+ detail: `${seconds}s retry-after`,
35
+ });
36
+ await safeReply(ctx, escapeHTML(`Too many link attempts. Try again in ${seconds}s.`), {
37
+ fallbackText: `Too many link attempts. Try again in ${seconds}s.`,
38
+ });
39
+ return;
40
+ }
41
+ try {
42
+ const linked = userStore.consumeTelegramLinkCode(code, {
43
+ telegramUserId: ctx.from.id,
44
+ username: ctx.from.username,
45
+ firstName: ctx.from.first_name,
46
+ lastName: ctx.from.last_name,
47
+ });
48
+ resetRateLimit(linkAttempts, limitKey);
49
+ contextUsers.set(ctx, linked);
50
+ audit({
51
+ action: "telegram_linked",
52
+ status: "ok",
53
+ contextKey: String(ctx.chat.id),
54
+ actorId: ctx.from.id,
55
+ actorRole: linked.groups.map((group) => group.name).join(", "),
56
+ description: `Linked ${linked.user.email}`,
57
+ });
58
+ await safeReply(ctx, escapeHTML(`Linked Telegram account to ${linked.user.email}.`), {
59
+ fallbackText: `Linked Telegram account to ${linked.user.email}.`,
60
+ });
61
+ }
62
+ catch (error) {
63
+ const message = friendlyErrorText(error);
64
+ audit({
65
+ action: "auth_login_failed",
66
+ status: "failed",
67
+ contextKey: String(ctx.chat.id),
68
+ actorId: ctx.from.id,
69
+ description: "Telegram link failed",
70
+ detail: message,
71
+ });
72
+ await safeReply(ctx, `<b>Link failed:</b> ${escapeHTML(message)}`, { fallbackText: `Link failed: ${message}` });
73
+ }
74
+ });
75
+ bot.command("whoami", async (ctx) => {
76
+ const authUser = contextUsers.get(ctx);
77
+ if (!authUser) {
78
+ await safeReply(ctx, escapeHTML("Not linked."), { fallbackText: "Not linked." });
79
+ return;
80
+ }
81
+ const text = [
82
+ `User: ${authUser.user.displayName} <${authUser.user.email}>`,
83
+ `Groups: ${authUser.groups.map((group) => group.name).join(", ") || "-"}`,
84
+ `Permissions: ${authUser.permissions.join(", ") || "-"}`,
85
+ ].join("\n");
86
+ await safeReply(ctx, `<b>User:</b> ${escapeHTML(authUser.user.displayName)}\n<b>Email:</b> <code>${escapeHTML(authUser.user.email)}</code>\n<b>Groups:</b> <code>${escapeHTML(authUser.groups.map((group) => group.name).join(", ") || "-")}</code>`, {
87
+ fallbackText: text,
88
+ });
89
+ });
90
+ bot.command("register_chat", async (ctx) => {
91
+ const authUser = contextUsers.get(ctx);
92
+ if (!authUser || !userStore.hasPermission(authUser, "users.write")) {
93
+ await safeReply(ctx, escapeHTML("Access denied: users.write permission required."), {
94
+ fallbackText: "Access denied: users.write permission required.",
95
+ });
96
+ return;
97
+ }
98
+ if (!ctx.chat?.id || ctx.chat.type === "private") {
99
+ await safeReply(ctx, escapeHTML("Run /register_chat inside a Telegram group or supergroup."), {
100
+ fallbackText: "Run /register_chat inside a Telegram group or supergroup.",
101
+ });
102
+ return;
103
+ }
104
+ const chat = userStore.registerTelegramChat({
105
+ chatId: ctx.chat.id,
106
+ title: "title" in ctx.chat ? ctx.chat.title : undefined,
107
+ type: ctx.chat.type,
108
+ enabled: true,
109
+ allowedGroupIds: [],
110
+ });
111
+ audit({
112
+ action: "telegram_chat_updated",
113
+ status: "ok",
114
+ contextKey: String(ctx.chat.id),
115
+ actorId: ctx.from?.id,
116
+ actorRole: getUserRole(ctx),
117
+ description: `Registered Telegram chat ${chat.chatId}`,
118
+ });
119
+ await safeReply(ctx, escapeHTML(`Telegram chat enabled for NordRelay.\nChat ID: ${chat.chatId}`), {
120
+ fallbackText: `Telegram chat enabled for NordRelay.\nChat ID: ${chat.chatId}`,
121
+ });
122
+ });
123
+ }
@@ -0,0 +1,129 @@
1
+ import { permissionForCallbackData, permissionForCommand } from "./access-control.js";
2
+ import { extractCommandName } from "./bot-rendering.js";
3
+ import { escapeHTML } from "./format.js";
4
+ import { safeReply } from "./telegram-output.js";
5
+ import { UserStore } from "./user-management.js";
6
+ export function createTelegramAccessMiddleware(options) {
7
+ const { userStore, contextUsers, audit } = options;
8
+ return async (ctx, next) => {
9
+ const fromId = ctx.from?.id;
10
+ const chatId = ctx.chat?.id;
11
+ const chatType = ctx.chat?.type;
12
+ const commandName = ctx.message?.text?.startsWith("/") ? extractCommandName(ctx.message.text) : undefined;
13
+ if (commandName === "link") {
14
+ await next();
15
+ return;
16
+ }
17
+ if (!userStore.hasAdminUser()) {
18
+ const message = "NordRelay has no admin user yet. Run `nordrelay user create-admin` on the host.";
19
+ if (ctx.callbackQuery) {
20
+ await ctx.answerCallbackQuery({ text: "No admin user configured" }).catch(() => { });
21
+ }
22
+ else if (ctx.chat) {
23
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
24
+ }
25
+ return;
26
+ }
27
+ const authUser = userStore.resolveTelegramUser(fromId);
28
+ if (!authUser) {
29
+ const message = "Unauthorized. Link this Telegram account to a NordRelay user first.";
30
+ audit({
31
+ action: "permission_denied",
32
+ status: "denied",
33
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
34
+ actorId: fromId,
35
+ description: "Telegram account is not linked",
36
+ });
37
+ if (ctx.callbackQuery) {
38
+ await ctx.answerCallbackQuery({ text: "Unauthorized" }).catch(() => { });
39
+ }
40
+ else if (ctx.chat?.type === "private") {
41
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
42
+ }
43
+ return;
44
+ }
45
+ contextUsers.set(ctx, authUser);
46
+ const chatAllowed = userStore.isTelegramChatAllowed(typeof chatId === "number" ? chatId : undefined, chatType, authUser);
47
+ if (!chatAllowed && commandName !== "register_chat") {
48
+ const message = "This Telegram chat is not enabled for NordRelay. An admin can run /register_chat in this chat.";
49
+ audit({
50
+ action: "permission_denied",
51
+ status: "denied",
52
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
53
+ actorId: fromId,
54
+ actorRole: getUserRole(contextUsers, ctx),
55
+ description: "Telegram chat is not enabled or outside user scope",
56
+ });
57
+ if (ctx.callbackQuery) {
58
+ await ctx.answerCallbackQuery({ text: "Chat not enabled" }).catch(() => { });
59
+ }
60
+ else if (ctx.chat?.type === "private") {
61
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
62
+ }
63
+ return;
64
+ }
65
+ const permission = getRequiredPermission(ctx);
66
+ if (!permission) {
67
+ const message = "Unsupported command or action.";
68
+ audit({
69
+ action: "permission_denied",
70
+ status: "denied",
71
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
72
+ actorId: fromId,
73
+ actorRole: getUserRole(contextUsers, ctx),
74
+ description: commandName ? `Unsupported command /${commandName}` : "Unsupported callback",
75
+ });
76
+ if (ctx.callbackQuery) {
77
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
78
+ }
79
+ else {
80
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
81
+ }
82
+ return;
83
+ }
84
+ if (!userStore.hasPermission(authUser, permission)) {
85
+ const message = `Access denied: ${permission} permission required.`;
86
+ audit({
87
+ action: "permission_denied",
88
+ status: "denied",
89
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
90
+ actorId: fromId,
91
+ actorRole: getUserRole(contextUsers, ctx),
92
+ description: `${permission} required`,
93
+ });
94
+ if (ctx.callbackQuery) {
95
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
96
+ }
97
+ else {
98
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
99
+ }
100
+ return;
101
+ }
102
+ await next();
103
+ };
104
+ }
105
+ function getUserRole(contextUsers, ctx) {
106
+ const authUser = contextUsers.get(ctx);
107
+ return authUser?.groups.map((group) => group.name).join(", ") || "unauthenticated";
108
+ }
109
+ function getRequiredPermission(ctx) {
110
+ if (ctx.callbackQuery?.data) {
111
+ return permissionForCallbackData(ctx.callbackQuery.data);
112
+ }
113
+ if (ctx.message?.voice || ctx.message?.audio || ctx.message?.photo || ctx.message?.document) {
114
+ return "files.write";
115
+ }
116
+ const text = ctx.message?.text?.trim();
117
+ if (!text) {
118
+ return "inspect";
119
+ }
120
+ if (!text.startsWith("/")) {
121
+ return "prompt.send";
122
+ }
123
+ const command = extractCommandName(text);
124
+ if (command === "queue") {
125
+ const argument = text.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
126
+ return argument ? "queue.write" : "queue.read";
127
+ }
128
+ return permissionForCommand(command);
129
+ }
@@ -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,54 @@
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: "lock", description: "Lock session writes to you" },
46
+ { command: "unlock", description: "Release session write lock" },
47
+ { command: "locks", description: "List session write locks" },
48
+ { command: "restart", description: "Admin: restart connector" },
49
+ { command: "update", description: "Admin: update connector or agents" },
50
+ { command: "handback", description: "Hand session back to CLI" },
51
+ { command: "attach", description: "Bind a session to this topic" },
52
+ { command: "switch", description: "Switch to a thread by ID" },
53
+ ]);
54
+ }
@@ -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
+ }