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({