diff --git a/src/channels/dock.test.ts b/src/channels/dock.test.ts new file mode 100644 index 000000000..dcd7ecfa7 --- /dev/null +++ b/src/channels/dock.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { getChannelDock } from "./dock.js"; + +function emptyConfig(): OpenClawConfig { + return {} as OpenClawConfig; +} + +describe("channels dock", () => { + it("telegram and googlechat threading contexts map thread ids consistently", () => { + const hasRepliedRef = { value: false }; + const telegramDock = getChannelDock("telegram"); + const googleChatDock = getChannelDock("googlechat"); + + const telegramContext = telegramDock?.threading?.buildToolContext?.({ + cfg: emptyConfig(), + context: { To: " room-1 ", MessageThreadId: 42, ReplyToId: "fallback" }, + hasRepliedRef, + }); + const googleChatContext = googleChatDock?.threading?.buildToolContext?.({ + cfg: emptyConfig(), + context: { To: " space-1 ", ReplyToId: "thread-abc" }, + hasRepliedRef, + }); + + expect(telegramContext).toEqual({ + currentChannelId: "room-1", + currentThreadTs: "42", + hasRepliedRef, + }); + expect(googleChatContext).toEqual({ + currentChannelId: "space-1", + currentThreadTs: "thread-abc", + hasRepliedRef, + }); + }); + + it("irc resolveDefaultTo matches account id case-insensitively", () => { + const ircDock = getChannelDock("irc"); + const cfg = { + channels: { + irc: { + defaultTo: "#root", + accounts: { + Work: { defaultTo: "#work" }, + }, + }, + }, + } as OpenClawConfig; + + const accountDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "work" }); + const rootDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "missing" }); + + expect(accountDefault).toBe("#work"); + expect(rootDefault).toBe("#root"); + }); + + it("signal allowFrom formatter normalizes values and preserves wildcard", () => { + const signalDock = getChannelDock("signal"); + + const formatted = signalDock?.config?.formatAllowFrom?.({ + cfg: emptyConfig(), + allowFrom: [" signal:+14155550100 ", " * "], + }); + + expect(formatted).toEqual(["+14155550100", "*"]); + }); + + it("telegram allowFrom formatter trims, strips prefix, and lowercases", () => { + const telegramDock = getChannelDock("telegram"); + + const formatted = telegramDock?.config?.formatAllowFrom?.({ + cfg: emptyConfig(), + allowFrom: [" TG:User ", "telegram:Foo", " Plain "], + }); + + expect(formatted).toEqual(["user", "foo", "plain"]); + }); +}); diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 12fd9c32d..df7dcbfe7 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -1,4 +1,3 @@ -import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, @@ -32,6 +31,7 @@ import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js"; import type { ChannelCapabilities, ChannelCommandAdapter, + ChannelConfigAdapter, ChannelElevatedAdapter, ChannelGroupAdapter, ChannelId, @@ -53,21 +53,10 @@ export type ChannelDock = { }; streaming?: ChannelDockStreaming; elevated?: ChannelElevatedAdapter; - config?: { - resolveAllowFrom?: (params: { - cfg: OpenClawConfig; - accountId?: string | null; - }) => Array | undefined; - formatAllowFrom?: (params: { - cfg: OpenClawConfig; - accountId?: string | null; - allowFrom: Array; - }) => string[]; - resolveDefaultTo?: (params: { - cfg: OpenClawConfig; - accountId?: string | null; - }) => string | undefined; - }; + config?: Pick< + ChannelConfigAdapter, + "resolveAllowFrom" | "formatAllowFrom" | "resolveDefaultTo" + >; groups?: ChannelGroupAdapter; mentions?: ChannelMentionAdapter; threading?: ChannelThreadingAdapter; @@ -87,6 +76,12 @@ const formatLower = (allowFrom: Array) => .filter(Boolean) .map((entry) => entry.toLowerCase()); +const stringifyAllowFrom = (allowFrom: Array) => + allowFrom.map((entry) => String(entry)); + +const trimAllowFromEntries = (allowFrom: Array) => + allowFrom.map((entry) => String(entry).trim()).filter(Boolean); + const formatDiscordAllowFrom = (allowFrom: Array) => allowFrom .map((entry) => @@ -133,6 +128,18 @@ function buildIMessageThreadToolContext(params: { }; } +function buildThreadToolContextFromMessageThreadOrReply(params: { + context: ChannelThreadingContext; + hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"]; +}): ChannelThreadingToolContext { + const threadId = params.context.MessageThreadId ?? params.context.ReplyToId; + return { + currentChannelId: params.context.To?.trim() || undefined, + currentThreadTs: threadId != null ? String(threadId) : undefined, + hasRepliedRef: params.hasRepliedRef, + }; +} + function resolveCaseInsensitiveAccount( accounts: Record | undefined, accountId?: string | null, @@ -182,13 +189,9 @@ const DOCKS: Record = { outbound: { textChunkLimit: 4000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => - (resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), + stringifyAllowFrom(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []), formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) + trimAllowFromEntries(allowFrom) .map((entry) => entry.replace(/^(telegram|tg):/i, "")) .map((entry) => entry.toLowerCase()), resolveDefaultTo: ({ cfg, accountId }) => { @@ -202,14 +205,8 @@ const DOCKS: Record = { }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => { - const threadId = context.MessageThreadId ?? context.ReplyToId; - return { - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: threadId != null ? String(threadId) : undefined, - hasRepliedRef, - }; - }, + buildToolContext: ({ context, hasRepliedRef }) => + buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }), }, }, whatsapp: { @@ -426,14 +423,8 @@ const DOCKS: Record = { }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => { - const threadId = context.MessageThreadId ?? context.ReplyToId; - return { - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: threadId != null ? String(threadId) : undefined, - hasRepliedRef, - }; - }, + buildToolContext: ({ context, hasRepliedRef }) => + buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }), }, }, slack: { @@ -487,13 +478,9 @@ const DOCKS: Record = { }, config: { resolveAllowFrom: ({ cfg, accountId }) => - (resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), + stringifyAllowFrom(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []), formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) + trimAllowFromEntries(allowFrom) .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) .filter(Boolean), resolveDefaultTo: ({ cfg, accountId }) => diff --git a/src/channels/draft-stream-controls.test.ts b/src/channels/draft-stream-controls.test.ts new file mode 100644 index 000000000..a8ef3ebf3 --- /dev/null +++ b/src/channels/draft-stream-controls.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearFinalizableDraftMessage, + createFinalizableDraftLifecycle, + createFinalizableDraftStreamControlsForState, + takeMessageIdAfterStop, +} from "./draft-stream-controls.js"; + +describe("draft-stream-controls", () => { + it("takeMessageIdAfterStop stops, reads, and clears message id", async () => { + const events: string[] = []; + let messageId: string | undefined = "m-1"; + + const result = await takeMessageIdAfterStop({ + stopForClear: async () => { + events.push("stop"); + }, + readMessageId: () => { + events.push("read"); + return messageId; + }, + clearMessageId: () => { + events.push("clear"); + messageId = undefined; + }, + }); + + expect(result).toBe("m-1"); + expect(messageId).toBeUndefined(); + expect(events).toEqual(["stop", "read", "clear"]); + }); + + it("clearFinalizableDraftMessage deletes valid message ids", async () => { + const deleteMessage = vi.fn(async () => {}); + const onDeleteSuccess = vi.fn(); + + await clearFinalizableDraftMessage({ + stopForClear: async () => {}, + readMessageId: () => "m-2", + clearMessageId: () => {}, + isValidMessageId: (value): value is string => typeof value === "string", + deleteMessage, + onDeleteSuccess, + warnPrefix: "cleanup failed", + }); + + expect(deleteMessage).toHaveBeenCalledWith("m-2"); + expect(onDeleteSuccess).toHaveBeenCalledWith("m-2"); + }); + + it("clearFinalizableDraftMessage skips invalid message ids", async () => { + const deleteMessage = vi.fn(async () => {}); + + await clearFinalizableDraftMessage({ + stopForClear: async () => {}, + readMessageId: () => 123, + clearMessageId: () => {}, + isValidMessageId: (value): value is string => typeof value === "string", + deleteMessage, + warnPrefix: "cleanup failed", + }); + + expect(deleteMessage).not.toHaveBeenCalled(); + }); + + it("clearFinalizableDraftMessage warns when delete fails", async () => { + const warn = vi.fn(); + + await clearFinalizableDraftMessage({ + stopForClear: async () => {}, + readMessageId: () => "m-3", + clearMessageId: () => {}, + isValidMessageId: (value): value is string => typeof value === "string", + deleteMessage: async () => { + throw new Error("boom"); + }, + warn, + warnPrefix: "cleanup failed", + }); + + expect(warn).toHaveBeenCalledWith("cleanup failed: boom"); + }); + + it("controls ignore updates after final", async () => { + const sendOrEditStreamMessage = vi.fn(async () => true); + const controls = createFinalizableDraftStreamControlsForState({ + throttleMs: 250, + state: { stopped: false, final: true }, + sendOrEditStreamMessage, + }); + + controls.update("ignored"); + await controls.loop.flush(); + + expect(sendOrEditStreamMessage).not.toHaveBeenCalled(); + }); + + it("lifecycle clear marks stopped, clears id, and deletes preview message", async () => { + const state = { stopped: false, final: false }; + let messageId: string | undefined = "m-4"; + const deleteMessage = vi.fn(async () => {}); + + const lifecycle = createFinalizableDraftLifecycle({ + throttleMs: 250, + state, + sendOrEditStreamMessage: async () => true, + readMessageId: () => messageId, + clearMessageId: () => { + messageId = undefined; + }, + isValidMessageId: (value): value is string => typeof value === "string", + deleteMessage, + warnPrefix: "cleanup failed", + }); + + await lifecycle.clear(); + + expect(state.stopped).toBe(true); + expect(messageId).toBeUndefined(); + expect(deleteMessage).toHaveBeenCalledWith("m-4"); + }); +}); diff --git a/src/channels/draft-stream-controls.ts b/src/channels/draft-stream-controls.ts index 056e69f69..88acd0777 100644 --- a/src/channels/draft-stream-controls.ts +++ b/src/channels/draft-stream-controls.ts @@ -5,6 +5,26 @@ export type FinalizableDraftStreamState = { final: boolean; }; +type StopAndClearMessageIdParams = { + stopForClear: () => Promise; + readMessageId: () => T | undefined; + clearMessageId: () => void; +}; + +type ClearFinalizableDraftMessageParams = StopAndClearMessageIdParams & { + isValidMessageId: (value: unknown) => value is T; + deleteMessage: (messageId: T) => Promise; + onDeleteSuccess?: (messageId: T) => void; + warn?: (message: string) => void; + warnPrefix: string; +}; + +type FinalizableDraftLifecycleParams = ClearFinalizableDraftMessageParams & { + throttleMs: number; + state: FinalizableDraftStreamState; + sendOrEditStreamMessage: (text: string) => Promise; +}; + export function createFinalizableDraftStreamControls(params: { throttleMs: number; isStopped: () => boolean; @@ -64,27 +84,18 @@ export function createFinalizableDraftStreamControlsForState(params: { }); } -export async function takeMessageIdAfterStop(params: { - stopForClear: () => Promise; - readMessageId: () => T | undefined; - clearMessageId: () => void; -}): Promise { +export async function takeMessageIdAfterStop( + params: StopAndClearMessageIdParams, +): Promise { await params.stopForClear(); const messageId = params.readMessageId(); params.clearMessageId(); return messageId; } -export async function clearFinalizableDraftMessage(params: { - stopForClear: () => Promise; - readMessageId: () => T | undefined; - clearMessageId: () => void; - isValidMessageId: (value: unknown) => value is T; - deleteMessage: (messageId: T) => Promise; - onDeleteSuccess?: (messageId: T) => void; - warn?: (message: string) => void; - warnPrefix: string; -}): Promise { +export async function clearFinalizableDraftMessage( + params: ClearFinalizableDraftMessageParams, +): Promise { const messageId = await takeMessageIdAfterStop({ stopForClear: params.stopForClear, readMessageId: params.readMessageId, @@ -101,18 +112,7 @@ export async function clearFinalizableDraftMessage(params: { } } -export function createFinalizableDraftLifecycle(params: { - throttleMs: number; - state: FinalizableDraftStreamState; - sendOrEditStreamMessage: (text: string) => Promise; - readMessageId: () => T | undefined; - clearMessageId: () => void; - isValidMessageId: (value: unknown) => value is T; - deleteMessage: (messageId: T) => Promise; - onDeleteSuccess?: (messageId: T) => void; - warn?: (message: string) => void; - warnPrefix: string; -}) { +export function createFinalizableDraftLifecycle(params: FinalizableDraftLifecycleParams) { const controls = createFinalizableDraftStreamControlsForState({ throttleMs: params.throttleMs, state: params.state, diff --git a/src/channels/plugins/outbound/discord.test.ts b/src/channels/plugins/outbound/discord.test.ts index e6d45429a..1d14a9271 100644 --- a/src/channels/plugins/outbound/discord.test.ts +++ b/src/channels/plugins/outbound/discord.test.ts @@ -36,6 +36,24 @@ vi.mock("../../../discord/monitor/thread-bindings.js", async (importOriginal) => const { discordOutbound } = await import("./discord.js"); +function mockBoundThreadManager() { + hoisted.getThreadBindingManagerMock.mockReturnValue({ + getByThreadId: () => ({ + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "codex-thread", + webhookId: "wh-1", + webhookToken: "tok-1", + boundBy: "system", + boundAt: Date.now(), + }), + }); +} + describe("normalizeDiscordOutboundTarget", () => { it("normalizes bare numeric IDs to channel: prefix", () => { expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({ @@ -110,21 +128,7 @@ describe("discordOutbound", () => { }); it("uses webhook persona delivery for bound thread text replies", async () => { - hoisted.getThreadBindingManagerMock.mockReturnValue({ - getByThreadId: () => ({ - accountId: "default", - channelId: "parent-1", - threadId: "thread-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - agentId: "main", - label: "codex-thread", - webhookId: "wh-1", - webhookToken: "tok-1", - boundBy: "system", - boundAt: Date.now(), - }), - }); + mockBoundThreadManager(); const result = await discordOutbound.sendText?.({ cfg: {}, @@ -160,20 +164,7 @@ describe("discordOutbound", () => { }); it("falls back to bot send for silent delivery on bound threads", async () => { - hoisted.getThreadBindingManagerMock.mockReturnValue({ - getByThreadId: () => ({ - accountId: "default", - channelId: "parent-1", - threadId: "thread-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - agentId: "main", - webhookId: "wh-1", - webhookToken: "tok-1", - boundBy: "system", - boundAt: Date.now(), - }), - }); + mockBoundThreadManager(); const result = await discordOutbound.sendText?.({ cfg: {}, @@ -201,20 +192,7 @@ describe("discordOutbound", () => { }); it("falls back to bot send when webhook send fails", async () => { - hoisted.getThreadBindingManagerMock.mockReturnValue({ - getByThreadId: () => ({ - accountId: "default", - channelId: "parent-1", - threadId: "thread-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - agentId: "main", - webhookId: "wh-1", - webhookToken: "tok-1", - boundBy: "system", - boundAt: Date.now(), - }), - }); + mockBoundThreadManager(); hoisted.sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited")); const result = await discordOutbound.sendText?.({ diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 1315e2c2c..ce0f9bbb8 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -57,7 +57,7 @@ export type ChannelConfigAdapter = { resolveAllowFrom?: (params: { cfg: OpenClawConfig; accountId?: string | null; - }) => string[] | undefined; + }) => Array | undefined; formatAllowFrom?: (params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 96e59da99..144faed13 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -41,6 +41,21 @@ const createEnabledController = ( return { adapter, calls, controller }; }; +const createSetOnlyController = () => { + const calls: { method: string; emoji: string }[] = []; + const adapter: StatusReactionAdapter = { + setReaction: vi.fn(async (emoji: string) => { + calls.push({ method: "set", emoji }); + }), + }; + const controller = createStatusReactionController({ + enabled: true, + adapter, + initialEmoji: "👀", + }); + return { calls, controller }; +}; + // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── @@ -245,19 +260,7 @@ describe("createStatusReactionController", () => { }); it("should only call setReaction when adapter lacks removeReaction", async () => { - const calls: { method: string; emoji: string }[] = []; - const adapter: StatusReactionAdapter = { - setReaction: vi.fn(async (emoji: string) => { - calls.push({ method: "set", emoji }); - }), - // No removeReaction - }; - - const controller = createStatusReactionController({ - enabled: true, - adapter, - initialEmoji: "👀", - }); + const { calls, controller } = createSetOnlyController(); void controller.setQueued(); await vi.runAllTimersAsync(); @@ -285,18 +288,7 @@ describe("createStatusReactionController", () => { }); it("should handle clear gracefully when adapter lacks removeReaction", async () => { - const calls: { method: string; emoji: string }[] = []; - const adapter: StatusReactionAdapter = { - setReaction: vi.fn(async (emoji: string) => { - calls.push({ method: "set", emoji }); - }), - }; - - const controller = createStatusReactionController({ - enabled: true, - adapter, - initialEmoji: "👀", - }); + const { calls, controller } = createSetOnlyController(); await controller.clear(); diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 78ebee9f0..1eb3200ba 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -18,6 +18,36 @@ vi.mock("../send.js", () => ({ describe("deliverDiscordReply", () => { const runtime = {} as RuntimeEnv; + const createBoundThreadBindings = async ( + overrides: Partial<{ + threadId: string; + channelId: string; + targetSessionKey: string; + agentId: string; + label: string; + webhookId: string; + webhookToken: string; + introText: string; + }> = {}, + ) => { + const threadBindings = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + await threadBindings.bindTarget({ + threadId: "thread-1", + channelId: "parent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + webhookId: "wh_1", + webhookToken: "tok_1", + introText: "", + ...overrides, + }); + return threadBindings; + }; beforeEach(() => { sendMessageDiscordMock.mockClear().mockResolvedValue({ @@ -136,22 +166,7 @@ describe("deliverDiscordReply", () => { }); it("sends bound-session text replies through webhook delivery", async () => { - const threadBindings = createThreadBindingManager({ - accountId: "default", - persist: false, - enableSweeper: false, - }); - await threadBindings.bindTarget({ - threadId: "thread-1", - channelId: "parent-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - agentId: "main", - label: "codex-refactor", - webhookId: "wh_1", - webhookToken: "tok_1", - introText: "", - }); + const threadBindings = await createBoundThreadBindings({ label: "codex-refactor" }); await deliverDiscordReply({ replies: [{ text: "Hello from subagent" }], @@ -179,21 +194,7 @@ describe("deliverDiscordReply", () => { }); it("falls back to bot send when webhook delivery fails", async () => { - const threadBindings = createThreadBindingManager({ - accountId: "default", - persist: false, - enableSweeper: false, - }); - await threadBindings.bindTarget({ - threadId: "thread-1", - channelId: "parent-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - agentId: "main", - webhookId: "wh_1", - webhookToken: "tok_1", - introText: "", - }); + const threadBindings = await createBoundThreadBindings(); sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited")); await deliverDiscordReply({ @@ -217,21 +218,7 @@ describe("deliverDiscordReply", () => { }); it("does not use thread webhook when outbound target is not a bound thread", async () => { - const threadBindings = createThreadBindingManager({ - accountId: "default", - persist: false, - enableSweeper: false, - }); - await threadBindings.bindTarget({ - threadId: "thread-1", - channelId: "parent-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - agentId: "main", - webhookId: "wh_1", - webhookToken: "tok_1", - introText: "", - }); + const threadBindings = await createBoundThreadBindings(); await deliverDiscordReply({ replies: [{ text: "Parent channel delivery" }], diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index 1592eaf71..9da7fdf0f 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -99,6 +99,20 @@ const baseParams = () => ({ removeAckAfterReply: false, }); +type ThreadStarterClient = Parameters[0]["client"]; + +function createThreadStarterRepliesClient( + response: { messages?: Array<{ text?: string; user?: string; ts?: string }> } = { + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + }, +): { replies: ReturnType; client: ThreadStarterClient } { + const replies = vi.fn(async () => response); + const client = { + conversations: { replies }, + } as unknown as ThreadStarterClient; + return { replies, client }; +} + describe("normalizeSlackChannelType", () => { it("infers channel types from ids when missing", () => { expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel"); @@ -185,12 +199,7 @@ describe("resolveSlackThreadStarter cache", () => { }); it("returns cached thread starter without refetching within ttl", async () => { - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; + const { replies, client } = createThreadStarterRepliesClient(); const first = await resolveSlackThreadStarter({ channelId: "C1", @@ -211,12 +220,7 @@ describe("resolveSlackThreadStarter cache", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; + const { replies, client } = createThreadStarterRepliesClient(); await resolveSlackThreadStarter({ channelId: "C1", @@ -234,13 +238,29 @@ describe("resolveSlackThreadStarter cache", () => { expect(replies).toHaveBeenCalledTimes(2); }); + it("does not cache empty starter text", async () => { + const { replies, client } = createThreadStarterRepliesClient({ + messages: [{ text: " ", user: "U1", ts: "1000.1" }], + }); + + const first = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + const second = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(replies).toHaveBeenCalledTimes(2); + }); + it("evicts oldest entries once cache exceeds bounded size", async () => { - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; + const { replies, client } = createThreadStarterRepliesClient(); // Cache cap is 2000; add enough distinct keys to force eviction of earliest keys. for (let i = 0; i <= 2000; i += 1) { diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index 8b2aee9e9..f265c6efb 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -9,6 +9,13 @@ vi.mock("../../auto-reply/commands-registry.js", () => { const reportLongCommand = { key: "reportlong", nativeName: "reportlong" }; const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" }; const periodArg = { name: "period", description: "period" }; + const baseReportPeriodChoices = [ + { value: "day", label: "day" }, + { value: "week", label: "week" }, + { value: "month", label: "month" }, + { value: "quarter", label: "quarter" }, + ]; + const fullReportPeriodChoices = [...baseReportPeriodChoices, { value: "year", label: "year" }]; const hasNonEmptyArgValue = (values: unknown, key: string) => { const raw = typeof values === "object" && values !== null @@ -113,31 +120,18 @@ vi.mock("../../auto-reply/commands-registry.js", () => { }) => { if (params.command?.key === "report") { return resolvePeriodMenu(params, [ - { value: "day", label: "day" }, - { value: "week", label: "week" }, - { value: "month", label: "month" }, - { value: "quarter", label: "quarter" }, - { value: "year", label: "year" }, + ...fullReportPeriodChoices, { value: "all", label: "all" }, ]); } if (params.command?.key === "reportlong") { return resolvePeriodMenu(params, [ - { value: "day", label: "day" }, - { value: "week", label: "week" }, - { value: "month", label: "month" }, - { value: "quarter", label: "quarter" }, - { value: "year", label: "year" }, + ...fullReportPeriodChoices, { value: "x".repeat(90), label: "long" }, ]); } if (params.command?.key === "reportcompact") { - return resolvePeriodMenu(params, [ - { value: "day", label: "day" }, - { value: "week", label: "week" }, - { value: "month", label: "month" }, - { value: "quarter", label: "quarter" }, - ]); + return resolvePeriodMenu(params, baseReportPeriodChoices); } if (params.command?.key === "reportexternal") { return { @@ -320,6 +314,12 @@ function expectArgMenuLayout(respond: ReturnType): { return findFirstActionsBlock(payload) ?? { type: "actions", elements: [] }; } +function expectSingleDispatchedSlashBody(expectedBody: string) { + expect(dispatchMock).toHaveBeenCalledTimes(1); + const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } }; + expect(call.ctx?.Body).toBe(expectedBody); +} + async function runArgMenuAction( handler: (args: unknown) => Promise, params: { @@ -509,9 +509,7 @@ describe("Slack native command argument menus", () => { }, }); - expect(dispatchMock).toHaveBeenCalledTimes(1); - const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } }; - expect(call.ctx?.Body).toBe("/report month"); + expectSingleDispatchedSlashBody("/report month"); }); it("dispatches the command when an overflow option is chosen", async () => { @@ -528,9 +526,7 @@ describe("Slack native command argument menus", () => { }, }); - expect(dispatchMock).toHaveBeenCalledTimes(1); - const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } }; - expect(call.ctx?.Body).toBe("/reportcompact quarter"); + expectSingleDispatchedSlashBody("/reportcompact quarter"); }); it("shows an external_select menu when choices exceed static_select options max", async () => { diff --git a/src/telegram/audit.test.ts b/src/telegram/audit.test.ts index 914c3d7d9..c7524c6ca 100644 --- a/src/telegram/audit.test.ts +++ b/src/telegram/audit.test.ts @@ -3,6 +3,27 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds; let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership; +function mockGetChatMemberStatus(status: string) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, result: { status } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); +} + +async function auditSingleGroup() { + return auditTelegramGroupMembership({ + token: "t", + botId: 123, + groupIds: ["-1001"], + timeoutMs: 5000, + }); +} + describe("telegram audit", () => { beforeAll(async () => { ({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } = @@ -27,42 +48,16 @@ describe("telegram audit", () => { }); it("audits membership via getChatMember", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true, result: { status: "member" } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ), - ); - const res = await auditTelegramGroupMembership({ - token: "t", - botId: 123, - groupIds: ["-1001"], - timeoutMs: 5000, - }); + mockGetChatMemberStatus("member"); + const res = await auditSingleGroup(); expect(res.ok).toBe(true); expect(res.groups[0]?.chatId).toBe("-1001"); expect(res.groups[0]?.status).toBe("member"); }); it("reports bot not in group when status is left", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true, result: { status: "left" } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ), - ); - const res = await auditTelegramGroupMembership({ - token: "t", - botId: 123, - groupIds: ["-1001"], - timeoutMs: 5000, - }); + mockGetChatMemberStatus("left"); + const res = await auditSingleGroup(); expect(res.ok).toBe(false); expect(res.groups[0]?.ok).toBe(false); expect(res.groups[0]?.status).toBe("left"); diff --git a/src/telegram/bot-message-context.audio-transcript.test.ts b/src/telegram/bot-message-context.audio-transcript.test.ts index 663260ca5..4e6a06132 100644 --- a/src/telegram/bot-message-context.audio-transcript.test.ts +++ b/src/telegram/bot-message-context.audio-transcript.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { buildTelegramMessageContext } from "./bot-message-context.js"; +import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const transcribeFirstAudioMock = vi.fn(); @@ -11,39 +11,22 @@ describe("buildTelegramMessageContext audio transcript body", () => { it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => { transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help"); - const ctx = await buildTelegramMessageContext({ - primaryCtx: { - message: { - message_id: 1, - chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, - date: 1700000000, - from: { id: 42, first_name: "Alice" }, - voice: { file_id: "voice-1" }, - }, - me: { id: 7, username: "bot" }, - } as never, + const ctx = await buildTelegramMessageContextForTest({ + message: { + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: undefined, + from: { id: 42, first_name: "Alice" }, + voice: { file_id: "voice-1" }, + }, allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }], - storeAllowFrom: [], options: { forceWasMentioned: true }, - bot: { - api: { - sendChatAction: vi.fn(), - setMessageReaction: vi.fn(), - }, - } as never, cfg: { agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, channels: { telegram: {} }, messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, - } as never, - account: { accountId: "default" } as never, - historyLimit: 0, - groupHistories: new Map(), - dmPolicy: "open", - allowFrom: [], - groupAllowFrom: [], - ackReactionScope: "off", - logger: { info: vi.fn() }, + }, resolveGroupActivation: () => true, resolveGroupRequireMention: () => true, resolveTelegramGroupConfig: () => ({ diff --git a/src/telegram/bot-message-context.sender-prefix.test.ts b/src/telegram/bot-message-context.sender-prefix.test.ts index 2a6a8cd22..f49dd2837 100644 --- a/src/telegram/bot-message-context.sender-prefix.test.ts +++ b/src/telegram/bot-message-context.sender-prefix.test.ts @@ -1,50 +1,17 @@ -import { describe, expect, it, vi } from "vitest"; -import { buildTelegramMessageContext } from "./bot-message-context.js"; +import { describe, expect, it } from "vitest"; +import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; describe("buildTelegramMessageContext sender prefix", () => { - async function buildCtx(params: { - messageId: number; - options?: Record; - }): Promise>> { - return await buildTelegramMessageContext({ - primaryCtx: { - message: { - message_id: params.messageId, - chat: { id: -99, type: "supergroup", title: "Dev Chat" }, - date: 1700000000, - text: "hello", - from: { id: 42, first_name: "Alice" }, - }, - me: { id: 7, username: "bot" }, - } as never, - allMedia: [], - storeAllowFrom: [], - options: params.options ?? {}, - bot: { - api: { - sendChatAction: vi.fn(), - setMessageReaction: vi.fn(), - }, - } as never, - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { telegram: {} }, - messages: { groupChat: { mentionPatterns: [] } }, - } as never, - account: { accountId: "default" } as never, - historyLimit: 0, - groupHistories: new Map(), - dmPolicy: "open", - allowFrom: [], - groupAllowFrom: [], - ackReactionScope: "off", - logger: { info: vi.fn() }, - resolveGroupActivation: () => undefined, - resolveGroupRequireMention: () => false, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: undefined, - }), + async function buildCtx(params: { messageId: number; options?: Record }) { + return await buildTelegramMessageContextForTest({ + message: { + message_id: params.messageId, + chat: { id: -99, type: "supergroup", title: "Dev Chat" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + options: params.options, }); } diff --git a/src/telegram/bot-message-context.test-harness.ts b/src/telegram/bot-message-context.test-harness.ts index 3809bf712..9a1fca9b2 100644 --- a/src/telegram/bot-message-context.test-harness.ts +++ b/src/telegram/bot-message-context.test-harness.ts @@ -9,8 +9,15 @@ export const baseTelegramMessageContextConfig = { type BuildTelegramMessageContextForTestParams = { message: Record; + allMedia?: Array>; options?: Record; + cfg?: Record; resolveGroupActivation?: () => boolean | undefined; + resolveGroupRequireMention?: () => boolean; + resolveTelegramGroupConfig?: () => { + groupConfig?: { requireMention?: boolean }; + topicConfig?: unknown; + }; }; export async function buildTelegramMessageContextForTest( @@ -27,7 +34,7 @@ export async function buildTelegramMessageContextForTest( }, me: { id: 7, username: "bot" }, } as never, - allMedia: [], + allMedia: params.allMedia ?? [], storeAllowFrom: [], options: params.options ?? {}, bot: { @@ -36,7 +43,7 @@ export async function buildTelegramMessageContextForTest( setMessageReaction: vi.fn(), }, } as never, - cfg: baseTelegramMessageContextConfig, + cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never, account: { accountId: "default" } as never, historyLimit: 0, groupHistories: new Map(), @@ -46,10 +53,12 @@ export async function buildTelegramMessageContextForTest( ackReactionScope: "off", logger: { info: vi.fn() }, resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined), - resolveGroupRequireMention: () => false, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: undefined, - }), + resolveGroupRequireMention: params.resolveGroupRequireMention ?? (() => false), + resolveTelegramGroupConfig: + params.resolveTelegramGroupConfig ?? + (() => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + })), }); } diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index ea32380b1..e6d5bf9ad 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -62,6 +62,7 @@ import { } from "./bot/helpers.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; +import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; import { buildTelegramStatusReactionVariants, resolveTelegramAllowedEmojiReactions, @@ -675,13 +676,10 @@ export const buildTelegramMessageContext = async ({ }); } - const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); - const systemPromptParts = [ - groupConfig?.systemPrompt?.trim() || null, - topicConfig?.systemPrompt?.trim() || null, - ].filter((entry): entry is string => Boolean(entry)); - const groupSystemPrompt = - systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ + groupConfig, + topicConfig, + }); const commandBody = normalizeCommandBody(rawBody, { botUsername }); const inboundHistory = isGroup && historyKey && historyLimit > 0 diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index 2076bd47f..d74607700 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -36,6 +36,20 @@ vi.mock("./bot/delivery.js", () => ({ })); describe("registerTelegramNativeCommands", () => { + type RegisteredCommand = { + command: string; + description: string; + }; + + async function waitForRegisteredCommands( + setMyCommands: ReturnType, + ): Promise { + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalled(); + }); + return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[]; + } + beforeEach(() => { listSkillCommandsForAgents.mockClear(); listSkillCommandsForAgents.mockReturnValue([]); @@ -166,14 +180,7 @@ describe("registerTelegramNativeCommands", () => { } as unknown as Parameters[0]["bot"], }); - await vi.waitFor(() => { - expect(setMyCommands).toHaveBeenCalled(); - }); - - const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{ - command: string; - description: string; - }>; + const registeredCommands = await waitForRegisteredCommands(setMyCommands); expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true); expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false); @@ -207,14 +214,7 @@ describe("registerTelegramNativeCommands", () => { } as TelegramAccountConfig, }); - await vi.waitFor(() => { - expect(setMyCommands).toHaveBeenCalled(); - }); - - const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{ - command: string; - description: string; - }>; + const registeredCommands = await waitForRegisteredCommands(setMyCommands); expect(registeredCommands.length).toBeGreaterThan(0); for (const entry of registeredCommands) { diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 8bb4d4a95..17906ebc6 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -41,7 +41,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; +import { isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { buildCappedTelegramMenuCommands, buildPluginTelegramMenuCommands, @@ -64,6 +64,7 @@ import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, } from "./group-access.js"; +import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; import { buildInlineKeyboard } from "./send.js"; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; @@ -552,13 +553,10 @@ export const registerTelegramNativeCommands = ({ }) : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; - const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); - const systemPromptParts = [ - groupConfig?.systemPrompt?.trim() || null, - topicConfig?.systemPrompt?.trim() || null, - ].filter((entry): entry is string => Boolean(entry)); - const groupSystemPrompt = - systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ + groupConfig, + topicConfig, + }); const conversationLabel = isGroup ? msg.chat.title ? `${msg.chat.title} id:${chatId}` diff --git a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.e2e.test.ts b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.e2e.test.ts index 165c000b0..677503a10 100644 --- a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.e2e.test.ts +++ b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.e2e.test.ts @@ -17,6 +17,11 @@ async function createMessageHandlerAndReplySpy() { return { handler, replySpy }; } +function expectSingleReplyPayload(replySpy: ReturnType) { + expect(replySpy).toHaveBeenCalledTimes(1); + return replySpy.mock.calls[0][0] as Record; +} + describe("telegram inbound media", () => { const _INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; it( @@ -40,8 +45,7 @@ describe("telegram inbound media", () => { getFile: async () => ({ file_path: "unused" }), }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; + const payload = expectSingleReplyPayload(replySpy); expect(payload.Body).toContain("Meet here"); expect(payload.Body).toContain("48.858844"); expect(payload.LocationLat).toBe(48.858844); @@ -72,8 +76,7 @@ describe("telegram inbound media", () => { getFile: async () => ({ file_path: "unused" }), }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; + const payload = expectSingleReplyPayload(replySpy); expect(payload.Body).toContain("Eiffel Tower"); expect(payload.LocationName).toBe("Eiffel Tower"); expect(payload.LocationAddress).toBe("Champ de Mars, Paris"); diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index c6d5b944f..2e4290803 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -61,6 +61,16 @@ function mockMediaLoad(fileName: string, contentType: string, data: string) { }); } +function createSendMessageHarness(messageId = 4) { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: messageId, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + return { runtime, sendMessage, bot }; +} + describe("deliverReplies", () => { beforeEach(() => { loadWebMedia.mockReset(); @@ -178,12 +188,7 @@ describe("deliverReplies", () => { }); it("includes message_thread_id for DM topics", async () => { - const runtime = createRuntime(); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 4, - chat: { id: "123" }, - }); - const bot = createBot({ sendMessage }); + const { runtime, sendMessage, bot } = createSendMessageHarness(); await deliverWith({ replies: [{ text: "Hello" }], @@ -202,12 +207,7 @@ describe("deliverReplies", () => { }); it("does not include link_preview_options when linkPreview is true", async () => { - const runtime = createRuntime(); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 4, - chat: { id: "123" }, - }); - const bot = createBot({ sendMessage }); + const { runtime, sendMessage, bot } = createSendMessageHarness(); await deliverWith({ replies: [{ text: "Check https://example.com" }], diff --git a/src/telegram/group-config-helpers.ts b/src/telegram/group-config-helpers.ts new file mode 100644 index 000000000..15f74e3dc --- /dev/null +++ b/src/telegram/group-config-helpers.ts @@ -0,0 +1,19 @@ +import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; +import { firstDefined } from "./bot-access.js"; + +export function resolveTelegramGroupPromptSettings(params: { + groupConfig?: TelegramGroupConfig; + topicConfig?: TelegramTopicConfig; +}): { + skillFilter: string[] | undefined; + groupSystemPrompt: string | undefined; +} { + const skillFilter = firstDefined(params.topicConfig?.skills, params.groupConfig?.skills); + const systemPromptParts = [ + params.groupConfig?.systemPrompt?.trim() || null, + params.topicConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + const groupSystemPrompt = + systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + return { skillFilter, groupSystemPrompt }; +} diff --git a/src/telegram/reaction-level.test.ts b/src/telegram/reaction-level.test.ts index a90f49f20..6cc8e2dd3 100644 --- a/src/telegram/reaction-level.test.ts +++ b/src/telegram/reaction-level.test.ts @@ -2,9 +2,44 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resolveTelegramReactionLevel } from "./reaction-level.js"; +type ReactionResolution = ReturnType; + describe("resolveTelegramReactionLevel", () => { const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + const expectReactionFlags = ( + result: ReactionResolution, + expected: { + level: "off" | "ack" | "minimal" | "extensive"; + ackEnabled: boolean; + agentReactionsEnabled: boolean; + agentReactionGuidance?: "minimal" | "extensive"; + }, + ) => { + expect(result.level).toBe(expected.level); + expect(result.ackEnabled).toBe(expected.ackEnabled); + expect(result.agentReactionsEnabled).toBe(expected.agentReactionsEnabled); + expect(result.agentReactionGuidance).toBe(expected.agentReactionGuidance); + }; + + const expectMinimalFlags = (result: ReactionResolution) => { + expectReactionFlags(result, { + level: "minimal", + ackEnabled: false, + agentReactionsEnabled: true, + agentReactionGuidance: "minimal", + }); + }; + + const expectExtensiveFlags = (result: ReactionResolution) => { + expectReactionFlags(result, { + level: "extensive", + ackEnabled: false, + agentReactionsEnabled: true, + agentReactionGuidance: "extensive", + }); + }; + beforeAll(() => { process.env.TELEGRAM_BOT_TOKEN = "test-token"; }); @@ -23,10 +58,7 @@ describe("resolveTelegramReactionLevel", () => { }; const result = resolveTelegramReactionLevel({ cfg }); - expect(result.level).toBe("minimal"); - expect(result.ackEnabled).toBe(false); - expect(result.agentReactionsEnabled).toBe(true); - expect(result.agentReactionGuidance).toBe("minimal"); + expectMinimalFlags(result); }); it("returns off level with no reactions enabled", () => { @@ -35,10 +67,11 @@ describe("resolveTelegramReactionLevel", () => { }; const result = resolveTelegramReactionLevel({ cfg }); - expect(result.level).toBe("off"); - expect(result.ackEnabled).toBe(false); - expect(result.agentReactionsEnabled).toBe(false); - expect(result.agentReactionGuidance).toBeUndefined(); + expectReactionFlags(result, { + level: "off", + ackEnabled: false, + agentReactionsEnabled: false, + }); }); it("returns ack level with only ackEnabled", () => { @@ -47,10 +80,11 @@ describe("resolveTelegramReactionLevel", () => { }; const result = resolveTelegramReactionLevel({ cfg }); - expect(result.level).toBe("ack"); - expect(result.ackEnabled).toBe(true); - expect(result.agentReactionsEnabled).toBe(false); - expect(result.agentReactionGuidance).toBeUndefined(); + expectReactionFlags(result, { + level: "ack", + ackEnabled: true, + agentReactionsEnabled: false, + }); }); it("returns minimal level with agent reactions enabled and minimal guidance", () => { @@ -59,10 +93,7 @@ describe("resolveTelegramReactionLevel", () => { }; const result = resolveTelegramReactionLevel({ cfg }); - expect(result.level).toBe("minimal"); - expect(result.ackEnabled).toBe(false); - expect(result.agentReactionsEnabled).toBe(true); - expect(result.agentReactionGuidance).toBe("minimal"); + expectMinimalFlags(result); }); it("returns extensive level with agent reactions enabled and extensive guidance", () => { @@ -71,10 +102,7 @@ describe("resolveTelegramReactionLevel", () => { }; const result = resolveTelegramReactionLevel({ cfg }); - expect(result.level).toBe("extensive"); - expect(result.ackEnabled).toBe(false); - expect(result.agentReactionsEnabled).toBe(true); - expect(result.agentReactionGuidance).toBe("extensive"); + expectExtensiveFlags(result); }); it("resolves reaction level from a specific account", () => { @@ -90,10 +118,7 @@ describe("resolveTelegramReactionLevel", () => { }; const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); - expect(result.level).toBe("extensive"); - expect(result.ackEnabled).toBe(false); - expect(result.agentReactionsEnabled).toBe(true); - expect(result.agentReactionGuidance).toBe("extensive"); + expectExtensiveFlags(result); }); it("falls back to global level when account has no reactionLevel", () => { @@ -109,8 +134,6 @@ describe("resolveTelegramReactionLevel", () => { }; const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); - expect(result.level).toBe("minimal"); - expect(result.agentReactionsEnabled).toBe(true); - expect(result.agentReactionGuidance).toBe("minimal"); + expectMinimalFlags(result); }); });