mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 20:44:47 +00:00
refactor: share channel route target collection
This commit is contained in:
@@ -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<ChannelDoctorModule>(
|
||||
() => 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<string, Set<string>>();
|
||||
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<string>();
|
||||
byAgent.set(normalizedAgentId, channels);
|
||||
}
|
||||
channels.add(trimmedChannel);
|
||||
};
|
||||
|
||||
const routeBindings: AgentRouteBinding[] = listRouteBindings(cfg);
|
||||
const fullyCoveredChannels = new Set<string>();
|
||||
const coveredAccountsByChannel = new Map<string, Set<string>>();
|
||||
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<string>();
|
||||
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 [];
|
||||
|
||||
92
src/routing/channel-route-targets.test.ts
Normal file
92
src/routing/channel-route-targets.test.ts
Normal file
@@ -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<string, string[]> {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
98
src/routing/channel-route-targets.ts
Normal file
98
src/routing/channel-route-targets.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, Set<string>>, agentId: string, channel: string): void {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const trimmedChannel = channel.trim();
|
||||
if (!normalizedAgentId || !trimmedChannel) {
|
||||
return;
|
||||
}
|
||||
const channels = byAgent.get(normalizedAgentId) ?? new Set<string>();
|
||||
channels.add(trimmedChannel);
|
||||
byAgent.set(normalizedAgentId, channels);
|
||||
}
|
||||
|
||||
export function collectChannelRouteTargets(cfg: OpenClawConfig): ChannelRouteTarget[] {
|
||||
const byAgent = new Map<string, Set<string>>();
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user