From 4aa8da375659f34914d243ef4c0bb532d17f591b Mon Sep 17 00:00:00 2001 From: pashpashpash Date: Tue, 28 Apr 2026 17:27:18 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + docs/channels/discord.md | 2 + docs/tools/exec-approvals-advanced.md | 7 + .../telegram/src/exec-approvals.test.ts | 19 ++ .../reply/commands-diagnostics.test.ts | 14 +- src/auto-reply/reply/commands-diagnostics.ts | 26 +- .../reply/commands-export-trajectory.test.ts | 8 +- .../reply/commands-export-trajectory.ts | 14 +- .../reply/commands-private-route.test.ts | 288 ++++++++++++++++++ .../reply/commands-private-route.ts | 165 ++++++++-- 10 files changed, 500 insertions(+), 44 deletions(-) create mode 100644 src/auto-reply/reply/commands-private-route.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60827572428..a7892a33e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 8b3b3022cb8..7381b969ccd 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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. diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index e661fb5187a..a04050c2543 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -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. diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts index b01880e96ed..1ece513c104 100644 --- a/extensions/telegram/src/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -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 }, diff --git a/src/auto-reply/reply/commands-diagnostics.test.ts b/src/auto-reply/reply/commands-diagnostics.test.ts index ada174d46c7..0bc65611664 100644 --- a/src/auto-reply/reply/commands-diagnostics.test.ts +++ b/src/auto-reply/reply/commands-diagnostics.test.ts @@ -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"); }); diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts index cc1827d3d6b..5716a3cc159 100644 --- a/src/auto-reply/reply/commands-diagnostics.ts +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -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, }); diff --git a/src/auto-reply/reply/commands-export-trajectory.test.ts b/src/auto-reply/reply/commands-export-trajectory.test.ts index 78a385b74ff..71a1a1ba2eb 100644 --- a/src/auto-reply/reply/commands-export-trajectory.test.ts +++ b/src/auto-reply/reply/commands-export-trajectory.test.ts @@ -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", }); diff --git a/src/auto-reply/reply/commands-export-trajectory.ts b/src/auto-reply/reply/commands-export-trajectory.ts index ec0465b322d..daa684f4842 100644 --- a/src/auto-reply/reply/commands-export-trajectory.ts +++ b/src/auto-reply/reply/commands-export-trajectory.ts @@ -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, }); diff --git a/src/auto-reply/reply/commands-private-route.test.ts b/src/auto-reply/reply/commands-private-route.test.ts new file mode 100644 index 00000000000..7ec63382353 --- /dev/null +++ b/src/auto-reply/reply/commands-private-route.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/src/auto-reply/reply/commands-private-route.ts b/src/auto-reply/reply/commands-private-route.ts index 24328038971..bffcf7e0b12 100644 --- a/src/auto-reply/reply/commands-private-route.ts +++ b/src/auto-reply/reply/commands-private-route.ts @@ -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 { - 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> => + Boolean(plugin?.id), + ); + const seen = new Set(); + 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 { + const channel = normalizeLowercaseStringOrEmpty(target.channel); + const to = normalizeLowercaseStringOrEmpty(target.to); + const keys = new Set(); + 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[] {