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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,18 +9,19 @@ import {
} from "./rich-menu.js";
describe("messageAction", () => {
it("creates a message action", () => {
const action = messageAction("Help", "/help");
expect(action.type).toBe("message");
expect(action.label).toBe("Help");
expect((action as { text: string }).text).toBe("/help");
});
it("uses label as text when text not provided", () => {
const action = messageAction("Click");
expect((action as { text: string }).text).toBe("Click");
it("creates message actions with explicit or default text", () => {
const cases = [
{ name: "explicit text", label: "Help", text: "/help", expectedText: "/help" },
{ name: "defaults to label", label: "Click", text: undefined, expectedText: "Click" },
] as const;
for (const testCase of cases) {
const action = testCase.text
? messageAction(testCase.label, testCase.text)
: messageAction(testCase.label);
expect(action.type, testCase.name).toBe("message");
expect(action.label, testCase.name).toBe(testCase.label);
expect((action as { text: string }).text, testCase.name).toBe(testCase.expectedText);
}
});
});
@@ -61,47 +62,32 @@ describe("postbackAction", () => {
expect((action as { displayText: string }).displayText).toBe("Selected item 1");
});
it("truncates data to 300 characters", () => {
const longData = "x".repeat(400);
const action = postbackAction("Test", longData);
it("applies postback payload truncation and displayText behavior", () => {
const truncatedData = postbackAction("Test", "x".repeat(400));
expect((truncatedData as { data: string }).data.length).toBe(300);
expect((action as { data: string }).data.length).toBe(300);
});
const truncatedDisplay = postbackAction("Test", "data", "y".repeat(400));
expect((truncatedDisplay as { displayText: string }).displayText?.length).toBe(300);
it("truncates displayText to 300 characters", () => {
const longText = "y".repeat(400);
const action = postbackAction("Test", "data", longText);
expect((action as { displayText: string }).displayText?.length).toBe(300);
});
it("omits displayText when not provided", () => {
const action = postbackAction("Test", "data");
expect((action as { displayText?: string }).displayText).toBeUndefined();
const noDisplayText = postbackAction("Test", "data");
expect((noDisplayText as { displayText?: string }).displayText).toBeUndefined();
});
});
describe("datetimePickerAction", () => {
it("creates a date picker action", () => {
const action = datetimePickerAction("Pick date", "date_picked", "date");
expect(action.type).toBe("datetimepicker");
expect(action.label).toBe("Pick date");
expect((action as { mode: string }).mode).toBe("date");
expect((action as { data: string }).data).toBe("date_picked");
});
it("creates a time picker action", () => {
const action = datetimePickerAction("Pick time", "time_picked", "time");
expect((action as { mode: string }).mode).toBe("time");
});
it("creates a datetime picker action", () => {
const action = datetimePickerAction("Pick datetime", "datetime_picked", "datetime");
expect((action as { mode: string }).mode).toBe("datetime");
it("creates picker actions for all supported modes", () => {
const cases = [
{ label: "Pick date", data: "date_picked", mode: "date" as const },
{ label: "Pick time", data: "time_picked", mode: "time" as const },
{ label: "Pick datetime", data: "datetime_picked", mode: "datetime" as const },
];
for (const testCase of cases) {
const action = datetimePickerAction(testCase.label, testCase.data, testCase.mode);
expect(action.type).toBe("datetimepicker");
expect(action.label).toBe(testCase.label);
expect((action as { mode: string }).mode).toBe(testCase.mode);
expect((action as { data: string }).data).toBe(testCase.data);
}
});
it("includes initial/min/max when provided", () => {
@@ -136,37 +122,22 @@ describe("createGridLayout", () => {
];
}
it("creates a 2x3 grid layout for tall menu", () => {
it("computes expected 2x3 layout for supported menu heights", () => {
const actions = createSixSimpleActions();
const areas = createGridLayout(1686, actions);
expect(areas.length).toBe(6);
// Check first row positions
expect(areas[0].bounds.x).toBe(0);
expect(areas[0].bounds.y).toBe(0);
expect(areas[1].bounds.x).toBe(833);
expect(areas[1].bounds.y).toBe(0);
expect(areas[2].bounds.x).toBe(1666);
expect(areas[2].bounds.y).toBe(0);
// Check second row positions
expect(areas[3].bounds.y).toBe(843);
expect(areas[4].bounds.y).toBe(843);
expect(areas[5].bounds.y).toBe(843);
});
it("creates a 2x3 grid layout for short menu", () => {
const actions = createSixSimpleActions();
const areas = createGridLayout(843, actions);
expect(areas.length).toBe(6);
// Row height should be half of 843
expect(areas[0].bounds.height).toBe(421);
expect(areas[3].bounds.y).toBe(421);
const cases = [
{ height: 1686, firstRowY: 0, secondRowY: 843, rowHeight: 843 },
{ height: 843, firstRowY: 0, secondRowY: 421, rowHeight: 421 },
] as const;
for (const testCase of cases) {
const areas = createGridLayout(testCase.height, actions);
expect(areas.length).toBe(6);
expect(areas[0]?.bounds.y).toBe(testCase.firstRowY);
expect(areas[0]?.bounds.height).toBe(testCase.rowHeight);
expect(areas[3]?.bounds.y).toBe(testCase.secondRowY);
expect(areas[0]?.bounds.x).toBe(0);
expect(areas[1]?.bounds.x).toBe(833);
expect(areas[2]?.bounds.x).toBe(1666);
}
});
it("assigns correct actions to areas", () => {
@@ -222,17 +193,12 @@ describe("createDefaultMenuConfig", () => {
}
});
it("has message actions for all areas", () => {
it("uses message actions with expected default commands", () => {
const config = createDefaultMenuConfig();
for (const area of config.areas) {
expect(area.action.type).toBe("message");
}
});
it("has expected default commands", () => {
const config = createDefaultMenuConfig();
const commands = config.areas.map((a) => (a.action as { text: string }).text);
expect(commands).toContain("/help");
expect(commands).toContain("/status");

View File

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

View File

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

View File

@@ -3,40 +3,22 @@ import { markdownToSignalText } from "./format.js";
describe("markdownToSignalText", () => {
describe("duplicate URL display", () => {
it("does not duplicate URL when label matches URL without protocol", () => {
// [selfh.st](http://selfh.st) should render as "selfh.st" not "selfh.st (http://selfh.st)"
const res = markdownToSignalText("[selfh.st](http://selfh.st)");
expect(res.text).toBe("selfh.st");
});
it("does not duplicate URL for normalized equivalent labels", () => {
const equivalentCases = [
{ input: "[selfh.st](http://selfh.st)", expected: "selfh.st" },
{ input: "[example.com](https://example.com)", expected: "example.com" },
{ input: "[www.example.com](https://example.com)", expected: "www.example.com" },
{ input: "[example.com](https://example.com/)", expected: "example.com" },
{ input: "[example.com](https://example.com///)", expected: "example.com" },
{ input: "[example.com](https://www.example.com)", expected: "example.com" },
{ input: "[EXAMPLE.COM](https://example.com)", expected: "EXAMPLE.COM" },
{ input: "[example.com/page](https://example.com/page)", expected: "example.com/page" },
] as const;
it("does not duplicate URL when label matches URL without https protocol", () => {
const res = markdownToSignalText("[example.com](https://example.com)");
expect(res.text).toBe("example.com");
});
it("does not duplicate URL when label matches URL without www prefix", () => {
const res = markdownToSignalText("[www.example.com](https://example.com)");
expect(res.text).toBe("www.example.com");
});
it("does not duplicate URL when label matches URL without trailing slash", () => {
const res = markdownToSignalText("[example.com](https://example.com/)");
expect(res.text).toBe("example.com");
});
it("does not duplicate URL when label matches URL with multiple trailing slashes", () => {
const res = markdownToSignalText("[example.com](https://example.com///)");
expect(res.text).toBe("example.com");
});
it("does not duplicate URL when label includes www but URL does not", () => {
const res = markdownToSignalText("[example.com](https://www.example.com)");
expect(res.text).toBe("example.com");
});
it("handles case-insensitive domain comparison", () => {
const res = markdownToSignalText("[EXAMPLE.COM](https://example.com)");
expect(res.text).toBe("EXAMPLE.COM");
for (const { input, expected } of equivalentCases) {
const res = markdownToSignalText(input);
expect(res.text).toBe(expected);
}
});
it("still shows URL when label is meaningfully different", () => {
@@ -49,10 +31,5 @@ describe("markdownToSignalText", () => {
const res = markdownToSignalText("[example.com](https://example.com/page)");
expect(res.text).toBe("example.com (https://example.com/page)");
});
it("does not duplicate when label matches full URL with path", () => {
const res = markdownToSignalText("[example.com/page](https://example.com/page)");
expect(res.text).toBe("example.com/page");
});
});
});

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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