diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index b16f498d213..4d16b46d8a1 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -1,24 +1,14 @@ -import { resolveAgentConfig, resolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; +import { resolveAgentConfig } from "../../../agents/agent-scope-config.js"; import { pickSandboxToolPolicy } from "../../../agents/sandbox-tool-policy.js"; import { isToolAllowedByPolicies } from "../../../agents/tool-policy-match.js"; import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../../agents/tool-policy.js"; -import { normalizeChatChannelId } from "../../../channels/ids.js"; -import { listRouteBindings } from "../../../config/bindings.js"; -import type { AgentRouteBinding } from "../../../config/types.agents.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { AgentToolsConfig, ToolsConfig } from "../../../config/types.tools.js"; -import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeAgentId, -} from "../../../routing/session-key.js"; +import { collectChannelRouteTargets } from "../../../routing/channel-route-targets.js"; import { createLazyImportLoader } from "../../../shared/lazy-promise.js"; -import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; type ChannelDoctorModule = typeof import("./channel-doctor.js"); -const CHANNELS_CONFIG_META_KEYS = new Set(["defaults", "modelByChannel"]); - const channelDoctorModuleLoader = createLazyImportLoader( () => import("./channel-doctor.js"), ); @@ -39,47 +29,6 @@ function hasChannels(cfg: OpenClawConfig): boolean { return hasRecord(cfg.channels); } -function normalizeChannelKey(raw?: string | null): string { - return normalizeChatChannelId(raw) ?? normalizeLowercaseStringOrEmpty(raw); -} - -function normalizeRouteBindingChannelKey(raw?: string | null): string { - return normalizeLowercaseStringOrEmpty(raw); -} - -function listConfiguredChannelIds(cfg: OpenClawConfig): string[] { - if (!hasRecord(cfg.channels)) { - return []; - } - return Object.entries(cfg.channels) - .filter(([id, value]) => { - if (CHANNELS_CONFIG_META_KEYS.has(id)) { - return false; - } - return !(hasRecord(value) && value.enabled === false); - }) - .map(([id]) => normalizeChannelKey(id)) - .filter(Boolean) - .toSorted(); -} - -function listConfiguredChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] { - if (!hasRecord(cfg.channels)) { - return []; - } - const channel = Object.entries(cfg.channels).find( - ([id]) => normalizeChannelKey(id) === channelId, - )?.[1]; - if (!hasRecord(channel) || !hasRecord(channel.accounts)) { - return []; - } - return Object.entries(channel.accounts) - .filter(([, value]) => !(hasRecord(value) && value.enabled === false)) - .map(([accountId]) => normalizeAccountId(accountId)) - .filter(Boolean) - .toSorted(); -} - function hasPlugins(cfg: OpenClawConfig): boolean { return hasRecord(cfg.plugins); } @@ -254,100 +203,8 @@ function formatChannelList(channels: string[]): string { .join(", ")}, and ${channels.length - 2} more`; } -function isUnscopedRouteBinding(binding: AgentRouteBinding): boolean { - const match = binding.match; - const hasRoles = Array.isArray(match.roles) && match.roles.length > 0; - return !match.peer && !match.guildId && !match.teamId && !hasRoles; -} - -function isUnscopedChannelRouteBinding(binding: AgentRouteBinding): boolean { - const accountId = binding.match.accountId?.trim(); - return accountId === "*" && isUnscopedRouteBinding(binding); -} - -function resolveUnscopedBindingAccountId(binding: AgentRouteBinding): string | undefined { - if (!isUnscopedRouteBinding(binding)) { - return undefined; - } - const accountId = binding.match.accountId?.trim(); - if (accountId === "*") { - return "*"; - } - return normalizeAccountId(accountId || DEFAULT_ACCOUNT_ID); -} - -function collectBoundChannelTargets(cfg: OpenClawConfig): Array<{ - agentId: string; - channels: string[]; -}> { - const byAgent = new Map>(); - const add = (agentId: string, channel: string) => { - const normalizedAgentId = normalizeAgentId(agentId); - const trimmedChannel = channel.trim(); - if (!normalizedAgentId || !trimmedChannel) { - return; - } - let channels = byAgent.get(normalizedAgentId); - if (!channels) { - channels = new Set(); - byAgent.set(normalizedAgentId, channels); - } - channels.add(trimmedChannel); - }; - - const routeBindings: AgentRouteBinding[] = listRouteBindings(cfg); - const fullyCoveredChannels = new Set(); - const coveredAccountsByChannel = new Map>(); - for (const binding of routeBindings) { - const channel = normalizeRouteBindingChannelKey(binding.match.channel); - add(binding.agentId, channel); - if (!channel) { - continue; - } - if (isUnscopedChannelRouteBinding(binding)) { - fullyCoveredChannels.add(channel); - continue; - } - const coveredAccountId = resolveUnscopedBindingAccountId(binding); - if (coveredAccountId && coveredAccountId !== "*") { - let coveredAccounts = coveredAccountsByChannel.get(channel); - if (!coveredAccounts) { - coveredAccounts = new Set(); - coveredAccountsByChannel.set(channel, coveredAccounts); - } - coveredAccounts.add(coveredAccountId); - } - } - - const defaultAgentId = resolveDefaultAgentId(cfg); - for (const channel of listConfiguredChannelIds(cfg)) { - if (fullyCoveredChannels.has(channel)) { - continue; - } - const configuredAccountIds = listConfiguredChannelAccountIds(cfg, channel); - const coveredAccounts = coveredAccountsByChannel.get(channel); - if (configuredAccountIds.length === 0) { - if (!coveredAccounts?.has(DEFAULT_ACCOUNT_ID)) { - add(defaultAgentId, channel); - } - continue; - } - if (configuredAccountIds.some((accountId) => !coveredAccounts?.has(accountId))) { - add(defaultAgentId, channel); - } - } - - return Array.from(byAgent.entries()) - .map(([agentId, channels]) => ({ - agentId, - channels: Array.from(channels).toSorted(), - })) - .filter((target) => target.channels.length > 0) - .toSorted((a, b) => a.agentId.localeCompare(b.agentId)); -} - export function collectChannelBoundMessageToolPolicyWarnings(cfg: OpenClawConfig): string[] { - return collectBoundChannelTargets(cfg).flatMap((target) => { + return collectChannelRouteTargets(cfg).flatMap((target) => { const agentTools = resolveAgentConfig(cfg, target.agentId)?.tools; if (resolveMessageToolAvailability({ globalTools: cfg.tools, agentTools })) { return []; diff --git a/src/routing/channel-route-targets.test.ts b/src/routing/channel-route-targets.test.ts new file mode 100644 index 00000000000..34c6dc34702 --- /dev/null +++ b/src/routing/channel-route-targets.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { collectChannelRouteTargets } from "./channel-route-targets.js"; + +function targetMap(cfg: OpenClawConfig): Map { + return new Map( + collectChannelRouteTargets(cfg).map((target) => [target.agentId, target.channels]), + ); +} + +describe("collectChannelRouteTargets", () => { + it("uses runtime routing to suppress default targets covered by channel routes", () => { + const targets = targetMap({ + channels: { + discord: {}, + telegram: {}, + }, + agents: { + list: [{ id: "main", default: true }, { id: "commander" }], + }, + bindings: [ + { + agentId: "commander", + match: { + channel: "discord", + }, + }, + ], + }); + + expect(targets.get("commander")).toEqual(["discord"]); + expect(targets.get("main")).toEqual(["telegram"]); + }); + + it("samples configured accounts through resolveAgentRoute", () => { + const targets = targetMap({ + channels: { + discord: { + accounts: { + personal: {}, + work: {}, + }, + }, + }, + agents: { + list: [{ id: "main", default: true }, { id: "personal-agent" }, { id: "work-agent" }], + }, + bindings: [ + { + agentId: "personal-agent", + match: { + channel: "Discord", + accountId: "personal", + }, + }, + { + agentId: "work-agent", + match: { + channel: "Discord", + accountId: "work", + }, + }, + ], + }); + + expect(targets.get("personal-agent")).toEqual(["discord"]); + expect(targets.get("work-agent")).toEqual(["discord"]); + expect(targets.has("main")).toBe(false); + }); + + it("does not treat route-binding channel aliases as configured channel coverage", () => { + const targets = targetMap({ + channels: { + imessage: {}, + }, + agents: { + list: [{ id: "main", default: true }, { id: "ios-agent" }], + }, + bindings: [ + { + agentId: "ios-agent", + match: { + channel: "imsg", + }, + }, + ], + }); + + expect(targets.get("ios-agent")).toEqual(["imsg"]); + expect(targets.get("main")).toEqual(["imessage"]); + }); +}); diff --git a/src/routing/channel-route-targets.ts b/src/routing/channel-route-targets.ts new file mode 100644 index 00000000000..e221bd89280 --- /dev/null +++ b/src/routing/channel-route-targets.ts @@ -0,0 +1,98 @@ +import { normalizeChatChannelId } from "../channels/ids.js"; +import { listRouteBindings } from "../config/bindings.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { resolveAgentRoute } from "./resolve-route.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeAgentId } from "./session-key.js"; + +export type ChannelRouteTarget = { + agentId: string; + channels: string[]; +}; + +const CHANNELS_CONFIG_META_KEYS = new Set(["defaults", "modelByChannel"]); + +function hasRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeConfiguredChannelKey(raw?: string | null): string { + return normalizeChatChannelId(raw) ?? normalizeLowercaseStringOrEmpty(raw); +} + +function normalizeRouteBindingChannelKey(raw?: string | null): string { + return normalizeLowercaseStringOrEmpty(raw); +} + +function listConfiguredChannelIds(cfg: OpenClawConfig): string[] { + if (!hasRecord(cfg.channels)) { + return []; + } + return Object.entries(cfg.channels) + .filter(([id, value]) => { + if (CHANNELS_CONFIG_META_KEYS.has(id)) { + return false; + } + return !(hasRecord(value) && value.enabled === false); + }) + .map(([id]) => normalizeConfiguredChannelKey(id)) + .filter(Boolean) + .toSorted(); +} + +function listConfiguredChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] { + if (!hasRecord(cfg.channels)) { + return []; + } + const channel = Object.entries(cfg.channels).find( + ([id]) => normalizeConfiguredChannelKey(id) === channelId, + )?.[1]; + if (!hasRecord(channel) || !hasRecord(channel.accounts)) { + return []; + } + return Object.entries(channel.accounts) + .filter(([, value]) => !(hasRecord(value) && value.enabled === false)) + .map(([accountId]) => normalizeAccountId(accountId)) + .filter(Boolean) + .toSorted(); +} + +function addTarget(byAgent: Map>, agentId: string, channel: string): void { + const normalizedAgentId = normalizeAgentId(agentId); + const trimmedChannel = channel.trim(); + if (!normalizedAgentId || !trimmedChannel) { + return; + } + const channels = byAgent.get(normalizedAgentId) ?? new Set(); + channels.add(trimmedChannel); + byAgent.set(normalizedAgentId, channels); +} + +export function collectChannelRouteTargets(cfg: OpenClawConfig): ChannelRouteTarget[] { + const byAgent = new Map>(); + + for (const binding of listRouteBindings(cfg)) { + addTarget(byAgent, binding.agentId, normalizeRouteBindingChannelKey(binding.match.channel)); + } + + for (const channel of listConfiguredChannelIds(cfg)) { + const accountIds = listConfiguredChannelAccountIds(cfg, channel); + const sampledAccountIds = accountIds.length > 0 ? accountIds : [DEFAULT_ACCOUNT_ID]; + for (const accountId of sampledAccountIds) { + const route = resolveAgentRoute({ + cfg, + channel, + accountId, + }); + addTarget(byAgent, route.agentId, channel); + } + } + + return Array.from(byAgent.entries()) + .map(([agentId, channels]) => ({ + agentId, + channels: Array.from(channels).toSorted(), + })) + .filter((target) => target.channels.length > 0) + .toSorted((a, b) => a.agentId.localeCompare(b.agentId)); +}