diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 24234bc..0000000 --- a/.eslintignore +++ /dev/null @@ -1,10 +0,0 @@ -# don't ever lint node_modules -node_modules -# don't lint build output (make sure it's set to your correct build folder name) -dist -# don't lint nyc coverage output -coverage -# don't lint Lib -Libs -# dont' lint pm2 config file -ecosystem.config.js diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index b96322b..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "env": { - "es2021": true, - "node": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 12, - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint" - ], - "rules": { - "indent": [ - "warn", - 4, - { - "SwitchCase": 1 - } - ], - "linebreak-style": [ - "warn", - "unix" - ], - "quotes": [ - "warn", - "single" - ], - "semi": [ - "warn", - "always" - ], - "@typescript-eslint/explicit-module-boundary-types": 0 - } -} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 230c938..eb15a25 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,11 +10,10 @@ jobs: - name: Check out code uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: '20' - # cache: 'yarn' + node-version: '22' - name: Cache dependencies uses: actions/cache@v3 @@ -30,5 +29,8 @@ jobs: - name: Install Dependencies run: yarn install --immutable + - name: Lint + run: yarn lint:check + - name: Build run: yarn build diff --git a/.gitignore b/.gitignore index 606484a..3680903 100644 --- a/.gitignore +++ b/.gitignore @@ -120,13 +120,14 @@ dist #liveshare .vsls.json -# project -logs/ +# IDE .vscode/ -config.json -vlogdata.json -__pycache__/ .idea/ + +# project assets/ -vlogdata.json.bak +logs/ caches/ +config*.json +vlogdata.json +vlogdata.json.bak diff --git a/.yarn/patches/eris-npm-0.17.2-40f32b9a18.patch b/.yarn/patches/eris-npm-0.17.2-40f32b9a18.patch deleted file mode 100644 index 5a312b7..0000000 --- a/.yarn/patches/eris-npm-0.17.2-40f32b9a18.patch +++ /dev/null @@ -1,95 +0,0 @@ -diff --git a/lib/voice/VoiceConnection.js b/lib/voice/VoiceConnection.js -index 0c8ff0e12e647afbb1bf4e9b1b9d868306e7cfa0..6c3f707e700dd984f17d7b1c16b0b7d11c96115a 100644 ---- a/lib/voice/VoiceConnection.js -+++ b/lib/voice/VoiceConnection.js -@@ -120,6 +120,7 @@ class VoiceConnection extends EventEmitter { - this.connectionTimeout = null; - this.connecting = false; - this.reconnecting = false; -+ this.resumeing = false; - this.ready = false; - - this.sendBuffer = Buffer.allocUnsafe(16 + 32 + MAX_FRAME_SIZE); -@@ -199,6 +200,14 @@ class VoiceConnection extends EventEmitter { - clearTimeout(this.connectionTimeout); - this.connectionTimeout = null; - } -+ if(this.resumeing) { -+ this.sendWS(VoiceOPCodes.RESUME, { -+ server_id: this.id === "call" ? data.channel_id : this.id, -+ session_id: data.session_id, -+ token: data.token -+ }) -+ return; -+ } - this.sendWS(VoiceOPCodes.IDENTIFY, { - server_id: this.id === "call" ? data.channel_id : this.id, - user_id: data.user_id, -@@ -269,6 +278,11 @@ class VoiceConnection extends EventEmitter { - this.sendUDPPacket(udpMessage); - break; - } -+ case VoiceOPCodes.RESUMED: { -+ this.connecting = false; -+ this.resumeing = false; -+ break; -+ } - case VoiceOPCodes.SESSION_DESCRIPTION: { - this.mode = packet.d.mode; - this.secret = Buffer.from(packet.d.secret_key); -@@ -355,8 +369,20 @@ class VoiceConnection extends EventEmitter { - this.emit("warn", `Voice WS close ${code}: ${reason}`); - if(this.connecting || this.ready) { - let reconnecting = true; -+ if(code < 4000 || code === 4015) { -+ this.resumeing = true; -+ setTimeout(() => { -+ this.connect(data); -+ }, 500).unref(); -+ return; -+ } - if(code === 4006) { -- reconnecting = false; -+ if(this.channelID) { -+ reconnecting = true; -+ err = null; -+ } else { -+ reconnecting = false; -+ } - } else if(code === 4014) { - if(this.channelID) { - data.endpoint = null; -@@ -383,6 +409,7 @@ class VoiceConnection extends EventEmitter { - disconnect(error, reconnecting) { - this.connecting = false; - this.reconnecting = reconnecting; -+ this.resumeing = false; - this.ready = false; - this.speaking = false; - this.timestamp = 0; -diff --git a/lib/voice/streams/BaseTransformer.js b/lib/voice/streams/BaseTransformer.js -index 7160c6ce2715cafccad27b521f1f69ba9a09bdd5..4d4917c27e3c83b7196743865bd5f71b62a052fb 100644 ---- a/lib/voice/streams/BaseTransformer.js -+++ b/lib/voice/streams/BaseTransformer.js -@@ -10,7 +10,7 @@ class BaseTransformer extends TransformStream { - options.allowHalfOpen = true; - } - if(options.highWaterMark === undefined) { -- options.highWaterMark = 0; -+ options.highWaterMark = 1; - } - super(options); - this.manualCB = false; -diff --git a/lib/voice/streams/OggOpusTransformer.js b/lib/voice/streams/OggOpusTransformer.js -index 6c4baed362327ff8f14101a963f75a5346c2cbeb..755cea061e6f46589497ffa02f7b330e67a72c55 100644 ---- a/lib/voice/streams/OggOpusTransformer.js -+++ b/lib/voice/streams/OggOpusTransformer.js -@@ -74,7 +74,7 @@ class OggOpusTransformer extends BaseTransformer { - } - - _final() { -- if(!this._bitstream) { -+ if(!this._bitstream === undefined) { - this.emit("error", new Error("No Opus stream was found")); - } - } diff --git a/.yarn/patches/eris-npm-0.18.0-57724b4df9.patch b/.yarn/patches/eris-npm-0.18.0-57724b4df9.patch new file mode 100644 index 0000000..6719fa8 --- /dev/null +++ b/.yarn/patches/eris-npm-0.18.0-57724b4df9.patch @@ -0,0 +1,31 @@ +diff --git a/lib/voice/VoiceConnection.js b/lib/voice/VoiceConnection.js +index 858a4b60a46388f0647a5a70a44c8c96a9c2ab25..dd4a970abd5c60c99d4079f51c9e39635bbb3172 100644 +--- a/lib/voice/VoiceConnection.js ++++ b/lib/voice/VoiceConnection.js +@@ -378,7 +378,12 @@ class VoiceConnection extends EventEmitter { + return; + } + if (code === 4006) { +- reconnecting = false; ++ if (this.channelID) { ++ reconnecting = true; ++ err = null; ++ } else { ++ reconnecting = false; ++ } + } else if (code === 4014) { + if (this.channelID) { + data.endpoint = null; +diff --git a/lib/voice/streams/OggOpusTransformer.js b/lib/voice/streams/OggOpusTransformer.js +index 8c2aeb78a35c8d3cdf79fa6e21b35ff0b9b4bd6d..24c72f353acb45b517863e5f12cab17f8e4a9de3 100644 +--- a/lib/voice/streams/OggOpusTransformer.js ++++ b/lib/voice/streams/OggOpusTransformer.js +@@ -74,7 +74,7 @@ class OggOpusTransformer extends BaseTransformer { + } + + _final(cb) { +- if (!this._bitstream) { ++ if (!this._bitstream === undefined) { + this.emit("error", new Error("No Opus stream was found")); + } + cb(); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..4ee8994 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,51 @@ +import typescriptEslint from 'typescript-eslint' +import globals from 'globals' +import pluginJs from '@eslint/js' +import stylistic from '@stylistic/eslint-plugin' + +export default [ + pluginJs.configs.recommended, + ...typescriptEslint.configs.recommended, + { + ignores: [ + '**/node_modules', + '**/dist', + '**/coverage', + '**/ecosystem.config.js' + ] + }, + { + plugins: { + '@stylistic': stylistic + }, + + languageOptions: { + globals: { + ...globals.node + }, + ecmaVersion: 12, + sourceType: 'module' + }, + + rules: { + eqeqeq: 'error', + 'no-unused-vars': 'warn', + camelcase: ['warn', { properties: 'never', ignoreDestructuring: true, ignoreImports: true }], + 'no-var': 'error', + 'no-useless-return': 'error', + 'no-else-return': 'error', + 'no-empty': 'error', + 'no-duplicate-imports': 'error', + + '@stylistic/indent': ['error', 2, { SwitchCase: 1 }], + '@stylistic/linebreak-style': ['error', 'unix'], + '@stylistic/quotes': ['error', 'single'], + '@stylistic/semi': ['error', 'never'], + '@stylistic/no-trailing-spaces': 'error', + '@stylistic/space-before-function-paren': ['error', { named: 'never' }], + '@stylistic/space-in-parens': 'error', + '@stylistic/space-before-blocks': 'error', + '@stylistic/comma-dangle': 'error' + } + } +] diff --git a/package.json b/package.json index ed47470..6653798 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "discord_voice_log", - "version": "4.3.0", + "version": "5.0.0", "main": "dist/index.js", "repository": { "type": "git", @@ -9,14 +9,15 @@ "author": "Jim Chen ", "license": "AGPL-3.0-only", "scripts": { - "lint": "tslint --project . --fix", + "lint": "eslint . --fix", + "lint:check": "eslint . --max-warnings 0", "build": "tsc --pretty", - "build:prod": "tsc --pretty --sourceMap false" + "build:prod": "tsc --pretty --inlineSourceMap false" }, "keywords": [], "dependencies": { "async-wait-until": "^2.0.18", - "eris": "^0.17.2", + "eris": "patch:eris@npm%3A0.18.0#~/.yarn/patches/eris-npm-0.18.0-57724b4df9.patch", "fix-esm": "^1.0.1", "md5": "^2.3.0", "mongodb": "^6.12.0", @@ -26,10 +27,12 @@ "promise-queue": "^2.2.5", "slash-create": "^6.3.0", "sprintf-js": "^1.1.3", - "status-client": "jimchen5209/StatusClient_JS#v2.2.1", - "tslog-helper": "jimchen5209/TSLog-Helper#v2.3.1" + "status-client": "jimchen5209/StatusClient_JS#v2.3.0", + "tslog": "^4.9.3" }, "devDependencies": { + "@eslint/js": "^9.17.0", + "@stylistic/eslint-plugin": "^2.12.1", "@types/md5": "^2.3.5", "@types/node": "^22.10.2", "@types/node-fetch": "^2.6.12", @@ -37,13 +40,10 @@ "@types/promise-queue": "^2.2.3", "@types/sprintf-js": "^1.1.4", "@types/ws": "^8.5.13", - "@typescript-eslint/eslint-plugin": "^8.18.0", - "@typescript-eslint/parser": "^8.18.0", - "eslint": "^9.16.0", - "typescript": "^5.7.2" + "eslint": "^9.17.0", + "globals": "^15.13.0", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.0" }, - "packageManager": "yarn@4.5.3", - "resolutions": { - "eris@^0.17.2": "patch:eris@npm%3A0.17.2#./.yarn/patches/eris-npm-0.17.2-40f32b9a18.patch" - } + "packageManager": "yarn@4.5.3" } diff --git a/src/Components/Discord/Components/Command.ts b/src/Components/Discord/Components/Command.ts deleted file mode 100644 index 990df2c..0000000 --- a/src/Components/Discord/Components/Command.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Client } from 'eris'; -import { Logger } from 'tslog-helper'; -import { AnyRequestData, GatewayServer, SlashCommand, SlashCreator } from 'slash-create'; -import { Core } from '../../..'; -import { Config } from '../../../Core/Config'; -import { Lang } from '../../../Core/Lang'; -import { Discord } from '../Core'; -import { LogCommand } from './Commands/Log'; -import { RefreshCacheCommand } from './Commands/RefreshCache'; -import { VoiceCommand } from './Commands/Voice'; -import { VoiceLog } from './VoiceLog'; - -export class Command { - private config: Config; - private bot: Client; - private creator: SlashCreator; - private logger: Logger; - private voiceLog: VoiceLog; - private lang: Lang; - private registered = false; - - constructor(voiceLog: VoiceLog, core: Core, discord: Discord, bot: Client) { - this.config = core.config; - this.bot = bot; - this.logger = core.mainLogger.getChildLogger({ name: 'Command'}); - this.voiceLog = voiceLog; - this.lang = discord.lang; - - this.creator = new SlashCreator({ - applicationID: this.config.discord.applicationID, - publicKey: this.config.discord.publicKey, - token: this.config.discord.botToken - }); - - this.creator - .withServer( - new GatewayServer( - (handler) => this.bot.on('rawWS', event => { - if (event.t === 'INTERACTION_CREATE') handler(event.d as AnyRequestData); - }) - ) - ); - } - - public refreshCommands() { - if (this.registered) return; - - this.logger.info('Refreshing commands to all guilds...'); - - this.bot.getRESTGuilds({ limit: 200 }).then(value => { - const guildIDs: string[] = []; - - value.forEach(value => { guildIDs.push(value.id); }); - - const commands: SlashCommand[] = [ - new VoiceCommand(this.creator, guildIDs, this.voiceLog), - new LogCommand(this.creator, guildIDs, this.lang, this.voiceLog), - new RefreshCacheCommand(this.creator, guildIDs, this.voiceLog), - ]; - - this.creator.registerCommands(commands); - this.registered = true; - this.creator.syncCommands(); - }); - } -} \ No newline at end of file diff --git a/src/Components/Discord/Components/Commands/Log.ts b/src/Components/Discord/Components/Commands/Log.ts deleted file mode 100644 index ec225e8..0000000 --- a/src/Components/Discord/Components/Commands/Log.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { CommandContext, CommandOptionType, SlashCommand, SlashCreator } from 'slash-create'; -import { VoiceLog } from '../VoiceLog'; -import { Lang } from '../../../../Core/Lang'; - -export class LogCommand extends SlashCommand { - private voiceLog: VoiceLog; - constructor(creator: SlashCreator, guildIDs: string[], lang: Lang, voiceLog: VoiceLog) { - super(creator, { - name: 'log', - description: 'VoiceLog log option', - guildIDs, - options: [ - { - name: 'set', - description: 'Set and enable voiceLog (admin)', - type: CommandOptionType.SUB_COMMAND, - options: lang.genOptions(false) - }, - { - name: 'unset', - description: 'Disable voiceLog (admin)', - type: CommandOptionType.SUB_COMMAND - }, - { - name: 'language', - description: 'Set voiceLog language (admin)', - type: CommandOptionType.SUB_COMMAND, - options: lang.genOptions(true) - } - ] - }); - this.voiceLog = voiceLog; - } - - async run(ctx: CommandContext) { - if (ctx.options.set) { - this.voiceLog.command.commandSetVoiceLog(ctx); - } - if (ctx.options.unset) { - this.voiceLog.command.commandUnsetVoiceLog(ctx); - } - if (ctx.options.language) { - this.voiceLog.command.commandLang(ctx); - } - } -} \ No newline at end of file diff --git a/src/Components/Discord/Components/Commands/RefreshCache.ts b/src/Components/Discord/Components/Commands/RefreshCache.ts deleted file mode 100644 index c01661f..0000000 --- a/src/Components/Discord/Components/Commands/RefreshCache.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommandContext, SlashCommand, SlashCreator } from 'slash-create'; -import { VoiceLog } from '../VoiceLog'; - -export class RefreshCacheCommand extends SlashCommand { - private voiceLog: VoiceLog; - constructor(creator: SlashCreator, guildIDs: string[], voiceLog: VoiceLog) { - super(creator, { - name: 'refresh_cache', - description: 'Download and cache all tts file (bot operator)', - guildIDs - }); - this.voiceLog = voiceLog; - } - - async run(ctx: CommandContext) { - this.voiceLog.command.commandRefreshCache(ctx); - } -} \ No newline at end of file diff --git a/src/Components/Discord/Components/Commands/Voice.ts b/src/Components/Discord/Components/Commands/Voice.ts deleted file mode 100644 index 0adab75..0000000 --- a/src/Components/Discord/Components/Commands/Voice.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { CommandContext, CommandOptionType, SlashCommand, SlashCreator } from 'slash-create'; -import { VoiceLog } from '../VoiceLog'; - -export class VoiceCommand extends SlashCommand { - private voiceLog: VoiceLog; - constructor(creator: SlashCreator, guildIDs: string[], voiceLog: VoiceLog) { - super(creator, { - name: 'voice', - description: 'VoiceLog voice option', - guildIDs, - options: [ - { - name: 'join', - description: 'Make bot join your channel (admin)', - type: CommandOptionType.SUB_COMMAND - }, - { - name: 'leave', - description: 'Make bot leave channel (admin)', - type: CommandOptionType.SUB_COMMAND - } - ] - }); - this.voiceLog = voiceLog; - } - - async run(ctx: CommandContext) { - if (ctx.options.join) { - this.voiceLog.command.commandJoin(ctx); - } - if (ctx.options.leave) { - this.voiceLog.command.commandLeave(ctx); - } - } -} \ No newline at end of file diff --git a/src/Components/Discord/Components/Text.ts b/src/Components/Discord/Components/Text.ts deleted file mode 100644 index 8081b26..0000000 --- a/src/Components/Discord/Components/Text.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Client, TextChannel } from 'eris'; -import { Logger } from 'tslog-helper'; -import { Core } from '../../..'; - -export class DiscordText { - private bot: Client; - private logger: Logger; - - constructor(_core: Core, bot: Client, logger: Logger) { - this.bot = bot; - this.logger = logger.getChildLogger({ name: 'Discord/Text'}); - - this.bot.on('messageCreate', msg => { - - if (!msg.member) { - return; - } - - const channelName = ((msg.channel) as TextChannel).name; - const channelID = msg.channel.id; - - const userNick = (msg.member.nick) ? msg.member.nick : ''; - const userName = msg.member.user.username; - const userID = msg.member.user.id; - - const messageContent = msg.content; - // const message = `${msg.author.username : ${(msg.content)}`; - messageContent.split('\n').forEach(content => { - this.logger.info(`${userNick}[${userName}, ${userID}] => ${channelName} (${channelID}): ${content}`); - }); - if (msg.attachments.length !== 0) { - msg.attachments.forEach(attachments => { - this.logger.info(`Attachment: ${attachments.url}`); - }); - } - }); - } -} - diff --git a/src/Components/Discord/Components/Voice.ts b/src/Components/Discord/Components/Voice.ts deleted file mode 100644 index 204cf49..0000000 --- a/src/Components/Discord/Components/Voice.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { waitUntil } from 'async-wait-until'; -import { Client, Member, VoiceConnection } from 'eris'; -import fs from 'fs'; -import { Logger } from 'tslog-helper'; -import Queue from 'promise-queue'; -import { TTSHelper } from '../../../Core/TTSHelper'; -import { PluginManager } from '../../Plugin/Core'; - -export class DiscordVoice { - private _init = true; - private _channelId: string; - private bot: Client; - private voice: VoiceConnection | undefined; - private logger: Logger; - private queue: Queue = new Queue(1, Infinity); - private plugins: PluginManager; - private ttsHelper: TTSHelper; - - constructor( - bot: Client, - logger: Logger, - plugins: PluginManager, - ttsHelper: TTSHelper, - channel: string, - voice: VoiceConnection | undefined = undefined - ) { - this.bot = bot; - this.logger = logger; - this.ttsHelper = ttsHelper; - this.plugins = plugins; - - this._channelId = channel; - - if (voice && voice.ready) { - this.voice = voice; - this._init = false; - this.logger.info(`Using the existing voice connection for ${this._channelId}`); - } else { - this.joinVoiceChannel(channel).then(connection => { - this.voice = connection; - if (connection) { - this._init = false; - this.logger.info(`Connected to ${this._channelId}`); - } - }); - } - } - - public switchChannel(channel: string) { - this.destroy(); - - this._channelId = channel; - - this.joinVoiceChannel(channel).then(connection => { - this.voice = connection; - }); - } - - public get channelId() { - return this._channelId; - } - - public get init() { - return this._init; - } - - public async playReady() { - const voiceFile = await this.ttsHelper.getWaveTTS('VoiceLog is ready.', 'en-US', 'en-US-Wavenet-D'); - if (voiceFile !== null) this.queue.add(() => this.play(voiceFile, 'ogg')); - } - - public async playMoved() { - const voiceFile = await this.ttsHelper.getWaveTTS('VoiceLog is moved to your channel.', 'en-US', 'en-US-Wavenet-D'); - if (voiceFile !== null) this.queue.add(() => this.play(voiceFile, 'ogg')); - } - - public async playVoice(member: Member, type: string) { - let overwritten = false; - - for (const voice of this.plugins.voiceOverwrites) { - const overwrittenFile = await voice.playVoice(member, type); - if (overwrittenFile) { - this.queue.add(() => this.play(overwrittenFile, 'pcm')); - overwritten = true; - break; - } - } - - if (overwritten) return; - - let voiceFile = ''; - let format: string| undefined; - if (fs.existsSync(`assets/${member.id}.json`)) { - const tts = JSON.parse(fs.readFileSync(`assets/${member.id}.json`, { encoding: 'utf-8' })); - if (tts.use_wave_tts && tts.lang && tts.voice && tts[type]) { - voiceFile = await this.ttsHelper.getWaveTTS(tts[type], tts.lang, tts.voice); - format = 'ogg'; - } else if (tts.lang && tts[type]) { - const file = await this.ttsHelper.getTTSFile(tts[type], tts.lang); - if (file !== null) { - voiceFile = file; - format = 'pcm'; - } else if (fs.existsSync(`assets/${member.id}_${type}.wav`)) { - voiceFile = `assets/${member.id}_${type}.wav`; - } - } else if (fs.existsSync(`assets/${member.id}_${type}.wav`)) { - voiceFile = `assets/${member.id}_${type}.wav`; - } - } else if (fs.existsSync(`assets/${member.id}_${type}.wav`)) { - voiceFile = `assets/${member.id}_${type}.wav`; - } - if (voiceFile !== '') this.queue.add(() => this.play(voiceFile, format)); - } - - public isReady(): boolean { - return (this.voice !== undefined) && this.voice.ready; - } - - public destroy() { - if (this.voice) { - this.voice.stopPlaying(); - this.voice.removeAllListeners(); - if (this.voice.channelID) this.bot.leaveVoiceChannel(this.voice.channelID); - this.voice = undefined; - } - } - - private async joinVoiceChannel(channelID: string): Promise { - this.logger.info(`Connecting to ${channelID}...`); - try { - const connection = await this.bot.joinVoiceChannel(channelID); - connection.on('warn', (message: string) => { - this.logger.warn(`Warning from ${channelID}: ${message}`); - }); - connection.on('error', err => { - this.logger.error(`Error from voice connection ${channelID}: ${err.message}`, err); - }); - connection.on('debug', (message) => this.logger.debug(message)); - connection.once('ready', () => { - this.logger.error('Voice connection reconnected.'); - const channelId = connection.channelID; - if (channelId) { - if (channelId !== this._channelId) { - this.logger.warn(`Voice channel changed from ${this._channelId} to ${channelId}`); - this._channelId = channelId; - } - this.switchChannel(channelId); - } - }); - connection.once('disconnect', err => { - this.logger.error(`Error from voice connection ${channelID}: ${err?.message}`, err); - connection.stopPlaying(); - this.bot.leaveVoiceChannel(channelID); - setTimeout(() => { - this.joinVoiceChannel(this._channelId || channelID).then(newConnection => { - this.voice = newConnection; - }); - }, 5 * 1000); - }); - return connection; - } catch (e) { - if (e instanceof Error) { - this.logger.error(`Error from ${channelID}: ${e.name} ${e.message}`, e); - } - } - return; - } - - private play(file: string, format: string | undefined = undefined) { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (res) => { - if (file === '') { - res(); - return; - } - this.logger.info(`Playing ${file}`); - try { - await waitUntil(() => this.voice && this.voice.ready); - } catch (error) { - this.logger.error('Voice timed out, trying to reconnect', error); - return; - } - this.voice?.once('end', () => res()); - this.voice?.play(file, { format, inlineVolume: true }); - }); - } -} diff --git a/src/Components/Discord/Components/VoiceLog.ts b/src/Components/Discord/Components/VoiceLog.ts deleted file mode 100644 index 211a034..0000000 --- a/src/Components/Discord/Components/VoiceLog.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Client, Member, VoiceChannel } from 'eris'; -import fs from 'fs'; -import { Logger } from 'tslog-helper'; -import { scheduleJob } from 'node-schedule'; -import Queue from 'promise-queue'; -import { Core } from '../../..'; -import { ServerConfigManager } from '../../MongoDB/db/ServerConfig'; -import { Discord } from '../Core'; -import { VoiceLogCommands } from './VoiceLog/Commands'; -import { VoiceLogText } from './VoiceLog/Text'; -import { VoiceLogVoice } from './VoiceLog/Voice'; - -export class VoiceLog { - private bot: Client; - private _voice: VoiceLogVoice; - private _text: VoiceLogText; - private _command: VoiceLogCommands; - private queue: Queue = new Queue(1, Infinity); - private logger: Logger; - private data: ServerConfigManager; - - constructor(core: Core, discord: Discord, bot: Client, logger: Logger) { - this.bot = bot; - this.logger = logger.getChildLogger({ name: 'VoiceLog'}); - this.data = core.data; - this._voice = new VoiceLogVoice(core, discord, bot, logger); - this._text = new VoiceLogText(core, discord, bot, logger); - this._command = new VoiceLogCommands(this, core, discord, bot, this.logger); - - this.bot.on('voiceChannelJoin', async (member: Member, newChannel: VoiceChannel) => { - this.queue.add(async () => { - if (member.id === this.bot.user.id) return; - const guildId = member.guild.id; - const channelID = await this._voice.autoLeaveChannel(undefined, newChannel, guildId); - const voice = this._voice.getCurrentVoice(guildId); - const data = await this.data.get(guildId); - - if (data) { - if (data.channelID !== '') { - this.bot.createMessage(data.channelID, this._text.genVoiceLogEmbed(member, data.lang, 'join', undefined, newChannel)); - } - } - - if (newChannel.id === channelID) { - if (voice) voice.playVoice(member, 'join'); - } - }); - }); - - this.bot.on('voiceChannelLeave', async (member: Member, oldChannel: VoiceChannel) => { - this.queue.add(async () => { - if (member.id === this.bot.user.id) return; - const guildId = member.guild.id; - const channelID = await this._voice.autoLeaveChannel(oldChannel, undefined, guildId); - const voice = this._voice.getCurrentVoice(guildId); - const data = await this.data.get(guildId); - if (data) { - if (data.channelID !== '') { - this.bot.createMessage(data.channelID, this._text.genVoiceLogEmbed(member, data.lang, 'leave', oldChannel, undefined)); - } - } - if (oldChannel.id === channelID) { - if (voice) voice.playVoice(member, 'left'); - } - }); - }); - - this.bot.on('voiceChannelSwitch', async (member: Member, newChannel: VoiceChannel, oldChannel: VoiceChannel) => { - this.queue.add(async () => { - if (member.id === this.bot.user.id) { - this.data.updateLastVoiceChannel(member.guild.id, newChannel.id); - this.data.updateCurrentVoiceChannel(member.guild.id, ''); - return; - } - const guildId = member.guild.id; - const channelID = await this._voice.autoLeaveChannel(oldChannel, newChannel, guildId); - const voice = this._voice.getCurrentVoice(guildId); - const data = await this.data.get(guildId); - - if (data) { - if (data.channelID !== '') { - this.bot.createMessage(data.channelID, this._text.genVoiceLogEmbed(member, data.lang, 'move', oldChannel, newChannel)); - } - } - if (oldChannel.id === channelID) { - if (voice) voice.playVoice(member, 'switched_out'); - } - if (newChannel.id === channelID) { - if (voice) voice.playVoice(member, 'switched_in'); - } - }); - }); - } - - public get voice() { - return this._voice; - } - - public get text() { - return this._text; - } - - public get command() { - return this._command; - } - - public async start() { - if (!fs.existsSync('./assets')) fs.mkdirSync('./assets'); - if (!fs.existsSync('./caches')) fs.mkdirSync('./caches'); - const channels = await this.data.getCurrentChannels(); - channels.forEach(element => { - this.logger.info(`Reconnecting to ${element.currentVoiceChannel}...`); - this._voice.join(element.serverID, element.currentVoiceChannel); - }); - scheduleJob('0 0 * * *', () => { this._voice.refreshCache(undefined); }); - } - -} diff --git a/src/Components/Discord/Components/VoiceLog/Commands.ts b/src/Components/Discord/Components/VoiceLog/Commands.ts deleted file mode 100644 index 06c9888..0000000 --- a/src/Components/Discord/Components/VoiceLog/Commands.ts +++ /dev/null @@ -1,327 +0,0 @@ -import waitUntil from 'async-wait-until'; -import { Client } from 'eris'; -import { CommandContext, MessageEmbedOptions } from 'slash-create'; -import { vsprintf } from 'sprintf-js'; -import { Logger } from 'tslog-helper'; -import { Core } from '../../../..'; -import { Config } from '../../../../Core/Config'; -import { Lang } from '../../../../Core/Lang'; -import { ServerConfigManager } from '../../../MongoDB/db/ServerConfig'; -import { Discord } from '../../Core'; -import { VoiceLog } from '../VoiceLog'; -import { VoiceLogSetStatus } from './Text'; - -const ERR_MISSING_LANG = 'Language not exist.'; -const ERR_MISSING_LANG_DEFAULT = 'Language not exist, will not change your language.'; - -export class VoiceLogCommands { - private bot: Client; - private voiceLog: VoiceLog; - private logger: Logger; - private config: Config; - private data: ServerConfigManager; - private lang: Lang; - - constructor(voiceLog: VoiceLog, core: Core, discord: Discord, bot: Client, logger: Logger) { - this.config = core.config; - this.data = core.data; - this.logger = logger.getChildLogger({ name: 'VoiceLog/Voice' }); - this.lang = discord.lang; - this.voiceLog = voiceLog; - this.bot = bot; - } - - public async commandJoin(context: CommandContext) { - if (!context.guildID || !context.member) return; - const member = await this.bot.getRESTGuildMember(context.guildID, context.member.id); - if (!member) return; - - const data = await this.data.getOrCreate(member.guild.id); - - if (!(member.permissions.has('manageMessages')) && !(this.config.discord.admins.includes(member.id))) { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.no_permission)], - ephemeral: true - }); - return; - } - - const guildId = member.guild.id; - const channelID = member.voiceState.channelID; - if (channelID) { - const voice = this.voiceLog.voice.getCurrentVoice(guildId); - if (voice && voice.channelId === channelID) { - await context.send({ - embeds: [this.genNotChangedMessage(this.lang.get(data.lang).display.command.already_connected)], - ephemeral: true - }); - } else { - const newVoice = await this.voiceLog.voice.join(guildId, channelID, true, true); - try { - await waitUntil(() => newVoice?.isReady()); - } catch (error) { - this.logger.error('Voice timed out', error); - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.error)], - ephemeral: true - }); - return; - } - await context.send({ - embeds: [this.genSuccessMessage(this.lang.get(data.lang).display.command.join_success)] - }); - } - } else { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.not_in_channel)], - ephemeral: true - }); - } - } - - public async commandLeave(context: CommandContext) { - if (!context.guildID || !context.member) return; - const member = await this.bot.getRESTGuildMember(context.guildID, context.member.id); - if (!member) return; - - const data = await this.data.getOrCreate(member.guild.id); - - if (!(member.permissions.has('manageMessages')) && !(this.config.discord.admins.includes(member.id))) { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.no_permission)], - ephemeral: true - }); - return; - } - const guildId = member.guild.id; - - const voice = this.voiceLog.voice.getCurrentVoice(guildId); - - if (voice) { - await this.voiceLog.voice.destroy(guildId, true); - await context.send({ - embeds: [this.genSuccessMessage(this.lang.get(data.lang).display.command.leave_success)] - }); - } else { - await context.send({ - embeds: [this.genNotChangedMessage(this.lang.get(data.lang).display.command.bot_not_connected)], - ephemeral: true - }); - } - } - - public async commandSetVoiceLog(context: CommandContext) { - if (!context.guildID || !context.member) return; - const member = await this.bot.getRESTGuildMember(context.guildID, context.member.id); - if (!member) return; - - const data = await this.data.getOrCreate(member.guild.id); - - if (!(member.permissions.has('manageMessages')) && !(this.config.discord.admins.includes(member.id))) { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.no_permission)], - ephemeral: true - }); - return; - } - - const guildId = member.guild.id; - const channelId = context.channelID; - - if (!context.options.set.language) { - try { - switch (await this.voiceLog.text.setVoiceLog(guildId, channelId)) { - case VoiceLogSetStatus.NotChanged: - await context.send({ - embeds: [this.genNotChangedMessage(this.lang.get(data.lang).display.config.exist)], - ephemeral: true - }); - return; - case VoiceLogSetStatus.ChannelSuccess: - await context.send({ - embeds: [this.genSuccessMessage(this.lang.get(data.lang).display.config.success)] - }); - return; - } - } catch { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.config.error)], - ephemeral: true - }); - } - } else { - const newLang = context.options.set.language as string; - - try { - switch (await this.voiceLog.text.setVoiceLog(guildId, channelId, newLang)) { - case VoiceLogSetStatus.AllSuccess: - await context.send({ - embeds: [ - this.genSuccessMessage(vsprintf(this.lang.get(newLang).display.config.lang_success, [this.lang.get(newLang).displayName])), - this.genSuccessMessage(this.lang.get(newLang).display.config.success) - ] - }); - return; - case VoiceLogSetStatus.ChannelSuccess: - await context.send({ - embeds: [this.genNotChangedMessage(vsprintf(this.lang.get(data.lang).display.config.lang_exist, [this.lang.get(data.lang).displayName]))], - ephemeral : true - }); - await context.send({ - embeds: [this.genSuccessMessage(this.lang.get(data.lang).display.config.success)] - }); - return; - case VoiceLogSetStatus.ChannelSuccess_MissingLang: - await context.send({ - embeds: [this.genErrorMessage(ERR_MISSING_LANG_DEFAULT)], - ephemeral: true - }); - await context.send({ - embeds: [this.genSuccessMessage(this.lang.get(data.lang).display.config.success)] - }); - return; - case VoiceLogSetStatus.LangSuccess: - await context.send({ - embeds: [this.genSuccessMessage(vsprintf(this.lang.get(newLang).display.config.lang_success, [this.lang.get(newLang).displayName]))] - }); - await context.send({ - embeds: [this.genNotChangedMessage(this.lang.get(newLang).display.config.exist)], - ephemeral: true - }); - return; - case VoiceLogSetStatus.MissingLang: - await context.send({ - embeds: [ - this.genErrorMessage(ERR_MISSING_LANG_DEFAULT), - this.genNotChangedMessage(this.lang.get(data.lang).display.config.exist) - ], - ephemeral: true - }); - return; - case VoiceLogSetStatus.NotChanged: - await context.send({ - embeds: [ - this.genNotChangedMessage(vsprintf(this.lang.get(data.lang).display.config.lang_exist, [this.lang.get(data.lang).displayName])), - this.genNotChangedMessage(this.lang.get(data.lang).display.config.exist) - ], - ephemeral: true - }); - return; - } - } catch { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.config.error)], - ephemeral: true - }); - } - } - } - - public async commandLang(context: CommandContext) { - if (!context.guildID || !context.member) return; - const member = await this.bot.getRESTGuildMember(context.guildID, context.member.id); - if (!member) return; - - const data = await this.data.getOrCreate(member.guild.id); - - if (!(member.permissions.has('manageMessages')) && !(this.config.discord.admins.includes(member.id))) { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.no_permission)], - ephemeral: true - }); - return; - } - - const guildId = member.guild.id; - const newLang = context.options.language.language as string; - - switch (await this.voiceLog.text.setLang(guildId, newLang)) { - case VoiceLogSetStatus.LangSuccess: - await context.send({ - embeds: [this.genSuccessMessage(vsprintf(this.lang.get(newLang).display.config.lang_success, [this.lang.get(newLang).displayName]))] - }); - return; - case VoiceLogSetStatus.MissingLang: - await context.send({ - embeds: [this.genErrorMessage(ERR_MISSING_LANG)], - ephemeral: true - }); - return; - case VoiceLogSetStatus.NotChanged: - await context.send({ - embeds: [this.genNotChangedMessage(vsprintf(this.lang.get(data.lang).display.config.lang_exist, [this.lang.get(data.lang).displayName]))], - ephemeral: true - }); - return; - default: - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.config.error)], - ephemeral: true - }); - return; - } - } - - public async commandUnsetVoiceLog(context: CommandContext) { - if (!context.guildID || !context.member) return; - const member = await this.bot.getRESTGuildMember(context.guildID, context.member.id); - if (!member) return; - - const data = await this.data.getOrCreate(member.guild.id); - - if (!(member.permissions.has('manageMessages')) && !(this.config.discord.admins.includes(member.id))) { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.no_permission)], - ephemeral: true - }); - return; - } - - await this.voiceLog.text.unsetVoiceLog(member.guild.id); - await context.send({ - embeds: [this.genSuccessMessage(this.lang.get(data.lang).display.config.unset_success)] - }); - } - - public async commandRefreshCache(context: CommandContext) { - if (!context.guildID || !context.member) return; - const member = await this.bot.getRESTGuildMember(context.guildID, context.member.id); - if (!member) return; - - const data = await this.data.getOrCreate(member.guild.id); - - if (!(this.config.discord.admins.includes(member.id))) { - await context.send({ - embeds: [this.genErrorMessage(this.lang.get(data.lang).display.command.no_permission)], - ephemeral: true - }); - return; - } - - this.voiceLog.voice.refreshCache(context); - } - - private genSuccessMessage(msg: string) { - return { - title: 'Success', - color: 4289797, - description: msg - } as MessageEmbedOptions; - } - - private genNotChangedMessage(msg: string) { - return { - title: 'Nothing Changed', - color: 9274675, - description: msg - } as MessageEmbedOptions; - } - - private genErrorMessage(msg: string) { - return { - title: 'Error', - color: 13632027, - description: msg - } as MessageEmbedOptions; - } -} \ No newline at end of file diff --git a/src/Components/Discord/Components/VoiceLog/Text.ts b/src/Components/Discord/Components/VoiceLog/Text.ts deleted file mode 100644 index 9e78826..0000000 --- a/src/Components/Discord/Components/VoiceLog/Text.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Member, VoiceChannel, MessageContent, Client, TextChannel } from 'eris'; -import { Logger } from 'tslog-helper'; -import { vsprintf } from 'sprintf-js'; -import { Core } from '../../../..'; -import { Lang } from '../../../../Core/Lang'; -import { ServerConfigManager } from '../../../MongoDB/db/ServerConfig'; -import { Discord } from '../../Core'; - -const ERR_UNEXPECTED_LANG_STATUS = new Error('Unexpected lang set status'); -const ERR_NO_PERMISSION = new Error('Not enough permissions to send message'); - -export enum VoiceLogSetStatus { - AllSuccess, - NotChanged, - ChannelSuccess, - LangSuccess, - MissingLang, - ChannelSuccess_MissingLang -} - -export class VoiceLogText { - private bot: Client; - private logger: Logger; - private data: ServerConfigManager; - private lang: Lang; - - constructor(core: Core, discord: Discord, bot: Client, logger: Logger) { - this.bot = bot; - this.logger = logger.getChildLogger({ name: 'VoiceLog/Text'}); - this.data = core.data; - this.lang = discord.lang; - } - - public async setVoiceLog(guildId: string, channelId: string, lang: string | undefined = undefined): Promise { - const permissionCheck = ((this.bot.getChannel(channelId)) as TextChannel).permissionsOf(this.bot.user.id); - if (!permissionCheck.has('sendMessages') || !permissionCheck.has('embedLinks')) { - this.logger.error('Not enough permissions to send message'); - throw ERR_NO_PERMISSION; - } - - const data = await this.data.getOrCreate(guildId); - if (lang) { - if (data.channelID === channelId && data.lang === lang) return VoiceLogSetStatus.NotChanged; - if (data.channelID === channelId) { - return await this.setLang(guildId, lang); - } else { - await this.data.updateChannel(guildId, channelId); - switch (await this.setLang(guildId, lang)) { - case VoiceLogSetStatus.LangSuccess: - return VoiceLogSetStatus.AllSuccess; - case VoiceLogSetStatus.MissingLang: - return VoiceLogSetStatus.ChannelSuccess_MissingLang; - case VoiceLogSetStatus.NotChanged: - return VoiceLogSetStatus.ChannelSuccess; - default: - this.logger.error('Unexpected lang set status'); - throw ERR_UNEXPECTED_LANG_STATUS; - } - } - } else { - if (data.channelID === channelId) return VoiceLogSetStatus.NotChanged; - - await this.data.updateChannel(guildId, channelId); - return VoiceLogSetStatus.ChannelSuccess; - } - } - - public async setLang(guildId: string, lang: string): Promise { - if (!this.lang.isExist(lang)) return VoiceLogSetStatus.MissingLang; - - const data = await this.data.getOrCreate(guildId); - - if (data.lang === lang) return VoiceLogSetStatus.NotChanged; - - await this.data.updateLang(guildId, lang); - return VoiceLogSetStatus.LangSuccess; - } - - public async unsetVoiceLog(guildId: string) { - await this.data.updateChannel(guildId, ''); - } - - public genVoiceLogEmbed(member: Member, lang: string, type: string, oldChannel: VoiceChannel | undefined, newChannel: VoiceChannel | undefined) { - let color: number; - let content: string; - switch (type) { - case 'join': - color = 4289797; - content = vsprintf(this.lang.get(lang).display.voice_log.joined, [newChannel?.name]); - break; - case 'leave': - color = 8454161; - content = vsprintf(this.lang.get(lang).display.voice_log.left, [oldChannel?.name]); - break; - case 'move': - color = 10448150; - content = vsprintf('%0s ▶️ %1s', [oldChannel?.name, newChannel?.name]); - break; - default: - color = 6776679; - content = 'Unknown type'; - break; - } - return { - embed: { - color, - title: member.nick ? member.nick : member.username, - description: content, - timestamp: (new Date()).toISOString(), - author: { name: '𝅺', icon_url: member.avatarURL } - } - } as MessageContent; - } -} \ No newline at end of file diff --git a/src/Components/Discord/Components/VoiceLog/Voice.ts b/src/Components/Discord/Components/VoiceLog/Voice.ts deleted file mode 100644 index 50b4162..0000000 --- a/src/Components/Discord/Components/VoiceLog/Voice.ts +++ /dev/null @@ -1,290 +0,0 @@ -import waitUntil from 'async-wait-until'; -import { Client, VoiceChannel } from 'eris'; -import { readdirSync, readFileSync, unlinkSync } from 'fs'; -import { Logger } from 'tslog-helper'; -import path from 'path'; -import Queue from 'promise-queue'; -import { CommandContext, MessageEmbedOptions } from 'slash-create'; -import { Core } from '../../../..'; -import { ServerConfigManager } from '../../../MongoDB/db/ServerConfig'; -import { TTSHelper } from '../../../../Core/TTSHelper'; -import { Discord } from '../../Core'; -import { DiscordVoice } from '../Voice'; -import { PluginManager } from '../../../Plugin/Core'; - -export class VoiceLogVoice { - private bot: Client; - private audios: { [key: string]: DiscordVoice } = {}; - private logger: Logger; - private voiceLogger: Logger; - private data: ServerConfigManager; - private ttsHelper: TTSHelper; - private plugins: PluginManager; - private updateLock = false; - - constructor(core: Core, discord: Discord, bot: Client, logger: Logger) { - this.bot = bot; - this.logger = logger.getChildLogger({ name: 'VoiceLog/Voice'}); - this.voiceLogger = logger.getChildLogger({ name: 'Discord/Voice'}); - this.data = core.data; - this.ttsHelper = discord.ttsHelper; - this.plugins = core.plugins; - } - - public getCurrentVoice(guildId: string): DiscordVoice | undefined { - const voice = this.audios[guildId]; - if (!voice) { - const botVoice = this.bot.voiceConnections.get(guildId); - if (botVoice && botVoice.ready) { - if (botVoice.channelID) this.audios[guildId] = new DiscordVoice(this.bot, this.voiceLogger, this.plugins, this.ttsHelper, botVoice.channelID, botVoice); - return this.audios[guildId]; - } - return undefined; - } else if (!voice.isReady()) { - this.destroy(guildId); - return undefined; - } - - return this.audios[guildId]; - } - - public async join(guildId: string, channelId: string, updateDatabase = false, playJoin = false): Promise { - if (this.audios[guildId]) { - if (!this.audios[guildId].isReady() && !this.audios[guildId].init) { - this.destroy(guildId); - } else if (this.audios[guildId].channelId !== channelId) { - this.audios[guildId].switchChannel(channelId); - - if (updateDatabase) { - this.data.updateLastVoiceChannel(guildId, ''); - this.data.updateCurrentVoiceChannel(guildId, channelId); - } - - if (playJoin) this.audios[guildId].playMoved(); - return this.audios[guildId]; - } else { - return this.audios[guildId]; - } - } - - this.audios[guildId] = new DiscordVoice(this.bot, this.voiceLogger, this.plugins, this.ttsHelper, channelId); - try { - await waitUntil(() => this.audios[guildId] && this.audios[guildId].isReady()); - } catch (error) { - this.logger.error('Voice timed out:', error); - return; - } - if (updateDatabase) { - this.data.updateLastVoiceChannel(guildId, ''); - this.data.updateCurrentVoiceChannel(guildId, channelId); - } - - if (playJoin) this.audios[guildId].playReady(); - - return this.audios[guildId]; - } - - public async destroy(guildId: string, updateDatabase = false) { - this.audios[guildId].destroy(); - delete this.audios[guildId]; - if (updateDatabase) { - this.data.updateLastVoiceChannel(guildId, ''); - this.data.updateCurrentVoiceChannel(guildId, ''); - } - } - - private async sleep(guildId: string, channelId: string) { - this.destroy(guildId); - this.data.updateLastVoiceChannel(guildId, channelId); - this.data.updateCurrentVoiceChannel(guildId, ''); - } - - public async refreshCache(context: CommandContext | undefined) { - if (this.updateLock) { - await context?.send({ - embeds: [{ - title: 'Operation skipped', - color: 13632027, - description: 'Currently refreshing in progress...' - } as MessageEmbedOptions], - ephemeral: true - }); - - return; - } - this.updateLock = true; - this.logger.info('Starting cache refresh...'); - const title = '➡️ Refreshing Caches'; - let seekCounter = 0; - let seekFilename = ''; - let seekDone = false; - let seekField = { - name: `${seekDone ? '✅' : '➡️'} Seeking for files ...${seekDone ? ' Done' : ''}`, - value: `${(seekDone || seekCounter === 0) ? '' : `Current ${seekFilename}, `} Seeked ${seekCounter} files. ` - }; - let progressMessage = this.genProgressMessage(title, [seekField]); - await context?.send({ embeds: [progressMessage] }); - let progressCount = 0; - let progressTotal = 0; - const queue = new Queue(1, Infinity); - const getTTS = (text: string, lang: string) => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (res) => { - progressCount++; - const progressField = { - name: '➡️ Processing texts...', - value: `(${progressCount}/${progressTotal}) ${text} in ${lang}` - }; - progressMessage = this.genProgressMessage(title, [seekField, progressField]); - await context?.editOriginal({ embeds: [progressMessage] }); - this.ttsHelper.getTTSFile(text, lang).then(fileName => { - this.logger.info(`(${progressCount}/${progressTotal}) ${text} in ${lang} -> ${fileName}`); - if (fileName !== null) ttsList.push(fileName); - setTimeout(() => { res(); }, 500); - }); - }); - }; - const getWaveTTS = (text: string, lang: string, voice: string) => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (res) => { - progressCount++; - const progressField = { - name: '➡️ Processing texts...', - value: `(${progressCount}/${progressTotal}) ${text} in ${lang} with voice ${voice}` - }; - progressMessage = this.genProgressMessage(title, [seekField, progressField]); - await context?.editOriginal({ embeds: [progressMessage] }); - this.ttsHelper.getWaveTTS(text, lang, voice).then(fileName => { - this.logger.info(`(${progressCount}/${progressTotal}) ${text} in ${lang} with voice ${voice} -> ${fileName}`); - if (fileName !== null) ttsList.push(fileName); - setTimeout(() => { res(); }, 500); - }); - }); - }; - const ttsList: string[] = []; - queue.add(() => getWaveTTS('VoiceLog is moved to your channel.', 'en-US', 'en-US-Wavenet-D')); - queue.add(() => getWaveTTS('VoiceLog is ready.', 'en-US', 'en-US-Wavenet-D')); - progressTotal += 2; - const typeList = ['join', 'left', 'switched_out', 'switched_in']; - const files = readdirSync('assets/'); - files.forEach(file => { - if (path.extname(file) === '.json') { - seekCounter++; - seekFilename = file; - seekField = { - name: `${seekDone ? '✅' : '➡️'} Seeking for files ...${seekDone ? ' Done' : ''}`, - value: `${(seekDone || seekCounter === 0) ? '' : `Current ${seekFilename}, `} Seeked ${seekCounter} files. ` - }; - const tts = JSON.parse(readFileSync(`assets/${file}`, { encoding: 'utf-8' })); - if (tts.use_wave_tts && tts.lang && tts.voice) { - typeList.forEach(type => { - if (tts[type]) { - progressTotal++; - queue.add(() => getWaveTTS(tts[type], tts.lang, tts.voice)); - } - }); - } else if (tts.lang) { - typeList.forEach(type => { - if (tts[type]) { - progressTotal++; - queue.add(() => getTTS(tts[type], tts.lang)); - } - }); - } - } - }); - seekDone = true; - seekField = { - name: `${seekDone ? '✅' : '➡️'} Seeking for files ...${seekDone ? ' Done' : ''}`, - value: `${(seekDone || seekCounter === 0) ? '' : `Current ${seekFilename}, `} Seeked ${seekCounter} files. ` - }; - const afterWork = () => { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (res) => { - const progressField = { - name: '✅ Processing files... Done', - value: `Processed ${progressTotal} texts.` - }; - let cacheRemoveCount = 0; - let cacheField = { - name: '➡️ Removing unused cache...', - value: (cacheRemoveCount === 0) ? 'Seeking...' : `Removed ${cacheRemoveCount} unused ${(cacheRemoveCount === 1) ? 'cache' : 'caches'}.` - }; - progressMessage = this.genProgressMessage(title, [seekField, progressField, cacheField]); - await context?.editOriginal({ embeds: [progressMessage] }); - const cacheFiles = readdirSync('caches/'); - cacheFiles.forEach(async file => { - if (!ttsList.includes(`./caches/${file}`)) { - unlinkSync(`./caches/${file}`); - this.logger.info(`Deleted unused file ./caches/${file}`); - cacheRemoveCount++; - progressMessage = this.genProgressMessage(title, [seekField, progressField, cacheField]); - await context?.editOriginal({ embeds: [progressMessage] }); - } - }); - cacheField = { - name: '✅ Removing unused cache... Done', - value: (cacheRemoveCount === 0) ? 'No unused caches found.' : `Removed ${cacheRemoveCount} unused ${(cacheRemoveCount === 1) ? 'cache' : 'caches'}.` - }; - progressMessage = this.genProgressMessage('✅ Refresh Caches Done', [seekField, progressField, cacheField], true); - await context?.editOriginal({ embeds: [progressMessage] }); - this.updateLock = false; - res(); - }); - }; - queue.add(() => afterWork()); - } - - private genProgressMessage(title: string, fields: Array<{ name: string, value: string }>, isDone = false) { - return { - color: (isDone) ? 4289797 : 16312092, - title, - fields - } as MessageEmbedOptions; - } - - public async autoLeaveChannel(oldChannel: VoiceChannel | undefined, newChannel: VoiceChannel | undefined, guildId: string): Promise { - let channelToCheck: VoiceChannel | undefined; - - const voice = this.getCurrentVoice(guildId); - const data = await this.data.get(guildId); - - if (voice?.isReady()) { - channelToCheck = (oldChannel?.id === voice?.channelId) ? oldChannel : ((newChannel?.id === voice?.channelId) ? newChannel : undefined); - } else if (data) { - channelToCheck = (oldChannel?.id === data.lastVoiceChannel) ? oldChannel : ((newChannel?.id === data.lastVoiceChannel) ? newChannel : undefined); - } - - if (!channelToCheck) return; - - let noUser = true; - - channelToCheck.voiceMembers?.forEach(user => { - if (!user.bot) { - noUser = false; - return; - } - }); - - if (noUser) { - if (voice) { - await this.sleep(guildId, channelToCheck.id); - } - return; - } else { - let connection = await this.join(guildId, channelToCheck.id, true); - for (let i = 0; i < 5; ++i){ - if (!connection) { - this.logger.warn(`Auto reconnect failed, retrying (${i + 1} / 5)...`); - await this.destroy(guildId); - connection = await this.join(guildId, channelToCheck.id, true); - } else { - return channelToCheck.id; - } - } - - this.logger.error('Auto reconnect fails after 5 tries'); - return; - } - } -} diff --git a/src/Components/Discord/Core.ts b/src/Components/Discord/Core.ts deleted file mode 100644 index 2195557..0000000 --- a/src/Components/Discord/Core.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Client } from 'eris'; -import { Logger } from 'tslog-helper'; -import { Core } from '../..'; -import { Config } from '../../Core/Config'; -import { Lang } from '../../Core/Lang'; -import { TTSHelper } from '../../Core/TTSHelper'; -import { Command } from './Components/Command'; -import { DiscordText } from './Components/Text'; -import { VoiceLog } from './Components/VoiceLog'; - -const ERR_MISSING_TOKEN = Error('Discord token missing'); - -export class Discord { - private _lang: Lang; - private _ttsHelper: TTSHelper; - private _voiceLog: VoiceLog; - private config: Config; - private bot: Client; - private command: Command; - private logger: Logger; - - constructor(core: Core) { - this.config = core.config; - this.logger = core.mainLogger.getChildLogger({ name: 'Discord'}); - this._lang = core.lang; - this._ttsHelper = core.ttsHelper; - - if (this.config.discord.botToken === '') throw ERR_MISSING_TOKEN; - - this.bot = new Client( - this.config.discord.botToken, - { restMode: true, intents: ['guilds','guildIntegrations', 'guildMessages', 'guildVoiceStates', 'guildMembers'] } - ); - - this._voiceLog = new VoiceLog(core, this, this.bot, this.logger); - - this.command = new Command(this._voiceLog, core, this, this.bot); - - process.on('warning', e => { - this.logger.warn(e.message); - }); - - this.bot.on('ready', async () => { - this.logger.info(`Logged in as ${this.bot.user.username} (${this.bot.user.id})`); - this.command.refreshCommands(); - this._voiceLog.start(); - }); - - // tslint:disable-next-line:no-unused-expression - new DiscordText(core, this.bot, this.logger); - - this.bot.connect(); - } - - public get lang() { - return this._lang; - } - - public get ttsHelper() { - return this._ttsHelper; - } - - public get voiceLog() { - return this._voiceLog; - } - -} diff --git a/src/Components/MongoDB/Core.ts b/src/Components/MongoDB/Core.ts deleted file mode 100644 index 16301d1..0000000 --- a/src/Components/MongoDB/Core.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EventEmitter } from 'events'; -import { Logger } from 'tslog-helper'; -import { Db, MongoClient } from 'mongodb'; -import { Core } from '../..'; - -export const ERR_DB_NOT_INIT = Error('Database is not initialized'); -export const ERR_INSERT_FAILURE = Error('Data insert failed.'); - -// tslint:disable-next-line:interface-name -export declare interface MongoDB { - on(event: 'connect', listen: (database: Db) => void): this; - on(event: 'error', listen: (error: Error) => void): this; -} - -export class MongoDB extends EventEmitter { - private _client?: Db; - private logger: Logger; - - constructor(core: Core) { - super(); - - this.logger = core.mainLogger.getChildLogger({ name: 'MongoDB' }); - this.logger.info('Loading MongoDB...'); - - const config = core.config.mongodb; - - let connectTryCount = 0; - const maxConnectTryCount = 5; - const tryConnect = () => { - this.logger.info(`Trying to connect to mongoDB...`); - MongoClient.connect(config.host) - .then(client => { - this.logger.info('Successfully connected to mongoDB'); - - this._client = client.db(config.name); - - this.emit('connect', this._client); - }) - .catch((reason) => { - this.logger.error(`Failed to connect to mongoDB: ${reason}`); - connectTryCount++; - if (connectTryCount > maxConnectTryCount) { - this.logger.error('Unable to connect to mongoDB.'); - this.emit('error', reason); - } - this.logger.warn(`Retrying to in 5 seconds... (try ${connectTryCount} / ${maxConnectTryCount} )`); - setTimeout(tryConnect, 5 * 1000); - }); - } - - tryConnect(); - } - - public get client() { - return this._client; - } -} diff --git a/src/Components/MongoDB/db/ServerConfig.ts b/src/Components/MongoDB/db/ServerConfig.ts deleted file mode 100644 index 12e3a09..0000000 --- a/src/Components/MongoDB/db/ServerConfig.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { existsSync, readFileSync, renameSync } from 'fs'; -import { Collection, ObjectId, ReturnDocument } from 'mongodb'; -import { Core } from '../../..'; -import { ERR_DB_NOT_INIT, ERR_INSERT_FAILURE } from '../Core'; - -export interface IServerConfig { - _id: ObjectId; - serverID: string; - lang: string; - channelID: string; - lastVoiceChannel: string; - currentVoiceChannel: string; -} - -export class ServerConfigManager { - private database?: Collection; - // private cache: { [key: string]: IServerConfig } = {}; - - constructor(core: Core) { - core.on('ready', () => {this.init(core).catch(core.mainLogger.error);}); - } - - private async init(core: Core) { - if (!core.database.client) throw Error('Database client not init'); - this.database = core.database.client.collection('serverConfig'); - this.database.createIndex({ serverID: 1 }); - if (existsSync('./vlogdata.json')) { - core.mainLogger.info('Old data found. Migrating to db...'); - const dataRaw = JSON.parse(readFileSync('./vlogdata.json', { encoding: 'utf-8' })); - for (const key of Object.keys(dataRaw)) { - if (dataRaw[key] === undefined) continue; - await this.create(key, dataRaw[key].channel, dataRaw[key].lang, dataRaw[key].lastVoiceChannel); - } - renameSync('./vlogdata.json', './vlogdata.json.bak'); - } - - // Add field admin to old lists - this.database.updateMany({ currentVoiceChannel: { $exists: false } }, { $set: { currentVoiceChannel: '' } }); - } - - public async create(serverID: string, channelID = '', lang = 'en_US', lastVoiceChannel = '', currentVoiceChannel = '') { - if (!this.database) throw ERR_DB_NOT_INIT; - - const data = { - serverID, - channelID, - lang, - lastVoiceChannel, - currentVoiceChannel - } as IServerConfig; - - return (await this.database.insertOne(data)).acknowledged ? data : null; - } - - public get(serverID: string) { - if (!this.database) throw ERR_DB_NOT_INIT; - - return this.database.findOne({ serverID }); - } - - public async getOrCreate(guildId: string) { - let data = await this.get(guildId); - if (!data) data = await this.create(guildId); - if (!data) throw ERR_INSERT_FAILURE; - - return data; - } - - public getCurrentChannels() { - if (!this.database) throw ERR_DB_NOT_INIT; - - return this.database.find({ currentVoiceChannel: { $ne: '' } }).toArray(); - } - - public async updateChannel(serverID: string, channelID: string) { - if (!this.database) throw ERR_DB_NOT_INIT; - - return (await this.database.findOneAndUpdate( - { serverID }, - { $set: { channelID } }, - { returnDocument: ReturnDocument.AFTER } - )); - } - - public async updateLang(serverID: string, lang: string) { - if (!this.database) throw ERR_DB_NOT_INIT; - - return (await this.database.findOneAndUpdate( - { serverID }, - { $set: { lang } }, - { returnDocument: ReturnDocument.AFTER } - )); - } - - public async updateLastVoiceChannel(serverID: string, lastVoiceChannel: string) { - if (!this.database) throw ERR_DB_NOT_INIT; - - return (await this.database.findOneAndUpdate( - { serverID }, - { $set: { lastVoiceChannel } }, - { returnDocument: ReturnDocument.AFTER } - )); - } - - public async updateCurrentVoiceChannel(serverID: string, currentVoiceChannel: string) { - if (!this.database) throw ERR_DB_NOT_INIT; - - return (await this.database.findOneAndUpdate( - { serverID }, - { $set: { currentVoiceChannel } }, - { returnDocument: ReturnDocument.AFTER } - )); - } -} diff --git a/src/Components/Plugin/Base/PluginBase.ts b/src/Components/Plugin/Base/PluginBase.ts deleted file mode 100644 index 31d8b18..0000000 --- a/src/Components/Plugin/Base/PluginBase.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Core } from '../../..'; - -export interface IPluginBase { - pluginName:string; - description:string; -} - -interface IPluginConstructor { - new(core: Core): void; -} - -declare const IPluginBase: IPluginConstructor; \ No newline at end of file diff --git a/src/Components/Plugin/Base/VoiceOverwrite.ts b/src/Components/Plugin/Base/VoiceOverwrite.ts deleted file mode 100644 index 0f9e6a9..0000000 --- a/src/Components/Plugin/Base/VoiceOverwrite.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Member } from 'eris'; -import { IPluginBase } from './PluginBase'; - -export interface IVoiceOverwrite extends IPluginBase { - typeVoiceOverwrite: boolean; - - playVoice(member: Member, type: string): Promise; -} diff --git a/src/Components/Plugin/Core.ts b/src/Components/Plugin/Core.ts deleted file mode 100644 index 6e0dea6..0000000 --- a/src/Components/Plugin/Core.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { readdirSync, existsSync } from 'fs'; -import { Logger } from 'tslog-helper'; -import path from 'path'; -import { Core } from '../..'; -import { IPluginBase } from './Base/PluginBase'; -import { IVoiceOverwrite } from './Base/VoiceOverwrite'; - -export class PluginManager { - private core: Core; - private logger: Logger; - private loadedPlugins: { [key: string]: IPluginBase } = {}; - private _voiceOverwrites: { [key: string]: IVoiceOverwrite } = {}; - - constructor(core: Core) { - this.core = core; - this.logger = core.mainLogger.getChildLogger({ name: 'Plugin'}); - - this.reloadPluginList().then(() => { - this.enablePlugins(); - }); - } - - public async reloadPluginList() { - this.logger.info('Loading plugins...'); - if (!existsSync(`${__dirname}/Plugins/`)) { - this.logger.error(`${__dirname}/Plugins/ not found, skipping`); - return; - } - const files = readdirSync(`${__dirname}/Plugins/`); - - for (const file of files) { - if (path.extname(file) === '.js') { - const value = await import(`${__dirname}/Plugins/${file}`); - for (const className of Object.keys(value)) { - if (!value[className]) continue; - this.logger.info(`Found ${className} in ${file}`); - this.loadedPlugins[className] = new value[className](this.core); - this.logger.info(`Loaded ${className} as ${this.loadedPlugins[className].pluginName} (${this.loadedPlugins[className].description})`); - } - } - } - } - - private enablePlugins() { - // Todo config - for (const className of Object.keys(this.loadedPlugins)) { - this.enablePlugin(className); - } - } - - public enablePlugin(className: string): boolean { - if ((this.loadedPlugins[className] as IVoiceOverwrite).typeVoiceOverwrite) { - this._voiceOverwrites[className] = this.loadedPlugins[className] as IVoiceOverwrite; - this.logger.info(`Enabled ${this.loadedPlugins[className].pluginName} as IVoiceOverwrite`); - return true; - } - - this.logger.error(`Enable ${this.loadedPlugins[className].pluginName} failed: No compatible plugins found`); - return false; - } - - - public disablePlugin(className: string): boolean { - if (Object.keys(this._voiceOverwrites).includes(className)) { - delete this._voiceOverwrites[className]; - this.logger.info(`Disabled ${this.loadedPlugins[className].pluginName}`); - return true; - } - - this.logger.error(`Disable ${this.loadedPlugins[className].pluginName} failed: Plugin is not enabled`); - return false; - } - - public get voiceOverwrites() { - return Object.values(this._voiceOverwrites); - } -} \ No newline at end of file diff --git a/src/Core/Config.ts b/src/Core/Config.ts deleted file mode 100644 index 834402d..0000000 --- a/src/Core/Config.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { Logger } from 'tslog-helper'; -import { Core } from '..'; - -export class Config { - private configVersion = 2; - private _discord: { botToken: string, applicationID: string, publicKey: string, admins: string[] }; - private _googleTTS: { apiKey: string }; - private _mongodb: { host: string, name: string }; - private _debug: boolean; - private logger: Logger; - - constructor(core: Core) { - this.logger = core.mainLogger.getChildLogger({ name: 'Config'}); - this.logger.info('Loading Config...'); - - const discordDefault = { botToken: '', applicationID: '', publicKey: '', admins: [] }; - const googleTTSDefault = { apiKey: '' }; - const mongodbDefault = { host: 'mongodb://localhost:27017', name: 'VoiceLog' }; - - let versionChanged = false; - - if (existsSync('./config.json')) { - const config = JSON.parse(readFileSync('config.json', { encoding: 'utf-8' })); - - if (!config.configVersion || config.configVersion < this.configVersion) versionChanged = true; - if (config.configVersion > this.configVersion) { - this.logger.fatal('This config version is newer than me! Consider upgrading to the latest version or reset your configuration.'); - process.exit(1); - } - - // read and migrate config - if (!config.discord) config.discord = {}; - this._discord = { - botToken: (config.discord.botToken) ? config.discord.botToken : ((config.TOKEN) ? config.TOKEN : discordDefault.botToken), - applicationID: (config.discord.applicationID) ? config.discord.applicationID : discordDefault.applicationID, - publicKey: (config.discord.publicKey) ? config.discord.publicKey : discordDefault.publicKey, - admins: (config.discord.admins) ? config.discord.admins : ((config.admins) ? config.admins : discordDefault.admins) - }; - - if (!config.googleTTS) config.googleTTS = {}; - this._googleTTS = { - apiKey: (config.googleTTS.apiKey) ? config.googleTTS.apiKey : ((config.googleAPIKey) ? config.googleAPIKey : googleTTSDefault.apiKey) - }; - - if (!config.mongodb) config.mongodb = {}; - this._mongodb = { - host: (config.mongodb.host) ? config.mongodb.host : ((config.database.host) ? config.database.host : mongodbDefault.host), - name: (config.mongodb.name) ? config.mongodb.name : ((config.database.name) ? config.database.name : mongodbDefault.name) - }; - - this._debug = (config.debug) ? config.debug : ((config.Debug) ? config.Debug : false); - - this.write(); - - if (versionChanged) { - this.logger.info('Detected config version change and we have tried to migrate into it! Consider checking your config file.'); - process.exit(1); - } - } else { - this.logger.fatal('Can\'t load config.json: File not found.'); - this.logger.info('Generating empty config...'); - this._discord = discordDefault; - this._googleTTS = googleTTSDefault; - this._mongodb = mongodbDefault; - this._debug = false; - this.write(); - this.logger.info('Fill your config and try again.'); - process.exit(1); - } - - } - - private write() { - const json = JSON.stringify({ - configVersion: this.configVersion, - discord: this._discord, - googleTTS: this._googleTTS, - mongodb: this._mongodb, - debug: this._debug - }, null, 4); - writeFileSync('./config.json', json, 'utf8'); - } - - public get discord() { - return this._discord; - } - - public get googleTTS() { - return this._googleTTS; - } - - public get mongodb() { - return this._mongodb; - } - - public get debug() { - return this._debug; - } -} diff --git a/src/Core/Discord/Core.ts b/src/Core/Discord/Core.ts new file mode 100644 index 0000000..5718154 --- /dev/null +++ b/src/Core/Discord/Core.ts @@ -0,0 +1,59 @@ +import { Client } from 'eris' +import { Command } from './Core/Command' +import { VoiceLog } from './VoiceLog/VoiceLog' +import { instances } from '../../Utils/Instances' + +const ERR_MISSING_TOKEN = Error('Discord token missing') + +const token = instances.config.discord.botToken +if (!token) throw ERR_MISSING_TOKEN + +export class Discord { + private _client: Client + private _voiceLog: VoiceLog + private command: Command + private _logger = instances.mainLogger.getSubLogger({ name: 'Discord' }) + + constructor() { + this._client = new Client(token, { + restMode: true, + intents: [ + 'guilds', + 'guildVoiceStates' + ] + }) + this._voiceLog = new VoiceLog(this) + this.command = new Command(this) + + this._client.on('ready', async () => { + this._logger.info( + `Logged in as ${this._client.user.username} (${this._client.user.id})` + ) + this.command.refreshCommands() + this._voiceLog.start() + }) + } + + public get client() { + return this._client + } + + public get logger() { + return this._logger + } + + public get voiceLog() { + return this._voiceLog + } + + public start() { + this._client.connect() + } + + public stop() { + this._logger.info('Logging out...') + this._voiceLog.end().then(() => { + this._client.disconnect({ reconnect: false }) + }) + } +} diff --git a/src/Core/Discord/Core/Command.ts b/src/Core/Discord/Core/Command.ts new file mode 100644 index 0000000..bbe866e --- /dev/null +++ b/src/Core/Discord/Core/Command.ts @@ -0,0 +1,59 @@ +import { Client } from 'eris' +import { ILogObj, Logger } from 'tslog' +import { AnyRequestData, GatewayServer, SlashCommand, SlashCreator } from 'slash-create' +import { Discord } from '../Core' +import { LogCommand } from './Commands/Log' +import { RefreshCacheCommand } from './Commands/RefreshCache' +import { VoiceCommand } from './Commands/Voice' +import { instances } from '../../../Utils/Instances' + +export class Command { + private client: Client + private creator: SlashCreator + private logger: Logger + private registered = false + + constructor(discord: Discord) { + this.client = discord.client + this.logger = discord.logger.getSubLogger({ name: 'Command' }) + + this.creator = new SlashCreator({ + client: { + voiceLog: discord.voiceLog, + discord: this.client + }, + applicationID: instances.config.discord.applicationID, + publicKey: instances.config.discord.publicKey, + token: instances.config.discord.botToken + }) + + this.creator.withServer( + new GatewayServer((handler) => + this.client.on('rawWS', (event) => { + if (event.t === 'INTERACTION_CREATE') + handler(event.d as AnyRequestData) + }) + ) + ) + } + + public refreshCommands() { + if (this.registered) return + + this.logger.info('Refreshing commands to all guilds...') + + this.client.getRESTGuilds({ limit: 200 }).then((value) => { + this.creator.client.guildIDs = value.map((value) => value.id) + + const commands: SlashCommand[] = [ + new VoiceCommand(this.creator), + new LogCommand(this.creator), + new RefreshCacheCommand(this.creator) + ] + + this.creator.registerCommands(commands) + this.registered = true + this.creator.syncCommands() + }) + } +} diff --git a/src/Core/Discord/Core/Commands/Log.ts b/src/Core/Discord/Core/Commands/Log.ts new file mode 100644 index 0000000..7555bf5 --- /dev/null +++ b/src/Core/Discord/Core/Commands/Log.ts @@ -0,0 +1,47 @@ +import { CommandContext, CommandOptionType, SlashCommand, SlashCreator } from 'slash-create' +import { VoiceLog } from '../../VoiceLog/VoiceLog' +import { instances } from '../../../../Utils/Instances' + +export class LogCommand extends SlashCommand { + private voiceLog: VoiceLog + + constructor(creator: SlashCreator) { + super(creator, { + name: 'log', + description: 'VoiceLog log option', + guildIDs: creator.client.guildIDs, + options: [ + { + name: 'set', + description: 'Set and enable voiceLog (admin)', + type: CommandOptionType.SUB_COMMAND, + options: instances.lang.genOptions(true) + }, + { + name: 'unset', + description: 'Disable voiceLog (admin)', + type: CommandOptionType.SUB_COMMAND + }, + { + name: 'language', + description: 'Set voiceLog language (admin)', + type: CommandOptionType.SUB_COMMAND, + options: instances.lang.genOptions(true) + } + ] + }) + this.voiceLog = creator.client.voiceLog + } + + async run(ctx: CommandContext) { + if (ctx.options.set) { + this.voiceLog.command.commandSetVoiceLog(ctx) + } + if (ctx.options.unset) { + this.voiceLog.command.commandUnsetVoiceLog(ctx) + } + if (ctx.options.language) { + this.voiceLog.command.commandLang(ctx) + } + } +} diff --git a/src/Core/Discord/Core/Commands/RefreshCache.ts b/src/Core/Discord/Core/Commands/RefreshCache.ts new file mode 100644 index 0000000..bfbd7b6 --- /dev/null +++ b/src/Core/Discord/Core/Commands/RefreshCache.ts @@ -0,0 +1,19 @@ +import { CommandContext, SlashCommand, SlashCreator } from 'slash-create' +import { VoiceLog } from '../../VoiceLog/VoiceLog' + +export class RefreshCacheCommand extends SlashCommand { + private voiceLog: VoiceLog + + constructor(creator: SlashCreator) { + super(creator, { + name: 'refresh_cache', + description: 'Download and cache all tts file (bot operator)', + guildIDs: creator.client.guildIDs + }) + this.voiceLog = creator.client.voiceLog + } + + async run(ctx: CommandContext) { + this.voiceLog.command.commandRefreshCache(ctx) + } +} diff --git a/src/Core/Discord/Core/Commands/Voice.ts b/src/Core/Discord/Core/Commands/Voice.ts new file mode 100644 index 0000000..354c744 --- /dev/null +++ b/src/Core/Discord/Core/Commands/Voice.ts @@ -0,0 +1,36 @@ +import { CommandContext, CommandOptionType, SlashCommand, SlashCreator } from 'slash-create' +import { VoiceLog } from '../../VoiceLog/VoiceLog' + +export class VoiceCommand extends SlashCommand { + private voiceLog: VoiceLog + + constructor(creator: SlashCreator) { + super(creator, { + name: 'voice', + description: 'VoiceLog voice option', + guildIDs: creator.client.guildIDs, + options: [ + { + name: 'join', + description: 'Make bot join your channel (admin)', + type: CommandOptionType.SUB_COMMAND + }, + { + name: 'leave', + description: 'Make bot leave channel (admin)', + type: CommandOptionType.SUB_COMMAND + } + ] + }) + this.voiceLog = creator.client.voiceLog + } + + async run(ctx: CommandContext) { + if (ctx.options.join) { + this.voiceLog.command.commandJoin(ctx) + } + if (ctx.options.leave) { + this.voiceLog.command.commandLeave(ctx) + } + } +} diff --git a/src/Core/Discord/Core/Voice.ts b/src/Core/Discord/Core/Voice.ts new file mode 100644 index 0000000..aecdd47 --- /dev/null +++ b/src/Core/Discord/Core/Voice.ts @@ -0,0 +1,186 @@ +import { waitUntil } from 'async-wait-until' +import { Client, Member, VoiceConnection } from 'eris' +import { existsSync as exists, readFileSync as readFile } from 'fs' +import { ILogObj, Logger } from 'tslog' +import Queue from 'promise-queue' +import { instances } from '../../../Utils/Instances' +import { Discord } from '../Core' + +export class DiscordVoice { + private _init = true + private _channelId: string + private queue: Queue = new Queue(1, Infinity) + private client: Client + private voice: VoiceConnection | undefined + private logger: Logger + + constructor( + discord: Discord, + channel: string, + voice: VoiceConnection | undefined = undefined + ) { + this.client = discord.client + this.logger = discord.logger.getSubLogger({ + name: 'Voice', + prefix: [`[${channel}]`] + }) + + this._channelId = channel + + if (voice && voice.ready) { + this.voice = voice + this._init = false + this.logger.info('Using the existing voice connection') + } else { + this.joinVoiceChannel(channel).then(connection => { + this.voice = connection + if (connection) { + this._init = false + this.logger.info('Connected') + } + }) + } + } + + public switchChannel(channel: string) { + this.destroy() + + this._channelId = channel + + this.joinVoiceChannel(channel).then(connection => { + this.voice = connection + }) + } + + public get channelId() { + return this._channelId + } + + public get init() { + return this._init + } + + public async playReady() { + const voiceFile = await instances.ttsHelper.getWaveTTS('VoiceLog is ready.', 'en-US', 'en-US-Wavenet-D') + if (voiceFile !== null) this.queue.add(() => this.play(voiceFile, 'ogg')) + } + + public async playMoved() { + const voiceFile = await instances.ttsHelper.getWaveTTS('VoiceLog is moved to your channel.', 'en-US', 'en-US-Wavenet-D') + if (voiceFile !== null) this.queue.add(() => this.play(voiceFile, 'ogg')) + } + + public async playVoice(member: Member, type: string) { + let overwritten = false + + for (const voice of instances.pluginManager.voiceOverwrites) { + const overwrittenFile = await voice.playVoice(member, type) + if (overwrittenFile) { + this.queue.add(() => this.play(overwrittenFile, 'pcm')) + overwritten = true + break + } + } + + if (overwritten) return + + let voiceFile = '' + let format: string| undefined + if (exists(`assets/${member.id}.json`)) { + const tts = JSON.parse(readFile(`assets/${member.id}.json`, { encoding: 'utf-8' })) + if (tts.use_wave_tts && tts.lang && tts.voice && tts[type]) { + voiceFile = await instances.ttsHelper.getWaveTTS(tts[type], tts.lang, tts.voice) + format = 'ogg' + } else if (tts.lang && tts[type]) { + const file = await instances.ttsHelper.getTTSFile(tts[type], tts.lang) + if (file !== null) { + voiceFile = file + format = 'pcm' + } else if (exists(`assets/${member.id}_${type}.wav`)) { + voiceFile = `assets/${member.id}_${type}.wav` + } + } else if (exists(`assets/${member.id}_${type}.wav`)) { + voiceFile = `assets/${member.id}_${type}.wav` + } + } else if (exists(`assets/${member.id}_${type}.wav`)) { + voiceFile = `assets/${member.id}_${type}.wav` + } + if (voiceFile !== '') this.queue.add(() => this.play(voiceFile, format)) + } + + public isReady(): boolean { + return (this.voice !== undefined) && this.voice.ready + } + + public destroy() { + if (this.voice) { + this.logger.info('Ending voice connection...') + this.voice.stopPlaying() + this.voice.removeAllListeners() + if (this.voice.channelID) this.client.leaveVoiceChannel(this.voice.channelID) + this.voice = undefined + } + } + + private async joinVoiceChannel(channelID: string): Promise { + this.logger.info('Connecting...') + try { + const connection = await this.client.joinVoiceChannel(channelID) + connection.on('warn', (message: string) => { + this.logger.warn(message) + }) + connection.on('error', err => { + this.logger.error(err.message, err) + }) + connection.on('debug', (message) => this.logger.debug(message)) + connection.once('ready', () => { + this.logger.error('Voice connection reconnected.') + const channelId = connection.channelID + if (channelId) { + if (channelId !== this._channelId) { + this._channelId = channelId + this.logger.settings.prefix = [`[${channelId}]`] + this.logger.warn(`Voice channel changed from ${this._channelId} to ${channelId}`) + } + this.switchChannel(channelId) + } + }) + connection.once('disconnect', err => { + this.logger.error(err?.message, err) + connection.stopPlaying() + this.client.leaveVoiceChannel(channelID) + this.logger.warn('Trying to reconnect in 5 seconds...') + setTimeout(() => { + this.joinVoiceChannel(this._channelId || channelID).then(newConnection => { + this.voice = newConnection + }) + }, 5 * 1000) + }) + return connection + } catch (e) { + if (e instanceof Error) { + this.logger.error(`${e.name} - ${e.message}`, e) + } + } + + } + + private play(file: string, format: string | undefined = undefined) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (res) => { + if (file === '') { + res() + return + } + this.logger.info(`Playing ${file}`) + try { + await waitUntil(() => this.voice && this.voice.ready) + } catch (error) { + this.logger.error('Voice timed out, trying to reconnect', error) + return + } + this.voice?.once('end', () => res()) + this.voice?.play(file, { format, inlineVolume: true }) + }) + } +} diff --git a/src/Core/Discord/VoiceLog/VoiceLog.ts b/src/Core/Discord/VoiceLog/VoiceLog.ts new file mode 100644 index 0000000..e7f8682 --- /dev/null +++ b/src/Core/Discord/VoiceLog/VoiceLog.ts @@ -0,0 +1,143 @@ +import { Client, Member, VoiceChannel } from 'eris' +import { existsSync as exists, mkdirSync as mkDir } from 'fs' +import { ILogObj, Logger } from 'tslog' +import { scheduleJob } from 'node-schedule' +import Queue from 'promise-queue' +import { DbServerConfigManager } from '../../MongoDB/db/ServerConfig' +import { Discord } from '../Core' +import { VoiceLogCommands } from './VoiceLog/Commands' +import { VoiceLogText } from './VoiceLog/Text' +import { VoiceLogVoice } from './VoiceLog/Voice' +import { instances } from '../../../Utils/Instances' +import { ERR_DB_NOT_INIT } from '../../MongoDB/Core' + +export class VoiceLog { + private _voice: VoiceLogVoice + private _text: VoiceLogText + private _command: VoiceLogCommands + private _logger: Logger + private _serverConfig: DbServerConfigManager + private client: Client + private queue: Queue = new Queue(1, Infinity) + + constructor(discord: Discord) { + this.client = discord.client + this._logger = discord.logger.getSubLogger({ name: 'VoiceLog' }) + + const serverConfig = instances.mongoDB?.serverConfig + if (!serverConfig) throw ERR_DB_NOT_INIT + this._serverConfig = serverConfig + + this._voice = new VoiceLogVoice(this, discord) + this._text = new VoiceLogText(this, discord) + this._command = new VoiceLogCommands(this, discord) + + this.client.on('voiceChannelJoin', async (member: Member, newChannel: VoiceChannel) => { + if (member.id === this.client.user.id) return + this.logger.debug(`Queue (${this.queue.getQueueLength() + 1}): User ${member.username} (${member.id}) joined voice channel ${newChannel.name} (${newChannel.id}) in guild ${member.guild.name} (${member.guild.id})`) + this.queue.add(async () => { + this.logger.info(`User ${member.username} (${member.id}) joined voice channel ${newChannel.name} (${newChannel.id}) in guild ${member.guild.name} (${member.guild.id})`) + + const guildId = member.guild.id + const channelID = await this._voice.autoLeaveChannel(undefined, newChannel, guildId) + const voice = this._voice.getCurrentVoice(guildId) + const data = await this._serverConfig.get(guildId) + + if (data) { + if (data.channelID !== '') { + this.client.createMessage(data.channelID, this._text.genVoiceLogEmbed(member, data.lang, 'join', undefined, newChannel)) + } + } + + if (newChannel.id === channelID) { + if (voice) voice.playVoice(member, 'join') + } + }) + }) + + this.client.on('voiceChannelLeave', async (member: Member, oldChannel: VoiceChannel) => { + if (member.id === this.client.user.id) return + this.logger.debug(`Queue (${this.queue.getQueueLength() + 1}): User ${member.username} (${member.id}) left voice channel ${oldChannel.name} (${oldChannel.id}) in guild ${member.guild.name} (${member.guild.id})`) + this.queue.add(async () => { + this.logger.info(`User ${member.username} (${member.id}) left voice channel ${oldChannel.name} (${oldChannel.id}) in guild ${member.guild.name} (${member.guild.id})`) + + const guildId = member.guild.id + const channelID = await this._voice.autoLeaveChannel(oldChannel, undefined, guildId) + const voice = this._voice.getCurrentVoice(guildId) + const data = await this._serverConfig.get(guildId) + if (data) { + if (data.channelID !== '') { + this.client.createMessage(data.channelID, this._text.genVoiceLogEmbed(member, data.lang, 'leave', oldChannel, undefined)) + } + } + if (oldChannel.id === channelID) { + if (voice) voice.playVoice(member, 'left') + } + }) + }) + + this.client.on('voiceChannelSwitch', async (member: Member, newChannel: VoiceChannel, oldChannel: VoiceChannel) => { + if (member.id === this.client.user.id) { + this._serverConfig.updateLastVoiceChannel(member.guild.id, newChannel.id) + this._serverConfig.updateCurrentVoiceChannel(member.guild.id, '') + return + } + this.logger.debug(`Queue (${this.queue.getQueueLength() + 1}): User ${member.username} (${member.id}) switched voice channel from ${oldChannel.name} (${oldChannel.id}) to ${newChannel.name} (${newChannel.id}) in guild ${member.guild.name} (${member.guild.id})`) + this.queue.add(async () => { + this.logger.info(`User ${member.username} (${member.id}) switched voice channel from ${oldChannel.name} (${oldChannel.id}) to ${newChannel.name} (${newChannel.id}) in guild ${member.guild.name} (${member.guild.id})`) + + const guildId = member.guild.id + const channelID = await this._voice.autoLeaveChannel(oldChannel, newChannel, guildId) + const voice = this._voice.getCurrentVoice(guildId) + const data = await this._serverConfig.get(guildId) + + if (data) { + if (data.channelID !== '') { + this.client.createMessage(data.channelID, this._text.genVoiceLogEmbed(member, data.lang, 'move', oldChannel, newChannel)) + } + } + if (oldChannel.id === channelID) { + if (voice) voice.playVoice(member, 'switched_out') + } + if (newChannel.id === channelID) { + if (voice) voice.playVoice(member, 'switched_in') + } + }) + }) + } + + public get voice() { + return this._voice + } + + public get text() { + return this._text + } + + public get command() { + return this._command + } + + public get logger() { + return this._logger + } + + public get serverConfig() { + return this._serverConfig + } + + public async start() { + if (!exists('./assets')) mkDir('./assets') + if (!exists('./caches')) mkDir('./caches') + const channels = await this._serverConfig.getCurrentChannels() + channels.forEach(element => { + this._logger.info(`Reconnecting to ${element.currentVoiceChannel}...`) + this._voice.join(element.serverID, element.currentVoiceChannel) + }) + scheduleJob('0 0 * * *', () => { this._voice.refreshCache(undefined) }) + } + + public async end() { + await this.voice.end() + } +} diff --git a/src/Core/Discord/VoiceLog/VoiceLog/Commands.ts b/src/Core/Discord/VoiceLog/VoiceLog/Commands.ts new file mode 100644 index 0000000..7acac3d --- /dev/null +++ b/src/Core/Discord/VoiceLog/VoiceLog/Commands.ts @@ -0,0 +1,321 @@ +import waitUntil from 'async-wait-until' +import { Client } from 'eris' +import { CommandContext, MessageEmbedOptions } from 'slash-create' +import { vsprintf } from 'sprintf-js' +import { ILogObj, Logger } from 'tslog' +import { DbServerConfigManager } from '../../../MongoDB/db/ServerConfig' +import { Discord } from '../../Core' +import { VoiceLog } from '../VoiceLog' +import { VoiceLogSetStatus } from './Text' +import { instances } from '../../../../Utils/Instances' + +const ERR_MISSING_LANG = 'Language not exist.' +const ERR_MISSING_LANG_DEFAULT = 'Language not exist, will not change your language.' + +export class VoiceLogCommands { + private client: Client + private voiceLog: VoiceLog + private logger: Logger + private serverConfig: DbServerConfigManager + + constructor(voiceLog: VoiceLog, discord: Discord) { + this.client = discord.client + this.voiceLog = voiceLog + this.serverConfig = voiceLog.serverConfig + this.logger = voiceLog.logger.getSubLogger({ name: 'command' }) + } + + public async commandJoin(context: CommandContext) { + if (!context.guildID || !context.member) return + const member = await this.client.getRESTGuildMember(context.guildID, context.member.id) + if (!member) return + + const data = await this.serverConfig.getOrCreate(member.guild.id) + + if (!(member.permissions.has('manageMessages')) && !(instances.config.discord.admins.includes(member.id))) { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.no_permission)], + ephemeral: true + }) + return + } + + const guildId = member.guild.id + const channelID = member.voiceState.channelID + if (channelID) { + const voice = this.voiceLog.voice.getCurrentVoice(guildId) + if (voice && voice.channelId === channelID) { + await context.send({ + embeds: [this.genNotChangedMessage(instances.lang.get(data.lang).display.command.already_connected)], + ephemeral: true + }) + } else { + const newVoice = await this.voiceLog.voice.join(guildId, channelID, true, true) + try { + await waitUntil(() => newVoice?.isReady()) + } catch (error) { + this.logger.error('Voice timed out', error) + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.error)], + ephemeral: true + }) + return + } + await context.send({ + embeds: [this.genSuccessMessage(instances.lang.get(data.lang).display.command.join_success)] + }) + } + } else { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.not_in_channel)], + ephemeral: true + }) + } + } + + public async commandLeave(context: CommandContext) { + if (!context.guildID || !context.member) return + const member = await this.client.getRESTGuildMember(context.guildID, context.member.id) + if (!member) return + + const data = await this.serverConfig.getOrCreate(member.guild.id) + + if (!(member.permissions.has('manageMessages')) && !(instances.config.discord.admins.includes(member.id))) { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.no_permission)], + ephemeral: true + }) + return + } + const guildId = member.guild.id + + const voice = this.voiceLog.voice.getCurrentVoice(guildId) + + if (voice) { + await this.voiceLog.voice.destroy(guildId, true) + await context.send({ + embeds: [this.genSuccessMessage(instances.lang.get(data.lang).display.command.leave_success)] + }) + } else { + await context.send({ + embeds: [this.genNotChangedMessage(instances.lang.get(data.lang).display.command.bot_not_connected)], + ephemeral: true + }) + } + } + + public async commandSetVoiceLog(context: CommandContext) { + if (!context.guildID || !context.member) return + const member = await this.client.getRESTGuildMember(context.guildID, context.member.id) + if (!member) return + + const data = await this.serverConfig.getOrCreate(member.guild.id) + + if (!(member.permissions.has('manageMessages')) && !(instances.config.discord.admins.includes(member.id))) { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.no_permission)], + ephemeral: true + }) + return + } + + const guildId = member.guild.id + const channelId = context.channelID + + if (!context.options.set.language) { + try { + switch (await this.voiceLog.text.setVoiceLog(guildId, channelId)) { + case VoiceLogSetStatus.NotChanged: + await context.send({ + embeds: [this.genNotChangedMessage(instances.lang.get(data.lang).display.config.exist)], + ephemeral: true + }) + return + case VoiceLogSetStatus.ChannelSuccess: + await context.send({ + embeds: [this.genSuccessMessage(instances.lang.get(data.lang).display.config.success)] + }) + + } + } catch { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.config.error)], + ephemeral: true + }) + } + } else { + const newLang = context.options.set.language as string + + try { + switch (await this.voiceLog.text.setVoiceLog(guildId, channelId, newLang)) { + case VoiceLogSetStatus.AllSuccess: + await context.send({ + embeds: [ + this.genSuccessMessage(vsprintf(instances.lang.get(newLang).display.config.lang_success, [instances.lang.get(newLang).displayName])), + this.genSuccessMessage(instances.lang.get(newLang).display.config.success) + ] + }) + return + case VoiceLogSetStatus.ChannelSuccess: + await context.send({ + embeds: [this.genNotChangedMessage(vsprintf(instances.lang.get(data.lang).display.config.lang_exist, [instances.lang.get(data.lang).displayName]))], + ephemeral : true + }) + await context.send({ + embeds: [this.genSuccessMessage(instances.lang.get(data.lang).display.config.success)] + }) + return + case VoiceLogSetStatus.ChannelSuccess_MissingLang: + await context.send({ + embeds: [this.genErrorMessage(ERR_MISSING_LANG_DEFAULT)], + ephemeral: true + }) + await context.send({ + embeds: [this.genSuccessMessage(instances.lang.get(data.lang).display.config.success)] + }) + return + case VoiceLogSetStatus.LangSuccess: + await context.send({ + embeds: [this.genSuccessMessage(vsprintf(instances.lang.get(newLang).display.config.lang_success, [instances.lang.get(newLang).displayName]))] + }) + await context.send({ + embeds: [this.genNotChangedMessage(instances.lang.get(newLang).display.config.exist)], + ephemeral: true + }) + return + case VoiceLogSetStatus.MissingLang: + await context.send({ + embeds: [ + this.genErrorMessage(ERR_MISSING_LANG_DEFAULT), + this.genNotChangedMessage(instances.lang.get(data.lang).display.config.exist) + ], + ephemeral: true + }) + return + case VoiceLogSetStatus.NotChanged: + await context.send({ + embeds: [ + this.genNotChangedMessage(vsprintf(instances.lang.get(data.lang).display.config.lang_exist, [instances.lang.get(data.lang).displayName])), + this.genNotChangedMessage(instances.lang.get(data.lang).display.config.exist) + ], + ephemeral: true + }) + + } + } catch { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.config.error)], + ephemeral: true + }) + } + } + } + + public async commandLang(context: CommandContext) { + if (!context.guildID || !context.member) return + const member = await this.client.getRESTGuildMember(context.guildID, context.member.id) + if (!member) return + + const data = await this.serverConfig.getOrCreate(member.guild.id) + + if (!(member.permissions.has('manageMessages')) && !(instances.config.discord.admins.includes(member.id))) { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.no_permission)], + ephemeral: true + }) + return + } + + const guildId = member.guild.id + const newLang = context.options.language.language as string + + switch (await this.voiceLog.text.setLang(guildId, newLang)) { + case VoiceLogSetStatus.LangSuccess: + await context.send({ + embeds: [this.genSuccessMessage(vsprintf(instances.lang.get(newLang).display.config.lang_success, [instances.lang.get(newLang).displayName]))] + }) + return + case VoiceLogSetStatus.MissingLang: + await context.send({ + embeds: [this.genErrorMessage(ERR_MISSING_LANG)], + ephemeral: true + }) + return + case VoiceLogSetStatus.NotChanged: + await context.send({ + embeds: [this.genNotChangedMessage(vsprintf(instances.lang.get(data.lang).display.config.lang_exist, [instances.lang.get(data.lang).displayName]))], + ephemeral: true + }) + return + default: + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.config.error)], + ephemeral: true + }) + + } + } + + public async commandUnsetVoiceLog(context: CommandContext) { + if (!context.guildID || !context.member) return + const member = await this.client.getRESTGuildMember(context.guildID, context.member.id) + if (!member) return + + const data = await this.serverConfig.getOrCreate(member.guild.id) + + if (!(member.permissions.has('manageMessages')) && !(instances.config.discord.admins.includes(member.id))) { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.no_permission)], + ephemeral: true + }) + return + } + + await this.voiceLog.text.unsetVoiceLog(member.guild.id) + await context.send({ + embeds: [this.genSuccessMessage(instances.lang.get(data.lang).display.config.unset_success)] + }) + } + + public async commandRefreshCache(context: CommandContext) { + if (!context.guildID || !context.member) return + const member = await this.client.getRESTGuildMember(context.guildID, context.member.id) + if (!member) return + + const data = await this.serverConfig.getOrCreate(member.guild.id) + + if (!(instances.config.discord.admins.includes(member.id))) { + await context.send({ + embeds: [this.genErrorMessage(instances.lang.get(data.lang).display.command.no_permission)], + ephemeral: true + }) + return + } + + this.voiceLog.voice.refreshCache(context) + } + + private genSuccessMessage(msg: string) { + return { + title: 'Success', + color: 4289797, + description: msg + } as MessageEmbedOptions + } + + private genNotChangedMessage(msg: string) { + return { + title: 'Nothing Changed', + color: 9274675, + description: msg + } as MessageEmbedOptions + } + + private genErrorMessage(msg: string) { + return { + title: 'Error', + color: 13632027, + description: msg + } as MessageEmbedOptions + } +} diff --git a/src/Core/Discord/VoiceLog/VoiceLog/Text.ts b/src/Core/Discord/VoiceLog/VoiceLog/Text.ts new file mode 100644 index 0000000..1fd1e3d --- /dev/null +++ b/src/Core/Discord/VoiceLog/VoiceLog/Text.ts @@ -0,0 +1,114 @@ +import { Member, VoiceChannel, MessageContent, Client, TextChannel } from 'eris' +import { ILogObj, Logger } from 'tslog' +import { vsprintf } from 'sprintf-js' +import { DbServerConfigManager } from '../../../MongoDB/db/ServerConfig' +import { Discord } from '../../Core' +import { instances } from '../../../../Utils/Instances' +import { VoiceLog } from '../VoiceLog' + +const ERR_UNEXPECTED_LANG_STATUS = new Error('Unexpected lang set status') +const ERR_NO_PERMISSION = new Error('Not enough permissions to send message') + +/* eslint-disable no-unused-vars -- definition*/ +export enum VoiceLogSetStatus { + AllSuccess, + NotChanged, + ChannelSuccess, + LangSuccess, + MissingLang, + ChannelSuccess_MissingLang +} +/* eslint-enable no-unused-vars */ + +export class VoiceLogText { + private client: Client + private logger: Logger + private serverConfig: DbServerConfigManager + + constructor(voiceLog: VoiceLog, discord: Discord) { + this.client = discord.client + this.logger = voiceLog.logger.getSubLogger({ name: 'Text' }) + this.serverConfig = voiceLog.serverConfig + } + + public async setVoiceLog(guildId: string, channelId: string, lang: string | undefined = undefined): Promise { + const permissionCheck = ((this.client.getChannel(channelId)) as TextChannel).permissionsOf(this.client.user.id) + if (!permissionCheck.has('sendMessages') || !permissionCheck.has('embedLinks')) { + this.logger.error('Not enough permissions to send message') + throw ERR_NO_PERMISSION + } + + const data = await this.serverConfig.getOrCreate(guildId) + if (lang) { + if (data.channelID === channelId && data.lang === lang) return VoiceLogSetStatus.NotChanged + if (data.channelID === channelId) { + return await this.setLang(guildId, lang) + } + await this.serverConfig.updateChannel(guildId, channelId) + switch (await this.setLang(guildId, lang)) { + case VoiceLogSetStatus.LangSuccess: + return VoiceLogSetStatus.AllSuccess + case VoiceLogSetStatus.MissingLang: + return VoiceLogSetStatus.ChannelSuccess_MissingLang + case VoiceLogSetStatus.NotChanged: + return VoiceLogSetStatus.ChannelSuccess + default: + this.logger.error('Unexpected lang set status') + throw ERR_UNEXPECTED_LANG_STATUS + } + + } else { + if (data.channelID === channelId) return VoiceLogSetStatus.NotChanged + + await this.serverConfig.updateChannel(guildId, channelId) + return VoiceLogSetStatus.ChannelSuccess + } + } + + public async setLang(guildId: string, lang: string): Promise { + if (!instances.lang.isExist(lang)) return VoiceLogSetStatus.MissingLang + + const data = await this.serverConfig.getOrCreate(guildId) + + if (data.lang === lang) return VoiceLogSetStatus.NotChanged + + await this.serverConfig.updateLang(guildId, lang) + return VoiceLogSetStatus.LangSuccess + } + + public async unsetVoiceLog(guildId: string) { + await this.serverConfig.updateChannel(guildId, '') + } + + public genVoiceLogEmbed(member: Member, lang: string, type: string, oldChannel: VoiceChannel | undefined, newChannel: VoiceChannel | undefined) { + let color: number + let content: string + switch (type) { + case 'join': + color = 4289797 + content = vsprintf(instances.lang.get(lang).display.voice_log.joined, [newChannel?.name]) + break + case 'leave': + color = 8454161 + content = vsprintf(instances.lang.get(lang).display.voice_log.left, [oldChannel?.name]) + break + case 'move': + color = 10448150 + content = vsprintf('%0s ▶️ %1s', [oldChannel?.name, newChannel?.name]) + break + default: + color = 6776679 + content = 'Unknown type' + break + } + return { + embed: { + color, + title: member.nick ? member.nick : member.username, + description: content, + timestamp: (new Date()).toISOString(), + author: { name: '𝅺', icon_url: member.avatarURL } + } + } as MessageContent + } +} diff --git a/src/Core/Discord/VoiceLog/VoiceLog/Voice.ts b/src/Core/Discord/VoiceLog/VoiceLog/Voice.ts new file mode 100644 index 0000000..087c035 --- /dev/null +++ b/src/Core/Discord/VoiceLog/VoiceLog/Voice.ts @@ -0,0 +1,289 @@ +import waitUntil from 'async-wait-until' +import { VoiceChannel } from 'eris' +import { readdirSync as readDir, readFileSync as readFile, unlinkSync as deleteFile } from 'fs' +import { ILogObj, Logger } from 'tslog' +import { extname } from 'path' +import Queue from 'promise-queue' +import { CommandContext, MessageEmbedOptions } from 'slash-create' +import { DbServerConfigManager } from '../../../MongoDB/db/ServerConfig' +import { Discord } from '../../Core' +import { DiscordVoice } from '../../Core/Voice' +import { VoiceLog } from '../VoiceLog' +import { instances } from '../../../../Utils/Instances' + +export class VoiceLogVoice { + private updateLock = false + private discord: Discord + private audios: { [key: string]: DiscordVoice } = {} + private logger: Logger + private serverConfig: DbServerConfigManager + + constructor(voiceLog: VoiceLog, discord: Discord) { + this.discord = discord + this.logger = voiceLog.logger.getSubLogger({ name: 'Voice' }) + this.serverConfig = voiceLog.serverConfig + } + + public getCurrentVoice(guildId: string): DiscordVoice | undefined { + const voice = this.audios[guildId] + if (!voice) { + const botVoice = this.discord.client.voiceConnections.get(guildId) + if (botVoice && botVoice.ready) { + if (botVoice.channelID) this.audios[guildId] = new DiscordVoice(this.discord, botVoice.channelID, botVoice) + return this.audios[guildId] + } + return undefined + } else if (!voice.isReady()) { + this.destroy(guildId) + return undefined + } + + return this.audios[guildId] + } + + public async join(guildId: string, channelId: string, updateDatabase = false, playJoin = false): Promise { + if (this.audios[guildId]) { + if (!this.audios[guildId].isReady() && !this.audios[guildId].init) { + this.destroy(guildId) + } else if (this.audios[guildId].channelId !== channelId) { + this.audios[guildId].switchChannel(channelId) + + if (updateDatabase) { + this.serverConfig.updateLastVoiceChannel(guildId, '') + this.serverConfig.updateCurrentVoiceChannel(guildId, channelId) + } + + if (playJoin) this.audios[guildId].playMoved() + return this.audios[guildId] + } else { + return this.audios[guildId] + } + } + + this.audios[guildId] = new DiscordVoice(this.discord, channelId) + try { + await waitUntil(() => this.audios[guildId] && this.audios[guildId].isReady()) + } catch (error) { + this.logger.error('Voice timed out:', error) + return + } + if (updateDatabase) { + this.serverConfig.updateLastVoiceChannel(guildId, '') + this.serverConfig.updateCurrentVoiceChannel(guildId, channelId) + } + + if (playJoin) this.audios[guildId].playReady() + + return this.audios[guildId] + } + + public async destroy(guildId: string, updateDatabase = false) { + this.audios[guildId].destroy() + delete this.audios[guildId] + if (updateDatabase) { + this.serverConfig.updateLastVoiceChannel(guildId, '') + this.serverConfig.updateCurrentVoiceChannel(guildId, '') + } + } + + private async sleep(guildId: string, channelId: string) { + this.logger.info(`No user in ${channelId}, sleeping...`) + this.destroy(guildId) + this.serverConfig.updateLastVoiceChannel(guildId, channelId) + this.serverConfig.updateCurrentVoiceChannel(guildId, '') + } + + public async end() { + for (const guildId in this.audios) { + this.destroy(guildId) + } + } + + public async refreshCache(context: CommandContext | undefined) { + if (this.updateLock) { + await context?.send({ + embeds: [{ + title: 'Operation skipped', + color: 13632027, + description: 'Currently refreshing in progress...' + } as MessageEmbedOptions], + ephemeral: true + }) + + return + } + this.updateLock = true + this.logger.info('Starting cache refresh...') + const title = '➡️ Refreshing Caches' + let seekCounter = 0 + let seekFilename = '' + let seekDone = false + let seekField = { + name: `${seekDone ? '✅' : '➡️'} Seeking for files ...${seekDone ? ' Done' : ''}`, + value: `${(seekDone || seekCounter === 0) ? '' : `Current ${seekFilename}, `} Seeked ${seekCounter} files. ` + } + let progressMessage = this.genProgressMessage(title, [seekField]) + await context?.send({ embeds: [progressMessage] }) + let progressCount = 0 + let progressTotal = 0 + const queue = new Queue(1, Infinity) + const getTTS = (text: string, lang: string) => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (res) => { + progressCount++ + const progressField = { + name: '➡️ Processing texts...', + value: `(${progressCount}/${progressTotal}) ${text} in ${lang}` + } + progressMessage = this.genProgressMessage(title, [seekField, progressField]) + await context?.editOriginal({ embeds: [progressMessage] }) + instances.ttsHelper.getTTSFile(text, lang).then(fileName => { + this.logger.info(`(${progressCount}/${progressTotal}) ${text} in ${lang} -> ${fileName}`) + if (fileName !== null) ttsList.push(fileName) + setTimeout(() => { res() }, 500) + }) + }) + } + const getWaveTTS = (text: string, lang: string, voice: string) => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (res) => { + progressCount++ + const progressField = { + name: '➡️ Processing texts...', + value: `(${progressCount}/${progressTotal}) ${text} in ${lang} with voice ${voice}` + } + progressMessage = this.genProgressMessage(title, [seekField, progressField]) + await context?.editOriginal({ embeds: [progressMessage] }) + instances.ttsHelper.getWaveTTS(text, lang, voice).then(fileName => { + this.logger.info(`(${progressCount}/${progressTotal}) ${text} in ${lang} with voice ${voice} -> ${fileName}`) + if (fileName !== null) ttsList.push(fileName) + setTimeout(() => { res() }, 500) + }) + }) + } + const ttsList: string[] = [] + queue.add(() => getWaveTTS('VoiceLog is moved to your channel.', 'en-US', 'en-US-Wavenet-D')) + queue.add(() => getWaveTTS('VoiceLog is ready.', 'en-US', 'en-US-Wavenet-D')) + progressTotal += 2 + const typeList = ['join', 'left', 'switched_out', 'switched_in'] + const files = readDir('assets/') + files.forEach(file => { + if (extname(file) === '.json') { + seekCounter++ + seekFilename = file + seekField = { + name: `${seekDone ? '✅' : '➡️'} Seeking for files ...${seekDone ? ' Done' : ''}`, + value: `${(seekDone || seekCounter === 0) ? '' : `Current ${seekFilename}, `} Seeked ${seekCounter} files. ` + } + const tts = JSON.parse(readFile(`assets/${file}`, { encoding: 'utf-8' })) + if (tts.use_wave_tts && tts.lang && tts.voice) { + typeList.forEach(type => { + if (tts[type]) { + progressTotal++ + queue.add(() => getWaveTTS(tts[type], tts.lang, tts.voice)) + } + }) + } else if (tts.lang) { + typeList.forEach(type => { + if (tts[type]) { + progressTotal++ + queue.add(() => getTTS(tts[type], tts.lang)) + } + }) + } + } + }) + seekDone = true + seekField = { + name: `${seekDone ? '✅' : '➡️'} Seeking for files ...${seekDone ? ' Done' : ''}`, + value: `${(seekDone || seekCounter === 0) ? '' : `Current ${seekFilename}, `} Seeked ${seekCounter} files. ` + } + const afterWork = () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (res) => { + const progressField = { + name: '✅ Processing files... Done', + value: `Processed ${progressTotal} texts.` + } + let cacheRemoveCount = 0 + let cacheField = { + name: '➡️ Removing unused cache...', + value: (cacheRemoveCount === 0) ? 'Seeking...' : `Removed ${cacheRemoveCount} unused ${(cacheRemoveCount === 1) ? 'cache' : 'caches'}.` + } + progressMessage = this.genProgressMessage(title, [seekField, progressField, cacheField]) + await context?.editOriginal({ embeds: [progressMessage] }) + const cacheFiles = readDir('caches/') + cacheFiles.forEach(async file => { + if (!ttsList.includes(`./caches/${file}`)) { + deleteFile(`./caches/${file}`) + this.logger.info(`Deleted unused file ./caches/${file}`) + cacheRemoveCount++ + progressMessage = this.genProgressMessage(title, [seekField, progressField, cacheField]) + await context?.editOriginal({ embeds: [progressMessage] }) + } + }) + cacheField = { + name: '✅ Removing unused cache... Done', + value: (cacheRemoveCount === 0) ? 'No unused caches found.' : `Removed ${cacheRemoveCount} unused ${(cacheRemoveCount === 1) ? 'cache' : 'caches'}.` + } + progressMessage = this.genProgressMessage('✅ Refresh Caches Done', [seekField, progressField, cacheField], true) + await context?.editOriginal({ embeds: [progressMessage] }) + this.updateLock = false + res() + }) + } + queue.add(() => afterWork()) + } + + private genProgressMessage(title: string, fields: Array<{ name: string, value: string }>, isDone = false) { + return { + color: (isDone) ? 4289797 : 16312092, + title, + fields + } as MessageEmbedOptions + } + + public async autoLeaveChannel(oldChannel: VoiceChannel | undefined, newChannel: VoiceChannel | undefined, guildId: string): Promise { + let channelToCheck: VoiceChannel | undefined + + const voice = this.getCurrentVoice(guildId) + const data = await this.serverConfig.get(guildId) + + if (voice?.isReady()) { + channelToCheck = (oldChannel?.id === voice?.channelId) ? oldChannel : ((newChannel?.id === voice?.channelId) ? newChannel : undefined) + } else if (data) { + channelToCheck = (oldChannel?.id === data.lastVoiceChannel) ? oldChannel : ((newChannel?.id === data.lastVoiceChannel) ? newChannel : undefined) + } + + if (!channelToCheck) return + + let noUser = true + + channelToCheck.voiceMembers?.forEach(user => { + if (!user.bot) { + noUser = false + } + }) + + if (noUser) { + if (voice) { + await this.sleep(guildId, channelToCheck.id) + } + + } else { + let connection = await this.join(guildId, channelToCheck.id, true) + for (let i = 0; i < 5; ++i) { + if (!connection) { + this.logger.warn(`Auto reconnect failed, retrying (${i + 1} / 5)...`) + await this.destroy(guildId) + connection = await this.join(guildId, channelToCheck.id, true) + } else { + return channelToCheck.id + } + } + + this.logger.error('Auto reconnect fails after 5 tries') + + } + } +} diff --git a/src/Core/Lang.ts b/src/Core/Lang.ts deleted file mode 100644 index 3ab701d..0000000 --- a/src/Core/Lang.ts +++ /dev/null @@ -1,67 +0,0 @@ -import fs from 'fs'; -import { resolve } from 'path'; -import { ApplicationCommandOption, ApplicationCommandOptionChoice, CommandOptionType } from 'slash-create'; -import { Core } from '..'; - -export class Lang { - private lang: { [key: string]: { display: { [key: string]: { [key: string]: string } }, displayName: string } } = {}; - constructor(core: Core) { - if (!fs.existsSync('./langs')) { - core.mainLogger.error('Directory langs/ not found. Try re-pulling source code.'); - process.exit(1); - } - if (!fs.existsSync('./langs/list.json')) { - core.mainLogger.error('Directory langs/list.json not found. Try re-pulling source code.'); - process.exit(1); - } - let listRaw: { [key: string]: { file: string, display_name: string } }; - try { - listRaw = require(resolve('./langs/list.json')); - } catch (error) { - core.mainLogger.error(`Error when loading langs/list.json: ${error}`); - process.exit(1); - } - for (const key of Object.keys(listRaw)) { - try { - this.lang[key] = { - display: require(resolve(listRaw[key].file)), - displayName: listRaw[key].display_name - }; - } catch (error) { - core.mainLogger.error(`Error when loading ${listRaw[key].file}: ${error}`); - } - } - } - public get(lang: string) { - if (lang in this.lang) { - return this.lang[lang]; - } else { - return this.lang.en_US; - } - } - - public isExist(lang: string) { return (lang in this.lang); } - - public genOptions(required: boolean) { - const choice: ApplicationCommandOptionChoice[] = []; - - for (const key of Object.keys(this.lang)) { - if (!this.lang[key]) continue; - - choice.push({ - name: this.lang[key].displayName, - value: key - }); - } - - const options: ApplicationCommandOption[] = [{ - name: 'language', - description: 'VoiceLog Language', - required: required, - choices: choice, - type: CommandOptionType.STRING - }]; - - return options; - } -} diff --git a/src/Core/MongoDB/Core.ts b/src/Core/MongoDB/Core.ts new file mode 100644 index 0000000..4446878 --- /dev/null +++ b/src/Core/MongoDB/Core.ts @@ -0,0 +1,69 @@ +import { EventEmitter } from 'events' +import { Db, MongoClient, ServerApiVersion } from 'mongodb' +import { DbServerConfigManager } from './db/ServerConfig' +import { instances } from '../../Utils/Instances' + +export const ERR_DB_NOT_INIT = Error('Database is not initialized') +export const ERR_INSERT_FAILURE = Error('Data insert failed.') + +export class MongoDB extends EventEmitter { + private client: MongoClient = new MongoClient(instances.config.mongodb.host, { + serverApi: { + version: ServerApiVersion.v1, + strict: true, + deprecationErrors: true + } + }) + private _db?: Db + private _serverConfig?: DbServerConfigManager + private logger = instances.mainLogger.getSubLogger({ name: 'MongoDB' }) + + constructor() { + super() + + let connectTryCount = 0 + const maxConnectTryCount = 5 + const tryConnect = () => { + this.logger.info('Trying to connect to mongoDB...') + this.client + .connect() + .then(() => { + this.logger.info('Successfully connected to mongoDB') + + this._db = this.client.db(instances.config.mongodb.name) + + // Initialize collections + this._serverConfig = new DbServerConfigManager(this._db) + + this.emit('connect', this.client) + }) + .catch((err) => { + this.logger.error('Failed to connect to mongoDB:', err) + + connectTryCount++ + if (connectTryCount > maxConnectTryCount) { + this.logger.fatal('Unable to connect to mongoDB.') + this.emit('error') + } + + this.logger.warn( + `Retrying to in 5 seconds... (try ${connectTryCount} / ${maxConnectTryCount} )` + ) + setTimeout(tryConnect, 5 * 1000) + }) + } + + tryConnect() + } + + public close() { + this.logger.info('Closing mongoDB connection...') + this.client.close() + } + + public get serverConfig() { + if (!this._serverConfig) throw ERR_DB_NOT_INIT + + return this._serverConfig + } +} diff --git a/src/Core/MongoDB/db/ServerConfig.ts b/src/Core/MongoDB/db/ServerConfig.ts new file mode 100644 index 0000000..6f27573 --- /dev/null +++ b/src/Core/MongoDB/db/ServerConfig.ts @@ -0,0 +1,137 @@ +import { existsSync as exists, readFileSync as readFile, renameSync as renameFile } from 'fs' +import { Collection, Db, ObjectId, ReturnDocument } from 'mongodb' +import { ERR_DB_NOT_INIT, ERR_INSERT_FAILURE } from '../Core' +import { instances } from '../../../Utils/Instances' + +export interface IServerConfig { + _id: ObjectId; + serverID: string; + lang: string; + channelID: string; + lastVoiceChannel: string; + currentVoiceChannel: string; +} + +export class DbServerConfigManager { + private database?: Collection + + constructor(db: Db) { + if (!db) throw Error('Database client not init') + + this.database = db.collection('serverConfig') + this.database.createIndex({ serverID: 1 }) + + this.migrateData() + } + + private async migrateData() { + if (exists('./vlogdata.json')) { + instances.mainLogger.info('Old data found. Migrating to db...') + const dataRaw = JSON.parse( + readFile('./vlogdata.json', { encoding: 'utf-8' }) + ) + for (const key of Object.keys(dataRaw)) { + if (dataRaw[key] === undefined) continue + await this.create( + key, + dataRaw[key].channel, + dataRaw[key].lang, + dataRaw[key].lastVoiceChannel + ) + } + renameFile('./vlogdata.json', './vlogdata.json.bak') + } + + // Add field admin to old lists + this.database?.updateMany( + { currentVoiceChannel: { $exists: false } }, + { $set: { currentVoiceChannel: '' } } + ) + } + + public async create( + serverID: string, + channelID = '', + lang = 'en_US', + lastVoiceChannel = '', + currentVoiceChannel = '' + ) { + if (!this.database) throw ERR_DB_NOT_INIT + + const data = { + serverID, + channelID, + lang, + lastVoiceChannel, + currentVoiceChannel + } as IServerConfig + + return (await this.database.insertOne(data)).acknowledged ? data : null + } + + public get(serverID: string) { + if (!this.database) throw ERR_DB_NOT_INIT + + return this.database.findOne({ serverID }) + } + + public async getOrCreate(guildId: string) { + let data = await this.get(guildId) + if (!data) data = await this.create(guildId) + if (!data) throw ERR_INSERT_FAILURE + + return data + } + + public getCurrentChannels() { + if (!this.database) throw ERR_DB_NOT_INIT + + return this.database.find({ currentVoiceChannel: { $ne: '' } }).toArray() + } + + public async updateChannel(serverID: string, channelID: string) { + if (!this.database) throw ERR_DB_NOT_INIT + + return await this.database.findOneAndUpdate( + { serverID }, + { $set: { channelID } }, + { returnDocument: ReturnDocument.AFTER } + ) + } + + public async updateLang(serverID: string, lang: string) { + if (!this.database) throw ERR_DB_NOT_INIT + + return await this.database.findOneAndUpdate( + { serverID }, + { $set: { lang } }, + { returnDocument: ReturnDocument.AFTER } + ) + } + + public async updateLastVoiceChannel( + serverID: string, + lastVoiceChannel: string + ) { + if (!this.database) throw ERR_DB_NOT_INIT + + return await this.database.findOneAndUpdate( + { serverID }, + { $set: { lastVoiceChannel } }, + { returnDocument: ReturnDocument.AFTER } + ) + } + + public async updateCurrentVoiceChannel( + serverID: string, + currentVoiceChannel: string + ) { + if (!this.database) throw ERR_DB_NOT_INIT + + return await this.database.findOneAndUpdate( + { serverID }, + { $set: { currentVoiceChannel } }, + { returnDocument: ReturnDocument.AFTER } + ) + } +} diff --git a/src/Core/TTSHelper.ts b/src/Core/TTSHelper.ts deleted file mode 100644 index d5fe4b7..0000000 --- a/src/Core/TTSHelper.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createWriteStream, existsSync } from 'fs'; -import { Logger } from 'tslog-helper'; -import md5 from 'md5'; -import fetch, { RequestInit } from 'node-fetch'; -import { Readable } from 'stream'; -import { Core } from '..'; -import { Config } from './Config'; -import { MPEGDecoderWebWorker } from 'mpg123-decoder'; -import { writeFile } from 'fs/promises'; - -export class TTSHelper { - private logger: Logger; - private config: Config; - private mp3Decoder: MPEGDecoderWebWorker; - - constructor(core: Core) { - this.logger = core.mainLogger.getChildLogger({ name: 'TTSHelper'}); - this.config = core.config; - // eslint-disable-next-line @typescript-eslint/no-var-requires - this.mp3Decoder = new (require('fix-esm').require('mpg123-decoder')).MPEGDecoderWebWorker(); - } - - public async getTTSFile(text: string, lang: string): Promise { - const filePath = `./caches/${md5(`${text}-${lang}`)}.pcm`; - if (!existsSync(filePath)) { - const ttsURL = encodeURI(`https://translate.google.com.tw/translate_tts?ie=UTF-8&q=${text}&tl=${lang}&client=tw-ob`); - try { - const res = await fetch(ttsURL); - if (res.ok) { - const mp3 = res.arrayBuffer(); - await this.mp3Decoder.ready; - - // Decode mp3 to PCM 24kHz mono f32 - const { channelData } = await this.mp3Decoder.decode(new Uint8Array(await mp3)); - await this.mp3Decoder.reset(); - - // Covent to 48kHz stereo s16 - const pcm = new Int16Array(channelData[0].length * 4); - let temp = 0; - channelData[0].forEach((v, index) => { - const i = v < 0 ? v * 0x8000 : v * 0x7FFF; // f32 to s16 - - // Linear interpolation - const i1 = Math.round((temp + i) / 2); - const i2 = Math.round(i); - temp = i; - - pcm.set([i1, i1, i2, i2], index * 4); // 24kHz mono to 48kHz stereo - }); - await writeFile(filePath, pcm); - } else { - this.logger.error(`TTS ${text} in ${lang} download failed. response code: ${res.status}`); - } - } catch (error) { - if (error instanceof Error) { - this.logger.error(`TTS ${text} in ${lang} download failed: ${error.message}`, error); - } - return null; - } - } - return filePath; - } - - public async getWaveTTS(text: string, lang: string, voice: string): Promise { - const filePath = `./caches/${md5(`${text}-${lang}-${voice}`)}.opus`; - if (!existsSync(filePath)) { - const key = this.config.googleTTS.apiKey; - const url = `https://content-texttospeech.googleapis.com/v1/text:synthesize?alt=json&key=${key}`; - const options = { - body: `{"input":{"text":"${text}"},"voice":{"name":"${voice}","languageCode":"${lang}"},"audioConfig":{"audioEncoding":"OGG_OPUS"}}`, - headers: { - 'Content-Type': 'application/json', - 'X-Origin': 'https://explorer.apis.google.com', - 'X-Referer': 'https://explorer.apis.google.com', - }, - method: 'POST', - }; - await this.downloadWaveTTS(url, options, filePath); - } - return filePath; - } - - private async downloadWaveTTS(url: string, options: RequestInit, path: string) { - await fetch(url, options) - .then(response => response.json()) - .then(data => { - const imgBuffer = Buffer.from(data.audioContent, 'base64'); - - const s = new Readable(); - - s.push(imgBuffer); - s.push(null); - - s.pipe(createWriteStream(path)); - }); - } -} diff --git a/src/Plugin/Base/PluginBase.ts b/src/Plugin/Base/PluginBase.ts new file mode 100644 index 0000000..0ebb559 --- /dev/null +++ b/src/Plugin/Base/PluginBase.ts @@ -0,0 +1,11 @@ + +export interface IPluginBase { + pluginName:string; + description:string; +} + +interface IPluginConstructor { + new(): void; +} + +declare const IPluginBase: IPluginConstructor diff --git a/src/Plugin/Base/VoiceOverwrite.ts b/src/Plugin/Base/VoiceOverwrite.ts new file mode 100644 index 0000000..abf90bd --- /dev/null +++ b/src/Plugin/Base/VoiceOverwrite.ts @@ -0,0 +1,9 @@ +/* eslint-disable no-unused-vars -- definition file*/ +import { Member } from 'eris' +import { IPluginBase } from './PluginBase' + +export interface IVoiceOverwrite extends IPluginBase { + typeVoiceOverwrite: boolean; + + playVoice(member: Member, type: string): Promise; +} diff --git a/src/Plugin/Core.ts b/src/Plugin/Core.ts new file mode 100644 index 0000000..0d68705 --- /dev/null +++ b/src/Plugin/Core.ts @@ -0,0 +1,74 @@ +import { readdirSync as readDir, existsSync as exists } from 'fs' +import { ILogObj, Logger } from 'tslog' +import { extname } from 'path' +import { IPluginBase } from './Base/PluginBase' +import { IVoiceOverwrite } from './Base/VoiceOverwrite' + +export class PluginManager { + private logger: Logger + private loadedPlugins: { [key: string]: IPluginBase } = {} + private _voiceOverwrites: { [key: string]: IVoiceOverwrite } = {} + + constructor(mainLogger: Logger) { + this.logger = mainLogger.getSubLogger({ name: 'Plugin'}) + + this.reloadPluginList().then(() => { + this.enablePlugins() + }) + } + + public async reloadPluginList() { + this.logger.info('Loading plugins...') + if (!exists(`${__dirname}/Plugins/`)) { + this.logger.error(`${__dirname}/Plugins/ not found, skipping`) + return + } + const files = readDir(`${__dirname}/Plugins/`) + + for (const file of files) { + if (extname(file) === '.js') { + const value = await import(`${__dirname}/Plugins/${file}`) + for (const className of Object.keys(value)) { + if (!value[className]) continue + this.logger.info(`Found ${className} in ${file}`) + this.loadedPlugins[className] = new value[className]() + this.logger.info(`Loaded ${className} as ${this.loadedPlugins[className].pluginName} (${this.loadedPlugins[className].description})`) + } + } + } + } + + private enablePlugins() { + // Todo config + for (const className of Object.keys(this.loadedPlugins)) { + this.enablePlugin(className) + } + } + + public enablePlugin(className: string): boolean { + if ((this.loadedPlugins[className] as IVoiceOverwrite).typeVoiceOverwrite) { + this._voiceOverwrites[className] = this.loadedPlugins[className] as IVoiceOverwrite + this.logger.info(`Enabled ${this.loadedPlugins[className].pluginName} as IVoiceOverwrite`) + return true + } + + this.logger.error(`Enable ${this.loadedPlugins[className].pluginName} failed: No compatible plugins found`) + return false + } + + + public disablePlugin(className: string): boolean { + if (Object.keys(this._voiceOverwrites).includes(className)) { + delete this._voiceOverwrites[className] + this.logger.info(`Disabled ${this.loadedPlugins[className].pluginName}`) + return true + } + + this.logger.error(`Disable ${this.loadedPlugins[className].pluginName} failed: Plugin is not enabled`) + return false + } + + public get voiceOverwrites() { + return Object.values(this._voiceOverwrites) + } +} diff --git a/src/Components/Plugin/Plugins/.gitkeep b/src/Plugin/Plugins/.gitkeep similarity index 100% rename from src/Components/Plugin/Plugins/.gitkeep rename to src/Plugin/Plugins/.gitkeep diff --git a/src/Utils/Config.ts b/src/Utils/Config.ts new file mode 100644 index 0000000..74ed5bb --- /dev/null +++ b/src/Utils/Config.ts @@ -0,0 +1,168 @@ +import { constants, copyFileSync as copyFile, existsSync as exists, readFileSync as readFile, writeFileSync as writeFile } from 'fs' +import { ILogObj, ISettingsParam, Logger } from 'tslog' + +export interface ConfigValue { + configVersion: string | number, + discord: DiscordConfig + googleTTS: GoogleTTSConfig + mongodb: MongoDBConfig + debug: boolean +} + +export interface DiscordConfig { + botToken: string + applicationID: string + publicKey: string + admins: string[] +} + +export interface GoogleTTSConfig { + apiKey: string +} + +export interface MongoDBConfig { + host: string + name: string +} + +export const loggerOptions: ISettingsParam = { + name: 'Main', + prettyLogTimeZone: 'local', + hideLogPositionForProduction: true, + minLevel: 3 // Info +} + +export class Config { + private configVersion = 2 + private _discord: DiscordConfig + private _googleTTS: GoogleTTSConfig + private _mongodb: MongoDBConfig + private _debug: boolean + private logger: Logger + + private readonly discordDefault = { botToken: '', applicationID: '', publicKey: '', admins: [] } + private readonly googleTTSDefault = { apiKey: '' } + private readonly mongodbDefault = { host: 'mongodb://localhost:27017', name: 'VoiceLog' } + + constructor(mainLogger: Logger) { + this.logger = mainLogger.getSubLogger({ name: 'Config' }) + this.logger.info('Loading Config...') + + let versionChanged = false + + if (exists('./config.json')) { + const config = JSON.parse(readFile('config.json', { encoding: 'utf-8' })) + + versionChanged = this.checkVersion(config.configVersion) + + // read and migrate config + if (!config.discord) config.discord = {} + this._discord = this.mergeDiscordConfig(config) + + if (!config.googleTTS) config.googleTTS = {} + this._googleTTS = this.mergeGoogleTTSConfig(config) + + if (!config.mongodb) config.mongodb = {} + this._mongodb = this.mergeMongoDBConfig(config) + + this._debug = (config.debug) ? config.debug : ((config.Debug) ? config.Debug : false) + + this.save() + + if (versionChanged) { + this.backupAndQuit(config) + } + } else { + this.logger.fatal('Can\'t load config.json: File not found.') + this.logger.info('Generating empty config...') + this._discord = this.discordDefault + this._googleTTS = this.googleTTSDefault + this._mongodb = this.mongodbDefault + this._debug = false + this.save() + this.logger.info('Fill your config and try again.') + process.exit(1) + } + + } + + private checkVersion(version: number) { + if (!version || version < this.configVersion) return true + if (version > this.configVersion) { + this.logger.fatal('This config version is newer than me! Consider upgrading to the latest version or reset your configuration.') + process.exit(1) + } + return false + } + + private mergeDiscordConfig(config: { discord: { botToken: string; applicationID: string; publicKey: string; admins: string[] }; TOKEN: string; admins: string[] }) { + return { + botToken: (config.discord.botToken) ? config.discord.botToken : ((config.TOKEN) ? config.TOKEN : this.discordDefault.botToken), + applicationID: (config.discord.applicationID) ? config.discord.applicationID : this.discordDefault.applicationID, + publicKey: (config.discord.publicKey) ? config.discord.publicKey : this.discordDefault.publicKey, + admins: (config.discord.admins) ? config.discord.admins : ((config.admins) ? config.admins : this.discordDefault.admins) + } as DiscordConfig + } + + private mergeGoogleTTSConfig(config: { googleTTS: { apiKey: string }; googleAPIKey: string }) { + return { + apiKey: (config.googleTTS.apiKey) ? config.googleTTS.apiKey : ((config.googleAPIKey) ? config.googleAPIKey : this.googleTTSDefault.apiKey) + } as GoogleTTSConfig + } + + private mergeMongoDBConfig(config: { mongodb: { host: string; name: string }; database: { host: string; name: string } }) { + return { + host: (config.mongodb.host) ? config.mongodb.host : ((config.database.host) ? config.database.host : this.mongodbDefault.host), + name: (config.mongodb.name) ? config.mongodb.name : ((config.database.name) ? config.database.name : this.mongodbDefault.name) + } as MongoDBConfig + } + + private backupAndQuit(config: ConfigValue) { + if (!config.configVersion) config.configVersion = 'legacy' + let copyConfigName = `./config-${config.configVersion}.json` + if (exists(copyConfigName)) { + let copyNumber = 1 + copyConfigName = `./config-${config.configVersion}-${copyNumber}.json` + while (exists(copyConfigName)) { + copyNumber++ + copyConfigName = `./config-${config.configVersion}-${copyNumber}.json` + } + } + + // backup old config + copyFile('./config.json', copyConfigName, constants.COPYFILE_EXCL) + // save new config + this.save() + + this.logger.warn('Detected config version change and we have tried to backup and migrate into it! Consider checking your config file.') + process.exit(1) + } + + private save() { + const json = JSON.stringify({ + '//configVersion': 'DO NOT MODIFY THIS UNLESS YOU KNOW WHAT YOU ARE DOING!!!!!', + configVersion: this.configVersion, + discord: this._discord, + googleTTS: this._googleTTS, + mongodb: this._mongodb, + debug: this._debug + }, null, 4) + writeFile('./config.json', json, 'utf8') + } + + public get discord() { + return this._discord + } + + public get googleTTS() { + return this._googleTTS + } + + public get mongodb() { + return this._mongodb + } + + public get debug() { + return this._debug + } +} diff --git a/src/Utils/Instances.ts b/src/Utils/Instances.ts new file mode 100644 index 0000000..27a70c9 --- /dev/null +++ b/src/Utils/Instances.ts @@ -0,0 +1,34 @@ +import { ILogObj, Logger } from 'tslog' +import type { Discord } from '../Core/Discord/Core' +import type { MongoDB } from '../Core/MongoDB/Core' +import { Config, loggerOptions } from './Config' +import { Lang } from './Lang' +import { TTSHelper } from './TTSHelper' +import { PluginManager } from '../Plugin/Core' + +interface Instances { + mainLogger: Logger + config: Config + lang: Lang + ttsHelper: TTSHelper + pluginManager: PluginManager + discord: Discord | undefined + mongoDB: MongoDB | undefined +} + +// Static instances +const mainLogger = new Logger(loggerOptions) +const config = new Config(mainLogger) +const lang = new Lang(mainLogger) +const ttsHelper = new TTSHelper(config, mainLogger) +const pluginManager = new PluginManager(mainLogger) + +export const instances: Instances = { + mainLogger, + config, + lang, + ttsHelper, + pluginManager, + discord: undefined, + mongoDB: undefined +} diff --git a/src/Utils/Lang.ts b/src/Utils/Lang.ts new file mode 100644 index 0000000..7631f17 --- /dev/null +++ b/src/Utils/Lang.ts @@ -0,0 +1,66 @@ +import { existsSync as exists, readFileSync as readFile } from 'fs' +import { ApplicationCommandOption, ApplicationCommandOptionChoice, CommandOptionType } from 'slash-create' +import { Logger, ILogObj } from 'tslog' + +export class Lang { + private lang: { [key: string]: { display: { [key: string]: { [key: string]: string } }, displayName: string } } = {} + constructor(mainLogger: Logger) { + if (!exists('./langs')) { + mainLogger.error('Directory langs/ not found. Try re-pulling source code.') + process.exit(1) + } + if (!exists('./langs/list.json')) { + mainLogger.error('Directory langs/list.json not found. Try re-pulling source code.') + process.exit(1) + } + let listRaw: { [key: string]: { file: string, display_name: string } } + try { + listRaw = JSON.parse(readFile('./langs/list.json', { encoding: 'utf-8' })) + } catch (error) { + mainLogger.error(`Error when loading langs/list.json: ${error}`) + process.exit(1) + } + for (const key of Object.keys(listRaw)) { + try { + this.lang[key] = { + display: JSON.parse(readFile(listRaw[key].file, { encoding: 'utf-8' })), + displayName: listRaw[key].display_name + } + } catch (error) { + mainLogger.error(`Error when loading ${listRaw[key].file}: ${error}`) + } + } + } + public get(lang: string) { + if (lang in this.lang) { + return this.lang[lang] + } + return this.lang.en_US + + } + + public isExist(lang: string) { return (lang in this.lang) } + + public genOptions(required: boolean) { + const choice: ApplicationCommandOptionChoice[] = [] + + for (const key of Object.keys(this.lang)) { + if (!this.lang[key]) continue + + choice.push({ + name: this.lang[key].displayName, + value: key + }) + } + + const options: ApplicationCommandOption[] = [{ + name: 'language', + description: 'VoiceLog Language', + required: required, + choices: choice, + type: CommandOptionType.STRING + }] + + return options + } +} diff --git a/src/Utils/TTSHelper.ts b/src/Utils/TTSHelper.ts new file mode 100644 index 0000000..66f8c38 --- /dev/null +++ b/src/Utils/TTSHelper.ts @@ -0,0 +1,124 @@ +import { createWriteStream, existsSync } from 'fs' +import { ILogObj, Logger } from 'tslog' +import md5 from 'md5' +import fetch, { RequestInit } from 'node-fetch' +import { Readable } from 'stream' +import { Config } from './Config' +import { MPEGDecoderWebWorker } from 'mpg123-decoder' +import { writeFile } from 'fs/promises' + +export class TTSHelper { + private logger: Logger + private config: Config + private mp3Decoder: MPEGDecoderWebWorker + + constructor(config: Config, mainLogger: Logger) { + this.logger = mainLogger.getSubLogger({ name: 'TTSHelper' }) + this.config = config + + // eslint-disable-next-line @typescript-eslint/no-require-imports + this.mp3Decoder = new (require('fix-esm').require( + 'mpg123-decoder' + ).MPEGDecoderWebWorker)() + } + + public async getTTSFile(text: string, lang: string): Promise { + const filePath = `./caches/${md5(`${text}-${lang}`)}.pcm` + if (!existsSync(filePath)) { + const ttsURL = encodeURI( + `https://translate.google.com.tw/translate_tts?ie=UTF-8&q=${text}&tl=${lang}&client=tw-ob` + ) + try { + const res = await fetch(ttsURL) + if (res.ok) { + const mp3 = res.arrayBuffer() + await this.mp3Decoder.ready + + // Decode mp3 to PCM 24kHz mono f32 + const { channelData } = await this.mp3Decoder.decode( + new Uint8Array(await mp3) + ) + await this.mp3Decoder.reset() + + // Covent to 48kHz stereo s16 + const pcm = new Int16Array(channelData[0].length * 4) + let temp = 0 + channelData[0].forEach((v, index) => { + const i = v < 0 ? v * 0x8000 : v * 0x7fff // f32 to s16 + + // Linear interpolation + const i1 = Math.round((temp + i) / 2) + const i2 = Math.round(i) + temp = i + + pcm.set([i1, i1, i2, i2], index * 4) // 24kHz mono to 48kHz stereo + }) + await writeFile(filePath, pcm) + } else { + this.logger.error( + `TTS ${text} in ${lang} download failed. response code: ${res.status}` + ) + } + } catch (error) { + if (error instanceof Error) { + this.logger.error( + `TTS ${text} in ${lang} download failed: ${error.message}`, + error + ) + } + return null + } + } + return filePath + } + + public async getWaveTTS( + text: string, + lang: string, + voice: string + ): Promise { + const filePath = `./caches/${md5(`${text}-${lang}-${voice}`)}.opus` + if (!existsSync(filePath)) { + const key = this.config.googleTTS.apiKey + const url = `https://content-texttospeech.googleapis.com/v1/text:synthesize?alt=json&key=${key}` + const options = { + body: `{"input":{"text":"${text}"},"voice":{"name":"${voice}","languageCode":"${lang}"},"audioConfig":{"audioEncoding":"OGG_OPUS"}}`, + headers: { + 'Content-Type': 'application/json', + 'X-Origin': 'https://explorer.apis.google.com', + 'X-Referer': 'https://explorer.apis.google.com' + }, + method: 'POST' + } + await this.downloadWaveTTS(url, options, filePath) + } + return filePath + } + + private async downloadWaveTTS( + url: string, + options: RequestInit, + path: string + ) { + await fetch(url, options) + .then((response) => response.json()) + .then((data) => { + const imgBuffer = Buffer.from(data.audioContent, 'base64') + + const s = new Readable() + + s.push(imgBuffer) + s.push(null) + + s.pipe(createWriteStream(path)) + }) + .catch((error) => { + if (error instanceof Error) { + this.logger.error( + `Download TTS failed: ${error.message}`, + error + ) + } + }) + } +} diff --git a/src/index.ts b/src/index.ts index a3e441e..23b94ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,49 +1,46 @@ -import { EventEmitter } from 'events'; -import { LogHelper } from 'tslog-helper'; -import { Discord } from './Components/Discord/Core'; -import { Config } from './Core/Config'; -import { MongoDB } from './Components/MongoDB/Core'; -import { ServerConfigManager } from './Components/MongoDB/db/ServerConfig'; -import { Status }from 'status-client'; -import { PluginManager } from './Components/Plugin/Core'; -import { TTSHelper } from './Core/TTSHelper'; -import { Lang } from './Core/Lang'; - -export class Core extends EventEmitter { - private readonly logHelper = new LogHelper(); - public readonly mainLogger = this.logHelper.logger; - public readonly config = new Config(this); - public readonly database = new MongoDB(this); - public readonly data = new ServerConfigManager(this); - public readonly ttsHelper = new TTSHelper(this); - public readonly lang = new Lang(this); - public readonly plugins = new PluginManager(this); - private readonly status = new Status('VoiceLog'); - constructor() { - super(); - this.mainLogger.info('Starting...'); - - if (this.config.debug) - this.mainLogger.setSettings({ minLevel: 'silly' }); - - this.emit('init', this); - - // Wait DB connect - this.database.on('connect', () => this.emit('ready')); - this.database.on('error', () => { - this.mainLogger.error('Unable to connect to database. Quitting...'); - process.exit(1); - }); - this.on('ready', async () => { - try { - new Discord(this); - } catch (error) { - console.error(error); - } - - this.status.set_status(); - }); - } -} +import { Discord } from './Core/Discord/Core' +import { MongoDB } from './Core/MongoDB/Core' +import { Status }from 'status-client' +import { instances } from './Utils/Instances' + +const logger = instances.mainLogger +logger.info('Starting...') +if (instances.config.debug) instances.mainLogger.settings.minLevel = 0 // Silly + +const status = new Status('VoiceLog') + +// Initialize MongoDB +const mongoDB = (instances.mongoDB = new MongoDB()) + +mongoDB.once('connect', () => { + // Initialize the bot + const discord = (instances.discord = new Discord()) -new Core(); + discord.start() + status.set_status() +}) + +mongoDB.once('error', () => { + logger.error('Unable to connect to database. Quitting...') + process.exit(1) +}) + +process.on('warning', (e) => { + logger.warn(e.message) +}) + +// Graceful shutdown +const stop = () => { + console.log() + logger.info('Shutting down...') + instances.discord?.stop() + instances.mongoDB?.close() + + // Wait for 5 seconds before force quitting + setTimeout(() => { + logger.warn('Force quitting...') + process.exit(0) + }, 5 * 1000) +} +process.on('SIGINT', () => stop()) +process.on('SIGTERM', () => stop()) diff --git a/tsconfig.json b/tsconfig.json index 1cdbb23..4d81280 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,8 +52,8 @@ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ "outDir": "./dist", /* Specify an output folder for all emitted files. */ "removeComments": true, /* Disable emitting comments. */ diff --git a/yarn.lock b/yarn.lock index bbbcd9a..d346f33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -287,10 +287,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.16.0": - version: 9.16.0 - resolution: "@eslint/js@npm:9.16.0" - checksum: 10c0/a55846a4ddade720662d36682f3eaaf38eac06eeee12c83bb837bba2b7d550dadcb3445b104219f0bc1da2e09b4fe5fb5ba123b8338c8c787bcfbd540878df75 +"@eslint/js@npm:9.17.0, @eslint/js@npm:^9.17.0": + version: 9.17.0 + resolution: "@eslint/js@npm:9.17.0" + checksum: 10c0/a0fda8657a01c60aa540f95397754267ba640ffb126e011b97fd65c322a94969d161beeaef57c1441c495da2f31167c34bd38209f7c146c7225072378c3a933d languageName: node linkType: hard @@ -433,6 +433,21 @@ __metadata: languageName: node linkType: hard +"@stylistic/eslint-plugin@npm:^2.12.1": + version: 2.12.1 + resolution: "@stylistic/eslint-plugin@npm:2.12.1" + dependencies: + "@typescript-eslint/utils": "npm:^8.13.0" + eslint-visitor-keys: "npm:^4.2.0" + espree: "npm:^10.3.0" + estraverse: "npm:^5.3.0" + picomatch: "npm:^4.0.2" + peerDependencies: + eslint: ">=8.40.0" + checksum: 10c0/52859e4148a268c8a16cad53dd2d89a641a26e1e61bb4f7368cf5ee1b7fc9904519ade65719096607dc03f22cf2a06d6d363f5b0e8510609e0a2ebd89acc344d + languageName: node + linkType: hard + "@types/estree@npm:^1.0.6": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" @@ -521,7 +536,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^8.18.0": +"@typescript-eslint/eslint-plugin@npm:8.18.0": version: 8.18.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.18.0" dependencies: @@ -542,7 +557,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:^8.18.0": +"@typescript-eslint/parser@npm:8.18.0": version: 8.18.0 resolution: "@typescript-eslint/parser@npm:8.18.0" dependencies: @@ -608,7 +623,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.18.0": +"@typescript-eslint/utils@npm:8.18.0, @typescript-eslint/utils@npm:^8.13.0": version: 8.18.0 resolution: "@typescript-eslint/utils@npm:8.18.0" dependencies: @@ -739,16 +754,16 @@ __metadata: linkType: hard "browserslist@npm:^4.24.0": - version: 4.24.2 - resolution: "browserslist@npm:4.24.2" + version: 4.24.3 + resolution: "browserslist@npm:4.24.3" dependencies: - caniuse-lite: "npm:^1.0.30001669" - electron-to-chromium: "npm:^1.5.41" - node-releases: "npm:^2.0.18" + caniuse-lite: "npm:^1.0.30001688" + electron-to-chromium: "npm:^1.5.73" + node-releases: "npm:^2.0.19" update-browserslist-db: "npm:^1.1.1" bin: browserslist: cli.js - checksum: 10c0/d747c9fb65ed7b4f1abcae4959405707ed9a7b835639f8a9ba0da2911995a6ab9b0648fd05baf2a4d4e3cf7f9fdbad56d3753f91881e365992c1d49c8d88ff7a + checksum: 10c0/bab261ef7b6e1656a719a9fa31240ae7ce4d5ba68e479f6b11e348d819346ab4c0ff6f4821f43adcc9c193a734b186775a83b37979e70a69d182965909fe569a languageName: node linkType: hard @@ -759,13 +774,6 @@ __metadata: languageName: node linkType: hard -"buffer-from@npm:^1.0.0": - version: 1.1.2 - resolution: "buffer-from@npm:1.1.2" - checksum: 10c0/124fff9d66d691a86d3b062eff4663fe437a9d9ee4b47b1b9e97f5a5d14f6d5399345db80f796827be7c95e70a8e765dd404b7c3ff3b3324f98e9b0c8826cc34 - languageName: node - linkType: hard - "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -773,7 +781,7 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001669": +"caniuse-lite@npm:^1.0.30001688": version: 1.0.30001688 resolution: "caniuse-lite@npm:1.0.30001688" checksum: 10c0/2ef3145ac69ea5faf403b613912a3a72006db2e004e58abcf40dc89904aa05568032b5a6dcfb267556944fd380a9b018ad645f93d84e543bed3471e4950a89f4 @@ -845,7 +853,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.5": +"cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -863,13 +871,6 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.10": - version: 1.11.13 - resolution: "dayjs@npm:1.11.13" - checksum: 10c0/a3caf6ac8363c7dade9d1ee797848ddcf25c1ace68d9fe8678ecf8ba0675825430de5d793672ec87b24a69bf04a1544b176547b2539982275d5542a7955f35b7 - languageName: node - linkType: hard - "debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": version: 4.4.0 resolution: "debug@npm:4.4.0" @@ -900,6 +901,8 @@ __metadata: version: 0.0.0-use.local resolution: "discord_voice_log@workspace:." dependencies: + "@eslint/js": "npm:^9.17.0" + "@stylistic/eslint-plugin": "npm:^2.12.1" "@types/md5": "npm:^2.3.5" "@types/node": "npm:^22.10.2" "@types/node-fetch": "npm:^2.6.12" @@ -907,12 +910,11 @@ __metadata: "@types/promise-queue": "npm:^2.2.3" "@types/sprintf-js": "npm:^1.1.4" "@types/ws": "npm:^8.5.13" - "@typescript-eslint/eslint-plugin": "npm:^8.18.0" - "@typescript-eslint/parser": "npm:^8.18.0" async-wait-until: "npm:^2.0.18" - eris: "npm:^0.17.2" - eslint: "npm:^9.16.0" + eris: "patch:eris@npm%3A0.18.0#~/.yarn/patches/eris-npm-0.18.0-57724b4df9.patch" + eslint: "npm:^9.17.0" fix-esm: "npm:^1.0.1" + globals: "npm:^15.13.0" md5: "npm:^2.3.0" mongodb: "npm:^6.12.0" mpg123-decoder: "npm:^1.0.0" @@ -921,22 +923,23 @@ __metadata: promise-queue: "npm:^2.2.5" slash-create: "npm:^6.3.0" sprintf-js: "npm:^1.1.3" - status-client: "jimchen5209/StatusClient_JS#v2.2.1" - tslog-helper: "jimchen5209/TSLog-Helper#v2.3.1" + status-client: "jimchen5209/StatusClient_JS#v2.3.0" + tslog: "npm:^4.9.3" typescript: "npm:^5.7.2" + typescript-eslint: "npm:^8.18.0" languageName: unknown linkType: soft -"electron-to-chromium@npm:^1.5.41": +"electron-to-chromium@npm:^1.5.73": version: 1.5.73 resolution: "electron-to-chromium@npm:1.5.73" checksum: 10c0/b97118d469f2b3b7a816932004cd36d82879829904ca4a8daf70eaefbe686a23afa6e39e0ad0cdc39d00a9ebab97160d072b786fdeb6964f13fb15aa688958f1 languageName: node linkType: hard -"eris@npm:0.17.2": - version: 0.17.2 - resolution: "eris@npm:0.17.2" +"eris@npm:0.18.0": + version: 0.18.0 + resolution: "eris@npm:0.18.0" dependencies: opusscript: "npm:^0.0.8" tweetnacl: "npm:^1.0.3" @@ -946,13 +949,13 @@ __metadata: optional: true tweetnacl: optional: true - checksum: 10c0/74608d0daea43c34baffc1a44000703e06e37ed6fb26388c5f8e84aee7e635efceb1871af0bb8fcb523df2a7ed5577975db9cef08f173406f8d5e57bd43fc697 + checksum: 10c0/76104ded92e8c2b94b9b1abe89a20d4643175c0f96e1082c9e637f53cbc8c310569a1b13bd414a48872ec4f041a757e3a8cd8ad9ce1ee260a02716fdfc0473a9 languageName: node linkType: hard -"eris@patch:eris@npm%3A0.17.2#./.yarn/patches/eris-npm-0.17.2-40f32b9a18.patch::locator=discord_voice_log%40workspace%3A.": - version: 0.17.2 - resolution: "eris@patch:eris@npm%3A0.17.2#./.yarn/patches/eris-npm-0.17.2-40f32b9a18.patch::version=0.17.2&hash=f980e8&locator=discord_voice_log%40workspace%3A." +"eris@patch:eris@npm%3A0.18.0#~/.yarn/patches/eris-npm-0.18.0-57724b4df9.patch": + version: 0.18.0 + resolution: "eris@patch:eris@npm%3A0.18.0#~/.yarn/patches/eris-npm-0.18.0-57724b4df9.patch::version=0.18.0&hash=6647b1" dependencies: opusscript: "npm:^0.0.8" tweetnacl: "npm:^1.0.3" @@ -962,7 +965,7 @@ __metadata: optional: true tweetnacl: optional: true - checksum: 10c0/97537d6cc68e639804635457ecfce812f589eb4cf58c5f3e4b67516f122eb8b4074a62eeeec56736da3513305322291559d01ee5b774b74fb6ea7438422542ec + checksum: 10c0/9117c7752784c77b2c623d4b390caf006d113afe87e2ca2a90ea5599a04afab0d57fb8561efc6b046748c5d4582ac673db52c554dfc15f4f7f6486551b05f6b6 languageName: node linkType: hard @@ -1004,16 +1007,16 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.16.0": - version: 9.16.0 - resolution: "eslint@npm:9.16.0" +"eslint@npm:^9.17.0": + version: 9.17.0 + resolution: "eslint@npm:9.17.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" "@eslint/config-array": "npm:^0.19.0" "@eslint/core": "npm:^0.9.0" "@eslint/eslintrc": "npm:^3.2.0" - "@eslint/js": "npm:9.16.0" + "@eslint/js": "npm:9.17.0" "@eslint/plugin-kit": "npm:^0.2.3" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" @@ -1022,7 +1025,7 @@ __metadata: "@types/json-schema": "npm:^7.0.15" ajv: "npm:^6.12.4" chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.5" + cross-spawn: "npm:^7.0.6" debug: "npm:^4.3.2" escape-string-regexp: "npm:^4.0.0" eslint-scope: "npm:^8.2.0" @@ -1049,7 +1052,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10c0/f36d12652c6f20bab8a77375b8ad29a6af030c3840deb0a5f9dd4cee49d68a2d68d7dc73b0c25918df59d83cd686dd5712e11387e696e1f3842e8dde15cd3255 + checksum: 10c0/9edd8dd782b4ae2eb00a158ed4708194835d4494d75545fa63a51f020ed17f865c49b4ae1914a2ecbc7fdb262bd8059e811aeef9f0bae63dced9d3293be1bbdd languageName: node linkType: hard @@ -1082,7 +1085,7 @@ __metadata: languageName: node linkType: hard -"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": +"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0, estraverse@npm:^5.3.0": version: 5.3.0 resolution: "estraverse@npm:5.3.0" checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 @@ -1252,6 +1255,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^15.13.0": + version: 15.13.0 + resolution: "globals@npm:15.13.0" + checksum: 10c0/640365115ca5f81d91e6a7667f4935021705e61a1a5a76a6ec5c3a5cdf6e53f165af7f9db59b7deb65cf2e1f83d03ac8d6660d0b14c569c831a9b6483eeef585 + languageName: node + linkType: hard + "graphemer@npm:^1.4.0": version: 1.4.0 resolution: "graphemer@npm:1.4.0" @@ -1599,7 +1609,7 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.18": +"node-releases@npm:^2.0.19": version: 2.0.19 resolution: "node-releases@npm:2.0.19" checksum: 10c0/52a0dbd25ccf545892670d1551690fe0facb6a471e15f2cfa1b20142a5b255b3aa254af5f59d6ecb69c2bec7390bc643c43aa63b13bf5e64b6075952e716b1aa @@ -1693,6 +1703,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -1812,23 +1829,6 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.21": - version: 0.5.21 - resolution: "source-map-support@npm:0.5.21" - dependencies: - buffer-from: "npm:^1.0.0" - source-map: "npm:^0.6.0" - checksum: 10c0/9ee09942f415e0f721d6daad3917ec1516af746a8120bba7bb56278707a37f1eb8642bde456e98454b8a885023af81a16e646869975f06afc1a711fb90484e7d - languageName: node - linkType: hard - -"source-map@npm:^0.6.0": - version: 0.6.1 - resolution: "source-map@npm:0.6.1" - checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 - languageName: node - linkType: hard - "sparse-bitfield@npm:^3.0.3": version: 3.0.3 resolution: "sparse-bitfield@npm:3.0.3" @@ -1845,10 +1845,10 @@ __metadata: languageName: node linkType: hard -"status-client@jimchen5209/StatusClient_JS#v2.2.1": - version: 2.2.0 - resolution: "status-client@https://github.com/jimchen5209/StatusClient_JS.git#commit=70bb3e70dcf094bb10ebc4d44ad40de43954538a" - checksum: 10c0/2827a8f767211a8a9352c5d8da4028037b8f02fbafeca1e48561b953bba80d1e651b2542af79f0b3905f1fb2e89c06ac8859ebb852ef1a4526a6c29af191101f +"status-client@jimchen5209/StatusClient_JS#v2.3.0": + version: 2.3.0 + resolution: "status-client@https://github.com/jimchen5209/StatusClient_JS.git#commit=be7568ff41c68bf6a87b892698145af7c8a8c983" + checksum: 10c0/a350046f703de98cd47cac6ba82c950df8a25d9f8ddb48dd76d45911c2b32cb846b8e6769ccb2e8773ee3ac83215505d8adefef41fe2453dd8a093771a3e7e54 languageName: node linkType: hard @@ -1902,22 +1902,10 @@ __metadata: languageName: node linkType: hard -"tslog-helper@jimchen5209/TSLog-Helper#v2.3.1": - version: 2.3.1 - resolution: "tslog-helper@https://github.com/jimchen5209/TSLog-Helper.git#commit=791feab0dc6e41fe1cd44974ad1c09e016d8ac51" - dependencies: - dayjs: "npm:^1.11.10" - tslog: "npm:^3.3.4" - checksum: 10c0/d1b6a80bb2671f20df1e516d04c9ea8295c4a1a8e7de9f1da9abed33b4038bd16b245ff6cb2c60d7dda28404b1f38ccf9ffb73821972efe4fd8e5feb79381373 - languageName: node - linkType: hard - -"tslog@npm:^3.3.4": - version: 3.3.4 - resolution: "tslog@npm:3.3.4" - dependencies: - source-map-support: "npm:^0.5.21" - checksum: 10c0/ebe8b1f4242498c4d95a2cb8dbfa6a144e76c5d08590b945cebb787efebbd94338111606afb1e82c2e6a5d1d88fc18efad7ae438f9fa8182e5d4615bc776045c +"tslog@npm:^4.9.3": + version: 4.9.3 + resolution: "tslog@npm:4.9.3" + checksum: 10c0/9db2c02b01be44a97e447061958565ceb8faf33a241cf9c030d5f1ebefff74434b66090985f5a75189a514aae2267999851c281b8cd8dc8cd93dd158b1033319 languageName: node linkType: hard @@ -1937,6 +1925,20 @@ __metadata: languageName: node linkType: hard +"typescript-eslint@npm:^8.18.0": + version: 8.18.0 + resolution: "typescript-eslint@npm:8.18.0" + dependencies: + "@typescript-eslint/eslint-plugin": "npm:8.18.0" + "@typescript-eslint/parser": "npm:8.18.0" + "@typescript-eslint/utils": "npm:8.18.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.8.0" + checksum: 10c0/dda882cbfc1ebad6903864571bc69bfd7e32e17fec67d98fdfab2bd652348d425c6a1c3697734d59cd5dd15d26d496db3c3808c1de5840fa29b9e76184fa1865 + languageName: node + linkType: hard + "typescript@npm:^5.7.2": version: 5.7.2 resolution: "typescript@npm:5.7.2"