@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.
Files changed (63) hide show
  1. package/openclaw.plugin.json +50 -6
  2. package/package.json +5 -4
  3. package/src/actions/handle-action.test.ts +121 -0
  4. package/src/actions/handle-action.ts +49 -9
  5. package/src/actions/runtime.messaging.send.ts +8 -4
  6. package/src/actions/runtime.messaging.shared.ts +5 -0
  7. package/src/actions/runtime.test.ts +32 -1
  8. package/src/actions/runtime.ts +6 -0
  9. package/src/channel-actions.test.ts +42 -3
  10. package/src/channel-actions.ts +5 -0
  11. package/src/channel-api.ts +1 -0
  12. package/src/channel.test.ts +8 -0
  13. package/src/channel.ts +1 -0
  14. package/src/client.ts +0 -7
  15. package/src/config-ui-hints.ts +6 -6
  16. package/src/internal/client.test.ts +111 -0
  17. package/src/internal/client.ts +63 -1
  18. package/src/internal/command-deploy.ts +41 -6
  19. package/src/internal/gateway.test.ts +128 -0
  20. package/src/internal/gateway.ts +44 -5
  21. package/src/internal/interaction-dispatch.ts +33 -1
  22. package/src/internal/interactions.test.ts +72 -0
  23. package/src/internal/interactions.ts +41 -0
  24. package/src/internal/rest-scheduler.ts +188 -43
  25. package/src/internal/rest.test.ts +236 -0
  26. package/src/internal/rest.ts +114 -5
  27. package/src/internal/structures.test.ts +43 -0
  28. package/src/internal/structures.ts +2 -0
  29. package/src/monitor/agent-components-context.ts +12 -2
  30. package/src/monitor/agent-components.dispatch.ts +2 -2
  31. package/src/monitor/agent-components.types.ts +1 -0
  32. package/src/monitor/allow-list.test.ts +14 -0
  33. package/src/monitor/allow-list.ts +10 -0
  34. package/src/monitor/channel-access.test.ts +99 -0
  35. package/src/monitor/channel-access.ts +36 -4
  36. package/src/monitor/message-handler.context.ts +16 -3
  37. package/src/monitor/message-handler.preflight-channel-context.test.ts +18 -0
  38. package/src/monitor/message-handler.preflight-channel-context.ts +4 -1
  39. package/src/monitor/message-handler.preflight-pluralkit.ts +1 -2
  40. package/src/monitor/message-handler.preflight.test.ts +79 -0
  41. package/src/monitor/message-handler.preflight.ts +21 -22
  42. package/src/monitor/message-handler.preflight.types.ts +1 -0
  43. package/src/monitor/message-handler.process.test.ts +70 -2
  44. package/src/monitor/message-handler.process.ts +4 -2
  45. package/src/monitor/message-media.ts +2 -0
  46. package/src/monitor/message-utils.test.ts +7 -1
  47. package/src/monitor/native-command-agent-reply.ts +3 -1
  48. package/src/monitor/native-command-reply.ts +2 -0
  49. package/src/monitor/native-command.plugin-dispatch.test.ts +82 -0
  50. package/src/monitor/native-command.ts +2 -1
  51. package/src/monitor/provider.lifecycle.test.ts +56 -1
  52. package/src/monitor/provider.lifecycle.ts +7 -0
  53. package/src/monitor/thread-bindings.discord-api.ts +6 -14
  54. package/src/proxy-request-client.ts +1 -34
  55. package/src/send.components.ts +2 -4
  56. package/src/send.outbound.ts +4 -5
  57. package/src/send.sends-basic-channel-messages.test.ts +22 -0
  58. package/src/send.shared.ts +3 -1
  59. package/src/setup-core.ts +37 -5
  60. package/src/setup-surface.test.ts +41 -0
  61. package/src/shared.test.ts +6 -0
  62. package/src/subagent-hooks.test.ts +91 -38
  63. 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 () => []);
@@ -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
- function stableComparableObject(value: unknown): unknown {
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
- return value.map((entry) => stableComparableObject(entry));
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]) => entry !== undefined)
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
  });
@@ -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(payload.d);
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
- if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) {
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.min(30_000, 1_000 * 2 ** Math.min(this.reconnectAttempts, 5));
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", () => {