test: trim plugin-heavy unit test imports

This commit is contained in:
Peter Steinberger 2026-03-20 18:35:09 +00:00
parent 740b345a2e
commit b26edfe1ff
12 changed files with 532 additions and 125 deletions

View File

@ -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<OpenClawConfig["bindings"]>[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<ChannelPlugin, "id" | "meta" | "capabilities" | "config" | "bindings"> {
return {
...createChannelTestPluginBase({ id }),
bindings,
};
}
function createCfgWithBindings(
bindings: ConfiguredBinding[],
overrides?: Partial<OpenClawConfig>,
@ -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([

View File

@ -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<typeof import("../../../extensions/discord/src/send.js")>();
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<typeof import("../../../extensions/telegram/src/send.js")>();
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<typeof routeReply>[0]["payload"],
overrides: Partial<Parameters<typeof routeReply>[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",
},
]);

View File

@ -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({

View File

@ -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<ChannelPlugin, "actions"> = {
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<string>();
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<ChannelPlugin, "actions"> = {
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<ChannelPlugin, "actions"> = {
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<ChannelPlugin, "actions"> = {
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<ChannelPlugin, "actions"> = {
actions: {
describeMessageTool: () => ({ actions: [], capabilities: [] }),
supportsAction: () => true,
},
};
describe("channel action capability matrix", () => {
afterEach(() => {

View File

@ -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<typeof collectStatusIssuesFromLastError>[1]) =>
collectStatusIssuesFromLastError("signal", accounts),
},
};
describe("channels command", () => {
beforeEach(() => {
setActivePluginRegistry(

View File

@ -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<typeof captureEnv>;
const EMPTY_TEST_REGISTRY = createTestRegistry([]);
const setRegistry = async (registry: ReturnType<typeof createTestRegistry>) => {
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<string, unknown>,
resolvedConfig: resolvedConfig as unknown as Record<string, unknown>,
});
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(),

View File

@ -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",
},
]);

View File

@ -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<TextDisplay | Separator> = [];
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)]);

View File

@ -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 }),
}),

View File

@ -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<TextDisplay | Separator> = [];
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" },
]),
);
});

View File

@ -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", () => {

View File

@ -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<CommandsModule>
}
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
);
setActivePluginRegistry(createTestRegistry([]));
});
afterEach(() => {