mirror of https://github.com/openclaw/openclaw.git
refactor: split remaining monitor runtime helpers
This commit is contained in:
parent
6556a40330
commit
005b25e9d4
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue