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");