diff --git a/extensions/discord/runtime-setter-api.ts b/extensions/discord/runtime-setter-api.ts index e1c078ae5c1..6fc6718ce64 100644 --- a/extensions/discord/runtime-setter-api.ts +++ b/extensions/discord/runtime-setter-api.ts @@ -1,3 +1,3 @@ // Keep bundled registration fast: runtime wiring only needs the store setter, -// while runtime-api.js remains the broad compatibility barrel. +// while runtime-api.js remains the broad runtime surface. export { setDiscordRuntime } from "./src/runtime.js"; diff --git a/extensions/discord/src/actions/runtime.guild.ts b/extensions/discord/src/actions/runtime.guild.ts index bc91f9b76e4..5f33e6d1765 100644 --- a/extensions/discord/src/actions/runtime.guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -32,6 +32,7 @@ import { resolveEventCoverImage, } from "../send.js"; import { + createDiscordActionOptions, readDiscordChannelCreateParams, readDiscordChannelEditParams, readDiscordChannelMoveParams, @@ -70,7 +71,7 @@ type DiscordRoleMutation = ( ) => Promise; async function runRoleMutation(params: { - cfgOptions: { cfg: OpenClawConfig }; + cfg: OpenClawConfig; accountId?: string; values: Record; mutate: DiscordRoleMutation; @@ -80,10 +81,7 @@ async function runRoleMutation(params: { const roleId = readStringParam(params.values, "roleId", { required: true }); await params.mutate( { guildId, userId, roleId }, - { - ...params.cfgOptions, - ...(params.accountId ? { accountId: params.accountId } : {}), - }, + createDiscordActionOptions({ cfg: params.cfg, accountId: params.accountId }), ); } @@ -105,12 +103,8 @@ export async function handleDiscordGuildAction( if (!cfg) { throw new Error("Discord guild actions require a resolved runtime config."); } - const cfgOptions = { cfg }; - const withOpts = (extra?: Record) => ({ - ...cfgOptions, - ...(accountId ? { accountId } : {}), - ...extra, - }); + const withOpts = (extra?: Record) => + createDiscordActionOptions({ cfg, accountId, extra }); switch (action) { case "memberInfo": { if (!isActionEnabled("memberInfo")) { @@ -123,12 +117,11 @@ export async function handleDiscordGuildAction( required: true, }); const effectiveAccountId = accountId ?? resolveDefaultDiscordAccountId(cfg); - const member = effectiveAccountId - ? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { - ...cfgOptions, - accountId: effectiveAccountId, - }) - : await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, cfgOptions); + const member = await discordGuildActionRuntime.fetchMemberInfoDiscord( + guildId, + userId, + createDiscordActionOptions({ cfg, accountId: effectiveAccountId }), + ); const presence = getPresence(effectiveAccountId, userId); const activities = presence?.activities ?? undefined; const status = presence?.status ?? undefined; @@ -209,7 +202,7 @@ export async function handleDiscordGuildAction( throw new Error("Discord role changes are disabled."); } await runRoleMutation({ - cfgOptions, + cfg, accountId, values: params, mutate: discordGuildActionRuntime.addRoleDiscord, @@ -221,7 +214,7 @@ export async function handleDiscordGuildAction( throw new Error("Discord role changes are disabled."); } await runRoleMutation({ - cfgOptions, + cfg, accountId, values: params, mutate: discordGuildActionRuntime.removeRoleDiscord, diff --git a/extensions/discord/src/actions/runtime.messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts index fd464c0112b..2bcf9f0cefd 100644 --- a/extensions/discord/src/actions/runtime.messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -44,6 +44,7 @@ import { type DiscordSendEmbeds, } from "../send.shared.js"; import { resolveDiscordChannelId } from "../targets.js"; +import { createDiscordActionOptions } from "./runtime.shared.js"; export const discordMessagingActionRuntime = { createThreadDiscord, @@ -135,6 +136,8 @@ export async function handleDiscordMessagingAction( throw new Error("Discord messaging actions require a resolved runtime config."); } const cfgOptions = { cfg }; + const withOpts = (extra?: Record) => + createDiscordActionOptions({ cfg, accountId, extra }); const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(cfg); const resolveReactionChannelId = async () => { const target = @@ -227,11 +230,7 @@ export async function handleDiscordMessagingAction( required: true, label: "stickerIds", }); - await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), - content, - }); + await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, withOpts({ content })); return jsonResult({ ok: true }); } case "poll": { @@ -253,7 +252,7 @@ export async function handleDiscordMessagingAction( await discordMessagingActionRuntime.sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, - { ...cfgOptions, ...(accountId ? { accountId } : {}), content }, + withOpts({ content }), ); return jsonResult({ ok: true }); } @@ -262,12 +261,10 @@ export async function handleDiscordMessagingAction( throw new Error("Discord permissions are disabled."); } const channelId = resolveChannelId(); - const permissions = accountId - ? await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, { - ...cfgOptions, - accountId, - }) - : await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, cfgOptions); + const permissions = await discordMessagingActionRuntime.fetchChannelPermissionsDiscord( + channelId, + withOpts(), + ); return jsonResult({ ok: true, permissions }); } case "fetchMessage": { @@ -289,12 +286,11 @@ export async function handleDiscordMessagingAction( "Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).", ); } - const message = accountId - ? await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, { - ...cfgOptions, - accountId, - }) - : await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, cfgOptions); + const message = await discordMessagingActionRuntime.fetchMessageDiscord( + channelId, + messageId, + withOpts(), + ); return jsonResult({ ok: true, message: normalizeMessage(message), @@ -314,12 +310,11 @@ export async function handleDiscordMessagingAction( after: readStringParam(params, "after"), around: readStringParam(params, "around"), }; - const messages = accountId - ? await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, { - ...cfgOptions, - accountId, - }) - : await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, cfgOptions); + const messages = await discordMessagingActionRuntime.readMessagesDiscord( + channelId, + query, + withOpts(), + ); return jsonResult({ ok: true, messages: messages.map((message) => normalizeMessage(message)), @@ -372,8 +367,7 @@ export async function handleDiscordMessagingAction( to, payload, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), + ...withOpts(), silent, replyTo: replyTo ?? undefined, sessionKey: sessionKey ?? undefined, @@ -399,8 +393,7 @@ export async function handleDiscordMessagingAction( } assertMediaNotDataUrl(mediaUrl); const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), + ...withOpts(), replyTo, silent, }); @@ -408,8 +401,7 @@ export async function handleDiscordMessagingAction( } const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", { - ...cfgOptions, - ...(accountId ? { accountId } : {}), + ...withOpts(), mediaUrl, filename: filename ?? undefined, mediaLocalRoots: options?.mediaLocalRoots, @@ -432,19 +424,12 @@ export async function handleDiscordMessagingAction( const content = readStringParam(params, "content", { required: true, }); - const message = accountId - ? await discordMessagingActionRuntime.editMessageDiscord( - channelId, - messageId, - { content }, - { ...cfgOptions, accountId }, - ) - : await discordMessagingActionRuntime.editMessageDiscord( - channelId, - messageId, - { content }, - cfgOptions, - ); + const message = await discordMessagingActionRuntime.editMessageDiscord( + channelId, + messageId, + { content }, + withOpts(), + ); return jsonResult({ ok: true, message }); } case "deleteMessage": { @@ -455,14 +440,7 @@ export async function handleDiscordMessagingAction( const messageId = readStringParam(params, "messageId", { required: true, }); - if (accountId) { - await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, { - ...cfgOptions, - accountId, - }); - } else { - await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, cfgOptions); - } + await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, withOpts()); return jsonResult({ ok: true }); } case "threadCreate": { @@ -482,12 +460,11 @@ export async function handleDiscordMessagingAction( content, appliedTags: appliedTags ?? undefined, }; - const thread = accountId - ? await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, { - ...cfgOptions, - accountId, - }) - : await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, cfgOptions); + const thread = await discordMessagingActionRuntime.createThreadDiscord( + channelId, + payload, + withOpts(), + ); return jsonResult({ ok: true, thread }); } case "threadList": { @@ -501,27 +478,16 @@ export async function handleDiscordMessagingAction( const includeArchived = readBooleanParam(params, "includeArchived"); const before = readStringParam(params, "before"); const limit = readNumberParam(params, "limit"); - const threads = accountId - ? await discordMessagingActionRuntime.listThreadsDiscord( - { - guildId, - channelId, - includeArchived, - before, - limit, - }, - { ...cfgOptions, accountId }, - ) - : await discordMessagingActionRuntime.listThreadsDiscord( - { - guildId, - channelId, - includeArchived, - before, - limit, - }, - cfgOptions, - ); + const threads = await discordMessagingActionRuntime.listThreadsDiscord( + { + guildId, + channelId, + includeArchived, + before, + limit, + }, + withOpts(), + ); return jsonResult({ ok: true, threads }); } case "threadReply": { @@ -538,8 +504,7 @@ export async function handleDiscordMessagingAction( `channel:${channelId}`, content, { - ...cfgOptions, - ...(accountId ? { accountId } : {}), + ...withOpts(), mediaUrl, mediaLocalRoots: options?.mediaLocalRoots, mediaReadFile: options?.mediaReadFile, @@ -556,14 +521,7 @@ export async function handleDiscordMessagingAction( const messageId = readStringParam(params, "messageId", { required: true, }); - if (accountId) { - await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, { - ...cfgOptions, - accountId, - }); - } else { - await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, cfgOptions); - } + await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, withOpts()); return jsonResult({ ok: true }); } case "unpinMessage": { @@ -574,14 +532,7 @@ export async function handleDiscordMessagingAction( const messageId = readStringParam(params, "messageId", { required: true, }); - if (accountId) { - await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, { - ...cfgOptions, - accountId, - }); - } else { - await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, cfgOptions); - } + await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, withOpts()); return jsonResult({ ok: true }); } case "listPins": { @@ -589,12 +540,7 @@ export async function handleDiscordMessagingAction( throw new Error("Discord pins are disabled."); } const channelId = resolveChannelId(); - const pins = accountId - ? await discordMessagingActionRuntime.listPinsDiscord(channelId, { - ...cfgOptions, - accountId, - }) - : await discordMessagingActionRuntime.listPinsDiscord(channelId, cfgOptions); + const pins = await discordMessagingActionRuntime.listPinsDiscord(channelId, withOpts()); return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) }); } case "searchMessages": { @@ -614,27 +560,16 @@ export async function handleDiscordMessagingAction( const limit = readNumberParam(params, "limit"); const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])]; const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])]; - const results = accountId - ? await discordMessagingActionRuntime.searchMessagesDiscord( - { - guildId, - content, - channelIds: channelIdList.length ? channelIdList : undefined, - authorIds: authorIdList.length ? authorIdList : undefined, - limit, - }, - { ...cfgOptions, accountId }, - ) - : await discordMessagingActionRuntime.searchMessagesDiscord( - { - guildId, - content, - channelIds: channelIdList.length ? channelIdList : undefined, - authorIds: authorIdList.length ? authorIdList : undefined, - limit, - }, - cfgOptions, - ); + const results = await discordMessagingActionRuntime.searchMessagesDiscord( + { + guildId, + content, + channelIds: channelIdList.length ? channelIdList : undefined, + authorIds: authorIdList.length ? authorIdList : undefined, + limit, + }, + withOpts(), + ); if (!results || typeof results !== "object") { return jsonResult({ ok: true, results }); } diff --git a/extensions/discord/src/actions/runtime.moderation.ts b/extensions/discord/src/actions/runtime.moderation.ts index 722ec93484e..81caf7f915c 100644 --- a/extensions/discord/src/actions/runtime.moderation.ts +++ b/extensions/discord/src/actions/runtime.moderation.ts @@ -17,6 +17,7 @@ import { readDiscordModerationCommand, requiredGuildPermissionForModerationAction, } from "./runtime.moderation-shared.js"; +import { createDiscordActionOptions } from "./runtime.shared.js"; export const discordModerationActionRuntime = { banMemberDiscord, @@ -30,7 +31,7 @@ async function verifySenderModerationPermission(params: { senderUserId?: string; requiredPermission: bigint; accountId?: string; - cfgOptions: { cfg: OpenClawConfig }; + cfg: OpenClawConfig; }) { // CLI/manual flows may not have sender context; enforce only when present. if (!params.senderUserId) { @@ -40,10 +41,7 @@ async function verifySenderModerationPermission(params: { params.guildId, params.senderUserId, [params.requiredPermission], - { - ...params.cfgOptions, - ...(params.accountId ? { accountId: params.accountId } : {}), - }, + createDiscordActionOptions({ cfg: params.cfg, accountId: params.accountId }), ); if (!hasPermission) { throw new Error("Sender does not have required permissions for this moderation action."); @@ -65,20 +63,16 @@ export async function handleDiscordModerationAction( if (!cfg) { throw new Error("Discord moderation actions require a resolved runtime config."); } - const cfgOptions = { cfg }; - const command = readDiscordModerationCommand(action, params); const accountId = readStringParam(params, "accountId"); + const command = readDiscordModerationCommand(action, params); const senderUserId = readStringParam(params, "senderUserId"); - const withOpts = () => ({ - ...cfgOptions, - ...(accountId ? { accountId } : {}), - }); + const withOpts = () => createDiscordActionOptions({ cfg, accountId }); await verifySenderModerationPermission({ guildId: command.guildId, senderUserId, requiredPermission: requiredGuildPermissionForModerationAction(command.action), accountId, - cfgOptions, + cfg, }); switch (command.action) { case "timeout": { diff --git a/extensions/discord/src/actions/runtime.shared.ts b/extensions/discord/src/actions/runtime.shared.ts index 12e195d6001..04d70069972 100644 --- a/extensions/discord/src/actions/runtime.shared.ts +++ b/extensions/discord/src/actions/runtime.shared.ts @@ -1,4 +1,5 @@ import { parseAvailableTags, readNumberParam, readStringParam } from "../runtime-api.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import type { DiscordChannelCreate, DiscordChannelEdit, @@ -24,6 +25,20 @@ function readDiscordBooleanParam( return typeof params[key] === "boolean" ? params[key] : undefined; } +export function createDiscordActionOptions< + T extends Record = Record, +>(params: { + cfg: OpenClawConfig; + accountId?: string; + extra?: T; +}): { cfg: OpenClawConfig; accountId?: string } & T { + return { + cfg: params.cfg, + ...(params.accountId ? { accountId: params.accountId } : {}), + ...(params.extra ?? ({} as T)), + }; +} + export function readDiscordChannelCreateParams( params: Record, ): DiscordChannelCreate { diff --git a/extensions/discord/src/monitor/agent-components-auth.ts b/extensions/discord/src/monitor/agent-components-auth.ts index f0c178f9ee5..d0ae162f834 100644 --- a/extensions/discord/src/monitor/agent-components-auth.ts +++ b/extensions/discord/src/monitor/agent-components-auth.ts @@ -1,514 +1,8 @@ -import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native"; -import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; -import type { DiscordComponentEntry } from "../components.js"; -import { - resolveComponentInteractionContext, - resolveDiscordChannelContext, -} from "./agent-components-context.js"; -import { - readStoreAllowFromForDmPolicy, - upsertChannelPairingRequest, -} from "./agent-components-helpers.runtime.js"; -import { - type AgentComponentContext, - type AgentComponentInteraction, - type ComponentInteractionContext, - type DiscordChannelContext, - type DiscordUser, -} from "./agent-components.types.js"; -import { - isDiscordGroupAllowedByPolicy, - normalizeDiscordAllowList, - resolveDiscordAllowListMatch, - resolveDiscordChannelConfigWithFallback, - resolveDiscordGuildEntry, - resolveDiscordMemberAccessState, - resolveDiscordOwnerAccess, - resolveGroupDmAllow, -} from "./allow-list.js"; -import { formatDiscordUserTag } from "./format.js"; - -async function replySilently( - interaction: AgentComponentInteraction, - params: { content: string; ephemeral?: boolean }, -) { - try { - await interaction.reply(params); - } catch {} -} - -export async function ensureGuildComponentMemberAllowed(params: { - interaction: AgentComponentInteraction; - guildInfo: ReturnType; - channelId: string; - rawGuildId: string | undefined; - channelCtx: DiscordChannelContext; - memberRoleIds: string[]; - user: DiscordUser; - replyOpts: { ephemeral?: boolean }; - componentLabel: string; - unauthorizedReply: string; - allowNameMatching: boolean; - groupPolicy: "open" | "disabled" | "allowlist"; -}) { - const { - interaction, - guildInfo, - channelId, - rawGuildId, - channelCtx, - memberRoleIds, - user, - replyOpts, - componentLabel, - unauthorizedReply, - } = params; - - if (!rawGuildId) { - return true; - } - - const replyUnauthorized = async () => { - await replySilently(interaction, { content: unauthorizedReply, ...replyOpts }); - }; - - const channelConfig = resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId, - channelName: channelCtx.channelName, - channelSlug: channelCtx.channelSlug, - parentId: channelCtx.parentId, - parentName: channelCtx.parentName, - parentSlug: channelCtx.parentSlug, - scope: channelCtx.isThread ? "thread" : "channel", - }); - - if (channelConfig?.enabled === false) { - await replyUnauthorized(); - return false; - } - const channelAllowlistConfigured = - Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; - const channelAllowed = channelConfig?.allowed !== false; - if ( - !isDiscordGroupAllowedByPolicy({ - groupPolicy: params.groupPolicy, - guildAllowlisted: Boolean(guildInfo), - channelAllowlistConfigured, - channelAllowed, - }) - ) { - await replyUnauthorized(); - return false; - } - if (channelConfig?.allowed === false) { - await replyUnauthorized(); - return false; - } - - const { memberAllowed } = resolveDiscordMemberAccessState({ - channelConfig, - guildInfo, - memberRoleIds, - sender: { - id: user.id, - name: user.username, - tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined, - }, - allowNameMatching: params.allowNameMatching, - }); - if (memberAllowed) { - return true; - } - - logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`); - await replyUnauthorized(); - return false; -} - -export async function ensureComponentUserAllowed(params: { - entry: DiscordComponentEntry; - interaction: AgentComponentInteraction; - user: DiscordUser; - replyOpts: { ephemeral?: boolean }; - componentLabel: string; - unauthorizedReply: string; - allowNameMatching: boolean; -}) { - const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [ - "discord:", - "user:", - "pk:", - ]); - if (!allowList) { - return true; - } - const match = resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: params.user.id, - name: params.user.username, - tag: formatDiscordUserTag(params.user), - }, - allowNameMatching: params.allowNameMatching, - }); - if (match.allowed) { - return true; - } - - logVerbose( - `discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`, - ); - await replySilently(params.interaction, { - content: params.unauthorizedReply, - ...params.replyOpts, - }); - return false; -} - -export async function ensureAgentComponentInteractionAllowed(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - channelId: string; - rawGuildId: string | undefined; - memberRoleIds: string[]; - user: DiscordUser; - replyOpts: { ephemeral?: boolean }; - componentLabel: string; - unauthorizedReply: string; -}) { - const guildInfo = resolveDiscordGuildEntry({ - guild: params.interaction.guild ?? undefined, - guildId: params.rawGuildId, - guildEntries: params.ctx.guildEntries, - }); - const channelCtx = resolveDiscordChannelContext(params.interaction); - const memberAllowed = await ensureGuildComponentMemberAllowed({ - interaction: params.interaction, - guildInfo, - channelId: params.channelId, - rawGuildId: params.rawGuildId, - channelCtx, - memberRoleIds: params.memberRoleIds, - user: params.user, - replyOpts: params.replyOpts, - componentLabel: params.componentLabel, - unauthorizedReply: params.unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), - groupPolicy: resolveOpenProviderRuntimeGroupPolicy({ - providerConfigPresent: params.ctx.cfg.channels?.discord !== undefined, - groupPolicy: params.ctx.discordConfig?.groupPolicy, - defaultGroupPolicy: params.ctx.cfg.channels?.defaults?.groupPolicy, - }).groupPolicy, - }); - if (!memberAllowed) { - return null; - } - return { parentId: channelCtx.parentId }; -} - -async function ensureDmComponentAuthorized(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - user: DiscordUser; - componentLabel: string; - replyOpts: { ephemeral?: boolean }; -}) { - const { ctx, interaction, user, componentLabel, replyOpts } = params; - const allowFromPrefixes = ["discord:", "user:", "pk:"]; - const resolveAllowMatch = (entries: string[]) => { - const allowList = normalizeDiscordAllowList(entries, allowFromPrefixes); - return allowList - ? resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: user.id, - name: user.username, - tag: formatDiscordUserTag(user), - }, - allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), - }) - : { allowed: false }; - }; - const dmPolicy = ctx.dmPolicy ?? "pairing"; - if (dmPolicy === "disabled") { - logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`); - await replySilently(interaction, { content: "DM interactions are disabled.", ...replyOpts }); - return false; - } - if (dmPolicy === "allowlist") { - const allowMatch = resolveAllowMatch(ctx.allowFrom ?? []); - if (allowMatch.allowed) { - return true; - } - logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); - await replySilently(interaction, { - content: `You are not authorized to use this ${componentLabel}.`, - ...replyOpts, - }); - return false; - } - - const storeAllowFrom = - dmPolicy === "open" - ? [] - : await readStoreAllowFromForDmPolicy({ - provider: "discord", - accountId: ctx.accountId, - dmPolicy, - }); - const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]); - if (allowMatch.allowed) { - return true; - } - - if (dmPolicy === "pairing") { - const pairingResult = await createChannelPairingChallengeIssuer({ - channel: "discord", - upsertPairingRequest: async ({ id, meta }) => { - return await upsertChannelPairingRequest({ - channel: "discord", - id, - accountId: ctx.accountId, - meta, - }); - }, - })({ - senderId: user.id, - senderIdLine: `Your Discord user id: ${user.id}`, - meta: { - tag: formatDiscordUserTag(user), - name: user.username, - }, - sendPairingReply: async (text) => { - await interaction.reply({ - content: text, - ...replyOpts, - }); - }, - }); - if (!pairingResult.created) { - await replySilently(interaction, { - content: "Pairing already requested. Ask the bot owner to approve your code.", - ...replyOpts, - }); - } - return false; - } - - logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); - await replySilently(interaction, { - content: `You are not authorized to use this ${componentLabel}.`, - ...replyOpts, - }); - return false; -} - -async function ensureGroupDmComponentAuthorized(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - channelId: string; - componentLabel: string; - replyOpts: { ephemeral?: boolean }; -}) { - const { ctx, interaction, channelId, componentLabel, replyOpts } = params; - const groupDmEnabled = ctx.discordConfig?.dm?.groupEnabled ?? false; - if (!groupDmEnabled) { - logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (group DMs disabled)`); - await replySilently(interaction, { - content: "Group DM interactions are disabled.", - ...replyOpts, - }); - return false; - } - - const channelCtx = resolveDiscordChannelContext(interaction); - const allowed = resolveGroupDmAllow({ - channels: ctx.discordConfig?.dm?.groupChannels, - channelId, - channelName: channelCtx.channelName, - channelSlug: channelCtx.channelSlug, - }); - if (allowed) { - return true; - } - - logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (not allowlisted)`); - await replySilently(interaction, { - content: `You are not authorized to use this ${componentLabel}.`, - ...replyOpts, - }); - return false; -} - -export async function resolveInteractionContextWithDmAuth(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - label: string; - componentLabel: string; - defer?: boolean; -}) { - const interactionCtx = await resolveComponentInteractionContext({ - interaction: params.interaction, - label: params.label, - defer: params.defer, - }); - if (!interactionCtx) { - return null; - } - if (interactionCtx.isDirectMessage) { - const authorized = await ensureDmComponentAuthorized({ - ctx: params.ctx, - interaction: params.interaction, - user: interactionCtx.user, - componentLabel: params.componentLabel, - replyOpts: interactionCtx.replyOpts, - }); - if (!authorized) { - return null; - } - } - if (interactionCtx.isGroupDm) { - const authorized = await ensureGroupDmComponentAuthorized({ - ctx: params.ctx, - interaction: params.interaction, - channelId: interactionCtx.channelId, - componentLabel: params.componentLabel, - replyOpts: interactionCtx.replyOpts, - }); - if (!authorized) { - return null; - } - } - return interactionCtx; -} - -export async function resolveAuthorizedComponentInteraction(params: { - ctx: AgentComponentContext; - interaction: AgentComponentInteraction; - label: string; - componentLabel: string; - unauthorizedReply: string; - defer?: boolean; -}) { - const interactionCtx = await resolveInteractionContextWithDmAuth({ - ctx: params.ctx, - interaction: params.interaction, - label: params.label, - componentLabel: params.componentLabel, - defer: params.defer, - }); - if (!interactionCtx) { - return null; - } - - const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; - const guildInfo = resolveDiscordGuildEntry({ - guild: params.interaction.guild ?? undefined, - guildId: rawGuildId, - guildEntries: params.ctx.guildEntries, - }); - const channelCtx = resolveDiscordChannelContext(params.interaction); - const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig); - const channelConfig = resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId, - channelName: channelCtx.channelName, - channelSlug: channelCtx.channelSlug, - parentId: channelCtx.parentId, - parentName: channelCtx.parentName, - parentSlug: channelCtx.parentSlug, - scope: channelCtx.isThread ? "thread" : "channel", - }); - const memberAllowed = await ensureGuildComponentMemberAllowed({ - interaction: params.interaction, - guildInfo, - channelId, - rawGuildId, - channelCtx, - memberRoleIds, - user, - replyOpts, - componentLabel: params.componentLabel, - unauthorizedReply: params.unauthorizedReply, - allowNameMatching, - groupPolicy: resolveOpenProviderRuntimeGroupPolicy({ - providerConfigPresent: params.ctx.cfg.channels?.discord !== undefined, - groupPolicy: params.ctx.discordConfig?.groupPolicy, - defaultGroupPolicy: params.ctx.cfg.channels?.defaults?.groupPolicy, - }).groupPolicy, - }); - if (!memberAllowed) { - return null; - } - - const commandAuthorized = resolveComponentCommandAuthorized({ - ctx: params.ctx, - interactionCtx, - channelConfig, - guildInfo, - allowNameMatching, - }); - - return { - interactionCtx, - channelCtx, - guildInfo, - channelConfig, - allowNameMatching, - commandAuthorized, - user, - replyOpts, - }; -} - -export function resolveComponentCommandAuthorized(params: { - ctx: AgentComponentContext; - interactionCtx: ComponentInteractionContext; - channelConfig: ReturnType; - guildInfo: ReturnType; - allowNameMatching: boolean; -}) { - const { ctx, interactionCtx, channelConfig, guildInfo } = params; - if (interactionCtx.isDirectMessage) { - return true; - } - - const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ - allowFrom: ctx.allowFrom, - sender: { - id: interactionCtx.user.id, - name: interactionCtx.user.username, - tag: formatDiscordUserTag(interactionCtx.user), - }, - allowNameMatching: params.allowNameMatching, - }); - - const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ - channelConfig, - guildInfo, - memberRoleIds: interactionCtx.memberRoleIds, - sender: { - id: interactionCtx.user.id, - name: interactionCtx.user.username, - tag: formatDiscordUserTag(interactionCtx.user), - }, - allowNameMatching: params.allowNameMatching, - }); - const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; - const authorizers = useAccessGroups - ? [ - { configured: ownerAllowList != null, allowed: ownerOk }, - { configured: hasAccessRestrictions, allowed: memberAllowed }, - ] - : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; - - return resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers, - modeWhenAccessGroupsOff: "configured", - }); -} +export { resolveInteractionContextWithDmAuth } from "./agent-components-dm-auth.js"; +export { + ensureAgentComponentInteractionAllowed, + ensureComponentUserAllowed, + ensureGuildComponentMemberAllowed, + resolveAuthorizedComponentInteraction, + resolveComponentCommandAuthorized, +} from "./agent-components-guild-auth.js"; diff --git a/extensions/discord/src/monitor/agent-components-dm-auth.ts b/extensions/discord/src/monitor/agent-components-dm-auth.ts new file mode 100644 index 00000000000..4a45669cbc6 --- /dev/null +++ b/extensions/discord/src/monitor/agent-components-dm-auth.ts @@ -0,0 +1,199 @@ +import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + resolveComponentInteractionContext, + resolveDiscordChannelContext, +} from "./agent-components-context.js"; +import { + readStoreAllowFromForDmPolicy, + upsertChannelPairingRequest, +} from "./agent-components-helpers.runtime.js"; +import { replySilently } from "./agent-components-reply.js"; +import type { + AgentComponentContext, + AgentComponentInteraction, + DiscordUser, +} from "./agent-components.types.js"; +import { + normalizeDiscordAllowList, + resolveDiscordAllowListMatch, + resolveGroupDmAllow, +} from "./allow-list.js"; +import { formatDiscordUserTag } from "./format.js"; + +async function ensureDmComponentAuthorized(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + user: DiscordUser; + componentLabel: string; + replyOpts: { ephemeral?: boolean }; +}) { + const { ctx, interaction, user, componentLabel, replyOpts } = params; + const allowFromPrefixes = ["discord:", "user:", "pk:"]; + const resolveAllowMatch = (entries: string[]) => { + const allowList = normalizeDiscordAllowList(entries, allowFromPrefixes); + return allowList + ? resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }, + allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), + }) + : { allowed: false }; + }; + const dmPolicy = ctx.dmPolicy ?? "pairing"; + if (dmPolicy === "disabled") { + logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`); + await replySilently(interaction, { content: "DM interactions are disabled.", ...replyOpts }); + return false; + } + if (dmPolicy === "allowlist") { + const allowMatch = resolveAllowMatch(ctx.allowFrom ?? []); + if (allowMatch.allowed) { + return true; + } + logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); + await replySilently(interaction, { + content: `You are not authorized to use this ${componentLabel}.`, + ...replyOpts, + }); + return false; + } + + const storeAllowFrom = + dmPolicy === "open" + ? [] + : await readStoreAllowFromForDmPolicy({ + provider: "discord", + accountId: ctx.accountId, + dmPolicy, + }); + const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]); + if (allowMatch.allowed) { + return true; + } + + if (dmPolicy === "pairing") { + const pairingResult = await createChannelPairingChallengeIssuer({ + channel: "discord", + upsertPairingRequest: async ({ id, meta }) => { + return await upsertChannelPairingRequest({ + channel: "discord", + id, + accountId: ctx.accountId, + meta, + }); + }, + })({ + senderId: user.id, + senderIdLine: `Your Discord user id: ${user.id}`, + meta: { + tag: formatDiscordUserTag(user), + name: user.username, + }, + sendPairingReply: async (text) => { + await interaction.reply({ + content: text, + ...replyOpts, + }); + }, + }); + if (!pairingResult.created) { + await replySilently(interaction, { + content: "Pairing already requested. Ask the bot owner to approve your code.", + ...replyOpts, + }); + } + return false; + } + + logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`); + await replySilently(interaction, { + content: `You are not authorized to use this ${componentLabel}.`, + ...replyOpts, + }); + return false; +} + +async function ensureGroupDmComponentAuthorized(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + channelId: string; + componentLabel: string; + replyOpts: { ephemeral?: boolean }; +}) { + const { ctx, interaction, channelId, componentLabel, replyOpts } = params; + const groupDmEnabled = ctx.discordConfig?.dm?.groupEnabled ?? false; + if (!groupDmEnabled) { + logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (group DMs disabled)`); + await replySilently(interaction, { + content: "Group DM interactions are disabled.", + ...replyOpts, + }); + return false; + } + + const channelCtx = resolveDiscordChannelContext(interaction); + const allowed = resolveGroupDmAllow({ + channels: ctx.discordConfig?.dm?.groupChannels, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + }); + if (allowed) { + return true; + } + + logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (not allowlisted)`); + await replySilently(interaction, { + content: `You are not authorized to use this ${componentLabel}.`, + ...replyOpts, + }); + return false; +} + +export async function resolveInteractionContextWithDmAuth(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + label: string; + componentLabel: string; + defer?: boolean; +}) { + const interactionCtx = await resolveComponentInteractionContext({ + interaction: params.interaction, + label: params.label, + defer: params.defer, + }); + if (!interactionCtx) { + return null; + } + if (interactionCtx.isDirectMessage) { + const authorized = await ensureDmComponentAuthorized({ + ctx: params.ctx, + interaction: params.interaction, + user: interactionCtx.user, + componentLabel: params.componentLabel, + replyOpts: interactionCtx.replyOpts, + }); + if (!authorized) { + return null; + } + } + if (interactionCtx.isGroupDm) { + const authorized = await ensureGroupDmComponentAuthorized({ + ctx: params.ctx, + interaction: params.interaction, + channelId: interactionCtx.channelId, + componentLabel: params.componentLabel, + replyOpts: interactionCtx.replyOpts, + }); + if (!authorized) { + return null; + } + } + return interactionCtx; +} diff --git a/extensions/discord/src/monitor/agent-components-guild-auth.ts b/extensions/discord/src/monitor/agent-components-guild-auth.ts new file mode 100644 index 00000000000..0645f42c808 --- /dev/null +++ b/extensions/discord/src/monitor/agent-components-guild-auth.ts @@ -0,0 +1,322 @@ +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; +import type { DiscordComponentEntry } from "../components.js"; +import { resolveDiscordChannelContext } from "./agent-components-context.js"; +import { resolveInteractionContextWithDmAuth } from "./agent-components-dm-auth.js"; +import { replySilently } from "./agent-components-reply.js"; +import type { + AgentComponentContext, + AgentComponentInteraction, + ComponentInteractionContext, + DiscordChannelContext, + DiscordUser, +} from "./agent-components.types.js"; +import { + isDiscordGroupAllowedByPolicy, + normalizeDiscordAllowList, + resolveDiscordAllowListMatch, + resolveDiscordChannelConfigWithFallback, + resolveDiscordGuildEntry, + resolveDiscordMemberAccessState, + resolveDiscordOwnerAccess, +} from "./allow-list.js"; +import { formatDiscordUserTag } from "./format.js"; + +function resolveComponentRuntimeGroupPolicy(ctx: AgentComponentContext) { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: ctx.cfg.channels?.discord !== undefined, + groupPolicy: ctx.discordConfig?.groupPolicy, + defaultGroupPolicy: ctx.cfg.channels?.defaults?.groupPolicy, + }).groupPolicy; +} + +export async function ensureGuildComponentMemberAllowed(params: { + interaction: AgentComponentInteraction; + guildInfo: ReturnType; + channelId: string; + rawGuildId: string | undefined; + channelCtx: DiscordChannelContext; + memberRoleIds: string[]; + user: DiscordUser; + replyOpts: { ephemeral?: boolean }; + componentLabel: string; + unauthorizedReply: string; + allowNameMatching: boolean; + groupPolicy: "open" | "disabled" | "allowlist"; +}) { + const { + interaction, + guildInfo, + channelId, + rawGuildId, + channelCtx, + memberRoleIds, + user, + replyOpts, + componentLabel, + unauthorizedReply, + } = params; + + if (!rawGuildId) { + return true; + } + + const replyUnauthorized = async () => { + await replySilently(interaction, { content: unauthorizedReply, ...replyOpts }); + }; + + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); + + if (channelConfig?.enabled === false) { + await replyUnauthorized(); + return false; + } + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + if ( + !isDiscordGroupAllowedByPolicy({ + groupPolicy: params.groupPolicy, + guildAllowlisted: Boolean(guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) + ) { + await replyUnauthorized(); + return false; + } + if (channelConfig?.allowed === false) { + await replyUnauthorized(); + return false; + } + + const { memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds, + sender: { + id: user.id, + name: user.username, + tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined, + }, + allowNameMatching: params.allowNameMatching, + }); + if (memberAllowed) { + return true; + } + + logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`); + await replyUnauthorized(); + return false; +} + +export async function ensureComponentUserAllowed(params: { + entry: DiscordComponentEntry; + interaction: AgentComponentInteraction; + user: DiscordUser; + replyOpts: { ephemeral?: boolean }; + componentLabel: string; + unauthorizedReply: string; + allowNameMatching: boolean; +}) { + const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [ + "discord:", + "user:", + "pk:", + ]); + if (!allowList) { + return true; + } + const match = resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: params.user.id, + name: params.user.username, + tag: formatDiscordUserTag(params.user), + }, + allowNameMatching: params.allowNameMatching, + }); + if (match.allowed) { + return true; + } + + logVerbose( + `discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`, + ); + await replySilently(params.interaction, { + content: params.unauthorizedReply, + ...params.replyOpts, + }); + return false; +} + +export async function ensureAgentComponentInteractionAllowed(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + channelId: string; + rawGuildId: string | undefined; + memberRoleIds: string[]; + user: DiscordUser; + replyOpts: { ephemeral?: boolean }; + componentLabel: string; + unauthorizedReply: string; +}) { + const guildInfo = resolveDiscordGuildEntry({ + guild: params.interaction.guild ?? undefined, + guildId: params.rawGuildId, + guildEntries: params.ctx.guildEntries, + }); + const channelCtx = resolveDiscordChannelContext(params.interaction); + const memberAllowed = await ensureGuildComponentMemberAllowed({ + interaction: params.interaction, + guildInfo, + channelId: params.channelId, + rawGuildId: params.rawGuildId, + channelCtx, + memberRoleIds: params.memberRoleIds, + user: params.user, + replyOpts: params.replyOpts, + componentLabel: params.componentLabel, + unauthorizedReply: params.unauthorizedReply, + allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + groupPolicy: resolveComponentRuntimeGroupPolicy(params.ctx), + }); + if (!memberAllowed) { + return null; + } + return { parentId: channelCtx.parentId }; +} + +export async function resolveAuthorizedComponentInteraction(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + label: string; + componentLabel: string; + unauthorizedReply: string; + defer?: boolean; +}) { + const interactionCtx = await resolveInteractionContextWithDmAuth({ + ctx: params.ctx, + interaction: params.interaction, + label: params.label, + componentLabel: params.componentLabel, + defer: params.defer, + }); + if (!interactionCtx) { + return null; + } + + const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; + const guildInfo = resolveDiscordGuildEntry({ + guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, + guildEntries: params.ctx.guildEntries, + }); + const channelCtx = resolveDiscordChannelContext(params.interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); + const memberAllowed = await ensureGuildComponentMemberAllowed({ + interaction: params.interaction, + guildInfo, + channelId, + rawGuildId, + channelCtx, + memberRoleIds, + user, + replyOpts, + componentLabel: params.componentLabel, + unauthorizedReply: params.unauthorizedReply, + allowNameMatching, + groupPolicy: resolveComponentRuntimeGroupPolicy(params.ctx), + }); + if (!memberAllowed) { + return null; + } + + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: params.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); + + return { + interactionCtx, + channelCtx, + guildInfo, + channelConfig, + allowNameMatching, + commandAuthorized, + user, + replyOpts, + }; +} + +export function resolveComponentCommandAuthorized(params: { + ctx: AgentComponentContext; + interactionCtx: ComponentInteractionContext; + channelConfig: ReturnType; + guildInfo: ReturnType; + allowNameMatching: boolean; +}) { + const { ctx, interactionCtx, channelConfig, guildInfo } = params; + if (interactionCtx.isDirectMessage) { + return true; + } + + const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ + allowFrom: ctx.allowFrom, + sender: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }); + + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds: interactionCtx.memberRoleIds, + sender: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }); + const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; + const authorizers = useAccessGroups + ? [ + { configured: ownerAllowList != null, allowed: ownerOk }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ] + : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + + return resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers, + modeWhenAccessGroupsOff: "configured", + }); +} diff --git a/extensions/discord/src/monitor/agent-components-reply.ts b/extensions/discord/src/monitor/agent-components-reply.ts new file mode 100644 index 00000000000..c2dc3242b99 --- /dev/null +++ b/extensions/discord/src/monitor/agent-components-reply.ts @@ -0,0 +1,10 @@ +import type { AgentComponentInteraction } from "./agent-components.types.js"; + +export async function replySilently( + interaction: AgentComponentInteraction, + params: { content: string; ephemeral?: boolean }, +) { + try { + await interaction.reply(params); + } catch {} +} diff --git a/src/plugin-sdk/discord.test.ts b/src/plugin-sdk/discord.test.ts index 1e0d9fb8891..b8fe32e1801 100644 --- a/src/plugin-sdk/discord.test.ts +++ b/src/plugin-sdk/discord.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => { text: params.spec.text ?? "", })), collectDiscordStatusIssues: vi.fn(() => []), - discordOnboardingAdapter: { kind: "legacy-onboarding" }, + discordOnboardingAdapter: { kind: "discord-onboarding" }, inspectDiscordAccount: vi.fn(() => ({ accountId: "default" })), listDiscordAccountIds: vi.fn(() => ["default"]), listDiscordDirectoryGroupsFromConfig: vi.fn(() => []), diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index c2c08fcd303..3b365d5d8d2 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -9,25 +9,11 @@ const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const RUNTIME_API_EXPORT_GUARDS: Record = { [bundledPluginFile({ rootDir: ROOT_DIR, pluginId: "discord", relativePath: "runtime-api.ts" })]: [ - 'export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./src/audit.js";', - 'export { handleDiscordAction } from "./src/actions/runtime.js";', - 'export { isDiscordModerationAction, readDiscordModerationCommand, requiredGuildPermissionForModerationAction, type DiscordModerationAction, type DiscordModerationCommand } from "./src/actions/runtime.moderation-shared.js";', - 'export { readDiscordChannelCreateParams, readDiscordChannelEditParams, readDiscordChannelMoveParams, readDiscordParentIdParam } from "./src/actions/runtime.shared.js";', - 'export { discordMessageActions } from "./src/channel-actions.js";', - 'export { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./src/directory-live.js";', - 'export { allowListMatches, buildDiscordMediaPayload, createDiscordMessageHandler, createDiscordNativeCommand, isDiscordGroupAllowedByPolicy, monitorDiscordProvider, normalizeDiscordAllowList, normalizeDiscordSlug, registerDiscordListener, resolveDiscordChannelConfig, resolveDiscordChannelConfigWithFallback, resolveDiscordCommandAuthorized, resolveDiscordGuildEntry, resolveDiscordReplyTarget, resolveDiscordShouldRequireMention, resolveGroupDmAllow, sanitizeDiscordThreadName, shouldEmitDiscordReactionNotification, type DiscordAllowList, type DiscordChannelConfigResolved, type DiscordGuildEntryResolved, type DiscordMessageEvent, type DiscordMessageHandler, type MonitorDiscordOpts } from "./src/monitor.js";', - 'export { createDiscordGatewayPlugin, resolveDiscordGatewayIntents, waitForDiscordGatewayPluginRegistration } from "./src/monitor/gateway-plugin.js";', - 'export { clearGateways, getGateway, registerGateway, unregisterGateway } from "./src/monitor/gateway-registry.js";', - 'export { clearPresences, getPresence, presenceCacheSize, setPresence } from "./src/monitor/presence-cache.js";', - 'export { __testing, autoBindSpawnedDiscordSubagent, createNoopThreadBindingManager, createThreadBindingManager, formatThreadBindingDurationLabel, getThreadBindingManager, isRecentlyUnboundThreadWebhookMessage, listThreadBindingsBySessionKey, listThreadBindingsForAccount, reconcileAcpThreadBindingsOnStartup, resolveDiscordThreadBindingIdleTimeoutMs, resolveDiscordThreadBindingMaxAgeMs, resolveThreadBindingIdleTimeoutMs, resolveThreadBindingInactivityExpiresAt, resolveThreadBindingIntroText, resolveThreadBindingMaxAgeExpiresAt, resolveThreadBindingMaxAgeMs, resolveThreadBindingPersona, resolveThreadBindingPersonaFromRecord, resolveThreadBindingsEnabled, resolveThreadBindingThreadName, setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, type AcpThreadBindingReconciliationResult, type ThreadBindingManager, type ThreadBindingRecord, type ThreadBindingTargetKind } from "./src/monitor/thread-bindings.js";', - 'export { DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS, DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, mergeAbortSignals } from "./src/monitor/timeouts.js";', - 'export { fetchDiscordApplicationId, fetchDiscordApplicationSummary, parseApplicationIdFromToken, probeDiscord, resolveDiscordPrivilegedIntentsFromFlags, type DiscordApplicationSummary, type DiscordPrivilegedIntentsSummary, type DiscordPrivilegedIntentStatus, type DiscordProbe } from "./src/probe.js";', - 'export { resolveDiscordChannelAllowlist, type DiscordChannelResolution } from "./src/resolve-channels.js";', - 'export { resolveDiscordUserAllowlist, type DiscordUserResolution } from "./src/resolve-users.js";', - 'export { resolveDiscordOutboundSessionRoute, type ResolveDiscordOutboundSessionRouteParams } from "./src/outbound-session-route.js";', - 'export { addRoleDiscord, banMemberDiscord, createChannelDiscord, createScheduledEventDiscord, createThreadDiscord, deleteChannelDiscord, deleteMessageDiscord, DiscordSendError, editChannelDiscord, editMessageDiscord, fetchChannelInfoDiscord, fetchChannelPermissionsDiscord, fetchMemberGuildPermissionsDiscord, fetchMemberInfoDiscord, fetchMessageDiscord, fetchReactionsDiscord, fetchRoleInfoDiscord, fetchVoiceStatusDiscord, hasAllGuildPermissionsDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, listGuildChannelsDiscord, listGuildEmojisDiscord, listPinsDiscord, listScheduledEventsDiscord, listThreadsDiscord, moveChannelDiscord, pinMessageDiscord, reactMessageDiscord, readMessagesDiscord, removeChannelPermissionDiscord, removeOwnReactionsDiscord, removeReactionDiscord, removeRoleDiscord, resolveEventCoverImage, searchMessagesDiscord, sendMessageDiscord, sendPollDiscord, sendStickerDiscord, sendTypingDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord, setChannelPermissionDiscord, timeoutMemberDiscord, unpinMessageDiscord, uploadEmojiDiscord, uploadStickerDiscord, type DiscordChannelCreate, type DiscordChannelEdit, type DiscordChannelMove, type DiscordChannelPermissionSet, type DiscordEmojiUpload, type DiscordMessageEdit, type DiscordMessageQuery, type DiscordModerationTarget, type DiscordPermissionsSummary, type DiscordReactionRuntimeContext, type DiscordReactionSummary, type DiscordReactionUser, type DiscordReactOpts, type DiscordRoleChange, type DiscordRuntimeAccountContext, type DiscordSearchQuery, type DiscordSendResult, type DiscordStickerUpload, type DiscordThreadCreate, type DiscordThreadList, type DiscordTimeoutTarget } from "./src/send.js";', - 'export { editDiscordComponentMessage, registerBuiltDiscordComponentMessage, sendDiscordComponentMessage } from "./src/send.components.js";', - 'export { setDiscordRuntime } from "./src/runtime.js";', + 'export { discordMessageActions, handleDiscordAction, isDiscordModerationAction, readDiscordChannelCreateParams, readDiscordChannelEditParams, readDiscordChannelMoveParams, readDiscordModerationCommand, readDiscordParentIdParam, requiredGuildPermissionForModerationAction, type DiscordModerationAction, type DiscordModerationCommand } from "./runtime-api.actions.js";', + 'export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds, fetchDiscordApplicationId, fetchDiscordApplicationSummary, listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive, parseApplicationIdFromToken, probeDiscord, resolveDiscordChannelAllowlist, resolveDiscordPrivilegedIntentsFromFlags, resolveDiscordUserAllowlist, setDiscordRuntime, type DiscordApplicationSummary, type DiscordChannelResolution, type DiscordPrivilegedIntentsSummary, type DiscordPrivilegedIntentStatus, type DiscordProbe, type DiscordUserResolution } from "./runtime-api.lookup.js";', + 'export { DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS, DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, allowListMatches, buildDiscordMediaPayload, clearGateways, clearPresences, createDiscordGatewayPlugin, createDiscordMessageHandler, createDiscordNativeCommand, getGateway, getPresence, isDiscordGroupAllowedByPolicy, mergeAbortSignals, monitorDiscordProvider, normalizeDiscordAllowList, normalizeDiscordSlug, presenceCacheSize, registerDiscordListener, registerGateway, resolveDiscordChannelConfig, resolveDiscordChannelConfigWithFallback, resolveDiscordCommandAuthorized, resolveDiscordGatewayIntents, resolveDiscordGuildEntry, resolveDiscordReplyTarget, resolveDiscordShouldRequireMention, resolveGroupDmAllow, sanitizeDiscordThreadName, setPresence, shouldEmitDiscordReactionNotification, unregisterGateway, waitForDiscordGatewayPluginRegistration, type DiscordAllowList, type DiscordChannelConfigResolved, type DiscordGuildEntryResolved, type DiscordMessageEvent, type DiscordMessageHandler, type MonitorDiscordOpts } from "./runtime-api.monitor.js";', + 'export { DiscordSendError, addRoleDiscord, banMemberDiscord, createChannelDiscord, createScheduledEventDiscord, createThreadDiscord, deleteChannelDiscord, deleteMessageDiscord, editChannelDiscord, editDiscordComponentMessage, editMessageDiscord, fetchChannelInfoDiscord, fetchChannelPermissionsDiscord, fetchMemberGuildPermissionsDiscord, fetchMemberInfoDiscord, fetchMessageDiscord, fetchReactionsDiscord, fetchRoleInfoDiscord, fetchVoiceStatusDiscord, hasAllGuildPermissionsDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, listGuildChannelsDiscord, listGuildEmojisDiscord, listPinsDiscord, listScheduledEventsDiscord, listThreadsDiscord, moveChannelDiscord, pinMessageDiscord, reactMessageDiscord, readMessagesDiscord, registerBuiltDiscordComponentMessage, removeChannelPermissionDiscord, removeOwnReactionsDiscord, removeReactionDiscord, removeRoleDiscord, resolveDiscordOutboundSessionRoute, resolveEventCoverImage, searchMessagesDiscord, sendDiscordComponentMessage, sendMessageDiscord, sendPollDiscord, sendStickerDiscord, sendTypingDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord, setChannelPermissionDiscord, timeoutMemberDiscord, unpinMessageDiscord, uploadEmojiDiscord, uploadStickerDiscord, type DiscordChannelCreate, type DiscordChannelEdit, type DiscordChannelMove, type DiscordChannelPermissionSet, type DiscordEmojiUpload, type DiscordMessageEdit, type DiscordMessageQuery, type DiscordModerationTarget, type DiscordPermissionsSummary, type DiscordReactionRuntimeContext, type DiscordReactionSummary, type DiscordReactionUser, type DiscordReactOpts, type DiscordRoleChange, type DiscordRuntimeAccountContext, type DiscordSearchQuery, type DiscordSendResult, type DiscordStickerUpload, type DiscordThreadCreate, type DiscordThreadList, type DiscordTimeoutTarget, type ResolveDiscordOutboundSessionRouteParams } from "./runtime-api.send.js";', + 'export { __testing, autoBindSpawnedDiscordSubagent, createNoopThreadBindingManager, createThreadBindingManager, formatThreadBindingDurationLabel, getThreadBindingManager, isRecentlyUnboundThreadWebhookMessage, listThreadBindingsBySessionKey, listThreadBindingsForAccount, reconcileAcpThreadBindingsOnStartup, resolveDiscordThreadBindingIdleTimeoutMs, resolveDiscordThreadBindingMaxAgeMs, resolveThreadBindingIdleTimeoutMs, resolveThreadBindingInactivityExpiresAt, resolveThreadBindingIntroText, resolveThreadBindingMaxAgeExpiresAt, resolveThreadBindingMaxAgeMs, resolveThreadBindingPersona, resolveThreadBindingPersonaFromRecord, resolveThreadBindingsEnabled, resolveThreadBindingThreadName, setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, type AcpThreadBindingReconciliationResult, type ThreadBindingManager, type ThreadBindingRecord, type ThreadBindingTargetKind } from "./runtime-api.threads.js";', ], [bundledPluginFile({ rootDir: ROOT_DIR, pluginId: "imessage", relativePath: "runtime-api.ts" })]: [ diff --git a/src/plugins/contracts/test-helpers/bundled-plugin-roots.ts b/src/plugins/contracts/test-helpers/bundled-plugin-roots.ts index 85606fc91ec..99b3f0ec596 100644 --- a/src/plugins/contracts/test-helpers/bundled-plugin-roots.ts +++ b/src/plugins/contracts/test-helpers/bundled-plugin-roots.ts @@ -1,8 +1,16 @@ import { relative, resolve } from "node:path"; import { loadPluginManifestRegistry } from "../../manifest-registry.js"; +const sourceExtensionsDir = resolve(process.cwd(), "extensions"); const bundledPluginRoots = new Map( - loadPluginManifestRegistry({ config: {} }) + loadPluginManifestRegistry({ + config: {}, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: sourceExtensionsDir, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + }, + }) .plugins.filter((plugin) => plugin.origin === "bundled") .map((plugin) => [plugin.id, plugin.rootDir] as const), );