mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
Route sensitive group commands to the owner privately (#73872)
* fix(commands): route sensitive group approvals privately * fix(commands): require owner private routes * test(commands): cover owner-derived Telegram diagnostics routing
This commit is contained in:
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
|
||||
- Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash.
|
||||
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
|
||||
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
|
||||
- Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX.
|
||||
|
||||
@@ -904,6 +904,8 @@ Default slash command settings:
|
||||
|
||||
Discord auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `commands.ownerAllowFrom`. Discord does not infer exec approvers from channel `allowFrom`, legacy `dm.allowFrom`, or direct-message `defaultTo`. Set `enabled: false` to disable Discord as a native approval client explicitly.
|
||||
|
||||
For sensitive owner-only group commands such as `/diagnostics` and `/export-trajectory`, OpenClaw sends approval prompts and final results privately. It tries Discord DM first when the invoking owner has a Discord owner route; if that is not available, it falls back to the first available owner route from `commands.ownerAllowFrom`, such as Telegram.
|
||||
|
||||
When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only resolved approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery.
|
||||
|
||||
Discord also renders the shared approval buttons used by other chat channels. The native Discord adapter mainly adds approver DM routing and channel fanout.
|
||||
|
||||
@@ -313,6 +313,13 @@ Shared behavior:
|
||||
- pending exec approvals expire after 30 minutes by default
|
||||
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
|
||||
|
||||
Sensitive owner-only group commands such as `/diagnostics` and `/export-trajectory` use private
|
||||
owner routing for approval prompts and final results. OpenClaw first tries a private route on the
|
||||
same surface where the owner ran the command. If that surface has no private owner route, it falls
|
||||
back to the first available owner route from `commands.ownerAllowFrom`, so a Discord group command
|
||||
can still send the approval and result to the owner's Telegram DM when Telegram is the configured
|
||||
primary private interface. The group chat only gets a short acknowledgement.
|
||||
|
||||
Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you
|
||||
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
|
||||
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.
|
||||
|
||||
@@ -165,6 +165,25 @@ describe("telegram exec approvals", () => {
|
||||
expect(isTelegramExecApprovalApprover({ cfg, senderId: "67890" })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require explicit Telegram exec approvers when command owner identifies the Telegram operator", () => {
|
||||
const cfg = {
|
||||
...buildConfig(),
|
||||
commands: {
|
||||
ownerAllowFrom: ["telegram:12345"],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(cfg.channels?.telegram?.execApprovals?.approvers).toBeUndefined();
|
||||
expect(getTelegramExecApprovalApprovers({ cfg })).toEqual(["12345"]);
|
||||
expect(isTelegramExecApprovalClientEnabled({ cfg })).toBe(true);
|
||||
expect(
|
||||
shouldHandleTelegramExecApprovalRequest({
|
||||
cfg,
|
||||
request: makeForeignChannelApprovalRequest({ id: "discord-diagnostics" }),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not infer approvers from Telegram chat allowlists", () => {
|
||||
const cfg = buildConfig(
|
||||
{ enabled: true },
|
||||
|
||||
@@ -467,7 +467,10 @@ describe("diagnostics command", () => {
|
||||
const { calls } = registerCodexDiagnosticsCommandForTest(async () => null);
|
||||
const { execCalls, privateReplies, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(
|
||||
{
|
||||
privateTargets: [{ channel: "whatsapp", to: "owner-dm", accountId: "account-1" }],
|
||||
privateTargets: [
|
||||
{ channel: "telegram", to: "owner-dm", accountId: "account-1" },
|
||||
{ channel: "whatsapp", to: "backup-owner-dm", accountId: "account-2" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -492,6 +495,7 @@ describe("diagnostics command", () => {
|
||||
expect(privateReplies).toHaveLength(0);
|
||||
expect(execCalls).toHaveLength(1);
|
||||
expect(execCalls[0]?.defaults).toMatchObject({
|
||||
messageProvider: "telegram",
|
||||
currentChannelId: "owner-dm",
|
||||
accountId: "account-1",
|
||||
});
|
||||
@@ -543,7 +547,10 @@ describe("diagnostics command", () => {
|
||||
ownership: "reserved",
|
||||
});
|
||||
const { privateReplies, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest({
|
||||
privateTargets: [{ channel: "whatsapp", to: "owner-dm", accountId: "account-1" }],
|
||||
privateTargets: [
|
||||
{ channel: "telegram", to: "owner-dm", accountId: "account-1" },
|
||||
{ channel: "whatsapp", to: "backup-owner-dm", accountId: "account-2" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await handleDiagnosticsCommand(
|
||||
@@ -555,6 +562,9 @@ describe("diagnostics command", () => {
|
||||
"Diagnostics are sensitive. I sent the diagnostics details and approval prompts to the owner privately.",
|
||||
);
|
||||
expect(privateReplies).toHaveLength(1);
|
||||
expect(privateReplies[0]?.targets).toEqual([
|
||||
{ channel: "telegram", to: "owner-dm", accountId: "account-1" },
|
||||
]);
|
||||
expect(privateReplies[0]?.text).toContain("Codex diagnostics sent to OpenAI servers:");
|
||||
expect(privateReplies[0]?.text).toContain("codex-thread-1");
|
||||
});
|
||||
|
||||
@@ -116,9 +116,16 @@ async function handleDiagnosticsCommandWithDeps(
|
||||
reply: { text: DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE },
|
||||
};
|
||||
}
|
||||
const privateTarget = targets[0];
|
||||
if (!privateTarget) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE },
|
||||
};
|
||||
}
|
||||
const privateReply = await buildDiagnosticsReply(deps, params, args, {
|
||||
diagnosticsPrivateRouted: true,
|
||||
privateApprovalTarget: targets[0],
|
||||
privateApprovalTarget: privateTarget,
|
||||
});
|
||||
if (!privateReply) {
|
||||
return {
|
||||
@@ -128,7 +135,7 @@ async function handleDiagnosticsCommandWithDeps(
|
||||
}
|
||||
const delivered = await deps.deliverPrivateDiagnosticsReply({
|
||||
commandParams: params,
|
||||
targets,
|
||||
targets: [privateTarget],
|
||||
reply: privateReply,
|
||||
});
|
||||
return {
|
||||
@@ -177,9 +184,16 @@ async function deliverGroupDiagnosticsReplyPrivately(
|
||||
reply: { text: DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE },
|
||||
};
|
||||
}
|
||||
const privateTarget = targets[0];
|
||||
if (!privateTarget) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE },
|
||||
};
|
||||
}
|
||||
const delivered = await deps.deliverPrivateDiagnosticsReply({
|
||||
commandParams: params,
|
||||
targets,
|
||||
targets: [privateTarget],
|
||||
reply,
|
||||
});
|
||||
return {
|
||||
@@ -294,14 +308,16 @@ async function requestGatewayDiagnosticsExportApproval(
|
||||
cwd: params.workspaceDir,
|
||||
agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.command.channel,
|
||||
messageProvider: options.privateApprovalTarget?.channel ?? params.command.channel,
|
||||
currentChannelId: options.privateApprovalTarget?.to ?? readCommandDeliveryTarget(params),
|
||||
currentThreadTs: options.privateApprovalTarget
|
||||
? options.privateApprovalTarget.threadId == null
|
||||
? undefined
|
||||
: String(options.privateApprovalTarget.threadId)
|
||||
: messageThreadId,
|
||||
accountId: options.privateApprovalTarget?.accountId ?? params.ctx.AccountId ?? undefined,
|
||||
accountId: options.privateApprovalTarget
|
||||
? (options.privateApprovalTarget.accountId ?? undefined)
|
||||
: (params.ctx.AccountId ?? undefined),
|
||||
notifyOnExit: params.cfg.tools?.exec?.notifyOnExit,
|
||||
notifyOnExitEmptySuccess: params.cfg.tools?.exec?.notifyOnExitEmptySuccess,
|
||||
});
|
||||
|
||||
@@ -401,7 +401,10 @@ describe("buildExportTrajectoryCommandReply", () => {
|
||||
it("routes group trajectory export approval privately", async () => {
|
||||
const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js");
|
||||
const { execCalls, privateReplies, deps } = createExecDeps({
|
||||
privateTargets: [{ channel: "quietchat", to: "owner-dm", accountId: "account-1" }],
|
||||
privateTargets: [
|
||||
{ channel: "telegram", to: "owner-dm", accountId: "account-1" },
|
||||
{ channel: "whatsapp", to: "backup-owner-dm", accountId: "account-2" },
|
||||
],
|
||||
});
|
||||
const params = makeParams();
|
||||
params.isGroup = true;
|
||||
@@ -415,13 +418,14 @@ describe("buildExportTrajectoryCommandReply", () => {
|
||||
expect(reply.text).not.toContain("agent:target:session");
|
||||
expect(privateReplies).toHaveLength(1);
|
||||
expect(privateReplies[0]?.targets).toEqual([
|
||||
{ channel: "quietchat", to: "owner-dm", accountId: "account-1" },
|
||||
{ channel: "telegram", to: "owner-dm", accountId: "account-1" },
|
||||
]);
|
||||
expect(privateReplies[0]?.text).toContain("Trajectory exports can include prompts");
|
||||
expect(privateReplies[0]?.text).toContain("openclaw sessions export-trajectory");
|
||||
expect(privateReplies[0]?.text).toContain("Session: agent:target:session");
|
||||
expect(execCalls).toHaveLength(1);
|
||||
expect(execCalls[0]?.defaults).toMatchObject({
|
||||
messageProvider: "telegram",
|
||||
currentChannelId: "owner-dm",
|
||||
accountId: "account-1",
|
||||
});
|
||||
|
||||
@@ -81,12 +81,16 @@ export async function buildExportTrajectoryCommandReply(
|
||||
if (targets.length === 0) {
|
||||
return { text: EXPORT_TRAJECTORY_PRIVATE_ROUTE_UNAVAILABLE };
|
||||
}
|
||||
const privateTarget = targets[0];
|
||||
if (!privateTarget) {
|
||||
return { text: EXPORT_TRAJECTORY_PRIVATE_ROUTE_UNAVAILABLE };
|
||||
}
|
||||
const privateReply = await buildExportTrajectoryApprovalReply(resolvedDeps, params, request, {
|
||||
privateApprovalTarget: targets[0],
|
||||
privateApprovalTarget: privateTarget,
|
||||
});
|
||||
const delivered = await resolvedDeps.deliverPrivateTrajectoryReply({
|
||||
commandParams: params,
|
||||
targets,
|
||||
targets: [privateTarget],
|
||||
reply: privateReply,
|
||||
});
|
||||
return {
|
||||
@@ -241,14 +245,16 @@ async function requestTrajectoryExportApproval(
|
||||
cwd: params.workspaceDir,
|
||||
agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.command.channel,
|
||||
messageProvider: options.privateApprovalTarget?.channel ?? params.command.channel,
|
||||
currentChannelId: options.privateApprovalTarget?.to ?? readCommandDeliveryTarget(params),
|
||||
currentThreadTs: options.privateApprovalTarget
|
||||
? options.privateApprovalTarget.threadId == null
|
||||
? undefined
|
||||
: String(options.privateApprovalTarget.threadId)
|
||||
: messageThreadId,
|
||||
accountId: options.privateApprovalTarget?.accountId ?? params.ctx.AccountId ?? undefined,
|
||||
accountId: options.privateApprovalTarget
|
||||
? (options.privateApprovalTarget.accountId ?? undefined)
|
||||
: (params.ctx.AccountId ?? undefined),
|
||||
notifyOnExit: params.cfg.tools?.exec?.notifyOnExit,
|
||||
notifyOnExitEmptySuccess: params.cfg.tools?.exec?.notifyOnExitEmptySuccess,
|
||||
});
|
||||
|
||||
288
src/auto-reply/reply/commands-private-route.test.ts
Normal file
288
src/auto-reply/reply/commands-private-route.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { ExecApprovalRequest } from "../../infra/exec-approvals.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { resolvePrivateCommandRouteTargets } from "./commands-private-route.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
|
||||
function createApprovalChannelPlugin(params: {
|
||||
id: "discord" | "telegram" | "whatsapp";
|
||||
targets: Array<{ to: string; threadId?: string | number | null }>;
|
||||
enabled?: boolean;
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
...createChannelTestPluginBase({
|
||||
id: params.id,
|
||||
label: params.id,
|
||||
}),
|
||||
approvalCapability: {
|
||||
native: {
|
||||
describeDeliveryCapabilities: vi.fn(() => ({
|
||||
enabled: params.enabled !== false,
|
||||
preferredSurface: "approver-dm" as const,
|
||||
supportsOriginSurface: false,
|
||||
supportsApproverDmSurface: true,
|
||||
})),
|
||||
resolveApproverDmTargets: vi.fn(() => params.targets),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOwnerDerivedApprovalChannelPlugin(params: {
|
||||
id: "telegram";
|
||||
ownerPrefixes: string[];
|
||||
}): ChannelPlugin {
|
||||
const resolveOwnerTargets = (cfg: OpenClawConfig) =>
|
||||
(cfg.commands?.ownerAllowFrom ?? [])
|
||||
.map((owner) => String(owner))
|
||||
.flatMap((owner) => {
|
||||
const trimmed = owner.trim();
|
||||
const prefix = params.ownerPrefixes.find((candidate) =>
|
||||
trimmed.toLowerCase().startsWith(`${candidate}:`),
|
||||
);
|
||||
if (prefix) {
|
||||
const value = trimmed.slice(prefix.length + 1).trim();
|
||||
return value ? [value] : [];
|
||||
}
|
||||
return /^\d+$/.test(trimmed) ? [trimmed] : [];
|
||||
})
|
||||
.map((to) => ({ to }));
|
||||
|
||||
return {
|
||||
...createChannelTestPluginBase({
|
||||
id: params.id,
|
||||
label: params.id,
|
||||
}),
|
||||
approvalCapability: {
|
||||
native: {
|
||||
describeDeliveryCapabilities: vi.fn(({ cfg }) => {
|
||||
const targets = resolveOwnerTargets(cfg);
|
||||
return {
|
||||
enabled: targets.length > 0,
|
||||
preferredSurface: "approver-dm" as const,
|
||||
supportsOriginSurface: false,
|
||||
supportsApproverDmSurface: true,
|
||||
};
|
||||
}),
|
||||
resolveApproverDmTargets: vi.fn(({ cfg }) => resolveOwnerTargets(cfg)),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerApprovalChannelPlugins(plugins: ChannelPlugin[]) {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry(
|
||||
plugins.map((plugin) => ({
|
||||
pluginId: plugin.id,
|
||||
source: "test",
|
||||
plugin,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCommandParams(cfg: OpenClawConfig): HandleCommandsParams {
|
||||
return {
|
||||
cfg,
|
||||
ctx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
AccountId: "discord-bot-account",
|
||||
} as MsgContext,
|
||||
command: {
|
||||
commandBodyNormalized: "/diagnostics",
|
||||
isAuthorizedSender: true,
|
||||
senderIsOwner: true,
|
||||
senderId: "493655423946194964",
|
||||
channel: "discord",
|
||||
channelId: "discord",
|
||||
surface: "discord",
|
||||
ownerList: [],
|
||||
rawBodyNormalized: "/diagnostics",
|
||||
from: "493655423946194964",
|
||||
to: "channel:1487138064806449297",
|
||||
},
|
||||
sessionKey: "agent:main:discord:channel:1487138064806449297",
|
||||
workspaceDir: "/tmp",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
contextTokens: 0,
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: true,
|
||||
directives: {},
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
} as unknown as HandleCommandsParams;
|
||||
}
|
||||
|
||||
function buildApprovalRequest(): ExecApprovalRequest {
|
||||
return {
|
||||
id: "diagnostics-private-route",
|
||||
request: {
|
||||
command: "openclaw gateway diagnostics export --json",
|
||||
sessionKey: "agent:main:discord:channel:1487138064806449297",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceTo: "channel:1487138064806449297",
|
||||
turnSourceAccountId: "discord-bot-account",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 60_001,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
describe("resolvePrivateCommandRouteTargets", () => {
|
||||
it("prefers a same-surface private owner route even when another owner route is listed first", async () => {
|
||||
registerApprovalChannelPlugins([
|
||||
createApprovalChannelPlugin({
|
||||
id: "telegram",
|
||||
targets: [{ to: "849985193" }],
|
||||
}),
|
||||
createApprovalChannelPlugin({
|
||||
id: "discord",
|
||||
targets: [{ to: "493655423946194964" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const targets = await resolvePrivateCommandRouteTargets({
|
||||
commandParams: buildCommandParams({
|
||||
commands: {
|
||||
ownerAllowFrom: ["telegram:849985193", "discord:493655423946194964"],
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
request: buildApprovalRequest(),
|
||||
});
|
||||
|
||||
expect(targets[0]).toEqual({
|
||||
channel: "discord",
|
||||
to: "493655423946194964",
|
||||
accountId: "discord-bot-account",
|
||||
threadId: undefined,
|
||||
});
|
||||
expect(targets[1]).toEqual({
|
||||
channel: "telegram",
|
||||
to: "849985193",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the first configured owner route when the source surface has no private route", async () => {
|
||||
registerApprovalChannelPlugins([
|
||||
createApprovalChannelPlugin({
|
||||
id: "discord",
|
||||
targets: [],
|
||||
}),
|
||||
createApprovalChannelPlugin({
|
||||
id: "whatsapp",
|
||||
targets: [{ to: "+15555550100" }],
|
||||
}),
|
||||
createApprovalChannelPlugin({
|
||||
id: "telegram",
|
||||
targets: [{ to: "849985193" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const targets = await resolvePrivateCommandRouteTargets({
|
||||
commandParams: buildCommandParams({
|
||||
commands: {
|
||||
ownerAllowFrom: [
|
||||
"discord:493655423946194964",
|
||||
"telegram:849985193",
|
||||
"whatsapp:+15555550100",
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
request: buildApprovalRequest(),
|
||||
});
|
||||
|
||||
expect(targets[0]).toMatchObject({
|
||||
channel: "telegram",
|
||||
to: "849985193",
|
||||
});
|
||||
expect(targets[1]).toMatchObject({
|
||||
channel: "whatsapp",
|
||||
to: "+15555550100",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not select a same-surface exec approver unless it is also an owner route", async () => {
|
||||
registerApprovalChannelPlugins([
|
||||
createApprovalChannelPlugin({
|
||||
id: "discord",
|
||||
targets: [{ to: "non-owner-approver" }],
|
||||
}),
|
||||
createApprovalChannelPlugin({
|
||||
id: "telegram",
|
||||
targets: [{ to: "849985193" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const targets = await resolvePrivateCommandRouteTargets({
|
||||
commandParams: buildCommandParams({
|
||||
commands: {
|
||||
ownerAllowFrom: ["telegram:849985193"],
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
request: buildApprovalRequest(),
|
||||
});
|
||||
|
||||
expect(targets).toEqual([
|
||||
{
|
||||
channel: "telegram",
|
||||
to: "849985193",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes a Discord group command to the Telegram owner without Telegram exec approvers", async () => {
|
||||
registerApprovalChannelPlugins([
|
||||
createApprovalChannelPlugin({
|
||||
id: "discord",
|
||||
targets: [],
|
||||
}),
|
||||
createOwnerDerivedApprovalChannelPlugin({
|
||||
id: "telegram",
|
||||
ownerPrefixes: ["telegram", "tg"],
|
||||
}),
|
||||
]);
|
||||
|
||||
const targets = await resolvePrivateCommandRouteTargets({
|
||||
commandParams: buildCommandParams({
|
||||
commands: {
|
||||
ownerAllowFrom: ["telegram:849985193"],
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "test-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
request: buildApprovalRequest(),
|
||||
});
|
||||
|
||||
expect(targets).toEqual([
|
||||
{
|
||||
channel: "telegram",
|
||||
to: "849985193",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
getLoadedChannelPlugin,
|
||||
listChannelPlugins,
|
||||
resolveChannelApprovalAdapter,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import type { ExecApprovalRequest } from "../../infra/exec-approvals.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "../../shared/string-coerce.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
@@ -20,37 +24,49 @@ export async function resolvePrivateCommandRouteTargets(params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
request: ExecApprovalRequest;
|
||||
}): Promise<PrivateCommandRouteTarget[]> {
|
||||
const adapter = resolveChannelApprovalAdapter(
|
||||
getLoadedChannelPlugin(params.commandParams.command.channel),
|
||||
);
|
||||
const native = adapter?.native;
|
||||
if (!native?.resolveApproverDmTargets) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.commandParams.ctx.AccountId ?? undefined;
|
||||
const capabilities = native.describeDeliveryCapabilities({
|
||||
cfg: params.commandParams.cfg,
|
||||
accountId,
|
||||
approvalKind: "exec",
|
||||
request: params.request,
|
||||
});
|
||||
if (!capabilities.enabled || !capabilities.supportsApproverDmSurface) {
|
||||
return [];
|
||||
}
|
||||
const targets = await native.resolveApproverDmTargets({
|
||||
cfg: params.commandParams.cfg,
|
||||
accountId,
|
||||
approvalKind: "exec",
|
||||
request: params.request,
|
||||
});
|
||||
return dedupePrivateCommandRouteTargets(
|
||||
targets.map((target) => ({
|
||||
channel: params.commandParams.command.channel,
|
||||
to: target.to,
|
||||
const originChannel = params.commandParams.command.channel;
|
||||
const targets: PrivateCommandRouteTarget[] = [];
|
||||
for (const candidate of listPrivateCommandRouteCandidateChannels(originChannel)) {
|
||||
const native = resolveChannelApprovalAdapter(candidate.plugin)?.native;
|
||||
if (!native?.resolveApproverDmTargets) {
|
||||
continue;
|
||||
}
|
||||
const accountId =
|
||||
candidate.channel === originChannel
|
||||
? (params.commandParams.ctx.AccountId ?? undefined)
|
||||
: undefined;
|
||||
const capabilities = native.describeDeliveryCapabilities({
|
||||
cfg: params.commandParams.cfg,
|
||||
accountId,
|
||||
threadId: target.threadId,
|
||||
})),
|
||||
);
|
||||
approvalKind: "exec",
|
||||
request: params.request,
|
||||
});
|
||||
if (!capabilities.enabled || !capabilities.supportsApproverDmSurface) {
|
||||
continue;
|
||||
}
|
||||
const resolvedTargets = await native.resolveApproverDmTargets({
|
||||
cfg: params.commandParams.cfg,
|
||||
accountId,
|
||||
approvalKind: "exec",
|
||||
request: params.request,
|
||||
});
|
||||
for (const target of resolvedTargets) {
|
||||
targets.push({
|
||||
channel: candidate.channel,
|
||||
to: target.to,
|
||||
accountId,
|
||||
threadId: target.threadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return sortPrivateCommandRouteTargets({
|
||||
cfg: params.commandParams.cfg,
|
||||
originChannel,
|
||||
targets: filterPrivateCommandRouteOwnerTargets({
|
||||
cfg: params.commandParams.cfg,
|
||||
targets: dedupePrivateCommandRouteTargets(targets),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deliverPrivateCommandReply(params: {
|
||||
@@ -92,6 +108,93 @@ export function readCommandDeliveryTarget(params: HandleCommandsParams): string
|
||||
);
|
||||
}
|
||||
|
||||
function listPrivateCommandRouteCandidateChannels(originChannel: string) {
|
||||
const plugins = [getLoadedChannelPlugin(originChannel), ...listChannelPlugins()].filter(
|
||||
(plugin): plugin is NonNullable<ReturnType<typeof getLoadedChannelPlugin>> =>
|
||||
Boolean(plugin?.id),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const candidates: Array<{ channel: string; plugin: (typeof plugins)[number] }> = [];
|
||||
for (const plugin of plugins) {
|
||||
const channel = normalizeOptionalString(plugin.id) ?? "";
|
||||
if (!channel || seen.has(channel)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(channel);
|
||||
candidates.push({ channel, plugin });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveOwnerPreferenceIndex(params: {
|
||||
cfg: HandleCommandsParams["cfg"];
|
||||
target: PrivateCommandRouteTarget;
|
||||
}): number {
|
||||
const owners = params.cfg.commands?.ownerAllowFrom;
|
||||
if (!Array.isArray(owners) || owners.length === 0) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
const keys = buildPrivateCommandRouteOwnerKeys(params.target);
|
||||
const index = owners.findIndex((owner) =>
|
||||
keys.has(normalizeLowercaseStringOrEmpty(String(owner))),
|
||||
);
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||
}
|
||||
|
||||
function buildPrivateCommandRouteOwnerKeys(target: PrivateCommandRouteTarget): Set<string> {
|
||||
const channel = normalizeLowercaseStringOrEmpty(target.channel);
|
||||
const to = normalizeLowercaseStringOrEmpty(target.to);
|
||||
const keys = new Set<string>();
|
||||
if (to) {
|
||||
keys.add(to);
|
||||
keys.add(`user:${to}`);
|
||||
}
|
||||
if (channel && to) {
|
||||
keys.add(`${channel}:${to}`);
|
||||
if (channel === "telegram") {
|
||||
keys.add(`tg:${to}`);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function sortPrivateCommandRouteTargets(params: {
|
||||
cfg: HandleCommandsParams["cfg"];
|
||||
originChannel: string;
|
||||
targets: PrivateCommandRouteTarget[];
|
||||
}): PrivateCommandRouteTarget[] {
|
||||
return params.targets
|
||||
.map((target, index) => ({
|
||||
target,
|
||||
index,
|
||||
ownerPreference: resolveOwnerPreferenceIndex({ cfg: params.cfg, target }),
|
||||
originPreference: target.channel === params.originChannel ? 0 : 1,
|
||||
}))
|
||||
.toSorted((a, b) => {
|
||||
if (a.originPreference !== b.originPreference) {
|
||||
return a.originPreference - b.originPreference;
|
||||
}
|
||||
if (a.ownerPreference !== b.ownerPreference) {
|
||||
return a.ownerPreference - b.ownerPreference;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.target);
|
||||
}
|
||||
|
||||
function filterPrivateCommandRouteOwnerTargets(params: {
|
||||
cfg: HandleCommandsParams["cfg"];
|
||||
targets: PrivateCommandRouteTarget[];
|
||||
}): PrivateCommandRouteTarget[] {
|
||||
return params.targets.filter(
|
||||
(target) =>
|
||||
resolveOwnerPreferenceIndex({
|
||||
cfg: params.cfg,
|
||||
target,
|
||||
}) !== Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
}
|
||||
|
||||
function dedupePrivateCommandRouteTargets(
|
||||
targets: PrivateCommandRouteTarget[],
|
||||
): PrivateCommandRouteTarget[] {
|
||||
|
||||
Reference in New Issue
Block a user