| import type { ChannelDock } from "../channels/dock.js"; |
| import { getChannelDock, listChannelDocks } from "../channels/dock.js"; |
| import type { ChannelId } from "../channels/plugins/types.js"; |
| import { normalizeAnyChannelId } from "../channels/registry.js"; |
| import type { OpenClawConfig } from "../config/config.js"; |
| import { normalizeStringEntries } from "../shared/string-normalization.js"; |
| import { |
| INTERNAL_MESSAGE_CHANNEL, |
| isInternalMessageChannel, |
| normalizeMessageChannel, |
| } from "../utils/message-channel.js"; |
| import type { MsgContext } from "./templating.js"; |
|
|
| export type CommandAuthorization = { |
| providerId?: ChannelId; |
| ownerList: string[]; |
| senderId?: string; |
| senderIsOwner: boolean; |
| isAuthorizedSender: boolean; |
| from?: string; |
| to?: string; |
| }; |
|
|
| function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): ChannelId | undefined { |
| const explicitMessageChannel = |
| normalizeMessageChannel(ctx.Provider) ?? |
| normalizeMessageChannel(ctx.Surface) ?? |
| normalizeMessageChannel(ctx.OriginatingChannel); |
| if (explicitMessageChannel === INTERNAL_MESSAGE_CHANNEL) { |
| return undefined; |
| } |
| const direct = |
| normalizeAnyChannelId(explicitMessageChannel ?? undefined) ?? |
| normalizeAnyChannelId(ctx.Provider) ?? |
| normalizeAnyChannelId(ctx.Surface) ?? |
| normalizeAnyChannelId(ctx.OriginatingChannel); |
| if (direct) { |
| return direct; |
| } |
| const candidates = [ctx.From, ctx.To] |
| .filter((value): value is string => Boolean(value?.trim())) |
| .flatMap((value) => value.split(":").map((part) => part.trim())); |
| for (const candidate of candidates) { |
| const normalizedCandidateChannel = normalizeMessageChannel(candidate); |
| if (normalizedCandidateChannel === INTERNAL_MESSAGE_CHANNEL) { |
| return undefined; |
| } |
| const normalized = |
| normalizeAnyChannelId(normalizedCandidateChannel ?? undefined) ?? |
| normalizeAnyChannelId(candidate); |
| if (normalized) { |
| return normalized; |
| } |
| } |
| const configured = listChannelDocks() |
| .map((dock) => { |
| if (!dock.config?.resolveAllowFrom) { |
| return null; |
| } |
| const allowFrom = dock.config.resolveAllowFrom({ |
| cfg, |
| accountId: ctx.AccountId, |
| }); |
| if (!Array.isArray(allowFrom) || allowFrom.length === 0) { |
| return null; |
| } |
| return dock.id; |
| }) |
| .filter((value): value is ChannelId => Boolean(value)); |
| if (configured.length === 1) { |
| return configured[0]; |
| } |
| return undefined; |
| } |
|
|
| function formatAllowFromList(params: { |
| dock?: ChannelDock; |
| cfg: OpenClawConfig; |
| accountId?: string | null; |
| allowFrom: Array<string | number>; |
| }): string[] { |
| const { dock, cfg, accountId, allowFrom } = params; |
| if (!allowFrom || allowFrom.length === 0) { |
| return []; |
| } |
| if (dock?.config?.formatAllowFrom) { |
| return dock.config.formatAllowFrom({ cfg, accountId, allowFrom }); |
| } |
| return normalizeStringEntries(allowFrom); |
| } |
|
|
| function normalizeAllowFromEntry(params: { |
| dock?: ChannelDock; |
| cfg: OpenClawConfig; |
| accountId?: string | null; |
| value: string; |
| }): string[] { |
| const normalized = formatAllowFromList({ |
| dock: params.dock, |
| cfg: params.cfg, |
| accountId: params.accountId, |
| allowFrom: [params.value], |
| }); |
| return normalized.filter((entry) => entry.trim().length > 0); |
| } |
|
|
| function resolveOwnerAllowFromList(params: { |
| dock?: ChannelDock; |
| cfg: OpenClawConfig; |
| accountId?: string | null; |
| providerId?: ChannelId; |
| allowFrom?: Array<string | number>; |
| }): string[] { |
| const raw = params.allowFrom ?? params.cfg.commands?.ownerAllowFrom; |
| if (!Array.isArray(raw) || raw.length === 0) { |
| return []; |
| } |
| const filtered: string[] = []; |
| for (const entry of raw) { |
| const trimmed = String(entry ?? "").trim(); |
| if (!trimmed) { |
| continue; |
| } |
| const separatorIndex = trimmed.indexOf(":"); |
| if (separatorIndex > 0) { |
| const prefix = trimmed.slice(0, separatorIndex); |
| const channel = normalizeAnyChannelId(prefix); |
| if (channel) { |
| if (params.providerId && channel !== params.providerId) { |
| continue; |
| } |
| const remainder = trimmed.slice(separatorIndex + 1).trim(); |
| if (remainder) { |
| filtered.push(remainder); |
| } |
| continue; |
| } |
| } |
| filtered.push(trimmed); |
| } |
| return formatAllowFromList({ |
| dock: params.dock, |
| cfg: params.cfg, |
| accountId: params.accountId, |
| allowFrom: filtered, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| function resolveCommandsAllowFromList(params: { |
| dock?: ChannelDock; |
| cfg: OpenClawConfig; |
| accountId?: string | null; |
| providerId?: ChannelId; |
| }): string[] | null { |
| const { dock, cfg, accountId, providerId } = params; |
| const commandsAllowFrom = cfg.commands?.allowFrom; |
| if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { |
| return null; |
| } |
|
|
| |
| const providerKey = providerId ?? ""; |
| const providerList = commandsAllowFrom[providerKey]; |
| const globalList = commandsAllowFrom["*"]; |
|
|
| const rawList = Array.isArray(providerList) ? providerList : globalList; |
| if (!Array.isArray(rawList)) { |
| return null; |
| } |
|
|
| return formatAllowFromList({ |
| dock, |
| cfg, |
| accountId, |
| allowFrom: rawList, |
| }); |
| } |
|
|
| function isConversationLikeIdentity(value: string): boolean { |
| const normalized = value.trim().toLowerCase(); |
| if (!normalized) { |
| return false; |
| } |
| if (normalized.includes("@g.us")) { |
| return true; |
| } |
| if (normalized.startsWith("chat_id:")) { |
| return true; |
| } |
| return /(^|:)(channel|group|thread|topic|room|space|spaces):/.test(normalized); |
| } |
|
|
| function shouldUseFromAsSenderFallback(params: { |
| from?: string | null; |
| chatType?: string | null; |
| }): boolean { |
| const from = (params.from ?? "").trim(); |
| if (!from) { |
| return false; |
| } |
| const chatType = (params.chatType ?? "").trim().toLowerCase(); |
| if (chatType && chatType !== "direct") { |
| return false; |
| } |
| return !isConversationLikeIdentity(from); |
| } |
|
|
| function resolveSenderCandidates(params: { |
| dock?: ChannelDock; |
| providerId?: ChannelId; |
| cfg: OpenClawConfig; |
| accountId?: string | null; |
| senderId?: string | null; |
| senderE164?: string | null; |
| from?: string | null; |
| chatType?: string | null; |
| }): string[] { |
| const { dock, cfg, accountId } = params; |
| const candidates: string[] = []; |
| const pushCandidate = (value?: string | null) => { |
| const trimmed = (value ?? "").trim(); |
| if (!trimmed) { |
| return; |
| } |
| candidates.push(trimmed); |
| }; |
| if (params.providerId === "whatsapp") { |
| pushCandidate(params.senderE164); |
| pushCandidate(params.senderId); |
| } else { |
| pushCandidate(params.senderId); |
| pushCandidate(params.senderE164); |
| } |
| if ( |
| candidates.length === 0 && |
| shouldUseFromAsSenderFallback({ from: params.from, chatType: params.chatType }) |
| ) { |
| pushCandidate(params.from); |
| } |
|
|
| const normalized: string[] = []; |
| for (const sender of candidates) { |
| const entries = normalizeAllowFromEntry({ dock, cfg, accountId, value: sender }); |
| for (const entry of entries) { |
| if (!normalized.includes(entry)) { |
| normalized.push(entry); |
| } |
| } |
| } |
| return normalized; |
| } |
|
|
| export function resolveCommandAuthorization(params: { |
| ctx: MsgContext; |
| cfg: OpenClawConfig; |
| commandAuthorized: boolean; |
| }): CommandAuthorization { |
| const { ctx, cfg, commandAuthorized } = params; |
| const providerId = resolveProviderFromContext(ctx, cfg); |
| const dock = providerId ? getChannelDock(providerId) : undefined; |
| const from = (ctx.From ?? "").trim(); |
| const to = (ctx.To ?? "").trim(); |
|
|
| |
| const commandsAllowFromList = resolveCommandsAllowFromList({ |
| dock, |
| cfg, |
| accountId: ctx.AccountId, |
| providerId, |
| }); |
|
|
| const allowFromRaw = dock?.config?.resolveAllowFrom |
| ? dock.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId }) |
| : []; |
| const allowFromList = formatAllowFromList({ |
| dock, |
| cfg, |
| accountId: ctx.AccountId, |
| allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [], |
| }); |
| const configOwnerAllowFromList = resolveOwnerAllowFromList({ |
| dock, |
| cfg, |
| accountId: ctx.AccountId, |
| providerId, |
| allowFrom: cfg.commands?.ownerAllowFrom, |
| }); |
| const contextOwnerAllowFromList = resolveOwnerAllowFromList({ |
| dock, |
| cfg, |
| accountId: ctx.AccountId, |
| providerId, |
| allowFrom: ctx.OwnerAllowFrom, |
| }); |
| const allowAll = |
| allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"); |
|
|
| const ownerCandidatesForCommands = allowAll ? [] : allowFromList.filter((entry) => entry !== "*"); |
| if (!allowAll && ownerCandidatesForCommands.length === 0 && to) { |
| const normalizedTo = normalizeAllowFromEntry({ |
| dock, |
| cfg, |
| accountId: ctx.AccountId, |
| value: to, |
| }); |
| if (normalizedTo.length > 0) { |
| ownerCandidatesForCommands.push(...normalizedTo); |
| } |
| } |
| const ownerAllowAll = configOwnerAllowFromList.some((entry) => entry.trim() === "*"); |
| const explicitOwners = configOwnerAllowFromList.filter((entry) => entry !== "*"); |
| const explicitOverrides = contextOwnerAllowFromList.filter((entry) => entry !== "*"); |
| const ownerList = Array.from( |
| new Set( |
| explicitOwners.length > 0 |
| ? explicitOwners |
| : ownerAllowAll |
| ? [] |
| : explicitOverrides.length > 0 |
| ? explicitOverrides |
| : ownerCandidatesForCommands, |
| ), |
| ); |
|
|
| const senderCandidates = resolveSenderCandidates({ |
| dock, |
| providerId, |
| cfg, |
| accountId: ctx.AccountId, |
| senderId: ctx.SenderId, |
| senderE164: ctx.SenderE164, |
| from, |
| chatType: ctx.ChatType, |
| }); |
| const matchedSender = ownerList.length |
| ? senderCandidates.find((candidate) => ownerList.includes(candidate)) |
| : undefined; |
| const matchedCommandOwner = ownerCandidatesForCommands.length |
| ? senderCandidates.find((candidate) => ownerCandidatesForCommands.includes(candidate)) |
| : undefined; |
| const senderId = matchedSender ?? senderCandidates[0]; |
|
|
| const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands); |
| const senderIsOwnerByIdentity = Boolean(matchedSender); |
| const senderIsOwnerByScope = |
| isInternalMessageChannel(ctx.Provider) && |
| Array.isArray(ctx.GatewayClientScopes) && |
| ctx.GatewayClientScopes.includes("operator.admin"); |
| const ownerAllowlistConfigured = ownerAllowAll || explicitOwners.length > 0; |
| const senderIsOwner = senderIsOwnerByIdentity || senderIsOwnerByScope || ownerAllowAll; |
| const requireOwner = enforceOwner || ownerAllowlistConfigured; |
| const isOwnerForCommands = !requireOwner |
| ? true |
| : ownerAllowAll |
| ? true |
| : ownerAllowlistConfigured |
| ? senderIsOwner |
| : allowAll || ownerCandidatesForCommands.length === 0 || Boolean(matchedCommandOwner); |
|
|
| |
| |
| let isAuthorizedSender: boolean; |
| if (commandsAllowFromList !== null) { |
| |
| const commandsAllowAll = commandsAllowFromList.some((entry) => entry.trim() === "*"); |
| const matchedCommandsAllowFrom = commandsAllowFromList.length |
| ? senderCandidates.find((candidate) => commandsAllowFromList.includes(candidate)) |
| : undefined; |
| isAuthorizedSender = commandsAllowAll || Boolean(matchedCommandsAllowFrom); |
| } else { |
| |
| isAuthorizedSender = commandAuthorized && isOwnerForCommands; |
| } |
|
|
| return { |
| providerId, |
| ownerList, |
| senderId: senderId || undefined, |
| senderIsOwner, |
| isAuthorizedSender, |
| from: from || undefined, |
| to: to || undefined, |
| }; |
| } |
|
|