Repository: discordjs/voice
Pull request #129 (2021-06-16 11:13:50)

feat!(VoiceConnection): add disconnect and rename reconnect to rejoin
**Please describe the changes this PR makes and why it should be merged:**

- Adds `VoiceConnection#disconnect` if you want to keep the VoiceConnection instance while still disconnecting from voice.
- Rename `VoiceConnection#reconnect` to `VoiceConnection#rejoin` for accuracy (it actually attempts to rejoin the voice channel, not just the voice server)
- Rename `VoiceConnection#reconnectAttempts` to `VoiceConnection#rejoinAttempts`
- Allow `VoiceConnection#rejoin` to take a partial JoinConfig. For example, `vc.rejoin({ channelId: 'newid' })`

**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)

codecov[bot]: # [Codecov](https://codecov.io/gh/discordjs/voice/pull/129?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs) Report
> Merging [#129](https://codecov.io/gh/discordjs/voice/pull/129?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs) (c872cd6) into [main](https://codecov.io/gh/discordjs/voice/commit/e9c3a94070cc55cb0e9cc664c084c72c868d9ab1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs) (e9c3a94) will **decrease** coverage by `0.59%`.
> The diff coverage is `25.00%`.

[![Impacted file tree graph](https://codecov.io/gh/discordjs/voice/pull/129/graphs/tree.svg?width=650&height=150&src=pr&token=u7oQ23UoxX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs)](https://codecov.io/gh/discordjs/voice/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs)

```diff
@@            Coverage Diff             @@
##             main     #129      +/-   ##
==========================================
- Coverage   66.25%   65.65%   -0.60%     
==========================================
  Files          19       19              
  Lines         880      891      +11     
  Branches      214      218       +4     
==========================================
+ Hits          583      585       +2     
- Misses        295      304       +9     
  Partials        2        2              
```


| [Impacted Files](https://codecov.io/gh/discordjs/voice/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs) | Coverage Δ | |
|---|---|---|
| [src/VoiceConnection.ts](https://codecov.io/gh/discordjs/voice/pull/129/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs#diff-c3JjL1ZvaWNlQ29ubmVjdGlvbi50cw==) | `74.80% <25.00%> (-5.20%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/discordjs/voice/pull/129?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/discordjs/voice/pull/129?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs). Last update [e9c3a94...c872cd6](https://codecov.io/gh/discordjs/voice/pull/129?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=discordjs).


Patch:
diff --git a/examples/music-bot/src/music/subscription.ts b/examples/music-bot/src/music/subscription.ts
index ab0c4cc..932c9fd 100644
--- a/examples/music-bot/src/music/subscription.ts
+++ b/examples/music-bot/src/music/subscription.ts
@@ -47,12 +47,12 @@ export class MusicSubscription {
 						this.voiceConnection.destroy();
 						// Probably removed from voice channel
 					}
-				} else if (this.voiceConnection.reconnectAttempts < 5) {
+				} else if (this.voiceConnection.rejoinAttempts < 5) {
 					/*
 						The disconnect in this case is recoverable, and we also have <5 repeated attempts so we will reconnect.
 					*/
-					await wait((this.voiceConnection.reconnectAttempts + 1) * 5_000);
-					this.voiceConnection.reconnect();
+					await wait((this.voiceConnection.rejoinAttempts + 1) * 5_000);
+					this.voiceConnection.rejoin();
 				} else {
 					/*
 						The disconnect in this case may be recoverable, but we have no more remaining attempts - destroy.
diff --git a/src/VoiceConnection.ts b/src/VoiceConnection.ts
index b7b1acf..bb9cac7 100644
--- a/src/VoiceConnection.ts
+++ b/src/VoiceConnection.ts
@@ -66,6 +66,10 @@ export enum VoiceConnectionDisconnectReason {
 	 * When a VOICE_SERVER_UPDATE packet is received with a null endpoint, causing the connection to be severed.
 	 */
 	EndpointRemoved,
+	/**
+	 * When a manual disconnect was requested.
+	 */
+	Manual,
 }
 
 /**
@@ -160,10 +164,10 @@ export type VoiceConnectionEvents = {
  */
 export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
 	/**
-	 * The number of consecutive reconnect attempts. Initially 0, and increments for each reconnect.
+	 * The number of consecutive rejoin attempts. Initially 0, and increments for each rejoin.
 	 * When a connection is successfully established, it resets to 0.
 	 */
-	public reconnectAttempts: number;
+	public rejoinAttempts: number;
 
 	/**
 	 * The state of the voice connection
@@ -200,7 +204,7 @@ export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
 		super();
 
 		this.debug = debug ? (message: string) => this.emit('debug', message) : null;
-		this.reconnectAttempts = 0;
+		this.rejoinAttempts = 0;
 
 		this.onNetworkingClose = this.onNetworkingClose.bind(this);
 		this.onNetworkingStateChange = this.onNetworkingStateChange.bind(this);
@@ -251,7 +255,7 @@ export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
 		}
 
 		if (newState.status === VoiceConnectionStatus.Ready) {
-			this.reconnectAttempts = 0;
+			this.rejoinAttempts = 0;
 		}
 
 		// If destroyed, the adapter can also be destroyed so it can be cleaned up by the user
@@ -375,7 +379,7 @@ export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
 				...this.state,
 				status: VoiceConnectionStatus.Signalling,
 			};
-			this.reconnectAttempts++;
+			this.rejoinAttempts++;
 			if (!this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) {
 				this.state = {
 					...this.state,
@@ -482,34 +486,65 @@ export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
 	}
 
 	/**
-	 * Attempts to reconnect the VoiceConnection if it is in the Disconnected state.
+	 * Disconnects the VoiceConnection, allowing the possibility of rejoining later on.
+	 * @returns - true if the connection was successfully disconnected.
+	 */
+	public disconnect() {
+		if (
+			this.state.status === VoiceConnectionStatus.Destroyed ||
+			this.state.status === VoiceConnectionStatus.Signalling
+		) {
+			return false;
+		}
+		if (!this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) {
+			this.state = {
+				adapter: this.state.adapter,
+				subscription: this.state.subscription,
+				status: VoiceConnectionStatus.Disconnected,
+				reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
+			};
+			return false;
+		}
+		this.state = {
+			adapter: this.state.adapter,
+			reason: VoiceConnectionDisconnectReason.Manual,
+			status: VoiceConnectionStatus.Disconnected,
+		};
+		return true;
+	}
+
+	/**
+	 * Attempts to rejoin (better explanation soon:tm:)
 	 *
 	 * @remarks
-	 * Calling this method successfully will automatically increment the `reconnectAttempts` counter,
+	 * Calling this method successfully will automatically increment the `rejoinAttempts` counter,
 	 * which you can use to inform whether or not you'd like to keep attempting to reconnect your
 	 * voice connection.
 	 *
 	 * A state transition from Disconnected to Signalling will be observed when this is called.
 	 */
-	public reconnect() {
-		if (this.state.status !== VoiceConnectionStatus.Disconnected) {
+	public rejoin(joinConfig?: Omit<JoinConfig, 'guildId'>) {
+		if (this.state.status === VoiceConnectionStatus.Destroyed) {
 			return false;
 		}
 
-		this.reconnectAttempts++;
-		if (!this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) {
+		this.rejoinAttempts++;
+		Object.assign(this.joinConfig, joinConfig);
+		if (this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) {
 			this.state = {
 				...this.state,
-				status: VoiceConnectionStatus.Disconnected,
+				status: VoiceConnectionStatus.Signalling,
 			};
-			return false;
+			return true;
 		}
 
 		this.state = {
-			...this.state,
-			status: VoiceConnectionStatus.Signalling,
+			adapter: this.state.adapter,
+			subscription: this.state.subscription,
+			status: VoiceConnectionStatus.Disconnected,
+			reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
 		};
-		return true;
+		return false;
 	}
 
 	/**
@@ -592,7 +627,13 @@ export function createVoiceConnection(joinConfig: JoinConfig, options: CreateVoi
 	const payload = createJoinVoiceChannelPayload(joinConfig);
 	const existing = getVoiceConnection(joinConfig.guildId);
 	if (existing && existing.state.status !== VoiceConnectionStatus.Destroyed) {
-		if (!existing.state.adapter.sendPayload(payload)) {
+		if (existing.state.status === VoiceConnectionStatus.Disconnected) {
+			existing.rejoin({
+				channelId: joinConfig.channelId,
+				selfDeaf: joinConfig.selfDeaf,
+				selfMute: joinConfig.selfMute,
+			});
+		} else if (!existing.state.adapter.sendPayload(payload)) {
 			existing.state = {
 				...existing.state,
 				status: VoiceConnectionStatus.Disconnected,


Test patch:
diff --git a/src/__tests__/VoiceConnection.test.ts b/src/__tests__/VoiceConnection.test.ts
index fe3264f..07b9c00 100644
--- a/src/__tests__/VoiceConnection.test.ts
+++ b/src/__tests__/VoiceConnection.test.ts
@@ -3,6 +3,7 @@ import {
 	createVoiceConnection,
 	VoiceConnection,
 	VoiceConnectionConnectingState,
+	VoiceConnectionDisconnectReason,
 	VoiceConnectionSignallingState,
 	VoiceConnectionStatus,
 } from '../VoiceConnection';
@@ -133,6 +134,44 @@ describe('createVoiceConnection', () => {
 		expect(stateSetter).not.toHaveBeenCalled();
 	});
 
+	test('Calls rejoin() on existing disconnected connection', () => {
+		const mockPayload = Symbol('mock') as any;
+
+		DataStore.createJoinVoiceChannelPayload.mockImplementation(() => mockPayload);
+
+		const existingAdapter = createFakeAdapter();
+		const existingJoinConfig = createJoinConfig();
+		const existingVoiceConnection = new VoiceConnection(existingJoinConfig, {
+			debug: false,
+			adapterCreator: existingAdapter.creator,
+		});
+		existingVoiceConnection.state = {
+			status: VoiceConnectionStatus.Disconnected,
+			adapter: existingAdapter,
+			reason: VoiceConnectionDisconnectReason.EndpointRemoved,
+		};
+
+		const rejoinSpy = jest.spyOn(existingVoiceConnection, 'rejoin');
+
+		DataStore.getVoiceConnection.mockImplementation((guildId) =>
+			guildId === existingJoinConfig.guildId ? existingVoiceConnection : null,
+		);
+
+		const newAdapter = createFakeAdapter();
+		const newJoinConfig = createJoinConfig();
+		const { guildId, ...rejoinConfig } = newJoinConfig;
+		const newVoiceConnection = createVoiceConnection(newJoinConfig, {
+			debug: false,
+			adapterCreator: newAdapter.creator,
+		});
+		expect(DataStore.getVoiceConnection).toHaveBeenCalledWith(newJoinConfig.guildId);
+		expect(DataStore.trackVoiceConnection).not.toHaveBeenCalled();
+		expect(DataStore.untrackVoiceConnection).not.toHaveBeenCalled();
+		expect(newAdapter.creator).not.toHaveBeenCalled();
+		expect(rejoinSpy).toHaveBeenCalledWith(rejoinConfig);
+		expect(newVoiceConnection).toBe(existingVoiceConnection);
+	});
+
 	test('Reconfiguring existing connection with adapter failure', () => {
 		const mockPayload = Symbol('mock') as any;
 
@@ -317,7 +356,7 @@ describe('VoiceConnection#onNetworkingClose', () => {
 		expect(adapter.sendPayload).not.toHaveBeenCalled();
 	});
 
-	test('Attempts reconnect for codes != 4014', () => {
+	test('Attempts rejoin for codes != 4014', () => {
 		const dummyPayload = Symbol('dummy') as any;
 		const { voiceConnection, adapter, joinConfig } = createFakeVoiceConnection();
 		DataStore.createJoinVoiceChannelPayload.mockImplementation((config) =>
@@ -326,10 +365,10 @@ describe('VoiceConnection#onNetworkingClose', () => {
 		voiceConnection['onNetworkingClose'](1234);
 		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Signalling);
 		expect(adapter.sendPayload).toHaveBeenCalledWith(dummyPayload);
-		expect(voiceConnection.reconnectAttempts).toBe(1);
+		expect(voiceConnection.rejoinAttempts).toBe(1);
 	});
 
-	test('Attempts reconnect for codes != 4014 (with adapter failure)', () => {
+	test('Attempts rejoin for codes != 4014 (with adapter failure)', () => {
 		const dummyPayload = Symbol('dummy') as any;
 		const { voiceConnection, adapter, joinConfig } = createFakeVoiceConnection();
 		DataStore.createJoinVoiceChannelPayload.mockImplementation((config) =>
@@ -339,7 +378,7 @@ describe('VoiceConnection#onNetworkingClose', () => {
 		voiceConnection['onNetworkingClose'](1234);
 		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Disconnected);
 		expect(adapter.sendPayload).toHaveBeenCalledWith(dummyPayload);
-		expect(voiceConnection.reconnectAttempts).toBe(1);
+		expect(voiceConnection.rejoinAttempts).toBe(1);
 	});
 });
 
@@ -437,17 +476,50 @@ describe('VoiceConnection#destroy', () => {
 	});
 });
 
-describe('VoiceConnection#reconnect', () => {
-	test('Does nothing in a non-disconnected state', () => {
+describe('VoiceConnection#disconnect', () => {
+	test('Fails in Destroyed and Signalling states', () => {
 		const { voiceConnection, adapter } = createFakeVoiceConnection();
-		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Signalling);
-		expect(voiceConnection.reconnect()).toBe(false);
-		expect(voiceConnection.reconnectAttempts).toBe(0);
-		expect(adapter.sendPayload).not.toHaveBeenCalled();
+		voiceConnection.state = { status: VoiceConnectionStatus.Destroyed };
+		expect(voiceConnection.disconnect()).toBe(false);
+		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Destroyed);
+
+		voiceConnection.state = { status: VoiceConnectionStatus.Signalling, adapter };
+		expect(voiceConnection.disconnect()).toBe(false);
 		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Signalling);
 	});
 
-	test('Reconnects in a disconnected state', () => {
+	test('Disconnects - available adapter', () => {
+		const { voiceConnection, adapter } = createFakeVoiceConnection();
+		voiceConnection.state = {
+			status: VoiceConnectionStatus.Ready,
+			adapter,
+			networking: new Networking.Networking({} as any, false),
+		};
+		expect(voiceConnection.disconnect()).toBe(true);
+		expect(voiceConnection.state).toMatchObject({
+			status: VoiceConnectionStatus.Disconnected,
+			reason: VoiceConnectionDisconnectReason.Manual,
+		});
+	});
+
+	test('Disconnects - unavailable adapter', () => {
+		const { voiceConnection, adapter } = createFakeVoiceConnection();
+		voiceConnection.state = {
+			status: VoiceConnectionStatus.Ready,
+			adapter,
+			networking: new Networking.Networking({} as any, false),
+		};
+		adapter.sendPayload.mockImplementation(() => false);
+		expect(voiceConnection.disconnect()).toBe(false);
+		expect(voiceConnection.state).toMatchObject({
+			status: VoiceConnectionStatus.Disconnected,
+			reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
+		});
+	});
+});
+
+describe('VoiceConnection#rejoin', () => {
+	test('Rejoins in a disconnected state', () => {
 		const dummy = Symbol('dummy') as any;
 		DataStore.createJoinVoiceChannelPayload.mockImplementation(() => dummy);
 
@@ -455,10 +527,11 @@ describe('VoiceConnection#reconnect', () => {
 		voiceConnection.state = {
 			...(voiceConnection.state as VoiceConnectionSignallingState),
 			status: VoiceConnectionStatus.Disconnected,
+			reason: VoiceConnectionDisconnectReason.WebSocketClose,
 			closeCode: 1000,
 		};
-		expect(voiceConnection.reconnect()).toBe(true);
-		expect(voiceConnection.reconnectAttempts).toBe(1);
+		expect(voiceConnection.rejoin()).toBe(true);
+		expect(voiceConnection.rejoinAttempts).toBe(1);
 		expect(adapter.sendPayload).toHaveBeenCalledWith(dummy);
 		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Signalling);
 	});
@@ -471,11 +544,12 @@ describe('VoiceConnection#reconnect', () => {
 		voiceConnection.state = {
 			...(voiceConnection.state as VoiceConnectionSignallingState),
 			status: VoiceConnectionStatus.Disconnected,
+			reason: VoiceConnectionDisconnectReason.WebSocketClose,
 			closeCode: 1000,
 		};
 		adapter.sendPayload.mockReturnValue(false);
-		expect(voiceConnection.reconnect()).toBe(false);
-		expect(voiceConnection.reconnectAttempts).toBe(1);
+		expect(voiceConnection.rejoin()).toBe(false);
+		expect(voiceConnection.rejoinAttempts).toBe(1);
 		expect(adapter.sendPayload).toHaveBeenCalledWith(dummy);
 		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Disconnected);
 	});