diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts new file mode 100644 index 000000000..25b3fd8e7 --- /dev/null +++ b/src/@types/global.d.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2023 mtripg6666tdr + * + * This file is part of mtripg6666tdr/Discord-SimpleMusicBot. + * (npm package name: 'discord-music-bot' / repository url: ) + * + * mtripg6666tdr/Discord-SimpleMusicBot is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + * + * mtripg6666tdr/Discord-SimpleMusicBot is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with mtripg6666tdr/Discord-SimpleMusicBot. + * If not, see . + */ + +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable no-var */ +import type { Worker } from "worker_threads"; + +declare global { + var workerThread:Worker; +} diff --git a/src/@types/globals.d.ts b/src/@types/node.d.ts similarity index 100% rename from src/@types/globals.d.ts rename to src/@types/node.d.ts diff --git a/src/AudioSource/youtube/spawner.ts b/src/AudioSource/youtube/spawner.ts index 9bf307ba5..25cfc8366 100644 --- a/src/AudioSource/youtube/spawner.ts +++ b/src/AudioSource/youtube/spawner.ts @@ -26,6 +26,7 @@ import { type exportableYouTube, YouTube } from ".."; import Util from "../../Util"; const worker = isMainThread ? new Worker(path.join(__dirname, "./worker.js")).on("error", console.error) : null; +global.workerThread = worker; export type WithId = T & {id:string}; export type spawnerJobMessage = spawnerGetInfoMessage | spawnerSearchMessage; @@ -60,12 +61,14 @@ export type workerLoggingMessage = { }; type jobCallback = (callback:workerMessage & {id: string}) => void; -const jobQueue = worker && new Map(); +}; +const jobQueue = worker && new Map(); if(worker){ + worker.unref(); worker.on("message", (message:WithId) => { if(message.type === "log"){ Util.logger.log(message.data, message.level); diff --git a/src/AudioSource/youtube/stream.ts b/src/AudioSource/youtube/stream.ts index 54663b592..61707d80f 100644 --- a/src/AudioSource/youtube/stream.ts +++ b/src/AudioSource/youtube/stream.ts @@ -84,7 +84,7 @@ export function createRefreshableYTLiveStream(info:ytdl.videoInfo, options:ytdl. stream.updatePlaylist(await refresher()); Util.logger.log("playlist updated"); } - }, 60 * 60 * 1000); + }, 60 * 60 * 1000).unref(); }); stream.once("close", () => { clearInterval(timeout); diff --git a/src/AudioSource/youtube/worker.ts b/src/AudioSource/youtube/worker.ts index d024d5cf2..f54fa4ca4 100644 --- a/src/AudioSource/youtube/worker.ts +++ b/src/AudioSource/youtube/worker.ts @@ -26,6 +26,8 @@ import * as ytsr from "ytsr"; import { YouTube } from "."; import Util from "../../Util"; +parentPort.unref(); + function postMessage(message:workerMessage|WithId){ parentPort.postMessage(message); } diff --git a/src/Commands/bulkdelete.ts b/src/Commands/bulkdelete.ts index d7292cc26..5bf6ce781 100644 --- a/src/Commands/bulkdelete.ts +++ b/src/Commands/bulkdelete.ts @@ -81,7 +81,7 @@ export default class BulkDelete extends BaseCommand { await reply.edit(messages.length + "件見つかりました。削除を実行します。"); await options.client.deleteMessages(message.channel.id, messages.map(msg => msg.id), `${message.member.username}#${message.member.discriminator}により${count}件のメッセージの削除が要求されたため。`); await reply.edit(":sparkles:完了!(このメッセージは自動的に消去されます)"); - setTimeout(() => reply.delete().catch(() => {}), 10 * 1000); + setTimeout(() => reply.delete().catch(() => {}), 10 * 1000).unref(); } catch(er){ Util.logger.log(er, "error"); diff --git a/src/Commands/effect.ts b/src/Commands/effect.ts index ba43c3505..89d577799 100644 --- a/src/Commands/effect.ts +++ b/src/Commands/effect.ts @@ -45,7 +45,7 @@ export default class Effect extends BaseCommand { embeds: [embed.toEris()], components: [messageActions] }); - setTimeout(() => reply.edit({components: []}), 5 * 60 * 1000); + setTimeout(() => reply.edit({components: []}), 5 * 60 * 1000).unref(); } catch(e){ Util.logger.log(e, "error"); diff --git a/src/Component/PlayManager.ts b/src/Component/PlayManager.ts index 032bcc9f0..92857991a 100644 --- a/src/Component/PlayManager.ts +++ b/src/Component/PlayManager.ts @@ -488,7 +488,7 @@ export class PlayManager extends ServerManagerBase { this.onStreamFailed(); }) ; - }); + }).unref(); }else{ this._errorReportChannel?.createMessage(":tired_face:曲の再生に失敗しました...。" + (this._errorCount + 1 >= this.retryLimit ? "スキップします。" : "再試行します。")); this.onStreamFailed(); @@ -560,7 +560,7 @@ export class PlayManager extends ServerManagerBase { ; } this.disconnect(); - }, 10 * 60 * 1000); + }, 10 * 60 * 1000).unref(); this._finishTimeout = true; const playHandler = () => { clearTimeout(timer); diff --git a/src/Component/backupper/httpBased.ts b/src/Component/backupper/httpBased.ts index ae0ede6cb..c6b7874b7 100644 --- a/src/Component/backupper/httpBased.ts +++ b/src/Component/backupper/httpBased.ts @@ -316,6 +316,10 @@ export class HttpBackupper extends Backupper { ; }); } + + destroy(){ + /* empty */ + } } type getResult = { diff --git a/src/Component/backupper/index.ts b/src/Component/backupper/index.ts index 611528e20..9d4afd00b 100644 --- a/src/Component/backupper/index.ts +++ b/src/Component/backupper/index.ts @@ -51,4 +51,8 @@ export abstract class Backupper extends LogEmitter { * バックアップ済みのキューのデータを取得します */ abstract getQueueDataFromBackup(guildids:string[]):Promise>; + /** + * サーバーとの接続を破棄します + */ + abstract destroy():void|Promise; } diff --git a/src/Component/backupper/mongodb.ts b/src/Component/backupper/mongodb.ts index 550096364..9c2ec0171 100644 --- a/src/Component/backupper/mongodb.ts +++ b/src/Component/backupper/mongodb.ts @@ -174,4 +174,9 @@ export class MongoBackupper extends Backupper { return null; } } + + async destroy(){ + await this.client.close(); + this.collections = null; + } } diff --git a/src/Component/streams/normalizer.ts b/src/Component/streams/normalizer.ts index afe94fd22..0f43883c5 100644 --- a/src/Component/streams/normalizer.ts +++ b/src/Component/streams/normalizer.ts @@ -41,7 +41,7 @@ export class Normalizer extends Readable { this.pauseOrigin(); } }); - }); + }).unref(); this.origin.once("end", () => this.push(null)); this.origin.on("error", er => this.destroy(er)); diff --git a/src/Structure/GuildDataContainer.ts b/src/Structure/GuildDataContainer.ts index 48791067c..f49893d73 100644 --- a/src/Structure/GuildDataContainer.ts +++ b/src/Structure/GuildDataContainer.ts @@ -366,7 +366,7 @@ export class GuildDataContainer extends LogEmitter { */ async playFromURL(message:CommandMessage, rawArg:string, first:boolean = true, cancellable:boolean = false){ const t = Util.time.timer.start("MusicBot#PlayFromURL"); - setTimeout(() => message.suppressEmbeds(true).catch(e => this.Log(Util.general.StringifyObject(e), "warn")), 4000); + setTimeout(() => message.suppressEmbeds(true).catch(e => this.Log(Util.general.StringifyObject(e), "warn")), 4000).unref(); if(rawArg.match(/^https?:\/\/(www\.|canary\.|ptb\.)?discord(app)?\.com\/channels\/[0-9]+\/[0-9]+\/[0-9]+$/)){ // Discordメッセへのリンクならば const smsg = await message.reply("🔍メッセージを取得しています..."); diff --git a/src/Util/general.ts b/src/Util/general.ts index 3e7733e96..426920a76 100644 --- a/src/Util/general.ts +++ b/src/Util/general.ts @@ -147,7 +147,7 @@ export function waitForEnteringState(predicate:()=>boolean, timeout:number = 10 resolve(Date.now() - startTime); } } - }, timeStep); + }, timeStep).unref(); }); } @@ -156,7 +156,7 @@ export function waitForEnteringState(predicate:()=>boolean, timeout:number = 10 * @param time 待機時間(ミリ秒単位) */ export function wait(time:number){ - return new Promise(resolve => setTimeout(resolve, time)); + return new Promise(resolve => setTimeout(resolve, time).unref()); } const UUID_TEMPLATE = "10000000-1000-4000-8000-100000000000"; diff --git a/src/Util/log.ts b/src/Util/log.ts index 9f2b8699d..e67a18316 100644 --- a/src/Util/log.ts +++ b/src/Util/log.ts @@ -28,6 +28,7 @@ export type LoggerType = (content:string, level?:LogLevels)=>void; class LogStore { private readonly loggingStream = null as fs.WriteStream; + private destroyed = false; constructor(){ if(config.debug && isMainThread){ @@ -36,19 +37,12 @@ class LogStore { fs.mkdirSync(path.join(__dirname, dirPath)); } this.loggingStream = fs.createWriteStream(path.join(__dirname, `${dirPath}/log-${Date.now()}.log`)); - const onExit = () => { - if(!this.loggingStream.destroyed){ - this.loggingStream.write(Buffer.from(`INFO ${new Date().toISOString()} [Logger] detect process exiting, closing stream...`)); - this.loggingStream.destroy(); - } - }; - process.on("exit", onExit); - process.on("SIGINT", onExit); } } log:boolean = true; maxLength = 30; + private readonly _data:string[] = []; get data():Readonly{ return this._data; @@ -56,6 +50,7 @@ class LogStore { // eslint-disable-next-line @typescript-eslint/no-shadow addLog(level:LogLevels, log:string){ + if(this.destroyed) return; if(level !== "debug"){ this._data.push(`${level[0].toUpperCase()}:${log}`); if(this.data.length > this.maxLength){ @@ -78,6 +73,15 @@ class LogStore { }\r\n`)); } } + + destroy(){ + if(this.destroyed) return; + this.destroyed = true; + if(!this.loggingStream.destroyed){ + this.loggingStream.write(Buffer.from(`INFO ${new Date().toISOString()} [Logger] detect process exiting, closing stream...`)); + this.loggingStream.destroy(); + } + } } export const logStore = new LogStore(); diff --git a/src/bot.ts b/src/bot.ts index 3d4a56313..43385cba6 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -74,6 +74,8 @@ export class MusicBot extends MusicBotBase { .on("voiceChannelLeave", this.onVoiceChannelLeave.bind(this)) .on("voiceChannelSwitch", this.onVoiceChannelSwitch.bind(this)) .on("error", this.onError.bind(this)) + .on("debug", this.onDebug.bind(this)) + .on("warn", this.onWarn.bind(this)) ; } @@ -147,8 +149,8 @@ export class MusicBot extends MusicBotBase { // Set main tick setTimeout(() => { this.maintenanceTick(); - setInterval(this.maintenanceTick.bind(this), 1 * 60 * 1000); - }, 10 * 1000); + setInterval(this.maintenanceTick.bind(this), 1 * 60 * 1000).unref(); + }, 10 * 1000).unref(); this.Log("Interval jobs set up successfully"); // Command instance preparing @@ -499,7 +501,7 @@ export class MusicBot extends MusicBotBase { this._client.createMessage(server.boundTextChannel, ":postbox: 長時間使用しなかったため、終了します").catch(e => this.Log(e, "error")); server.player.disconnect(); } - }, 10 * 60 * 1000); + }, 10 * 60 * 1000).unref(); const playHandler = () => clearTimeout(timer); server.player.once("playCalled", playHandler); server.player.once("disconnect", playHandler); @@ -519,15 +521,23 @@ export class MusicBot extends MusicBotBase { private async onError(er:Error){ Util.logger.log(er, "error"); - this.Log("Attempt reconnecting after waiting for a while..."); - await Util.general.wait(3000); - this.client.connect() - .then(() => Util.logger.log("Reconnected!")) - .catch(_er => { - this.Log(_er); - Util.logger.log("Reconnect attempt failed"); - }) - ; + if(er.message?.startsWith("Invalid token")){ + this.Log("Invalid token detected. Please ensure that you set the correct token. You can also re-generate new token for your bot."); + process.exit(1); + }else{ + this.Log("Attempt reconnecting after waiting for a while..."); + this._client.disconnect({ + reconnect: "auto", + }); + } + } + + private onDebug(message:string, id?:number){ + this.Log(`${message} (ID: ${id || "NaN"})`, "debug"); + } + + private onWarn(message:string, id?:number){ + this.Log(`${message} (ID: ${id || "NaN"})`, "warn"); } /** @@ -541,6 +551,19 @@ export class MusicBot extends MusicBotBase { if(debugLogStoreLength) Util.logger.logStore.maxLength = debugLogStoreLength; } + async stop(){ + this.Log("Shutting down the bot..."); + this._client.removeAllListeners(); + this._client.on("error", () => {}); + if(this._backupper){ + this.Log("Shutting down the db..."); + await this._backupper.destroy(); + } + this._client.disconnect({ + reconnect: false, + }); + } + /** * コマンドを実行する際にランナーに渡す引数を生成します * @param options コマンドのパース済み引数 diff --git a/src/index.ts b/src/index.ts index 4b84ebe7c..166f759c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,60 +19,80 @@ import "./dotenv"; import "./dangerouslyRequireOverride"; -import * as http from "http"; +import type { LogLevels } from "./Util/log"; +import type * as http from "http"; import { Util } from "./Util"; import { MusicBot } from "./bot"; -// ============= -// メインエントリ -// ============= -Util.logger.log("[Entry] Discord-SimpleMusicBot by mtripg6666tdr"); -Util.logger.log("[Entry] This application was originally built by mtripg6666tdr and is licensed under GPLv3 or later."); -Util.logger.log("[Entry] There is no warranty for the work, both of the original and its forks."); -Util.logger.log("[Entry] However if you found any bugs in the original please feel free to report them by creating an issue on GitHub."); -Util.logger.log("[Entry] Thank you for using Discord-SimpleMusicBot!"); -Util.logger.log(`[Entry] Node.js v${process.versions.node}`); +import { createServer } from "./server"; + +const logger = (content:any, loglevel?:LogLevels) => Util.logger.log(`[Entry]${typeof content === "string" ? content : Util.general.StringifyObject(content)}`, loglevel); + +logger("Discord-SimpleMusicBot by mtripg6666tdr"); +logger("This application was originally built by mtripg6666tdr and is licensed under GPLv3 or later."); +logger("There is no warranty for the work, both of the original and its forks."); +logger("However if you found any bugs in the original please feel free to report them by creating an issue on GitHub."); +logger("Thank you for using Discord-SimpleMusicBot!"); +logger(`Node.js v${process.versions.node}`); const bot = new MusicBot(process.env.TOKEN, Boolean(Util.config.maintenance)); +let server:http.Server = null; // Webサーバーのインスタンス化 if(Util.config.webserver){ - http.createServer((req, res) => { - res.writeHead(200, { "Content-Type": "application/json" }); - const data = { - status: 200, - message: "Discord bot is active now", - client: bot.client?.user ? Buffer.from(bot.client?.user.id).toString("base64") : null, - readyAt: bot.client?.uptime ? Buffer.from(bot.client.uptime.toString()).toString("base64") : null, - guilds: bot.client?.guilds.size || null, - }; - Util.logger.log("[Server]Received a http request"); - res.end(JSON.stringify(data)); - }).listen(8081); + server = createServer(bot.client, Number(process.env.PORT) || 8081, Util.logger.log); }else{ - Util.logger.log("[Entry] Skipping to start server"); + logger("[Entry] Skipping to start server"); } if(!Util.config.debug){ // ハンドルされなかったエラーのハンドル process.on("uncaughtException", async (error)=>{ - console.error(error); - if(bot.client && Util.config.errorChannel){ - try{ - const errorText = typeof error === "string" ? error : JSON.stringify(error); - await bot.client.createMessage(Util.config.errorChannel, errorText); - } - catch(e){ - console.error(e); - process.exit(1); - } - } - }).on("SIGINT", ()=>{ + logger(error, "error"); if(bot.client && Util.config.errorChannel){ - bot.client.createMessage(Util.config.errorChannel, "Process terminated"); + await reportError(error); } }); } +let terminating = false; +const onTerminated = async function(code:string){ + if(terminating) return; + terminating = true; + logger(`${code} detected`); + logger("Shutting down the bot..."); + await bot.stop(); + if(server && server.listening){ + logger("Shutting down the server..."); + await new Promise(resolve => server.close(resolve)); + } + // 強制終了を報告 + if(bot.client && Util.config.errorChannel){ + bot.client.createMessage(Util.config.errorChannel, "Process terminated").catch(() => {}); + } + if(global.workerThread){ + logger("Shutting down worker..."); + await global.workerThread.terminate(); + } + logger("Shutting down completed"); + Util.logger.logStore.destroy(); + setTimeout(() => { + console.error("Killing... (forced)"); + process.exit(1); + }, 5000).unref(); +}; +["SIGINT", "SIGTERM", "SIGUSR2"].forEach(signal => { + process.on(signal, onTerminated.bind(undefined, signal)); +}); + // ボット開始 bot.run(true, 40); + +async function reportError(err:any){ + try{ + await bot.client.createMessage(Util.config.errorChannel, Util.general.StringifyObject(err)).catch(() => {}); + } + catch(e){ + logger(e, "error"); + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 000000000..65f3e11db --- /dev/null +++ b/src/server.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2023 mtripg6666tdr + * + * This file is part of mtripg6666tdr/Discord-SimpleMusicBot. + * (npm package name: 'discord-music-bot' / repository url: ) + * + * mtripg6666tdr/Discord-SimpleMusicBot is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + * + * mtripg6666tdr/Discord-SimpleMusicBot is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with mtripg6666tdr/Discord-SimpleMusicBot. + * If not, see . + */ + +import type { Client } from "eris"; + +import * as http from "http"; + +export function createServer(client:Client, port:number, logger:(content:string) => void){ + return http.createServer((_, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + const data = { + status: 200, + message: "Discord bot is active now", + client: client?.user ? Buffer.from(client?.user.id).toString("base64") : null, + readyAt: client?.uptime ? Buffer.from(client.uptime.toString()).toString("base64") : null, + guilds: client?.guilds.size || null, + }; + logger("[Server]Received a http request"); + res.end(JSON.stringify(data)); + }).listen(port); +}