refactor(approvals): share origin target reconciliation

This commit is contained in:
Peter Steinberger
2026-03-31 23:02:10 +01:00
parent ddce362d34
commit aa6cf87814
8 changed files with 342 additions and 253 deletions

View File

@@ -1,14 +1,9 @@
import {
createApproverRestrictedNativeApprovalAdapter,
doesApprovalRequestMatchChannelAccount,
resolveApprovalRequestSessionTarget,
resolveApprovalRequestOriginTarget,
} from "openclaw/plugin-sdk/approval-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalRequest,
ExecApprovalSessionTarget,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
import {
getDiscordExecApprovalApprovers,
@@ -52,70 +47,45 @@ function normalizeDiscordOriginChannelId(value?: string | null): string | null {
return /^\d+$/.test(trimmed) ? trimmed : null;
}
function resolveRequestSessionTarget(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
}): ExecApprovalSessionTarget | null {
return resolveApprovalRequestSessionTarget(params);
}
function resolveDiscordOriginTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}) {
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "discord",
accountId: params.accountId,
})
) {
return null;
}
const sessionKind = extractDiscordSessionKind(params.request.request.sessionKey?.trim() || null);
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const rawTurnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
const turnSourceTarget =
turnSourceChannel === "discord" &&
turnSourceTo &&
sessionKind !== "dm" &&
(hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group")
? {
to: turnSourceTo,
}
: null;
const sessionTarget = resolveRequestSessionTarget(params);
if (
turnSourceTarget &&
sessionTarget?.channel === "discord" &&
turnSourceTarget.to !== normalizeDiscordOriginChannelId(sessionTarget.to)
) {
return null;
}
if (turnSourceTarget) {
return { to: turnSourceTarget.to };
}
if (sessionKind === "dm") {
return null;
}
if (sessionTarget?.channel === "discord") {
const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to);
return targetTo ? { to: targetTo } : null;
}
const legacyChannelId = extractDiscordChannelId(
params.request.request.sessionKey?.trim() || null,
);
if (legacyChannelId) {
return { to: legacyChannelId };
}
return null;
return resolveApprovalRequestOriginTarget({
cfg: params.cfg,
request: params.request,
channel: "discord",
accountId: params.accountId,
resolveTurnSourceTarget: (request) => {
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
if (turnSourceChannel !== "discord" || !turnSourceTo || sessionKind === "dm") {
return null;
}
return hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group"
? { to: turnSourceTo }
: null;
},
resolveSessionTarget: (sessionTarget) => {
if (sessionKind === "dm") {
return null;
}
const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to);
return targetTo ? { to: targetTo } : null;
},
targetsMatch: (a, b) => a.to === b.to,
resolveFallbackTarget: (request) => {
if (sessionKind === "dm") {
return null;
}
const legacyChannelId = extractDiscordChannelId(request.request.sessionKey?.trim() || null);
return legacyChannelId ? { to: legacyChannelId } : null;
},
});
}
function resolveDiscordApproverDmTargets(params: {

View File

@@ -1,5 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions.js";
import { slackNativeApprovalAdapter } from "./approval-native.js";
function buildConfig(
@@ -21,6 +25,13 @@ function buildConfig(
} as OpenClawConfig;
}
const STORE_PATH = path.join(os.tmpdir(), "openclaw-slack-approval-native-test.json");
function writeStore(store: Record<string, unknown>) {
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
clearSessionStoreCacheForTest();
}
describe("slack native approval adapter", () => {
it("describes native slack approval delivery capabilities", () => {
const capabilities = slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
@@ -120,6 +131,45 @@ describe("slack native approval adapter", () => {
expect(targets).toEqual([{ to: "user:U123APPROVER" }]);
});
it("falls back to the session-bound origin target for plugin approvals", async () => {
writeStore({
"agent:main:slack:channel:c123": {
sessionId: "sess",
updatedAt: Date.now(),
deliveryContext: {
channel: "slack",
to: "channel:C123",
accountId: "default",
threadId: "1712345678.123456",
},
},
});
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678",
});
});
it("skips native delivery when agent filters do not match", async () => {
const cfg = buildConfig({
execApprovals: {

View File

@@ -1,14 +1,9 @@
import {
createApproverRestrictedNativeApprovalAdapter,
resolveExecApprovalSessionTarget,
resolveApprovalRequestOriginTarget,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalRequest,
ExecApprovalSessionTarget,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { listSlackAccountIds } from "./accounts.js";
import { isSlackApprovalAuthorizedSender } from "./approval-auth.js";
import {
@@ -21,30 +16,7 @@ import {
import { parseSlackTarget } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type SlackOriginTarget = { to: string; threadId?: string; accountId?: string };
function isExecApprovalRequest(request: ApprovalRequest): request is ExecApprovalRequest {
return "command" in request.request;
}
function toExecLikeRequest(request: ApprovalRequest): ExecApprovalRequest {
if (isExecApprovalRequest(request)) {
return request;
}
return {
id: request.id,
request: {
command: request.request.title,
sessionKey: request.request.sessionKey ?? undefined,
turnSourceChannel: request.request.turnSourceChannel ?? undefined,
turnSourceTo: request.request.turnSourceTo ?? undefined,
turnSourceAccountId: request.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: request.request.turnSourceThreadId ?? undefined,
},
createdAtMs: request.createdAtMs,
expiresAtMs: request.expiresAtMs,
};
}
type SlackOriginTarget = { to: string; threadId?: string };
function extractSlackSessionKind(
sessionKey?: string | null,
@@ -69,38 +41,13 @@ function normalizeSlackThreadMatchKey(threadId?: string): string {
return leadingEpoch ?? trimmed;
}
function resolveRequestSessionTarget(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
}): ExecApprovalSessionTarget | null {
const execLikeRequest = toExecLikeRequest(params.request);
return resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: execLikeRequest,
turnSourceChannel: execLikeRequest.request.turnSourceChannel ?? undefined,
turnSourceTo: execLikeRequest.request.turnSourceTo ?? undefined,
turnSourceAccountId: execLikeRequest.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: execLikeRequest.request.turnSourceThreadId ?? undefined,
});
}
function resolveTurnSourceSlackOriginTarget(params: {
accountId: string;
request: ApprovalRequest;
}): SlackOriginTarget | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
function resolveTurnSourceSlackOriginTarget(request: ApprovalRequest): SlackOriginTarget | null {
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
if (turnSourceChannel !== "slack" || !turnSourceTo) {
return null;
}
if (
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const sessionKind = extractSlackSessionKind(params.request.request.sessionKey ?? undefined);
const sessionKind = extractSlackSessionKind(request.request.sessionKey ?? undefined);
const parsed = parseSlackTarget(turnSourceTo, {
defaultKind: sessionKind === "direct" ? "user" : "channel",
});
@@ -108,33 +55,21 @@ function resolveTurnSourceSlackOriginTarget(params: {
return null;
}
const threadId =
typeof params.request.request.turnSourceThreadId === "string"
? params.request.request.turnSourceThreadId.trim() || undefined
: typeof params.request.request.turnSourceThreadId === "number"
? String(params.request.request.turnSourceThreadId)
typeof request.request.turnSourceThreadId === "string"
? request.request.turnSourceThreadId.trim() || undefined
: typeof request.request.turnSourceThreadId === "number"
? String(request.request.turnSourceThreadId)
: undefined;
return {
to: `${parsed.kind}:${parsed.id}`,
threadId,
accountId: turnSourceAccountId || undefined,
};
}
function resolveSessionSlackOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}): SlackOriginTarget | null {
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "slack") {
return null;
}
if (
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
function resolveSessionSlackOriginTarget(sessionTarget: {
to: string;
threadId?: string | number | null;
}): SlackOriginTarget {
return {
to: sessionTarget.to,
threadId:
@@ -143,19 +78,13 @@ function resolveSessionSlackOriginTarget(params: {
: typeof sessionTarget.threadId === "number"
? String(sessionTarget.threadId)
: undefined,
accountId: sessionTarget.accountId ?? undefined,
};
}
function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean {
const accountMatches =
!a.accountId ||
!b.accountId ||
normalizeAccountId(a.accountId) === normalizeAccountId(b.accountId);
return (
normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) &&
normalizeSlackThreadMatchKey(a.threadId) === normalizeSlackThreadMatchKey(b.threadId) &&
accountMatches
normalizeSlackThreadMatchKey(a.threadId) === normalizeSlackThreadMatchKey(b.threadId)
);
}
@@ -167,13 +96,15 @@ function resolveSlackOriginTarget(params: {
if (!shouldHandleSlackExecApprovalRequest(params)) {
return null;
}
const turnSourceTarget = resolveTurnSourceSlackOriginTarget(params);
const sessionTarget = resolveSessionSlackOriginTarget(params);
if (turnSourceTarget && sessionTarget && !slackTargetsMatch(turnSourceTarget, sessionTarget)) {
return null;
}
const target = turnSourceTarget ?? sessionTarget;
return target ? { to: target.to, threadId: target.threadId } : null;
return resolveApprovalRequestOriginTarget({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget,
resolveSessionTarget: resolveSessionSlackOriginTarget,
targetsMatch: slackTargetsMatch,
});
}
function resolveSlackApproverDmTargets(params: {

View File

@@ -1,5 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions.js";
import { telegramNativeApprovalAdapter } from "./approval-native.js";
function buildConfig(
@@ -20,6 +24,13 @@ function buildConfig(
} as OpenClawConfig;
}
const STORE_PATH = path.join(os.tmpdir(), "openclaw-telegram-approval-native-test.json");
function writeStore(store: Record<string, unknown>) {
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
clearSessionStoreCacheForTest();
}
describe("telegram native approval adapter", () => {
it("normalizes direct-chat origin targets so DM dedupe can converge", async () => {
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
@@ -45,4 +56,43 @@ describe("telegram native approval adapter", () => {
threadId: undefined,
});
});
it("falls back to the session-bound origin target for plugin approvals", async () => {
writeStore({
"agent:main:telegram:group:-1003841603622:topic:928": {
sessionId: "sess",
updatedAt: Date.now(),
deliveryContext: {
channel: "telegram",
to: "-1003841603622",
accountId: "default",
threadId: 928,
},
},
});
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "-1003841603622",
threadId: 928,
});
});
});

View File

@@ -1,12 +1,9 @@
import { createApproverRestrictedNativeApprovalAdapter } from "openclaw/plugin-sdk/approval-runtime";
import {
createApproverRestrictedNativeApprovalAdapter,
resolveApprovalRequestOriginTarget,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalRequest,
ExecApprovalSessionTarget,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalSessionTarget } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { listTelegramAccountIds } from "./accounts.js";
import {
getTelegramExecApprovalApprovers,
@@ -18,106 +15,43 @@ import {
import { normalizeTelegramChatId } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type TelegramOriginTarget = { to: string; threadId?: number; accountId?: string };
type TelegramOriginTarget = { to: string; threadId?: number };
function isExecApprovalRequest(request: ApprovalRequest): request is ExecApprovalRequest {
return "command" in request.request;
}
function toExecLikeRequest(request: ApprovalRequest): ExecApprovalRequest {
if (isExecApprovalRequest(request)) {
return request;
}
return {
id: request.id,
request: {
command: request.request.title,
sessionKey: request.request.sessionKey ?? undefined,
turnSourceChannel: request.request.turnSourceChannel ?? undefined,
turnSourceTo: request.request.turnSourceTo ?? undefined,
turnSourceAccountId: request.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: request.request.turnSourceThreadId ?? undefined,
},
createdAtMs: request.createdAtMs,
expiresAtMs: request.expiresAtMs,
};
}
function resolveRequestSessionTarget(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
}): ExecApprovalSessionTarget | null {
const execLikeRequest = toExecLikeRequest(params.request);
return resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: execLikeRequest,
turnSourceChannel: execLikeRequest.request.turnSourceChannel ?? undefined,
turnSourceTo: execLikeRequest.request.turnSourceTo ?? undefined,
turnSourceAccountId: execLikeRequest.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: execLikeRequest.request.turnSourceThreadId ?? undefined,
});
}
function resolveTurnSourceTelegramOriginTarget(params: {
accountId: string;
request: ApprovalRequest;
}): TelegramOriginTarget | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const rawTurnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
function resolveTurnSourceTelegramOriginTarget(
request: ApprovalRequest,
): TelegramOriginTarget | null {
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
const turnSourceTo = normalizeTelegramChatId(rawTurnSourceTo) ?? rawTurnSourceTo;
if (turnSourceChannel !== "telegram" || !turnSourceTo) {
return null;
}
if (
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const threadId =
typeof params.request.request.turnSourceThreadId === "number"
? params.request.request.turnSourceThreadId
: typeof params.request.request.turnSourceThreadId === "string"
? Number.parseInt(params.request.request.turnSourceThreadId, 10)
typeof request.request.turnSourceThreadId === "number"
? request.request.turnSourceThreadId
: typeof request.request.turnSourceThreadId === "string"
? Number.parseInt(request.request.turnSourceThreadId, 10)
: undefined;
return {
to: turnSourceTo,
threadId: Number.isFinite(threadId) ? threadId : undefined,
accountId: turnSourceAccountId || undefined,
};
}
function resolveSessionTelegramOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}): TelegramOriginTarget | null {
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "telegram") {
return null;
}
if (
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
function resolveSessionTelegramOriginTarget(sessionTarget: {
to: string;
threadId?: number | null;
}): TelegramOriginTarget {
return {
to: normalizeTelegramChatId(sessionTarget.to) ?? sessionTarget.to,
threadId: sessionTarget.threadId,
accountId: sessionTarget.accountId,
threadId: sessionTarget.threadId ?? undefined,
};
}
function telegramTargetsMatch(a: TelegramOriginTarget, b: TelegramOriginTarget): boolean {
const accountMatches =
!a.accountId ||
!b.accountId ||
normalizeAccountId(a.accountId) === normalizeAccountId(b.accountId);
const normalizedA = normalizeTelegramChatId(a.to) ?? a.to;
const normalizedB = normalizeTelegramChatId(b.to) ?? b.to;
return normalizedA === normalizedB && a.threadId === b.threadId && accountMatches;
return normalizedA === normalizedB && a.threadId === b.threadId;
}
function resolveTelegramOriginTarget(params: {
@@ -125,13 +59,15 @@ function resolveTelegramOriginTarget(params: {
accountId: string;
request: ApprovalRequest;
}) {
const turnSourceTarget = resolveTurnSourceTelegramOriginTarget(params);
const sessionTarget = resolveSessionTelegramOriginTarget(params);
if (turnSourceTarget && sessionTarget && !telegramTargetsMatch(turnSourceTarget, sessionTarget)) {
return null;
}
const target = turnSourceTarget ?? sessionTarget;
return target ? { to: target.to, threadId: target.threadId } : null;
return resolveApprovalRequestOriginTarget({
cfg: params.cfg,
request: params.request,
channel: "telegram",
accountId: params.accountId,
resolveTurnSourceTarget: resolveTurnSourceTelegramOriginTarget,
resolveSessionTarget: resolveSessionTelegramOriginTarget,
targetsMatch: telegramTargetsMatch,
});
}
function resolveTelegramApproverDmTargets(params: {

View File

@@ -7,9 +7,11 @@ import type { SessionEntry } from "../config/sessions.js";
import {
doesApprovalRequestMatchChannelAccount,
resolveApprovalRequestAccountId,
resolveApprovalRequestOriginTarget,
resolveExecApprovalSessionTarget,
} from "./exec-approval-session-target.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
const tempDirs: string[] = [];
@@ -65,6 +67,22 @@ function buildRequest(
};
}
function buildPluginRequest(
overrides: Partial<PluginApprovalRequest["request"]> = {},
): PluginApprovalRequest {
return {
id: "plugin:req-1",
request: {
title: "Plugin approval",
description: "Allow plugin action",
sessionKey: "agent:main:main",
...overrides,
},
createdAtMs: 1000,
expiresAtMs: 6000,
};
}
describe("exec approval session target", () => {
type PlaceholderStoreCase = {
name: string;
@@ -278,4 +296,81 @@ describe("exec approval session target", () => {
}),
).toBe(true);
});
it("reconciles plugin-request turn source and session origin targets through the shared helper", () => {
const tmpDir = createTempDir();
const storePath = path.join(tmpDir, "sessions.json");
const cfg = writeStoreFile(storePath, {
"agent:main:main": {
sessionId: "main",
updatedAt: 1,
lastChannel: "slack",
lastTo: "channel:C123",
},
});
const target = resolveApprovalRequestOriginTarget({
cfg,
request: buildPluginRequest({
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
}),
channel: "slack",
accountId: "default",
resolveTurnSourceTarget: (request) =>
request.request.turnSourceChannel === "slack" && request.request.turnSourceTo
? { to: request.request.turnSourceTo }
: null,
resolveSessionTarget: (sessionTarget) => ({ to: sessionTarget.to }),
targetsMatch: (a, b) => a.to === b.to,
});
expect(target).toEqual({ to: "channel:C123" });
});
it("returns null when explicit turn source conflicts with the session-bound origin target", () => {
const tmpDir = createTempDir();
const storePath = path.join(tmpDir, "sessions.json");
const cfg = writeStoreFile(storePath, {
"agent:main:main": {
sessionId: "main",
updatedAt: 1,
lastChannel: "slack",
lastTo: "channel:C123",
},
});
const target = resolveApprovalRequestOriginTarget({
cfg,
request: buildPluginRequest({
turnSourceChannel: "slack",
turnSourceTo: "channel:C999",
}),
channel: "slack",
accountId: "default",
resolveTurnSourceTarget: (request) =>
request.request.turnSourceChannel === "slack" && request.request.turnSourceTo
? { to: request.request.turnSourceTo }
: null,
resolveSessionTarget: (sessionTarget) => ({ to: sessionTarget.to }),
targetsMatch: (a, b) => a.to === b.to,
});
expect(target).toBeNull();
});
it("falls back to a legacy origin target when no turn-source or session target exists", () => {
const target = resolveApprovalRequestOriginTarget({
cfg: {} as OpenClawConfig,
request: buildPluginRequest({ sessionKey: "agent:main:missing" }),
channel: "discord",
accountId: "default",
resolveTurnSourceTarget: () => null,
resolveSessionTarget: () => ({ to: "unused" }),
targetsMatch: (a, b) => a.to === b.to,
resolveFallbackTarget: () => ({ to: "channel:legacy" }),
});
expect(target).toEqual({ to: "channel:legacy" });
});
});

View File

@@ -20,6 +20,16 @@ type ApprovalRequestSessionBinding = {
};
type ApprovalRequestLike = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalRequestOriginTargetResolver<TTarget> = {
cfg: OpenClawConfig;
request: ApprovalRequestLike;
channel: string;
accountId?: string | null;
resolveTurnSourceTarget: (request: ApprovalRequestLike) => TTarget | null;
resolveSessionTarget: (sessionTarget: ExecApprovalSessionTarget) => TTarget | null;
targetsMatch: (a: TTarget, b: TTarget) => boolean;
resolveFallbackTarget?: (request: ApprovalRequestLike) => TTarget | null;
};
function normalizeOptionalString(value?: string | null): string | undefined {
const normalized = value?.trim();
@@ -142,6 +152,17 @@ export function resolveApprovalRequestSessionTarget(params: {
});
}
function resolveApprovalRequestStoredSessionTarget(params: {
cfg: OpenClawConfig;
request: ApprovalRequestLike;
}): ExecApprovalSessionTarget | null {
const execLikeRequest = toExecLikeApprovalRequest(params.request);
return resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: execLikeRequest,
});
}
export function resolveApprovalRequestAccountId(params: {
cfg: OpenClawConfig;
request: ApprovalRequestLike;
@@ -214,3 +235,38 @@ export function doesApprovalRequestMatchChannelAccount(params: {
const boundAccountId = sessionAccountId;
return !expectedAccountId || !boundAccountId || expectedAccountId === boundAccountId;
}
export function resolveApprovalRequestOriginTarget<TTarget>(
params: ApprovalRequestOriginTargetResolver<TTarget>,
): TTarget | null {
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: params.channel,
accountId: params.accountId,
})
) {
return null;
}
const turnSourceTarget = params.resolveTurnSourceTarget(params.request);
const expectedChannel = normalizeOptionalChannel(params.channel);
const sessionTargetBinding = resolveApprovalRequestStoredSessionTarget({
cfg: params.cfg,
request: params.request,
});
const sessionTarget =
sessionTargetBinding &&
normalizeOptionalChannel(sessionTargetBinding.channel) === expectedChannel
? params.resolveSessionTarget(sessionTargetBinding)
: null;
if (turnSourceTarget && sessionTarget && !params.targetsMatch(turnSourceTarget, sessionTarget)) {
return null;
}
return (
turnSourceTarget ?? sessionTarget ?? params.resolveFallbackTarget?.(params.request) ?? null
);
}

View File

@@ -19,6 +19,7 @@ export {
export { resolveExecApprovalCommandDisplay } from "../infra/exec-approval-command-display.js";
export {
doesApprovalRequestMatchChannelAccount,
resolveApprovalRequestOriginTarget,
resolveApprovalRequestAccountId,
resolveApprovalRequestSessionTarget,
resolveExecApprovalSessionTarget,