refactor(discord): split component auth helpers

This commit is contained in:
Peter Steinberger
2026-04-29 16:56:12 +01:00
parent 334f4624e0
commit e8b82d1cf9
12 changed files with 646 additions and 690 deletions

View File

@@ -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";

View File

@@ -32,6 +32,7 @@ import {
resolveEventCoverImage,
} from "../send.js";
import {
createDiscordActionOptions,
readDiscordChannelCreateParams,
readDiscordChannelEditParams,
readDiscordChannelMoveParams,
@@ -70,7 +71,7 @@ type DiscordRoleMutation = (
) => Promise<unknown>;
async function runRoleMutation(params: {
cfgOptions: { cfg: OpenClawConfig };
cfg: OpenClawConfig;
accountId?: string;
values: Record<string, unknown>;
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<string, unknown>) => ({
...cfgOptions,
...(accountId ? { accountId } : {}),
...extra,
});
const withOpts = (extra?: Record<string, unknown>) =>
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,

View File

@@ -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<string, unknown>) =>
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 });
}

View File

@@ -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": {

View File

@@ -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<string, unknown> = Record<string, never>,
>(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<string, unknown>,
): DiscordChannelCreate {

View File

@@ -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<typeof resolveDiscordGuildEntry>;
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<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
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";

View File

@@ -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;
}

View File

@@ -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<typeof resolveDiscordGuildEntry>;
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<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
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",
});
}

View File

@@ -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 {}
}