diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index b9fc0c9e9b3..2be5eabe372 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { feishuPlugin } from "../../extensions/feishu/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; +import { parseTelegramTopicConversation } from "../../extensions/telegram/runtime-api.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import * as persistentBindingsResolveModule from "./persistent-bindings.resolve.js"; import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js"; const managerMocks = vi.hoisted(() => ({ @@ -39,6 +39,10 @@ type PersistentBindingsModule = Pick< "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" >; let persistentBindings: PersistentBindingsModule; +let lifecycleBindingsModule: Pick< + typeof import("./persistent-bindings.lifecycle.js"), + "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" +>; type ConfiguredBinding = NonNullable[number]; type BindingRecordInput = Parameters< @@ -58,6 +62,131 @@ const baseCfg = { const defaultDiscordConversationId = "1478836151241412759"; const defaultDiscordAccountId = "default"; +const discordBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const normalized = conversationId.trim(); + return normalized ? { conversationId: normalized } : null; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + if (compiledBinding.conversationId === conversationId) { + return { conversationId, matchPriority: 2 }; + } + if ( + parentConversationId && + parentConversationId !== conversationId && + compiledBinding.conversationId === parentConversationId + ) { + return { conversationId: parentConversationId, matchPriority: 1 }; + } + return null; + }, +}; + +const telegramBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseTelegramTopicConversation({ conversationId }); + if (!parsed || !parsed.chatId.startsWith("-")) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: parsed.chatId, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseTelegramTopicConversation({ + conversationId, + parentConversationId, + }); + if (!incoming || !incoming.chatId.startsWith("-")) { + return null; + } + if (compiledBinding.conversationId !== incoming.canonicalConversationId) { + return null; + } + return { + conversationId: incoming.canonicalConversationId, + parentConversationId: incoming.chatId, + matchPriority: 2, + }; + }, +}; + +function isSupportedFeishuDirectConversationId(conversationId: string): boolean { + const trimmed = conversationId.trim(); + if (!trimmed || trimmed.includes(":")) { + return false; + } + if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { + return false; + } + return true; +} + +const feishuBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseFeishuConversationId({ conversationId }); + if ( + !parsed || + (parsed.scope !== "group_topic" && + parsed.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) + ) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: + parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" + ? parsed.chatId + : undefined, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseFeishuConversationId({ + conversationId, + parentConversationId, + }); + if ( + !incoming || + (incoming.scope !== "group_topic" && + incoming.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId)) + ) { + return null; + } + const matchesCanonicalConversation = + compiledBinding.conversationId === incoming.canonicalConversationId; + const matchesParentTopicForSenderScopedConversation = + incoming.scope === "group_topic_sender" && + compiledBinding.parentConversationId === incoming.chatId && + compiledBinding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`; + if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { + return null; + } + return { + conversationId: matchesParentTopicForSenderScopedConversation + ? compiledBinding.conversationId + : incoming.canonicalConversationId, + parentConversationId: + incoming.scope === "group_topic" || incoming.scope === "group_topic_sender" + ? incoming.chatId + : undefined, + matchPriority: matchesCanonicalConversation ? 2 : 1, + }; + }, +}; + +function createConfiguredBindingTestPlugin( + id: ChannelPlugin["id"], + bindings: ChannelConfiguredBindingProvider, +): Pick { + return { + ...createChannelTestPluginBase({ id }), + bindings, + }; +} + function createCfgWithBindings( bindings: ConfiguredBinding[], overrides?: Partial, @@ -185,20 +314,26 @@ beforeEach(() => { persistentBindingsResolveModule.resolveConfiguredAcpBindingRecord, resolveConfiguredAcpBindingSpecBySessionKey: persistentBindingsResolveModule.resolveConfiguredAcpBindingSpecBySessionKey, - ensureConfiguredAcpBindingSession: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.ensureConfiguredAcpBindingSession(...args); - }, - resetAcpSessionInPlace: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.resetAcpSessionInPlace(...args); - }, + ensureConfiguredAcpBindingSession: lifecycleBindingsModule.ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace: lifecycleBindingsModule.resetAcpSessionInPlace, }; setActivePluginRegistry( createTestRegistry([ - { pluginId: "discord", plugin: discordPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - { pluginId: "feishu", plugin: feishuPlugin, source: "test" }, + { + pluginId: "discord", + plugin: createConfiguredBindingTestPlugin("discord", discordBindings), + source: "test", + }, + { + pluginId: "telegram", + plugin: createConfiguredBindingTestPlugin("telegram", telegramBindings), + source: "test", + }, + { + pluginId: "feishu", + plugin: createConfiguredBindingTestPlugin("feishu", feishuBindings), + source: "test", + }, ]), ); managerMocks.resolveSession.mockReset(); @@ -211,6 +346,10 @@ beforeEach(() => { sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); }); +beforeAll(async () => { + lifecycleBindingsModule = await import("./persistent-bindings.lifecycle.js"); +}); + describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { const cfg = createCfgWithBindings([ diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 515d71726fb..c0eca8d6996 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { discordOutbound, imessageOutbound, @@ -9,7 +7,12 @@ import { telegramOutbound, whatsappOutbound, } from "../../../test/channel-outbounds.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; +import type { + ChannelMessagingAdapter, + ChannelOutboundAdapter, + ChannelPlugin, + ChannelThreadingAdapter, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -28,13 +31,22 @@ const mocks = vi.hoisted(() => ({ sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })), sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })), - sendMessageMattermost: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), + sendMessageMattermost: vi.fn(async (..._args: unknown[]) => ({ + messageId: "m1", + channelId: "c1", + })), deliverOutboundPayloads: vi.fn(), })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, -})); +vi.mock("../../../extensions/discord/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: mocks.sendMessageDiscord, + sendPollDiscord: mocks.sendMessageDiscord, + sendWebhookMessageDiscord: vi.fn(), + }; +}); vi.mock("../../../extensions/imessage/src/send.js", () => ({ sendMessageIMessage: mocks.sendMessageIMessage, })); @@ -44,21 +56,17 @@ vi.mock("../../../extensions/signal/src/send.js", () => ({ vi.mock("../../../extensions/slack/src/send.js", () => ({ sendMessageSlack: mocks.sendMessageSlack, })); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); +vi.mock("../../../extensions/telegram/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageTelegram: mocks.sendMessageTelegram, + }; +}); vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ sendMessageWhatsApp: mocks.sendMessageWhatsApp, sendPollWhatsApp: mocks.sendMessageWhatsApp, })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, - sendPollDiscord: mocks.sendMessageDiscord, - sendWebhookMessageDiscord: vi.fn(), -})); vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({ sendMessageMattermost: mocks.sendMessageMattermost, })); @@ -132,6 +140,47 @@ const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): Chan outbound: params.outbound, }); +const slackMessaging: ChannelMessagingAdapter = { + enableInteractiveReplies: ({ cfg }) => + (cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined) + ?.capabilities?.interactiveReplies === true, + hasStructuredReplyPayload: ({ payload }) => { + const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks; + if (typeof blocks === "string") { + return blocks.trim().length > 0; + } + return Array.isArray(blocks) && blocks.length > 0; + }, +}; + +const slackThreading: ChannelThreadingAdapter = { + resolveReplyTransport: ({ threadId, replyToId }) => ({ + replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined), + threadId: null, + }), +}; + +const mattermostOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async ({ to, text, cfg, accountId, replyToId, threadId }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }); + return { channel: "mattermost", ...result }; + }, + sendMedia: async ({ to, text, cfg, accountId, replyToId, threadId, mediaUrl }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + mediaUrl, + }); + return { channel: "mattermost", ...result }; + }, +}; + async function expectSlackNoSend( payload: Parameters[0]["payload"], overrides: Partial[0]> = {}, @@ -553,8 +602,8 @@ const defaultRegistry = createTestRegistry([ pluginId: "slack", plugin: { ...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), - messaging: slackPlugin.messaging, - threading: slackPlugin.threading, + messaging: slackMessaging, + threading: slackThreading, }, source: "test", }, @@ -595,7 +644,11 @@ const defaultRegistry = createTestRegistry([ }, { pluginId: "mattermost", - plugin: mattermostPlugin, + plugin: createOutboundTestPlugin({ + id: "mattermost", + outbound: mattermostOutbound, + label: "Mattermost", + }), source: "test", }, ]); diff --git a/src/auto-reply/reply/telegram-context.test.ts b/src/auto-reply/reply/telegram-context.test.ts index b38397a1c01..7b58b780180 100644 --- a/src/auto-reply/reply/telegram-context.test.ts +++ b/src/auto-reply/reply/telegram-context.test.ts @@ -1,15 +1,6 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { describe, expect, it } from "vitest"; import { resolveTelegramConversationId } from "./telegram-context.js"; -beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]), - ); -}); - describe("resolveTelegramConversationId", () => { it("builds canonical topic ids from chat target and message thread id", () => { const conversationId = resolveTelegramConversationId({ diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index 459193d0792..153d9e7c424 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -29,13 +29,104 @@ vi.mock("../../../extensions/discord/src/runtime.js", () => ({ }), })); -const { slackPlugin } = await import("../../../extensions/slack/src/channel.js"); const { telegramPlugin } = await import("../../../extensions/telegram/src/channel.js"); const { discordPlugin } = await import("../../../extensions/discord/src/channel.js"); -const { mattermostPlugin } = await import("../../../extensions/mattermost/src/channel.js"); -const { feishuPlugin } = await import("../../../extensions/feishu/src/channel.js"); -const { msteamsPlugin } = await import("../../../extensions/msteams/src/channel.js"); -const { zaloPlugin } = await import("../../../extensions/zalo/src/channel.js"); + +// Keep this matrix focused on capability wiring. The extension packages already +// cover their own full channel/plugin boot paths, so local stubs are enough here. +const slackPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.slack; + const enabled = + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.appToken === "string" && + account.appToken.trim() !== ""; + const capabilities = new Set(); + if (enabled) { + capabilities.add("blocks"); + } + if ( + account?.capabilities && + (account.capabilities as { interactiveReplies?: unknown }).interactiveReplies === true + ) { + capabilities.add("interactive"); + } + return { + actions: enabled ? ["send"] : [], + capabilities: Array.from(capabilities) as Array<"blocks" | "interactive">, + }; + }, + supportsAction: () => true, + }, +}; + +const mattermostPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.mattermost; + const enabled = + account?.enabled !== false && + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.baseUrl === "string" && + account.baseUrl.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["buttons"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const feishuPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.feishu; + const enabled = + account?.enabled !== false && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appSecret === "string" && + account.appSecret.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const msteamsPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.msteams; + const enabled = + account?.enabled !== false && + typeof account?.tenantId === "string" && + account.tenantId.trim() !== "" && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appPassword === "string" && + account.appPassword.trim() !== ""; + return { + actions: enabled ? ["poll"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const zaloPlugin: Pick = { + actions: { + describeMessageTool: () => ({ actions: [], capabilities: [] }), + supportsAction: () => true, + }, +}; describe("channel action capability matrix", () => { afterEach(() => { diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index 83ef8718b0a..e2437c8b667 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { signalPlugin } from "../../extensions/signal/src/channel.js"; +import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { formatGatewayChannelsStatusLines } from "./channels/status.js"; +const signalPlugin = { + ...createChannelTestPluginBase({ id: "signal" }), + status: { + collectStatusIssues: (accounts: Parameters[1]) => + collectStatusIssuesFromLastError("signal", accounts), + }, +}; + describe("channels command", () => { beforeEach(() => { setActivePluginRegistry( diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 29df194cf2d..daeb4e95893 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -5,6 +5,7 @@ import type { ChannelPlugin, } from "../channels/plugins/types.js"; import type { CliDeps } from "../cli/deps.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { captureEnv } from "../test-utils/env.js"; @@ -69,21 +70,17 @@ vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); +import { messageCommand } from "./message.js"; + let envSnapshot: ReturnType; +const EMPTY_TEST_REGISTRY = createTestRegistry([]); -const setRegistry = async (registry: ReturnType) => { - const { setActivePluginRegistry } = await import("../plugins/runtime.js"); - setActivePluginRegistry(registry); -}; - -beforeEach(async () => { - vi.resetModules(); +beforeEach(() => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN"]); process.env.TELEGRAM_BOT_TOKEN = ""; process.env.DISCORD_BOT_TOKEN = ""; testConfig = {}; - ({ messageCommand } = await import("./message.js")); - await setRegistry(createTestRegistry([])); + setActivePluginRegistry(EMPTY_TEST_REGISTRY); callGatewayMock.mockClear(); webAuthExists.mockClear().mockResolvedValue(false); handleDiscordAction.mockClear(); @@ -197,8 +194,6 @@ const createTelegramPollPluginRegistration = () => ({ }), }); -let messageCommand: typeof import("./message.js").messageCommand; - function createTelegramSecretRawConfig() { return { channels: { @@ -247,7 +242,7 @@ async function runTelegramDirectOutboundSend(params: { messageId: "msg-2", chatId: "123456", })); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", @@ -288,7 +283,7 @@ describe("messageCommand", () => { rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -379,7 +374,7 @@ describe("messageCommand", () => { it("defaults channel when only one configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -401,7 +396,7 @@ describe("messageCommand", () => { it("requires channel when multiple configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; process.env.DISCORD_BOT_TOKEN = "token-discord"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -426,7 +421,7 @@ describe("messageCommand", () => { it("sends via gateway for WhatsApp", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", @@ -456,7 +451,7 @@ describe("messageCommand", () => { }); it("routes discord polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createDiscordPollPluginRegistration(), @@ -485,7 +480,7 @@ describe("messageCommand", () => { }); it("routes telegram polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramPollPluginRegistration(), diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 2dfc1c97dbd..4e1f0b003e2 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/src/exec-approvals.js"; +import { buildTelegramExecApprovalButtons } from "../../extensions/telegram/src/approval-buttons.js"; +import { isTelegramExecApprovalClientEnabled } from "../../extensions/telegram/src/exec-approvals.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { buildExecApprovalPendingReplyPayload } from "../infra/exec-approval-reply.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; const baseRequest = { @@ -23,15 +26,65 @@ afterEach(() => { }); const emptyRegistry = createTestRegistry([]); +const telegramApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "telegram" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target, request }) => { + if (target.channel !== "telegram" || request.request.turnSourceChannel !== "telegram") { + return false; + } + const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim(); + return isTelegramExecApprovalClientEnabled({ cfg, accountId }); + }, + buildPendingPayload: ({ request, nowMs }) => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { buttons }, + }, + }; + }, + }, +}; +const discordApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target }) => + target.channel === "discord" && + isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), + }, +}; const defaultRegistry = createTestRegistry([ { pluginId: "telegram", - plugin: telegramPlugin, + plugin: telegramApprovalPlugin, source: "test", }, { pluginId: "discord", - plugin: discordPlugin, + plugin: discordApprovalPlugin, source: "test", }, ]); diff --git a/src/infra/outbound/channel-adapters.test.ts b/src/infra/outbound/channel-adapters.test.ts index ca39b403226..7656c879b3b 100644 --- a/src/infra/outbound/channel-adapters.test.ts +++ b/src/infra/outbound/channel-adapters.test.ts @@ -1,15 +1,42 @@ -import { Separator, TextDisplay } from "@buape/carbon"; +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { getChannelMessageAdapter } from "./channel-adapters.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + describe("getChannelMessageAdapter", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); @@ -31,10 +58,10 @@ describe("getChannelMessageAdapter", () => { cfg: {} as never, accountId: "primary", }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); - expect(container).toBeInstanceOf(DiscordUiContainer); + expect(container).toBeInstanceOf(TestDiscordUiContainer); expect(container?.components).toEqual([ expect.any(TextDisplay), expect.any(Separator), @@ -49,7 +76,7 @@ describe("getChannelMessageAdapter", () => { message: " ", cfg: {} as never, }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); expect(container?.components).toEqual([expect.any(TextDisplay)]); diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index 3442711eab4..309a237af52 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -27,28 +27,67 @@ function createToolContext( }; } +function resolveSlackAutoThreadId(params: { + to: string; + toolContext?: { + currentChannelId?: string; + currentThreadTs?: string; + replyToMode?: "off" | "first" | "all"; + hasRepliedRef?: { value: boolean }; + }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + if (context.replyToMode !== "all" && context.replyToMode !== "first") { + return undefined; + } + const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); + if (!parsedTarget || parsedTarget.kind !== "channel") { + return undefined; + } + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { + return undefined; + } + if (context.replyToMode === "first" && context.hasRepliedRef?.value) { + return undefined; + } + return context.currentThreadTs; +} + +function resolveTelegramAutoThreadId(params: { + to: string; + toolContext?: { currentThreadTs?: string; currentChannelId?: string }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + const parsedTo = parseTelegramTarget(params.to); + const parsedChannel = parseTelegramTarget(context.currentChannelId); + if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { + return undefined; + } + return context.currentThreadTs; +} + describe("message action threading helpers", () => { it("resolves Slack auto-thread ids only for matching active channels", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "#c123", toolContext: createToolContext(), }), ).toBe("thread-1"); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "channel:C999", toolContext: createToolContext(), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "user:U123", toolContext: createToolContext(), }), @@ -57,9 +96,7 @@ describe("message action threading helpers", () => { it("skips Slack auto-thread ids when reply mode or context blocks them", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "first", @@ -68,17 +105,13 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "off" }), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ currentThreadTs: undefined }), }), @@ -87,9 +120,7 @@ describe("message action threading helpers", () => { it("resolves Telegram auto-thread ids for matching chats across target formats", () => { expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "telegram:group:-100123:topic:77", toolContext: createToolContext({ currentChannelId: "tg:group:-100123", @@ -97,9 +128,7 @@ describe("message action threading helpers", () => { }), ).toBe("thread-1"); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100999:77", toolContext: createToolContext({ currentChannelId: "-100123", @@ -107,9 +136,7 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100123", toolContext: createToolContext({ currentChannelId: undefined }), }), diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 43e71afb923..72abac24d58 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -1,8 +1,12 @@ +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, @@ -10,6 +14,29 @@ import { shouldApplyCrossContextMarker, } from "./outbound-policy.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + const slackConfig = { channels: { slack: { @@ -28,7 +55,9 @@ const discordConfig = { describe("outbound policy helpers", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index f90fc7f221e..006a160e6ab 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -44,9 +43,7 @@ import { import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); describe("delivery-queue", () => { diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 9f10ae7fe81..51997a53fff 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { __testing, @@ -21,9 +20,7 @@ async function importCommandsModule(cacheBust: string): Promise } beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); afterEach(() => {