mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 14:14:32 +00:00
test: dedupe channel and transport adapters
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
|
||||
@@ -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("<b>nope</b>");
|
||||
});
|
||||
|
||||
it("escapes unsafe characters", () => {
|
||||
const res = markdownToTelegramHtml("a & b < c");
|
||||
expect(res).toBe("a & b < 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>", "<b>nope</b>"],
|
||||
["escapes unsafe characters", "a & b < c", "a & b < 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", () => {
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user