refactor(discord): internalize discord client

This commit is contained in:
Peter Steinberger
2026-04-29 13:22:15 +01:00
parent 20e2117371
commit f0adbd48e8
200 changed files with 16205 additions and 8341 deletions

View File

@@ -1103,11 +1103,11 @@ openclaw logs --follow
- `Slow listener detected ...`
- `stuck session: sessionKey=agent:...:discord:... state=processing ...`
Carbon gateway queue knobs:
Discord gateway queue knobs:
- single-account: `channels.discord.eventQueue.listenerTimeout`
- multi-account: `channels.discord.accounts.<accountId>.eventQueue.listenerTimeout`
- this only controls Carbon gateway listener work, not agent turn lifetime
- this only controls Discord gateway listener work, not agent turn lifetime
Discord does not apply a channel-owned timeout to queued agent turns. Message listeners hand off immediately, and queued Discord runs preserve per-session ordering until the session/tool/runtime lifecycle completes or aborts the work.

View File

@@ -52,8 +52,10 @@ export {
formatDiscordComponentEventText,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForCarbon,
parseDiscordComponentCustomIdForInteraction,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForCarbon,
parseDiscordModalCustomIdForInteraction,
readDiscordComponentSpec,
resolveDiscordComponentAttachmentName,
} from "./src/components.js";

View File

@@ -4,11 +4,11 @@
"description": "OpenClaw Discord channel plugin",
"type": "module",
"dependencies": {
"@buape/carbon": "0.16.0",
"@discordjs/voice": "^0.19.2",
"discord-api-types": "^0.38.47",
"https-proxy-agent": "^9.0.0",
"opusscript": "^0.1.1",
"typebox": "1.1.33",
"undici": "8.1.0",
"ws": "^8.20.0"
},

View File

@@ -1,6 +1,6 @@
import type { GatewayPlugin } from "@buape/carbon/gateway";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayPlugin } from "../internal/gateway.js";
import { clearGateways, registerGateway } from "../monitor/gateway-registry.js";
import type { ActionGate } from "../runtime-api.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";

View File

@@ -1,6 +1,6 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { Activity, UpdatePresenceData } from "../internal/gateway.js";
import { getGateway } from "../monitor/gateway-registry.js";
import {
type ActionGate,

View File

@@ -1,13 +1,4 @@
import {
Button,
Row,
Separator,
TextDisplay,
serializePayload,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
@@ -25,6 +16,19 @@ import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-
import { logDebug, logError, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import {
Button,
createChannelMessage,
createUserDmChannel,
deleteChannelMessage,
editChannelMessage,
Row,
Separator,
TextDisplay,
serializePayload,
type MessagePayloadObject,
type TopLevelComponents,
} from "./internal/discord.js";
import { createDiscordClient, stripUndefinedFields } from "./send.shared.js";
import { DiscordUiContainer } from "./ui.js";
@@ -364,7 +368,7 @@ async function updateMessage(params: {
const payload = buildExecApprovalPayload(params.container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(params.channelId, params.messageId), {
editChannelMessage(rest, params.channelId, params.messageId, {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
@@ -394,7 +398,7 @@ async function finalizeMessage(params: {
accountId: params.accountId,
});
await discordRequest(
() => rest.delete(Routes.channelMessage(params.channelId, params.messageId)) as Promise<void>,
() => deleteChannelMessage(rest, params.channelId, params.messageId),
"delete-approval",
);
} catch (err) {
@@ -524,10 +528,7 @@ export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAd
});
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
() => createUserDmChannel(rest, userId),
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
@@ -561,9 +562,13 @@ export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAd
});
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
body: pendingPayload.body,
}) as Promise<{ id: string; channel_id: string }>,
createChannelMessage<{ id: string; channel_id: string }>(
rest,
preparedTarget.discordChannelId,
{
body: pendingPayload.body,
},
),
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {

View File

@@ -1,7 +1,13 @@
import type { RequestClient } from "@buape/carbon";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import {
parseDiscordComponentCustomIdForCarbon,
parseDiscordComponentCustomIdForInteraction,
parseDiscordModalCustomIdForCarbon,
parseDiscordModalCustomIdForInteraction,
} from "../api.js";
import { createDiscordRestClient } from "./client.js";
import type { RequestClient } from "./internal/discord.js";
describe("createDiscordRestClient", () => {
const fakeRest = {} as RequestClient;
@@ -74,3 +80,14 @@ describe("createDiscordRestClient", () => {
expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(/unresolved SecretRef/i);
});
});
describe("public Discord API compatibility", () => {
it("keeps legacy Carbon parser aliases wired to the interaction parsers", () => {
expect(parseDiscordComponentCustomIdForCarbon).toBe(
parseDiscordComponentCustomIdForInteraction,
);
expect(parseDiscordModalCustomIdForCarbon).toBe(parseDiscordModalCustomIdForInteraction);
expect(parseDiscordComponentCustomIdForCarbon("occomp:cid=one").data.cid).toBe("one");
expect(parseDiscordModalCustomIdForCarbon("ocmodal:mid=two").data.mid).toBe("two");
});
});

View File

@@ -1,4 +1,3 @@
import { RequestClient } from "@buape/carbon";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { RetryConfig, RetryRunner } from "openclaw/plugin-sdk/retry-runtime";
@@ -10,6 +9,7 @@ import {
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { RequestClient } from "./internal/discord.js";
import { resolveDiscordProxyFetchForAccount } from "./proxy-fetch.js";
import { createDiscordRequestClient } from "./proxy-request-client.js";
import { createDiscordRetryRunner } from "./retry.js";

View File

@@ -1,4 +1,4 @@
import { parseCustomId, type ComponentParserResult } from "@buape/carbon";
import { parseCustomId, type ComponentParserResult } from "./internal/discord.js";
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
@@ -49,7 +49,7 @@ function isDiscordComponentWildcardRegistrationId(id: string): boolean {
return /^__openclaw_discord_component_[a-z_]+_wildcard__$/.test(id);
}
export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentParserResult {
export function parseDiscordComponentCustomIdForInteraction(id: string): ComponentParserResult {
if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) {
return { key: "*", data: {} };
}
@@ -60,7 +60,9 @@ export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentPar
return { key: "*", data: parsed.data };
}
export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserResult {
export const parseDiscordComponentCustomIdForCarbon = parseDiscordComponentCustomIdForInteraction;
export function parseDiscordModalCustomIdForInteraction(id: string): ComponentParserResult {
if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) {
return { key: "*", data: {} };
}
@@ -70,3 +72,5 @@ export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserR
}
return { key: "*", data: parsed.data };
}
export const parseDiscordModalCustomIdForCarbon = parseDiscordModalCustomIdForInteraction;

View File

@@ -0,0 +1,421 @@
import crypto from "node:crypto";
import { ButtonStyle, MessageFlags } from "discord-api-types/v10";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { buildDiscordComponentCustomId as buildDiscordComponentCustomIdImpl } from "./component-custom-id.js";
import { mapButtonStyle, normalizeModalFieldName } from "./components.parse.js";
import type {
DiscordComponentBuildResult,
DiscordComponentButtonSpec,
DiscordComponentEntry,
DiscordComponentMessageSpec,
DiscordComponentSelectSpec,
DiscordComponentSelectType,
DiscordModalEntry,
} from "./components.types.js";
import {
Button,
ChannelSelectMenu,
Container,
File,
LinkButton,
MediaGallery,
MentionableSelectMenu,
RoleSelectMenu,
Row,
Section,
Separator,
StringSelectMenu,
TextDisplay,
Thumbnail,
UserSelectMenu,
type TopLevelComponents,
} from "./internal/discord.js";
function createShortId(prefix: string) {
return `${prefix}${crypto.randomBytes(6).toString("base64url")}`;
}
function buildTextDisplays(text?: string, texts?: string[]): TextDisplay[] {
if (texts && texts.length > 0) {
return texts.map((entry) => new TextDisplay(entry));
}
if (text) {
return [new TextDisplay(text)];
}
return [];
}
function createButtonComponent(params: {
spec: DiscordComponentButtonSpec;
componentId?: string;
modalId?: string;
}): { component: Button | LinkButton; entry?: DiscordComponentEntry } {
const style = mapButtonStyle(params.spec.style);
const isLink = style === ButtonStyle.Link || Boolean(params.spec.url);
if (isLink) {
if (!params.spec.url) {
throw new Error("Link buttons require a url");
}
const linkUrl = params.spec.url;
class DynamicLinkButton extends LinkButton {
label = params.spec.label;
url = linkUrl;
}
return { component: new DynamicLinkButton() };
}
const componentId = params.componentId ?? createShortId("btn_");
const internalCustomId =
typeof params.spec.internalCustomId === "string" && params.spec.internalCustomId.trim()
? params.spec.internalCustomId.trim()
: undefined;
const customId =
internalCustomId ??
buildDiscordComponentCustomIdImpl({
componentId,
modalId: params.modalId,
});
class DynamicButton extends Button {
label = params.spec.label;
customId = customId;
style = style;
emoji = params.spec.emoji;
disabled = params.spec.disabled ?? false;
}
if (internalCustomId) {
return {
component: new DynamicButton(),
};
}
return {
component: new DynamicButton(),
entry: {
id: componentId,
kind: params.modalId ? "modal-trigger" : "button",
label: params.spec.label,
callbackData: params.spec.callbackData,
modalId: params.modalId,
allowedUsers: params.spec.allowedUsers,
},
};
}
function createSelectComponent(params: {
spec: DiscordComponentSelectSpec;
componentId?: string;
}): {
component:
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu;
entry: DiscordComponentEntry;
} {
const type = normalizeLowercaseStringOrEmpty(
params.spec.type ?? "string",
) as DiscordComponentSelectType;
const componentId = params.componentId ?? createShortId("sel_");
const customId = buildDiscordComponentCustomIdImpl({ componentId });
if (type === "string") {
const options = params.spec.options ?? [];
if (options.length === 0) {
throw new Error("String select menus require options");
}
class DynamicStringSelect extends StringSelectMenu {
customId = customId;
options = options;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicStringSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "select",
callbackData: params.spec.callbackData,
selectType: "string",
options: options.map((option) => ({ value: option.value, label: option.label })),
allowedUsers: params.spec.allowedUsers,
},
};
}
if (type === "user") {
class DynamicUserSelect extends UserSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicUserSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "user select",
callbackData: params.spec.callbackData,
selectType: "user",
allowedUsers: params.spec.allowedUsers,
},
};
}
if (type === "role") {
class DynamicRoleSelect extends RoleSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicRoleSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "role select",
callbackData: params.spec.callbackData,
selectType: "role",
allowedUsers: params.spec.allowedUsers,
},
};
}
if (type === "mentionable") {
class DynamicMentionableSelect extends MentionableSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicMentionableSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "mentionable select",
callbackData: params.spec.callbackData,
selectType: "mentionable",
allowedUsers: params.spec.allowedUsers,
},
};
}
class DynamicChannelSelect extends ChannelSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicChannelSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "channel select",
callbackData: params.spec.callbackData,
selectType: "channel",
allowedUsers: params.spec.allowedUsers,
},
};
}
function isSelectComponent(
component: unknown,
): component is
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu {
return (
component instanceof StringSelectMenu ||
component instanceof UserSelectMenu ||
component instanceof RoleSelectMenu ||
component instanceof MentionableSelectMenu ||
component instanceof ChannelSelectMenu
);
}
export function buildDiscordComponentMessage(params: {
spec: DiscordComponentMessageSpec;
fallbackText?: string;
sessionKey?: string;
agentId?: string;
accountId?: string;
}): DiscordComponentBuildResult {
const entries: DiscordComponentEntry[] = [];
const modals: DiscordModalEntry[] = [];
const components: TopLevelComponents[] = [];
const containerChildren: Array<
| Row<
| Button
| LinkButton
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
>
| TextDisplay
| Section
| MediaGallery
| Separator
| File
> = [];
const addEntry = (entry: DiscordComponentEntry) => {
entries.push({
...entry,
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
reusable: entry.reusable ?? params.spec.reusable,
});
};
const text = params.spec.text ?? params.fallbackText;
if (text) {
containerChildren.push(new TextDisplay(text));
}
for (const block of params.spec.blocks ?? []) {
if (block.type === "text") {
containerChildren.push(new TextDisplay(block.text));
continue;
}
if (block.type === "section") {
const displays = buildTextDisplays(block.text, block.texts);
if (displays.length > 3) {
throw new Error("Section blocks support up to 3 text displays");
}
let accessory: Thumbnail | Button | LinkButton | undefined;
if (block.accessory?.type === "thumbnail") {
accessory = new Thumbnail(block.accessory.url);
} else if (block.accessory?.type === "button") {
const { component, entry } = createButtonComponent({ spec: block.accessory.button });
accessory = component;
if (entry) {
addEntry(entry);
}
}
containerChildren.push(new Section(displays, accessory));
continue;
}
if (block.type === "separator") {
containerChildren.push(new Separator({ spacing: block.spacing, divider: block.divider }));
continue;
}
if (block.type === "media-gallery") {
containerChildren.push(new MediaGallery(block.items));
continue;
}
if (block.type === "file") {
containerChildren.push(new File(block.file, block.spoiler));
continue;
}
if (block.type === "actions") {
const rowComponents: Array<
| Button
| LinkButton
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
> = [];
if (block.buttons) {
if (block.buttons.length > 5) {
throw new Error("Action rows support up to 5 buttons");
}
for (const button of block.buttons) {
const { component, entry } = createButtonComponent({ spec: button });
rowComponents.push(component);
if (entry) {
addEntry(entry);
}
}
} else if (block.select) {
const { component, entry } = createSelectComponent({ spec: block.select });
rowComponents.push(component);
addEntry(entry);
}
containerChildren.push(new Row(rowComponents));
}
}
if (params.spec.modal) {
const modalId = createShortId("mdl_");
const fields = params.spec.modal.fields.map((field, index) => ({
id: createShortId("fld_"),
name: normalizeModalFieldName(field.name, index),
label: field.label,
type: field.type,
description: field.description,
placeholder: field.placeholder,
required: field.required,
options: field.options,
minValues: field.minValues,
maxValues: field.maxValues,
minLength: field.minLength,
maxLength: field.maxLength,
style: field.style,
}));
modals.push({
id: modalId,
title: params.spec.modal.title,
callbackData: params.spec.modal.callbackData,
fields,
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
reusable: params.spec.reusable,
allowedUsers: params.spec.modal.allowedUsers,
});
const triggerSpec: DiscordComponentButtonSpec = {
label: params.spec.modal.triggerLabel ?? "Open form",
style: params.spec.modal.triggerStyle ?? "primary",
allowedUsers: params.spec.modal.allowedUsers,
};
const { component, entry } = createButtonComponent({
spec: triggerSpec,
modalId,
});
if (entry) {
addEntry(entry);
}
const lastChild = containerChildren.at(-1);
if (lastChild instanceof Row) {
const row = lastChild;
const hasSelect = row.components.some((entry) => isSelectComponent(entry));
if (row.components.length < 5 && !hasSelect) {
row.addComponent(component as Button);
} else {
containerChildren.push(new Row([component as Button]));
}
} else {
containerChildren.push(new Row([component as Button]));
}
}
if (containerChildren.length === 0) {
throw new Error("components must include at least one block, text, or modal trigger");
}
const container = new Container(containerChildren, params.spec.container);
components.push(container);
return { components, entries, modals };
}
export function buildDiscordComponentMessageFlags(
components: TopLevelComponents[],
): number | undefined {
const hasV2 = components.some((component) => component.isV2);
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
}

View File

@@ -0,0 +1,124 @@
import {
buildDiscordModalCustomId as buildDiscordModalCustomIdImpl,
parseDiscordModalCustomIdForInteraction as parseDiscordModalCustomIdForInteractionImpl,
} from "./component-custom-id.js";
import { mapTextInputStyle } from "./components.parse.js";
import type { DiscordModalEntry, DiscordModalFieldDefinition } from "./components.types.js";
import {
CheckboxGroup,
Label,
Modal,
RadioGroup,
RoleSelectMenu,
StringSelectMenu,
TextDisplay,
TextInput,
UserSelectMenu,
} from "./internal/discord.js";
// Some test-only module graphs partially mock `./internal/discord.js` and can drop `Modal`.
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
const ModalBase: typeof Modal = Modal ?? (function ModalFallback() {} as unknown as typeof Modal);
function createModalFieldComponent(
field: DiscordModalFieldDefinition,
): TextInput | StringSelectMenu | UserSelectMenu | RoleSelectMenu | CheckboxGroup | RadioGroup {
if (field.type === "text") {
class DynamicTextInput extends TextInput {
customId = field.id;
style = mapTextInputStyle(field.style);
placeholder = field.placeholder;
required = field.required;
minLength = field.minLength;
maxLength = field.maxLength;
}
return new DynamicTextInput();
}
if (field.type === "select") {
const options = field.options ?? [];
class DynamicModalSelect extends StringSelectMenu {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalSelect();
}
if (field.type === "role-select") {
class DynamicModalRoleSelect extends RoleSelectMenu {
customId = field.id;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalRoleSelect();
}
if (field.type === "user-select") {
class DynamicModalUserSelect extends UserSelectMenu {
customId = field.id;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalUserSelect();
}
if (field.type === "checkbox") {
const options = field.options ?? [];
class DynamicCheckboxGroup extends CheckboxGroup {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
}
return new DynamicCheckboxGroup();
}
const options = field.options ?? [];
class DynamicRadioGroup extends RadioGroup {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
}
return new DynamicRadioGroup();
}
export class DiscordFormModal extends ModalBase {
title: string;
customId: string;
components: Array<Label | TextDisplay>;
customIdParser = parseDiscordModalCustomIdForInteractionImpl;
constructor(params: { modalId: string; title: string; fields: DiscordModalFieldDefinition[] }) {
super();
this.title = params.title;
this.customId = buildDiscordModalCustomIdImpl(params.modalId);
this.components = params.fields.map((field) => {
const component = createModalFieldComponent(field);
class DynamicLabel extends Label {
label = field.label;
description = field.description;
component = component;
customId = field.id;
}
return new DynamicLabel(component);
});
}
async run(): Promise<void> {
throw new Error("Modal handler is not registered for dynamic forms");
}
}
export function createDiscordFormModal(entry: DiscordModalEntry): Modal {
return new DiscordFormModal({
modalId: entry.id,
title: entry.title,
fields: entry.fields,
});
}

View File

@@ -0,0 +1,418 @@
import { ButtonStyle, TextInputStyle } from "discord-api-types/v10";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type {
DiscordComponentBlock,
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentMessageSpec,
DiscordComponentModalFieldType,
DiscordComponentSectionAccessory,
DiscordComponentSelectOption,
DiscordComponentSelectSpec,
DiscordComponentSelectType,
DiscordModalFieldSpec,
DiscordModalSpec,
} from "./components.types.js";
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2;
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
["row", "actions"],
["action-row", "actions"],
]);
function requireObject(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string {
if (typeof value !== "string") {
throw new Error(`${label} must be a string`);
}
const trimmed = value.trim();
if (!opts?.allowEmpty && !trimmed) {
throw new Error(`${label} cannot be empty`);
}
return opts?.allowEmpty ? value : trimmed;
}
function readOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readOptionalStringArray(value: unknown, label: string): string[] | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value)) {
throw new Error(`${label} must be an array`);
}
if (value.length === 0) {
return undefined;
}
return value.map((entry, index) => readString(entry, `${label}[${index}]`));
}
function readOptionalNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return value;
}
export function normalizeModalFieldName(value: string | undefined, index: number) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
return `field_${index + 1}`;
}
function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error(`${label} must include an attachment filename`);
}
return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`;
}
export function resolveDiscordComponentAttachmentName(value: string): string {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(
`Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`,
);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error("Attachment reference must include a filename");
}
return attachmentName;
}
export function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle {
switch (normalizeLowercaseStringOrEmpty(style ?? "primary")) {
case "secondary":
return ButtonStyle.Secondary;
case "success":
return ButtonStyle.Success;
case "danger":
return ButtonStyle.Danger;
case "link":
return ButtonStyle.Link;
case "primary":
default:
return ButtonStyle.Primary;
}
}
export function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) {
return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short;
}
function normalizeBlockType(raw: string) {
const lowered = normalizeLowercaseStringOrEmpty(raw);
return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]);
}
function parseSelectOptions(
raw: unknown,
label: string,
): DiscordComponentSelectOption[] | undefined {
if (raw === undefined) {
return undefined;
}
if (!Array.isArray(raw)) {
throw new Error(`${label} must be an array`);
}
return raw.map((entry, index) => {
const obj = requireObject(entry, `${label}[${index}]`);
return {
label: readString(obj.label, `${label}[${index}].label`),
value: readString(obj.value, `${label}[${index}].value`),
description: readOptionalString(obj.description),
emoji:
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
? {
name: readString(
(obj.emoji as { name?: unknown }).name,
`${label}[${index}].emoji.name`,
),
id: readOptionalString((obj.emoji as { id?: unknown }).id),
animated:
typeof (obj.emoji as { animated?: unknown }).animated === "boolean"
? (obj.emoji as { animated?: boolean }).animated
: undefined,
}
: undefined,
default: typeof obj.default === "boolean" ? obj.default : undefined,
};
});
}
function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec {
const obj = requireObject(raw, label);
const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined;
const url = readOptionalString(obj.url);
if ((style === "link" || url) && !url) {
throw new Error(`${label}.url is required for link buttons`);
}
return {
label: readString(obj.label, `${label}.label`),
style,
url,
callbackData: readOptionalString(obj.callbackData),
emoji:
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
? {
name: readString((obj.emoji as { name?: unknown }).name, `${label}.emoji.name`),
id: readOptionalString((obj.emoji as { id?: unknown }).id),
animated:
typeof (obj.emoji as { animated?: unknown }).animated === "boolean"
? (obj.emoji as { animated?: boolean }).animated
: undefined,
}
: undefined,
disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
};
}
function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec {
const obj = requireObject(raw, label);
const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined;
const allowedTypes: DiscordComponentSelectType[] = [
"string",
"user",
"role",
"mentionable",
"channel",
];
if (type && !allowedTypes.includes(type)) {
throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`);
}
return {
type,
callbackData: readOptionalString(obj.callbackData),
placeholder: readOptionalString(obj.placeholder),
minValues: readOptionalNumber(obj.minValues),
maxValues: readOptionalNumber(obj.maxValues),
options: parseSelectOptions(obj.options, `${label}.options`),
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
};
}
function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec {
const obj = requireObject(raw, label);
const type = normalizeLowercaseStringOrEmpty(
readString(obj.type, `${label}.type`),
) as DiscordComponentModalFieldType;
const supported: DiscordComponentModalFieldType[] = [
"text",
"checkbox",
"radio",
"select",
"role-select",
"user-select",
];
if (!supported.includes(type)) {
throw new Error(`${label}.type must be one of ${supported.join(", ")}`);
}
const options = parseSelectOptions(obj.options, `${label}.options`);
if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) {
throw new Error(`${label}.options is required for ${type} fields`);
}
return {
type,
name: normalizeModalFieldName(readOptionalString(obj.name), index),
label: readString(obj.label, `${label}.label`),
description: readOptionalString(obj.description),
placeholder: readOptionalString(obj.placeholder),
required: typeof obj.required === "boolean" ? obj.required : undefined,
options,
minValues: readOptionalNumber(obj.minValues),
maxValues: readOptionalNumber(obj.maxValues),
minLength: readOptionalNumber(obj.minLength),
maxLength: readOptionalNumber(obj.maxLength),
style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"],
};
}
function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock {
const obj = requireObject(raw, label);
const typeRaw = normalizeLowercaseStringOrEmpty(readString(obj.type, `${label}.type`));
const type = normalizeBlockType(typeRaw);
switch (type) {
case "text":
return {
type: "text",
text: readString(obj.text, `${label}.text`),
};
case "section": {
const text = readOptionalString(obj.text);
const textsRaw = obj.texts;
const texts = Array.isArray(textsRaw)
? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`))
: undefined;
if (!text && (!texts || texts.length === 0)) {
throw new Error(`${label}.text or ${label}.texts is required for section blocks`);
}
let accessory: DiscordComponentSectionAccessory | undefined;
if (obj.accessory !== undefined) {
const accessoryObj = requireObject(obj.accessory, `${label}.accessory`);
const accessoryType = normalizeLowercaseStringOrEmpty(
readString(accessoryObj.type, `${label}.accessory.type`),
);
if (accessoryType === "thumbnail") {
accessory = {
type: "thumbnail",
url: readString(accessoryObj.url, `${label}.accessory.url`),
};
} else if (accessoryType === "button") {
accessory = {
type: "button",
button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`),
};
} else {
throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`);
}
}
return {
type: "section",
text,
texts,
accessory,
};
}
case "separator": {
const spacingRaw = obj.spacing;
let spacing: DiscordComponentSeparatorSpacing | undefined;
if (spacingRaw === "small" || spacingRaw === "large") {
spacing = spacingRaw;
} else if (spacingRaw === 1 || spacingRaw === 2) {
spacing = spacingRaw;
} else if (spacingRaw !== undefined) {
throw new Error(`${label}.spacing must be "small", "large", 1, or 2`);
}
const divider = typeof obj.divider === "boolean" ? obj.divider : undefined;
return {
type: "separator",
spacing,
divider,
};
}
case "actions": {
const buttonsRaw = obj.buttons;
const buttons = Array.isArray(buttonsRaw)
? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`))
: undefined;
const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined;
if ((!buttons || buttons.length === 0) && !select) {
throw new Error(`${label} requires buttons or select`);
}
if (buttons && select) {
throw new Error(`${label} cannot include both buttons and select`);
}
return {
type: "actions",
buttons,
select,
};
}
case "media-gallery": {
const itemsRaw = obj.items;
if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) {
throw new Error(`${label}.items must be a non-empty array`);
}
const items = itemsRaw.map((entry, idx) => {
const itemObj = requireObject(entry, `${label}.items[${idx}]`);
return {
url: readString(itemObj.url, `${label}.items[${idx}].url`),
description: readOptionalString(itemObj.description),
spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined,
};
});
return {
type: "media-gallery",
items,
};
}
case "file": {
const file = readString(obj.file, `${label}.file`);
return {
type: "file",
file: normalizeAttachmentRef(file, `${label}.file`),
spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined,
};
}
default:
throw new Error(`${label}.type must be a supported component block`);
}
}
export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null {
if (raw === undefined || raw === null) {
return null;
}
const obj = requireObject(raw, "components");
const blocksRaw = obj.blocks;
const blocks = Array.isArray(blocksRaw)
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
: undefined;
const modalRaw = obj.modal;
const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined;
let modal: DiscordModalSpec | undefined;
if (modalRaw !== undefined) {
const modalObj = requireObject(modalRaw, "components.modal");
const fieldsRaw = modalObj.fields;
if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) {
throw new Error("components.modal.fields must be a non-empty array");
}
if (fieldsRaw.length > 5) {
throw new Error("components.modal.fields supports up to 5 inputs");
}
const fields = fieldsRaw.map((entry, idx) =>
parseModalField(entry, `components.modal.fields[${idx}]`, idx),
);
modal = {
title: readString(modalObj.title, "components.modal.title"),
callbackData: readOptionalString(modalObj.callbackData),
triggerLabel: readOptionalString(modalObj.triggerLabel),
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
fields,
};
}
return {
text: readOptionalString(obj.text),
reusable,
container:
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
? {
accentColor: (obj.container as { accentColor?: unknown }).accentColor as
| string
| number
| undefined,
spoiler:
typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean"
? ((obj.container as { spoiler?: boolean }).spoiler as boolean)
: undefined,
}
: undefined,
blocks,
modal,
};
}

View File

@@ -1,51 +1,25 @@
import crypto from "node:crypto";
import {
Button,
ChannelSelectMenu,
CheckboxGroup,
Container,
File,
Label,
LinkButton,
MediaGallery,
MentionableSelectMenu,
Modal,
RadioGroup,
RoleSelectMenu,
Row,
Section,
Separator,
StringSelectMenu,
TextDisplay,
TextInput,
Thumbnail,
UserSelectMenu,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
buildDiscordComponentCustomId as buildDiscordComponentCustomIdImpl,
buildDiscordModalCustomId as buildDiscordModalCustomIdImpl,
parseDiscordModalCustomIdForCarbon as parseDiscordModalCustomIdForCarbonImpl,
export {
DISCORD_COMPONENT_CUSTOM_ID_KEY,
DISCORD_MODAL_CUSTOM_ID_KEY,
buildDiscordComponentCustomId,
buildDiscordModalCustomId,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForCarbon,
parseDiscordComponentCustomIdForInteraction,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForCarbon,
parseDiscordModalCustomIdForInteraction,
} from "./component-custom-id.js";
import type {
DiscordComponentBlock,
DiscordComponentBuildResult,
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentEntry,
DiscordComponentMessageSpec,
DiscordComponentModalFieldType,
DiscordComponentSectionAccessory,
DiscordComponentSelectOption,
DiscordComponentSelectSpec,
DiscordComponentSelectType,
DiscordModalEntry,
DiscordModalFieldDefinition,
DiscordModalFieldSpec,
DiscordModalSpec,
} from "./components.types.js";
export {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
} from "./components.builders.js";
export {
DISCORD_COMPONENT_ATTACHMENT_PREFIX,
readDiscordComponentSpec,
resolveDiscordComponentAttachmentName,
} from "./components.parse.js";
export { DiscordFormModal, createDiscordFormModal } from "./components.modal.js";
export type {
DiscordComponentBlock,
DiscordComponentBuildResult,
@@ -63,915 +37,8 @@ export type {
DiscordModalFieldSpec,
DiscordModalSpec,
} from "./components.types.js";
// Some test-only module graphs partially mock `@buape/carbon` and can drop `Modal`.
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
const ModalBase: typeof Modal = Modal ?? (function ModalFallback() {} as unknown as typeof Modal);
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2;
export {
DISCORD_COMPONENT_CUSTOM_ID_KEY,
DISCORD_MODAL_CUSTOM_ID_KEY,
buildDiscordComponentCustomId,
buildDiscordModalCustomId,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForCarbon,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForCarbon,
} from "./component-custom-id.js";
export { buildDiscordInteractiveComponents } from "./shared-interactive.js";
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
["row", "actions"],
["action-row", "actions"],
]);
function createShortId(prefix: string) {
return `${prefix}${crypto.randomBytes(6).toString("base64url")}`;
}
function requireObject(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string {
if (typeof value !== "string") {
throw new Error(`${label} must be a string`);
}
const trimmed = value.trim();
if (!opts?.allowEmpty && !trimmed) {
throw new Error(`${label} cannot be empty`);
}
return opts?.allowEmpty ? value : trimmed;
}
function readOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readOptionalStringArray(value: unknown, label: string): string[] | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value)) {
throw new Error(`${label} must be an array`);
}
if (value.length === 0) {
return undefined;
}
return value.map((entry, index) => readString(entry, `${label}[${index}]`));
}
function readOptionalNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return value;
}
function normalizeModalFieldName(value: string | undefined, index: number) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
return `field_${index + 1}`;
}
function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error(`${label} must include an attachment filename`);
}
return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`;
}
export function resolveDiscordComponentAttachmentName(value: string): string {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(
`Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`,
);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error("Attachment reference must include a filename");
}
return attachmentName;
}
function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle {
switch (normalizeLowercaseStringOrEmpty(style ?? "primary")) {
case "secondary":
return ButtonStyle.Secondary;
case "success":
return ButtonStyle.Success;
case "danger":
return ButtonStyle.Danger;
case "link":
return ButtonStyle.Link;
case "primary":
default:
return ButtonStyle.Primary;
}
}
function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) {
return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short;
}
function normalizeBlockType(raw: string) {
const lowered = normalizeLowercaseStringOrEmpty(raw);
return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]);
}
function parseSelectOptions(
raw: unknown,
label: string,
): DiscordComponentSelectOption[] | undefined {
if (raw === undefined) {
return undefined;
}
if (!Array.isArray(raw)) {
throw new Error(`${label} must be an array`);
}
return raw.map((entry, index) => {
const obj = requireObject(entry, `${label}[${index}]`);
return {
label: readString(obj.label, `${label}[${index}].label`),
value: readString(obj.value, `${label}[${index}].value`),
description: readOptionalString(obj.description),
emoji:
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
? {
name: readString(
(obj.emoji as { name?: unknown }).name,
`${label}[${index}].emoji.name`,
),
id: readOptionalString((obj.emoji as { id?: unknown }).id),
animated:
typeof (obj.emoji as { animated?: unknown }).animated === "boolean"
? (obj.emoji as { animated?: boolean }).animated
: undefined,
}
: undefined,
default: typeof obj.default === "boolean" ? obj.default : undefined,
};
});
}
function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec {
const obj = requireObject(raw, label);
const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined;
const url = readOptionalString(obj.url);
if ((style === "link" || url) && !url) {
throw new Error(`${label}.url is required for link buttons`);
}
return {
label: readString(obj.label, `${label}.label`),
style,
url,
callbackData: readOptionalString(obj.callbackData),
emoji:
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
? {
name: readString((obj.emoji as { name?: unknown }).name, `${label}.emoji.name`),
id: readOptionalString((obj.emoji as { id?: unknown }).id),
animated:
typeof (obj.emoji as { animated?: unknown }).animated === "boolean"
? (obj.emoji as { animated?: boolean }).animated
: undefined,
}
: undefined,
disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
};
}
function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec {
const obj = requireObject(raw, label);
const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined;
const allowedTypes: DiscordComponentSelectType[] = [
"string",
"user",
"role",
"mentionable",
"channel",
];
if (type && !allowedTypes.includes(type)) {
throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`);
}
return {
type,
callbackData: readOptionalString(obj.callbackData),
placeholder: readOptionalString(obj.placeholder),
minValues: readOptionalNumber(obj.minValues),
maxValues: readOptionalNumber(obj.maxValues),
options: parseSelectOptions(obj.options, `${label}.options`),
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
};
}
function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec {
const obj = requireObject(raw, label);
const type = normalizeLowercaseStringOrEmpty(
readString(obj.type, `${label}.type`),
) as DiscordComponentModalFieldType;
const supported: DiscordComponentModalFieldType[] = [
"text",
"checkbox",
"radio",
"select",
"role-select",
"user-select",
];
if (!supported.includes(type)) {
throw new Error(`${label}.type must be one of ${supported.join(", ")}`);
}
const options = parseSelectOptions(obj.options, `${label}.options`);
if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) {
throw new Error(`${label}.options is required for ${type} fields`);
}
return {
type,
name: normalizeModalFieldName(readOptionalString(obj.name), index),
label: readString(obj.label, `${label}.label`),
description: readOptionalString(obj.description),
placeholder: readOptionalString(obj.placeholder),
required: typeof obj.required === "boolean" ? obj.required : undefined,
options,
minValues: readOptionalNumber(obj.minValues),
maxValues: readOptionalNumber(obj.maxValues),
minLength: readOptionalNumber(obj.minLength),
maxLength: readOptionalNumber(obj.maxLength),
style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"],
};
}
function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock {
const obj = requireObject(raw, label);
const typeRaw = normalizeLowercaseStringOrEmpty(readString(obj.type, `${label}.type`));
const type = normalizeBlockType(typeRaw);
switch (type) {
case "text":
return {
type: "text",
text: readString(obj.text, `${label}.text`),
};
case "section": {
const text = readOptionalString(obj.text);
const textsRaw = obj.texts;
const texts = Array.isArray(textsRaw)
? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`))
: undefined;
if (!text && (!texts || texts.length === 0)) {
throw new Error(`${label}.text or ${label}.texts is required for section blocks`);
}
let accessory: DiscordComponentSectionAccessory | undefined;
if (obj.accessory !== undefined) {
const accessoryObj = requireObject(obj.accessory, `${label}.accessory`);
const accessoryType = normalizeLowercaseStringOrEmpty(
readString(accessoryObj.type, `${label}.accessory.type`),
);
if (accessoryType === "thumbnail") {
accessory = {
type: "thumbnail",
url: readString(accessoryObj.url, `${label}.accessory.url`),
};
} else if (accessoryType === "button") {
accessory = {
type: "button",
button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`),
};
} else {
throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`);
}
}
return {
type: "section",
text,
texts,
accessory,
};
}
case "separator": {
const spacingRaw = obj.spacing;
let spacing: DiscordComponentSeparatorSpacing | undefined;
if (spacingRaw === "small" || spacingRaw === "large") {
spacing = spacingRaw;
} else if (spacingRaw === 1 || spacingRaw === 2) {
spacing = spacingRaw;
} else if (spacingRaw !== undefined) {
throw new Error(`${label}.spacing must be "small", "large", 1, or 2`);
}
const divider = typeof obj.divider === "boolean" ? obj.divider : undefined;
return {
type: "separator",
spacing,
divider,
};
}
case "actions": {
const buttonsRaw = obj.buttons;
const buttons = Array.isArray(buttonsRaw)
? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`))
: undefined;
const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined;
if ((!buttons || buttons.length === 0) && !select) {
throw new Error(`${label} requires buttons or select`);
}
if (buttons && select) {
throw new Error(`${label} cannot include both buttons and select`);
}
return {
type: "actions",
buttons,
select,
};
}
case "media-gallery": {
const itemsRaw = obj.items;
if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) {
throw new Error(`${label}.items must be a non-empty array`);
}
const items = itemsRaw.map((entry, idx) => {
const itemObj = requireObject(entry, `${label}.items[${idx}]`);
return {
url: readString(itemObj.url, `${label}.items[${idx}].url`),
description: readOptionalString(itemObj.description),
spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined,
};
});
return {
type: "media-gallery",
items,
};
}
case "file": {
const file = readString(obj.file, `${label}.file`);
return {
type: "file",
file: normalizeAttachmentRef(file, `${label}.file`),
spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined,
};
}
default:
throw new Error(`${label}.type must be a supported component block`);
}
}
export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null {
if (raw === undefined || raw === null) {
return null;
}
const obj = requireObject(raw, "components");
const blocksRaw = obj.blocks;
const blocks = Array.isArray(blocksRaw)
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
: undefined;
const modalRaw = obj.modal;
const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined;
let modal: DiscordModalSpec | undefined;
if (modalRaw !== undefined) {
const modalObj = requireObject(modalRaw, "components.modal");
const fieldsRaw = modalObj.fields;
if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) {
throw new Error("components.modal.fields must be a non-empty array");
}
if (fieldsRaw.length > 5) {
throw new Error("components.modal.fields supports up to 5 inputs");
}
const fields = fieldsRaw.map((entry, idx) =>
parseModalField(entry, `components.modal.fields[${idx}]`, idx),
);
modal = {
title: readString(modalObj.title, "components.modal.title"),
callbackData: readOptionalString(modalObj.callbackData),
triggerLabel: readOptionalString(modalObj.triggerLabel),
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
fields,
};
}
return {
text: readOptionalString(obj.text),
reusable,
container:
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
? {
accentColor: (obj.container as { accentColor?: unknown }).accentColor as
| string
| number
| undefined,
spoiler:
typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean"
? ((obj.container as { spoiler?: boolean }).spoiler as boolean)
: undefined,
}
: undefined,
blocks,
modal,
};
}
function buildTextDisplays(text?: string, texts?: string[]): TextDisplay[] {
if (texts && texts.length > 0) {
return texts.map((entry) => new TextDisplay(entry));
}
if (text) {
return [new TextDisplay(text)];
}
return [];
}
function createButtonComponent(params: {
spec: DiscordComponentButtonSpec;
componentId?: string;
modalId?: string;
}): { component: Button | LinkButton; entry?: DiscordComponentEntry } {
const style = mapButtonStyle(params.spec.style);
const isLink = style === ButtonStyle.Link || Boolean(params.spec.url);
if (isLink) {
if (!params.spec.url) {
throw new Error("Link buttons require a url");
}
const linkUrl = params.spec.url;
class DynamicLinkButton extends LinkButton {
label = params.spec.label;
url = linkUrl;
}
return { component: new DynamicLinkButton() };
}
const componentId = params.componentId ?? createShortId("btn_");
const internalCustomId =
typeof params.spec.internalCustomId === "string" && params.spec.internalCustomId.trim()
? params.spec.internalCustomId.trim()
: undefined;
const customId =
internalCustomId ??
buildDiscordComponentCustomIdImpl({
componentId,
modalId: params.modalId,
});
class DynamicButton extends Button {
label = params.spec.label;
customId = customId;
style = style;
emoji = params.spec.emoji;
disabled = params.spec.disabled ?? false;
}
if (internalCustomId) {
return {
component: new DynamicButton(),
};
}
return {
component: new DynamicButton(),
entry: {
id: componentId,
kind: params.modalId ? "modal-trigger" : "button",
label: params.spec.label,
callbackData: params.spec.callbackData,
modalId: params.modalId,
allowedUsers: params.spec.allowedUsers,
},
};
}
function createSelectComponent(params: {
spec: DiscordComponentSelectSpec;
componentId?: string;
}): {
component:
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu;
entry: DiscordComponentEntry;
} {
const type = normalizeLowercaseStringOrEmpty(
params.spec.type ?? "string",
) as DiscordComponentSelectType;
const componentId = params.componentId ?? createShortId("sel_");
const customId = buildDiscordComponentCustomIdImpl({ componentId });
if (type === "string") {
const options = params.spec.options ?? [];
if (options.length === 0) {
throw new Error("String select menus require options");
}
class DynamicStringSelect extends StringSelectMenu {
customId = customId;
options = options;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicStringSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "select",
callbackData: params.spec.callbackData,
selectType: "string",
options: options.map((option) => ({ value: option.value, label: option.label })),
allowedUsers: params.spec.allowedUsers,
},
};
}
if (type === "user") {
class DynamicUserSelect extends UserSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicUserSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "user select",
callbackData: params.spec.callbackData,
selectType: "user",
allowedUsers: params.spec.allowedUsers,
},
};
}
if (type === "role") {
class DynamicRoleSelect extends RoleSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicRoleSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "role select",
callbackData: params.spec.callbackData,
selectType: "role",
allowedUsers: params.spec.allowedUsers,
},
};
}
if (type === "mentionable") {
class DynamicMentionableSelect extends MentionableSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicMentionableSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "mentionable select",
callbackData: params.spec.callbackData,
selectType: "mentionable",
allowedUsers: params.spec.allowedUsers,
},
};
}
class DynamicChannelSelect extends ChannelSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicChannelSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "channel select",
callbackData: params.spec.callbackData,
selectType: "channel",
allowedUsers: params.spec.allowedUsers,
},
};
}
function isSelectComponent(
component: unknown,
): component is
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu {
return (
component instanceof StringSelectMenu ||
component instanceof UserSelectMenu ||
component instanceof RoleSelectMenu ||
component instanceof MentionableSelectMenu ||
component instanceof ChannelSelectMenu
);
}
function createModalFieldComponent(
field: DiscordModalFieldDefinition,
): TextInput | StringSelectMenu | UserSelectMenu | RoleSelectMenu | CheckboxGroup | RadioGroup {
if (field.type === "text") {
class DynamicTextInput extends TextInput {
customId = field.id;
style = mapTextInputStyle(field.style);
placeholder = field.placeholder;
required = field.required;
minLength = field.minLength;
maxLength = field.maxLength;
}
return new DynamicTextInput();
}
if (field.type === "select") {
const options = field.options ?? [];
class DynamicModalSelect extends StringSelectMenu {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalSelect();
}
if (field.type === "role-select") {
class DynamicModalRoleSelect extends RoleSelectMenu {
customId = field.id;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalRoleSelect();
}
if (field.type === "user-select") {
class DynamicModalUserSelect extends UserSelectMenu {
customId = field.id;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalUserSelect();
}
if (field.type === "checkbox") {
const options = field.options ?? [];
class DynamicCheckboxGroup extends CheckboxGroup {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
}
return new DynamicCheckboxGroup();
}
const options = field.options ?? [];
class DynamicRadioGroup extends RadioGroup {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
}
return new DynamicRadioGroup();
}
export function buildDiscordComponentMessage(params: {
spec: DiscordComponentMessageSpec;
fallbackText?: string;
sessionKey?: string;
agentId?: string;
accountId?: string;
}): DiscordComponentBuildResult {
const entries: DiscordComponentEntry[] = [];
const modals: DiscordModalEntry[] = [];
const components: TopLevelComponents[] = [];
const containerChildren: Array<
| Row<
| Button
| LinkButton
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
>
| TextDisplay
| Section
| MediaGallery
| Separator
| File
> = [];
const addEntry = (entry: DiscordComponentEntry) => {
entries.push({
...entry,
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
reusable: entry.reusable ?? params.spec.reusable,
});
};
const text = params.spec.text ?? params.fallbackText;
if (text) {
containerChildren.push(new TextDisplay(text));
}
for (const block of params.spec.blocks ?? []) {
if (block.type === "text") {
containerChildren.push(new TextDisplay(block.text));
continue;
}
if (block.type === "section") {
const displays = buildTextDisplays(block.text, block.texts);
if (displays.length > 3) {
throw new Error("Section blocks support up to 3 text displays");
}
let accessory: Thumbnail | Button | LinkButton | undefined;
if (block.accessory?.type === "thumbnail") {
accessory = new Thumbnail(block.accessory.url);
} else if (block.accessory?.type === "button") {
const { component, entry } = createButtonComponent({ spec: block.accessory.button });
accessory = component;
if (entry) {
addEntry(entry);
}
}
containerChildren.push(new Section(displays, accessory));
continue;
}
if (block.type === "separator") {
containerChildren.push(new Separator({ spacing: block.spacing, divider: block.divider }));
continue;
}
if (block.type === "media-gallery") {
containerChildren.push(new MediaGallery(block.items));
continue;
}
if (block.type === "file") {
containerChildren.push(new File(block.file, block.spoiler));
continue;
}
if (block.type === "actions") {
const rowComponents: Array<
| Button
| LinkButton
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
> = [];
if (block.buttons) {
if (block.buttons.length > 5) {
throw new Error("Action rows support up to 5 buttons");
}
for (const button of block.buttons) {
const { component, entry } = createButtonComponent({ spec: button });
rowComponents.push(component);
if (entry) {
addEntry(entry);
}
}
} else if (block.select) {
const { component, entry } = createSelectComponent({ spec: block.select });
rowComponents.push(component);
addEntry(entry);
}
containerChildren.push(new Row(rowComponents));
}
}
if (params.spec.modal) {
const modalId = createShortId("mdl_");
const fields = params.spec.modal.fields.map((field, index) => ({
id: createShortId("fld_"),
name: normalizeModalFieldName(field.name, index),
label: field.label,
type: field.type,
description: field.description,
placeholder: field.placeholder,
required: field.required,
options: field.options,
minValues: field.minValues,
maxValues: field.maxValues,
minLength: field.minLength,
maxLength: field.maxLength,
style: field.style,
}));
modals.push({
id: modalId,
title: params.spec.modal.title,
callbackData: params.spec.modal.callbackData,
fields,
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
reusable: params.spec.reusable,
allowedUsers: params.spec.modal.allowedUsers,
});
const triggerSpec: DiscordComponentButtonSpec = {
label: params.spec.modal.triggerLabel ?? "Open form",
style: params.spec.modal.triggerStyle ?? "primary",
allowedUsers: params.spec.modal.allowedUsers,
};
const { component, entry } = createButtonComponent({
spec: triggerSpec,
modalId,
});
if (entry) {
addEntry(entry);
}
const lastChild = containerChildren.at(-1);
if (lastChild instanceof Row) {
const row = lastChild;
const hasSelect = row.components.some((entry) => isSelectComponent(entry));
if (row.components.length < 5 && !hasSelect) {
row.addComponent(component as Button);
} else {
containerChildren.push(new Row([component as Button]));
}
} else {
containerChildren.push(new Row([component as Button]));
}
}
if (containerChildren.length === 0) {
throw new Error("components must include at least one block, text, or modal trigger");
}
const container = new Container(containerChildren, params.spec.container);
components.push(container);
return { components, entries, modals };
}
export function buildDiscordComponentMessageFlags(
components: TopLevelComponents[],
): number | undefined {
const hasV2 = components.some((component) => component.isV2);
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
}
export class DiscordFormModal extends ModalBase {
title: string;
customId: string;
components: Array<Label | TextDisplay>;
customIdParser = parseDiscordModalCustomIdForCarbonImpl;
constructor(params: { modalId: string; title: string; fields: DiscordModalFieldDefinition[] }) {
super();
this.title = params.title;
this.customId = buildDiscordModalCustomIdImpl(params.modalId);
this.components = params.fields.map((field) => {
const component = createModalFieldComponent(field);
class DynamicLabel extends Label {
label = field.label;
description = field.description;
component = component;
customId = field.id;
}
return new DynamicLabel(component);
});
}
async run(): Promise<void> {
throw new Error("Modal handler is not registered for dynamic forms");
}
}
export function createDiscordFormModal(entry: DiscordModalEntry): Modal {
return new DiscordFormModal({
modalId: entry.id,
title: entry.title,
fields: entry.fields,
});
}
export { Modal, type ComponentData } from "./internal/discord.js";
export function formatDiscordComponentEventText(params: {
kind: "button" | "select";

View File

@@ -1,4 +1,4 @@
import type { TopLevelComponents } from "@buape/carbon";
import type { TopLevelComponents } from "./internal/discord.js";
export type DiscordComponentButtonStyle = "primary" | "secondary" | "success" | "danger" | "link";

View File

@@ -1,7 +1,11 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
createChannelMessage,
deleteChannelMessage,
editChannelMessage,
type RequestClient,
} from "./internal/discord.js";
/** Discord messages cap at 2000 characters. */
const DISCORD_STREAM_MAX_CHARS = 2000;
@@ -76,7 +80,7 @@ export function createDiscordDraftStream(params: {
try {
if (streamMessageId !== undefined) {
// Edit existing message
await rest.patch(Routes.channelMessage(channelId, streamMessageId), {
await editChannelMessage(rest, channelId, streamMessageId, {
body: { content: trimmed, allowed_mentions: DISCORD_PREVIEW_ALLOWED_MENTIONS },
});
return true;
@@ -86,13 +90,13 @@ export function createDiscordDraftStream(params: {
const messageReference = replyToMessageId
? { message_id: replyToMessageId, fail_if_not_exists: false }
: undefined;
const sent = (await rest.post(Routes.channelMessages(channelId), {
const sent = await createChannelMessage<{ id?: string }>(rest, channelId, {
body: {
content: trimmed,
allowed_mentions: DISCORD_PREVIEW_ALLOWED_MENTIONS,
...(messageReference ? { message_reference: messageReference } : {}),
},
})) as { id?: string } | undefined;
});
const sentMessageId = sent?.id;
if (typeof sentMessageId !== "string" || !sentMessageId) {
streamState.stopped = true;
@@ -114,7 +118,7 @@ export function createDiscordDraftStream(params: {
};
const isValidStreamMessageId = (value: unknown): value is string => typeof value === "string";
const deleteStreamMessage = async (messageId: string) => {
await rest.delete(Routes.channelMessage(channelId, messageId));
await deleteChannelMessage(rest, channelId, messageId);
};
const { loop, update, stop, clear, discardPending, seal } = createFinalizableDraftLifecycle({

View File

@@ -0,0 +1,51 @@
import { Routes, type APIApplicationCommand } from "discord-api-types/v10";
import type { RequestClient } from "./rest.js";
export async function listApplicationCommands(
rest: RequestClient,
clientId: string,
): Promise<APIApplicationCommand[]> {
return (await rest.get(Routes.applicationCommands(clientId))) as APIApplicationCommand[];
}
export async function createApplicationCommand(
rest: RequestClient,
clientId: string,
body: unknown,
): Promise<unknown> {
return await rest.post(Routes.applicationCommands(clientId), { body });
}
export async function editApplicationCommand(
rest: RequestClient,
clientId: string,
commandId: string,
body: unknown,
): Promise<unknown> {
return await rest.patch(Routes.applicationCommand(clientId, commandId), { body });
}
export async function deleteApplicationCommand(
rest: RequestClient,
clientId: string,
commandId: string,
): Promise<void> {
await rest.delete(Routes.applicationCommand(clientId, commandId));
}
export async function overwriteApplicationCommands(
rest: RequestClient,
clientId: string,
body: unknown,
): Promise<void> {
await rest.put(Routes.applicationCommands(clientId), { body });
}
export async function overwriteGuildApplicationCommands(
rest: RequestClient,
clientId: string,
guildId: string,
body: unknown,
): Promise<void> {
await rest.put(Routes.applicationGuildCommands(clientId, guildId), { body });
}

View File

@@ -0,0 +1,164 @@
import {
Routes,
type APIChannel,
type APIGuild,
type APIGuildMember,
type APIGuildScheduledEvent,
type APIRole,
type APIVoiceState,
type RESTPostAPIGuildScheduledEventJSONBody,
} from "discord-api-types/v10";
import type { RequestClient, RequestData } from "./rest.js";
export async function getGuild(rest: RequestClient, guildId: string): Promise<APIGuild> {
return (await rest.get(Routes.guild(guildId))) as APIGuild;
}
export async function createGuildChannel(
rest: RequestClient,
guildId: string,
data: RequestData,
): Promise<APIChannel> {
return (await rest.post(Routes.guildChannels(guildId), data)) as APIChannel;
}
export async function moveGuildChannels(
rest: RequestClient,
guildId: string,
data: RequestData,
): Promise<void> {
await rest.patch(Routes.guildChannels(guildId), data);
}
export async function getGuildMember(
rest: RequestClient,
guildId: string,
userId: string,
): Promise<APIGuildMember> {
return (await rest.get(Routes.guildMember(guildId, userId))) as APIGuildMember;
}
export async function listGuildRoles(rest: RequestClient, guildId: string): Promise<APIRole[]> {
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
}
export async function listGuildChannels(
rest: RequestClient,
guildId: string,
): Promise<APIChannel[]> {
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
}
export async function putChannelPermission(
rest: RequestClient,
channelId: string,
targetId: string,
data: RequestData,
): Promise<void> {
await rest.put(Routes.channelPermission(channelId, targetId), data);
}
export async function deleteChannelPermission(
rest: RequestClient,
channelId: string,
targetId: string,
): Promise<void> {
await rest.delete(Routes.channelPermission(channelId, targetId));
}
export async function listGuildActiveThreads(
rest: RequestClient,
guildId: string,
): Promise<unknown> {
return await rest.get(Routes.guildActiveThreads(guildId));
}
export async function getGuildVoiceState(
rest: RequestClient,
guildId: string,
userId: string,
): Promise<APIVoiceState> {
return (await rest.get(Routes.guildVoiceState(guildId, userId))) as APIVoiceState;
}
export async function listGuildScheduledEvents(
rest: RequestClient,
guildId: string,
): Promise<APIGuildScheduledEvent[]> {
return (await rest.get(Routes.guildScheduledEvents(guildId))) as APIGuildScheduledEvent[];
}
export async function createGuildScheduledEvent(
rest: RequestClient,
guildId: string,
body: RESTPostAPIGuildScheduledEventJSONBody,
): Promise<APIGuildScheduledEvent> {
return (await rest.post(Routes.guildScheduledEvents(guildId), {
body,
})) as APIGuildScheduledEvent;
}
export async function timeoutGuildMember(
rest: RequestClient,
guildId: string,
userId: string,
data: RequestData,
): Promise<APIGuildMember> {
return (await rest.patch(Routes.guildMember(guildId, userId), data)) as APIGuildMember;
}
export async function addGuildMemberRole(
rest: RequestClient,
guildId: string,
userId: string,
roleId: string,
): Promise<void> {
await rest.put(Routes.guildMemberRole(guildId, userId, roleId));
}
export async function removeGuildMemberRole(
rest: RequestClient,
guildId: string,
userId: string,
roleId: string,
): Promise<void> {
await rest.delete(Routes.guildMemberRole(guildId, userId, roleId));
}
export async function removeGuildMember(
rest: RequestClient,
guildId: string,
userId: string,
data?: RequestData,
): Promise<void> {
await rest.delete(Routes.guildMember(guildId, userId), data);
}
export async function createGuildBan(
rest: RequestClient,
guildId: string,
userId: string,
data?: RequestData,
): Promise<void> {
await rest.put(Routes.guildBan(guildId, userId), data);
}
export async function listGuildEmojis(rest: RequestClient, guildId: string): Promise<unknown> {
return await rest.get(Routes.guildEmojis(guildId));
}
export async function createGuildEmoji(
rest: RequestClient,
guildId: string,
data: RequestData,
): Promise<unknown> {
return await rest.post(Routes.guildEmojis(guildId), data);
}
export async function createGuildSticker(
rest: RequestClient,
guildId: string,
data: RequestData,
): Promise<unknown> {
return await rest.post(Routes.guildStickers(guildId), data);
}

View File

@@ -0,0 +1,53 @@
import { Routes } from "discord-api-types/v10";
import type { RequestQuery } from "./rest-scheduler.js";
import type { RequestClient, RequestData } from "./rest.js";
export async function createInteractionCallback(
rest: RequestClient,
interactionId: string,
token: string,
body: unknown,
): Promise<unknown> {
return await rest.post(Routes.interactionCallback(interactionId, token), { body });
}
export async function editWebhookMessage(
rest: RequestClient,
applicationId: string,
token: string,
messageId: string,
data: RequestData,
query?: RequestQuery,
): Promise<unknown> {
return query
? await rest.patch(Routes.webhookMessage(applicationId, token, messageId), data, query)
: await rest.patch(Routes.webhookMessage(applicationId, token, messageId), data);
}
export async function deleteWebhookMessage(
rest: RequestClient,
applicationId: string,
token: string,
messageId: string,
): Promise<unknown> {
return await rest.delete(Routes.webhookMessage(applicationId, token, messageId));
}
export async function getWebhookMessage(
rest: RequestClient,
applicationId: string,
token: string,
messageId: string,
): Promise<unknown> {
return await rest.get(Routes.webhookMessage(applicationId, token, messageId));
}
export async function createWebhookMessage(
rest: RequestClient,
applicationId: string,
token: string,
data: RequestData,
query?: RequestQuery,
): Promise<unknown> {
return await rest.post(Routes.webhook(applicationId, token), data, query);
}

View File

@@ -0,0 +1,113 @@
import { Routes, type APIChannel, type APIMessage } from "discord-api-types/v10";
import type { RequestQuery } from "./rest-scheduler.js";
import type { RequestClient, RequestData } from "./rest.js";
export async function getChannel(rest: RequestClient, channelId: string): Promise<APIChannel> {
return (await rest.get(Routes.channel(channelId))) as APIChannel;
}
export async function editChannel(
rest: RequestClient,
channelId: string,
data: RequestData,
): Promise<APIChannel> {
return (await rest.patch(Routes.channel(channelId), data)) as APIChannel;
}
export async function deleteChannel(rest: RequestClient, channelId: string): Promise<void> {
await rest.delete(Routes.channel(channelId));
}
export async function listChannelMessages(
rest: RequestClient,
channelId: string,
query?: RequestQuery,
): Promise<APIMessage[]> {
return (await rest.get(Routes.channelMessages(channelId), query)) as APIMessage[];
}
export async function getChannelMessage(
rest: RequestClient,
channelId: string,
messageId: string,
): Promise<APIMessage> {
return (await rest.get(Routes.channelMessage(channelId, messageId))) as APIMessage;
}
export async function createChannelMessage<T extends object = APIMessage>(
rest: RequestClient,
channelId: string,
data: RequestData,
): Promise<T> {
return (await rest.post(Routes.channelMessages(channelId), data)) as T;
}
export async function editChannelMessage(
rest: RequestClient,
channelId: string,
messageId: string,
data: RequestData,
): Promise<APIMessage> {
return (await rest.patch(Routes.channelMessage(channelId, messageId), data)) as APIMessage;
}
export async function deleteChannelMessage(
rest: RequestClient,
channelId: string,
messageId: string,
): Promise<void> {
await rest.delete(Routes.channelMessage(channelId, messageId));
}
export async function pinChannelMessage(
rest: RequestClient,
channelId: string,
messageId: string,
): Promise<void> {
await rest.put(Routes.channelPin(channelId, messageId));
}
export async function unpinChannelMessage(
rest: RequestClient,
channelId: string,
messageId: string,
): Promise<void> {
await rest.delete(Routes.channelPin(channelId, messageId));
}
export async function listChannelPins(
rest: RequestClient,
channelId: string,
): Promise<APIMessage[]> {
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
}
export async function sendChannelTyping(rest: RequestClient, channelId: string): Promise<void> {
await rest.post(Routes.channelTyping(channelId));
}
export async function createThread<T extends object = APIChannel>(
rest: RequestClient,
channelId: string,
data: RequestData,
messageId?: string,
): Promise<T> {
const route = messageId ? Routes.threads(channelId, messageId) : Routes.threads(channelId);
return (await rest.post(route, data)) as T;
}
export async function listChannelArchivedThreads(
rest: RequestClient,
channelId: string,
query?: RequestQuery,
): Promise<unknown> {
return await rest.get(Routes.channelThreads(channelId, "public"), query);
}
export async function searchGuildMessages(
rest: RequestClient,
guildId: string,
params: URLSearchParams,
): Promise<unknown> {
return await rest.get(`/guilds/${guildId}/messages/search?${params.toString()}`);
}

View File

@@ -0,0 +1,38 @@
import { Routes } from "discord-api-types/v10";
import type { RequestQuery } from "./rest-scheduler.js";
import type { RequestClient } from "./rest.js";
export async function createOwnMessageReaction(
rest: RequestClient,
channelId: string,
messageId: string,
encodedEmoji: string,
): Promise<void> {
await rest.put(Routes.channelMessageOwnReaction(channelId, messageId, encodedEmoji));
}
export async function deleteOwnMessageReaction(
rest: RequestClient,
channelId: string,
messageId: string,
encodedEmoji: string,
): Promise<void> {
await rest.delete(Routes.channelMessageOwnReaction(channelId, messageId, encodedEmoji));
}
export async function listMessageReactionUsers(
rest: RequestClient,
channelId: string,
messageId: string,
encodedEmoji: string,
query?: RequestQuery,
): Promise<Array<{ id: string; username?: string; discriminator?: string }>> {
return (await rest.get(
Routes.channelMessageReaction(channelId, messageId, encodedEmoji),
query,
)) as Array<{
id: string;
username?: string;
discriminator?: string;
}>;
}

View File

@@ -0,0 +1,262 @@
import { Routes } from "discord-api-types/v10";
import { describe, expect, it } from "vitest";
import {
createApplicationCommand,
createChannelWebhook,
createChannelMessage,
createInteractionCallback,
createGuildBan,
createGuildScheduledEvent,
createOwnMessageReaction,
createThread,
createUserDmChannel,
deleteChannelMessage,
deleteOwnMessageReaction,
deleteWebhookMessage,
editApplicationCommand,
editWebhookMessage,
getCurrentUser,
getChannelMessage,
getUser,
getWebhookMessage,
createWebhookMessage,
listMessageReactionUsers,
listApplicationCommands,
listChannelMessages,
listGuildChannels,
overwriteApplicationCommands,
pinChannelMessage,
searchGuildMessages,
sendChannelTyping,
} from "./api.js";
import { createFakeRestClient } from "./test-builders.test-support.js";
describe("Discord REST API helpers", () => {
it("routes message helpers through the typed REST client", async () => {
const rest = createFakeRestClient([
[{ id: "m1" }],
{ id: "m2" },
{ id: "m3" },
{ id: "t1" },
undefined,
undefined,
undefined,
]);
const query = { limit: 2 };
await expect(listChannelMessages(rest, "c1", query)).resolves.toEqual([{ id: "m1" }]);
await expect(getChannelMessage(rest, "c1", "m2")).resolves.toEqual({ id: "m2" });
await expect(createChannelMessage(rest, "c1", { body: { content: "hello" } })).resolves.toEqual(
{ id: "m3" },
);
await expect(createThread(rest, "c1", { body: { name: "thread" } }, "m2")).resolves.toEqual({
id: "t1",
});
await sendChannelTyping(rest, "c1");
await pinChannelMessage(rest, "c1", "m2");
await deleteChannelMessage(rest, "c1", "m2");
expect(rest.calls).toEqual([
{ method: "GET", path: Routes.channelMessages("c1"), query },
{ method: "GET", path: Routes.channelMessage("c1", "m2") },
{
method: "POST",
path: Routes.channelMessages("c1"),
data: { body: { content: "hello" } },
},
{
method: "POST",
path: Routes.threads("c1", "m2"),
data: { body: { name: "thread" } },
},
{ method: "POST", path: Routes.channelTyping("c1") },
{ method: "PUT", path: Routes.channelPin("c1", "m2") },
{ method: "DELETE", path: Routes.channelMessage("c1", "m2") },
]);
});
it("routes guild helpers through the typed REST client", async () => {
const rest = createFakeRestClient([[{ id: "c1" }], { id: "event1" }, undefined]);
const body = {
name: "standup",
scheduled_start_time: "2026-04-29T10:00:00.000Z",
privacy_level: 2,
entity_type: 3,
entity_metadata: { location: "voice" },
} as const;
await expect(listGuildChannels(rest, "g1")).resolves.toEqual([{ id: "c1" }]);
await expect(createGuildScheduledEvent(rest, "g1", body)).resolves.toEqual({ id: "event1" });
await createGuildBan(rest, "g1", "u1", { body: { delete_message_seconds: 0 } });
expect(rest.calls).toEqual([
{ method: "GET", path: Routes.guildChannels("g1") },
{
method: "POST",
path: Routes.guildScheduledEvents("g1"),
data: { body },
},
{
method: "PUT",
path: Routes.guildBan("g1", "u1"),
data: { body: { delete_message_seconds: 0 } },
},
]);
});
it("routes command helpers through the typed REST client", async () => {
const rest = createFakeRestClient([
[{ id: "cmd1" }],
{ id: "cmd2" },
{ id: "cmd3" },
undefined,
]);
await expect(listApplicationCommands(rest, "app1")).resolves.toEqual([{ id: "cmd1" }]);
await expect(createApplicationCommand(rest, "app1", { name: "ping" })).resolves.toEqual({
id: "cmd2",
});
await expect(
editApplicationCommand(rest, "app1", "cmd2", { description: "Pong" }),
).resolves.toEqual({ id: "cmd3" });
await overwriteApplicationCommands(rest, "app1", [{ name: "ping" }]);
expect(rest.calls).toEqual([
{ method: "GET", path: Routes.applicationCommands("app1") },
{
method: "POST",
path: Routes.applicationCommands("app1"),
data: { body: { name: "ping" } },
},
{
method: "PATCH",
path: Routes.applicationCommand("app1", "cmd2"),
data: { body: { description: "Pong" } },
},
{
method: "PUT",
path: Routes.applicationCommands("app1"),
data: { body: [{ name: "ping" }] },
},
]);
});
it("routes user helpers through the typed REST client", async () => {
const rest = createFakeRestClient([{ id: "me" }, { id: "u1" }, { id: "dm1" }]);
await expect(getCurrentUser(rest)).resolves.toEqual({ id: "me" });
await expect(getUser(rest, "u1")).resolves.toEqual({ id: "u1" });
await expect(createUserDmChannel(rest, "u1")).resolves.toEqual({ id: "dm1" });
expect(rest.calls).toEqual([
{ method: "GET", path: Routes.user("@me") },
{ method: "GET", path: Routes.user("u1") },
{
method: "POST",
path: Routes.userChannels(),
data: { body: { recipient_id: "u1" } },
},
]);
});
it("routes reaction helpers through the typed REST client", async () => {
const rest = createFakeRestClient([undefined, [{ id: "u1" }], undefined]);
const query = { limit: 10 };
await createOwnMessageReaction(rest, "c1", "m1", "%F0%9F%91%8D");
await expect(
listMessageReactionUsers(rest, "c1", "m1", "%F0%9F%91%8D", query),
).resolves.toEqual([{ id: "u1" }]);
await deleteOwnMessageReaction(rest, "c1", "m1", "%F0%9F%91%8D");
expect(rest.calls).toEqual([
{
method: "PUT",
path: Routes.channelMessageOwnReaction("c1", "m1", "%F0%9F%91%8D"),
},
{
method: "GET",
path: Routes.channelMessageReaction("c1", "m1", "%F0%9F%91%8D"),
query,
},
{
method: "DELETE",
path: Routes.channelMessageOwnReaction("c1", "m1", "%F0%9F%91%8D"),
},
]);
});
it("routes webhook helper through the typed REST client", async () => {
const rest = createFakeRestClient([{ id: "wh1", token: "token1" }]);
await expect(createChannelWebhook(rest, "c1", { body: { name: "OpenClaw" } })).resolves.toEqual(
{
id: "wh1",
token: "token1",
},
);
expect(rest.calls).toEqual([
{
method: "POST",
path: Routes.channelWebhooks("c1"),
data: { body: { name: "OpenClaw" } },
},
]);
});
it("routes interaction webhook helpers through the typed REST client", async () => {
const rest = createFakeRestClient([
{ ok: true },
{ id: "m1" },
{ id: "m2" },
{ id: "m3" },
undefined,
]);
const query = { wait: "true" };
await expect(createInteractionCallback(rest, "i1", "itoken", { type: 5 })).resolves.toEqual({
ok: true,
});
await expect(
createWebhookMessage(rest, "app1", "wtoken", { body: { content: "hello" } }, query),
).resolves.toEqual({ id: "m1" });
await expect(getWebhookMessage(rest, "app1", "wtoken", "m1")).resolves.toEqual({ id: "m2" });
await expect(
editWebhookMessage(rest, "app1", "wtoken", "m1", { body: { content: "updated" } }),
).resolves.toEqual({ id: "m3" });
await deleteWebhookMessage(rest, "app1", "wtoken", "m1");
expect(rest.calls).toEqual([
{
method: "POST",
path: Routes.interactionCallback("i1", "itoken"),
data: { body: { type: 5 } },
},
{
method: "POST",
path: Routes.webhook("app1", "wtoken"),
data: { body: { content: "hello" } },
query,
},
{ method: "GET", path: Routes.webhookMessage("app1", "wtoken", "m1") },
{
method: "PATCH",
path: Routes.webhookMessage("app1", "wtoken", "m1"),
data: { body: { content: "updated" } },
},
{ method: "DELETE", path: Routes.webhookMessage("app1", "wtoken", "m1") },
]);
});
it("keeps unsupported Discord search route isolated", async () => {
const rest = createFakeRestClient([{ messages: [] }]);
const params = new URLSearchParams({ content: "hello" });
await expect(searchGuildMessages(rest, "g1", params)).resolves.toEqual({ messages: [] });
expect(rest.calls).toEqual([
{ method: "GET", path: "/guilds/g1/messages/search?content=hello" },
]);
});
});

View File

@@ -0,0 +1,61 @@
export {
createApplicationCommand,
deleteApplicationCommand,
editApplicationCommand,
listApplicationCommands,
overwriteApplicationCommands,
overwriteGuildApplicationCommands,
} from "./api.commands.js";
export {
addGuildMemberRole,
createGuildBan,
createGuildChannel,
createGuildEmoji,
createGuildScheduledEvent,
createGuildSticker,
deleteChannelPermission,
getGuild,
getGuildMember,
getGuildVoiceState,
listGuildActiveThreads,
listGuildChannels,
listGuildEmojis,
listGuildRoles,
listGuildScheduledEvents,
moveGuildChannels,
putChannelPermission,
removeGuildMember,
removeGuildMemberRole,
timeoutGuildMember,
} from "./api.guild.js";
export {
createInteractionCallback,
createWebhookMessage,
deleteWebhookMessage,
editWebhookMessage,
getWebhookMessage,
} from "./api.interactions.js";
export {
createChannelMessage,
createThread,
deleteChannel,
deleteChannelMessage,
editChannel,
editChannelMessage,
getChannel,
getChannelMessage,
listChannelArchivedThreads,
listChannelMessages,
listChannelPins,
pinChannelMessage,
searchGuildMessages,
sendChannelTyping,
unpinChannelMessage,
} from "./api.messages.js";
export {
createOwnMessageReaction,
deleteOwnMessageReaction,
listMessageReactionUsers,
} from "./api.reactions.js";
export { createUserDmChannel, getCurrentUser, getUser } from "./api.users.js";
export { createChannelWebhook } from "./api.webhooks.js";

View File

@@ -0,0 +1,19 @@
import { Routes, type APIChannel, type APIUser } from "discord-api-types/v10";
import type { RequestClient } from "./rest.js";
export async function getCurrentUser(rest: RequestClient): Promise<APIUser> {
return (await rest.get(Routes.user("@me"))) as APIUser;
}
export async function getUser(rest: RequestClient, userId: string): Promise<APIUser> {
return (await rest.get(Routes.user(userId))) as APIUser;
}
export async function createUserDmChannel(
rest: RequestClient,
recipientId: string,
): Promise<Pick<APIChannel, "id">> {
return (await rest.post(Routes.userChannels(), {
body: { recipient_id: recipientId },
})) as Pick<APIChannel, "id">;
}

View File

@@ -0,0 +1,13 @@
import { Routes } from "discord-api-types/v10";
import type { RequestClient, RequestData } from "./rest.js";
export async function createChannelWebhook(
rest: RequestClient,
channelId: string,
data: RequestData,
): Promise<{ id?: string; token?: string }> {
return (await rest.post(Routes.channelWebhooks(channelId), data)) as {
id?: string;
token?: string;
};
}

View File

@@ -0,0 +1,278 @@
import { ApplicationCommandType, ComponentType, Routes } from "discord-api-types/v10";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Client, ComponentRegistry, type AnyListener } from "./client.js";
import { BaseCommand } from "./commands.js";
import { Button, StringSelectMenu, parseCustomId } from "./components.js";
import { attachRestMock, createInternalTestClient } from "./test-builders.test-support.js";
function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
let reject: (reason?: unknown) => void = () => {};
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
function createTestCommand(params: {
name: string;
guildIds?: string[];
options?: unknown[];
}): BaseCommand {
return new (class extends BaseCommand {
name = params.name;
description = `${params.name} command`;
type = ApplicationCommandType.ChatInput;
guildIds = params.guildIds;
serializeOptions() {
return params.options;
}
})();
}
describe("ComponentRegistry", () => {
it("preserves digit-only custom id values as strings", () => {
const parsed = parseCustomId("agent:user=123456789012345678;count=42;enabled=true");
expect(parsed.data.user).toBe("123456789012345678");
expect(parsed.data.count).toBe("42");
expect(parsed.data.enabled).toBe(true);
});
it("resolves wildcard parser entries by component type", () => {
const registry = new ComponentRegistry<Button | StringSelectMenu>();
class WildcardButton extends Button {
label = "button";
customId = "__button_wildcard__";
customIdParser = (id: string) =>
id === this.customId || id.startsWith("occomp:")
? { key: "*", data: {} }
: parseCustomId(id);
}
class WildcardSelect extends StringSelectMenu {
customId = "__select_wildcard__";
options = [];
customIdParser = (id: string) =>
id === this.customId || id.startsWith("occomp:")
? { key: "*", data: {} }
: parseCustomId(id);
}
const button = new WildcardButton();
const select = new WildcardSelect();
registry.register(button);
registry.register(select);
expect(registry.resolve("occomp:cid=one", { componentType: ComponentType.Button })).toBe(
button,
);
expect(registry.resolve("occomp:cid=one", { componentType: ComponentType.StringSelect })).toBe(
select,
);
});
});
describe("Client.deployCommands", () => {
it("bulk overwrites all guild commands for the same guild together", async () => {
const client = createInternalTestClient([
createTestCommand({ name: "one", guildIds: ["g1"] }),
createTestCommand({ name: "two", guildIds: ["g1"] }),
]);
const put = vi.fn(async () => undefined);
attachRestMock(client, { put });
await client.deployCommands({ mode: "overwrite" });
expect(put).toHaveBeenCalledWith(Routes.applicationGuildCommands("app1", "g1"), {
body: [expect.objectContaining({ name: "one" }), expect.objectContaining({ name: "two" })],
});
expect(put).toHaveBeenCalledTimes(2);
});
it("does not patch semantically unchanged nested command options", async () => {
const client = createInternalTestClient([
createTestCommand({
name: "one",
options: [{ type: 3, name: "value", description: "Value" }],
}),
]);
const get = vi.fn(async () => [
{
id: "cmd1",
application_id: "app1",
type: ApplicationCommandType.ChatInput,
name: "one",
description: "one command",
options: [{ description: "Value", name: "value", type: 3 }],
default_member_permissions: null,
integration_types: [0, 1],
contexts: [0, 1, 2],
},
]);
const patch = vi.fn(async () => undefined);
const post = vi.fn(async () => undefined);
const deleteRequest = vi.fn(async () => undefined);
attachRestMock(client, { get, patch, post, delete: deleteRequest });
await client.deployCommands({ mode: "reconcile" });
expect(patch).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();
expect(deleteRequest).not.toHaveBeenCalled();
});
it("skips command deploy when the serialized command set is unchanged", async () => {
const client = createInternalTestClient([createTestCommand({ name: "one" })]);
const get = vi.fn(async () => []);
const post = vi.fn(async () => undefined);
attachRestMock(client, { get, post });
await client.deployCommands({ mode: "reconcile" });
await client.deployCommands({ mode: "reconcile" });
expect(get).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
});
it("caches REST object fetches briefly and invalidates from gateway updates", async () => {
const client = createInternalTestClient();
const get = vi.fn(async () => ({ id: "c1", type: 0, name: "general" }));
attachRestMock(client, { get });
await client.fetchChannel("c1");
await client.fetchChannel("c1");
expect(get).toHaveBeenCalledTimes(1);
await client.dispatchGatewayEvent("CHANNEL_UPDATE", { id: "c1" });
await client.fetchChannel("c1");
expect(get).toHaveBeenCalledTimes(2);
});
});
describe("Client gateway event queue", () => {
function createQueuedClient(params: {
listeners: AnyListener[];
eventQueue?: ConstructorParameters<typeof Client>[0]["eventQueue"];
}): Client {
return new Client(
{
baseUrl: "http://localhost",
clientId: "app1",
publicKey: "public",
token: "token",
eventQueue: params.eventQueue,
},
{ listeners: params.listeners },
);
}
it("uses OpenClaw Discord event queue defaults", () => {
const client = createQueuedClient({
listeners: [],
eventQueue: {},
});
expect(client.getRuntimeMetrics().eventQueue).toEqual(
expect.objectContaining({
maxQueueSize: 10_000,
maxConcurrency: 50,
}),
);
});
it("times out hung queued listeners", async () => {
vi.useFakeTimers();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const listener = {
type: "READY",
handle: vi.fn(async () => await new Promise<void>(() => {})),
} satisfies AnyListener;
const client = createQueuedClient({
listeners: [listener],
eventQueue: { listenerTimeout: 10, maxConcurrency: 1 },
});
const dispatch = client.dispatchGatewayEvent("READY", {});
await vi.advanceTimersByTimeAsync(10);
await expect(dispatch).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
"[EventQueue] Listener Object timed out after 10ms for event READY",
);
expect(client.getRuntimeMetrics().eventQueue).toEqual(
expect.objectContaining({ processed: 1, timeouts: 1 }),
);
});
it("limits queued listener concurrency", async () => {
const started: string[] = [];
const releaseFirst = createDeferred();
const releaseSecond = createDeferred();
const first = {
type: "READY",
handle: vi.fn(async () => {
started.push("first");
await releaseFirst.promise;
}),
} satisfies AnyListener;
const second = {
type: "READY",
handle: vi.fn(async () => {
started.push("second");
await releaseSecond.promise;
}),
} satisfies AnyListener;
const client = createQueuedClient({
listeners: [first, second],
eventQueue: { maxConcurrency: 1, listenerTimeout: 1_000 },
});
const dispatch = client.dispatchGatewayEvent("READY", {});
await vi.waitFor(() => expect(started).toEqual(["first"]));
releaseFirst.resolve();
await vi.waitFor(() => expect(started).toEqual(["first", "second"]));
releaseSecond.resolve();
await expect(dispatch).resolves.toBeUndefined();
});
it("rejects when queued listener work exceeds maxQueueSize", async () => {
const releases: Array<() => void> = [];
const listener = {
type: "READY",
handle: vi.fn(
async () =>
await new Promise<void>((resolve) => {
releases.push(resolve);
}),
),
} satisfies AnyListener;
const client = createQueuedClient({
listeners: [listener],
eventQueue: { maxConcurrency: 1, maxQueueSize: 1, listenerTimeout: 1_000 },
});
const first = client.dispatchGatewayEvent("READY", {});
await vi.waitFor(() => expect(listener.handle).toHaveBeenCalledTimes(1));
const second = client.dispatchGatewayEvent("READY", {});
await expect(client.dispatchGatewayEvent("READY", {})).rejects.toThrow(
"Discord event queue is full for READY; maxQueueSize=1",
);
releases.shift()?.();
await vi.waitFor(() => expect(listener.handle).toHaveBeenCalledTimes(2));
releases.shift()?.();
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
});
});

View File

@@ -0,0 +1,238 @@
import type { APIApplicationCommand, APIInteraction } from "discord-api-types/v10";
import { DiscordCommandDeployer, type DeployCommandOptions } from "./command-deploy.js";
import type { BaseCommand } from "./commands.js";
import { BaseMessageInteractiveComponent, parseCustomId, type Modal } from "./components.js";
import { DiscordEntityCache } from "./entity-cache.js";
import { DiscordEventQueue, type DiscordEventQueueOptions } from "./event-queue.js";
import { dispatchInteraction } from "./interaction-dispatch.js";
import { RequestClient, type RequestClientOptions } from "./rest.js";
import type { Guild, GuildMember, User } from "./structures.js";
export interface Route {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: `/${string}`;
handler(req: Request, ctx?: Context): Response | Promise<Response>;
protected?: boolean;
disabled?: boolean;
}
export interface Context {
waitUntil?(promise: Promise<unknown>): void;
env?: unknown;
}
export abstract class Plugin {
abstract readonly id: string;
registerClient?(client: Client): Promise<void> | void;
registerRoutes?(client: Client): Promise<void> | void;
onRequest?(req: Request, ctx: Context): Promise<Response | undefined> | Response | undefined;
}
export type AnyListener = {
type: string;
handle(data: unknown, client: Client): Promise<void> | void;
};
export interface ClientOptions {
baseUrl: string;
clientId: string;
deploySecret?: string;
publicKey: string | string[];
token: string;
requestOptions?: RequestClientOptions;
autoDeploy?: boolean;
disableDeployRoute?: boolean;
disableInteractionsRoute?: boolean;
disableEventsRoute?: boolean;
devGuilds?: string[];
eventQueue?: DiscordEventQueueOptions;
restCacheTtlMs?: number;
}
export class ComponentRegistry<
T extends { customId: string; customIdParser?: typeof parseCustomId; type?: number },
> {
private entries = new Map<string, T[]>();
private wildcardEntries: T[] = [];
register(entry: T): void {
const key = parseRegistryKey(entry.customId, entry.customIdParser);
if (key === "*") {
if (!this.wildcardEntries.includes(entry)) {
this.wildcardEntries.push(entry);
}
return;
}
const entries = this.entries.get(key) ?? [];
if (!entries.includes(entry)) {
entries.push(entry);
this.entries.set(key, entries);
}
}
resolve(customId: string, options?: { componentType?: number }): T | undefined {
const entries = [
...(this.entries.get(parseRegistryKey(customId)) ?? []),
...this.wildcardEntries,
];
return entries.find((entry) => {
if (options?.componentType !== undefined && entry.type !== options.componentType) {
return false;
}
return true;
});
}
}
function parseRegistryKey(customId: string, parser: typeof parseCustomId = parseCustomId): string {
return parser(customId).key;
}
export class Client {
routes: Route[] = [];
plugins: Array<{ id: string; plugin: Plugin }> = [];
options: ClientOptions;
commands: BaseCommand[];
listeners: AnyListener[];
rest: RequestClient;
componentHandler = new ComponentRegistry<BaseMessageInteractiveComponent>();
private commandDeployer: DiscordCommandDeployer;
private entityCache: DiscordEntityCache;
private eventQueue?: DiscordEventQueue;
modalHandler = new ComponentRegistry<Modal>();
shardId?: number;
totalShards?: number;
constructor(
options: ClientOptions,
handlers: {
commands?: BaseCommand[];
listeners?: AnyListener[];
components?: BaseMessageInteractiveComponent[];
modals?: Modal[];
},
plugins: Plugin[] = [],
) {
if (!options.clientId) {
throw new Error("Missing Discord application ID");
}
if (!options.token) {
throw new Error("Missing Discord bot token");
}
this.options = { ...options, baseUrl: options.baseUrl.replace(/\/+$/, "") };
this.commands = handlers.commands ?? [];
this.listeners = handlers.listeners ?? [];
this.rest = new RequestClient(options.token, options.requestOptions);
this.eventQueue = this.options.eventQueue
? new DiscordEventQueue(this.options.eventQueue)
: undefined;
this.entityCache = new DiscordEntityCache({
client: this,
rest: () => this.rest,
ttlMs: this.options.restCacheTtlMs,
});
this.commandDeployer = new DiscordCommandDeployer({
clientId: this.options.clientId,
commands: this.commands,
devGuilds: this.options.devGuilds,
rest: () => this.rest,
});
for (const component of handlers.components ?? []) {
this.componentHandler.register(component);
}
for (const command of this.commands) {
for (const component of command.components ?? []) {
this.componentHandler.register(component);
}
}
for (const modal of handlers.modals ?? []) {
this.modalHandler.register(modal);
}
for (const plugin of plugins) {
void plugin.registerClient?.(this);
void plugin.registerRoutes?.(this);
this.plugins.push({ id: plugin.id, plugin });
}
}
getPlugin<T = Plugin>(id: string): T | undefined {
return this.plugins.find((entry) => entry.id === id)?.plugin as T | undefined;
}
registerListener(listener: AnyListener): AnyListener {
if (!this.listeners.includes(listener)) {
this.listeners.push(listener);
}
return listener;
}
unregisterListener(listener: AnyListener): boolean {
const index = this.listeners.indexOf(listener);
if (index < 0) {
return false;
}
this.listeners.splice(index, 1);
return true;
}
getRuntimeMetrics() {
return {
request: this.rest.getSchedulerMetrics(),
eventQueue: this.eventQueue?.getMetrics(),
};
}
async fetchUser(id: string): Promise<User> {
return await this.entityCache.fetchUser(id);
}
async fetchChannel(id: string) {
return await this.entityCache.fetchChannel(id);
}
async fetchGuild(id: string): Promise<Guild> {
return await this.entityCache.fetchGuild(id);
}
async fetchMember(guildId: string, userId: string): Promise<GuildMember> {
return await this.entityCache.fetchMember(guildId, userId);
}
async getDiscordCommands(): Promise<APIApplicationCommand[]> {
return await this.commandDeployer.getCommands();
}
async deployCommands(options: DeployCommandOptions = {}) {
return await this.commandDeployer.deploy(options);
}
async reconcileCommands() {
return await this.deployCommands({ mode: "reconcile" });
}
async handleInteraction(rawData: APIInteraction, _ctx?: Context): Promise<void> {
await dispatchInteraction(this, rawData);
}
async dispatchGatewayEvent(type: string, data: unknown): Promise<void> {
this.entityCache.invalidateForGatewayEvent(type, data);
const listeners = this.listeners.filter((entry) => entry.type === type);
if (!this.eventQueue) {
for (const listener of listeners) {
await listener.handle(data, this);
}
return;
}
await Promise.all(
listeners.map((listener) =>
this.eventQueue!.enqueue({
eventType: type,
listenerName: listener.constructor.name || "AnonymousListener",
run: async () => {
await listener.handle(data, this);
},
}),
),
);
}
}

View File

@@ -0,0 +1,202 @@
import { createHash } from "node:crypto";
import { ApplicationCommandType, type APIApplicationCommand } from "discord-api-types/v10";
import {
createApplicationCommand,
deleteApplicationCommand,
editApplicationCommand,
listApplicationCommands,
overwriteApplicationCommands,
overwriteGuildApplicationCommands,
} from "./api.js";
import type { BaseCommand } from "./commands.js";
import type { RequestClient } from "./rest.js";
export type DeployCommandOptions = {
mode?: "overwrite" | "reconcile";
force?: boolean;
};
type SerializedCommand = ReturnType<BaseCommand["serialize"]>;
export class DiscordCommandDeployer {
private readonly hashes = new Map<string, string>();
constructor(
private readonly params: {
clientId: string;
commands: BaseCommand[];
devGuilds?: string[];
rest: () => RequestClient;
},
) {}
async getCommands(): Promise<APIApplicationCommand[]> {
return await listApplicationCommands(this.rest, this.params.clientId);
}
async deploy(options: DeployCommandOptions = {}) {
const commands = this.params.commands.filter((command) => command.name !== "*");
const globalCommands = commands.filter((command) => !command.guildIds);
const serializedGlobal = globalCommands.map((command) => command.serialize());
for (const [guildId, entries] of groupGuildCommands(commands)) {
await this.putCommandSetIfChanged(
`guild:${guildId}`,
entries,
async () => {
await overwriteGuildApplicationCommands(
this.rest,
this.params.clientId,
guildId,
entries,
);
},
options,
);
}
if (this.params.devGuilds?.length) {
for (const guildId of this.params.devGuilds) {
const entries = commands.map((command) => command.serialize());
await this.putCommandSetIfChanged(
`dev-guild:${guildId}`,
entries,
async () => {
await overwriteGuildApplicationCommands(
this.rest,
this.params.clientId,
guildId,
entries,
);
},
options,
);
}
return { mode: options.mode ?? "reconcile", usedDevGuilds: true };
}
if (options.mode !== "overwrite") {
await this.putCommandSetIfChanged(
"global:reconcile",
serializedGlobal,
async () => {
await this.reconcileGlobalCommands(serializedGlobal);
},
options,
);
return { mode: "reconcile" as const, usedDevGuilds: false };
}
await this.putCommandSetIfChanged(
"global:overwrite",
serializedGlobal,
async () => {
await overwriteApplicationCommands(this.rest, this.params.clientId, serializedGlobal);
},
options,
);
return { mode: "overwrite" as const, usedDevGuilds: false };
}
private async reconcileGlobalCommands(desired: SerializedCommand[]) {
const existing = await this.getCommands();
const existingByKey = new Map(existing.map((command) => [stableCommandKey(command), command]));
const desiredKeys = new Set<string>();
for (const command of desired) {
const key = stableCommandKey(command as APIApplicationCommand);
desiredKeys.add(key);
const current = existingByKey.get(key);
if (!current) {
await createApplicationCommand(this.rest, this.params.clientId, command);
continue;
}
if (!commandsEqual(current, command)) {
await editApplicationCommand(this.rest, this.params.clientId, current.id, command);
}
}
for (const command of existing) {
if (!desiredKeys.has(stableCommandKey(command))) {
await deleteApplicationCommand(this.rest, this.params.clientId, command.id);
}
}
}
private async putCommandSetIfChanged(
key: string,
commands: SerializedCommand[],
deploy: () => Promise<void>,
options: { force?: boolean },
): Promise<void> {
const hash = stableCommandSetHash(commands);
if (!options.force && this.hashes.get(key) === hash) {
return;
}
await deploy();
this.hashes.set(key, hash);
}
private get rest(): RequestClient {
return this.params.rest();
}
}
function groupGuildCommands(commands: BaseCommand[]): Map<string, SerializedCommand[]> {
const guildCommands = new Map<string, SerializedCommand[]>();
for (const command of commands.filter((entry) => entry.guildIds)) {
for (const guildId of command.guildIds ?? []) {
const entries = guildCommands.get(guildId) ?? [];
entries.push(command.serialize());
guildCommands.set(guildId, entries);
}
}
return guildCommands;
}
function stableCommandKey(command: Pick<APIApplicationCommand, "name" | "type">) {
return `${command.type ?? ApplicationCommandType.ChatInput}:${command.name}`;
}
function comparableCommand(value: unknown): unknown {
if (!value || typeof value !== "object") {
return value;
}
const omit = new Set([
"id",
"application_id",
"guild_id",
"version",
"default_permission",
"nsfw",
]);
return stableComparableObject(
Object.fromEntries(
Object.entries(value).filter(([key, entry]) => !omit.has(key) && entry !== undefined),
),
);
}
function stableComparableObject(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => stableComparableObject(entry));
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, entry]) => [key, stableComparableObject(entry)]),
);
}
function commandsEqual(a: unknown, b: unknown) {
return JSON.stringify(comparableCommand(a)) === JSON.stringify(comparableCommand(b));
}
function stableCommandSetHash(commands: SerializedCommand[]): string {
const stable = commands
.map((command) => stableComparableObject(command))
.toSorted((a, b) =>
stableCommandKey(a as APIApplicationCommand).localeCompare(
stableCommandKey(b as APIApplicationCommand),
),
);
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
}

View File

@@ -0,0 +1,188 @@
import {
ApplicationCommandOptionType,
ApplicationCommandType,
InteractionContextType,
type RESTPostAPIApplicationCommandsJSONBody,
} from "discord-api-types/v10";
import type { BaseMessageInteractiveComponent } from "./components.js";
import type { AutocompleteInteraction, CommandInteraction } from "./interactions.js";
export type ConditionalCommandOption = (interaction: unknown) => boolean;
export type CommandOption = Record<string, unknown> & {
name: string;
description?: string;
type: ApplicationCommandOptionType;
required?: boolean;
choices?: Array<{ name: string; value: string | number | boolean }>;
autocomplete?: boolean | ((interaction: AutocompleteInteraction) => Promise<void>);
};
export type CommandOptions = CommandOption[];
type RawSubcommandOption = {
name?: unknown;
type?: unknown;
options?: RawSubcommandOption[];
};
function clean<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
}
function resolveConditionalCommandOption(
value: boolean | ConditionalCommandOption,
interaction: unknown,
): boolean {
return typeof value === "function" ? value(interaction) : value;
}
export async function deferCommandInteractionIfNeeded(
command: BaseCommand,
interaction: CommandInteraction,
): Promise<void> {
if (!resolveConditionalCommandOption(command.defer, interaction)) {
return;
}
await interaction.defer({
ephemeral: resolveConditionalCommandOption(command.ephemeral, interaction),
});
}
function readRawCommandOptions(interaction: CommandInteraction): RawSubcommandOption[] {
const options = (interaction.rawData as { data?: { options?: unknown } }).data?.options;
return Array.isArray(options) ? (options as RawSubcommandOption[]) : [];
}
function findSelectedSubcommand(
subcommands: Command[],
interaction: CommandInteraction,
): Command | undefined {
const subcommandName = readRawCommandOptions(interaction).find(
(option) => option.type === ApplicationCommandOptionType.Subcommand,
)?.name;
return typeof subcommandName === "string"
? subcommands.find((command) => command.name === subcommandName)
: undefined;
}
function findCommandOption(
options: CommandOptions | undefined,
name: string | undefined,
): CommandOption | undefined {
if (!name) {
return undefined;
}
return options?.find((option) => option.name === name);
}
function hasCommandOptions(
command: BaseCommand,
): command is BaseCommand & { options?: CommandOptions } {
return "options" in command;
}
export function resolveFocusedCommandOptionAutocompleteHandler(
command: BaseCommand,
interaction: AutocompleteInteraction,
): ((interaction: AutocompleteInteraction) => Promise<void>) | undefined {
const focusedName = interaction.options.getFocused()?.name;
const options =
"subcommands" in command && Array.isArray(command.subcommands)
? findSelectedSubcommand(command.subcommands, interaction)?.options
: hasCommandOptions(command)
? command.options
: undefined;
const autocomplete = findCommandOption(options, focusedName)?.autocomplete;
return typeof autocomplete === "function" ? autocomplete : undefined;
}
export abstract class BaseCommand {
id?: string;
abstract name: string;
description?: string;
nameLocalizations?: Record<string, string>;
descriptionLocalizations?: Record<string, string>;
defer: boolean | ConditionalCommandOption = false;
ephemeral: boolean | ConditionalCommandOption = false;
abstract type: ApplicationCommandType;
integrationTypes = [0, 1];
contexts = [
InteractionContextType.Guild,
InteractionContextType.BotDM,
InteractionContextType.PrivateChannel,
];
permission?: bigint | bigint[];
components?: BaseMessageInteractiveComponent[];
guildIds?: string[];
abstract serializeOptions(): unknown[] | undefined;
serialize(): RESTPostAPIApplicationCommandsJSONBody {
return clean({
name: this.name,
name_localizations: this.nameLocalizations,
description:
this.type === ApplicationCommandType.ChatInput ? (this.description ?? "") : undefined,
description_localizations: this.descriptionLocalizations,
type: this.type,
options: this.serializeOptions() as RESTPostAPIApplicationCommandsJSONBody["options"],
integration_types: this.integrationTypes,
contexts: this.contexts,
default_member_permissions: Array.isArray(this.permission)
? this.permission.reduce((sum, entry) => sum | entry, 0n).toString()
: this.permission
? this.permission.toString()
: null,
}) as RESTPostAPIApplicationCommandsJSONBody;
}
}
export abstract class Command extends BaseCommand {
options?: CommandOptions;
type = ApplicationCommandType.ChatInput;
abstract run(interaction: unknown): unknown;
async autocomplete(interaction: unknown): Promise<void> {
throw new Error(
`The ${(interaction as { rawData?: { data?: { name?: string } } }).rawData?.data?.name ?? this.name} command does not support autocomplete`,
);
}
async preCheck(interaction: unknown): Promise<unknown> {
return Boolean(interaction) || true;
}
serializeOptions() {
return this.options?.map((option) => {
if (typeof option.autocomplete === "function") {
const { autocomplete: _autocomplete, ...rest } = option;
return { ...rest, autocomplete: true };
}
return option;
}) as unknown[];
}
}
export abstract class CommandWithSubcommands extends BaseCommand {
type = ApplicationCommandType.ChatInput;
abstract subcommands: Command[];
async run(interaction: CommandInteraction): Promise<unknown> {
const subcommand = findSelectedSubcommand(this.subcommands, interaction);
if (!subcommand) {
const subcommandName = readRawCommandOptions(interaction).find(
(option) => option.type === ApplicationCommandOptionType.Subcommand,
)?.name;
throw new Error(
`Unknown Discord subcommand: ${typeof subcommandName === "string" ? subcommandName : "<missing>"}`,
);
}
await deferCommandInteractionIfNeeded(subcommand, interaction);
return await subcommand.run(interaction);
}
serializeOptions() {
return this.subcommands.map((command) =>
clean({
name: command.name,
name_localizations: command.nameLocalizations,
description: command.description ?? "",
description_localizations: command.descriptionLocalizations,
type: ApplicationCommandOptionType.Subcommand,
options: command.serializeOptions(),
}),
);
}
}

View File

@@ -0,0 +1,65 @@
import type { BaseComponentInteraction } from "./interactions.js";
export type ComponentParserResult = {
key: string;
data: Record<string, string | boolean>;
};
export type ComponentData<
T extends keyof ComponentParserResult["data"] = keyof ComponentParserResult["data"],
> = {
[K in T]: ComponentParserResult["data"][K];
};
export type ConditionalComponentOption = (interaction: BaseComponentInteraction) => boolean;
export function parseCustomId(id: string): ComponentParserResult {
const [rawKey, ...parts] = id.split(";");
const [keyPart, firstValue] = rawKey.split("=");
const key = keyPart.includes(":") ? keyPart.split(":")[0] : keyPart;
const data: ComponentParserResult["data"] = {};
const entries = firstValue === undefined ? parts : [rawKey.slice(key.length + 1), ...parts];
for (const entry of entries) {
const index = entry.indexOf("=");
if (index < 0) {
continue;
}
const name = entry.slice(0, index).replace(/^[^:]+:/, "");
const raw = entry.slice(index + 1);
data[name] = raw === "true" ? true : raw === "false" ? false : raw;
}
return { key, data };
}
export function clean<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
}
export function colorToNumber(value: string | number | undefined): number | undefined {
if (typeof value === "number") {
return value;
}
if (typeof value === "string" && /^#?[0-9a-f]{6}$/i.test(value)) {
return Number.parseInt(value.replace(/^#/, ""), 16);
}
return undefined;
}
export abstract class BaseComponent {
abstract readonly type: number;
readonly isV2: boolean = false;
abstract serialize(): unknown;
}
export abstract class BaseMessageInteractiveComponent extends BaseComponent {
readonly isV2 = false;
defer: boolean | ConditionalComponentOption = false;
ephemeral: boolean | ConditionalComponentOption = false;
abstract customId: string;
customIdParser = parseCustomId;
run(_interaction: BaseComponentInteraction, _data: ComponentData): unknown {
return undefined;
}
}
export abstract class BaseModalComponent extends BaseComponent {
abstract customId: string;
}

View File

@@ -0,0 +1,279 @@
import {
ButtonStyle,
ComponentType,
type APIActionRowComponent,
type APIButtonComponent,
type APIChannelSelectComponent,
type APIComponentInMessageActionRow,
type APIContainerComponent,
type APIFileComponent,
type APIMediaGalleryComponent,
type APISectionComponent,
type APISeparatorComponent,
type APIStringSelectComponent,
type APITextDisplayComponent,
type APIThumbnailComponent,
} from "discord-api-types/v10";
import {
BaseComponent,
BaseMessageInteractiveComponent,
clean,
colorToNumber,
} from "./components.base.js";
abstract class BaseButton extends BaseMessageInteractiveComponent {
readonly type = ComponentType.Button;
abstract label: string;
emoji?: { name: string; id?: string; animated?: boolean };
style: ButtonStyle = ButtonStyle.Primary;
disabled = false;
}
export abstract class Button extends BaseButton {
serialize(): APIButtonComponent {
return clean({
type: this.type,
style: this.style,
custom_id: this.customId,
label: this.label,
emoji: this.emoji,
disabled: this.disabled || undefined,
}) as APIButtonComponent;
}
}
export abstract class LinkButton extends BaseButton {
customId = "";
abstract url: string;
style = ButtonStyle.Link;
async run(): Promise<never> {
throw new Error("Link buttons do not run handlers");
}
serialize(): APIButtonComponent {
return clean({
type: this.type,
style: this.style,
label: this.label,
emoji: this.emoji,
disabled: this.disabled || undefined,
url: this.url,
}) as APIButtonComponent;
}
}
export abstract class AnySelectMenu extends BaseMessageInteractiveComponent {
placeholder?: string;
minValues?: number;
maxValues?: number;
disabled = false;
required?: boolean;
abstract serializeOptions(): Record<string, unknown>;
serialize() {
return clean({
...this.serializeOptions(),
custom_id: this.customId,
placeholder: this.placeholder,
min_values: this.minValues,
max_values: this.maxValues,
disabled: this.disabled || undefined,
required: this.required,
});
}
}
export abstract class StringSelectMenu extends AnySelectMenu {
readonly type = ComponentType.StringSelect;
abstract options: APIStringSelectComponent["options"];
serializeOptions() {
return { type: this.type, options: this.options };
}
}
export abstract class UserSelectMenu extends AnySelectMenu {
readonly type = ComponentType.UserSelect;
defaultValues?: unknown[];
serializeOptions() {
return { type: this.type, default_values: this.defaultValues };
}
}
export abstract class RoleSelectMenu extends AnySelectMenu {
readonly type = ComponentType.RoleSelect;
defaultValues?: unknown[];
serializeOptions() {
return { type: this.type, default_values: this.defaultValues };
}
}
export abstract class MentionableSelectMenu extends AnySelectMenu {
readonly type = ComponentType.MentionableSelect;
defaultValues?: unknown[];
serializeOptions() {
return { type: this.type, default_values: this.defaultValues };
}
}
export abstract class ChannelSelectMenu extends AnySelectMenu {
readonly type = ComponentType.ChannelSelect;
channelTypes?: APIChannelSelectComponent["channel_types"];
defaultValues?: unknown[];
serializeOptions() {
return {
type: this.type,
default_values: this.defaultValues,
channel_types: this.channelTypes,
};
}
}
export class Row<T extends BaseMessageInteractiveComponent> extends BaseComponent {
readonly type = ComponentType.ActionRow;
readonly isV2 = false;
components: T[];
constructor(components: T[] = []) {
super();
this.components = components;
}
addComponent(component: T): void {
this.components.push(component);
}
removeComponent(component: T): void {
this.components = this.components.filter((entry) => entry !== component);
}
removeAllComponents(): void {
this.components = [];
}
serialize(): APIActionRowComponent<APIComponentInMessageActionRow> {
return {
type: this.type,
components: this.components.map(
(entry) => entry.serialize() as APIComponentInMessageActionRow,
),
};
}
}
export class TextDisplay extends BaseComponent {
readonly type = ComponentType.TextDisplay;
readonly isV2 = true;
constructor(public content?: string) {
super();
}
serialize(): APITextDisplayComponent {
return clean({ type: this.type, content: this.content }) as APITextDisplayComponent;
}
}
export class Separator extends BaseComponent {
readonly type = ComponentType.Separator;
readonly isV2 = true;
divider = true;
spacing: 1 | 2 | "small" | "large" = "small";
constructor(options?: { spacing?: Separator["spacing"]; divider?: boolean }) {
super();
this.spacing = options?.spacing ?? this.spacing;
this.divider = options?.divider ?? this.divider;
}
serialize(): APISeparatorComponent {
return clean({
type: this.type,
divider: this.divider,
spacing: this.spacing === "large" ? 2 : this.spacing === "small" ? 1 : this.spacing,
}) as APISeparatorComponent;
}
}
export class Thumbnail extends BaseComponent {
readonly type = ComponentType.Thumbnail;
readonly isV2 = true;
constructor(public url?: string) {
super();
}
serialize(): APIThumbnailComponent {
return clean({
type: this.type,
media: this.url ? { url: this.url } : undefined,
}) as APIThumbnailComponent;
}
}
export class Section extends BaseComponent {
readonly type = ComponentType.Section;
readonly isV2 = true;
constructor(
public components: TextDisplay[] = [],
public accessory?: Thumbnail | Button | LinkButton,
) {
super();
}
serialize(): APISectionComponent {
return clean({
type: this.type,
components: this.components.map((entry) => entry.serialize()),
accessory: this.accessory?.serialize(),
}) as APISectionComponent;
}
}
export class MediaGallery extends BaseComponent {
readonly type = ComponentType.MediaGallery;
readonly isV2 = true;
constructor(public items: Array<{ url: string; description?: string; spoiler?: boolean }> = []) {
super();
}
serialize(): APIMediaGalleryComponent {
return {
type: this.type,
items: this.items.map((entry) => ({
media: { url: entry.url },
description: entry.description,
spoiler: entry.spoiler,
})),
};
}
}
export class File extends BaseComponent {
readonly type = ComponentType.File;
readonly isV2 = true;
constructor(
public file?: `attachment://${string}`,
public spoiler = false,
) {
super();
}
serialize(): APIFileComponent {
return clean({
type: this.type,
file: this.file ? { url: this.file } : undefined,
spoiler: this.spoiler || undefined,
}) as APIFileComponent;
}
}
export class Container extends BaseComponent {
readonly type = ComponentType.Container;
readonly isV2 = true;
components: Array<
Row<BaseMessageInteractiveComponent> | TextDisplay | Section | MediaGallery | Separator | File
>;
accentColor?: string | number;
spoiler = false;
constructor(
components: Container["components"] = [],
options?: { accentColor?: string | number; spoiler?: boolean },
) {
super();
this.components = components;
this.accentColor = options?.accentColor;
this.spoiler = options?.spoiler ?? false;
}
serialize(): APIContainerComponent {
return clean({
type: this.type,
components: this.components.map((entry) => entry.serialize()),
accent_color: colorToNumber(this.accentColor),
spoiler: this.spoiler || undefined,
}) as APIContainerComponent;
}
}

View File

@@ -0,0 +1,95 @@
import { ComponentType, TextInputStyle, type APITextInputComponent } from "discord-api-types/v10";
import { BaseModalComponent, clean, parseCustomId, type ComponentData } from "./components.base.js";
import { AnySelectMenu, TextDisplay } from "./components.message.js";
export abstract class TextInput extends BaseModalComponent {
readonly type = ComponentType.TextInput;
customIdParser = parseCustomId;
style: TextInputStyle = TextInputStyle.Short;
minLength?: number;
maxLength?: number;
required?: boolean;
value?: string;
placeholder?: string;
serialize(): APITextInputComponent {
return clean({
type: this.type,
custom_id: this.customId,
style: this.style,
min_length: this.minLength,
max_length: this.maxLength,
required: this.required,
value: this.value,
placeholder: this.placeholder,
}) as APITextInputComponent;
}
}
export abstract class CheckboxGroup extends BaseModalComponent {
readonly type = 22;
options: Array<{ value: string; label: string; description?: string; default?: boolean }> = [];
required?: boolean;
minValues?: number;
maxValues?: number;
serialize() {
return clean({
type: this.type,
custom_id: this.customId,
options: this.options,
required: this.required,
min_values: this.minValues,
max_values: this.maxValues,
});
}
}
export abstract class RadioGroup extends BaseModalComponent {
readonly type = 21;
options: Array<{ value: string; label: string; description?: string; default?: boolean }> = [];
required?: boolean;
minValues?: number;
maxValues?: number;
serialize() {
return clean({
type: this.type,
custom_id: this.customId,
options: this.options,
required: this.required,
min_values: this.minValues,
max_values: this.maxValues,
});
}
}
export abstract class Label extends BaseModalComponent {
readonly type = ComponentType.Label;
abstract label: string;
description?: string;
customId = "";
constructor(public component?: TextInput | AnySelectMenu | CheckboxGroup | RadioGroup) {
super();
}
serialize() {
return clean({
type: this.type,
label: this.label,
description: this.description,
component: this.component?.serialize(),
});
}
}
export abstract class Modal {
abstract title: string;
components: Array<Label | TextDisplay> = [];
abstract customId: string;
customIdParser = parseCustomId;
abstract run(interaction: unknown, data: ComponentData): unknown;
serialize() {
return {
title: this.title,
custom_id: this.customId,
components: this.components.map((entry) => entry.serialize()),
};
}
}

View File

@@ -0,0 +1,31 @@
export {
BaseComponent,
BaseMessageInteractiveComponent,
BaseModalComponent,
clean,
colorToNumber,
parseCustomId,
type ComponentData,
type ComponentParserResult,
type ConditionalComponentOption,
} from "./components.base.js";
export {
AnySelectMenu,
Button,
ChannelSelectMenu,
Container,
File,
LinkButton,
MediaGallery,
MentionableSelectMenu,
RoleSelectMenu,
Row,
Section,
Separator,
StringSelectMenu,
TextDisplay,
Thumbnail,
UserSelectMenu,
} from "./components.message.js";
export { CheckboxGroup, Label, Modal, RadioGroup, TextInput } from "./components.modal.js";
export { serializePayload, type MessagePayload, type MessagePayloadObject } from "./payload.js";

View File

@@ -0,0 +1,11 @@
export * from "discord-api-types/v10";
export * from "./api.js";
export * from "./client.js";
export * from "./commands.js";
export * from "./components.js";
export * from "./embeds.js";
export * from "./interactions.js";
export * from "./listeners.js";
export * from "./payload.js";
export * from "./rest.js";
export * from "./structures.js";

View File

@@ -0,0 +1,35 @@
import type { APIEmbed } from "discord-api-types/v10";
function clean<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
}
export class Embed {
title?: string;
description?: string;
url?: string;
timestamp?: string;
color?: number;
footer?: APIEmbed["footer"];
image?: string | APIEmbed["image"];
thumbnail?: string | APIEmbed["thumbnail"];
author?: APIEmbed["author"];
fields?: APIEmbed["fields"];
constructor(embed?: APIEmbed) {
Object.assign(this, embed);
}
serialize(): APIEmbed {
return clean({
title: this.title,
description: this.description,
url: this.url,
timestamp: this.timestamp,
color: this.color,
footer: this.footer,
image: typeof this.image === "string" ? { url: this.image } : this.image,
thumbnail: typeof this.thumbnail === "string" ? { url: this.thumbnail } : this.thumbnail,
author: this.author,
fields: this.fields,
}) as APIEmbed;
}
}

View File

@@ -0,0 +1,99 @@
import { GatewayDispatchEvents } from "discord-api-types/v10";
import { getChannel, getGuild, getGuildMember, getUser } from "./api.js";
import type { Client } from "./client.js";
import type { RequestClient } from "./rest.js";
import { Guild, GuildMember, User, channelFactory } from "./structures.js";
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const DEFAULT_REST_CACHE_TTL_MS = 30_000;
export class DiscordEntityCache {
private readonly entries = new Map<string, CacheEntry<unknown>>();
constructor(
private readonly params: {
client: Client;
rest: RequestClient | (() => RequestClient);
ttlMs?: number;
},
) {}
async fetchUser(id: string): Promise<User> {
return await this.fetchCached(`user:${id}`, async () => {
const raw = await getUser(this.rest, id);
return new User(this.params.client, raw);
});
}
async fetchChannel(id: string) {
return await this.fetchCached(`channel:${id}`, async () => {
const raw = await getChannel(this.rest, id);
return channelFactory(this.params.client, raw);
});
}
async fetchGuild(id: string): Promise<Guild> {
return await this.fetchCached(`guild:${id}`, async () => {
const raw = await getGuild(this.rest, id);
return new Guild(this.params.client, raw);
});
}
async fetchMember(guildId: string, userId: string): Promise<GuildMember> {
return await this.fetchCached(`member:${guildId}:${userId}`, async () => {
const raw = await getGuildMember(this.rest, guildId, userId);
return new GuildMember(this.params.client, raw);
});
}
invalidateForGatewayEvent(type: string, data: unknown): void {
const raw = data && typeof data === "object" ? (data as Record<string, unknown>) : {};
const channelUpdate: string = GatewayDispatchEvents.ChannelUpdate;
const channelDelete: string = GatewayDispatchEvents.ChannelDelete;
const guildUpdate: string = GatewayDispatchEvents.GuildUpdate;
const guildMemberUpdate: string = GatewayDispatchEvents.GuildMemberUpdate;
if (type === channelUpdate || type === channelDelete) {
this.deleteId("channel", raw.id);
}
if (type === guildUpdate) {
this.deleteId("guild", raw.id);
}
if (type === guildMemberUpdate) {
const guildId = raw.guild_id;
const user = raw.user && typeof raw.user === "object" ? (raw.user as { id?: unknown }) : {};
if (typeof guildId === "string" && typeof user.id === "string") {
this.entries.delete(`member:${guildId}:${user.id}`);
this.entries.delete(`user:${user.id}`);
}
}
}
private deleteId(prefix: string, id: unknown): void {
if (typeof id === "string") {
this.entries.delete(`${prefix}:${id}`);
}
}
private async fetchCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const ttl = this.params.ttlMs ?? DEFAULT_REST_CACHE_TTL_MS;
if (ttl > 0) {
const cached = this.entries.get(key) as CacheEntry<T> | undefined;
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
}
const value = await fetcher();
if (ttl > 0) {
this.entries.set(key, { expiresAt: Date.now() + ttl, value });
}
return value;
}
private get rest(): RequestClient {
return typeof this.params.rest === "function" ? this.params.rest() : this.params.rest;
}
}

View File

@@ -0,0 +1,162 @@
export type DiscordEventQueueOptions = {
maxQueueSize?: number;
maxConcurrency?: number;
listenerTimeout?: number;
slowListenerThreshold?: number;
};
type DiscordEventQueueJob = {
eventType: string;
listenerName: string;
run: () => Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
};
type DiscordEventQueueMetrics = {
queueSize: number;
processing: number;
processed: number;
dropped: number;
timeouts: number;
maxQueueSize: number;
maxConcurrency: number;
};
const DEFAULT_MAX_QUEUE_SIZE = 10_000;
const DEFAULT_MAX_CONCURRENCY = 50;
const DEFAULT_LISTENER_TIMEOUT_MS = 120_000;
const DEFAULT_SLOW_LISTENER_THRESHOLD_MS = 30_000;
export class DiscordEventQueue {
private readonly options: Required<DiscordEventQueueOptions>;
private readonly queue: DiscordEventQueueJob[] = [];
private processing = 0;
private processedCount = 0;
private droppedCount = 0;
private timeoutCount = 0;
constructor(options: DiscordEventQueueOptions = {}) {
this.options = {
maxQueueSize: normalizePositiveInteger(options.maxQueueSize, DEFAULT_MAX_QUEUE_SIZE),
maxConcurrency: normalizePositiveInteger(options.maxConcurrency, DEFAULT_MAX_CONCURRENCY),
listenerTimeout: normalizePositiveInteger(
options.listenerTimeout,
DEFAULT_LISTENER_TIMEOUT_MS,
),
slowListenerThreshold: normalizePositiveInteger(
options.slowListenerThreshold,
DEFAULT_SLOW_LISTENER_THRESHOLD_MS,
),
};
}
enqueue(params: Omit<DiscordEventQueueJob, "resolve" | "reject">): Promise<void> {
if (this.queue.length >= this.options.maxQueueSize) {
this.droppedCount += 1;
return Promise.reject(
new Error(
`Discord event queue is full for ${params.eventType}; maxQueueSize=${this.options.maxQueueSize}`,
),
);
}
return new Promise<void>((resolve, reject) => {
this.queue.push({ ...params, resolve, reject });
this.processNext();
});
}
getMetrics(): DiscordEventQueueMetrics {
return {
queueSize: this.queue.length,
processing: this.processing,
processed: this.processedCount,
dropped: this.droppedCount,
timeouts: this.timeoutCount,
maxQueueSize: this.options.maxQueueSize,
maxConcurrency: this.options.maxConcurrency,
};
}
private processNext(): void {
while (this.processing < this.options.maxConcurrency && this.queue.length > 0) {
const job = this.queue.shift();
if (!job) {
return;
}
this.processing += 1;
void this.runJob(job)
.then(job.resolve, job.reject)
.finally(() => {
this.processing -= 1;
this.processedCount += 1;
this.processNext();
});
}
}
private async runJob(job: DiscordEventQueueJob): Promise<void> {
const startedAt = Date.now();
try {
await this.runWithTimeout(job);
this.logSlowListener(job, Date.now() - startedAt);
} catch (error) {
if (isListenerTimeoutError(error)) {
this.timeoutCount += 1;
console.error(
`[EventQueue] Listener ${job.listenerName} timed out after ${this.options.listenerTimeout}ms for event ${job.eventType}`,
);
return;
}
console.error(
`[EventQueue] Listener ${job.listenerName} failed for event ${job.eventType}:`,
error,
);
}
}
private async runWithTimeout(job: DiscordEventQueueJob): Promise<void> {
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
job.run(),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(createListenerTimeoutError(this.options.listenerTimeout));
}, this.options.listenerTimeout);
timeout.unref?.();
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
private logSlowListener(job: DiscordEventQueueJob, durationMs: number): void {
if (durationMs < this.options.slowListenerThreshold) {
return;
}
console.warn(
`[EventQueue] Slow listener detected: ${job.listenerName} took ${durationMs}ms for event ${job.eventType}`,
);
}
}
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.max(1, Math.floor(value));
}
function createListenerTimeoutError(timeoutMs: number): Error {
const error = new Error(`Listener timeout after ${timeoutMs}ms`);
error.name = "DiscordEventQueueListenerTimeoutError";
return error;
}
function isListenerTimeoutError(error: unknown): boolean {
return error instanceof Error && error.name === "DiscordEventQueueListenerTimeoutError";
}

View File

@@ -0,0 +1,96 @@
import { GatewayDispatchEvents, type APIMessage, type APIUser } from "discord-api-types/v10";
import type { Client } from "./client.js";
import { Guild, Message, User } from "./structures.js";
type VoicePluginAdapter = {
onVoiceServerUpdate?: (data: unknown) => void;
onVoiceStateUpdate?: (data: unknown) => void;
};
export function dispatchVoiceGatewayEvent(client: Client, type: string, data: unknown): void {
const guildId = readGuildId(data);
if (!guildId) {
return;
}
const adapters = client.getPlugin<{ adapters?: Map<string, VoicePluginAdapter> }>(
"voice",
)?.adapters;
const adapter = adapters?.get(guildId);
const voiceServerUpdate: string = GatewayDispatchEvents.VoiceServerUpdate;
const voiceStateUpdate: string = GatewayDispatchEvents.VoiceStateUpdate;
if (type === voiceServerUpdate) {
adapter?.onVoiceServerUpdate?.(data);
}
if (type === voiceStateUpdate) {
adapter?.onVoiceStateUpdate?.(data);
}
}
export function mapGatewayDispatchData(client: Client, type: string, data: unknown): unknown {
const messageCreate: string = GatewayDispatchEvents.MessageCreate;
const reactionAdd: string = GatewayDispatchEvents.MessageReactionAdd;
const reactionRemove: string = GatewayDispatchEvents.MessageReactionRemove;
if (type === messageCreate) {
return createMessageDispatchData(client, data as MessageCreatePayload);
}
if (type === reactionAdd || type === reactionRemove) {
return createReactionDispatchData(client, data as ReactionPayload);
}
return data;
}
type MessageCreatePayload = {
id: string;
channel_id: string;
guild_id?: string;
author?: APIUser;
member?: { roles?: string[] };
};
function createMessageDispatchData(client: Client, data: MessageCreatePayload) {
const message = new Message(client, data as APIMessage);
return {
...data,
id: data.id,
channel_id: data.channel_id,
channelId: data.channel_id,
message,
author: message.author ?? (data.author ? new User(client, data.author) : null),
member: data.member,
rawMember: data.member,
guild: data.guild_id ? new Guild<true>(client, data.guild_id) : null,
};
}
type ReactionPayload = {
user_id: string;
channel_id: string;
message_id: string;
guild_id?: string;
member?: { user?: unknown; roles?: string[] };
};
function createReactionDispatchData(client: Client, data: ReactionPayload) {
const userRaw =
data.member?.user && typeof data.member.user === "object"
? ({ id: data.user_id, username: "", ...data.member.user } as APIUser)
: ({ id: data.user_id, username: "" } as APIUser);
return {
...data,
user: new User(client, userRaw),
rawMember: data.member,
guild: data.guild_id ? new Guild<true>(client, data.guild_id) : null,
message: new Message<true>(client, {
id: data.message_id,
channelId: data.channel_id,
}),
};
}
function readGuildId(data: unknown): string | undefined {
return data &&
typeof data === "object" &&
typeof (data as { guild_id?: unknown }).guild_id === "string"
? (data as { guild_id: string }).guild_id
: undefined;
}

View File

@@ -0,0 +1,26 @@
const IDENTIFY_WINDOW_MS = 5_000;
export class GatewayIdentifyLimiter {
private nextAllowedAtByKey = new Map<number, number>();
async wait(params: { shardId?: number; maxConcurrency?: number }): Promise<void> {
const maxConcurrency = Math.max(1, Math.floor(params.maxConcurrency ?? 1));
const rateKey = (params.shardId ?? 0) % maxConcurrency;
const now = Date.now();
const nextAllowedAt = this.nextAllowedAtByKey.get(rateKey) ?? now;
const waitMs = Math.max(0, nextAllowedAt - now);
this.nextAllowedAtByKey.set(rateKey, Math.max(now, nextAllowedAt) + IDENTIFY_WINDOW_MS);
if (waitMs > 0) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, waitMs);
timer.unref?.();
});
}
}
reset(): void {
this.nextAllowedAtByKey.clear();
}
}
export const sharedGatewayIdentifyLimiter = new GatewayIdentifyLimiter();

View File

@@ -0,0 +1,61 @@
type GatewayTimer = NodeJS.Timeout;
export class GatewayHeartbeatTimers {
heartbeatInterval?: GatewayTimer;
firstHeartbeatTimeout?: GatewayTimer;
start(params: {
intervalMs: number;
isAcked: () => boolean;
onAckTimeout: () => void;
onHeartbeat: () => void;
random?: () => number;
}): void {
this.stop();
const random = params.random ?? Math.random;
this.firstHeartbeatTimeout = setTimeout(
params.onHeartbeat,
Math.max(0, params.intervalMs * random()),
);
this.firstHeartbeatTimeout.unref?.();
this.heartbeatInterval = setInterval(() => {
if (!params.isAcked()) {
params.onAckTimeout();
return;
}
params.onHeartbeat();
}, params.intervalMs);
this.heartbeatInterval.unref?.();
}
stop(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = undefined;
}
if (this.firstHeartbeatTimeout) {
clearTimeout(this.firstHeartbeatTimeout);
this.firstHeartbeatTimeout = undefined;
}
}
}
export class GatewayReconnectTimer {
timeout?: GatewayTimer;
stop(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
schedule(delayMs: number, callback: () => void): void {
this.stop();
this.timeout = setTimeout(() => {
this.timeout = undefined;
callback();
}, delayMs);
this.timeout.unref?.();
}
}

View File

@@ -0,0 +1,104 @@
export const GATEWAY_SEND_LIMIT = 120;
export const GATEWAY_SEND_WINDOW_MS = 60_000;
type QueuedGatewaySend = {
payload: string;
};
export class GatewaySendLimiter {
private outboundSendTimestamps: number[] = [];
private outboundQueue: QueuedGatewaySend[] = [];
private outboundFlushTimer?: NodeJS.Timeout;
constructor(
private sendNow: (payload: string) => void,
private emitError: (error: Error) => void,
) {}
send(serialized: string, options?: { critical?: boolean }): void {
if (options?.critical || this.canSend(Date.now())) {
this.sendSerialized(serialized);
return;
}
this.outboundQueue.push({ payload: serialized });
this.scheduleFlush();
}
clear(): void {
if (this.outboundFlushTimer) {
clearTimeout(this.outboundFlushTimer);
this.outboundFlushTimer = undefined;
}
this.outboundQueue = [];
}
getStatus() {
const now = Date.now();
this.pruneWindow(now);
const oldest = this.outboundSendTimestamps[0] ?? now;
return {
remainingEvents: Math.max(0, GATEWAY_SEND_LIMIT - this.outboundSendTimestamps.length),
resetTime:
this.outboundSendTimestamps.length > 0
? oldest + GATEWAY_SEND_WINDOW_MS
: now + GATEWAY_SEND_WINDOW_MS,
currentEventCount: this.outboundSendTimestamps.length,
queuedEvents: this.outboundQueue.length,
};
}
private pruneWindow(now: number): void {
const windowStart = now - GATEWAY_SEND_WINDOW_MS;
while (
this.outboundSendTimestamps.length > 0 &&
(this.outboundSendTimestamps[0] ?? 0) <= windowStart
) {
this.outboundSendTimestamps.shift();
}
}
private canSend(now: number): boolean {
this.pruneWindow(now);
return this.outboundSendTimestamps.length < GATEWAY_SEND_LIMIT;
}
private sendSerialized(serialized: string): void {
this.outboundSendTimestamps.push(Date.now());
this.sendNow(serialized);
}
private scheduleFlush(): void {
if (this.outboundFlushTimer || this.outboundQueue.length === 0) {
return;
}
const now = Date.now();
this.pruneWindow(now);
const oldest = this.outboundSendTimestamps[0] ?? now;
const delayMs =
this.outboundSendTimestamps.length >= GATEWAY_SEND_LIMIT
? Math.max(0, oldest + GATEWAY_SEND_WINDOW_MS - now)
: 0;
this.outboundFlushTimer = setTimeout(() => {
this.outboundFlushTimer = undefined;
this.flush();
}, delayMs);
this.outboundFlushTimer.unref?.();
}
private flush(): void {
while (this.outboundQueue.length > 0 && this.canSend(Date.now())) {
const queued = this.outboundQueue.shift();
if (!queued) {
continue;
}
try {
this.sendSerialized(queued.payload);
} catch (error) {
this.emitError(error instanceof Error ? error : new Error(String(error), { cause: error }));
this.clear();
return;
}
}
this.scheduleFlush();
}
}

View File

@@ -0,0 +1,398 @@
import { EventEmitter } from "node:events";
import {
GatewayCloseCodes,
GatewayDispatchEvents,
GatewayOpcodes,
InteractionType,
PresenceUpdateStatus,
type GatewaySendPayload,
} from "discord-api-types/v10";
import { afterEach, describe, expect, it, vi } from "vitest";
import { sharedGatewayIdentifyLimiter } from "./gateway-identify-limiter.js";
import { GatewayPlugin } from "./gateway.js";
function attachOpenSocket(gateway: GatewayPlugin) {
const send = vi.fn();
(gateway as unknown as { ws: unknown }).ws = {
readyState: 1,
send,
};
return send;
}
function presenceUpdate(
status: PresenceUpdateStatus.Online | PresenceUpdateStatus.Idle = PresenceUpdateStatus.Online,
): GatewaySendPayload {
return {
op: GatewayOpcodes.PresenceUpdate,
d: {
since: null,
activities: [],
status,
afk: false,
},
};
}
class FakeSocket extends EventEmitter {
readyState = 1;
send = vi.fn();
close = vi.fn();
}
class TestGatewayPlugin extends GatewayPlugin {
sockets: FakeSocket[] = [];
connectCalls: boolean[] = [];
connect(resume = false): void {
this.connectCalls.push(resume);
super.connect(resume);
}
protected createWebSocket(): never {
const socket = new FakeSocket();
this.sockets.push(socket);
return socket as never;
}
}
describe("GatewayPlugin", () => {
afterEach(() => {
vi.useRealTimers();
sharedGatewayIdentifyLimiter.reset();
});
it("does not auto-handle interactions when autoInteractions is disabled", async () => {
const gateway = new GatewayPlugin({ autoInteractions: false });
const handleInteraction = vi.fn(async () => {});
const dispatchGatewayEvent = vi.fn(async () => {
await handleInteraction();
});
(gateway as unknown as { client: unknown }).client = {
dispatchGatewayEvent,
handleInteraction,
};
await (
gateway as unknown as {
handleDispatch(payload: { t: string; d: unknown }): Promise<void>;
}
).handleDispatch({
t: GatewayDispatchEvents.InteractionCreate,
d: { id: "interaction-1", type: InteractionType.MessageComponent },
});
expect(dispatchGatewayEvent).toHaveBeenCalledTimes(1);
expect(handleInteraction).toHaveBeenCalledTimes(1);
});
it("emits async dispatch failures as gateway errors", async () => {
const gateway = new GatewayPlugin({ autoInteractions: false });
const error = new Error("listener failed");
(gateway as unknown as { client: unknown }).client = {
dispatchGatewayEvent: async () => {
throw error;
},
};
const errorSpy = vi.fn();
gateway.emitter.on("error", errorSpy);
(
gateway as unknown as {
handlePayload(
payload: { op: number; t?: string; s?: number; d: unknown },
resume: boolean,
): void;
}
).handlePayload(
{
op: GatewayOpcodes.Dispatch,
t: GatewayDispatchEvents.MessageCreate,
s: 1,
d: { id: "m1", channel_id: "c1", author: { id: "u1", username: "user" } },
},
false,
);
await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledWith(error));
});
it("preserves MESSAGE_CREATE author payloads for inbound dispatch", async () => {
const gateway = new GatewayPlugin({ autoInteractions: false });
const dispatchGatewayEvent = vi.fn(async (_event: string, _data: unknown) => {});
(gateway as unknown as { client: unknown }).client = {
dispatchGatewayEvent,
};
await (
gateway as unknown as {
handleDispatch(payload: { t: string; d: unknown }): Promise<void>;
}
).handleDispatch({
t: GatewayDispatchEvents.MessageCreate,
d: {
id: "m1",
channel_id: "c1",
content: "hello",
attachments: [],
timestamp: new Date().toISOString(),
author: { id: "u1", username: "user", discriminator: "0", avatar: null },
type: 0,
tts: false,
mention_everyone: false,
pinned: false,
flags: 0,
},
});
expect(dispatchGatewayEvent).toHaveBeenCalledTimes(1);
const dispatched = dispatchGatewayEvent.mock.calls[0]?.[1] as {
author?: { id: string };
message?: { author?: { id: string } | null; content?: string };
};
expect(dispatched.author?.id).toBe("u1");
expect(dispatched.message?.author?.id).toBe("u1");
expect(dispatched.message?.content).toBe("hello");
});
it("marks successful gateway resumes connected", async () => {
const gateway = new GatewayPlugin({ autoInteractions: false });
(gateway as unknown as { client: unknown }).client = {
dispatchGatewayEvent: vi.fn(async () => {}),
};
gateway.isConnected = false;
(gateway as unknown as { reconnectAttempts: number }).reconnectAttempts = 7;
await (
gateway as unknown as {
handleDispatch(payload: { t: string; d: unknown }): Promise<void>;
}
).handleDispatch({
t: GatewayDispatchEvents.Resumed,
d: {},
});
expect(gateway.isConnected).toBe(true);
expect((gateway as unknown as { reconnectAttempts: number }).reconnectAttempts).toBe(0);
});
it("queues outbound gateway events when the connection window is exhausted", () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const gateway = new GatewayPlugin({ autoInteractions: false });
const send = attachOpenSocket(gateway);
for (let index = 0; index < 120; index += 1) {
gateway.send(presenceUpdate());
}
gateway.send(presenceUpdate(PresenceUpdateStatus.Idle));
expect(send).toHaveBeenCalledTimes(120);
expect(gateway.getRateLimitStatus()).toEqual(
expect.objectContaining({ remainingEvents: 0, currentEventCount: 120, queuedEvents: 1 }),
);
vi.advanceTimersByTime(59_999);
expect(send).toHaveBeenCalledTimes(120);
vi.advanceTimersByTime(1);
expect(send).toHaveBeenCalledTimes(121);
expect(gateway.getRateLimitStatus()).toEqual(
expect.objectContaining({ currentEventCount: 1, queuedEvents: 0 }),
);
});
it("sends critical gateway events immediately even when regular sends are queued", () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const gateway = new GatewayPlugin({ autoInteractions: false });
const send = attachOpenSocket(gateway);
for (let index = 0; index < 120; index += 1) {
gateway.send(presenceUpdate());
}
gateway.send(presenceUpdate(PresenceUpdateStatus.Idle));
gateway.send({ op: GatewayOpcodes.Heartbeat, d: 1 }, true);
expect(send).toHaveBeenCalledTimes(121);
expect(JSON.parse(send.mock.calls.at(-1)?.[0] as string)).toEqual({
op: GatewayOpcodes.Heartbeat,
d: 1,
});
expect(gateway.getRateLimitStatus()).toEqual(
expect.objectContaining({ remainingEvents: 0, queuedEvents: 1 }),
);
});
it("ignores stale socket close events after reconnecting", () => {
const gateway = new TestGatewayPlugin({
autoInteractions: false,
url: "wss://gateway.example.test",
});
gateway.connect(false);
const oldSocket = gateway.sockets[0];
oldSocket.emit("open");
gateway.connect(false);
const heartbeat = setInterval(() => {}, 1_000);
gateway.heartbeatInterval = heartbeat;
gateway.isConnected = true;
oldSocket.emit("close", 1006);
expect(gateway.isConnected).toBe(true);
expect(gateway.heartbeatInterval).toBe(heartbeat);
clearInterval(heartbeat);
});
it("reconnects after active remote normal closes", async () => {
vi.useFakeTimers();
const gateway = new TestGatewayPlugin({
autoInteractions: false,
url: "wss://gateway.example.test",
});
gateway.connect(false);
gateway.sockets[0]?.emit("open");
gateway.sockets[0]?.emit("close", 1000);
expect(gateway.sockets).toHaveLength(1);
await vi.advanceTimersByTimeAsync(2_000);
expect(gateway.connectCalls).toEqual([false, true]);
expect(gateway.sockets).toHaveLength(2);
});
it("re-identifies after non-resumable gateway closes", async () => {
vi.useFakeTimers();
const gateway = new TestGatewayPlugin({
autoInteractions: false,
url: "wss://gateway.example.test",
});
gateway.connect(false);
gateway.sockets[0]?.emit("open");
gateway.sockets[0]?.emit("close", GatewayCloseCodes.InvalidSeq);
await vi.advanceTimersByTimeAsync(2_000);
expect(gateway.connectCalls).toEqual([false, false]);
expect(gateway.sockets).toHaveLength(2);
});
it("does not reconnect after fatal gateway closes", async () => {
vi.useFakeTimers();
const gateway = new TestGatewayPlugin({
autoInteractions: false,
url: "wss://gateway.example.test",
});
const errorSpy = vi.fn();
gateway.emitter.on("error", errorSpy);
gateway.connect(false);
gateway.sockets[0]?.emit("open");
gateway.sockets[0]?.emit("close", GatewayCloseCodes.InvalidIntents);
await vi.advanceTimersByTimeAsync(30_000);
expect(errorSpy).toHaveBeenCalledWith(new Error("Fatal gateway close code: 4013"));
expect(gateway.connectCalls).toEqual([false]);
expect(gateway.sockets).toHaveLength(1);
});
it("clears heartbeat timers before delayed reconnects", () => {
vi.useFakeTimers();
const gateway = new GatewayPlugin({
autoInteractions: false,
url: "wss://gateway.example.test",
});
const send = vi.fn();
const close = vi.fn();
gateway.ws = {
readyState: 1,
send,
close,
} as unknown as GatewayPlugin["ws"];
const firstHeartbeatTimeout = setTimeout(() => {
(
gateway as unknown as {
sendHeartbeat(): void;
}
).sendHeartbeat();
}, 10);
const heartbeatInterval = setInterval(() => {
(
gateway as unknown as {
sendHeartbeat(): void;
}
).sendHeartbeat();
}, 10);
gateway.firstHeartbeatTimeout = firstHeartbeatTimeout;
gateway.heartbeatInterval = heartbeatInterval;
(gateway as unknown as { shouldReconnect: boolean }).shouldReconnect = true;
(
gateway as unknown as {
handlePayload(payload: { op: number; d: unknown }, resume: boolean): void;
}
).handlePayload({ op: GatewayOpcodes.Reconnect, d: null }, false);
expect(close).toHaveBeenCalledTimes(1);
expect(gateway.ws).toBeNull();
expect(gateway.firstHeartbeatTimeout).toBeUndefined();
expect(gateway.heartbeatInterval).toBeUndefined();
expect(() => vi.advanceTimersByTime(20)).not.toThrow();
expect(send).not.toHaveBeenCalled();
expect(() =>
(
gateway as unknown as {
sendHeartbeat(): void;
}
).sendHeartbeat(),
).not.toThrow();
});
it("spaces identify sends by gateway max concurrency bucket", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const first = new GatewayPlugin(
{ autoInteractions: false, shard: [0, 2] },
{
url: "wss://gateway.discord.gg/",
shards: 2,
session_start_limit: { total: 1000, remaining: 1000, reset_after: 0, max_concurrency: 1 },
},
);
const second = new GatewayPlugin(
{ autoInteractions: false, shard: [1, 2] },
{
url: "wss://gateway.discord.gg/",
shards: 2,
session_start_limit: { total: 1000, remaining: 1000, reset_after: 0, max_concurrency: 1 },
},
);
(first as unknown as { client: unknown }).client = { options: { token: "token" } };
(second as unknown as { client: unknown }).client = { options: { token: "token" } };
const firstSend = attachOpenSocket(first);
const secondSend = attachOpenSocket(second);
for (const gateway of [first, second]) {
(
gateway as unknown as {
handlePayload(payload: { op: number; d: unknown }, resume: boolean): void;
}
).handlePayload({ op: GatewayOpcodes.Hello, d: { heartbeat_interval: 45_000 } }, false);
}
await vi.advanceTimersByTimeAsync(0);
expect(firstSend).toHaveBeenCalledWith(
expect.stringContaining(`"op":${GatewayOpcodes.Identify}`),
);
expect(secondSend).not.toHaveBeenCalledWith(
expect.stringContaining(`"op":${GatewayOpcodes.Identify}`),
);
await vi.advanceTimersByTimeAsync(5_000);
expect(secondSend).toHaveBeenCalledWith(
expect.stringContaining(`"op":${GatewayOpcodes.Identify}`),
);
});
});

View File

@@ -0,0 +1,438 @@
import { EventEmitter } from "node:events";
import {
GatewayCloseCodes,
GatewayDispatchEvents,
GatewayIntentBits,
GatewayOpcodes,
type APIGatewayBotInfo,
type GatewayDispatchPayload,
type GatewayHeartbeat,
type GatewayIdentify,
type GatewayPresenceUpdateData,
type GatewayReceivePayload,
type GatewaySendPayload,
type GatewayVoiceStateUpdateData,
} from "discord-api-types/v10";
import * as ws from "ws";
import { Plugin, type Client } from "./client.js";
import { dispatchVoiceGatewayEvent, mapGatewayDispatchData } from "./gateway-dispatch.js";
import { sharedGatewayIdentifyLimiter } from "./gateway-identify-limiter.js";
import { GatewayHeartbeatTimers, GatewayReconnectTimer } from "./gateway-lifecycle.js";
import { GatewaySendLimiter } from "./gateway-rate-limit.js";
export { GatewayCloseCodes };
export const GatewayIntents = GatewayIntentBits;
export type Activity = NonNullable<GatewayPresenceUpdateData["activities"]>[number];
export type UpdatePresenceData = Omit<GatewayPresenceUpdateData, "status"> & {
status: "online" | "idle" | "dnd" | "invisible" | "offline";
};
export type UpdateVoiceStateData = GatewayVoiceStateUpdateData;
export type RequestGuildMembersData = {
guild_id: string;
query?: string;
limit: number;
presences?: boolean;
user_ids?: string | string[];
nonce?: string;
};
export type GatewayWebSocketLike = ws.WebSocket;
type GatewayPluginOptions = {
reconnect?: { maxAttempts?: number };
intents?: number;
autoInteractions?: boolean;
shard?: [number, number];
url?: string;
};
const READY_STATE_OPEN = 1;
const DEFAULT_GATEWAY_URL = "wss://gateway.discord.gg/";
function ensureGatewayParams(url: string): string {
const parsed = new URL(url);
parsed.searchParams.set("v", parsed.searchParams.get("v") ?? "10");
parsed.searchParams.set("encoding", parsed.searchParams.get("encoding") ?? "json");
return parsed.toString();
}
function decodeGatewayMessage(incoming: unknown): GatewayReceivePayload | null {
const text = Buffer.isBuffer(incoming)
? incoming.toString("utf8")
: incoming instanceof ArrayBuffer
? Buffer.from(incoming).toString("utf8")
: Array.isArray(incoming)
? Buffer.concat(incoming.map((entry) => Buffer.from(entry))).toString("utf8")
: String(incoming);
try {
return JSON.parse(text) as GatewayReceivePayload;
} catch {
return null;
}
}
function isFatalGatewayCloseCode(code: GatewayCloseCodes): boolean {
return (
code === GatewayCloseCodes.AuthenticationFailed ||
code === GatewayCloseCodes.InvalidShard ||
code === GatewayCloseCodes.ShardingRequired ||
code === GatewayCloseCodes.InvalidAPIVersion ||
code === GatewayCloseCodes.InvalidIntents ||
code === GatewayCloseCodes.DisallowedIntents
);
}
function canResumeAfterGatewayClose(code: GatewayCloseCodes): boolean {
return (
code !== GatewayCloseCodes.NotAuthenticated &&
code !== GatewayCloseCodes.InvalidSeq &&
code !== GatewayCloseCodes.SessionTimedOut
);
}
export class GatewayPlugin extends Plugin {
readonly id = "gateway";
protected client?: Client;
readonly options: Required<Pick<GatewayPluginOptions, "autoInteractions">> & GatewayPluginOptions;
public ws: ws.WebSocket | null = null;
public sequence: number | null = null;
public lastHeartbeatAck = true;
public emitter = new EventEmitter();
public shardId?: number;
public totalShards?: number;
protected gatewayInfo?: APIGatewayBotInfo;
public isConnected = false;
private sessionId: string | null = null;
private resumeGatewayUrl: string | null = null;
private reconnectAttempts = 0;
private shouldReconnect = false;
private isConnecting = false;
private readonly heartbeatTimers = new GatewayHeartbeatTimers();
private readonly reconnectTimer = new GatewayReconnectTimer();
private outboundLimiter = new GatewaySendLimiter(
(payload) => this.sendSerializedGatewayEvent(payload),
(error) => this.emitter.emit("error", error),
);
constructor(options: GatewayPluginOptions, gatewayInfo?: APIGatewayBotInfo) {
super();
this.options = {
...options,
reconnect: { maxAttempts: 50, ...options.reconnect },
autoInteractions: options.autoInteractions ?? true,
intents: options.intents ?? 0,
};
this.gatewayInfo = gatewayInfo;
}
get ping(): number | null {
return null;
}
get heartbeatInterval(): NodeJS.Timeout | undefined {
return this.heartbeatTimers.heartbeatInterval;
}
set heartbeatInterval(timer: NodeJS.Timeout | undefined) {
this.heartbeatTimers.heartbeatInterval = timer;
}
get firstHeartbeatTimeout(): NodeJS.Timeout | undefined {
return this.heartbeatTimers.firstHeartbeatTimeout;
}
set firstHeartbeatTimeout(timer: NodeJS.Timeout | undefined) {
this.heartbeatTimers.firstHeartbeatTimeout = timer;
}
async registerClient(client: Client): Promise<void> {
this.client = client;
if (this.options.shard) {
client.shardId = this.options.shard[0];
client.totalShards = this.options.shard[1];
this.shardId = this.options.shard[0];
this.totalShards = this.options.shard[1];
}
this.shouldReconnect = true;
this.connect(false);
}
connect(resume = false): void {
if (this.isConnecting) {
return;
}
this.stopReconnectTimer();
this.stopHeartbeat();
this.shouldReconnect = true;
this.lastHeartbeatAck = true;
this.ws?.close(1000, "Reconnecting");
const baseUrl =
resume && this.resumeGatewayUrl
? this.resumeGatewayUrl
: (this.gatewayInfo?.url ?? this.options.url ?? DEFAULT_GATEWAY_URL);
this.ws = this.createWebSocket(ensureGatewayParams(baseUrl));
this.isConnecting = true;
this.isConnected = false;
this.setupWebSocket(resume);
}
disconnect(): void {
this.shouldReconnect = false;
this.stopReconnectTimer();
this.stopHeartbeat();
this.outboundLimiter.clear();
this.ws?.close(1000, "Client disconnect");
this.ws = null;
this.isConnecting = false;
this.isConnected = false;
this.reconnectAttempts = 0;
}
protected createWebSocket(url: string): ws.WebSocket {
return new ws.WebSocket(url);
}
private setupWebSocket(resume: boolean): void {
const socket = this.ws;
if (!socket) {
return;
}
socket.on("open", () => {
if (socket !== this.ws) {
return;
}
this.isConnecting = false;
this.emitter.emit("debug", "Gateway websocket opened");
});
socket.on("message", (incoming) => {
if (socket !== this.ws) {
return;
}
const payload = decodeGatewayMessage(incoming);
if (!payload) {
this.emitter.emit("error", new Error("Invalid gateway payload"));
return;
}
this.handlePayload(payload, resume);
});
socket.on("close", (code) => {
if (socket !== this.ws) {
return;
}
const closeCode = code as GatewayCloseCodes;
this.stopHeartbeat();
this.outboundLimiter.clear();
this.isConnecting = false;
this.isConnected = false;
this.emitter.emit("debug", `Gateway websocket closed: ${code}`);
if (!this.shouldReconnect) {
return;
}
if (isFatalGatewayCloseCode(closeCode)) {
this.shouldReconnect = false;
this.emitter.emit("error", new Error(`Fatal gateway close code: ${code}`));
return;
}
this.scheduleReconnect(canResumeAfterGatewayClose(closeCode));
});
socket.on("error", (error) => {
if (socket !== this.ws) {
return;
}
this.emitter.emit("error", error);
});
}
private handlePayload(payload: GatewayReceivePayload, resume: boolean): void {
if (payload.s !== null && payload.s !== undefined) {
this.sequence = payload.s;
}
switch (payload.op) {
case GatewayOpcodes.Hello:
this.startHeartbeat(
(payload.d as { heartbeat_interval?: number }).heartbeat_interval ?? 45_000,
);
if (resume && this.sessionId) {
this.send(
{
op: GatewayOpcodes.Resume,
d: {
token: this.client?.options.token ?? "",
session_id: this.sessionId,
seq: this.sequence ?? 0,
},
} as GatewaySendPayload,
true,
);
} else {
void this.identifyWithConcurrency();
}
break;
case GatewayOpcodes.HeartbeatAck:
this.lastHeartbeatAck = true;
break;
case GatewayOpcodes.Heartbeat:
this.sendHeartbeat();
break;
case GatewayOpcodes.Dispatch:
void this.handleDispatch(payload).catch((error: unknown) => {
this.emitter.emit(
"error",
error instanceof Error ? error : new Error(String(error), { cause: error }),
);
});
break;
case GatewayOpcodes.InvalidSession:
this.scheduleReconnect(payload.d);
break;
case GatewayOpcodes.Reconnect:
this.scheduleReconnect(true);
break;
}
}
private startHeartbeat(intervalMs: number): void {
this.heartbeatTimers.start({
intervalMs,
isAcked: () => this.lastHeartbeatAck,
onHeartbeat: () => this.sendHeartbeat(),
onAckTimeout: () => {
this.emitter.emit("error", new Error("Gateway heartbeat ACK timeout"));
this.scheduleReconnect(true);
},
});
}
private stopHeartbeat(): void {
this.heartbeatTimers.stop();
}
private stopReconnectTimer(): void {
this.reconnectTimer.stop();
}
private sendHeartbeat(): void {
if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) {
return;
}
this.lastHeartbeatAck = false;
this.send({ op: GatewayOpcodes.Heartbeat, d: this.sequence } as GatewayHeartbeat, true);
}
private identify(): void {
this.send(
{
op: GatewayOpcodes.Identify,
d: {
token: this.client?.options.token ?? "",
intents: this.options.intents ?? 0,
properties: { os: process.platform, browser: "openclaw", device: "openclaw" },
shard: this.options.shard,
},
} as GatewayIdentify,
true,
);
}
private async identifyWithConcurrency(): Promise<void> {
await sharedGatewayIdentifyLimiter.wait({
shardId: this.shardId,
maxConcurrency: this.gatewayInfo?.session_start_limit.max_concurrency,
});
if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) {
return;
}
this.identify();
}
send(payload: GatewaySendPayload | GatewayReceivePayload, skipRateLimit = false): void {
if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) {
throw new Error("Discord gateway socket is not open");
}
const serialized = JSON.stringify(payload);
this.outboundLimiter.send(serialized, { critical: skipRateLimit });
}
private sendSerializedGatewayEvent(serialized: string): void {
if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) {
throw new Error("Discord gateway socket is not open");
}
this.ws.send(serialized);
}
private async handleDispatch(payload: GatewayDispatchPayload): Promise<void> {
if (!this.client || !payload.t) {
return;
}
if (payload.t === GatewayDispatchEvents.Ready) {
const ready = payload.d as { session_id?: string; resume_gateway_url?: string };
this.sessionId = ready.session_id ?? null;
this.resumeGatewayUrl = ready.resume_gateway_url ?? null;
this.reconnectAttempts = 0;
this.isConnected = true;
}
if (payload.t === GatewayDispatchEvents.Resumed) {
this.reconnectAttempts = 0;
this.isConnected = true;
}
dispatchVoiceGatewayEvent(this.client, payload.t, payload.d);
const data = mapGatewayDispatchData(this.client, payload.t, payload.d);
await this.client.dispatchGatewayEvent(payload.t, data);
if (payload.t === GatewayDispatchEvents.InteractionCreate && this.options.autoInteractions) {
await this.client.handleInteraction(payload.d);
}
}
private scheduleReconnect(resume: boolean): void {
if (!this.shouldReconnect) {
return;
}
this.stopHeartbeat();
this.stopReconnectTimer();
this.ws?.close();
this.ws = null;
this.isConnecting = false;
this.isConnected = false;
this.outboundLimiter.clear();
this.reconnectAttempts += 1;
if (this.reconnectAttempts > (this.options.reconnect?.maxAttempts ?? 50)) {
this.emitter.emit("error", new Error("Max reconnect attempts reached"));
return;
}
const delay = Math.min(30_000, 1_000 * 2 ** Math.min(this.reconnectAttempts, 5));
this.reconnectTimer.schedule(delay, () => {
this.connect(resume);
});
}
updatePresence(data: UpdatePresenceData): void {
this.send({ op: GatewayOpcodes.PresenceUpdate, d: data } as GatewaySendPayload);
}
updateVoiceState(data: UpdateVoiceStateData): void {
this.send({ op: GatewayOpcodes.VoiceStateUpdate, d: data } as GatewaySendPayload, true);
}
requestGuildMembers(data: RequestGuildMembersData): void {
this.send({ op: GatewayOpcodes.RequestGuildMembers, d: data } as GatewaySendPayload);
}
getRateLimitStatus() {
return this.outboundLimiter.getStatus();
}
getIntentsInfo() {
const intents = this.options.intents ?? 0;
return {
intents,
hasGuilds: this.hasIntent(GatewayIntentBits.Guilds),
hasGuildMembers: this.hasIntent(GatewayIntentBits.GuildMembers),
hasGuildPresences: this.hasIntent(GatewayIntentBits.GuildPresences),
hasGuildMessages: this.hasIntent(GatewayIntentBits.GuildMessages),
hasMessageContent: this.hasIntent(GatewayIntentBits.MessageContent),
};
}
hasIntent(intent: number): boolean {
return Boolean((this.options.intents ?? 0) & intent);
}
}

View File

@@ -0,0 +1,148 @@
import {
ApplicationCommandOptionType,
InteractionResponseType,
InteractionType,
} from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import { Command, CommandWithSubcommands } from "./commands.js";
import type { AutocompleteInteraction, CommandInteraction } from "./interactions.js";
import {
attachRestMock,
createInternalInteractionPayload,
createInternalTestClient,
} from "./test-builders.test-support.js";
describe("dispatchInteraction", () => {
it("passes command ephemeral defaults into deferred responses", async () => {
const run = vi.fn(async (interaction: CommandInteraction) => {
await interaction.reply("done");
});
class DeferredCommand extends Command {
name = "deferred";
description = "Deferred command";
defer = true;
ephemeral = true;
run = run;
}
const client = createInternalTestClient([new DeferredCommand()]);
const post = vi.fn(async () => undefined);
const patch = vi.fn(async () => undefined);
attachRestMock(client, { post, patch });
await client.handleInteraction(
createInternalInteractionPayload({
id: "interaction1",
token: "token1",
data: { id: "command1", name: "deferred", type: 1 },
}),
);
expect(post).toHaveBeenNthCalledWith(1, "/interactions/interaction1/token1/callback", {
body: {
type: InteractionResponseType.DeferredChannelMessageWithSource,
data: { flags: 64 },
},
});
expect(patch).toHaveBeenCalledWith("/webhooks/app1/token1/messages/%40original", {
body: { content: "done" },
});
expect(run).toHaveBeenCalledTimes(1);
});
it("dispatches the focused option autocomplete handler", async () => {
const optionAutocomplete = vi.fn(async (interaction: AutocompleteInteraction) => {
await interaction.respond([{ name: "alpha", value: "alpha" }]);
});
class OptionAutocompleteCommand extends Command {
name = "choose";
description = "Choose";
options = [
{
name: "model",
description: "Model",
type: ApplicationCommandOptionType.String,
autocomplete: optionAutocomplete,
},
];
run() {}
}
const client = createInternalTestClient([new OptionAutocompleteCommand()]);
const post = vi.fn(async () => undefined);
attachRestMock(client, { post });
await client.handleInteraction(
createInternalInteractionPayload({
id: "interaction1",
token: "token1",
type: InteractionType.ApplicationCommandAutocomplete,
data: {
id: "command1",
name: "choose",
type: 1,
options: [
{
name: "model",
type: ApplicationCommandOptionType.String,
value: "a",
focused: true,
},
],
},
}),
);
expect(optionAutocomplete).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/interactions/interaction1/token1/callback", {
body: {
type: InteractionResponseType.ApplicationCommandAutocompleteResult,
data: { choices: [{ name: "alpha", value: "alpha" }] },
},
});
});
it("defers selected subcommands before running them", async () => {
const run = vi.fn(async (interaction: CommandInteraction) => {
await interaction.reply("joined");
});
class JoinCommand extends Command {
name = "join";
description = "Join";
defer = true;
ephemeral = true;
run = run;
}
class VoiceCommand extends CommandWithSubcommands {
name = "vc";
description = "Voice";
subcommands = [new JoinCommand()];
}
const client = createInternalTestClient([new VoiceCommand()]);
const post = vi.fn(async () => undefined);
const patch = vi.fn(async () => undefined);
attachRestMock(client, { post, patch });
await client.handleInteraction(
createInternalInteractionPayload({
id: "interaction1",
token: "token1",
data: {
id: "command1",
name: "vc",
type: 1,
options: [{ name: "join", type: ApplicationCommandOptionType.Subcommand }],
},
}),
);
expect(post).toHaveBeenNthCalledWith(1, "/interactions/interaction1/token1/callback", {
body: {
type: InteractionResponseType.DeferredChannelMessageWithSource,
data: { flags: 64 },
},
});
expect(patch).toHaveBeenCalledWith("/webhooks/app1/token1/messages/%40original", {
body: { content: "joined" },
});
expect(run).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,107 @@
import { InteractionType, type APIInteraction } from "discord-api-types/v10";
import type { Client } from "./client.js";
import {
deferCommandInteractionIfNeeded,
resolveFocusedCommandOptionAutocompleteHandler,
} from "./commands.js";
import {
AutocompleteInteraction,
BaseComponentInteraction,
CommandInteraction,
ModalInteraction,
createInteraction,
parseComponentInteractionData,
type RawInteraction,
} from "./interactions.js";
export async function dispatchInteraction(client: Client, rawData: APIInteraction): Promise<void> {
const interaction = createInteraction(client, rawData as RawInteraction);
if (rawData.type === InteractionType.ApplicationCommandAutocomplete) {
const command = client.commands.find((entry) => entry.name === readInteractionName(rawData));
if (!command) {
return;
}
const autocompleteInteraction = interaction as AutocompleteInteraction;
const optionAutocomplete = resolveFocusedCommandOptionAutocompleteHandler(
command,
autocompleteInteraction,
);
if (optionAutocomplete) {
await optionAutocomplete(autocompleteInteraction);
return;
}
if ("autocomplete" in command) {
await (
command as { autocomplete: (interaction: AutocompleteInteraction) => Promise<void> }
).autocomplete(autocompleteInteraction);
}
return;
}
if (rawData.type === InteractionType.ApplicationCommand) {
const command = client.commands.find((entry) => entry.name === readInteractionName(rawData));
if (command && "run" in command) {
await deferCommandInteractionIfNeeded(command, interaction as CommandInteraction);
await (command as { run: (interaction: CommandInteraction) => Promise<void> }).run(
interaction as CommandInteraction,
);
}
return;
}
if (rawData.type === InteractionType.MessageComponent) {
const customId = readCustomId(rawData);
if (!customId) {
return;
}
const component = client.componentHandler.resolve(customId, {
componentType: (rawData as { data?: { component_type?: number } }).data?.component_type,
});
if (component) {
const componentInteraction = interaction as BaseComponentInteraction;
await deferComponentInteractionIfNeeded(component, componentInteraction);
await component.run(componentInteraction, parseComponentInteractionData(component, customId));
}
return;
}
if (rawData.type === InteractionType.ModalSubmit) {
const customId = readCustomId(rawData);
if (!customId) {
return;
}
const modal = client.modalHandler.resolve(customId);
if (modal) {
await modal.run(interaction as ModalInteraction, modal.customIdParser(customId).data);
}
}
}
function resolveConditionalComponentOption(
value: boolean | ((interaction: BaseComponentInteraction) => boolean),
interaction: BaseComponentInteraction,
): boolean {
return typeof value === "function" ? value(interaction) : value;
}
async function deferComponentInteractionIfNeeded(
component: {
defer: boolean | ((interaction: BaseComponentInteraction) => boolean);
ephemeral: boolean | ((interaction: BaseComponentInteraction) => boolean);
},
interaction: BaseComponentInteraction,
): Promise<void> {
if (!resolveConditionalComponentOption(component.defer, interaction)) {
return;
}
if (resolveConditionalComponentOption(component.ephemeral, interaction)) {
await interaction.defer({ ephemeral: true });
return;
}
await interaction.acknowledge();
}
function readInteractionName(rawData: APIInteraction): string | undefined {
return (rawData as { data?: { name?: string } }).data?.name;
}
function readCustomId(rawData: APIInteraction): string | undefined {
return (rawData as { data?: { custom_id?: string } }).data?.custom_id;
}

View File

@@ -0,0 +1,95 @@
import {
type APIApplicationCommandInteractionDataBasicOption,
type APIApplicationCommandInteractionDataOption,
type APIChannel,
type APIInteractionDataResolvedChannel,
} from "discord-api-types/v10";
import type { Client } from "./client.js";
import { channelFactory } from "./structures.js";
function readFocusedOption(
options: APIApplicationCommandInteractionDataOption[] | undefined,
): APIApplicationCommandInteractionDataBasicOption | undefined {
for (const option of options ?? []) {
if ("focused" in option && option.focused) {
return option as APIApplicationCommandInteractionDataBasicOption;
}
const child = readFocusedOption(readChildOptions(option));
if (child) {
return child;
}
}
return undefined;
}
function findOption(
options: APIApplicationCommandInteractionDataOption[] | undefined,
name: string,
): APIApplicationCommandInteractionDataOption | undefined {
for (const option of options ?? []) {
if (option.name === name) {
return option;
}
const child = findOption(readChildOptions(option), name);
if (child) {
return child;
}
}
return undefined;
}
function readChildOptions(
option: APIApplicationCommandInteractionDataOption,
): APIApplicationCommandInteractionDataOption[] | undefined {
if (!("options" in option) || !Array.isArray(option.options)) {
return undefined;
}
return option.options;
}
export class OptionsHandler {
constructor(
private rawOptions: APIApplicationCommandInteractionDataOption[] | undefined,
private client: Client,
private resolvedChannels: Record<string, APIInteractionDataResolvedChannel> | undefined,
) {}
getString(name: string): string | null {
const option = findOption(this.rawOptions, name);
const value = option && "value" in option ? option.value : undefined;
return typeof value === "string" ? value : null;
}
getNumber(name: string): number | null {
const option = findOption(this.rawOptions, name);
const value = option && "value" in option ? option.value : undefined;
return typeof value === "number" ? value : null;
}
getBoolean(name: string): boolean | null {
const option = findOption(this.rawOptions, name);
const value = option && "value" in option ? option.value : undefined;
return typeof value === "boolean" ? value : null;
}
async getChannel(name: string, required = false) {
const option = findOption(this.rawOptions, name);
const value = option && "value" in option ? option.value : undefined;
const id = typeof value === "string" ? value : undefined;
const resolved = id ? this.resolvedChannels?.[id] : undefined;
if (resolved) {
return channelFactory(this.client, resolved as APIChannel);
}
if (id) {
return await this.client.fetchChannel(id);
}
if (required) {
throw new Error(`Missing required channel option ${name}`);
}
return null;
}
getFocused(): APIApplicationCommandInteractionDataBasicOption | undefined {
return readFocusedOption(this.rawOptions);
}
}

View File

@@ -0,0 +1,53 @@
import { InteractionResponseType, MessageFlags } from "discord-api-types/v10";
export type InteractionResponseState =
| "unacknowledged"
| "deferred"
| "deferred-update"
| "replied";
export type InteractionReplyAction = "initial" | "edit" | "follow-up";
export class InteractionResponseController {
state: InteractionResponseState = "unacknowledged";
get acknowledged(): boolean {
return this.state !== "unacknowledged";
}
recordCallback(type: InteractionResponseType): void {
if (type === InteractionResponseType.DeferredChannelMessageWithSource) {
this.state = "deferred";
return;
}
if (type === InteractionResponseType.DeferredMessageUpdate) {
this.state = "deferred-update";
return;
}
this.state = "replied";
}
nextReplyAction(): InteractionReplyAction {
if (this.state === "deferred" || this.state === "deferred-update") {
return "edit";
}
if (this.state === "unacknowledged") {
return "initial";
}
return "follow-up";
}
recordReplyEdit(): void {
this.state = "replied";
}
}
export function needsComponentsV2Query(body: unknown): boolean {
return (
body !== null &&
typeof body === "object" &&
"flags" in body &&
typeof (body as { flags?: unknown }).flags === "number" &&
((body as { flags: number }).flags & MessageFlags.IsComponentsV2) !== 0
);
}

View File

@@ -0,0 +1,253 @@
import {
ComponentType,
type GuildMemberFlags,
InteractionResponseType,
InteractionType,
MessageFlags,
} from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import { Container, TextDisplay } from "./components.js";
import {
BaseInteraction,
ModalInteraction,
createInteraction,
type RawInteraction,
} from "./interactions.js";
import {
attachRestMock,
createInternalComponentInteractionPayload,
createInternalInteractionPayload,
createInternalModalInteractionPayload,
createInternalTestClient,
} from "./test-builders.test-support.js";
describe("BaseInteraction", () => {
it("edits the original interaction response after defer", async () => {
const post = vi.fn(async () => undefined);
const patch = vi.fn(async () => undefined);
const client = createInternalTestClient();
attachRestMock(client, { patch, post });
const interaction = new BaseInteraction(
client,
createInternalInteractionPayload({ id: "interaction1", token: "token1" }),
);
await interaction.defer({ ephemeral: true });
await interaction.reply({ content: "done", ephemeral: true });
expect(post).toHaveBeenNthCalledWith(1, "/interactions/interaction1/token1/callback", {
body: {
type: InteractionResponseType.DeferredChannelMessageWithSource,
data: { flags: 64 },
},
});
expect(patch).toHaveBeenCalledWith("/webhooks/app1/token1/messages/%40original", {
body: { content: "done", flags: 64 },
});
});
it("uses with_components for Components V2 follow-ups", async () => {
const post = vi.fn(async () => undefined);
const client = createInternalTestClient();
attachRestMock(client, { post });
const interaction = new BaseInteraction(
client,
createInternalInteractionPayload({ id: "interaction1", token: "token1" }),
);
await interaction.reply("first");
await interaction.reply({
components: [new Container([new TextDisplay("done")])],
});
expect(post).toHaveBeenNthCalledWith(
2,
"/webhooks/app1/token1",
{
body: {
components: [
{
type: 17,
components: [{ type: 10, content: "done" }],
},
],
flags: MessageFlags.IsComponentsV2,
},
},
{ with_components: true },
);
});
it("uses with_components when editing deferred Components V2 replies", async () => {
const post = vi.fn(async () => undefined);
const patch = vi.fn(async () => undefined);
const client = createInternalTestClient();
attachRestMock(client, { patch, post });
const interaction = new BaseInteraction(
client,
createInternalInteractionPayload({ id: "interaction1", token: "token1" }),
);
await interaction.defer();
await interaction.reply({
components: [new Container([new TextDisplay("done")])],
});
expect(patch).toHaveBeenCalledWith(
"/webhooks/app1/token1/messages/%40original",
{
body: {
components: [
{
type: 17,
components: [{ type: 10, content: "done" }],
},
],
flags: MessageFlags.IsComponentsV2,
},
},
{ with_components: true },
);
});
it("edits the original component message after acknowledge", async () => {
const post = vi.fn(async () => undefined);
const patch = vi.fn(async () => undefined);
const client = createInternalTestClient();
attachRestMock(client, { patch, post });
const interaction = createInteraction(
client,
createInternalComponentInteractionPayload({
id: "interaction1",
token: "token1",
data: {
component_type: ComponentType.Button,
custom_id: "button1",
},
}),
);
await interaction.acknowledge();
await interaction.reply({ content: "updated", components: [] });
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/interactions/interaction1/token1/callback", {
body: { type: InteractionResponseType.DeferredMessageUpdate },
});
expect(patch).toHaveBeenCalledWith("/webhooks/app1/token1/messages/%40original", {
body: { content: "updated", components: [] },
});
});
it("rejects malformed interaction payloads at the boundary", () => {
expect(() =>
createInteraction(createInternalTestClient(), {
id: "interaction1",
type: 3,
} as unknown as RawInteraction),
).toThrow(/Invalid Discord interaction payload/);
});
it("preserves guild member user identity fields", () => {
const interaction = createInteraction(
createInternalTestClient(),
createInternalInteractionPayload({
id: "interaction1",
token: "token1",
type: InteractionType.ApplicationCommand,
guild_id: "guild1",
member: {
roles: [],
permissions: "0",
flags: 0 as GuildMemberFlags,
joined_at: "2026-01-01T00:00:00.000Z",
deaf: false,
mute: false,
user: {
id: "user1",
username: "alice",
global_name: "Alice Cooper",
discriminator: "1234",
avatar: null,
},
},
}),
);
expect(interaction.user?.id).toBe("user1");
expect(interaction.user?.username).toBe("alice");
expect(interaction.user?.globalName).toBe("Alice Cooper");
expect(interaction.user?.discriminator).toBe("1234");
});
});
describe("ModalInteraction", () => {
it("reads submitted fields from Components V2 label wrappers", () => {
const interaction = createInteraction(
createInternalTestClient(),
createInternalModalInteractionPayload({
id: "interaction1",
token: "token1",
data: {
components: [
{
type: ComponentType.Label,
component: {
type: ComponentType.TextInput,
custom_id: "title",
value: "Hello",
},
},
],
},
}),
);
expect(interaction).toBeInstanceOf(ModalInteraction);
expect((interaction as ModalInteraction).fields.getText("title")).toBe("Hello");
});
it("acknowledges modal submits as message updates", async () => {
const post = vi.fn(async () => undefined);
const client = createInternalTestClient();
attachRestMock(client, { post });
const interaction = createInteraction(
client,
createInternalModalInteractionPayload({
id: "interaction1",
token: "token1",
}),
);
await (interaction as ModalInteraction).acknowledge();
expect(post).toHaveBeenCalledWith("/interactions/interaction1/token1/callback", {
body: { type: InteractionResponseType.DeferredMessageUpdate },
});
});
it("edits the original modal source message after acknowledge", async () => {
const post = vi.fn(async () => undefined);
const patch = vi.fn(async () => undefined);
const client = createInternalTestClient();
attachRestMock(client, { patch, post });
const interaction = createInteraction(
client,
createInternalModalInteractionPayload({
id: "interaction1",
token: "token1",
}),
);
await (interaction as ModalInteraction).acknowledge();
await interaction.reply({ content: "cleared", components: [] });
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/interactions/interaction1/token1/callback", {
body: { type: InteractionResponseType.DeferredMessageUpdate },
});
expect(patch).toHaveBeenCalledWith("/webhooks/app1/token1/messages/%40original", {
body: { content: "cleared", components: [] },
});
});
});

View File

@@ -0,0 +1,318 @@
import {
ComponentType,
InteractionResponseType,
InteractionType,
type APIApplicationCommandInteraction,
type APIApplicationCommandInteractionDataOption,
type APIChannel,
type APIInteraction,
type APIInteractionDataResolvedChannel,
type APIMessageComponentInteraction,
type APIModalSubmitInteraction,
type APIUser,
} from "discord-api-types/v10";
import {
createInteractionCallback,
createWebhookMessage,
deleteWebhookMessage,
editWebhookMessage,
getWebhookMessage,
} from "./api.js";
import type { Client } from "./client.js";
import { type ComponentData, type Modal } from "./components.js";
import { OptionsHandler } from "./interaction-options.js";
import {
InteractionResponseController,
needsComponentsV2Query,
type InteractionResponseState,
} from "./interaction-response.js";
import { extractModalFields, ModalFields } from "./modal-fields.js";
import { serializePayload, type MessagePayload } from "./payload.js";
import { assertDiscordInteractionPayload } from "./schemas.js";
import { channelFactory, Guild, Message, User, type DiscordChannel } from "./structures.js";
export { OptionsHandler } from "./interaction-options.js";
export { ModalFields } from "./modal-fields.js";
export type RawInteraction = APIInteraction & {
token: string;
member?: { user?: APIUser; roles?: string[] };
guild_id?: string;
channel_id?: string;
channel?: unknown;
data?: {
custom_id?: string;
component_type?: number;
values?: string[];
components?: unknown[];
options?: APIApplicationCommandInteractionDataOption[];
resolved?: {
channels?: Record<string, APIInteractionDataResolvedChannel>;
roles?: Record<string, { id: string; name?: string }>;
users?: Record<string, { id: string; username?: string; discriminator?: string }>;
};
};
message?: unknown;
};
type CommandRawInteraction = APIApplicationCommandInteraction & RawInteraction;
type MessageComponentRawInteraction = APIMessageComponentInteraction & RawInteraction;
type ModalSubmitRawInteraction = APIModalSubmitInteraction & RawInteraction;
function toCommandRawInteraction(rawData: RawInteraction): CommandRawInteraction {
return rawData as CommandRawInteraction;
}
function toMessageComponentRawInteraction(rawData: RawInteraction): MessageComponentRawInteraction {
return rawData as MessageComponentRawInteraction;
}
function toModalSubmitRawInteraction(rawData: RawInteraction): ModalSubmitRawInteraction {
return rawData as ModalSubmitRawInteraction;
}
function readInteractionUser(rawData: RawInteraction, client: Client): User | null {
const directUser = "user" in rawData ? rawData.user : undefined;
if (directUser && typeof directUser === "object" && "id" in directUser) {
return new User(client, directUser);
}
const memberUser = rawData.member?.user;
if (memberUser && typeof memberUser === "object" && typeof memberUser.id === "string") {
const user = { ...memberUser } as APIUser;
if (typeof user.username !== "string") {
user.username = "";
}
return new User(client, user);
}
return null;
}
export class BaseInteraction {
readonly id: string;
readonly token: string;
readonly user: User | null;
readonly userId: string;
readonly guild: Guild | null;
readonly channel: DiscordChannel | null;
message: Message | null = null;
private readonly response = new InteractionResponseController();
constructor(
public client: Client,
public rawData: RawInteraction,
) {
this.id = rawData.id;
this.token = rawData.token;
this.user = readInteractionUser(rawData, client);
this.userId = this.user?.id ?? "";
this.guild = rawData.guild_id ? new Guild<true>(client, rawData.guild_id) : null;
this.channel =
"channel" in rawData && rawData.channel
? channelFactory(client, rawData.channel as APIChannel)
: null;
}
get acknowledged(): boolean {
return this.response.acknowledged;
}
get responseState(): InteractionResponseState {
return this.response.state;
}
set responseState(nextState: InteractionResponseState) {
this.response.state = nextState;
}
protected async callback(type: InteractionResponseType, data?: unknown) {
this.response.recordCallback(type);
return await createInteractionCallback(
this.client.rest,
this.id,
this.token,
data === undefined ? { type } : { type, data },
);
}
async reply(payload: MessagePayload): Promise<unknown> {
const action = this.response.nextReplyAction();
if (action === "edit") {
return await this.editReply(payload);
}
if (action === "follow-up") {
return await this.followUp(payload);
}
return await this.callback(
InteractionResponseType.ChannelMessageWithSource,
serializePayload(payload),
);
}
async defer(options?: { ephemeral?: boolean }): Promise<unknown> {
return await this.callback(
InteractionResponseType.DeferredChannelMessageWithSource,
options?.ephemeral ? { flags: 64 } : undefined,
);
}
async acknowledge(): Promise<unknown> {
return await this.defer();
}
async editReply(payload: MessagePayload): Promise<unknown> {
const body = serializePayload(payload);
const query = needsComponentsV2Query(body) ? { with_components: true } : undefined;
const result = query
? await editWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
{ body },
query,
)
: await editWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
{ body },
);
this.response.recordReplyEdit();
return result;
}
async deleteReply(): Promise<unknown> {
return await deleteWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
);
}
async fetchReply(): Promise<unknown> {
return await getWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
);
}
async followUp(payload: MessagePayload): Promise<unknown> {
const body = serializePayload(payload);
return await createWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
{ body },
needsComponentsV2Query(body) ? { with_components: true } : undefined,
);
}
}
export class CommandInteraction extends BaseInteraction {
readonly options: OptionsHandler;
constructor(client: Client, rawData: APIApplicationCommandInteraction & RawInteraction) {
super(client, rawData);
this.options = new OptionsHandler(
rawData.data.options,
client,
rawData.data.resolved?.channels,
);
}
}
export class AutocompleteInteraction extends CommandInteraction {
async respond(choices: Array<{ name: string; value: string | number }>): Promise<unknown> {
return await this.callback(InteractionResponseType.ApplicationCommandAutocompleteResult, {
choices,
});
}
}
export class BaseComponentInteraction extends BaseInteraction {
readonly values: string[];
constructor(client: Client, rawData: APIMessageComponentInteraction & RawInteraction) {
super(client, rawData);
this.message =
rawData.message && typeof rawData.message === "object"
? new Message(client, rawData.message)
: null;
this.values = Array.isArray(rawData.data.values) ? rawData.data.values.map(String) : [];
}
async update(payload: MessagePayload): Promise<unknown> {
return await this.callback(InteractionResponseType.UpdateMessage, serializePayload(payload));
}
async acknowledge(): Promise<unknown> {
return await this.callback(InteractionResponseType.DeferredMessageUpdate);
}
async showModal(modal: Modal): Promise<unknown> {
return await this.callback(InteractionResponseType.Modal, modal.serialize());
}
}
export class ButtonInteraction extends BaseComponentInteraction {}
export class StringSelectMenuInteraction extends BaseComponentInteraction {}
export class UserSelectMenuInteraction extends BaseComponentInteraction {}
export class RoleSelectMenuInteraction extends BaseComponentInteraction {}
export class MentionableSelectMenuInteraction extends BaseComponentInteraction {}
export class ChannelSelectMenuInteraction extends BaseComponentInteraction {}
export class ModalInteraction extends BaseInteraction {
readonly fields: ModalFields;
constructor(client: Client, rawData: APIModalSubmitInteraction & RawInteraction) {
super(client, rawData);
this.fields = new ModalFields(
extractModalFields(rawData.data.components ?? []),
rawData.data.resolved,
client,
);
}
async acknowledge(): Promise<unknown> {
return await this.callback(InteractionResponseType.DeferredMessageUpdate);
}
}
export function createInteraction(client: Client, rawData: RawInteraction) {
assertDiscordInteractionPayload(rawData);
if (rawData.type === InteractionType.ApplicationCommandAutocomplete) {
return new AutocompleteInteraction(client, toCommandRawInteraction(rawData));
}
if (rawData.type === InteractionType.ApplicationCommand) {
return new CommandInteraction(client, toCommandRawInteraction(rawData));
}
if (rawData.type === InteractionType.ModalSubmit) {
return new ModalInteraction(client, toModalSubmitRawInteraction(rawData));
}
if (rawData.type === InteractionType.MessageComponent) {
const componentRawData = toMessageComponentRawInteraction(rawData);
switch (rawData.data?.component_type) {
case ComponentType.Button:
return new ButtonInteraction(client, componentRawData);
case ComponentType.StringSelect:
return new StringSelectMenuInteraction(client, componentRawData);
case ComponentType.UserSelect:
return new UserSelectMenuInteraction(client, componentRawData);
case ComponentType.RoleSelect:
return new RoleSelectMenuInteraction(client, componentRawData);
case ComponentType.MentionableSelect:
return new MentionableSelectMenuInteraction(client, componentRawData);
case ComponentType.ChannelSelect:
return new ChannelSelectMenuInteraction(client, componentRawData);
default:
return new BaseComponentInteraction(client, componentRawData);
}
}
return new BaseInteraction(client, rawData);
}
export function parseComponentInteractionData(
component: { customIdParser: (id: string) => { data: ComponentData } },
customId: string,
): ComponentData {
return component.customIdParser(customId).data;
}

View File

@@ -0,0 +1,81 @@
import {
GatewayDispatchEvents,
type APIMessage,
type APIReaction,
type GatewayPresenceUpdateDispatchData,
type GatewayThreadUpdateDispatchData,
} from "discord-api-types/v10";
import type { Client } from "./client.js";
import { Guild, Message, User } from "./structures.js";
export type DiscordMessageDispatchData = {
id?: string;
channel_id: string;
channelId?: string;
guild_id?: string;
message: Message;
author: User | null;
member?: { roles?: string[]; nick?: string | null; nickname?: string | null };
rawMember?: { roles?: string[]; nick?: string | null; nickname?: string | null };
guild?: Guild | null;
channel?: unknown;
};
export type DiscordReactionDispatchData = {
user_id?: string;
channel_id: string;
message_id: string;
guild_id?: string;
emoji: APIReaction["emoji"];
burst?: boolean;
type?: number;
user: User;
rawMember?: { roles?: string[] };
guild?: Guild | null;
message: Message<true> | { fetch(): Promise<{ author?: User | null }> };
rawMessage?: APIMessage;
};
export abstract class BaseListener {
abstract readonly type: string;
abstract handle(data: unknown, client: Client): Promise<void> | void;
}
export abstract class ReadyListener extends BaseListener {
readonly type = GatewayDispatchEvents.Ready;
}
export abstract class MessageCreateListener extends BaseListener {
readonly type = GatewayDispatchEvents.MessageCreate;
abstract override handle(data: DiscordMessageDispatchData, client: Client): Promise<void> | void;
}
export abstract class InteractionCreateListener extends BaseListener {
readonly type = GatewayDispatchEvents.InteractionCreate;
}
export abstract class MessageReactionAddListener extends BaseListener {
readonly type = GatewayDispatchEvents.MessageReactionAdd;
abstract override handle(data: DiscordReactionDispatchData, client: Client): Promise<void> | void;
}
export abstract class MessageReactionRemoveListener extends BaseListener {
readonly type = GatewayDispatchEvents.MessageReactionRemove;
abstract override handle(data: DiscordReactionDispatchData, client: Client): Promise<void> | void;
}
export abstract class PresenceUpdateListener extends BaseListener {
readonly type = GatewayDispatchEvents.PresenceUpdate;
abstract override handle(
data: GatewayPresenceUpdateDispatchData,
client: Client,
): Promise<void> | void;
}
export abstract class ThreadUpdateListener extends BaseListener {
readonly type = GatewayDispatchEvents.ThreadUpdate;
abstract override handle(
data: GatewayThreadUpdateDispatchData,
client: Client,
): Promise<void> | void;
}

View File

@@ -0,0 +1,26 @@
import { Routes } from "discord-api-types/v10";
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../../../../src/agents/live-test-helpers.js";
import { parseApplicationIdFromToken } from "../probe.js";
import { RequestClient } from "./rest.js";
const TOKEN = process.env.DISCORD_BOT_TOKEN ?? "";
const LIVE = isLiveTestEnabled(["DISCORD_LIVE_TEST"]) && TOKEN.length > 0;
const describeLive = LIVE ? describe : describe.skip;
describeLive("discord live smoke", () => {
it("resolves bot identity and gateway metadata", async () => {
const rest = new RequestClient(TOKEN, { queueRequests: false, timeout: 15_000 });
const me = (await rest.get(Routes.user("@me"))) as { id?: string; bot?: boolean };
expect(me.bot).toBe(true);
expect(me.id).toBe(parseApplicationIdFromToken(TOKEN));
const gateway = (await rest.get(Routes.gatewayBot())) as {
url?: string;
session_start_limit?: { max_concurrency?: number };
};
expect(gateway.url).toMatch(/^wss:\/\//);
expect(gateway.session_start_limit?.max_concurrency).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,96 @@
import { type APIRole, type APIUser } from "discord-api-types/v10";
import type { Client } from "./client.js";
import { Role, User } from "./structures.js";
type ModalResolvedData = {
roles?: Record<string, { id: string; name?: string }>;
users?: Record<string, { id: string; username?: string; discriminator?: string }>;
};
export function extractModalFields(components: unknown[]): Record<string, string | string[]> {
const out: Record<string, string | string[]> = {};
for (const component of flattenModalComponents(components)) {
const raw = component as { custom_id?: unknown; value?: unknown; values?: unknown };
if (typeof raw.custom_id !== "string") {
continue;
}
if (Array.isArray(raw.values)) {
out[raw.custom_id] = raw.values.map(String);
} else if (
typeof raw.value === "string" ||
typeof raw.value === "number" ||
typeof raw.value === "boolean"
) {
out[raw.custom_id] = String(raw.value);
}
}
return out;
}
function flattenModalComponents(components: unknown[]): unknown[] {
const out: unknown[] = [];
for (const entry of components) {
if (!entry || typeof entry !== "object") {
continue;
}
const component = entry as { component?: unknown; components?: unknown[] };
if (component.component && typeof component.component === "object") {
out.push(component.component);
}
if (Array.isArray(component.components)) {
out.push(...flattenModalComponents(component.components));
}
out.push(entry);
}
return out;
}
export class ModalFields {
constructor(
private values: Record<string, string | string[]>,
private resolved?: ModalResolvedData,
private client?: Client,
) {}
private value(id: string, required: boolean): string | string[] | undefined {
const value = this.values[id];
if (required && (value === undefined || (Array.isArray(value) && value.length === 0))) {
throw new Error(`Missing required modal field ${id}`);
}
return value;
}
getText(id: string, required = false): string | null {
const value = this.value(id, required);
return typeof value === "string" ? value : null;
}
getStringSelect(id: string, required = false): string[] {
const value = this.value(id, required);
if (Array.isArray(value)) {
return value;
}
return typeof value === "string" ? [value] : [];
}
getRoleSelect(id: string, required = false): Role[] {
const values = this.getStringSelect(id, required);
return values.map((roleId) => {
const raw = this.resolved?.roles?.[roleId];
return raw
? new Role(this.client!, { id: roleId, name: raw.name ?? "" } as APIRole)
: new Role<true>(this.client!, roleId);
});
}
getUserSelect(id: string, required = false): User[] {
const values = this.getStringSelect(id, required);
return values.map((userId) => {
const raw = this.resolved?.users?.[userId];
return new User(this.client!, {
id: userId,
username: raw?.username ?? "",
} as APIUser);
});
}
}

View File

@@ -0,0 +1,83 @@
import { MessageFlags, type APIEmbed } from "discord-api-types/v10";
import type {
BaseMessageInteractiveComponent,
Container,
File,
MediaGallery,
Row,
Section,
Separator,
TextDisplay,
} from "./components.js";
import { Embed } from "./embeds.js";
export type MessagePayloadFile = {
name: string;
data: Blob | Uint8Array | ArrayBuffer;
description?: string;
duration_secs?: number;
waveform?: string;
};
export type MessagePayloadObject = {
content?: string;
embeds?: Array<APIEmbed | Embed>;
components?: TopLevelComponents[];
allowedMentions?: unknown;
allowed_mentions?: unknown;
flags?: number;
tts?: boolean;
files?: MessagePayloadFile[];
poll?: unknown;
ephemeral?: boolean;
stickers?: [string, string, string] | [string, string] | [string];
};
export type MessagePayload = string | MessagePayloadObject;
export type TopLevelComponents =
| Row<BaseMessageInteractiveComponent>
| Container
| File
| MediaGallery
| Section
| Separator
| TextDisplay;
function clean<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
}
function serializeAnyComponent(component: { serialize: () => unknown }): unknown {
return component.serialize();
}
function payloadHasV2Components(payload: MessagePayloadObject): boolean {
return Boolean(payload.components?.some((component) => component.isV2));
}
function normalizePayloadFlags(payload: MessagePayloadObject): number | undefined {
const flags = payload.ephemeral ? (payload.flags ?? 0) | MessageFlags.Ephemeral : payload.flags;
if (!payloadHasV2Components(payload)) {
return flags;
}
if (payload.content || payload.embeds?.length) {
throw new Error("Discord Components V2 payloads cannot include content or embeds");
}
return (flags ?? 0) | MessageFlags.IsComponentsV2;
}
export function serializePayload(payload: MessagePayload) {
if (typeof payload === "string") {
return { content: payload };
}
const flags = normalizePayloadFlags(payload);
return clean({
content: payload.content,
embeds: payload.embeds?.map((entry) => ("serialize" in entry ? entry.serialize() : entry)),
components: payload.components?.map((entry) => serializeAnyComponent(entry)),
allowed_mentions: payload.allowed_mentions ?? payload.allowedMentions,
flags,
tts: payload.tts,
files: payload.files,
poll: payload.poll,
sticker_ids: payload.stickers,
});
}

View File

@@ -0,0 +1,110 @@
import type { RequestData } from "./rest.js";
export function serializeRequestBody(
data: RequestData | undefined,
headers: Headers,
): BodyInit | undefined {
if (data?.headers) {
for (const [key, value] of Object.entries(data.headers)) {
headers.set(key, value);
}
}
if (data?.body == null) {
return undefined;
}
if (typeof data.body === "object") {
const bodyObject = data.body as Record<string, unknown>;
const topLevelFiles = Array.isArray(bodyObject.files) ? bodyObject.files : undefined;
const nestedData =
bodyObject.data && typeof bodyObject.data === "object"
? (bodyObject.data as Record<string, unknown>)
: undefined;
const nestedFiles =
nestedData && Array.isArray(nestedData.files) ? nestedData.files : undefined;
const files = topLevelFiles ?? nestedFiles;
const filesContainer = topLevelFiles ? bodyObject : nestedFiles ? nestedData : undefined;
if (files?.length && filesContainer) {
if (data.multipartStyle === "form") {
const formData = new FormData();
for (const [key, value] of Object.entries(filesContainer)) {
if (key === "files" || value === undefined || value === null) {
continue;
}
formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
}
for (const file of files) {
const item = file as {
fieldName?: unknown;
name?: unknown;
data?: unknown;
contentType?: unknown;
};
const name = typeof item.name === "string" && item.name ? item.name : "file";
const blob =
item.data instanceof Blob
? item.data
: new Blob([item.data as BlobPart], {
type: typeof item.contentType === "string" ? item.contentType : undefined,
});
formData.append(
typeof item.fieldName === "string" && item.fieldName ? item.fieldName : "file",
blob,
name,
);
}
return formData;
}
const payloadJson = topLevelFiles
? { ...bodyObject }
: { ...bodyObject, data: { ...nestedData } };
const payloadFilesContainer = topLevelFiles
? (payloadJson as Record<string, unknown>)
: ((payloadJson as { data: Record<string, unknown> }).data ?? {});
const formData = new FormData();
const existingAttachments = Array.isArray(payloadFilesContainer.attachments)
? [...payloadFilesContainer.attachments]
: [];
const uploaded = files.map((file, index) => {
const item = file as {
name?: unknown;
data?: unknown;
contentType?: unknown;
description?: unknown;
duration_secs?: unknown;
waveform?: unknown;
};
const name = typeof item.name === "string" && item.name ? item.name : `file-${index}`;
const blob =
item.data instanceof Blob
? item.data
: new Blob([item.data as BlobPart], {
type: typeof item.contentType === "string" ? item.contentType : undefined,
});
const id = existingAttachments.length + index;
formData.append(`files[${id}]`, blob, name);
const attachment: Record<string, unknown> = {
id,
filename: name,
};
if (typeof item.description === "string") {
attachment.description = item.description;
}
if (typeof item.duration_secs === "number") {
attachment.duration_secs = item.duration_secs;
}
if (typeof item.waveform === "string") {
attachment.waveform = item.waveform;
}
return attachment;
});
payloadFilesContainer.attachments = [...existingAttachments, ...uploaded];
delete payloadFilesContainer.files;
formData.append("payload_json", JSON.stringify(payloadJson));
return formData;
}
}
if (!data.rawBody) {
headers.set("Content-Type", "application/json");
}
return data.rawBody ? (data.body as BodyInit) : JSON.stringify(data.body);
}

View File

@@ -0,0 +1,73 @@
export function readDiscordCode(body: unknown): number | undefined {
const value =
body && typeof body === "object" && "code" in body
? (body as { code?: unknown }).code
: undefined;
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && /^\d+$/.test(value)) {
return Number(value);
}
return undefined;
}
export function readDiscordMessage(body: unknown, fallback: string): string {
const value =
body && typeof body === "object" && "message" in body
? (body as { message?: unknown }).message
: undefined;
return typeof value === "string" && value.trim() ? value : fallback;
}
export function readRetryAfter(body: unknown, response: Response): number {
const bodyValue =
body && typeof body === "object" && "retry_after" in body
? (body as { retry_after?: unknown }).retry_after
: undefined;
const headerValue = response.headers.get("Retry-After");
const seconds =
typeof bodyValue === "number"
? bodyValue
: typeof bodyValue === "string"
? Number(bodyValue)
: headerValue
? Number(headerValue)
: 0;
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
}
export class DiscordError extends Error {
readonly status: number;
readonly statusCode: number;
readonly rawBody: unknown;
readonly rawError: unknown;
discordCode?: number;
constructor(response: Response, body: unknown) {
super(readDiscordMessage(body, `Discord API request failed (${response.status})`));
this.name = "DiscordError";
this.status = response.status;
this.statusCode = response.status;
this.rawBody = body;
this.rawError = body;
this.discordCode = readDiscordCode(body);
}
}
export class RateLimitError extends DiscordError {
readonly retryAfter: number;
readonly scope: string | null;
readonly bucket: string | null;
constructor(
response: Response,
body: { message: string; retry_after: number; global: boolean; code?: number | string },
) {
super(response, body);
this.name = "RateLimitError";
this.retryAfter = readRetryAfter(body, response);
this.scope = body.global ? "global" : response.headers.get("X-RateLimit-Scope");
this.bucket = response.headers.get("X-RateLimit-Bucket");
}
}

View File

@@ -0,0 +1,50 @@
import type { QueuedRequest } from "./rest.js";
export function createRouteKey(method: string, path: string): string {
return `${method.toUpperCase()} ${path.split("?")[0] ?? path}`;
}
function readTopLevelRouteKey(path: string): string {
const [pathname = path] = path.split("?");
const [first, id, token] = pathname.replace(/^\/+/, "").split("/");
if (!first || !id) {
return pathname;
}
if (first === "channels" || first === "guilds" || first === "webhooks") {
return first === "webhooks" && token ? `${first}/${id}/${token}` : `${first}/${id}`;
}
return first;
}
export function createBucketKey(bucket: string, path: string): string {
return `${bucket}:${readTopLevelRouteKey(path)}`;
}
export function readHeaderNumber(headers: Headers, name: string): number | undefined {
const value = headers.get(name);
if (!value) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function readResetAt(response: Response): number | undefined {
const resetAfter = readHeaderNumber(response.headers, "X-RateLimit-Reset-After");
if (resetAfter !== undefined) {
return Date.now() + Math.max(0, resetAfter * 1000);
}
const reset = readHeaderNumber(response.headers, "X-RateLimit-Reset");
return reset !== undefined ? reset * 1000 : undefined;
}
export function appendQuery(path: string, query?: QueuedRequest["query"]): string {
if (!query || Object.keys(query).length === 0) {
return path;
}
const search = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
search.set(key, String(value));
}
return `${path}?${search.toString()}`;
}

View File

@@ -0,0 +1,331 @@
import { readRetryAfter } from "./rest-errors.js";
import { createBucketKey, createRouteKey, readHeaderNumber, readResetAt } from "./rest-routes.js";
export type RequestQuery = Record<string, string | number | boolean>;
export type ScheduledRequest<TData> = {
method: string;
path: string;
data?: TData;
query?: RequestQuery;
routeKey: string;
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
};
type BucketState<TData> = {
active: number;
bucket?: string;
invalidRequests: number;
limit?: number;
pending: Array<ScheduledRequest<TData>>;
rateLimitHits: number;
remaining?: number;
resetAt: number;
routeKeys: Set<string>;
};
export type RestSchedulerOptions = {
maxConcurrency: number;
maxQueueSize: number;
};
const INVALID_REQUEST_WINDOW_MS = 10 * 60_000;
export class RestScheduler<TData> {
private activeWorkers = 0;
private buckets = new Map<string, BucketState<TData>>();
private drainTimer: NodeJS.Timeout | undefined;
private globalRateLimitUntil = 0;
private invalidRequestTimestamps: Array<{ at: number; status: number }> = [];
private queuedRequests = 0;
private routeBuckets = new Map<string, string>();
constructor(
private readonly options: RestSchedulerOptions,
private readonly executor: (request: ScheduledRequest<TData>) => Promise<unknown>,
) {}
enqueue(params: {
method: string;
path: string;
data?: TData;
query?: RequestQuery;
}): Promise<unknown> {
if (this.queuedRequests >= this.options.maxQueueSize) {
throw new Error("Discord request queue is full");
}
const routeKey = createRouteKey(params.method, params.path);
const bucket = this.getBucket(this.routeBuckets.get(routeKey) ?? routeKey);
return new Promise((resolve, reject) => {
this.queuedRequests += 1;
bucket.pending.push({ ...params, routeKey, resolve, reject });
this.drainQueues();
});
}
recordResponse(routeKey: string, path: string, response: Response, parsed: unknown): void {
this.updateRateLimitState(routeKey, path, response, parsed);
this.recordInvalidRequest(routeKey, path, response);
}
clearQueue(): void {
if (this.drainTimer) {
clearTimeout(this.drainTimer);
this.drainTimer = undefined;
}
this.rejectPending(new Error("Discord request queue cleared"));
}
abortPending(): void {
this.rejectPending(new DOMException("Aborted", "AbortError"));
}
get queueSize(): number {
return this.queuedRequests;
}
getMetrics() {
this.pruneInvalidRequests();
return {
globalRateLimitUntil: this.globalRateLimitUntil,
activeBuckets: this.buckets.size,
buckets: Array.from(this.buckets.entries()).map(([key, bucket]) => ({
key,
active: bucket.active,
bucket: bucket.bucket,
invalidRequests: bucket.invalidRequests,
pending: bucket.pending.length,
rateLimitHits: bucket.rateLimitHits,
remaining: bucket.remaining,
resetAt: bucket.resetAt,
routeKeyCount: bucket.routeKeys.size,
})),
invalidRequestCount: this.invalidRequestTimestamps.length,
invalidRequestCountByStatus: this.invalidRequestTimestamps.reduce<Record<number, number>>(
(counts, entry) => {
counts[entry.status] = (counts[entry.status] ?? 0) + 1;
return counts;
},
{},
),
queueSize: this.queueSize,
activeWorkers: this.activeWorkers,
maxConcurrentWorkers: this.maxConcurrentWorkers,
};
}
private get maxConcurrentWorkers(): number {
return Math.max(1, Math.floor(this.options.maxConcurrency));
}
private getBucket(key: string): BucketState<TData> {
const existing = this.buckets.get(key);
if (existing) {
return existing;
}
const bucket: BucketState<TData> = {
active: 0,
invalidRequests: 0,
pending: [],
rateLimitHits: 0,
resetAt: 0,
routeKeys: new Set([key]),
};
this.buckets.set(key, bucket);
return bucket;
}
private hasBucketReference(key: string): boolean {
for (const bucketKey of this.routeBuckets.values()) {
if (bucketKey === key) {
return true;
}
}
return false;
}
private bindRouteToBucket(routeKey: string, bucketKey: string): BucketState<TData> {
const target = this.getBucket(bucketKey);
target.routeKeys.add(routeKey);
this.routeBuckets.set(routeKey, bucketKey);
const routeBucket = this.buckets.get(routeKey);
if (routeBucket && routeBucket !== target) {
target.pending.push(...routeBucket.pending);
routeBucket.pending = [];
if (routeBucket.active === 0) {
this.buckets.delete(routeKey);
}
}
return target;
}
private updateRateLimitState(
routeKey: string,
path: string,
response: Response,
parsed: unknown,
): void {
const bucketHeader = response.headers.get("X-RateLimit-Bucket");
const bucket = bucketHeader
? this.bindRouteToBucket(routeKey, createBucketKey(bucketHeader, path))
: this.getBucket(this.routeBuckets.get(routeKey) ?? routeKey);
bucket.bucket = bucketHeader ?? bucket.bucket;
const limit = readHeaderNumber(response.headers, "X-RateLimit-Limit");
if (limit !== undefined) {
bucket.limit = limit;
}
const remaining = readHeaderNumber(response.headers, "X-RateLimit-Remaining");
if (remaining !== undefined) {
bucket.remaining = remaining;
}
const resetAt = readResetAt(response);
if (resetAt !== undefined) {
bucket.resetAt = resetAt;
}
if (response.status !== 429) {
return;
}
bucket.rateLimitHits += 1;
const retryAfterMs = Math.max(0, readRetryAfter(parsed, response) * 1000);
const retryAt = Date.now() + retryAfterMs;
if (response.headers.get("X-RateLimit-Global") === "true" || isGlobalRateLimit(parsed)) {
this.globalRateLimitUntil = Math.max(this.globalRateLimitUntil, retryAt);
return;
}
bucket.remaining = 0;
bucket.resetAt = Math.max(bucket.resetAt, retryAt);
}
private recordInvalidRequest(routeKey: string, path: string, response: Response): void {
if (response.status !== 401 && response.status !== 403 && response.status !== 429) {
return;
}
if (response.status === 429 && response.headers.get("X-RateLimit-Scope") === "shared") {
return;
}
const now = Date.now();
this.invalidRequestTimestamps.push({ at: now, status: response.status });
this.pruneInvalidRequests(now);
const bucketHeader = response.headers.get("X-RateLimit-Bucket");
const bucketKey = bucketHeader
? createBucketKey(bucketHeader, path)
: (this.routeBuckets.get(routeKey) ?? routeKey);
const bucket = this.buckets.get(bucketKey);
if (bucket) {
bucket.invalidRequests += 1;
}
}
private pruneInvalidRequests(now = Date.now()): void {
const cutoff = now - INVALID_REQUEST_WINDOW_MS;
while (
this.invalidRequestTimestamps.length > 0 &&
(this.invalidRequestTimestamps[0]?.at ?? 0) <= cutoff
) {
this.invalidRequestTimestamps.shift();
}
}
private getBucketWaitMs(bucket: BucketState<TData>, now: number): number {
if (bucket.remaining === 0 && bucket.resetAt > now) {
return bucket.resetAt - now;
}
if (bucket.remaining === 0 && bucket.resetAt <= now) {
bucket.remaining = bucket.limit;
}
return 0;
}
private scheduleDrain(delayMs = 0): void {
if (this.drainTimer) {
return;
}
this.drainTimer = setTimeout(
() => {
this.drainTimer = undefined;
this.drainQueues();
},
Math.max(0, delayMs),
);
this.drainTimer.unref?.();
}
private drainQueues(): void {
const now = Date.now();
if (this.globalRateLimitUntil > now) {
this.scheduleDrain(this.globalRateLimitUntil - now);
return;
}
let nextDelayMs = Number.POSITIVE_INFINITY;
for (const [key, bucket] of this.buckets) {
if (this.activeWorkers >= this.maxConcurrentWorkers) {
break;
}
if (bucket.pending.length === 0) {
if (bucket.active === 0 && !this.routeBuckets.has(key) && !this.hasBucketReference(key)) {
this.buckets.delete(key);
}
continue;
}
if (bucket.active > 0) {
continue;
}
const waitMs = this.getBucketWaitMs(bucket, now);
if (waitMs > 0) {
nextDelayMs = Math.min(nextDelayMs, waitMs);
continue;
}
const queued = bucket.pending.shift();
if (!queued) {
continue;
}
if (bucket.remaining !== undefined && bucket.remaining > 0) {
bucket.remaining -= 1;
}
bucket.active += 1;
this.activeWorkers += 1;
void this.runQueuedRequest(queued, bucket);
}
if (Number.isFinite(nextDelayMs)) {
this.scheduleDrain(nextDelayMs);
}
}
private async runQueuedRequest(
queued: ScheduledRequest<TData>,
bucket: BucketState<TData>,
): Promise<void> {
try {
queued.resolve(await this.executor(queued));
} catch (error) {
queued.reject(error);
} finally {
bucket.active = Math.max(0, bucket.active - 1);
this.activeWorkers = Math.max(0, this.activeWorkers - 1);
this.queuedRequests = Math.max(0, this.queuedRequests - 1);
if (bucket.active === 0 && bucket.pending.length === 0) {
for (const routeKey of bucket.routeKeys) {
if (this.routeBuckets.get(routeKey) === routeKey) {
this.routeBuckets.delete(routeKey);
}
}
}
this.drainQueues();
}
}
private rejectPending(error: Error | DOMException): void {
for (const bucket of this.buckets.values()) {
for (const queued of bucket.pending.splice(0)) {
queued.reject(error);
this.queuedRequests = Math.max(0, this.queuedRequests - 1);
}
}
}
}
function isGlobalRateLimit(parsed: unknown): boolean {
return parsed && typeof parsed === "object" && "global" in parsed
? Boolean((parsed as { global?: unknown }).global)
: false;
}

View File

@@ -0,0 +1,228 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { serializeRequestBody } from "./rest-body.js";
import { RequestClient } from "./rest.js";
function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
}
describe("RequestClient", () => {
afterEach(() => {
vi.useRealTimers();
});
it("tracks queued requests and enforces maxQueueSize", async () => {
const firstResponse = createDeferred<Response>();
const queuedResponses = [
firstResponse.promise,
Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })),
];
const fetchSpy = vi.fn(async () => {
const response = queuedResponses.shift();
if (!response) {
throw new Error("unexpected request");
}
return await response;
});
const client = new RequestClient("test-token", {
fetch: fetchSpy,
maxQueueSize: 2,
});
const first = client.get("/users/@me");
const second = client.get("/users/@me");
expect(client.queueSize).toBe(2);
await expect(client.get("/users/@me")).rejects.toThrow(/queue is full/);
firstResponse.resolve(new Response(JSON.stringify({ id: "u1" }), { status: 200 }));
await expect(first).resolves.toEqual({ id: "u1" });
await expect(second).resolves.toEqual({ ok: true });
expect(client.queueSize).toBe(0);
});
it("runs independent route buckets concurrently", async () => {
const channelResponse = createDeferred<Response>();
const guildResponse = createDeferred<Response>();
const fetchSpy = vi.fn(async (input: string | URL | Request) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return await (url.includes("/channels/") ? channelResponse.promise : guildResponse.promise);
});
const client = new RequestClient("test-token", {
fetch: fetchSpy,
scheduler: { maxConcurrency: 2 },
});
const channel = client.get("/channels/c1/messages");
const guild = client.get("/guilds/g1/roles");
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2));
channelResponse.resolve(
new Response(JSON.stringify({ id: "channel" }), {
status: 200,
headers: { "X-RateLimit-Bucket": "channel-messages", "X-RateLimit-Remaining": "1" },
}),
);
guildResponse.resolve(
new Response(JSON.stringify({ id: "guild" }), {
status: 200,
headers: { "X-RateLimit-Bucket": "guild-roles", "X-RateLimit-Remaining": "1" },
}),
);
await expect(Promise.all([channel, guild])).resolves.toEqual([
{ id: "channel" },
{ id: "guild" },
]);
});
it("waits for a learned bucket reset before dispatching the next request", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const responses = [
Promise.resolve(
new Response(JSON.stringify({ id: "first" }), {
status: 200,
headers: {
"X-RateLimit-Bucket": "channel-messages",
"X-RateLimit-Limit": "1",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset-After": "0.1",
},
}),
),
Promise.resolve(
new Response(JSON.stringify({ id: "second" }), {
status: 200,
headers: {
"X-RateLimit-Bucket": "channel-messages",
"X-RateLimit-Limit": "1",
"X-RateLimit-Remaining": "1",
},
}),
),
];
const fetchSpy = vi.fn(async () => {
const response = responses.shift();
if (!response) {
throw new Error("unexpected request");
}
return await response;
});
const client = new RequestClient("test-token", { fetch: fetchSpy });
await expect(client.get("/channels/c1/messages")).resolves.toEqual({ id: "first" });
const second = client.get("/channels/c1/messages");
await Promise.resolve();
expect(fetchSpy).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(99);
expect(fetchSpy).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(second).resolves.toEqual({ id: "second" });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("preserves Discord error codes on rate limit errors", async () => {
const client = new RequestClient("test-token", {
queueRequests: false,
fetch: async () =>
new Response(
JSON.stringify({
message: "Max number of daily application command creates has been reached (200)",
retry_after: 60,
global: false,
code: 30034,
}),
{ status: 429 },
),
});
await expect(client.post("/applications/app/commands", { body: {} })).rejects.toMatchObject({
name: "RateLimitError",
discordCode: 30034,
retryAfter: 60,
});
});
it("tracks invalid requests and exposes bucket scheduler metrics", async () => {
const client = new RequestClient("test-token", {
queueRequests: false,
fetch: async () =>
new Response(JSON.stringify({ message: "Forbidden", code: 50013 }), {
status: 403,
headers: { "X-RateLimit-Bucket": "permissions" },
}),
});
await expect(client.get("/channels/c1/messages")).rejects.toMatchObject({ status: 403 });
expect(client.getSchedulerMetrics()).toEqual(
expect.objectContaining({
invalidRequestCount: 1,
invalidRequestCountByStatus: { 403: 1 },
}),
);
});
it("serializes message multipart uploads with payload_json", async () => {
const headers = new Headers();
const body = serializeRequestBody(
{
body: {
content: "file",
files: [{ name: "a.txt", data: new Uint8Array([1]), contentType: "text/plain" }],
},
},
headers,
);
expect(body).toBeInstanceOf(FormData);
const form = body as FormData;
expect(form.get("payload_json")).toBe(
JSON.stringify({
content: "file",
attachments: [{ id: 0, filename: "a.txt" }],
}),
);
expect(form.get("files[0]")).toBeInstanceOf(Blob);
});
it("serializes form multipart uploads for sticker-style endpoints", () => {
const headers = new Headers();
const body = serializeRequestBody(
{
multipartStyle: "form",
body: {
name: "Sticker",
tags: "tag",
files: [
{
fieldName: "file",
name: "sticker.png",
data: new Uint8Array([1]),
contentType: "image/png",
},
],
},
},
headers,
);
expect(body).toBeInstanceOf(FormData);
const form = body as FormData;
expect(form.get("name")).toBe("Sticker");
expect(form.get("tags")).toBe("tag");
expect(form.get("file")).toBeInstanceOf(Blob);
expect(form.get("payload_json")).toBeNull();
});
});

View File

@@ -0,0 +1,212 @@
import { inspect } from "node:util";
import { serializeRequestBody } from "./rest-body.js";
import {
DiscordError,
RateLimitError,
readDiscordCode,
readDiscordMessage,
readRetryAfter,
} from "./rest-errors.js";
import { appendQuery, createRouteKey } from "./rest-routes.js";
import { RestScheduler, type RequestQuery } from "./rest-scheduler.js";
import { isDiscordRateLimitBody } from "./schemas.js";
export { DiscordError, RateLimitError } from "./rest-errors.js";
export type RuntimeProfile = "serverless" | "persistent";
export type RequestPriority = "critical" | "standard" | "background";
export type RequestSchedulerOptions = {
maxConcurrency?: number;
maxRateLimitRetries?: number;
};
export type RequestClientOptions = {
tokenHeader?: "Bot" | "Bearer";
baseUrl?: string;
apiVersion?: number;
userAgent?: string;
timeout?: number;
queueRequests?: boolean;
maxQueueSize?: number;
runtimeProfile?: RuntimeProfile;
scheduler?: RequestSchedulerOptions;
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
};
export type RequestData = {
body?: unknown;
multipartStyle?: "message" | "form";
rawBody?: boolean;
headers?: Record<string, string>;
};
export type QueuedRequest = {
method: string;
path: string;
data?: RequestData;
query?: RequestQuery;
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
routeKey: string;
};
const defaultOptions = {
tokenHeader: "Bot" as const,
baseUrl: "https://discord.com/api",
apiVersion: 10,
userAgent: "OpenClaw Discord",
timeout: 15_000,
queueRequests: true,
maxQueueSize: 1000,
runtimeProfile: "persistent" as RuntimeProfile,
};
const DEFAULT_MAX_CONCURRENT_WORKERS = 4;
function coerceResponseBody(raw: string): unknown {
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
export class RequestClient {
readonly options: RequestClientOptions;
protected token: string;
protected customFetch: RequestClientOptions["fetch"];
protected requestControllers = new Set<AbortController>();
private scheduler: RestScheduler<RequestData>;
constructor(token: string, options?: RequestClientOptions) {
this.token = token.replace(/^Bot\s+/i, "");
this.customFetch = options?.fetch;
this.options = { ...defaultOptions, ...options };
this.scheduler = new RestScheduler<RequestData>(
{
maxConcurrency: this.options.scheduler?.maxConcurrency ?? DEFAULT_MAX_CONCURRENT_WORKERS,
maxQueueSize: this.options.maxQueueSize ?? defaultOptions.maxQueueSize,
},
async (request) =>
await this.executeRequest(
request.method,
request.path,
{ data: request.data, query: request.query },
request.routeKey,
),
);
}
async get(path: string, query?: QueuedRequest["query"]): Promise<unknown> {
return await this.request("GET", path, { query });
}
async post(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise<unknown> {
return await this.request("POST", path, { data, query });
}
async patch(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise<unknown> {
return await this.request("PATCH", path, { data, query });
}
async put(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise<unknown> {
return await this.request("PUT", path, { data, query });
}
async delete(path: string, data?: RequestData, query?: QueuedRequest["query"]): Promise<unknown> {
return await this.request("DELETE", path, { data, query });
}
protected async request(
method: string,
path: string,
params: { data?: RequestData; query?: QueuedRequest["query"] },
): Promise<unknown> {
const routeKey = createRouteKey(method, path);
if (!this.options.queueRequests) {
return await this.executeRequest(method, path, params, routeKey);
}
return await this.scheduler.enqueue({ method, path, ...params });
}
protected async executeRequest(
method: string,
path: string,
params: { data?: RequestData; query?: QueuedRequest["query"] },
routeKey = createRouteKey(method, path),
): Promise<unknown> {
const url = `${this.options.baseUrl}/v${this.options.apiVersion}${appendQuery(path, params.query)}`;
const headers = new Headers({
"User-Agent": this.options.userAgent ?? defaultOptions.userAgent,
});
if (this.token !== "webhook") {
headers.set("Authorization", `${this.options.tokenHeader ?? "Bot"} ${this.token}`);
}
const body = serializeRequestBody(params.data, headers);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeout ?? 15_000);
timeout.unref?.();
this.requestControllers.add(controller);
try {
const response = await (this.customFetch ?? fetch)(url, {
method,
headers,
body,
signal: controller.signal,
});
const text = await response.text();
const parsed = coerceResponseBody(text);
this.scheduler.recordResponse(routeKey, path, response, parsed);
if (response.status === 204) {
return undefined;
}
if (response.status === 429) {
const rateLimitBody = isDiscordRateLimitBody(parsed) ? parsed : undefined;
throw new RateLimitError(response, {
message: readDiscordMessage(rateLimitBody, "Rate limited"),
retry_after: readRetryAfter(rateLimitBody, response),
code: readDiscordCode(rateLimitBody),
global: Boolean(rateLimitBody?.global),
});
}
if (!response.ok) {
throw new DiscordError(response, parsed);
}
return parsed;
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
if (error instanceof Error) {
throw error;
}
throw new Error(`Discord request failed: ${inspect(error)}`, { cause: error });
} finally {
clearTimeout(timeout);
this.requestControllers.delete(controller);
}
}
clearQueue(): void {
this.scheduler.clearQueue();
}
get queueSize(): number {
return this.scheduler.queueSize;
}
getSchedulerMetrics() {
return this.scheduler.getMetrics();
}
abortAllRequests(): void {
this.scheduler.abortPending();
for (const controller of this.requestControllers) {
controller.abort();
}
this.requestControllers.clear();
}
}

View File

@@ -0,0 +1,36 @@
import { Type } from "typebox";
import { Check } from "typebox/value";
const discordInteractionPayloadSchema = Type.Object(
{
id: Type.String({ minLength: 1 }),
token: Type.String({ minLength: 1 }),
type: Type.Number(),
},
{ additionalProperties: true },
);
const discordRateLimitBodySchema = Type.Object(
{
message: Type.Optional(Type.String()),
retry_after: Type.Optional(Type.Union([Type.Number(), Type.String()])),
global: Type.Optional(Type.Boolean()),
code: Type.Optional(Type.Union([Type.Number(), Type.String()])),
},
{ additionalProperties: true },
);
export function assertDiscordInteractionPayload(value: unknown): void {
if (!Check(discordInteractionPayloadSchema, value)) {
throw new Error("Invalid Discord interaction payload");
}
}
export function isDiscordRateLimitBody(value: unknown): value is {
message?: string;
retry_after?: number | string;
global?: boolean;
code?: number | string;
} {
return Check(discordRateLimitBodySchema, value);
}

View File

@@ -0,0 +1,274 @@
import {
type APIChannel,
type APIEmbed,
type APIGuild,
type APIGuildMember,
type APIMessage,
type APIRole,
type APIUser,
type MessageType,
} from "discord-api-types/v10";
import {
createChannelMessage,
createUserDmChannel,
deleteChannelMessage,
editChannelMessage,
getChannelMessage,
pinChannelMessage,
unpinChannelMessage,
} from "./api.js";
import type { Client } from "./client.js";
import { serializePayload, type MessagePayload } from "./payload.js";
type RawOrId<T> = T | string | { id: string; channelId?: string };
export class Base {
constructor(protected client: Client) {}
}
export class User<IsPartial extends boolean = false> extends Base {
protected _rawData: APIUser | null;
readonly id: string;
constructor(client: Client, rawDataOrId: IsPartial extends true ? string : APIUser) {
super(client);
this._rawData = typeof rawDataOrId === "string" ? null : rawDataOrId;
this.id = typeof rawDataOrId === "string" ? rawDataOrId : rawDataOrId.id;
}
get rawData(): Readonly<APIUser> {
if (!this._rawData) {
throw new Error("Partial Discord user has no raw data");
}
return this._rawData;
}
get partial(): IsPartial {
return (this._rawData === null) as IsPartial;
}
get username() {
return this._rawData?.username ?? "";
}
get globalName() {
return this._rawData?.global_name;
}
get discriminator() {
return this._rawData?.discriminator;
}
get bot() {
return this._rawData?.bot;
}
get avatar() {
return this._rawData?.avatar;
}
get avatarUrl() {
return this.avatar ? `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.png` : null;
}
toString(): string {
return `<@${this.id}>`;
}
async fetch(): Promise<User> {
return this.client.fetchUser(this.id);
}
async createDm() {
return await createUserDmChannel(this.client.rest, this.id);
}
async send(data: MessagePayload): Promise<Message> {
const dm = await this.createDm();
const message = await createChannelMessage(this.client.rest, dm.id, {
body: serializePayload(data),
});
return new Message(this.client, message);
}
}
export class Role<IsPartial extends boolean = false> extends Base {
protected _rawData: APIRole | null;
readonly id: string;
constructor(client: Client, rawDataOrId: IsPartial extends true ? string : APIRole) {
super(client);
this._rawData = typeof rawDataOrId === "string" ? null : rawDataOrId;
this.id = typeof rawDataOrId === "string" ? rawDataOrId : rawDataOrId.id;
}
get name() {
return this._rawData?.name ?? "";
}
}
export class Guild<IsPartial extends boolean = false> extends Base {
protected _rawData: APIGuild | null;
readonly id: string;
constructor(client: Client, rawDataOrId: IsPartial extends true ? string : APIGuild) {
super(client);
this._rawData = typeof rawDataOrId === "string" ? null : rawDataOrId;
this.id = typeof rawDataOrId === "string" ? rawDataOrId : rawDataOrId.id;
}
get name() {
return this._rawData?.name ?? "";
}
}
export class GuildMember extends Base {
constructor(
client: Client,
public rawData: APIGuildMember,
) {
super(client);
}
get user() {
return this.rawData.user ? new User(this.client, this.rawData.user) : null;
}
get roles() {
return (this.rawData.roles ?? []) as Array<string | Role>;
}
get nickname() {
return this.rawData.nick ?? undefined;
}
}
export class Message<IsPartial extends boolean = false> extends Base {
protected _rawData: APIMessage | null;
readonly id: string;
readonly channelId: string;
constructor(client: Client, rawDataOrIds: RawOrId<APIMessage>) {
super(client);
this._rawData =
typeof rawDataOrIds === "string" || !("author" in rawDataOrIds) ? null : rawDataOrIds;
this.id = typeof rawDataOrIds === "string" ? rawDataOrIds : rawDataOrIds.id;
this.channelId =
typeof rawDataOrIds === "string"
? ""
: "channel_id" in rawDataOrIds
? rawDataOrIds.channel_id
: (rawDataOrIds.channelId ?? "");
}
get rawData(): Readonly<APIMessage> {
if (!this._rawData) {
throw new Error("Partial Discord message has no raw data");
}
return this._rawData;
}
get partial(): IsPartial {
return (this._rawData === null) as IsPartial;
}
get message(): Message<IsPartial> {
return this;
}
get channel_id() {
return this.channelId;
}
get guild_id() {
return (this._rawData as { guild_id?: string } | null)?.guild_id;
}
get guild() {
return this.guild_id ? new Guild<true>(this.client, this.guild_id) : null;
}
get webhookId() {
return this.webhook_id;
}
get webhook_id() {
return (this._rawData as { webhook_id?: string | null } | null)?.webhook_id ?? null;
}
get member() {
const member = (this._rawData as { member?: APIGuildMember } | null)?.member;
return member ? new GuildMember(this.client, member) : null;
}
get rawMember() {
return (this._rawData as { member?: APIGuildMember } | null)?.member;
}
get content() {
return this._rawData?.content ?? "";
}
get author() {
return this._rawData?.author ? new User(this.client, this._rawData.author) : null;
}
get embeds(): APIEmbed[] {
return this._rawData?.embeds ?? [];
}
get attachments() {
return this._rawData?.attachments ?? [];
}
get stickers() {
return this._rawData?.sticker_items ?? [];
}
get mentionedUsers() {
return (this._rawData?.mentions ?? []).map((user) => new User(this.client, user));
}
get mentionedRoles() {
return this._rawData?.mention_roles ?? [];
}
get mentionedEveryone() {
return this._rawData?.mention_everyone ?? false;
}
get timestamp() {
return this._rawData?.timestamp;
}
get type(): MessageType | undefined {
return this._rawData?.type;
}
get messageReference() {
return this._rawData?.message_reference;
}
get referencedMessage() {
return this._rawData?.referenced_message
? new Message(this.client, this._rawData.referenced_message)
: null;
}
get thread() {
return this._rawData?.thread ? channelFactory(this.client, this._rawData.thread) : null;
}
async fetch(): Promise<Message> {
const raw = await getChannelMessage(this.client.rest, this.channelId, this.id);
return new Message(this.client, raw);
}
async delete(): Promise<void> {
await deleteChannelMessage(this.client.rest, this.channelId, this.id);
}
async edit(data: MessagePayload): Promise<Message> {
const raw = await editChannelMessage(this.client.rest, this.channelId, this.id, {
body: serializePayload(data),
});
return new Message(this.client, raw);
}
async reply(data: MessagePayload): Promise<Message> {
const raw = await createChannelMessage(this.client.rest, this.channelId, {
body: {
...serializePayload(data),
message_reference: { message_id: this.id, fail_if_not_exists: false },
},
});
return new Message(this.client, raw);
}
async pin(): Promise<void> {
await pinChannelMessage(this.client.rest, this.channelId, this.id);
}
async unpin(): Promise<void> {
await unpinChannelMessage(this.client.rest, this.channelId, this.id);
}
}
export type DiscordChannel = APIChannel & {
rawData?: APIChannel;
guildId?: string;
guild?: Guild;
name?: string;
parentId?: string | null;
};
export function channelFactory(
_client: Client,
channelData: APIChannel,
_partial?: boolean,
): DiscordChannel {
return {
...channelData,
rawData: channelData,
guildId: "guild_id" in channelData ? channelData.guild_id : undefined,
guild:
"guild_id" in channelData && typeof channelData.guild_id === "string"
? new Guild<true>(_client, channelData.guild_id)
: undefined,
parentId: "parent_id" in channelData ? channelData.parent_id : undefined,
} as DiscordChannel;
}

View File

@@ -0,0 +1,129 @@
import { ComponentType, InteractionType } from "discord-api-types/v10";
import { vi, type Mock } from "vitest";
import { Client } from "./client.js";
import type { BaseCommand } from "./commands.js";
import type { RawInteraction } from "./interactions.js";
import type { QueuedRequest, RequestClient, RequestData } from "./rest.js";
type RestMock = Partial<Record<"get" | "post" | "patch" | "put" | "delete", Mock>>;
type RestMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
type RawInteractionOverrides = Omit<Partial<RawInteraction>, "data" | "type"> &
Pick<RawInteraction, "id" | "token"> & {
data?: Record<string, unknown>;
};
export type FakeRestCall = {
method: RestMethod;
path: string;
data?: RequestData;
query?: QueuedRequest["query"];
};
export type FakeRestClient = RequestClient & {
calls: FakeRestCall[];
enqueueResponse: (value: unknown) => void;
};
export function createInternalTestClient(commands: BaseCommand[] = []): Client {
return new Client(
{
baseUrl: "http://localhost",
clientId: "app1",
publicKey: "public",
token: "token",
},
{ commands },
);
}
export function createRestMock(overrides: RestMock = {}): RestMock & RequestClient {
return {
get: vi.fn(async () => undefined),
post: vi.fn(async () => undefined),
patch: vi.fn(async () => undefined),
put: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
...overrides,
} as RestMock & RequestClient;
}
export function attachRestMock(client: Client, rest: RestMock): RestMock & RequestClient {
const mock = createRestMock(rest);
client.rest = mock;
return mock;
}
export function createFakeRestClient(responses: unknown[] = []): FakeRestClient {
const calls: FakeRestCall[] = [];
const queued = [...responses];
const request = async (
method: RestMethod,
path: string,
data?: RequestData,
query?: QueuedRequest["query"],
) => {
calls.push({ method, path, data, query });
return queued.shift();
};
return {
calls,
enqueueResponse: (value: unknown) => {
queued.push(value);
},
get: async (path, query) => await request("GET", path, undefined, query),
post: async (path, data, query) => await request("POST", path, data, query),
patch: async (path, data, query) => await request("PATCH", path, data, query),
put: async (path, data, query) => await request("PUT", path, data, query),
delete: async (path, data, query) => await request("DELETE", path, data, query),
} as FakeRestClient;
}
export function createInternalInteractionPayload(
overrides: Partial<RawInteraction> & Pick<RawInteraction, "id" | "token">,
): RawInteraction {
return {
application_id: "app1",
type: InteractionType.ApplicationCommand,
version: 1,
data: {
id: "command1",
name: "test",
type: 1,
},
...overrides,
} as unknown as RawInteraction;
}
export function createInternalComponentInteractionPayload(
overrides: RawInteractionOverrides,
): RawInteraction {
const { data, ...rest } = overrides;
return {
application_id: "app1",
version: 1,
data: {
component_type: ComponentType.Button,
custom_id: "component1",
...data,
},
...rest,
type: InteractionType.MessageComponent,
} as unknown as RawInteraction;
}
export function createInternalModalInteractionPayload(
overrides: RawInteractionOverrides,
): RawInteraction {
const { data, ...rest } = overrides;
return {
application_id: "app1",
version: 1,
data: {
custom_id: "modal1",
components: [],
...data,
},
...rest,
type: InteractionType.ModalSubmit,
} as unknown as RawInteraction;
}

View File

@@ -0,0 +1,49 @@
import type {
DiscordGatewayAdapterCreator,
DiscordGatewayAdapterLibraryMethods,
} from "@discordjs/voice";
import type { GatewaySendPayload } from "discord-api-types/v10";
import { Plugin, type Client } from "./client.js";
import type { GatewayPlugin } from "./gateway.js";
export class VoicePlugin extends Plugin {
readonly id = "voice";
protected client?: Client;
readonly adapters = new Map<string, DiscordGatewayAdapterLibraryMethods>();
private gatewayPlugin?: GatewayPlugin;
registerClient(client: Client): void {
this.client = client;
this.gatewayPlugin = client.getPlugin<GatewayPlugin>("gateway");
if (!this.gatewayPlugin) {
throw new Error("Discord voice cannot be used without a gateway connection.");
}
}
getGateway(_guildId: string): GatewayPlugin | undefined {
return this.gatewayPlugin;
}
getGatewayAdapterCreator(guildId: string): DiscordGatewayAdapterCreator {
const gateway = this.getGateway(guildId);
if (!gateway) {
throw new Error("Discord voice cannot be used without a gateway connection.");
}
return (methods) => {
this.adapters.set(guildId, methods);
return {
sendPayload(payload) {
try {
gateway.send(payload as GatewaySendPayload, true);
return true;
} catch {
return false;
}
},
destroy: () => {
this.adapters.delete(guildId);
},
};
};
}
}

View File

@@ -1,6 +1,6 @@
import { ChannelType, type Guild } from "@buape/carbon";
import { typedCases } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType, type Guild } from "./internal/discord.js";
import {
allowListMatches,
type DiscordGuildEntryResolved,
@@ -121,7 +121,7 @@ describe("DiscordMessageListener", () => {
const handlePromise = listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
{} as unknown as import("./internal/discord.js").Client,
);
// handle() returns immediately while the background queue starts on the next tick.
@@ -153,13 +153,13 @@ describe("DiscordMessageListener", () => {
await expect(
listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
{} as unknown as import("./internal/discord.js").Client,
),
).resolves.toBeUndefined();
await expect(
listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
{} as unknown as import("./internal/discord.js").Client,
),
).resolves.toBeUndefined();
@@ -186,7 +186,7 @@ describe("DiscordMessageListener", () => {
await listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
{} as unknown as import("./internal/discord.js").Client,
);
await flushAsyncWork();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("discord handler failed"));
@@ -205,7 +205,7 @@ describe("DiscordMessageListener", () => {
const handlePromise = listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
{} as unknown as import("./internal/discord.js").Client,
);
await expect(handlePromise).resolves.toBeUndefined();
@@ -959,7 +959,7 @@ function makeReactionEvent(overrides?: {
message: {
fetch: messageFetch,
},
} as DiscordReactionEvent;
} as unknown as DiscordReactionEvent;
}
function makeReactionClient(options?: {

View File

@@ -1,4 +1,3 @@
import type { RequestClient } from "@buape/carbon";
import {
createStatusReactionController,
logAckFailure,
@@ -7,6 +6,7 @@ import {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createDiscordRuntimeAccountContext } from "../client.js";
import type { RequestClient } from "../internal/discord.js";
import { reactMessageDiscord, removeReactionDiscord } from "../send.js";
import type { DiscordReactionRuntimeContext } from "../send.types.js";

View File

@@ -1,5 +1,5 @@
import { ChannelType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType } from "../internal/discord.js";
const loadConfigMock = vi.hoisted(() => vi.fn());

View File

@@ -0,0 +1,435 @@
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import type { DiscordComponentEntry } from "../components.js";
import {
resolveComponentInteractionContext,
resolveDiscordChannelContext,
} from "./agent-components-context.js";
import {
readStoreAllowFromForDmPolicy,
upsertChannelPairingRequest,
} from "./agent-components-helpers.runtime.js";
import {
type AgentComponentContext,
type AgentComponentInteraction,
type ComponentInteractionContext,
type DiscordChannelContext,
type DiscordUser,
} from "./agent-components.types.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
resolveGroupDmAllow,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
async function replySilently(
interaction: AgentComponentInteraction,
params: { content: string; ephemeral?: boolean },
) {
try {
await interaction.reply(params);
} catch {}
}
export async function ensureGuildComponentMemberAllowed(params: {
interaction: AgentComponentInteraction;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
channelId: string;
rawGuildId: string | undefined;
channelCtx: DiscordChannelContext;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
groupPolicy: "open" | "disabled" | "allowlist";
}) {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
const replyUnauthorized = async () => {
await replySilently(interaction, { content: unauthorizedReply, ...replyOpts });
};
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
parentId: channelCtx.parentId,
parentName: channelCtx.parentName,
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
if (channelConfig?.enabled === false) {
await replyUnauthorized();
return false;
}
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
!isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
})
) {
await replyUnauthorized();
return false;
}
if (channelConfig?.allowed === false) {
await replyUnauthorized();
return false;
}
const { memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender: {
id: user.id,
name: user.username,
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
},
allowNameMatching: params.allowNameMatching,
});
if (memberAllowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
await replyUnauthorized();
return false;
}
export async function ensureComponentUserAllowed(params: {
entry: DiscordComponentEntry;
interaction: AgentComponentInteraction;
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}) {
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return true;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
});
if (match.allowed) {
return true;
}
logVerbose(
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
);
await replySilently(params.interaction, {
content: params.unauthorizedReply,
...params.replyOpts,
});
return false;
}
export async function ensureAgentComponentInteractionAllowed(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
rawGuildId: string | undefined;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
}) {
const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined,
guildId: params.rawGuildId,
guildEntries: params.ctx.guildEntries,
});
const channelCtx = resolveDiscordChannelContext(params.interaction);
const memberAllowed = await ensureGuildComponentMemberAllowed({
interaction: params.interaction,
guildInfo,
channelId: params.channelId,
rawGuildId: params.rawGuildId,
channelCtx,
memberRoleIds: params.memberRoleIds,
user: params.user,
replyOpts: params.replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
groupPolicy: resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.ctx.cfg.channels?.discord !== undefined,
groupPolicy: params.ctx.discordConfig?.groupPolicy,
defaultGroupPolicy: params.ctx.cfg.channels?.defaults?.groupPolicy,
}).groupPolicy,
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
async function ensureDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
user: DiscordUser;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const allowFromPrefixes = ["discord:", "user:", "pk:"];
const resolveAllowMatch = (entries: string[]) => {
const allowList = normalizeDiscordAllowList(entries, allowFromPrefixes);
return allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
};
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
await replySilently(interaction, { content: "DM interactions are disabled.", ...replyOpts });
return false;
}
if (dmPolicy === "allowlist") {
const allowMatch = resolveAllowMatch(ctx.allowFrom ?? []);
if (allowMatch.allowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
await replySilently(interaction, {
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
return false;
}
const storeAllowFrom =
dmPolicy === "open"
? []
: await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]);
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await createChannelPairingChallengeIssuer({
channel: "discord",
upsertPairingRequest: async ({ id, meta }) => {
return await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
});
},
})({
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
await replySilently(interaction, {
content: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts,
});
}
return false;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
await replySilently(interaction, {
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
return false;
}
async function ensureGroupDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, channelId, componentLabel, replyOpts } = params;
const groupDmEnabled = ctx.discordConfig?.dm?.groupEnabled ?? false;
if (!groupDmEnabled) {
logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (group DMs disabled)`);
await replySilently(interaction, {
content: "Group DM interactions are disabled.",
...replyOpts,
});
return false;
}
const channelCtx = resolveDiscordChannelContext(interaction);
const allowed = resolveGroupDmAllow({
channels: ctx.discordConfig?.dm?.groupChannels,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
});
if (allowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (not allowlisted)`);
await replySilently(interaction, {
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
return false;
}
export async function resolveInteractionContextWithDmAuth(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
label: string;
componentLabel: string;
defer?: boolean;
}) {
const interactionCtx = await resolveComponentInteractionContext({
interaction: params.interaction,
label: params.label,
defer: params.defer,
});
if (!interactionCtx) {
return null;
}
if (interactionCtx.isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
user: interactionCtx.user,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
if (interactionCtx.isGroupDm) {
const authorized = await ensureGroupDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
channelId: interactionCtx.channelId,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
return interactionCtx;
}
export function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
allowNameMatching: boolean;
}) {
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
if (interactionCtx.isDirectMessage) {
return true;
}
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
allowFrom: ctx.allowFrom,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds: interactionCtx.memberRoleIds,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
}

View File

@@ -0,0 +1,144 @@
import { ChannelType } from "discord-api-types/v10";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import {
type AgentComponentContext,
type AgentComponentInteraction,
type AgentComponentMessageInteraction,
type ComponentInteractionContext,
type DiscordChannelContext,
} from "./agent-components.types.js";
import { normalizeDiscordSlug } from "./allow-list.js";
import { resolveDiscordChannelInfoSafe } from "./channel-access.js";
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
);
}
export function resolveAgentComponentRoute(params: {
ctx: AgentComponentContext;
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
isGroupDm: 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" : params.isGroupDm ? "group" : "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 channelInfo = resolveDiscordChannelInfoSafe(channel);
const channelName = channelInfo.name;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channelInfo.type;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread) {
parentId = channelInfo.parentId;
parentName = channelInfo.parentName;
if (parentName) {
parentSlug = normalizeDiscordSlug(parentName);
}
}
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
}
export async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
defer?: boolean;
}): Promise<ComponentInteractionContext | null> {
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 channelType = resolveDiscordChannelContext(interaction).channelType;
const isGroupDm = channelType === ChannelType.GroupDM;
const isDirectMessage =
channelType === ChannelType.DM || (!rawGuildId && !isGroupDm && channelType == null);
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => roleId)
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
isGroupDm,
memberRoleIds,
};
}

View File

@@ -0,0 +1,224 @@
import { logError } from "openclaw/plugin-sdk/text-runtime";
import {
parseDiscordComponentCustomId,
parseDiscordModalCustomId,
} from "../component-custom-id.js";
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
import type { ComponentData, ModalInteraction } from "../internal/discord.js";
import type { AgentComponentInteraction } from "./agent-components.types.js";
import { formatDiscordUserTag } from "./format.js";
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);
}
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 };
}
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()}`;
}

View File

@@ -1,133 +1,6 @@
import {
type ButtonInteraction,
type ChannelSelectMenuInteraction,
type ComponentData,
type MentionableSelectMenuInteraction,
type ModalInteraction,
type RoleSelectMenuInteraction,
type StringSelectMenuInteraction,
type UserSelectMenuInteraction,
} from "@buape/carbon";
import { ChannelType } from "discord-api-types/v10";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import {
parseDiscordComponentCustomId,
parseDiscordModalCustomId,
} from "../component-custom-id.js";
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
upsertChannelPairingRequest,
} from "./agent-components-helpers.runtime.js";
import {
type DiscordGuildEntryResolved,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordChannelInfoSafe } from "./channel-access.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.
@@ -140,721 +13,36 @@ 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;
isGroupDm: 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" : params.isGroupDm ? "group" : "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 channelInfo = resolveDiscordChannelInfoSafe(channel);
const channelName = channelInfo.name;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channelInfo.type;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread) {
parentId = channelInfo.parentId;
parentName = channelInfo.parentName;
if (parentName) {
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 channelType = resolveDiscordChannelContext(interaction).channelType;
const isGroupDm = channelType === ChannelType.GroupDM;
const isDirectMessage =
channelType === ChannelType.DM || (!rawGuildId && !isGroupDm && channelType == null);
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => roleId)
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
isGroupDm,
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;
groupPolicy: "open" | "disabled" | "allowlist";
}) {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
async function replyUnauthorized() {
try {
await interaction.reply({
content: unauthorizedReply,
...replyOpts,
});
} catch {}
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
parentId: channelCtx.parentId,
parentName: channelCtx.parentName,
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
if (channelConfig?.enabled === false) {
await replyUnauthorized();
return false;
}
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
!isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
})
) {
await replyUnauthorized();
return false;
}
if (channelConfig?.allowed === false) {
await replyUnauthorized();
return false;
}
const { memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender: {
id: user.id,
name: user.username,
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
},
allowNameMatching: params.allowNameMatching,
});
if (memberAllowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
await replyUnauthorized();
return false;
}
export async function ensureComponentUserAllowed(params: {
entry: DiscordComponentEntry;
interaction: AgentComponentInteraction;
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}) {
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return true;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
});
if (match.allowed) {
return true;
}
logVerbose(
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
);
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),
groupPolicy: resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.ctx.cfg.channels?.discord !== undefined,
groupPolicy: params.ctx.discordConfig?.groupPolicy,
defaultGroupPolicy: params.ctx.cfg.channels?.defaults?.groupPolicy,
}).groupPolicy,
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
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 allowFromPrefixes = ["discord:", "user:", "pk:"];
const resolveAllowMatch = (entries: string[]) => {
const allowList = normalizeDiscordAllowList(entries, allowFromPrefixes);
return allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
};
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
try {
await interaction.reply({
content: "DM interactions are disabled.",
...replyOpts,
});
} catch {}
return false;
}
if (dmPolicy === "allowlist") {
const allowMatch = resolveAllowMatch(ctx.allowFrom ?? []);
if (allowMatch.allowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {}
return false;
}
const storeAllowFrom =
dmPolicy === "open"
? []
: await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]);
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await createChannelPairingChallengeIssuer({
channel: "discord",
upsertPairingRequest: async ({ id, meta }) => {
return await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
});
},
})({
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
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;
}
async function ensureGroupDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, channelId, componentLabel, replyOpts } = params;
const groupDmEnabled = ctx.discordConfig?.dm?.groupEnabled ?? false;
if (!groupDmEnabled) {
logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (group DMs disabled)`);
try {
await interaction.reply({
content: "Group DM interactions are disabled.",
...replyOpts,
});
} catch {}
return false;
}
const channelCtx = resolveDiscordChannelContext(interaction);
const allowed = resolveGroupDmAllow({
channels: ctx.discordConfig?.dm?.groupChannels,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
});
if (allowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked group dm ${channelId} (not allowlisted)`);
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;
}
}
if (interactionCtx.isGroupDm) {
const authorized = await ensureGroupDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
channelId: interactionCtx.channelId,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
return interactionCtx;
}
export 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 };
export {
ackComponentInteraction,
resolveAgentComponentRoute,
resolveComponentInteractionContext,
resolveDiscordChannelContext,
} from "./agent-components-context.js";
export {
ensureAgentComponentInteractionAllowed,
ensureComponentUserAllowed,
ensureGuildComponentMemberAllowed,
resolveComponentCommandAuthorized,
resolveInteractionContextWithDmAuth,
} from "./agent-components-auth.js";
export {
formatModalSubmissionText,
mapSelectValues,
parseAgentComponentData,
parseDiscordComponentData,
parseDiscordModalId,
resolveDiscordInteractionId,
resolveInteractionCustomId,
resolveModalFieldValues,
} from "./agent-components-data.js";
export type {
AgentComponentContext,
AgentComponentInteraction,
AgentComponentMessageInteraction,
ComponentInteractionContext,
DiscordChannelContext,
DiscordUser,
} from "./agent-components.types.js";
export { resolveDiscordGuildEntry } from "./allow-list.js";
export { resolvePinnedMainDmOwnerFromAllowlist } from "./agent-components-helpers.runtime.js";

View File

@@ -0,0 +1,345 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { createDiscordRestClient } from "../client.js";
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
import {
resolveAgentComponentRoute,
resolveComponentCommandAuthorized,
resolvePinnedMainDmOwnerFromAllowlist,
type AgentComponentContext,
type AgentComponentInteraction,
type ComponentInteractionContext,
type DiscordChannelContext,
} from "./agent-components-helpers.js";
import { readSessionUpdatedAt, resolveStorePath } from "./agent-components.deps.runtime.js";
import {
normalizeDiscordAllowList,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
import {
buildDiscordGroupSystemPrompt,
buildDiscordInboundAccessContext,
} from "./inbound-context.js";
import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
import { deliverDiscordReply } from "./reply-delivery.js";
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
let replyPipelineRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>
| undefined;
let typingRuntimePromise: Promise<typeof import("./typing.js")> | undefined;
async function loadConversationRuntime() {
conversationRuntimePromise ??= import("./agent-components.runtime.js");
return await conversationRuntimePromise;
}
async function loadReplyPipelineRuntime() {
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-reply-pipeline");
return await replyPipelineRuntimePromise;
}
async function loadTypingRuntime() {
typingRuntimePromise ??= import("./typing.js");
return await typingRuntimePromise;
}
function buildDiscordComponentConversationLabel(params: {
interactionCtx: ComponentInteractionContext;
interaction: AgentComponentInteraction;
channelCtx: DiscordChannelContext;
}) {
if (params.interactionCtx.isDirectMessage) {
return buildDirectLabel(params.interactionCtx.user);
}
if (params.interactionCtx.isGroupDm) {
return `Group DM #${params.channelCtx.channelName ?? params.interactionCtx.channelId} channel id:${params.interactionCtx.channelId}`;
}
return buildGuildLabel({
guild: params.interaction.guild ?? undefined,
channelName: params.channelCtx.channelName ?? params.interactionCtx.channelId,
channelId: params.interactionCtx.channelId,
});
}
function resolveDiscordComponentChatType(interactionCtx: ComponentInteractionContext) {
if (interactionCtx.isDirectMessage) {
return "direct";
}
if (interactionCtx.isGroupDm) {
return "group";
}
return "channel";
}
export function resolveDiscordComponentOriginatingTo(
interactionCtx: Pick<ComponentInteractionContext, "isDirectMessage" | "userId" | "channelId">,
) {
return resolveDiscordConversationIdentity({
isDirectMessage: interactionCtx.isDirectMessage,
userId: interactionCtx.userId,
channelId: interactionCtx.channelId,
});
}
export async function dispatchDiscordComponentEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
interactionCtx: ComponentInteractionContext;
channelCtx: DiscordChannelContext;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
eventText: string;
replyToId?: string;
routeOverrides?: { sessionKey?: string; agentId?: string; accountId?: string };
}): Promise<void> {
const { ctx, interaction, interactionCtx, channelCtx, guildInfo, eventText } = params;
const runtime = ctx.runtime ?? createNonExitingRuntime();
const route = resolveAgentComponentRoute({
ctx,
rawGuildId: interactionCtx.rawGuildId,
memberRoleIds: interactionCtx.memberRoleIds,
isDirectMessage: interactionCtx.isDirectMessage,
isGroupDm: interactionCtx.isGroupDm,
userId: interactionCtx.userId,
channelId: interactionCtx.channelId,
parentId: channelCtx.parentId,
});
const sessionKey = params.routeOverrides?.sessionKey ?? route.sessionKey;
const agentId = params.routeOverrides?.agentId ?? route.agentId;
const accountId = params.routeOverrides?.accountId ?? route.accountId;
const fromLabel = buildDiscordComponentConversationLabel({
interactionCtx,
interaction,
channelCtx,
});
const chatType = resolveDiscordComponentChatType(interactionCtx);
const senderName = interactionCtx.user.globalName ?? interactionCtx.user.username;
const senderUsername = interactionCtx.user.username;
const senderTag = formatDiscordUserTag(interactionCtx.user);
const groupChannel =
!interactionCtx.isDirectMessage && channelCtx.channelSlug
? `#${channelCtx.channelSlug}`
: undefined;
const groupSubject = interactionCtx.isDirectMessage ? undefined : groupChannel;
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: interactionCtx.channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
parentId: channelCtx.parentId,
parentName: channelCtx.parentName,
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig);
const { ownerAllowFrom } = buildDiscordInboundAccessContext({
channelConfig,
guildInfo,
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
allowNameMatching,
isGuild: !interactionCtx.isDirectMessage,
});
const groupSystemPrompt = buildDiscordGroupSystemPrompt(channelConfig);
const pinnedMainDmOwner = interactionCtx.isDirectMessage
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: ctx.cfg.session?.dmScope,
allowFrom: channelConfig?.users ?? guildInfo?.users,
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;
},
})
: null;
const commandAuthorized = resolveComponentCommandAuthorized({
ctx,
interactionCtx,
channelConfig,
guildInfo,
allowNameMatching,
});
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey,
});
const timestamp = Date.now();
const combinedBody = formatInboundEnvelope({
channel: "Discord",
from: fromLabel,
timestamp,
body: eventText,
chatType,
senderLabel: senderName,
previousTimestamp,
envelope: envelopeOptions,
});
const {
createReplyReferencePlanner,
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext,
resolveChunkMode,
resolveTextChunkLimit,
recordInboundSession,
} = await (async () => {
const conversationRuntime = await loadConversationRuntime();
return {
...conversationRuntime,
};
})();
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
BodyForAgent: eventText,
RawBody: eventText,
CommandBody: eventText,
From: interactionCtx.isDirectMessage
? `discord:${interactionCtx.userId}`
: interactionCtx.isGroupDm
? `discord:group:${interactionCtx.channelId}`
: `discord:channel:${interactionCtx.channelId}`,
To: `channel:${interactionCtx.channelId}`,
SessionKey: sessionKey,
AccountId: accountId,
ChatType: chatType,
ConversationLabel: fromLabel,
SenderName: senderName,
SenderId: interactionCtx.userId,
SenderUsername: senderUsername,
SenderTag: senderTag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
MemberRoleIds: interactionCtx.memberRoleIds,
GroupSystemPrompt: interactionCtx.isDirectMessage ? undefined : groupSystemPrompt,
GroupSpace: guildInfo?.id ?? guildInfo?.slug ?? interactionCtx.rawGuildId ?? undefined,
OwnerAllowFrom: ownerAllowFrom,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: true,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
MessageSid: interaction.rawData.id,
Timestamp: timestamp,
OriginatingChannel: "discord" as const,
OriginatingTo:
resolveDiscordComponentOriginatingTo(interactionCtx) ?? `channel:${interactionCtx.channelId}`,
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? sessionKey,
ctx: ctxPayload,
updateLastRoute: interactionCtx.isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "discord",
to:
resolveDiscordComponentOriginatingTo(interactionCtx) ?? `user:${interactionCtx.userId}`,
accountId,
mainDmOwnerPin: pinnedMainDmOwner
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: interactionCtx.userId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
},
});
const deliverTarget = `channel:${interactionCtx.channelId}`;
const typingChannelId = interactionCtx.channelId;
const { createChannelReplyPipeline } = await loadReplyPipelineRuntime();
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: ctx.cfg,
agentId,
channel: "discord",
accountId,
});
const tableMode = resolveMarkdownTableMode({
cfg: ctx.cfg,
channel: "discord",
accountId,
});
const textLimit = resolveTextChunkLimit(ctx.cfg, "discord", accountId, {
fallbackLimit: 2000,
});
const token = ctx.token ?? "";
const feedbackRest = createDiscordRestClient({
cfg: ctx.cfg,
token,
accountId,
}).rest;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(ctx.cfg, agentId);
const replyToMode =
ctx.discordConfig?.replyToMode ?? ctx.cfg.channels?.discord?.replyToMode ?? "off";
const replyReference = createReplyReferencePlanner({
replyToMode,
startId: params.replyToId,
});
await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
replyOptions: { onModelSelected },
dispatcherOptions: {
...replyPipeline,
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
deliver: async (payload) => {
const replyToId = replyReference.use();
await deliverDiscordReply({
cfg: ctx.cfg,
replies: [payload],
target: deliverTarget,
token,
accountId,
rest: interaction.client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId,
}),
tableMode,
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
mediaLocalRoots,
});
replyReference.markSent();
},
onReplyStart: async () => {
try {
const { sendTyping } = await loadTypingRuntime();
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
} catch (err) {
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
}
},
onError: (err) => {
logError(`discord component dispatch failed: ${String(err)}`);
},
},
});
}

View File

@@ -0,0 +1,187 @@
import { ChannelType } from "discord-api-types/v10";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import {
dispatchDiscordPluginInteractiveHandler,
type DiscordInteractiveHandlerContext,
} from "../interactive-dispatch.js";
import type { TopLevelComponents } from "../internal/discord.js";
import { editDiscordComponentMessage } from "../send.components.js";
import {
resolveDiscordInteractionId,
type AgentComponentContext,
type AgentComponentInteraction,
type ComponentInteractionContext,
type DiscordChannelContext,
} from "./agent-components-helpers.js";
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
async function loadConversationRuntime() {
conversationRuntimePromise ??= import("./agent-components.runtime.js");
return await conversationRuntimePromise;
}
export async function dispatchPluginDiscordInteractiveEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
interactionCtx: ComponentInteractionContext;
channelCtx: DiscordChannelContext;
isAuthorizedSender: boolean;
data: string;
kind: "button" | "select" | "modal";
values?: string[];
fields?: Array<{ id: string; name: string; values: string[] }>;
messageId?: string;
}): Promise<"handled" | "unmatched"> {
const normalizedConversationId =
params.interactionCtx.rawGuildId || params.channelCtx.channelType === ChannelType.GroupDM
? `channel:${params.interactionCtx.channelId}`
: `user:${params.interactionCtx.userId}`;
let responded = false;
let acknowledged = false;
const updateOriginalMessage = async (input: {
text?: string;
components?: TopLevelComponents[];
}) => {
const payload = {
...(input.text !== undefined ? { content: input.text } : {}),
...(input.components !== undefined ? { components: input.components } : {}),
};
if (acknowledged) {
await params.interaction.reply(payload);
return;
}
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot update the source message");
}
await params.interaction.update(payload);
};
const respond: DiscordInteractiveHandlerContext["respond"] = {
acknowledge: async () => {
if (responded) {
return;
}
await params.interaction.acknowledge();
acknowledged = true;
responded = true;
},
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
responded = true;
await params.interaction.reply({
content: text,
ephemeral,
});
},
followUp: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
responded = true;
await params.interaction.followUp({
content: text,
ephemeral,
});
},
editMessage: async (
input: Parameters<DiscordInteractiveHandlerContext["respond"]["editMessage"]>[0],
) => {
const { text, components } = input;
responded = true;
await updateOriginalMessage({
text,
components: components as TopLevelComponents[] | undefined,
});
},
clearComponents: async (input?: { text?: string }) => {
responded = true;
await updateOriginalMessage({
text: input?.text,
components: [],
});
},
};
const conversationRuntime = await loadConversationRuntime();
const pluginBindingApproval = conversationRuntime.parsePluginBindingApprovalCustomId(params.data);
if (pluginBindingApproval) {
const { buildPluginBindingResolvedText, resolvePluginConversationBindingApproval } =
conversationRuntime;
try {
await respond.acknowledge();
} catch {
// Interaction may have expired; try to continue anyway.
}
const resolved = await resolvePluginConversationBindingApproval({
approvalId: pluginBindingApproval.approvalId,
decision: pluginBindingApproval.decision,
senderId: params.interactionCtx.userId,
});
const approvalMessageId = params.messageId?.trim() || params.interaction.message?.id?.trim();
if (approvalMessageId) {
try {
await editDiscordComponentMessage(
normalizedConversationId,
approvalMessageId,
{
text: buildPluginBindingResolvedText(resolved),
},
{
cfg: params.ctx.cfg,
accountId: params.ctx.accountId,
},
);
} catch (err) {
logError(`discord plugin binding approval: failed to clear prompt: ${String(err)}`);
}
}
if (resolved.status !== "approved") {
try {
await respond.followUp({
text: buildPluginBindingResolvedText(resolved),
ephemeral: true,
});
} catch (err) {
logError(`discord plugin binding approval: failed to follow up: ${String(err)}`);
}
}
return "handled";
}
const dispatched = await dispatchDiscordPluginInteractiveHandler({
data: params.data,
interactionId: resolveDiscordInteractionId(params.interaction),
ctx: {
accountId: params.ctx.accountId,
interactionId: resolveDiscordInteractionId(params.interaction),
conversationId: normalizedConversationId,
parentConversationId: params.channelCtx.parentId,
guildId: params.interactionCtx.rawGuildId,
senderId: params.interactionCtx.userId,
senderUsername: params.interactionCtx.username,
auth: { isAuthorizedSender: params.isAuthorizedSender },
interaction: {
kind: params.kind,
messageId: params.messageId,
values: params.values,
fields: params.fields,
},
},
respond,
onMatched: async () => {
try {
await respond.acknowledge();
} catch {
// Interaction may have expired before the plugin handler ran.
}
},
});
if (!dispatched.matched) {
return "unmatched";
}
if (dispatched.handled) {
if (!responded) {
try {
await respond.acknowledge();
} catch {
// Interaction may have expired after the handler finished.
}
}
return "handled";
}
return "unmatched";
}

View File

@@ -0,0 +1,211 @@
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import {
Button,
StringSelectMenu,
type ButtonInteraction,
type ComponentData,
type StringSelectMenuInteraction,
} from "../internal/discord.js";
import {
AGENT_BUTTON_KEY,
AGENT_SELECT_KEY,
ackComponentInteraction,
ensureAgentComponentInteractionAllowed,
parseAgentComponentData,
resolveAgentComponentRoute,
resolveInteractionContextWithDmAuth,
type AgentComponentContext,
} from "./agent-components-helpers.js";
import { enqueueSystemEvent } from "./agent-components.deps.runtime.js";
export class AgentComponentButton extends Button {
label = AGENT_BUTTON_KEY;
customId = `${AGENT_BUTTON_KEY}:seed=1`;
style = ButtonStyle.Primary;
private ctx: AgentComponentContext;
constructor(ctx: AgentComponentContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
const parsed = parseAgentComponentData(data);
if (!parsed) {
logError("agent button: failed to parse component data");
try {
await interaction.reply({
content: "This button is no longer valid.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
}
const { componentId } = parsed;
const interactionCtx = await resolveInteractionContextWithDmAuth({
ctx: this.ctx,
interaction,
label: "agent button",
componentLabel: "button",
defer: false,
});
if (!interactionCtx) {
return;
}
const {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
isGroupDm,
memberRoleIds,
} = interactionCtx;
const allowed = await ensureAgentComponentInteractionAllowed({
ctx: this.ctx,
interaction,
channelId,
rawGuildId,
memberRoleIds,
user,
replyOpts,
componentLabel: "button",
unauthorizedReply: "You are not authorized to use this button.",
});
if (!allowed) {
return;
}
const { parentId } = allowed;
const route = resolveAgentComponentRoute({
ctx: this.ctx,
rawGuildId,
memberRoleIds,
isDirectMessage,
isGroupDm,
userId,
channelId,
parentId,
});
const eventText = `[Discord component: ${componentId} clicked by ${username} (${userId})]`;
logDebug(`agent button: enqueuing event for channel ${channelId}: ${eventText}`);
enqueueSystemEvent(eventText, {
sessionKey: route.sessionKey,
contextKey: `discord:agent-button:${channelId}:${componentId}:${userId}`,
});
await ackComponentInteraction({ interaction, replyOpts, label: "agent button" });
}
}
export class AgentSelectMenu extends StringSelectMenu {
customId = `${AGENT_SELECT_KEY}:seed=1`;
options: APIStringSelectComponent["options"] = [];
private ctx: AgentComponentContext;
constructor(ctx: AgentComponentContext) {
super();
this.ctx = ctx;
}
async run(interaction: StringSelectMenuInteraction, data: ComponentData): Promise<void> {
const parsed = parseAgentComponentData(data);
if (!parsed) {
logError("agent select: failed to parse component data");
try {
await interaction.reply({
content: "This select menu is no longer valid.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
}
const { componentId } = parsed;
const interactionCtx = await resolveInteractionContextWithDmAuth({
ctx: this.ctx,
interaction,
label: "agent select",
componentLabel: "select menu",
defer: false,
});
if (!interactionCtx) {
return;
}
const {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
isGroupDm,
memberRoleIds,
} = interactionCtx;
const allowed = await ensureAgentComponentInteractionAllowed({
ctx: this.ctx,
interaction,
channelId,
rawGuildId,
memberRoleIds,
user,
replyOpts,
componentLabel: "select",
unauthorizedReply: "You are not authorized to use this select menu.",
});
if (!allowed) {
return;
}
const { parentId } = allowed;
const values = interaction.values ?? [];
const valuesText = values.length > 0 ? ` (selected: ${values.join(", ")})` : "";
const route = resolveAgentComponentRoute({
ctx: this.ctx,
rawGuildId,
memberRoleIds,
isDirectMessage,
isGroupDm,
userId,
channelId,
parentId,
});
const eventText = `[Discord select menu: ${componentId} interacted by ${username} (${userId})${valuesText}]`;
logDebug(`agent select: enqueuing event for channel ${channelId}: ${eventText}`);
enqueueSystemEvent(eventText, {
sessionKey: route.sessionKey,
contextKey: `discord:agent-select:${channelId}:${componentId}:${userId}`,
});
await ackComponentInteraction({ interaction, replyOpts, label: "agent select" });
}
}
export function createAgentComponentButton(ctx: AgentComponentContext): Button {
return new AgentComponentButton(ctx);
}
export function createAgentSelectMenu(ctx: AgentComponentContext): StringSelectMenu {
return new AgentSelectMenu(ctx);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type {
ButtonInteraction,
ChannelSelectMenuInteraction,
MentionableSelectMenuInteraction,
ModalInteraction,
RoleSelectMenuInteraction,
StringSelectMenuInteraction,
UserSelectMenuInteraction,
} from "../internal/discord.js";
import type { DiscordGuildEntryResolved } from "./allow-list.js";
import type { formatDiscordUserTag } from "./format.js";
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 = {
channelId: string;
user: DiscordUser;
username: string;
userId: string;
replyOpts: { ephemeral?: boolean };
rawGuildId: string | undefined;
isDirectMessage: boolean;
isGroupDm: boolean;
memberRoleIds: string[];
};

View File

@@ -0,0 +1,247 @@
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import { parseDiscordComponentCustomIdForInteraction } from "../component-custom-id.js";
import {
Button,
ChannelSelectMenu,
MentionableSelectMenu,
RoleSelectMenu,
StringSelectMenu,
UserSelectMenu,
type ButtonInteraction,
type ChannelSelectMenuInteraction,
type ComponentData,
type MentionableSelectMenuInteraction,
type RoleSelectMenuInteraction,
type StringSelectMenuInteraction,
type UserSelectMenuInteraction,
} from "../internal/discord.js";
import {
parseDiscordComponentData,
resolveInteractionContextWithDmAuth,
resolveInteractionCustomId,
type AgentComponentContext,
type AgentComponentMessageInteraction,
type ComponentInteractionContext,
} from "./agent-components-helpers.js";
export type DiscordComponentControlHandlers = {
handleComponentEvent: (params: {
ctx: AgentComponentContext;
interaction: AgentComponentMessageInteraction;
data: ComponentData;
componentLabel: string;
values?: string[];
label: string;
}) => Promise<void>;
handleModalTrigger: (params: {
ctx: AgentComponentContext;
interaction: ButtonInteraction;
data: ComponentData;
label: string;
interactionCtx?: ComponentInteractionContext;
}) => Promise<void>;
};
class DiscordComponentButton extends Button {
label = "component";
customId = "__openclaw_discord_component_button_wildcard__";
style = ButtonStyle.Primary;
customIdParser = parseDiscordComponentCustomIdForInteraction;
constructor(
private ctx: AgentComponentContext,
private handlers: DiscordComponentControlHandlers,
) {
super();
}
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;
}
await this.handlers.handleComponentEvent({
ctx: this.ctx,
interaction,
data,
componentLabel: "button",
label: "discord component button",
});
}
}
class DiscordComponentStringSelect extends StringSelectMenu {
customId = "__openclaw_discord_component_string_select_wildcard__";
options: APIStringSelectComponent["options"] = [];
customIdParser = parseDiscordComponentCustomIdForInteraction;
constructor(
private ctx: AgentComponentContext,
private handlers: DiscordComponentControlHandlers,
) {
super();
}
async run(interaction: StringSelectMenuInteraction, data: ComponentData): Promise<void> {
await this.handlers.handleComponentEvent({
ctx: this.ctx,
interaction,
data,
componentLabel: "select menu",
label: "discord component select",
values: interaction.values ?? [],
});
}
}
class DiscordComponentUserSelect extends UserSelectMenu {
customId = "__openclaw_discord_component_user_select_wildcard__";
customIdParser = parseDiscordComponentCustomIdForInteraction;
constructor(
private ctx: AgentComponentContext,
private handlers: DiscordComponentControlHandlers,
) {
super();
}
async run(interaction: UserSelectMenuInteraction, data: ComponentData): Promise<void> {
await this.handlers.handleComponentEvent({
ctx: this.ctx,
interaction,
data,
componentLabel: "user select",
label: "discord component user select",
values: interaction.values ?? [],
});
}
}
class DiscordComponentRoleSelect extends RoleSelectMenu {
customId = "__openclaw_discord_component_role_select_wildcard__";
customIdParser = parseDiscordComponentCustomIdForInteraction;
constructor(
private ctx: AgentComponentContext,
private handlers: DiscordComponentControlHandlers,
) {
super();
}
async run(interaction: RoleSelectMenuInteraction, data: ComponentData): Promise<void> {
await this.handlers.handleComponentEvent({
ctx: this.ctx,
interaction,
data,
componentLabel: "role select",
label: "discord component role select",
values: interaction.values ?? [],
});
}
}
class DiscordComponentMentionableSelect extends MentionableSelectMenu {
customId = "__openclaw_discord_component_mentionable_select_wildcard__";
customIdParser = parseDiscordComponentCustomIdForInteraction;
constructor(
private ctx: AgentComponentContext,
private handlers: DiscordComponentControlHandlers,
) {
super();
}
async run(interaction: MentionableSelectMenuInteraction, data: ComponentData): Promise<void> {
await this.handlers.handleComponentEvent({
ctx: this.ctx,
interaction,
data,
componentLabel: "mentionable select",
label: "discord component mentionable select",
values: interaction.values ?? [],
});
}
}
class DiscordComponentChannelSelect extends ChannelSelectMenu {
customId = "__openclaw_discord_component_channel_select_wildcard__";
customIdParser = parseDiscordComponentCustomIdForInteraction;
constructor(
private ctx: AgentComponentContext,
private handlers: DiscordComponentControlHandlers,
) {
super();
}
async run(interaction: ChannelSelectMenuInteraction, data: ComponentData): Promise<void> {
await this.handlers.handleComponentEvent({
ctx: this.ctx,
interaction,
data,
componentLabel: "channel select",
label: "discord component channel select",
values: interaction.values ?? [],
});
}
}
export function createDiscordComponentButtonControl(
ctx: AgentComponentContext,
handlers: DiscordComponentControlHandlers,
): Button {
return new DiscordComponentButton(ctx, handlers);
}
export function createDiscordComponentStringSelectControl(
ctx: AgentComponentContext,
handlers: DiscordComponentControlHandlers,
): StringSelectMenu {
return new DiscordComponentStringSelect(ctx, handlers);
}
export function createDiscordComponentUserSelectControl(
ctx: AgentComponentContext,
handlers: DiscordComponentControlHandlers,
): UserSelectMenu {
return new DiscordComponentUserSelect(ctx, handlers);
}
export function createDiscordComponentRoleSelectControl(
ctx: AgentComponentContext,
handlers: DiscordComponentControlHandlers,
): RoleSelectMenu {
return new DiscordComponentRoleSelect(ctx, handlers);
}
export function createDiscordComponentMentionableSelectControl(
ctx: AgentComponentContext,
handlers: DiscordComponentControlHandlers,
): MentionableSelectMenu {
return new DiscordComponentMentionableSelect(ctx, handlers);
}
export function createDiscordComponentChannelSelectControl(
ctx: AgentComponentContext,
handlers: DiscordComponentControlHandlers,
): ChannelSelectMenu {
return new DiscordComponentChannelSelect(ctx, handlers);
}

View File

@@ -1,4 +1,3 @@
import type { Guild, User } from "@buape/carbon";
import type { AllowlistMatch } from "openclaw/plugin-sdk/allow-from";
import {
buildChannelKeyCandidates,
@@ -11,6 +10,7 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import type { Guild, User } from "../internal/discord.js";
import { formatDiscordUserTag } from "./format.js";
export type DiscordAllowList = {

View File

@@ -1,4 +1,3 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import {
clearExpiredCooldowns,
ensureAuthProfileStore,
@@ -12,6 +11,7 @@ import type {
DiscordAutoPresenceConfig,
} from "openclaw/plugin-sdk/config-types";
import { warn } from "openclaw/plugin-sdk/runtime-env";
import type { Activity, UpdatePresenceData } from "../internal/gateway.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4;

View File

@@ -1,6 +1,6 @@
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ButtonInteraction, ComponentData } from "../internal/discord.js";
const resolveApprovalOverGatewayMock = vi.hoisted(() => vi.fn());

View File

@@ -1,4 +1,3 @@
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
import { ButtonStyle } from "discord-api-types/v10";
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import type {
@@ -9,6 +8,7 @@ import type {
PluginApprovalResolved,
} from "openclaw/plugin-sdk/approval-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { Button, type ButtonInteraction, type ComponentData } from "../internal/discord.js";
export { buildExecApprovalCustomId } from "../approval-handler.runtime.js";
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";

View File

@@ -1,4 +1,4 @@
import type { Guild, User } from "@buape/carbon";
import type { Guild, User } from "../internal/discord.js";
export function resolveDiscordSystemLocation(params: {
isDirectMessage: boolean;

View File

@@ -1,5 +1,5 @@
import type { EventEmitter } from "node:events";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import type { GatewayPlugin } from "../internal/gateway.js";
export const DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT =
"openclaw:discord-gateway-transport-activity";

View File

@@ -0,0 +1,295 @@
import type { APIGatewayBotInfo } from "discord-api-types/v10";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { captureHttpExchange } from "openclaw/plugin-sdk/proxy-capture";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { Type } from "typebox";
import { Check, Errors } from "typebox/value";
import { withAbortTimeout } from "./timeouts.js";
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
const DISCORD_API_HOST = "discord.com";
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
const DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 30_000;
const MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 120_000;
const DISCORD_GATEWAY_INFO_TIMEOUT_ENV = "OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS";
const DISCORD_GATEWAY_METADATA_FALLBACK_LOG_INTERVAL_MS = 60_000;
export type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
export type DiscordGatewayFetchInit = Record<string, unknown> & {
headers?: Record<string, string>;
};
export type DiscordGatewayFetch = (
input: string,
init?: DiscordGatewayFetchInit,
) => Promise<DiscordGatewayMetadataResponse>;
type DiscordGatewayMetadataError = Error & { transient?: boolean };
const discordGatewayBotInfoSchema = Type.Object({
url: Type.String({ minLength: 1 }),
shards: Type.Integer({ minimum: 1 }),
session_start_limit: Type.Object({
total: Type.Integer({ minimum: 0 }),
remaining: Type.Integer({ minimum: 0 }),
reset_after: Type.Number({ minimum: 0 }),
max_concurrency: Type.Integer({ minimum: 1 }),
}),
});
const gatewayMetadataFallbackLogLastAt = new WeakMap<RuntimeEnv, number>();
function resolveFetchInputUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
async function materializeGuardedResponse(response: Response): Promise<Response> {
const body = await response.arrayBuffer();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
function normalizeGatewayInfoTimeoutMs(value: unknown): number | undefined {
const numeric =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined;
}
return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
}
export function resolveDiscordGatewayInfoTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
return (
normalizeGatewayInfoTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayInfoTimeoutMs(params?.env?.[DISCORD_GATEWAY_INFO_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS
);
}
function summarizeGatewayResponseBody(body: string): string {
const normalized = body.trim().replace(/\s+/g, " ");
if (!normalized) {
return "<empty>";
}
return normalized.slice(0, 240);
}
function isTransientDiscordGatewayResponse(status: number, body: string): boolean {
if (status >= 500) {
return true;
}
const normalized = normalizeLowercaseStringOrEmpty(body);
return (
normalized.includes("upstream connect error") ||
normalized.includes("disconnect/reset before headers") ||
normalized.includes("reset reason:")
);
}
function createGatewayMetadataError(params: {
detail: string;
transient: boolean;
cause?: unknown;
}): Error {
const error = new Error(
params.transient
? "Failed to get gateway information from Discord: fetch failed"
: `Failed to get gateway information from Discord: ${params.detail}`,
{
cause: params.cause ?? (params.transient ? new Error(params.detail) : undefined),
},
) as DiscordGatewayMetadataError;
Object.defineProperty(error, "transient", {
value: params.transient,
enumerable: false,
});
return error;
}
function isTransientGatewayMetadataError(error: unknown): boolean {
return Boolean((error as DiscordGatewayMetadataError | undefined)?.transient);
}
function createDefaultGatewayInfo(): APIGatewayBotInfo {
return {
url: DEFAULT_DISCORD_GATEWAY_URL,
shards: 1,
session_start_limit: {
total: 1,
remaining: 1,
reset_after: 0,
max_concurrency: 1,
},
};
}
function summarizeGatewaySchemaErrors(value: unknown): string {
const errors = Errors(discordGatewayBotInfoSchema, value);
if (errors.length === 0) {
return "unknown schema mismatch";
}
return errors
.slice(0, 3)
.map((error) => `${error.instancePath || "/"} ${error.message}`)
.join("; ");
}
export function parseDiscordGatewayInfoBody(body: string): APIGatewayBotInfo {
const parsed = JSON.parse(body) as unknown;
if (!Check(discordGatewayBotInfoSchema, parsed)) {
throw new Error(summarizeGatewaySchemaErrors(parsed));
}
return parsed;
}
export async function fetchDiscordGatewayInfo(params: {
token: string;
fetchImpl: DiscordGatewayFetch;
fetchInit?: DiscordGatewayFetchInit;
}): Promise<APIGatewayBotInfo> {
let response: DiscordGatewayMetadataResponse;
try {
response = await params.fetchImpl(DISCORD_GATEWAY_BOT_URL, {
...params.fetchInit,
headers: {
...params.fetchInit?.headers,
Authorization: `Bot ${params.token}`,
},
});
} catch (error) {
throw createGatewayMetadataError({
detail: formatErrorMessage(error),
transient: true,
cause: error,
});
}
let body: string;
try {
body = await response.text();
} catch (error) {
throw createGatewayMetadataError({
detail: formatErrorMessage(error),
transient: true,
cause: error,
});
}
const summary = summarizeGatewayResponseBody(body);
const transient = isTransientDiscordGatewayResponse(response.status, body);
if (!response.ok) {
throw createGatewayMetadataError({
detail: `Discord API /gateway/bot failed (${response.status}): ${summary}`,
transient,
});
}
try {
return parseDiscordGatewayInfoBody(body);
} catch (error) {
throw createGatewayMetadataError({
detail: `Discord API /gateway/bot returned invalid metadata: ${formatErrorMessage(error)} (${summary})`,
transient,
cause: error,
});
}
}
export async function fetchDiscordGatewayInfoWithTimeout(params: {
token: string;
fetchImpl: DiscordGatewayFetch;
fetchInit?: DiscordGatewayFetchInit;
timeoutMs?: number;
}): Promise<APIGatewayBotInfo> {
const timeoutMs = Math.max(1, params.timeoutMs ?? DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
return await withAbortTimeout({
timeoutMs,
createTimeoutError: () =>
createGatewayMetadataError({
detail: `Discord API /gateway/bot timed out after ${timeoutMs}ms`,
transient: true,
cause: new Error("gateway metadata timeout"),
}),
run: async (signal) =>
await fetchDiscordGatewayInfo({
token: params.token,
fetchImpl: params.fetchImpl,
fetchInit: {
...params.fetchInit,
signal,
},
}),
});
}
export function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; error: unknown }): {
info: APIGatewayBotInfo;
usedFallback: boolean;
} {
if (!isTransientGatewayMetadataError(params.error)) {
throw params.error;
}
const message = formatErrorMessage(params.error);
const now = Date.now();
if (params.runtime) {
const previous = gatewayMetadataFallbackLogLastAt.get(params.runtime);
if (
previous === undefined ||
now - previous >= DISCORD_GATEWAY_METADATA_FALLBACK_LOG_INTERVAL_MS
) {
params.runtime.log?.(
`discord: gateway metadata lookup failed transiently; using default gateway url (${message})`,
);
gatewayMetadataFallbackLogLastAt.set(params.runtime, now);
}
}
return {
info: createDefaultGatewayInfo(),
usedFallback: true,
};
}
export async function fetchDiscordGatewayMetadataDirect(
input: string,
init?: DiscordGatewayFetchInit,
capture?: false | { flowId: string; meta: Record<string, unknown> },
): Promise<Response> {
const guarded = await fetchWithSsrFGuard({
url: resolveFetchInputUrl(input),
init: init as RequestInit,
policy: { allowedHostnames: [DISCORD_API_HOST] },
capture: false,
auditContext: "discord.gateway.metadata",
});
let response: Response;
try {
response = await materializeGuardedResponse(guarded.response);
} finally {
await guarded.release();
}
if (capture) {
captureHttpExchange({
url: input,
method: (init?.method as string | undefined) ?? "GET",
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
response,
flowId: capture.flowId,
meta: capture.meta,
});
}
return response;
}

View File

@@ -56,9 +56,7 @@ const { baseConnectSpy, GatewayIntents, GatewayPlugin } = vi.hoisted(() => {
return { baseConnectSpy, GatewayIntents, GatewayPlugin };
});
vi.mock("@buape/carbon/gateway", () => ({ GatewayIntents, GatewayPlugin }));
vi.mock("@buape/carbon/dist/src/plugins/gateway/index.js", () => ({
vi.mock("../internal/gateway.js", () => ({
GatewayIntents,
GatewayPlugin,
}));
@@ -76,12 +74,14 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
describe("SafeGatewayPlugin.connect()", () => {
let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
let parseDiscordGatewayInfoBody: typeof import("./gateway-plugin.js").parseDiscordGatewayInfoBody;
let resolveDiscordGatewayIntents: typeof import("./gateway-plugin.js").resolveDiscordGatewayIntents;
let resolveDiscordGatewayInfoTimeoutMs: typeof import("./gateway-plugin.js").resolveDiscordGatewayInfoTimeoutMs;
beforeAll(async () => {
({
createDiscordGatewayPlugin,
parseDiscordGatewayInfoBody,
resolveDiscordGatewayIntents,
resolveDiscordGatewayInfoTimeoutMs,
} = await import("./gateway-plugin.js"));
@@ -91,49 +91,6 @@ describe("SafeGatewayPlugin.connect()", () => {
baseConnectSpy.mockClear();
});
it("includes GuildVoiceStates when voice is enabled by default", () => {
expect(resolveDiscordGatewayIntents() & GatewayIntents.GuildVoiceStates).toBe(
GatewayIntents.GuildVoiceStates,
);
});
it("omits GuildVoiceStates when voice is disabled", () => {
const intents = resolveDiscordGatewayIntents({ voiceEnabled: false });
expect(intents & GatewayIntents.GuildVoiceStates).toBe(0);
});
it("lets intents.voiceStates override voice enablement", () => {
const enabled = resolveDiscordGatewayIntents({
intentsConfig: { voiceStates: true },
voiceEnabled: false,
});
const disabled = resolveDiscordGatewayIntents({
intentsConfig: { voiceStates: false },
voiceEnabled: true,
});
expect(enabled & GatewayIntents.GuildVoiceStates).toBe(GatewayIntents.GuildVoiceStates);
expect(disabled & GatewayIntents.GuildVoiceStates).toBe(0);
});
it("keeps the legacy intents-config argument shape working", () => {
const intents = resolveDiscordGatewayIntents({ presence: true, guildMembers: true });
expect(intents & GatewayIntents.GuildPresences).toBe(GatewayIntents.GuildPresences);
expect(intents & GatewayIntents.GuildMembers).toBe(GatewayIntents.GuildMembers);
});
it("resolves gateway metadata timeout from config, env, then default", () => {
expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
expect(
resolveDiscordGatewayInfoTimeoutMs({
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "25000" },
}),
).toBe(25_000);
expect(resolveDiscordGatewayInfoTimeoutMs({ env: {} })).toBe(30_000);
});
function createPlugin(
testing?: NonNullable<Parameters<typeof createDiscordGatewayPlugin>[0]["__testing"]>,
discordConfig: Parameters<typeof createDiscordGatewayPlugin>[0]["discordConfig"] = {},
@@ -175,8 +132,10 @@ describe("SafeGatewayPlugin.connect()", () => {
expect(disabled & GatewayIntents.GuildVoiceStates).toBe(0);
});
it("keeps the legacy intents-config argument shape working", () => {
const intents = resolveDiscordGatewayIntents({ presence: true, guildMembers: true });
it("includes optional configured privileged intents", () => {
const intents = resolveDiscordGatewayIntents({
intentsConfig: { presence: true, guildMembers: true },
});
expect(intents & GatewayIntents.GuildPresences).toBe(GatewayIntents.GuildPresences);
expect(intents & GatewayIntents.GuildMembers).toBe(GatewayIntents.GuildMembers);
@@ -192,6 +151,49 @@ describe("SafeGatewayPlugin.connect()", () => {
expect(resolveDiscordGatewayInfoTimeoutMs({ env: {} })).toBe(30_000);
});
it("parses valid Discord gateway metadata", () => {
expect(
parseDiscordGatewayInfoBody(
JSON.stringify({
url: "wss://gateway.discord.gg",
shards: 1,
session_start_limit: {
total: 1000,
remaining: 999,
reset_after: 0,
max_concurrency: 1,
},
}),
),
).toEqual({
url: "wss://gateway.discord.gg",
shards: 1,
session_start_limit: {
total: 1000,
remaining: 999,
reset_after: 0,
max_concurrency: 1,
},
});
});
it("rejects malformed Discord gateway metadata", () => {
expect(() =>
parseDiscordGatewayInfoBody(
JSON.stringify({
url: "",
shards: 0,
session_start_limit: {
total: 1000,
remaining: 999,
reset_after: 0,
max_concurrency: 1,
},
}),
),
).toThrow(/url|shards/);
});
it("omits voice states when Discord voice is disabled in account config", () => {
const plugin = createPlugin(undefined, { voice: { enabled: false } });
const options = (plugin as unknown as { options?: { intents?: number } }).options;
@@ -218,7 +220,7 @@ describe("SafeGatewayPlugin.connect()", () => {
}
});
it("leaves Carbon autoInteractions disabled so OpenClaw owns interaction handoff", () => {
it("leaves autoInteractions disabled so OpenClaw owns interaction handoff", () => {
const plugin = createPlugin();
expect((plugin as unknown as { options?: { autoInteractions?: boolean } }).options).toEqual(
@@ -226,7 +228,7 @@ describe("SafeGatewayPlugin.connect()", () => {
);
});
it("keeps OpenClaw metadata timeout out of Carbon gateway options", () => {
it("keeps OpenClaw metadata timeout out of gateway options", () => {
const plugin = createDiscordGatewayPlugin({
discordConfig: { gatewayInfoTimeoutMs: 5_000 },
runtime: {

View File

@@ -1,330 +1,84 @@
import { randomUUID } from "node:crypto";
import * as carbonGateway from "@buape/carbon/gateway";
import type { APIGatewayBotInfo } from "discord-api-types/v10";
import * as httpsProxyAgent from "https-proxy-agent";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
captureHttpExchange,
captureWsEvent,
resolveEffectiveDebugProxyUrl,
resolveDebugProxySettings,
} from "openclaw/plugin-sdk/proxy-capture";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as ws from "ws";
import * as discordGateway from "../internal/gateway.js";
import { validateDiscordProxyUrl } from "../proxy-fetch.js";
import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js";
import {
fetchDiscordGatewayInfoWithTimeout,
fetchDiscordGatewayMetadataDirect,
resolveDiscordGatewayInfoTimeoutMs,
resolveGatewayInfoWithFallback,
type DiscordGatewayFetch,
type DiscordGatewayFetchInit,
} from "./gateway-metadata.js";
export {
parseDiscordGatewayInfoBody,
resolveDiscordGatewayInfoTimeoutMs,
} from "./gateway-metadata.js";
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
const DISCORD_API_HOST = "discord.com";
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
const DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 30_000;
const MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 120_000;
const DISCORD_GATEWAY_INFO_TIMEOUT_ENV = "OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS";
const DISCORD_GATEWAY_METADATA_FALLBACK_LOG_INTERVAL_MS = 60_000;
const DISCORD_GATEWAY_HANDSHAKE_TIMEOUT_MS = 30_000;
type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
type DiscordGatewayFetchInit = Record<string, unknown> & {
headers?: Record<string, string>;
};
type DiscordGatewayFetch = (
input: string,
init?: DiscordGatewayFetchInit,
) => Promise<DiscordGatewayMetadataResponse>;
type DiscordGatewayMetadataError = Error & { transient?: boolean };
type DiscordGatewayWebSocketCtor = new (
url: string,
options?: { agent?: unknown; handshakeTimeout?: number },
) => ws.WebSocket;
const registrationPromises = new WeakMap<carbonGateway.GatewayPlugin, Promise<void>>();
const gatewayMetadataFallbackLogLastAt = new WeakMap<RuntimeEnv, number>();
type CarbonGatewayRegistrationState = {
client?: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0];
const registrationPromises = new WeakMap<discordGateway.GatewayPlugin, Promise<void>>();
type DiscordGatewayRegistrationState = {
client?: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0];
ws?: unknown;
isConnecting?: boolean;
};
function resolveFetchInputUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
async function materializeGuardedResponse(response: Response): Promise<Response> {
const body = await response.arrayBuffer();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
function assignCarbonGatewayClient(
plugin: carbonGateway.GatewayPlugin,
client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0],
function assignGatewayClient(
plugin: discordGateway.GatewayPlugin,
client: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0],
): void {
(plugin as unknown as CarbonGatewayRegistrationState).client = client;
(plugin as unknown as DiscordGatewayRegistrationState).client = client;
}
function hasCarbonGatewaySocketStarted(plugin: carbonGateway.GatewayPlugin): boolean {
const state = plugin as unknown as CarbonGatewayRegistrationState;
function hasGatewaySocketStarted(plugin: discordGateway.GatewayPlugin): boolean {
const state = plugin as unknown as DiscordGatewayRegistrationState;
return state.ws != null || state.isConnecting === true;
}
type ResolveDiscordGatewayIntentsParams =
| import("openclaw/plugin-sdk/config-types").DiscordIntentsConfig
| {
intentsConfig?: import("openclaw/plugin-sdk/config-types").DiscordIntentsConfig;
voiceEnabled?: boolean;
};
function isGatewayIntentsResolverOptions(
value: ResolveDiscordGatewayIntentsParams | undefined,
): value is Exclude<ResolveDiscordGatewayIntentsParams, undefined> & {
type ResolveDiscordGatewayIntentsParams = {
intentsConfig?: import("openclaw/plugin-sdk/config-types").DiscordIntentsConfig;
voiceEnabled?: boolean;
} {
return Boolean(value && ("intentsConfig" in value || "voiceEnabled" in value));
}
};
export function resolveDiscordGatewayIntents(params?: ResolveDiscordGatewayIntentsParams): number {
const intentsConfig = isGatewayIntentsResolverOptions(params) ? params.intentsConfig : params;
const voiceEnabled = isGatewayIntentsResolverOptions(params) ? params.voiceEnabled : undefined;
const intentsConfig = params?.intentsConfig;
const voiceEnabled = params?.voiceEnabled;
const voiceStatesEnabled = intentsConfig?.voiceStates ?? voiceEnabled ?? true;
let intents =
carbonGateway.GatewayIntents.Guilds |
carbonGateway.GatewayIntents.GuildMessages |
carbonGateway.GatewayIntents.MessageContent |
carbonGateway.GatewayIntents.DirectMessages |
carbonGateway.GatewayIntents.GuildMessageReactions |
carbonGateway.GatewayIntents.DirectMessageReactions;
discordGateway.GatewayIntents.Guilds |
discordGateway.GatewayIntents.GuildMessages |
discordGateway.GatewayIntents.MessageContent |
discordGateway.GatewayIntents.DirectMessages |
discordGateway.GatewayIntents.GuildMessageReactions |
discordGateway.GatewayIntents.DirectMessageReactions;
if (voiceStatesEnabled) {
intents |= carbonGateway.GatewayIntents.GuildVoiceStates;
intents |= discordGateway.GatewayIntents.GuildVoiceStates;
}
if (intentsConfig?.presence) {
intents |= carbonGateway.GatewayIntents.GuildPresences;
intents |= discordGateway.GatewayIntents.GuildPresences;
}
if (intentsConfig?.guildMembers) {
intents |= carbonGateway.GatewayIntents.GuildMembers;
intents |= discordGateway.GatewayIntents.GuildMembers;
}
return intents;
}
function normalizeGatewayInfoTimeoutMs(value: unknown): number | undefined {
const numeric =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined;
}
return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
}
export function resolveDiscordGatewayInfoTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
return (
normalizeGatewayInfoTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayInfoTimeoutMs(params?.env?.[DISCORD_GATEWAY_INFO_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS
);
}
function summarizeGatewayResponseBody(body: string): string {
const normalized = body.trim().replace(/\s+/g, " ");
if (!normalized) {
return "<empty>";
}
return normalized.slice(0, 240);
}
function isTransientDiscordGatewayResponse(status: number, body: string): boolean {
if (status >= 500) {
return true;
}
const normalized = normalizeLowercaseStringOrEmpty(body);
return (
normalized.includes("upstream connect error") ||
normalized.includes("disconnect/reset before headers") ||
normalized.includes("reset reason:")
);
}
function createGatewayMetadataError(params: {
detail: string;
transient: boolean;
cause?: unknown;
}): Error {
const error = new Error(
params.transient
? "Failed to get gateway information from Discord: fetch failed"
: `Failed to get gateway information from Discord: ${params.detail}`,
{
cause: params.cause ?? (params.transient ? new Error(params.detail) : undefined),
},
) as DiscordGatewayMetadataError;
Object.defineProperty(error, "transient", {
value: params.transient,
enumerable: false,
});
return error;
}
function isTransientGatewayMetadataError(error: unknown): boolean {
return Boolean((error as DiscordGatewayMetadataError | undefined)?.transient);
}
function createDefaultGatewayInfo(): APIGatewayBotInfo {
return {
url: DEFAULT_DISCORD_GATEWAY_URL,
shards: 1,
session_start_limit: {
total: 1,
remaining: 1,
reset_after: 0,
max_concurrency: 1,
},
};
}
async function fetchDiscordGatewayInfo(params: {
token: string;
fetchImpl: DiscordGatewayFetch;
fetchInit?: DiscordGatewayFetchInit;
}): Promise<APIGatewayBotInfo> {
let response: DiscordGatewayMetadataResponse;
try {
response = await params.fetchImpl(DISCORD_GATEWAY_BOT_URL, {
...params.fetchInit,
headers: {
...params.fetchInit?.headers,
Authorization: `Bot ${params.token}`,
},
});
} catch (error) {
throw createGatewayMetadataError({
detail: formatErrorMessage(error),
transient: true,
cause: error,
});
}
let body: string;
try {
body = await response.text();
} catch (error) {
throw createGatewayMetadataError({
detail: formatErrorMessage(error),
transient: true,
cause: error,
});
}
const summary = summarizeGatewayResponseBody(body);
const transient = isTransientDiscordGatewayResponse(response.status, body);
if (!response.ok) {
throw createGatewayMetadataError({
detail: `Discord API /gateway/bot failed (${response.status}): ${summary}`,
transient,
});
}
try {
const parsed = JSON.parse(body) as Partial<APIGatewayBotInfo>;
return {
...parsed,
url:
typeof parsed.url === "string" && parsed.url.trim()
? parsed.url
: DEFAULT_DISCORD_GATEWAY_URL,
} as APIGatewayBotInfo;
} catch (error) {
throw createGatewayMetadataError({
detail: `Discord API /gateway/bot returned invalid JSON: ${summary}`,
transient,
cause: error,
});
}
}
async function fetchDiscordGatewayInfoWithTimeout(params: {
token: string;
fetchImpl: DiscordGatewayFetch;
fetchInit?: DiscordGatewayFetchInit;
timeoutMs?: number;
}): Promise<APIGatewayBotInfo> {
const timeoutMs = Math.max(1, params.timeoutMs ?? DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
const abortController = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
abortController.abort();
reject(
createGatewayMetadataError({
detail: `Discord API /gateway/bot timed out after ${timeoutMs}ms`,
transient: true,
cause: new Error("gateway metadata timeout"),
}),
);
}, timeoutMs);
timeoutId.unref?.();
});
try {
return await Promise.race([
fetchDiscordGatewayInfo({
token: params.token,
fetchImpl: params.fetchImpl,
fetchInit: {
...params.fetchInit,
signal: abortController.signal,
},
}),
timeoutPromise,
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; error: unknown }): {
info: APIGatewayBotInfo;
usedFallback: boolean;
} {
if (!isTransientGatewayMetadataError(params.error)) {
throw params.error;
}
const message = formatErrorMessage(params.error);
const now = Date.now();
if (params.runtime) {
const previous = gatewayMetadataFallbackLogLastAt.get(params.runtime);
if (
previous === undefined ||
now - previous >= DISCORD_GATEWAY_METADATA_FALLBACK_LOG_INTERVAL_MS
) {
params.runtime.log?.(
`discord: gateway metadata lookup failed transiently; using default gateway url (${message})`,
);
gatewayMetadataFallbackLogLastAt.set(params.runtime, now);
}
}
return {
info: createDefaultGatewayInfo(),
usedFallback: true,
};
}
function createGatewayPlugin(params: {
options: {
reconnect: { maxAttempts: number };
@@ -338,13 +92,13 @@ function createGatewayPlugin(params: {
runtime?: RuntimeEnv;
testing?: {
registerClient?: (
plugin: carbonGateway.GatewayPlugin,
client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0],
plugin: discordGateway.GatewayPlugin,
client: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0],
) => Promise<void>;
webSocketCtor?: DiscordGatewayWebSocketCtor;
};
}): carbonGateway.GatewayPlugin {
class SafeGatewayPlugin extends carbonGateway.GatewayPlugin {
}): discordGateway.GatewayPlugin {
class SafeGatewayPlugin extends discordGateway.GatewayPlugin {
private gatewayInfoUsedFallback = false;
constructor() {
@@ -352,11 +106,8 @@ function createGatewayPlugin(params: {
}
public override connect(resume = false): void {
// Guard against stale heartbeat timers from the @buape/carbon
// firstHeartbeatTimeout race (openclaw/openclaw#65009, #64011, #63387).
// Parent connect() only calls stopHeartbeat() when isConnecting=false.
// If isConnecting=true it returns early — leaving a stale setInterval
// that fires with a closed reconnectCallback and crashes the process.
// Guard against stale heartbeat timers from an early reconnect race
// (openclaw/openclaw#65009, #64011, #63387).
if (this.heartbeatInterval !== undefined) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = undefined;
@@ -368,24 +119,21 @@ function createGatewayPlugin(params: {
super.connect(resume);
}
override registerClient(client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0]) {
override registerClient(client: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0]) {
const registration = this.registerClientInternal(client);
// Carbon 0.16 invokes async plugin hooks from Client construction without
// awaiting them. Mark the promise handled immediately, then let OpenClaw
// startup await the original promise explicitly.
// Client construction starts plugin hooks without awaiting them. Mark the
// promise handled immediately, then let startup await the original promise.
registration.catch(() => {});
registrationPromises.set(this, registration);
return registration;
}
private async registerClientInternal(
client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0],
client: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0],
) {
// Carbon's Client constructor does not await plugin registerClient().
// Match Carbon's own GatewayPlugin ordering by publishing the client
// reference before our metadata fetch can yield, so an external
// Publish the client reference before the metadata fetch can yield, so an external
// connect()->identify() cannot silently drop IDENTIFY (#52372).
assignCarbonGatewayClient(this, client);
assignGatewayClient(this, client);
if (!this.gatewayInfo || this.gatewayInfoUsedFallback) {
const resolved = await fetchDiscordGatewayInfoWithTimeout({
@@ -407,10 +155,8 @@ function createGatewayPlugin(params: {
return;
}
// If the lifecycle timeout already started a socket while metadata was
// loading, do not call Carbon's registerClient() again; it would close
// that socket and open another one. Carbon stores these as runtime fields
// even though they are protected/private in the .d.ts.
if (hasCarbonGatewaySocketStarted(this)) {
// loading, do not register again; it would close that socket and open another one.
if (hasGatewaySocketStarted(this)) {
return;
}
return super.registerClient(client);
@@ -488,45 +234,13 @@ function createGatewayPlugin(params: {
return new SafeGatewayPlugin();
}
async function fetchDiscordGatewayMetadataDirect(
input: string,
init?: DiscordGatewayFetchInit,
capture?: false | { flowId: string; meta: Record<string, unknown> },
): Promise<Response> {
const guarded = await fetchWithSsrFGuard({
url: resolveFetchInputUrl(input),
init: init as RequestInit,
policy: { allowedHostnames: [DISCORD_API_HOST] },
capture: false,
auditContext: "discord.gateway.metadata",
});
let response: Response;
try {
response = await materializeGuardedResponse(guarded.response);
} finally {
await guarded.release();
}
if (capture) {
captureHttpExchange({
url: input,
method: (init?.method as string | undefined) ?? "GET",
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
response,
flowId: capture.flowId,
meta: capture.meta,
});
}
return response;
}
export function waitForDiscordGatewayPluginRegistration(
plugin: unknown,
): Promise<void> | undefined {
if (typeof plugin !== "object" || plugin === null) {
return undefined;
}
return registrationPromises.get(plugin as carbonGateway.GatewayPlugin);
return registrationPromises.get(plugin as discordGateway.GatewayPlugin);
}
export function createDiscordGatewayPlugin(params: {
@@ -536,11 +250,11 @@ export function createDiscordGatewayPlugin(params: {
HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent;
webSocketCtor?: DiscordGatewayWebSocketCtor;
registerClient?: (
plugin: carbonGateway.GatewayPlugin,
client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0],
plugin: discordGateway.GatewayPlugin,
client: Parameters<discordGateway.GatewayPlugin["registerClient"]>[0],
) => Promise<void>;
};
}): carbonGateway.GatewayPlugin {
}): discordGateway.GatewayPlugin {
const intents = resolveDiscordGatewayIntents({
intentsConfig: params.discordConfig?.intents,
voiceEnabled: params.discordConfig?.voice?.enabled !== false,
@@ -554,8 +268,7 @@ export function createDiscordGatewayPlugin(params: {
const options = {
reconnect: { maxAttempts: 50 },
intents,
// OpenClaw registers its own async interaction listener. Carbon's default
// InteractionEventListener awaits the full handler on the critical event lane.
// OpenClaw registers its own async interaction listener.
autoInteractions: false,
};

View File

@@ -1,4 +1,4 @@
import type { GatewayPlugin } from "@buape/carbon/gateway";
import type { GatewayPlugin } from "../internal/gateway.js";
/**
* Module-level registry of active Discord GatewayPlugin instances.

View File

@@ -7,7 +7,7 @@ import {
} from "./gateway-supervisor.js";
describe("classifyDiscordGatewayEvent", () => {
it("maps current Carbon gateway errors onto domain events", () => {
it("maps current gateway errors onto domain events", () => {
const transientTypeError = new TypeError();
transientTypeError.stack = "TypeError\n at gatewayCrash (discord-gateway.js:12:34)";
const reconnectEvent = classifyDiscordGatewayEvent({

View File

@@ -1,5 +1,5 @@
import { Message } from "@buape/carbon";
import { describe, expect, it } from "vitest";
import { Message } from "../internal/discord.js";
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";
import {
buildDiscordInboundJob,
@@ -134,7 +134,7 @@ describe("buildDiscordInboundJob", () => {
expect(job.replayKeys).toEqual(["default:ch-1:m-1"]);
});
it("preserves Carbon message getters across queued jobs", async () => {
it("preserves Discord message getters across queued jobs", async () => {
const ctx = await createBaseDiscordMessageContext();
const message = new Message(
ctx.client as never,

View File

@@ -153,7 +153,7 @@ describe("DiscordMessageListener", () => {
});
describe("DiscordInteractionListener", () => {
it("returns immediately without awaiting Carbon interaction handling", async () => {
it("returns immediately without awaiting Discord interaction handling", async () => {
const handlerDone = createDeferred();
const handleInteraction = vi.fn(async () => {
await handlerDone.promise;

View File

@@ -1,14 +1,3 @@
import {
ChannelType,
type Client,
InteractionCreateListener,
MessageCreateListener,
MessageReactionAddListener,
MessageReactionRemoveListener,
PresenceUpdateListener,
ThreadUpdateListener,
type User,
} from "@buape/carbon";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import {
@@ -22,6 +11,17 @@ import {
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk/security-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import {
ChannelType,
type Client,
InteractionCreateListener,
MessageCreateListener,
MessageReactionAddListener,
MessageReactionRemoveListener,
PresenceUpdateListener,
ThreadUpdateListener,
type User,
} from "../internal/discord.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
@@ -77,7 +77,7 @@ type DiscordReactionRoutingParams = {
type DiscordReactionMode = "off" | "own" | "all" | "allowlist";
type DiscordReactionChannelConfig = ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
type DiscordReactionIngressAccess = Awaited<ReturnType<typeof authorizeDiscordReactionIngress>>;
type DiscordFetchedReactionMessage = { author?: User } | null;
type DiscordFetchedReactionMessage = { author?: User | null } | null;
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000;
const discordEventQueueLog = createSubsystemLogger("discord/event-queue");
@@ -183,9 +183,8 @@ export class DiscordMessageListener extends MessageCreateListener {
async handle(data: DiscordMessageEvent, client: Client) {
this.onEvent?.();
// Fire-and-forget: hand off to the handler without blocking the
// Carbon listener. Per-session ordering is owned by the message run queue,
// so the listener no longer serializes or applies its own timeout.
// Fire-and-forget: hand off to the handler without blocking gateway dispatch.
// Per-session ordering is owned by the message run queue.
void Promise.resolve()
.then(() => this.handler(data, client))
.catch((err) => {
@@ -205,9 +204,8 @@ export class DiscordInteractionListener extends InteractionCreateListener {
async handle(data: DiscordInteractionEvent, client: Client) {
this.onEvent?.();
// Carbon awaits interaction listeners on its critical gateway lane. Hand off
// immediately so slash/component handling can wait on session locks or compaction
// without tripping Carbon's listener timeout and dropping later gateway events.
// Hand off immediately so slash/component handling can wait on session locks
// or compaction without blocking later gateway events.
void Promise.resolve()
.then(() => client.handleInteraction(data as Parameters<Client["handleInteraction"]>[0], {}))
.catch((err) => {
@@ -701,7 +699,7 @@ async function handleDiscordReactionEvent(
memberRoleIds,
allowNameMatching: params.allowNameMatching,
});
const emitReactionWithAuthor = (message: { author?: User } | null) => {
const emitReactionWithAuthor = (message: DiscordFetchedReactionMessage) => {
const { baseText } = resolveReactionBase();
const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
@@ -789,16 +787,12 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
try {
const userId =
"user" in data && data.user && typeof data.user === "object" && "id" in data.user
? String(data.user.id)
? data.user.id
: undefined;
if (!userId) {
return;
}
setPresence(
this.accountId,
userId,
data as import("discord-api-types/v10").GatewayPresenceUpdate,
);
setPresence(this.accountId, userId, data);
} catch (err) {
const logger = this.logger ?? discordEventQueueLog;
logger.error(danger(`discord presence handler failed: ${String(err)}`));

View File

@@ -0,0 +1,88 @@
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
import type { ChannelType, Client, Message } from "../internal/discord.js";
import { resolveDiscordChannelInfoSafe } from "./channel-access.js";
export type DiscordChannelInfo = {
type: ChannelType;
name?: string;
topic?: string;
parentId?: string;
ownerId?: string;
};
type DiscordMessageWithChannelId = Message & {
channel_id?: unknown;
rawData?: { channel_id?: unknown };
};
const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000;
const DISCORD_CHANNEL_INFO_CACHE = new Map<
string,
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
export function __resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
function normalizeDiscordChannelId(value: unknown): string {
return normalizeOptionalStringifiedId(value) ?? "";
}
export function resolveDiscordMessageChannelId(params: {
message: Message;
eventChannelId?: string | number | null;
}): string {
const message = params.message as DiscordMessageWithChannelId;
return (
normalizeDiscordChannelId(message.channelId) ||
normalizeDiscordChannelId(message.channel_id) ||
normalizeDiscordChannelId(message.rawData?.channel_id) ||
normalizeDiscordChannelId(params.eventChannelId)
);
}
export async function resolveDiscordChannelInfo(
client: Client,
channelId: string,
): Promise<DiscordChannelInfo | null> {
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) {
return cached.value;
}
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
}
try {
const channel = await client.fetchChannel(channelId);
if (!channel) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
const channelInfo = resolveDiscordChannelInfoSafe(channel);
const payload: DiscordChannelInfo = {
type: (channelInfo.type as ChannelType | undefined) ?? channel.type,
name: channelInfo.name,
topic: channelInfo.topic,
parentId: channelInfo.parentId,
ownerId: channelInfo.ownerId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
return payload;
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
}

View File

@@ -0,0 +1,363 @@
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { buildPendingHistoryContextFromMap } from "openclaw/plugin-sdk/reply-history";
import { buildAgentSessionKey, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
import { ChannelType } from "../internal/discord.js";
import { normalizeDiscordSlug } from "./allow-list.js";
import { resolveTimestampMs } from "./format.js";
import {
buildDiscordInboundAccessContext,
createDiscordSupplementalContextAccessChecker,
} from "./inbound-context.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import {
buildDiscordMediaPayload,
resolveDiscordMessageText,
type DiscordMediaInfo,
} from "./message-utils.js";
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js";
export async function buildDiscordMessageProcessContext(params: {
ctx: DiscordMessagePreflightContext;
text: string;
mediaList: DiscordMediaInfo[];
}) {
const { ctx, text, mediaList } = params;
const {
cfg,
discordConfig,
accountId,
runtime,
guildHistories,
historyLimit,
replyToMode,
message,
author,
sender,
data,
client,
channelInfo,
channelName,
messageChannelId,
isGuildMessage,
isDirectMessage,
baseText,
preflightAudioTranscript,
threadChannel,
threadParentId,
threadParentName,
threadParentType,
threadName,
displayChannelSlug,
guildInfo,
guildSlug,
memberRoleIds,
channelConfig,
baseSessionKey,
boundSessionKey,
route,
commandAuthorized,
} = ctx;
const fromLabel = isDirectMessage
? buildDirectLabel(author)
: buildGuildLabel({
guild: data.guild ?? undefined,
channelName: channelName ?? messageChannelId,
channelId: messageChannelId,
});
const senderLabel = sender.label;
const isForumParent =
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
const forumParentSlug =
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const threadChannelId = threadChannel?.id;
const threadParentInheritanceEnabled = discordConfig?.thread?.inheritParent ?? false;
const isForumStarter =
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const senderName = sender.isPluralKit
? (sender.name ?? author.username)
: (data.member?.nickname ?? author.globalName ?? author.username);
const senderUsername = sender.isPluralKit
? (sender.tag ?? sender.name ?? author.username)
: author.username;
const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = buildDiscordInboundAccessContext({
channelConfig,
guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
isGuild: isGuildMessage,
channelTopic: channelInfo?.topic,
messageBody: text,
});
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg,
channel: "discord",
accountId,
});
const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig);
const isSupplementalContextSenderAllowed = createDiscordSupplementalContextAccessChecker({
channelConfig,
guildInfo,
allowNameMatching,
isGuild: isGuildMessage,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
let combinedBody = formatInboundEnvelope({
channel: "Discord",
from: fromLabel,
timestamp: resolveTimestampMs(message.timestamp),
body: text,
chatType: isDirectMessage ? "direct" : "channel",
senderLabel,
previousTimestamp,
envelope: envelopeOptions,
});
const shouldIncludeChannelHistory =
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
if (shouldIncludeChannelHistory) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatInboundEnvelope({
channel: "Discord",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${messageChannelId}]`,
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
const replyVisibility = replyContext
? evaluateSupplementalContextVisibility({
mode: contextVisibilityMode,
kind: "quote",
senderAllowed: isSupplementalContextSenderAllowed({
id: replyContext.senderId,
name: replyContext.senderName,
tag: replyContext.senderTag,
memberRoleIds: replyContext.memberRoleIds,
}),
})
: null;
const filteredReplyContext = replyContext && replyVisibility?.include ? replyContext : null;
if (replyContext && !filteredReplyContext && isGuildMessage) {
logVerbose(`discord: drop reply context (mode=${contextVisibilityMode})`);
}
if (forumContextLine) {
combinedBody = `${combinedBody}\n${forumContextLine}`;
}
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
let parentSessionKey: string | undefined;
let modelParentSessionKey: string | undefined;
if (threadChannel) {
const includeThreadStarter = channelConfig?.includeThreadStarter !== false;
if (includeThreadStarter) {
const starter = await resolveDiscordThreadStarter({
channel: threadChannel,
client,
parentId: threadParentId,
parentType: threadParentType,
resolveTimestampMs,
});
if (starter?.text) {
const starterVisibility = evaluateSupplementalContextVisibility({
mode: contextVisibilityMode,
kind: "thread",
senderAllowed: isSupplementalContextSenderAllowed({
id: starter.authorId,
name: starter.authorName ?? starter.author,
tag: starter.authorTag,
memberRoleIds: starter.memberRoleIds,
}),
});
if (starterVisibility.include) {
threadStarterBody = starter.text;
} else {
logVerbose(`discord: drop thread starter context (mode=${contextVisibilityMode})`);
}
}
}
const parentName = threadParentName ?? "parent";
threadLabel = threadName
? `Discord thread #${normalizeDiscordSlug(parentName)} ${threadName}`
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
if (threadParentId) {
parentSessionKey = buildAgentSessionKey({
agentId: route.agentId,
channel: route.channel,
peer: { kind: "channel", id: threadParentId },
});
modelParentSessionKey = parentSessionKey;
}
if (!threadParentInheritanceEnabled) {
parentSessionKey = undefined;
}
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
const preflightAudioIndex =
preflightAudioTranscript === undefined
? -1
: mediaList.findIndex((media) => media.contentType?.startsWith("audio/"));
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: threadChannel ? messageChannelId : undefined,
parentSessionKey,
useSuffix: false,
});
const replyPlan = await resolveDiscordAutoThreadReplyPlan({
client,
message,
messageChannelId,
isGuildMessage,
channelConfig,
threadChannel,
channelType: channelInfo?.type,
channelName: channelInfo?.name,
channelDescription: channelInfo?.topic,
baseText: baseText ?? "",
combinedBody,
replyToMode,
agentId: route.agentId,
channel: route.channel,
cfg,
threadParentInheritanceEnabled,
});
const deliverTarget = replyPlan.deliverTarget;
const replyTarget = replyPlan.replyTarget;
const replyReference = replyPlan.replyReference;
const autoThreadContext = replyPlan.autoThreadContext;
const effectiveFrom = isDirectMessage
? `discord:${author.id}`
: (autoThreadContext?.From ?? `discord:channel:${messageChannelId}`);
const effectiveTo = autoThreadContext?.To ?? replyTarget;
if (!effectiveTo) {
runtime.error?.(danger("discord: missing reply target"));
return null;
}
const dmConversationTarget = isDirectMessage
? resolveDiscordConversationIdentity({
isDirectMessage,
userId: author.id,
})
: undefined;
const lastRouteTo = dmConversationTarget ?? effectiveTo;
const inboundHistory =
shouldIncludeChannelHistory && historyLimit > 0
? (guildHistories.get(messageChannelId) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const originatingTo = autoThreadContext?.OriginatingTo ?? dmConversationTarget ?? replyTarget;
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
BodyForAgent: preflightAudioTranscript ?? baseText ?? text,
InboundHistory: inboundHistory,
RawBody: preflightAudioTranscript ?? baseText,
CommandBody: preflightAudioTranscript ?? baseText,
...(preflightAudioTranscript !== undefined ? { Transcript: preflightAudioTranscript } : {}),
From: effectiveFrom,
To: effectiveTo,
SessionKey: boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: fromLabel,
SenderName: senderName,
SenderId: sender.id,
SenderUsername: senderUsername,
SenderTag: sender.tag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
MemberRoleIds: memberRoleIds,
UntrustedContext: untrustedContext,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
OwnerAllowFrom: ownerAllowFrom,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: ctx.effectiveWasMentioned,
MessageSid: message.id,
ReplyToId: filteredReplyContext?.id,
ReplyToBody: filteredReplyContext?.body,
ReplyToSender: filteredReplyContext?.sender,
ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
ModelParentSessionKey:
autoThreadContext?.ModelParentSessionKey ?? modelParentSessionKey ?? undefined,
MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
...mediaPayload,
...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}),
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "discord" as const,
OriginatingTo: originatingTo,
});
const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
await recordInboundSession({
storePath,
sessionKey: persistedSessionKey,
ctx: ctxPayload,
updateLastRoute: {
sessionKey: persistedSessionKey,
channel: "discord",
to: lastRouteTo,
accountId: route.accountId,
},
onRecordError: (err) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
},
});
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose(
`discord inbound: channel=${messageChannelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
);
}
return {
ctxPayload,
persistedSessionKey,
replyPlan,
deliverTarget,
replyTarget,
replyReference,
};
}

View File

@@ -0,0 +1,120 @@
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
import type { User } from "../internal/discord.js";
import { resolveDiscordDmCommandAccess, type DiscordDmPolicy } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import { formatDiscordUserTag } from "./format.js";
import type {
DiscordMessagePreflightParams,
DiscordSenderIdentity,
} from "./message-handler.preflight.types.js";
let conversationRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/conversation-binding-runtime")>
| undefined;
let discordSendRuntimePromise: Promise<typeof import("../send.js")> | undefined;
async function loadConversationRuntime() {
conversationRuntimePromise ??= import("openclaw/plugin-sdk/conversation-binding-runtime");
return await conversationRuntimePromise;
}
async function loadDiscordSendRuntime() {
discordSendRuntimePromise ??= import("../send.js");
return await discordSendRuntimePromise;
}
export async function resolveDiscordDmPreflightAccess(params: {
preflight: DiscordMessagePreflightParams;
author: User;
sender: DiscordSenderIdentity;
dmPolicy: DiscordDmPolicy;
resolvedAccountId: string;
allowNameMatching: boolean;
useAccessGroups: boolean;
}): Promise<{ commandAuthorized: boolean } | null> {
if (params.dmPolicy === "disabled") {
logVerbose("discord: drop dm (dmPolicy: disabled)");
return null;
}
const directBindingConversationId =
resolveDiscordConversationIdentity({
isDirectMessage: true,
userId: params.author.id,
}) ?? `user:${params.author.id}`;
const directBindingRecord = (await loadConversationRuntime())
.getSessionBindingService()
.resolveByConversation({
channel: "discord",
accountId: params.preflight.accountId,
conversationId: directBindingConversationId,
});
const dmAccess = await resolveDiscordDmCommandAccess({
accountId: params.resolvedAccountId,
dmPolicy: params.dmPolicy,
configuredAllowFrom: params.preflight.allowFrom ?? [],
sender: {
id: params.sender.id,
name: params.sender.name,
tag: params.sender.tag,
},
allowNameMatching: params.allowNameMatching,
useAccessGroups: params.useAccessGroups,
});
const commandAuthorized = dmAccess.commandAuthorized || directBindingRecord != null;
if (dmAccess.decision === "allow") {
return { commandAuthorized };
}
if (directBindingRecord) {
logVerbose(
`discord: allow bound DM conversation ${directBindingConversationId} despite dmPolicy=${params.dmPolicy}`,
);
return { commandAuthorized };
}
const allowMatchMeta = formatAllowlistMatchMeta(
dmAccess.allowMatch.allowed ? dmAccess.allowMatch : undefined,
);
await handleDiscordDmCommandDecision({
dmAccess,
accountId: params.resolvedAccountId,
sender: {
id: params.author.id,
tag: formatDiscordUserTag(params.author),
name: params.author.username ?? undefined,
},
onPairingCreated: async (code) => {
logVerbose(
`discord pairing request sender=${params.author.id} tag=${formatDiscordUserTag(params.author)} (${allowMatchMeta})`,
);
try {
const conversationRuntime = await loadConversationRuntime();
const { sendMessageDiscord } = await loadDiscordSendRuntime();
await sendMessageDiscord(
`user:${params.author.id}`,
conversationRuntime.buildPairingReply({
channel: "discord",
idLine: `Your Discord user id: ${params.author.id}`,
code,
}),
{
cfg: params.preflight.cfg,
token: params.preflight.token,
rest: params.preflight.client.rest,
accountId: params.preflight.accountId,
},
);
} catch (err) {
logVerbose(`discord pairing reply failed for ${params.author.id}: ${String(err)}`);
}
},
onUnauthorized: async () => {
logVerbose(
`Blocked unauthorized discord sender ${params.sender.id} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`,
);
},
});
return null;
}

View File

@@ -0,0 +1,246 @@
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingPreviewToolProgress,
} from "openclaw/plugin-sdk/channel-streaming";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
convertMarkdownTables,
stripInlineDirectiveTagsForDelivery,
stripReasoningTagsFromText,
} from "openclaw/plugin-sdk/text-runtime";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
import { createDiscordDraftStream } from "../draft-stream.js";
import type { RequestClient } from "../internal/discord.js";
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
type DraftReplyReference = {
peek: () => string | undefined;
};
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
export function createDiscordDraftPreviewController(params: {
cfg: OpenClawConfig;
discordConfig: DiscordConfig;
accountId: string;
sourceRepliesAreToolOnly: boolean;
textLimit: number;
deliveryRest: RequestClient;
deliverChannelId: string;
replyReference: DraftReplyReference;
tableMode: Parameters<typeof convertMarkdownTables>[1];
maxLinesPerMessage: number | undefined;
chunkMode: Parameters<typeof chunkDiscordTextWithMode>[1]["chunkMode"];
log: (message: string) => void;
}) {
const discordStreamMode = resolveDiscordPreviewStreamMode(params.discordConfig);
const draftMaxChars = Math.min(params.textLimit, 2000);
const accountBlockStreamingEnabled =
resolveChannelStreamingBlockEnabled(params.discordConfig) ??
params.cfg.agents?.defaults?.blockStreamingDefault === "on";
const canStreamDraft =
!params.sourceRepliesAreToolOnly &&
discordStreamMode !== "off" &&
!accountBlockStreamingEnabled;
const draftStream = canStreamDraft
? createDiscordDraftStream({
rest: params.deliveryRest,
channelId: params.deliverChannelId,
maxChars: draftMaxChars,
replyToMessageId: () => params.replyReference.peek(),
minInitialChars: 30,
throttleMs: 1200,
log: params.log,
warn: params.log,
})
: undefined;
const draftChunking =
draftStream && discordStreamMode === "block"
? resolveDiscordDraftStreamingChunking(params.cfg, params.accountId)
: undefined;
const shouldSplitPreviewMessages = discordStreamMode === "block";
const draftChunker = draftChunking ? new EmbeddedBlockChunker(draftChunking) : undefined;
let lastPartialText = "";
let draftText = "";
let hasStreamedMessage = false;
let finalizedViaPreviewMessage = false;
let finalDeliveryHandled = false;
const previewToolProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
let previewToolProgressSuppressed = false;
let previewToolProgressLines: string[] = [];
const resetProgressState = () => {
lastPartialText = "";
draftText = "";
draftChunker?.reset();
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
};
const forceNewMessageIfNeeded = () => {
if (shouldSplitPreviewMessages && hasStreamedMessage) {
params.log("discord: calling forceNewMessage() for draft stream");
draftStream?.forceNewMessage();
}
resetProgressState();
};
return {
draftStream,
previewToolProgressEnabled,
get finalizedViaPreviewMessage() {
return finalizedViaPreviewMessage;
},
markFinalDeliveryHandled() {
finalDeliveryHandled = true;
},
markPreviewFinalized() {
finalizedViaPreviewMessage = true;
},
disableBlockStreamingForDraft: draftStream ? true : undefined,
pushToolProgress(line?: string) {
if (!draftStream || !previewToolProgressEnabled || previewToolProgressSuppressed) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(-8);
const previewText = [
"Working…",
...previewToolProgressLines.map((entry) => `${entry}`),
].join("\n");
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
},
resolvePreviewFinalText(text?: string) {
if (typeof text !== "string") {
return undefined;
}
const formatted = convertMarkdownTables(
stripInlineDirectiveTagsForDelivery(text).text,
params.tableMode,
);
const chunks = chunkDiscordTextWithMode(formatted, {
maxChars: draftMaxChars,
maxLines: params.maxLinesPerMessage,
chunkMode: params.chunkMode,
});
if (!chunks.length && formatted) {
chunks.push(formatted);
}
if (chunks.length !== 1) {
return undefined;
}
const trimmed = chunks[0].trim();
if (!trimmed) {
return undefined;
}
const currentPreviewText = discordStreamMode === "block" ? draftText : lastPartialText;
if (
currentPreviewText &&
currentPreviewText.startsWith(trimmed) &&
trimmed.length < currentPreviewText.length
) {
return undefined;
}
return trimmed;
},
updateFromPartial(text?: string) {
if (!draftStream || !text) {
return;
}
const cleaned = stripInlineDirectiveTagsForDelivery(
stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }),
).text;
if (!cleaned || cleaned.startsWith("Reasoning:\n")) {
return;
}
if (cleaned === lastPartialText) {
return;
}
previewToolProgressSuppressed = true;
previewToolProgressLines = [];
hasStreamedMessage = true;
if (discordStreamMode === "partial") {
if (
lastPartialText &&
lastPartialText.startsWith(cleaned) &&
cleaned.length < lastPartialText.length
) {
return;
}
lastPartialText = cleaned;
draftStream.update(cleaned);
return;
}
let delta = cleaned;
if (cleaned.startsWith(lastPartialText)) {
delta = cleaned.slice(lastPartialText.length);
} else {
draftChunker?.reset();
draftText = "";
}
lastPartialText = cleaned;
if (!delta) {
return;
}
if (!draftChunker) {
draftText = cleaned;
draftStream.update(draftText);
return;
}
draftChunker.append(delta);
draftChunker.drain({
force: false,
emit: (chunk) => {
draftText += chunk;
draftStream.update(draftText);
},
});
},
handleAssistantMessageBoundary: forceNewMessageIfNeeded,
async flush() {
if (!draftStream) {
return;
}
if (draftChunker?.hasBuffered()) {
draftChunker.drain({
force: true,
emit: (chunk) => {
draftText += chunk;
},
});
draftChunker.reset();
if (draftText) {
draftStream.update(draftText);
}
}
await draftStream.flush();
},
async cleanup() {
try {
if (!finalDeliveryHandled) {
await draftStream?.discardPending();
}
if (!finalDeliveryHandled && !finalizedViaPreviewMessage && draftStream?.messageId()) {
await draftStream.clear();
}
} catch (err) {
params.log(`discord: draft cleanup failed: ${String(err)}`);
}
},
};
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { Message } from "../internal/discord.js";
import {
createFakeRestClient,
createInternalTestClient,
} from "../internal/test-builders.test-support.js";
import { hydrateDiscordMessageIfNeeded } from "./message-handler.hydration.js";
describe("hydrateDiscordMessageIfNeeded", () => {
it("hydrates partial internal messages without assigning over getters", async () => {
const client = createInternalTestClient();
const rest = createFakeRestClient([
{
id: "m1",
channel_id: "c1",
content: "hello <@u2>",
attachments: [{ id: "a1", filename: "note.txt" }],
embeds: [{ title: "Embed" }],
mentions: [
{
id: "u2",
username: "bob",
global_name: "Bob Builder",
discriminator: "0",
avatar: null,
},
],
mention_roles: ["role1"],
mention_everyone: false,
timestamp: new Date().toISOString(),
author: {
id: "u1",
username: "alice",
discriminator: "0",
avatar: null,
},
referenced_message: {
id: "m0",
channel_id: "c1",
content: "earlier",
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
mention_everyone: false,
timestamp: new Date().toISOString(),
author: {
id: "u3",
username: "carol",
discriminator: "0",
avatar: null,
},
type: 0,
tts: false,
pinned: false,
flags: 0,
},
type: 0,
tts: false,
pinned: false,
flags: 0,
},
]);
const message = new Message<true>(client, { id: "m1", channelId: "c1" }) as unknown as Message;
const hydrated = await hydrateDiscordMessageIfNeeded({
client: { rest },
message,
messageChannelId: "c1",
});
expect(hydrated).toBeInstanceOf(Message);
expect(hydrated.content).toBe("hello <@u2>");
expect(hydrated.attachments).toHaveLength(1);
expect(hydrated.embeds).toHaveLength(1);
expect(hydrated.mentionedUsers[0]?.globalName).toBe("Bob Builder");
expect(hydrated.mentionedRoles).toEqual(["role1"]);
expect(hydrated.referencedMessage?.content).toBe("earlier");
});
});

View File

@@ -0,0 +1,198 @@
import type { APIMessage, APIUser } from "discord-api-types/v10";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getChannelMessage, Message as DiscordMessage, type Message } from "../internal/discord.js";
import { resolveDiscordMessageText, type DiscordChannelInfo } from "./message-utils.js";
function mergeFetchedDiscordMessage(base: Message, fetched: APIMessage): Message {
const baseRawData = readMessageRawData(base);
const baseFallback = readMessageFallback(base);
const rawData = {
...baseRawData,
...fetched,
id: fetched.id ?? baseRawData.id ?? baseFallback.id,
channel_id: fetched.channel_id ?? baseRawData.channel_id ?? baseFallback.channel_id,
content: fetched.content ?? baseRawData.content ?? baseFallback.content,
author: fetched.author ?? baseRawData.author ?? baseFallback.author,
attachments: fetched.attachments ?? baseRawData.attachments ?? baseFallback.attachments,
embeds: fetched.embeds ?? baseRawData.embeds ?? baseFallback.embeds,
mentions: fetched.mentions ?? baseRawData.mentions ?? baseFallback.mentions,
mention_roles: fetched.mention_roles ?? baseRawData.mention_roles ?? baseFallback.mention_roles,
mention_everyone:
fetched.mention_everyone ?? baseRawData.mention_everyone ?? baseFallback.mention_everyone,
timestamp: fetched.timestamp ?? baseRawData.timestamp ?? baseFallback.timestamp,
tts: fetched.tts ?? baseRawData.tts ?? false,
pinned: fetched.pinned ?? baseRawData.pinned ?? false,
type: fetched.type ?? baseRawData.type ?? 0,
message_snapshots:
fetched.message_snapshots ?? baseRawData.message_snapshots ?? baseFallback.message_snapshots,
sticker_items:
(fetched as { sticker_items?: unknown }).sticker_items ??
(baseRawData as { sticker_items?: unknown }).sticker_items ??
baseFallback.sticker_items,
} as APIMessage;
const hydrated = new DiscordMessage(readMessageClient(base), rawData);
copyRuntimeMessageFields(base, hydrated);
return hydrated;
}
function readMessageClient(message: Message): ConstructorParameters<typeof DiscordMessage>[0] {
return (message as unknown as { client: ConstructorParameters<typeof DiscordMessage>[0] }).client;
}
function readMessageRawData(message: Message): Partial<APIMessage> {
try {
const rawData = message.rawData as APIMessage | undefined;
return rawData && typeof rawData === "object" ? rawData : {};
} catch {
return {};
}
}
type MessageFallback = Partial<Omit<APIMessage, "message_snapshots" | "sticker_items">> & {
channel_id: string;
sticker_items?: APIMessage["sticker_items"];
message_snapshots?: APIMessage["message_snapshots"];
};
function readMessageFallback(message: Message): MessageFallback {
const value = message as unknown as {
id?: unknown;
channelId?: unknown;
channel_id?: unknown;
content?: unknown;
author?: unknown;
attachments?: unknown;
embeds?: unknown;
mentionedUsers?: unknown;
mentionedRoles?: unknown;
mentionedEveryone?: unknown;
timestamp?: unknown;
stickers?: unknown;
sticker_items?: unknown;
message_snapshots?: unknown;
};
return {
id: typeof value.id === "string" ? value.id : "",
channel_id: readString(value.channel_id) ?? readString(value.channelId) ?? "",
content: typeof value.content === "string" ? value.content : "",
author: normalizeApiUser(value.author),
attachments: Array.isArray(value.attachments) ? value.attachments : [],
embeds: Array.isArray(value.embeds) ? value.embeds : [],
mentions: normalizeApiUsers(value.mentionedUsers),
mention_roles: normalizeStringArray(value.mentionedRoles),
mention_everyone: value.mentionedEveryone === true,
timestamp: readString(value.timestamp) ?? "1970-01-01T00:00:00.000Z",
sticker_items: Array.isArray(value.sticker_items)
? (value.sticker_items as APIMessage["sticker_items"])
: Array.isArray(value.stickers)
? (value.stickers as APIMessage["sticker_items"])
: undefined,
message_snapshots: Array.isArray(value.message_snapshots)
? (value.message_snapshots as APIMessage["message_snapshots"])
: undefined,
};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.flatMap((entry) => (typeof entry === "string" ? [entry] : []))
: [];
}
function normalizeApiUsers(value: unknown): APIUser[] {
return Array.isArray(value)
? value.flatMap((entry) => {
const user = normalizeApiUser(entry);
return user.id ? [user] : [];
})
: [];
}
function normalizeApiUser(value: unknown): APIUser {
if (!value || typeof value !== "object") {
return {
id: "",
username: "",
discriminator: "0",
global_name: null,
avatar: null,
};
}
const input = value as {
id?: unknown;
username?: unknown;
global_name?: unknown;
globalName?: unknown;
discriminator?: unknown;
avatar?: unknown;
bot?: unknown;
};
return {
id: readString(input.id) ?? "",
username: readString(input.username) ?? "",
discriminator: readString(input.discriminator) ?? "0",
global_name: readString(input.global_name) ?? readString(input.globalName) ?? null,
avatar: input.avatar === null ? null : (readString(input.avatar) ?? null),
...(typeof input.bot === "boolean" ? { bot: input.bot } : {}),
};
}
function copyRuntimeMessageFields(source: Message, target: Message): void {
const channelDescriptor = Object.getOwnPropertyDescriptor(source, "channel");
if (channelDescriptor) {
Object.defineProperty(target, "channel", channelDescriptor);
}
}
function shouldHydrateDiscordMessage(params: { message: Message }) {
let currentText = "";
try {
currentText = resolveDiscordMessageText(params.message, {
includeForwarded: true,
});
} catch {
return true;
}
if (!currentText) {
return true;
}
const hasMentionMetadata =
(params.message.mentionedUsers?.length ?? 0) > 0 ||
(params.message.mentionedRoles?.length ?? 0) > 0 ||
params.message.mentionedEveryone;
if (hasMentionMetadata) {
return false;
}
return /<@!?\d+>|<@&\d+>|@everyone|@here/u.test(currentText);
}
export async function hydrateDiscordMessageIfNeeded(params: {
client: { rest: Parameters<typeof getChannelMessage>[0] };
message: Message;
messageChannelId: string;
channelInfo?: DiscordChannelInfo | null;
}): Promise<Message> {
void params.channelInfo;
if (!shouldHydrateDiscordMessage({ message: params.message })) {
return params.message;
}
try {
const fetched = (await getChannelMessage(
params.client.rest,
params.messageChannelId,
params.message.id,
)) as APIMessage | null | undefined;
if (!fetched) {
return params.message;
}
logVerbose(`discord: hydrated inbound payload via REST for ${params.message.id}`);
return mergeFetchedDiscordMessage(params.message, fetched);
} catch (err) {
logVerbose(`discord: failed to hydrate message ${params.message.id}: ${String(err)}`);
return params.message;
}
}

View File

@@ -0,0 +1,161 @@
import {
implicitMentionKindWhen,
matchesMentionWithExplicit,
} from "openclaw/plugin-sdk/channel-inbound";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { ChannelType, type Message } from "../internal/discord.js";
import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js";
import type { DiscordChannelInfo } from "./message-utils.js";
import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js";
const DISCORD_BOUND_THREAD_SYSTEM_PREFIXES = ["⚙️", "🤖", "🧰"];
export function isBoundThreadBotSystemMessage(params: {
isBoundThreadSession: boolean;
isBotAuthor: boolean;
text?: string;
}): boolean {
if (!params.isBoundThreadSession || !params.isBotAuthor) {
return false;
}
const text = params.text?.trim();
if (!text) {
return false;
}
return DISCORD_BOUND_THREAD_SYSTEM_PREFIXES.some((prefix) => text.startsWith(prefix));
}
export type BoundThreadLookupRecordLike = {
webhookId?: string | null;
metadata?: {
webhookId?: string | null;
};
};
function isDiscordThreadChannelType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
export function isDiscordThreadChannelMessage(params: {
isGuildMessage: boolean;
message: Message;
channelInfo: DiscordChannelInfo | null;
}): boolean {
if (!params.isGuildMessage) {
return false;
}
const channel =
"channel" in params.message ? (params.message as { channel?: unknown }).channel : undefined;
return Boolean(
(channel &&
typeof channel === "object" &&
"isThread" in channel &&
typeof (channel as { isThread?: unknown }).isThread === "function" &&
(channel as { isThread: () => boolean }).isThread()) ||
isDiscordThreadChannelType(params.channelInfo?.type),
);
}
export function resolveInjectedBoundThreadLookupRecord(params: {
threadBindings: DiscordMessagePreflightParams["threadBindings"];
threadId: string;
}): BoundThreadLookupRecordLike | undefined {
const getByThreadId = (params.threadBindings as { getByThreadId?: (threadId: string) => unknown })
.getByThreadId;
if (typeof getByThreadId !== "function") {
return undefined;
}
const binding = getByThreadId(params.threadId);
return binding && typeof binding === "object"
? (binding as BoundThreadLookupRecordLike)
: undefined;
}
export function resolveDiscordMentionState(params: {
authorIsBot: boolean;
botId?: string;
hasAnyMention: boolean;
isDirectMessage: boolean;
isExplicitlyMentioned: boolean;
mentionRegexes: RegExp[];
mentionText: string;
mentionedEveryone: boolean;
referencedAuthorId?: string;
senderIsPluralKit: boolean;
transcript?: string;
}) {
if (params.isDirectMessage) {
return {
implicitMentionKinds: [],
wasMentioned: false,
};
}
const everyoneMentioned =
params.mentionedEveryone && (!params.authorIsBot || params.senderIsPluralKit);
const wasMentioned =
everyoneMentioned ||
matchesMentionWithExplicit({
text: params.mentionText,
mentionRegexes: params.mentionRegexes,
explicit: {
hasAnyMention: params.hasAnyMention,
isExplicitlyMentioned: params.isExplicitlyMentioned,
canResolveExplicit: Boolean(params.botId),
},
transcript: params.transcript,
});
const implicitMentionKinds = implicitMentionKindWhen(
"reply_to_bot",
Boolean(params.botId) &&
Boolean(params.referencedAuthorId) &&
params.referencedAuthorId === params.botId,
);
return {
implicitMentionKinds,
wasMentioned,
};
}
export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean;
bypassMentionRequirement: boolean;
}): boolean {
if (!params.shouldRequireMention) {
return false;
}
return !params.bypassMentionRequirement;
}
export function shouldIgnoreBoundThreadWebhookMessage(params: {
accountId?: string;
threadId?: string;
webhookId?: string | null;
threadBinding?: BoundThreadLookupRecordLike;
}): boolean {
const webhookId = normalizeOptionalString(params.webhookId) ?? "";
if (!webhookId) {
return false;
}
const boundWebhookId =
normalizeOptionalString(params.threadBinding?.webhookId) ??
normalizeOptionalString(params.threadBinding?.metadata?.webhookId) ??
"";
if (!boundWebhookId) {
const threadId = normalizeOptionalString(params.threadId) ?? "";
if (!threadId) {
return false;
}
return isRecentlyUnboundThreadWebhookMessage({
accountId: params.accountId,
threadId,
webhookId,
});
}
return webhookId === boundWebhookId;
}

View File

@@ -1,11 +1,11 @@
import { ChannelType } from "@buape/carbon";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { ChannelType } from "../internal/discord.js";
import type { preflightDiscordMessage } from "./message-handler.preflight.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
export type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
export type DiscordClient = import("@buape/carbon").Client;
export type DiscordClient = import("../internal/discord.js").Client;
export const DEFAULT_PREFLIGHT_CFG = {
session: {
@@ -32,8 +32,8 @@ export function createGuildTextClient(channelId: string): DiscordClient {
export function createGuildEvent(params: {
channelId: string;
guildId: string;
author: import("@buape/carbon").Message["author"];
message: import("@buape/carbon").Message;
author: import("../internal/discord.js").Message["author"];
message: import("../internal/discord.js").Message;
includeGuildObject?: boolean;
}): DiscordMessageEvent {
return {
@@ -64,7 +64,7 @@ export function createDiscordMessage(params: {
mentionedUsers?: Array<{ id: string }>;
mentionedEveryone?: boolean;
attachments?: Array<Record<string, unknown>>;
}): import("@buape/carbon").Message {
}): import("../internal/discord.js").Message {
return {
id: params.id,
content: params.content,
@@ -75,7 +75,7 @@ export function createDiscordMessage(params: {
mentionedRoles: [],
mentionedEveryone: params.mentionedEveryone ?? false,
author: params.author,
} as unknown as import("@buape/carbon").Message;
} as unknown as import("../internal/discord.js").Message;
}
export function createDiscordPreflightArgs(params: {

View File

@@ -1,5 +1,5 @@
import { ChannelType } from "@buape/carbon";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType } from "../internal/discord.js";
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
@@ -120,7 +120,7 @@ function createDmClient(channelId: string): DiscordClient {
async function runThreadBoundPreflight(params: {
threadId: string;
parentId: string;
message: import("@buape/carbon").Message;
message: import("../internal/discord.js").Message;
threadBinding: import("openclaw/plugin-sdk/conversation-runtime").SessionBindingRecord;
discordConfig: DiscordConfig;
registerBindingAdapter?: boolean;
@@ -161,7 +161,7 @@ async function runThreadBoundPreflight(params: {
async function runGuildPreflight(params: {
channelId: string;
guildId: string;
message: import("@buape/carbon").Message;
message: import("../internal/discord.js").Message;
discordConfig: DiscordConfig;
cfg?: import("openclaw/plugin-sdk/config-types").OpenClawConfig;
guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"];
@@ -186,7 +186,7 @@ async function runGuildPreflight(params: {
async function runDmPreflight(params: {
channelId: string;
message: import("@buape/carbon").Message;
message: import("../internal/discord.js").Message;
discordConfig: DiscordConfig;
}) {
return preflightDiscordMessage({
@@ -206,7 +206,7 @@ async function runDmPreflight(params: {
async function runMentionOnlyBotPreflight(params: {
channelId: string;
guildId: string;
message: import("@buape/carbon").Message;
message: import("../internal/discord.js").Message;
}) {
return runGuildPreflight({
channelId: params.channelId,
@@ -221,7 +221,7 @@ async function runMentionOnlyBotPreflight(params: {
async function runIgnoreOtherMentionsPreflight(params: {
channelId: string;
guildId: string;
message: import("@buape/carbon").Message;
message: import("../internal/discord.js").Message;
}) {
return runGuildPreflight({
channelId: params.channelId,

View File

@@ -1,18 +1,13 @@
import { ChannelType, MessageType, type Message, type User } from "@buape/carbon";
import { Routes, type APIMessage } from "discord-api-types/v10";
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import {
buildMentionRegexes,
implicitMentionKindWhen,
logInboundDrop,
matchesMentionWithExplicit,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-binding-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import {
recordPendingHistoryEntryIfEnabled,
@@ -20,9 +15,9 @@ import {
} from "openclaw/plugin-sdk/reply-history";
import { getChildLogger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { logDebug, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { logDebug } from "openclaw/plugin-sdk/text-runtime";
import { resolveDefaultDiscordAccountId } from "../accounts.js";
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
import { ChannelType, MessageType, type User } from "../internal/discord.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordSlug,
@@ -34,62 +29,49 @@ import {
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordChannelInfoSafe, resolveDiscordChannelNameSafe } from "./channel-access.js";
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import { resolveDiscordSystemLocation, resolveTimestampMs } from "./format.js";
import { resolveDiscordDmPreflightAccess } from "./message-handler.dm-preflight.js";
import { hydrateDiscordMessageIfNeeded } from "./message-handler.hydration.js";
import {
formatDiscordUserTag,
resolveDiscordSystemLocation,
resolveTimestampMs,
} from "./format.js";
isBoundThreadBotSystemMessage,
isDiscordThreadChannelMessage,
resolveDiscordMentionState,
resolveInjectedBoundThreadLookupRecord,
resolvePreflightMentionRequirement,
shouldIgnoreBoundThreadWebhookMessage,
} from "./message-handler.preflight-helpers.js";
import type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
import { resolveDiscordPreflightRoute } from "./message-handler.routing-preflight.js";
import {
resolveDiscordChannelInfo,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
} from "./message-utils.js";
import {
buildDiscordRoutePeer,
resolveDiscordConversationRoute,
resolveDiscordEffectiveRoute,
shouldIgnoreStaleDiscordRouteBinding,
} from "./route-resolution.js";
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js";
export type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
const DISCORD_BOUND_THREAD_SYSTEM_PREFIXES = ["⚙️", "🤖", "🧰"];
export {
resolvePreflightMentionRequirement,
shouldIgnoreBoundThreadWebhookMessage,
} from "./message-handler.preflight-helpers.js";
let conversationRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/conversation-binding-runtime")>
| undefined;
let pluralkitRuntimePromise: Promise<typeof import("../pluralkit.js")> | undefined;
let discordSendRuntimePromise: Promise<typeof import("../send.js")> | undefined;
let preflightAudioRuntimePromise: Promise<typeof import("./preflight-audio.js")> | undefined;
let systemEventsRuntimePromise: Promise<typeof import("./system-events.js")> | undefined;
let discordThreadingRuntimePromise: Promise<typeof import("./threading.js")> | undefined;
async function loadConversationRuntime() {
conversationRuntimePromise ??= import("openclaw/plugin-sdk/conversation-binding-runtime");
return await conversationRuntimePromise;
}
async function loadPluralKitRuntime() {
pluralkitRuntimePromise ??= import("../pluralkit.js");
return await pluralkitRuntimePromise;
}
async function loadDiscordSendRuntime() {
discordSendRuntimePromise ??= import("../send.js");
return await discordSendRuntimePromise;
}
async function loadPreflightAudioRuntime() {
preflightAudioRuntimePromise ??= import("./preflight-audio.js");
return await preflightAudioRuntimePromise;
@@ -109,267 +91,6 @@ function isPreflightAborted(abortSignal?: AbortSignal): boolean {
return Boolean(abortSignal?.aborted);
}
function isBoundThreadBotSystemMessage(params: {
isBoundThreadSession: boolean;
isBotAuthor: boolean;
text?: string;
}): boolean {
if (!params.isBoundThreadSession || !params.isBotAuthor) {
return false;
}
const text = params.text?.trim();
if (!text) {
return false;
}
return DISCORD_BOUND_THREAD_SYSTEM_PREFIXES.some((prefix) => text.startsWith(prefix));
}
type BoundThreadLookupRecordLike = {
webhookId?: string | null;
metadata?: {
webhookId?: string | null;
};
};
function isDiscordThreadChannelType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
function isDiscordThreadChannelMessage(params: {
isGuildMessage: boolean;
message: Message;
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
}): boolean {
if (!params.isGuildMessage) {
return false;
}
const channel =
"channel" in params.message ? (params.message as { channel?: unknown }).channel : undefined;
return Boolean(
(channel &&
typeof channel === "object" &&
"isThread" in channel &&
typeof (channel as { isThread?: unknown }).isThread === "function" &&
(channel as { isThread: () => boolean }).isThread()) ||
isDiscordThreadChannelType(params.channelInfo?.type),
);
}
function resolveInjectedBoundThreadLookupRecord(params: {
threadBindings: DiscordMessagePreflightParams["threadBindings"];
threadId: string;
}): BoundThreadLookupRecordLike | undefined {
const getByThreadId = (params.threadBindings as { getByThreadId?: (threadId: string) => unknown })
.getByThreadId;
if (typeof getByThreadId !== "function") {
return undefined;
}
const binding = getByThreadId(params.threadId);
return binding && typeof binding === "object"
? (binding as BoundThreadLookupRecordLike)
: undefined;
}
function resolveDiscordMentionState(params: {
authorIsBot: boolean;
botId?: string;
hasAnyMention: boolean;
isDirectMessage: boolean;
isExplicitlyMentioned: boolean;
mentionRegexes: RegExp[];
mentionText: string;
mentionedEveryone: boolean;
referencedAuthorId?: string;
senderIsPluralKit: boolean;
transcript?: string;
}) {
if (params.isDirectMessage) {
return {
implicitMentionKinds: [],
wasMentioned: false,
};
}
const everyoneMentioned =
params.mentionedEveryone && (!params.authorIsBot || params.senderIsPluralKit);
const wasMentioned =
everyoneMentioned ||
matchesMentionWithExplicit({
text: params.mentionText,
mentionRegexes: params.mentionRegexes,
explicit: {
hasAnyMention: params.hasAnyMention,
isExplicitlyMentioned: params.isExplicitlyMentioned,
canResolveExplicit: Boolean(params.botId),
},
transcript: params.transcript,
});
const implicitMentionKinds = implicitMentionKindWhen(
"reply_to_bot",
Boolean(params.botId) &&
Boolean(params.referencedAuthorId) &&
params.referencedAuthorId === params.botId,
);
return {
implicitMentionKinds,
wasMentioned,
};
}
export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean;
bypassMentionRequirement: boolean;
}): boolean {
if (!params.shouldRequireMention) {
return false;
}
return !params.bypassMentionRequirement;
}
export function shouldIgnoreBoundThreadWebhookMessage(params: {
accountId?: string;
threadId?: string;
webhookId?: string | null;
threadBinding?: BoundThreadLookupRecordLike;
}): boolean {
const webhookId = normalizeOptionalString(params.webhookId) ?? "";
if (!webhookId) {
return false;
}
const boundWebhookId =
normalizeOptionalString(params.threadBinding?.webhookId) ??
normalizeOptionalString(params.threadBinding?.metadata?.webhookId) ??
"";
if (!boundWebhookId) {
const threadId = normalizeOptionalString(params.threadId) ?? "";
if (!threadId) {
return false;
}
return isRecentlyUnboundThreadWebhookMessage({
accountId: params.accountId,
threadId,
webhookId,
});
}
return webhookId === boundWebhookId;
}
function mergeFetchedDiscordMessage(base: Message, fetched: APIMessage): Message {
const baseReferenced = (
base as unknown as {
referencedMessage?: {
mentionedUsers?: unknown[];
mentionedRoles?: unknown[];
mentionedEveryone?: boolean;
};
}
).referencedMessage;
const fetchedMentions = Array.isArray(fetched.mentions)
? fetched.mentions.map((mention) => ({
...mention,
globalName: mention.global_name ?? undefined,
}))
: undefined;
const assignWithPrototype = <T extends object>(baseObject: T, ...sources: object[]): T =>
Object.assign(
Object.create(Object.getPrototypeOf(baseObject) ?? Object.prototype),
baseObject,
...sources,
) as T;
const referencedMessage = fetched.referenced_message
? assignWithPrototype(
((base as { referencedMessage?: Message }).referencedMessage ?? {}) as Message,
fetched.referenced_message,
{
mentionedUsers: Array.isArray(fetched.referenced_message.mentions)
? fetched.referenced_message.mentions.map((mention) => ({
...mention,
globalName: mention.global_name ?? undefined,
}))
: (baseReferenced?.mentionedUsers ?? []),
mentionedRoles:
fetched.referenced_message.mention_roles ?? baseReferenced?.mentionedRoles ?? [],
mentionedEveryone:
fetched.referenced_message.mention_everyone ??
baseReferenced?.mentionedEveryone ??
false,
} satisfies Record<string, unknown>,
)
: (base as { referencedMessage?: Message }).referencedMessage;
const baseRawData = (base as { rawData?: Record<string, unknown> }).rawData;
const rawData = {
...(base as { rawData?: Record<string, unknown> }).rawData,
message_snapshots:
fetched.message_snapshots ??
(base as { rawData?: { message_snapshots?: unknown } }).rawData?.message_snapshots,
sticker_items:
(fetched as { sticker_items?: unknown }).sticker_items ?? baseRawData?.sticker_items,
};
return assignWithPrototype(base, fetched, {
content: fetched.content ?? base.content,
attachments: fetched.attachments ?? base.attachments,
embeds: fetched.embeds ?? base.embeds,
stickers:
(fetched as { stickers?: unknown }).stickers ??
(fetched as { sticker_items?: unknown }).sticker_items ??
base.stickers,
mentionedUsers: fetchedMentions ?? base.mentionedUsers,
mentionedRoles: fetched.mention_roles ?? base.mentionedRoles,
mentionedEveryone: fetched.mention_everyone ?? base.mentionedEveryone,
referencedMessage,
rawData,
}) as unknown as Message;
}
function shouldHydrateDiscordMessage(params: { message: Message }) {
const currentText = resolveDiscordMessageText(params.message, {
includeForwarded: true,
});
if (!currentText) {
return true;
}
const hasMentionMetadata =
(params.message.mentionedUsers?.length ?? 0) > 0 ||
(params.message.mentionedRoles?.length ?? 0) > 0 ||
params.message.mentionedEveryone;
if (hasMentionMetadata) {
return false;
}
return /<@!?\d+>|<@&\d+>|@everyone|@here/u.test(currentText);
}
async function hydrateDiscordMessageIfNeeded(params: {
client: DiscordMessagePreflightParams["client"];
message: Message;
messageChannelId: string;
}): Promise<Message> {
if (!shouldHydrateDiscordMessage({ message: params.message })) {
return params.message;
}
const rest = params.client.rest as { get?: (route: string) => Promise<unknown> } | undefined;
if (typeof rest?.get !== "function") {
return params.message;
}
try {
const fetched = (await rest.get(
Routes.channelMessage(params.messageChannelId, params.message.id),
)) as APIMessage | null | undefined;
if (!fetched) {
return params.message;
}
logVerbose(`discord: hydrated inbound payload via REST for ${params.message.id}`);
return mergeFetchedDiscordMessage(params.message, fetched);
} catch (err) {
logVerbose(`discord: failed to hydrate message ${params.message.id}: ${String(err)}`);
return params.message;
}
}
export async function preflightDiscordMessage(
params: DiscordMessagePreflightParams,
): Promise<DiscordMessagePreflightContext | null> {
@@ -505,71 +226,22 @@ export async function preflightDiscordMessage(
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
let commandAuthorized = true;
if (isDirectMessage) {
if (dmPolicy === "disabled") {
logVerbose("discord: drop dm (dmPolicy: disabled)");
return null;
}
const dmAccess = await resolveDiscordDmCommandAccess({
accountId: resolvedAccountId,
const access = await resolveDiscordDmPreflightAccess({
preflight: params,
author,
sender,
dmPolicy,
configuredAllowFrom: params.allowFrom ?? [],
sender: {
id: sender.id,
name: sender.name,
tag: sender.tag,
},
resolvedAccountId,
allowNameMatching,
useAccessGroups,
});
if (isPreflightAborted(params.abortSignal)) {
return null;
}
commandAuthorized = dmAccess.commandAuthorized;
if (dmAccess.decision !== "allow") {
const allowMatchMeta = formatAllowlistMatchMeta(
dmAccess.allowMatch.allowed ? dmAccess.allowMatch : undefined,
);
await handleDiscordDmCommandDecision({
dmAccess,
accountId: resolvedAccountId,
sender: {
id: author.id,
tag: formatDiscordUserTag(author),
name: author.username ?? undefined,
},
onPairingCreated: async (code) => {
logVerbose(
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`,
);
try {
const conversationRuntime = await loadConversationRuntime();
const { sendMessageDiscord } = await loadDiscordSendRuntime();
await sendMessageDiscord(
`user:${author.id}`,
conversationRuntime.buildPairingReply({
channel: "discord",
idLine: `Your Discord user id: ${author.id}`,
code,
}),
{
cfg: params.cfg,
token: params.token,
rest: params.client.rest,
accountId: params.accountId,
},
);
} catch (err) {
logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`);
}
},
onUnauthorized: async () => {
logVerbose(
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
},
});
if (!access) {
return null;
}
commandAuthorized = access.commandAuthorized;
}
const botId = params.botUserId;
@@ -593,7 +265,11 @@ export async function preflightDiscordMessage(
// Resolve thread parent early for binding inheritance
const channelName =
channelInfo?.name ??
(isGuildMessage || isGroupDm ? resolveDiscordChannelNameSafe(message.channel) : undefined);
(isGuildMessage || isGroupDm
? resolveDiscordChannelNameSafe(
"channel" in message ? (message as { channel?: unknown }).channel : undefined,
)
: undefined);
const { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } =
await loadDiscordThreadingRuntime();
const earlyThreadChannel = resolveDiscordThreadChannel({
@@ -624,68 +300,24 @@ export async function preflightDiscordMessage(
const memberRoleIds = Array.isArray(params.data.rawMember?.roles)
? params.data.rawMember.roles
: [];
const conversationRuntime = await loadConversationRuntime();
const route = resolveDiscordConversationRoute({
cfg: params.cfg,
accountId: params.accountId,
guildId: params.data.guild_id ?? undefined,
const routeState = await resolveDiscordPreflightRoute({
preflight: params,
author,
isDirectMessage,
isGroupDm,
messageChannelId,
memberRoleIds,
peer: buildDiscordRoutePeer({
isDirectMessage,
isGroupDm,
directUserId: author.id,
conversationId: messageChannelId,
}),
parentConversationId: earlyThreadParentId,
earlyThreadParentId,
});
const bindingConversationId = isDirectMessage
? (resolveDiscordConversationIdentity({
isDirectMessage,
userId: author.id,
}) ?? `user:${author.id}`)
: messageChannelId;
let threadBinding: SessionBindingRecord | undefined;
let runtimeRoute = conversationRuntime.resolveRuntimeConversationBindingRoute({
route,
conversation: {
channel: "discord",
accountId: params.accountId,
conversationId: bindingConversationId,
parentConversationId: earlyThreadParentId,
},
});
if (
shouldIgnoreStaleDiscordRouteBinding({
bindingRecord: runtimeRoute.bindingRecord,
route,
})
) {
logVerbose(
`discord: ignoring stale route binding for conversation ${bindingConversationId} (${runtimeRoute.bindingRecord?.targetSessionKey} -> ${route.sessionKey})`,
);
runtimeRoute = {
bindingRecord: null,
route,
};
}
threadBinding = runtimeRoute.bindingRecord ?? undefined;
const configuredRoute =
threadBinding == null
? conversationRuntime.resolveConfiguredBindingRoute({
cfg: params.cfg,
route,
conversation: {
channel: "discord",
accountId: params.accountId,
conversationId: messageChannelId,
parentConversationId: earlyThreadParentId,
},
})
: null;
const configuredBinding = configuredRoute?.bindingResolution ?? null;
if (!threadBinding && configuredBinding) {
threadBinding = configuredBinding.record;
}
const {
conversationRuntime,
threadBinding,
configuredBinding,
boundSessionKey,
effectiveRoute,
boundAgentId,
baseSessionKey,
} = routeState;
if (
shouldIgnoreBoundThreadWebhookMessage({
accountId: params.accountId,
@@ -697,18 +329,6 @@ export async function preflightDiscordMessage(
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
return null;
}
const boundSessionKey = conversationRuntime.isPluginOwnedSessionBindingRecord(threadBinding)
? ""
: (runtimeRoute.boundSessionKey ?? threadBinding?.targetSessionKey?.trim());
const effectiveRoute = runtimeRoute.boundSessionKey
? runtimeRoute.route
: resolveDiscordEffectiveRoute({
route,
boundSessionKey,
configuredRoute,
matchedBy: "binding.channel",
});
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
const bypassMentionRequirement = isBoundThreadSession;
if (
@@ -725,12 +345,11 @@ export async function preflightDiscordMessage(
const explicitlyMentioned = Boolean(
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
);
const hasAnyMention = Boolean(
const hasAnyMention =
!isDirectMessage &&
((message.mentionedUsers?.length ?? 0) > 0 ||
(message.mentionedRoles?.length ?? 0) > 0 ||
(message.mentionedEveryone && (!author.bot || sender.isPluralKit))),
);
(message.mentionedEveryone && (!author.bot || sender.isPluralKit)));
const hasUserOrRoleMention =
!isDirectMessage &&
((message.mentionedUsers?.length ?? 0) > 0 || (message.mentionedRoles?.length ?? 0) > 0);
@@ -786,7 +405,6 @@ export async function preflightDiscordMessage(
const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const baseSessionKey = effectiveRoute.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfigWithFallback({
guildInfo,
@@ -938,7 +556,7 @@ export async function preflightDiscordMessage(
isExplicitlyMentioned: explicitlyMentioned,
mentionRegexes,
mentionText,
mentionedEveryone: Boolean(message.mentionedEveryone),
mentionedEveryone: message.mentionedEveryone,
referencedAuthorId: message.referencedMessage?.author?.id,
senderIsPluralKit: sender.isPluralKit,
transcript: preflightTranscript,

View File

@@ -1,8 +1,8 @@
import type { ChannelType, Client, User } from "@buape/carbon";
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types";
import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import type { ChannelType, Client, User } from "../internal/discord.js";
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
import type { DiscordChannelInfo } from "./message-utils.js";
import type { DiscordThreadBindingLookup } from "./reply-delivery.js";

Some files were not shown because too many files have changed in this diff Show More