mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
refactor(discord): internalize discord client
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -52,8 +52,10 @@ export {
|
||||
formatDiscordComponentEventText,
|
||||
parseDiscordComponentCustomId,
|
||||
parseDiscordComponentCustomIdForCarbon,
|
||||
parseDiscordComponentCustomIdForInteraction,
|
||||
parseDiscordModalCustomId,
|
||||
parseDiscordModalCustomIdForCarbon,
|
||||
parseDiscordModalCustomIdForInteraction,
|
||||
readDiscordComponentSpec,
|
||||
resolveDiscordComponentAttachmentName,
|
||||
} from "./src/components.js";
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
421
extensions/discord/src/components.builders.ts
Normal file
421
extensions/discord/src/components.builders.ts
Normal 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;
|
||||
}
|
||||
124
extensions/discord/src/components.modal.ts
Normal file
124
extensions/discord/src/components.modal.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
418
extensions/discord/src/components.parse.ts
Normal file
418
extensions/discord/src/components.parse.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
51
extensions/discord/src/internal/api.commands.ts
Normal file
51
extensions/discord/src/internal/api.commands.ts
Normal 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 });
|
||||
}
|
||||
164
extensions/discord/src/internal/api.guild.ts
Normal file
164
extensions/discord/src/internal/api.guild.ts
Normal 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);
|
||||
}
|
||||
53
extensions/discord/src/internal/api.interactions.ts
Normal file
53
extensions/discord/src/internal/api.interactions.ts
Normal 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);
|
||||
}
|
||||
113
extensions/discord/src/internal/api.messages.ts
Normal file
113
extensions/discord/src/internal/api.messages.ts
Normal 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()}`);
|
||||
}
|
||||
38
extensions/discord/src/internal/api.reactions.ts
Normal file
38
extensions/discord/src/internal/api.reactions.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
262
extensions/discord/src/internal/api.test.ts
Normal file
262
extensions/discord/src/internal/api.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
61
extensions/discord/src/internal/api.ts
Normal file
61
extensions/discord/src/internal/api.ts
Normal 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";
|
||||
19
extensions/discord/src/internal/api.users.ts
Normal file
19
extensions/discord/src/internal/api.users.ts
Normal 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">;
|
||||
}
|
||||
13
extensions/discord/src/internal/api.webhooks.ts
Normal file
13
extensions/discord/src/internal/api.webhooks.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
278
extensions/discord/src/internal/client.test.ts
Normal file
278
extensions/discord/src/internal/client.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
238
extensions/discord/src/internal/client.ts
Normal file
238
extensions/discord/src/internal/client.ts
Normal 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);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
202
extensions/discord/src/internal/command-deploy.ts
Normal file
202
extensions/discord/src/internal/command-deploy.ts
Normal 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");
|
||||
}
|
||||
188
extensions/discord/src/internal/commands.ts
Normal file
188
extensions/discord/src/internal/commands.ts
Normal 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(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
extensions/discord/src/internal/components.base.ts
Normal file
65
extensions/discord/src/internal/components.base.ts
Normal 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;
|
||||
}
|
||||
279
extensions/discord/src/internal/components.message.ts
Normal file
279
extensions/discord/src/internal/components.message.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
95
extensions/discord/src/internal/components.modal.ts
Normal file
95
extensions/discord/src/internal/components.modal.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
31
extensions/discord/src/internal/components.ts
Normal file
31
extensions/discord/src/internal/components.ts
Normal 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";
|
||||
11
extensions/discord/src/internal/discord.ts
Normal file
11
extensions/discord/src/internal/discord.ts
Normal 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";
|
||||
35
extensions/discord/src/internal/embeds.ts
Normal file
35
extensions/discord/src/internal/embeds.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
99
extensions/discord/src/internal/entity-cache.ts
Normal file
99
extensions/discord/src/internal/entity-cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
162
extensions/discord/src/internal/event-queue.ts
Normal file
162
extensions/discord/src/internal/event-queue.ts
Normal 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";
|
||||
}
|
||||
96
extensions/discord/src/internal/gateway-dispatch.ts
Normal file
96
extensions/discord/src/internal/gateway-dispatch.ts
Normal 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;
|
||||
}
|
||||
26
extensions/discord/src/internal/gateway-identify-limiter.ts
Normal file
26
extensions/discord/src/internal/gateway-identify-limiter.ts
Normal 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();
|
||||
61
extensions/discord/src/internal/gateway-lifecycle.ts
Normal file
61
extensions/discord/src/internal/gateway-lifecycle.ts
Normal 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?.();
|
||||
}
|
||||
}
|
||||
104
extensions/discord/src/internal/gateway-rate-limit.ts
Normal file
104
extensions/discord/src/internal/gateway-rate-limit.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
398
extensions/discord/src/internal/gateway.test.ts
Normal file
398
extensions/discord/src/internal/gateway.test.ts
Normal 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}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
438
extensions/discord/src/internal/gateway.ts
Normal file
438
extensions/discord/src/internal/gateway.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
148
extensions/discord/src/internal/interaction-dispatch.test.ts
Normal file
148
extensions/discord/src/internal/interaction-dispatch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
107
extensions/discord/src/internal/interaction-dispatch.ts
Normal file
107
extensions/discord/src/internal/interaction-dispatch.ts
Normal 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;
|
||||
}
|
||||
95
extensions/discord/src/internal/interaction-options.ts
Normal file
95
extensions/discord/src/internal/interaction-options.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
extensions/discord/src/internal/interaction-response.ts
Normal file
53
extensions/discord/src/internal/interaction-response.ts
Normal 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
|
||||
);
|
||||
}
|
||||
253
extensions/discord/src/internal/interactions.test.ts
Normal file
253
extensions/discord/src/internal/interactions.test.ts
Normal 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: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
318
extensions/discord/src/internal/interactions.ts
Normal file
318
extensions/discord/src/internal/interactions.ts
Normal 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;
|
||||
}
|
||||
81
extensions/discord/src/internal/listeners.ts
Normal file
81
extensions/discord/src/internal/listeners.ts
Normal 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;
|
||||
}
|
||||
26
extensions/discord/src/internal/live-smoke.live.test.ts
Normal file
26
extensions/discord/src/internal/live-smoke.live.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
96
extensions/discord/src/internal/modal-fields.ts
Normal file
96
extensions/discord/src/internal/modal-fields.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
83
extensions/discord/src/internal/payload.ts
Normal file
83
extensions/discord/src/internal/payload.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
110
extensions/discord/src/internal/rest-body.ts
Normal file
110
extensions/discord/src/internal/rest-body.ts
Normal 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);
|
||||
}
|
||||
73
extensions/discord/src/internal/rest-errors.ts
Normal file
73
extensions/discord/src/internal/rest-errors.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
50
extensions/discord/src/internal/rest-routes.ts
Normal file
50
extensions/discord/src/internal/rest-routes.ts
Normal 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()}`;
|
||||
}
|
||||
331
extensions/discord/src/internal/rest-scheduler.ts
Normal file
331
extensions/discord/src/internal/rest-scheduler.ts
Normal 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;
|
||||
}
|
||||
228
extensions/discord/src/internal/rest.test.ts
Normal file
228
extensions/discord/src/internal/rest.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
212
extensions/discord/src/internal/rest.ts
Normal file
212
extensions/discord/src/internal/rest.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
36
extensions/discord/src/internal/schemas.ts
Normal file
36
extensions/discord/src/internal/schemas.ts
Normal 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);
|
||||
}
|
||||
274
extensions/discord/src/internal/structures.ts
Normal file
274
extensions/discord/src/internal/structures.ts
Normal 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;
|
||||
}
|
||||
129
extensions/discord/src/internal/test-builders.test-support.ts
Normal file
129
extensions/discord/src/internal/test-builders.test-support.ts
Normal 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;
|
||||
}
|
||||
49
extensions/discord/src/internal/voice.ts
Normal file
49
extensions/discord/src/internal/voice.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
435
extensions/discord/src/monitor/agent-components-auth.ts
Normal file
435
extensions/discord/src/monitor/agent-components-auth.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
144
extensions/discord/src/monitor/agent-components-context.ts
Normal file
144
extensions/discord/src/monitor/agent-components-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
224
extensions/discord/src/monitor/agent-components-data.ts
Normal file
224
extensions/discord/src/monitor/agent-components-data.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
345
extensions/discord/src/monitor/agent-components.dispatch.ts
Normal file
345
extensions/discord/src/monitor/agent-components.dispatch.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
57
extensions/discord/src/monitor/agent-components.types.ts
Normal file
57
extensions/discord/src/monitor/agent-components.types.ts
Normal 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[];
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
295
extensions/discord/src/monitor/gateway-metadata.ts
Normal file
295
extensions/discord/src/monitor/gateway-metadata.ts
Normal 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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`));
|
||||
|
||||
88
extensions/discord/src/monitor/message-channel-info.ts
Normal file
88
extensions/discord/src/monitor/message-channel-info.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
363
extensions/discord/src/monitor/message-handler.context.ts
Normal file
363
extensions/discord/src/monitor/message-handler.context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
120
extensions/discord/src/monitor/message-handler.dm-preflight.ts
Normal file
120
extensions/discord/src/monitor/message-handler.dm-preflight.ts
Normal 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;
|
||||
}
|
||||
246
extensions/discord/src/monitor/message-handler.draft-preview.ts
Normal file
246
extensions/discord/src/monitor/message-handler.draft-preview.ts
Normal 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)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
198
extensions/discord/src/monitor/message-handler.hydration.ts
Normal file
198
extensions/discord/src/monitor/message-handler.hydration.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user