Files
openclaw/extensions/qqbot/src/exec-approvals.ts
2026-05-01 15:31:33 +01:00

219 lines
7.9 KiB
TypeScript

import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
import {
createChannelExecApprovalProfile,
isChannelExecApprovalClientEnabledFromConfig,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { listQQBotAccountIds, resolveQQBotAccount } from "./bridge/config.js";
import type { QQBotExecApprovalConfig } from "./types.js";
function normalizeApproverId(value: string | number): string | undefined {
const trimmed = normalizeOptionalString(String(value));
return trimmed || undefined;
}
export function resolveQQBotExecApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): QQBotExecApprovalConfig | undefined {
const account = resolveQQBotAccount(params.cfg, params.accountId);
const config = account.config.execApprovals;
if (!config) {
return undefined;
}
return {
...config,
enabled: account.enabled && account.secretSource !== "none" ? config.enabled : false,
};
}
function getQQBotExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const accountConfig = resolveQQBotAccount(params.cfg, params.accountId).config;
return resolveApprovalApprovers({
explicit: resolveQQBotExecApprovalConfig(params)?.approvers,
allowFrom: accountConfig.allowFrom,
normalizeApprover: normalizeApproverId,
});
}
function countQQBotExecApprovalEligibleAccounts(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest | PluginApprovalRequest;
}): number {
return listQQBotAccountIds(params.cfg).filter((accountId) => {
const account = resolveQQBotAccount(params.cfg, accountId);
if (!account.enabled || account.secretSource === "none") {
return false;
}
const config = resolveQQBotExecApprovalConfig({
cfg: params.cfg,
accountId,
});
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getQQBotExecApprovalApprovers({ cfg: params.cfg, accountId }).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
fallbackAgentIdFromSessionKey: true,
})
);
}).length;
}
function matchesQQBotRequestAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
channel: "qqbot",
});
if (turnSourceChannel && turnSourceChannel !== "qqbot" && !boundAccountId) {
return (
countQQBotExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
}) <= 1
);
}
return (
!boundAccountId ||
!params.accountId ||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
);
}
/**
* Count QQBot accounts that could actually deliver a native approval
* message — i.e. accounts that are enabled and have resolvable secrets.
* Disabled or unconfigured accounts never spawn a handler, so they
* must not contribute to the single-account shortcut in the fallback
* ownership check below.
*/
function countQQBotFallbackEligibleAccounts(cfg: OpenClawConfig): number {
return listQQBotAccountIds(cfg).filter((accountId) => {
const account = resolveQQBotAccount(cfg, accountId);
return account.enabled && account.secretSource !== "none";
}).length;
}
/**
* Fallback account-ownership check — applied when `execApprovals` is NOT
* configured for any QQBot account. In this mode every enabled account
* handler would otherwise race to deliver the same approval to its own
* openid namespace, so we must enforce per-account isolation.
*
* Rules:
* - If the request carries a bound account (via `turnSourceAccountId`
* or session binding), only the handler whose `accountId` matches it
* delivers the approval. This is strict: a handler with an unknown
* `accountId` (null/undefined) must not claim a bound request.
* - If no account is bound, only deliver when there is a single
* *eligible* QQBot account (enabled + secret resolved). Disabled or
* unconfigured accounts never deliver anyway, so they shouldn't
* block the remaining single account from handling the approval.
* Multiple eligible accounts cannot safely race because openids are
* account-scoped — cross-account delivery hits the QQ Bot API with
* a mismatched token and fails.
*/
function matchesQQBotFallbackRequestAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean {
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
channel: "qqbot",
});
if (boundAccountId) {
if (!params.accountId) {
return false;
}
return normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId);
}
return countQQBotFallbackEligibleAccounts(params.cfg) <= 1;
}
/**
* Minimal structural shape required to evaluate per-account ownership.
*
* The SDK types (`ExecApprovalRequest` / `PluginApprovalRequest`) and the
* channel-local approval request types (see `engine/approval/index.ts`)
* share the same logical fields but differ on bookkeeping metadata
* (e.g. `createdAtMs`), so we accept any object exposing the relevant
* routing fields. Consumers can pass either flavor safely.
*/
type QQBotApprovalAccountOwnershipRequest = {
request: {
sessionKey?: string | null;
turnSourceChannel?: string | null;
turnSourceTo?: string | null;
turnSourceAccountId?: string | null;
};
};
/**
* Unified per-account ownership check used by both the profile and
* fallback approval paths. Dispatches to the profile rules when the
* current account has `execApprovals` configured, otherwise uses the
* fallback rules.
*
* This is the single source of truth for "does this QQBot handler own
* this approval request?" and is consumed by both the capability
* gate (shouldHandle) and the lazy native runtime adapter.
*/
export function matchesQQBotApprovalAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: QQBotApprovalAccountOwnershipRequest;
}): boolean {
const normalized = {
cfg: params.cfg,
accountId: params.accountId,
request: params.request as unknown as ExecApprovalRequest | PluginApprovalRequest,
};
if (resolveQQBotExecApprovalConfig(normalized) !== undefined) {
return matchesQQBotRequestAccount(normalized);
}
return matchesQQBotFallbackRequestAccount(normalized);
}
const qqbotExecApprovalProfile = createChannelExecApprovalProfile({
resolveConfig: resolveQQBotExecApprovalConfig,
resolveApprovers: getQQBotExecApprovalApprovers,
matchesRequestAccount: matchesQQBotRequestAccount,
fallbackAgentIdFromSessionKey: true,
requireClientEnabledForLocalPromptSuppression: false,
});
export const isQQBotExecApprovalClientEnabled = qqbotExecApprovalProfile.isClientEnabled;
export const isQQBotExecApprovalApprover = qqbotExecApprovalProfile.isApprover;
export const isQQBotExecApprovalAuthorizedSender = qqbotExecApprovalProfile.isAuthorizedSender;
export const shouldHandleQQBotExecApprovalRequest = qqbotExecApprovalProfile.shouldHandleRequest;