Skip to content

Commit aaa194c

Browse files
committed
fix(discord): align internal gateway and component parity
1 parent d8b2550 commit aaa194c

4 files changed

Lines changed: 120 additions & 12 deletions

File tree

extensions/discord/src/internal/client.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,25 @@ describe("ComponentRegistry", () => {
8080
select,
8181
);
8282
});
83+
84+
it("uses each registered component parser when resolving specific keys", () => {
85+
const registry = new ComponentRegistry<Button>();
86+
class EncodedButton extends Button {
87+
label = "button";
88+
customId = "encoded:seed=one";
89+
customIdParser = (id: string) => ({
90+
key: id.startsWith("encoded:") ? "encoded" : parseCustomId(id).key,
91+
data: {},
92+
});
93+
}
94+
const button = new EncodedButton();
95+
96+
registry.register(button);
97+
98+
expect(registry.resolve("encoded:payload=two", { componentType: ComponentType.Button })).toBe(
99+
button,
100+
);
101+
});
83102
});
84103

85104
describe("Client.deployCommands", () => {

extensions/discord/src/internal/client.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,19 @@ export class ComponentRegistry<
7171
}
7272

7373
resolve(customId: string, options?: { componentType?: number }): T | undefined {
74-
const entries = [
75-
...(this.entries.get(parseRegistryKey(customId)) ?? []),
76-
...this.wildcardEntries,
77-
];
78-
return entries.find((entry) => {
74+
for (const entries of this.entries.values()) {
75+
const match = entries.find((entry) => {
76+
if (options?.componentType !== undefined && entry.type !== options.componentType) {
77+
return false;
78+
}
79+
const parser = entry.customIdParser ?? parseCustomId;
80+
return parseRegistryKey(entry.customId, parser) === parseRegistryKey(customId, parser);
81+
});
82+
if (match) {
83+
return match;
84+
}
85+
}
86+
return this.wildcardEntries.find((entry) => {
7987
if (options?.componentType !== undefined && entry.type !== options.componentType) {
8088
return false;
8189
}

extensions/discord/src/internal/gateway.test.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ class TestGatewayPlugin extends GatewayPlugin {
5656
}
5757
}
5858

59+
type GatewaySessionState = {
60+
sessionId: string | null;
61+
resumeGatewayUrl: string | null;
62+
sequence: number | null;
63+
};
64+
65+
function gatewaySessionState(gateway: GatewayPlugin): GatewaySessionState {
66+
return gateway as unknown as GatewaySessionState;
67+
}
68+
5969
describe("GatewayPlugin", () => {
6070
afterEach(() => {
6171
vi.useRealTimers();
@@ -263,20 +273,71 @@ describe("GatewayPlugin", () => {
263273
expect(gateway.sockets).toHaveLength(2);
264274
});
265275

266-
it("re-identifies after non-resumable gateway closes", async () => {
276+
it.each([GatewayCloseCodes.InvalidSeq, GatewayCloseCodes.AlreadyAuthenticated])(
277+
"re-identifies after non-resumable gateway close %s",
278+
async (closeCode) => {
279+
vi.useFakeTimers();
280+
const gateway = new TestGatewayPlugin({
281+
autoInteractions: false,
282+
url: "wss://gateway.example.test",
283+
});
284+
285+
gateway.connect(false);
286+
gateway.sockets[0]?.emit("open");
287+
gateway.sockets[0]?.emit("close", closeCode);
288+
await vi.advanceTimersByTimeAsync(2_000);
289+
290+
expect(gateway.connectCalls).toEqual([false, false]);
291+
expect(gateway.sockets).toHaveLength(2);
292+
},
293+
);
294+
295+
it("clears resume state after invalid session false", async () => {
267296
vi.useFakeTimers();
268297
const gateway = new TestGatewayPlugin({
269298
autoInteractions: false,
270299
url: "wss://gateway.example.test",
271300
});
301+
const sessionState = gatewaySessionState(gateway);
302+
sessionState.sessionId = "session1";
303+
sessionState.resumeGatewayUrl = "wss://resume.example.test";
304+
sessionState.sequence = 123;
272305

273306
gateway.connect(false);
274307
gateway.sockets[0]?.emit("open");
275-
gateway.sockets[0]?.emit("close", GatewayCloseCodes.InvalidSeq);
308+
(
309+
gateway as unknown as {
310+
handlePayload(payload: { op: number; d: unknown }, resume: boolean): void;
311+
}
312+
).handlePayload({ op: GatewayOpcodes.InvalidSession, d: false }, true);
276313
await vi.advanceTimersByTimeAsync(2_000);
277314

278315
expect(gateway.connectCalls).toEqual([false, false]);
279-
expect(gateway.sockets).toHaveLength(2);
316+
expect(sessionState.sessionId).toBeNull();
317+
expect(sessionState.resumeGatewayUrl).toBeNull();
318+
expect(sessionState.sequence).toBeNull();
319+
});
320+
321+
it("includes close code details when reconnect attempts are exhausted", async () => {
322+
vi.useFakeTimers();
323+
const gateway = new TestGatewayPlugin({
324+
autoInteractions: false,
325+
reconnect: { maxAttempts: 0 },
326+
url: "wss://gateway.example.test",
327+
});
328+
const errorSpy = vi.fn();
329+
gateway.emitter.on("error", errorSpy);
330+
331+
gateway.connect(false);
332+
gateway.sockets[0]?.emit("open");
333+
gateway.sockets[0]?.emit("close", 1006);
334+
await vi.advanceTimersByTimeAsync(30_000);
335+
336+
expect(errorSpy).toHaveBeenCalledWith(
337+
new Error("Max reconnect attempts (0) reached after close code 1006"),
338+
);
339+
expect(gateway.connectCalls).toEqual([false]);
340+
expect(gateway.sockets).toHaveLength(1);
280341
});
281342

282343
it("does not reconnect after fatal gateway closes", async () => {

extensions/discord/src/internal/gateway.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ function canResumeAfterGatewayClose(code: GatewayCloseCodes): boolean {
8585
return (
8686
code !== GatewayCloseCodes.NotAuthenticated &&
8787
code !== GatewayCloseCodes.InvalidSeq &&
88-
code !== GatewayCloseCodes.SessionTimedOut
88+
code !== GatewayCloseCodes.SessionTimedOut &&
89+
code !== GatewayCloseCodes.AlreadyAuthenticated
8990
);
9091
}
9192

@@ -232,7 +233,11 @@ export class GatewayPlugin extends Plugin {
232233
this.emitter.emit("error", new Error(`Fatal gateway close code: ${code}`));
233234
return;
234235
}
235-
this.scheduleReconnect(canResumeAfterGatewayClose(closeCode));
236+
const canResume = canResumeAfterGatewayClose(closeCode);
237+
if (!canResume) {
238+
this.resetSessionState();
239+
}
240+
this.scheduleReconnect(canResume, closeCode);
236241
});
237242
socket.on("error", (error) => {
238243
if (socket !== this.ws) {
@@ -282,6 +287,9 @@ export class GatewayPlugin extends Plugin {
282287
});
283288
break;
284289
case GatewayOpcodes.InvalidSession:
290+
if (!payload.d) {
291+
this.resetSessionState();
292+
}
285293
this.scheduleReconnect(payload.d);
286294
break;
287295
case GatewayOpcodes.Reconnect:
@@ -382,7 +390,13 @@ export class GatewayPlugin extends Plugin {
382390
}
383391
}
384392

385-
private scheduleReconnect(resume: boolean): void {
393+
private resetSessionState(): void {
394+
this.sessionId = null;
395+
this.resumeGatewayUrl = null;
396+
this.sequence = null;
397+
}
398+
399+
private scheduleReconnect(resume: boolean, closeCode?: number): void {
386400
if (!this.shouldReconnect) {
387401
return;
388402
}
@@ -395,7 +409,13 @@ export class GatewayPlugin extends Plugin {
395409
this.outboundLimiter.clear();
396410
this.reconnectAttempts += 1;
397411
if (this.reconnectAttempts > (this.options.reconnect?.maxAttempts ?? 50)) {
398-
this.emitter.emit("error", new Error("Max reconnect attempts reached"));
412+
const maxAttempts = this.options.reconnect?.maxAttempts ?? 50;
413+
this.emitter.emit(
414+
"error",
415+
new Error(
416+
`Max reconnect attempts (${maxAttempts}) reached${closeCode !== undefined ? ` after close code ${closeCode}` : ""}`,
417+
),
418+
);
399419
return;
400420
}
401421
const delay = Math.min(30_000, 1_000 * 2 ** Math.min(this.reconnectAttempts, 5));

0 commit comments

Comments
 (0)