mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 22:11:23 +00:00
Discord: CV2! (#16364)
This commit is contained in:
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user