mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 21:21:10 +00:00
refactor(approvals): share origin target reconciliation
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
export { resolveExecApprovalCommandDisplay } from "../infra/exec-approval-command-display.js";
|
||||
export {
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
resolveApprovalRequestOriginTarget,
|
||||
resolveApprovalRequestAccountId,
|
||||
resolveApprovalRequestSessionTarget,
|
||||
resolveExecApprovalSessionTarget,
|
||||
|
||||
Reference in New Issue
Block a user