Files
openclaw/extensions/irc/src/send.ts
Peter Steinberger c34b97f0c6 improve(irc): reuse transient connection for chunked sends (#128487)
* perf(irc): reuse transient connection across chunks

Amp-Thread-ID: https://ampcode.com/threads/T-01a02f9f-3359-7327-bff0-6a4f85f666b7

* fix(irc): honor cancellation during connect

* fix(irc): stop chunk fanout after disconnect

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-08-23 22:44:16 -07:00

171 lines
4.8 KiB
TypeScript

// Irc plugin module implements send behavior.
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { convertMarkdownTables, stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
import { resolveIrcAccount } from "./accounts.js";
import type { IrcClient } from "./client.js";
import { connectIrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import { normalizeIrcMessagingTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
type SendIrcOptions = {
cfg: CoreConfig;
accountId?: string;
replyTo?: string;
target?: string;
client?: IrcClient;
abortSignal?: AbortSignal;
onPlatformSendDispatch?: () => Promise<void>;
};
type SendIrcMessage = {
text: string;
replyTo?: string;
};
export type SendIrcResult = {
messageId: string;
target: string;
receipt: MessageReceipt;
};
function recordIrcOutboundActivity(accountId: string): void {
try {
getIrcRuntime().channel.activity.record({
channel: "irc",
accountId,
direction: "outbound",
});
} catch (error) {
if (!(error instanceof Error) || error.message !== "IRC runtime not initialized") {
throw error;
}
}
}
function resolveTarget(to: string, opts?: SendIrcOptions): string {
const fromArg = normalizeIrcMessagingTarget(to);
if (fromArg) {
return fromArg;
}
const fromOpt = normalizeIrcMessagingTarget(opts?.target ?? "");
if (fromOpt) {
return fromOpt;
}
throw new Error(`Invalid IRC target: ${to}`);
}
export async function sendIrcMessages(
to: string,
messages: readonly SendIrcMessage[],
opts: SendIrcOptions,
onDeliveryResult?: (result: SendIrcResult) => Promise<void> | void,
): Promise<SendIrcResult[]> {
const cfg = requireRuntimeConfig(opts.cfg, "IRC send") as CoreConfig;
const account = resolveIrcAccount({
cfg,
accountId: opts.accountId,
});
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
);
}
const target = resolveTarget(to, opts);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "irc",
accountId: account.accountId,
});
const preparedMessages = messages.map((message) => {
const prepared = stripMarkdown(convertMarkdownTables(message.text.trim(), tableMode));
if (!prepared.trim()) {
throw new Error("Message must be non-empty for IRC sends");
}
return {
payload: message.replyTo ? `${prepared}\n\n[reply:${message.replyTo}]` : prepared,
replyTo: message.replyTo,
};
});
if (preparedMessages.length === 0) {
return [];
}
opts.abortSignal?.throwIfAborted();
let transient: IrcClient | undefined;
const client = opts.client?.isReady()
? opts.client
: (transient = await connectIrcClient(
buildIrcConnectOptions(account, {
connectTimeoutMs: 12000,
abortSignal: opts.abortSignal,
}),
));
const results: SendIrcResult[] = [];
try {
opts.abortSignal?.throwIfAborted();
if (transient && (target.startsWith("#") || target.startsWith("&"))) {
client.join(target);
}
for (const message of preparedMessages) {
opts.abortSignal?.throwIfAborted();
if (!client.isReady()) {
throw new Error("IRC connection closed before send");
}
await opts.onPlatformSendDispatch?.();
opts.abortSignal?.throwIfAborted();
if (!client.isReady()) {
throw new Error("IRC connection closed before send");
}
client.sendPrivmsg(target, message.payload);
recordIrcOutboundActivity(account.accountId);
const messageId = makeIrcMessageId();
const result = {
messageId,
target,
receipt: createMessageReceiptFromOutboundResults({
results: [
{
channel: "irc",
messageId,
conversationId: target,
},
],
kind: "text",
...(message.replyTo ? { replyToId: message.replyTo } : {}),
}),
};
results.push(result);
await onDeliveryResult?.(result);
}
return results;
} finally {
transient?.quit("sent");
}
}
export async function sendMessageIrc(
to: string,
text: string,
opts: SendIrcOptions,
): Promise<SendIrcResult> {
const result = (
await sendIrcMessages(to, [{ text, ...(opts.replyTo ? { replyTo: opts.replyTo } : {}) }], opts)
)[0];
if (!result) {
throw new Error("Message must be non-empty for IRC sends");
}
return result;
}