discord-voice
Version:
A complete framework to facilitate the tracking of user voice time using discord.js
550 lines (547 loc) • 21.4 kB
JavaScript
const { EventEmitter } = require("events");
const merge = require("deepmerge");
const { writeFile, readFile, access } = require('fs/promises');
const serialize = require("serialize-javascript");
const lodash = require("lodash");
const { defaultVoiceManagerOptions, defaultUserOptions, defaultConfigOptions, VoiceManagerOptions, UserOptions, ConfigOptions, UserData, ConfigData, UserEditOptions, ConfigEditOptions } = require("./Constants.js");
const Config = require("./Config.js");
const User = require("./User.js");
/**
* Voice Manager
* @example
* // Requires Manager from discord-voice
* const { VoiceManager } = require("discord-voice");
* // Create a new instance of the manager class
* const manager = new VoiceManager(client, {
* userStorage: "./users.json",
* configStorage: "./configs.json",
* checkMembersEvery: 5000,
* default: {
* trackBots: false,
* trackAllChannels: true,
* },
* });
* // We now have a voiceManager property to access the manager everywhere!
* client.voiceManager = manager;
*/
class VoiceManager extends EventEmitter {
/**
* @param {Client} client The Discord Client
* @param {VoiceManagerOptions} options The manager options
*/
constructor(client, options, init = true) {
super();
if (!client?.options) throw new Error(`Client is a required option. (val=${client})`);
/**
* The Discord Client
* @type {Client}
*/
this.client = client;
/**
* Whether the manager is ready
* @type {Boolean}
*/
this.ready = false;
/**
* The user's managed by this manager
* @type {User[]}
*/
this.users = [];
/**
* The config's managed by this manager
* @type {Config[]}
*/
this.configs = [];
/**
* The manager options
* @type {VoiceManagerOptions}
*/
this.options = merge(defaultVoiceManagerOptions, options);
if (init) this._init();
}
/**
* Creates a new user in the database
*
* @param {Snowflake} userId The id of the user
* @param {Snowflake} guildId The id of the user's guild
* @param {UserOptions} options The options for the user
*
* @returns {Promise<User>}
*
* @example
* manager.createUser(message.author.id, message.guild.id, {
* levelingData: {
* xp: 0,
* level: 0,
* },
* // The user will have 0 xp and 0 level.
* });
*/
createUser(userId, guildId, options) {
return new Promise(async (resolve, reject) => {
if (!this.ready) {
return reject("The manager is not ready yet.");
}
options = options && typeof options === "object" ? merge(defaultUserOptions, options) : defaultUserOptions;
if (!userId) {
return reject(`userId is not a valid user. (val=${userId})`);
}
if (!guildId) {
return reject(`guildId is not a valid guild. (val=${guildId})`);
}
const user = new User(this, {
userId: userId,
guildId: guildId,
data: options
});
this.users.push(user);
await this.saveUser(userId, guildId, user.data);
resolve(user);
});
}
/**
* Creates a new config in the database
*
* @param {Snowflake} guildId The id of the config's guild
* @param {ConfigOptions} options The options for config
*
* @returns {Promise<Config>}
*
* @example
* manager.createConfig(message.guild.id, {
* trackBots: false, // If the user is a bot it will not be tracked.
* trackAllChannels: true, // All of the channels in the guild will be tracked.
* exemptChannels: () => false, // The user will not be tracked in these channels. (This is a function).
* channelIds: [], // The channel ids to track. (If trackAllChannels is true, this is ignored)
* exemptPermissions: [], // The user permissions to not track.
* exemptMembers: () => false, // The user will not be tracked. (This is a function).
* trackMute: true, // It will track users if they are muted aswell.
* trackDeaf: true, // It will track users if they are deafen aswell.
* minUserCountToParticipate: 0, // The min amount of users to be in a channel to be tracked.
* maxUserCountToParticipate: 0, // The max amount of users to be in a channel to be tracked.
* minXpToParticipate: 0, // The min amount of xp needed to be tracked.
* minLevelToParticipate: 0, // The min level needed to be tracked.
* maxXpToParticipate: 0, // The max amount of xp needed to be tracked.
* maxLevelToParticipate: 0, // The max level needed to be tracked.
* xpAmountToAdd: () => Math.floor(Math.random() * 10) + 1, // The amount of xp to add to the user (This is a function).
* voiceTimeToAdd: () => 1000, // The amount of time in ms to add to the user (This is a function).
* voiceTimeTrackingEnabled: true, // Whether the voiceTimeTracking module is enabled.
* levelingTrackingEnabled: true, // Whether the levelingTracking module is enabled.
* levelMultiplier: () => 0.1, // This will set level multiplier to 0.1 (This is a function).
* });
*/
createConfig(guildId, options) {
return new Promise(async (resolve, reject) => {
if (!this.ready) {
return reject("The manager is not ready yet.");
}
options = options && typeof options === "object" ? merge(defaultConfigOptions, options) : defaultConfigOptions;
if (!guildId) {
return reject(`guildId is not a valid guild. (val=${guildId})`);
}
const config = new Config(this, {
guildId: guildId,
data: options
});
this.configs.push(config);
await this.saveConfig(guildId, config.data);
resolve(config);
});
}
/**
* Remove's the user from the database
*
* @param {Snowflake} userId The id of the user
* @param {Snowflake} guildId The id of the user's guild
*
* @returns {Promise<void>}
*
* @example
* manager.removeUser(message.author.id, message.guild.id);
*/
removeUser(userId, guildId) {
return new Promise(async (resolve, reject) => {
const user = this.users.find((u) => u.guildId === guildId && u.userId === userId);
if (!user) {
return reject("No user found with Id " + userId + " in guild with Id" + guildId + ".");
}
this.users = this.users.filter(
(d) =>
d !==
{
userId: userId,
guildId: guildId,
data: user.data.data
}
);
await this.deleteUser(userId, guildId);
resolve();
});
}
/**
* Remove's the config from the database
*
* @param {Snowflake} guildId The id of the config's guild
*
* @returns {Promise<void>}
*
* @example
* manager.removeConfig(message.guild.id);
*/
removeConfig(guildId) {
return new Promise(async (resolve, reject) => {
const config = this.configs.find((c) => c.guildId === guildId);
if (!config) {
return reject("No config found for guild with Id " + guildId + ".");
}
this.configs = this.configs.filter((c) => c.guildId !== guildId);
await this.deleteConfig(guildId);
resolve();
});
}
/**
* Edits a user. The modifications will be applicated when the user will be updated.
* @param {Snowflake} userId The id of the user
* @param {Snowflake} guildId The id of the user's guild
* @param {UserEditOptions} options The edit options
* @returns {Promise<User>}
*
* @example
* manager.updateUser('122925169588043776','815261972450115585', {
* newVoiceTime: {
* channels: [],
* total: 0,
* }, // The new voice time user will have.
* });
*/
updateUser(userId, guildId, options = {}) {
return new Promise(async (resolve, reject) => {
const user = this.users.find((u) => u.guildId === guildId && u.userId === userId);
if (!user) {
return reject("No user found with Id " + userId + " in guild with Id" + guildId + ".");
}
user.edit(options).then(resolve).catch(reject);
});
}
/**
* Edits a config.
* @param {Snowflake} guildId The id of the user's guild
* @param {ConfigEditOptions} options The edit options
* @returns {Promise<Config>}
*
* @example
* manager.updateConfig('815261972450115585', {
* newTrackBots: true, // The module will now track bot user's voice time aswell.
* });
*/
updateConfig(guildId, options = {}) {
return new Promise(async (resolve, reject) => {
const config = this.configs.find((c) => c.guildId === guildId);
if (!config) {
return reject("No config found for guild with Id " + guildId + ".");
}
config.edit(options).then(resolve).catch(reject);
});
}
/**
* Delete a user from the database
* @param {Snowflake} userId The id of the user
* @param {Snowflake} guildId The id of the user's guild
* @returns {Promise<void>}
* @ignore
*/
async deleteUser(userId, guildId) {
await writeFile(this.options.userStorage,
JSON.stringify(this.users.map((user) => user.data), (_, v) => typeof v === 'bigint' ? serialize(v) : v),
'utf-8'
);
this.refreshUserStorage();
return;
}
/**
* Delete a config from the database
* @param {Snowflake} guildId The id of the config's guild
* @returns {Promise<void>}
* @ignore
*/
async deleteConfig(guildId) {
await writeFile(
this.options.configStorage,
JSON.stringify(
this.configs.map((config) => config.data),
(_, v) => (typeof v === "bigint" ? serialize(v) : v)
),
'utf-8'
);
this.refreshConfigStorage();
return;
}
/**
* Refresh the user cache to support shards.
* @ignore
*/
async refreshUserStorage() {
return true;
}
/**
* Refresh the config cache to support shards.
* @ignore
*/
async refreshConfigStorage() {
return true;
}
/**
* Edit the user in the database
* @ignore
* @param {Snowflake} userId The id of the user
* @param {Snowflake} guildId The id of the user's guild
* @param {UserData} userData The user data to save
*/
async editUser(_userId, _guildId, _userData) {
await writeFile(
this.options.userStorage,
JSON.stringify(
this.users.map((user) => user.data),
(_, v) => (typeof v === "bigint" ? serialize(v) : v)
),
'utf-8'
);
this.refreshUserStorage();
return;
}
/**
* Edit the config in the database
* @ignore
* @param {Snowflake} guildId The id of the config's guild
* @param {ConfigData} ConfigData The config data to save
*/
async editConfig(_guildId, _configData) {
await writeFile(
this.options.configStorage,
JSON.stringify(
this.configs.map((config) => config.data),
(_, v) => (typeof v === "bigint" ? serialize(v) : v)
),
'utf-8'
);
this.refreshConfigStorage();
return;
}
/**
* Save the user in the database
* @ignore
* @param {Snowflake} userId The id of the user
* @param {Snowflake} guildId The id of the user's guild
* @param {UserData} userData The user data to save
*/
async saveUser(userId, guildId, userData) {
await writeFile(
this.options.userStorage,
JSON.stringify(
this.users.map((user) => user.data),
(_, v) => (typeof v === "bigint" ? serialize(v) : v)
),
'utf-8'
);
this.refreshUserStorage();
return;
}
/**
* Save the config in the database
* @ignore
* @param {Snowflake} guildId The id of the config's guild
* @param {ConfigData} configData The config data to save
*/
async saveConfig(guildId, configData) {
await writeFile(
this.options.configStorage,
JSON.stringify(
this.configs.map((config) => config.data),
(_, v) => (typeof v === "bigint" ? serialize(v) : v)
),
'utf-8'
);
this.refreshConfigStorage();
return;
}
/**
* Gets the user's from the storage file, or create it
* @ignore
* @returns {Promise<UserData[]>}
*/
async getAllUsers() {
const storageExists = await access(this.options.userStorage)
.then(() => true)
.catch(() => false);
if (!storageExists) {
await writeFile(this.options.userStorage, '[]', 'utf-8');
return [];
} else {
const storageContent = await readFile(this.options.userStorage, (_, v) => (typeof v === "string" && /BigInt\("(-?\d+)"\)/.test(v) ? eval(v) : v));
try {
const users = await JSON.parse(storageContent.toString());
if (Array.isArray(users)) {
return users;
} else {
console.log(storageContent, users);
throw new SyntaxError("The storage file is not properly formatted (users is not an array).");
}
} catch (err) {
if (err.message === "Unexpected end of JSON input") {
throw new SyntaxError("The storage file is not properly formatted (Unexpected end of JSON input).");
} else throw err;
}
}
}
/**
* Gets the config's from the storage file, or create it
* @ignore
* @returns {Promise<ConfigData[]>}
*/
async getAllConfigs() {
const storageExists = await access(this.options.configStorage)
.then(() => true)
.catch(() => false);
if (!storageExists) {
await writeFile(this.options.configStorage, '[]', 'utf-8');
return [];
} else {
const storageContent = await readFile(this.options.configStorage, (_, v) => (typeof v === "string" && /BigInt\("(-?\d+)"\)/.test(v) ? eval(v) : v));
try {
const configs = await JSON.parse(storageContent.toString());
if (Array.isArray(configs)) {
return configs;
} else {
console.log(storageContent, configs);
throw new SyntaxError("The storage file is not properly formatted (configs is not an array).");
}
} catch (err) {
if (err.message === "Unexpected end of JSON input") {
throw new SyntaxError("The storage file is not properly formatted (Unexpected end of JSON input).");
} else throw err;
}
}
}
/**
* Checks each user and update it if needed
* @ignore
* @private
*/
_checkUsers() {
if (this.users.length <= 0) return;
this.users.forEach(async (user) => {
if (user.member && user.channel) {
let config = this.configs.find((g) => g.guildId === user.guildId);
if (!config) {
config = await this.createConfig(user.guildId);
}
if (!((await config.checkMember(user.member)) && (await config.checkChannel(user.channel)))) return;
const oldUser = lodash._.cloneDeep(user);
if (config.voiceTimeTrackingEnabled) {
let previousVoiceTime;
user.voiceTime.channels.length <= 0
? (previousVoiceTime = {
channelId: user.channel.id,
voiceTime: 0
})
: user.voiceTime.channels.find((chn) => chn.channelId === user.channel.id)
? (previousVoiceTime = user.voiceTime.channels.find((chn) => chn.channelId === user.channel.id))
: (previousVoiceTime = {
channelId: user.channel.id,
voiceTime: 0
});
let index = user.voiceTime.channels.indexOf(previousVoiceTime);
previousVoiceTime.voiceTime += await config.voiceTimeToAdd();
if (index === -1) user.voiceTime.channels.push(previousVoiceTime);
else user.voiceTime.channels[index] = previousVoiceTime;
user.voiceTime.total = user.voiceTime.channels.reduce(function (sum, data) {
return sum + data.voiceTime;
}, 0);
/**
* Emitted when voice time is added to the user.
* @event VoiceManager#userVoiceTimeAdd
* @param {User} oldUser The user before the update
* @param {User} newUser The user after the update
*
*/
this.emit("userVoiceTimeAdd", oldUser, user);
}
if (config.levelingTrackingEnabled) {
user.levelingData.xp += await config.xpAmountToAdd();
user.levelingData.level = Math.floor((await config.levelMultiplier()) * Math.sqrt(user.levelingData.xp));
/**
* Emitted when xp is added to the user.
* @event VoiceManager#userXpAdd
* @param {User} oldUser The user before the update
* @param {User} newUser The user after the update
*
*/
this.emit("userXpAdd", oldUser, user);
if (user.levelingData.level > oldUser.levelingData.level) {
/**
* Emitted when the user levels up.
* @event VoiceManager#userLevelUp
* @param {User} oldUser The user before the update
* @param {User} newUser The user after the update
*
*/
this.emit("userLevelUp", oldUser, user);
}
}
await this.editUser(user.userId, user.guildId, user.data);
return;
}
});
}
/**
* Checks the provided user
* @ignore
* @private
*/
async _checkUser(memberAndChannel) {
let config = this.configs.find((g) => g.guildId === memberAndChannel.member.guild.id);
if (!config) {
config = await this.createConfig(memberAndChannel.member.guild.id);
}
if (!((await config.checkMember(memberAndChannel.member)) && (await config.checkChannel(memberAndChannel.channel)))) return false;
else return await this.createUser(memberAndChannel.member.id, memberAndChannel.member.guild.id);
}
/**
* Saves the new user to the storage file
* @ignore
* @private
*/
async _handleVoiceStateUpdate(oldState, newState) {
if (newState.channel) {
if (!this.users.find((u) => u.userId === newState.member.id)) {
let config = this.configs.find((g) => g.guildId === newState.member.guild.id);
if (!config) {
config = await this.createConfig(newState.member.guild.id);
}
if (!((await config.checkMember(newState.member)) && (await config.checkChannel(newState.channel)))) return;
else return await this.createUser(newState.member.id, newState.member.guild.id);
}
}
}
/**
* Inits the manager
* @ignore
* @private
*/
async _init() {
const rawUsers = await this.getAllUsers();
rawUsers.forEach((user) => {
this.users.push(new User(this, user));
});
const rawConfig = await this.getAllConfigs();
rawConfig.forEach((config) => {
this.configs.push(new Config(this, config));
});
setInterval(() => {
if (this.client.readyAt) this._checkUsers.call(this);
}, this.options.checkMembersEvery);
this.ready = true;
this.client.on("voiceStateUpdate", (oldState, newState) => this._handleVoiceStateUpdate(oldState, newState));
}
}
module.exports = VoiceManager;