mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:41:36 +00:00
test: dedupe telegram formatting and send cases
This commit is contained in:
@@ -201,80 +201,106 @@ describe("edge cases", () => {
|
|||||||
expect(result).toBe("README.md");
|
expect(result).toBe("README.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wraps supported TLD extensions (.am, .at, .be, .cc)", () => {
|
it("classifies extension-like tokens as file refs or domains", () => {
|
||||||
const result = markdownToTelegramHtml("Makefile.am and code.at and app.be and main.cc");
|
|
||||||
expect(result).toContain("<code>Makefile.am</code>");
|
|
||||||
expect(result).toContain("<code>code.at</code>");
|
|
||||||
expect(result).toContain("<code>app.be</code>");
|
|
||||||
expect(result).toContain("<code>main.cc</code>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wrap popular domain TLDs (.ai, .io, .tv, .fm)", () => {
|
|
||||||
// These are commonly used as real domains (x.ai, vercel.io, github.io)
|
|
||||||
const result = markdownToTelegramHtml("Check x.ai and vercel.io and app.tv and radio.fm");
|
|
||||||
// Should be links, not code
|
|
||||||
expect(result).toContain('<a href="http://x.ai">');
|
|
||||||
expect(result).toContain('<a href="http://vercel.io">');
|
|
||||||
expect(result).toContain('<a href="http://app.tv">');
|
|
||||||
expect(result).toContain('<a href="http://radio.fm">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps .co domains as links", () => {
|
|
||||||
const result = markdownToTelegramHtml("Visit t.co and openclaw.co");
|
|
||||||
expect(result).toContain('<a href="http://t.co">');
|
|
||||||
expect(result).toContain('<a href="http://openclaw.co">');
|
|
||||||
expect(result).not.toContain("<code>t.co</code>");
|
|
||||||
expect(result).not.toContain("<code>openclaw.co</code>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wrap non-TLD extensions", () => {
|
|
||||||
const result = markdownToTelegramHtml("image.png and style.css and script.js");
|
|
||||||
expect(result).not.toContain("<code>image.png</code>");
|
|
||||||
expect(result).not.toContain("<code>style.css</code>");
|
|
||||||
expect(result).not.toContain("<code>script.js</code>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles file refs at message boundaries", () => {
|
|
||||||
const cases = [
|
const cases = [
|
||||||
["README.md is important", "<code>README.md</code> is important"],
|
{
|
||||||
["Check the README.md", "Check the <code>README.md</code>"],
|
name: "supported file-style extensions",
|
||||||
|
input: "Makefile.am and code.at and app.be and main.cc",
|
||||||
|
contains: [
|
||||||
|
"<code>Makefile.am</code>",
|
||||||
|
"<code>code.at</code>",
|
||||||
|
"<code>app.be</code>",
|
||||||
|
"<code>main.cc</code>",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "popular domain TLDs stay links",
|
||||||
|
input: "Check x.ai and vercel.io and app.tv and radio.fm",
|
||||||
|
contains: [
|
||||||
|
'<a href="http://x.ai">',
|
||||||
|
'<a href="http://vercel.io">',
|
||||||
|
'<a href="http://app.tv">',
|
||||||
|
'<a href="http://radio.fm">',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ".co stays links",
|
||||||
|
input: "Visit t.co and openclaw.co",
|
||||||
|
contains: ['<a href="http://t.co">', '<a href="http://openclaw.co">'],
|
||||||
|
notContains: ["<code>t.co</code>", "<code>openclaw.co</code>"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-target extensions stay plain text",
|
||||||
|
input: "image.png and style.css and script.js",
|
||||||
|
notContains: ["<code>image.png</code>", "<code>style.css</code>", "<code>script.js</code>"],
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
for (const [input, expected] of cases) {
|
for (const testCase of cases) {
|
||||||
expect(markdownToTelegramHtml(input), input).toBe(expected);
|
const result = markdownToTelegramHtml(testCase.input);
|
||||||
|
for (const expected of testCase.contains ?? []) {
|
||||||
|
expect(result, testCase.name).toContain(expected);
|
||||||
|
}
|
||||||
|
for (const unexpected of testCase.notContains ?? []) {
|
||||||
|
expect(result, testCase.name).not.toContain(unexpected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles multiple file refs in sequence", () => {
|
it("wraps file refs across boundaries, sequences, and path variants", () => {
|
||||||
const result = markdownToTelegramHtml("README.md CHANGELOG.md LICENSE.md");
|
const cases = [
|
||||||
expect(result).toContain("<code>README.md</code>");
|
{
|
||||||
expect(result).toContain("<code>CHANGELOG.md</code>");
|
name: "message start boundary",
|
||||||
expect(result).toContain("<code>LICENSE.md</code>");
|
input: "README.md is important",
|
||||||
});
|
expectedExact: "<code>README.md</code> is important",
|
||||||
|
},
|
||||||
it("handles nested path without domain-like segments", () => {
|
{
|
||||||
const result = markdownToTelegramHtml("src/utils/helpers/format.go");
|
name: "message end boundary",
|
||||||
expect(result).toContain("<code>src/utils/helpers/format.go</code>");
|
input: "Check the README.md",
|
||||||
});
|
expectedExact: "Check the <code>README.md</code>",
|
||||||
|
},
|
||||||
it("wraps path with version-like segment (not a domain)", () => {
|
{
|
||||||
// v1.0/README.md is not linkified by markdown-it (no TLD), so it's wrapped
|
name: "multiple file refs",
|
||||||
const result = markdownToTelegramHtml("v1.0/README.md");
|
input: "README.md CHANGELOG.md LICENSE.md",
|
||||||
expect(result).toContain("<code>v1.0/README.md</code>");
|
contains: [
|
||||||
});
|
"<code>README.md</code>",
|
||||||
|
"<code>CHANGELOG.md</code>",
|
||||||
it("preserves domain path with version segment", () => {
|
"<code>LICENSE.md</code>",
|
||||||
// example.com/v1.0/README.md IS linkified (has domain), preserved as link
|
],
|
||||||
const result = markdownToTelegramHtml("example.com/v1.0/README.md");
|
},
|
||||||
expect(result).toContain('<a href="http://example.com/v1.0/README.md">');
|
{
|
||||||
});
|
name: "nested path",
|
||||||
|
input: "src/utils/helpers/format.go",
|
||||||
it("wraps hyphen/underscore filenames and uppercase extensions", () => {
|
contains: ["<code>src/utils/helpers/format.go</code>"],
|
||||||
const first = markdownToTelegramHtml("my-file_name.md");
|
},
|
||||||
expect(first).toContain("<code>my-file_name.md</code>");
|
{
|
||||||
|
name: "version-like non-domain path",
|
||||||
const second = markdownToTelegramHtml("README.MD and SCRIPT.PY");
|
input: "v1.0/README.md",
|
||||||
expect(second).toContain("<code>README.MD</code>");
|
contains: ["<code>v1.0/README.md</code>"],
|
||||||
expect(second).toContain("<code>SCRIPT.PY</code>");
|
},
|
||||||
|
{
|
||||||
|
name: "domain with version path",
|
||||||
|
input: "example.com/v1.0/README.md",
|
||||||
|
contains: ['<a href="http://example.com/v1.0/README.md">'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hyphen underscore and uppercase extensions",
|
||||||
|
input: "my-file_name.md README.MD and SCRIPT.PY",
|
||||||
|
contains: [
|
||||||
|
"<code>my-file_name.md</code>",
|
||||||
|
"<code>README.MD</code>",
|
||||||
|
"<code>SCRIPT.PY</code>",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const result = markdownToTelegramHtml(testCase.input);
|
||||||
|
if ("expectedExact" in testCase) {
|
||||||
|
expect(result, testCase.name).toBe(testCase.expectedExact);
|
||||||
|
}
|
||||||
|
for (const expected of testCase.contains ?? []) {
|
||||||
|
expect(result, testCase.name).toContain(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles nested code tags (depth tracking)", () => {
|
it("handles nested code tags (depth tracking)", () => {
|
||||||
@@ -325,24 +351,6 @@ describe("edge cases", () => {
|
|||||||
expect(result).toBe("<code>x.md</code> <b>bold</b>");
|
expect(result).toBe("<code>x.md</code> <b>bold</b>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not wrap orphaned TLD inside existing code tags", () => {
|
|
||||||
// R&D.md is already inside <code>, orphaned pass should NOT wrap D.md again
|
|
||||||
const input = "<code>R&D.md</code>";
|
|
||||||
const result = wrapFileReferencesInHtml(input);
|
|
||||||
// Should remain unchanged - no nested code tags
|
|
||||||
expect(result).toBe(input);
|
|
||||||
expect(result).not.toContain("<code><code>");
|
|
||||||
expect(result).not.toContain("</code></code>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wrap orphaned TLD inside anchor link text", () => {
|
|
||||||
// R&D.md inside anchor text should NOT have D.md wrapped
|
|
||||||
const input = '<a href="https://example.com">R&D.md</a>';
|
|
||||||
const result = wrapFileReferencesInHtml(input);
|
|
||||||
expect(result).toBe(input);
|
|
||||||
expect(result).not.toContain("<code>D.md</code>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles malformed HTML with stray closing tags (negative depth)", () => {
|
it("handles malformed HTML with stray closing tags (negative depth)", () => {
|
||||||
// Stray </code> before content shouldn't break protection logic
|
// Stray </code> before content shouldn't break protection logic
|
||||||
// (depth should clamp at 0, not go negative)
|
// (depth should clamp at 0, not go negative)
|
||||||
@@ -356,15 +364,19 @@ describe("edge cases", () => {
|
|||||||
expect(result).not.toContain("<code><code>");
|
expect(result).not.toContain("<code><code>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not wrap orphaned TLD fragments inside HTML attributes", () => {
|
it("does not wrap orphaned TLD fragments inside protected HTML contexts", () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
|
"<code>R&D.md</code>",
|
||||||
|
'<a href="https://example.com">R&D.md</a>',
|
||||||
'<a href="http://example.com/R&D.md">link</a>',
|
'<a href="http://example.com/R&D.md">link</a>',
|
||||||
'<img src="logo/R&D.md" alt="R&D.md">',
|
'<img src="logo/R&D.md" alt="R&D.md">',
|
||||||
] as const;
|
] as const;
|
||||||
for (const input of cases) {
|
for (const input of cases) {
|
||||||
const result = wrapFileReferencesInHtml(input);
|
const result = wrapFileReferencesInHtml(input);
|
||||||
expect(result).toBe(input);
|
expect(result, input).toBe(input);
|
||||||
expect(result).not.toContain("<code>D.md</code>");
|
expect(result, input).not.toContain("<code>D.md</code>");
|
||||||
|
expect(result, input).not.toContain("<code><code>");
|
||||||
|
expect(result, input).not.toContain("</code></code>");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,97 +73,115 @@ describe("sent-message-cache", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildInlineKeyboard", () => {
|
describe("buildInlineKeyboard", () => {
|
||||||
it("returns undefined for empty input", () => {
|
it("normalizes keyboard inputs", () => {
|
||||||
expect(buildInlineKeyboard()).toBeUndefined();
|
const cases = [
|
||||||
expect(buildInlineKeyboard([])).toBeUndefined();
|
{
|
||||||
});
|
name: "empty input",
|
||||||
|
input: undefined,
|
||||||
it("builds inline keyboards for valid input", () => {
|
expected: undefined,
|
||||||
const result = buildInlineKeyboard([
|
},
|
||||||
[{ text: "Option A", callback_data: "cmd:a" }],
|
{
|
||||||
[
|
name: "empty rows",
|
||||||
{ text: "Option B", callback_data: "cmd:b" },
|
input: [],
|
||||||
{ text: "Option C", callback_data: "cmd:c" },
|
expected: undefined,
|
||||||
],
|
},
|
||||||
]);
|
{
|
||||||
expect(result).toEqual({
|
name: "valid rows",
|
||||||
inline_keyboard: [
|
input: [
|
||||||
[{ text: "Option A", callback_data: "cmd:a" }],
|
[{ text: "Option A", callback_data: "cmd:a" }],
|
||||||
[
|
[
|
||||||
{ text: "Option B", callback_data: "cmd:b" },
|
{ text: "Option B", callback_data: "cmd:b" },
|
||||||
{ text: "Option C", callback_data: "cmd:c" },
|
{ text: "Option C", callback_data: "cmd:c" },
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
expected: {
|
||||||
});
|
inline_keyboard: [
|
||||||
});
|
[{ text: "Option A", callback_data: "cmd:a" }],
|
||||||
|
[
|
||||||
it("passes through button style", () => {
|
{ text: "Option B", callback_data: "cmd:b" },
|
||||||
const result = buildInlineKeyboard([
|
{ text: "Option C", callback_data: "cmd:c" },
|
||||||
[
|
],
|
||||||
{
|
],
|
||||||
text: "Option A",
|
|
||||||
callback_data: "cmd:a",
|
|
||||||
style: "primary",
|
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
]);
|
{
|
||||||
expect(result).toEqual({
|
name: "keeps button style fields",
|
||||||
inline_keyboard: [
|
input: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Option A",
|
text: "Option A",
|
||||||
callback_data: "cmd:a",
|
callback_data: "cmd:a",
|
||||||
style: "primary",
|
style: "primary",
|
||||||
},
|
},
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
expected: {
|
||||||
});
|
inline_keyboard: [
|
||||||
});
|
[
|
||||||
|
{
|
||||||
it("filters invalid buttons and empty rows", () => {
|
text: "Option A",
|
||||||
const result = buildInlineKeyboard([
|
callback_data: "cmd:a",
|
||||||
[
|
style: "primary",
|
||||||
{ text: "", callback_data: "cmd:skip" },
|
},
|
||||||
{ text: "Ok", callback_data: "cmd:ok" },
|
],
|
||||||
],
|
],
|
||||||
[{ text: "Missing data", callback_data: "" }],
|
},
|
||||||
[],
|
},
|
||||||
]);
|
{
|
||||||
expect(result).toEqual({
|
name: "filters invalid buttons and empty rows",
|
||||||
inline_keyboard: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
input: [
|
||||||
});
|
[
|
||||||
|
{ text: "", callback_data: "cmd:skip" },
|
||||||
|
{ text: "Ok", callback_data: "cmd:ok" },
|
||||||
|
],
|
||||||
|
[{ text: "Missing data", callback_data: "" }],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
inline_keyboard: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
for (const testCase of cases) {
|
||||||
|
expect(buildInlineKeyboard(testCase.input), testCase.name).toEqual(testCase.expected);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessageTelegram", () => {
|
describe("sendMessageTelegram", () => {
|
||||||
it("passes timeoutSeconds to grammY client when configured", async () => {
|
it("applies timeoutSeconds config precedence", async () => {
|
||||||
loadConfig.mockReturnValue({
|
const cases = [
|
||||||
channels: { telegram: { timeoutSeconds: 60 } },
|
{
|
||||||
});
|
name: "global telegram timeout",
|
||||||
await sendMessageTelegram("123", "hi", { token: "tok" });
|
cfg: { channels: { telegram: { timeoutSeconds: 60 } } },
|
||||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
opts: { token: "tok" },
|
||||||
"tok",
|
expectedTimeout: 60,
|
||||||
expect.objectContaining({
|
|
||||||
client: expect.objectContaining({ timeoutSeconds: 60 }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it("prefers per-account timeoutSeconds overrides", async () => {
|
|
||||||
loadConfig.mockReturnValue({
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
timeoutSeconds: 60,
|
|
||||||
accounts: { foo: { timeoutSeconds: 61 } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" });
|
name: "per-account timeout override",
|
||||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
cfg: {
|
||||||
"tok",
|
channels: {
|
||||||
expect.objectContaining({
|
telegram: {
|
||||||
client: expect.objectContaining({ timeoutSeconds: 61 }),
|
timeoutSeconds: 60,
|
||||||
}),
|
accounts: { foo: { timeoutSeconds: 61 } },
|
||||||
);
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
opts: { token: "tok", accountId: "foo" },
|
||||||
|
expectedTimeout: 61,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
for (const testCase of cases) {
|
||||||
|
botCtorSpy.mockClear();
|
||||||
|
loadConfig.mockReturnValue(testCase.cfg);
|
||||||
|
await sendMessageTelegram("123", "hi", testCase.opts);
|
||||||
|
expect(botCtorSpy, testCase.name).toHaveBeenCalledWith(
|
||||||
|
"tok",
|
||||||
|
expect.objectContaining({
|
||||||
|
client: expect.objectContaining({ timeoutSeconds: testCase.expectedTimeout }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to plain text when Telegram rejects HTML", async () => {
|
it("falls back to plain text when Telegram rejects HTML", async () => {
|
||||||
@@ -196,60 +214,46 @@ describe("sendMessageTelegram", () => {
|
|||||||
expect(res.messageId).toBe("42");
|
expect(res.messageId).toBe("42");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds link_preview_options when previews are disabled in config", async () => {
|
it("keeps link_preview_options disabled for both html and plain-text fallback", async () => {
|
||||||
const chatId = "123";
|
|
||||||
const sendMessage = vi.fn().mockResolvedValue({
|
|
||||||
message_id: 7,
|
|
||||||
chat: { id: chatId },
|
|
||||||
});
|
|
||||||
const api = { sendMessage } as unknown as {
|
|
||||||
sendMessage: typeof sendMessage;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadConfig.mockReturnValue({
|
|
||||||
channels: { telegram: { linkPreview: false } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendMessageTelegram(chatId, "hi", { token: "tok", api });
|
|
||||||
|
|
||||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
|
|
||||||
parse_mode: "HTML",
|
|
||||||
link_preview_options: { is_disabled: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps link_preview_options on plain-text fallback when disabled", async () => {
|
|
||||||
const chatId = "123";
|
|
||||||
const parseErr = new Error(
|
const parseErr = new Error(
|
||||||
"400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9",
|
"400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9",
|
||||||
);
|
);
|
||||||
const sendMessage = vi
|
const cases = [
|
||||||
.fn()
|
{
|
||||||
.mockRejectedValueOnce(parseErr)
|
name: "html send succeeds",
|
||||||
.mockResolvedValueOnce({
|
text: "hi",
|
||||||
message_id: 42,
|
sendMessage: vi.fn().mockResolvedValue({ message_id: 7, chat: { id: "123" } }),
|
||||||
chat: { id: chatId },
|
expectedCalls: [
|
||||||
|
["123", "hi", { parse_mode: "HTML", link_preview_options: { is_disabled: true } }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "html parse fails then plain-text fallback",
|
||||||
|
text: "_oops_",
|
||||||
|
sendMessage: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(parseErr)
|
||||||
|
.mockResolvedValueOnce({ message_id: 42, chat: { id: "123" } }),
|
||||||
|
expectedCalls: [
|
||||||
|
[
|
||||||
|
"123",
|
||||||
|
"<i>oops</i>",
|
||||||
|
{ parse_mode: "HTML", link_preview_options: { is_disabled: true } },
|
||||||
|
],
|
||||||
|
["123", "_oops_", { link_preview_options: { is_disabled: true } }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
for (const testCase of cases) {
|
||||||
|
loadConfig.mockReturnValue({
|
||||||
|
channels: { telegram: { linkPreview: false } },
|
||||||
});
|
});
|
||||||
const api = { sendMessage } as unknown as {
|
const api = { sendMessage: testCase.sendMessage } as unknown as {
|
||||||
sendMessage: typeof sendMessage;
|
sendMessage: typeof testCase.sendMessage;
|
||||||
};
|
};
|
||||||
|
await sendMessageTelegram("123", testCase.text, { token: "tok", api });
|
||||||
loadConfig.mockReturnValue({
|
expect(testCase.sendMessage.mock.calls, testCase.name).toEqual(testCase.expectedCalls);
|
||||||
channels: { telegram: { linkPreview: false } },
|
}
|
||||||
});
|
|
||||||
|
|
||||||
await sendMessageTelegram(chatId, "_oops_", {
|
|
||||||
token: "tok",
|
|
||||||
api,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "<i>oops</i>", {
|
|
||||||
parse_mode: "HTML",
|
|
||||||
link_preview_options: { is_disabled: true },
|
|
||||||
});
|
|
||||||
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_", {
|
|
||||||
link_preview_options: { is_disabled: true },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses native fetch for BAN compatibility when api is omitted", async () => {
|
it("uses native fetch for BAN compatibility when api is omitted", async () => {
|
||||||
@@ -676,147 +680,102 @@ describe("sendMessageTelegram", () => {
|
|||||||
expect(res.messageId).toBe("9");
|
expect(res.messageId).toBe("9");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends audio media as files by default", async () => {
|
it("routes audio media to sendAudio/sendVoice based on voice compatibility", async () => {
|
||||||
const chatId = "123";
|
const cases = [
|
||||||
const sendAudio = vi.fn().mockResolvedValue({
|
{
|
||||||
message_id: 10,
|
name: "default audio send",
|
||||||
chat: { id: chatId },
|
chatId: "123",
|
||||||
});
|
text: "caption",
|
||||||
const sendVoice = vi.fn().mockResolvedValue({
|
mediaUrl: "https://example.com/clip.mp3",
|
||||||
message_id: 11,
|
contentType: "audio/mpeg",
|
||||||
chat: { id: chatId },
|
fileName: "clip.mp3",
|
||||||
});
|
expectedMethod: "sendAudio" as const,
|
||||||
const api = { sendAudio, sendVoice } as unknown as {
|
expectedOptions: { caption: "caption", parse_mode: "HTML" },
|
||||||
sendAudio: typeof sendAudio;
|
},
|
||||||
sendVoice: typeof sendVoice;
|
{
|
||||||
};
|
name: "voice-compatible media with thread params",
|
||||||
|
chatId: "-1001234567890",
|
||||||
|
text: "voice note",
|
||||||
|
mediaUrl: "https://example.com/note.ogg",
|
||||||
|
contentType: "audio/ogg",
|
||||||
|
fileName: "note.ogg",
|
||||||
|
asVoice: true,
|
||||||
|
messageThreadId: 271,
|
||||||
|
replyToMessageId: 500,
|
||||||
|
expectedMethod: "sendVoice" as const,
|
||||||
|
expectedOptions: {
|
||||||
|
caption: "voice note",
|
||||||
|
parse_mode: "HTML",
|
||||||
|
message_thread_id: 271,
|
||||||
|
reply_to_message_id: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "asVoice fallback for non-voice media",
|
||||||
|
chatId: "123",
|
||||||
|
text: "caption",
|
||||||
|
mediaUrl: "https://example.com/clip.wav",
|
||||||
|
contentType: "audio/wav",
|
||||||
|
fileName: "clip.wav",
|
||||||
|
asVoice: true,
|
||||||
|
expectedMethod: "sendAudio" as const,
|
||||||
|
expectedOptions: { caption: "caption", parse_mode: "HTML" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "asVoice accepts mp3",
|
||||||
|
chatId: "123",
|
||||||
|
text: "caption",
|
||||||
|
mediaUrl: "https://example.com/clip.mp3",
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
fileName: "clip.mp3",
|
||||||
|
asVoice: true,
|
||||||
|
expectedMethod: "sendVoice" as const,
|
||||||
|
expectedOptions: { caption: "caption", parse_mode: "HTML" },
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
loadWebMedia.mockResolvedValueOnce({
|
for (const testCase of cases) {
|
||||||
buffer: Buffer.from("audio"),
|
const sendAudio = vi.fn().mockResolvedValue({
|
||||||
contentType: "audio/mpeg",
|
message_id: 10,
|
||||||
fileName: "clip.mp3",
|
chat: { id: testCase.chatId },
|
||||||
});
|
});
|
||||||
|
const sendVoice = vi.fn().mockResolvedValue({
|
||||||
|
message_id: 11,
|
||||||
|
chat: { id: testCase.chatId },
|
||||||
|
});
|
||||||
|
const api = { sendAudio, sendVoice } as unknown as {
|
||||||
|
sendAudio: typeof sendAudio;
|
||||||
|
sendVoice: typeof sendVoice;
|
||||||
|
};
|
||||||
|
|
||||||
await sendMessageTelegram(chatId, "caption", {
|
loadWebMedia.mockResolvedValueOnce({
|
||||||
token: "tok",
|
buffer: Buffer.from("audio"),
|
||||||
api,
|
contentType: testCase.contentType,
|
||||||
mediaUrl: "https://example.com/clip.mp3",
|
fileName: testCase.fileName,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), {
|
await sendMessageTelegram(testCase.chatId, testCase.text, {
|
||||||
caption: "caption",
|
token: "tok",
|
||||||
parse_mode: "HTML",
|
api,
|
||||||
});
|
mediaUrl: testCase.mediaUrl,
|
||||||
expect(sendVoice).not.toHaveBeenCalled();
|
...(testCase.asVoice ? { asVoice: true } : {}),
|
||||||
});
|
...(testCase.messageThreadId !== undefined
|
||||||
|
? { messageThreadId: testCase.messageThreadId }
|
||||||
|
: {}),
|
||||||
|
...(testCase.replyToMessageId !== undefined
|
||||||
|
? { replyToMessageId: testCase.replyToMessageId }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
it("sends voice messages when asVoice is true and preserves thread params", async () => {
|
const called = testCase.expectedMethod === "sendVoice" ? sendVoice : sendAudio;
|
||||||
const chatId = "-1001234567890";
|
const notCalled = testCase.expectedMethod === "sendVoice" ? sendAudio : sendVoice;
|
||||||
const sendAudio = vi.fn().mockResolvedValue({
|
expect(called, testCase.name).toHaveBeenCalledWith(
|
||||||
message_id: 12,
|
testCase.chatId,
|
||||||
chat: { id: chatId },
|
expect.anything(),
|
||||||
});
|
testCase.expectedOptions,
|
||||||
const sendVoice = vi.fn().mockResolvedValue({
|
);
|
||||||
message_id: 13,
|
expect(notCalled, testCase.name).not.toHaveBeenCalled();
|
||||||
chat: { id: chatId },
|
}
|
||||||
});
|
|
||||||
const api = { sendAudio, sendVoice } as unknown as {
|
|
||||||
sendAudio: typeof sendAudio;
|
|
||||||
sendVoice: typeof sendVoice;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadWebMedia.mockResolvedValueOnce({
|
|
||||||
buffer: Buffer.from("voice"),
|
|
||||||
contentType: "audio/ogg",
|
|
||||||
fileName: "note.ogg",
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendMessageTelegram(chatId, "voice note", {
|
|
||||||
token: "tok",
|
|
||||||
api,
|
|
||||||
mediaUrl: "https://example.com/note.ogg",
|
|
||||||
asVoice: true,
|
|
||||||
messageThreadId: 271,
|
|
||||||
replyToMessageId: 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendVoice).toHaveBeenCalledWith(chatId, expect.anything(), {
|
|
||||||
caption: "voice note",
|
|
||||||
parse_mode: "HTML",
|
|
||||||
message_thread_id: 271,
|
|
||||||
reply_to_message_id: 500,
|
|
||||||
});
|
|
||||||
expect(sendAudio).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to audio when asVoice is true but media is not voice compatible", async () => {
|
|
||||||
const chatId = "123";
|
|
||||||
const sendAudio = vi.fn().mockResolvedValue({
|
|
||||||
message_id: 14,
|
|
||||||
chat: { id: chatId },
|
|
||||||
});
|
|
||||||
const sendVoice = vi.fn().mockResolvedValue({
|
|
||||||
message_id: 15,
|
|
||||||
chat: { id: chatId },
|
|
||||||
});
|
|
||||||
const api = { sendAudio, sendVoice } as unknown as {
|
|
||||||
sendAudio: typeof sendAudio;
|
|
||||||
sendVoice: typeof sendVoice;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadWebMedia.mockResolvedValueOnce({
|
|
||||||
buffer: Buffer.from("audio"),
|
|
||||||
contentType: "audio/wav",
|
|
||||||
fileName: "clip.wav",
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendMessageTelegram(chatId, "caption", {
|
|
||||||
token: "tok",
|
|
||||||
api,
|
|
||||||
mediaUrl: "https://example.com/clip.wav",
|
|
||||||
asVoice: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), {
|
|
||||||
caption: "caption",
|
|
||||||
parse_mode: "HTML",
|
|
||||||
});
|
|
||||||
expect(sendVoice).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends MP3 as voice when asVoice is true", async () => {
|
|
||||||
const chatId = "123";
|
|
||||||
const sendAudio = vi.fn().mockResolvedValue({
|
|
||||||
message_id: 16,
|
|
||||||
chat: { id: chatId },
|
|
||||||
});
|
|
||||||
const sendVoice = vi.fn().mockResolvedValue({
|
|
||||||
message_id: 17,
|
|
||||||
chat: { id: chatId },
|
|
||||||
});
|
|
||||||
const api = { sendAudio, sendVoice } as unknown as {
|
|
||||||
sendAudio: typeof sendAudio;
|
|
||||||
sendVoice: typeof sendVoice;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadWebMedia.mockResolvedValueOnce({
|
|
||||||
buffer: Buffer.from("audio"),
|
|
||||||
contentType: "audio/mpeg",
|
|
||||||
fileName: "clip.mp3",
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendMessageTelegram(chatId, "caption", {
|
|
||||||
token: "tok",
|
|
||||||
api,
|
|
||||||
mediaUrl: "https://example.com/clip.mp3",
|
|
||||||
asVoice: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendVoice).toHaveBeenCalledWith(chatId, expect.anything(), {
|
|
||||||
caption: "caption",
|
|
||||||
parse_mode: "HTML",
|
|
||||||
});
|
|
||||||
expect(sendAudio).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps message_thread_id for forum/private/group sends", async () => {
|
it("keeps message_thread_id for forum/private/group sends", async () => {
|
||||||
@@ -1250,68 +1209,79 @@ describe("editMessageTelegram", () => {
|
|||||||
botCtorSpy.mockReset();
|
botCtorSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
|
it("handles button payload + parse fallback behavior", async () => {
|
||||||
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
const cases = [
|
||||||
|
{
|
||||||
|
name: "buttons undefined keeps existing keyboard",
|
||||||
|
setup: () => {
|
||||||
|
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||||
|
return { text: "hi", buttons: undefined as [] | undefined };
|
||||||
|
},
|
||||||
|
expectedCalls: 1,
|
||||||
|
firstExpectNoReplyMarkup: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "buttons empty clears keyboard",
|
||||||
|
setup: () => {
|
||||||
|
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||||
|
return { text: "hi", buttons: [] as [] };
|
||||||
|
},
|
||||||
|
expectedCalls: 1,
|
||||||
|
firstExpectReplyMarkup: { inline_keyboard: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parse error fallback preserves cleared keyboard",
|
||||||
|
setup: () => {
|
||||||
|
botApi.editMessageText
|
||||||
|
.mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
|
||||||
|
.mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
|
||||||
|
return { text: "<bad> html", buttons: [] as [] };
|
||||||
|
},
|
||||||
|
expectedCalls: 2,
|
||||||
|
firstExpectReplyMarkup: { inline_keyboard: [] },
|
||||||
|
secondExpectReplyMarkup: { inline_keyboard: [] },
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
await editMessageTelegram("123", 1, "hi", {
|
for (const testCase of cases) {
|
||||||
token: "tok",
|
botApi.editMessageText.mockReset();
|
||||||
cfg: {},
|
botCtorSpy.mockReset();
|
||||||
});
|
const input = testCase.setup();
|
||||||
|
|
||||||
expect(botCtorSpy).toHaveBeenCalledTimes(1);
|
await editMessageTelegram("123", 1, input.text, {
|
||||||
expect(botCtorSpy.mock.calls[0]?.[0]).toBe("tok");
|
token: "tok",
|
||||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
|
cfg: {},
|
||||||
const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
|
buttons: input.buttons,
|
||||||
expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
|
});
|
||||||
expect(params).not.toHaveProperty("reply_markup");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
|
expect(botCtorSpy, testCase.name).toHaveBeenCalledTimes(1);
|
||||||
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
expect(botCtorSpy.mock.calls[0]?.[0], testCase.name).toBe("tok");
|
||||||
|
expect(botApi.editMessageText, testCase.name).toHaveBeenCalledTimes(testCase.expectedCalls);
|
||||||
|
|
||||||
await editMessageTelegram("123", 1, "hi", {
|
const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<
|
||||||
token: "tok",
|
string,
|
||||||
cfg: {},
|
unknown
|
||||||
buttons: [],
|
>;
|
||||||
});
|
expect(firstParams, testCase.name).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
|
||||||
|
if (testCase.firstExpectNoReplyMarkup) {
|
||||||
|
expect(firstParams, testCase.name).not.toHaveProperty("reply_markup");
|
||||||
|
}
|
||||||
|
if (testCase.firstExpectReplyMarkup) {
|
||||||
|
expect(firstParams, testCase.name).toEqual(
|
||||||
|
expect.objectContaining({ reply_markup: testCase.firstExpectReplyMarkup }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
|
if (testCase.secondExpectReplyMarkup) {
|
||||||
const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
|
const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record<
|
||||||
expect(params).toEqual(
|
string,
|
||||||
expect.objectContaining({
|
unknown
|
||||||
parse_mode: "HTML",
|
>;
|
||||||
reply_markup: { inline_keyboard: [] },
|
expect(secondParams, testCase.name).toEqual(
|
||||||
}),
|
expect.objectContaining({ reply_markup: testCase.secondExpectReplyMarkup }),
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
|
|
||||||
botApi.editMessageText
|
|
||||||
.mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
|
|
||||||
.mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
|
|
||||||
|
|
||||||
await editMessageTelegram("123", 1, "<bad> html", {
|
|
||||||
token: "tok",
|
|
||||||
cfg: {},
|
|
||||||
buttons: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
|
|
||||||
expect(firstParams).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
parse_mode: "HTML",
|
|
||||||
reply_markup: { inline_keyboard: [] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record<string, unknown>;
|
|
||||||
expect(secondParams).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
reply_markup: { inline_keyboard: [] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats 'message is not modified' as success", async () => {
|
it("treats 'message is not modified' as success", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user