test: dedupe channel and transport adapters

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

View File

@@ -105,35 +105,34 @@ describe("discord message actions", () => {
expect(actions).not.toContain("channel-create"); expect(actions).not.toContain("channel-create");
}); });
it("lists moderation actions when per-account config enables them", () => { it("lists moderation when at least one account enables it", () => {
const cfg = { const cases = [
channels: { {
discord: { channels: {
accounts: { discord: {
vime: { token: "d1", actions: { moderation: true } }, accounts: {
vime: { token: "d1", actions: { moderation: true } },
},
}, },
}, },
}, },
} as OpenClawConfig; {
const actions = discordMessageActions.listActions?.({ cfg }) ?? []; channels: {
discord: {
expectModerationActions(actions); accounts: {
}); ops: { token: "d1", actions: { moderation: true } },
chat: { token: "d2" },
it("lists moderation when one account enables and another omits", () => { },
const cfg = {
channels: {
discord: {
accounts: {
ops: { token: "d1", actions: { moderation: true } },
chat: { token: "d2" },
}, },
}, },
}, },
} as OpenClawConfig; ] as const;
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
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", () => { it("omits moderation when all accounts omit it", () => {
@@ -382,11 +381,52 @@ describe("handleDiscordMessageAction", () => {
}); });
describe("telegramMessageActions", () => { describe("telegramMessageActions", () => {
it("excludes sticker actions when not enabled", () => { it("lists sticker actions only when enabled by config", () => {
const cfg = telegramCfg(); const cases = [
const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; {
expect(actions).not.toContain("sticker"); name: "default config",
expect(actions).not.toContain("sticker-search"); 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 () => { it("allows media-only sends and passes asVoice", async () => {
@@ -495,39 +535,6 @@ describe("telegramMessageActions", () => {
expect(handleTelegramAction).not.toHaveBeenCalled(); 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", () => { it("inherits top-level reaction gate when account overrides sticker only", () => {
const cfg = { const cfg = {
channels: { channels: {
@@ -602,30 +609,42 @@ describe("telegramMessageActions", () => {
}); });
describe("signalMessageActions", () => { describe("signalMessageActions", () => {
it("returns no actions when no configured accounts exist", () => { it("lists actions based on account presence and reaction gates", () => {
const cfg = {} as OpenClawConfig; const cases = [
expect(signalMessageActions.listActions?.({ cfg }) ?? []).toEqual([]); {
}); name: "no configured accounts",
cfg: {} as OpenClawConfig,
it("hides react when reactions are disabled", () => { expected: [],
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 } },
},
},
}, },
} 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", () => { it("skips send for plugin dispatch", () => {
@@ -775,102 +794,113 @@ describe("slack actions adapter", () => {
}); });
}); });
it("forwards blocks JSON for send", async () => { it("forwards blocks for send/edit actions", async () => {
await runSlackAction("send", { const cases = [
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(
{ {
to: "channel:C1", action: "send" as const,
message: "", params: {
blocks: "{bad-json", 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", action: "send" as const,
message: "", params: {
blocks: "[]", 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", action: "edit" as const,
message: "", params: {
media: "https://example.com/image.png", channelId: "C1",
blocks: JSON.stringify([{ type: "divider" }]), 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 () => { it("rejects invalid send block combinations before dispatch", async () => {
await runSlackAction("edit", { const cases = [
channelId: "C1", {
messageId: "171234.567", name: "invalid JSON",
message: "", params: {
blocks: JSON.stringify([{ type: "divider" }]), 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({ for (const testCase of cases) {
action: "editMessage", handleSlackAction.mockClear();
channelId: "C1", await expectSlackSendRejected(testCase.params, testCase.error);
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" } }],
});
}); });
it("rejects edit when both message and blocks are missing", async () => { it("rejects edit when both message and blocks are missing", async () => {

View File

@@ -424,45 +424,27 @@ describe("discord mention gating", () => {
).toBe(true); ).toBe(true);
}); });
it("does not require mention inside autoThread threads", () => { it("applies autoThread mention rules based on thread ownership", () => {
const { guildInfo, channelConfig } = createAutoThreadMentionContext(); const cases = [
expect( { name: "bot-owned thread", threadOwnerId: "bot123", expected: false },
resolveDiscordShouldRequireMention({ { name: "user-owned thread", threadOwnerId: "user456", expected: true },
isGuildMessage: true, { name: "unknown thread owner", threadOwnerId: undefined, expected: true },
isThread: true, ] as const;
botId: "bot123",
threadOwnerId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(false);
});
it("requires mention inside user-created threads with autoThread enabled", () => { for (const testCase of cases) {
const { guildInfo, channelConfig } = createAutoThreadMentionContext(); const { guildInfo, channelConfig } = createAutoThreadMentionContext();
expect( expect(
resolveDiscordShouldRequireMention({ resolveDiscordShouldRequireMention({
isGuildMessage: true, isGuildMessage: true,
isThread: true, isThread: true,
botId: "bot123", botId: "bot123",
threadOwnerId: "user456", threadOwnerId: testCase.threadOwnerId,
channelConfig, channelConfig,
guildInfo, guildInfo,
}), }),
).toBe(true); testCase.name,
}); ).toBe(testCase.expected);
}
it("requires mention when thread owner is unknown", () => {
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(true);
}); });
it("inherits parent channel mention rules for threads", () => { it("inherits parent channel mention rules for threads", () => {
@@ -496,70 +478,73 @@ describe("discord mention gating", () => {
}); });
describe("discord groupPolicy gating", () => { describe("discord groupPolicy gating", () => {
it("allows when policy is open", () => { it("applies open/disabled/allowlist policy rules", () => {
expect( const cases = [
isDiscordGroupAllowedByPolicy({ {
groupPolicy: "open", name: "open policy always allows",
guildAllowlisted: false, input: {
channelAllowlistConfigured: false, groupPolicy: "open" as const,
channelAllowed: false, guildAllowlisted: false,
}), channelAllowlistConfigured: false,
).toBe(true); 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", () => { for (const testCase of cases) {
expect( expect(isDiscordGroupAllowedByPolicy(testCase.input), testCase.name).toBe(testCase.expected);
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);
}); });
}); });
@@ -596,48 +581,45 @@ describe("discord group DM gating", () => {
}); });
describe("discord reply target selection", () => { describe("discord reply target selection", () => {
it("skips replies when mode is off", () => { it("handles off/first/all reply modes", () => {
expect( const cases = [
resolveDiscordReplyTarget({ { name: "off mode", replyToMode: "off" as const, hasReplied: false, expected: undefined },
replyToMode: "off", {
replyToId: "123", name: "first mode before reply",
replyToMode: "first" as const,
hasReplied: false, hasReplied: false,
}), expected: "123",
).toBeUndefined(); },
}); {
name: "first mode after reply",
it("replies only once when mode is first", () => { replyToMode: "first" as const,
expect(
resolveDiscordReplyTarget({
replyToMode: "first",
replyToId: "123",
hasReplied: false,
}),
).toBe("123");
expect(
resolveDiscordReplyTarget({
replyToMode: "first",
replyToId: "123",
hasReplied: true, hasReplied: true,
}), expected: undefined,
).toBeUndefined(); },
}); {
name: "all mode before reply",
it("replies on every message when mode is all", () => { replyToMode: "all" as const,
expect(
resolveDiscordReplyTarget({
replyToMode: "all",
replyToId: "123",
hasReplied: false, hasReplied: false,
}), expected: "123",
).toBe("123"); },
expect( {
resolveDiscordReplyTarget({ name: "all mode after reply",
replyToMode: "all", replyToMode: "all" as const,
replyToId: "123",
hasReplied: true, hasReplied: true,
}), expected: "123",
).toBe("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", () => { describe("discord reaction notification gating", () => {
it("defaults to own when mode is unset", () => { it("applies mode-specific reaction notification rules", () => {
expect( const cases = [
shouldEmitDiscordReactionNotification({ {
mode: undefined, name: "unset defaults to own (author is bot)",
botId: "bot-1", input: {
messageAuthorId: "bot-1", mode: undefined,
userId: "user-1", botId: "bot-1",
}), messageAuthorId: "bot-1",
).toBe(true); userId: "user-1",
expect( },
shouldEmitDiscordReactionNotification({ expected: true,
mode: undefined, },
botId: "bot-1", {
messageAuthorId: "user-1", name: "unset defaults to own (author is not bot)",
userId: "user-2", input: {
}), mode: undefined,
).toBe(false); 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", () => { for (const testCase of cases) {
expect( expect(shouldEmitDiscordReactionNotification(testCase.input), testCase.name).toBe(
shouldEmitDiscordReactionNotification({ testCase.expected,
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);
}); });
}); });
@@ -858,37 +852,37 @@ function makeReactionListenerParams(overrides?: {
} }
describe("discord DM reaction handling", () => { describe("discord DM reaction handling", () => {
it("processes DM reactions instead of dropping them", async () => { it("processes DM reactions with or without guild allowlists", async () => {
enqueueSystemEventSpy.mockClear(); const cases = [
resolveAgentRouteMock.mockClear(); { name: "no guild allowlist", guildEntries: undefined },
{
name: "guild allowlist configured",
guildEntries: makeEntries({
"guild-123": { slug: "guild-123" },
}),
},
] as const;
const data = makeReactionEvent({ botAsAuthor: true }); for (const testCase of cases) {
const client = makeReactionClient({ channelType: ChannelType.DM }); enqueueSystemEventSpy.mockClear();
const listener = new DiscordReactionListener(makeReactionListenerParams()); 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(); await listener.handle(data, client);
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");
});
it("does not drop DM reactions when guild allowlist is configured", async () => { expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledOnce();
enqueueSystemEventSpy.mockClear(); const [text, opts] = enqueueSystemEventSpy.mock.calls[0];
resolveAgentRouteMock.mockClear(); expect(text, testCase.name).toContain("Discord reaction added");
expect(text, testCase.name).toContain("👍");
const data = makeReactionEvent({ botAsAuthor: true }); expect(text, testCase.name).toContain("dm");
const client = makeReactionClient({ channelType: ChannelType.DM }); expect(text, testCase.name).not.toContain("undefined");
const guildEntries = makeEntries({ expect(opts.sessionKey, testCase.name).toBe("discord:acc-1:dm:user-1");
"guild-123": { slug: "guild-123" }, }
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
await listener.handle(data, client);
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
}); });
it("still processes guild reactions (no regression)", async () => { it("still processes guild reactions (no regression)", async () => {
@@ -916,22 +910,6 @@ describe("discord DM reaction handling", () => {
expect(text).toContain("Discord reaction added"); 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 () => { it("routes DM reactions with peer kind 'direct' and user id", async () => {
enqueueSystemEventSpy.mockClear(); enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear(); resolveAgentRouteMock.mockClear();
@@ -977,111 +955,102 @@ describe("discord reaction notification modes", () => {
const guildId = "guild-900"; const guildId = "guild-900";
const guild = fakeGuild(guildId, "Mode Guild"); const guild = fakeGuild(guildId, "Mode Guild");
it("skips message fetch when mode is off", async () => { it("applies message-fetch behavior across notification modes and channel types", async () => {
enqueueSystemEventSpy.mockClear(); const cases = [
resolveAgentRouteMock.mockClear(); {
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 () => ({ for (const testCase of cases) {
author: { id: "bot-1", username: "bot", discriminator: "0" }, enqueueSystemEventSpy.mockClear();
})); resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ guildId, guild, messageFetch });
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const guildEntries = makeEntries({
[guildId]: { reactionNotifications: "off" },
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
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(); await listener.handle(data, client);
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("skips message fetch when mode is all", async () => { expect(messageFetch, testCase.name).toHaveBeenCalledTimes(testCase.expectedMessageFetchCalls);
enqueueSystemEventSpy.mockClear(); expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledTimes(
resolveAgentRouteMock.mockClear(); testCase.expectedEnqueueCalls,
);
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();
}); });
}); });

View File

@@ -1,7 +1,7 @@
import type { Client } from "@buape/carbon"; import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10"; 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 { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import { import {
dispatchMock, dispatchMock,
@@ -64,6 +64,12 @@ beforeEach(() => {
const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000;
type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; 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() { function makeRuntime() {
return { return {
@@ -76,7 +82,6 @@ function makeRuntime() {
} }
async function createHandler(cfg: LoadedConfig) { async function createHandler(cfg: LoadedConfig) {
const { createDiscordMessageHandler } = await import("./monitor.js");
return createDiscordMessageHandler({ return createDiscordMessageHandler({
cfg, cfg,
discordConfig: cfg.channels?.discord, discordConfig: cfg.channels?.discord,
@@ -267,7 +272,6 @@ describe("discord tool result dispatch", () => {
"skips tool results for native slash commands", "skips tool results for native slash commands",
{ timeout: MENTION_PATTERNS_TEST_TIMEOUT_MS }, { timeout: MENTION_PATTERNS_TEST_TIMEOUT_MS },
async () => { async () => {
const { createDiscordNativeCommand } = await import("./monitor.js");
const cfg = { const cfg = {
agents: { agents: {
defaults: { defaults: {

View File

@@ -204,42 +204,50 @@ describe("roundtrip encoding", () => {
// ─── extractDiscordChannelId ────────────────────────────────────────────────── // ─── extractDiscordChannelId ──────────────────────────────────────────────────
describe("extractDiscordChannelId", () => { describe("extractDiscordChannelId", () => {
it("extracts channel ID from standard session key", () => { it("extracts channel IDs and rejects invalid session key inputs", () => {
expect(extractDiscordChannelId("agent:main:discord:channel:123456789")).toBe("123456789"); 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", () => { for (const testCase of cases) {
expect(extractDiscordChannelId("agent:test-agent:discord:channel:999888777")).toBe("999888777"); expect(extractDiscordChannelId(testCase.input), testCase.name).toBe(testCase.expected);
}); }
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",
);
}); });
}); });
@@ -353,19 +361,29 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => {
// ─── DiscordExecApprovalHandler.getApprovers ────────────────────────────────── // ─── DiscordExecApprovalHandler.getApprovers ──────────────────────────────────
describe("DiscordExecApprovalHandler.getApprovers", () => { describe("DiscordExecApprovalHandler.getApprovers", () => {
it("returns configured approvers", () => { it("returns approvers for configured, empty, and undefined lists", () => {
const handler = createHandler({ enabled: true, approvers: ["111", "222"] }); const cases = [
expect(handler.getApprovers()).toEqual(["111", "222"]); {
}); 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", () => { for (const testCase of cases) {
const handler = createHandler({ enabled: true, approvers: [] }); const handler = createHandler(testCase.config);
expect(handler.getApprovers()).toEqual([]); expect(handler.getApprovers(), testCase.name).toEqual(testCase.expected);
}); }
it("returns empty array when approvers is undefined", () => {
const handler = createHandler({ enabled: true } as DiscordExecApprovalConfig);
expect(handler.getApprovers()).toEqual([]);
}); });
}); });
@@ -530,44 +548,46 @@ describe("DiscordExecApprovalHandler target config", () => {
mockRestDelete.mockReset(); mockRestDelete.mockReset();
}); });
it("defaults target to dm when not specified", () => { it("accepts all target modes and defaults to dm when target is omitted", () => {
const config: DiscordExecApprovalConfig = { const cases = [
enabled: true, {
approvers: ["123"], name: "default target",
}; config: { enabled: true, approvers: ["123"] } as DiscordExecApprovalConfig,
// target should be undefined, handler defaults to "dm" expectedTarget: undefined,
expect(config.target).toBeUndefined(); },
{
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); for (const testCase of cases) {
// Handler should still handle requests (no crash on missing target) if ("expectedTarget" in testCase) {
expect(handler.shouldHandle(createRequest())).toBe(true); expect(testCase.config.target, testCase.name).toBe(testCase.expectedTarget);
}); }
const handler = createHandler(testCase.config);
it("accepts target=channel in config", () => { expect(handler.shouldHandle(createRequest()), testCase.name).toBe(true);
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);
}); });
}); });

View File

@@ -631,105 +631,133 @@ describe("resolveDiscordPresenceUpdate", () => {
}); });
describe("resolveDiscordAutoThreadContext", () => { describe("resolveDiscordAutoThreadContext", () => {
it("returns null when no createdThreadId", () => { it("returns null without a created thread and re-keys context when present", () => {
expect( const cases = [
resolveDiscordAutoThreadContext({ {
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", agentId: "agent",
channel: "discord", channel: "discord",
messageChannelId: "parent", messageChannelId: "parent",
createdThreadId: undefined, createdThreadId: testCase.createdThreadId,
}), });
).toBeNull();
});
it("re-keys session context to the created thread", () => { if (testCase.expectedNull) {
const context = resolveDiscordAutoThreadContext({ expect(context, testCase.name).toBeNull();
agentId: "agent", continue;
channel: "discord", }
messageChannelId: "parent",
createdThreadId: "thread", expect(context, testCase.name).not.toBeNull();
}); expect(context?.To, testCase.name).toBe("channel:thread");
expect(context).not.toBeNull(); expect(context?.From, testCase.name).toBe("discord:channel:thread");
expect(context?.To).toBe("channel:thread"); expect(context?.OriginatingTo, testCase.name).toBe("channel:thread");
expect(context?.From).toBe("discord:channel:thread"); expect(context?.SessionKey, testCase.name).toBe(
expect(context?.OriginatingTo).toBe("channel:thread"); buildAgentSessionKey({
expect(context?.SessionKey).toBe( agentId: "agent",
buildAgentSessionKey({ channel: "discord",
agentId: "agent", peer: { kind: "channel", id: "thread" },
channel: "discord", }),
peer: { kind: "channel", id: "thread" }, );
}), expect(context?.ParentSessionKey, testCase.name).toBe(
); buildAgentSessionKey({
expect(context?.ParentSessionKey).toBe( agentId: "agent",
buildAgentSessionKey({ channel: "discord",
agentId: "agent", peer: { kind: "channel", id: "parent" },
channel: "discord", }),
peer: { kind: "channel", id: "parent" }, );
}), }
);
}); });
}); });
describe("resolveDiscordReplyDeliveryPlan", () => { describe("resolveDiscordReplyDeliveryPlan", () => {
it("uses reply references when posting to the original target", () => { it("applies delivery targets and reply reference behavior across thread modes", () => {
const plan = resolveDiscordReplyDeliveryPlan({ const cases = [
replyTarget: "channel:parent", {
replyToMode: "all", name: "original target with reply references",
messageId: "m1", input: {
threadChannel: null, replyTarget: "channel:parent" as const,
createdThreadId: null, replyToMode: "all" as const,
}); messageId: "m1",
expect(plan.deliverTarget).toBe("channel:parent"); threadChannel: null,
expect(plan.replyTarget).toBe("channel:parent"); createdThreadId: null,
expect(plan.replyReference.use()).toBe("m1"); },
}); 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", () => { for (const testCase of cases) {
const plan = resolveDiscordReplyDeliveryPlan({ const plan = resolveDiscordReplyDeliveryPlan(testCase.input);
replyTarget: "channel:parent", expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget);
replyToMode: "all", expect(plan.replyTarget, testCase.name).toBe(testCase.expectedReplyTarget);
messageId: "m1", for (const expected of testCase.expectedReplyReferenceCalls) {
threadChannel: null, expect(plan.replyReference.use(), testCase.name).toBe(expected);
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();
}); });
}); });
@@ -751,34 +779,35 @@ describe("maybeCreateDiscordAutoThread", () => {
}; };
} }
it("returns existing thread ID when creation fails due to race condition", async () => { it("handles create-thread failures with and without an existing thread", async () => {
const client = { const cases = [
rest: { {
post: async () => { name: "race condition returns existing thread",
throw new Error("A thread has already been created on this message"); postError: "A thread has already been created on this message",
}, getResponse: { thread: { id: "existing-thread" } },
get: async () => ({ thread: { id: "existing-thread" } }), expected: "existing-thread",
}, },
} as unknown as Client; {
name: "other error returns undefined",
const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client)); postError: "Some other error",
getResponse: { thread: null },
expect(result).toBe("existing-thread"); expected: undefined,
});
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 }),
}, },
} 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 () => { it("applies auto-thread reply planning across created, existing, and disabled modes", async () => {
const plan = await resolveDiscordAutoThreadReplyPlan(createAutoThreadPlanParams()); const cases = [
expect(plan.deliverTarget).toBe("channel:thread"); {
expect(plan.replyReference.use()).toBeUndefined(); name: "created thread",
expect(plan.autoThreadContext?.SessionKey).toBe( params: undefined,
buildAgentSessionKey({ expectedDeliverTarget: "channel:thread",
agentId: "agent", expectedReplyReference: undefined,
channel: "discord", expectedSessionKey: buildAgentSessionKey({
peer: { kind: "channel", id: "thread" }, 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 () => { for (const testCase of cases) {
const plan = await resolveDiscordAutoThreadReplyPlan( const plan = await resolveDiscordAutoThreadReplyPlan(
createAutoThreadPlanParams({ createAutoThreadPlanParams(testCase.params),
threadChannel: { id: "thread" }, );
}), expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget);
); expect(plan.replyReference.use(), testCase.name).toBe(testCase.expectedReplyReference);
expect(plan.deliverTarget).toBe("channel:thread"); if (testCase.expectedSessionKey == null) {
expect(plan.replyTarget).toBe("channel:thread"); expect(plan.autoThreadContext, testCase.name).toBeNull();
expect(plan.replyReference.use()).toBe("m1"); } else {
expect(plan.autoThreadContext).toBeNull(); expect(plan.autoThreadContext?.SessionKey, testCase.name).toBe(testCase.expectedSessionKey);
}); }
}
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();
}); });
}); });

View File

@@ -77,8 +77,8 @@ Table 2:
}); });
describe("extractCodeBlocks", () => { describe("extractCodeBlocks", () => {
it("extracts a code block with language", () => { it("extracts code blocks across language/no-language/multiple variants", () => {
const text = `Here is some code: const withLanguage = `Here is some code:
\`\`\`javascript \`\`\`javascript
const x = 1; const x = 1;
@@ -86,31 +86,23 @@ console.log(x);
\`\`\` \`\`\`
And more text.`; 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); const withoutLanguage = `\`\`\`
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 = `\`\`\`
plain code 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); const multiple = `\`\`\`python
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0].language).toBeUndefined();
expect(codeBlocks[0].code).toBe("plain code");
});
it("extracts multiple code blocks", () => {
const text = `\`\`\`python
print("hello") print("hello")
\`\`\` \`\`\`
@@ -119,12 +111,10 @@ Some text
\`\`\`bash \`\`\`bash
echo "world" echo "world"
\`\`\``; \`\`\``;
const multipleResult = extractCodeBlocks(multiple);
const { codeBlocks } = extractCodeBlocks(text); expect(multipleResult.codeBlocks).toHaveLength(2);
expect(multipleResult.codeBlocks[0].language).toBe("python");
expect(codeBlocks).toHaveLength(2); expect(multipleResult.codeBlocks[1].language).toBe("bash");
expect(codeBlocks[0].language).toBe("python");
expect(codeBlocks[1].language).toBe("bash");
}); });
}); });
@@ -142,27 +132,20 @@ describe("extractLinks", () => {
}); });
describe("stripMarkdown", () => { describe("stripMarkdown", () => {
it("strips bold markers", () => { it("strips inline markdown marker variants", () => {
expect(stripMarkdown("This is **bold** text")).toBe("This is bold text"); const cases = [
expect(stripMarkdown("This is __bold__ text")).toBe("This is bold text"); ["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"],
it("strips italic markers", () => { ["strips italic _", "This is _italic_ text", "This is italic text"],
expect(stripMarkdown("This is *italic* text")).toBe("This is italic text"); ["strips strikethrough", "This is ~~deleted~~ text", "This is deleted text"],
expect(stripMarkdown("This is _italic_ text")).toBe("This is italic 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"],
it("strips strikethrough markers", () => { ] as const;
expect(stripMarkdown("This is ~~deleted~~ text")).toBe("This is deleted text"); for (const [name, input, expected] of cases) {
}); expect(stripMarkdown(input), name).toBe(expected);
}
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("handles complex markdown", () => { it("handles complex markdown", () => {

View File

@@ -9,18 +9,19 @@ import {
} from "./rich-menu.js"; } from "./rich-menu.js";
describe("messageAction", () => { describe("messageAction", () => {
it("creates a message action", () => { it("creates message actions with explicit or default text", () => {
const action = messageAction("Help", "/help"); const cases = [
{ name: "explicit text", label: "Help", text: "/help", expectedText: "/help" },
expect(action.type).toBe("message"); { name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" },
expect(action.label).toBe("Help"); ] as const;
expect((action as { text: string }).text).toBe("/help"); for (const testCase of cases) {
}); const action = testCase.text
? messageAction(testCase.label, testCase.text)
it("uses label as text when text not provided", () => { : messageAction(testCase.label);
const action = messageAction("Click"); expect(action.type, testCase.name).toBe("message");
expect(action.label, testCase.name).toBe(testCase.label);
expect((action as { text: string }).text).toBe("Click"); 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"); expect((action as { displayText: string }).displayText).toBe("Selected item 1");
}); });
it("truncates data to 300 characters", () => { it("applies postback payload truncation and displayText behavior", () => {
const longData = "x".repeat(400); const truncatedData = postbackAction("Test", "x".repeat(400));
const action = postbackAction("Test", longData); 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 noDisplayText = postbackAction("Test", "data");
const longText = "y".repeat(400); expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined();
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();
}); });
}); });
describe("datetimePickerAction", () => { describe("datetimePickerAction", () => {
it("creates a date picker action", () => { it("creates picker actions for all supported modes", () => {
const action = datetimePickerAction("Pick date", "date_picked", "date"); const cases = [
{ label: "Pick date", data: "date_picked", mode: "date" as const },
expect(action.type).toBe("datetimepicker"); { label: "Pick time", data: "time_picked", mode: "time" as const },
expect(action.label).toBe("Pick date"); { label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const },
expect((action as { mode: string }).mode).toBe("date"); ];
expect((action as { data: string }).data).toBe("date_picked"); for (const testCase of cases) {
}); const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode);
expect(action.type).toBe("datetimepicker");
it("creates a time picker action", () => { expect(action.label).toBe(testCase.label);
const action = datetimePickerAction("Pick time", "time_picked", "time"); expect((action as { mode: string }).mode).toBe(testCase.mode);
expect((action as { data: string }).data).toBe(testCase.data);
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("includes initial/min/max when provided", () => { 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 actions = createSixSimpleActions();
const cases = [
const areas = createGridLayout(1686, actions); { height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 },
{ height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 },
expect(areas.length).toBe(6); ] as const;
for (const testCase of cases) {
// Check first row positions const areas = createGridLayout(testCase.height, actions);
expect(areas[0].bounds.x).toBe(0); expect(areas.length).toBe(6);
expect(areas[0].bounds.y).toBe(0); expect(areas[0]?.bounds.y).toBe(testCase.firstRowY);
expect(areas[1].bounds.x).toBe(833); expect(areas[0]?.bounds.height).toBe(testCase.rowHeight);
expect(areas[1].bounds.y).toBe(0); expect(areas[3]?.bounds.y).toBe(testCase.secondRowY);
expect(areas[2].bounds.x).toBe(1666); expect(areas[0]?.bounds.x).toBe(0);
expect(areas[2].bounds.y).toBe(0); expect(areas[1]?.bounds.x).toBe(833);
expect(areas[2]?.bounds.x).toBe(1666);
// 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);
}); });
it("assigns correct actions to areas", () => { 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(); const config = createDefaultMenuConfig();
for (const area of config.areas) { for (const area of config.areas) {
expect(area.action.type).toBe("message"); 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); const commands = config.areas.map((a) => (a.action as { text: string }).text);
expect(commands).toContain("/help"); expect(commands).toContain("/help");
expect(commands).toContain("/status"); expect(commands).toContain("/status");

View File

@@ -2,24 +2,27 @@ import { describe, expect, it } from "vitest";
import { markdownToWhatsApp } from "./whatsapp.js"; import { markdownToWhatsApp } from "./whatsapp.js";
describe("markdownToWhatsApp", () => { describe("markdownToWhatsApp", () => {
it("converts **bold** to *bold*", () => { it("handles common markdown-to-whatsapp conversions", () => {
expect(markdownToWhatsApp("**SOD Blast:**")).toBe("*SOD Blast:*"); const cases = [
}); ["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"],
["converts __bold__ to *bold*", "__important__", "*important*"],
it("converts __bold__ to *bold*", () => { ["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"],
expect(markdownToWhatsApp("__important__")).toBe("*important*"); ["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"],
it("converts ~~strikethrough~~ to ~strikethrough~", () => { [
expect(markdownToWhatsApp("~~deleted~~")).toBe("~deleted~"); "handles mixed formatting",
}); "**bold** and ~~strike~~ and _italic_",
"*bold* and ~strike~ and _italic_",
it("leaves single *italic* unchanged (already WhatsApp bold)", () => { ],
expect(markdownToWhatsApp("*text*")).toBe("*text*"); ["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"],
it("leaves _italic_ unchanged (already WhatsApp italic)", () => { ["handles bold inside a sentence", "This is **very** important", "This is *very* important"],
expect(markdownToWhatsApp("_text_")).toBe("_text_"); ] as const;
for (const [name, input, expected] of cases) {
expect(markdownToWhatsApp(input), name).toBe(expected);
}
}); });
it("preserves fenced code blocks", () => { it("preserves fenced code blocks", () => {
@@ -27,32 +30,6 @@ describe("markdownToWhatsApp", () => {
expect(markdownToWhatsApp(input)).toBe(input); 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", () => { it("preserves code block with formatting inside", () => {
const input = "Before ```**bold** and ~~strike~~``` after **real bold**"; const input = "Before ```**bold** and ~~strike~~``` after **real bold**";
expect(markdownToWhatsApp(input)).toBe( expect(markdownToWhatsApp(input)).toBe(

View File

@@ -70,47 +70,38 @@ describe("rejectNonPostWebhookRequest", () => {
}); });
describe("resolveSingleWebhookTarget", () => { describe("resolveSingleWebhookTarget", () => {
it("returns none when no target matches", () => { const resolvers: Array<{
const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "c"); name: string;
run: (
targets: readonly string[],
isMatch: (value: string) => boolean | Promise<boolean>,
) => 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" }); expect(result).toEqual({ kind: "none" });
}); });
it("returns the single match", () => { it.each(resolvers)("returns the single match ($name)", async ({ run }) => {
const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "b"); const result = await run(["a", "b"], (value) => value === "b");
expect(result).toEqual({ kind: "single", target: "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 calls: string[] = [];
const result = resolveSingleWebhookTarget(["a", "b", "c"], (value) => { const result = await run(["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) => {
calls.push(value); calls.push(value);
return value === "a" || value === "b"; return value === "a" || value === "b";
}); });

View File

@@ -3,40 +3,22 @@ import { markdownToSignalText } from "./format.js";
describe("markdownToSignalText", () => { describe("markdownToSignalText", () => {
describe("duplicate URL display", () => { describe("duplicate URL display", () => {
it("does not duplicate URL when label matches URL without protocol", () => { it("does not duplicate URL for normalized equivalent labels", () => {
// [selfh.st](http://selfh.st) should render as "selfh.st" not "selfh.st (http://selfh.st)" const equivalentCases = [
const res = markdownToSignalText("[selfh.st](http://selfh.st)"); { input: "[selfh.st](http://selfh.st)", expected: "selfh.st" },
expect(res.text).toBe("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", () => { for (const { input, expected } of equivalentCases) {
const res = markdownToSignalText("[example.com](https://example.com)"); const res = markdownToSignalText(input);
expect(res.text).toBe("example.com"); expect(res.text).toBe(expected);
}); }
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");
}); });
it("still shows URL when label is meaningfully different", () => { it("still shows URL when label is meaningfully different", () => {
@@ -49,10 +31,5 @@ describe("markdownToSignalText", () => {
const res = markdownToSignalText("[example.com](https://example.com/page)"); const res = markdownToSignalText("[example.com](https://example.com/page)");
expect(res.text).toBe("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");
});
}); });
}); });

View File

@@ -15,10 +15,9 @@ const { monitorSignalProvider } = await import("./monitor.js");
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } = const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
getSignalToolResultTestMocks(); getSignalToolResultTestMocks();
async function runMonitorWithMocks( type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
opts: Parameters<(typeof import("./monitor.js"))["monitorSignalProvider"]>[0],
) { async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
const { monitorSignalProvider } = await import("./monitor.js");
return monitorSignalProvider(opts); return monitorSignalProvider(opts);
} }
describe("monitorSignalProvider tool results", () => { describe("monitorSignalProvider tool results", () => {

View File

@@ -14,7 +14,7 @@ import {
installSignalToolResultTestHooks(); installSignalToolResultTestHooks();
// Import after the harness registers `vi.mock(...)` for Signal internals. // Import after the harness registers `vi.mock(...)` for Signal internals.
await import("./monitor.js"); const { monitorSignalProvider } = await import("./monitor.js");
const { const {
replyMock, replyMock,
@@ -26,6 +26,7 @@ const {
} = getSignalToolResultTestMocks(); } = getSignalToolResultTestMocks();
const SIGNAL_BASE_URL = "http://127.0.0.1:8080"; const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
function createMonitorRuntime() { function createMonitorRuntime() {
return { return {
@@ -69,16 +70,13 @@ function createAutoAbortController() {
return abortController; return abortController;
} }
async function runMonitorWithMocks( async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
opts: Parameters<(typeof import("./monitor.js"))["monitorSignalProvider"]>[0],
) {
const { monitorSignalProvider } = await import("./monitor.js");
return monitorSignalProvider(opts); return monitorSignalProvider(opts);
} }
async function receiveSignalPayloads(params: { async function receiveSignalPayloads(params: {
payloads: unknown[]; payloads: unknown[];
opts?: Partial<Parameters<(typeof import("./monitor.js"))["monitorSignalProvider"]>[0]>; opts?: Partial<MonitorSignalProviderOptions>;
}) { }) {
const abortController = new AbortController(); const abortController = new AbortController();
streamMock.mockImplementation(async ({ onEvent }) => { streamMock.mockImplementation(async ({ onEvent }) => {
@@ -122,7 +120,7 @@ function makeBaseEnvelope(overrides: Record<string, unknown> = {}) {
async function receiveSingleEnvelope( async function receiveSingleEnvelope(
envelope: Record<string, unknown>, envelope: Record<string, unknown>,
opts?: Partial<Parameters<(typeof import("./monitor.js"))["monitorSignalProvider"]>[0]>, opts?: Partial<MonitorSignalProviderOptions>,
) { ) {
await receiveSignalPayloads({ await receiveSignalPayloads({
payloads: [{ envelope }], payloads: [{ envelope }],

View File

@@ -2,84 +2,44 @@ import { describe, expect, it } from "vitest";
import { markdownToSlackMrkdwn } from "./format.js"; import { markdownToSlackMrkdwn } from "./format.js";
describe("markdownToSlackMrkdwn", () => { describe("markdownToSlackMrkdwn", () => {
it("converts bold from double asterisks to single", () => { it("handles core markdown formatting conversions", () => {
const res = markdownToSlackMrkdwn("**bold text**"); const cases = [
expect(res).toBe("*bold text*"); ["converts bold from double asterisks to single", "**bold text**", "*bold text*"],
}); ["preserves italic underscore format", "_italic text_", "_italic text_"],
[
it("preserves italic underscore format", () => { "converts strikethrough from double tilde to single",
const res = markdownToSlackMrkdwn("_italic text_"); "~~strikethrough~~",
expect(res).toBe("_italic text_"); "~strikethrough~",
}); ],
[
it("converts strikethrough from double tilde to single", () => { "renders basic inline formatting together",
const res = markdownToSlackMrkdwn("~~strikethrough~~"); "hi _there_ **boss** `code`",
expect(res).toBe("~strikethrough~"); "hi _there_ *boss* `code`",
}); ],
["renders inline code", "use `npm install`", "use `npm install`"],
it("renders basic inline formatting together", () => { ["renders fenced code blocks", "```js\nconst x = 1;\n```", "```\nconst x = 1;\n```"],
const res = markdownToSlackMrkdwn("hi _there_ **boss** `code`"); [
expect(res).toBe("hi _there_ *boss* `code`"); "renders links with Slack mrkdwn syntax",
}); "see [docs](https://example.com)",
"see <https://example.com|docs>",
it("renders inline code", () => { ],
const res = markdownToSlackMrkdwn("use `npm install`"); ["does not duplicate bare URLs", "see https://example.com", "see https://example.com"],
expect(res).toBe("use `npm install`"); ["escapes unsafe characters", "a & b < c > d", "a &amp; b &lt; c &gt; d"],
}); [
"preserves Slack angle-bracket markup (mentions/links)",
it("renders fenced code blocks", () => { "hi <@U123> see <https://example.com|docs> and <!here>",
const res = markdownToSlackMrkdwn("```js\nconst x = 1;\n```"); "hi <@U123> see <https://example.com|docs> and <!here>",
expect(res).toBe("```\nconst x = 1;\n```"); ],
}); ["escapes raw HTML", "<b>nope</b>", "&lt;b&gt;nope&lt;/b&gt;"],
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
it("renders links with Slack mrkdwn syntax", () => { ["renders bullet lists", "- one\n- two", "• one\n• two"],
const res = markdownToSlackMrkdwn("see [docs](https://example.com)"); ["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"],
expect(res).toBe("see <https://example.com|docs>"); ["renders headings as bold text", "# Title", "*Title*"],
}); ["renders blockquotes", "> Quote", "> Quote"],
] as const;
it("does not duplicate bare URLs", () => { for (const [name, input, expected] of cases) {
const res = markdownToSlackMrkdwn("see https://example.com"); expect(markdownToSlackMrkdwn(input), name).toBe(expected);
expect(res).toBe("see https://example.com"); }
});
it("escapes unsafe characters", () => {
const res = markdownToSlackMrkdwn("a & b < c > d");
expect(res).toBe("a &amp; b &lt; c &gt; d");
});
it("preserves Slack angle-bracket markup (mentions/links)", () => {
const res = markdownToSlackMrkdwn("hi <@U123> see <https://example.com|docs> and <!here>");
expect(res).toBe("hi <@U123> see <https://example.com|docs> and <!here>");
});
it("escapes raw HTML", () => {
const res = markdownToSlackMrkdwn("<b>nope</b>");
expect(res).toBe("&lt;b&gt;nope&lt;/b&gt;");
});
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 nested list items", () => { it("handles nested list items", () => {

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -10,99 +10,89 @@ import {
} from "./model-buttons.js"; } from "./model-buttons.js";
describe("parseModelCallbackData", () => { describe("parseModelCallbackData", () => {
it("parses mdl_prov callback", () => { it("parses supported callback variants", () => {
const result = parseModelCallbackData("mdl_prov"); const cases = [
expect(result).toEqual({ type: "providers" }); ["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", () => { it("returns null for unsupported callback variants", () => {
const result = parseModelCallbackData("mdl_back"); const invalid = [
expect(result).toEqual({ type: "back" }); "commands_page_1",
}); "other_callback",
"",
it("parses mdl_list callback with provider and page", () => { "mdl_invalid",
const result = parseModelCallbackData("mdl_list_anthropic_2"); "mdl_list_",
expect(result).toEqual({ type: "list", provider: "anthropic", page: 2 }); "mdl_sel_noslash",
}); ];
for (const input of invalid) {
it("parses mdl_list callback with hyphenated provider", () => { expect(parseModelCallbackData(input), input).toBeNull();
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" });
}); });
}); });
describe("buildProviderKeyboard", () => { describe("buildProviderKeyboard", () => {
it("returns empty array for no providers", () => { it("lays out providers in two-column rows", () => {
const result = buildProviderKeyboard([]); const cases = [
expect(result).toEqual([]); {
}); name: "empty input",
input: [],
expected: [],
},
{
name: "single provider",
input: [{ id: "anthropic", count: 5 }],
expected: [[{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" }]],
},
{
name: "exactly one full row",
input: [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
],
expected: [
[
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
],
],
},
{
name: "wraps overflow to second row",
input: [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
{ id: "google", count: 3 },
],
expected: [
[
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
],
[{ text: "google (3)", callback_data: "mdl_list_google_1" }],
],
},
] as const satisfies Array<{
name: string;
input: ProviderInfo[];
expected: ReturnType<typeof buildProviderKeyboard>;
}>;
it("builds single provider as one row", () => { for (const testCase of cases) {
const providers: ProviderInfo[] = [{ id: "anthropic", count: 5 }]; expect(buildProviderKeyboard(testCase.input), testCase.name).toEqual(testCase.expected);
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)");
}); });
}); });
@@ -119,112 +109,105 @@ describe("buildModelsKeyboard", () => {
expect(result[0]?.[0]?.callback_data).toBe("mdl_back"); expect(result[0]?.[0]?.callback_data).toBe("mdl_back");
}); });
it("shows models with one per row", () => { it("renders model rows and optional current-model indicator", () => {
const result = buildModelsKeyboard({ const cases = [
provider: "anthropic", {
models: ["claude-sonnet-4", "claude-opus-4"], name: "no current model",
currentPage: 1, currentModel: undefined,
totalPages: 1, firstText: "claude-sonnet-4",
}); },
// 2 model rows + back button {
expect(result).toHaveLength(3); name: "current model marked",
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4"); currentModel: "anthropic/claude-sonnet-4",
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4"); firstText: "claude-sonnet-4",
expect(result[1]?.[0]?.text).toBe("claude-opus-4"); },
expect(result[2]?.[0]?.text).toBe("<< Back"); ] 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", () => { it("renders pagination controls for first, middle, and last pages", () => {
const result = buildModelsKeyboard({ const cases = [
provider: "anthropic", {
models: ["claude-sonnet-4", "claude-opus-4"], name: "first page",
currentModel: "anthropic/claude-sonnet-4", params: { currentPage: 1, models: ["model1", "model2"] },
currentPage: 1, expectedPagination: ["1/3", "Next ▶"],
totalPages: 1, },
}); {
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4 ✓"); name: "middle page",
expect(result[1]?.[0]?.text).toBe("claude-opus-4"); 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", () => { it("keeps short display IDs untouched and truncates overly long IDs", () => {
const result = buildModelsKeyboard({ const cases = [
provider: "anthropic", {
models: ["model1", "model2"], name: "max-length display",
currentPage: 1, provider: "anthropic",
totalPages: 3, model: "claude-3-5-sonnet-20241022-with-suffix",
pageSize: 2, expected: "claude-3-5-sonnet-20241022-with-suffix",
}); },
// 2 model rows + pagination row + back button {
expect(result).toHaveLength(4); name: "overly long display",
const paginationRow = result[2]; provider: "a",
expect(paginationRow).toHaveLength(2); // no prev on first page model: "this-model-name-is-long-enough-to-need-truncation-abcd",
expect(paginationRow?.[0]?.text).toBe("1/3"); startsWith: "…",
expect(paginationRow?.[1]?.text).toBe("Next ▶"); maxLength: 38,
}); },
] as const;
it("shows prev and next on middle pages", () => { for (const testCase of cases) {
// 6 models with pageSize 2 = 3 pages const result = buildModelsKeyboard({
const result = buildModelsKeyboard({ provider: testCase.provider,
provider: "anthropic", models: [testCase.model],
models: ["model1", "model2", "model3", "model4", "model5", "model6"], currentPage: 1,
currentPage: 2, totalPages: 1,
totalPages: 3, });
pageSize: 2, const text = result[0]?.[0]?.text;
}); if ("expected" in testCase) {
// 2 model rows + pagination row + back button expect(text, testCase.name).toBe(testCase.expected);
expect(result).toHaveLength(4); } else {
const paginationRow = result[2]; expect(text?.startsWith(testCase.startsWith), testCase.name).toBe(true);
expect(paginationRow).toHaveLength(3); expect(text?.length, testCase.name).toBeLessThanOrEqual(testCase.maxLength);
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);
}); });
}); });

View File

@@ -297,20 +297,6 @@ describe("sendMessageTelegram", () => {
}); });
}); });
it("wraps chat-not-found with actionable context", async () => {
const chatId = "123";
const err = new Error("400: Bad Request: chat not found");
const sendMessage = vi.fn().mockRejectedValue(err);
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expectChatNotFoundWithChatId(
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
chatId,
);
});
it("preserves thread params in plain text fallback", async () => { it("preserves thread params in plain text fallback", async () => {
const chatId = "-1001234567890"; const chatId = "-1001234567890";
const parseErr = new Error( 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 chatId = "123";
const text = "ignored caption context";
const sendVideoNote = vi.fn().mockResolvedValue({ {
message_id: 101, const text = "ignored caption context";
chat: { id: chatId }, const sendVideoNote = vi.fn().mockResolvedValue({
}); message_id: 101,
const sendMessage = vi.fn().mockResolvedValue({ chat: { id: chatId },
message_id: 102, });
chat: { id: chatId }, const sendMessage = vi.fn().mockResolvedValue({
}); message_id: 102,
const api = { sendVideoNote, sendMessage } as unknown as { chat: { id: chatId },
sendVideoNote: typeof sendVideoNote; });
sendMessage: typeof sendMessage; const api = { sendVideoNote, sendMessage } as unknown as {
}; sendVideoNote: typeof sendVideoNote;
sendMessage: typeof sendMessage;
};
loadWebMedia.mockResolvedValueOnce({ loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("fake-video"), buffer: Buffer.from("fake-video"),
contentType: "video/mp4", contentType: "video/mp4",
fileName: "video.mp4", fileName: "video.mp4",
}); });
const res = await sendMessageTelegram(chatId, text, { const res = await sendMessageTelegram(chatId, text, {
token: "tok", token: "tok",
api, api,
mediaUrl: "https://example.com/video.mp4", mediaUrl: "https://example.com/video.mp4",
asVideoNote: true, asVideoNote: true,
}); });
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {}); expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
expect(sendMessage).toHaveBeenCalledWith(chatId, text, { expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
parse_mode: "HTML", parse_mode: "HTML",
}); });
expect(res.messageId).toBe("102"); 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 chatId = "123";
const text = "my caption"; const cases = [
{
const sendVideo = vi.fn().mockResolvedValue({ text: "Check this out",
message_id: 201, options: {
chat: { id: chatId }, buttons: [[{ text: "Btn", callback_data: "dat" }]],
}); },
const api = { sendVideo } as unknown as { expectedVideoNote: {},
sendVideo: typeof sendVideo; expectedMessage: {
}; parse_mode: "HTML",
reply_markup: {
loadWebMedia.mockResolvedValueOnce({ inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]],
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" }]],
}, },
}); {
}); 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 () => { for (const testCase of cases) {
const chatId = "123"; const sendVideoNote = vi.fn().mockResolvedValue({
const text = "Threaded reply"; 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({ loadWebMedia.mockResolvedValueOnce({
message_id: 401, buffer: Buffer.from("fake-video"),
chat: { id: chatId }, contentType: "video/mp4",
}); fileName: "video.mp4",
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({ await sendMessageTelegram(chatId, testCase.text, {
buffer: Buffer.from("fake-video"), token: "tok",
contentType: "video/mp4", api,
fileName: "video.mp4", mediaUrl: "https://example.com/video.mp4",
}); asVideoNote: true,
...testCase.options,
});
await sendMessageTelegram(chatId, text, { expect(sendVideoNote).toHaveBeenCalledWith(
token: "tok", chatId,
api, expect.anything(),
mediaUrl: "https://example.com/video.mp4", testCase.expectedVideoNote,
asVideoNote: true, );
replyToMessageId: 999, expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, testCase.expectedMessage);
}); }
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {
reply_to_message_id: 999,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
parse_mode: "HTML",
reply_to_message_id: 999,
});
}); });
it("retries on transient errors with retry_after", async () => { it("retries on transient errors with retry_after", async () => {
@@ -847,171 +819,144 @@ describe("sendMessageTelegram", () => {
expect(sendAudio).not.toHaveBeenCalled(); expect(sendAudio).not.toHaveBeenCalled();
}); });
it("includes message_thread_id for forum topic messages", async () => { it("keeps message_thread_id for forum/private/group sends", async () => {
const chatId = "-1001234567890"; const cases = [
const sendMessage = vi.fn().mockResolvedValue({ {
message_id: 55, name: "forum topic",
chat: { id: chatId }, chatId: "-1001234567890",
}); text: "hello forum",
const api = { sendMessage } as unknown as { messageId: 55,
sendMessage: typeof sendMessage; },
}; {
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", { for (const testCase of cases) {
token: "tok", const sendMessage = vi.fn().mockResolvedValue({
api, message_id: testCase.messageId,
messageThreadId: 271, chat: { id: testCase.chatId },
});
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 },
}); });
const api = { sendMessage } as unknown as { const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage; sendMessage: typeof sendMessage;
}; };
const res = await sendMessageTelegram(chatId, "hello forum", { await sendMessageTelegram(testCase.chatId, testCase.text, {
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", {
token: "tok", token: "tok",
api, api,
messageThreadId: 271, messageThreadId: 271,
}), });
).rejects.toThrow(/chat not found/i);
expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage, testCase.name).toHaveBeenCalledWith(testCase.chatId, testCase.text, {
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", { parse_mode: "HTML",
parse_mode: "HTML", message_thread_id: 271,
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 () => { 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 () => { it("retries media sends without message_thread_id when thread is missing", async () => {
const chatId = "-100123"; const chatId = "-100123";
const threadErr = new Error("400: Bad Request: message thread not found"); const threadErr = new Error("400: Bad Request: message thread not found");
@@ -1224,42 +1147,6 @@ describe("sendStickerTelegram", () => {
expect(res.messageId).toBe("109"); 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 () => { it("trims whitespace from fileId", async () => {
const chatId = "123"; const chatId = "123";
const sendSticker = vi.fn().mockResolvedValue({ 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", () => { describe("editMessageTelegram", () => {
beforeEach(() => { beforeEach(() => {
botApi.editMessageText.mockReset(); botApi.editMessageText.mockReset();