Refactor channel approval capability seams (#58634)

Merged via squash.

Prepared head SHA: c9ad4e4706
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-01 17:10:25 -04:00
committed by GitHub
parent d9a7ffe003
commit c87c8e66bf
48 changed files with 2214 additions and 861 deletions

View File

@@ -258,7 +258,7 @@ describe("slack native approval adapter", () => {
});
it("suppresses generic slack fallback only for slack-originated approvals", () => {
const shouldSuppress = slackNativeApprovalAdapter.delivery.shouldSuppressForwardingFallback;
const shouldSuppress = slackNativeApprovalAdapter.delivery?.shouldSuppressForwardingFallback;
if (!shouldSuppress) {
throw new Error("slack native delivery suppression unavailable");
}
@@ -266,12 +266,16 @@ describe("slack native approval adapter", () => {
expect(
shouldSuppress({
cfg: buildConfig(),
target: { channel: "slack", accountId: "default" },
target: { channel: "slack", to: "channel:C123ROOM", accountId: "default" },
request: {
id: "approval-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceAccountId: "default",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
}),
).toBe(true);
@@ -279,12 +283,16 @@ describe("slack native approval adapter", () => {
expect(
shouldSuppress({
cfg: buildConfig(),
target: { channel: "slack", accountId: "default" },
target: { channel: "slack", to: "channel:C123ROOM", accountId: "default" },
request: {
id: "approval-1",
request: {
command: "echo hi",
turnSourceChannel: "discord",
turnSourceAccountId: "default",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
}),
).toBe(false);
@@ -301,7 +309,7 @@ describe("slack native approval adapter", () => {
});
expect(
slackNativeApprovalAdapter.auth.authorizeActorAction({
slackNativeApprovalAdapter.auth.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "U123OWNER",
@@ -311,7 +319,7 @@ describe("slack native approval adapter", () => {
).toEqual({ authorized: true });
expect(
slackNativeApprovalAdapter.auth.authorizeActorAction({
slackNativeApprovalAdapter.auth.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "U999EXEC",
@@ -324,7 +332,7 @@ describe("slack native approval adapter", () => {
});
expect(
slackNativeApprovalAdapter.auth.authorizeActorAction({
slackNativeApprovalAdapter.auth.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "U999EXEC",

View File

@@ -1,6 +1,8 @@
import {
createApproverRestrictedNativeApprovalAdapter,
resolveApprovalRequestOriginTarget,
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
@@ -88,40 +90,31 @@ function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean
);
}
function resolveSlackOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}) {
if (!shouldHandleSlackExecApprovalRequest(params)) {
return null;
}
return resolveApprovalRequestOriginTarget({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget,
resolveSessionTarget: resolveSessionSlackOriginTarget,
targetsMatch: slackTargetsMatch,
});
}
const resolveSlackOriginTarget = createChannelNativeOriginTargetResolver({
channel: "slack",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleSlackExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget,
resolveSessionTarget: resolveSessionSlackOriginTarget,
targetsMatch: slackTargetsMatch,
});
function resolveSlackApproverDmTargets(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}) {
if (!shouldHandleSlackExecApprovalRequest(params)) {
return [];
}
return getSlackExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).map((approver) => ({ to: `user:${approver}` }));
}
const resolveSlackApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleSlackExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveApprovers: getSlackExecApprovalApprovers,
mapApprover: (approver) => ({ to: `user:${approver}` }),
});
export const slackNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
export const slackApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "slack",
channelLabel: "Slack",
listAccountIds: listSlackAccountIds,
@@ -138,9 +131,9 @@ export const slackNativeApprovalAdapter = createApproverRestrictedNativeApproval
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
resolveOriginTarget: ({ cfg, accountId, request }) =>
accountId ? resolveSlackOriginTarget({ cfg, accountId, request }) : null,
resolveApproverDmTargets: ({ cfg, accountId, request }) =>
resolveSlackApproverDmTargets({ cfg, accountId, request }),
resolveOriginTarget: resolveSlackOriginTarget,
resolveApproverDmTargets: resolveSlackApproverDmTargets,
notifyOriginWhenDmOnly: true,
});
export const slackNativeApprovalAdapter = splitChannelApprovalCapability(slackApprovalCapability);

View File

@@ -38,7 +38,7 @@ import {
} from "./accounts.js";
import type { SlackActionContext } from "./action-runtime.js";
import { resolveSlackAutoThreadId } from "./action-threading.js";
import { slackNativeApprovalAdapter } from "./approval-native.js";
import { slackApprovalCapability } from "./approval-native.js";
import { createSlackActions } from "./channel-actions.js";
import { resolveSlackChannelType } from "./channel-type.js";
import {
@@ -283,11 +283,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
}),
resolveNames: resolveSlackAllowlistNames,
},
auth: slackNativeApprovalAdapter.auth,
approvals: {
delivery: slackNativeApprovalAdapter.delivery,
native: slackNativeApprovalAdapter.native,
},
approvalCapability: slackApprovalCapability,
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,

View File

@@ -1,17 +1,12 @@
import {
createChannelExecApprovalProfile,
doesApprovalRequestMatchChannelAccount,
getExecApprovalReplyMetadata,
matchesApprovalRequestFilters,
isChannelExecApprovalTargetRecipient,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { resolveSlackAccount } from "./accounts.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
export function normalizeSlackApproverId(value: string | number): string | undefined {
const trimmed = String(value).trim();
if (!trimmed) {
@@ -38,123 +33,49 @@ function resolveSlackOwnerApprovers(cfg: OpenClawConfig): string[] {
normalizeApprover: normalizeSlackApproverId,
});
}
export function shouldHandleSlackExecApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
})
) {
return false;
}
const config = resolveSlackAccount(params).config.execApprovals;
if (!config?.enabled) {
return false;
}
if (getSlackExecApprovalApprovers(params).length === 0) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config.agentFilter,
sessionFilter: config.sessionFilter,
});
}
export function getSlackExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveSlackAccount(params).config;
return resolveApprovalApprovers({
explicit:
resolveSlackAccount(params).config.execApprovals?.approvers ??
resolveSlackOwnerApprovers(params.cfg),
explicit: account.execApprovals?.approvers ?? resolveSlackOwnerApprovers(params.cfg),
normalizeApprover: normalizeSlackApproverId,
});
}
export function isSlackExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveSlackAccount(params).config.execApprovals;
return Boolean(config?.enabled && getSlackExecApprovalApprovers(params).length > 0);
}
export function isSlackExecApprovalApprover(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const senderId = params.senderId ? normalizeSlackApproverId(params.senderId) : undefined;
if (!senderId) {
return false;
}
return getSlackExecApprovalApprovers(params).includes(senderId);
}
function isSlackExecApprovalTargetsMode(cfg: OpenClawConfig): boolean {
const execApprovals = cfg.approvals?.exec;
if (!execApprovals?.enabled) {
return false;
}
return execApprovals.mode === "targets" || execApprovals.mode === "both";
}
export function isSlackExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
accountId?: string | null;
}): boolean {
const senderId = params.senderId ? normalizeSlackApproverId(params.senderId) : undefined;
if (!senderId || !isSlackExecApprovalTargetsMode(params.cfg)) {
return false;
}
const targets = params.cfg.approvals?.exec?.targets;
if (!targets) {
return false;
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
return targets.some((target) => {
if (target.channel?.trim().toLowerCase() !== "slack") {
return false;
}
if (accountId && target.accountId && normalizeAccountId(target.accountId) !== accountId) {
return false;
}
return normalizeSlackApproverId(target.to) === senderId;
return isChannelExecApprovalTargetRecipient({
...params,
channel: "slack",
normalizeSenderId: normalizeSlackApproverId,
matchTarget: ({ target, normalizedSenderId }) =>
normalizeSlackApproverId(target.to) === normalizedSenderId,
});
}
export function isSlackExecApprovalAuthorizedSender(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
return isSlackExecApprovalApprover(params) || isSlackExecApprovalTargetRecipient(params);
}
const slackExecApprovalProfile = createChannelExecApprovalProfile({
resolveConfig: (params) => resolveSlackAccount(params).config.execApprovals,
resolveApprovers: getSlackExecApprovalApprovers,
normalizeSenderId: normalizeSlackApproverId,
isTargetRecipient: isSlackExecApprovalTargetRecipient,
matchesRequestAccount: (params) =>
doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
}),
});
export function resolveSlackExecApprovalTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): "dm" | "channel" | "both" {
return resolveSlackAccount(params).config.execApprovals?.target ?? "dm";
}
export function shouldSuppressLocalSlackExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
return (
isSlackExecApprovalClientEnabled(params) &&
getExecApprovalReplyMetadata(params.payload) !== null
);
}
export const isSlackExecApprovalClientEnabled = slackExecApprovalProfile.isClientEnabled;
export const isSlackExecApprovalApprover = slackExecApprovalProfile.isApprover;
export const isSlackExecApprovalAuthorizedSender = slackExecApprovalProfile.isAuthorizedSender;
export const resolveSlackExecApprovalTarget = slackExecApprovalProfile.resolveTarget;
export const shouldHandleSlackExecApprovalRequest = slackExecApprovalProfile.shouldHandleRequest;
export const shouldSuppressLocalSlackExecApprovalPrompt =
slackExecApprovalProfile.shouldSuppressLocalPrompt;

View File

@@ -3,8 +3,7 @@ import type { Block, KnownBlock } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
buildApprovalInteractiveReply,
createExecApprovalChannelRuntime,
deliverApprovalRequestViaChannelNativePlan,
createChannelNativeApprovalRuntime,
getExecApprovalApproverDmNoticeText,
resolveExecApprovalCommandDisplay,
type ExecApprovalChannelRuntime,
@@ -27,6 +26,10 @@ type SlackPendingApproval = {
channelId: string;
messageTs: string;
};
type SlackPendingDelivery = {
text: string;
blocks: SlackBlock[];
};
type SlackExecApprovalConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>["execApprovals"]
@@ -219,11 +222,19 @@ export class SlackExecApprovalHandler {
constructor(opts: SlackExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createExecApprovalChannelRuntime<SlackPendingApproval>({
this.runtime = createChannelNativeApprovalRuntime<
SlackPendingApproval,
{ to: string; threadTs?: string },
SlackPendingDelivery,
ExecApprovalRequest,
ExecApprovalResolved
>({
label: "slack/exec-approvals",
clientDisplayName: "Slack Exec Approvals",
cfg: opts.cfg,
accountId: opts.accountId,
gatewayUrl: opts.gatewayUrl,
nativeAdapter: slackNativeApprovalAdapter.native,
isConfigured: () =>
Boolean(
opts.config.enabled &&
@@ -233,7 +244,49 @@ export class SlackExecApprovalHandler {
}).length > 0,
),
shouldHandle: (request) => this.shouldHandle(request),
deliverRequested: async (request) => await this.deliverRequested(request),
buildPendingContent: ({ request }) => ({
text: buildSlackPendingApprovalText(request),
blocks: buildSlackPendingApprovalBlocks(request),
}),
sendOriginNotice: async ({ originTarget }) => {
await sendMessageSlack(originTarget.to, getExecApprovalApproverDmNoticeText(), {
cfg: this.opts.cfg,
accountId: this.opts.accountId,
threadTs: originTarget.threadId != null ? String(originTarget.threadId) : undefined,
client: this.opts.app.client,
});
},
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`,
target: {
to: plannedTarget.target.to,
threadTs:
plannedTarget.target.threadId != null
? String(plannedTarget.target.threadId)
: undefined,
},
}),
deliverTarget: async ({ preparedTarget, pendingContent, request }) => {
const message = await sendMessageSlack(preparedTarget.to, pendingContent.text, {
cfg: this.opts.cfg,
accountId: this.opts.accountId,
threadTs: preparedTarget.threadTs,
blocks: pendingContent.blocks,
client: this.opts.app.client,
});
return {
channelId: message.channelId,
messageTs: message.messageId,
};
},
onOriginNoticeError: ({ error }) => {
logError(`slack exec approvals: failed to send DM redirect notice: ${String(error)}`);
},
onDeliveryError: ({ error, request }) => {
logError(
`slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`,
);
},
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
@@ -248,7 +301,14 @@ export class SlackExecApprovalHandler {
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
});
})
? slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
approvalKind: "exec",
request,
}).enabled === true
: false;
}
async start(): Promise<void> {
@@ -271,57 +331,6 @@ export class SlackExecApprovalHandler {
await this.runtime.handleExpired(approvalId);
}
private async deliverRequested(request: ExecApprovalRequest): Promise<SlackPendingApproval[]> {
const text = buildSlackPendingApprovalText(request);
const blocks = buildSlackPendingApprovalBlocks(request);
return await deliverApprovalRequestViaChannelNativePlan({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
approvalKind: "exec",
request,
adapter: slackNativeApprovalAdapter.native,
sendOriginNotice: async ({ originTarget }) => {
await sendMessageSlack(originTarget.to, getExecApprovalApproverDmNoticeText(), {
cfg: this.opts.cfg,
accountId: this.opts.accountId,
threadTs: originTarget.threadId != null ? String(originTarget.threadId) : undefined,
client: this.opts.app.client,
});
},
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`,
target: {
to: plannedTarget.target.to,
threadTs:
plannedTarget.target.threadId != null
? String(plannedTarget.target.threadId)
: undefined,
},
}),
deliverTarget: async ({ preparedTarget }) => {
const message = await sendMessageSlack(preparedTarget.to, text, {
cfg: this.opts.cfg,
accountId: this.opts.accountId,
threadTs: preparedTarget.threadTs,
blocks,
client: this.opts.app.client,
});
return {
channelId: message.channelId,
messageTs: message.messageId,
};
},
onOriginNoticeError: ({ error }) => {
logError(`slack exec approvals: failed to send DM redirect notice: ${String(error)}`);
},
onDeliveryError: ({ error }) => {
logError(
`slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`,
);
},
});
}
private async finalizeResolved(
request: ExecApprovalRequest,
resolved: ExecApprovalResolved,