refactor: split remaining monitor runtime helpers

This commit is contained in:
Peter Steinberger 2026-03-17 20:34:18 -07:00
parent 6556a40330
commit 005b25e9d4
No known key found for this signature in database
19 changed files with 1209 additions and 1234 deletions

View File

@ -0,0 +1,754 @@
import {
type ButtonInteraction,
type ChannelSelectMenuInteraction,
type ComponentData,
type MentionableSelectMenuInteraction,
type ModalInteraction,
type RoleSelectMenuInteraction,
type StringSelectMenuInteraction,
type UserSelectMenuInteraction,
} from "@buape/carbon";
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ChannelType } from "discord-api-types/v10";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import {
issuePairingChallenge,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import {
createDiscordFormModal,
parseDiscordComponentCustomId,
parseDiscordModalCustomId,
type DiscordComponentEntry,
type DiscordModalEntry,
} from "../components.js";
import {
type DiscordGuildEntryResolved,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
export const AGENT_BUTTON_KEY = "agent";
export const AGENT_SELECT_KEY = "agentsel";
export type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
export type AgentComponentMessageInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| RoleSelectMenuInteraction
| UserSelectMenuInteraction
| MentionableSelectMenuInteraction
| ChannelSelectMenuInteraction;
export type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
export type DiscordChannelContext = {
channelName: string | undefined;
channelSlug: string;
channelType: number | undefined;
isThread: boolean;
parentId: string | undefined;
parentName: string | undefined;
parentSlug: string;
};
export type AgentComponentContext = {
cfg: OpenClawConfig;
accountId: string;
discordConfig?: DiscordAccountConfig;
runtime?: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
allowFrom?: string[];
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
};
export type ComponentInteractionContext = NonNullable<
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
>;
function formatUsername(user: { username: string; discriminator?: string | null }): string {
if (user.discriminator && user.discriminator !== "0") {
return `${user.username}#${user.discriminator}`;
}
return user.username;
}
function isThreadChannelType(channelType: number | undefined): boolean {
return (
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread
);
}
function readParsedComponentId(data: ComponentData): unknown {
if (!data || typeof data !== "object") {
return undefined;
}
return "cid" in data
? (data as Record<string, unknown>).cid
: (data as Record<string, unknown>).componentId;
}
function normalizeComponentId(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
function mapOptionLabels(
options: Array<{ value: string; label: string }> | undefined,
values: string[],
) {
if (!options || options.length === 0) {
return values;
}
const map = new Map(options.map((option) => [option.value, option.label]));
return values.map((value) => map.get(value) ?? value);
}
/**
* The component custom id only carries the logical button id. Channel binding
* comes from Discord's trusted interaction payload.
*/
export function buildAgentButtonCustomId(componentId: string): string {
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
export function buildAgentSelectCustomId(componentId: string): string {
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
export function resolveAgentComponentRoute(params: {
ctx: AgentComponentContext;
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
userId: string;
channelId: string;
parentId: string | undefined;
}) {
return resolveAgentRoute({
cfg: params.ctx.cfg,
channel: "discord",
accountId: params.ctx.accountId,
guildId: params.rawGuildId,
memberRoleIds: params.memberRoleIds,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.userId : params.channelId,
},
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
});
}
export async function ackComponentInteraction(params: {
interaction: AgentComponentInteraction;
replyOpts: { ephemeral?: boolean };
label: string;
}) {
try {
await params.interaction.reply({
content: "✓",
...params.replyOpts,
});
} catch (err) {
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
}
}
export function resolveDiscordChannelContext(
interaction: AgentComponentInteraction,
): DiscordChannelContext {
const channel = interaction.channel;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread && channel && "parentId" in channel) {
parentId = (channel.parentId as string) ?? undefined;
if ("parent" in channel) {
const parent = (channel as { parent?: { name?: string } }).parent;
if (parent?.name) {
parentName = parent.name;
parentSlug = normalizeDiscordSlug(parentName);
}
}
}
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
}
export async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
defer?: boolean;
}) {
const { interaction, label } = params;
const channelId = interaction.rawData.channel_id;
if (!channelId) {
logError(`${label}: missing channel_id in interaction`);
return null;
}
const user = interaction.user;
if (!user) {
logError(`${label}: missing user in interaction`);
return null;
}
const shouldDefer = params.defer !== false && "defer" in interaction;
let didDefer = false;
if (shouldDefer) {
try {
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`${label}: failed to defer interaction: ${String(err)}`);
}
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
memberRoleIds,
};
}
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;
}) {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
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 } = 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)`);
try {
await interaction.reply({
content: unauthorizedReply,
...replyOpts,
});
} catch {}
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)`,
);
try {
await params.interaction.reply({
content: params.unauthorizedReply,
...params.replyOpts,
});
} catch {}
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),
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
export function parseAgentComponentData(data: ComponentData): { componentId: string } | null {
const raw = readParsedComponentId(data);
const decodeSafe = (value: string): string => {
if (!value.includes("%")) {
return value;
}
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
return value;
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const componentId =
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
if (!componentId) {
return null;
}
return { componentId };
}
async function ensureDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
user: DiscordUser;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
try {
await interaction.reply({
content: "DM interactions are disabled.",
...replyOpts,
});
} catch {}
return false;
}
if (dmPolicy === "open") {
return true;
}
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await issuePairingChallenge({
channel: "discord",
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
}),
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
try {
await interaction.reply({
content: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts,
});
} catch {}
}
return false;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {}
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;
}
}
return interactionCtx;
}
export function parseDiscordComponentData(
data: ComponentData,
customId?: string,
): { componentId: string; modalId?: string } | null {
if (!data || typeof data !== "object") {
return null;
}
const rawComponentId = readParsedComponentId(data);
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
let componentId = normalizeComponentId(rawComponentId);
let modalId = normalizeComponentId(rawModalId);
if (!componentId && customId) {
const parsed = parseDiscordComponentCustomId(customId);
if (parsed) {
componentId = parsed.componentId;
modalId = parsed.modalId;
}
}
if (!componentId) {
return null;
}
return { componentId, modalId };
}
export function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
if (data && typeof data === "object") {
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
const modalId = normalizeComponentId(rawModalId);
if (modalId) {
return modalId;
}
}
if (customId) {
return parseDiscordModalCustomId(customId);
}
return null;
}
export function resolveInteractionCustomId(
interaction: AgentComponentInteraction,
): string | undefined {
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
return undefined;
}
if (!("data" in interaction.rawData)) {
return undefined;
}
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
const customId = data?.custom_id;
if (typeof customId !== "string") {
return undefined;
}
const trimmed = customId.trim();
return trimmed ? trimmed : undefined;
}
export function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
if (entry.selectType === "string") {
return mapOptionLabels(entry.options, values);
}
if (entry.selectType === "user") {
return values.map((value) => `user:${value}`);
}
if (entry.selectType === "role") {
return values.map((value) => `role:${value}`);
}
if (entry.selectType === "mentionable") {
return values.map((value) => `mentionable:${value}`);
}
if (entry.selectType === "channel") {
return values.map((value) => `channel:${value}`);
}
return values;
}
export function resolveModalFieldValues(
field: DiscordModalEntry["fields"][number],
interaction: ModalInteraction,
): string[] {
const fields = interaction.fields;
const optionLabels = field.options?.map((option) => ({
value: option.value,
label: option.label,
}));
const required = field.required === true;
try {
switch (field.type) {
case "text": {
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
return value ? [value] : [];
}
case "select":
case "checkbox":
case "radio": {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return mapOptionLabels(optionLabels, values);
}
case "role-select": {
try {
const roles = required
? fields.getRoleSelect(field.id, true)
: (fields.getRoleSelect(field.id) ?? []);
return roles.map((role) => role.name ?? role.id);
} catch {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return values;
}
}
case "user-select": {
const users = required
? fields.getUserSelect(field.id, true)
: (fields.getUserSelect(field.id) ?? []);
return users.map((user) => formatDiscordUserTag(user));
}
default:
return [];
}
} catch (err) {
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
return [];
}
}
export function formatModalSubmissionText(
entry: DiscordModalEntry,
interaction: ModalInteraction,
): string {
const lines: string[] = [`Form "${entry.title}" submitted.`];
for (const field of entry.fields) {
const values = resolveModalFieldValues(field, interaction);
if (values.length === 0) {
continue;
}
lines.push(`- ${field.label}: ${values.join(", ")}`);
}
if (lines.length === 1) {
lines.push("- (no values)");
}
return lines.join("\n");
}
export function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
const rawId =
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
? (interaction.rawData as { id?: unknown }).id
: undefined;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
if (typeof rawId === "number" && Number.isFinite(rawId)) {
return String(rawId);
}
return `discord-interaction:${Date.now()}`;
}
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 { resolveDiscordGuildEntry, resolvePinnedMainDmOwnerFromAllowlist };

View File

@ -19,7 +19,6 @@ import {
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ButtonStyle, ChannelType } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
@ -27,8 +26,6 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
@ -48,32 +45,51 @@ import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
import {
createDiscordFormModal,
formatDiscordComponentEventText,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForCarbon,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForCarbon,
type DiscordComponentEntry,
type DiscordModalEntry,
} from "../components.js";
import {
AGENT_BUTTON_KEY,
AGENT_SELECT_KEY,
ackComponentInteraction,
buildAgentButtonCustomId,
buildAgentSelectCustomId,
type AgentComponentContext,
type AgentComponentInteraction,
type AgentComponentMessageInteraction,
ensureAgentComponentInteractionAllowed,
ensureComponentUserAllowed,
ensureGuildComponentMemberAllowed,
formatModalSubmissionText,
mapSelectValues,
parseAgentComponentData,
parseDiscordComponentData,
parseDiscordModalId,
resolveAgentComponentRoute,
resolveComponentCommandAuthorized,
type ComponentInteractionContext,
resolveDiscordChannelContext,
type DiscordChannelContext,
resolveDiscordInteractionId,
resolveInteractionContextWithDmAuth,
resolveInteractionCustomId,
resolveModalFieldValues,
resolvePinnedMainDmOwnerFromAllowlist,
type DiscordUser,
} from "./agent-components-helpers.js";
import {
type DiscordGuildEntryResolved,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
import {
@ -84,714 +100,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { sendTyping } from "./typing.js";
const AGENT_BUTTON_KEY = "agent";
const AGENT_SELECT_KEY = "agentsel";
type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
type AgentComponentMessageInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| RoleSelectMenuInteraction
| UserSelectMenuInteraction
| MentionableSelectMenuInteraction
| ChannelSelectMenuInteraction;
type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
type ComponentInteractionContext = NonNullable<
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
>;
type DiscordChannelContext = {
channelName: string | undefined;
channelSlug: string;
channelType: number | undefined;
isThread: boolean;
parentId: string | undefined;
parentName: string | undefined;
parentSlug: string;
};
function resolveAgentComponentRoute(params: {
ctx: AgentComponentContext;
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
userId: string;
channelId: string;
parentId: string | undefined;
}) {
return resolveAgentRoute({
cfg: params.ctx.cfg,
channel: "discord",
accountId: params.ctx.accountId,
guildId: params.rawGuildId,
memberRoleIds: params.memberRoleIds,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.userId : params.channelId,
},
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
});
}
async function ackComponentInteraction(params: {
interaction: AgentComponentInteraction;
replyOpts: { ephemeral?: boolean };
label: string;
}) {
try {
await params.interaction.reply({
content: "✓",
...params.replyOpts,
});
} catch (err) {
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
}
}
function resolveDiscordChannelContext(
interaction: AgentComponentInteraction,
): DiscordChannelContext {
const channel = interaction.channel;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread && channel && "parentId" in channel) {
parentId = (channel.parentId as string) ?? undefined;
if ("parent" in channel) {
const parent = (channel as { parent?: { name?: string } }).parent;
if (parent?.name) {
parentName = parent.name;
parentSlug = normalizeDiscordSlug(parentName);
}
}
}
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
}
async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
defer?: boolean;
}): Promise<{
channelId: string;
user: DiscordUser;
username: string;
userId: string;
replyOpts: { ephemeral?: boolean };
rawGuildId: string | undefined;
isDirectMessage: boolean;
memberRoleIds: string[];
} | null> {
const { interaction, label } = params;
// Use interaction's actual channel_id (trusted source from Discord)
// This prevents channel spoofing attacks
const channelId = interaction.rawData.channel_id;
if (!channelId) {
logError(`${label}: missing channel_id in interaction`);
return null;
}
const user = interaction.user;
if (!user) {
logError(`${label}: missing user in interaction`);
return null;
}
const shouldDefer = params.defer !== false && "defer" in interaction;
let didDefer = false;
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement.
// We use an ephemeral deferred reply so subsequent interaction.reply() calls
// can safely edit the original deferred response.
if (shouldDefer) {
try {
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`${label}: failed to defer interaction: ${String(err)}`);
}
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
memberRoleIds,
};
}
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;
}): Promise<boolean> {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
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 } = 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)`);
try {
await interaction.reply({
content: unauthorizedReply,
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function ensureComponentUserAllowed(params: {
entry: DiscordComponentEntry;
interaction: AgentComponentInteraction;
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}): Promise<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)`,
);
try {
await params.interaction.reply({
content: params.unauthorizedReply,
...params.replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function ensureAgentComponentInteractionAllowed(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
rawGuildId: string | undefined;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
}): Promise<{ parentId: string | undefined } | null> {
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),
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
export type AgentComponentContext = {
cfg: OpenClawConfig;
accountId: string;
discordConfig?: DiscordAccountConfig;
runtime?: RuntimeEnv;
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
/** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */
allowFrom?: string[];
/** DM policy (default: "pairing") */
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
};
/**
* Build agent button custom ID: agent:componentId=<id>
* The channelId is NOT embedded in customId - we use interaction.rawData.channel_id instead
* to prevent channel spoofing attacks.
*
* Carbon's customIdParser parses "key:arg1=value1;arg2=value2" into { arg1: value1, arg2: value2 }
*/
export function buildAgentButtonCustomId(componentId: string): string {
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
/**
* Build agent select menu custom ID: agentsel:componentId=<id>
*/
export function buildAgentSelectCustomId(componentId: string): string {
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
/**
* Parse agent component data from Carbon's parsed ComponentData
* Supports both legacy { componentId } and Components v2 { cid } payloads.
*/
function readParsedComponentId(data: ComponentData): unknown {
if (!data || typeof data !== "object") {
return undefined;
}
return "cid" in data
? (data as Record<string, unknown>).cid
: (data as Record<string, unknown>).componentId;
}
function parseAgentComponentData(data: ComponentData): {
componentId: string;
} | null {
const raw = readParsedComponentId(data);
const decodeSafe = (value: string): string => {
// `cid` values may be raw (not URI-encoded). Guard against malformed % sequences.
// Only attempt decoding when it looks like it contains percent-encoding.
if (!value.includes("%")) {
return value;
}
// If it has a % but not a valid %XX sequence, skip decode.
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
return value;
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const componentId =
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
if (!componentId) {
return null;
}
return { componentId };
}
function formatUsername(user: { username: string; discriminator?: string | null }): string {
if (user.discriminator && user.discriminator !== "0") {
return `${user.username}#${user.discriminator}`;
}
return user.username;
}
/**
* Check if a channel type is a thread type
*/
function isThreadChannelType(channelType: number | undefined): boolean {
return (
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread
);
}
async function ensureDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
user: DiscordUser;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}): Promise<boolean> {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
try {
await interaction.reply({
content: "DM interactions are disabled.",
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
if (dmPolicy === "open") {
return true;
}
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await issuePairingChallenge({
channel: "discord",
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
}),
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
try {
await interaction.reply({
content: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts,
});
} catch {
// Interaction may have expired
}
}
return false;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function resolveInteractionContextWithDmAuth(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
label: string;
componentLabel: string;
defer?: boolean;
}): Promise<ComponentInteractionContext | null> {
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;
}
}
return interactionCtx;
}
function normalizeComponentId(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
function parseDiscordComponentData(
data: ComponentData,
customId?: string,
): { componentId: string; modalId?: string } | null {
if (!data || typeof data !== "object") {
return null;
}
const rawComponentId = readParsedComponentId(data);
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
let componentId = normalizeComponentId(rawComponentId);
let modalId = normalizeComponentId(rawModalId);
if (!componentId && customId) {
const parsed = parseDiscordComponentCustomId(customId);
if (parsed) {
componentId = parsed.componentId;
modalId = parsed.modalId;
}
}
if (!componentId) {
return null;
}
return { componentId, modalId };
}
function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
if (data && typeof data === "object") {
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
const modalId = normalizeComponentId(rawModalId);
if (modalId) {
return modalId;
}
}
if (customId) {
return parseDiscordModalCustomId(customId);
}
return null;
}
function resolveInteractionCustomId(interaction: AgentComponentInteraction): string | undefined {
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
return undefined;
}
if (!("data" in interaction.rawData)) {
return undefined;
}
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
const customId = data?.custom_id;
if (typeof customId !== "string") {
return undefined;
}
const trimmed = customId.trim();
return trimmed ? trimmed : undefined;
}
function mapOptionLabels(
options: Array<{ value: string; label: string }> | undefined,
values: string[],
) {
if (!options || options.length === 0) {
return values;
}
const map = new Map(options.map((option) => [option.value, option.label]));
return values.map((value) => map.get(value) ?? value);
}
function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
if (entry.selectType === "string") {
return mapOptionLabels(entry.options, values);
}
if (entry.selectType === "user") {
return values.map((value) => `user:${value}`);
}
if (entry.selectType === "role") {
return values.map((value) => `role:${value}`);
}
if (entry.selectType === "mentionable") {
return values.map((value) => `mentionable:${value}`);
}
if (entry.selectType === "channel") {
return values.map((value) => `channel:${value}`);
}
return values;
}
function resolveModalFieldValues(
field: DiscordModalEntry["fields"][number],
interaction: ModalInteraction,
): string[] {
const fields = interaction.fields;
const optionLabels = field.options?.map((option) => ({
value: option.value,
label: option.label,
}));
const required = field.required === true;
try {
switch (field.type) {
case "text": {
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
return value ? [value] : [];
}
case "select":
case "checkbox":
case "radio": {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return mapOptionLabels(optionLabels, values);
}
case "role-select": {
try {
const roles = required
? fields.getRoleSelect(field.id, true)
: (fields.getRoleSelect(field.id) ?? []);
return roles.map((role) => role.name ?? role.id);
} catch {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return values;
}
}
case "user-select": {
const users = required
? fields.getUserSelect(field.id, true)
: (fields.getUserSelect(field.id) ?? []);
return users.map((user) => formatDiscordUserTag(user));
}
default:
return [];
}
} catch (err) {
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
return [];
}
}
function formatModalSubmissionText(
entry: DiscordModalEntry,
interaction: ModalInteraction,
): string {
const lines: string[] = [`Form "${entry.title}" submitted.`];
for (const field of entry.fields) {
const values = resolveModalFieldValues(field, interaction);
if (values.length === 0) {
continue;
}
lines.push(`- ${field.label}: ${values.join(", ")}`);
}
if (lines.length === 1) {
lines.push("- (no values)");
}
return lines.join("\n");
}
function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
const rawId =
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
? (interaction.rawData as { id?: unknown }).id
: undefined;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
if (typeof rawId === "number" && Number.isFinite(rawId)) {
return String(rawId);
}
return `discord-interaction:${Date.now()}`;
}
async function dispatchPluginDiscordInteractiveEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@ -931,54 +239,6 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
return "unmatched";
}
function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
allowNameMatching: boolean;
}): 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",
});
}
async function dispatchDiscordComponentEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@ -1045,7 +305,7 @@ async function dispatchDiscordComponentEvent(params: {
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: ctx.cfg.session?.dmScope,
allowFrom: channelConfig?.users ?? guildInfo?.users,
normalizeEntry: (entry) => {
normalizeEntry: (entry: string) => {
const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
const candidate = normalized?.ids.values().next().value;
return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;

View File

@ -7,11 +7,11 @@ import type {
import type { Client } from "@buape/carbon";
import { ChannelType } from "discord-api-types/v10";
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
@ -50,7 +50,6 @@ const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const dispatchReplyMock = vi.hoisted(() => vi.fn());
const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
const resolveStorePathMock = vi.hoisted(() => vi.fn());
@ -59,37 +58,20 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/security/dm-policy-shared.js")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
};
});
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
}));
vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
}));
vi.mock("../../../../src/channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/pairing/pairing-store.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
@ -105,6 +87,42 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal
};
});
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../../../src/auto-reply/reply/provider-dispatcher.js")
>();
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
};
});
vi.mock("../../../../src/channels/session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/channels/session.js")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
};
});
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
return {
@ -287,12 +305,18 @@ describe("discord component interactions", () => {
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const rest = {
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
post: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
};
const interaction = {
rawData: { channel_id: "dm-channel", id: "interaction-1" },
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
customId: "occomp:cid=btn_1",
message: { id: "msg-1" },
client: { rest: {} },
client: { rest },
defer,
reply,
...overrides,
@ -303,6 +327,12 @@ describe("discord component interactions", () => {
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
const rest = {
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
post: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
};
const fields = {
getText: (key: string) => (key === "fld_1" ? "Casey" : undefined),
getStringSelect: (_key: string) => undefined,
@ -316,7 +346,7 @@ describe("discord component interactions", () => {
fields,
acknowledge,
reply,
client: { rest: {} },
client: { rest },
...overrides,
} as unknown as ModalInteraction;
return { interaction, acknowledge, reply };
@ -363,7 +393,6 @@ describe("discord component interactions", () => {
lastDispatchCtx = params.ctx;
await params.dispatcherOptions.deliver({ text: "ok" });
});
deliverDiscordReplyMock.mockClear();
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
@ -415,8 +444,6 @@ describe("discord component interactions", () => {
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
@ -482,8 +509,6 @@ describe("discord component interactions", () => {
expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey");
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
});

View File

@ -0,0 +1,121 @@
import { createFeishuClient } from "./client.js";
import type { ResolvedFeishuAccount } from "./types.js";
export type FeishuPermissionError = {
code: number;
message: string;
grantUrl?: string;
};
type SenderNameResult = {
name?: string;
permissionError?: FeishuPermissionError;
};
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
"contact:contact.base:readonly": "contact:user.base:readonly",
};
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
function correctFeishuScopeInUrl(url: string): string {
let corrected = url;
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
corrected = corrected.replaceAll(wrong, right);
}
return corrected;
}
function shouldSuppressPermissionErrorNotice(permissionError: FeishuPermissionError): boolean {
const message = permissionError.message.toLowerCase();
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
}
function extractPermissionError(err: unknown): FeishuPermissionError | null {
if (!err || typeof err !== "object") {
return null;
}
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") {
return null;
}
const feishuErr = data as { code?: number; msg?: string };
if (feishuErr.code !== 99991672) {
return null;
}
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
return {
code: feishuErr.code,
message: msg,
grantUrl: urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined,
};
}
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
const trimmed = senderId.trim();
if (trimmed.startsWith("ou_")) {
return "open_id";
}
if (trimmed.startsWith("on_")) {
return "union_id";
}
return "user_id";
}
export async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderId: string;
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderId, log } = params;
if (!account.configured) {
return {};
}
const normalizedSenderId = senderId.trim();
if (!normalizedSenderId) {
return {};
}
const cached = senderNameCache.get(normalizedSenderId);
const now = Date.now();
if (cached && cached.expireAt > now) {
return { name: cached.name };
}
try {
const client = createFeishuClient(account);
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
const res: any = await client.contact.user.get({
path: { user_id: normalizedSenderId },
params: { user_id_type: userIdType },
});
const name: string | undefined =
res?.data?.user?.name ||
res?.data?.user?.display_name ||
res?.data?.user?.nickname ||
res?.data?.user?.en_name;
if (name && typeof name === "string") {
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
const permErr = extractPermissionError(err);
if (permErr) {
if (shouldSuppressPermissionErrorNotice(permErr)) {
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
return {};
}
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
return {};
}
}

View File

@ -22,6 +22,7 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
} from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
import { createFeishuClient } from "./client.js";
import { buildFeishuConversationId } from "./conversation-id.js";
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
@ -39,150 +40,13 @@ import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { FeishuMessageContext, FeishuMediaInfo } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
code: number;
message: string;
grantUrl?: string;
};
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
// Feishu API sometimes returns incorrect scope names in permission error
// responses (e.g. "contact:contact.base:readonly" instead of the valid
// "contact:user.base:readonly"). This map corrects known mismatches.
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
"contact:contact.base:readonly": "contact:user.base:readonly",
};
function correctFeishuScopeInUrl(url: string): string {
let corrected = url;
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
corrected = corrected.replaceAll(wrong, right);
}
return corrected;
}
function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
const message = permissionError.message.toLowerCase();
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
}
function extractPermissionError(err: unknown): PermissionError | null {
if (!err || typeof err !== "object") return null;
// Axios error structure: err.response.data contains the Feishu error
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") return null;
const feishuErr = data as {
code?: number;
msg?: string;
error?: { permission_violations?: Array<{ uri?: string }> };
};
// Feishu permission error code: 99991672
if (feishuErr.code !== 99991672) return null;
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
return {
code: feishuErr.code,
message: msg,
grantUrl,
};
}
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
// Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map<string, number>();
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
type SenderNameResult = {
name?: string;
permissionError?: PermissionError;
};
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
const trimmed = senderId.trim();
if (trimmed.startsWith("ou_")) {
return "open_id";
}
if (trimmed.startsWith("on_")) {
return "union_id";
}
return "user_id";
}
async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderId: string;
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderId, log } = params;
if (!account.configured) return {};
const normalizedSenderId = senderId.trim();
if (!normalizedSenderId) return {};
const cached = senderNameCache.get(normalizedSenderId);
const now = Date.now();
if (cached && cached.expireAt > now) return { name: cached.name };
try {
const client = createFeishuClient(account);
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
const res: any = await client.contact.user.get({
path: { user_id: normalizedSenderId },
params: { user_id_type: userIdType },
});
const name: string | undefined =
res?.data?.user?.name ||
res?.data?.user?.display_name ||
res?.data?.user?.nickname ||
res?.data?.user?.en_name;
if (name && typeof name === "string") {
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
// Check if this is a permission error
const permErr = extractPermissionError(err);
if (permErr) {
if (shouldSuppressPermissionErrorNotice(permErr)) {
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
return {};
}
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
// Best-effort. Don't fail message handling if name lookup fails.
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
return {};
}
}
export type FeishuMessageEvent = {
sender: {
sender_id: {
@ -848,7 +712,7 @@ export function buildFeishuAgentBody(params: {
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
>;
quotedContent?: string;
permissionErrorForAgent?: PermissionError;
permissionErrorForAgent?: FeishuPermissionError;
botOpenId?: string;
}): string {
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
@ -967,7 +831,7 @@ export async function handleFeishuMessage(params: {
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
let permissionErrorForAgent: PermissionError | undefined;
let permissionErrorForAgent: FeishuPermissionError | undefined;
if (feishuCfg?.resolveSenderNames ?? true) {
const senderResult = await resolveFeishuSenderName({
account,

View File

@ -1,83 +1 @@
export {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "../../src/agents/tools/common.js";
export type { ReplyPayload } from "../../src/auto-reply/types.js";
export {
compileAllowlist,
resolveCompiledAllowlistMatch,
} from "../../src/channels/allowlist-match.js";
export { mergeAllowlist, summarizeMapping } from "../../src/channels/allowlists/resolve-utils.js";
export { resolveControlCommandGate } from "../../src/channels/command-gating.js";
export type { NormalizedLocation } from "../../src/channels/location.js";
export { formatLocationText, toLocationContext } from "../../src/channels/location.js";
export { logInboundDrop, logTypingFailure } from "../../src/channels/logging.js";
export type { AllowlistMatch } from "../../src/channels/plugins/allowlist-match.js";
export { formatAllowlistMatchMeta } from "../../src/channels/plugins/allowlist-match.js";
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
} from "../../src/channels/plugins/channel-config.js";
export { buildChannelConfigSchema } from "../../src/channels/plugins/config-schema.js";
export { createAccountListHelpers } from "../../src/channels/plugins/account-helpers.js";
export type {
BaseProbeResult,
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelOutboundAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelToolSend,
} from "../../src/channels/plugins/types.js";
export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";
export { createReplyPrefixOptions } from "../../src/channels/reply-prefix.js";
export { createTypingCallbacks } from "../../src/channels/typing.js";
export {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "../../src/config/runtime-group-policy.js";
export type {
DmPolicy,
GroupPolicy,
GroupToolPolicyConfig,
MarkdownTableMode,
} from "../../src/config/types.js";
export type { SecretInput } from "../../src/config/types.secrets.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "../../src/config/types.secrets.js";
export { ToolPolicySchema } from "../../src/config/zod-schema.agent-runtime.js";
export { MarkdownConfigSchema } from "../../src/config/zod-schema.core.js";
export { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js";
export { issuePairingChallenge } from "../../src/pairing/pairing-challenge.js";
export type { PluginRuntime, RuntimeLogger } from "../../src/plugins/runtime/types.js";
export { DEFAULT_ACCOUNT_ID } from "../../src/routing/session-key.js";
export type { PollInput } from "../../src/polls.js";
export {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../../src/security/dm-policy-shared.js";
export { normalizeStringEntries } from "../../src/shared/string-normalization.js";
export {
evaluateGroupRouteAccessForPolicy,
resolveSenderScopedGroupPolicy,
} from "../../src/plugin-sdk/group-access.js";
export { createScopedPairingAccess } from "../../src/plugin-sdk/pairing-access.js";
export { runPluginCommandWithTimeout } from "../../src/plugin-sdk/run-command.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "../../src/plugin-sdk/inbound-reply-dispatch.js";
export { resolveRuntimeEnv } from "../../src/plugin-sdk/runtime.js";
export { resolveInboundSessionEnvelopeContext } from "../../src/channels/session-envelope.js";
export {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
} from "../../src/plugin-sdk/status-helpers.js";
export * from "openclaw/plugin-sdk/matrix";

View File

@ -0,0 +1,99 @@
import type { ChatType, OpenClawConfig } from "../runtime-api.js";
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
if (!channelType) {
return "channel";
}
const normalized = channelType.trim().toUpperCase();
if (normalized === "D") {
return "direct";
}
if (normalized === "G" || normalized === "P") {
return "group";
}
return "channel";
}
export type MattermostRequireMentionResolverInput = {
cfg: OpenClawConfig;
channel: "mattermost";
accountId: string;
groupId: string;
requireMentionOverride?: boolean;
};
export type MattermostMentionGateInput = {
kind: ChatType;
cfg: OpenClawConfig;
accountId: string;
channelId: string;
threadRootId?: string;
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
oncharTriggered: boolean;
canDetectMention: boolean;
};
type MattermostMentionGateDecision = {
shouldRequireMention: boolean;
shouldBypassMention: boolean;
effectiveWasMentioned: boolean;
dropReason: "onchar-not-triggered" | "missing-mention" | null;
};
export function evaluateMattermostMentionGate(
params: MattermostMentionGateInput,
): MattermostMentionGateDecision {
const shouldRequireMention =
params.kind !== "direct" &&
params.resolveRequireMention({
cfg: params.cfg,
channel: "mattermost",
accountId: params.accountId,
groupId: params.channelId,
requireMentionOverride: params.requireMentionOverride,
});
const shouldBypassMention =
params.isControlCommand &&
shouldRequireMention &&
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "onchar-not-triggered",
};
}
if (
params.kind !== "direct" &&
shouldRequireMention &&
params.canDetectMention &&
!effectiveWasMentioned
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "missing-mention",
};
}
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: null,
};
}

View File

@ -67,6 +67,10 @@ import {
isMattermostSenderAllowed,
normalizeMattermostAllowList,
} from "./monitor-auth.js";
import {
evaluateMattermostMentionGate,
mapMattermostChannelTypeToChatType,
} from "./monitor-gating.js";
import {
createDedupeCache,
formatInboundFromLabel,
@ -96,6 +100,15 @@ import {
getSlashCommandState,
} from "./slash-state.js";
export {
evaluateMattermostMentionGate,
mapMattermostChannelTypeToChatType,
} from "./monitor-gating.js";
export type {
MattermostMentionGateInput,
MattermostRequireMentionResolverInput,
} from "./monitor-gating.js";
export type MonitorMattermostOpts = {
botToken?: string;
baseUrl?: string;
@ -150,27 +163,6 @@ function isSystemPost(post: MattermostPost): boolean {
return Boolean(type);
}
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
if (!channelType) {
return "channel";
}
// Mattermost channel types: D=direct, G=group DM, O=public channel, P=private channel.
const normalized = channelType.trim().toUpperCase();
if (normalized === "D") {
return "direct";
}
if (normalized === "G") {
return "group";
}
if (normalized === "P") {
// Private channels are invitation-restricted spaces; route as "group" so
// groupPolicy / groupAllowFrom can gate access separately from open public
// channels (type "O"), and the From prefix becomes mattermost:group:<id>.
return "group";
}
return "channel";
}
function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
if (kind === "direct") {
return "direct";
@ -181,90 +173,6 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
return "channel";
}
export type MattermostRequireMentionResolverInput = {
cfg: OpenClawConfig;
channel: "mattermost";
accountId: string;
groupId: string;
requireMentionOverride?: boolean;
};
export type MattermostMentionGateInput = {
kind: ChatType;
cfg: OpenClawConfig;
accountId: string;
channelId: string;
threadRootId?: string;
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
oncharTriggered: boolean;
canDetectMention: boolean;
};
type MattermostMentionGateDecision = {
shouldRequireMention: boolean;
shouldBypassMention: boolean;
effectiveWasMentioned: boolean;
dropReason: "onchar-not-triggered" | "missing-mention" | null;
};
export function evaluateMattermostMentionGate(
params: MattermostMentionGateInput,
): MattermostMentionGateDecision {
const shouldRequireMention =
params.kind !== "direct" &&
params.resolveRequireMention({
cfg: params.cfg,
channel: "mattermost",
accountId: params.accountId,
groupId: params.channelId,
requireMentionOverride: params.requireMentionOverride,
});
const shouldBypassMention =
params.isControlCommand &&
shouldRequireMention &&
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "onchar-not-triggered",
};
}
if (
params.kind !== "direct" &&
shouldRequireMention &&
params.canDetectMention &&
!effectiveWasMentioned
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "missing-mention",
};
}
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: null,
};
}
export function resolveMattermostReplyRootId(params: {
threadRootId?: string;
replyToId?: string;

View File

@ -0,0 +1,40 @@
import type { Message } from "@grammyjs/types";
import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
export const APPROVE_CALLBACK_DATA_RE =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
export function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
}
export function isRecoverableMediaGroupError(err: unknown): boolean {
return err instanceof MediaFetchError || isMediaSizeLimitError(err);
}
export function hasInboundMedia(msg: Message): boolean {
return (
Boolean(msg.media_group_id) ||
(Array.isArray(msg.photo) && msg.photo.length > 0) ||
Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker)
);
}
export function hasReplyTargetMedia(msg: Message): boolean {
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const replyTarget = msg.reply_to_message ?? externalReply;
return Boolean(replyTarget && hasInboundMedia(replyTarget));
}
export function resolveInboundMediaFileId(msg: Message): string | undefined {
return (
msg.sticker?.file_id ??
msg.photo?.[msg.photo.length - 1]?.file_id ??
msg.video?.file_id ??
msg.video_note?.file_id ??
msg.document?.file_id ??
msg.audio?.file_id ??
msg.voice?.file_id
);
}

View File

@ -25,7 +25,6 @@ import {
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
import {
createInboundDebouncer,
@ -48,6 +47,14 @@ import {
normalizeDmAllowFromWithStore,
type NormalizedAllowFrom,
} from "./bot-access.js";
import {
APPROVE_CALLBACK_DATA_RE,
hasInboundMedia,
hasReplyTargetMedia,
isMediaSizeLimitError,
isRecoverableMediaGroupError,
resolveInboundMediaFileId,
} from "./bot-handlers.media.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
@ -92,44 +99,6 @@ import {
import { buildInlineKeyboard } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
const APPROVE_CALLBACK_DATA_RE =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
}
function isRecoverableMediaGroupError(err: unknown): boolean {
return err instanceof MediaFetchError || isMediaSizeLimitError(err);
}
function hasInboundMedia(msg: Message): boolean {
return (
Boolean(msg.media_group_id) ||
(Array.isArray(msg.photo) && msg.photo.length > 0) ||
Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker)
);
}
function hasReplyTargetMedia(msg: Message): boolean {
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const replyTarget = msg.reply_to_message ?? externalReply;
return Boolean(replyTarget && hasInboundMedia(replyTarget));
}
function resolveInboundMediaFileId(msg: Message): string | undefined {
return (
msg.sticker?.file_id ??
msg.photo?.[msg.photo.length - 1]?.file_id ??
msg.video?.file_id ??
msg.video_note?.file_id ??
msg.document?.file_id ??
msg.audio?.file_id ??
msg.voice?.file_id
);
}
export const registerTelegramHandlers = ({
cfg,
accountId,

View File

@ -0,0 +1,30 @@
import type { OpenClawConfig } from "../../api.js";
import type { TlonSettingsStore } from "../settings.js";
type ChannelAuthorization = {
mode?: "restricted" | "open";
allowedShips?: string[];
};
export function resolveChannelAuthorization(
cfg: OpenClawConfig,
channelNest: string,
settings?: TlonSettingsStore,
): { mode: "restricted" | "open"; allowedShips: string[] } {
const tlonConfig = cfg.channels?.tlon as
| {
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
defaultAuthorizedShips?: string[];
}
| undefined;
const fileRules = tlonConfig?.authorization?.channelRules ?? {};
const settingsRules = settings?.channelRules ?? {};
const rule = settingsRules[channelNest] ?? fileRules[channelNest];
const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
return {
mode: rule?.mode ?? "restricted",
allowedShips: rule?.allowedShips ?? defaultShips,
};
}

View File

@ -24,6 +24,7 @@ import {
formatBlockedList,
formatPendingList,
} from "./approval.js";
import { resolveChannelAuthorization } from "./authorization.js";
import { fetchAllChannels, fetchInitData } from "./discovery.js";
import { cacheMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
import { downloadMessageImages } from "./media.js";
@ -46,40 +47,6 @@ export type MonitorTlonOpts = {
accountId?: string | null;
};
type ChannelAuthorization = {
mode?: "restricted" | "open";
allowedShips?: string[];
};
/**
* Resolve channel authorization by merging file config with settings store.
* Settings store takes precedence for fields it defines.
*/
function resolveChannelAuthorization(
cfg: OpenClawConfig,
channelNest: string,
settings?: TlonSettingsStore,
): { mode: "restricted" | "open"; allowedShips: string[] } {
const tlonConfig = cfg.channels?.tlon as
| {
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
defaultAuthorizedShips?: string[];
}
| undefined;
// Merge channel rules: settings override file config
const fileRules = tlonConfig?.authorization?.channelRules ?? {};
const settingsRules = settings?.channelRules ?? {};
const rule = settingsRules[channelNest] ?? fileRules[channelNest];
// Merge default authorized ships: settings override file config
const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
const allowedShips = rule?.allowedShips ?? defaultShips;
const mode = rule?.mode ?? "restricted";
return { mode, allowedShips };
}
export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
const core = getTlonRuntime();
const cfg = core.config.loadConfig() as OpenClawConfig;

View File

@ -34,6 +34,11 @@ function buildPluginInspectJson(params: {
report: PluginStatusReport;
}): {
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>;
compatibilityWarnings: Array<{
code: string;
severity: string;
message: string;
}>;
install: PluginInstallRecord | null;
} | null {
const inspect = buildPluginInspectReport({
@ -60,6 +65,11 @@ function buildAllPluginInspectJson(params: {
report: PluginStatusReport;
}): Array<{
inspect: ReturnType<typeof buildAllPluginInspectReports>[number];
compatibilityWarnings: Array<{
code: string;
severity: string;
message: string;
}>;
install: PluginInstallRecord | null;
}> {
return buildAllPluginInspectReports({

View File

@ -5,6 +5,7 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { buildPluginCompatibilityNotices } from "../plugins/status.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { getAgentLocalStatuses } from "./status.agent-local.js";
@ -185,6 +186,7 @@ export async function scanStatusJsonFast(
: null;
const memoryPlugin = resolveMemoryPluginStatus(cfg);
const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin });
const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg });
return {
cfg,
@ -209,5 +211,6 @@ export async function scanStatusJsonFast(
summary,
memory,
memoryPlugin,
pluginCompatibility,
};
}

View File

@ -1,5 +1,6 @@
import type { Mock } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { PluginCompatibilityNotice } from "../plugins/status.js";
import { captureEnv } from "../test-utils/env.js";
let envSnapshot: ReturnType<typeof captureEnv>;
@ -205,7 +206,7 @@ const mocks = vi.hoisted(() => ({
},
],
}),
buildPluginCompatibilityNotices: vi.fn(() => []),
buildPluginCompatibilityNotices: vi.fn((): PluginCompatibilityNotice[] => []),
}));
vi.mock("../memory/manager.js", () => ({

View File

@ -4,6 +4,7 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
import type { PluginCompatibilityNotice } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter, WizardSelectParams } from "./prompts.js";
import { runSetupWizard } from "./setup.js";
@ -88,7 +89,9 @@ const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: tru
const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {}));
const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {}));
const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const buildPluginCompatibilityNotices = vi.hoisted(() => vi.fn(() => []));
const buildPluginCompatibilityNotices = vi.hoisted(() =>
vi.fn((): PluginCompatibilityNotice[] => []),
);
vi.mock("../commands/onboard-channels.js", () => ({
setupChannels,
@ -456,7 +459,12 @@ describe("runSetupWizard", () => {
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true);
expect(calls.some((call) => String(call?.[0] ?? "").includes("legacy-plugin"))).toBe(true);
expect(
calls.some((call) => {
const body = call?.[0];
return typeof body === "string" && body.includes("legacy-plugin");
}),
).toBe(true);
});
it("resolves gateway.auth.password SecretRef for local setup probe", async () => {

View File

@ -63,7 +63,7 @@
background: transparent;
}
.chat-thread-inner> :first-child {
.chat-thread-inner > :first-child {
margin-top: 0 !important;
}
@ -320,7 +320,7 @@
}
/* Hide the "Message" label - keep textarea only */
.chat-compose__field>span {
.chat-compose__field > span {
display: none;
}
@ -391,7 +391,7 @@
}
}
.agent-chat__input>textarea {
.agent-chat__input > textarea {
width: 100%;
min-height: 40px;
max-height: 150px;
@ -407,7 +407,7 @@
box-sizing: border-box;
}
.agent-chat__input>textarea::placeholder {
.agent-chat__input > textarea::placeholder {
color: var(--muted);
}
@ -549,7 +549,7 @@
scrollbar-width: thin;
}
.slash-menu-group+.slash-menu-group {
.slash-menu-group + .slash-menu-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);

View File

@ -1019,11 +1019,11 @@
position: relative;
}
.cron-filter-dropdown__details>summary {
.cron-filter-dropdown__details > summary {
list-style: none;
}
.cron-filter-dropdown__details>summary::-webkit-details-marker {
.cron-filter-dropdown__details > summary::-webkit-details-marker {
display: none;
}
@ -1645,7 +1645,6 @@
}
@media (max-width: 1100px) {
.table-head,
.table-row {
grid-template-columns: 1fr;
@ -1653,7 +1652,6 @@
}
@container (max-width: 1100px) {
.table-head,
.table-row {
grid-template-columns: 1fr;
@ -2306,7 +2304,6 @@
}
@keyframes chatStreamPulse {
0%,
100% {
border-color: var(--border);
@ -2341,7 +2338,7 @@
height: 12px;
}
.chat-reading-indicator__dots>span {
.chat-reading-indicator__dots > span {
display: inline-block;
width: 6px;
height: 6px;
@ -2353,16 +2350,15 @@
will-change: transform, opacity;
}
.chat-reading-indicator__dots>span:nth-child(2) {
.chat-reading-indicator__dots > span:nth-child(2) {
animation-delay: 0.15s;
}
.chat-reading-indicator__dots>span:nth-child(3) {
.chat-reading-indicator__dots > span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes chatReadingDot {
0%,
80%,
100% {
@ -2377,7 +2373,7 @@
}
@media (prefers-reduced-motion: reduce) {
.chat-reading-indicator__dots>span {
.chat-reading-indicator__dots > span {
animation: none;
opacity: 0.6;
}
@ -3063,7 +3059,7 @@
min-width: 0;
}
.agent-kv>div {
.agent-kv > div {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
@ -3318,7 +3314,7 @@
gap: 8px;
}
.agent-skills-header>span:last-child {
.agent-skills-header > span:last-child {
margin-left: auto;
}

View File

@ -70,7 +70,7 @@
padding-top: 0;
}
.shell--chat-focus .content>*+* {
.shell--chat-focus .content > * + * {
margin-top: 0;
}
@ -688,9 +688,11 @@
.sidebar--collapsed .nav-item.active,
.sidebar--collapsed .nav-item--active {
background: linear-gradient(180deg,
color-mix(in srgb, var(--accent) 14%, var(--bg-elevated) 86%) 0%,
color-mix(in srgb, var(--accent) 8%, var(--bg) 92%) 100%);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 14%, var(--bg-elevated) 86%) 0%,
color-mix(in srgb, var(--accent) 8%, var(--bg) 92%) 100%
);
border-color: color-mix(in srgb, var(--accent) 18%, var(--border) 82%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 8%, transparent),
@ -853,7 +855,7 @@
overflow-x: hidden;
}
.content>*+* {
.content > * + * {
margin-top: 20px;
}
@ -869,7 +871,7 @@
padding-bottom: 0;
}
.content--chat>*+* {
.content--chat > * + * {
margin-top: 0;
}
@ -928,7 +930,7 @@
padding-bottom: 0;
}
.content--chat .content-header>div:first-child {
.content--chat .content-header > div:first-child {
text-align: left;
}