From 58254b3b5787469334264d0e0bcd21f63030040a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 21:43:18 +0000 Subject: [PATCH] test: dedupe channel and transport adapters --- src/channels/plugins/actions/actions.test.ts | 370 ++--- src/discord/monitor.test.ts | 695 +++++---- ...messages-mentionpatterns-match.e2e.test.ts | 10 +- src/discord/monitor/exec-approvals.test.ts | 188 +-- src/discord/monitor/monitor.test.ts | 333 +++-- src/line/markdown-to-line.test.ts | 83 +- src/line/rich-menu.test.ts | 132 +- src/markdown/whatsapp.test.ts | 65 +- src/plugin-sdk/webhook-targets.test.ts | 59 +- src/signal/format.links.test.ts | 53 +- ...y-senders-uuid-allowlist-entry.e2e.test.ts | 7 +- ...ends-tool-summaries-responseprefix.test.ts | 12 +- src/slack/format.test.ts | 116 +- src/telegram/bot.create-telegram-bot.test.ts | 1242 +++++++---------- ...dia-file-path-no-file-download.e2e.test.ts | 19 +- src/telegram/format.test.ts | 60 +- src/telegram/format.wrap-md.test.ts | 236 ++-- src/telegram/model-buttons.test.ts | 359 +++-- src/telegram/send.test.ts | 693 +++++---- 19 files changed, 2187 insertions(+), 2545 deletions(-) diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 9e3a99bfaf9..1f0210bcf9f 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -105,35 +105,34 @@ describe("discord message actions", () => { expect(actions).not.toContain("channel-create"); }); - it("lists moderation actions when per-account config enables them", () => { - const cfg = { - channels: { - discord: { - accounts: { - vime: { token: "d1", actions: { moderation: true } }, + it("lists moderation when at least one account enables it", () => { + const cases = [ + { + channels: { + discord: { + accounts: { + vime: { token: "d1", actions: { moderation: true } }, + }, }, }, }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expectModerationActions(actions); - }); - - it("lists moderation when one account enables and another omits", () => { - const cfg = { - channels: { - discord: { - accounts: { - ops: { token: "d1", actions: { moderation: true } }, - chat: { token: "d2" }, + { + channels: { + discord: { + accounts: { + ops: { token: "d1", actions: { moderation: true } }, + chat: { token: "d2" }, + }, }, }, }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + ] as const; - expectModerationActions(actions); + for (const channelConfig of cases) { + const cfg = channelConfig as unknown as OpenClawConfig; + const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + expectModerationActions(actions); + } }); it("omits moderation when all accounts omit it", () => { @@ -382,11 +381,52 @@ describe("handleDiscordMessageAction", () => { }); describe("telegramMessageActions", () => { - it("excludes sticker actions when not enabled", () => { - const cfg = telegramCfg(); - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - expect(actions).not.toContain("sticker"); - expect(actions).not.toContain("sticker-search"); + it("lists sticker actions only when enabled by config", () => { + const cases = [ + { + name: "default config", + cfg: telegramCfg(), + expectSticker: false, + }, + { + name: "per-account sticker enabled", + cfg: { + channels: { + telegram: { + accounts: { + media: { botToken: "tok", actions: { sticker: true } }, + }, + }, + }, + } as OpenClawConfig, + expectSticker: true, + }, + { + name: "all accounts omit sticker", + cfg: { + channels: { + telegram: { + accounts: { + a: { botToken: "tok1" }, + b: { botToken: "tok2" }, + }, + }, + }, + } as OpenClawConfig, + expectSticker: false, + }, + ] as const; + + for (const testCase of cases) { + const actions = telegramMessageActions.listActions?.({ cfg: testCase.cfg }) ?? []; + if (testCase.expectSticker) { + expect(actions, testCase.name).toContain("sticker"); + expect(actions, testCase.name).toContain("sticker-search"); + } else { + expect(actions, testCase.name).not.toContain("sticker"); + expect(actions, testCase.name).not.toContain("sticker-search"); + } + } }); it("allows media-only sends and passes asVoice", async () => { @@ -495,39 +535,6 @@ describe("telegramMessageActions", () => { expect(handleTelegramAction).not.toHaveBeenCalled(); }); - it("lists sticker actions when per-account config enables them", () => { - const cfg = { - channels: { - telegram: { - accounts: { - media: { botToken: "tok", actions: { sticker: true } }, - }, - }, - }, - } as OpenClawConfig; - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("sticker"); - expect(actions).toContain("sticker-search"); - }); - - it("omits sticker when all accounts omit it", () => { - const cfg = { - channels: { - telegram: { - accounts: { - a: { botToken: "tok1" }, - b: { botToken: "tok2" }, - }, - }, - }, - } as OpenClawConfig; - const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("sticker"); - expect(actions).not.toContain("sticker-search"); - }); - it("inherits top-level reaction gate when account overrides sticker only", () => { const cfg = { channels: { @@ -602,30 +609,42 @@ describe("telegramMessageActions", () => { }); describe("signalMessageActions", () => { - it("returns no actions when no configured accounts exist", () => { - const cfg = {} as OpenClawConfig; - expect(signalMessageActions.listActions?.({ cfg }) ?? []).toEqual([]); - }); - - it("hides react when reactions are disabled", () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - expect(signalMessageActions.listActions?.({ cfg }) ?? []).toEqual(["send"]); - }); - - it("enables react when at least one account allows reactions", () => { - const cfg = { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, + it("lists actions based on account presence and reaction gates", () => { + const cases = [ + { + name: "no configured accounts", + cfg: {} as OpenClawConfig, + expected: [], }, - } as OpenClawConfig; - expect(signalMessageActions.listActions?.({ cfg }) ?? []).toEqual(["send", "react"]); + { + name: "reactions disabled", + cfg: { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig, + expected: ["send"], + }, + { + name: "account-level reactions enabled", + cfg: { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig, + expected: ["send", "react"], + }, + ] as const; + + for (const testCase of cases) { + expect( + signalMessageActions.listActions?.({ cfg: testCase.cfg }) ?? [], + testCase.name, + ).toEqual(testCase.expected); + } }); it("skips send for plugin dispatch", () => { @@ -775,102 +794,113 @@ describe("slack actions adapter", () => { }); }); - it("forwards blocks JSON for send", async () => { - await runSlackAction("send", { - to: "channel:C1", - message: "", - blocks: JSON.stringify([{ type: "divider" }]), - }); - - expectFirstSlackAction({ - action: "sendMessage", - to: "channel:C1", - content: "", - blocks: [{ type: "divider" }], - }); - }); - - it("forwards blocks arrays for send", async () => { - await runSlackAction("send", { - to: "channel:C1", - message: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], - }); - - expectFirstSlackAction({ - action: "sendMessage", - to: "channel:C1", - content: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], - }); - }); - - it("rejects invalid blocks JSON for send", async () => { - await expectSlackSendRejected( + it("forwards blocks for send/edit actions", async () => { + const cases = [ { - to: "channel:C1", - message: "", - blocks: "{bad-json", + action: "send" as const, + params: { + to: "channel:C1", + message: "", + blocks: JSON.stringify([{ type: "divider" }]), + }, + expected: { + action: "sendMessage", + to: "channel:C1", + content: "", + blocks: [{ type: "divider" }], + }, }, - /blocks must be valid JSON/i, - ); - }); - - it("rejects empty blocks arrays for send", async () => { - await expectSlackSendRejected( { - to: "channel:C1", - message: "", - blocks: "[]", + action: "send" as const, + params: { + to: "channel:C1", + message: "", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], + }, + expected: { + action: "sendMessage", + to: "channel:C1", + content: "", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], + }, }, - /at least one block/i, - ); - }); - - it("rejects send when both blocks and media are provided", async () => { - await expectSlackSendRejected( { - to: "channel:C1", - message: "", - media: "https://example.com/image.png", - blocks: JSON.stringify([{ type: "divider" }]), + action: "edit" as const, + params: { + channelId: "C1", + messageId: "171234.567", + message: "", + blocks: JSON.stringify([{ type: "divider" }]), + }, + expected: { + action: "editMessage", + channelId: "C1", + messageId: "171234.567", + content: "", + blocks: [{ type: "divider" }], + }, }, - /does not support blocks with media/i, - ); + { + action: "edit" as const, + params: { + channelId: "C1", + messageId: "171234.567", + message: "", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], + }, + expected: { + action: "editMessage", + channelId: "C1", + messageId: "171234.567", + content: "", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], + }, + }, + ] as const; + + for (const testCase of cases) { + handleSlackAction.mockClear(); + await runSlackAction(testCase.action, testCase.params); + expectFirstSlackAction(testCase.expected); + } }); - it("forwards blocks JSON for edit", async () => { - await runSlackAction("edit", { - channelId: "C1", - messageId: "171234.567", - message: "", - blocks: JSON.stringify([{ type: "divider" }]), - }); + it("rejects invalid send block combinations before dispatch", async () => { + const cases = [ + { + name: "invalid JSON", + params: { + to: "channel:C1", + message: "", + blocks: "{bad-json", + }, + error: /blocks must be valid JSON/i, + }, + { + name: "empty blocks", + params: { + to: "channel:C1", + message: "", + blocks: "[]", + }, + error: /at least one block/i, + }, + { + name: "blocks with media", + params: { + to: "channel:C1", + message: "", + media: "https://example.com/image.png", + blocks: JSON.stringify([{ type: "divider" }]), + }, + error: /does not support blocks with media/i, + }, + ] as const; - expectFirstSlackAction({ - action: "editMessage", - channelId: "C1", - messageId: "171234.567", - content: "", - blocks: [{ type: "divider" }], - }); - }); - - it("forwards blocks arrays for edit", async () => { - await runSlackAction("edit", { - channelId: "C1", - messageId: "171234.567", - message: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], - }); - - expectFirstSlackAction({ - action: "editMessage", - channelId: "C1", - messageId: "171234.567", - content: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], - }); + for (const testCase of cases) { + handleSlackAction.mockClear(); + await expectSlackSendRejected(testCase.params, testCase.error); + } }); it("rejects edit when both message and blocks are missing", async () => { diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 1607e72c236..2d0347a56ad 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -424,45 +424,27 @@ describe("discord mention gating", () => { ).toBe(true); }); - it("does not require mention inside autoThread threads", () => { - const { guildInfo, channelConfig } = createAutoThreadMentionContext(); - expect( - resolveDiscordShouldRequireMention({ - isGuildMessage: true, - isThread: true, - botId: "bot123", - threadOwnerId: "bot123", - channelConfig, - guildInfo, - }), - ).toBe(false); - }); + it("applies autoThread mention rules based on thread ownership", () => { + const cases = [ + { name: "bot-owned thread", threadOwnerId: "bot123", expected: false }, + { name: "user-owned thread", threadOwnerId: "user456", expected: true }, + { name: "unknown thread owner", threadOwnerId: undefined, expected: true }, + ] as const; - it("requires mention inside user-created threads with autoThread enabled", () => { - const { guildInfo, channelConfig } = createAutoThreadMentionContext(); - expect( - resolveDiscordShouldRequireMention({ - isGuildMessage: true, - isThread: true, - botId: "bot123", - threadOwnerId: "user456", - channelConfig, - guildInfo, - }), - ).toBe(true); - }); - - it("requires mention when thread owner is unknown", () => { - const { guildInfo, channelConfig } = createAutoThreadMentionContext(); - expect( - resolveDiscordShouldRequireMention({ - isGuildMessage: true, - isThread: true, - botId: "bot123", - channelConfig, - guildInfo, - }), - ).toBe(true); + for (const testCase of cases) { + const { guildInfo, channelConfig } = createAutoThreadMentionContext(); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + threadOwnerId: testCase.threadOwnerId, + channelConfig, + guildInfo, + }), + testCase.name, + ).toBe(testCase.expected); + } }); it("inherits parent channel mention rules for threads", () => { @@ -496,70 +478,73 @@ describe("discord mention gating", () => { }); describe("discord groupPolicy gating", () => { - it("allows when policy is open", () => { - expect( - isDiscordGroupAllowedByPolicy({ - groupPolicy: "open", - guildAllowlisted: false, - channelAllowlistConfigured: false, - channelAllowed: false, - }), - ).toBe(true); - }); + it("applies open/disabled/allowlist policy rules", () => { + const cases = [ + { + name: "open policy always allows", + input: { + groupPolicy: "open" as const, + guildAllowlisted: false, + channelAllowlistConfigured: false, + channelAllowed: false, + }, + expected: true, + }, + { + name: "disabled policy always blocks", + input: { + groupPolicy: "disabled" as const, + guildAllowlisted: true, + channelAllowlistConfigured: true, + channelAllowed: true, + }, + expected: false, + }, + { + name: "allowlist blocks when guild not allowlisted", + input: { + groupPolicy: "allowlist" as const, + guildAllowlisted: false, + channelAllowlistConfigured: false, + channelAllowed: true, + }, + expected: false, + }, + { + name: "allowlist allows when guild allowlisted and no channel allowlist", + input: { + groupPolicy: "allowlist" as const, + guildAllowlisted: true, + channelAllowlistConfigured: false, + channelAllowed: true, + }, + expected: true, + }, + { + name: "allowlist allows when channel is allowed", + input: { + groupPolicy: "allowlist" as const, + guildAllowlisted: true, + channelAllowlistConfigured: true, + channelAllowed: true, + }, + expected: true, + }, + { + name: "allowlist blocks when channel is not allowed", + input: { + groupPolicy: "allowlist" as const, + guildAllowlisted: true, + channelAllowlistConfigured: true, + channelAllowed: false, + }, + expected: false, + }, + ] as const; - it("blocks when policy is disabled", () => { - expect( - isDiscordGroupAllowedByPolicy({ - groupPolicy: "disabled", - guildAllowlisted: true, - channelAllowlistConfigured: true, - channelAllowed: true, - }), - ).toBe(false); - }); - - it("blocks allowlist when guild is not allowlisted", () => { - expect( - isDiscordGroupAllowedByPolicy({ - groupPolicy: "allowlist", - guildAllowlisted: false, - channelAllowlistConfigured: false, - channelAllowed: true, - }), - ).toBe(false); - }); - - it("allows allowlist when guild allowlisted but no channel allowlist", () => { - expect( - isDiscordGroupAllowedByPolicy({ - groupPolicy: "allowlist", - guildAllowlisted: true, - channelAllowlistConfigured: false, - channelAllowed: true, - }), - ).toBe(true); - }); - - it("allows allowlist when channel is allowed", () => { - expect( - isDiscordGroupAllowedByPolicy({ - groupPolicy: "allowlist", - guildAllowlisted: true, - channelAllowlistConfigured: true, - channelAllowed: true, - }), - ).toBe(true); - }); - - it("blocks allowlist when channel is not allowed", () => { - expect( - isDiscordGroupAllowedByPolicy({ - groupPolicy: "allowlist", - guildAllowlisted: true, - channelAllowlistConfigured: true, - channelAllowed: false, - }), - ).toBe(false); + for (const testCase of cases) { + expect(isDiscordGroupAllowedByPolicy(testCase.input), testCase.name).toBe(testCase.expected); + } }); }); @@ -596,48 +581,45 @@ describe("discord group DM gating", () => { }); describe("discord reply target selection", () => { - it("skips replies when mode is off", () => { - expect( - resolveDiscordReplyTarget({ - replyToMode: "off", - replyToId: "123", + it("handles off/first/all reply modes", () => { + const cases = [ + { name: "off mode", replyToMode: "off" as const, hasReplied: false, expected: undefined }, + { + name: "first mode before reply", + replyToMode: "first" as const, hasReplied: false, - }), - ).toBeUndefined(); - }); - - it("replies only once when mode is first", () => { - expect( - resolveDiscordReplyTarget({ - replyToMode: "first", - replyToId: "123", - hasReplied: false, - }), - ).toBe("123"); - expect( - resolveDiscordReplyTarget({ - replyToMode: "first", - replyToId: "123", + expected: "123", + }, + { + name: "first mode after reply", + replyToMode: "first" as const, hasReplied: true, - }), - ).toBeUndefined(); - }); - - it("replies on every message when mode is all", () => { - expect( - resolveDiscordReplyTarget({ - replyToMode: "all", - replyToId: "123", + expected: undefined, + }, + { + name: "all mode before reply", + replyToMode: "all" as const, hasReplied: false, - }), - ).toBe("123"); - expect( - resolveDiscordReplyTarget({ - replyToMode: "all", - replyToId: "123", + expected: "123", + }, + { + name: "all mode after reply", + replyToMode: "all" as const, hasReplied: true, - }), - ).toBe("123"); + expected: "123", + }, + ] as const; + + for (const testCase of cases) { + expect( + resolveDiscordReplyTarget({ + replyToMode: testCase.replyToMode, + replyToId: "123", + hasReplied: testCase.hasReplied, + }), + testCase.name, + ).toBe(testCase.expected); + } }); }); @@ -654,86 +636,98 @@ describe("discord autoThread name sanitization", () => { }); describe("discord reaction notification gating", () => { - it("defaults to own when mode is unset", () => { - expect( - shouldEmitDiscordReactionNotification({ - mode: undefined, - botId: "bot-1", - messageAuthorId: "bot-1", - userId: "user-1", - }), - ).toBe(true); - expect( - shouldEmitDiscordReactionNotification({ - mode: undefined, - botId: "bot-1", - messageAuthorId: "user-1", - userId: "user-2", - }), - ).toBe(false); - }); + it("applies mode-specific reaction notification rules", () => { + const cases = [ + { + name: "unset defaults to own (author is bot)", + input: { + mode: undefined, + botId: "bot-1", + messageAuthorId: "bot-1", + userId: "user-1", + }, + expected: true, + }, + { + name: "unset defaults to own (author is not bot)", + input: { + mode: undefined, + botId: "bot-1", + messageAuthorId: "user-1", + userId: "user-2", + }, + expected: false, + }, + { + name: "off mode", + input: { + mode: "off" as const, + botId: "bot-1", + messageAuthorId: "bot-1", + userId: "user-1", + }, + expected: false, + }, + { + name: "all mode", + input: { + mode: "all" as const, + botId: "bot-1", + messageAuthorId: "user-1", + userId: "user-2", + }, + expected: true, + }, + { + name: "own mode with bot-authored message", + input: { + mode: "own" as const, + botId: "bot-1", + messageAuthorId: "bot-1", + userId: "user-2", + }, + expected: true, + }, + { + name: "own mode with non-bot-authored message", + input: { + mode: "own" as const, + botId: "bot-1", + messageAuthorId: "user-2", + userId: "user-3", + }, + expected: false, + }, + { + name: "allowlist mode without match", + input: { + mode: "allowlist" as const, + botId: "bot-1", + messageAuthorId: "user-1", + userId: "user-2", + allowlist: [], + }, + expected: false, + }, + { + name: "allowlist mode with id match", + input: { + mode: "allowlist" as const, + botId: "bot-1", + messageAuthorId: "user-1", + userId: "123", + userName: "steipete", + allowlist: ["123", "other"], + }, + expected: true, + }, + ] as const; - it("skips when mode is off", () => { - expect( - shouldEmitDiscordReactionNotification({ - mode: "off", - botId: "bot-1", - messageAuthorId: "bot-1", - userId: "user-1", - }), - ).toBe(false); - }); - - it("allows all reactions when mode is all", () => { - expect( - shouldEmitDiscordReactionNotification({ - mode: "all", - botId: "bot-1", - messageAuthorId: "user-1", - userId: "user-2", - }), - ).toBe(true); - }); - - it("requires bot ownership when mode is own", () => { - expect( - shouldEmitDiscordReactionNotification({ - mode: "own", - botId: "bot-1", - messageAuthorId: "bot-1", - userId: "user-2", - }), - ).toBe(true); - expect( - shouldEmitDiscordReactionNotification({ - mode: "own", - botId: "bot-1", - messageAuthorId: "user-2", - userId: "user-3", - }), - ).toBe(false); - }); - - it("requires allowlist matches when mode is allowlist", () => { - expect( - shouldEmitDiscordReactionNotification({ - mode: "allowlist", - botId: "bot-1", - messageAuthorId: "user-1", - userId: "user-2", - allowlist: [], - }), - ).toBe(false); - expect( - shouldEmitDiscordReactionNotification({ - mode: "allowlist", - botId: "bot-1", - messageAuthorId: "user-1", - userId: "123", - userName: "steipete", - allowlist: ["123", "other"], - }), - ).toBe(true); + for (const testCase of cases) { + expect(shouldEmitDiscordReactionNotification(testCase.input), testCase.name).toBe( + testCase.expected, + ); + } }); }); @@ -858,37 +852,37 @@ function makeReactionListenerParams(overrides?: { } describe("discord DM reaction handling", () => { - it("processes DM reactions instead of dropping them", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); + it("processes DM reactions with or without guild allowlists", async () => { + const cases = [ + { name: "no guild allowlist", guildEntries: undefined }, + { + name: "guild allowlist configured", + guildEntries: makeEntries({ + "guild-123": { slug: "guild-123" }, + }), + }, + ] as const; - const data = makeReactionEvent({ botAsAuthor: true }); - const client = makeReactionClient({ channelType: ChannelType.DM }); - const listener = new DiscordReactionListener(makeReactionListenerParams()); + for (const testCase of cases) { + enqueueSystemEventSpy.mockClear(); + resolveAgentRouteMock.mockClear(); - await listener.handle(data, client); + const data = makeReactionEvent({ botAsAuthor: true }); + const client = makeReactionClient({ channelType: ChannelType.DM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ guildEntries: testCase.guildEntries }), + ); - expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); - const [text, opts] = enqueueSystemEventSpy.mock.calls[0]; - expect(text).toContain("Discord reaction added"); - expect(text).toContain("šŸ‘"); - expect(opts.sessionKey).toBe("discord:acc-1:dm:user-1"); - }); + await listener.handle(data, client); - it("does not drop DM reactions when guild allowlist is configured", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); - - const data = makeReactionEvent({ botAsAuthor: true }); - const client = makeReactionClient({ channelType: ChannelType.DM }); - const guildEntries = makeEntries({ - "guild-123": { slug: "guild-123" }, - }); - const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries })); - - await listener.handle(data, client); - - expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); + expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledOnce(); + const [text, opts] = enqueueSystemEventSpy.mock.calls[0]; + expect(text, testCase.name).toContain("Discord reaction added"); + expect(text, testCase.name).toContain("šŸ‘"); + expect(text, testCase.name).toContain("dm"); + expect(text, testCase.name).not.toContain("undefined"); + expect(opts.sessionKey, testCase.name).toBe("discord:acc-1:dm:user-1"); + } }); it("still processes guild reactions (no regression)", async () => { @@ -916,22 +910,6 @@ describe("discord DM reaction handling", () => { expect(text).toContain("Discord reaction added"); }); - it("uses 'dm' in log text for DM reactions, not 'undefined'", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); - - const data = makeReactionEvent({ botAsAuthor: true }); - const client = makeReactionClient({ channelType: ChannelType.DM }); - const listener = new DiscordReactionListener(makeReactionListenerParams()); - - await listener.handle(data, client); - - expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); - const [text] = enqueueSystemEventSpy.mock.calls[0]; - expect(text).toContain("dm"); - expect(text).not.toContain("undefined"); - }); - it("routes DM reactions with peer kind 'direct' and user id", async () => { enqueueSystemEventSpy.mockClear(); resolveAgentRouteMock.mockClear(); @@ -977,111 +955,102 @@ describe("discord reaction notification modes", () => { const guildId = "guild-900"; const guild = fakeGuild(guildId, "Mode Guild"); - it("skips message fetch when mode is off", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); + it("applies message-fetch behavior across notification modes and channel types", async () => { + const cases = [ + { + name: "off mode", + reactionNotifications: "off" as const, + users: undefined, + userId: undefined, + channelType: ChannelType.GuildText, + channelId: undefined, + parentId: undefined, + messageAuthorId: "other-user", + expectedMessageFetchCalls: 0, + expectedEnqueueCalls: 0, + }, + { + name: "all mode", + reactionNotifications: "all" as const, + users: undefined, + userId: undefined, + channelType: ChannelType.GuildText, + channelId: undefined, + parentId: undefined, + messageAuthorId: "other-user", + expectedMessageFetchCalls: 0, + expectedEnqueueCalls: 1, + }, + { + name: "allowlist mode", + reactionNotifications: "allowlist" as const, + users: ["123"], + userId: "123", + channelType: ChannelType.GuildText, + channelId: undefined, + parentId: undefined, + messageAuthorId: "other-user", + expectedMessageFetchCalls: 0, + expectedEnqueueCalls: 1, + }, + { + name: "own mode", + reactionNotifications: "own" as const, + users: undefined, + userId: undefined, + channelType: ChannelType.GuildText, + channelId: undefined, + parentId: undefined, + messageAuthorId: "bot-1", + expectedMessageFetchCalls: 1, + expectedEnqueueCalls: 1, + }, + { + name: "all mode thread channel", + reactionNotifications: "all" as const, + users: undefined, + userId: undefined, + channelType: ChannelType.PublicThread, + channelId: "thread-1", + parentId: "parent-1", + messageAuthorId: "other-user", + expectedMessageFetchCalls: 0, + expectedEnqueueCalls: 1, + }, + ] as const; - const messageFetch = vi.fn(async () => ({ - author: { id: "bot-1", username: "bot", discriminator: "0" }, - })); - const data = makeReactionEvent({ guildId, guild, messageFetch }); - const client = makeReactionClient({ channelType: ChannelType.GuildText }); - const guildEntries = makeEntries({ - [guildId]: { reactionNotifications: "off" }, - }); - const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries })); + for (const testCase of cases) { + enqueueSystemEventSpy.mockClear(); + resolveAgentRouteMock.mockClear(); - await listener.handle(data, client); + const messageFetch = vi.fn(async () => ({ + author: { id: testCase.messageAuthorId, username: "author", discriminator: "0" }, + })); + const data = makeReactionEvent({ + guildId, + guild, + userId: testCase.userId, + channelId: testCase.channelId, + messageFetch, + }); + const client = makeReactionClient({ + channelType: testCase.channelType, + parentId: testCase.parentId, + }); + const guildEntries = makeEntries({ + [guildId]: { + reactionNotifications: testCase.reactionNotifications, + users: testCase.users, + }, + }); + const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries })); - expect(messageFetch).not.toHaveBeenCalled(); - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); + await listener.handle(data, client); - it("skips message fetch when mode is all", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); - - const messageFetch = vi.fn(async () => ({ - author: { id: "other-user", username: "other", discriminator: "0" }, - })); - const data = makeReactionEvent({ guildId, guild, messageFetch }); - const client = makeReactionClient({ channelType: ChannelType.GuildText }); - const guildEntries = makeEntries({ - [guildId]: { reactionNotifications: "all" }, - }); - const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries })); - - await listener.handle(data, client); - - expect(messageFetch).not.toHaveBeenCalled(); - expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); - }); - - it("skips message fetch when mode is allowlist", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); - - const messageFetch = vi.fn(async () => ({ - author: { id: "other-user", username: "other", discriminator: "0" }, - })); - const data = makeReactionEvent({ guildId, guild, userId: "123", messageFetch }); - const client = makeReactionClient({ channelType: ChannelType.GuildText }); - const guildEntries = makeEntries({ - [guildId]: { reactionNotifications: "allowlist", users: ["123"] }, - }); - const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries })); - - await listener.handle(data, client); - - expect(messageFetch).not.toHaveBeenCalled(); - expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); - }); - - it("fetches message when mode is own", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); - - const messageFetch = vi.fn(async () => ({ - author: { id: "bot-1", username: "bot", discriminator: "0" }, - })); - const data = makeReactionEvent({ guildId, guild, messageFetch }); - const client = makeReactionClient({ channelType: ChannelType.GuildText }); - const guildEntries = makeEntries({ - [guildId]: { reactionNotifications: "own" }, - }); - const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries })); - - await listener.handle(data, client); - - expect(messageFetch).toHaveBeenCalledOnce(); - expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); - }); - - it("skips message fetch for thread channels in all mode", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); - - const messageFetch = vi.fn(async () => ({ - author: { id: "other-user", username: "other", discriminator: "0" }, - })); - const data = makeReactionEvent({ - guildId, - guild, - channelId: "thread-1", - messageFetch, - }); - const client = makeReactionClient({ - channelType: ChannelType.PublicThread, - parentId: "parent-1", - }); - const guildEntries = makeEntries({ - [guildId]: { reactionNotifications: "all" }, - }); - const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries })); - - await listener.handle(data, client); - - expect(messageFetch).not.toHaveBeenCalled(); - expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); + expect(messageFetch, testCase.name).toHaveBeenCalledTimes(testCase.expectedMessageFetchCalls); + expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledTimes( + testCase.expectedEnqueueCalls, + ); + } }); }); diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index 92a86189a91..7e875f6804c 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -1,7 +1,7 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; import { dispatchMock, @@ -64,6 +64,12 @@ beforeEach(() => { const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; +let createDiscordMessageHandler: typeof import("./monitor.js").createDiscordMessageHandler; +let createDiscordNativeCommand: typeof import("./monitor.js").createDiscordNativeCommand; + +beforeAll(async () => { + ({ createDiscordMessageHandler, createDiscordNativeCommand } = await import("./monitor.js")); +}); function makeRuntime() { return { @@ -76,7 +82,6 @@ function makeRuntime() { } async function createHandler(cfg: LoadedConfig) { - const { createDiscordMessageHandler } = await import("./monitor.js"); return createDiscordMessageHandler({ cfg, discordConfig: cfg.channels?.discord, @@ -267,7 +272,6 @@ describe("discord tool result dispatch", () => { "skips tool results for native slash commands", { timeout: MENTION_PATTERNS_TEST_TIMEOUT_MS }, async () => { - const { createDiscordNativeCommand } = await import("./monitor.js"); const cfg = { agents: { defaults: { diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index de600ad5241..cbabca89b5b 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -204,42 +204,50 @@ describe("roundtrip encoding", () => { // ─── extractDiscordChannelId ────────────────────────────────────────────────── describe("extractDiscordChannelId", () => { - it("extracts channel ID from standard session key", () => { - expect(extractDiscordChannelId("agent:main:discord:channel:123456789")).toBe("123456789"); - }); + it("extracts channel IDs and rejects invalid session key inputs", () => { + const cases: Array<{ + name: string; + input: string | null | undefined; + expected: string | null; + }> = [ + { + name: "standard session key", + input: "agent:main:discord:channel:123456789", + expected: "123456789", + }, + { + name: "agent-specific session key", + input: "agent:test-agent:discord:channel:999888777", + expected: "999888777", + }, + { + name: "group session key", + input: "agent:main:discord:group:222333444", + expected: "222333444", + }, + { + name: "longer session key", + input: "agent:my-agent:discord:channel:111222333:thread:444555", + expected: "111222333", + }, + { + name: "non-discord session key", + input: "agent:main:telegram:channel:123456789", + expected: null, + }, + { + name: "missing channel/group segment", + input: "agent:main:discord:dm:123456789", + expected: null, + }, + { name: "null input", input: null, expected: null }, + { name: "undefined input", input: undefined, expected: null }, + { name: "empty input", input: "", expected: null }, + ]; - it("extracts channel ID from agent session key", () => { - expect(extractDiscordChannelId("agent:test-agent:discord:channel:999888777")).toBe("999888777"); - }); - - it("extracts channel ID from group session key", () => { - expect(extractDiscordChannelId("agent:main:discord:group:222333444")).toBe("222333444"); - }); - - it("returns null for non-discord session key", () => { - expect(extractDiscordChannelId("agent:main:telegram:channel:123456789")).toBeNull(); - }); - - it("returns null for session key without channel segment", () => { - expect(extractDiscordChannelId("agent:main:discord:dm:123456789")).toBeNull(); - }); - - it("returns null for null input", () => { - expect(extractDiscordChannelId(null)).toBeNull(); - }); - - it("returns null for undefined input", () => { - expect(extractDiscordChannelId(undefined)).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(extractDiscordChannelId("")).toBeNull(); - }); - - it("extracts from longer session keys", () => { - expect(extractDiscordChannelId("agent:my-agent:discord:channel:111222333:thread:444555")).toBe( - "111222333", - ); + for (const testCase of cases) { + expect(extractDiscordChannelId(testCase.input), testCase.name).toBe(testCase.expected); + } }); }); @@ -353,19 +361,29 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => { // ─── DiscordExecApprovalHandler.getApprovers ────────────────────────────────── describe("DiscordExecApprovalHandler.getApprovers", () => { - it("returns configured approvers", () => { - const handler = createHandler({ enabled: true, approvers: ["111", "222"] }); - expect(handler.getApprovers()).toEqual(["111", "222"]); - }); + it("returns approvers for configured, empty, and undefined lists", () => { + const cases = [ + { + name: "configured approvers", + config: { enabled: true, approvers: ["111", "222"] } as DiscordExecApprovalConfig, + expected: ["111", "222"], + }, + { + name: "empty approvers", + config: { enabled: true, approvers: [] } as DiscordExecApprovalConfig, + expected: [], + }, + { + name: "undefined approvers", + config: { enabled: true } as DiscordExecApprovalConfig, + expected: [], + }, + ] as const; - it("returns empty array when no approvers configured", () => { - const handler = createHandler({ enabled: true, approvers: [] }); - expect(handler.getApprovers()).toEqual([]); - }); - - it("returns empty array when approvers is undefined", () => { - const handler = createHandler({ enabled: true } as DiscordExecApprovalConfig); - expect(handler.getApprovers()).toEqual([]); + for (const testCase of cases) { + const handler = createHandler(testCase.config); + expect(handler.getApprovers(), testCase.name).toEqual(testCase.expected); + } }); }); @@ -530,44 +548,46 @@ describe("DiscordExecApprovalHandler target config", () => { mockRestDelete.mockReset(); }); - it("defaults target to dm when not specified", () => { - const config: DiscordExecApprovalConfig = { - enabled: true, - approvers: ["123"], - }; - // target should be undefined, handler defaults to "dm" - expect(config.target).toBeUndefined(); + it("accepts all target modes and defaults to dm when target is omitted", () => { + const cases = [ + { + name: "default target", + config: { enabled: true, approvers: ["123"] } as DiscordExecApprovalConfig, + expectedTarget: undefined, + }, + { + name: "channel target", + config: { + enabled: true, + approvers: ["123"], + target: "channel", + } as DiscordExecApprovalConfig, + }, + { + name: "both target", + config: { + enabled: true, + approvers: ["123"], + target: "both", + } as DiscordExecApprovalConfig, + }, + { + name: "dm target", + config: { + enabled: true, + approvers: ["123"], + target: "dm", + } as DiscordExecApprovalConfig, + }, + ] as const; - const handler = createHandler(config); - // Handler should still handle requests (no crash on missing target) - expect(handler.shouldHandle(createRequest())).toBe(true); - }); - - it("accepts target=channel in config", () => { - const handler = createHandler({ - enabled: true, - approvers: ["123"], - target: "channel", - }); - expect(handler.shouldHandle(createRequest())).toBe(true); - }); - - it("accepts target=both in config", () => { - const handler = createHandler({ - enabled: true, - approvers: ["123"], - target: "both", - }); - expect(handler.shouldHandle(createRequest())).toBe(true); - }); - - it("accepts target=dm in config", () => { - const handler = createHandler({ - enabled: true, - approvers: ["123"], - target: "dm", - }); - expect(handler.shouldHandle(createRequest())).toBe(true); + for (const testCase of cases) { + if ("expectedTarget" in testCase) { + expect(testCase.config.target, testCase.name).toBe(testCase.expectedTarget); + } + const handler = createHandler(testCase.config); + expect(handler.shouldHandle(createRequest()), testCase.name).toBe(true); + } }); }); diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index e1359bda422..d9abf4103aa 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -631,105 +631,133 @@ describe("resolveDiscordPresenceUpdate", () => { }); describe("resolveDiscordAutoThreadContext", () => { - it("returns null when no createdThreadId", () => { - expect( - resolveDiscordAutoThreadContext({ + it("returns null without a created thread and re-keys context when present", () => { + const cases = [ + { + name: "no created thread", + createdThreadId: undefined, + expectedNull: true, + }, + { + name: "created thread", + createdThreadId: "thread", + expectedNull: false, + }, + ] as const; + + for (const testCase of cases) { + const context = resolveDiscordAutoThreadContext({ agentId: "agent", channel: "discord", messageChannelId: "parent", - createdThreadId: undefined, - }), - ).toBeNull(); - }); + createdThreadId: testCase.createdThreadId, + }); - it("re-keys session context to the created thread", () => { - const context = resolveDiscordAutoThreadContext({ - agentId: "agent", - channel: "discord", - messageChannelId: "parent", - createdThreadId: "thread", - }); - expect(context).not.toBeNull(); - expect(context?.To).toBe("channel:thread"); - expect(context?.From).toBe("discord:channel:thread"); - expect(context?.OriginatingTo).toBe("channel:thread"); - expect(context?.SessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "thread" }, - }), - ); - expect(context?.ParentSessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "parent" }, - }), - ); + if (testCase.expectedNull) { + expect(context, testCase.name).toBeNull(); + continue; + } + + expect(context, testCase.name).not.toBeNull(); + expect(context?.To, testCase.name).toBe("channel:thread"); + expect(context?.From, testCase.name).toBe("discord:channel:thread"); + expect(context?.OriginatingTo, testCase.name).toBe("channel:thread"); + expect(context?.SessionKey, testCase.name).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + ); + expect(context?.ParentSessionKey, testCase.name).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), + ); + } }); }); describe("resolveDiscordReplyDeliveryPlan", () => { - it("uses reply references when posting to the original target", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:parent", - replyToMode: "all", - messageId: "m1", - threadChannel: null, - createdThreadId: null, - }); - expect(plan.deliverTarget).toBe("channel:parent"); - expect(plan.replyTarget).toBe("channel:parent"); - expect(plan.replyReference.use()).toBe("m1"); - }); + it("applies delivery targets and reply reference behavior across thread modes", () => { + const cases = [ + { + name: "original target with reply references", + input: { + replyTarget: "channel:parent" as const, + replyToMode: "all" as const, + messageId: "m1", + threadChannel: null, + createdThreadId: null, + }, + expectedDeliverTarget: "channel:parent", + expectedReplyTarget: "channel:parent", + expectedReplyReferenceCalls: ["m1"], + }, + { + name: "created thread disables reply references", + input: { + replyTarget: "channel:parent" as const, + replyToMode: "all" as const, + messageId: "m1", + threadChannel: null, + createdThreadId: "thread", + }, + expectedDeliverTarget: "channel:thread", + expectedReplyTarget: "channel:thread", + expectedReplyReferenceCalls: [undefined], + }, + { + name: "thread + off mode", + input: { + replyTarget: "channel:thread" as const, + replyToMode: "off" as const, + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }, + expectedDeliverTarget: "channel:thread", + expectedReplyTarget: "channel:thread", + expectedReplyReferenceCalls: [undefined], + }, + { + name: "thread + all mode", + input: { + replyTarget: "channel:thread" as const, + replyToMode: "all" as const, + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }, + expectedDeliverTarget: "channel:thread", + expectedReplyTarget: "channel:thread", + expectedReplyReferenceCalls: ["m1", "m1"], + }, + { + name: "thread + first mode", + input: { + replyTarget: "channel:thread" as const, + replyToMode: "first" as const, + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }, + expectedDeliverTarget: "channel:thread", + expectedReplyTarget: "channel:thread", + expectedReplyReferenceCalls: ["m1", undefined], + }, + ] as const; - it("disables reply references when autoThread creates a new thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:parent", - replyToMode: "all", - messageId: "m1", - threadChannel: null, - createdThreadId: "thread", - }); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBeUndefined(); - }); - - it("respects replyToMode off even inside a thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "off", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - expect(plan.replyReference.use()).toBeUndefined(); - }); - - it("uses existingId when inside a thread with replyToMode all", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "all", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.replyReference.use()).toBe("m1"); - }); - - it("uses existingId only on first call with replyToMode first inside a thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "first", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.replyReference.use()).toBeUndefined(); + for (const testCase of cases) { + const plan = resolveDiscordReplyDeliveryPlan(testCase.input); + expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget); + expect(plan.replyTarget, testCase.name).toBe(testCase.expectedReplyTarget); + for (const expected of testCase.expectedReplyReferenceCalls) { + expect(plan.replyReference.use(), testCase.name).toBe(expected); + } + } }); }); @@ -751,34 +779,35 @@ describe("maybeCreateDiscordAutoThread", () => { }; } - it("returns existing thread ID when creation fails due to race condition", async () => { - const client = { - rest: { - post: async () => { - throw new Error("A thread has already been created on this message"); - }, - get: async () => ({ thread: { id: "existing-thread" } }), + it("handles create-thread failures with and without an existing thread", async () => { + const cases = [ + { + name: "race condition returns existing thread", + postError: "A thread has already been created on this message", + getResponse: { thread: { id: "existing-thread" } }, + expected: "existing-thread", }, - } as unknown as Client; - - const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client)); - - expect(result).toBe("existing-thread"); - }); - - it("returns undefined when creation fails and no existing thread found", async () => { - const client = { - rest: { - post: async () => { - throw new Error("Some other error"); - }, - get: async () => ({ thread: null }), + { + name: "other error returns undefined", + postError: "Some other error", + getResponse: { thread: null }, + expected: undefined, }, - } as unknown as Client; + ] as const; - const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client)); + for (const testCase of cases) { + const client = { + rest: { + post: async () => { + throw new Error(testCase.postError); + }, + get: async () => testCase.getResponse, + }, + } as unknown as Client; - expect(result).toBeUndefined(); + const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client)); + expect(result, testCase.name).toBe(testCase.expected); + } }); }); @@ -809,38 +838,50 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { }; } - it("switches delivery + session context to the created thread", async () => { - const plan = await resolveDiscordAutoThreadReplyPlan(createAutoThreadPlanParams()); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBeUndefined(); - expect(plan.autoThreadContext?.SessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "thread" }, - }), - ); - }); + it("applies auto-thread reply planning across created, existing, and disabled modes", async () => { + const cases = [ + { + name: "created thread", + params: undefined, + expectedDeliverTarget: "channel:thread", + expectedReplyReference: undefined, + expectedSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + }, + { + name: "existing thread channel", + params: { + threadChannel: { id: "thread" }, + }, + expectedDeliverTarget: "channel:thread", + expectedReplyReference: "m1", + expectedSessionKey: null, + }, + { + name: "autoThread disabled", + params: { + channelConfig: { autoThread: false } as unknown as DiscordChannelConfigResolved, + }, + expectedDeliverTarget: "channel:parent", + expectedReplyReference: "m1", + expectedSessionKey: null, + }, + ] as const; - it("routes replies to an existing thread channel", async () => { - const plan = await resolveDiscordAutoThreadReplyPlan( - createAutoThreadPlanParams({ - threadChannel: { id: "thread" }, - }), - ); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.autoThreadContext).toBeNull(); - }); - - it("does nothing when autoThread is disabled", async () => { - const plan = await resolveDiscordAutoThreadReplyPlan( - createAutoThreadPlanParams({ - channelConfig: { autoThread: false } as unknown as DiscordChannelConfigResolved, - }), - ); - expect(plan.deliverTarget).toBe("channel:parent"); - expect(plan.autoThreadContext).toBeNull(); + for (const testCase of cases) { + const plan = await resolveDiscordAutoThreadReplyPlan( + createAutoThreadPlanParams(testCase.params), + ); + expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget); + expect(plan.replyReference.use(), testCase.name).toBe(testCase.expectedReplyReference); + if (testCase.expectedSessionKey == null) { + expect(plan.autoThreadContext, testCase.name).toBeNull(); + } else { + expect(plan.autoThreadContext?.SessionKey, testCase.name).toBe(testCase.expectedSessionKey); + } + } }); }); diff --git a/src/line/markdown-to-line.test.ts b/src/line/markdown-to-line.test.ts index a8daa0260f0..7745d35fceb 100644 --- a/src/line/markdown-to-line.test.ts +++ b/src/line/markdown-to-line.test.ts @@ -77,8 +77,8 @@ Table 2: }); describe("extractCodeBlocks", () => { - it("extracts a code block with language", () => { - const text = `Here is some code: + it("extracts code blocks across language/no-language/multiple variants", () => { + const withLanguage = `Here is some code: \`\`\`javascript const x = 1; @@ -86,31 +86,23 @@ console.log(x); \`\`\` And more text.`; + const withLanguageResult = extractCodeBlocks(withLanguage); + expect(withLanguageResult.codeBlocks).toHaveLength(1); + expect(withLanguageResult.codeBlocks[0].language).toBe("javascript"); + expect(withLanguageResult.codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);"); + expect(withLanguageResult.textWithoutCode).toContain("Here is some code:"); + expect(withLanguageResult.textWithoutCode).toContain("And more text."); + expect(withLanguageResult.textWithoutCode).not.toContain("```"); - const { codeBlocks, textWithoutCode } = extractCodeBlocks(text); - - expect(codeBlocks).toHaveLength(1); - expect(codeBlocks[0].language).toBe("javascript"); - expect(codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);"); - expect(textWithoutCode).toContain("Here is some code:"); - expect(textWithoutCode).toContain("And more text."); - expect(textWithoutCode).not.toContain("```"); - }); - - it("extracts a code block without language", () => { - const text = `\`\`\` + const withoutLanguage = `\`\`\` plain code \`\`\``; + const withoutLanguageResult = extractCodeBlocks(withoutLanguage); + expect(withoutLanguageResult.codeBlocks).toHaveLength(1); + expect(withoutLanguageResult.codeBlocks[0].language).toBeUndefined(); + expect(withoutLanguageResult.codeBlocks[0].code).toBe("plain code"); - const { codeBlocks } = extractCodeBlocks(text); - - expect(codeBlocks).toHaveLength(1); - expect(codeBlocks[0].language).toBeUndefined(); - expect(codeBlocks[0].code).toBe("plain code"); - }); - - it("extracts multiple code blocks", () => { - const text = `\`\`\`python + const multiple = `\`\`\`python print("hello") \`\`\` @@ -119,12 +111,10 @@ Some text \`\`\`bash echo "world" \`\`\``; - - const { codeBlocks } = extractCodeBlocks(text); - - expect(codeBlocks).toHaveLength(2); - expect(codeBlocks[0].language).toBe("python"); - expect(codeBlocks[1].language).toBe("bash"); + const multipleResult = extractCodeBlocks(multiple); + expect(multipleResult.codeBlocks).toHaveLength(2); + expect(multipleResult.codeBlocks[0].language).toBe("python"); + expect(multipleResult.codeBlocks[1].language).toBe("bash"); }); }); @@ -142,27 +132,20 @@ describe("extractLinks", () => { }); describe("stripMarkdown", () => { - it("strips bold markers", () => { - expect(stripMarkdown("This is **bold** text")).toBe("This is bold text"); - expect(stripMarkdown("This is __bold__ text")).toBe("This is bold text"); - }); - - it("strips italic markers", () => { - expect(stripMarkdown("This is *italic* text")).toBe("This is italic text"); - expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text"); - }); - - it("strips strikethrough markers", () => { - expect(stripMarkdown("This is ~~deleted~~ text")).toBe("This is deleted text"); - }); - - it("removes horizontal rules", () => { - expect(stripMarkdown("Above\n---\nBelow")).toBe("Above\n\nBelow"); - expect(stripMarkdown("Above\n***\nBelow")).toBe("Above\n\nBelow"); - }); - - it("strips inline code markers", () => { - expect(stripMarkdown("Use `const` keyword")).toBe("Use const keyword"); + it("strips inline markdown marker variants", () => { + const cases = [ + ["strips bold **", "This is **bold** text", "This is bold text"], + ["strips bold __", "This is __bold__ text", "This is bold text"], + ["strips italic *", "This is *italic* text", "This is italic text"], + ["strips italic _", "This is _italic_ text", "This is italic text"], + ["strips strikethrough", "This is ~~deleted~~ text", "This is deleted text"], + ["removes hr ---", "Above\n---\nBelow", "Above\n\nBelow"], + ["removes hr ***", "Above\n***\nBelow", "Above\n\nBelow"], + ["strips inline code markers", "Use `const` keyword", "Use const keyword"], + ] as const; + for (const [name, input, expected] of cases) { + expect(stripMarkdown(input), name).toBe(expected); + } }); it("handles complex markdown", () => { diff --git a/src/line/rich-menu.test.ts b/src/line/rich-menu.test.ts index 731f9b2e0be..b6604ebd6ac 100644 --- a/src/line/rich-menu.test.ts +++ b/src/line/rich-menu.test.ts @@ -9,18 +9,19 @@ import { } from "./rich-menu.js"; describe("messageAction", () => { - it("creates a message action", () => { - const action = messageAction("Help", "/help"); - - expect(action.type).toBe("message"); - expect(action.label).toBe("Help"); - expect((action as { text: string }).text).toBe("/help"); - }); - - it("uses label as text when text not provided", () => { - const action = messageAction("Click"); - - expect((action as { text: string }).text).toBe("Click"); + it("creates message actions with explicit or default text", () => { + const cases = [ + { name: "explicit text", label: "Help", text: "/help", expectedText: "/help" }, + { name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" }, + ] as const; + for (const testCase of cases) { + const action = testCase.text + ? messageAction(testCase.label, testCase.text) + : messageAction(testCase.label); + expect(action.type, testCase.name).toBe("message"); + expect(action.label, testCase.name).toBe(testCase.label); + expect((action as { text: string }).text, testCase.name).toBe(testCase.expectedText); + } }); }); @@ -61,47 +62,32 @@ describe("postbackAction", () => { expect((action as { displayText: string }).displayText).toBe("Selected item 1"); }); - it("truncates data to 300 characters", () => { - const longData = "x".repeat(400); - const action = postbackAction("Test", longData); + it("applies postback payload truncation and displayText behavior", () => { + const truncatedData = postbackAction("Test", "x".repeat(400)); + expect((truncatedData as { data: string }).data.length).toBe(300); - expect((action as { data: string }).data.length).toBe(300); - }); + const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400)); + expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300); - it("truncates displayText to 300 characters", () => { - const longText = "y".repeat(400); - const action = postbackAction("Test", "data", longText); - - expect((action as { displayText: string }).displayText?.length).toBe(300); - }); - - it("omits displayText when not provided", () => { - const action = postbackAction("Test", "data"); - - expect((action as { displayText?: string }).displayText).toBeUndefined(); + const noDisplayText = postbackAction("Test", "data"); + expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined(); }); }); describe("datetimePickerAction", () => { - it("creates a date picker action", () => { - const action = datetimePickerAction("Pick date", "date_picked", "date"); - - expect(action.type).toBe("datetimepicker"); - expect(action.label).toBe("Pick date"); - expect((action as { mode: string }).mode).toBe("date"); - expect((action as { data: string }).data).toBe("date_picked"); - }); - - it("creates a time picker action", () => { - const action = datetimePickerAction("Pick time", "time_picked", "time"); - - expect((action as { mode: string }).mode).toBe("time"); - }); - - it("creates a datetime picker action", () => { - const action = datetimePickerAction("Pick datetime", "datetime_picked", "datetime"); - - expect((action as { mode: string }).mode).toBe("datetime"); + it("creates picker actions for all supported modes", () => { + const cases = [ + { label: "Pick date", data: "date_picked", mode: "date" as const }, + { label: "Pick time", data: "time_picked", mode: "time" as const }, + { label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const }, + ]; + for (const testCase of cases) { + const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode); + expect(action.type).toBe("datetimepicker"); + expect(action.label).toBe(testCase.label); + expect((action as { mode: string }).mode).toBe(testCase.mode); + expect((action as { data: string }).data).toBe(testCase.data); + } }); it("includes initial/min/max when provided", () => { @@ -136,37 +122,22 @@ describe("createGridLayout", () => { ]; } - it("creates a 2x3 grid layout for tall menu", () => { + it("computes expected 2x3 layout for supported menu heights", () => { const actions = createSixSimpleActions(); - - const areas = createGridLayout(1686, actions); - - expect(areas.length).toBe(6); - - // Check first row positions - expect(areas[0].bounds.x).toBe(0); - expect(areas[0].bounds.y).toBe(0); - expect(areas[1].bounds.x).toBe(833); - expect(areas[1].bounds.y).toBe(0); - expect(areas[2].bounds.x).toBe(1666); - expect(areas[2].bounds.y).toBe(0); - - // Check second row positions - expect(areas[3].bounds.y).toBe(843); - expect(areas[4].bounds.y).toBe(843); - expect(areas[5].bounds.y).toBe(843); - }); - - it("creates a 2x3 grid layout for short menu", () => { - const actions = createSixSimpleActions(); - - const areas = createGridLayout(843, actions); - - expect(areas.length).toBe(6); - - // Row height should be half of 843 - expect(areas[0].bounds.height).toBe(421); - expect(areas[3].bounds.y).toBe(421); + const cases = [ + { height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 }, + { height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 }, + ] as const; + for (const testCase of cases) { + const areas = createGridLayout(testCase.height, actions); + expect(areas.length).toBe(6); + expect(areas[0]?.bounds.y).toBe(testCase.firstRowY); + expect(areas[0]?.bounds.height).toBe(testCase.rowHeight); + expect(areas[3]?.bounds.y).toBe(testCase.secondRowY); + expect(areas[0]?.bounds.x).toBe(0); + expect(areas[1]?.bounds.x).toBe(833); + expect(areas[2]?.bounds.x).toBe(1666); + } }); it("assigns correct actions to areas", () => { @@ -222,17 +193,12 @@ describe("createDefaultMenuConfig", () => { } }); - it("has message actions for all areas", () => { + it("uses message actions with expected default commands", () => { const config = createDefaultMenuConfig(); for (const area of config.areas) { expect(area.action.type).toBe("message"); } - }); - - it("has expected default commands", () => { - const config = createDefaultMenuConfig(); - const commands = config.areas.map((a) => (a.action as { text: string }).text); expect(commands).toContain("/help"); expect(commands).toContain("/status"); diff --git a/src/markdown/whatsapp.test.ts b/src/markdown/whatsapp.test.ts index e69cfbeaf19..07ee16d9225 100644 --- a/src/markdown/whatsapp.test.ts +++ b/src/markdown/whatsapp.test.ts @@ -2,24 +2,27 @@ import { describe, expect, it } from "vitest"; import { markdownToWhatsApp } from "./whatsapp.js"; describe("markdownToWhatsApp", () => { - it("converts **bold** to *bold*", () => { - expect(markdownToWhatsApp("**SOD Blast:**")).toBe("*SOD Blast:*"); - }); - - it("converts __bold__ to *bold*", () => { - expect(markdownToWhatsApp("__important__")).toBe("*important*"); - }); - - it("converts ~~strikethrough~~ to ~strikethrough~", () => { - expect(markdownToWhatsApp("~~deleted~~")).toBe("~deleted~"); - }); - - it("leaves single *italic* unchanged (already WhatsApp bold)", () => { - expect(markdownToWhatsApp("*text*")).toBe("*text*"); - }); - - it("leaves _italic_ unchanged (already WhatsApp italic)", () => { - expect(markdownToWhatsApp("_text_")).toBe("_text_"); + it("handles common markdown-to-whatsapp conversions", () => { + const cases = [ + ["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"], + ["converts __bold__ to *bold*", "__important__", "*important*"], + ["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"], + ["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"], + ["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"], + ["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"], + [ + "handles mixed formatting", + "**bold** and ~~strike~~ and _italic_", + "*bold* and ~strike~ and _italic_", + ], + ["handles multiple bold segments", "**one** then **two**", "*one* then *two*"], + ["returns empty string for empty input", "", ""], + ["returns plain text unchanged", "no formatting here", "no formatting here"], + ["handles bold inside a sentence", "This is **very** important", "This is *very* important"], + ] as const; + for (const [name, input, expected] of cases) { + expect(markdownToWhatsApp(input), name).toBe(expected); + } }); it("preserves fenced code blocks", () => { @@ -27,32 +30,6 @@ describe("markdownToWhatsApp", () => { expect(markdownToWhatsApp(input)).toBe(input); }); - it("preserves inline code", () => { - expect(markdownToWhatsApp("Use `**not bold**` here")).toBe("Use `**not bold**` here"); - }); - - it("handles mixed formatting", () => { - expect(markdownToWhatsApp("**bold** and ~~strike~~ and _italic_")).toBe( - "*bold* and ~strike~ and _italic_", - ); - }); - - it("handles multiple bold segments", () => { - expect(markdownToWhatsApp("**one** then **two**")).toBe("*one* then *two*"); - }); - - it("returns empty string for empty input", () => { - expect(markdownToWhatsApp("")).toBe(""); - }); - - it("returns plain text unchanged", () => { - expect(markdownToWhatsApp("no formatting here")).toBe("no formatting here"); - }); - - it("handles bold inside a sentence", () => { - expect(markdownToWhatsApp("This is **very** important")).toBe("This is *very* important"); - }); - it("preserves code block with formatting inside", () => { const input = "Before ```**bold** and ~~strike~~``` after **real bold**"; expect(markdownToWhatsApp(input)).toBe( diff --git a/src/plugin-sdk/webhook-targets.test.ts b/src/plugin-sdk/webhook-targets.test.ts index 5c4255533da..753e0ddc186 100644 --- a/src/plugin-sdk/webhook-targets.test.ts +++ b/src/plugin-sdk/webhook-targets.test.ts @@ -70,47 +70,38 @@ describe("rejectNonPostWebhookRequest", () => { }); describe("resolveSingleWebhookTarget", () => { - it("returns none when no target matches", () => { - const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "c"); + const resolvers: Array<{ + name: string; + run: ( + targets: readonly string[], + isMatch: (value: string) => boolean | Promise, + ) => Promise<{ kind: "none" } | { kind: "single"; target: string } | { kind: "ambiguous" }>; + }> = [ + { + name: "sync", + run: async (targets, isMatch) => + resolveSingleWebhookTarget(targets, (value) => Boolean(isMatch(value))), + }, + { + name: "async", + run: (targets, isMatch) => + resolveSingleWebhookTargetAsync(targets, async (value) => Boolean(await isMatch(value))), + }, + ]; + + it.each(resolvers)("returns none when no target matches ($name)", async ({ run }) => { + const result = await run(["a", "b"], (value) => value === "c"); expect(result).toEqual({ kind: "none" }); }); - it("returns the single match", () => { - const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "b"); + it.each(resolvers)("returns the single match ($name)", async ({ run }) => { + const result = await run(["a", "b"], (value) => value === "b"); expect(result).toEqual({ kind: "single", target: "b" }); }); - it("returns ambiguous after second match", () => { + it.each(resolvers)("returns ambiguous after second match ($name)", async ({ run }) => { const calls: string[] = []; - const result = resolveSingleWebhookTarget(["a", "b", "c"], (value) => { - calls.push(value); - return value === "a" || value === "b"; - }); - expect(result).toEqual({ kind: "ambiguous" }); - expect(calls).toEqual(["a", "b"]); - }); -}); - -describe("resolveSingleWebhookTargetAsync", () => { - it("returns none when no target matches", async () => { - const result = await resolveSingleWebhookTargetAsync( - ["a", "b"], - async (value) => value === "c", - ); - expect(result).toEqual({ kind: "none" }); - }); - - it("returns the single async match", async () => { - const result = await resolveSingleWebhookTargetAsync( - ["a", "b"], - async (value) => value === "b", - ); - expect(result).toEqual({ kind: "single", target: "b" }); - }); - - it("returns ambiguous after second async match", async () => { - const calls: string[] = []; - const result = await resolveSingleWebhookTargetAsync(["a", "b", "c"], async (value) => { + const result = await run(["a", "b", "c"], (value) => { calls.push(value); return value === "a" || value === "b"; }); diff --git a/src/signal/format.links.test.ts b/src/signal/format.links.test.ts index 7ef77e71db5..c6ec112a7df 100644 --- a/src/signal/format.links.test.ts +++ b/src/signal/format.links.test.ts @@ -3,40 +3,22 @@ import { markdownToSignalText } from "./format.js"; describe("markdownToSignalText", () => { describe("duplicate URL display", () => { - it("does not duplicate URL when label matches URL without protocol", () => { - // [selfh.st](http://selfh.st) should render as "selfh.st" not "selfh.st (http://selfh.st)" - const res = markdownToSignalText("[selfh.st](http://selfh.st)"); - expect(res.text).toBe("selfh.st"); - }); + it("does not duplicate URL for normalized equivalent labels", () => { + const equivalentCases = [ + { input: "[selfh.st](http://selfh.st)", expected: "selfh.st" }, + { input: "[example.com](https://example.com)", expected: "example.com" }, + { input: "[www.example.com](https://example.com)", expected: "www.example.com" }, + { input: "[example.com](https://example.com/)", expected: "example.com" }, + { input: "[example.com](https://example.com///)", expected: "example.com" }, + { input: "[example.com](https://www.example.com)", expected: "example.com" }, + { input: "[EXAMPLE.COM](https://example.com)", expected: "EXAMPLE.COM" }, + { input: "[example.com/page](https://example.com/page)", expected: "example.com/page" }, + ] as const; - it("does not duplicate URL when label matches URL without https protocol", () => { - const res = markdownToSignalText("[example.com](https://example.com)"); - expect(res.text).toBe("example.com"); - }); - - it("does not duplicate URL when label matches URL without www prefix", () => { - const res = markdownToSignalText("[www.example.com](https://example.com)"); - expect(res.text).toBe("www.example.com"); - }); - - it("does not duplicate URL when label matches URL without trailing slash", () => { - const res = markdownToSignalText("[example.com](https://example.com/)"); - expect(res.text).toBe("example.com"); - }); - - it("does not duplicate URL when label matches URL with multiple trailing slashes", () => { - const res = markdownToSignalText("[example.com](https://example.com///)"); - expect(res.text).toBe("example.com"); - }); - - it("does not duplicate URL when label includes www but URL does not", () => { - const res = markdownToSignalText("[example.com](https://www.example.com)"); - expect(res.text).toBe("example.com"); - }); - - it("handles case-insensitive domain comparison", () => { - const res = markdownToSignalText("[EXAMPLE.COM](https://example.com)"); - expect(res.text).toBe("EXAMPLE.COM"); + for (const { input, expected } of equivalentCases) { + const res = markdownToSignalText(input); + expect(res.text).toBe(expected); + } }); it("still shows URL when label is meaningfully different", () => { @@ -49,10 +31,5 @@ describe("markdownToSignalText", () => { const res = markdownToSignalText("[example.com](https://example.com/page)"); expect(res.text).toBe("example.com (https://example.com/page)"); }); - - it("does not duplicate when label matches full URL with path", () => { - const res = markdownToSignalText("[example.com/page](https://example.com/page)"); - expect(res.text).toBe("example.com/page"); - }); }); }); diff --git a/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.e2e.test.ts b/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.e2e.test.ts index 7a6b6153add..9017da6732d 100644 --- a/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.e2e.test.ts +++ b/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.e2e.test.ts @@ -15,10 +15,9 @@ const { monitorSignalProvider } = await import("./monitor.js"); const { replyMock, sendMock, streamMock, upsertPairingRequestMock } = getSignalToolResultTestMocks(); -async function runMonitorWithMocks( - opts: Parameters<(typeof import("./monitor.js"))["monitorSignalProvider"]>[0], -) { - const { monitorSignalProvider } = await import("./monitor.js"); +type MonitorSignalProviderOptions = Parameters[0]; + +async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) { return monitorSignalProvider(opts); } describe("monitorSignalProvider tool results", () => { diff --git a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index 7c55375abe4..f21d2230324 100644 --- a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -14,7 +14,7 @@ import { installSignalToolResultTestHooks(); // Import after the harness registers `vi.mock(...)` for Signal internals. -await import("./monitor.js"); +const { monitorSignalProvider } = await import("./monitor.js"); const { replyMock, @@ -26,6 +26,7 @@ const { } = getSignalToolResultTestMocks(); const SIGNAL_BASE_URL = "http://127.0.0.1:8080"; +type MonitorSignalProviderOptions = Parameters[0]; function createMonitorRuntime() { return { @@ -69,16 +70,13 @@ function createAutoAbortController() { return abortController; } -async function runMonitorWithMocks( - opts: Parameters<(typeof import("./monitor.js"))["monitorSignalProvider"]>[0], -) { - const { monitorSignalProvider } = await import("./monitor.js"); +async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) { return monitorSignalProvider(opts); } async function receiveSignalPayloads(params: { payloads: unknown[]; - opts?: Partial[0]>; + opts?: Partial; }) { const abortController = new AbortController(); streamMock.mockImplementation(async ({ onEvent }) => { @@ -122,7 +120,7 @@ function makeBaseEnvelope(overrides: Record = {}) { async function receiveSingleEnvelope( envelope: Record, - opts?: Partial[0]>, + opts?: Partial, ) { await receiveSignalPayloads({ payloads: [{ envelope }], diff --git a/src/slack/format.test.ts b/src/slack/format.test.ts index eebb2bbf79b..2b44c63a4c1 100644 --- a/src/slack/format.test.ts +++ b/src/slack/format.test.ts @@ -2,84 +2,44 @@ import { describe, expect, it } from "vitest"; import { markdownToSlackMrkdwn } from "./format.js"; describe("markdownToSlackMrkdwn", () => { - it("converts bold from double asterisks to single", () => { - const res = markdownToSlackMrkdwn("**bold text**"); - expect(res).toBe("*bold text*"); - }); - - it("preserves italic underscore format", () => { - const res = markdownToSlackMrkdwn("_italic text_"); - expect(res).toBe("_italic text_"); - }); - - it("converts strikethrough from double tilde to single", () => { - const res = markdownToSlackMrkdwn("~~strikethrough~~"); - expect(res).toBe("~strikethrough~"); - }); - - it("renders basic inline formatting together", () => { - const res = markdownToSlackMrkdwn("hi _there_ **boss** `code`"); - expect(res).toBe("hi _there_ *boss* `code`"); - }); - - it("renders inline code", () => { - const res = markdownToSlackMrkdwn("use `npm install`"); - expect(res).toBe("use `npm install`"); - }); - - it("renders fenced code blocks", () => { - const res = markdownToSlackMrkdwn("```js\nconst x = 1;\n```"); - expect(res).toBe("```\nconst x = 1;\n```"); - }); - - it("renders links with Slack mrkdwn syntax", () => { - const res = markdownToSlackMrkdwn("see [docs](https://example.com)"); - expect(res).toBe("see "); - }); - - it("does not duplicate bare URLs", () => { - const res = markdownToSlackMrkdwn("see https://example.com"); - expect(res).toBe("see https://example.com"); - }); - - it("escapes unsafe characters", () => { - const res = markdownToSlackMrkdwn("a & b < c > d"); - expect(res).toBe("a & b < c > d"); - }); - - it("preserves Slack angle-bracket markup (mentions/links)", () => { - const res = markdownToSlackMrkdwn("hi <@U123> see and "); - expect(res).toBe("hi <@U123> see and "); - }); - - it("escapes raw HTML", () => { - const res = markdownToSlackMrkdwn("nope"); - expect(res).toBe("<b>nope</b>"); - }); - - it("renders paragraphs with blank lines", () => { - const res = markdownToSlackMrkdwn("first\n\nsecond"); - expect(res).toBe("first\n\nsecond"); - }); - - it("renders bullet lists", () => { - const res = markdownToSlackMrkdwn("- one\n- two"); - expect(res).toBe("• one\n• two"); - }); - - it("renders ordered lists with numbering", () => { - const res = markdownToSlackMrkdwn("2. two\n3. three"); - expect(res).toBe("2. two\n3. three"); - }); - - it("renders headings as bold text", () => { - const res = markdownToSlackMrkdwn("# Title"); - expect(res).toBe("*Title*"); - }); - - it("renders blockquotes", () => { - const res = markdownToSlackMrkdwn("> Quote"); - expect(res).toBe("> Quote"); + it("handles core markdown formatting conversions", () => { + const cases = [ + ["converts bold from double asterisks to single", "**bold text**", "*bold text*"], + ["preserves italic underscore format", "_italic text_", "_italic text_"], + [ + "converts strikethrough from double tilde to single", + "~~strikethrough~~", + "~strikethrough~", + ], + [ + "renders basic inline formatting together", + "hi _there_ **boss** `code`", + "hi _there_ *boss* `code`", + ], + ["renders inline code", "use `npm install`", "use `npm install`"], + ["renders fenced code blocks", "```js\nconst x = 1;\n```", "```\nconst x = 1;\n```"], + [ + "renders links with Slack mrkdwn syntax", + "see [docs](https://example.com)", + "see ", + ], + ["does not duplicate bare URLs", "see https://example.com", "see https://example.com"], + ["escapes unsafe characters", "a & b < c > d", "a & b < c > d"], + [ + "preserves Slack angle-bracket markup (mentions/links)", + "hi <@U123> see and ", + "hi <@U123> see and ", + ], + ["escapes raw HTML", "nope", "<b>nope</b>"], + ["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"], + ["renders bullet lists", "- one\n- two", "• one\n• two"], + ["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"], + ["renders headings as bold text", "# Title", "*Title*"], + ["renders blockquotes", "> Quote", "> Quote"], + ] as const; + for (const [name, input, expected] of cases) { + expect(markdownToSlackMrkdwn(input), name).toBe(expected); + } }); it("handles nested list items", () => { diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index ac1d8bd8f42..428b1a2dcb0 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -514,176 +514,138 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(2); }); - it("blocks all group messages when groupPolicy is 'disabled'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "disabled", - allowFrom: ["123456789"], + const groupPolicyCases: Array<{ + name: string; + config: Record; + message: Record; + expectedReplyCount: number; + }> = [ + { + name: "blocks all group messages when groupPolicy is 'disabled'", + config: { + channels: { + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 123456789, username: "testuser" }, text: "@openclaw_bot hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["123456789"], + expectedReplyCount: 0, + }, + { + name: "blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", + config: { + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 999999, username: "notallowed" }, text: "@openclaw_bot hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["123456789"], - groups: { "*": { requireMention: false } }, + expectedReplyCount: 0, + }, + { + name: "allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", + config: { + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { "*": { requireMention: false } }, + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 123456789, username: "testuser" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("blocks group messages when allowFrom is configured with @username entries (numeric IDs required)", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["@testuser"], - groups: { "*": { requireMention: false } }, + expectedReplyCount: 1, + }, + { + name: "blocks group messages when allowFrom is configured with @username entries (numeric IDs required)", + config: { + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@testuser"], + groups: { "*": { requireMention: false } }, + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 12345, username: "testuser" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(0); - }); - it("allows group messages from tg:-prefixed allowFrom entries case-insensitively", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["TG:77112533"], - groups: { "*": { requireMention: false } }, + expectedReplyCount: 0, + }, + { + name: "allows group messages from tg:-prefixed allowFrom entries case-insensitively", + config: { + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:77112533"], + groups: { "*": { requireMention: false } }, + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 77112533, username: "mneves" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows all group messages when groupPolicy is 'open'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, + expectedReplyCount: 1, + }, + { + name: "allows all group messages when groupPolicy is 'open'", + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 999999, username: "random" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + expectedReplyCount: 1, + }, + ]; - expect(replySpy).toHaveBeenCalledTimes(1); + it("applies groupPolicy cases", async () => { + for (const [index, testCase] of groupPolicyCases.entries()) { + resetHarnessSpies(); + loadConfig.mockReturnValue(testCase.config); + await dispatchMessage({ + message: { + ...testCase.message, + message_id: 1_000 + index, + date: 1_736_380_800 + index, + }, + }); + expect(replySpy.mock.calls.length, testCase.name).toBe(testCase.expectedReplyCount); + } }); it("routes DMs by telegram accountId binding", async () => { @@ -729,234 +691,187 @@ describe("createTelegramBot", () => { expect(payload.AccountId).toBe("opie"); expect(payload.SessionKey).toBe("agent:opie:main"); }); - it("allows per-group requireMention override", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { - "*": { requireMention: true }, - "123": { requireMention: false }, - }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "group", title: "Dev Chat" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows per-topic requireMention override", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { - "*": { requireMention: true }, - "-1001234567890": { - requireMention: true, - topics: { - "99": { requireMention: false }, + it("applies group mention overrides and fallback behavior", async () => { + const cases: Array<{ + config: Record; + message: Record; + me?: Record; + }> = [ + { + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "123": { requireMention: false }, }, }, }, }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - text: "hello", - date: 1736380800, - message_thread_id: 99, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("honors groups default when no explicit group override exists", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 456, type: "group", title: "Ops" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("does not block group messages when bot username is unknown", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 789, type: "group", title: "No Me" }, - text: "hello", - date: 1736380800, - }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("routes forum topic messages using parent group binding", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - agents: { - list: [{ id: "forum-agent" }], - }, - bindings: [ - { - agentId: "forum-agent", - match: { - channel: "telegram", - peer: { kind: "group", id: "-1001234567890" }, + { + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "-1001234567890": { + requireMention: true, + topics: { + "99": { requireMention: false }, + }, + }, + }, + }, }, }, - ], - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello", + date: 1736380800, + message_thread_id: 99, }, + }, + { + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }, + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + }, + }, + { + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }, + message: { + chat: { id: 789, type: "group", title: "No Me" }, + text: "hello", + date: 1736380800, + }, + me: {}, + }, + ]; + + for (const testCase of cases) { + resetHarnessSpies(); + loadConfig.mockReturnValue(testCase.config); + await dispatchMessage({ + message: testCase.message, + me: testCase.me, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + } + }); + + it("routes forum topics to parent or topic-specific bindings", async () => { + const cases: Array<{ + config: Record; + expectedSessionKeyFragment: string; + text: string; + }> = [ + { + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + agents: { + list: [{ id: "forum-agent" }], + }, + bindings: [ + { + agentId: "forum-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890" }, + }, + }, + ], + }, + expectedSessionKeyFragment: "agent:forum-agent:", text: "hello from topic", - date: 1736380800, - message_id: 42, - message_thread_id: 99, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.SessionKey).toContain("agent:forum-agent:"); - }); - it("prefers specific topic binding over parent group binding", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - agents: { - list: [{ id: "topic-agent" }, { id: "group-agent" }], - }, - bindings: [ - { - agentId: "topic-agent", - match: { - channel: "telegram", - peer: { kind: "group", id: "-1001234567890:topic:99" }, + { + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, }, - }, - { - agentId: "group-agent", - match: { - channel: "telegram", - peer: { kind: "group", id: "-1001234567890" }, + agents: { + list: [{ id: "topic-agent" }, { id: "group-agent" }], }, + bindings: [ + { + agentId: "topic-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890:topic:99" }, + }, + }, + { + agentId: "group-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890" }, + }, + }, + ], }, - ], - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, + expectedSessionKeyFragment: "agent:topic-agent:", text: "hello from topic 99", - date: 1736380800, - message_id: 42, - message_thread_id: 99, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + ]; - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.SessionKey).toContain("agent:topic-agent:"); + for (const testCase of cases) { + resetHarnessSpies(); + loadConfig.mockReturnValue(testCase.config); + await dispatchMessage({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: testCase.text, + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment); + } }); it("sends GIF replies as animations", async () => { @@ -1021,78 +936,68 @@ describe("createTelegramBot", () => { }); } - it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", + it("accepts mentionPatterns matches with and without unrelated mentions", async () => { + const cases = [ + { + name: "plain mention pattern text", + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, }, + assertEnvelope: true, }, - identity: { name: "Bert" }, - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, + { + name: "mention pattern plus another @mention", + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: hello @alice", + entities: [{ type: "mention", offset: 12, length: 6 }], + date: 1736380801, + message_id: 3, + from: { id: 9, first_name: "Ada" }, }, + assertEnvelope: false, }, - }); + ] as const; - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "bert: introduce yourself", - date: 1736380800, - message_id: 1, - from: { id: 9, first_name: "Ada" }, - }, - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.WasMentioned).toBe(true); - expect(payload.SenderName).toBe("Ada"); - expect(payload.SenderId).toBe("9"); - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), - ); - }); - it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", + for (const testCase of cases) { + resetHarnessSpies(); + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, }, - }, - identity: { name: "Bert" }, - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, }, - }, - }); + }); - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "bert: hello @alice", - entities: [{ type: "mention", offset: 12, length: 6 }], - date: 1736380800, - message_id: 3, - from: { id: 9, first_name: "Ada" }, - }, - }); + await dispatchMessage({ + message: testCase.message, + }); - expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true); + expect(replySpy.mock.calls.length, testCase.name).toBe(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned, testCase.name).toBe(true); + if (testCase.assertEnvelope) { + expect(payload.SenderName).toBe("Ada"); + expect(payload.SenderId).toBe("9"); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); + } + } }); it("keeps group envelope headers stable (sender identity is separate)", async () => { resetHarnessSpies(); @@ -1176,58 +1081,53 @@ describe("createTelegramBot", () => { expect(setMyCommandsSpy).toHaveBeenCalledWith([]); }); - it("skips group messages when requireMention is enabled and no mention matches", async () => { - resetHarnessSpies(); + it("handles requireMention when mentions do and do not resolve", async () => { + const cases = [ + { + name: "mention pattern configured but no match", + config: { messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } } }, + me: { username: "openclaw_bot" }, + expectedReplyCount: 0, + expectedWasMentioned: undefined, + }, + { + name: "mention detection unavailable", + config: { messages: { groupChat: { mentionPatterns: [] } } }, + me: {}, + expectedReplyCount: 1, + expectedWasMentioned: false, + }, + ] as const; - loadConfig.mockReturnValue({ - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, + for (const [index, testCase] of cases.entries()) { + resetHarnessSpies(); + loadConfig.mockReturnValue({ + ...testCase.config, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, }, - }, - }); + }); - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "hello everyone", - date: 1736380800, - message_id: 2, - from: { id: 9, first_name: "Ada" }, - }, - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("allows group messages when requireMention is enabled but mentions cannot be detected", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - messages: { groupChat: { mentionPatterns: [] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, + await dispatchMessage({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1_736_380_800 + index, + message_id: 2 + index, + from: { id: 9, first_name: "Ada" }, }, - }, - }); + me: testCase.me, + }); - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "hello everyone", - date: 1736380800, - message_id: 3, - from: { id: 9, first_name: "Ada" }, - }, - me: {}, - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.WasMentioned).toBe(false); + expect(replySpy.mock.calls.length, testCase.name).toBe(testCase.expectedReplyCount); + if (testCase.expectedWasMentioned != null) { + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned, testCase.name).toBe(testCase.expectedWasMentioned); + } + } }); it("includes reply-to context when a Telegram reply is received", async () => { resetHarnessSpies(); @@ -1254,33 +1154,50 @@ describe("createTelegramBot", () => { expect(payload.ReplyToSender).toBe("Ada"); }); - it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - groups: { "*": { requireMention: false } }, + it("blocks group messages for restrictive group config edge cases", async () => { + const blockedCases = [ + { + name: "allowlist policy with no groupAllowFrom", + config: { + channels: { + telegram: { + groupPolicy: "allowlist", + groups: { "*": { requireMention: false } }, + }, + }, + }, + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 123456789, username: "testuser" }, - text: "hello", - date: 1736380800, + { + name: "groups map without wildcard", + config: { + channels: { + telegram: { + groups: { + "123": { requireMention: false }, + }, + }, + }, + }, + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "@openclaw_bot hello", + date: 1736380800, + }, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + ] as const; - expect(replySpy).not.toHaveBeenCalled(); + for (const testCase of blockedCases) { + resetHarnessSpies(); + loadConfig.mockReturnValue(testCase.config); + await dispatchMessage({ message: testCase.message }); + expect(replySpy.mock.calls.length, testCase.name).toBe(0); + } }); it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { onSpy.mockReset(); @@ -1311,262 +1228,206 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); }); - it("isolates forum topic sessions and carries thread metadata", async () => { - onSpy.mockReset(); - sendChatActionSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, + it("handles forum topic metadata and typing thread fallbacks", async () => { + const forumCases = [ + { + name: "topic-scoped forum message", + threadId: 99, + expectedTypingThreadId: 99, + assertTopicMetadata: true, }, - }); + { + name: "General topic forum message", + threadId: undefined, + expectedTypingThreadId: 1, + assertTopicMetadata: false, + }, + ] as const; - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; + for (const testCase of forumCases) { + resetHarnessSpies(); + sendChatActionSpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); - await handler(makeForumGroupMessageCtx({ threadId: 99 })); + const handler = getMessageHandler(); + await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId })); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); - expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); - expect(payload.MessageThreadId).toBe(99); - expect(payload.IsForum).toBe(true); - expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { - message_thread_id: 99, - }); + expect(replySpy.mock.calls.length, testCase.name).toBe(1); + const payload = replySpy.mock.calls[0][0]; + if (testCase.assertTopicMetadata) { + expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); + expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); + expect(payload.MessageThreadId).toBe(99); + expect(payload.IsForum).toBe(true); + } + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: testCase.expectedTypingThreadId, + }); + } }); - it("falls back to General topic thread id for typing in forums", async () => { - onSpy.mockReset(); - sendChatActionSpy.mockReset(); - replySpy.mockReset(); + it("threads forum replies only when a topic id exists", async () => { + const threadCases = [ + { name: "General topic reply", threadId: undefined, expectedMessageThreadId: undefined }, + { name: "topic reply", threadId: 99, expectedMessageThreadId: 99 }, + ] as const; - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, + for (const testCase of threadCases) { + resetHarnessSpies(); + replySpy.mockResolvedValue({ text: "response" }); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, }, - }, - }); + }); - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; + const handler = getMessageHandler(); + await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId })); - await handler(makeForumGroupMessageCtx({ threadId: undefined })); - - expect(replySpy).toHaveBeenCalledTimes(1); - expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { - message_thread_id: 1, - }); - }); - it("routes General topic replies using thread id 1", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ text: "response" }); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - from: { id: 12345, username: "testuser" }, - text: "hello", - date: 1736380800, - message_id: 42, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - const sendParams = sendMessageSpy.mock.calls[0]?.[2] as { message_thread_id?: number }; - expect(sendParams?.message_thread_id).toBeUndefined(); + expect(sendMessageSpy.mock.calls.length, testCase.name).toBe(1); + const sendParams = sendMessageSpy.mock.calls[0]?.[2] as { message_thread_id?: number }; + if (testCase.expectedMessageThreadId == null) { + expect(sendParams?.message_thread_id, testCase.name).toBeUndefined(); + } else { + expect(sendParams?.message_thread_id, testCase.name).toBe(testCase.expectedMessageThreadId); + } + } }); - it("allows direct messages regardless of groupPolicy", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "disabled", - allowFrom: ["123456789"], + const allowFromEdgeCases: Array<{ + name: string; + config: Record; + message: Record; + expectedReplyCount: number; + }> = [ + { + name: "allows direct messages regardless of groupPolicy", + config: { + channels: { + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: 123456789, type: "private" }, from: { id: 123456789, username: "testuser" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - allowFrom: [" TG:123456789 "], + expectedReplyCount: 1, + }, + { + name: "allows direct messages with tg/Telegram-prefixed allowFrom entries", + config: { + channels: { + telegram: { + allowFrom: [" TG:123456789 "], + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: 123456789, type: "private" }, from: { id: 123456789, username: "testuser" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("matches direct message allowFrom against sender user id when chat id differs", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - allowFrom: ["123456789"], + expectedReplyCount: 1, + }, + { + name: "matches direct message allowFrom against sender user id when chat id differs", + config: { + channels: { + telegram: { + allowFrom: ["123456789"], + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: 777777777, type: "private" }, from: { id: 123456789, username: "testuser" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("falls back to direct message chat id when sender user id is missing", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - allowFrom: ["123456789"], + expectedReplyCount: 1, + }, + { + name: "falls back to direct message chat id when sender user id is missing", + config: { + channels: { + telegram: { + allowFrom: ["123456789"], + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: 123456789, type: "private" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["*"], - groups: { "*": { requireMention: false } }, + expectedReplyCount: 1, + }, + { + name: "allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", + config: { + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 999999, username: "random" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["123456789"], + expectedReplyCount: 1, + }, + { + name: "blocks group messages with no sender ID when groupPolicy is 'allowlist'", + config: { + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, }, }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, text: "hello", date: 1736380800, }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + expectedReplyCount: 0, + }, + ]; - expect(replySpy).not.toHaveBeenCalled(); + it("applies allowFrom edge cases", async () => { + for (const [index, testCase] of allowFromEdgeCases.entries()) { + resetHarnessSpies(); + loadConfig.mockReturnValue(testCase.config); + await dispatchMessage({ + message: { + ...testCase.message, + message_id: 2_000 + index, + date: 1_736_380_900 + index, + }, + }); + expect(replySpy.mock.calls.length, testCase.name).toBe(testCase.expectedReplyCount); + } }); it("sends replies without native reply threading", async () => { onSpy.mockReset(); @@ -1655,34 +1516,6 @@ describe("createTelegramBot", () => { } } }); - it("blocks group messages when telegram.groups is set without a wildcard", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groups: { - "123": { requireMention: false }, - }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 456, type: "group", title: "Ops" }, - text: "@openclaw_bot hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); it("honors routed group activation from session store", async () => { onSpy.mockReset(); replySpy.mockReset(); @@ -1766,33 +1599,6 @@ describe("createTelegramBot", () => { const opts = replySpy.mock.calls[0][1] as { skillFilter?: unknown }; expect(opts?.skillFilter).toEqual([]); }); - it("passes message_thread_id to topic replies", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - commandSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ text: "response" }); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler(makeForumGroupMessageCtx({ threadId: 99 })); - - expect(sendMessageSpy).toHaveBeenCalledWith( - "-1001234567890", - expect.any(String), - expect.objectContaining({ message_thread_id: 99 }), - ); - }); it("threads native command replies inside topics", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts index 91c18e77329..ab9c6b495e1 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts @@ -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; async function createBotHandler(): Promise<{ handler: (ctx: Record) => Promise; @@ -30,10 +32,6 @@ async function createBotHandlerWithOptions(options: { replySpy: ReturnType; runtimeError: ReturnType; }> { - const { createTelegramBot } = await import("./bot.js"); - const replyModule = await import("../auto-reply/reply.js"); - const replySpy = (replyModule as unknown as { __replySpy: ReturnType }).__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 }).__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 }) - .__replySpy; - onSpy.mockReset(); replySpy.mockReset(); diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index dd872374440..0e27bc074e3 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -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 there boss code"); - }); - - it("renders links as Telegram-safe HTML", () => { - const res = markdownToTelegramHtml("see [docs](https://example.com)"); - expect(res).toBe('see docs'); - }); - - it("escapes raw HTML", () => { - const res = markdownToTelegramHtml("nope"); - 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 there boss code", + ], + [ + "renders links as Telegram-safe HTML", + "see [docs](https://example.com)", + 'see docs', + ], + ["escapes raw HTML", "nope", "<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", () => { diff --git a/src/telegram/format.wrap-md.test.ts b/src/telegram/format.wrap-md.test.ts index 5c82f1ee5a7..d77ab792c55 100644 --- a/src/telegram/format.wrap-md.test.ts +++ b/src/telegram/format.wrap-md.test.ts @@ -7,58 +7,33 @@ import { } from "./format.js"; describe("wrapFileReferencesInHtml", () => { - it("wraps .md filenames in code tags", () => { - expect(wrapFileReferencesInHtml("Check README.md")).toContain("Check README.md"); - expect(wrapFileReferencesInHtml("See HEARTBEAT.md for status")).toContain( - "See HEARTBEAT.md for status", - ); + it("wraps supported file references and paths", () => { + const cases = [ + ["Check README.md", "Check README.md"], + ["See HEARTBEAT.md for status", "See HEARTBEAT.md for status"], + ["Check main.go", "Check main.go"], + ["Run script.py", "Run script.py"], + ["Check backup.pl", "Check backup.pl"], + ["Run backup.sh", "Run backup.sh"], + ["Look at squad/friday/HEARTBEAT.md", "Look at squad/friday/HEARTBEAT.md"], + ] 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 main.go"); - }); - - it("wraps .py filenames", () => { - expect(wrapFileReferencesInHtml("Run script.py")).toContain("Run script.py"); - }); - - it("wraps .pl filenames", () => { - expect(wrapFileReferencesInHtml("Check backup.pl")).toContain("Check backup.pl"); - }); - - it("wraps .sh filenames", () => { - expect(wrapFileReferencesInHtml("Run backup.sh")).toContain("Run backup.sh"); - }); - - it("wraps file paths", () => { - expect(wrapFileReferencesInHtml("Look at squad/friday/HEARTBEAT.md")).toContain( - "Look at squad/friday/HEARTBEAT.md", - ); - }); - - it("does not wrap inside existing code tags", () => { - const input = "Already wrapped.md here"; - const result = wrapFileReferencesInHtml(input); - expect(result).toBe(input); - expect(result).not.toContain(""); - }); - - it("does not wrap inside pre tags", () => { - const input = "
README.md
"; - const result = wrapFileReferencesInHtml(input); - expect(result).toBe(input); - }); - - it("does not wrap inside anchor tags", () => { - const input = 'Link'; - const result = wrapFileReferencesInHtml(input); - expect(result).toBe(input); - }); - - it("does not wrap file refs inside real URL anchor tags", () => { - const input = 'Visit example.com/README.md'; - const result = wrapFileReferencesInHtml(input); - expect(result).toBe(input); + it("does not wrap inside protected html contexts", () => { + const cases = [ + "Already wrapped.md here", + "
README.md
", + 'Link', + 'Visit example.com/README.md', + ] as const; + for (const input of cases) { + const result = wrapFileReferencesInHtml(input); + expect(result, input).toBe(input); + } + expect(wrapFileReferencesInHtml(cases[0])).not.toContain(""); }); it("handles mixed content correctly", () => { @@ -67,32 +42,51 @@ describe("wrapFileReferencesInHtml", () => { expect(result).toContain("CONTRIBUTING.md"); }); - it("handles edge cases", () => { - expect(wrapFileReferencesInHtml("No markdown files here")).not.toContain(""); - expect(wrapFileReferencesInHtml("File.md at start")).toContain("File.md"); - expect(wrapFileReferencesInHtml("Ends with file.md")).toContain("file.md"); + it("handles boundary and punctuation wrapping cases", () => { + const cases = [ + { input: "No markdown files here", contains: undefined }, + { input: "File.md at start", contains: "File.md" }, + { input: "Ends with file.md", contains: "file.md" }, + { input: "See README.md.", contains: "README.md." }, + { input: "See README.md,", contains: "README.md," }, + { input: "(README.md)", contains: "(README.md)" }, + { input: "README.md:", contains: "README.md:" }, + ] as const; + + for (const testCase of cases) { + const result = wrapFileReferencesInHtml(testCase.input); + if (!testCase.contains) { + expect(result).not.toContain(""); + continue; + } + expect(result).toContain(testCase.contains); + } }); - it("wraps file refs with punctuation boundaries", () => { - expect(wrapFileReferencesInHtml("See README.md.")).toContain("README.md."); - expect(wrapFileReferencesInHtml("See README.md,")).toContain("README.md,"); - expect(wrapFileReferencesInHtml("(README.md)")).toContain("(README.md)"); - expect(wrapFileReferencesInHtml("README.md:")).toContain("README.md:"); - }); - - it("de-linkifies auto-linkified file ref anchors", () => { - const input = 'README.md'; - expect(wrapFileReferencesInHtml(input)).toBe("README.md"); - }); - - it("de-linkifies auto-linkified path anchors", () => { - const input = 'squad/friday/HEARTBEAT.md'; - expect(wrapFileReferencesInHtml(input)).toBe("squad/friday/HEARTBEAT.md"); + it("de-linkifies auto-linkified anchors for plain files and paths", () => { + const cases = [ + { + input: 'README.md', + expected: "README.md", + }, + { + input: 'squad/friday/HEARTBEAT.md', + expected: "squad/friday/HEARTBEAT.md", + }, + ] 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 = 'click here'; - expect(wrapFileReferencesInHtml(input)).toBe(input); + const cases = [ + 'click here', + 'README.md', + ] 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("README.md"); - }); - - it("wraps file ref inside italic tags", () => { - const result = markdownToTelegramHtml("*script.py*"); - expect(result).toBe("script.py"); + it("wraps file refs inside emphasis tags", () => { + const cases = [ + ["**README.md**", "README.md"], + ["*script.py*", "script.py"], + ] 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(""); }); - it("preserves domain-like paths as anchor tags", () => { - const result = markdownToTelegramHtml("example.com/README.md"); - expect(result).toContain(''); - expect(result).not.toContain(""); - }); - - it("preserves github URLs with file paths", () => { - const result = markdownToTelegramHtml("https://github.com/foo/README.md"); - expect(result).toContain(''); + 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(``); + expect(result).not.toContain(""); + } }); it("handles wrapFileRefs: false (plain text output)", () => { @@ -233,14 +234,14 @@ describe("edge cases", () => { expect(result).not.toContain("script.js"); }); - it("handles file ref at start of message", () => { - const result = markdownToTelegramHtml("README.md is important"); - expect(result).toBe("README.md is important"); - }); - - it("handles file ref at end of message", () => { - const result = markdownToTelegramHtml("Check the README.md"); - expect(result).toBe("Check the README.md"); + it("handles file refs at message boundaries", () => { + const cases = [ + ["README.md is important", "README.md is important"], + ["Check the README.md", "Check the README.md"], + ] 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(''); }); - it("handles file ref with hyphen and underscore in name", () => { - const result = markdownToTelegramHtml("my-file_name.md"); - expect(result).toContain("my-file_name.md"); - }); + it("wraps hyphen/underscore filenames and uppercase extensions", () => { + const first = markdownToTelegramHtml("my-file_name.md"); + expect(first).toContain("my-file_name.md"); - it("handles uppercase extensions", () => { - const result = markdownToTelegramHtml("README.MD and SCRIPT.PY"); - expect(result).toContain("README.MD"); - expect(result).toContain("SCRIPT.PY"); + const second = markdownToTelegramHtml("README.MD and SCRIPT.PY"); + expect(second).toContain("README.MD"); + expect(second).toContain("SCRIPT.PY"); }); it("handles nested code tags (depth tracking)", () => { @@ -293,12 +292,6 @@ describe("edge cases", () => { expect(result).toContain(" script.py"); }); - it("preserves anchor when href and label differ (no backreference match)", () => { - // Different href and label - should NOT de-linkify - const input = 'README.md'; - 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(""); }); - it("does not wrap orphaned TLD inside href attributes", () => { - // D.md inside href should NOT be wrapped - const input = 'link'; - const result = wrapFileReferencesInHtml(input); - // href should be untouched - expect(result).toBe(input); - expect(result).not.toContain("D.md"); - }); - - it("does not wrap orphaned TLD inside any HTML attribute", () => { - const input = 'R&D.md'; - const result = wrapFileReferencesInHtml(input); - expect(result).toBe(input); + it("does not wrap orphaned TLD fragments inside HTML attributes", () => { + const cases = [ + 'link', + 'R&D.md', + ] as const; + for (const input of cases) { + const result = wrapFileReferencesInHtml(input); + expect(result).toBe(input); + expect(result).not.toContain("D.md"); + } }); it("handles multiple orphaned TLDs with HTML tags (offset stability)", () => { diff --git a/src/telegram/model-buttons.test.ts b/src/telegram/model-buttons.test.ts index bff2ea17c11..0ddc229090c 100644 --- a/src/telegram/model-buttons.test.ts +++ b/src/telegram/model-buttons.test.ts @@ -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; + }>; - 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); + } + } }); }); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index fae898159f1..8e4ac35e0d4 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -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();