mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 20:01:36 +00:00
refactor: split approval auth delivery and rendering
This commit is contained in:
@@ -154,7 +154,7 @@ describe("discordPlugin outbound", () => {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
};
|
||||
const payload = discordPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
const payload = discordPlugin.execApprovals?.render?.plugin?.buildPendingPayload?.({
|
||||
cfg,
|
||||
request: createPluginApprovalRequest(),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
@@ -179,7 +179,7 @@ describe("discordPlugin outbound", () => {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
};
|
||||
const payload = discordPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
const payload = discordPlugin.execApprovals?.render?.plugin?.buildPendingPayload?.({
|
||||
cfg,
|
||||
request: createPluginApprovalRequest({
|
||||
title: "Heads up @everyone <@123> <@&456>",
|
||||
@@ -210,7 +210,7 @@ describe("discordPlugin outbound", () => {
|
||||
});
|
||||
|
||||
it("falls back to non-interactive plugin approval pending payload when Discord exec approvals are disabled", () => {
|
||||
const payload = discordPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
const payload = discordPlugin.execApprovals?.render?.plugin?.buildPendingPayload?.({
|
||||
cfg: createCfg(),
|
||||
request: createPluginApprovalRequest(),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
@@ -230,7 +230,7 @@ describe("discordPlugin outbound", () => {
|
||||
});
|
||||
|
||||
it("builds rich plugin approval resolved payloads when request snapshot is available", () => {
|
||||
const payload = discordPlugin.execApprovals?.buildPluginResolvedPayload?.({
|
||||
const payload = discordPlugin.execApprovals?.render?.plugin?.buildResolvedPayload?.({
|
||||
cfg: createCfg(),
|
||||
resolved: createPluginApprovalResolved(createPluginApprovalRequest().request),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
@@ -245,7 +245,7 @@ describe("discordPlugin outbound", () => {
|
||||
});
|
||||
|
||||
it("falls back to plain text plugin resolved payload when request snapshot is missing", () => {
|
||||
const payload = discordPlugin.execApprovals?.buildPluginResolvedPayload?.({
|
||||
const payload = discordPlugin.execApprovals?.render?.plugin?.buildResolvedPayload?.({
|
||||
cfg: createCfg(),
|
||||
resolved: createPluginApprovalResolved(undefined),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
createNestedAllowlistOverrideResolver,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import {
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
createApproverRestrictedNativeApprovalAdapter,
|
||||
buildPluginApprovalRequestMessage,
|
||||
buildPluginApprovalResolvedMessage,
|
||||
buildPluginApprovalResolvedReplyPayload,
|
||||
type PluginApprovalRequest,
|
||||
type PluginApprovalResolved,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
@@ -300,6 +302,20 @@ function buildDiscordCrossContextComponents(params: {
|
||||
return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })];
|
||||
}
|
||||
|
||||
const discordNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
|
||||
channel: "discord",
|
||||
channelLabel: "Discord",
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
getDiscordExecApprovalApprovers({ cfg, accountId }).length > 0,
|
||||
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isDiscordExecApprovalApprover({ cfg, accountId, senderId }),
|
||||
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
|
||||
isDiscordExecApprovalClientEnabled({ cfg, accountId }),
|
||||
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
|
||||
resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ?? "dm",
|
||||
});
|
||||
|
||||
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
|
||||
resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds,
|
||||
outerLabel: (guildKey) => `guild ${guildKey}`,
|
||||
@@ -480,73 +496,58 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
},
|
||||
},
|
||||
execApprovals: {
|
||||
...createApproverRestrictedNativeApprovalAdapter({
|
||||
channel: "discord",
|
||||
channelLabel: "Discord",
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
getDiscordExecApprovalApprovers({ cfg, accountId }).length > 0,
|
||||
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isDiscordExecApprovalApprover({ cfg, accountId, senderId }),
|
||||
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
|
||||
isDiscordExecApprovalClientEnabled({ cfg, accountId }),
|
||||
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
|
||||
resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ?? "dm",
|
||||
}),
|
||||
shouldSuppressLocalPrompt: ({ cfg, accountId, payload }) =>
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId,
|
||||
payload,
|
||||
}),
|
||||
buildPluginPendingPayload: ({ cfg, request, target, nowMs }) => {
|
||||
const text = formatDiscordApprovalPreview(
|
||||
buildPluginApprovalRequestMessage(request, nowMs),
|
||||
10_000,
|
||||
);
|
||||
const execApproval = {
|
||||
approvalId: request.id,
|
||||
approvalSlug: request.id.slice(0, 8),
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"] as const,
|
||||
};
|
||||
const normalizedChannel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const interactiveEnabled =
|
||||
normalizedChannel === "discord" &&
|
||||
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId });
|
||||
if (!interactiveEnabled) {
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
discord: {
|
||||
components: buildDiscordPluginPendingComponentSpec({ request }),
|
||||
},
|
||||
},
|
||||
};
|
||||
auth: discordNativeApprovalAdapter.auth,
|
||||
delivery: {
|
||||
...discordNativeApprovalAdapter.delivery,
|
||||
shouldSuppressLocalPrompt: ({ cfg, accountId, payload }) =>
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId,
|
||||
payload,
|
||||
}),
|
||||
},
|
||||
buildPluginResolvedPayload: ({ resolved }) => {
|
||||
const componentSpec = buildDiscordPluginResolvedComponentSpec({ resolved });
|
||||
const text = formatDiscordApprovalPreview(
|
||||
buildPluginApprovalResolvedMessage(resolved),
|
||||
10_000,
|
||||
);
|
||||
return componentSpec
|
||||
? {
|
||||
text,
|
||||
channelData: {
|
||||
discord: {
|
||||
components: componentSpec,
|
||||
},
|
||||
},
|
||||
}
|
||||
: { text };
|
||||
render: {
|
||||
plugin: {
|
||||
buildPendingPayload: ({ cfg, request, target, nowMs }) => {
|
||||
const normalizedChannel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const interactiveEnabled =
|
||||
normalizedChannel === "discord" &&
|
||||
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId });
|
||||
return buildPluginApprovalPendingReplyPayload({
|
||||
request,
|
||||
nowMs,
|
||||
text: formatDiscordApprovalPreview(
|
||||
buildPluginApprovalRequestMessage(request, nowMs),
|
||||
10_000,
|
||||
),
|
||||
approvalSlug: request.id.slice(0, 8),
|
||||
channelData: interactiveEnabled
|
||||
? {
|
||||
discord: {
|
||||
components: buildDiscordPluginPendingComponentSpec({ request }),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
buildResolvedPayload: ({ resolved }) => {
|
||||
const componentSpec = buildDiscordPluginResolvedComponentSpec({ resolved });
|
||||
return buildPluginApprovalResolvedReplyPayload({
|
||||
resolved,
|
||||
text: formatDiscordApprovalPreview(
|
||||
buildPluginApprovalResolvedMessage(resolved),
|
||||
10_000,
|
||||
),
|
||||
channelData: componentSpec
|
||||
? {
|
||||
discord: {
|
||||
components: componentSpec,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
|
||||
@@ -582,7 +582,7 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
|
||||
it("builds plugin approval pending payload with callback ids that preserve allow-always", () => {
|
||||
const request = createPluginApprovalRequest();
|
||||
const payload = telegramPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
const payload = telegramPlugin.execApprovals?.render?.plugin?.buildPendingPayload?.({
|
||||
cfg: createCfg(),
|
||||
request,
|
||||
target: { channel: "telegram", to: "12345" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createNestedAllowlistOverrideResolver,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import {
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
buildPluginApprovalRequestMessage,
|
||||
createApproverRestrictedNativeApprovalAdapter,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
@@ -325,6 +326,25 @@ function resolveTelegramOutboundSessionRoute(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
|
||||
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
|
||||
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isTelegramExecApprovalApprover({ cfg, accountId, senderId }),
|
||||
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
|
||||
isTelegramExecApprovalClientEnabled({ cfg, accountId }),
|
||||
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
|
||||
resolveTelegramExecApprovalTarget({ cfg, accountId }),
|
||||
requireMatchingTurnSourceChannel: true,
|
||||
resolveSuppressionAccountId: ({ target, request }) =>
|
||||
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
|
||||
});
|
||||
|
||||
const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: (ctx) =>
|
||||
getTelegramRuntime().channel.telegram.messageActions?.describeMessageTool?.(ctx) ?? null,
|
||||
@@ -450,72 +470,54 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
},
|
||||
},
|
||||
execApprovals: {
|
||||
...createApproverRestrictedNativeApprovalAdapter({
|
||||
channel: "telegram",
|
||||
channelLabel: "Telegram",
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
|
||||
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
|
||||
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isTelegramExecApprovalApprover({ cfg, accountId, senderId }),
|
||||
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
|
||||
isTelegramExecApprovalClientEnabled({ cfg, accountId }),
|
||||
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
|
||||
resolveTelegramExecApprovalTarget({ cfg, accountId }),
|
||||
requireMatchingTurnSourceChannel: true,
|
||||
resolveSuppressionAccountId: ({ target, request }) =>
|
||||
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
|
||||
}),
|
||||
buildPendingPayload: ({ request, nowMs }) =>
|
||||
buildTelegramExecApprovalPendingPayload({ request, nowMs }),
|
||||
beforeDeliverPending: async ({ cfg, target, payload }) => {
|
||||
const hasExecApprovalData =
|
||||
payload.channelData &&
|
||||
typeof payload.channelData === "object" &&
|
||||
!Array.isArray(payload.channelData) &&
|
||||
payload.channelData.execApproval;
|
||||
if (!hasExecApprovalData) {
|
||||
return;
|
||||
}
|
||||
const threadId =
|
||||
typeof target.threadId === "number"
|
||||
? target.threadId
|
||||
: typeof target.threadId === "string"
|
||||
? Number.parseInt(target.threadId, 10)
|
||||
: undefined;
|
||||
await sendTypingTelegram(target.to, {
|
||||
cfg,
|
||||
accountId: target.accountId ?? undefined,
|
||||
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
|
||||
}).catch(() => {});
|
||||
auth: telegramNativeApprovalAdapter.auth,
|
||||
delivery: {
|
||||
...telegramNativeApprovalAdapter.delivery,
|
||||
beforeDeliverPending: async ({ cfg, target, payload }) => {
|
||||
const hasExecApprovalData =
|
||||
payload.channelData &&
|
||||
typeof payload.channelData === "object" &&
|
||||
!Array.isArray(payload.channelData) &&
|
||||
payload.channelData.execApproval;
|
||||
if (!hasExecApprovalData) {
|
||||
return;
|
||||
}
|
||||
const threadId =
|
||||
typeof target.threadId === "number"
|
||||
? target.threadId
|
||||
: typeof target.threadId === "string"
|
||||
? Number.parseInt(target.threadId, 10)
|
||||
: undefined;
|
||||
await sendTypingTelegram(target.to, {
|
||||
cfg,
|
||||
accountId: target.accountId ?? undefined,
|
||||
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
|
||||
}).catch(() => {});
|
||||
},
|
||||
},
|
||||
buildPluginPendingPayload: ({ request, nowMs }) => {
|
||||
const text = buildPluginApprovalRequestMessage(request, nowMs);
|
||||
const buttons = buildTelegramExecApprovalButtons(request.id);
|
||||
const execApproval = {
|
||||
approvalId: request.id,
|
||||
approvalSlug: request.id,
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"] as const,
|
||||
};
|
||||
if (!buttons) {
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
telegram: {
|
||||
buttons,
|
||||
},
|
||||
render: {
|
||||
exec: {
|
||||
buildPendingPayload: ({ request, nowMs }) =>
|
||||
buildTelegramExecApprovalPendingPayload({ request, nowMs }),
|
||||
},
|
||||
plugin: {
|
||||
buildPendingPayload: ({ request, nowMs }) => {
|
||||
const buttons = buildTelegramExecApprovalButtons(request.id);
|
||||
return buildPluginApprovalPendingReplyPayload({
|
||||
request,
|
||||
nowMs,
|
||||
text: buildPluginApprovalRequestMessage(request, nowMs),
|
||||
approvalSlug: request.id,
|
||||
channelData: buttons
|
||||
? {
|
||||
telegram: {
|
||||
buttons,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
|
||||
@@ -13,7 +13,7 @@ export function shouldSuppressLocalExecApprovalPrompt(params: {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getChannelPlugin(channel)?.execApprovals?.shouldSuppressLocalPrompt?.({
|
||||
getChannelPlugin(channel)?.execApprovals?.delivery?.shouldSuppressLocalPrompt?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
payload: params.payload,
|
||||
|
||||
@@ -465,7 +465,7 @@ export type ChannelLifecycleAdapter = {
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ChannelExecApprovalAdapter = {
|
||||
export type ChannelExecApprovalAuthAdapter = {
|
||||
authorizeCommand?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -479,6 +479,9 @@ export type ChannelExecApprovalAdapter = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) => ChannelExecApprovalInitiatingSurfaceState;
|
||||
};
|
||||
|
||||
export type ChannelExecApprovalDeliveryAdapter = {
|
||||
shouldSuppressLocalPrompt?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -506,17 +509,41 @@ export type ChannelExecApprovalAdapter = {
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
payload: ReplyPayload;
|
||||
}) => Promise<void> | void;
|
||||
buildPluginPendingPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: PluginApprovalRequest;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
nowMs: number;
|
||||
}) => ReplyPayload | null;
|
||||
buildPluginResolvedPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
resolved: PluginApprovalResolved;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
}) => ReplyPayload | null;
|
||||
};
|
||||
|
||||
export type ChannelExecApprovalRenderAdapter = {
|
||||
exec?: {
|
||||
buildPendingPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
nowMs: number;
|
||||
}) => ReplyPayload | null;
|
||||
buildResolvedPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
resolved: ExecApprovalResolved;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
}) => ReplyPayload | null;
|
||||
};
|
||||
plugin?: {
|
||||
buildPendingPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: PluginApprovalRequest;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
nowMs: number;
|
||||
}) => ReplyPayload | null;
|
||||
buildResolvedPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
resolved: PluginApprovalResolved;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
}) => ReplyPayload | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelExecApprovalAdapter = {
|
||||
auth?: ChannelExecApprovalAuthAdapter;
|
||||
delivery?: ChannelExecApprovalDeliveryAdapter;
|
||||
render?: ChannelExecApprovalRenderAdapter;
|
||||
};
|
||||
|
||||
export type ChannelAllowlistAdapter = {
|
||||
|
||||
@@ -27,8 +27,12 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
it("delegates to the channel approval override when present", () => {
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
execApprovals: {
|
||||
authorizeCommand: ({ kind }: { kind: "exec" | "plugin" }) =>
|
||||
kind === "plugin" ? { authorized: false, reason: "plugin denied" } : { authorized: true },
|
||||
auth: {
|
||||
authorizeCommand: ({ kind }: { kind: "exec" | "plugin" }) =>
|
||||
kind === "plugin"
|
||||
? { authorized: false, reason: "plugin denied" }
|
||||
: { authorized: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function resolveApprovalCommandAuthorization(params: {
|
||||
return { authorized: true };
|
||||
}
|
||||
return (
|
||||
getChannelPlugin(channel)?.execApprovals?.authorizeCommand?.({
|
||||
getChannelPlugin(channel)?.execApprovals?.auth?.authorizeCommand?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
|
||||
@@ -52,10 +52,16 @@ const telegramApprovalPlugin: Pick<
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "telegram" }),
|
||||
execApprovals: {
|
||||
shouldSuppressForwardingFallback: (params) =>
|
||||
shouldSuppressTelegramExecApprovalForwardingFallback(params),
|
||||
buildPendingPayload: ({ request, nowMs }) =>
|
||||
buildTelegramExecApprovalPendingPayload({ request, nowMs }),
|
||||
delivery: {
|
||||
shouldSuppressForwardingFallback: (params) =>
|
||||
shouldSuppressTelegramExecApprovalForwardingFallback(params),
|
||||
},
|
||||
render: {
|
||||
exec: {
|
||||
buildPendingPayload: ({ request, nowMs }) =>
|
||||
buildTelegramExecApprovalPendingPayload({ request, nowMs }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const discordApprovalPlugin: Pick<
|
||||
@@ -64,9 +70,11 @@ const discordApprovalPlugin: Pick<
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "discord" }),
|
||||
execApprovals: {
|
||||
shouldSuppressForwardingFallback: ({ cfg, target }) =>
|
||||
target.channel === "discord" &&
|
||||
isDiscordExecApprovalClientEnabledForTest({ cfg, accountId: target.accountId }),
|
||||
delivery: {
|
||||
shouldSuppressForwardingFallback: ({ cfg, target }) =>
|
||||
target.channel === "discord" &&
|
||||
isDiscordExecApprovalClientEnabledForTest({ cfg, accountId: target.accountId }),
|
||||
},
|
||||
},
|
||||
};
|
||||
const defaultRegistry = createTestRegistry([
|
||||
|
||||
@@ -127,7 +127,7 @@ function shouldSkipForwardingFallback(params: {
|
||||
}
|
||||
const adapter = getChannelPlugin(channel)?.execApprovals;
|
||||
return (
|
||||
adapter?.shouldSuppressForwardingFallback?.({
|
||||
adapter?.delivery?.shouldSuppressForwardingFallback?.({
|
||||
cfg: params.cfg,
|
||||
target: params.target,
|
||||
request: params.request,
|
||||
@@ -274,7 +274,7 @@ function buildRequestPayloadForTarget(
|
||||
): ReplyPayload {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const pluginPayload = channel
|
||||
? getChannelPlugin(channel)?.execApprovals?.buildPendingPayload?.({
|
||||
? getChannelPlugin(channel)?.execApprovals?.render?.exec?.buildPendingPayload?.({
|
||||
cfg,
|
||||
request,
|
||||
target,
|
||||
@@ -294,7 +294,7 @@ function buildResolvedPayloadForTarget(
|
||||
): ReplyPayload {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const pluginPayload = channel
|
||||
? getChannelPlugin(channel)?.execApprovals?.buildResolvedPayload?.({
|
||||
? getChannelPlugin(channel)?.execApprovals?.render?.exec?.buildResolvedPayload?.({
|
||||
cfg,
|
||||
resolved,
|
||||
target,
|
||||
@@ -409,7 +409,7 @@ export function createExecApprovalForwarder(
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
await getChannelPlugin(channel)?.execApprovals?.beforeDeliverPending?.({
|
||||
await getChannelPlugin(channel)?.execApprovals?.delivery?.beforeDeliverPending?.({
|
||||
cfg,
|
||||
target,
|
||||
payload,
|
||||
@@ -556,7 +556,7 @@ export function createExecApprovalForwarder(
|
||||
buildPayload: (target) => {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const adapterPayload = channel
|
||||
? getChannelPlugin(channel)?.execApprovals?.buildPluginPendingPayload?.({
|
||||
? getChannelPlugin(channel)?.execApprovals?.render?.plugin?.buildPendingPayload?.({
|
||||
cfg,
|
||||
request,
|
||||
target,
|
||||
@@ -570,7 +570,7 @@ export function createExecApprovalForwarder(
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
await getChannelPlugin(channel)?.execApprovals?.beforeDeliverPending?.({
|
||||
await getChannelPlugin(channel)?.execApprovals?.delivery?.beforeDeliverPending?.({
|
||||
cfg,
|
||||
target,
|
||||
payload,
|
||||
@@ -624,7 +624,7 @@ export function createExecApprovalForwarder(
|
||||
buildPayload: (target) => {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const adapterPayload = channel
|
||||
? getChannelPlugin(channel)?.execApprovals?.buildPluginResolvedPayload?.({
|
||||
? getChannelPlugin(channel)?.execApprovals?.render?.plugin?.buildResolvedPayload?.({
|
||||
cfg,
|
||||
resolved,
|
||||
target,
|
||||
|
||||
@@ -83,13 +83,17 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
channel === "telegram"
|
||||
? {
|
||||
execApprovals: {
|
||||
getInitiatingSurfaceState: () => ({ kind: "enabled" }),
|
||||
auth: {
|
||||
getInitiatingSurfaceState: () => ({ kind: "enabled" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
: channel === "discord"
|
||||
? {
|
||||
execApprovals: {
|
||||
getInitiatingSurfaceState: () => ({ kind: "disabled" }),
|
||||
auth: {
|
||||
getInitiatingSurfaceState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
@@ -128,7 +132,9 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
channel === "telegram"
|
||||
? {
|
||||
execApprovals: {
|
||||
getInitiatingSurfaceState: () => ({ kind: "disabled" }),
|
||||
auth: {
|
||||
getInitiatingSurfaceState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
@@ -172,12 +178,16 @@ describe("hasConfiguredExecApprovalDmRoute", () => {
|
||||
plugins: [
|
||||
{
|
||||
execApprovals: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
execApprovals: {
|
||||
hasConfiguredDmRoute: () => true,
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -187,12 +197,16 @@ describe("hasConfiguredExecApprovalDmRoute", () => {
|
||||
plugins: [
|
||||
{
|
||||
execApprovals: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
execApprovals: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: () => false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ export function resolveExecApprovalInitiatingSurfaceState(params: {
|
||||
}
|
||||
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const state = getChannelPlugin(channel)?.execApprovals?.getInitiatingSurfaceState?.({
|
||||
const state = getChannelPlugin(channel)?.execApprovals?.auth?.getInitiatingSurfaceState?.({
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
@@ -53,6 +53,6 @@ export function resolveExecApprovalInitiatingSurfaceState(params: {
|
||||
|
||||
export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
|
||||
return listChannelPlugins().some(
|
||||
(plugin) => plugin.execApprovals?.hasConfiguredDmRoute?.({ cfg }) ?? false,
|
||||
(plugin) => plugin.execApprovals?.delivery?.hasConfiguredDmRoute?.({ cfg }) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,11 @@ describe("plugin approval forwarding", () => {
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "slack" as ChannelPlugin["id"] }),
|
||||
execApprovals: {
|
||||
buildPluginPendingPayload: vi.fn().mockReturnValue(mockPayload),
|
||||
render: {
|
||||
plugin: {
|
||||
buildPendingPayload: vi.fn().mockReturnValue(mockPayload),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry = createTestRegistry([
|
||||
@@ -197,7 +201,9 @@ describe("plugin approval forwarding", () => {
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "slack" as ChannelPlugin["id"] }),
|
||||
execApprovals: {
|
||||
beforeDeliverPending,
|
||||
delivery: {
|
||||
beforeDeliverPending,
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry = createTestRegistry([
|
||||
@@ -222,7 +228,11 @@ describe("plugin approval forwarding", () => {
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "slack" as ChannelPlugin["id"] }),
|
||||
execApprovals: {
|
||||
buildPluginResolvedPayload: vi.fn().mockReturnValue(mockPayload),
|
||||
render: {
|
||||
plugin: {
|
||||
buildResolvedPayload: vi.fn().mockReturnValue(mockPayload),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry = createTestRegistry([
|
||||
|
||||
@@ -13,9 +13,10 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
isNativeDeliveryEnabled: () => true,
|
||||
resolveNativeDeliveryMode: () => "dm",
|
||||
});
|
||||
const authorizeCommand = adapter.auth.authorizeCommand;
|
||||
|
||||
expect(
|
||||
adapter.authorizeCommand({
|
||||
authorizeCommand({
|
||||
cfg: {} as never,
|
||||
accountId: "work",
|
||||
senderId: "exec-owner",
|
||||
@@ -24,7 +25,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
adapter.authorizeCommand({
|
||||
authorizeCommand({
|
||||
cfg: {} as never,
|
||||
accountId: "work",
|
||||
senderId: "plugin-owner",
|
||||
@@ -33,7 +34,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
adapter.authorizeCommand({
|
||||
authorizeCommand({
|
||||
cfg: {} as never,
|
||||
accountId: "work",
|
||||
senderId: "someone-else",
|
||||
@@ -56,14 +57,16 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
resolveNativeDeliveryMode: ({ accountId }) =>
|
||||
accountId === "channel-only" ? "channel" : "dm",
|
||||
});
|
||||
const getInitiatingSurfaceState = adapter.auth.getInitiatingSurfaceState;
|
||||
const hasConfiguredDmRoute = adapter.delivery.hasConfiguredDmRoute;
|
||||
|
||||
expect(adapter.getInitiatingSurfaceState({ cfg: {} as never, accountId: "dm-only" })).toEqual({
|
||||
expect(getInitiatingSurfaceState({ cfg: {} as never, accountId: "dm-only" })).toEqual({
|
||||
kind: "enabled",
|
||||
});
|
||||
expect(
|
||||
adapter.getInitiatingSurfaceState({ cfg: {} as never, accountId: "no-approvers" }),
|
||||
).toEqual({ kind: "disabled" });
|
||||
expect(adapter.hasConfiguredDmRoute({ cfg: {} as never })).toBe(true);
|
||||
expect(getInitiatingSurfaceState({ cfg: {} as never, accountId: "no-approvers" })).toEqual({
|
||||
kind: "disabled",
|
||||
});
|
||||
expect(hasConfiguredDmRoute({ cfg: {} as never })).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses forwarding fallback only for matching native-delivery surfaces", () => {
|
||||
@@ -82,9 +85,10 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
resolveSuppressionAccountId: ({ request }) =>
|
||||
request.request.turnSourceAccountId?.trim() || undefined,
|
||||
});
|
||||
const shouldSuppressForwardingFallback = adapter.delivery.shouldSuppressForwardingFallback;
|
||||
|
||||
expect(
|
||||
adapter.shouldSuppressForwardingFallback({
|
||||
shouldSuppressForwardingFallback({
|
||||
cfg: {} as never,
|
||||
target: { channel: "telegram" },
|
||||
request: {
|
||||
@@ -94,7 +98,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
adapter.shouldSuppressForwardingFallback({
|
||||
shouldSuppressForwardingFallback({
|
||||
cfg: {} as never,
|
||||
target: { channel: "telegram" },
|
||||
request: {
|
||||
@@ -104,7 +108,7 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
adapter.shouldSuppressForwardingFallback({
|
||||
shouldSuppressForwardingFallback({
|
||||
cfg: {} as never,
|
||||
target: { channel: "slack" },
|
||||
request: {
|
||||
|
||||
@@ -34,66 +34,72 @@ export function createApproverRestrictedNativeApprovalAdapter(params: {
|
||||
const pluginSenderAuth = params.isPluginAuthorizedSender ?? params.isExecAuthorizedSender;
|
||||
|
||||
return {
|
||||
authorizeCommand: ({
|
||||
cfg,
|
||||
accountId,
|
||||
senderId,
|
||||
kind,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
kind: ApprovalKind;
|
||||
}) => {
|
||||
const authorized =
|
||||
kind === "plugin"
|
||||
? pluginSenderAuth({ cfg, accountId, senderId })
|
||||
: params.isExecAuthorizedSender({ cfg, accountId, senderId });
|
||||
return authorized
|
||||
? { authorized: true }
|
||||
: {
|
||||
authorized: false,
|
||||
reason: `❌ You are not authorized to approve ${kind} requests on ${params.channelLabel}.`,
|
||||
};
|
||||
auth: {
|
||||
authorizeCommand: ({
|
||||
cfg,
|
||||
accountId,
|
||||
senderId,
|
||||
kind,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
kind: ApprovalKind;
|
||||
}) => {
|
||||
const authorized =
|
||||
kind === "plugin"
|
||||
? pluginSenderAuth({ cfg, accountId, senderId })
|
||||
: params.isExecAuthorizedSender({ cfg, accountId, senderId });
|
||||
return authorized
|
||||
? { authorized: true }
|
||||
: {
|
||||
authorized: false,
|
||||
reason: `❌ You are not authorized to approve ${kind} requests on ${params.channelLabel}.`,
|
||||
};
|
||||
},
|
||||
getInitiatingSurfaceState: ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) =>
|
||||
params.hasApprovers({ cfg, accountId })
|
||||
? ({ kind: "enabled" } as const)
|
||||
: ({ kind: "disabled" } as const),
|
||||
},
|
||||
getInitiatingSurfaceState: ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) =>
|
||||
params.hasApprovers({ cfg, accountId })
|
||||
? ({ kind: "enabled" } as const)
|
||||
: ({ kind: "disabled" } as const),
|
||||
hasConfiguredDmRoute: ({ cfg }: { cfg: OpenClawConfig }) =>
|
||||
params.listAccountIds(cfg).some((accountId) => {
|
||||
if (!params.hasApprovers({ cfg, accountId })) {
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: ({ cfg }: { cfg: OpenClawConfig }) =>
|
||||
params.listAccountIds(cfg).some((accountId) => {
|
||||
if (!params.hasApprovers({ cfg, accountId })) {
|
||||
return false;
|
||||
}
|
||||
if (!params.isNativeDeliveryEnabled({ cfg, accountId })) {
|
||||
return false;
|
||||
}
|
||||
const target = params.resolveNativeDeliveryMode({ cfg, accountId });
|
||||
return target === "dm" || target === "both";
|
||||
}),
|
||||
shouldSuppressForwardingFallback: (input: DeliverySuppressionParams) => {
|
||||
const channel = normalizeMessageChannel(input.target.channel) ?? input.target.channel;
|
||||
if (channel !== params.channel) {
|
||||
return false;
|
||||
}
|
||||
if (!params.isNativeDeliveryEnabled({ cfg, accountId })) {
|
||||
return false;
|
||||
if (params.requireMatchingTurnSourceChannel) {
|
||||
const turnSourceChannel = normalizeMessageChannel(
|
||||
input.request.request.turnSourceChannel,
|
||||
);
|
||||
if (turnSourceChannel !== params.channel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const target = params.resolveNativeDeliveryMode({ cfg, accountId });
|
||||
return target === "dm" || target === "both";
|
||||
}),
|
||||
shouldSuppressForwardingFallback: (input: DeliverySuppressionParams) => {
|
||||
const channel = normalizeMessageChannel(input.target.channel) ?? input.target.channel;
|
||||
if (channel !== params.channel) {
|
||||
return false;
|
||||
}
|
||||
if (params.requireMatchingTurnSourceChannel) {
|
||||
const turnSourceChannel = normalizeMessageChannel(input.request.request.turnSourceChannel);
|
||||
if (turnSourceChannel !== params.channel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const resolvedAccountId = params.resolveSuppressionAccountId?.(input);
|
||||
const accountId =
|
||||
(resolvedAccountId === undefined
|
||||
? input.target.accountId?.trim()
|
||||
: resolvedAccountId.trim()) || undefined;
|
||||
return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId });
|
||||
const resolvedAccountId = params.resolveSuppressionAccountId?.(input);
|
||||
const accountId =
|
||||
(resolvedAccountId === undefined
|
||||
? input.target.accountId?.trim()
|
||||
: resolvedAccountId.trim()) || undefined;
|
||||
return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
63
src/plugin-sdk/approval-renderers.test.ts
Normal file
63
src/plugin-sdk/approval-renderers.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
buildPluginApprovalResolvedReplyPayload,
|
||||
} from "./approval-renderers.js";
|
||||
|
||||
describe("plugin-sdk/approval-renderers", () => {
|
||||
it("builds plugin pending payloads with approval metadata and extra channel data", () => {
|
||||
const payload = buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: "plugin-approval-123",
|
||||
request: {
|
||||
title: "Sensitive action",
|
||||
description: "Needs approval",
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
},
|
||||
nowMs: 1_000,
|
||||
approvalSlug: "custom-slug",
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve id allow-once" }]],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.text).toContain("Plugin approval required");
|
||||
expect(payload.channelData).toMatchObject({
|
||||
execApproval: {
|
||||
approvalId: "plugin-approval-123",
|
||||
approvalSlug: "custom-slug",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
telegram: {
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve id allow-once" }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds plugin resolved payloads with optional channel data", () => {
|
||||
const payload = buildPluginApprovalResolvedReplyPayload({
|
||||
resolved: {
|
||||
id: "plugin-approval-123",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "discord:user:1",
|
||||
ts: 2_000,
|
||||
},
|
||||
channelData: {
|
||||
discord: {
|
||||
components: [{ type: "container" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.text).toContain("Plugin approval allowed once");
|
||||
expect(payload.channelData).toEqual({
|
||||
discord: {
|
||||
components: [{ type: "container" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/plugin-sdk/approval-renderers.ts
Normal file
46
src/plugin-sdk/approval-renderers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ExecApprovalReplyDecision } from "../infra/exec-approval-reply.js";
|
||||
import {
|
||||
buildPluginApprovalRequestMessage,
|
||||
buildPluginApprovalResolvedMessage,
|
||||
type PluginApprovalRequest,
|
||||
type PluginApprovalResolved,
|
||||
} from "../infra/plugin-approvals.js";
|
||||
|
||||
const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as const;
|
||||
|
||||
export function buildPluginApprovalPendingReplyPayload(params: {
|
||||
request: PluginApprovalRequest;
|
||||
nowMs: number;
|
||||
text?: string;
|
||||
approvalSlug?: string;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
channelData?: Record<string, unknown>;
|
||||
}): ReplyPayload {
|
||||
return {
|
||||
text: params.text ?? buildPluginApprovalRequestMessage(params.request, params.nowMs),
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: params.request.id,
|
||||
approvalSlug: params.approvalSlug ?? params.request.id.slice(0, 8),
|
||||
allowedDecisions: params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS,
|
||||
},
|
||||
...params.channelData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginApprovalResolvedReplyPayload(params: {
|
||||
resolved: PluginApprovalResolved;
|
||||
text?: string;
|
||||
channelData?: Record<string, unknown>;
|
||||
}): ReplyPayload {
|
||||
return params.channelData
|
||||
? {
|
||||
text: params.text ?? buildPluginApprovalResolvedMessage(params.resolved),
|
||||
channelData: params.channelData,
|
||||
}
|
||||
: {
|
||||
text: params.text ?? buildPluginApprovalResolvedMessage(params.resolved),
|
||||
};
|
||||
}
|
||||
@@ -32,3 +32,7 @@ export {
|
||||
type PluginApprovalResolved,
|
||||
} from "../infra/plugin-approvals.js";
|
||||
export { createApproverRestrictedNativeApprovalAdapter } from "./approval-delivery-helpers.js";
|
||||
export {
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
buildPluginApprovalResolvedReplyPayload,
|
||||
} from "./approval-renderers.js";
|
||||
|
||||
Reference in New Issue
Block a user