Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 254 additions & 4 deletions src/channels/__tests__/telegram.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
message_id: 42,
Expand All @@ -17,6 +23,7 @@ const mockEditMessageText = mock(
) => ({}),
);
const mockSendChatAction = mock(async (_chatId: number | string, _action: string) => {});
const mockReply = mock(async (_text: string, _opts?: Record<string, unknown>) => ({ message_id: 99 }));

type HandlerFn = (ctx: Record<string, unknown>) => Promise<void>;
const commandHandlers = new Map<string, HandlerFn>();
Expand Down Expand Up @@ -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();
Expand All @@ -60,6 +72,7 @@ describe("TelegramChannel", () => {
mockSendMessage.mockClear();
mockEditMessageText.mockClear();
mockSendChatAction.mockClear();
mockReply.mockClear();
});

test("has correct id and capabilities", () => {
Expand Down Expand Up @@ -124,6 +137,7 @@ describe("TelegramChannel", () => {
chat: { id: 67890 },
message_id: 1,
},
reply: mockReply,
});
}

Expand All @@ -149,20 +163,52 @@ 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();

const result = await channel.send("telegram:67890", { text: "Hello" });
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<string, unknown>];
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 <world>" });
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<string, unknown> | undefined];
expect(plainOpts?.parse_mode).toBeUndefined();
});

test("startTyping sends chat action and sets interval", async () => {
Expand All @@ -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<string, unknown>,
];
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("<b>bold</b>")).toBe("bold");
});

test("decodes HTML entities", () => {
expect(stripHtml("a &lt; b &gt; c &amp; d")).toBe("a < b > c & d");
});

test("handles nested tags", () => {
expect(stripHtml("<pre><code>hello</code></pre>")).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("<b>bold</b>");
});

test("converts italic markdown to HTML", () => {
expect(markdownToTelegramHtml("*italic*")).toBe("<i>italic</i>");
});

test("converts strikethrough to HTML", () => {
expect(markdownToTelegramHtml("~~strike~~")).toBe("<s>strike</s>");
});

test("converts headings to bold", () => {
expect(markdownToTelegramHtml("# Heading")).toBe("<b>Heading</b>");
});

test("converts fenced code blocks to pre/code", () => {
const result = markdownToTelegramHtml("```\nhello\n```");
expect(result).toContain("<pre><code>");
expect(result).toContain("hello");
});

test("converts inline code to code tags", () => {
expect(markdownToTelegramHtml("`code`")).toBe("<code>code</code>");
});

test("escapes HTML special chars in plain text", () => {
expect(markdownToTelegramHtml("a < b & c > d")).toBe("a &lt; b &amp; c &gt; d");
});

test("does not escape HTML inside code blocks", () => {
const result = markdownToTelegramHtml("```\na < b\n```");
expect(result).toContain("&lt;");
});

test("converts list items to bullet points", () => {
expect(markdownToTelegramHtml("- item")).toBe("• item");
});
});
Loading
Loading