test: dedupe channel and transport adapters

This commit is contained in:
Peter Steinberger
2026-02-21 21:43:18 +00:00
parent 52ddb6ae18
commit 58254b3b57
19 changed files with 2187 additions and 2545 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../infra/net/ssrf.js";
import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js";
@@ -12,6 +12,8 @@ const TELEGRAM_TEST_TIMINGS = {
mediaGroupFlushMs: 20,
textFragmentGapMs: 30,
} as const;
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
let replySpy: ReturnType<typeof vi.fn>;
async function createBotHandler(): Promise<{
handler: (ctx: Record<string, unknown>) => Promise<void>;
@@ -30,10 +32,6 @@ async function createBotHandlerWithOptions(options: {
replySpy: ReturnType<typeof vi.fn>;
runtimeError: ReturnType<typeof vi.fn>;
}> {
const { createTelegramBot } = await import("./bot.js");
const replyModule = await import("../auto-reply/reply.js");
const replySpy = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
onSpy.mockReset();
replySpy.mockReset();
sendChatActionSpy.mockReset();
@@ -96,6 +94,12 @@ afterEach(() => {
resolvePinnedHostnameSpy = null;
});
beforeAll(async () => {
({ createTelegramBot } = await import("./bot.js"));
const replyModule = await import("../auto-reply/reply.js");
replySpy = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
});
vi.mock("./sticker-cache.js", () => ({
cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args),
getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args),
@@ -521,11 +525,6 @@ describe("telegram text fragments", () => {
it(
"buffers near-limit text and processes sequential parts as one message",
async () => {
const { createTelegramBot } = await import("./bot.js");
const replyModule = await import("../auto-reply/reply.js");
const replySpy = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> })
.__replySpy;
onSpy.mockReset();
replySpy.mockReset();

View File

@@ -2,44 +2,28 @@ import { describe, expect, it } from "vitest";
import { markdownToTelegramHtml } from "./format.js";
describe("markdownToTelegramHtml", () => {
it("renders basic inline formatting", () => {
const res = markdownToTelegramHtml("hi _there_ **boss** `code`");
expect(res).toBe("hi <i>there</i> <b>boss</b> <code>code</code>");
});
it("renders links as Telegram-safe HTML", () => {
const res = markdownToTelegramHtml("see [docs](https://example.com)");
expect(res).toBe('see <a href="https://example.com">docs</a>');
});
it("escapes raw HTML", () => {
const res = markdownToTelegramHtml("<b>nope</b>");
expect(res).toBe("&lt;b&gt;nope&lt;/b&gt;");
});
it("escapes unsafe characters", () => {
const res = markdownToTelegramHtml("a & b < c");
expect(res).toBe("a &amp; b &lt; c");
});
it("renders paragraphs with blank lines", () => {
const res = markdownToTelegramHtml("first\n\nsecond");
expect(res).toBe("first\n\nsecond");
});
it("renders lists without block HTML", () => {
const res = markdownToTelegramHtml("- one\n- two");
expect(res).toBe("• one\n• two");
});
it("renders ordered lists with numbering", () => {
const res = markdownToTelegramHtml("2. two\n3. three");
expect(res).toBe("2. two\n3. three");
});
it("flattens headings", () => {
const res = markdownToTelegramHtml("# Title");
expect(res).toBe("Title");
it("handles core markdown-to-telegram conversions", () => {
const cases = [
[
"renders basic inline formatting",
"hi _there_ **boss** `code`",
"hi <i>there</i> <b>boss</b> <code>code</code>",
],
[
"renders links as Telegram-safe HTML",
"see [docs](https://example.com)",
'see <a href="https://example.com">docs</a>',
],
["escapes raw HTML", "<b>nope</b>", "&lt;b&gt;nope&lt;/b&gt;"],
["escapes unsafe characters", "a & b < c", "a &amp; b &lt; c"],
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
["renders lists without block HTML", "- one\n- two", "• one\n• two"],
["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"],
["flattens headings", "# Title", "Title"],
] as const;
for (const [name, input, expected] of cases) {
expect(markdownToTelegramHtml(input), name).toBe(expected);
}
});
it("renders blockquotes as native Telegram blockquote tags", () => {

View File

@@ -7,58 +7,33 @@ import {
} from "./format.js";
describe("wrapFileReferencesInHtml", () => {
it("wraps .md filenames in code tags", () => {
expect(wrapFileReferencesInHtml("Check README.md")).toContain("Check <code>README.md</code>");
expect(wrapFileReferencesInHtml("See HEARTBEAT.md for status")).toContain(
"See <code>HEARTBEAT.md</code> for status",
);
it("wraps supported file references and paths", () => {
const cases = [
["Check README.md", "Check <code>README.md</code>"],
["See HEARTBEAT.md for status", "See <code>HEARTBEAT.md</code> for status"],
["Check main.go", "Check <code>main.go</code>"],
["Run script.py", "Run <code>script.py</code>"],
["Check backup.pl", "Check <code>backup.pl</code>"],
["Run backup.sh", "Run <code>backup.sh</code>"],
["Look at squad/friday/HEARTBEAT.md", "Look at <code>squad/friday/HEARTBEAT.md</code>"],
] as const;
for (const [input, expected] of cases) {
expect(wrapFileReferencesInHtml(input), input).toContain(expected);
}
});
it("wraps .go filenames", () => {
expect(wrapFileReferencesInHtml("Check main.go")).toContain("Check <code>main.go</code>");
});
it("wraps .py filenames", () => {
expect(wrapFileReferencesInHtml("Run script.py")).toContain("Run <code>script.py</code>");
});
it("wraps .pl filenames", () => {
expect(wrapFileReferencesInHtml("Check backup.pl")).toContain("Check <code>backup.pl</code>");
});
it("wraps .sh filenames", () => {
expect(wrapFileReferencesInHtml("Run backup.sh")).toContain("Run <code>backup.sh</code>");
});
it("wraps file paths", () => {
expect(wrapFileReferencesInHtml("Look at squad/friday/HEARTBEAT.md")).toContain(
"Look at <code>squad/friday/HEARTBEAT.md</code>",
);
});
it("does not wrap inside existing code tags", () => {
const input = "Already <code>wrapped.md</code> here";
const result = wrapFileReferencesInHtml(input);
expect(result).toBe(input);
expect(result).not.toContain("<code><code>");
});
it("does not wrap inside pre tags", () => {
const input = "<pre><code>README.md</code></pre>";
const result = wrapFileReferencesInHtml(input);
expect(result).toBe(input);
});
it("does not wrap inside anchor tags", () => {
const input = '<a href="README.md">Link</a>';
const result = wrapFileReferencesInHtml(input);
expect(result).toBe(input);
});
it("does not wrap file refs inside real URL anchor tags", () => {
const input = 'Visit <a href="https://example.com/README.md">example.com/README.md</a>';
const result = wrapFileReferencesInHtml(input);
expect(result).toBe(input);
it("does not wrap inside protected html contexts", () => {
const cases = [
"Already <code>wrapped.md</code> here",
"<pre><code>README.md</code></pre>",
'<a href="README.md">Link</a>',
'Visit <a href="https://example.com/README.md">example.com/README.md</a>',
] as const;
for (const input of cases) {
const result = wrapFileReferencesInHtml(input);
expect(result, input).toBe(input);
}
expect(wrapFileReferencesInHtml(cases[0])).not.toContain("<code><code>");
});
it("handles mixed content correctly", () => {
@@ -67,32 +42,51 @@ describe("wrapFileReferencesInHtml", () => {
expect(result).toContain("<code>CONTRIBUTING.md</code>");
});
it("handles edge cases", () => {
expect(wrapFileReferencesInHtml("No markdown files here")).not.toContain("<code>");
expect(wrapFileReferencesInHtml("File.md at start")).toContain("<code>File.md</code>");
expect(wrapFileReferencesInHtml("Ends with file.md")).toContain("<code>file.md</code>");
it("handles boundary and punctuation wrapping cases", () => {
const cases = [
{ input: "No markdown files here", contains: undefined },
{ input: "File.md at start", contains: "<code>File.md</code>" },
{ input: "Ends with file.md", contains: "<code>file.md</code>" },
{ input: "See README.md.", contains: "<code>README.md</code>." },
{ input: "See README.md,", contains: "<code>README.md</code>," },
{ input: "(README.md)", contains: "(<code>README.md</code>)" },
{ input: "README.md:", contains: "<code>README.md</code>:" },
] as const;
for (const testCase of cases) {
const result = wrapFileReferencesInHtml(testCase.input);
if (!testCase.contains) {
expect(result).not.toContain("<code>");
continue;
}
expect(result).toContain(testCase.contains);
}
});
it("wraps file refs with punctuation boundaries", () => {
expect(wrapFileReferencesInHtml("See README.md.")).toContain("<code>README.md</code>.");
expect(wrapFileReferencesInHtml("See README.md,")).toContain("<code>README.md</code>,");
expect(wrapFileReferencesInHtml("(README.md)")).toContain("(<code>README.md</code>)");
expect(wrapFileReferencesInHtml("README.md:")).toContain("<code>README.md</code>:");
});
it("de-linkifies auto-linkified file ref anchors", () => {
const input = '<a href="http://README.md">README.md</a>';
expect(wrapFileReferencesInHtml(input)).toBe("<code>README.md</code>");
});
it("de-linkifies auto-linkified path anchors", () => {
const input = '<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>';
expect(wrapFileReferencesInHtml(input)).toBe("<code>squad/friday/HEARTBEAT.md</code>");
it("de-linkifies auto-linkified anchors for plain files and paths", () => {
const cases = [
{
input: '<a href="http://README.md">README.md</a>',
expected: "<code>README.md</code>",
},
{
input: '<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>',
expected: "<code>squad/friday/HEARTBEAT.md</code>",
},
] as const;
for (const testCase of cases) {
expect(wrapFileReferencesInHtml(testCase.input)).toBe(testCase.expected);
}
});
it("preserves explicit links where label differs from href", () => {
const input = '<a href="http://README.md">click here</a>';
expect(wrapFileReferencesInHtml(input)).toBe(input);
const cases = [
'<a href="http://README.md">click here</a>',
'<a href="http://other.md">README.md</a>',
] as const;
for (const input of cases) {
expect(wrapFileReferencesInHtml(input)).toBe(input);
}
});
it("wraps file ref after closing anchor tag", () => {
@@ -167,14 +161,14 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
});
describe("edge cases", () => {
it("wraps file ref inside bold tags", () => {
const result = markdownToTelegramHtml("**README.md**");
expect(result).toBe("<b><code>README.md</code></b>");
});
it("wraps file ref inside italic tags", () => {
const result = markdownToTelegramHtml("*script.py*");
expect(result).toBe("<i><code>script.py</code></i>");
it("wraps file refs inside emphasis tags", () => {
const cases = [
["**README.md**", "<b><code>README.md</code></b>"],
["*script.py*", "<i><code>script.py</code></i>"],
] as const;
for (const [input, expected] of cases) {
expect(markdownToTelegramHtml(input), input).toBe(expected);
}
});
it("does not wrap inside fenced code blocks", () => {
@@ -183,15 +177,22 @@ describe("edge cases", () => {
expect(result).not.toContain("<code><code>");
});
it("preserves domain-like paths as anchor tags", () => {
const result = markdownToTelegramHtml("example.com/README.md");
expect(result).toContain('<a href="http://example.com/README.md">');
expect(result).not.toContain("<code>");
});
it("preserves github URLs with file paths", () => {
const result = markdownToTelegramHtml("https://github.com/foo/README.md");
expect(result).toContain('<a href="https://github.com/foo/README.md">');
it("preserves real URL/domain paths as anchors", () => {
const cases = [
{
input: "example.com/README.md",
href: 'href="http://example.com/README.md"',
},
{
input: "https://github.com/foo/README.md",
href: 'href="https://github.com/foo/README.md"',
},
] as const;
for (const testCase of cases) {
const result = markdownToTelegramHtml(testCase.input);
expect(result).toContain(`<a ${testCase.href}>`);
expect(result).not.toContain("<code>");
}
});
it("handles wrapFileRefs: false (plain text output)", () => {
@@ -233,14 +234,14 @@ describe("edge cases", () => {
expect(result).not.toContain("<code>script.js</code>");
});
it("handles file ref at start of message", () => {
const result = markdownToTelegramHtml("README.md is important");
expect(result).toBe("<code>README.md</code> is important");
});
it("handles file ref at end of message", () => {
const result = markdownToTelegramHtml("Check the README.md");
expect(result).toBe("Check the <code>README.md</code>");
it("handles file refs at message boundaries", () => {
const cases = [
["README.md is important", "<code>README.md</code> is important"],
["Check the README.md", "Check the <code>README.md</code>"],
] as const;
for (const [input, expected] of cases) {
expect(markdownToTelegramHtml(input), input).toBe(expected);
}
});
it("handles multiple file refs in sequence", () => {
@@ -267,15 +268,13 @@ describe("edge cases", () => {
expect(result).toContain('<a href="http://example.com/v1.0/README.md">');
});
it("handles file ref with hyphen and underscore in name", () => {
const result = markdownToTelegramHtml("my-file_name.md");
expect(result).toContain("<code>my-file_name.md</code>");
});
it("wraps hyphen/underscore filenames and uppercase extensions", () => {
const first = markdownToTelegramHtml("my-file_name.md");
expect(first).toContain("<code>my-file_name.md</code>");
it("handles uppercase extensions", () => {
const result = markdownToTelegramHtml("README.MD and SCRIPT.PY");
expect(result).toContain("<code>README.MD</code>");
expect(result).toContain("<code>SCRIPT.PY</code>");
const second = markdownToTelegramHtml("README.MD and SCRIPT.PY");
expect(second).toContain("<code>README.MD</code>");
expect(second).toContain("<code>SCRIPT.PY</code>");
});
it("handles nested code tags (depth tracking)", () => {
@@ -293,12 +292,6 @@ describe("edge cases", () => {
expect(result).toContain("</a> <code>script.py</code>");
});
it("preserves anchor when href and label differ (no backreference match)", () => {
// Different href and label - should NOT de-linkify
const input = '<a href="http://other.md">README.md</a>';
expect(wrapFileReferencesInHtml(input)).toBe(input);
});
it("wraps orphaned TLD pattern after special character", () => {
// R&D.md - the & breaks the main pattern, but D.md could be auto-linked
// So we wrap the orphaned D.md part to prevent Telegram linking it
@@ -363,19 +356,16 @@ describe("edge cases", () => {
expect(result).not.toContain("<code><code>");
});
it("does not wrap orphaned TLD inside href attributes", () => {
// D.md inside href should NOT be wrapped
const input = '<a href="http://example.com/R&D.md">link</a>';
const result = wrapFileReferencesInHtml(input);
// href should be untouched
expect(result).toBe(input);
expect(result).not.toContain("<code>D.md</code>");
});
it("does not wrap orphaned TLD inside any HTML attribute", () => {
const input = '<img src="logo/R&D.md" alt="R&D.md">';
const result = wrapFileReferencesInHtml(input);
expect(result).toBe(input);
it("does not wrap orphaned TLD fragments inside HTML attributes", () => {
const cases = [
'<a href="http://example.com/R&D.md">link</a>',
'<img src="logo/R&D.md" alt="R&D.md">',
] as const;
for (const input of cases) {
const result = wrapFileReferencesInHtml(input);
expect(result).toBe(input);
expect(result).not.toContain("<code>D.md</code>");
}
});
it("handles multiple orphaned TLDs with HTML tags (offset stability)", () => {

View File

@@ -10,99 +10,89 @@ import {
} from "./model-buttons.js";
describe("parseModelCallbackData", () => {
it("parses mdl_prov callback", () => {
const result = parseModelCallbackData("mdl_prov");
expect(result).toEqual({ type: "providers" });
it("parses supported callback variants", () => {
const cases = [
["mdl_prov", { type: "providers" }],
["mdl_back", { type: "back" }],
["mdl_list_anthropic_2", { type: "list", provider: "anthropic", page: 2 }],
["mdl_list_open-ai_1", { type: "list", provider: "open-ai", page: 1 }],
[
"mdl_sel_anthropic/claude-sonnet-4-5",
{ type: "select", provider: "anthropic", model: "claude-sonnet-4-5" },
],
["mdl_sel_openai/gpt-4/turbo", { type: "select", provider: "openai", model: "gpt-4/turbo" }],
[" mdl_prov ", { type: "providers" }],
] as const;
for (const [input, expected] of cases) {
expect(parseModelCallbackData(input), input).toEqual(expected);
}
});
it("parses mdl_back callback", () => {
const result = parseModelCallbackData("mdl_back");
expect(result).toEqual({ type: "back" });
});
it("parses mdl_list callback with provider and page", () => {
const result = parseModelCallbackData("mdl_list_anthropic_2");
expect(result).toEqual({ type: "list", provider: "anthropic", page: 2 });
});
it("parses mdl_list callback with hyphenated provider", () => {
const result = parseModelCallbackData("mdl_list_open-ai_1");
expect(result).toEqual({ type: "list", provider: "open-ai", page: 1 });
});
it("parses mdl_sel callback with provider/model", () => {
const result = parseModelCallbackData("mdl_sel_anthropic/claude-sonnet-4-5");
expect(result).toEqual({
type: "select",
provider: "anthropic",
model: "claude-sonnet-4-5",
});
});
it("parses mdl_sel callback with nested model path", () => {
const result = parseModelCallbackData("mdl_sel_openai/gpt-4/turbo");
expect(result).toEqual({
type: "select",
provider: "openai",
model: "gpt-4/turbo",
});
});
it("returns null for non-model callback data", () => {
expect(parseModelCallbackData("commands_page_1")).toBeNull();
expect(parseModelCallbackData("other_callback")).toBeNull();
expect(parseModelCallbackData("")).toBeNull();
});
it("returns null for invalid mdl_ patterns", () => {
expect(parseModelCallbackData("mdl_invalid")).toBeNull();
expect(parseModelCallbackData("mdl_list_")).toBeNull();
expect(parseModelCallbackData("mdl_sel_noslash")).toBeNull();
});
it("handles whitespace in callback data", () => {
expect(parseModelCallbackData(" mdl_prov ")).toEqual({ type: "providers" });
it("returns null for unsupported callback variants", () => {
const invalid = [
"commands_page_1",
"other_callback",
"",
"mdl_invalid",
"mdl_list_",
"mdl_sel_noslash",
];
for (const input of invalid) {
expect(parseModelCallbackData(input), input).toBeNull();
}
});
});
describe("buildProviderKeyboard", () => {
it("returns empty array for no providers", () => {
const result = buildProviderKeyboard([]);
expect(result).toEqual([]);
});
it("lays out providers in two-column rows", () => {
const cases = [
{
name: "empty input",
input: [],
expected: [],
},
{
name: "single provider",
input: [{ id: "anthropic", count: 5 }],
expected: [[{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" }]],
},
{
name: "exactly one full row",
input: [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
],
expected: [
[
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
],
],
},
{
name: "wraps overflow to second row",
input: [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
{ id: "google", count: 3 },
],
expected: [
[
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
],
[{ text: "google (3)", callback_data: "mdl_list_google_1" }],
],
},
] as const satisfies Array<{
name: string;
input: ProviderInfo[];
expected: ReturnType<typeof buildProviderKeyboard>;
}>;
it("builds single provider as one row", () => {
const providers: ProviderInfo[] = [{ id: "anthropic", count: 5 }];
const result = buildProviderKeyboard(providers);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
expect(result[0]?.[0]?.callback_data).toBe("mdl_list_anthropic_1");
});
it("builds two providers per row", () => {
const providers: ProviderInfo[] = [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
];
const result = buildProviderKeyboard(providers);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(2);
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
expect(result[0]?.[1]?.text).toBe("openai (8)");
});
it("wraps to next row after two providers", () => {
const providers: ProviderInfo[] = [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
{ id: "google", count: 3 },
];
const result = buildProviderKeyboard(providers);
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(2);
expect(result[1]).toHaveLength(1);
expect(result[1]?.[0]?.text).toBe("google (3)");
for (const testCase of cases) {
expect(buildProviderKeyboard(testCase.input), testCase.name).toEqual(testCase.expected);
}
});
});
@@ -119,112 +109,105 @@ describe("buildModelsKeyboard", () => {
expect(result[0]?.[0]?.callback_data).toBe("mdl_back");
});
it("shows models with one per row", () => {
const result = buildModelsKeyboard({
provider: "anthropic",
models: ["claude-sonnet-4", "claude-opus-4"],
currentPage: 1,
totalPages: 1,
});
// 2 model rows + back button
expect(result).toHaveLength(3);
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4");
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4");
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
expect(result[2]?.[0]?.text).toBe("<< Back");
it("renders model rows and optional current-model indicator", () => {
const cases = [
{
name: "no current model",
currentModel: undefined,
firstText: "claude-sonnet-4",
},
{
name: "current model marked",
currentModel: "anthropic/claude-sonnet-4",
firstText: "claude-sonnet-4",
},
] as const;
for (const testCase of cases) {
const result = buildModelsKeyboard({
provider: "anthropic",
models: ["claude-sonnet-4", "claude-opus-4"],
currentModel: testCase.currentModel,
currentPage: 1,
totalPages: 1,
});
// 2 model rows + back button
expect(result, testCase.name).toHaveLength(3);
expect(result[0]?.[0]?.text).toBe(testCase.firstText);
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4");
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
expect(result[2]?.[0]?.text).toBe("<< Back");
}
});
it("marks current model with checkmark", () => {
const result = buildModelsKeyboard({
provider: "anthropic",
models: ["claude-sonnet-4", "claude-opus-4"],
currentModel: "anthropic/claude-sonnet-4",
currentPage: 1,
totalPages: 1,
});
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4 ✓");
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
it("renders pagination controls for first, middle, and last pages", () => {
const cases = [
{
name: "first page",
params: { currentPage: 1, models: ["model1", "model2"] },
expectedPagination: ["1/3", "Next ▶"],
},
{
name: "middle page",
params: {
currentPage: 2,
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
},
expectedPagination: ["◀ Prev", "2/3", "Next ▶"],
},
{
name: "last page",
params: {
currentPage: 3,
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
},
expectedPagination: ["◀ Prev", "3/3"],
},
] as const;
for (const testCase of cases) {
const result = buildModelsKeyboard({
provider: "anthropic",
models: testCase.params.models,
currentPage: testCase.params.currentPage,
totalPages: 3,
pageSize: 2,
});
// 2 model rows + pagination row + back button
expect(result, testCase.name).toHaveLength(4);
expect(result[2]?.map((button) => button.text)).toEqual(testCase.expectedPagination);
}
});
it("shows pagination when multiple pages", () => {
const result = buildModelsKeyboard({
provider: "anthropic",
models: ["model1", "model2"],
currentPage: 1,
totalPages: 3,
pageSize: 2,
});
// 2 model rows + pagination row + back button
expect(result).toHaveLength(4);
const paginationRow = result[2];
expect(paginationRow).toHaveLength(2); // no prev on first page
expect(paginationRow?.[0]?.text).toBe("1/3");
expect(paginationRow?.[1]?.text).toBe("Next ▶");
});
it("shows prev and next on middle pages", () => {
// 6 models with pageSize 2 = 3 pages
const result = buildModelsKeyboard({
provider: "anthropic",
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
currentPage: 2,
totalPages: 3,
pageSize: 2,
});
// 2 model rows + pagination row + back button
expect(result).toHaveLength(4);
const paginationRow = result[2];
expect(paginationRow).toHaveLength(3);
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
expect(paginationRow?.[1]?.text).toBe("2/3");
expect(paginationRow?.[2]?.text).toBe("Next ▶");
});
it("shows only prev on last page", () => {
// 6 models with pageSize 2 = 3 pages
const result = buildModelsKeyboard({
provider: "anthropic",
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
currentPage: 3,
totalPages: 3,
pageSize: 2,
});
// 2 model rows + pagination row + back button
expect(result).toHaveLength(4);
const paginationRow = result[2];
expect(paginationRow).toHaveLength(2);
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
expect(paginationRow?.[1]?.text).toBe("3/3");
});
it("truncates long model IDs for display", () => {
// Model ID that's long enough to truncate display but still fits in callback_data
// callback_data = "mdl_sel_anthropic/" (18) + model (<=46) = 64 max
const longModel = "claude-3-5-sonnet-20241022-with-suffix";
const result = buildModelsKeyboard({
provider: "anthropic",
models: [longModel],
currentPage: 1,
totalPages: 1,
});
const text = result[0]?.[0]?.text;
// Model is 38 chars, fits exactly in 38-char display limit
expect(text).toBe(longModel);
});
it("truncates display text for very long model names", () => {
// Use short provider to allow longer model in callback_data (64 byte limit)
// "mdl_sel_a/" = 10 bytes, leaving 54 for model
const longModel = "this-model-name-is-long-enough-to-need-truncation-abcd";
const result = buildModelsKeyboard({
provider: "a",
models: [longModel],
currentPage: 1,
totalPages: 1,
});
const text = result[0]?.[0]?.text;
expect(text?.startsWith("…")).toBe(true);
expect(text?.length).toBeLessThanOrEqual(38);
it("keeps short display IDs untouched and truncates overly long IDs", () => {
const cases = [
{
name: "max-length display",
provider: "anthropic",
model: "claude-3-5-sonnet-20241022-with-suffix",
expected: "claude-3-5-sonnet-20241022-with-suffix",
},
{
name: "overly long display",
provider: "a",
model: "this-model-name-is-long-enough-to-need-truncation-abcd",
startsWith: "…",
maxLength: 38,
},
] as const;
for (const testCase of cases) {
const result = buildModelsKeyboard({
provider: testCase.provider,
models: [testCase.model],
currentPage: 1,
totalPages: 1,
});
const text = result[0]?.[0]?.text;
if ("expected" in testCase) {
expect(text, testCase.name).toBe(testCase.expected);
} else {
expect(text?.startsWith(testCase.startsWith), testCase.name).toBe(true);
expect(text?.length, testCase.name).toBeLessThanOrEqual(testCase.maxLength);
}
}
});
});

View File

@@ -297,20 +297,6 @@ describe("sendMessageTelegram", () => {
});
});
it("wraps chat-not-found with actionable context", async () => {
const chatId = "123";
const err = new Error("400: Bad Request: chat not found");
const sendMessage = vi.fn().mockRejectedValue(err);
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expectChatNotFoundWithChatId(
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
chatId,
);
});
it("preserves thread params in plain text fallback", async () => {
const chatId = "-1001234567890";
const parseErr = new Error(
@@ -478,153 +464,139 @@ describe("sendMessageTelegram", () => {
});
});
it("sends video as video note when asVideoNote is true", async () => {
it("sends video notes when requested and regular videos otherwise", async () => {
const chatId = "123";
const text = "ignored caption context";
const sendVideoNote = vi.fn().mockResolvedValue({
message_id: 101,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({
message_id: 102,
chat: { id: chatId },
});
const api = { sendVideoNote, sendMessage } as unknown as {
sendVideoNote: typeof sendVideoNote;
sendMessage: typeof sendMessage;
};
{
const text = "ignored caption context";
const sendVideoNote = vi.fn().mockResolvedValue({
message_id: 101,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({
message_id: 102,
chat: { id: chatId },
});
const api = { sendVideoNote, sendMessage } as unknown as {
sendVideoNote: typeof sendVideoNote;
sendMessage: typeof sendMessage;
};
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
const res = await sendMessageTelegram(chatId, text, {
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: true,
});
const res = await sendMessageTelegram(chatId, text, {
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: true,
});
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
parse_mode: "HTML",
});
expect(res.messageId).toBe("102");
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
parse_mode: "HTML",
});
expect(res.messageId).toBe("102");
}
{
const text = "my caption";
const sendVideo = vi.fn().mockResolvedValue({
message_id: 201,
chat: { id: chatId },
});
const api = { sendVideo } as unknown as {
sendVideo: typeof sendVideo;
};
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
const res = await sendMessageTelegram(chatId, text, {
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: false,
});
expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: expect.any(String),
parse_mode: "HTML",
});
expect(res.messageId).toBe("201");
}
});
it("sends regular video when asVideoNote is false", async () => {
it("applies reply markup and thread options to split video-note sends", async () => {
const chatId = "123";
const text = "my caption";
const sendVideo = vi.fn().mockResolvedValue({
message_id: 201,
chat: { id: chatId },
});
const api = { sendVideo } as unknown as {
sendVideo: typeof sendVideo;
};
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
const res = await sendMessageTelegram(chatId, text, {
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: false,
});
expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: expect.any(String),
parse_mode: "HTML",
});
expect(res.messageId).toBe("201");
});
it("adds reply_markup to separate text message for video notes", async () => {
const chatId = "123";
const text = "Check this out";
const sendVideoNote = vi.fn().mockResolvedValue({
message_id: 301,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({
message_id: 302,
chat: { id: chatId },
});
const api = { sendVideoNote, sendMessage } as unknown as {
sendVideoNote: typeof sendVideoNote;
sendMessage: typeof sendMessage;
};
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
await sendMessageTelegram(chatId, text, {
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: true,
buttons: [[{ text: "Btn", callback_data: "dat" }]],
});
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]],
const cases = [
{
text: "Check this out",
options: {
buttons: [[{ text: "Btn", callback_data: "dat" }]],
},
expectedVideoNote: {},
expectedMessage: {
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]],
},
},
},
});
});
{
text: "Threaded reply",
options: {
replyToMessageId: 999,
},
expectedVideoNote: { reply_to_message_id: 999 },
expectedMessage: {
parse_mode: "HTML",
reply_to_message_id: 999,
},
},
] as const;
it("threads video note and text message correctly", async () => {
const chatId = "123";
const text = "Threaded reply";
for (const testCase of cases) {
const sendVideoNote = vi.fn().mockResolvedValue({
message_id: 301,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({
message_id: 302,
chat: { id: chatId },
});
const api = { sendVideoNote, sendMessage } as unknown as {
sendVideoNote: typeof sendVideoNote;
sendMessage: typeof sendMessage;
};
const sendVideoNote = vi.fn().mockResolvedValue({
message_id: 401,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({
message_id: 402,
chat: { id: chatId },
});
const api = { sendVideoNote, sendMessage } as unknown as {
sendVideoNote: typeof sendVideoNote;
sendMessage: typeof sendMessage;
};
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
await sendMessageTelegram(chatId, testCase.text, {
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: true,
...testCase.options,
});
await sendMessageTelegram(chatId, text, {
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: true,
replyToMessageId: 999,
});
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {
reply_to_message_id: 999,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
parse_mode: "HTML",
reply_to_message_id: 999,
});
expect(sendVideoNote).toHaveBeenCalledWith(
chatId,
expect.anything(),
testCase.expectedVideoNote,
);
expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, testCase.expectedMessage);
}
});
it("retries on transient errors with retry_after", async () => {
@@ -847,171 +819,144 @@ describe("sendMessageTelegram", () => {
expect(sendAudio).not.toHaveBeenCalled();
});
it("includes message_thread_id for forum topic messages", async () => {
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 55,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
it("keeps message_thread_id for forum/private/group sends", async () => {
const cases = [
{
name: "forum topic",
chatId: "-1001234567890",
text: "hello forum",
messageId: 55,
},
{
name: "private chat topic (#18974)",
chatId: "123456789",
text: "hello private",
messageId: 56,
},
{
// Group/supergroup chats have negative IDs.
name: "group chat (#17242)",
chatId: "-1001234567890",
text: "hello group",
messageId: 57,
},
] as const;
await sendMessageTelegram(chatId, "hello forum", {
token: "tok",
api,
messageThreadId: 271,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
parse_mode: "HTML",
message_thread_id: 271,
});
});
it("keeps message_thread_id for private chat topic sends (#18974)", async () => {
const chatId = "123456789";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 56,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(chatId, "hello private", {
token: "tok",
api,
messageThreadId: 271,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", {
parse_mode: "HTML",
message_thread_id: 271,
});
});
it("keeps message_thread_id for group chat sends (#17242)", async () => {
// Group/supergroup chats have negative IDs.
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 57,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(chatId, "hello group", {
token: "tok",
api,
messageThreadId: 271,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello group", {
parse_mode: "HTML",
message_thread_id: 271,
});
});
it("retries without message_thread_id when Telegram reports missing thread", async () => {
const chatId = "-100123";
const threadErr = new Error("400: Bad Request: message thread not found");
const sendMessage = vi
.fn()
.mockRejectedValueOnce(threadErr)
.mockResolvedValueOnce({
message_id: 58,
chat: { id: chatId },
for (const testCase of cases) {
const sendMessage = vi.fn().mockResolvedValue({
message_id: testCase.messageId,
chat: { id: testCase.chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
const res = await sendMessageTelegram(chatId, "hello forum", {
token: "tok",
api,
messageThreadId: 271,
});
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "hello forum", {
parse_mode: "HTML",
message_thread_id: 271,
});
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "hello forum", {
parse_mode: "HTML",
});
expect(res.messageId).toBe("58");
});
it("retries private chat sends without message_thread_id on thread-not-found", async () => {
const chatId = "123456789";
const threadErr = new Error("400: Bad Request: message thread not found");
const sendMessage = vi
.fn()
.mockRejectedValueOnce(threadErr)
.mockResolvedValueOnce({
message_id: 59,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
const res = await sendMessageTelegram(chatId, "hello private", {
token: "tok",
api,
messageThreadId: 271,
});
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "hello private", {
parse_mode: "HTML",
message_thread_id: 271,
});
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "hello private", {
parse_mode: "HTML",
});
expect(res.messageId).toBe("59");
});
it("does not retry thread-not-found when no message_thread_id was provided", async () => {
const chatId = "123";
const threadErr = new Error("400: Bad Request: message thread not found");
const sendMessage = vi.fn().mockRejectedValueOnce(threadErr);
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expect(
sendMessageTelegram(chatId, "hello forum", {
token: "tok",
api,
}),
).rejects.toThrow("message thread not found");
expect(sendMessage).toHaveBeenCalledTimes(1);
});
it("does not retry without message_thread_id on chat-not-found", async () => {
const chatId = "123456789";
const chatErr = new Error("400: Bad Request: chat not found");
const sendMessage = vi.fn().mockRejectedValueOnce(chatErr);
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expect(
sendMessageTelegram(chatId, "hello private", {
await sendMessageTelegram(testCase.chatId, testCase.text, {
token: "tok",
api,
messageThreadId: 271,
}),
).rejects.toThrow(/chat not found/i);
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", {
parse_mode: "HTML",
message_thread_id: 271,
});
expect(sendMessage, testCase.name).toHaveBeenCalledWith(testCase.chatId, testCase.text, {
parse_mode: "HTML",
message_thread_id: 271,
});
}
});
it("retries sends without message_thread_id on thread-not-found", async () => {
const cases = [
{ name: "forum", chatId: "-100123", text: "hello forum", messageId: 58 },
{ name: "private", chatId: "123456789", text: "hello private", messageId: 59 },
] as const;
const threadErr = new Error("400: Bad Request: message thread not found");
for (const testCase of cases) {
const sendMessage = vi
.fn()
.mockRejectedValueOnce(threadErr)
.mockResolvedValueOnce({
message_id: testCase.messageId,
chat: { id: testCase.chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
const res = await sendMessageTelegram(testCase.chatId, testCase.text, {
token: "tok",
api,
messageThreadId: 271,
});
expect(sendMessage, testCase.name).toHaveBeenNthCalledWith(
1,
testCase.chatId,
testCase.text,
{
parse_mode: "HTML",
message_thread_id: 271,
},
);
expect(sendMessage, testCase.name).toHaveBeenNthCalledWith(
2,
testCase.chatId,
testCase.text,
{
parse_mode: "HTML",
},
);
expect(res.messageId, testCase.name).toBe(String(testCase.messageId));
}
});
it("does not retry on non-retriable thread/chat errors", async () => {
const cases: Array<{
chatId: string;
text: string;
error: Error;
opts?: { messageThreadId?: number };
expectedError: RegExp | string;
expectedCallArgs: [string, string, { parse_mode: "HTML"; message_thread_id?: number }];
}> = [
{
chatId: "123",
text: "hello forum",
error: new Error("400: Bad Request: message thread not found"),
expectedError: "message thread not found",
expectedCallArgs: ["123", "hello forum", { parse_mode: "HTML" }],
},
{
chatId: "123456789",
text: "hello private",
error: new Error("400: Bad Request: chat not found"),
opts: { messageThreadId: 271 },
expectedError: /chat not found/i,
expectedCallArgs: [
"123456789",
"hello private",
{ parse_mode: "HTML", message_thread_id: 271 },
],
},
];
for (const testCase of cases) {
const sendMessage = vi.fn().mockRejectedValueOnce(testCase.error);
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expect(
sendMessageTelegram(testCase.chatId, testCase.text, {
token: "tok",
api,
...testCase.opts,
}),
).rejects.toThrow(testCase.expectedError);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(...testCase.expectedCallArgs);
}
});
it("sets disable_notification when silent is true", async () => {
@@ -1057,28 +1002,6 @@ describe("sendMessageTelegram", () => {
});
});
it("includes reply_to_message_id for threaded replies", async () => {
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 56,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(chatId, "reply text", {
token: "tok",
api,
replyToMessageId: 100,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
parse_mode: "HTML",
reply_to_message_id: 100,
});
});
it("retries media sends without message_thread_id when thread is missing", async () => {
const chatId = "-100123";
const threadErr = new Error("400: Bad Request: message thread not found");
@@ -1224,42 +1147,6 @@ describe("sendStickerTelegram", () => {
expect(res.messageId).toBe("109");
});
it("includes reply_to_message_id for threaded replies", async () => {
const chatId = "123";
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
const sendSticker = vi.fn().mockResolvedValue({
message_id: 102,
chat: { id: chatId },
});
const api = { sendSticker } as unknown as {
sendSticker: typeof sendSticker;
};
await sendStickerTelegram(chatId, fileId, {
token: "tok",
api,
replyToMessageId: 500,
});
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
reply_to_message_id: 500,
});
});
it("wraps chat-not-found with actionable context", async () => {
const chatId = "123";
const err = new Error("400: Bad Request: chat not found");
const sendSticker = vi.fn().mockRejectedValue(err);
const api = { sendSticker } as unknown as {
sendSticker: typeof sendSticker;
};
await expectChatNotFoundWithChatId(
sendStickerTelegram(chatId, "fileId123", { token: "tok", api }),
chatId,
);
});
it("trims whitespace from fileId", async () => {
const chatId = "123";
const sendSticker = vi.fn().mockResolvedValue({
@@ -1279,6 +1166,84 @@ describe("sendStickerTelegram", () => {
});
});
describe("shared send behaviors", () => {
it("includes reply_to_message_id for threaded replies", async () => {
{
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 56,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(chatId, "reply text", {
token: "tok",
api,
replyToMessageId: 100,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
parse_mode: "HTML",
reply_to_message_id: 100,
});
}
{
const chatId = "123";
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
const sendSticker = vi.fn().mockResolvedValue({
message_id: 102,
chat: { id: chatId },
});
const api = { sendSticker } as unknown as {
sendSticker: typeof sendSticker;
};
await sendStickerTelegram(chatId, fileId, {
token: "tok",
api,
replyToMessageId: 500,
});
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
reply_to_message_id: 500,
});
}
});
it("wraps chat-not-found with actionable context", async () => {
{
const chatId = "123";
const err = new Error("400: Bad Request: chat not found");
const sendMessage = vi.fn().mockRejectedValue(err);
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expectChatNotFoundWithChatId(
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
chatId,
);
}
{
const chatId = "123";
const err = new Error("400: Bad Request: chat not found");
const sendSticker = vi.fn().mockRejectedValue(err);
const api = { sendSticker } as unknown as {
sendSticker: typeof sendSticker;
};
await expectChatNotFoundWithChatId(
sendStickerTelegram(chatId, "fileId123", { token: "tok", api }),
chatId,
);
}
});
});
describe("editMessageTelegram", () => {
beforeEach(() => {
botApi.editMessageText.mockReset();