Compare commits

..

4 Commits
main ... beta

Author SHA1 Message Date
a3e6793632 Various fixes
Hopefully prevent danging child processes
Reduce unnecessary db calls in moderation manager
2024-04-01 14:01:30 +03:00
5f07fd3e15 Eval command tweaks 2024-04-01 13:47:28 +03:00
c5c304d5d9 Refactor poll command 2024-04-01 13:47:17 +03:00
bc467e4dd9 Define test workflow 2024-04-01 13:27:47 +03:00
12 changed files with 201 additions and 172 deletions

View File

@ -0,0 +1,26 @@
on:
push:
branches:
- '*'
- '!master'
jobs:
tests:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v4
with:
node-version: latest
registry-url: https://registry.corgi.wtf
cache: yarn
- name: Install deps
run: yarn install
- name: Lint project
run: yarn run eslint src/
- name: Build project
run: yarn build
env:
CI: false

View File

@ -210,14 +210,6 @@ class DiscordClient extends Client
this.emit('invalidRequestWarning', ...args); this.emit('invalidRequestWarning', ...args);
}); });
// this.once('ready', () => {
// this._setActivity();
// setInterval(() => {
// this._setActivity();
// }, 1800000); // I think this is 30 minutes. I could be wrong.
// });
this.#loadEevents(); this.#loadEevents();
// process.on('uncaughtException', (err) => { // process.on('uncaughtException', (err) => {
@ -230,7 +222,13 @@ class DiscordClient extends Client
}); });
process.on('message', this.#handleMessage.bind(this)); process.on('message', this.#handleMessage.bind(this));
process.on('SIGINT', () => this.shutdown()); process.on('SIGINT', () => this.logger.info('Received SIGINT'));
process.on('SIGTERM', () => this.logger.info('Received SIGTERM'));
process.on('disconnect', () =>
{
this.logger.info('Process disconnected from parent for some reason, exiting');
this.shutdown(1);
});
} }
async build () async build ()
@ -271,7 +269,7 @@ class DiscordClient extends Client
this.#activityInterval = setInterval(() => this.#activityInterval = setInterval(() =>
{ {
this.setActivity(); this.setActivity();
}, Util.random(5, 10) * 60 * 60 * 1000); }, Util.random(5, 10) * 60 * 1000);
} }
this.#built = true; this.#built = true;
@ -296,6 +294,8 @@ class DiscordClient extends Client
// Handle misc. messages. // Handle misc. messages.
if (message._mEvalResult) if (message._mEvalResult)
this.evalResult(message); this.evalResult(message);
if (message._shutdown)
this.shutdown();
} }
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
@ -397,6 +397,10 @@ class DiscordClient extends Client
this.user?.setActivity(`${userCount} users`, { type: ActivityType.Listening }); this.user?.setActivity(`${userCount} users`, { type: ActivityType.Listening });
}, },
async () => async () =>
{
this.user?.setActivity('out for troublemakers', { type: ActivityType.Watching });
},
async () =>
{ {
this.user?.setActivity('for /help', { type: ActivityType.Listening }); this.user?.setActivity('for /help', { type: ActivityType.Listening });
} }

View File

@ -1,7 +1,5 @@
import { userInfo } from 'os'; import { userInfo } from 'os';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment import { Util as U, FilterUtil as FU } from '../../../../utilities/index.js';
// @ts-ignore
import { Util, FilterUtil } from '../../../../utilities/index.js';
import { inspect } from 'util'; import { inspect } from 'util';
import { CommandOptionType, CommandParams } from '../../../../../@types/Client.js'; import { CommandOptionType, CommandParams } from '../../../../../@types/Client.js';
import DiscordClient from '../../../DiscordClient.js'; import DiscordClient from '../../../DiscordClient.js';
@ -12,6 +10,8 @@ import { ReplyOptions } from '../../../../../@types/Wrappers.js';
const { username } = userInfo(); const { username } = userInfo();
class EvalCommand extends Command class EvalCommand extends Command
{ {
static Util = U;
static FilterUtil = FU;
constructor (client: DiscordClient) constructor (client: DiscordClient)
{ {
super(client, { super(client, {
@ -32,6 +32,10 @@ class EvalCommand extends Command
if (async?.asBool) if (async?.asBool)
params = `(async () => {${params}})()`; params = `(async () => {${params}})()`;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // declared for ease of use
const { Util, FilterUtil } = EvalCommand;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // declared for ease of use // @ts-ignore // declared for ease of use
const { guild, author, member, client } = invoker; const { guild, author, member, client } = invoker;

View File

@ -56,12 +56,19 @@ class PollCommand extends SlashCommand
async execute (invoker: InvokerWrapper<true>, { choices, channel, duration, multichoice, message }: CommandParams) async execute (invoker: InvokerWrapper<true>, { choices, channel, duration, multichoice, message }: CommandParams)
{ {
const { subcommand, author } = invoker; const { subcommand } = invoker;
const guild = invoker.guild!;
const member = invoker.member!;
if (subcommand!.name === 'create') if (subcommand!.name === 'create')
return this.#createPoll(invoker, { choices, channel, duration, multichoice });
else if (subcommand!.name === 'delete')
return this.#deletePoll(invoker, { message });
else if (subcommand!.name === 'end')
return this.#endPoll(invoker, { message });
}
async #createPoll (invoker: InvokerWrapper<true>, { choices, channel, duration, multichoice }: CommandParams)
{ {
const { guild, member, author } = invoker;
const targetChannel = (channel?.asChannel || invoker.channel) as GuildTextBasedChannel; const targetChannel = (channel?.asChannel || invoker.channel) as GuildTextBasedChannel;
if (!targetChannel?.isTextBased()) if (!targetChannel?.isTextBased())
throw new CommandError(invoker, { index: 'ERR_INVALID_CHANNEL_TYPE' }); throw new CommandError(invoker, { index: 'ERR_INVALID_CHANNEL_TYPE' });
@ -118,29 +125,7 @@ class PollCommand extends SlashCommand
await this.client.polls.create(poll, guild.id); await this.client.polls.create(poll, guild.id);
await invoker.editReply({ emoji: 'success', index: 'COMMAND_POLL_START', params: { channel: targetChannel.id } }); await invoker.editReply({ emoji: 'success', index: 'COMMAND_POLL_START', params: { channel: targetChannel.id } });
} }
else if (subcommand!.name === 'delete')
{
const poll = await this.client.polls.delete(message!.asString);
if (!poll)
return { index: 'COMMAND_POLL_404', emoji: 'failure' };
const pollChannel = await guild.resolveChannel<TextChannel>(poll.payload.channel);
if (!pollChannel)
return { index: 'COMMAND_POLL_MISSING_CHANNEL', emoji: 'failure' };
const msg = await pollChannel.messages.fetch(poll.payload.message).catch(() => null);
if (msg)
await msg.delete();
return { index: 'COMMAND_POLL_DELETED', emoji: 'success' };
}
else if (subcommand!.name === 'end')
{
const poll = await this.client.polls.find(message!.asString);
if (!poll)
return { index: 'COMMAND_POLL_404', emoji: 'failure' };
await this.client.polls.end(poll.payload);
// await guild._poll(poll.data as PollData & CallbackData);
return { index: 'COMMAND_POLL_ENDED', emoji: 'success' };
}
}
async #queryQuestions (invoker: InvokerWrapper<true>, choices: number, targetchannel: GuildTextBasedChannel) async #queryQuestions (invoker: InvokerWrapper<true>, choices: number, targetchannel: GuildTextBasedChannel)
{ {
const { guild, member } = invoker; const { guild, member } = invoker;
@ -166,6 +151,31 @@ class PollCommand extends SlashCommand
} }
return questions; return questions;
} }
async #deletePoll (invoker: InvokerWrapper<true>, { message }: CommandParams)
{
const { guild } = invoker;
const poll = await this.client.polls.delete(message!.asString);
if (!poll)
return { index: 'COMMAND_POLL_404', emoji: 'failure' };
const pollChannel = await guild.resolveChannel<TextChannel>(poll.payload.channel);
if (!pollChannel)
return { index: 'COMMAND_POLL_MISSING_CHANNEL', emoji: 'failure' };
const msg = await pollChannel.messages.fetch(poll.payload.message).catch(() => null);
if (msg)
await msg.delete();
return { index: 'COMMAND_POLL_DELETED', emoji: 'success' };
}
async #endPoll (_invoker: InvokerWrapper<true>, { message }: CommandParams)
{
const poll = await this.client.polls.find(message!.asString);
if (!poll)
return { index: 'COMMAND_POLL_404', emoji: 'failure' };
await this.client.polls.end(poll._id, poll.payload);
// await guild._poll(poll.data as PollData & CallbackData);
return { index: 'COMMAND_POLL_ENDED', emoji: 'success' };
}
} }
export default PollCommand; export default PollCommand;

View File

@ -185,9 +185,9 @@ class CallbackManager implements Initialisable
* *
* @async * @async
* @param {string} id * @param {string} id
* @returns {*} * @returns {Promise<void>}
*/ */
async removeCallback (id: string) async removeCallback (id: string): Promise<void>
{ {
await this.storage.deleteOne({ _id: id }); await this.storage.deleteOne({ _id: id });
const timeout = this.#timeouts.get(id); const timeout = this.#timeouts.get(id);

View File

@ -591,9 +591,9 @@ class ModerationManager implements Initialisable, CallbackClient
return responses; return responses;
} }
async handleCallback (id: string) async handleCallback (_id: string, infraction: InfractionJSON)
{ {
const infraction = await this.#client.mongodb.infractions.findOne({ id }); // const infraction = await this.#client.mongodb.infractions.findOne({ id });
if (!infraction) if (!infraction)
return; return;
this.#logger.debug(`Infraction callback: ${infraction.id} (${infraction.type})`); this.#logger.debug(`Infraction callback: ${infraction.id} (${infraction.type})`);
@ -667,7 +667,7 @@ class ModerationManager implements Initialisable, CallbackClient
if (expiresAt - currentDate <= 0) if (expiresAt - currentDate <= 0)
{ {
this.#logger.debug(`Expired infraction:\n${inspect(infraction)}`); this.#logger.debug(`Expired infraction:\n${inspect(infraction)}`);
return this.handleCallback(infraction.id); return this.handleCallback(infraction.id, infraction);
} }
this.#logger.debug(`Creating infraction callback for ${infraction.id} (${infraction.type}), expiring in ${Util.humanise(duration / 1000)}`); this.#logger.debug(`Creating infraction callback for ${infraction.id} (${infraction.type}), expiring in ${Util.humanise(duration / 1000)}`);

View File

@ -47,12 +47,12 @@ class PollManager implements CallbackClient
return poll; return poll;
} }
async handleCallback (_id: string, payload: PollData): Promise<void> async handleCallback (id: string, payload: PollData): Promise<void>
{ {
await this.end(payload); await this.end(id, payload);
} }
async end ({ user, channel, startedIn, message, multiChoice }: PollData) async end (id: string, { user, channel, startedIn, message, multiChoice }: PollData)
{ {
const startChannel = await this.#client.resolveChannel(startedIn); const startChannel = await this.#client.resolveChannel(startedIn);
const pollChannel = await this.#client.resolveChannel(channel); const pollChannel = await this.#client.resolveChannel(channel);
@ -89,6 +89,7 @@ class PollManager implements CallbackClient
if (startChannel && startChannel.isTextBased()) if (startChannel && startChannel.isTextBased())
await startChannel.send(guild.format('COMMAND_POLL_NOTIFY_STARTER', { user, channel })); await startChannel.send(guild.format('COMMAND_POLL_NOTIFY_STARTER', { user, channel }));
} }
await this.callbacks.removeCallback(id);
} }
} }

View File

@ -1,27 +1,47 @@
import { EventEmitter } from 'node:events'; import {
import { inspect } from 'node:util'; EventEmitter
} from 'node:events';
import {
inspect
} from 'node:util';
import {
existsSync
} from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { CommandsDef, IPCMessage } from '../../@types/Shared.js'; import {
import { BroadcastEvalOptions, ShardMethod, ShardingOptions } from '../../@types/Shard.js'; CommandsDef,
import { ControllerOptions } from '../../@types/Controller.js'; IPCMessage
} from '../../@types/Shared.js';
import { MasterLogger } from '@navy.gif/logger'; import {
import { Collection } from 'discord.js'; BroadcastEvalOptions,
ShardMethod,
ShardingOptions
} from '../../@types/Shard.js';
import {
ControllerOptions
} from '../../@types/Controller.js';
import {
MasterLogger
} from '@navy.gif/logger';
import {
Collection
} from 'discord.js';
// Available for evals // Available for evals
import ClientUtils from './ClientUtils.js'; import ClientUtils from './ClientUtils.js';
import Metrics from './Metrics.js'; import Metrics from './Metrics.js';
// import ApiClientUtil from './ApiClientUtil.js'; // import ApiClientUtil from './ApiClientUtil.js';
import SlashCommandManager from './rest/SlashCommandManager.js'; import SlashCommandManager from './SlashCommandManager.js';
import { Shard } from './shard/index.js';
import { existsSync } from 'node:fs';
import Util from '../utilities/Util.js'; import Util from '../utilities/Util.js';
import Shard from './Shard.js';
// Placeholder
type GalacticAPI = {
init: () => Promise<void>
}
class Controller extends EventEmitter class Controller extends EventEmitter
{ {
@ -39,7 +59,6 @@ class Controller extends EventEmitter
#readyAt: number | null; #readyAt: number | null;
#built: boolean; #built: boolean;
#api?: GalacticAPI;
clientUtils: typeof ClientUtils; clientUtils: typeof ClientUtils;
constructor (options: ControllerOptions, version: string) constructor (options: ControllerOptions, version: string)
@ -92,28 +111,7 @@ class Controller extends EventEmitter
async build () async build ()
{ {
const start = Date.now(); const start = Date.now();
// const API = this._options.api.load ? await import('/Documents/My programs/GBot/api/index.js')
// .catch(() => this.logger.warn(`Error importing API files, continuing without`)) : null;
// let API = null;
// if (this.#options.api.load)
// API = await import('../../api/index.js').catch(() => this.#logger.warn(`Error importing API files, continuing without`));
// if (API) {
// this.#logger.info('Booting up API');
// const { default: APIManager } = API;
// this.#api = new APIManager(this, this.#options.api) as GalacticAPI;
// await this.#api.init();
// const now = Date.now();
// this.#logger.info(`API ready. Took ${now - start} ms`);
// start = now;
// }
this.#logger.status('Starting bot shards'); this.#logger.status('Starting bot shards');
// await this.shardingManager.spawn().catch((error) => {
// this.#logger.error(`Fatal error during shard spawning:\n${error.stack || inspect(error)}`);
// // eslint-disable-next-line no-process-exit
// process.exit(); // Prevent a boot loop when shards die due to an error in the client
// });
const { totalShards, token } = this.#shardingOptions; const { totalShards, token } = this.#shardingOptions;
let shardCount = 0; let shardCount = 0;
@ -133,7 +131,6 @@ class Controller extends EventEmitter
throw new TypeError('Amount of shards must be an integer.'); throw new TypeError('Amount of shards must be an integer.');
} }
// const promises = [];
const retry: Shard[] = []; const retry: Shard[] = [];
for (let i = 0; i < shardCount; i++) for (let i = 0; i < shardCount; i++)
{ {
@ -148,9 +145,9 @@ class Controller extends EventEmitter
this.logger.info('Retrying shard', i); this.logger.info('Retrying shard', i);
retry.push(shard); retry.push(shard);
} }
// promises.push();
} }
// Retry failed spawns
for (const shard of retry) for (const shard of retry)
{ {
try try
@ -163,8 +160,6 @@ class Controller extends EventEmitter
} }
} }
// await Promise.all(promises);
this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`); this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`);
this.#built = true; this.#built = true;
@ -173,12 +168,11 @@ class Controller extends EventEmitter
async shutdown () async shutdown ()
{ {
this.logger.info('Received SIGINT, shutting down'); this.logger.info('Received SIGINT or SIGTERM, shutting down');
setTimeout(process.exit, 90_000); setTimeout(process.exit, 90_000);
const promises = this.shards const promises = this.shards
.filter(shard => shard.ready) .filter(shard => shard.ready)
.map(shard => shard.awaitShutdown() .map(shard => shard.kill());
.then(() => shard.removeAllListeners()));
if (promises.length) if (promises.length)
await Promise.all(promises); await Promise.all(promises);
this.logger.status('Shutdown complete, goodbye'); this.logger.status('Shutdown complete, goodbye');
@ -406,11 +400,6 @@ class Controller extends EventEmitter
return this.#logger; return this.#logger;
} }
get api ()
{
return this.#api;
}
get shards () get shards ()
{ {
return this.#shards.clone(); return this.#shards.clone();

View File

@ -1,6 +1,6 @@
import BaseClient from './Controller.js'; import BaseClient from './Controller.js';
import os from 'node:os'; import os from 'node:os';
import Shard from './shard/Shard.js'; import Shard from './Shard.js';
import { IPCMessage } from '../../@types/Shared.js'; import { IPCMessage } from '../../@types/Shared.js';
import djs, { GuildMessageManager } from 'discord.js'; import djs, { GuildMessageManager } from 'discord.js';
import DiscordClient from '../client/DiscordClient.js'; import DiscordClient from '../client/DiscordClient.js';

View File

@ -1,13 +1,14 @@
import EventEmitter from 'node:events'; import EventEmitter from 'node:events';
import path from 'node:path'; import path from 'node:path';
import { makePlainError, makeError, MakeErrorOptions } from 'discord.js';
import { Util } from '../../utilities/index.js';
import childProcess, { ChildProcess } from 'node:child_process'; import childProcess, { ChildProcess } from 'node:child_process';
import { makePlainError, makeError, MakeErrorOptions } from 'discord.js';
import { EnvObject, IPCMessage } from '../../../@types/Shared.js'; import Controller from './Controller.js';
import Controller from '../Controller.js'; import { Util } from '../utilities/index.js';
import { ShardOptions } from '../../../@types/Shard.js';
import { ClientOptions } from '../../../@types/Client.js'; import { ShardOptions } from '../../@types/Shard.js';
import { ClientOptions } from '../../@types/Client.js';
import { EnvObject, IPCMessage } from '../../@types/Shared.js';
const KillTO = 90 * 1000; const KillTO = 90 * 1000;
@ -16,8 +17,8 @@ class Shard extends EventEmitter
[key: string]: unknown; [key: string]: unknown;
#id: number; #id: number;
#manager: Controller; #controller: Controller;
#env: EnvObject; // { [key: string]: string | boolean | number }; #env: EnvObject;
#ready: boolean; #ready: boolean;
#clientOptions: ClientOptions; #clientOptions: ClientOptions;
@ -38,10 +39,10 @@ class Shard extends EventEmitter
#crashes: number[]; #crashes: number[];
#fatal: boolean; #fatal: boolean;
constructor (manager: Controller, id: number, options: ShardOptions) constructor (controller: Controller, id: number, options: ShardOptions)
{ {
super(); super();
this.#manager = manager; this.#controller = controller;
this.#id = id; this.#id = id;
this.#args = options.args ?? []; this.#args = options.args ?? [];
@ -370,7 +371,7 @@ class Shard extends EventEmitter
if (message._sFetchProp) if (message._sFetchProp)
{ {
const resp = { _sFetchProp: message._sFetchProp, _sFetchPropShard: message._sFetchPropShard }; const resp = { _sFetchProp: message._sFetchProp, _sFetchPropShard: message._sFetchPropShard };
this.#manager.fetchClientValues(message._sFetchProp, message._sFetchPropShard).then( this.#controller.fetchClientValues(message._sFetchProp, message._sFetchPropShard).then(
(results: unknown) => this.send({ ...resp, _result: results }), (results: unknown) => this.send({ ...resp, _result: results }),
(error: Error) => this.send({ ...resp, _error: makePlainError(error) }) (error: Error) => this.send({ ...resp, _error: makePlainError(error) })
); );
@ -380,7 +381,7 @@ class Shard extends EventEmitter
if (message._sEval) if (message._sEval)
{ {
const resp = { _sEval: message._sEval, _sEvalShard: message._sEvalShard }; const resp = { _sEval: message._sEval, _sEvalShard: message._sEvalShard };
this.#manager._performOnShards('eval', [ message._sEval ], message._sEvalShard).then( this.#controller._performOnShards('eval', [ message._sEval ], message._sEvalShard).then(
(results: unknown) => this.send({ ...resp, _result: results }), (results: unknown) => this.send({ ...resp, _result: results }),
(error: Error) => this.send({ ...resp, _error: makePlainError(error) }) (error: Error) => this.send({ ...resp, _error: makePlainError(error) })
); );
@ -390,7 +391,7 @@ class Shard extends EventEmitter
if (message._sRespawnAll) if (message._sRespawnAll)
{ {
const { shardDelay, respawnDelay, timeout } = message._sRespawnAll; const { shardDelay, respawnDelay, timeout } = message._sRespawnAll;
this.#manager.respawnAll({ shardDelay, respawnDelay, timeout }).catch(() => this.#controller.respawnAll({ shardDelay, respawnDelay, timeout }).catch(() =>
{ {
// //
}); });

View File

@ -5,8 +5,8 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { inspect } from 'node:util'; import { inspect } from 'node:util';
import BaseClient from '../Controller.js'; import BaseClient from './Controller.js';
import { Command, CommandOption, CommandsDef } from '../../../@types/Shared.js'; import { Command, CommandOption, CommandsDef } from '../../@types/Shared.js';
type CommandHashTable = { type CommandHashTable = {
global: string | null, global: string | null,

View File

@ -1,6 +0,0 @@
import Shard from './Shard.js';
// import ShardingManager from './ShardingManager.js';
export {
Shard,
// ShardingManager
};