mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 18:01:23 +00:00
test: dedupe channel and transport adapters
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 & b < c > 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("<b>nope</b>");
|
||||
});
|
||||
|
||||
it("renders paragraphs with blank lines", () => {
|
||||
const res = markdownToSlackMrkdwn("first\n\nsecond");
|
||||
expect(res).toBe("first\n\nsecond");
|
||||
});
|
||||
|
||||
it("renders bullet lists", () => {
|
||||
const res = markdownToSlackMrkdwn("- one\n- two");
|
||||
expect(res).toBe("• one\n• two");
|
||||
});
|
||||
|
||||
it("renders ordered lists with numbering", () => {
|
||||
const res = markdownToSlackMrkdwn("2. two\n3. three");
|
||||
expect(res).toBe("2. two\n3. three");
|
||||
});
|
||||
|
||||
it("renders headings as bold text", () => {
|
||||
const res = markdownToSlackMrkdwn("# Title");
|
||||
expect(res).toBe("*Title*");
|
||||
});
|
||||
|
||||
it("renders blockquotes", () => {
|
||||
const res = markdownToSlackMrkdwn("> Quote");
|
||||
expect(res).toBe("> Quote");
|
||||
it("handles core markdown formatting conversions", () => {
|
||||
const cases = [
|
||||
["converts bold from double asterisks to single", "**bold text**", "*bold text*"],
|
||||
["preserves italic underscore format", "_italic text_", "_italic text_"],
|
||||
[
|
||||
"converts strikethrough from double tilde to single",
|
||||
"~~strikethrough~~",
|
||||
"~strikethrough~",
|
||||
],
|
||||
[
|
||||
"renders basic inline formatting together",
|
||||
"hi _there_ **boss** `code`",
|
||||
"hi _there_ *boss* `code`",
|
||||
],
|
||||
["renders inline code", "use `npm install`", "use `npm install`"],
|
||||
["renders fenced code blocks", "```js\nconst x = 1;\n```", "```\nconst x = 1;\n```"],
|
||||
[
|
||||
"renders links with Slack mrkdwn syntax",
|
||||
"see [docs](https://example.com)",
|
||||
"see <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 & b < c > 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>", "<b>nope</b>"],
|
||||
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
|
||||
["renders bullet lists", "- one\n- two", "• one\n• two"],
|
||||
["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"],
|
||||
["renders headings as bold text", "# Title", "*Title*"],
|
||||
["renders blockquotes", "> Quote", "> Quote"],
|
||||
] as const;
|
||||
for (const [name, input, expected] of cases) {
|
||||
expect(markdownToSlackMrkdwn(input), name).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles nested list items", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as ssrf from "../infra/net/ssrf.js";
|
||||
import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js";
|
||||
|
||||
@@ -12,6 +12,8 @@ const TELEGRAM_TEST_TIMINGS = {
|
||||
mediaGroupFlushMs: 20,
|
||||
textFragmentGapMs: 30,
|
||||
} as const;
|
||||
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
|
||||
let replySpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
async function createBotHandler(): Promise<{
|
||||
handler: (ctx: Record<string, unknown>) => Promise<void>;
|
||||
@@ -30,10 +32,6 @@ async function createBotHandlerWithOptions(options: {
|
||||
replySpy: ReturnType<typeof vi.fn>;
|
||||
runtimeError: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
const { createTelegramBot } = await import("./bot.js");
|
||||
const replyModule = await import("../auto-reply/reply.js");
|
||||
const replySpy = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
|
||||
|
||||
onSpy.mockReset();
|
||||
replySpy.mockReset();
|
||||
sendChatActionSpy.mockReset();
|
||||
@@ -96,6 +94,12 @@ afterEach(() => {
|
||||
resolvePinnedHostnameSpy = null;
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createTelegramBot } = await import("./bot.js"));
|
||||
const replyModule = await import("../auto-reply/reply.js");
|
||||
replySpy = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
|
||||
});
|
||||
|
||||
vi.mock("./sticker-cache.js", () => ({
|
||||
cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args),
|
||||
getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args),
|
||||
@@ -521,11 +525,6 @@ describe("telegram text fragments", () => {
|
||||
it(
|
||||
"buffers near-limit text and processes sequential parts as one message",
|
||||
async () => {
|
||||
const { createTelegramBot } = await import("./bot.js");
|
||||
const replyModule = await import("../auto-reply/reply.js");
|
||||
const replySpy = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> })
|
||||
.__replySpy;
|
||||
|
||||
onSpy.mockReset();
|
||||
replySpy.mockReset();
|
||||
|
||||
|
||||
@@ -2,44 +2,28 @@ import { describe, expect, it } from "vitest";
|
||||
import { markdownToTelegramHtml } from "./format.js";
|
||||
|
||||
describe("markdownToTelegramHtml", () => {
|
||||
it("renders basic inline formatting", () => {
|
||||
const res = markdownToTelegramHtml("hi _there_ **boss** `code`");
|
||||
expect(res).toBe("hi <i>there</i> <b>boss</b> <code>code</code>");
|
||||
});
|
||||
|
||||
it("renders links as Telegram-safe HTML", () => {
|
||||
const res = markdownToTelegramHtml("see [docs](https://example.com)");
|
||||
expect(res).toBe('see <a href="https://example.com">docs</a>');
|
||||
});
|
||||
|
||||
it("escapes raw HTML", () => {
|
||||
const res = markdownToTelegramHtml("<b>nope</b>");
|
||||
expect(res).toBe("<b>nope</b>");
|
||||
});
|
||||
|
||||
it("escapes unsafe characters", () => {
|
||||
const res = markdownToTelegramHtml("a & b < c");
|
||||
expect(res).toBe("a & b < c");
|
||||
});
|
||||
|
||||
it("renders paragraphs with blank lines", () => {
|
||||
const res = markdownToTelegramHtml("first\n\nsecond");
|
||||
expect(res).toBe("first\n\nsecond");
|
||||
});
|
||||
|
||||
it("renders lists without block HTML", () => {
|
||||
const res = markdownToTelegramHtml("- one\n- two");
|
||||
expect(res).toBe("• one\n• two");
|
||||
});
|
||||
|
||||
it("renders ordered lists with numbering", () => {
|
||||
const res = markdownToTelegramHtml("2. two\n3. three");
|
||||
expect(res).toBe("2. two\n3. three");
|
||||
});
|
||||
|
||||
it("flattens headings", () => {
|
||||
const res = markdownToTelegramHtml("# Title");
|
||||
expect(res).toBe("Title");
|
||||
it("handles core markdown-to-telegram conversions", () => {
|
||||
const cases = [
|
||||
[
|
||||
"renders basic inline formatting",
|
||||
"hi _there_ **boss** `code`",
|
||||
"hi <i>there</i> <b>boss</b> <code>code</code>",
|
||||
],
|
||||
[
|
||||
"renders links as Telegram-safe HTML",
|
||||
"see [docs](https://example.com)",
|
||||
'see <a href="https://example.com">docs</a>',
|
||||
],
|
||||
["escapes raw HTML", "<b>nope</b>", "<b>nope</b>"],
|
||||
["escapes unsafe characters", "a & b < c", "a & b < c"],
|
||||
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
|
||||
["renders lists without block HTML", "- one\n- two", "• one\n• two"],
|
||||
["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"],
|
||||
["flattens headings", "# Title", "Title"],
|
||||
] as const;
|
||||
for (const [name, input, expected] of cases) {
|
||||
expect(markdownToTelegramHtml(input), name).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders blockquotes as native Telegram blockquote tags", () => {
|
||||
|
||||
@@ -7,58 +7,33 @@ import {
|
||||
} from "./format.js";
|
||||
|
||||
describe("wrapFileReferencesInHtml", () => {
|
||||
it("wraps .md filenames in code tags", () => {
|
||||
expect(wrapFileReferencesInHtml("Check README.md")).toContain("Check <code>README.md</code>");
|
||||
expect(wrapFileReferencesInHtml("See HEARTBEAT.md for status")).toContain(
|
||||
"See <code>HEARTBEAT.md</code> for status",
|
||||
);
|
||||
it("wraps supported file references and paths", () => {
|
||||
const cases = [
|
||||
["Check README.md", "Check <code>README.md</code>"],
|
||||
["See HEARTBEAT.md for status", "See <code>HEARTBEAT.md</code> for status"],
|
||||
["Check main.go", "Check <code>main.go</code>"],
|
||||
["Run script.py", "Run <code>script.py</code>"],
|
||||
["Check backup.pl", "Check <code>backup.pl</code>"],
|
||||
["Run backup.sh", "Run <code>backup.sh</code>"],
|
||||
["Look at squad/friday/HEARTBEAT.md", "Look at <code>squad/friday/HEARTBEAT.md</code>"],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(wrapFileReferencesInHtml(input), input).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps .go filenames", () => {
|
||||
expect(wrapFileReferencesInHtml("Check main.go")).toContain("Check <code>main.go</code>");
|
||||
});
|
||||
|
||||
it("wraps .py filenames", () => {
|
||||
expect(wrapFileReferencesInHtml("Run script.py")).toContain("Run <code>script.py</code>");
|
||||
});
|
||||
|
||||
it("wraps .pl filenames", () => {
|
||||
expect(wrapFileReferencesInHtml("Check backup.pl")).toContain("Check <code>backup.pl</code>");
|
||||
});
|
||||
|
||||
it("wraps .sh filenames", () => {
|
||||
expect(wrapFileReferencesInHtml("Run backup.sh")).toContain("Run <code>backup.sh</code>");
|
||||
});
|
||||
|
||||
it("wraps file paths", () => {
|
||||
expect(wrapFileReferencesInHtml("Look at squad/friday/HEARTBEAT.md")).toContain(
|
||||
"Look at <code>squad/friday/HEARTBEAT.md</code>",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not wrap inside existing code tags", () => {
|
||||
const input = "Already <code>wrapped.md</code> here";
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
expect(result).toBe(input);
|
||||
expect(result).not.toContain("<code><code>");
|
||||
});
|
||||
|
||||
it("does not wrap inside pre tags", () => {
|
||||
const input = "<pre><code>README.md</code></pre>";
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("does not wrap inside anchor tags", () => {
|
||||
const input = '<a href="README.md">Link</a>';
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("does not wrap file refs inside real URL anchor tags", () => {
|
||||
const input = 'Visit <a href="https://example.com/README.md">example.com/README.md</a>';
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
expect(result).toBe(input);
|
||||
it("does not wrap inside protected html contexts", () => {
|
||||
const cases = [
|
||||
"Already <code>wrapped.md</code> here",
|
||||
"<pre><code>README.md</code></pre>",
|
||||
'<a href="README.md">Link</a>',
|
||||
'Visit <a href="https://example.com/README.md">example.com/README.md</a>',
|
||||
] as const;
|
||||
for (const input of cases) {
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
expect(result, input).toBe(input);
|
||||
}
|
||||
expect(wrapFileReferencesInHtml(cases[0])).not.toContain("<code><code>");
|
||||
});
|
||||
|
||||
it("handles mixed content correctly", () => {
|
||||
@@ -67,32 +42,51 @@ describe("wrapFileReferencesInHtml", () => {
|
||||
expect(result).toContain("<code>CONTRIBUTING.md</code>");
|
||||
});
|
||||
|
||||
it("handles edge cases", () => {
|
||||
expect(wrapFileReferencesInHtml("No markdown files here")).not.toContain("<code>");
|
||||
expect(wrapFileReferencesInHtml("File.md at start")).toContain("<code>File.md</code>");
|
||||
expect(wrapFileReferencesInHtml("Ends with file.md")).toContain("<code>file.md</code>");
|
||||
it("handles boundary and punctuation wrapping cases", () => {
|
||||
const cases = [
|
||||
{ input: "No markdown files here", contains: undefined },
|
||||
{ input: "File.md at start", contains: "<code>File.md</code>" },
|
||||
{ input: "Ends with file.md", contains: "<code>file.md</code>" },
|
||||
{ input: "See README.md.", contains: "<code>README.md</code>." },
|
||||
{ input: "See README.md,", contains: "<code>README.md</code>," },
|
||||
{ input: "(README.md)", contains: "(<code>README.md</code>)" },
|
||||
{ input: "README.md:", contains: "<code>README.md</code>:" },
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = wrapFileReferencesInHtml(testCase.input);
|
||||
if (!testCase.contains) {
|
||||
expect(result).not.toContain("<code>");
|
||||
continue;
|
||||
}
|
||||
expect(result).toContain(testCase.contains);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps file refs with punctuation boundaries", () => {
|
||||
expect(wrapFileReferencesInHtml("See README.md.")).toContain("<code>README.md</code>.");
|
||||
expect(wrapFileReferencesInHtml("See README.md,")).toContain("<code>README.md</code>,");
|
||||
expect(wrapFileReferencesInHtml("(README.md)")).toContain("(<code>README.md</code>)");
|
||||
expect(wrapFileReferencesInHtml("README.md:")).toContain("<code>README.md</code>:");
|
||||
});
|
||||
|
||||
it("de-linkifies auto-linkified file ref anchors", () => {
|
||||
const input = '<a href="http://README.md">README.md</a>';
|
||||
expect(wrapFileReferencesInHtml(input)).toBe("<code>README.md</code>");
|
||||
});
|
||||
|
||||
it("de-linkifies auto-linkified path anchors", () => {
|
||||
const input = '<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>';
|
||||
expect(wrapFileReferencesInHtml(input)).toBe("<code>squad/friday/HEARTBEAT.md</code>");
|
||||
it("de-linkifies auto-linkified anchors for plain files and paths", () => {
|
||||
const cases = [
|
||||
{
|
||||
input: '<a href="http://README.md">README.md</a>',
|
||||
expected: "<code>README.md</code>",
|
||||
},
|
||||
{
|
||||
input: '<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>',
|
||||
expected: "<code>squad/friday/HEARTBEAT.md</code>",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(wrapFileReferencesInHtml(testCase.input)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit links where label differs from href", () => {
|
||||
const input = '<a href="http://README.md">click here</a>';
|
||||
expect(wrapFileReferencesInHtml(input)).toBe(input);
|
||||
const cases = [
|
||||
'<a href="http://README.md">click here</a>',
|
||||
'<a href="http://other.md">README.md</a>',
|
||||
] as const;
|
||||
for (const input of cases) {
|
||||
expect(wrapFileReferencesInHtml(input)).toBe(input);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps file ref after closing anchor tag", () => {
|
||||
@@ -167,14 +161,14 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("wraps file ref inside bold tags", () => {
|
||||
const result = markdownToTelegramHtml("**README.md**");
|
||||
expect(result).toBe("<b><code>README.md</code></b>");
|
||||
});
|
||||
|
||||
it("wraps file ref inside italic tags", () => {
|
||||
const result = markdownToTelegramHtml("*script.py*");
|
||||
expect(result).toBe("<i><code>script.py</code></i>");
|
||||
it("wraps file refs inside emphasis tags", () => {
|
||||
const cases = [
|
||||
["**README.md**", "<b><code>README.md</code></b>"],
|
||||
["*script.py*", "<i><code>script.py</code></i>"],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(markdownToTelegramHtml(input), input).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not wrap inside fenced code blocks", () => {
|
||||
@@ -183,15 +177,22 @@ describe("edge cases", () => {
|
||||
expect(result).not.toContain("<code><code>");
|
||||
});
|
||||
|
||||
it("preserves domain-like paths as anchor tags", () => {
|
||||
const result = markdownToTelegramHtml("example.com/README.md");
|
||||
expect(result).toContain('<a href="http://example.com/README.md">');
|
||||
expect(result).not.toContain("<code>");
|
||||
});
|
||||
|
||||
it("preserves github URLs with file paths", () => {
|
||||
const result = markdownToTelegramHtml("https://github.com/foo/README.md");
|
||||
expect(result).toContain('<a href="https://github.com/foo/README.md">');
|
||||
it("preserves real URL/domain paths as anchors", () => {
|
||||
const cases = [
|
||||
{
|
||||
input: "example.com/README.md",
|
||||
href: 'href="http://example.com/README.md"',
|
||||
},
|
||||
{
|
||||
input: "https://github.com/foo/README.md",
|
||||
href: 'href="https://github.com/foo/README.md"',
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = markdownToTelegramHtml(testCase.input);
|
||||
expect(result).toContain(`<a ${testCase.href}>`);
|
||||
expect(result).not.toContain("<code>");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles wrapFileRefs: false (plain text output)", () => {
|
||||
@@ -233,14 +234,14 @@ describe("edge cases", () => {
|
||||
expect(result).not.toContain("<code>script.js</code>");
|
||||
});
|
||||
|
||||
it("handles file ref at start of message", () => {
|
||||
const result = markdownToTelegramHtml("README.md is important");
|
||||
expect(result).toBe("<code>README.md</code> is important");
|
||||
});
|
||||
|
||||
it("handles file ref at end of message", () => {
|
||||
const result = markdownToTelegramHtml("Check the README.md");
|
||||
expect(result).toBe("Check the <code>README.md</code>");
|
||||
it("handles file refs at message boundaries", () => {
|
||||
const cases = [
|
||||
["README.md is important", "<code>README.md</code> is important"],
|
||||
["Check the README.md", "Check the <code>README.md</code>"],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(markdownToTelegramHtml(input), input).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple file refs in sequence", () => {
|
||||
@@ -267,15 +268,13 @@ describe("edge cases", () => {
|
||||
expect(result).toContain('<a href="http://example.com/v1.0/README.md">');
|
||||
});
|
||||
|
||||
it("handles file ref with hyphen and underscore in name", () => {
|
||||
const result = markdownToTelegramHtml("my-file_name.md");
|
||||
expect(result).toContain("<code>my-file_name.md</code>");
|
||||
});
|
||||
it("wraps hyphen/underscore filenames and uppercase extensions", () => {
|
||||
const first = markdownToTelegramHtml("my-file_name.md");
|
||||
expect(first).toContain("<code>my-file_name.md</code>");
|
||||
|
||||
it("handles uppercase extensions", () => {
|
||||
const result = markdownToTelegramHtml("README.MD and SCRIPT.PY");
|
||||
expect(result).toContain("<code>README.MD</code>");
|
||||
expect(result).toContain("<code>SCRIPT.PY</code>");
|
||||
const second = markdownToTelegramHtml("README.MD and SCRIPT.PY");
|
||||
expect(second).toContain("<code>README.MD</code>");
|
||||
expect(second).toContain("<code>SCRIPT.PY</code>");
|
||||
});
|
||||
|
||||
it("handles nested code tags (depth tracking)", () => {
|
||||
@@ -293,12 +292,6 @@ describe("edge cases", () => {
|
||||
expect(result).toContain("</a> <code>script.py</code>");
|
||||
});
|
||||
|
||||
it("preserves anchor when href and label differ (no backreference match)", () => {
|
||||
// Different href and label - should NOT de-linkify
|
||||
const input = '<a href="http://other.md">README.md</a>';
|
||||
expect(wrapFileReferencesInHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("wraps orphaned TLD pattern after special character", () => {
|
||||
// R&D.md - the & breaks the main pattern, but D.md could be auto-linked
|
||||
// So we wrap the orphaned D.md part to prevent Telegram linking it
|
||||
@@ -363,19 +356,16 @@ describe("edge cases", () => {
|
||||
expect(result).not.toContain("<code><code>");
|
||||
});
|
||||
|
||||
it("does not wrap orphaned TLD inside href attributes", () => {
|
||||
// D.md inside href should NOT be wrapped
|
||||
const input = '<a href="http://example.com/R&D.md">link</a>';
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
// href should be untouched
|
||||
expect(result).toBe(input);
|
||||
expect(result).not.toContain("<code>D.md</code>");
|
||||
});
|
||||
|
||||
it("does not wrap orphaned TLD inside any HTML attribute", () => {
|
||||
const input = '<img src="logo/R&D.md" alt="R&D.md">';
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
expect(result).toBe(input);
|
||||
it("does not wrap orphaned TLD fragments inside HTML attributes", () => {
|
||||
const cases = [
|
||||
'<a href="http://example.com/R&D.md">link</a>',
|
||||
'<img src="logo/R&D.md" alt="R&D.md">',
|
||||
] as const;
|
||||
for (const input of cases) {
|
||||
const result = wrapFileReferencesInHtml(input);
|
||||
expect(result).toBe(input);
|
||||
expect(result).not.toContain("<code>D.md</code>");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple orphaned TLDs with HTML tags (offset stability)", () => {
|
||||
|
||||
@@ -10,99 +10,89 @@ import {
|
||||
} from "./model-buttons.js";
|
||||
|
||||
describe("parseModelCallbackData", () => {
|
||||
it("parses mdl_prov callback", () => {
|
||||
const result = parseModelCallbackData("mdl_prov");
|
||||
expect(result).toEqual({ type: "providers" });
|
||||
it("parses supported callback variants", () => {
|
||||
const cases = [
|
||||
["mdl_prov", { type: "providers" }],
|
||||
["mdl_back", { type: "back" }],
|
||||
["mdl_list_anthropic_2", { type: "list", provider: "anthropic", page: 2 }],
|
||||
["mdl_list_open-ai_1", { type: "list", provider: "open-ai", page: 1 }],
|
||||
[
|
||||
"mdl_sel_anthropic/claude-sonnet-4-5",
|
||||
{ type: "select", provider: "anthropic", model: "claude-sonnet-4-5" },
|
||||
],
|
||||
["mdl_sel_openai/gpt-4/turbo", { type: "select", provider: "openai", model: "gpt-4/turbo" }],
|
||||
[" mdl_prov ", { type: "providers" }],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(parseModelCallbackData(input), input).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses mdl_back callback", () => {
|
||||
const result = parseModelCallbackData("mdl_back");
|
||||
expect(result).toEqual({ type: "back" });
|
||||
});
|
||||
|
||||
it("parses mdl_list callback with provider and page", () => {
|
||||
const result = parseModelCallbackData("mdl_list_anthropic_2");
|
||||
expect(result).toEqual({ type: "list", provider: "anthropic", page: 2 });
|
||||
});
|
||||
|
||||
it("parses mdl_list callback with hyphenated provider", () => {
|
||||
const result = parseModelCallbackData("mdl_list_open-ai_1");
|
||||
expect(result).toEqual({ type: "list", provider: "open-ai", page: 1 });
|
||||
});
|
||||
|
||||
it("parses mdl_sel callback with provider/model", () => {
|
||||
const result = parseModelCallbackData("mdl_sel_anthropic/claude-sonnet-4-5");
|
||||
expect(result).toEqual({
|
||||
type: "select",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses mdl_sel callback with nested model path", () => {
|
||||
const result = parseModelCallbackData("mdl_sel_openai/gpt-4/turbo");
|
||||
expect(result).toEqual({
|
||||
type: "select",
|
||||
provider: "openai",
|
||||
model: "gpt-4/turbo",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-model callback data", () => {
|
||||
expect(parseModelCallbackData("commands_page_1")).toBeNull();
|
||||
expect(parseModelCallbackData("other_callback")).toBeNull();
|
||||
expect(parseModelCallbackData("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid mdl_ patterns", () => {
|
||||
expect(parseModelCallbackData("mdl_invalid")).toBeNull();
|
||||
expect(parseModelCallbackData("mdl_list_")).toBeNull();
|
||||
expect(parseModelCallbackData("mdl_sel_noslash")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles whitespace in callback data", () => {
|
||||
expect(parseModelCallbackData(" mdl_prov ")).toEqual({ type: "providers" });
|
||||
it("returns null for unsupported callback variants", () => {
|
||||
const invalid = [
|
||||
"commands_page_1",
|
||||
"other_callback",
|
||||
"",
|
||||
"mdl_invalid",
|
||||
"mdl_list_",
|
||||
"mdl_sel_noslash",
|
||||
];
|
||||
for (const input of invalid) {
|
||||
expect(parseModelCallbackData(input), input).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderKeyboard", () => {
|
||||
it("returns empty array for no providers", () => {
|
||||
const result = buildProviderKeyboard([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
it("lays out providers in two-column rows", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "empty input",
|
||||
input: [],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: "single provider",
|
||||
input: [{ id: "anthropic", count: 5 }],
|
||||
expected: [[{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" }]],
|
||||
},
|
||||
{
|
||||
name: "exactly one full row",
|
||||
input: [
|
||||
{ id: "anthropic", count: 5 },
|
||||
{ id: "openai", count: 8 },
|
||||
],
|
||||
expected: [
|
||||
[
|
||||
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
|
||||
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "wraps overflow to second row",
|
||||
input: [
|
||||
{ id: "anthropic", count: 5 },
|
||||
{ id: "openai", count: 8 },
|
||||
{ id: "google", count: 3 },
|
||||
],
|
||||
expected: [
|
||||
[
|
||||
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
|
||||
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
|
||||
],
|
||||
[{ text: "google (3)", callback_data: "mdl_list_google_1" }],
|
||||
],
|
||||
},
|
||||
] as const satisfies Array<{
|
||||
name: string;
|
||||
input: ProviderInfo[];
|
||||
expected: ReturnType<typeof buildProviderKeyboard>;
|
||||
}>;
|
||||
|
||||
it("builds single provider as one row", () => {
|
||||
const providers: ProviderInfo[] = [{ id: "anthropic", count: 5 }];
|
||||
const result = buildProviderKeyboard(providers);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
|
||||
expect(result[0]?.[0]?.callback_data).toBe("mdl_list_anthropic_1");
|
||||
});
|
||||
|
||||
it("builds two providers per row", () => {
|
||||
const providers: ProviderInfo[] = [
|
||||
{ id: "anthropic", count: 5 },
|
||||
{ id: "openai", count: 8 },
|
||||
];
|
||||
const result = buildProviderKeyboard(providers);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0]?.[0]?.text).toBe("anthropic (5)");
|
||||
expect(result[0]?.[1]?.text).toBe("openai (8)");
|
||||
});
|
||||
|
||||
it("wraps to next row after two providers", () => {
|
||||
const providers: ProviderInfo[] = [
|
||||
{ id: "anthropic", count: 5 },
|
||||
{ id: "openai", count: 8 },
|
||||
{ id: "google", count: 3 },
|
||||
];
|
||||
const result = buildProviderKeyboard(providers);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[1]).toHaveLength(1);
|
||||
expect(result[1]?.[0]?.text).toBe("google (3)");
|
||||
for (const testCase of cases) {
|
||||
expect(buildProviderKeyboard(testCase.input), testCase.name).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,112 +109,105 @@ describe("buildModelsKeyboard", () => {
|
||||
expect(result[0]?.[0]?.callback_data).toBe("mdl_back");
|
||||
});
|
||||
|
||||
it("shows models with one per row", () => {
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: ["claude-sonnet-4", "claude-opus-4"],
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
// 2 model rows + back button
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4");
|
||||
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4");
|
||||
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
|
||||
expect(result[2]?.[0]?.text).toBe("<< Back");
|
||||
it("renders model rows and optional current-model indicator", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "no current model",
|
||||
currentModel: undefined,
|
||||
firstText: "claude-sonnet-4",
|
||||
},
|
||||
{
|
||||
name: "current model marked",
|
||||
currentModel: "anthropic/claude-sonnet-4",
|
||||
firstText: "claude-sonnet-4 ✓",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: ["claude-sonnet-4", "claude-opus-4"],
|
||||
currentModel: testCase.currentModel,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
// 2 model rows + back button
|
||||
expect(result, testCase.name).toHaveLength(3);
|
||||
expect(result[0]?.[0]?.text).toBe(testCase.firstText);
|
||||
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4");
|
||||
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
|
||||
expect(result[2]?.[0]?.text).toBe("<< Back");
|
||||
}
|
||||
});
|
||||
|
||||
it("marks current model with checkmark", () => {
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: ["claude-sonnet-4", "claude-opus-4"],
|
||||
currentModel: "anthropic/claude-sonnet-4",
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(result[0]?.[0]?.text).toBe("claude-sonnet-4 ✓");
|
||||
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
|
||||
it("renders pagination controls for first, middle, and last pages", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "first page",
|
||||
params: { currentPage: 1, models: ["model1", "model2"] },
|
||||
expectedPagination: ["1/3", "Next ▶"],
|
||||
},
|
||||
{
|
||||
name: "middle page",
|
||||
params: {
|
||||
currentPage: 2,
|
||||
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
||||
},
|
||||
expectedPagination: ["◀ Prev", "2/3", "Next ▶"],
|
||||
},
|
||||
{
|
||||
name: "last page",
|
||||
params: {
|
||||
currentPage: 3,
|
||||
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
||||
},
|
||||
expectedPagination: ["◀ Prev", "3/3"],
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: testCase.params.models,
|
||||
currentPage: testCase.params.currentPage,
|
||||
totalPages: 3,
|
||||
pageSize: 2,
|
||||
});
|
||||
// 2 model rows + pagination row + back button
|
||||
expect(result, testCase.name).toHaveLength(4);
|
||||
expect(result[2]?.map((button) => button.text)).toEqual(testCase.expectedPagination);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows pagination when multiple pages", () => {
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: ["model1", "model2"],
|
||||
currentPage: 1,
|
||||
totalPages: 3,
|
||||
pageSize: 2,
|
||||
});
|
||||
// 2 model rows + pagination row + back button
|
||||
expect(result).toHaveLength(4);
|
||||
const paginationRow = result[2];
|
||||
expect(paginationRow).toHaveLength(2); // no prev on first page
|
||||
expect(paginationRow?.[0]?.text).toBe("1/3");
|
||||
expect(paginationRow?.[1]?.text).toBe("Next ▶");
|
||||
});
|
||||
|
||||
it("shows prev and next on middle pages", () => {
|
||||
// 6 models with pageSize 2 = 3 pages
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
||||
currentPage: 2,
|
||||
totalPages: 3,
|
||||
pageSize: 2,
|
||||
});
|
||||
// 2 model rows + pagination row + back button
|
||||
expect(result).toHaveLength(4);
|
||||
const paginationRow = result[2];
|
||||
expect(paginationRow).toHaveLength(3);
|
||||
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
|
||||
expect(paginationRow?.[1]?.text).toBe("2/3");
|
||||
expect(paginationRow?.[2]?.text).toBe("Next ▶");
|
||||
});
|
||||
|
||||
it("shows only prev on last page", () => {
|
||||
// 6 models with pageSize 2 = 3 pages
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
|
||||
currentPage: 3,
|
||||
totalPages: 3,
|
||||
pageSize: 2,
|
||||
});
|
||||
// 2 model rows + pagination row + back button
|
||||
expect(result).toHaveLength(4);
|
||||
const paginationRow = result[2];
|
||||
expect(paginationRow).toHaveLength(2);
|
||||
expect(paginationRow?.[0]?.text).toBe("◀ Prev");
|
||||
expect(paginationRow?.[1]?.text).toBe("3/3");
|
||||
});
|
||||
|
||||
it("truncates long model IDs for display", () => {
|
||||
// Model ID that's long enough to truncate display but still fits in callback_data
|
||||
// callback_data = "mdl_sel_anthropic/" (18) + model (<=46) = 64 max
|
||||
const longModel = "claude-3-5-sonnet-20241022-with-suffix";
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "anthropic",
|
||||
models: [longModel],
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
const text = result[0]?.[0]?.text;
|
||||
// Model is 38 chars, fits exactly in 38-char display limit
|
||||
expect(text).toBe(longModel);
|
||||
});
|
||||
|
||||
it("truncates display text for very long model names", () => {
|
||||
// Use short provider to allow longer model in callback_data (64 byte limit)
|
||||
// "mdl_sel_a/" = 10 bytes, leaving 54 for model
|
||||
const longModel = "this-model-name-is-long-enough-to-need-truncation-abcd";
|
||||
const result = buildModelsKeyboard({
|
||||
provider: "a",
|
||||
models: [longModel],
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
const text = result[0]?.[0]?.text;
|
||||
expect(text?.startsWith("…")).toBe(true);
|
||||
expect(text?.length).toBeLessThanOrEqual(38);
|
||||
it("keeps short display IDs untouched and truncates overly long IDs", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "max-length display",
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet-20241022-with-suffix",
|
||||
expected: "claude-3-5-sonnet-20241022-with-suffix",
|
||||
},
|
||||
{
|
||||
name: "overly long display",
|
||||
provider: "a",
|
||||
model: "this-model-name-is-long-enough-to-need-truncation-abcd",
|
||||
startsWith: "…",
|
||||
maxLength: 38,
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = buildModelsKeyboard({
|
||||
provider: testCase.provider,
|
||||
models: [testCase.model],
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
const text = result[0]?.[0]?.text;
|
||||
if ("expected" in testCase) {
|
||||
expect(text, testCase.name).toBe(testCase.expected);
|
||||
} else {
|
||||
expect(text?.startsWith(testCase.startsWith), testCase.name).toBe(true);
|
||||
expect(text?.length, testCase.name).toBeLessThanOrEqual(testCase.maxLength);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -297,20 +297,6 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps chat-not-found with actionable context", async () => {
|
||||
const chatId = "123";
|
||||
const err = new Error("400: Bad Request: chat not found");
|
||||
const sendMessage = vi.fn().mockRejectedValue(err);
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await expectChatNotFoundWithChatId(
|
||||
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
|
||||
chatId,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves thread params in plain text fallback", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const parseErr = new Error(
|
||||
@@ -478,153 +464,139 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends video as video note when asVideoNote is true", async () => {
|
||||
it("sends video notes when requested and regular videos otherwise", async () => {
|
||||
const chatId = "123";
|
||||
const text = "ignored caption context";
|
||||
|
||||
const sendVideoNote = vi.fn().mockResolvedValue({
|
||||
message_id: 101,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 102,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendVideoNote, sendMessage } as unknown as {
|
||||
sendVideoNote: typeof sendVideoNote;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
{
|
||||
const text = "ignored caption context";
|
||||
const sendVideoNote = vi.fn().mockResolvedValue({
|
||||
message_id: 101,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 102,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendVideoNote, sendMessage } as unknown as {
|
||||
sendVideoNote: typeof sendVideoNote;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("fake-video"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "video.mp4",
|
||||
});
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("fake-video"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "video.mp4",
|
||||
});
|
||||
|
||||
const res = await sendMessageTelegram(chatId, text, {
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
asVideoNote: true,
|
||||
});
|
||||
const res = await sendMessageTelegram(chatId, text, {
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
asVideoNote: true,
|
||||
});
|
||||
|
||||
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(res.messageId).toBe("102");
|
||||
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(res.messageId).toBe("102");
|
||||
}
|
||||
|
||||
{
|
||||
const text = "my caption";
|
||||
const sendVideo = vi.fn().mockResolvedValue({
|
||||
message_id: 201,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendVideo } as unknown as {
|
||||
sendVideo: typeof sendVideo;
|
||||
};
|
||||
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("fake-video"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "video.mp4",
|
||||
});
|
||||
|
||||
const res = await sendMessageTelegram(chatId, text, {
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
asVideoNote: false,
|
||||
});
|
||||
|
||||
expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), {
|
||||
caption: expect.any(String),
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(res.messageId).toBe("201");
|
||||
}
|
||||
});
|
||||
|
||||
it("sends regular video when asVideoNote is false", async () => {
|
||||
it("applies reply markup and thread options to split video-note sends", async () => {
|
||||
const chatId = "123";
|
||||
const text = "my caption";
|
||||
|
||||
const sendVideo = vi.fn().mockResolvedValue({
|
||||
message_id: 201,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendVideo } as unknown as {
|
||||
sendVideo: typeof sendVideo;
|
||||
};
|
||||
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("fake-video"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "video.mp4",
|
||||
});
|
||||
|
||||
const res = await sendMessageTelegram(chatId, text, {
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
asVideoNote: false,
|
||||
});
|
||||
|
||||
expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), {
|
||||
caption: expect.any(String),
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(res.messageId).toBe("201");
|
||||
});
|
||||
|
||||
it("adds reply_markup to separate text message for video notes", async () => {
|
||||
const chatId = "123";
|
||||
const text = "Check this out";
|
||||
|
||||
const sendVideoNote = vi.fn().mockResolvedValue({
|
||||
message_id: 301,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 302,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendVideoNote, sendMessage } as unknown as {
|
||||
sendVideoNote: typeof sendVideoNote;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("fake-video"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "video.mp4",
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, text, {
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
asVideoNote: true,
|
||||
buttons: [[{ text: "Btn", callback_data: "dat" }]],
|
||||
});
|
||||
|
||||
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]],
|
||||
const cases = [
|
||||
{
|
||||
text: "Check this out",
|
||||
options: {
|
||||
buttons: [[{ text: "Btn", callback_data: "dat" }]],
|
||||
},
|
||||
expectedVideoNote: {},
|
||||
expectedMessage: {
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
{
|
||||
text: "Threaded reply",
|
||||
options: {
|
||||
replyToMessageId: 999,
|
||||
},
|
||||
expectedVideoNote: { reply_to_message_id: 999 },
|
||||
expectedMessage: {
|
||||
parse_mode: "HTML",
|
||||
reply_to_message_id: 999,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("threads video note and text message correctly", async () => {
|
||||
const chatId = "123";
|
||||
const text = "Threaded reply";
|
||||
for (const testCase of cases) {
|
||||
const sendVideoNote = vi.fn().mockResolvedValue({
|
||||
message_id: 301,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 302,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendVideoNote, sendMessage } as unknown as {
|
||||
sendVideoNote: typeof sendVideoNote;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
const sendVideoNote = vi.fn().mockResolvedValue({
|
||||
message_id: 401,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 402,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendVideoNote, sendMessage } as unknown as {
|
||||
sendVideoNote: typeof sendVideoNote;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("fake-video"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "video.mp4",
|
||||
});
|
||||
|
||||
loadWebMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("fake-video"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "video.mp4",
|
||||
});
|
||||
await sendMessageTelegram(chatId, testCase.text, {
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
asVideoNote: true,
|
||||
...testCase.options,
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, text, {
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
asVideoNote: true,
|
||||
replyToMessageId: 999,
|
||||
});
|
||||
|
||||
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {
|
||||
reply_to_message_id: 999,
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
|
||||
parse_mode: "HTML",
|
||||
reply_to_message_id: 999,
|
||||
});
|
||||
expect(sendVideoNote).toHaveBeenCalledWith(
|
||||
chatId,
|
||||
expect.anything(),
|
||||
testCase.expectedVideoNote,
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, testCase.expectedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
it("retries on transient errors with retry_after", async () => {
|
||||
@@ -847,171 +819,144 @@ describe("sendMessageTelegram", () => {
|
||||
expect(sendAudio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes message_thread_id for forum topic messages", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 55,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
it("keeps message_thread_id for forum/private/group sends", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "forum topic",
|
||||
chatId: "-1001234567890",
|
||||
text: "hello forum",
|
||||
messageId: 55,
|
||||
},
|
||||
{
|
||||
name: "private chat topic (#18974)",
|
||||
chatId: "123456789",
|
||||
text: "hello private",
|
||||
messageId: 56,
|
||||
},
|
||||
{
|
||||
// Group/supergroup chats have negative IDs.
|
||||
name: "group chat (#17242)",
|
||||
chatId: "-1001234567890",
|
||||
text: "hello group",
|
||||
messageId: 57,
|
||||
},
|
||||
] as const;
|
||||
|
||||
await sendMessageTelegram(chatId, "hello forum", {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps message_thread_id for private chat topic sends (#18974)", async () => {
|
||||
const chatId = "123456789";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 56,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(chatId, "hello private", {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps message_thread_id for group chat sends (#17242)", async () => {
|
||||
// Group/supergroup chats have negative IDs.
|
||||
const chatId = "-1001234567890";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 57,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(chatId, "hello group", {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello group", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries without message_thread_id when Telegram reports missing thread", async () => {
|
||||
const chatId = "-100123";
|
||||
const threadErr = new Error("400: Bad Request: message thread not found");
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(threadErr)
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 58,
|
||||
chat: { id: chatId },
|
||||
for (const testCase of cases) {
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: testCase.messageId,
|
||||
chat: { id: testCase.chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
const res = await sendMessageTelegram(chatId, "hello forum", {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "hello forum", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "hello forum", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(res.messageId).toBe("58");
|
||||
});
|
||||
|
||||
it("retries private chat sends without message_thread_id on thread-not-found", async () => {
|
||||
const chatId = "123456789";
|
||||
const threadErr = new Error("400: Bad Request: message thread not found");
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(threadErr)
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 59,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
const res = await sendMessageTelegram(chatId, "hello private", {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "hello private", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "hello private", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(res.messageId).toBe("59");
|
||||
});
|
||||
|
||||
it("does not retry thread-not-found when no message_thread_id was provided", async () => {
|
||||
const chatId = "123";
|
||||
const threadErr = new Error("400: Bad Request: message thread not found");
|
||||
const sendMessage = vi.fn().mockRejectedValueOnce(threadErr);
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram(chatId, "hello forum", {
|
||||
token: "tok",
|
||||
api,
|
||||
}),
|
||||
).rejects.toThrow("message thread not found");
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry without message_thread_id on chat-not-found", async () => {
|
||||
const chatId = "123456789";
|
||||
const chatErr = new Error("400: Bad Request: chat not found");
|
||||
const sendMessage = vi.fn().mockRejectedValueOnce(chatErr);
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram(chatId, "hello private", {
|
||||
await sendMessageTelegram(testCase.chatId, testCase.text, {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
}),
|
||||
).rejects.toThrow(/chat not found/i);
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
expect(sendMessage, testCase.name).toHaveBeenCalledWith(testCase.chatId, testCase.text, {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retries sends without message_thread_id on thread-not-found", async () => {
|
||||
const cases = [
|
||||
{ name: "forum", chatId: "-100123", text: "hello forum", messageId: 58 },
|
||||
{ name: "private", chatId: "123456789", text: "hello private", messageId: 59 },
|
||||
] as const;
|
||||
const threadErr = new Error("400: Bad Request: message thread not found");
|
||||
|
||||
for (const testCase of cases) {
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(threadErr)
|
||||
.mockResolvedValueOnce({
|
||||
message_id: testCase.messageId,
|
||||
chat: { id: testCase.chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
const res = await sendMessageTelegram(testCase.chatId, testCase.text, {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendMessage, testCase.name).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
testCase.chatId,
|
||||
testCase.text,
|
||||
{
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
},
|
||||
);
|
||||
expect(sendMessage, testCase.name).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
testCase.chatId,
|
||||
testCase.text,
|
||||
{
|
||||
parse_mode: "HTML",
|
||||
},
|
||||
);
|
||||
expect(res.messageId, testCase.name).toBe(String(testCase.messageId));
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry on non-retriable thread/chat errors", async () => {
|
||||
const cases: Array<{
|
||||
chatId: string;
|
||||
text: string;
|
||||
error: Error;
|
||||
opts?: { messageThreadId?: number };
|
||||
expectedError: RegExp | string;
|
||||
expectedCallArgs: [string, string, { parse_mode: "HTML"; message_thread_id?: number }];
|
||||
}> = [
|
||||
{
|
||||
chatId: "123",
|
||||
text: "hello forum",
|
||||
error: new Error("400: Bad Request: message thread not found"),
|
||||
expectedError: "message thread not found",
|
||||
expectedCallArgs: ["123", "hello forum", { parse_mode: "HTML" }],
|
||||
},
|
||||
{
|
||||
chatId: "123456789",
|
||||
text: "hello private",
|
||||
error: new Error("400: Bad Request: chat not found"),
|
||||
opts: { messageThreadId: 271 },
|
||||
expectedError: /chat not found/i,
|
||||
expectedCallArgs: [
|
||||
"123456789",
|
||||
"hello private",
|
||||
{ parse_mode: "HTML", message_thread_id: 271 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const sendMessage = vi.fn().mockRejectedValueOnce(testCase.error);
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await expect(
|
||||
sendMessageTelegram(testCase.chatId, testCase.text, {
|
||||
token: "tok",
|
||||
api,
|
||||
...testCase.opts,
|
||||
}),
|
||||
).rejects.toThrow(testCase.expectedError);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(...testCase.expectedCallArgs);
|
||||
}
|
||||
});
|
||||
|
||||
it("sets disable_notification when silent is true", async () => {
|
||||
@@ -1057,28 +1002,6 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reply_to_message_id for threaded replies", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 56,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(chatId, "reply text", {
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 100,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
|
||||
parse_mode: "HTML",
|
||||
reply_to_message_id: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries media sends without message_thread_id when thread is missing", async () => {
|
||||
const chatId = "-100123";
|
||||
const threadErr = new Error("400: Bad Request: message thread not found");
|
||||
@@ -1224,42 +1147,6 @@ describe("sendStickerTelegram", () => {
|
||||
expect(res.messageId).toBe("109");
|
||||
});
|
||||
|
||||
it("includes reply_to_message_id for threaded replies", async () => {
|
||||
const chatId = "123";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 102,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 500,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
reply_to_message_id: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps chat-not-found with actionable context", async () => {
|
||||
const chatId = "123";
|
||||
const err = new Error("400: Bad Request: chat not found");
|
||||
const sendSticker = vi.fn().mockRejectedValue(err);
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await expectChatNotFoundWithChatId(
|
||||
sendStickerTelegram(chatId, "fileId123", { token: "tok", api }),
|
||||
chatId,
|
||||
);
|
||||
});
|
||||
|
||||
it("trims whitespace from fileId", async () => {
|
||||
const chatId = "123";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
@@ -1279,6 +1166,84 @@ describe("sendStickerTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared send behaviors", () => {
|
||||
it("includes reply_to_message_id for threaded replies", async () => {
|
||||
{
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 56,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(chatId, "reply text", {
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 100,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
|
||||
parse_mode: "HTML",
|
||||
reply_to_message_id: 100,
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const chatId = "123";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 102,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 500,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
reply_to_message_id: 500,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps chat-not-found with actionable context", async () => {
|
||||
{
|
||||
const chatId = "123";
|
||||
const err = new Error("400: Bad Request: chat not found");
|
||||
const sendMessage = vi.fn().mockRejectedValue(err);
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await expectChatNotFoundWithChatId(
|
||||
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
|
||||
chatId,
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const chatId = "123";
|
||||
const err = new Error("400: Bad Request: chat not found");
|
||||
const sendSticker = vi.fn().mockRejectedValue(err);
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await expectChatNotFoundWithChatId(
|
||||
sendStickerTelegram(chatId, "fileId123", { token: "tok", api }),
|
||||
chatId,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMessageTelegram", () => {
|
||||
beforeEach(() => {
|
||||
botApi.editMessageText.mockReset();
|
||||
|
||||
Reference in New Issue
Block a user