UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

335 lines (305 loc) 11.2 kB
import { createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; import type { ResolvedIrcAccount } from "./accounts.js"; import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js"; import { resolveIrcMentionGate, resolveIrcGroupAccessGate, resolveIrcGroupMatch, resolveIrcGroupSenderAllowed, resolveIrcRequireMention, } from "./policy.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; import type { CoreConfig, IrcInboundMessage } from "./types.js"; const CHANNEL_ID = "irc" as const; const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); async function deliverIrcReply(params: { payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; target: string; accountId: string; sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { const text = params.payload.text ?? ""; const mediaList = params.payload.mediaUrls?.length ? params.payload.mediaUrls : params.payload.mediaUrl ? [params.payload.mediaUrl] : []; if (!text.trim() && mediaList.length === 0) { return; } const mediaBlock = mediaList.length ? mediaList.map((url) => `Attachment: ${url}`).join("\n") : ""; const combined = text.trim() ? mediaBlock ? `${text.trim()}\n\n${mediaBlock}` : text.trim() : mediaBlock; if (params.sendReply) { await params.sendReply(params.target, combined, params.payload.replyToId); } else { await sendMessageIrc(params.target, combined, { accountId: params.accountId, replyTo: params.payload.replyToId, }); } params.statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleIrcInbound(params: { message: IrcInboundMessage; account: ResolvedIrcAccount; config: CoreConfig; runtime: RuntimeEnv; connectedNick?: string; sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; }): Promise<void> { const { message, account, config, runtime, connectedNick, statusSink } = params; const core = getIrcRuntime(); const rawBody = message.text?.trim() ?? ""; if (!rawBody) { return; } statusSink?.({ lastInboundAt: message.timestamp }); const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick; const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); const storeAllowFrom = await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []); const storeAllowList = normalizeIrcAllowlist(storeAllowFrom); const groupMatch = resolveIrcGroupMatch({ groups: account.config.groups, target: message.target, }); if (message.isGroup) { const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch }); if (!groupAccess.allowed) { runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`); return; } } const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom); const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom); const groupAllowFrom = directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom; const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, surface: CHANNEL_ID, }); const useAccessGroups = config.commands?.useAccessGroups !== false; const senderAllowedForCommands = resolveIrcAllowlistMatch({ allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, message, }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ { configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, allowed: senderAllowedForCommands, }, ], allowTextCommands, hasControlCommand, }); const commandAuthorized = commandGate.commandAuthorized; if (message.isGroup) { const senderAllowed = resolveIrcGroupSenderAllowed({ groupPolicy, message, outerAllowFrom: effectiveGroupAllowFrom, innerAllowFrom: groupAllowFrom, }); if (!senderAllowed) { runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); return; } } else { if (dmPolicy === "disabled") { runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`); return; } if (dmPolicy !== "open") { const dmAllowed = resolveIrcAllowlistMatch({ allowFrom: effectiveAllowFrom, message, }).allowed; if (!dmAllowed) { if (dmPolicy === "pairing") { const { code, created } = await core.channel.pairing.upsertPairingRequest({ channel: CHANNEL_ID, id: senderDisplay.toLowerCase(), meta: { name: message.senderNick || undefined }, }); if (created) { try { const reply = core.channel.pairing.buildPairingReply({ channel: CHANNEL_ID, idLine: `Your IRC id: ${senderDisplay}`, code, }); await deliverIrcReply({ payload: { text: reply }, target: message.senderNick, accountId: account.accountId, sendReply: params.sendReply, statusSink, }); } catch (err) { runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`); } } } runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`); return; } } } if (message.isGroup && commandGate.shouldBlock) { logInboundDrop({ log: (line) => runtime.log?.(line), channel: CHANNEL_ID, reason: "control command (unauthorized)", target: senderDisplay, }); return; } const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig); const mentionNick = connectedNick?.trim() || account.nick; const explicitMentionRegex = mentionNick ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i") : null; const wasMentioned = core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) || (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false); const requireMention = message.isGroup ? resolveIrcRequireMention({ groupConfig: groupMatch.groupConfig, wildcardConfig: groupMatch.wildcardConfig, }) : false; const mentionGate = resolveIrcMentionGate({ isGroup: message.isGroup, requireMention, wasMentioned, hasControlCommand, allowTextCommands, commandAuthorized, }); if (mentionGate.shouldSkip) { runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`); return; } const peerId = message.isGroup ? message.target : message.senderNick; const route = core.channel.routing.resolveAgentRoute({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, peer: { kind: message.isGroup ? "group" : "direct", id: peerId, }, }); const fromLabel = message.isGroup ? message.target : senderDisplay; const storePath = core.channel.session.resolveStorePath(config.session?.store, { agentId: route.agentId, }); const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig); const previousTimestamp = core.channel.session.readSessionUpdatedAt({ storePath, sessionKey: route.sessionKey, }); const body = core.channel.reply.formatAgentEnvelope({ channel: "IRC", from: fromLabel, timestamp: message.timestamp, previousTimestamp, envelope: envelopeOptions, body: rawBody, }); const groupSystemPrompt = groupMatch.groupConfig?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: rawBody, CommandBody: rawBody, From: message.isGroup ? `irc:channel:${message.target}` : `irc:${senderDisplay}`, To: `irc:${peerId}`, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: message.isGroup ? "group" : "direct", ConversationLabel: fromLabel, SenderName: message.senderNick || undefined, SenderId: senderDisplay, GroupSubject: message.isGroup ? message.target : undefined, GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined, Provider: CHANNEL_ID, Surface: CHANNEL_ID, WasMentioned: message.isGroup ? wasMentioned : undefined, MessageSid: message.messageId, Timestamp: message.timestamp, OriginatingChannel: CHANNEL_ID, OriginatingTo: `irc:${peerId}`, CommandAuthorized: commandAuthorized, }); await core.channel.session.recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, onRecordError: (err) => { runtime.error?.(`irc: failed updating session meta: ${String(err)}`); }, }); const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg: config as OpenClawConfig, agentId: route.agentId, channel: CHANNEL_ID, accountId: account.accountId, }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, deliver: async (payload) => { await deliverIrcReply({ payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string; }, target: peerId, accountId: account.accountId, sendReply: params.sendReply, statusSink, }); }, onError: (err, info) => { runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); }, }, replyOptions: { skillFilter: groupMatch.groupConfig?.skills, onModelSelected, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming : undefined, }, }); }