Repository: discordjs/voice (ts)

Issue:
Allow the other side of the voice adapter to signal that it has been destroyed
If the producer of a voice adapter realises that the voice adapter can no longer be used (e.g. shard has been disconnected), it is currently unable to relay this information to @discordjs/voice.

In this situation, the producer should be able to signal to the library that the adapter can no longer be used, and the library should destroy the voice connection. When it is possible to recreate the voice adapter, the user can then recreate the voice connection.


Pull request description:
feat: allow adapter creator to call a destroy method
**Please describe the changes this PR makes and why it should be merged:**

Resolves #78.

When the provider of an adapter realises that the adapter it has provided is no longer valid (e.g. the shard of the guild it is for has disconnected), it can now call a `destroy()` method so that its VoiceConnection can self-destruct.

The example for a Discord.js adapter has been updated to reflect this.

Additionally, the existing `destroy()` function is now required (as opposed to optional), as not handling destroy calls is just bad behaviour.

**Status and versioning classification:**

- Code changes have been tested against the Discord API, or there are no code changes
- This PR changes the library's interface (methods or parameters added)
- This PR includes breaking changes (methods removed or renamed, parameters moved or removed)


Patch:
diff --git a/examples/basic/adapter.ts b/examples/basic/adapter.ts
index 2743452..73ab276 100644
--- a/examples/basic/adapter.ts
+++ b/examples/basic/adapter.ts
@@ -1,5 +1,5 @@
 import { DiscordGatewayAdapterCreator, DiscordGatewayAdapterLibraryMethods } from '../../';
-import { VoiceChannel, Snowflake, Client, Constants } from 'discord.js';
+import { VoiceChannel, Snowflake, Client, Constants, WebSocketShard, Guild } from 'discord.js';
 import {
 	GatewayVoiceServerUpdateDispatchData,
 	GatewayVoiceStateUpdateDispatchData,
@@ -25,6 +25,29 @@ function trackClient(client: Client) {
 	});
 }
 
+const trackedGuilds = new Map<WebSocketShard, Set<Snowflake>>();
+
+function cleanupGuilds(shard: WebSocketShard) {
+	const guilds = trackedGuilds.get(shard);
+	if (guilds) {
+		for (const guildID of guilds.values()) {
+			adapters.get(guildID)?.destroy();
+		}
+	}
+}
+
+function trackGuild(guild: Guild) {
+	let guilds = trackedGuilds.get(guild.shard);
+	if (!guilds) {
+		const cleanup = () => cleanupGuilds(guild.shard);
+		guild.shard.on('close', cleanup);
+		guild.shard.on('destroyed', cleanup);
+		guilds = new Set();
+		trackedGuilds.set(guild.shard, guilds);
+	}
+	guilds.add(guild.id);
+}
+
 /**
  * Creates an adapter for a Voice Channel
  * @param channel The channel to create the adapter for
@@ -33,6 +56,7 @@ export function createDiscordJSAdapter(channel: VoiceChannel): DiscordGatewayAda
 	return (methods) => {
 		adapters.set(channel.guild.id, methods);
 		trackClient(channel.client);
+		trackGuild(channel.guild);
 		return {
 			sendPayload(data) {
 				return channel.guild.shard.send(data);
diff --git a/src/VoiceConnection.ts b/src/VoiceConnection.ts
index fc6b616..d6e162b 100644
--- a/src/VoiceConnection.ts
+++ b/src/VoiceConnection.ts
@@ -158,6 +158,7 @@ export class VoiceConnection extends EventEmitter {
 		const adapter = adapterCreator({
 			onVoiceServerUpdate: (data) => this.addServerPacket(data),
 			onVoiceStateUpdate: (data) => this.addStatePacket(data),
+			destroy: () => this.destroy(false),
 		});
 
 		this._state = { status: VoiceConnectionStatus.Signalling, adapter };
@@ -203,7 +204,7 @@ export class VoiceConnection extends EventEmitter {
 
 		// If destroyed, the adapter can also be destroyed so it can be cleaned up by the user
 		if (oldState.status !== VoiceConnectionStatus.Destroyed && newState.status === VoiceConnectionStatus.Destroyed) {
-			oldState.adapter.destroy?.();
+			oldState.adapter.destroy();
 		}
 
 		this._state = newState;
@@ -396,15 +397,18 @@ export class VoiceConnection extends EventEmitter {
 	 * Destroys the VoiceConnection, preventing it from connecting to voice again.
 	 * This method should be called when you no longer require the VoiceConnection to
 	 * prevent memory leaks.
+	 * @param adapterAvailable - Whether the adapter can be used
 	 */
-	public destroy() {
+	public destroy(adapterAvailable = true) {
 		if (this.state.status === VoiceConnectionStatus.Destroyed) {
 			throw new Error('Cannot destroy VoiceConnection - it has already been destroyed');
 		}
 		if (getVoiceConnection(this.joinConfig.guildId) === this) {
 			untrackVoiceConnection(this.joinConfig.guildId);
 		}
-		this.state.adapter.sendPayload(createJoinVoiceChannelPayload({ ...this.joinConfig, channelId: null }));
+		if (adapterAvailable) {
+			this.state.adapter.sendPayload(createJoinVoiceChannelPayload({ ...this.joinConfig, channelId: null }));
+		}
 		this.state = {
 			status: VoiceConnectionStatus.Destroyed,
 		};
diff --git a/src/util/adapter.ts b/src/util/adapter.ts
index 3d4df38..f48ece6 100644
--- a/src/util/adapter.ts
+++ b/src/util/adapter.ts
@@ -10,6 +10,7 @@ import {
 export interface DiscordGatewayAdapterLibraryMethods {
 	onVoiceServerUpdate(data: GatewayVoiceServerUpdateDispatchData): void;
 	onVoiceStateUpdate(data: GatewayVoiceStateUpdateDispatchData): void;
+	destroy(): void;
 }
 
 /**
@@ -17,7 +18,7 @@ export interface DiscordGatewayAdapterLibraryMethods {
  */
 export interface DiscordGatewayAdapterImplementerMethods {
 	sendPayload(payload: any): void;
-	destroy?(): void;
+	destroy(): void;
 }
 
 /**


Test patch:
diff --git a/src/__tests__/VoiceConnection.test.ts b/src/__tests__/VoiceConnection.test.ts
index c61763b..5126c41 100644
--- a/src/__tests__/VoiceConnection.test.ts
+++ b/src/__tests__/VoiceConnection.test.ts
@@ -461,4 +461,11 @@ describe('Adapter', () => {
 		adapter.libMethods.onVoiceStateUpdate(dummy);
 		expect(voiceConnection['addStatePacket']).toHaveBeenCalledWith(dummy);
 	});
+
+	test('destroy', () => {
+		const { adapter, voiceConnection } = createFakeVoiceConnection();
+		adapter.libMethods.destroy();
+		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Destroyed);
+		expect(adapter.sendPayload).not.toHaveBeenCalled();
+	});
 });