mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
refactor(discord): simplify gateway and component wiring
This commit is contained in:
15
extensions/discord/runtime-api.actions.ts
Normal file
15
extensions/discord/runtime-api.actions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export { handleDiscordAction } from "./src/actions/runtime.js";
|
||||
export {
|
||||
isDiscordModerationAction,
|
||||
readDiscordModerationCommand,
|
||||
requiredGuildPermissionForModerationAction,
|
||||
type DiscordModerationAction,
|
||||
type DiscordModerationCommand,
|
||||
} from "./src/actions/runtime.moderation-shared.js";
|
||||
export {
|
||||
readDiscordChannelCreateParams,
|
||||
readDiscordChannelEditParams,
|
||||
readDiscordChannelMoveParams,
|
||||
readDiscordParentIdParam,
|
||||
} from "./src/actions/runtime.shared.js";
|
||||
export { discordMessageActions } from "./src/channel-actions.js";
|
||||
22
extensions/discord/runtime-api.lookup.ts
Normal file
22
extensions/discord/runtime-api.lookup.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./src/audit.js";
|
||||
export {
|
||||
listDiscordDirectoryGroupsLive,
|
||||
listDiscordDirectoryPeersLive,
|
||||
} from "./src/directory-live.js";
|
||||
export {
|
||||
fetchDiscordApplicationId,
|
||||
fetchDiscordApplicationSummary,
|
||||
parseApplicationIdFromToken,
|
||||
probeDiscord,
|
||||
resolveDiscordPrivilegedIntentsFromFlags,
|
||||
type DiscordApplicationSummary,
|
||||
type DiscordPrivilegedIntentsSummary,
|
||||
type DiscordPrivilegedIntentStatus,
|
||||
type DiscordProbe,
|
||||
} from "./src/probe.js";
|
||||
export {
|
||||
resolveDiscordChannelAllowlist,
|
||||
type DiscordChannelResolution,
|
||||
} from "./src/resolve-channels.js";
|
||||
export { resolveDiscordUserAllowlist, type DiscordUserResolution } from "./src/resolve-users.js";
|
||||
export { setDiscordRuntime } from "./src/runtime.js";
|
||||
50
extensions/discord/runtime-api.monitor.ts
Normal file
50
extensions/discord/runtime-api.monitor.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export {
|
||||
allowListMatches,
|
||||
buildDiscordMediaPayload,
|
||||
createDiscordMessageHandler,
|
||||
createDiscordNativeCommand,
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
monitorDiscordProvider,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
registerDiscordListener,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordCommandAuthorized,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordReplyTarget,
|
||||
resolveDiscordShouldRequireMention,
|
||||
resolveGroupDmAllow,
|
||||
sanitizeDiscordThreadName,
|
||||
shouldEmitDiscordReactionNotification,
|
||||
type DiscordAllowList,
|
||||
type DiscordChannelConfigResolved,
|
||||
type DiscordGuildEntryResolved,
|
||||
type DiscordMessageEvent,
|
||||
type DiscordMessageHandler,
|
||||
type MonitorDiscordOpts,
|
||||
} from "./src/monitor.js";
|
||||
export {
|
||||
createDiscordGatewayPlugin,
|
||||
resolveDiscordGatewayIntents,
|
||||
waitForDiscordGatewayPluginRegistration,
|
||||
} from "./src/monitor/gateway-plugin.js";
|
||||
export {
|
||||
clearGateways,
|
||||
getGateway,
|
||||
registerGateway,
|
||||
unregisterGateway,
|
||||
} from "./src/monitor/gateway-registry.js";
|
||||
export {
|
||||
clearPresences,
|
||||
getPresence,
|
||||
presenceCacheSize,
|
||||
setPresence,
|
||||
} from "./src/monitor/presence-cache.js";
|
||||
export {
|
||||
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
|
||||
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
|
||||
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
|
||||
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
|
||||
mergeAbortSignals,
|
||||
} from "./src/monitor/timeouts.js";
|
||||
79
extensions/discord/runtime-api.send.ts
Normal file
79
extensions/discord/runtime-api.send.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export {
|
||||
resolveDiscordOutboundSessionRoute,
|
||||
type ResolveDiscordOutboundSessionRouteParams,
|
||||
} from "./src/outbound-session-route.js";
|
||||
export {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createChannelDiscord,
|
||||
createScheduledEventDiscord,
|
||||
createThreadDiscord,
|
||||
deleteChannelDiscord,
|
||||
deleteMessageDiscord,
|
||||
DiscordSendError,
|
||||
editChannelDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchMemberGuildPermissionsDiscord,
|
||||
fetchMemberInfoDiscord,
|
||||
fetchMessageDiscord,
|
||||
fetchReactionsDiscord,
|
||||
fetchRoleInfoDiscord,
|
||||
fetchVoiceStatusDiscord,
|
||||
hasAllGuildPermissionsDiscord,
|
||||
hasAnyGuildPermissionDiscord,
|
||||
kickMemberDiscord,
|
||||
listGuildChannelsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listPinsDiscord,
|
||||
listScheduledEventsDiscord,
|
||||
listThreadsDiscord,
|
||||
moveChannelDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeChannelPermissionDiscord,
|
||||
removeOwnReactionsDiscord,
|
||||
removeReactionDiscord,
|
||||
removeRoleDiscord,
|
||||
resolveEventCoverImage,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
sendTypingDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
sendWebhookMessageDiscord,
|
||||
setChannelPermissionDiscord,
|
||||
timeoutMemberDiscord,
|
||||
unpinMessageDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
type DiscordChannelCreate,
|
||||
type DiscordChannelEdit,
|
||||
type DiscordChannelMove,
|
||||
type DiscordChannelPermissionSet,
|
||||
type DiscordEmojiUpload,
|
||||
type DiscordMessageEdit,
|
||||
type DiscordMessageQuery,
|
||||
type DiscordModerationTarget,
|
||||
type DiscordPermissionsSummary,
|
||||
type DiscordReactionRuntimeContext,
|
||||
type DiscordReactionSummary,
|
||||
type DiscordReactionUser,
|
||||
type DiscordReactOpts,
|
||||
type DiscordRoleChange,
|
||||
type DiscordRuntimeAccountContext,
|
||||
type DiscordSearchQuery,
|
||||
type DiscordSendResult,
|
||||
type DiscordStickerUpload,
|
||||
type DiscordThreadCreate,
|
||||
type DiscordThreadList,
|
||||
type DiscordTimeoutTarget,
|
||||
} from "./src/send.js";
|
||||
export {
|
||||
editDiscordComponentMessage,
|
||||
registerBuiltDiscordComponentMessage,
|
||||
sendDiscordComponentMessage,
|
||||
} from "./src/send.components.js";
|
||||
30
extensions/discord/runtime-api.threads.ts
Normal file
30
extensions/discord/runtime-api.threads.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export {
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
formatThreadBindingDurationLabel,
|
||||
getThreadBindingManager,
|
||||
isRecentlyUnboundThreadWebhookMessage,
|
||||
listThreadBindingsBySessionKey,
|
||||
listThreadBindingsForAccount,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
resolveDiscordThreadBindingIdleTimeoutMs,
|
||||
resolveDiscordThreadBindingMaxAgeMs,
|
||||
resolveThreadBindingIdleTimeoutMs,
|
||||
resolveThreadBindingInactivityExpiresAt,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingMaxAgeExpiresAt,
|
||||
resolveThreadBindingMaxAgeMs,
|
||||
resolveThreadBindingPersona,
|
||||
resolveThreadBindingPersonaFromRecord,
|
||||
resolveThreadBindingsEnabled,
|
||||
resolveThreadBindingThreadName,
|
||||
setThreadBindingIdleTimeoutBySessionKey,
|
||||
setThreadBindingMaxAgeBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
type AcpThreadBindingReconciliationResult,
|
||||
type ThreadBindingManager,
|
||||
type ThreadBindingRecord,
|
||||
type ThreadBindingTargetKind,
|
||||
} from "./src/monitor/thread-bindings.js";
|
||||
@@ -1,124 +1,80 @@
|
||||
export { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./src/audit.js";
|
||||
export { handleDiscordAction } from "./src/actions/runtime.js";
|
||||
export {
|
||||
discordMessageActions,
|
||||
handleDiscordAction,
|
||||
isDiscordModerationAction,
|
||||
readDiscordModerationCommand,
|
||||
requiredGuildPermissionForModerationAction,
|
||||
type DiscordModerationAction,
|
||||
type DiscordModerationCommand,
|
||||
} from "./src/actions/runtime.moderation-shared.js";
|
||||
export {
|
||||
readDiscordChannelCreateParams,
|
||||
readDiscordChannelEditParams,
|
||||
readDiscordChannelMoveParams,
|
||||
readDiscordModerationCommand,
|
||||
readDiscordParentIdParam,
|
||||
} from "./src/actions/runtime.shared.js";
|
||||
export { discordMessageActions } from "./src/channel-actions.js";
|
||||
requiredGuildPermissionForModerationAction,
|
||||
type DiscordModerationAction,
|
||||
type DiscordModerationCommand,
|
||||
} from "./runtime-api.actions.js";
|
||||
export {
|
||||
auditDiscordChannelPermissions,
|
||||
collectDiscordAuditChannelIds,
|
||||
fetchDiscordApplicationId,
|
||||
fetchDiscordApplicationSummary,
|
||||
listDiscordDirectoryGroupsLive,
|
||||
listDiscordDirectoryPeersLive,
|
||||
} from "./src/directory-live.js";
|
||||
parseApplicationIdFromToken,
|
||||
probeDiscord,
|
||||
resolveDiscordChannelAllowlist,
|
||||
resolveDiscordPrivilegedIntentsFromFlags,
|
||||
resolveDiscordUserAllowlist,
|
||||
setDiscordRuntime,
|
||||
type DiscordApplicationSummary,
|
||||
type DiscordChannelResolution,
|
||||
type DiscordPrivilegedIntentsSummary,
|
||||
type DiscordPrivilegedIntentStatus,
|
||||
type DiscordProbe,
|
||||
type DiscordUserResolution,
|
||||
} from "./runtime-api.lookup.js";
|
||||
export {
|
||||
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
|
||||
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
|
||||
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
|
||||
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
|
||||
allowListMatches,
|
||||
buildDiscordMediaPayload,
|
||||
clearGateways,
|
||||
clearPresences,
|
||||
createDiscordGatewayPlugin,
|
||||
createDiscordMessageHandler,
|
||||
createDiscordNativeCommand,
|
||||
getGateway,
|
||||
getPresence,
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
mergeAbortSignals,
|
||||
monitorDiscordProvider,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
presenceCacheSize,
|
||||
registerDiscordListener,
|
||||
registerGateway,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordCommandAuthorized,
|
||||
resolveDiscordGatewayIntents,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordReplyTarget,
|
||||
resolveDiscordShouldRequireMention,
|
||||
resolveGroupDmAllow,
|
||||
sanitizeDiscordThreadName,
|
||||
setPresence,
|
||||
shouldEmitDiscordReactionNotification,
|
||||
unregisterGateway,
|
||||
waitForDiscordGatewayPluginRegistration,
|
||||
type DiscordAllowList,
|
||||
type DiscordChannelConfigResolved,
|
||||
type DiscordGuildEntryResolved,
|
||||
type DiscordMessageEvent,
|
||||
type DiscordMessageHandler,
|
||||
type MonitorDiscordOpts,
|
||||
} from "./src/monitor.js";
|
||||
export {
|
||||
createDiscordGatewayPlugin,
|
||||
resolveDiscordGatewayIntents,
|
||||
waitForDiscordGatewayPluginRegistration,
|
||||
} from "./src/monitor/gateway-plugin.js";
|
||||
export {
|
||||
clearGateways,
|
||||
getGateway,
|
||||
registerGateway,
|
||||
unregisterGateway,
|
||||
} from "./src/monitor/gateway-registry.js";
|
||||
export {
|
||||
clearPresences,
|
||||
getPresence,
|
||||
presenceCacheSize,
|
||||
setPresence,
|
||||
} from "./src/monitor/presence-cache.js";
|
||||
export {
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
formatThreadBindingDurationLabel,
|
||||
getThreadBindingManager,
|
||||
isRecentlyUnboundThreadWebhookMessage,
|
||||
listThreadBindingsBySessionKey,
|
||||
listThreadBindingsForAccount,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
resolveDiscordThreadBindingIdleTimeoutMs,
|
||||
resolveDiscordThreadBindingMaxAgeMs,
|
||||
resolveThreadBindingIdleTimeoutMs,
|
||||
resolveThreadBindingInactivityExpiresAt,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingMaxAgeExpiresAt,
|
||||
resolveThreadBindingMaxAgeMs,
|
||||
resolveThreadBindingPersona,
|
||||
resolveThreadBindingPersonaFromRecord,
|
||||
resolveThreadBindingsEnabled,
|
||||
resolveThreadBindingThreadName,
|
||||
setThreadBindingIdleTimeoutBySessionKey,
|
||||
setThreadBindingMaxAgeBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
type AcpThreadBindingReconciliationResult,
|
||||
type ThreadBindingManager,
|
||||
type ThreadBindingRecord,
|
||||
type ThreadBindingTargetKind,
|
||||
} from "./src/monitor/thread-bindings.js";
|
||||
export {
|
||||
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
|
||||
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
|
||||
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
|
||||
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
|
||||
mergeAbortSignals,
|
||||
} from "./src/monitor/timeouts.js";
|
||||
export {
|
||||
fetchDiscordApplicationId,
|
||||
fetchDiscordApplicationSummary,
|
||||
parseApplicationIdFromToken,
|
||||
probeDiscord,
|
||||
resolveDiscordPrivilegedIntentsFromFlags,
|
||||
type DiscordApplicationSummary,
|
||||
type DiscordPrivilegedIntentsSummary,
|
||||
type DiscordPrivilegedIntentStatus,
|
||||
type DiscordProbe,
|
||||
} from "./src/probe.js";
|
||||
export {
|
||||
resolveDiscordChannelAllowlist,
|
||||
type DiscordChannelResolution,
|
||||
} from "./src/resolve-channels.js";
|
||||
export { resolveDiscordUserAllowlist, type DiscordUserResolution } from "./src/resolve-users.js";
|
||||
export {
|
||||
resolveDiscordOutboundSessionRoute,
|
||||
type ResolveDiscordOutboundSessionRouteParams,
|
||||
} from "./src/outbound-session-route.js";
|
||||
} from "./runtime-api.monitor.js";
|
||||
export {
|
||||
DiscordSendError,
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createChannelDiscord,
|
||||
@@ -126,8 +82,8 @@ export {
|
||||
createThreadDiscord,
|
||||
deleteChannelDiscord,
|
||||
deleteMessageDiscord,
|
||||
DiscordSendError,
|
||||
editChannelDiscord,
|
||||
editDiscordComponentMessage,
|
||||
editMessageDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
@@ -149,12 +105,15 @@ export {
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
registerBuiltDiscordComponentMessage,
|
||||
removeChannelPermissionDiscord,
|
||||
removeOwnReactionsDiscord,
|
||||
removeReactionDiscord,
|
||||
removeRoleDiscord,
|
||||
resolveDiscordOutboundSessionRoute,
|
||||
resolveEventCoverImage,
|
||||
searchMessagesDiscord,
|
||||
sendDiscordComponentMessage,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
@@ -187,10 +146,35 @@ export {
|
||||
type DiscordThreadCreate,
|
||||
type DiscordThreadList,
|
||||
type DiscordTimeoutTarget,
|
||||
} from "./src/send.js";
|
||||
type ResolveDiscordOutboundSessionRouteParams,
|
||||
} from "./runtime-api.send.js";
|
||||
export {
|
||||
editDiscordComponentMessage,
|
||||
registerBuiltDiscordComponentMessage,
|
||||
sendDiscordComponentMessage,
|
||||
} from "./src/send.components.js";
|
||||
export { setDiscordRuntime } from "./src/runtime.js";
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
formatThreadBindingDurationLabel,
|
||||
getThreadBindingManager,
|
||||
isRecentlyUnboundThreadWebhookMessage,
|
||||
listThreadBindingsBySessionKey,
|
||||
listThreadBindingsForAccount,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
resolveDiscordThreadBindingIdleTimeoutMs,
|
||||
resolveDiscordThreadBindingMaxAgeMs,
|
||||
resolveThreadBindingIdleTimeoutMs,
|
||||
resolveThreadBindingInactivityExpiresAt,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingMaxAgeExpiresAt,
|
||||
resolveThreadBindingMaxAgeMs,
|
||||
resolveThreadBindingPersona,
|
||||
resolveThreadBindingPersonaFromRecord,
|
||||
resolveThreadBindingsEnabled,
|
||||
resolveThreadBindingThreadName,
|
||||
setThreadBindingIdleTimeoutBySessionKey,
|
||||
setThreadBindingMaxAgeBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
type AcpThreadBindingReconciliationResult,
|
||||
type ThreadBindingManager,
|
||||
type ThreadBindingRecord,
|
||||
type ThreadBindingTargetKind,
|
||||
} from "./runtime-api.threads.js";
|
||||
|
||||
@@ -411,6 +411,22 @@ describe("GatewayPlugin", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("clears stale heartbeat timers before early reconnect exits", () => {
|
||||
vi.useFakeTimers();
|
||||
const gateway = new GatewayPlugin({
|
||||
autoInteractions: false,
|
||||
url: "wss://gateway.example.test",
|
||||
});
|
||||
(gateway as unknown as { isConnecting: boolean }).isConnecting = true;
|
||||
gateway.heartbeatInterval = setInterval(() => {}, 1_000);
|
||||
gateway.firstHeartbeatTimeout = setTimeout(() => {}, 1_000);
|
||||
|
||||
gateway.connect(true);
|
||||
|
||||
expect(gateway.heartbeatInterval).toBeUndefined();
|
||||
expect(gateway.firstHeartbeatTimeout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("spaces identify sends by gateway max concurrency bucket", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
|
||||
@@ -158,11 +158,11 @@ export class GatewayPlugin extends Plugin {
|
||||
}
|
||||
|
||||
connect(resume = false): void {
|
||||
this.stopReconnectTimer();
|
||||
this.stopHeartbeat();
|
||||
if (this.isConnecting) {
|
||||
return;
|
||||
}
|
||||
this.stopReconnectTimer();
|
||||
this.stopHeartbeat();
|
||||
this.shouldReconnect = true;
|
||||
this.lastHeartbeatAck = true;
|
||||
this.ws?.close(1000, "Reconnecting");
|
||||
|
||||
@@ -386,6 +386,85 @@ export async function resolveInteractionContextWithDmAuth(params: {
|
||||
return interactionCtx;
|
||||
}
|
||||
|
||||
export async function resolveAuthorizedComponentInteraction(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
label: string;
|
||||
componentLabel: string;
|
||||
unauthorizedReply: string;
|
||||
defer?: boolean;
|
||||
}) {
|
||||
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
label: params.label,
|
||||
componentLabel: params.componentLabel,
|
||||
defer: params.defer,
|
||||
});
|
||||
if (!interactionCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: params.interaction.guild ?? undefined,
|
||||
guildId: rawGuildId,
|
||||
guildEntries: params.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig);
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName: channelCtx.channelName,
|
||||
channelSlug: channelCtx.channelSlug,
|
||||
parentId: channelCtx.parentId,
|
||||
parentName: channelCtx.parentName,
|
||||
parentSlug: channelCtx.parentSlug,
|
||||
scope: channelCtx.isThread ? "thread" : "channel",
|
||||
});
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction: params.interaction,
|
||||
guildInfo,
|
||||
channelId,
|
||||
rawGuildId,
|
||||
channelCtx,
|
||||
memberRoleIds,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply: params.unauthorizedReply,
|
||||
allowNameMatching,
|
||||
groupPolicy: resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.ctx.cfg.channels?.discord !== undefined,
|
||||
groupPolicy: params.ctx.discordConfig?.groupPolicy,
|
||||
defaultGroupPolicy: params.ctx.cfg.channels?.defaults?.groupPolicy,
|
||||
}).groupPolicy,
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
ctx: params.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
return {
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
guildInfo,
|
||||
channelConfig,
|
||||
allowNameMatching,
|
||||
commandAuthorized,
|
||||
user,
|
||||
replyOpts,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveComponentCommandAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
ensureAgentComponentInteractionAllowed,
|
||||
ensureComponentUserAllowed,
|
||||
ensureGuildComponentMemberAllowed,
|
||||
resolveAuthorizedComponentInteraction,
|
||||
resolveComponentCommandAuthorized,
|
||||
resolveInteractionContextWithDmAuth,
|
||||
} from "./agent-components-auth.js";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import { logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
|
||||
import type { ButtonInteraction, ComponentData } from "../internal/discord.js";
|
||||
@@ -6,20 +5,14 @@ import {
|
||||
type AgentComponentContext,
|
||||
type AgentComponentMessageInteraction,
|
||||
ensureComponentUserAllowed,
|
||||
ensureGuildComponentMemberAllowed,
|
||||
mapSelectValues,
|
||||
parseDiscordComponentData,
|
||||
resolveComponentCommandAuthorized,
|
||||
resolveDiscordChannelContext,
|
||||
resolveInteractionContextWithDmAuth,
|
||||
resolveAuthorizedComponentInteraction,
|
||||
resolveInteractionCustomId,
|
||||
type ComponentInteractionContext,
|
||||
} from "./agent-components-helpers.js";
|
||||
import { dispatchDiscordComponentEvent } from "./agent-components.dispatch.js";
|
||||
import { dispatchPluginDiscordInteractiveEvent } from "./agent-components.plugin-interactive.js";
|
||||
import { resolveComponentGroupPolicy } from "./agent-components.policy.js";
|
||||
import type { DiscordComponentControlHandlers } from "./agent-components.wildcard-controls.js";
|
||||
import { resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry } from "./allow-list.js";
|
||||
|
||||
let componentsRuntimePromise: Promise<typeof import("../components.js")> | undefined;
|
||||
|
||||
@@ -66,52 +59,27 @@ async function handleDiscordComponentEvent(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
||||
const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`;
|
||||
const authorized = await resolveAuthorizedComponentInteraction({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
label: params.label,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply,
|
||||
defer: false,
|
||||
});
|
||||
if (!interactionCtx) {
|
||||
if (!authorized) {
|
||||
return;
|
||||
}
|
||||
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: params.interaction.guild ?? undefined,
|
||||
guildId: rawGuildId,
|
||||
guildEntries: params.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig);
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName: channelCtx.channelName,
|
||||
channelSlug: channelCtx.channelSlug,
|
||||
parentId: channelCtx.parentId,
|
||||
parentName: channelCtx.parentName,
|
||||
parentSlug: channelCtx.parentSlug,
|
||||
scope: channelCtx.isThread ? "thread" : "channel",
|
||||
});
|
||||
const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`;
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction: params.interaction,
|
||||
guildInfo,
|
||||
channelId,
|
||||
rawGuildId,
|
||||
const {
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
memberRoleIds,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
commandAuthorized,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply,
|
||||
allowNameMatching,
|
||||
groupPolicy: resolveComponentGroupPolicy(params.ctx),
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return;
|
||||
}
|
||||
} = authorized;
|
||||
|
||||
const componentAllowed = await ensureComponentUserAllowed({
|
||||
entry,
|
||||
@@ -125,14 +93,6 @@ async function handleDiscordComponentEvent(params: {
|
||||
if (!componentAllowed) {
|
||||
return;
|
||||
}
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
ctx: params.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const consumed = resolveDiscordComponentEntry({
|
||||
id: parsed.componentId,
|
||||
consume: !entry.reusable,
|
||||
@@ -216,7 +176,6 @@ async function handleDiscordModalTrigger(params: {
|
||||
interaction: ButtonInteraction;
|
||||
data: ComponentData;
|
||||
label: string;
|
||||
interactionCtx?: ComponentInteractionContext;
|
||||
}): Promise<void> {
|
||||
const parsed = parseDiscordComponentData(
|
||||
params.data,
|
||||
@@ -260,43 +219,19 @@ async function handleDiscordModalTrigger(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionCtx =
|
||||
params.interactionCtx ??
|
||||
(await resolveInteractionContextWithDmAuth({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
label: params.label,
|
||||
componentLabel: "form",
|
||||
defer: false,
|
||||
}));
|
||||
if (!interactionCtx) {
|
||||
return;
|
||||
}
|
||||
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: params.interaction.guild ?? undefined,
|
||||
guildId: rawGuildId,
|
||||
guildEntries: params.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
||||
const unauthorizedReply = "You are not authorized to use this form.";
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
const authorized = await resolveAuthorizedComponentInteraction({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
guildInfo,
|
||||
channelId,
|
||||
rawGuildId,
|
||||
channelCtx,
|
||||
memberRoleIds,
|
||||
user,
|
||||
replyOpts,
|
||||
label: params.label,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
groupPolicy: resolveComponentGroupPolicy(params.ctx),
|
||||
defer: false,
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
if (!authorized) {
|
||||
return;
|
||||
}
|
||||
const { user, replyOpts, allowNameMatching } = authorized;
|
||||
|
||||
const componentAllowed = await ensureComponentUserAllowed({
|
||||
entry,
|
||||
@@ -305,7 +240,7 @@ async function handleDiscordModalTrigger(params: {
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!componentAllowed) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import { logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { parseDiscordModalCustomIdForInteraction } from "../component-custom-id.js";
|
||||
import { resolveDiscordModalEntry } from "../components-registry.js";
|
||||
@@ -6,19 +5,14 @@ import { Modal, type ComponentData, type ModalInteraction } from "../internal/di
|
||||
import {
|
||||
type AgentComponentContext,
|
||||
ensureComponentUserAllowed,
|
||||
ensureGuildComponentMemberAllowed,
|
||||
formatModalSubmissionText,
|
||||
parseDiscordModalId,
|
||||
resolveComponentCommandAuthorized,
|
||||
resolveDiscordChannelContext,
|
||||
resolveInteractionContextWithDmAuth,
|
||||
resolveAuthorizedComponentInteraction,
|
||||
resolveInteractionCustomId,
|
||||
resolveModalFieldValues,
|
||||
} from "./agent-components-helpers.js";
|
||||
import { dispatchDiscordComponentEvent } from "./agent-components.dispatch.js";
|
||||
import { dispatchPluginDiscordInteractiveEvent } from "./agent-components.plugin-interactive.js";
|
||||
import { resolveComponentGroupPolicy } from "./agent-components.policy.js";
|
||||
import { resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry } from "./allow-list.js";
|
||||
|
||||
export class DiscordComponentModal extends Modal {
|
||||
title = "OpenClaw form";
|
||||
@@ -60,51 +54,27 @@ export class DiscordComponentModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
||||
const unauthorizedReply = "You are not authorized to use this form.";
|
||||
const authorized = await resolveAuthorizedComponentInteraction({
|
||||
ctx: this.ctx,
|
||||
interaction,
|
||||
label: "discord component modal",
|
||||
componentLabel: "form",
|
||||
unauthorizedReply,
|
||||
defer: false,
|
||||
});
|
||||
if (!interactionCtx) {
|
||||
if (!authorized) {
|
||||
return;
|
||||
}
|
||||
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: interaction.guild ?? undefined,
|
||||
guildId: rawGuildId,
|
||||
guildEntries: this.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(interaction);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig);
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName: channelCtx.channelName,
|
||||
channelSlug: channelCtx.channelSlug,
|
||||
parentId: channelCtx.parentId,
|
||||
parentName: channelCtx.parentName,
|
||||
parentSlug: channelCtx.parentSlug,
|
||||
scope: channelCtx.isThread ? "thread" : "channel",
|
||||
});
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction,
|
||||
guildInfo,
|
||||
channelId,
|
||||
rawGuildId,
|
||||
const {
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
memberRoleIds,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
commandAuthorized,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply: "You are not authorized to use this form.",
|
||||
allowNameMatching,
|
||||
groupPolicy: resolveComponentGroupPolicy(this.ctx),
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return;
|
||||
}
|
||||
} = authorized;
|
||||
|
||||
const modalAllowed = await ensureComponentUserAllowed({
|
||||
entry: {
|
||||
@@ -117,19 +87,12 @@ export class DiscordComponentModal extends Modal {
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply: "You are not authorized to use this form.",
|
||||
unauthorizedReply,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!modalAllowed) {
|
||||
return;
|
||||
}
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
ctx: this.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const consumed = resolveDiscordModalEntry({
|
||||
id: modalId,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Modal, type BaseMessageInteractiveComponent } from "../internal/discord
|
||||
import type { AgentComponentContext } from "./agent-components-helpers.js";
|
||||
import { discordComponentControlHandlers } from "./agent-components.handlers.js";
|
||||
import { DiscordComponentModal } from "./agent-components.modal.js";
|
||||
import {
|
||||
createAgentComponentButton,
|
||||
createAgentSelectMenu,
|
||||
} from "./agent-components.system-controls.js";
|
||||
import {
|
||||
createDiscordComponentButtonControl,
|
||||
createDiscordComponentChannelSelectControl,
|
||||
@@ -20,6 +24,8 @@ export {
|
||||
createAgentSelectMenu,
|
||||
} from "./agent-components.system-controls.js";
|
||||
|
||||
type ComponentFactory = (ctx: AgentComponentContext) => BaseMessageInteractiveComponent;
|
||||
|
||||
function bindDiscordComponentControl<T extends BaseMessageInteractiveComponent>(
|
||||
createControl: (ctx: AgentComponentContext, handlers: DiscordComponentControlHandlers) => T,
|
||||
) {
|
||||
@@ -45,6 +51,20 @@ export const createDiscordComponentChannelSelect = bindDiscordComponentControl(
|
||||
createDiscordComponentChannelSelectControl,
|
||||
);
|
||||
|
||||
export const createAgentComponentControls = [
|
||||
createAgentComponentButton,
|
||||
createAgentSelectMenu,
|
||||
] satisfies readonly ComponentFactory[];
|
||||
|
||||
export const createDiscordComponentControls = [
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentStringSelect,
|
||||
createDiscordComponentUserSelect,
|
||||
createDiscordComponentRoleSelect,
|
||||
createDiscordComponentMentionableSelect,
|
||||
createDiscordComponentChannelSelect,
|
||||
] satisfies readonly ComponentFactory[];
|
||||
|
||||
export function createDiscordComponentModal(ctx: AgentComponentContext): Modal {
|
||||
return new DiscordComponentModal(ctx);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
} from "../internal/discord.js";
|
||||
import {
|
||||
parseDiscordComponentData,
|
||||
resolveInteractionContextWithDmAuth,
|
||||
resolveInteractionCustomId,
|
||||
type AgentComponentContext,
|
||||
type AgentComponentMessageInteraction,
|
||||
type ComponentInteractionContext,
|
||||
} from "./agent-components-helpers.js";
|
||||
|
||||
export type DiscordComponentControlHandlers = {
|
||||
@@ -29,7 +27,6 @@ export type DiscordComponentControlHandlers = {
|
||||
interaction: ButtonInteraction;
|
||||
data: ComponentData;
|
||||
label: string;
|
||||
interactionCtx?: ComponentInteractionContext;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -122,22 +119,11 @@ class DiscordComponentButton extends Button {
|
||||
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
||||
const parsed = parseDiscordComponentData(data, resolveInteractionCustomId(interaction));
|
||||
if (parsed?.modalId) {
|
||||
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
||||
ctx: this.ctx,
|
||||
interaction,
|
||||
label: "discord component button",
|
||||
componentLabel: "form",
|
||||
defer: false,
|
||||
});
|
||||
if (!interactionCtx) {
|
||||
return;
|
||||
}
|
||||
await this.handlers.handleModalTrigger({
|
||||
ctx: this.ctx,
|
||||
interaction,
|
||||
data,
|
||||
label: "discord component modal",
|
||||
interactionCtx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,23 +221,6 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears stale heartbeat timers before reconnecting", () => {
|
||||
const plugin = createPlugin() as unknown as {
|
||||
connect: (resume?: boolean) => void;
|
||||
isConnecting: boolean;
|
||||
heartbeatInterval?: NodeJS.Timeout;
|
||||
firstHeartbeatTimeout?: NodeJS.Timeout;
|
||||
};
|
||||
plugin.isConnecting = true;
|
||||
plugin.heartbeatInterval = setInterval(() => {}, 1_000);
|
||||
plugin.firstHeartbeatTimeout = setTimeout(() => {}, 1_000);
|
||||
|
||||
plugin.connect(true);
|
||||
|
||||
expect(plugin.heartbeatInterval).toBeUndefined();
|
||||
expect(plugin.firstHeartbeatTimeout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits transport activity for current gateway socket messages", () => {
|
||||
const socket = new EventEmitter() as EventEmitter & { binaryType?: string };
|
||||
const plugin = createPlugin({
|
||||
|
||||
@@ -110,20 +110,6 @@ function createGatewayPlugin(params: {
|
||||
super(params.options);
|
||||
}
|
||||
|
||||
public override connect(resume = false): void {
|
||||
// Base connect returns early while isConnecting; clear stale gateway
|
||||
// timers first so early reconnect races cannot keep old heartbeats alive.
|
||||
if (this.heartbeatInterval !== undefined) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = undefined;
|
||||
}
|
||||
if (this.firstHeartbeatTimeout !== undefined) {
|
||||
clearTimeout(this.firstHeartbeatTimeout);
|
||||
this.firstHeartbeatTimeout = undefined;
|
||||
}
|
||||
super.connect(resume);
|
||||
}
|
||||
|
||||
override registerClient(client: DiscordGatewayClient) {
|
||||
const registration = this.registerClientInternal(client);
|
||||
// Client construction starts plugin hooks without awaiting them. Mark the
|
||||
|
||||
@@ -12,15 +12,9 @@ import {
|
||||
} from "../internal/discord.js";
|
||||
import { createDiscordVoiceCommand } from "../voice/command.js";
|
||||
import {
|
||||
createAgentComponentButton,
|
||||
createAgentSelectMenu,
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentChannelSelect,
|
||||
createDiscordComponentMentionableSelect,
|
||||
createAgentComponentControls,
|
||||
createDiscordComponentControls,
|
||||
createDiscordComponentModal,
|
||||
createDiscordComponentRoleSelect,
|
||||
createDiscordComponentStringSelect,
|
||||
createDiscordComponentUserSelect,
|
||||
} from "./agent-components.js";
|
||||
import {
|
||||
createDiscordExecApprovalButtonContext,
|
||||
@@ -157,14 +151,8 @@ export function createDiscordProviderInteractionSurface(params: {
|
||||
runtime: params.runtime,
|
||||
token: params.token,
|
||||
};
|
||||
components.push(createAgentComponentButton(componentContext));
|
||||
components.push(createAgentSelectMenu(componentContext));
|
||||
components.push(createDiscordComponentButton(componentContext));
|
||||
components.push(createDiscordComponentStringSelect(componentContext));
|
||||
components.push(createDiscordComponentUserSelect(componentContext));
|
||||
components.push(createDiscordComponentRoleSelect(componentContext));
|
||||
components.push(createDiscordComponentMentionableSelect(componentContext));
|
||||
components.push(createDiscordComponentChannelSelect(componentContext));
|
||||
components.push(...createAgentComponentControls.map((create) => create(componentContext)));
|
||||
components.push(...createDiscordComponentControls.map((create) => create(componentContext)));
|
||||
modals.push(createDiscordComponentModal(componentContext));
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ vi.mock("./runtime-config-snapshot.js", () => ({
|
||||
getRuntimeConfigSnapshot: () => mocks.runtimeConfig,
|
||||
}));
|
||||
|
||||
describe("discord plugin-sdk compatibility facade", () => {
|
||||
describe("discord plugin-sdk facade", () => {
|
||||
it("exports the @openclaw/discord 2026.3.13 import surface", async () => {
|
||||
const discordSdk = await import("./discord.js");
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("discord plugin-sdk compatibility facade", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards Discord component helpers through the compatibility facade", async () => {
|
||||
it("forwards Discord component helpers through the facade", async () => {
|
||||
const {
|
||||
buildDiscordComponentMessage,
|
||||
editDiscordComponentMessage,
|
||||
@@ -151,7 +151,7 @@ describe("discord plugin-sdk compatibility facade", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy Discord subagent auto-bind calls working without cfg", async () => {
|
||||
it("fills runtime config for Discord subagent auto-bind calls without cfg", async () => {
|
||||
const { autoBindSpawnedDiscordSubagent } = await import("./discord.js");
|
||||
|
||||
const binding = await autoBindSpawnedDiscordSubagent({
|
||||
|
||||
Reference in New Issue
Block a user