Discord: CV2! (#16364)

This commit is contained in:
Shadow
2026-02-15 10:24:53 -06:00
committed by GitHub
parent 95355ba25a
commit 9203a2fdb1
22 changed files with 753 additions and 225 deletions

View File

@@ -137,6 +137,34 @@ describe("exec approval forwarder", () => {
expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```");
});
it("skips discord forwarding when discord exec approvals target channel", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
channels: {
discord: {
execApprovals: {
enabled: true,
target: "channel",
approvers: ["123"],
},
},
},
} as OpenClawConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
});
await forwarder.handleRequested(baseRequest);
expect(deliver).not.toHaveBeenCalled();
});
it("uses a longer fence when command already contains triple backticks", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);

View File

@@ -98,6 +98,15 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string {
return [channel, target.to, accountId, threadId].join(":");
}
function shouldSkipDiscordForwarding(cfg: OpenClawConfig): boolean {
const discordConfig = cfg.channels?.discord?.execApprovals;
if (!discordConfig?.enabled) {
return false;
}
const target = discordConfig.target ?? "dm";
return target === "channel" || target === "both";
}
function formatApprovalCommand(command: string): { inline: boolean; text: string } {
if (!command.includes("\n") && !command.includes("`")) {
return { inline: true, text: `\`${command}\`` };
@@ -265,7 +274,11 @@ export function createExecApprovalForwarder(
}
}
if (targets.length === 0) {
const filteredTargets = shouldSkipDiscordForwarding(cfg)
? targets.filter((target) => normalizeMessageChannel(target.channel) !== "discord")
: targets;
if (filteredTargets.length === 0) {
return;
}
@@ -283,7 +296,7 @@ export function createExecApprovalForwarder(
}, expiresInMs);
timeoutId.unref?.();
const pendingEntry: PendingApproval = { request, targets, timeoutId };
const pendingEntry: PendingApproval = { request, targets: filteredTargets, timeoutId };
pending.set(request.id, pendingEntry);
if (pending.get(request.id) !== pendingEntry) {
@@ -293,7 +306,7 @@ export function createExecApprovalForwarder(
const text = buildRequestMessage(request, nowMs());
await deliverToTargets({
cfg,
targets,
targets: filteredTargets,
text,
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,

View File

@@ -1,20 +1,50 @@
import { Separator, TextDisplay, type TopLevelComponents } from "@buape/carbon";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { DiscordUiContainer } from "../../discord/ui.js";
export type CrossContextComponentsBuilder = (message: string) => TopLevelComponents[];
export type CrossContextComponentsFactory = (params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) => TopLevelComponents[];
export type ChannelMessageAdapter = {
supportsEmbeds: boolean;
buildCrossContextEmbeds?: (originLabel: string) => unknown[];
supportsComponentsV2: boolean;
buildCrossContextComponents?: CrossContextComponentsFactory;
};
type CrossContextContainerParams = {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
};
class CrossContextContainer extends DiscordUiContainer {
constructor({ originLabel, message, cfg, accountId }: CrossContextContainerParams) {
const trimmed = message.trim();
const components = [] as Array<TextDisplay | Separator>;
if (trimmed) {
components.push(new TextDisplay(message));
components.push(new Separator({ divider: true, spacing: "small" }));
}
components.push(new TextDisplay(`*From ${originLabel}*`));
super({ cfg, accountId, components });
}
}
const DEFAULT_ADAPTER: ChannelMessageAdapter = {
supportsEmbeds: false,
supportsComponentsV2: false,
};
const DISCORD_ADAPTER: ChannelMessageAdapter = {
supportsEmbeds: true,
buildCrossContextEmbeds: (originLabel: string) => [
{
description: `From ${originLabel}`,
},
supportsComponentsV2: true,
buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => [
new CrossContextContainer({ originLabel, message, cfg, accountId }),
],
};

View File

@@ -161,21 +161,21 @@ function applyCrossContextMessageDecoration({
params,
message,
decoration,
preferEmbeds,
preferComponents,
}: {
params: Record<string, unknown>;
message: string;
decoration: CrossContextDecoration;
preferEmbeds: boolean;
preferComponents: boolean;
}): string {
const applied = applyCrossContextDecoration({
message,
decoration,
preferEmbeds,
preferComponents,
});
params.message = applied.message;
if (applied.embeds?.length) {
params.embeds = applied.embeds;
if (applied.componentsBuilder) {
params.components = applied.componentsBuilder;
}
return applied.message;
}
@@ -189,7 +189,7 @@ async function maybeApplyCrossContextMarker(params: {
accountId?: string | null;
args: Record<string, unknown>;
message: string;
preferEmbeds: boolean;
preferComponents: boolean;
}): Promise<string> {
if (!shouldApplyCrossContextMarker(params.action) || !params.toolContext) {
return params.message;
@@ -208,7 +208,7 @@ async function maybeApplyCrossContextMarker(params: {
params: params.args,
message: params.message,
decoration,
preferEmbeds: params.preferEmbeds,
preferComponents: params.preferComponents,
});
}
@@ -454,7 +454,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
accountId,
args: params,
message,
preferEmbeds: true,
preferComponents: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
@@ -601,7 +601,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
accountId,
args: params,
message: base,
preferEmbeds: true,
preferComponents: false,
});
const poll = await executePollAction({

View File

@@ -70,7 +70,7 @@ describe("outbound policy", () => {
).toThrow(/Cross-context messaging denied/);
});
it("uses embeds when available and preferred", async () => {
it("uses components when available and preferred", async () => {
const decoration = await buildCrossContextDecoration({
cfg: discordConfig,
channel: "discord",
@@ -82,11 +82,12 @@ describe("outbound policy", () => {
const applied = applyCrossContextDecoration({
message: "hello",
decoration: decoration!,
preferEmbeds: true,
preferComponents: true,
});
expect(applied.usedEmbeds).toBe(true);
expect(applied.embeds?.length).toBeGreaterThan(0);
expect(applied.usedComponents).toBe(true);
expect(applied.componentsBuilder).toBeDefined();
expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0);
expect(applied.message).toBe("hello");
});
});

View File

@@ -4,14 +4,17 @@ import type {
ChannelThreadingToolContext,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getChannelMessageAdapter } from "./channel-adapters.js";
import {
getChannelMessageAdapter,
type CrossContextComponentsBuilder,
} from "./channel-adapters.js";
import { normalizeTargetForProvider } from "./target-normalization.js";
import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
export type CrossContextDecoration = {
prefix: string;
suffix: string;
embeds?: unknown[];
componentsBuilder?: CrossContextComponentsBuilder;
};
const CONTEXT_GUARDED_ACTIONS = new Set<ChannelMessageActionName>([
@@ -177,11 +180,19 @@ export async function buildCrossContextDecoration(params: {
const suffix = suffixTemplate.replaceAll("{channel}", originLabel);
const adapter = getChannelMessageAdapter(params.channel);
const embeds = adapter.supportsEmbeds
? (adapter.buildCrossContextEmbeds?.(originLabel) ?? undefined)
const componentsBuilder = adapter.supportsComponentsV2
? adapter.buildCrossContextComponents
? (message: string) =>
adapter.buildCrossContextComponents!({
originLabel,
message,
cfg: params.cfg,
accountId: params.accountId ?? undefined,
})
: undefined
: undefined;
return { prefix, suffix, embeds };
return { prefix, suffix, componentsBuilder };
}
export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean {
@@ -191,12 +202,20 @@ export function shouldApplyCrossContextMarker(action: ChannelMessageActionName):
export function applyCrossContextDecoration(params: {
message: string;
decoration: CrossContextDecoration;
preferEmbeds: boolean;
}): { message: string; embeds?: unknown[]; usedEmbeds: boolean } {
const useEmbeds = params.preferEmbeds && params.decoration.embeds?.length;
if (useEmbeds) {
return { message: params.message, embeds: params.decoration.embeds, usedEmbeds: true };
preferComponents: boolean;
}): {
message: string;
componentsBuilder?: CrossContextComponentsBuilder;
usedComponents: boolean;
} {
const useComponents = params.preferComponents && params.decoration.componentsBuilder;
if (useComponents) {
return {
message: params.message,
componentsBuilder: params.decoration.componentsBuilder,
usedComponents: true,
};
}
const message = `${params.decoration.prefix}${params.message}${params.decoration.suffix}`;
return { message, usedEmbeds: false };
return { message, usedComponents: false };
}