Repository: discordjs/voice (ts)

Issue:
Support for multiple bots in the same guilds
The library doesn't allow for running two different bots in the same guild - voice connections are tracked only by a guild ID, so two bots are unable to share the same guild.

This should be resolved, e.g. passing an optional context parameter when creating/accessing voice connections to allow them to be separated.

This should be non-breaking and allow the current behaviour. The majority of users will probably only run a single bot in the process, and so forcing them to specify a context when creating these voice connections is unnecessary.

Pull request:
feat: voice channel groups
**Please describe the changes this PR makes and why it should be merged:**

Resolves #144.

VoiceConnections are now stored in a 2D map (`{group_id -> {guild_id -> voice_connection}}`).

The default global group ID is `default` - this is so the update is backwards compatible. Users may optionally specify their own group ID, e.g. the client ID, when creating their voice connections if they wish to run more than one bot in the process.

Also exposes two new methods:

- `DataStore#getGroups()` - get the full 2D group map
- `DataStore#getVoiceConnections(group = 'default')` - gets the map of guild IDs to voice connections for the given group

**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)

Patch:
diff --git a/examples/music-bot/package.json b/examples/music-bot/package.json
index 40d6316..4040d71 100644
--- a/examples/music-bot/package.json
+++ b/examples/music-bot/package.json
@@ -19,7 +19,7 @@
 		"discord.js": "^13.0.0-dev.02693bc02f45980d8165820a103220f0027b96b7",
 		"libsodium-wrappers": "^0.7.9",
 		"youtube-dl-exec": "^1.2.4",
-		"ytdl-core": "^4.8.2"
+		"ytdl-core": "^4.8.3"
 	},
 	"devDependencies": {
 		"tsconfig-paths": "^3.9.0",
diff --git a/package-lock.json b/package-lock.json
index 2150577..c939bde 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
 	"name": "@discordjs/voice",
-	"version": "0.5.1",
+	"version": "0.5.3",
 	"lockfileVersion": 2,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "@discordjs/voice",
-			"version": "0.5.1",
+			"version": "0.5.3",
 			"license": "MIT",
 			"dependencies": {
 				"@types/ws": "^7.4.4",
@@ -21,6 +21,7 @@
 				"@babel/preset-typescript": "^7.13.0",
 				"@commitlint/cli": "^12.1.4",
 				"@commitlint/config-angular": "^12.1.4",
+				"@favware/rollup-type-bundler": "^1.0.2",
 				"@types/jest": "^26.0.23",
 				"@types/node": "^15.12.2",
 				"@typescript-eslint/eslint-plugin": "^4.26.1",
@@ -1885,6 +1886,54 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
+		"node_modules/@favware/rollup-type-bundler": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@favware/rollup-type-bundler/-/rollup-type-bundler-1.0.2.tgz",
+			"integrity": "sha512-55RGSz5bxoVKiSXPiZOSi+cwYN2UsHCpj8FQcvl6OVQhTbVx8pW9mEXUTi++V2g0XwqK1eVBLIEd8kBsm2TuTw==",
+			"dev": true,
+			"dependencies": {
+				"colorette": "^1.2.2",
+				"commander": "^8.0.0",
+				"js-yaml": "^4.1.0",
+				"rollup": "^2.52.6",
+				"rollup-plugin-dts": "^3.0.2"
+			},
+			"bin": {
+				"rollup-type-bundler": "dist/cli.js",
+				"rtb": "dist/cli.js"
+			},
+			"engines": {
+				"node": ">=16",
+				"npm": ">=7"
+			}
+		},
+		"node_modules/@favware/rollup-type-bundler/node_modules/argparse": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+			"dev": true
+		},
+		"node_modules/@favware/rollup-type-bundler/node_modules/commander": {
+			"version": "8.0.0",
+			"resolved": "https://registry.npmjs.org/commander/-/commander-8.0.0.tgz",
+			"integrity": "sha512-Xvf85aAtu6v22+E5hfVoLHqyul/jyxh91zvqk/ioJTQuJR7Z78n7H558vMPKanPSRgIEeZemT92I2g9Y8LPbSQ==",
+			"dev": true,
+			"engines": {
+				"node": ">= 12"
+			}
+		},
+		"node_modules/@favware/rollup-type-bundler/node_modules/js-yaml": {
+			"version": "4.1.0",
+			"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+			"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+			"dev": true,
+			"dependencies": {
+				"argparse": "^2.0.1"
+			},
+			"bin": {
+				"js-yaml": "bin/js-yaml.js"
+			}
+		},
 		"node_modules/@istanbuljs/load-nyc-config": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -8214,6 +8263,15 @@
 			"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
 			"dev": true
 		},
+		"node_modules/magic-string": {
+			"version": "0.25.7",
+			"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
+			"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
+			"dev": true,
+			"dependencies": {
+				"sourcemap-codec": "^1.4.4"
+			}
+		},
 		"node_modules/make-dir": {
 			"version": "3.1.0",
 			"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -9417,6 +9475,43 @@
 				"url": "https://github.com/sponsors/isaacs"
 			}
 		},
+		"node_modules/rollup": {
+			"version": "2.52.7",
+			"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.7.tgz",
+			"integrity": "sha512-55cSH4CCU6MaPr9TAOyrIC+7qFCHscL7tkNsm1MBfIJRRqRbCEY0mmeFn4Wg8FKsHtEH8r389Fz38r/o+kgXLg==",
+			"dev": true,
+			"bin": {
+				"rollup": "dist/bin/rollup"
+			},
+			"engines": {
+				"node": ">=10.0.0"
+			},
+			"optionalDependencies": {
+				"fsevents": "~2.3.2"
+			}
+		},
+		"node_modules/rollup-plugin-dts": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-3.0.2.tgz",
+			"integrity": "sha512-hswlsdWu/x7k5pXzaLP6OvKRKcx8Bzprksz9i9mUe72zvt8LvqAb/AZpzs6FkLgmyRaN8B6rUQOVtzA3yEt9Yw==",
+			"dev": true,
+			"dependencies": {
+				"magic-string": "^0.25.7"
+			},
+			"engines": {
+				"node": ">=v12.22.1"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/Swatinem"
+			},
+			"optionalDependencies": {
+				"@babel/code-frame": "^7.12.13"
+			},
+			"peerDependencies": {
+				"rollup": "^2.48.0",
+				"typescript": "^4.2.4"
+			}
+		},
 		"node_modules/run-parallel": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -9647,6 +9742,12 @@
 				"node": ">=0.10.0"
 			}
 		},
+		"node_modules/sourcemap-codec": {
+			"version": "1.4.8",
+			"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+			"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+			"dev": true
+		},
 		"node_modules/spdx-correct": {
 			"version": "3.1.1",
 			"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
@@ -12145,6 +12246,42 @@
 				}
 			}
 		},
+		"@favware/rollup-type-bundler": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/@favware/rollup-type-bundler/-/rollup-type-bundler-1.0.2.tgz",
+			"integrity": "sha512-55RGSz5bxoVKiSXPiZOSi+cwYN2UsHCpj8FQcvl6OVQhTbVx8pW9mEXUTi++V2g0XwqK1eVBLIEd8kBsm2TuTw==",
+			"dev": true,
+			"requires": {
+				"colorette": "^1.2.2",
+				"commander": "^8.0.0",
+				"js-yaml": "^4.1.0",
+				"rollup": "^2.52.6",
+				"rollup-plugin-dts": "^3.0.2"
+			},
+			"dependencies": {
+				"argparse": {
+					"version": "2.0.1",
+					"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+					"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+					"dev": true
+				},
+				"commander": {
+					"version": "8.0.0",
+					"resolved": "https://registry.npmjs.org/commander/-/commander-8.0.0.tgz",
+					"integrity": "sha512-Xvf85aAtu6v22+E5hfVoLHqyul/jyxh91zvqk/ioJTQuJR7Z78n7H558vMPKanPSRgIEeZemT92I2g9Y8LPbSQ==",
+					"dev": true
+				},
+				"js-yaml": {
+					"version": "4.1.0",
+					"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+					"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+					"dev": true,
+					"requires": {
+						"argparse": "^2.0.1"
+					}
+				}
+			}
+		},
 		"@istanbuljs/load-nyc-config": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -16888,6 +17025,15 @@
 			"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
 			"dev": true
 		},
+		"magic-string": {
+			"version": "0.25.7",
+			"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
+			"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
+			"dev": true,
+			"requires": {
+				"sourcemap-codec": "^1.4.4"
+			}
+		},
 		"make-dir": {
 			"version": "3.1.0",
 			"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -17769,6 +17915,25 @@
 				"glob": "^7.1.3"
 			}
 		},
+		"rollup": {
+			"version": "2.52.7",
+			"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.52.7.tgz",
+			"integrity": "sha512-55cSH4CCU6MaPr9TAOyrIC+7qFCHscL7tkNsm1MBfIJRRqRbCEY0mmeFn4Wg8FKsHtEH8r389Fz38r/o+kgXLg==",
+			"dev": true,
+			"requires": {
+				"fsevents": "~2.3.2"
+			}
+		},
+		"rollup-plugin-dts": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-3.0.2.tgz",
+			"integrity": "sha512-hswlsdWu/x7k5pXzaLP6OvKRKcx8Bzprksz9i9mUe72zvt8LvqAb/AZpzs6FkLgmyRaN8B6rUQOVtzA3yEt9Yw==",
+			"dev": true,
+			"requires": {
+				"@babel/code-frame": "^7.12.13",
+				"magic-string": "^0.25.7"
+			}
+		},
 		"run-parallel": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -17941,6 +18106,12 @@
 				}
 			}
 		},
+		"sourcemap-codec": {
+			"version": "1.4.8",
+			"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+			"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+			"dev": true
+		},
 		"spdx-correct": {
 			"version": "3.1.1",
 			"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
diff --git a/package.json b/package.json
index 3359c96..3a2a408 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@discordjs/voice",
-	"version": "0.5.1",
+	"version": "0.5.3",
 	"description": "Implementation of the Discord Voice API for Node.js",
 	"main": "dist/index.js",
 	"typings": "dist/index.d.ts",
@@ -17,7 +17,8 @@
 		"prettier": "prettier --write **/*.{ts,js,json,yml,yaml}",
 		"build": "tsc",
 		"build:check": "tsc --noEmit --incremental false",
-		"docs": "typedoc src/index.ts"
+		"docs": "typedoc src/index.ts",
+		"prepublishOnly": "rollup-type-bundler -e stream"
 	},
 	"repository": {
 		"type": "git",
@@ -50,6 +51,7 @@
 		"@babel/preset-typescript": "^7.13.0",
 		"@commitlint/cli": "^12.1.4",
 		"@commitlint/config-angular": "^12.1.4",
+		"@favware/rollup-type-bundler": "^1.0.2",
 		"@types/jest": "^26.0.23",
 		"@types/node": "^15.12.2",
 		"@typescript-eslint/eslint-plugin": "^4.26.1",
diff --git a/src/DataStore.ts b/src/DataStore.ts
index ac4f4d6..2e2c73c 100644
--- a/src/DataStore.ts
+++ b/src/DataStore.ts
@@ -7,6 +7,7 @@ export interface JoinConfig {
 	channelId: string | null;
 	selfDeaf: boolean;
 	selfMute: boolean;
+	group: string;
 }
 
 /**
@@ -28,18 +29,53 @@ export function createJoinVoiceChannelPayload(config: JoinConfig) {
 }
 
 // Voice Connections
-const voiceConnections: Map<string, VoiceConnection> = new Map();
+const groups: Map<string, Map<string, VoiceConnection>> = new Map();
+groups.set('default', new Map());
+
+function getOrCreateGroup(group: string) {
+	const existing = groups.get(group);
+	if (existing) return existing;
+	const map = new Map();
+	groups.set(group, map);
+	return map;
+}
+
+/**
+ * Retrieves the map of group names to maps of voice connections. By default, all voice connections
+ * are created under the 'default' group.
+ * @returns The group map
+ */
+export function getGroups() {
+	return groups;
+}
 
-export function getVoiceConnection(guildId: string) {
-	return voiceConnections.get(guildId);
+export function getVoiceConnections(group?: 'default'): Map<string, VoiceConnection>;
+export function getVoiceConnections(group: string): Map<string, VoiceConnection> | undefined;
+/**
+ * Retrieves all the voice connections under the given group name. Defaults to the 'default' group.
+ * @param group - The group to look up
+ * @returns The map of voice connections
+ */
+export function getVoiceConnections(group = 'default') {
+	return groups.get(group);
+}
+
+/**
+ * Finds a voice connection with the given guild ID and group. Defaults to the 'default' group.
+ * @param guildId - The guild ID of the voice connection
+ * @param group - the group that the voice connection was registered with
+ * @returns The voice connection, if it exists
+ */
+export function getVoiceConnection(guildId: string, group = 'default') {
+	return getVoiceConnections(group)?.get(guildId);
 }
 
-export function untrackVoiceConnection(guildId: string) {
-	return voiceConnections.delete(guildId);
+export function untrackVoiceConnection(voiceConnection: VoiceConnection) {
+	return getVoiceConnections(voiceConnection.joinConfig.group)?.delete(voiceConnection.joinConfig.guildId);
 }
 
-export function trackVoiceConnection(guildId: string, voiceConnection: VoiceConnection) {
-	return voiceConnections.set(guildId, voiceConnection);
+export function trackVoiceConnection(voiceConnection: VoiceConnection) {
+	return getOrCreateGroup(voiceConnection.joinConfig.group).set(voiceConnection.joinConfig.guildId, voiceConnection);
 }
 
 // Audio Players
diff --git a/src/VoiceConnection.ts b/src/VoiceConnection.ts
index bb91e27..41dd93a 100644
--- a/src/VoiceConnection.ts
+++ b/src/VoiceConnection.ts
@@ -478,7 +478,7 @@ export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
 			throw new Error('Cannot destroy VoiceConnection - it has already been destroyed');
 		}
 		if (getVoiceConnection(this.joinConfig.guildId) === this) {
-			untrackVoiceConnection(this.joinConfig.guildId);
+			untrackVoiceConnection(this);
 		}
 		if (adapterAvailable) {
 			this.state.adapter.sendPayload(createJoinVoiceChannelPayload({ ...this.joinConfig, channelId: null }));
@@ -527,7 +527,7 @@ export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
 	 *
 	 * A state transition from Disconnected to Signalling will be observed when this is called.
 	 */
-	public rejoin(joinConfig?: Omit<JoinConfig, 'guildId'>) {
+	public rejoin(joinConfig?: Omit<JoinConfig, 'guildId' | 'group'>) {
 		if (this.state.status === VoiceConnectionStatus.Destroyed) {
 			return false;
 		}
@@ -652,7 +652,7 @@ export function createVoiceConnection(joinConfig: JoinConfig, options: CreateVoi
 	}
 
 	const voiceConnection = new VoiceConnection(joinConfig, options);
-	trackVoiceConnection(joinConfig.guildId, voiceConnection);
+	trackVoiceConnection(voiceConnection);
 	if (voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) {
 		if (!voiceConnection.state.adapter.sendPayload(payload)) {
 			voiceConnection.state = {
diff --git a/src/index.ts b/src/index.ts
index d792b7a..a86a747 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -19,4 +19,4 @@ export {
 	VoiceConnectionEvents,
 } from './VoiceConnection';
 
-export { getVoiceConnection } from './DataStore';
+export { getVoiceConnection, getVoiceConnections, getGroups } from './DataStore';
diff --git a/src/joinVoiceChannel.ts b/src/joinVoiceChannel.ts
index 3889922..85de47d 100644
--- a/src/joinVoiceChannel.ts
+++ b/src/joinVoiceChannel.ts
@@ -34,6 +34,10 @@ export interface JoinVoiceChannelOptions {
 	 * Whether to join the channel muted (defaults to true)
 	 */
 	selfMute?: boolean;
+	/**
+	 * An optional group identifier for the voice connection
+	 */
+	group?: string;
 }
 
 /**
@@ -46,6 +50,7 @@ export function joinVoiceChannel(options: JoinVoiceChannelOptions & CreateVoiceC
 	const joinConfig: JoinConfig = {
 		selfDeaf: true,
 		selfMute: false,
+		group: 'default',
 		...options,
 	};
 


Test patch:
diff --git a/src/__tests__/DataStore.test.ts b/src/__tests__/DataStore.test.ts
new file mode 100644
index 0000000..1253fbf
--- /dev/null
+++ b/src/__tests__/DataStore.test.ts
@@ -0,0 +1,41 @@
+/* eslint-disable @typescript-eslint/dot-notation */
+import * as DataStore from '../DataStore';
+import { VoiceConnection } from '../VoiceConnection';
+jest.mock('../VoiceConnection');
+
+function createVoiceConnection(joinConfig: Pick<DataStore.JoinConfig, 'group' | 'guildId'>): VoiceConnection {
+	return {
+		joinConfig: { channelId: '123', selfMute: false, selfDeaf: true, ...joinConfig },
+	} as any;
+}
+
+beforeEach(() => {
+	const groups = DataStore.getGroups();
+	for (const groupKey of groups.keys()) {
+		groups.delete(groupKey);
+	}
+	groups.set('default', new Map());
+});
+
+const voiceConnectionDefault = createVoiceConnection({ guildId: '123', group: 'default' });
+const voiceConnectionAbc = createVoiceConnection({ guildId: '123', group: 'abc' });
+
+describe('DataStore', () => {
+	test('VoiceConnection management respects group', () => {
+		DataStore.trackVoiceConnection(voiceConnectionDefault);
+		DataStore.trackVoiceConnection(voiceConnectionAbc);
+		expect(DataStore.getVoiceConnection('123')).toBe(voiceConnectionDefault);
+		expect(DataStore.getVoiceConnection('123', 'default')).toBe(voiceConnectionDefault);
+		expect(DataStore.getVoiceConnection('123', 'abc')).toBe(voiceConnectionAbc);
+
+		expect([...DataStore.getGroups().keys()]).toEqual(['default', 'abc']);
+
+		expect([...DataStore.getVoiceConnections().values()]).toEqual([voiceConnectionDefault]);
+		expect([...DataStore.getVoiceConnections('default').values()]).toEqual([voiceConnectionDefault]);
+		expect([...DataStore.getVoiceConnections('abc').values()]).toEqual([voiceConnectionAbc]);
+
+		DataStore.untrackVoiceConnection(voiceConnectionDefault);
+		expect(DataStore.getVoiceConnection('123')).toBeUndefined();
+		expect(DataStore.getVoiceConnection('123', 'abc')).toBe(voiceConnectionAbc);
+	});
+});
diff --git a/src/__tests__/VoiceConnection.test.ts b/src/__tests__/VoiceConnection.test.ts
index 35e873d..37db1e2 100644
--- a/src/__tests__/VoiceConnection.test.ts
+++ b/src/__tests__/VoiceConnection.test.ts
@@ -48,6 +48,7 @@ function createJoinConfig() {
 		guildId: '2',
 		selfDeaf: true,
 		selfMute: false,
+		group: 'default',
 	};
 }
 
@@ -80,7 +81,7 @@ describe('createVoiceConnection', () => {
 		});
 		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Signalling);
 		expect(DataStore.getVoiceConnection).toHaveBeenCalledTimes(1);
-		expect(DataStore.trackVoiceConnection).toHaveBeenCalledWith(joinConfig.guildId, voiceConnection);
+		expect(DataStore.trackVoiceConnection).toHaveBeenCalledWith(voiceConnection);
 		expect(DataStore.untrackVoiceConnection).not.toHaveBeenCalled();
 		expect(adapter.sendPayload).toHaveBeenCalledWith(mockPayload);
 	});
@@ -97,7 +98,7 @@ describe('createVoiceConnection', () => {
 		});
 		expect(voiceConnection.state.status).toBe(VoiceConnectionStatus.Disconnected);
 		expect(DataStore.getVoiceConnection).toHaveBeenCalledTimes(1);
-		expect(DataStore.trackVoiceConnection).toHaveBeenCalledWith(joinConfig.guildId, voiceConnection);
+		expect(DataStore.trackVoiceConnection).toHaveBeenCalledWith(voiceConnection);
 		expect(DataStore.untrackVoiceConnection).not.toHaveBeenCalled();
 		expect(adapter.sendPayload).toHaveBeenCalledWith(mockPayload);
 	});
@@ -160,7 +161,7 @@ describe('createVoiceConnection', () => {
 
 		const newAdapter = createFakeAdapter();
 		const newJoinConfig = createJoinConfig();
-		const { guildId, ...rejoinConfig } = newJoinConfig;
+		const { guildId, group, ...rejoinConfig } = newJoinConfig;
 		const newVoiceConnection = createVoiceConnection(newJoinConfig, {
 			debug: false,
 			adapterCreator: newAdapter.creator,
@@ -467,7 +468,7 @@ describe('VoiceConnection#destroy', () => {
 		DataStore.createJoinVoiceChannelPayload.mockImplementation(() => dummy as any);
 		voiceConnection.destroy();
 		expect(DataStore.getVoiceConnection).toHaveReturnedWith(voiceConnection);
-		expect(DataStore.untrackVoiceConnection).toHaveBeenCalledWith(joinConfig.guildId);
+		expect(DataStore.untrackVoiceConnection).toHaveBeenCalledWith(voiceConnection);
 		expect(DataStore.createJoinVoiceChannelPayload.mock.calls[0][0]).toMatchObject({
 			channelId: null,
 			guildId: joinConfig.guildId,
diff --git a/src/__tests__/joinVoiceChannel.test.ts b/src/__tests__/joinVoiceChannel.test.ts
new file mode 100644
index 0000000..612d9a8
--- /dev/null
+++ b/src/__tests__/joinVoiceChannel.test.ts
@@ -0,0 +1,42 @@
+import { joinVoiceChannel } from '../joinVoiceChannel';
+import * as VoiceConnection from '../VoiceConnection';
+
+const adapterCreator = () => ({ destroy: jest.fn(), send: jest.fn() } as any);
+const createVoiceConnection = jest.spyOn(VoiceConnection, 'createVoiceConnection');
+
+beforeAll(() => {
+	createVoiceConnection.mockImplementation(() => null as any);
+});
+
+beforeEach(() => {
+	createVoiceConnection.mockClear();
+});
+
+describe('joinVoiceChannel', () => {
+	test('Uses default group', () => {
+		joinVoiceChannel({
+			channelId: '123',
+			guildId: '456',
+			adapterCreator,
+		});
+		expect(createVoiceConnection.mock.calls[0][0]).toMatchObject({
+			channelId: '123',
+			guildId: '456',
+			group: 'default',
+		});
+	});
+
+	test('Respects custom group', () => {
+		joinVoiceChannel({
+			channelId: '123',
+			guildId: '456',
+			group: 'abc',
+			adapterCreator,
+		});
+		expect(createVoiceConnection.mock.calls[0][0]).toMatchObject({
+			channelId: '123',
+			guildId: '456',
+			group: 'abc',
+		});
+	});
+});