UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

333 lines (332 loc) 10.7 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { i as formatErrorMessage } from "./errors-BXgSefBE.js"; import "./error-runtime-C8vbtAJt.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { n as createChannelIngressResolver, o as defineStableChannelIngressIdentity } from "./message-access-mwxdTUGC.js"; import "./channel-ingress-runtime-rGltVkKC.js"; import { i as getOrCreateClientManager, n as stripMarkdownForTwitch } from "./markdown-C7afQiMR.js"; import { t as getTwitchRuntime } from "./runtime-CNWwwLQZ.js"; //#region extensions/twitch/src/access-control.ts const twitchUserIdentity = defineStableChannelIngressIdentity({ key: "sender-id", entryIdPrefix: "twitch-user-entry" }); const twitchRoleIdentity = defineStableChannelIngressIdentity({ key: "role-moderator", kind: "role", normalizeEntry: normalizeTwitchRole, normalizeSubject: normalizeTwitchRole, aliases: [ "owner", "vip", "subscriber" ].map((role) => ({ key: `role-${role}`, kind: "role", normalizeEntry: () => null, normalizeSubject: normalizeTwitchRole })), isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all", resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}` }); async function checkTwitchAccessControl(params) { const { message, account, botUsername } = params; const policyKind = resolveTwitchPolicyKind(account); const decision = (await createChannelIngressResolver({ channelId: "twitch", accountId: "default", identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity }).message({ subject: policyKind === "role" ? twitchRoleSubject(message) : { stableId: message.userId }, conversation: { kind: "group", id: message.channel }, event: { mayPair: false }, mentionFacts: { canDetectMention: true, wasMentioned: mentionsBot(message.message, botUsername) }, dmPolicy: "open", groupPolicy: policyKind === "open" ? "open" : "allowlist", policy: { activation: { requireMention: account.requireMention ?? true, allowTextCommands: false, order: "before-sender" } }, groupAllowFrom: policyKind === "allowFrom" ? account.allowFrom : policyKind === "role" ? account.allowedRoles : void 0 })).ingress; if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") return { allowed: false, reason: "message does not mention the bot (requireMention is enabled)" }; if (decision.admission === "dispatch") { if (policyKind === "allowFrom") return { allowed: true, matchKey: params.message.userId, matchSource: "allowlist" }; if (policyKind === "role") return { allowed: true, matchKey: params.account.allowedRoles?.join(","), matchSource: "role" }; return { allowed: true }; } if (policyKind === "allowFrom") { if (!params.message.userId) return { allowed: false, reason: "sender user ID not available for allowlist check" }; return { allowed: false, reason: "sender is not in allowFrom allowlist" }; } if (policyKind === "role") return { allowed: false, reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}` }; return { allowed: false, reason: reasonForTwitchIngressDecision(decision) }; } function resolveTwitchPolicyKind(account) { if (account.allowFrom !== void 0) return "allowFrom"; if (account.allowedRoles && account.allowedRoles.length > 0) return "role"; return "open"; } function twitchRoleSubject(message) { return { stableId: message.isMod ? "moderator" : void 0, aliases: { "role-owner": message.isOwner ? "owner" : void 0, "role-vip": message.isVip ? "vip" : void 0, "role-subscriber": message.isSub ? "subscriber" : void 0 } }; } function normalizeTwitchRole(value) { const role = normalizeLowercaseStringOrEmpty(value); if (role === "*") return "all"; return role === "moderator" || role === "owner" || role === "vip" || role === "subscriber" || role === "all" ? role : null; } function reasonForTwitchIngressDecision(decision) { switch (decision.reasonCode) { case "activation_skipped": return "message does not mention the bot (requireMention is enabled)"; case "group_policy_empty_allowlist": case "group_policy_not_allowlisted": return "sender is not in allowFrom allowlist"; default: return decision.reasonCode; } } function mentionsBot(message, botUsername) { const expected = normalizeLowercaseStringOrEmpty(botUsername); const mentionRegex = /@(\w+)/g; let match; while ((match = mentionRegex.exec(message)) !== null) if ((match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "") === expected) return true; return false; } //#endregion //#region extensions/twitch/src/monitor.ts /** * Process an incoming Twitch message and dispatch to agent. */ async function processTwitchMessage(params) { const { message, account, accountId, config, runtime, core, statusSink } = params; const cfg = config; await core.channel.inbound.run({ channel: "twitch", accountId, raw: message, adapter: { ingest: (incoming) => ({ id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`, timestamp: incoming.timestamp?.getTime(), rawText: incoming.message, textForAgent: incoming.message, textForCommands: incoming.message, raw: incoming }), resolveTurn: async (input) => { const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "twitch", accountId, peer: { kind: "group", id: message.channel } }); const senderId = message.userId ?? message.username; const fromLabel = message.displayName ?? message.username; const body = core.channel.reply.formatAgentEnvelope({ channel: "Twitch", from: fromLabel, timestamp: input.timestamp, envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg), body: input.rawText }); const ctxPayload = core.channel.inbound.buildContext({ channel: "twitch", accountId, messageId: input.id, timestamp: input.timestamp, from: `twitch:user:${senderId}`, sender: { id: senderId, name: fromLabel, username: message.username }, conversation: { kind: "group", id: message.channel, label: message.channel }, route: { agentId: route.agentId, accountId: route.accountId, routeSessionKey: route.sessionKey }, reply: { to: `twitch:channel:${message.channel}` }, message: { body, rawBody: input.rawText, bodyForAgent: input.textForAgent, commandBody: input.textForCommands } }); const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId }); const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "twitch", accountId }); return { cfg, channel: "twitch", accountId, agentId: route.agentId, routeSessionKey: route.sessionKey, storePath, ctxPayload, recordInboundSession: core.channel.session.recordInboundSession, dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { durable: () => ({ to: `twitch:channel:${message.channel}` }), deliver: async (payload) => { return await deliverTwitchReply({ payload, channel: message.channel, account, accountId, config, tableMode, runtime }); }, onDelivered: (_payload, _info, result) => { if (result?.visibleReplySent !== false) statusSink?.({ lastOutboundAt: Date.now() }); }, onError: (err, info) => { runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`); } }, replyPipeline: {}, record: { onRecordError: (err) => { runtime.error?.(`Failed updating session meta: ${String(err)}`); } } }; } } }); } /** * Deliver a reply to Twitch chat. */ async function deliverTwitchReply(params) { const { payload, channel, account, accountId, config, runtime } = params; try { const client = await getOrCreateClientManager(accountId, { info: (msg) => runtime.log?.(msg), warn: (msg) => runtime.log?.(msg), error: (msg) => runtime.error?.(msg), debug: (msg) => runtime.log?.(msg) }).getClient(account, config, accountId); if (!client) { runtime.error?.(`No client available for sending reply`); return { visibleReplySent: false }; } if (!payload.text) { runtime.error?.(`No text to send in reply payload`); return { visibleReplySent: false }; } const textToSend = stripMarkdownForTwitch(payload.text); await client.say(channel, textToSend); return { visibleReplySent: true }; } catch (err) { runtime.error?.(`Failed to send reply: ${String(err)}`); return { visibleReplySent: false }; } } /** * Main monitor provider for Twitch. * * Sets up message handlers and processes incoming messages. */ async function monitorTwitchProvider(options) { const { account, accountId, config, runtime, abortSignal, statusSink } = options; const core = getTwitchRuntime(); let stopped = false; const coreLogger = core.logging.getChildLogger({ module: "twitch" }); const logVerboseMessage = (message) => { if (!core.logging.shouldLogVerbose()) return; coreLogger.debug?.(message); }; const clientManager = getOrCreateClientManager(accountId, { info: (msg) => coreLogger.info(msg), warn: (msg) => coreLogger.warn(msg), error: (msg) => coreLogger.error(msg), debug: logVerboseMessage }); try { await clientManager.getClient(account, config, accountId); } catch (error) { const errorMsg = formatErrorMessage(error); runtime.error?.(`Failed to connect: ${errorMsg}`); throw error; } const unregisterHandler = clientManager.onMessage(account, (message) => { if (stopped) return; (async () => { const botUsername = normalizeLowercaseStringOrEmpty(account.username); if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) return; const access = await checkTwitchAccessControl({ message, account, botUsername }); if (stopped || !access.allowed) return; statusSink?.({ lastInboundAt: Date.now() }); await processTwitchMessage({ message, account, accountId, config, runtime, core, statusSink }); })().catch((err) => { runtime.error?.(`Message processing failed: ${String(err)}`); }); }); const stop = () => { stopped = true; unregisterHandler(); }; abortSignal.addEventListener("abort", stop, { once: true }); return { stop }; } //#endregion export { monitorTwitchProvider };