@openclaw/discord 2026.5.1-beta.1 → 2026.5.2-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +50 -6
- package/package.json +5 -4
- package/src/actions/handle-action.test.ts +121 -0
- package/src/actions/handle-action.ts +49 -9
- package/src/actions/runtime.messaging.send.ts +8 -4
- package/src/actions/runtime.messaging.shared.ts +5 -0
- package/src/actions/runtime.test.ts +32 -1
- package/src/actions/runtime.ts +6 -0
- package/src/channel-actions.test.ts +42 -3
- package/src/channel-actions.ts +5 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.test.ts +8 -0
- package/src/channel.ts +1 -0
- package/src/client.ts +0 -7
- package/src/config-ui-hints.ts +6 -6
- package/src/internal/client.test.ts +111 -0
- package/src/internal/client.ts +63 -1
- package/src/internal/command-deploy.ts +41 -6
- package/src/internal/gateway.test.ts +128 -0
- package/src/internal/gateway.ts +44 -5
- package/src/internal/interaction-dispatch.ts +33 -1
- package/src/internal/interactions.test.ts +72 -0
- package/src/internal/interactions.ts +41 -0
- package/src/internal/rest-scheduler.ts +188 -43
- package/src/internal/rest.test.ts +236 -0
- package/src/internal/rest.ts +114 -5
- package/src/internal/structures.test.ts +43 -0
- package/src/internal/structures.ts +2 -0
- package/src/monitor/agent-components-context.ts +12 -2
- package/src/monitor/agent-components.dispatch.ts +2 -2
- package/src/monitor/agent-components.types.ts +1 -0
- package/src/monitor/allow-list.test.ts +14 -0
- package/src/monitor/allow-list.ts +10 -0
- package/src/monitor/channel-access.test.ts +99 -0
- package/src/monitor/channel-access.ts +36 -4
- package/src/monitor/message-handler.context.ts +16 -3
- package/src/monitor/message-handler.preflight-channel-context.test.ts +18 -0
- package/src/monitor/message-handler.preflight-channel-context.ts +4 -1
- package/src/monitor/message-handler.preflight-pluralkit.ts +1 -2
- package/src/monitor/message-handler.preflight.test.ts +79 -0
- package/src/monitor/message-handler.preflight.ts +21 -22
- package/src/monitor/message-handler.preflight.types.ts +1 -0
- package/src/monitor/message-handler.process.test.ts +70 -2
- package/src/monitor/message-handler.process.ts +4 -2
- package/src/monitor/message-media.ts +2 -0
- package/src/monitor/message-utils.test.ts +7 -1
- package/src/monitor/native-command-agent-reply.ts +3 -1
- package/src/monitor/native-command-reply.ts +2 -0
- package/src/monitor/native-command.plugin-dispatch.test.ts +82 -0
- package/src/monitor/native-command.ts +2 -1
- package/src/monitor/provider.lifecycle.test.ts +56 -1
- package/src/monitor/provider.lifecycle.ts +7 -0
- package/src/monitor/thread-bindings.discord-api.ts +6 -14
- package/src/proxy-request-client.ts +1 -34
- package/src/send.components.ts +2 -4
- package/src/send.outbound.ts +4 -5
- package/src/send.sends-basic-channel-messages.test.ts +22 -0
- package/src/send.shared.ts +3 -1
- package/src/setup-core.ts +37 -5
- package/src/setup-surface.test.ts +41 -0
- package/src/shared.test.ts +6 -0
- package/src/subagent-hooks.test.ts +91 -38
- package/src/subagent-hooks.ts +28 -29
|
@@ -150,6 +150,117 @@ describe("Client.deployCommands", () => {
|
|
|
150
150
|
expect(deleteRequest).not.toHaveBeenCalled();
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
it("does not patch live-only command metadata or reordered unordered arrays", async () => {
|
|
154
|
+
const client = createInternalTestClient([
|
|
155
|
+
createTestCommand({
|
|
156
|
+
name: "one",
|
|
157
|
+
options: [
|
|
158
|
+
{
|
|
159
|
+
type: 3,
|
|
160
|
+
name: "value",
|
|
161
|
+
description: "Value",
|
|
162
|
+
required: false,
|
|
163
|
+
autocomplete: false,
|
|
164
|
+
channel_types: [1, 0],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
const get = vi.fn(async () => [
|
|
170
|
+
{
|
|
171
|
+
id: "cmd1",
|
|
172
|
+
application_id: "app1",
|
|
173
|
+
type: ApplicationCommandType.ChatInput,
|
|
174
|
+
name: "one",
|
|
175
|
+
name_localized: "one",
|
|
176
|
+
description: "one command",
|
|
177
|
+
description_localized: "one command",
|
|
178
|
+
options: [
|
|
179
|
+
{
|
|
180
|
+
type: 3,
|
|
181
|
+
name: "value",
|
|
182
|
+
description: "Value",
|
|
183
|
+
description_localized: "Value",
|
|
184
|
+
channel_types: [0, 1],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
default_member_permissions: null,
|
|
188
|
+
dm_permission: true,
|
|
189
|
+
integration_types: [1, 0],
|
|
190
|
+
contexts: [2, 1, 0],
|
|
191
|
+
guild_id: undefined,
|
|
192
|
+
version: "1",
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
const patch = vi.fn(async () => undefined);
|
|
196
|
+
const post = vi.fn(async () => undefined);
|
|
197
|
+
const deleteRequest = vi.fn(async () => undefined);
|
|
198
|
+
attachRestMock(client, { get, patch, post, delete: deleteRequest });
|
|
199
|
+
|
|
200
|
+
await client.deployCommands({ mode: "reconcile" });
|
|
201
|
+
|
|
202
|
+
expect(patch).not.toHaveBeenCalled();
|
|
203
|
+
expect(post).not.toHaveBeenCalled();
|
|
204
|
+
expect(deleteRequest).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("patches changed option localization maps", async () => {
|
|
208
|
+
const client = createInternalTestClient([
|
|
209
|
+
createTestCommand({
|
|
210
|
+
name: "one",
|
|
211
|
+
options: [
|
|
212
|
+
{
|
|
213
|
+
type: 3,
|
|
214
|
+
name: "value",
|
|
215
|
+
name_localizations: { de: "wert" },
|
|
216
|
+
description: "Value",
|
|
217
|
+
description_localizations: { de: "Wert" },
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
}),
|
|
221
|
+
]);
|
|
222
|
+
const get = vi.fn(async () => [
|
|
223
|
+
{
|
|
224
|
+
id: "cmd1",
|
|
225
|
+
application_id: "app1",
|
|
226
|
+
type: ApplicationCommandType.ChatInput,
|
|
227
|
+
name: "one",
|
|
228
|
+
description: "one command",
|
|
229
|
+
options: [
|
|
230
|
+
{
|
|
231
|
+
type: 3,
|
|
232
|
+
name: "value",
|
|
233
|
+
name_localizations: { de: "alter-wert" },
|
|
234
|
+
description: "Value",
|
|
235
|
+
description_localizations: { de: "Alter Wert" },
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
]);
|
|
240
|
+
const patch = vi.fn(async () => undefined);
|
|
241
|
+
const post = vi.fn(async () => undefined);
|
|
242
|
+
const deleteRequest = vi.fn(async () => undefined);
|
|
243
|
+
attachRestMock(client, { get, patch, post, delete: deleteRequest });
|
|
244
|
+
|
|
245
|
+
await client.deployCommands({ mode: "reconcile" });
|
|
246
|
+
|
|
247
|
+
expect(patch).toHaveBeenCalledWith(
|
|
248
|
+
Routes.applicationCommand("app1", "cmd1"),
|
|
249
|
+
expect.objectContaining({
|
|
250
|
+
body: expect.objectContaining({
|
|
251
|
+
options: [
|
|
252
|
+
expect.objectContaining({
|
|
253
|
+
name_localizations: { de: "wert" },
|
|
254
|
+
description_localizations: { de: "Wert" },
|
|
255
|
+
}),
|
|
256
|
+
],
|
|
257
|
+
}),
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
expect(post).not.toHaveBeenCalled();
|
|
261
|
+
expect(deleteRequest).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
153
264
|
it("skips command deploy when the serialized command set is unchanged", async () => {
|
|
154
265
|
const client = createInternalTestClient([createTestCommand({ name: "one" })]);
|
|
155
266
|
const get = vi.fn(async () => []);
|
package/src/internal/client.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { DiscordEntityCache } from "./entity-cache.js";
|
|
|
6
6
|
import { DiscordEventQueue, type DiscordEventQueueOptions } from "./event-queue.js";
|
|
7
7
|
import { dispatchInteraction } from "./interaction-dispatch.js";
|
|
8
8
|
import { RequestClient, type RequestClientOptions } from "./rest.js";
|
|
9
|
-
import type { Guild, GuildMember, User } from "./structures.js";
|
|
9
|
+
import type { Guild, GuildMember, Message, User } from "./structures.js";
|
|
10
10
|
|
|
11
11
|
export interface Route {
|
|
12
12
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
@@ -49,10 +49,18 @@ export interface ClientOptions {
|
|
|
49
49
|
restCacheTtlMs?: number;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
type OneOffComponentResult =
|
|
53
|
+
| { success: true; customId: string; message: Message; values?: string[] }
|
|
54
|
+
| { success: false; message: Message; reason: "timed out" };
|
|
55
|
+
|
|
52
56
|
export class ComponentRegistry<
|
|
53
57
|
T extends { customId: string; customIdParser?: typeof parseCustomId; type?: number },
|
|
54
58
|
> {
|
|
55
59
|
private entries = new Map<string, T[]>();
|
|
60
|
+
private oneOffComponents = new Map<
|
|
61
|
+
string,
|
|
62
|
+
{ message: Message; resolve(result: OneOffComponentResult): void; timer: NodeJS.Timeout }
|
|
63
|
+
>();
|
|
56
64
|
private wildcardEntries: T[] = [];
|
|
57
65
|
|
|
58
66
|
register(entry: T): void {
|
|
@@ -90,12 +98,66 @@ export class ComponentRegistry<
|
|
|
90
98
|
return true;
|
|
91
99
|
});
|
|
92
100
|
}
|
|
101
|
+
|
|
102
|
+
waitForMessageComponent(message: Message, timeoutMs: number): Promise<OneOffComponentResult> {
|
|
103
|
+
const key = createOneOffComponentKey(message.id, message.channelId);
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
const existing = this.oneOffComponents.get(key);
|
|
106
|
+
if (existing) {
|
|
107
|
+
clearTimeout(existing.timer);
|
|
108
|
+
existing.resolve({ success: false, message, reason: "timed out" });
|
|
109
|
+
}
|
|
110
|
+
const timer = setTimeout(
|
|
111
|
+
() => {
|
|
112
|
+
this.oneOffComponents.delete(key);
|
|
113
|
+
resolve({ success: false, message, reason: "timed out" });
|
|
114
|
+
},
|
|
115
|
+
Math.max(0, timeoutMs),
|
|
116
|
+
);
|
|
117
|
+
timer.unref?.();
|
|
118
|
+
this.oneOffComponents.set(key, {
|
|
119
|
+
message,
|
|
120
|
+
timer,
|
|
121
|
+
resolve,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
resolveOneOffComponent(params: {
|
|
127
|
+
channelId?: string;
|
|
128
|
+
customId: string;
|
|
129
|
+
messageId?: string;
|
|
130
|
+
values?: string[];
|
|
131
|
+
}): boolean {
|
|
132
|
+
if (!params.messageId || !params.channelId) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const entry = this.oneOffComponents.get(
|
|
136
|
+
createOneOffComponentKey(params.messageId, params.channelId),
|
|
137
|
+
);
|
|
138
|
+
if (!entry) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
clearTimeout(entry.timer);
|
|
142
|
+
this.oneOffComponents.delete(createOneOffComponentKey(params.messageId, params.channelId));
|
|
143
|
+
entry.resolve({
|
|
144
|
+
success: true,
|
|
145
|
+
customId: params.customId,
|
|
146
|
+
message: entry.message,
|
|
147
|
+
values: params.values,
|
|
148
|
+
});
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
93
151
|
}
|
|
94
152
|
|
|
95
153
|
function parseRegistryKey(customId: string, parser: typeof parseCustomId = parseCustomId): string {
|
|
96
154
|
return parser(customId).key;
|
|
97
155
|
}
|
|
98
156
|
|
|
157
|
+
function createOneOffComponentKey(messageId: string, channelId: string): string {
|
|
158
|
+
return `${messageId}:${channelId}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
99
161
|
export class Client {
|
|
100
162
|
routes: Route[] = [];
|
|
101
163
|
plugins: Array<{ id: string; plugin: Plugin }> = [];
|
|
@@ -157,12 +157,15 @@ function comparableCommand(value: unknown): unknown {
|
|
|
157
157
|
return value;
|
|
158
158
|
}
|
|
159
159
|
const omit = new Set([
|
|
160
|
-
"id",
|
|
161
160
|
"application_id",
|
|
161
|
+
"description_localized",
|
|
162
|
+
"dm_permission",
|
|
162
163
|
"guild_id",
|
|
164
|
+
"id",
|
|
165
|
+
"name_localized",
|
|
166
|
+
"nsfw",
|
|
163
167
|
"version",
|
|
164
168
|
"default_permission",
|
|
165
|
-
"nsfw",
|
|
166
169
|
]);
|
|
167
170
|
return stableComparableObject(
|
|
168
171
|
Object.fromEntries(
|
|
@@ -171,18 +174,50 @@ function comparableCommand(value: unknown): unknown {
|
|
|
171
174
|
);
|
|
172
175
|
}
|
|
173
176
|
|
|
174
|
-
|
|
177
|
+
const unorderedCommandArrayFields = new Set(["channel_types", "contexts", "integration_types"]);
|
|
178
|
+
const optionComparisonOmittedFields = new Set([
|
|
179
|
+
"contexts",
|
|
180
|
+
"default_member_permissions",
|
|
181
|
+
"description_localized",
|
|
182
|
+
"integration_types",
|
|
183
|
+
"name_localized",
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
function stableComparableObject(value: unknown, path: string[] = []): unknown {
|
|
175
187
|
if (Array.isArray(value)) {
|
|
176
|
-
|
|
188
|
+
const normalized = value.map((entry) => stableComparableObject(entry, path));
|
|
189
|
+
const key = path.at(-1);
|
|
190
|
+
if (
|
|
191
|
+
key &&
|
|
192
|
+
unorderedCommandArrayFields.has(key) &&
|
|
193
|
+
normalized.every(
|
|
194
|
+
(entry) =>
|
|
195
|
+
typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean",
|
|
196
|
+
)
|
|
197
|
+
) {
|
|
198
|
+
return normalized.toSorted((left, right) => String(left).localeCompare(String(right)));
|
|
199
|
+
}
|
|
200
|
+
return normalized;
|
|
177
201
|
}
|
|
178
202
|
if (!value || typeof value !== "object") {
|
|
179
203
|
return value;
|
|
180
204
|
}
|
|
181
205
|
return Object.fromEntries(
|
|
182
206
|
Object.entries(value as Record<string, unknown>)
|
|
183
|
-
.filter(([, entry]) =>
|
|
207
|
+
.filter(([key, entry]) => {
|
|
208
|
+
if (entry === undefined) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
if (path.includes("options") && optionComparisonOmittedFields.has(key)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if ((key === "required" || key === "autocomplete") && entry === false) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
})
|
|
184
219
|
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
185
|
-
.map(([key, entry]) => [key, stableComparableObject(entry)]),
|
|
220
|
+
.map(([key, entry]) => [key, stableComparableObject(entry, [...path, key])]),
|
|
186
221
|
);
|
|
187
222
|
}
|
|
188
223
|
|
|
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import {
|
|
3
3
|
GatewayCloseCodes,
|
|
4
4
|
GatewayDispatchEvents,
|
|
5
|
+
GatewayIntentBits,
|
|
5
6
|
GatewayOpcodes,
|
|
6
7
|
InteractionType,
|
|
7
8
|
PresenceUpdateStatus,
|
|
@@ -127,6 +128,42 @@ describe("GatewayPlugin", () => {
|
|
|
127
128
|
await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledWith(error));
|
|
128
129
|
});
|
|
129
130
|
|
|
131
|
+
it("reconnects when the socket closes while waiting for identify concurrency", async () => {
|
|
132
|
+
vi.useFakeTimers();
|
|
133
|
+
vi.setSystemTime(0);
|
|
134
|
+
await sharedGatewayIdentifyLimiter.wait({ shardId: 0, maxConcurrency: 1 });
|
|
135
|
+
const gateway = new TestGatewayPlugin({
|
|
136
|
+
autoInteractions: false,
|
|
137
|
+
url: "wss://gateway.example.test",
|
|
138
|
+
});
|
|
139
|
+
const errorSpy = vi.fn();
|
|
140
|
+
gateway.emitter.on("error", errorSpy);
|
|
141
|
+
|
|
142
|
+
gateway.connect(false);
|
|
143
|
+
const socket = gateway.sockets[0];
|
|
144
|
+
socket?.emit("open");
|
|
145
|
+
socket?.emit(
|
|
146
|
+
"message",
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
op: GatewayOpcodes.Hello,
|
|
149
|
+
d: { heartbeat_interval: 45_000 },
|
|
150
|
+
s: null,
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
if (socket) {
|
|
154
|
+
socket.readyState = 3;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
158
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
159
|
+
new Error("Discord gateway socket closed before IDENTIFY could be sent"),
|
|
160
|
+
);
|
|
161
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
162
|
+
|
|
163
|
+
expect(gateway.connectCalls).toEqual([false, false]);
|
|
164
|
+
expect(gateway.sockets).toHaveLength(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
130
167
|
it("preserves MESSAGE_CREATE author payloads for inbound dispatch", async () => {
|
|
131
168
|
const gateway = new GatewayPlugin({ autoInteractions: false });
|
|
132
169
|
const dispatchGatewayEvent = vi.fn(async (_event: string, _data: unknown) => {});
|
|
@@ -234,6 +271,29 @@ describe("GatewayPlugin", () => {
|
|
|
234
271
|
);
|
|
235
272
|
});
|
|
236
273
|
|
|
274
|
+
it("rejects gateway payloads that exceed Discord's size limit", () => {
|
|
275
|
+
const gateway = new GatewayPlugin({ autoInteractions: false });
|
|
276
|
+
const send = attachOpenSocket(gateway);
|
|
277
|
+
|
|
278
|
+
expect(() =>
|
|
279
|
+
gateway.send({
|
|
280
|
+
op: GatewayOpcodes.PresenceUpdate,
|
|
281
|
+
d: {
|
|
282
|
+
since: null,
|
|
283
|
+
activities: [
|
|
284
|
+
{
|
|
285
|
+
name: "x".repeat(4_100),
|
|
286
|
+
type: 0,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
status: PresenceUpdateStatus.Online,
|
|
290
|
+
afk: false,
|
|
291
|
+
},
|
|
292
|
+
} as GatewaySendPayload),
|
|
293
|
+
).toThrow(/4096-byte limit/);
|
|
294
|
+
expect(send).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
237
297
|
it("ignores stale socket close events after reconnecting", () => {
|
|
238
298
|
const gateway = new TestGatewayPlugin({
|
|
239
299
|
autoInteractions: false,
|
|
@@ -294,6 +354,7 @@ describe("GatewayPlugin", () => {
|
|
|
294
354
|
|
|
295
355
|
it("clears resume state after invalid session false", async () => {
|
|
296
356
|
vi.useFakeTimers();
|
|
357
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
297
358
|
const gateway = new TestGatewayPlugin({
|
|
298
359
|
autoInteractions: false,
|
|
299
360
|
url: "wss://gateway.example.test",
|
|
@@ -318,6 +379,29 @@ describe("GatewayPlugin", () => {
|
|
|
318
379
|
expect(sessionState.sequence).toBeNull();
|
|
319
380
|
});
|
|
320
381
|
|
|
382
|
+
it("delays invalid-session reconnects by Discord's randomized cooldown floor", async () => {
|
|
383
|
+
vi.useFakeTimers();
|
|
384
|
+
vi.spyOn(Math, "random").mockReturnValue(0.75);
|
|
385
|
+
const gateway = new TestGatewayPlugin({
|
|
386
|
+
autoInteractions: false,
|
|
387
|
+
url: "wss://gateway.example.test",
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
gateway.connect(false);
|
|
391
|
+
gateway.sockets[0]?.emit("open");
|
|
392
|
+
(
|
|
393
|
+
gateway as unknown as {
|
|
394
|
+
handlePayload(payload: { op: number; d: unknown }, resume: boolean): void;
|
|
395
|
+
}
|
|
396
|
+
).handlePayload({ op: GatewayOpcodes.InvalidSession, d: true }, true);
|
|
397
|
+
|
|
398
|
+
await vi.advanceTimersByTimeAsync(3_999);
|
|
399
|
+
expect(gateway.connectCalls).toEqual([false]);
|
|
400
|
+
|
|
401
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
402
|
+
expect(gateway.connectCalls).toEqual([false, true]);
|
|
403
|
+
});
|
|
404
|
+
|
|
321
405
|
it("includes close code details when reconnect attempts are exhausted", async () => {
|
|
322
406
|
vi.useFakeTimers();
|
|
323
407
|
const gateway = new TestGatewayPlugin({
|
|
@@ -472,4 +556,48 @@ describe("GatewayPlugin", () => {
|
|
|
472
556
|
expect.stringContaining(`"op":${GatewayOpcodes.Identify}`),
|
|
473
557
|
);
|
|
474
558
|
});
|
|
559
|
+
|
|
560
|
+
it("validates requestGuildMembers before sending", () => {
|
|
561
|
+
const withoutMembersIntent = new GatewayPlugin({ autoInteractions: false });
|
|
562
|
+
attachOpenSocket(withoutMembersIntent);
|
|
563
|
+
|
|
564
|
+
expect(() =>
|
|
565
|
+
withoutMembersIntent.requestGuildMembers({ guild_id: "guild1", query: "", limit: 0 }),
|
|
566
|
+
).toThrow(/GUILD_MEMBERS intent/);
|
|
567
|
+
|
|
568
|
+
const withoutPresenceIntent = new GatewayPlugin({
|
|
569
|
+
autoInteractions: false,
|
|
570
|
+
intents: GatewayIntentBits.GuildMembers,
|
|
571
|
+
});
|
|
572
|
+
attachOpenSocket(withoutPresenceIntent);
|
|
573
|
+
|
|
574
|
+
expect(() =>
|
|
575
|
+
withoutPresenceIntent.requestGuildMembers({
|
|
576
|
+
guild_id: "guild1",
|
|
577
|
+
query: "",
|
|
578
|
+
limit: 0,
|
|
579
|
+
presences: true,
|
|
580
|
+
}),
|
|
581
|
+
).toThrow(/GUILD_PRESENCES intent/);
|
|
582
|
+
|
|
583
|
+
const valid = new GatewayPlugin({
|
|
584
|
+
autoInteractions: false,
|
|
585
|
+
intents: GatewayIntentBits.GuildMembers | GatewayIntentBits.GuildPresences,
|
|
586
|
+
});
|
|
587
|
+
const send = attachOpenSocket(valid);
|
|
588
|
+
|
|
589
|
+
expect(() =>
|
|
590
|
+
valid.requestGuildMembers({
|
|
591
|
+
guild_id: "guild1",
|
|
592
|
+
limit: 1,
|
|
593
|
+
}),
|
|
594
|
+
).toThrow(/query or user_ids/);
|
|
595
|
+
|
|
596
|
+
valid.requestGuildMembers({ guild_id: "guild1", query: "", limit: 0, presences: true });
|
|
597
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
598
|
+
expect(JSON.parse(send.mock.calls[0]?.[0] as string)).toEqual({
|
|
599
|
+
op: GatewayOpcodes.RequestGuildMembers,
|
|
600
|
+
d: { guild_id: "guild1", query: "", limit: 0, presences: true },
|
|
601
|
+
});
|
|
602
|
+
});
|
|
475
603
|
});
|
package/src/internal/gateway.ts
CHANGED
|
@@ -46,6 +46,9 @@ type GatewayPluginOptions = {
|
|
|
46
46
|
|
|
47
47
|
const READY_STATE_OPEN = 1;
|
|
48
48
|
const DEFAULT_GATEWAY_URL = "wss://gateway.discord.gg/";
|
|
49
|
+
const DISCORD_GATEWAY_PAYLOAD_LIMIT_BYTES = 4096;
|
|
50
|
+
const INVALID_SESSION_MIN_DELAY_MS = 1_000;
|
|
51
|
+
const INVALID_SESSION_JITTER_MS = 4_000;
|
|
49
52
|
|
|
50
53
|
function ensureGatewayParams(url: string): string {
|
|
51
54
|
const parsed = new URL(url);
|
|
@@ -248,7 +251,12 @@ export class GatewayPlugin extends Plugin {
|
|
|
248
251
|
true,
|
|
249
252
|
);
|
|
250
253
|
} else {
|
|
251
|
-
void this.identifyWithConcurrency()
|
|
254
|
+
void this.identifyWithConcurrency().catch((error: unknown) => {
|
|
255
|
+
this.emitter.emit(
|
|
256
|
+
"error",
|
|
257
|
+
error instanceof Error ? error : new Error(String(error), { cause: error }),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
252
260
|
}
|
|
253
261
|
break;
|
|
254
262
|
case GatewayOpcodes.HeartbeatAck:
|
|
@@ -269,7 +277,11 @@ export class GatewayPlugin extends Plugin {
|
|
|
269
277
|
if (!payload.d) {
|
|
270
278
|
this.resetSessionState();
|
|
271
279
|
}
|
|
272
|
-
this.scheduleReconnect(
|
|
280
|
+
this.scheduleReconnect(
|
|
281
|
+
payload.d,
|
|
282
|
+
undefined,
|
|
283
|
+
INVALID_SESSION_MIN_DELAY_MS + Math.floor(Math.random() * INVALID_SESSION_JITTER_MS),
|
|
284
|
+
);
|
|
273
285
|
break;
|
|
274
286
|
case GatewayOpcodes.Reconnect:
|
|
275
287
|
this.scheduleReconnect(true);
|
|
@@ -325,7 +337,13 @@ export class GatewayPlugin extends Plugin {
|
|
|
325
337
|
shardId: this.shardId,
|
|
326
338
|
maxConcurrency: this.gatewayInfo?.session_start_limit.max_concurrency,
|
|
327
339
|
});
|
|
328
|
-
|
|
340
|
+
const socket = this.ws;
|
|
341
|
+
if (!socket || socket.readyState !== READY_STATE_OPEN) {
|
|
342
|
+
const error = new Error("Discord gateway socket closed before IDENTIFY could be sent");
|
|
343
|
+
this.emitter.emit("error", error);
|
|
344
|
+
if (socket) {
|
|
345
|
+
this.scheduleReconnect(false);
|
|
346
|
+
}
|
|
329
347
|
return;
|
|
330
348
|
}
|
|
331
349
|
this.identify();
|
|
@@ -336,6 +354,15 @@ export class GatewayPlugin extends Plugin {
|
|
|
336
354
|
throw new Error("Discord gateway socket is not open");
|
|
337
355
|
}
|
|
338
356
|
const serialized = JSON.stringify(payload);
|
|
357
|
+
const payloadSize =
|
|
358
|
+
typeof Buffer !== "undefined"
|
|
359
|
+
? Buffer.byteLength(serialized, "utf8")
|
|
360
|
+
: new TextEncoder().encode(serialized).byteLength;
|
|
361
|
+
if (payloadSize > DISCORD_GATEWAY_PAYLOAD_LIMIT_BYTES) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Discord gateway payload exceeds ${DISCORD_GATEWAY_PAYLOAD_LIMIT_BYTES}-byte limit`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
339
366
|
this.outboundLimiter.send(serialized, { critical: skipRateLimit });
|
|
340
367
|
}
|
|
341
368
|
|
|
@@ -375,7 +402,7 @@ export class GatewayPlugin extends Plugin {
|
|
|
375
402
|
this.sequence = null;
|
|
376
403
|
}
|
|
377
404
|
|
|
378
|
-
private scheduleReconnect(resume: boolean, closeCode?: number): void {
|
|
405
|
+
private scheduleReconnect(resume: boolean, closeCode?: number, minDelayMs = 0): void {
|
|
379
406
|
if (!this.shouldReconnect) {
|
|
380
407
|
return;
|
|
381
408
|
}
|
|
@@ -397,7 +424,10 @@ export class GatewayPlugin extends Plugin {
|
|
|
397
424
|
);
|
|
398
425
|
return;
|
|
399
426
|
}
|
|
400
|
-
const delay = Math.
|
|
427
|
+
const delay = Math.max(
|
|
428
|
+
minDelayMs,
|
|
429
|
+
Math.min(30_000, 1_000 * 2 ** Math.min(this.reconnectAttempts, 5)),
|
|
430
|
+
);
|
|
401
431
|
this.reconnectTimer.schedule(delay, () => {
|
|
402
432
|
this.connect(resume);
|
|
403
433
|
});
|
|
@@ -412,6 +442,15 @@ export class GatewayPlugin extends Plugin {
|
|
|
412
442
|
}
|
|
413
443
|
|
|
414
444
|
requestGuildMembers(data: RequestGuildMembersData): void {
|
|
445
|
+
if (!this.hasIntent(GatewayIntentBits.GuildMembers)) {
|
|
446
|
+
throw new Error("GUILD_MEMBERS intent is required for requestGuildMembers");
|
|
447
|
+
}
|
|
448
|
+
if (data.presences && !this.hasIntent(GatewayIntentBits.GuildPresences)) {
|
|
449
|
+
throw new Error("GUILD_PRESENCES intent is required when requesting presences");
|
|
450
|
+
}
|
|
451
|
+
if (!data.query && data.query !== "" && !data.user_ids) {
|
|
452
|
+
throw new Error("Either query or user_ids is required for requestGuildMembers");
|
|
453
|
+
}
|
|
415
454
|
this.send({ op: GatewayOpcodes.RequestGuildMembers, d: data } as GatewaySendPayload);
|
|
416
455
|
}
|
|
417
456
|
|
|
@@ -30,6 +30,12 @@ type DispatchClient = Parameters<typeof createInteraction>[0] & {
|
|
|
30
30
|
commands: BaseCommand[];
|
|
31
31
|
componentHandler: {
|
|
32
32
|
resolve(customId: string, options?: { componentType?: number }): DispatchComponent | undefined;
|
|
33
|
+
resolveOneOffComponent(params: {
|
|
34
|
+
channelId?: string;
|
|
35
|
+
customId: string;
|
|
36
|
+
messageId?: string;
|
|
37
|
+
values?: string[];
|
|
38
|
+
}): boolean;
|
|
33
39
|
};
|
|
34
40
|
modalHandler: { resolve(customId: string): DispatchModal | undefined };
|
|
35
41
|
};
|
|
@@ -75,11 +81,22 @@ export async function dispatchInteraction(
|
|
|
75
81
|
if (!customId) {
|
|
76
82
|
return;
|
|
77
83
|
}
|
|
84
|
+
const componentInteraction = interaction as BaseComponentInteraction;
|
|
85
|
+
if (
|
|
86
|
+
client.componentHandler.resolveOneOffComponent({
|
|
87
|
+
channelId: readMessageChannelId(rawData),
|
|
88
|
+
customId,
|
|
89
|
+
messageId: readMessageId(rawData),
|
|
90
|
+
values: readComponentValues(rawData),
|
|
91
|
+
})
|
|
92
|
+
) {
|
|
93
|
+
await componentInteraction.acknowledge();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
78
96
|
const component = client.componentHandler.resolve(customId, {
|
|
79
97
|
componentType: (rawData as { data?: { component_type?: number } }).data?.component_type,
|
|
80
98
|
});
|
|
81
99
|
if (component) {
|
|
82
|
-
const componentInteraction = interaction as BaseComponentInteraction;
|
|
83
100
|
await deferComponentInteractionIfNeeded(component, componentInteraction);
|
|
84
101
|
await component.run(componentInteraction, parseComponentInteractionData(component, customId));
|
|
85
102
|
}
|
|
@@ -128,3 +145,18 @@ function readInteractionName(rawData: APIInteraction): string | undefined {
|
|
|
128
145
|
function readCustomId(rawData: APIInteraction): string | undefined {
|
|
129
146
|
return (rawData as { data?: { custom_id?: string } }).data?.custom_id;
|
|
130
147
|
}
|
|
148
|
+
|
|
149
|
+
function readComponentValues(rawData: APIInteraction): string[] | undefined {
|
|
150
|
+
const values = (rawData as { data?: { values?: unknown } }).data?.values;
|
|
151
|
+
return Array.isArray(values) ? values.map(String) : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readMessageId(rawData: APIInteraction): string | undefined {
|
|
155
|
+
const messageId = (rawData as { message?: { id?: unknown } }).message?.id;
|
|
156
|
+
return typeof messageId === "string" ? messageId : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readMessageChannelId(rawData: APIInteraction): string | undefined {
|
|
160
|
+
const channelId = (rawData as { message?: { channel_id?: unknown } }).message?.channel_id;
|
|
161
|
+
return typeof channelId === "string" ? channelId : undefined;
|
|
162
|
+
}
|
|
@@ -179,6 +179,78 @@ describe("BaseInteraction", () => {
|
|
|
179
179
|
expect(interaction.user?.globalName).toBe("Alice Cooper");
|
|
180
180
|
expect(interaction.user?.discriminator).toBe("1234");
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
it("waits for a one-off component reply without invoking registered handlers", async () => {
|
|
184
|
+
const get = vi.fn(async () => ({
|
|
185
|
+
id: "message1",
|
|
186
|
+
channel_id: "channel1",
|
|
187
|
+
author: {
|
|
188
|
+
id: "bot1",
|
|
189
|
+
username: "bot",
|
|
190
|
+
discriminator: "0000",
|
|
191
|
+
global_name: null,
|
|
192
|
+
avatar: null,
|
|
193
|
+
},
|
|
194
|
+
content: "pick",
|
|
195
|
+
timestamp: "2026-05-01T00:00:00.000Z",
|
|
196
|
+
}));
|
|
197
|
+
const post = vi.fn(async () => undefined);
|
|
198
|
+
const client = createInternalTestClient();
|
|
199
|
+
attachRestMock(client, { get, post });
|
|
200
|
+
const interaction = new BaseInteraction(
|
|
201
|
+
client,
|
|
202
|
+
createInternalInteractionPayload({ id: "interaction1", token: "token1" }),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const wait = interaction.replyAndWaitForComponent({ content: "pick" }, 1_000);
|
|
206
|
+
await vi.waitFor(() =>
|
|
207
|
+
expect(get).toHaveBeenCalledWith("/webhooks/app1/token1/messages/%40original"),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await client.handleInteraction(
|
|
211
|
+
createInternalComponentInteractionPayload({
|
|
212
|
+
id: "component-interaction1",
|
|
213
|
+
token: "component-token1",
|
|
214
|
+
data: { custom_id: "button1" },
|
|
215
|
+
message: {
|
|
216
|
+
id: "message1",
|
|
217
|
+
channel_id: "channel1",
|
|
218
|
+
author: {
|
|
219
|
+
id: "bot1",
|
|
220
|
+
username: "bot",
|
|
221
|
+
discriminator: "0000",
|
|
222
|
+
global_name: null,
|
|
223
|
+
avatar: null,
|
|
224
|
+
},
|
|
225
|
+
content: "pick",
|
|
226
|
+
timestamp: "2026-05-01T00:00:00.000Z",
|
|
227
|
+
edited_timestamp: null,
|
|
228
|
+
tts: false,
|
|
229
|
+
mention_everyone: false,
|
|
230
|
+
mentions: [],
|
|
231
|
+
mention_roles: [],
|
|
232
|
+
attachments: [],
|
|
233
|
+
embeds: [],
|
|
234
|
+
pinned: false,
|
|
235
|
+
type: 0,
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await expect(wait).resolves.toEqual({
|
|
241
|
+
success: true,
|
|
242
|
+
customId: "button1",
|
|
243
|
+
message: expect.objectContaining({ id: "message1", channelId: "channel1" }),
|
|
244
|
+
values: undefined,
|
|
245
|
+
});
|
|
246
|
+
expect(post).toHaveBeenNthCalledWith(
|
|
247
|
+
2,
|
|
248
|
+
"/interactions/component-interaction1/component-token1/callback",
|
|
249
|
+
{
|
|
250
|
+
body: { type: InteractionResponseType.DeferredMessageUpdate },
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
});
|
|
182
254
|
});
|
|
183
255
|
|
|
184
256
|
describe("ModalInteraction", () => {
|