diff --git a/extensions/irc/src/active-clients.ts b/extensions/irc/src/active-clients.ts new file mode 100644 index 00000000000..cd960e201be --- /dev/null +++ b/extensions/irc/src/active-clients.ts @@ -0,0 +1,35 @@ +import type { IrcClient } from "./client.js"; + +/** + * Registry of active IRC clients from the monitor. + * Keyed by accountId. Allows sendMessageIrc to use the persistent + * monitor client instead of creating a transient connection. + */ +const activeClients = new Map(); + +export function setActiveClient(accountId: string, client: IrcClient): void { + activeClients.set(accountId, client); +} + +export function getActiveClient(accountId: string): IrcClient | undefined { + const client = activeClients.get(accountId); + if (client && client.isReady()) { + return client; + } + return undefined; +} + +export function removeActiveClient(accountId: string): void { + activeClients.delete(accountId); +} + +/** + * Only remove the active client if it matches the expected instance. + * Prevents a stopping monitor from deregistering a newer monitor's + * healthy client during reconnect races. + */ +export function removeActiveClientIfMatch(accountId: string, expected: IrcClient): void { + if (activeClients.get(accountId) === expected) { + activeClients.delete(accountId); + } +} diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index 2a75b76ee08..6eb487f586f 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,5 +1,6 @@ import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared"; import { resolveIrcAccount } from "./accounts.js"; +import { setActiveClient, removeActiveClientIfMatch } from "./active-clients.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; import { handleIrcInbound } from "./inbound.js"; @@ -136,8 +137,13 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto `[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`, ); + setActiveClient(account.accountId, client); + return { stop: () => { + if (client) { + removeActiveClientIfMatch(account.accountId, client); + } client?.quit("shutdown"); client = null; }, diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index 544f81f3f47..8b832cf1e28 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -1,4 +1,5 @@ import { resolveIrcAccount } from "./accounts.js"; +import { getActiveClient } from "./active-clients.js"; import type { IrcClient } from "./client.js"; import { connectIrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; @@ -67,13 +68,19 @@ export async function sendMessageIrc( if (client?.isReady()) { client.sendPrivmsg(target, payload); } else { - const transient = await connectIrcClient( - buildIrcConnectOptions(account, { - connectTimeoutMs: 12000, - }), - ); - transient.sendPrivmsg(target, payload); - transient.quit("sent"); + // Try the monitor's persistent client first (already connected and joined to channels) + const active = getActiveClient(account.accountId); + if (active) { + active.sendPrivmsg(target, payload); + } else { + const transient = await connectIrcClient( + buildIrcConnectOptions(account, { + connectTimeoutMs: 12000, + }), + ); + transient.sendPrivmsg(target, payload); + transient.quit("sent"); + } } runtime.channel.activity.record({ diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..a806fe05f32 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -41,6 +41,8 @@ export type AgentRouteBinding = { agentId: string; comment?: string; match: AgentBindingMatch; + /** When "main", group/channel messages matching this binding route to the agent's main session. */ + groupScope?: "main" | "per-channel"; }; export type AgentAcpBinding = { diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 5dddfc9813a..8c732f9a16d 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -40,6 +40,7 @@ const RouteBindingSchema = z agentId: z.string(), comment: z.string().optional(), match: BindingMatchSchema, + groupScope: z.union([z.literal("main"), z.literal("per-channel")]).optional(), }) .strict(); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index f56fdc1319d..bd5ab8bd68d 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -95,6 +95,8 @@ export function buildAgentSessionKey(params: { peer?: RoutePeer | null; /** DM session scope. */ dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; + /** Group/channel session scope. When "main", group messages route to the main session. */ + groupScope?: "main" | "per-channel"; identityLinks?: Record; }): string { const channel = normalizeToken(params.channel) || "unknown"; @@ -107,6 +109,7 @@ export function buildAgentSessionKey(params: { peerKind: peer?.kind ?? "direct", peerId: peer ? normalizeId(peer.id) || "unknown" : null, dmScope: params.dmScope, + groupScope: params.groupScope, identityLinks: params.identityLinks, }); } @@ -658,7 +661,11 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const bindings = getEvaluatedBindingsForChannelAccount(input.cfg, channel, accountId); const bindingsIndex = getEvaluatedBindingIndexForChannelAccount(input.cfg, channel, accountId); - const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => { + const choose = ( + agentId: string, + matchedBy: ResolvedAgentRoute["matchedBy"], + bindingGroupScope?: "main" | "per-channel", + ) => { const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); const sessionKey = buildAgentSessionKey({ agentId: resolvedAgentId, @@ -666,6 +673,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR accountId, peer, dmScope, + groupScope: bindingGroupScope ?? "per-channel", identityLinks, }).toLowerCase(); const mainSessionKey = buildAgentMainSessionKey({ @@ -796,7 +804,8 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (shouldLogDebug) { logDebug(`[routing] match: matchedBy=${tier.matchedBy} agentId=${matched.binding.agentId}`); } - return choose(matched.binding.agentId, tier.matchedBy); + const routeBinding = matched.binding as { groupScope?: "main" | "per-channel" }; + return choose(matched.binding.agentId, tier.matchedBy, routeBinding.groupScope); } } diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 5a24c63c3a4..2fe4b7c76f7 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -134,6 +134,8 @@ export function buildAgentPeerSessionKey(params: { identityLinks?: Record; /** DM session scope. */ dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; + /** Group/channel session scope. When "main", group messages route to the main session. */ + groupScope?: "main" | "per-channel"; }): string { const peerKind = params.peerKind ?? "direct"; if (peerKind === "direct") { @@ -168,6 +170,14 @@ export function buildAgentPeerSessionKey(params: { mainKey: params.mainKey, }); } + // For group/channel peer kinds: if groupScope is "main", collapse to main session + const groupScope = params.groupScope ?? "per-channel"; + if (groupScope === "main") { + return buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: params.mainKey, + }); + } const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; const peerId = ((params.peerId ?? "").trim() || "unknown").toLowerCase(); return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`;