From c7b2f8b9fabe88c7797eba1b792c6a8faad35921 Mon Sep 17 00:00:00 2001 From: Arne Allisat Date: Wed, 1 Apr 2026 10:43:39 +0200 Subject: [PATCH] fix(telegram): reliability fixes - HTML mode, message splitting, access control Four bugs/gaps in the Telegram channel fixed: 1. HTML send mode with plain-text fallback: sendMessage() was using MarkdownV2 with a custom escaper that failed on parentheses and other edge cases. Switched to HTML mode with a markdownToTelegramHtml() converter. On any send failure (malformed HTML), retries the chunk as plain text so the user always gets a response. 2. Message splitting: Telegram rejects messages over 4096 characters with no error surfaced to the user. Added splitMessage() which chunks at newlines near the 4000-char limit (hard split as fallback). 3. try/catch in command handlers: /start, /status, /help called ctx.reply() without error handling, which caused "Unhandled promise rejection" in Telegraf on any transient network error. 4. allowed_user_ids access control: new optional config field that whitelists Telegram user IDs. When set, any user not on the list gets "Unauthorized." and the message handler is never invoked. Applies to text messages, callback queries, and all slash commands. Config schema updated in schemas.ts and wired through in index.ts. Utility functions (splitMessage, stripHtml, markdownToTelegramHtml) extracted to telegram-utils.ts to keep telegram.ts under the 300-line guideline. All 848 tests pass. bun run lint and bun run typecheck clean. --- src/channels/__tests__/telegram.test.ts | 258 +++++++++++++++++++++++- src/channels/telegram-utils.ts | 88 ++++++++ src/channels/telegram.ts | 161 ++++++++++----- src/config/schemas.ts | 2 + src/index.ts | 1 + 5 files changed, 454 insertions(+), 56 deletions(-) create mode 100644 src/channels/telegram-utils.ts diff --git a/src/channels/__tests__/telegram.test.ts b/src/channels/__tests__/telegram.test.ts index 3f1d667..c6820d8 100644 --- a/src/channels/__tests__/telegram.test.ts +++ b/src/channels/__tests__/telegram.test.ts @@ -1,8 +1,14 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { TELEGRAM_MAX_LENGTH, markdownToTelegramHtml, splitMessage, stripHtml } from "../telegram-utils.ts"; import { TelegramChannel, type TelegramChannelConfig } from "../telegram.ts"; // Mock Telegraf -const mockLaunch = mock(() => Promise.resolve()); +// launch() must call its onLaunch callback to unblock connect(), simulating the +// real Telegraf behavior where the callback fires after getMe() succeeds. +const mockLaunch = mock((onLaunch?: () => void) => { + onLaunch?.(); + return Promise.resolve(); +}); const mockStop = mock(() => {}); const mockSendMessage = mock(async (_chatId: number | string, _text: string, _opts?: Record) => ({ message_id: 42, @@ -17,6 +23,7 @@ const mockEditMessageText = mock( ) => ({}), ); const mockSendChatAction = mock(async (_chatId: number | string, _action: string) => {}); +const mockReply = mock(async (_text: string, _opts?: Record) => ({ message_id: 99 })); type HandlerFn = (ctx: Record) => Promise; const commandHandlers = new Map(); @@ -50,6 +57,11 @@ const testConfig: TelegramChannelConfig = { botToken: "123456:ABC-DEF", }; +const restrictedConfig: TelegramChannelConfig = { + botToken: "123456:ABC-DEF", + allowedUserIds: [111, 222], +}; + describe("TelegramChannel", () => { beforeEach(() => { commandHandlers.clear(); @@ -60,6 +72,7 @@ describe("TelegramChannel", () => { mockSendMessage.mockClear(); mockEditMessageText.mockClear(); mockSendChatAction.mockClear(); + mockReply.mockClear(); }); test("has correct id and capabilities", () => { @@ -124,6 +137,7 @@ describe("TelegramChannel", () => { chat: { id: 67890 }, message_id: 1, }, + reply: mockReply, }); } @@ -149,13 +163,14 @@ describe("TelegramChannel", () => { chat: { id: 67890 }, message_id: 1, }, + reply: mockReply, }); } expect(handlerCalled).toBe(false); }); - test("sends message via send method", async () => { + test("sends message via send method using HTML mode", async () => { const channel = new TelegramChannel(testConfig); await channel.connect(); @@ -163,6 +178,37 @@ describe("TelegramChannel", () => { expect(result.channelId).toBe("telegram"); expect(result.id).toBe("42"); expect(mockSendMessage).toHaveBeenCalledTimes(1); + // Verify HTML parse mode is used + const [, , opts] = mockSendMessage.mock.calls[0] as [unknown, unknown, Record]; + expect(opts?.parse_mode).toBe("HTML"); + }); + + test("splits long messages into multiple sends", async () => { + const channel = new TelegramChannel(testConfig); + await channel.connect(); + + // Create a message longer than TELEGRAM_MAX_LENGTH + const longText = "a".repeat(TELEGRAM_MAX_LENGTH + 100); + await channel.send("telegram:67890", { text: longText }); + + expect(mockSendMessage).toHaveBeenCalledTimes(2); + }); + + test("falls back to plain text when HTML send fails", async () => { + const channel = new TelegramChannel(testConfig); + await channel.connect(); + + // First call fails (malformed HTML), second call (plain text) succeeds + mockSendMessage + .mockRejectedValueOnce(new Error("Bad Request: can't parse entities")) + .mockResolvedValueOnce({ message_id: 55 }); + + const result = await channel.send("telegram:67890", { text: "Hello " }); + expect(mockSendMessage).toHaveBeenCalledTimes(2); + expect(result.id).toBe("55"); + // Second call has no parse_mode (plain text) + const [, , plainOpts] = mockSendMessage.mock.calls[1] as [unknown, unknown, Record | undefined]; + expect(plainOpts?.parse_mode).toBeUndefined(); }); test("startTyping sends chat action and sets interval", async () => { @@ -183,17 +229,221 @@ describe("TelegramChannel", () => { channel.stopTyping(67890); // The interval fires every 4s. Wait 4.5s to confirm it was cleared. - // Use a shorter wait than the old 5s to stay within bun's test timeout. mockSendChatAction.mockClear(); await new Promise((r) => setTimeout(r, 4500)); expect(mockSendChatAction).not.toHaveBeenCalled(); }, 10000); - test("editMessage calls telegram API", async () => { + test("editMessage calls telegram API using HTML mode", async () => { const channel = new TelegramChannel(testConfig); await channel.connect(); await channel.editMessage(67890, 42, "Updated text"); expect(mockEditMessageText).toHaveBeenCalledTimes(1); + const [, , , , editOpts] = mockEditMessageText.mock.calls[0] as [ + unknown, + unknown, + unknown, + unknown, + Record, + ]; + expect(editOpts?.parse_mode).toBe("HTML"); + }); + + describe("access control (allowedUserIds)", () => { + test("allows all users when no allowedUserIds configured", async () => { + const channel = new TelegramChannel(testConfig); + let handlerCalled = false; + channel.onMessage(async () => { + handlerCalled = true; + }); + await channel.connect(); + + const textHandler = eventHandlers.get("text"); + if (textHandler) { + await textHandler({ + message: { text: "hi", from: { id: 99999 }, chat: { id: 1 }, message_id: 1 }, + reply: mockReply, + }); + } + expect(handlerCalled).toBe(true); + }); + + test("allows whitelisted user", async () => { + const channel = new TelegramChannel(restrictedConfig); + let handlerCalled = false; + channel.onMessage(async () => { + handlerCalled = true; + }); + await channel.connect(); + + const textHandler = eventHandlers.get("text"); + if (textHandler) { + await textHandler({ + message: { text: "hi", from: { id: 111 }, chat: { id: 1 }, message_id: 1 }, + reply: mockReply, + }); + } + expect(handlerCalled).toBe(true); + }); + + test("blocks non-whitelisted user and replies Unauthorized", async () => { + const channel = new TelegramChannel(restrictedConfig); + let handlerCalled = false; + channel.onMessage(async () => { + handlerCalled = true; + }); + await channel.connect(); + + const textHandler = eventHandlers.get("text"); + if (textHandler) { + await textHandler({ + message: { text: "hi", from: { id: 99999 }, chat: { id: 1 }, message_id: 1 }, + reply: mockReply, + }); + } + expect(handlerCalled).toBe(false); + expect(mockReply).toHaveBeenCalledWith("Unauthorized."); + }); + + test("blocks unauthorized user from /start command", async () => { + const channel = new TelegramChannel(restrictedConfig); + await channel.connect(); + + const startHandler = commandHandlers.get("start"); + if (startHandler) { + await startHandler({ from: { id: 99999 }, reply: mockReply }); + } + expect(mockReply).toHaveBeenCalledWith("Unauthorized."); + }); + + test("blocks unauthorized user from /status command", async () => { + const channel = new TelegramChannel(restrictedConfig); + await channel.connect(); + + const statusHandler = commandHandlers.get("status"); + if (statusHandler) { + await statusHandler({ from: { id: 99999 }, reply: mockReply }); + } + expect(mockReply).toHaveBeenCalledWith("Unauthorized."); + }); + + test("blocks unauthorized user from /help command", async () => { + const channel = new TelegramChannel(restrictedConfig); + await channel.connect(); + + const helpHandler = commandHandlers.get("help"); + if (helpHandler) { + await helpHandler({ from: { id: 99999 }, reply: mockReply }); + } + expect(mockReply).toHaveBeenCalledWith("Unauthorized."); + }); + + test("authorized user gets /start response", async () => { + const channel = new TelegramChannel(restrictedConfig); + await channel.connect(); + + const startHandler = commandHandlers.get("start"); + if (startHandler) { + await startHandler({ from: { id: 111 }, reply: mockReply }); + } + expect(mockReply).toHaveBeenCalledTimes(1); + const [replyText] = mockReply.mock.calls[0] as [string]; + expect(replyText).toContain("Phantom"); + }); + }); +}); + +describe("splitMessage", () => { + test("returns single chunk for short messages", () => { + const chunks = splitMessage("Hello world"); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toBe("Hello world"); + }); + + test("splits at newline near limit", () => { + const line = "a".repeat(2000); + const text = `${line}\n${line}\n${line}`; + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(TELEGRAM_MAX_LENGTH); + } + }); + + test("hard-splits when no newline available", () => { + const text = "a".repeat(TELEGRAM_MAX_LENGTH + 500); + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(TELEGRAM_MAX_LENGTH); + } + }); + + test("preserves all content across chunks", () => { + const text = Array.from({ length: 10 }, (_, i) => `Line ${i}: ${"x".repeat(500)}`).join("\n"); + const chunks = splitMessage(text); + const rejoined = chunks.join("\n"); + // All original content should be present (trimStart may remove leading newlines between chunks) + expect(rejoined.replace(/\s+/g, " ").trim()).toBe(text.replace(/\s+/g, " ").trim()); + }); +}); + +describe("stripHtml", () => { + test("removes HTML tags", () => { + expect(stripHtml("bold")).toBe("bold"); + }); + + test("decodes HTML entities", () => { + expect(stripHtml("a < b > c & d")).toBe("a < b > c & d"); + }); + + test("handles nested tags", () => { + expect(stripHtml("
hello
")).toBe("hello"); + }); + + test("returns plain text unchanged", () => { + expect(stripHtml("plain text")).toBe("plain text"); + }); +}); + +describe("markdownToTelegramHtml", () => { + test("converts bold markdown to HTML", () => { + expect(markdownToTelegramHtml("**bold**")).toBe("bold"); + }); + + test("converts italic markdown to HTML", () => { + expect(markdownToTelegramHtml("*italic*")).toBe("italic"); + }); + + test("converts strikethrough to HTML", () => { + expect(markdownToTelegramHtml("~~strike~~")).toBe("strike"); + }); + + test("converts headings to bold", () => { + expect(markdownToTelegramHtml("# Heading")).toBe("Heading"); + }); + + test("converts fenced code blocks to pre/code", () => { + const result = markdownToTelegramHtml("```\nhello\n```"); + expect(result).toContain("
");
+		expect(result).toContain("hello");
+	});
+
+	test("converts inline code to code tags", () => {
+		expect(markdownToTelegramHtml("`code`")).toBe("code");
+	});
+
+	test("escapes HTML special chars in plain text", () => {
+		expect(markdownToTelegramHtml("a < b & c > d")).toBe("a < b & c > d");
+	});
+
+	test("does not escape HTML inside code blocks", () => {
+		const result = markdownToTelegramHtml("```\na < b\n```");
+		expect(result).toContain("<");
+	});
+
+	test("converts list items to bullet points", () => {
+		expect(markdownToTelegramHtml("- item")).toBe("• item");
 	});
 });
diff --git a/src/channels/telegram-utils.ts b/src/channels/telegram-utils.ts
new file mode 100644
index 0000000..66b4af2
--- /dev/null
+++ b/src/channels/telegram-utils.ts
@@ -0,0 +1,88 @@
+/**
+ * Utility functions for Telegram message formatting and splitting.
+ */
+
+export const TELEGRAM_MAX_LENGTH = 4000;
+
+/**
+ * Split a message into chunks that fit within Telegram's 4096 char limit.
+ * Prefers splitting at newlines to preserve readability.
+ */
+export function splitMessage(text: string): string[] {
+	if (text.length <= TELEGRAM_MAX_LENGTH) return [text];
+	const chunks: string[] = [];
+	let remaining = text;
+	while (remaining.length > 0) {
+		if (remaining.length <= TELEGRAM_MAX_LENGTH) {
+			chunks.push(remaining);
+			break;
+		}
+		// Prefer splitting at a newline near the limit
+		let splitAt = remaining.lastIndexOf("\n", TELEGRAM_MAX_LENGTH);
+		if (splitAt < TELEGRAM_MAX_LENGTH / 2) splitAt = TELEGRAM_MAX_LENGTH;
+		chunks.push(remaining.slice(0, splitAt));
+		remaining = remaining.slice(splitAt).trimStart();
+	}
+	return chunks;
+}
+
+/** Strip HTML tags for plain-text fallback. */
+export function stripHtml(html: string): string {
+	return html
+		.replace(/<[^>]+>/g, "")
+		.replace(/</g, "<")
+		.replace(/>/g, ">")
+		.replace(/&/g, "&");
+}
+
+/**
+ * Convert GitHub-flavored markdown to Telegram HTML.
+ * Telegram supports: , , , , , 
, 
+ */
+export function markdownToTelegramHtml(text: string): string {
+	const esc = (s: string): string => s.replace(/&/g, "&").replace(//g, ">");
+
+	// 1. Preserve fenced code blocks
+	const codeBlocks: string[] = [];
+	let out = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code: string) => {
+		codeBlocks.push(`
${esc(code.replace(/\n$/, ""))}
`); + return `\x00CB${codeBlocks.length - 1}\x00`; + }); + + // 2. Preserve inline code + const inlineCodes: string[] = []; + out = out.replace(/`([^`\n]+)`/g, (_match, code: string) => { + inlineCodes.push(`${esc(code)}`); + return `\x00IC${inlineCodes.length - 1}\x00`; + }); + + // 3. Escape HTML special chars in remaining text + out = esc(out); + + // 4. Headings to bold + out = out.replace(/^#{1,6} (.+)$/gm, "$1"); + + // 5. Bold: **text** or __text__ + out = out.replace(/\*\*(.+?)\*\*/g, "$1"); + out = out.replace(/__(.+?)__/g, "$1"); + + // 6. Italic: *text* or _text_ (single, not double) + out = out.replace(/(?$1
"); + out = out.replace(/(?$1"); + + // 7. Strikethrough: ~~text~~ + out = out.replace(/~~(.+?)~~/g, "$1"); + + // 8. Unordered list items: "- text" or "* text" at line start + out = out.replace(/^[*-] (.+)$/gm, "• $1"); + + // 9. Restore inline code and code blocks + for (let i = 0; i < inlineCodes.length; i++) { + out = out.replace(`\x00IC${i}\x00`, inlineCodes[i] as string); + } + for (let i = 0; i < codeBlocks.length; i++) { + out = out.replace(`\x00CB${i}\x00`, codeBlocks[i] as string); + } + + return out; +} diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 7750a2d..97a3fe7 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -1,13 +1,14 @@ /** * Telegram channel using Telegraf (long polling). * Supports inline keyboards, persistent typing, message editing, - * MarkdownV2 formatting, and command handling. + * HTML formatting, and command handling. */ +import { markdownToTelegramHtml, splitMessage, stripHtml } from "./telegram-utils.ts"; import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; type TelegrafBot = { - launch: () => Promise; + launch: (onLaunch?: () => void) => Promise; stop: () => void; command: (cmd: string, handler: (ctx: TelegrafContext) => Promise) => void; on: (event: string, handler: (ctx: TelegrafContext) => Promise) => void; @@ -49,6 +50,8 @@ type TelegrafContext = { export type TelegramChannelConfig = { botToken: string; + /** Whitelist of Telegram user IDs allowed to interact with the bot. Empty = allow all. */ + allowedUserIds?: number[]; }; type ConnectionState = "disconnected" | "connecting" | "connected" | "error"; @@ -86,7 +89,15 @@ export class TelegramChannel implements Channel { this.bot = new Telegraf(this.config.botToken) as unknown as TelegrafBot; this.registerHandlers(); - await this.bot.launch(); + + // launch() blocks forever (polling loop). Use the onLaunch callback which fires + // after getMe() succeeds but before polling starts, so we can resolve connect() + // without waiting for the loop to end. + const bot = this.bot; + await new Promise((resolve, reject) => { + bot.launch(resolve).catch(reject); + }); + this.connectionState = "connected"; console.log("[telegram] Bot connected via long polling"); } catch (err: unknown) { @@ -121,14 +132,33 @@ export class TelegramChannel implements Channel { if (!this.bot) throw new Error("Telegram bot not connected"); const chatId = parseTelegramConversationId(conversationId); - const text = escapeMarkdownV2(message.text); + const text = markdownToTelegramHtml(message.text); + const chunks = splitMessage(text); - const result = await this.bot.telegram.sendMessage(chatId, text, { - parse_mode: "MarkdownV2", - }); + let lastMessageId = 0; + for (const chunk of chunks) { + try { + const result = await this.bot.telegram.sendMessage(chatId, chunk, { + parse_mode: "HTML", + }); + lastMessageId = result.message_id; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[telegram] Failed to send message chunk: ${errMsg}`); + // Retry as plain text when HTML is malformed + try { + const result = await this.bot.telegram.sendMessage(chatId, stripHtml(chunk)); + lastMessageId = result.message_id; + } catch (fallbackErr: unknown) { + const fallbackMsg = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr); + console.error(`[telegram] Fallback send also failed: ${fallbackMsg}`); + throw fallbackErr; + } + } + } return { - id: String(result.message_id), + id: String(lastMessageId), channelId: this.id, conversationId, timestamp: new Date(), @@ -175,8 +205,8 @@ export class TelegramChannel implements Channel { ): Promise { if (!this.bot) throw new Error("Telegram bot not connected"); - const result = await this.bot.telegram.sendMessage(chatId, escapeMarkdownV2(text), { - parse_mode: "MarkdownV2", + const result = await this.bot.telegram.sendMessage(chatId, markdownToTelegramHtml(text), { + parse_mode: "HTML", reply_markup: { inline_keyboard: buttons }, }); return result.message_id; @@ -186,8 +216,8 @@ export class TelegramChannel implements Channel { async editMessage(chatId: number, messageId: number, text: string): Promise { if (!this.bot) return; try { - await this.bot.telegram.editMessageText(chatId, messageId, undefined, escapeMarkdownV2(text), { - parse_mode: "MarkdownV2", + await this.bot.telegram.editMessageText(chatId, messageId, undefined, markdownToTelegramHtml(text), { + parse_mode: "HTML", }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -198,21 +228,70 @@ export class TelegramChannel implements Channel { } } + private isAllowed(userId: number | undefined): boolean { + if (!userId) return false; + const { allowedUserIds } = this.config; + if (!allowedUserIds || allowedUserIds.length === 0) return true; + return allowedUserIds.includes(userId); + } + private registerHandlers(): void { if (!this.bot) return; this.bot.command("start", async (ctx) => { - await ctx.reply("Hello! I'm Phantom, your AI co-worker. Send me a message to get started."); + const userId = ctx.from?.id; + if (!this.isAllowed(userId)) { + console.warn(`[telegram] Unauthorized /start from user ${userId}`); + try { + await ctx.reply("Unauthorized."); + } catch { + /* ignore send errors */ + } + return; + } + try { + await ctx.reply("Hello! I'm Phantom, your AI co-worker. Send me a message to get started."); + } catch { + /* ignore send errors */ + } }); this.bot.command("status", async (ctx) => { - await ctx.reply("Phantom is running and ready to help."); + const userId = ctx.from?.id; + if (!this.isAllowed(userId)) { + console.warn(`[telegram] Unauthorized /status from user ${userId}`); + try { + await ctx.reply("Unauthorized."); + } catch { + /* ignore send errors */ + } + return; + } + try { + await ctx.reply("Phantom is running and ready to help."); + } catch { + /* ignore send errors */ + } }); this.bot.command("help", async (ctx) => { - await ctx.reply( - "Send me any message and I'll help you out.\n\nCommands:\n/start - Introduction\n/status - Check status\n/help - Show this message", - ); + const userId = ctx.from?.id; + if (!this.isAllowed(userId)) { + console.warn(`[telegram] Unauthorized /help from user ${userId}`); + try { + await ctx.reply("Unauthorized."); + } catch { + /* ignore send errors */ + } + return; + } + try { + await ctx.reply( + "Send me any message and I'll help you out.\n\nCommands:\n/start - Introduction\n/status - Check status\n/help - Show this message", + ); + } catch { + /* ignore send errors */ + } }); this.bot.on("text", async (ctx) => { @@ -224,6 +303,13 @@ export class TelegramChannel implements Channel { const chatId = ctx.message.chat.id; const from = ctx.message.from; + + if (!this.isAllowed(from?.id)) { + console.warn(`[telegram] Unauthorized message from user ${from?.id} (${from?.username ?? "unknown"})`); + await ctx.reply("Unauthorized."); + return; + } + const conversationId = `telegram:${chatId}`; const inbound: InboundMessage = { @@ -254,13 +340,17 @@ export class TelegramChannel implements Channel { await ctx.answerCbQuery(); } + const from = ctx.from; + if (!this.isAllowed(from?.id)) { + console.warn(`[telegram] Unauthorized callback from user ${from?.id} (${from?.username ?? "unknown"})`); + return; + } + const data = ctx.match?.[1]; if (!data || !this.messageHandler) return; const chatId = ctx.callbackQuery?.message?.chat.id; if (!chatId) return; - - const from = ctx.from; const conversationId = `telegram:${chatId}`; const inbound: InboundMessage = { @@ -293,36 +383,3 @@ function parseTelegramConversationId(conversationId: string): number { const chatId = conversationId.split(":")[1]; return Number(chatId); } - -/** - * Escape special characters for Telegram MarkdownV2. - * Characters that need escaping: _ * [ ] ( ) ~ ` > # + - = | { } . ! - */ -function escapeMarkdownV2(text: string): string { - // Preserve code blocks - const codeBlocks: string[] = []; - let result = text.replace(/```[\s\S]*?```/g, (match) => { - codeBlocks.push(match); - return `\x00CB${codeBlocks.length - 1}\x00`; - }); - - // Preserve inline code - const inlineCodes: string[] = []; - result = result.replace(/`[^`]+`/g, (match) => { - inlineCodes.push(match); - return `\x00IC${inlineCodes.length - 1}\x00`; - }); - - // Escape special characters outside of code - result = result.replace(/([_*\[\]()~>#+\-=|{}.!\\])/g, "\\$1"); - - // Restore inline code and code blocks - for (let i = 0; i < inlineCodes.length; i++) { - result = result.replace(`\x00IC${i}\x00`, inlineCodes[i]); - } - for (let i = 0; i < codeBlocks.length; i++) { - result = result.replace(`\x00CB${i}\x00`, codeBlocks[i]); - } - - return result; -} diff --git a/src/config/schemas.ts b/src/config/schemas.ts index dbce22c..7b24da7 100644 --- a/src/config/schemas.ts +++ b/src/config/schemas.ts @@ -32,6 +32,8 @@ export const SlackChannelConfigSchema = z.object({ export const TelegramChannelConfigSchema = z.object({ enabled: z.boolean().default(false), bot_token: z.string().min(1), + /** Whitelist of Telegram user IDs allowed to interact with the bot. Empty = allow all. */ + allowed_user_ids: z.array(z.number().int()).optional(), }); export const EmailChannelConfigSchema = z.object({ diff --git a/src/index.ts b/src/index.ts index a6e0066..9cc821c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -267,6 +267,7 @@ async function main(): Promise { if (channelsConfig?.telegram?.enabled && channelsConfig.telegram.bot_token) { telegramChannel = new TelegramChannel({ botToken: channelsConfig.telegram.bot_token, + allowedUserIds: channelsConfig.telegram.allowed_user_ids, }); router.register(telegramChannel); console.log("[phantom] Telegram channel registered");