From 70aa9204c0f55bc722d678d40d238c29cbc7b3d2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:32:47 -0700 Subject: [PATCH] Channels: centralize inbound context contracts --- .../message-handler.inbound-contract.test.ts | 2 +- .../event-handler.inbound-contract.test.ts | 2 +- .../monitor/message-handler/prepare.test.ts | 2 +- extensions/telegram/src/bot.test.ts | 2 +- .../process-message.inbound-contract.test.ts | 2 +- src/auto-reply/reply/reply-flow.test.ts | 2 +- .../inbound.discord.contract.test.ts | 24 +++++ .../contracts/inbound.signal.contract.test.ts | 73 ++++++++++++++ .../contracts/inbound.slack.contract.test.ts | 54 +++++++++++ .../inbound.telegram.contract.test.ts | 60 ++++++++++++ .../inbound.whatsapp.contract.test.ts | 97 +++++++++++++++++++ test/helpers/inbound-contract.ts | 19 ---- 12 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 src/channels/plugins/contracts/inbound.discord.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.signal.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.slack.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.telegram.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts delete mode 100644 test/helpers/inbound-contract.ts diff --git a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts index 97d18985460..6421d24a61a 100644 --- a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts +++ b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import { processDiscordMessage } from "./message-handler.process.js"; import { diff --git a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts index 62593156756..9a6cfc0e90e 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../../../../src/auto-reply/templating.js"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; import { createSignalEventHandler } from "./event-handler.js"; import { createBaseSignalEventHandlerDeps, diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index a6858e529af..a57614afaeb 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -3,10 +3,10 @@ import os from "node:os"; import path from "node:path"; import type { App } from "@slack/bolt"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js"; import type { OpenClawConfig } from "../../../../../src/config/config.js"; import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js"; -import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 9468f64c789..17f6870a964 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,12 +1,12 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js"; import { clearPluginInteractiveHandlers, registerPluginInteractiveHandler, } from "../../../src/plugins/interactive.js"; import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { answerCallbackQuerySpy, commandSpy, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts index 238c675e12d..566c8a76e1e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js"; let capturedCtx: unknown; let capturedDispatchParams: unknown; diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index d9e985c8b31..21a22faf8b2 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../../test/helpers/import-fresh.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../channels/plugins/contracts/suites.js"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import type { MsgContext } from "../templating.js"; diff --git a/src/channels/plugins/contracts/inbound.discord.contract.test.ts b/src/channels/plugins/contracts/inbound.discord.contract.test.ts new file mode 100644 index 00000000000..6b168f7d244 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.discord.contract.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { inboundCtxCapture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const { processDiscordMessage } = + await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); +const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = + await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); + +describe("discord inbound contract", () => { + it("keeps inbound context finalized", async () => { + inboundCtxCapture.ctx = undefined; + const messageCtx = await createBaseDiscordMessageContext({ + cfg: { messages: {} }, + ackReactionScope: "direct", + ...createDiscordDirectMessageContextOverrides(), + }); + + await processDiscordMessage(messageCtx); + + expect(inboundCtxCapture.ctx).toBeTruthy(); + expectChannelInboundContextContract(inboundCtxCapture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.signal.contract.test.ts b/src/channels/plugins/contracts/inbound.signal.contract.test.ts new file mode 100644 index 00000000000..abec31c0174 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.signal.contract.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createSignalEventHandler } from "../../../../extensions/signal/src/monitor/event-handler.js"; +import { + createBaseSignalEventHandlerDeps, + createSignalReceiveEvent, +} from "../../../../extensions/signal/src/monitor/event-handler.test-harness.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const capture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined })); +const dispatchInboundMessageMock = vi.hoisted(() => + vi.fn( + async (params: { + ctx: MsgContext; + replyOptions?: { onReplyStart?: () => void | Promise }; + }) => { + capture.ctx = params.ctx; + await Promise.resolve(params.replyOptions?.onReplyStart?.()); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }, + ), +); + +vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: dispatchInboundMessageMock, + dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, + }; +}); + +vi.mock("../../../../extensions/signal/src/send.js", () => ({ + sendMessageSignal: vi.fn(), + sendTypingSignal: vi.fn(async () => true), + sendReadReceiptSignal: vi.fn(async () => true), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: vi.fn().mockResolvedValue([]), + upsertChannelPairingRequest: vi.fn(), +})); + +describe("signal inbound contract", () => { + beforeEach(() => { + capture.ctx = undefined; + dispatchInboundMessageMock.mockClear(); + }); + + it("keeps inbound context finalized", async () => { + const handler = createSignalEventHandler( + createBaseSignalEventHandlerDeps({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: { inbound: { debounceMs: 0 } } } as any, + historyLimit: 0, + }), + ); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + message: "hi", + attachments: [], + groupInfo: { groupId: "g1", groupName: "Test Group" }, + }, + }), + ); + + expect(capture.ctx).toBeTruthy(); + expectChannelInboundContextContract(capture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.slack.contract.test.ts b/src/channels/plugins/contracts/inbound.slack.contract.test.ts new file mode 100644 index 00000000000..e013bed3b4f --- /dev/null +++ b/src/channels/plugins/contracts/inbound.slack.contract.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; +import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js"; +import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"; +import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { + return { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + userTokenSource: "none", + config, + replyToMode: config.replyToMode, + replyToModeByChatType: config.replyToModeByChatType, + dm: config.dm, + }; +} + +function createSlackMessage(overrides: Partial): SlackMessageEvent { + return { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hi", + ts: "1.000", + ...overrides, + } as SlackMessageEvent; +} + +describe("slack inbound contract", () => { + it("keeps inbound context finalized", async () => { + const ctx = createInboundSlackTestContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as OpenClawConfig, + }); + // oxlint-disable-next-line typescript/no-explicit-any + ctx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const prepared = await prepareSlackMessage({ + ctx, + account: createSlackAccount(), + message: createSlackMessage({}), + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + expectChannelInboundContextContract(prepared!.ctxPayload); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts new file mode 100644 index 00000000000..a872964bd53 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getLoadConfigMock, + getOnHandler, + onSpy, + replySpy, +} from "../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js"); + +describe("telegram inbound contract", () => { + const loadConfig = getLoadConfigMock(); + + beforeEach(() => { + onSpy.mockClear(); + replySpy.mockClear(); + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + } satisfies OpenClawConfig); + }); + + it("keeps inbound context finalized", async () => { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + const payload = replySpy.mock.calls[0]?.[0] as MsgContext | undefined; + expect(payload).toBeTruthy(); + expectChannelInboundContextContract(payload!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts new file mode 100644 index 00000000000..c36c2d50fc8 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { processMessage } from "../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const capture = vi.hoisted(() => ({ + ctx: undefined as MsgContext | undefined, +})); + +vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { + capture.ctx = params.ctx; + return { queuedFinal: false }; + }), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/monitor/last-route.js", () => ({ + trackBackgroundTask: (tasks: Set>, task: Promise) => { + tasks.add(task); + void task.finally(() => { + tasks.delete(task); + }); + }, + updateLastRouteInBackground: vi.fn(), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => ({ + deliverWebReply: vi.fn(async () => {}), +})); + +function makeProcessArgs(sessionStorePath: string) { + return { + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: {}, session: { store: sessionStorePath } } as any, + // oxlint-disable-next-line typescript/no-explicit-any + msg: { + id: "msg1", + from: "123@g.us", + to: "+15550001111", + chatType: "group", + body: "hi", + senderName: "Alice", + senderJid: "alice@s.whatsapp.net", + senderE164: "+15550002222", + groupSubject: "Test Group", + groupParticipants: [], + } as unknown as Record, + route: { + agentId: "main", + accountId: "default", + sessionKey: "agent:main:whatsapp:group:123", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + groupHistoryKey: "123@g.us", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + // oxlint-disable-next-line typescript/no-explicit-any + replyResolver: (async () => undefined) as any, + // oxlint-disable-next-line typescript/no-explicit-any + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set>(), + rememberSentText: () => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + groupHistory: [], + // oxlint-disable-next-line typescript/no-explicit-any + } as any; +} + +describe("whatsapp inbound contract", () => { + let sessionDir = ""; + + afterEach(async () => { + capture.ctx = undefined; + if (sessionDir) { + await fs.rm(sessionDir, { recursive: true, force: true }); + sessionDir = ""; + } + }); + + it("keeps inbound context finalized", async () => { + sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-")); + const sessionStorePath = path.join(sessionDir, "sessions.json"); + + await processMessage(makeProcessArgs(sessionStorePath)); + + expect(capture.ctx).toBeTruthy(); + expectChannelInboundContextContract(capture.ctx!); + }); +}); diff --git a/test/helpers/inbound-contract.ts b/test/helpers/inbound-contract.ts deleted file mode 100644 index 4ac4c2cc516..00000000000 --- a/test/helpers/inbound-contract.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect } from "vitest"; -import type { MsgContext } from "../../src/auto-reply/templating.js"; -import { normalizeChatType } from "../../src/channels/chat-type.js"; -import { resolveConversationLabel } from "../../src/channels/conversation-label.js"; -import { validateSenderIdentity } from "../../src/channels/sender-identity.js"; - -export function expectInboundContextContract(ctx: MsgContext) { - expect(validateSenderIdentity(ctx)).toEqual([]); - - expect(ctx.Body).toBeTypeOf("string"); - expect(ctx.BodyForAgent).toBeTypeOf("string"); - expect(ctx.BodyForCommands).toBeTypeOf("string"); - - const chatType = normalizeChatType(ctx.ChatType); - if (chatType && chatType !== "direct") { - const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); - expect(label).toBeTruthy(); - } -}