mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
Adds IRC as a first-class channel with core config surfaces (schema/hints/dock), plugin auto-enable detection, routing/policy alignment, and docs/tests. Co-authored-by: Vignesh <vigneshnatarajan92@gmail.com>
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import {
|
|
buildChannelConfigSchema,
|
|
DEFAULT_ACCOUNT_ID,
|
|
formatPairingApproveHint,
|
|
getChatChannelMeta,
|
|
PAIRING_APPROVED_MESSAGE,
|
|
setAccountEnabledInConfigSection,
|
|
deleteAccountFromConfigSection,
|
|
type ChannelPlugin,
|
|
} from "openclaw/plugin-sdk";
|
|
import type { CoreConfig, IrcProbe } from "./types.js";
|
|
import {
|
|
listIrcAccountIds,
|
|
resolveDefaultIrcAccountId,
|
|
resolveIrcAccount,
|
|
type ResolvedIrcAccount,
|
|
} from "./accounts.js";
|
|
import { IrcConfigSchema } from "./config-schema.js";
|
|
import { monitorIrcProvider } from "./monitor.js";
|
|
import {
|
|
normalizeIrcMessagingTarget,
|
|
looksLikeIrcTargetId,
|
|
isChannelTarget,
|
|
normalizeIrcAllowEntry,
|
|
} from "./normalize.js";
|
|
import { ircOnboardingAdapter } from "./onboarding.js";
|
|
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
|
import { probeIrc } from "./probe.js";
|
|
import { getIrcRuntime } from "./runtime.js";
|
|
import { sendMessageIrc } from "./send.js";
|
|
|
|
const meta = getChatChannelMeta("irc");
|
|
|
|
function normalizePairingTarget(raw: string): string {
|
|
const normalized = normalizeIrcAllowEntry(raw);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
|
|
}
|
|
|
|
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
|
id: "irc",
|
|
meta: {
|
|
...meta,
|
|
quickstartAllowFrom: true,
|
|
},
|
|
onboarding: ircOnboardingAdapter,
|
|
pairing: {
|
|
idLabel: "ircUser",
|
|
normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry),
|
|
notifyApproval: async ({ id }) => {
|
|
const target = normalizePairingTarget(id);
|
|
if (!target) {
|
|
throw new Error(`invalid IRC pairing id: ${id}`);
|
|
}
|
|
await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE);
|
|
},
|
|
},
|
|
capabilities: {
|
|
chatTypes: ["direct", "group"],
|
|
media: true,
|
|
blockStreaming: true,
|
|
},
|
|
reload: { configPrefixes: ["channels.irc"] },
|
|
configSchema: buildChannelConfigSchema(IrcConfigSchema),
|
|
config: {
|
|
listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig),
|
|
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig),
|
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
setAccountEnabledInConfigSection({
|
|
cfg: cfg as CoreConfig,
|
|
sectionKey: "irc",
|
|
accountId,
|
|
enabled,
|
|
allowTopLevel: true,
|
|
}),
|
|
deleteAccount: ({ cfg, accountId }) =>
|
|
deleteAccountFromConfigSection({
|
|
cfg: cfg as CoreConfig,
|
|
sectionKey: "irc",
|
|
accountId,
|
|
clearBaseFields: [
|
|
"name",
|
|
"host",
|
|
"port",
|
|
"tls",
|
|
"nick",
|
|
"username",
|
|
"realname",
|
|
"password",
|
|
"passwordFile",
|
|
"channels",
|
|
],
|
|
}),
|
|
isConfigured: (account) => account.configured,
|
|
describeAccount: (account) => ({
|
|
accountId: account.accountId,
|
|
name: account.name,
|
|
enabled: account.enabled,
|
|
configured: account.configured,
|
|
host: account.host,
|
|
port: account.port,
|
|
tls: account.tls,
|
|
nick: account.nick,
|
|
passwordSource: account.passwordSource,
|
|
}),
|
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
|
(entry) => String(entry),
|
|
),
|
|
formatAllowFrom: ({ allowFrom }) =>
|
|
allowFrom.map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean),
|
|
},
|
|
security: {
|
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
const useAccountPath = Boolean(cfg.channels?.irc?.accounts?.[resolvedAccountId]);
|
|
const basePath = useAccountPath
|
|
? `channels.irc.accounts.${resolvedAccountId}.`
|
|
: "channels.irc.";
|
|
return {
|
|
policy: account.config.dmPolicy ?? "pairing",
|
|
allowFrom: account.config.allowFrom ?? [],
|
|
policyPath: `${basePath}dmPolicy`,
|
|
allowFromPath: `${basePath}allowFrom`,
|
|
approveHint: formatPairingApproveHint("irc"),
|
|
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
|
|
};
|
|
},
|
|
collectWarnings: ({ account, cfg }) => {
|
|
const warnings: string[] = [];
|
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
if (groupPolicy === "open") {
|
|
warnings.push(
|
|
'- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.',
|
|
);
|
|
}
|
|
if (!account.config.tls) {
|
|
warnings.push(
|
|
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
|
|
);
|
|
}
|
|
if (account.config.nickserv?.register) {
|
|
warnings.push(
|
|
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
|
|
);
|
|
if (!account.config.nickserv.password?.trim()) {
|
|
warnings.push(
|
|
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
|
|
);
|
|
}
|
|
}
|
|
return warnings;
|
|
},
|
|
},
|
|
groups: {
|
|
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
|
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
|
if (!groupId) {
|
|
return true;
|
|
}
|
|
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
|
|
return resolveIrcRequireMention({
|
|
groupConfig: match.groupConfig,
|
|
wildcardConfig: match.wildcardConfig,
|
|
});
|
|
},
|
|
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
|
|
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
|
if (!groupId) {
|
|
return undefined;
|
|
}
|
|
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
|
|
return match.groupConfig?.tools ?? match.wildcardConfig?.tools;
|
|
},
|
|
},
|
|
messaging: {
|
|
normalizeTarget: normalizeIrcMessagingTarget,
|
|
targetResolver: {
|
|
looksLikeId: looksLikeIrcTargetId,
|
|
hint: "<#channel|nick>",
|
|
},
|
|
},
|
|
resolver: {
|
|
resolveTargets: async ({ inputs, kind }) => {
|
|
return inputs.map((input) => {
|
|
const normalized = normalizeIrcMessagingTarget(input);
|
|
if (!normalized) {
|
|
return {
|
|
input,
|
|
resolved: false,
|
|
note: "invalid IRC target",
|
|
};
|
|
}
|
|
if (kind === "group") {
|
|
const groupId = isChannelTarget(normalized) ? normalized : `#${normalized}`;
|
|
return {
|
|
input,
|
|
resolved: true,
|
|
id: groupId,
|
|
name: groupId,
|
|
};
|
|
}
|
|
if (isChannelTarget(normalized)) {
|
|
return {
|
|
input,
|
|
resolved: false,
|
|
note: "expected user target",
|
|
};
|
|
}
|
|
return {
|
|
input,
|
|
resolved: true,
|
|
id: normalized,
|
|
name: normalized,
|
|
};
|
|
});
|
|
},
|
|
},
|
|
directory: {
|
|
self: async () => null,
|
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
|
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
|
const q = query?.trim().toLowerCase() ?? "";
|
|
const ids = new Set<string>();
|
|
|
|
for (const entry of account.config.allowFrom ?? []) {
|
|
const normalized = normalizePairingTarget(String(entry));
|
|
if (normalized && normalized !== "*") {
|
|
ids.add(normalized);
|
|
}
|
|
}
|
|
for (const entry of account.config.groupAllowFrom ?? []) {
|
|
const normalized = normalizePairingTarget(String(entry));
|
|
if (normalized && normalized !== "*") {
|
|
ids.add(normalized);
|
|
}
|
|
}
|
|
for (const group of Object.values(account.config.groups ?? {})) {
|
|
for (const entry of group.allowFrom ?? []) {
|
|
const normalized = normalizePairingTarget(String(entry));
|
|
if (normalized && normalized !== "*") {
|
|
ids.add(normalized);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(ids)
|
|
.filter((id) => (q ? id.includes(q) : true))
|
|
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
.map((id) => ({ kind: "user", id }));
|
|
},
|
|
listGroups: async ({ cfg, accountId, query, limit }) => {
|
|
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
|
const q = query?.trim().toLowerCase() ?? "";
|
|
const groupIds = new Set<string>();
|
|
|
|
for (const channel of account.config.channels ?? []) {
|
|
const normalized = normalizeIrcMessagingTarget(channel);
|
|
if (normalized && isChannelTarget(normalized)) {
|
|
groupIds.add(normalized);
|
|
}
|
|
}
|
|
for (const group of Object.keys(account.config.groups ?? {})) {
|
|
if (group === "*") {
|
|
continue;
|
|
}
|
|
const normalized = normalizeIrcMessagingTarget(group);
|
|
if (normalized && isChannelTarget(normalized)) {
|
|
groupIds.add(normalized);
|
|
}
|
|
}
|
|
|
|
return Array.from(groupIds)
|
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
.map((id) => ({ kind: "group", id, name: id }));
|
|
},
|
|
},
|
|
outbound: {
|
|
deliveryMode: "direct",
|
|
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
chunkerMode: "markdown",
|
|
textChunkLimit: 350,
|
|
sendText: async ({ to, text, accountId, replyToId }) => {
|
|
const result = await sendMessageIrc(to, text, {
|
|
accountId: accountId ?? undefined,
|
|
replyTo: replyToId ?? undefined,
|
|
});
|
|
return { channel: "irc", ...result };
|
|
},
|
|
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
|
const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
|
const result = await sendMessageIrc(to, combined, {
|
|
accountId: accountId ?? undefined,
|
|
replyTo: replyToId ?? undefined,
|
|
});
|
|
return { channel: "irc", ...result };
|
|
},
|
|
},
|
|
status: {
|
|
defaultRuntime: {
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
running: false,
|
|
lastStartAt: null,
|
|
lastStopAt: null,
|
|
lastError: null,
|
|
},
|
|
buildChannelSummary: ({ account, snapshot }) => ({
|
|
configured: snapshot.configured ?? false,
|
|
host: account.host,
|
|
port: snapshot.port,
|
|
tls: account.tls,
|
|
nick: account.nick,
|
|
running: snapshot.running ?? false,
|
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
lastError: snapshot.lastError ?? null,
|
|
probe: snapshot.probe,
|
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
}),
|
|
probeAccount: async ({ cfg, account, timeoutMs }) =>
|
|
probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }),
|
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
accountId: account.accountId,
|
|
name: account.name,
|
|
enabled: account.enabled,
|
|
configured: account.configured,
|
|
host: account.host,
|
|
port: account.port,
|
|
tls: account.tls,
|
|
nick: account.nick,
|
|
passwordSource: account.passwordSource,
|
|
running: runtime?.running ?? false,
|
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
lastError: runtime?.lastError ?? null,
|
|
probe,
|
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
}),
|
|
},
|
|
gateway: {
|
|
startAccount: async (ctx) => {
|
|
const account = ctx.account;
|
|
if (!account.configured) {
|
|
throw new Error(
|
|
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
|
);
|
|
}
|
|
ctx.log?.info(
|
|
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
|
|
);
|
|
const { stop } = await monitorIrcProvider({
|
|
accountId: account.accountId,
|
|
config: ctx.cfg as CoreConfig,
|
|
runtime: ctx.runtime,
|
|
abortSignal: ctx.abortSignal,
|
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
});
|
|
return { stop };
|
|
},
|
|
},
|
|
};
|