feat: reset

This commit is contained in:
TresAbhi committed 2023-04-18 00:59:39 +00:00
1 parent d769b852a9
commit f7c356de1e
111 files changed
-11745

No files matched your search

-17
View File
@@ -1,17 +0,0 @@
import { ButtonInteraction } from 'discord.js';
import { EventData } from '../models/internal-models.js';
export interface Button {
ids: string[];
deferType: ButtonDeferType;
requireGuild: boolean;
requireEmbedAuthorTag: boolean;
execute(intr: ButtonInteraction, data: EventData): Promise<void>;
}
export enum ButtonDeferType {
REPLY = 'REPLY',
UPDATE = 'UPDATE',
NONE = 'NONE',
}
-1
View File
@@ -1 +0,0 @@
export { Button, ButtonDeferType } from './button.js';
-51
View File
@@ -1,51 +0,0 @@
import { APIApplicationCommandBasicOption, ApplicationCommandOptionType } from 'discord.js';
import { HelpOption, InfoOption } from '../enums/index.js';
import { Language } from '../models/enum-helpers/index.js';
import { Lang } from '../services/index.js';
export class Args {
public static readonly HELP_OPTION: APIApplicationCommandBasicOption = {
name: Lang.getRef('arguments.option', Language.Default),
name_localizations: Lang.getRefLocalizationMap('arguments.option'),
description: Lang.getRef('argDescs.helpOption', Language.Default),
description_localizations: Lang.getRefLocalizationMap('argDescs.helpOption'),
type: ApplicationCommandOptionType.String,
choices: [
{
name: Lang.getRef('helpOptionDescs.contactSupport', Language.Default),
name_localizations: Lang.getRefLocalizationMap('helpOptionDescs.contactSupport'),
value: HelpOption.CONTACT_SUPPORT,
},
{
name: Lang.getRef('helpOptionDescs.commands', Language.Default),
name_localizations: Lang.getRefLocalizationMap('helpOptionDescs.commands'),
value: HelpOption.COMMANDS,
},
],
};
public static readonly INFO_OPTION: APIApplicationCommandBasicOption = {
name: Lang.getRef('arguments.option', Language.Default),
name_localizations: Lang.getRefLocalizationMap('arguments.option'),
description: Lang.getRef('argDescs.helpOption', Language.Default),
description_localizations: Lang.getRefLocalizationMap('argDescs.helpOption'),
type: ApplicationCommandOptionType.String,
choices: [
{
name: Lang.getRef('infoOptions.about', Language.Default),
name_localizations: Lang.getRefLocalizationMap('infoOptions.about'),
value: InfoOption.ABOUT,
},
{
name: Lang.getRef('infoOptions.translate', Language.Default),
name_localizations: Lang.getRefLocalizationMap('infoOptions.translate'),
value: InfoOption.TRANSLATE,
},
{
name: Lang.getRef('infoOptions.dev', Language.Default),
name_localizations: Lang.getRefLocalizationMap('infoOptions.dev'),
value: InfoOption.DEV,
},
],
};
}
-51
View File
@@ -1,51 +0,0 @@
import { ChatInputCommandInteraction, EmbedBuilder, PermissionsString } from 'discord.js';
import { HelpOption } from '../../enums/index.js';
import { Language } from '../../models/enum-helpers/index.js';
import { EventData } from '../../models/internal-models.js';
import { Lang } from '../../services/index.js';
import { ClientUtils, FormatUtils, InteractionUtils } from '../../utils/index.js';
import { Command, CommandDeferType } from '../index.js';
export class HelpCommand implements Command {
public names = [Lang.getRef('chatCommands.help', Language.Default)];
public deferType = CommandDeferType.PUBLIC;
public requireClientPerms: PermissionsString[] = [];
public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise<void> {
let args = {
option: intr.options.getString(
Lang.getRef('arguments.option', Language.Default)
) as HelpOption,
};
let embed: EmbedBuilder;
switch (args.option) {
case HelpOption.CONTACT_SUPPORT: {
embed = Lang.getEmbed('displayEmbeds.helpContactSupport', data.lang);
break;
}
case HelpOption.COMMANDS: {
embed = Lang.getEmbed('displayEmbeds.helpCommands', data.lang, {
CMD_LINK_TEST: FormatUtils.commandMention(
await ClientUtils.findAppCommand(
intr.client,
Lang.getRef('chatCommands.test', Language.Default)
)
),
CMD_LINK_INFO: FormatUtils.commandMention(
await ClientUtils.findAppCommand(
intr.client,
Lang.getRef('chatCommands.info', Language.Default)
)
),
});
break;
}
default: {
return;
}
}
await InteractionUtils.send(intr, embed);
}
}
-3
View File
@@ -1,3 +0,0 @@
export { HelpCommand } from './help-command.js';
export { InfoCommand } from './info-command.js';
export { TestCommand } from './test-command.js';
-117
View File
@@ -1,117 +0,0 @@
import djs, { ChatInputCommandInteraction, EmbedBuilder, PermissionsString } from 'discord.js';
import { createRequire } from 'node:module';
import os from 'node:os';
import typescript from 'typescript';
import { InfoOption } from '../../enums/index.js';
import { Language } from '../../models/enum-helpers/index.js';
import { EventData } from '../../models/internal-models.js';
import { Lang } from '../../services/index.js';
import { FormatUtils, InteractionUtils, ShardUtils } from '../../utils/index.js';
import { Command, CommandDeferType } from '../index.js';
const require = createRequire(import.meta.url);
let Config = require('../../../config/config.json');
let TsConfig = require('../../../tsconfig.json');
export class InfoCommand implements Command {
public names = [Lang.getRef('chatCommands.info', Language.Default)];
public deferType = CommandDeferType.PUBLIC;
public requireClientPerms: PermissionsString[] = [];
public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise<void> {
let args = {
option: intr.options.getString(
Lang.getRef('arguments.option', Language.Default)
) as InfoOption,
};
let embed: EmbedBuilder;
switch (args.option) {
case InfoOption.ABOUT: {
embed = Lang.getEmbed('displayEmbeds.about', data.lang);
break;
}
case InfoOption.TRANSLATE: {
embed = Lang.getEmbed('displayEmbeds.translate', data.lang);
for (let langCode of Language.Enabled) {
embed.addFields([
{
name: Language.Data[langCode].nativeName,
value: Lang.getRef('meta.translators', langCode),
},
]);
}
break;
}
case InfoOption.DEV: {
if (!Config.developers.includes(intr.user.id)) {
await InteractionUtils.send(
intr,
Lang.getEmbed('validationEmbeds.devOnly', data.lang)
);
return;
}
let shardCount = intr.client.shard?.count ?? 1;
let serverCount: number;
if (intr.client.shard) {
try {
serverCount = await ShardUtils.serverCount(intr.client.shard);
} catch (error) {
if (error.name.includes('ShardingInProcess')) {
await InteractionUtils.send(
intr,
Lang.getEmbed('errorEmbeds.startupInProcess', data.lang)
);
return;
} else {
throw error;
}
}
} else {
serverCount = intr.client.guilds.cache.size;
}
let memory = process.memoryUsage();
embed = Lang.getEmbed('displayEmbeds.dev', data.lang, {
NODE_VERSION: process.version,
TS_VERSION: `v${typescript.version}`,
ES_VERSION: TsConfig.compilerOptions.target,
DJS_VERSION: `v${djs.version}`,
SHARD_COUNT: shardCount.toLocaleString(data.lang),
SERVER_COUNT: serverCount.toLocaleString(data.lang),
SERVER_COUNT_PER_SHARD: Math.round(serverCount / shardCount).toLocaleString(
data.lang
),
RSS_SIZE: FormatUtils.fileSize(memory.rss),
RSS_SIZE_PER_SERVER:
serverCount > 0
? FormatUtils.fileSize(memory.rss / serverCount)
: Lang.getRef('other.na', data.lang),
HEAP_TOTAL_SIZE: FormatUtils.fileSize(memory.heapTotal),
HEAP_TOTAL_SIZE_PER_SERVER:
serverCount > 0
? FormatUtils.fileSize(memory.heapTotal / serverCount)
: Lang.getRef('other.na', data.lang),
HEAP_USED_SIZE: FormatUtils.fileSize(memory.heapUsed),
HEAP_USED_SIZE_PER_SERVER:
serverCount > 0
? FormatUtils.fileSize(memory.heapUsed / serverCount)
: Lang.getRef('other.na', data.lang),
HOSTNAME: os.hostname(),
SHARD_ID: (intr.guild?.shardId ?? 0).toString(),
SERVER_ID: intr.guild?.id ?? Lang.getRef('other.na', data.lang),
BOT_ID: intr.client.user?.id,
USER_ID: intr.user.id,
});
break;
}
default: {
return;
}
}
await InteractionUtils.send(intr, embed);
}
}
-19
View File
@@ -1,19 +0,0 @@
import { ChatInputCommandInteraction, PermissionsString } from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { Language } from '../../models/enum-helpers/index.js';
import { EventData } from '../../models/internal-models.js';
import { Lang } from '../../services/index.js';
import { InteractionUtils } from '../../utils/index.js';
import { Command, CommandDeferType } from '../index.js';
export class TestCommand implements Command {
public names = [Lang.getRef('chatCommands.test', Language.Default)];
public cooldown = new RateLimiter(1, 5000);
public deferType = CommandDeferType.PUBLIC;
public requireClientPerms: PermissionsString[] = [];
public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise<void> {
await InteractionUtils.send(intr, Lang.getEmbed('displayEmbeds.test', data.lang));
}
}
-28
View File
@@ -1,28 +0,0 @@
import {
ApplicationCommandOptionChoiceData,
AutocompleteFocusedOption,
AutocompleteInteraction,
CommandInteraction,
PermissionsString,
} from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { EventData } from '../models/internal-models.js';
export interface Command {
names: string[];
cooldown?: RateLimiter;
deferType: CommandDeferType;
requireClientPerms: PermissionsString[];
autocomplete?(
intr: AutocompleteInteraction,
option: AutocompleteFocusedOption
): Promise<ApplicationCommandOptionChoiceData[]>;
execute(intr: CommandInteraction, data: EventData): Promise<void>;
}
export enum CommandDeferType {
PUBLIC = 'PUBLIC',
HIDDEN = 'HIDDEN',
NONE = 'NONE',
}
-3
View File
@@ -1,3 +0,0 @@
export { Args } from './args.js';
export { Command, CommandDeferType } from './command.js';
export { ChatCommandMetadata, MessageCommandMetadata, UserCommandMetadata } from './metadata.js';
-1
View File
@@ -1 +0,0 @@
export { ViewDateSent } from './view-date-sent.js';
-30
View File
@@ -1,30 +0,0 @@
import { MessageContextMenuCommandInteraction, PermissionsString } from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { DateTime } from 'luxon';
import { Language } from '../../models/enum-helpers/index.js';
import { EventData } from '../../models/internal-models.js';
import { Lang } from '../../services/index.js';
import { InteractionUtils } from '../../utils/index.js';
import { Command, CommandDeferType } from '../index.js';
export class ViewDateSent implements Command {
public names = [Lang.getRef('messageCommands.viewDateSent', Language.Default)];
public cooldown = new RateLimiter(1, 5000);
public deferType = CommandDeferType.PUBLIC;
public requireClientPerms: PermissionsString[] = [];
public async execute(
intr: MessageContextMenuCommandInteraction,
data: EventData
): Promise<void> {
await InteractionUtils.send(
intr,
Lang.getEmbed('displayEmbeds.viewDateSent', data.lang, {
DATE: DateTime.fromJSDate(intr.targetMessage.createdAt).toLocaleString(
DateTime.DATE_HUGE
),
})
);
}
}
-77
View File
@@ -1,77 +0,0 @@
import {
ApplicationCommandType,
RESTPostAPIChatInputApplicationCommandsJSONBody,
RESTPostAPIContextMenuApplicationCommandsJSONBody,
} from 'discord.js';
import { Args } from './index.js';
import { Language } from '../models/enum-helpers/index.js';
import { Lang } from '../services/index.js';
export const ChatCommandMetadata: {
[command: string]: RESTPostAPIChatInputApplicationCommandsJSONBody;
} = {
HELP: {
type: ApplicationCommandType.ChatInput,
name: Lang.getRef('chatCommands.help', Language.Default),
name_localizations: Lang.getRefLocalizationMap('chatCommands.help'),
description: Lang.getRef('commandDescs.help', Language.Default),
description_localizations: Lang.getRefLocalizationMap('commandDescs.help'),
dm_permission: true,
default_member_permissions: undefined,
options: [
{
...Args.HELP_OPTION,
required: true,
},
],
},
INFO: {
type: ApplicationCommandType.ChatInput,
name: Lang.getRef('chatCommands.info', Language.Default),
name_localizations: Lang.getRefLocalizationMap('chatCommands.info'),
description: Lang.getRef('commandDescs.info', Language.Default),
description_localizations: Lang.getRefLocalizationMap('commandDescs.info'),
dm_permission: true,
default_member_permissions: undefined,
options: [
{
...Args.INFO_OPTION,
required: true,
},
],
},
TEST: {
type: ApplicationCommandType.ChatInput,
name: Lang.getRef('chatCommands.test', Language.Default),
name_localizations: Lang.getRefLocalizationMap('chatCommands.test'),
description: Lang.getRef('commandDescs.test', Language.Default),
description_localizations: Lang.getRefLocalizationMap('commandDescs.test'),
dm_permission: true,
default_member_permissions: undefined,
},
};
export const MessageCommandMetadata: {
[command: string]: RESTPostAPIContextMenuApplicationCommandsJSONBody;
} = {
VIEW_DATE_SENT: {
type: ApplicationCommandType.Message,
name: Lang.getRef('messageCommands.viewDateSent', Language.Default),
name_localizations: Lang.getRefLocalizationMap('messageCommands.viewDateSent'),
default_member_permissions: undefined,
dm_permission: true,
},
};
export const UserCommandMetadata: {
[command: string]: RESTPostAPIContextMenuApplicationCommandsJSONBody;
} = {
VIEW_DATE_JOINED: {
type: ApplicationCommandType.User,
name: Lang.getRef('userCommands.viewDateJoined', Language.Default),
name_localizations: Lang.getRefLocalizationMap('userCommands.viewDateJoined'),
default_member_permissions: undefined,
dm_permission: true,
},
};
-1
View File
@@ -1 +0,0 @@
export { ViewDateJoined } from './view-date-joined.js';
-32
View File
@@ -1,32 +0,0 @@
import { DMChannel, PermissionsString, UserContextMenuCommandInteraction } from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { DateTime } from 'luxon';
import { Language } from '../../models/enum-helpers/index.js';
import { EventData } from '../../models/internal-models.js';
import { Lang } from '../../services/index.js';
import { InteractionUtils } from '../../utils/index.js';
import { Command, CommandDeferType } from '../index.js';
export class ViewDateJoined implements Command {
public names = [Lang.getRef('userCommands.viewDateJoined', Language.Default)];
public cooldown = new RateLimiter(1, 5000);
public deferType = CommandDeferType.PUBLIC;
public requireClientPerms: PermissionsString[] = [];
public async execute(intr: UserContextMenuCommandInteraction, data: EventData): Promise<void> {
let joinDate: Date;
if (!(intr.channel instanceof DMChannel)) {
let member = await intr.guild.members.fetch(intr.targetUser.id);
joinDate = member.joinedAt;
} else joinDate = intr.targetUser.createdAt;
await InteractionUtils.send(
intr,
Lang.getEmbed('displayEmbeds.viewDateJoined', data.lang, {
TARGET: intr.targetUser.toString(),
DATE: DateTime.fromJSDate(joinDate).toLocaleString(DateTime.DATE_HUGE),
})
);
}
}
-15
View File
@@ -1,15 +0,0 @@
export class DiscordLimits {
public static readonly GUILDS_PER_SHARD = 2500;
public static readonly CHANNELS_PER_GUILD = 500;
public static readonly ROLES_PER_GUILD = 250;
public static readonly PINS_PER_CHANNEL = 50;
public static readonly ACTIVE_THREADS_PER_GUILD = 1000;
public static readonly EMBEDS_PER_MESSAGE = 10;
public static readonly FIELDS_PER_EMBED = 25;
public static readonly CHOICES_PER_AUTOCOMPLETE = 25;
public static readonly EMBED_COMBINED_LENGTH = 6000;
public static readonly EMBED_TITLE_LENGTH = 256;
public static readonly EMBED_DESCRIPTION_LENGTH = 4096;
public static readonly EMBED_FIELD_NAME_LENGTH = 256;
public static readonly EMBED_FOOTER_LENGTH = 2048;
}
-1
View File
@@ -1 +0,0 @@
export { DiscordLimits } from './discord-limits.js';
-8
View File
@@ -1,8 +0,0 @@
import { Router } from 'express';
export interface Controller {
path: string;
router: Router;
authToken?: string;
register(): void;
}
-37
View File
@@ -1,37 +0,0 @@
import { ShardingManager } from 'discord.js';
import { Request, Response, Router } from 'express';
import router from 'express-promise-router';
import { createRequire } from 'node:module';
import { Controller } from './index.js';
import { GetGuildsResponse } from '../models/cluster-api/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
export class GuildsController implements Controller {
public path = '/guilds';
public router: Router = router();
public authToken: string = Config.api.secret;
constructor(private shardManager: ShardingManager) {}
public register(): void {
this.router.get('/', (req, res) => this.getGuilds(req, res));
}
private async getGuilds(req: Request, res: Response): Promise<void> {
let guilds: string[] = [
...new Set(
(
await this.shardManager.broadcastEval(client => [...client.guilds.cache.keys()])
).flat()
),
];
let resBody: GetGuildsResponse = {
guilds,
};
res.status(200).json(resBody);
}
}
-4
View File
@@ -1,4 +0,0 @@
export { Controller } from './controller.js';
export { GuildsController } from './guilds-controller.js';
export { ShardsController } from './shards-controller.js';
export { RootController } from './root-controller.js';
-17
View File
@@ -1,17 +0,0 @@
import { Request, Response, Router } from 'express';
import router from 'express-promise-router';
import { Controller } from './index.js';
export class RootController implements Controller {
public path = '/';
public router: Router = router();
public register(): void {
this.router.get('/', (req, res) => this.get(req, res));
}
private async get(req: Request, res: Response): Promise<void> {
res.status(200).json({ name: 'Discord Bot Cluster API', author: 'Kevin Novak' });
}
}
-80
View File
@@ -1,80 +0,0 @@
import { ActivityType, ShardingManager } from 'discord.js';
import { Request, Response, Router } from 'express';
import router from 'express-promise-router';
import { createRequire } from 'node:module';
import { Controller } from './index.js';
import { CustomClient } from '../extensions/index.js';
import { mapClass } from '../middleware/index.js';
import {
GetShardsResponse,
SetShardPresencesRequest,
ShardInfo,
ShardStats,
} from '../models/cluster-api/index.js';
import { Logger } from '../services/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
let Logs = require('../../lang/logs.json');
export class ShardsController implements Controller {
public path = '/shards';
public router: Router = router();
public authToken: string = Config.api.secret;
constructor(private shardManager: ShardingManager) {}
public register(): void {
this.router.get('/', (req, res) => this.getShards(req, res));
this.router.put('/presence', mapClass(SetShardPresencesRequest), (req, res) =>
this.setShardPresences(req, res)
);
}
private async getShards(req: Request, res: Response): Promise<void> {
let shardDatas = await Promise.all(
this.shardManager.shards.map(async shard => {
let shardInfo: ShardInfo = {
id: shard.id,
ready: shard.ready,
error: false,
};
try {
let uptime = (await shard.fetchClientValue('uptime')) as number;
shardInfo.uptimeSecs = Math.floor(uptime / 1000);
} catch (error) {
Logger.error(Logs.error.managerShardInfo, error);
shardInfo.error = true;
}
return shardInfo;
})
);
let stats: ShardStats = {
shardCount: this.shardManager.shards.size,
uptimeSecs: Math.floor(process.uptime()),
};
let resBody: GetShardsResponse = {
shards: shardDatas,
stats,
};
res.status(200).json(resBody);
}
private async setShardPresences(req: Request, res: Response): Promise<void> {
let reqBody: SetShardPresencesRequest = res.locals.input;
await this.shardManager.broadcastEval(
(client: CustomClient, context) => {
return client.setPresence(context.type, context.name, context.url);
},
{ context: { type: ActivityType[reqBody.type], name: reqBody.name, url: reqBody.url } }
);
res.sendStatus(200);
}
}
-4
View File
@@ -1,4 +0,0 @@
export enum HelpOption {
CONTACT_SUPPORT = 'CONTACT_SUPPORT',
COMMANDS = 'COMMANDS',
}
-2
View File
@@ -1,2 +0,0 @@
export { HelpOption } from './help-option.js';
export { InfoOption } from './info-option.js';
-5
View File
@@ -1,5 +0,0 @@
export enum InfoOption {
ABOUT = 'ABOUT',
TRANSLATE = 'TRANSLATE',
DEV = 'DEV',
}
-83
View File
@@ -1,83 +0,0 @@
import { ButtonInteraction } from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { createRequire } from 'node:module';
import { EventHandler } from './index.js';
import { Button, ButtonDeferType } from '../buttons/index.js';
import { EventDataService } from '../services/index.js';
import { InteractionUtils } from '../utils/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
export class ButtonHandler implements EventHandler {
private rateLimiter = new RateLimiter(
Config.rateLimiting.buttons.amount,
Config.rateLimiting.buttons.interval * 1000
);
constructor(private buttons: Button[], private eventDataService: EventDataService) {}
public async process(intr: ButtonInteraction): Promise<void> {
// Don't respond to self, or other bots
if (intr.user.id === intr.client.user?.id || intr.user.bot) {
return;
}
// Check if user is rate limited
let limited = this.rateLimiter.take(intr.user.id);
if (limited) {
return;
}
// Try to find the button the user wants
let button = this.findButton(intr.customId);
if (!button) {
return;
}
if (button.requireGuild && !intr.guild) {
return;
}
// Check if the embeds author equals the users tag
if (
button.requireEmbedAuthorTag &&
intr.message.embeds[0]?.author?.name !== intr.user.tag
) {
return;
}
// Defer interaction
// NOTE: Anything after this point we should be responding to the interaction
switch (button.deferType) {
case ButtonDeferType.REPLY: {
await InteractionUtils.deferReply(intr);
break;
}
case ButtonDeferType.UPDATE: {
await InteractionUtils.deferUpdate(intr);
break;
}
}
// Return if defer was unsuccessful
if (button.deferType !== ButtonDeferType.NONE && !intr.deferred) {
return;
}
// Get data from database
let data = await this.eventDataService.create({
user: intr.user,
channel: intr.channel,
guild: intr.guild,
});
// Execute the button
await button.execute(intr, data);
}
private findButton(id: string): Button {
return this.buttons.find(button => button.ids.includes(id));
}
}
-182
View File
@@ -1,182 +0,0 @@
import {
AutocompleteInteraction,
ChatInputCommandInteraction,
CommandInteraction,
NewsChannel,
TextChannel,
ThreadChannel,
} from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { createRequire } from 'node:module';
import { EventHandler } from './index.js';
import { Command, CommandDeferType } from '../commands/index.js';
import { DiscordLimits } from '../constants/index.js';
import { EventData } from '../models/internal-models.js';
import { EventDataService, Lang, Logger } from '../services/index.js';
import { CommandUtils, InteractionUtils } from '../utils/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
let Logs = require('../../lang/logs.json');
export class CommandHandler implements EventHandler {
private rateLimiter = new RateLimiter(
Config.rateLimiting.commands.amount,
Config.rateLimiting.commands.interval * 1000
);
constructor(public commands: Command[], private eventDataService: EventDataService) {}
public async process(intr: CommandInteraction | AutocompleteInteraction): Promise<void> {
// Don't respond to self, or other bots
if (intr.user.id === intr.client.user?.id || intr.user.bot) {
return;
}
let commandParts =
intr instanceof ChatInputCommandInteraction || intr instanceof AutocompleteInteraction
? [
intr.commandName,
intr.options.getSubcommandGroup(false),
intr.options.getSubcommand(false),
].filter(Boolean)
: [intr.commandName];
let commandName = commandParts.join(' ');
// Try to find the command the user wants
let command = CommandUtils.findCommand(this.commands, commandParts);
if (!command) {
Logger.error(
Logs.error.commandNotFound
.replaceAll('{INTERACTION_ID}', intr.id)
.replaceAll('{COMMAND_NAME}', commandName)
);
return;
}
if (intr instanceof AutocompleteInteraction) {
if (!command.autocomplete) {
Logger.error(
Logs.error.autocompleteNotFound
.replaceAll('{INTERACTION_ID}', intr.id)
.replaceAll('{COMMAND_NAME}', commandName)
);
return;
}
try {
let option = intr.options.getFocused(true);
let choices = await command.autocomplete(intr, option);
await InteractionUtils.respond(
intr,
choices?.slice(0, DiscordLimits.CHOICES_PER_AUTOCOMPLETE)
);
} catch (error) {
Logger.error(
intr.channel instanceof TextChannel ||
intr.channel instanceof NewsChannel ||
intr.channel instanceof ThreadChannel
? Logs.error.autocompleteGuild
.replaceAll('{INTERACTION_ID}', intr.id)
.replaceAll('{OPTION_NAME}', commandName)
.replaceAll('{COMMAND_NAME}', commandName)
.replaceAll('{USER_TAG}', intr.user.tag)
.replaceAll('{USER_ID}', intr.user.id)
.replaceAll('{CHANNEL_NAME}', intr.channel.name)
.replaceAll('{CHANNEL_ID}', intr.channel.id)
.replaceAll('{GUILD_NAME}', intr.guild?.name)
.replaceAll('{GUILD_ID}', intr.guild?.id)
: Logs.error.autocompleteOther
.replaceAll('{INTERACTION_ID}', intr.id)
.replaceAll('{OPTION_NAME}', commandName)
.replaceAll('{COMMAND_NAME}', commandName)
.replaceAll('{USER_TAG}', intr.user.tag)
.replaceAll('{USER_ID}', intr.user.id),
error
);
}
return;
}
// Check if user is rate limited
let limited = this.rateLimiter.take(intr.user.id);
if (limited) {
return;
}
// Defer interaction
// NOTE: Anything after this point we should be responding to the interaction
switch (command.deferType) {
case CommandDeferType.PUBLIC: {
await InteractionUtils.deferReply(intr, false);
break;
}
case CommandDeferType.HIDDEN: {
await InteractionUtils.deferReply(intr, true);
break;
}
}
// Return if defer was unsuccessful
if (command.deferType !== CommandDeferType.NONE && !intr.deferred) {
return;
}
// Get data from database
let data = await this.eventDataService.create({
user: intr.user,
channel: intr.channel,
guild: intr.guild,
args: intr instanceof ChatInputCommandInteraction ? intr.options : undefined,
});
try {
// Check if interaction passes command checks
let passesChecks = await CommandUtils.runChecks(command, intr, data);
if (passesChecks) {
// Execute the command
await command.execute(intr, data);
}
} catch (error) {
await this.sendError(intr, data);
// Log command error
Logger.error(
intr.channel instanceof TextChannel ||
intr.channel instanceof NewsChannel ||
intr.channel instanceof ThreadChannel
? Logs.error.commandGuild
.replaceAll('{INTERACTION_ID}', intr.id)
.replaceAll('{COMMAND_NAME}', commandName)
.replaceAll('{USER_TAG}', intr.user.tag)
.replaceAll('{USER_ID}', intr.user.id)
.replaceAll('{CHANNEL_NAME}', intr.channel.name)
.replaceAll('{CHANNEL_ID}', intr.channel.id)
.replaceAll('{GUILD_NAME}', intr.guild?.name)
.replaceAll('{GUILD_ID}', intr.guild?.id)
: Logs.error.commandOther
.replaceAll('{INTERACTION_ID}', intr.id)
.replaceAll('{COMMAND_NAME}', commandName)
.replaceAll('{USER_TAG}', intr.user.tag)
.replaceAll('{USER_ID}', intr.user.id),
error
);
}
}
private async sendError(intr: CommandInteraction, data: EventData): Promise<void> {
try {
await InteractionUtils.send(
intr,
Lang.getEmbed('errorEmbeds.command', data.lang, {
ERROR_CODE: intr.id,
GUILD_ID: intr.guild?.id ?? Lang.getRef('other.na', data.lang),
SHARD_ID: (intr.guild?.shardId ?? 0).toString(),
})
);
} catch {
// Ignore
}
}
}
-3
View File
@@ -1,3 +0,0 @@
export interface EventHandler {
process(...args: any[]): Promise<void>;
}
-67
View File
@@ -1,67 +0,0 @@
import { Guild } from 'discord.js';
import { createRequire } from 'node:module';
import { EventHandler } from './index.js';
import { Language } from '../models/enum-helpers/index.js';
import { EventDataService, Lang, Logger } from '../services/index.js';
import { ClientUtils, FormatUtils, MessageUtils } from '../utils/index.js';
const require = createRequire(import.meta.url);
let Logs = require('../../lang/logs.json');
export class GuildJoinHandler implements EventHandler {
constructor(private eventDataService: EventDataService) {}
public async process(guild: Guild): Promise<void> {
Logger.info(
Logs.info.guildJoined
.replaceAll('{GUILD_NAME}', guild.name)
.replaceAll('{GUILD_ID}', guild.id)
);
let owner = await guild.fetchOwner();
// Get data from database
let data = await this.eventDataService.create({
user: owner?.user,
guild,
});
// Send welcome message to the server's notify channel
let notifyChannel = await ClientUtils.findNotifyChannel(guild, data.langGuild);
if (notifyChannel) {
await MessageUtils.send(
notifyChannel,
Lang.getEmbed('displayEmbeds.welcome', data.langGuild, {
CMD_LINK_HELP: FormatUtils.commandMention(
await ClientUtils.findAppCommand(
guild.client,
Lang.getRef('chatCommands.help', Language.Default)
)
),
}).setAuthor({
name: guild.name,
iconURL: guild.iconURL(),
})
);
}
// Send welcome message to owner
if (owner) {
await MessageUtils.send(
owner.user,
Lang.getEmbed('displayEmbeds.welcome', data.lang, {
CMD_LINK_HELP: FormatUtils.commandMention(
await ClientUtils.findAppCommand(
guild.client,
Lang.getRef('chatCommands.help', Language.Default)
)
),
}).setAuthor({
name: guild.name,
iconURL: guild.iconURL(),
})
);
}
}
}
-18
View File
@@ -1,18 +0,0 @@
import { Guild } from 'discord.js';
import { createRequire } from 'node:module';
import { EventHandler } from './index.js';
import { Logger } from '../services/index.js';
const require = createRequire(import.meta.url);
let Logs = require('../../lang/logs.json');
export class GuildLeaveHandler implements EventHandler {
public async process(guild: Guild): Promise<void> {
Logger.info(
Logs.info.guildLeft
.replaceAll('{GUILD_NAME}', guild.name)
.replaceAll('{GUILD_ID}', guild.id)
);
}
}
-8
View File
@@ -1,8 +0,0 @@
export { ButtonHandler } from './button-handler.js';
export { CommandHandler } from './command-handler.js';
export { EventHandler } from './event-handler.js';
export { GuildJoinHandler } from './guild-join-handler.js';
export { GuildLeaveHandler } from './guild-leave-handler.js';
export { ReactionHandler } from './reaction-handler.js';
export { MessageHandler } from './message-handler.js';
export { TriggerHandler } from './trigger-handler.js';
-17
View File
@@ -1,17 +0,0 @@
import { Message } from 'discord.js';
import { EventHandler, TriggerHandler } from './index.js';
export class MessageHandler implements EventHandler {
constructor(private triggerHandler: TriggerHandler) {}
public async process(msg: Message): Promise<void> {
// Don't respond to system messages or self
if (msg.system || msg.author.id === msg.client.user?.id) {
return;
}
// Process trigger
await this.triggerHandler.process(msg);
}
}
-65
View File
@@ -1,65 +0,0 @@
import { Message, MessageReaction, User } from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { createRequire } from 'node:module';
import { EventHandler } from './index.js';
import { Reaction } from '../reactions/index.js';
import { EventDataService } from '../services/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
export class ReactionHandler implements EventHandler {
private rateLimiter = new RateLimiter(
Config.rateLimiting.reactions.amount,
Config.rateLimiting.reactions.interval * 1000
);
constructor(private reactions: Reaction[], private eventDataService: EventDataService) {}
public async process(msgReaction: MessageReaction, msg: Message, reactor: User): Promise<void> {
// Don't respond to self, or other bots
if (reactor.id === msgReaction.client.user?.id || reactor.bot) {
return;
}
// Check if user is rate limited
let limited = this.rateLimiter.take(msg.author.id);
if (limited) {
return;
}
// Try to find the reaction the user wants
let reaction = this.findReaction(msgReaction.emoji.name);
if (!reaction) {
return;
}
if (reaction.requireGuild && !msg.guild) {
return;
}
if (reaction.requireSentByClient && msg.author.id !== msg.client.user?.id) {
return;
}
// Check if the embeds author equals the reactors tag
if (reaction.requireEmbedAuthorTag && msg.embeds[0]?.author?.name !== reactor.tag) {
return;
}
// Get data from database
let data = await this.eventDataService.create({
user: reactor,
channel: msg.channel,
guild: msg.guild,
});
// Execute the reaction
await reaction.execute(msgReaction, msg, reactor, data);
}
private findReaction(emoji: string): Reaction {
return this.reactions.find(reaction => reaction.emoji === emoji);
}
}
-56
View File
@@ -1,56 +0,0 @@
import { Message } from 'discord.js';
import { RateLimiter } from 'discord.js-rate-limiter';
import { createRequire } from 'node:module';
import { EventDataService } from '../services/index.js';
import { Trigger } from '../triggers/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
export class TriggerHandler {
private rateLimiter = new RateLimiter(
Config.rateLimiting.triggers.amount,
Config.rateLimiting.triggers.interval * 1000
);
constructor(private triggers: Trigger[], private eventDataService: EventDataService) {}
public async process(msg: Message): Promise<void> {
// Check if user is rate limited
let limited = this.rateLimiter.take(msg.author.id);
if (limited) {
return;
}
// Find triggers caused by this message
let triggers = this.triggers.filter(trigger => {
if (trigger.requireGuild && !msg.guild) {
return false;
}
if (!trigger.triggered(msg)) {
return false;
}
return true;
});
// If this message causes no triggers then return
if (triggers.length === 0) {
return;
}
// Get data from database
let data = await this.eventDataService.create({
user: msg.author,
channel: msg.channel,
guild: msg.guild,
});
// Execute triggers
for (let trigger of triggers) {
await trigger.execute(msg, data);
}
}
}
-23
View File
@@ -1,23 +0,0 @@
import { ActivityType, Client, ClientOptions, Presence } from 'discord.js';
export class CustomClient extends Client {
constructor(clientOptions: ClientOptions) {
super(clientOptions);
}
public setPresence(
type: Exclude<ActivityType, ActivityType.Custom>,
name: string,
url: string
): Presence {
return this.user?.setPresence({
activities: [
{
type,
name,
url,
},
],
});
}
}
-1
View File
@@ -1 +0,0 @@
export { CustomClient } from './custom-client.js';
-2
View File
@@ -1,2 +0,0 @@
export { Job } from './job.js';
export { UpdateServerCountJob } from './update-server-count-job.js';
-6
View File
@@ -1,6 +0,0 @@
export interface Job {
name: string;
log: boolean;
schedule: string;
run(): Promise<void>;
}
-65
View File
@@ -1,65 +0,0 @@
import { ActivityType, ShardingManager } from 'discord.js';
import { createRequire } from 'node:module';
import { Job } from './index.js';
import { CustomClient } from '../extensions/index.js';
import { BotSite } from '../models/config-models.js';
import { HttpService, Lang, Logger } from '../services/index.js';
import { ShardUtils } from '../utils/index.js';
const require = createRequire(import.meta.url);
let BotSites: BotSite[] = require('../../config/bot-sites.json');
let Config = require('../../config/config.json');
let Logs = require('../../lang/logs.json');
export class UpdateServerCountJob implements Job {
public name = 'Update Server Count';
public schedule: string = Config.jobs.updateServerCount.schedule;
public log: boolean = Config.jobs.updateServerCount.log;
private botSites: BotSite[];
constructor(private shardManager: ShardingManager, private httpService: HttpService) {
this.botSites = BotSites.filter(botSite => botSite.enabled);
}
public async run(): Promise<void> {
let serverCount = await ShardUtils.serverCount(this.shardManager);
let type = ActivityType.Streaming;
let name = `to ${serverCount.toLocaleString()} servers`;
let url = Lang.getCom('links.stream');
await this.shardManager.broadcastEval(
(client: CustomClient, context) => {
return client.setPresence(context.type, context.name, context.url);
},
{ context: { type, name, url } }
);
Logger.info(
Logs.info.updatedServerCount.replaceAll('{SERVER_COUNT}', serverCount.toLocaleString())
);
for (let botSite of this.botSites) {
try {
let body = JSON.parse(
botSite.body.replaceAll('{{SERVER_COUNT}}', serverCount.toString())
);
let res = await this.httpService.post(botSite.url, botSite.authorization, body);
if (!res.ok) {
throw res;
}
} catch (error) {
Logger.error(
Logs.error.updatedServerCountSite.replaceAll('{BOT_SITE}', botSite.name),
error
);
continue;
}
Logger.info(Logs.info.updatedServerCountSite.replaceAll('{BOT_SITE}', botSite.name));
}
}
}
-11
View File
@@ -1,11 +0,0 @@
import { RequestHandler } from 'express';
export function checkAuth(token: string): RequestHandler {
return (req, res, next) => {
if (req.headers.authorization !== token) {
res.sendStatus(401);
return;
}
next();
};
}
-19
View File
@@ -1,19 +0,0 @@
import { ErrorRequestHandler } from 'express';
import { createRequire } from 'node:module';
import { Logger } from '../services/index.js';
const require = createRequire(import.meta.url);
let Logs = require('../../lang/logs.json');
export function handleError(): ErrorRequestHandler {
return (error, req, res, _next) => {
Logger.error(
Logs.error.apiRequest
.replaceAll('{HTTP_METHOD}', req.method)
.replaceAll('{URL}', req.url),
error
);
res.status(500).json({ error: true, message: error.message });
};
}
-3
View File
@@ -1,3 +0,0 @@
export { checkAuth } from './check-auth.js';
export { handleError } from './handle-error.js';
export { mapClass } from './map-class.js';
-40
View File
@@ -1,40 +0,0 @@
import { ClassConstructor, plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { NextFunction, Request, RequestHandler, Response } from 'express';
export function mapClass(cls: ClassConstructor<object>): RequestHandler {
return async (req: Request, res: Response, next: NextFunction) => {
// Map to class
let obj: object = plainToInstance(cls, req.body);
// Validate class
let errors = await validate(obj, {
skipMissingProperties: true,
whitelist: true,
forbidNonWhitelisted: false,
forbidUnknownValues: true,
});
if (errors.length > 0) {
res.status(400).send({ error: true, errors: formatValidationErrors(errors) });
return;
}
// Set validated class to locals
res.locals.input = obj;
next();
};
}
interface ValidationErrorLog {
property: string;
constraints?: { [type: string]: string };
children?: ValidationErrorLog[];
}
function formatValidationErrors(errors: ValidationError[]): ValidationErrorLog[] {
return errors.map(error => ({
property: error.property,
constraints: error.constraints,
children: error.children?.length > 0 ? formatValidationErrors(error.children) : undefined,
}));
}
-38
View File
@@ -1,38 +0,0 @@
import express, { Express } from 'express';
import { createRequire } from 'node:module';
import util from 'node:util';
import { Controller } from '../controllers/index.js';
import { checkAuth, handleError } from '../middleware/index.js';
import { Logger } from '../services/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
let Logs = require('../../lang/logs.json');
export class Api {
private app: Express;
constructor(public controllers: Controller[]) {
this.app = express();
this.app.use(express.json());
this.setupControllers();
this.app.use(handleError());
}
public async start(): Promise<void> {
let listen = util.promisify(this.app.listen.bind(this.app));
await listen(Config.api.port);
Logger.info(Logs.info.apiStarted.replaceAll('{PORT}', Config.api.port));
}
private setupControllers(): void {
for (let controller of this.controllers) {
if (controller.authToken) {
controller.router.use(checkAuth(controller.authToken));
}
controller.register();
this.app.use(controller.path, controller.router);
}
}
}
-203
View File
@@ -1,203 +0,0 @@
import {
AutocompleteInteraction,
ButtonInteraction,
Client,
CommandInteraction,
Events,
Guild,
Interaction,
Message,
MessageReaction,
PartialMessageReaction,
PartialUser,
RateLimitData,
RESTEvents,
User,
} from 'discord.js';
import { createRequire } from 'node:module';
import {
ButtonHandler,
CommandHandler,
GuildJoinHandler,
GuildLeaveHandler,
MessageHandler,
ReactionHandler,
} from '../events/index.js';
import { JobService, Logger } from '../services/index.js';
import { PartialUtils } from '../utils/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
let Debug = require('../../config/debug.json');
let Logs = require('../../lang/logs.json');
export class Bot {
private ready = false;
constructor(
private token: string,
private client: Client,
private guildJoinHandler: GuildJoinHandler,
private guildLeaveHandler: GuildLeaveHandler,
private messageHandler: MessageHandler,
private commandHandler: CommandHandler,
private buttonHandler: ButtonHandler,
private reactionHandler: ReactionHandler,
private jobService: JobService
) {}
public async start(): Promise<void> {
this.registerListeners();
await this.login(this.token);
}
private registerListeners(): void {
this.client.on(Events.ClientReady, () => this.onReady());
this.client.on(Events.ShardReady, (shardId: number, unavailableGuilds: Set<string>) =>
this.onShardReady(shardId, unavailableGuilds)
);
this.client.on(Events.GuildCreate, (guild: Guild) => this.onGuildJoin(guild));
this.client.on(Events.GuildDelete, (guild: Guild) => this.onGuildLeave(guild));
this.client.on(Events.MessageCreate, (msg: Message) => this.onMessage(msg));
this.client.on(Events.InteractionCreate, (intr: Interaction) => this.onInteraction(intr));
this.client.on(
Events.MessageReactionAdd,
(messageReaction: MessageReaction | PartialMessageReaction, user: User | PartialUser) =>
this.onReaction(messageReaction, user)
);
this.client.rest.on(RESTEvents.RateLimited, (rateLimitData: RateLimitData) =>
this.onRateLimit(rateLimitData)
);
}
private async login(token: string): Promise<void> {
try {
await this.client.login(token);
} catch (error) {
Logger.error(Logs.error.clientLogin, error);
return;
}
}
private async onReady(): Promise<void> {
let userTag = this.client.user?.tag;
Logger.info(Logs.info.clientLogin.replaceAll('{USER_TAG}', userTag));
if (!Debug.dummyMode.enabled) {
this.jobService.start();
}
this.ready = true;
Logger.info(Logs.info.clientReady);
}
private onShardReady(shardId: number, _unavailableGuilds: Set<string>): void {
Logger.setShardId(shardId);
}
private async onGuildJoin(guild: Guild): Promise<void> {
if (!this.ready || Debug.dummyMode.enabled) {
return;
}
try {
await this.guildJoinHandler.process(guild);
} catch (error) {
Logger.error(Logs.error.guildJoin, error);
}
}
private async onGuildLeave(guild: Guild): Promise<void> {
if (!this.ready || Debug.dummyMode.enabled) {
return;
}
try {
await this.guildLeaveHandler.process(guild);
} catch (error) {
Logger.error(Logs.error.guildLeave, error);
}
}
private async onMessage(msg: Message): Promise<void> {
if (
!this.ready ||
(Debug.dummyMode.enabled && !Debug.dummyMode.whitelist.includes(msg.author.id))
) {
return;
}
try {
msg = await PartialUtils.fillMessage(msg);
if (!msg) {
return;
}
await this.messageHandler.process(msg);
} catch (error) {
Logger.error(Logs.error.message, error);
}
}
private async onInteraction(intr: Interaction): Promise<void> {
if (
!this.ready ||
(Debug.dummyMode.enabled && !Debug.dummyMode.whitelist.includes(intr.user.id))
) {
return;
}
if (intr instanceof CommandInteraction || intr instanceof AutocompleteInteraction) {
try {
await this.commandHandler.process(intr);
} catch (error) {
Logger.error(Logs.error.command, error);
}
} else if (intr instanceof ButtonInteraction) {
try {
await this.buttonHandler.process(intr);
} catch (error) {
Logger.error(Logs.error.button, error);
}
}
}
private async onReaction(
msgReaction: MessageReaction | PartialMessageReaction,
reactor: User | PartialUser
): Promise<void> {
if (
!this.ready ||
(Debug.dummyMode.enabled && !Debug.dummyMode.whitelist.includes(reactor.id))
) {
return;
}
try {
msgReaction = await PartialUtils.fillReaction(msgReaction);
if (!msgReaction) {
return;
}
reactor = await PartialUtils.fillUser(reactor);
if (!reactor) {
return;
}
await this.reactionHandler.process(
msgReaction,
msgReaction.message as Message,
reactor
);
} catch (error) {
Logger.error(Logs.error.reaction, error);
}
}
private async onRateLimit(rateLimitData: RateLimitData): Promise<void> {
if (rateLimitData.timeToReset >= Config.logging.rateLimit.minTimeout * 1000) {
Logger.error(Logs.error.apiRateLimit, rateLimitData);
}
}
}
-3
View File
@@ -1,3 +0,0 @@
export interface GetGuildsResponse {
guilds: string[];
}
-2
View File
@@ -1,2 +0,0 @@
export { GetGuildsResponse } from './guilds.js';
export { GetShardsResponse, ShardInfo, ShardStats, SetShardPresencesRequest } from './shards.js';
-34
View File
@@ -1,34 +0,0 @@
import { IsDefined, IsEnum, IsString, IsUrl, Length } from 'class-validator';
import { ActivityType } from 'discord.js';
export interface GetShardsResponse {
shards: ShardInfo[];
stats: ShardStats;
}
export interface ShardStats {
shardCount: number;
uptimeSecs: number;
}
export interface ShardInfo {
id: number;
ready: boolean;
error: boolean;
uptimeSecs?: number;
}
export class SetShardPresencesRequest {
@IsDefined()
@IsEnum(ActivityType)
type: string;
@IsDefined()
@IsString()
@Length(1, 128)
name: string;
@IsDefined()
@IsUrl()
url: string;
}
-7
View File
@@ -1,7 +0,0 @@
export interface BotSite {
name: string;
enabled: boolean;
url: string;
authorization: string;
body: string;
}
-2
View File
@@ -1,2 +0,0 @@
export { Language } from './language.js';
export { Permission } from './permission.js';
-110
View File
@@ -1,110 +0,0 @@
import { Locale } from 'discord.js';
interface LanguageData {
englishName: string;
nativeName: string;
}
export class Language {
public static Default = Locale.EnglishUS;
public static Enabled: Locale[] = [Locale.EnglishUS, Locale.EnglishGB];
// See https://discord.com/developers/docs/reference#locales
public static Data: {
[key in Locale]: LanguageData;
} = {
bg: { englishName: 'Bulgarian', nativeName: 'български' },
cs: { englishName: 'Czech', nativeName: 'Čeština' },
da: { englishName: 'Danish', nativeName: 'Dansk' },
de: { englishName: 'German', nativeName: 'Deutsch' },
el: { englishName: 'Greek', nativeName: 'Ελληνικά' },
'en-GB': { englishName: 'English, UK', nativeName: 'English, UK' },
'en-US': { englishName: 'English, US', nativeName: 'English, US' },
'es-ES': { englishName: 'Spanish', nativeName: 'Español' },
fi: { englishName: 'Finnish', nativeName: 'Suomi' },
fr: { englishName: 'French', nativeName: 'Français' },
hi: { englishName: 'Hindi', nativeName: 'हिन्दी' },
hr: { englishName: 'Croatian', nativeName: 'Hrvatski' },
hu: { englishName: 'Hungarian', nativeName: 'Magyar' },
id: { englishName: 'Indonesian', nativeName: 'Bahasa Indonesia' },
it: { englishName: 'Italian', nativeName: 'Italiano' },
ja: { englishName: 'Japanese', nativeName: '日本語' },
ko: { englishName: 'Korean', nativeName: '한국어' },
lt: { englishName: 'Lithuanian', nativeName: 'Lietuviškai' },
nl: { englishName: 'Dutch', nativeName: 'Nederlands' },
no: { englishName: 'Norwegian', nativeName: 'Norsk' },
pl: { englishName: 'Polish', nativeName: 'Polski' },
'pt-BR': { englishName: 'Portuguese, Brazilian', nativeName: 'Português do Brasil' },
ro: { englishName: 'Romanian, Romania', nativeName: 'Română' },
ru: { englishName: 'Russian', nativeName: 'Pусский' },
'sv-SE': { englishName: 'Swedish', nativeName: 'Svenska' },
th: { englishName: 'Thai', nativeName: 'ไทย' },
tr: { englishName: 'Turkish', nativeName: 'Türkçe' },
uk: { englishName: 'Ukrainian', nativeName: 'Українська' },
vi: { englishName: 'Vietnamese', nativeName: 'Tiếng Việt' },
'zh-CN': { englishName: 'Chinese, China', nativeName: '中文' },
'zh-TW': { englishName: 'Chinese, Taiwan', nativeName: '繁體中文' },
};
public static find(input: string, enabled: boolean): Locale {
return this.findMultiple(input, enabled, 1)[0];
}
public static findMultiple(
input: string,
enabled: boolean,
limit: number = Number.MAX_VALUE
): Locale[] {
let langCodes = enabled ? this.Enabled : Object.values(Locale).sort();
let search = input.toLowerCase();
let found = new Set<Locale>();
// Exact match
if (found.size < limit)
langCodes
.filter(langCode => langCode.toLowerCase() === search)
.forEach(langCode => found.add(langCode));
if (found.size < limit)
langCodes
.filter(langCode => this.Data[langCode].nativeName.toLowerCase() === search)
.forEach(langCode => found.add(langCode));
if (found.size < limit)
langCodes
.filter(langCode => this.Data[langCode].nativeName.toLowerCase() === search)
.forEach(langCode => found.add(langCode));
if (found.size < limit)
langCodes
.filter(langCode => this.Data[langCode].englishName.toLowerCase() === search)
.forEach(langCode => found.add(langCode));
// Starts with search term
if (found.size < limit)
langCodes
.filter(langCode => langCode.toLowerCase().startsWith(search))
.forEach(langCode => found.add(langCode));
if (found.size < limit)
langCodes
.filter(langCode => this.Data[langCode].nativeName.toLowerCase().startsWith(search))
.forEach(langCode => found.add(langCode));
if (found.size < limit)
langCodes
.filter(langCode =>
this.Data[langCode].englishName.toLowerCase().startsWith(search)
)
.forEach(langCode => found.add(langCode));
// Includes search term
if (found.size < limit)
langCodes
.filter(langCode => langCode.toLowerCase().startsWith(search))
.forEach(langCode => found.add(langCode));
if (found.size < limit)
langCodes
.filter(langCode => this.Data[langCode].nativeName.toLowerCase().startsWith(search))
.forEach(langCode => found.add(langCode));
if (found.size < limit)
langCodes
.filter(langCode =>
this.Data[langCode].englishName.toLowerCase().startsWith(search)
)
.forEach(langCode => found.add(langCode));
return [...found];
}
}
-219
View File
@@ -1,219 +0,0 @@
import { Locale, PermissionsString } from 'discord.js';
import { Lang } from '../../services/index.js';
interface PermissionData {
displayName(langCode: Locale): string;
}
export class Permission {
public static Data: {
[key in PermissionsString]: PermissionData;
} = {
AddReactions: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.AddReactions', langCode);
},
},
Administrator: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.Administrator', langCode);
},
},
AttachFiles: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.AttachFiles', langCode);
},
},
BanMembers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.BanMembers', langCode);
},
},
ChangeNickname: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ChangeNickname', langCode);
},
},
Connect: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.Connect', langCode);
},
},
CreateInstantInvite: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.CreateInstantInvite', langCode);
},
},
CreatePrivateThreads: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.CreatePrivateThreads', langCode);
},
},
CreatePublicThreads: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.CreatePublicThreads', langCode);
},
},
DeafenMembers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.DeafenMembers', langCode);
},
},
EmbedLinks: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.EmbedLinks', langCode);
},
},
KickMembers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.KickMembers', langCode);
},
},
ManageChannels: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageChannels', langCode);
},
},
ManageEmojisAndStickers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageEmojisAndStickers', langCode);
},
},
ManageEvents: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageEvents', langCode);
},
},
ManageGuild: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageGuild', langCode);
},
},
ManageMessages: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageMessages', langCode);
},
},
ManageNicknames: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageNicknames', langCode);
},
},
ManageRoles: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageRoles', langCode);
},
},
ManageThreads: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageThreads', langCode);
},
},
ManageWebhooks: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ManageWebhooks', langCode);
},
},
MentionEveryone: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.MentionEveryone', langCode);
},
},
ModerateMembers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ModerateMembers', langCode);
},
},
MoveMembers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.MoveMembers', langCode);
},
},
MuteMembers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.MuteMembers', langCode);
},
},
PrioritySpeaker: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.PrioritySpeaker', langCode);
},
},
ReadMessageHistory: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ReadMessageHistory', langCode);
},
},
RequestToSpeak: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.RequestToSpeak', langCode);
},
},
SendMessages: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.SendMessages', langCode);
},
},
SendMessagesInThreads: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.SendMessagesInThreads', langCode);
},
},
SendTTSMessages: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.SendTTSMessages', langCode);
},
},
Speak: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.Speak', langCode);
},
},
Stream: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.Stream', langCode);
},
},
UseApplicationCommands: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.UseApplicationCommands', langCode);
},
},
UseEmbeddedActivities: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.UseEmbeddedActivities', langCode);
},
},
UseExternalEmojis: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.UseExternalEmojis', langCode);
},
},
UseExternalStickers: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.UseExternalStickers', langCode);
},
},
UseVAD: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.UseVAD', langCode);
},
},
ViewAuditLog: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ViewAuditLog', langCode);
},
},
ViewChannel: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ViewChannel', langCode);
},
},
ViewGuildInsights: {
displayName(langCode: Locale): string {
return Lang.getRef('permissions.ViewGuildInsights', langCode);
},
},
};
}
-12
View File
@@ -1,12 +0,0 @@
import { Locale } from 'discord.js';
// This class is used to store and pass data along in events
export class EventData {
// TODO: Add any data you want to store
constructor(
// Event language
public lang: Locale,
// Guild language
public langGuild: Locale
) {}
}
-50
View File
@@ -1,50 +0,0 @@
import { Shard, ShardingManager } from 'discord.js';
import { createRequire } from 'node:module';
import { JobService, Logger } from '../services/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
let Debug = require('../../config/debug.json');
let Logs = require('../../lang/logs.json');
export class Manager {
constructor(private shardManager: ShardingManager, private jobService: JobService) {}
public async start(): Promise<void> {
this.registerListeners();
let shardList = this.shardManager.shardList as number[];
try {
Logger.info(
Logs.info.managerSpawningShards
.replaceAll('{SHARD_COUNT}', shardList.length.toLocaleString())
.replaceAll('{SHARD_LIST}', shardList.join(', '))
);
await this.shardManager.spawn({
amount: this.shardManager.totalShards,
delay: Config.sharding.spawnDelay * 1000,
timeout: Config.sharding.spawnTimeout * 1000,
});
Logger.info(Logs.info.managerAllShardsSpawned);
} catch (error) {
Logger.error(Logs.error.managerSpawningShards, error);
return;
}
if (Debug.dummyMode.enabled) {
return;
}
this.jobService.start();
}
private registerListeners(): void {
this.shardManager.on('shardCreate', shard => this.onShardCreate(shard));
}
private onShardCreate(shard: Shard): void {
Logger.info(Logs.info.managerLaunchedShard.replaceAll('{SHARD_ID}', shard.id.toString()));
}
}
-42
View File
@@ -1,42 +0,0 @@
import { Type } from 'class-transformer';
import {
IsDefined,
IsInt,
IsPositive,
IsString,
IsUrl,
Length,
ValidateNested,
} from 'class-validator';
export class Callback {
@IsDefined()
@IsUrl({ require_tld: false })
url: string;
@IsDefined()
@IsString()
@Length(5, 2000)
token: string;
}
export class RegisterClusterRequest {
@IsDefined()
@IsInt()
@IsPositive()
shardCount: number;
@IsDefined()
@ValidateNested()
@Type(() => Callback)
callback: Callback;
}
export interface RegisterClusterResponse {
id: string;
}
export interface LoginClusterResponse {
shardList: number[];
totalShards: number;
}
-5
View File
@@ -1,5 +0,0 @@
export {
RegisterClusterRequest,
RegisterClusterResponse,
LoginClusterResponse,
} from './clusters.js';
-1
View File
@@ -1 +0,0 @@
export { Reaction } from './reaction.js';
-16
View File
@@ -1,16 +0,0 @@
import { Message, MessageReaction, User } from 'discord.js';
import { EventData } from '../models/internal-models.js';
export interface Reaction {
emoji: string;
requireGuild: boolean;
requireSentByClient: boolean;
requireEmbedAuthorTag: boolean;
execute(
msgReaction: MessageReaction,
msg: Message,
reactor: User,
data: EventData
): Promise<void>;
}
@@ -1,157 +0,0 @@
import { REST } from '@discordjs/rest';
import {
APIApplicationCommand,
RESTGetAPIApplicationCommandsResult,
RESTPatchAPIApplicationCommandJSONBody,
RESTPostAPIApplicationCommandsJSONBody,
Routes,
} from 'discord.js';
import { createRequire } from 'node:module';
import { Logger } from './logger.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
let Logs = require('../../lang/logs.json');
export class CommandRegistrationService {
constructor(private rest: REST) {}
public async process(
localCmds: RESTPostAPIApplicationCommandsJSONBody[],
args: string[]
): Promise<void> {
let remoteCmds = (await this.rest.get(
Routes.applicationCommands(Config.client.id)
)) as RESTGetAPIApplicationCommandsResult;
let localCmdsOnRemote = localCmds.filter(localCmd =>
remoteCmds.some(remoteCmd => remoteCmd.name === localCmd.name)
);
let localCmdsOnly = localCmds.filter(
localCmd => !remoteCmds.some(remoteCmd => remoteCmd.name === localCmd.name)
);
let remoteCmdsOnly = remoteCmds.filter(
remoteCmd => !localCmds.some(localCmd => localCmd.name === remoteCmd.name)
);
switch (args[3]) {
case 'view': {
Logger.info(
Logs.info.commandActionView
.replaceAll(
'{LOCAL_AND_REMOTE_LIST}',
this.formatCommandList(localCmdsOnRemote)
)
.replaceAll('{LOCAL_ONLY_LIST}', this.formatCommandList(localCmdsOnly))
.replaceAll('{REMOTE_ONLY_LIST}', this.formatCommandList(remoteCmdsOnly))
);
return;
}
case 'register': {
if (localCmdsOnly.length > 0) {
Logger.info(
Logs.info.commandActionCreating.replaceAll(
'{COMMAND_LIST}',
this.formatCommandList(localCmdsOnly)
)
);
for (let localCmd of localCmdsOnly) {
await this.rest.post(Routes.applicationCommands(Config.client.id), {
body: localCmd,
});
}
Logger.info(Logs.info.commandActionCreated);
}
if (localCmdsOnRemote.length > 0) {
Logger.info(
Logs.info.commandActionUpdating.replaceAll(
'{COMMAND_LIST}',
this.formatCommandList(localCmdsOnRemote)
)
);
for (let localCmd of localCmdsOnRemote) {
await this.rest.post(Routes.applicationCommands(Config.client.id), {
body: localCmd,
});
}
Logger.info(Logs.info.commandActionUpdated);
}
return;
}
case 'rename': {
let oldName = args[4];
let newName = args[5];
if (!(oldName && newName)) {
Logger.error(Logs.error.commandActionRenameMissingArg);
return;
}
let remoteCmd = remoteCmds.find(remoteCmd => remoteCmd.name == oldName);
if (!remoteCmd) {
Logger.error(
Logs.error.commandActionNotFound.replaceAll('{COMMAND_NAME}', oldName)
);
return;
}
Logger.info(
Logs.info.commandActionRenaming
.replaceAll('{OLD_COMMAND_NAME}', remoteCmd.name)
.replaceAll('{NEW_COMMAND_NAME}', newName)
);
let body: RESTPatchAPIApplicationCommandJSONBody = {
name: newName,
};
await this.rest.patch(Routes.applicationCommand(Config.client.id, remoteCmd.id), {
body,
});
Logger.info(Logs.info.commandActionRenamed);
return;
}
case 'delete': {
let name = args[4];
if (!name) {
Logger.error(Logs.error.commandActionDeleteMissingArg);
return;
}
let remoteCmd = remoteCmds.find(remoteCmd => remoteCmd.name == name);
if (!remoteCmd) {
Logger.error(
Logs.error.commandActionNotFound.replaceAll('{COMMAND_NAME}', name)
);
return;
}
Logger.info(
Logs.info.commandActionDeleting.replaceAll('{COMMAND_NAME}', remoteCmd.name)
);
await this.rest.delete(Routes.applicationCommand(Config.client.id, remoteCmd.id));
Logger.info(Logs.info.commandActionDeleted);
return;
}
case 'clear': {
Logger.info(
Logs.info.commandActionClearing.replaceAll(
'{COMMAND_LIST}',
this.formatCommandList(remoteCmds)
)
);
await this.rest.put(Routes.applicationCommands(Config.client.id), { body: [] });
Logger.info(Logs.info.commandActionCleared);
return;
}
}
}
private formatCommandList(
cmds: RESTPostAPIApplicationCommandsJSONBody[] | APIApplicationCommand[]
): string {
return cmds.length > 0
? cmds.map((cmd: { name: string }) => `'${cmd.name}'`).join(', ')
: 'N/A';
}
}
-39
View File
@@ -1,39 +0,0 @@
import {
Channel,
CommandInteractionOptionResolver,
Guild,
PartialDMChannel,
User,
} from 'discord.js';
import { Language } from '../models/enum-helpers/language.js';
import { EventData } from '../models/internal-models.js';
export class EventDataService {
public async create(
options: {
user?: User;
channel?: Channel | PartialDMChannel;
guild?: Guild;
args?: Omit<CommandInteractionOptionResolver, 'getMessage' | 'getFocused'>;
} = {}
): Promise<EventData> {
// TODO: Retrieve any data you want to pass along in events
// Event language
let lang =
options.guild?.preferredLocale &&
Language.Enabled.includes(options.guild.preferredLocale)
? options.guild.preferredLocale
: Language.Default;
// Guild language
let langGuild =
options.guild?.preferredLocale &&
Language.Enabled.includes(options.guild.preferredLocale)
? options.guild.preferredLocale
: Language.Default;
return new EventData(lang, langGuild);
}
}
-54
View File
@@ -1,54 +0,0 @@
import fetch, { Response } from 'node-fetch';
import { URL } from 'node:url';
export class HttpService {
public async get(url: string | URL, authorization: string): Promise<Response> {
return await fetch(url.toString(), {
method: 'get',
headers: {
Authorization: authorization,
Accept: 'application/json',
},
});
}
public async post(url: string | URL, authorization: string, body?: object): Promise<Response> {
return await fetch(url.toString(), {
method: 'post',
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
}
public async put(url: string | URL, authorization: string, body?: object): Promise<Response> {
return await fetch(url.toString(), {
method: 'put',
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
}
public async delete(
url: string | URL,
authorization: string,
body?: object
): Promise<Response> {
return await fetch(url.toString(), {
method: 'delete',
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
}
}
-7
View File
@@ -1,7 +0,0 @@
export { CommandRegistrationService } from './command-registration-service.js';
export { EventDataService } from './event-data-service.js';
export { HttpService } from './http-service.js';
export { JobService } from './job-service.js';
export { Lang } from './lang.js';
export { Logger } from './logger.js';
export { MasterApiService } from './master-api-service.js';
-37
View File
@@ -1,37 +0,0 @@
import schedule from 'node-schedule';
import { createRequire } from 'node:module';
import { Logger } from './index.js';
import { Job } from '../jobs/index.js';
const require = createRequire(import.meta.url);
let Logs = require('../../lang/logs.json');
export class JobService {
constructor(private jobs: Job[]) {}
public start(): void {
for (let job of this.jobs) {
schedule.scheduleJob(job.schedule, async () => {
try {
if (job.log) {
Logger.info(Logs.info.jobRun.replaceAll('{JOB}', job.name));
}
await job.run();
if (job.log) {
Logger.info(Logs.info.jobCompleted.replaceAll('{JOB}', job.name));
}
} catch (error) {
Logger.error(Logs.error.job.replaceAll('{JOB}', job.name), error);
}
});
Logger.info(
Logs.info.jobScheduled
.replaceAll('{JOB}', job.name)
.replaceAll('{SCHEDULE}', job.schedule)
);
}
}
}
-83
View File
@@ -1,83 +0,0 @@
import { EmbedBuilder, Locale, LocalizationMap, resolveColor } from 'discord.js';
import { Linguini, TypeMapper, TypeMappers, Utils } from 'linguini';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Language } from '../models/enum-helpers/index.js';
export class Lang {
private static linguini = new Linguini(
path.resolve(dirname(fileURLToPath(import.meta.url)), '../../lang'),
'lang'
);
public static getEmbed(
location: string,
langCode: Locale,
variables?: { [name: string]: string }
): EmbedBuilder {
return (
this.linguini.get(location, langCode, this.embedTm, variables) ??
this.linguini.get(location, Language.Default, this.embedTm, variables)
);
}
public static getRegex(location: string, langCode: Locale): RegExp {
return (
this.linguini.get(location, langCode, TypeMappers.RegExp) ??
this.linguini.get(location, Language.Default, TypeMappers.RegExp)
);
}
public static getRef(
location: string,
langCode: Locale,
variables?: { [name: string]: string }
): string {
return (
this.linguini.getRef(location, langCode, variables) ??
this.linguini.getRef(location, Language.Default, variables)
);
}
public static getRefLocalizationMap(
location: string,
variables?: { [name: string]: string }
): LocalizationMap {
let obj = {};
for (let langCode of Language.Enabled) {
obj[langCode] = this.getRef(location, langCode, variables);
}
return obj;
}
public static getCom(location: string, variables?: { [name: string]: string }): string {
return this.linguini.getCom(location, variables);
}
private static embedTm: TypeMapper<EmbedBuilder> = (jsonValue: any) => {
return new EmbedBuilder({
author: jsonValue.author,
title: Utils.join(jsonValue.title, '\n'),
url: jsonValue.url,
thumbnail: {
url: jsonValue.thumbnail,
},
description: Utils.join(jsonValue.description, '\n'),
fields: jsonValue.fields?.map(field => ({
name: Utils.join(field.name, '\n'),
value: Utils.join(field.value, '\n'),
inline: field.inline ? field.inline : false,
})),
image: {
url: jsonValue.image,
},
footer: {
text: Utils.join(jsonValue.footer?.text, '\n'),
iconURL: jsonValue.footer?.icon,
},
timestamp: jsonValue.timestamp ? Date.now() : undefined,
color: resolveColor(jsonValue.color ?? Lang.getCom('colors.default')),
});
};
}
-92
View File
@@ -1,92 +0,0 @@
import { DiscordAPIError } from 'discord.js';
import { Response } from 'node-fetch';
import { createRequire } from 'node:module';
import pino from 'pino';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
let logger = pino(
{
formatters: {
level: label => {
return { level: label };
},
},
},
Config.logging.pretty
? pino.transport({
target: 'pino-pretty',
options: {
colorize: true,
ignore: 'pid,hostname',
translateTime: 'yyyy-mm-dd HH:MM:ss.l',
},
})
: undefined
);
export class Logger {
private static shardId: number;
public static info(message: string, obj?: any): void {
obj ? logger.info(obj, message) : logger.info(message);
}
public static warn(message: string, obj?: any): void {
obj ? logger.warn(obj, message) : logger.warn(message);
}
public static async error(message: string, obj?: any): Promise<void> {
// Log just a message if no error object
if (!obj) {
logger.error(message);
return;
}
// Otherwise log details about the error
if (typeof obj === 'string') {
logger
.child({
message: obj,
})
.error(message);
} else if (obj instanceof Response) {
let resText: string;
try {
resText = await obj.text();
} catch {
// Ignore
}
logger
.child({
path: obj.url,
statusCode: obj.status,
statusName: obj.statusText,
headers: obj.headers.raw(),
body: resText,
})
.error(message);
} else if (obj instanceof DiscordAPIError) {
logger
.child({
message: obj.message,
code: obj.code,
statusCode: obj.status,
method: obj.method,
url: obj.url,
stack: obj.stack,
})
.error(message);
} else {
logger.error(obj, message);
}
}
public static setShardId(shardId: number): void {
if (this.shardId !== shardId) {
this.shardId = shardId;
logger = logger.child({ shardId });
}
}
}
-65
View File
@@ -1,65 +0,0 @@
import { createRequire } from 'node:module';
import { URL } from 'node:url';
import { HttpService } from './index.js';
import {
LoginClusterResponse,
RegisterClusterRequest,
RegisterClusterResponse,
} from '../models/master-api/index.js';
const require = createRequire(import.meta.url);
let Config = require('../../config/config.json');
export class MasterApiService {
private clusterId: string;
constructor(private httpService: HttpService) {}
public async register(): Promise<void> {
let reqBody: RegisterClusterRequest = {
shardCount: Config.clustering.shardCount,
callback: {
url: Config.clustering.callbackUrl,
token: Config.api.secret,
},
};
let res = await this.httpService.post(
new URL('/clusters', Config.clustering.masterApi.url),
Config.clustering.masterApi.token,
reqBody
);
if (!res.ok) {
throw res;
}
let resBody = (await res.json()) as RegisterClusterResponse;
this.clusterId = resBody.id;
}
public async login(): Promise<LoginClusterResponse> {
let res = await this.httpService.put(
new URL(`/clusters/${this.clusterId}/login`, Config.clustering.masterApi.url),
Config.clustering.masterApi.token
);
if (!res.ok) {
throw res;
}
return (await res.json()) as LoginClusterResponse;
}
public async ready(): Promise<void> {
let res = await this.httpService.put(
new URL(`/clusters/${this.clusterId}/ready`, Config.clustering.masterApi.url),
Config.clustering.masterApi.token
);
if (!res.ok) {
throw res;
}
}
}
-142
View File
@@ -1,142 +0,0 @@
import { REST } from '@discordjs/rest';
import { Options, Partials } from 'discord.js';
import { createRequire } from 'node:module';
import { Button } from './buttons/index.js';
import { HelpCommand, InfoCommand, TestCommand } from './commands/chat/index.js';
import {
ChatCommandMetadata,
Command,
MessageCommandMetadata,
UserCommandMetadata,
} from './commands/index.js';
import { ViewDateSent } from './commands/message/index.js';
import { ViewDateJoined } from './commands/user/index.js';
import {
ButtonHandler,
CommandHandler,
GuildJoinHandler,
GuildLeaveHandler,
MessageHandler,
ReactionHandler,
TriggerHandler,
} from './events/index.js';
import { CustomClient } from './extensions/index.js';
import { Job } from './jobs/index.js';
import { Bot } from './models/bot.js';
import { Reaction } from './reactions/index.js';
import {
CommandRegistrationService,
EventDataService,
JobService,
Logger,
} from './services/index.js';
import { Trigger } from './triggers/index.js';
const require = createRequire(import.meta.url);
let Config = require('../config/config.json');
let Logs = require('../lang/logs.json');
async function start(): Promise<void> {
// Services
let eventDataService = new EventDataService();
// Client
let client = new CustomClient({
intents: Config.client.intents,
partials: (Config.client.partials as string[]).map(partial => Partials[partial]),
makeCache: Options.cacheWithLimits({
// Keep default caching behavior
...Options.DefaultMakeCacheSettings,
// Override specific options from config
...Config.client.caches,
}),
});
// Commands
let commands: Command[] = [
// Chat Commands
new HelpCommand(),
new InfoCommand(),
new TestCommand(),
// Message Context Commands
new ViewDateSent(),
// User Context Commands
new ViewDateJoined(),
// TODO: Add new commands here
];
// Buttons
let buttons: Button[] = [
// TODO: Add new buttons here
];
// Reactions
let reactions: Reaction[] = [
// TODO: Add new reactions here
];
// Triggers
let triggers: Trigger[] = [
// TODO: Add new triggers here
];
// Event handlers
let guildJoinHandler = new GuildJoinHandler(eventDataService);
let guildLeaveHandler = new GuildLeaveHandler();
let commandHandler = new CommandHandler(commands, eventDataService);
let buttonHandler = new ButtonHandler(buttons, eventDataService);
let triggerHandler = new TriggerHandler(triggers, eventDataService);
let messageHandler = new MessageHandler(triggerHandler);
let reactionHandler = new ReactionHandler(reactions, eventDataService);
// Jobs
let jobs: Job[] = [
// TODO: Add new jobs here
];
// Bot
let bot = new Bot(
Config.client.token,
client,
guildJoinHandler,
guildLeaveHandler,
messageHandler,
commandHandler,
buttonHandler,
reactionHandler,
new JobService(jobs)
);
// Register
if (process.argv[2] == 'commands') {
try {
let rest = new REST({ version: '10' }).setToken(Config.client.token);
let commandRegistrationService = new CommandRegistrationService(rest);
let localCmds = [
...Object.values(ChatCommandMetadata).sort((a, b) => (a.name > b.name ? 1 : -1)),
...Object.values(MessageCommandMetadata).sort((a, b) => (a.name > b.name ? 1 : -1)),
...Object.values(UserCommandMetadata).sort((a, b) => (a.name > b.name ? 1 : -1)),
];
await commandRegistrationService.process(localCmds, process.argv);
} catch (error) {
Logger.error(Logs.error.commandAction, error);
}
// Wait for any final logs to be written.
await new Promise(resolve => setTimeout(resolve, 1000));
process.exit();
}
await bot.start();
}
process.on('unhandledRejection', (reason, _promise) => {
Logger.error(Logs.error.unhandledRejection, reason);
});
start().catch(error => {
Logger.error(Logs.error.unspecified, error);
});
-90
View File
@@ -1,90 +0,0 @@
import { ShardingManager } from 'discord.js';
import { createRequire } from 'node:module';
import 'reflect-metadata';
import { GuildsController, RootController, ShardsController } from './controllers/index.js';
import { Job, UpdateServerCountJob } from './jobs/index.js';
import { Api } from './models/api.js';
import { Manager } from './models/manager.js';
import { HttpService, JobService, Logger, MasterApiService } from './services/index.js';
import { MathUtils, ShardUtils } from './utils/index.js';
const require = createRequire(import.meta.url);
let Config = require('../config/config.json');
let Debug = require('../config/debug.json');
let Logs = require('../lang/logs.json');
async function start(): Promise<void> {
Logger.info(Logs.info.appStarted);
// Dependencies
let httpService = new HttpService();
let masterApiService = new MasterApiService(httpService);
if (Config.clustering.enabled) {
await masterApiService.register();
}
// Sharding
let shardList: number[];
let totalShards: number;
try {
if (Config.clustering.enabled) {
let resBody = await masterApiService.login();
shardList = resBody.shardList;
let requiredShards = await ShardUtils.requiredShardCount(Config.client.token);
totalShards = Math.max(requiredShards, resBody.totalShards);
} else {
let recommendedShards = await ShardUtils.recommendedShardCount(
Config.client.token,
Config.sharding.serversPerShard
);
shardList = MathUtils.range(0, recommendedShards);
totalShards = recommendedShards;
}
} catch (error) {
Logger.error(Logs.error.retrieveShards, error);
return;
}
if (shardList.length === 0) {
Logger.warn(Logs.warn.managerNoShards);
return;
}
let shardManager = new ShardingManager('dist/start-bot.js', {
token: Config.client.token,
mode: Debug.override.shardMode.enabled ? Debug.override.shardMode.value : 'process',
respawn: true,
totalShards,
shardList,
});
// Jobs
let jobs: Job[] = [
Config.clustering.enabled ? undefined : new UpdateServerCountJob(shardManager, httpService),
// TODO: Add new jobs here
].filter(Boolean);
let manager = new Manager(shardManager, new JobService(jobs));
// API
let guildsController = new GuildsController(shardManager);
let shardsController = new ShardsController(shardManager);
let rootController = new RootController();
let api = new Api([guildsController, shardsController, rootController]);
// Start
await manager.start();
await api.start();
if (Config.clustering.enabled) {
await masterApiService.ready();
}
}
process.on('unhandledRejection', (reason, _promise) => {
Logger.error(Logs.error.unhandledRejection, reason);
});
start().catch(error => {
Logger.error(Logs.error.unspecified, error);
});
-1
View File
@@ -1 +0,0 @@
export { Trigger } from './trigger.js';
-9
View File
@@ -1,9 +0,0 @@
import { Message } from 'discord.js';
import { EventData } from '../models/internal-models.js';
export interface Trigger {
requireGuild: boolean;
triggered(msg: Message): boolean;
execute(msg: Message, data: EventData): Promise<void>;
}
-247
View File
@@ -1,247 +0,0 @@
import {
ApplicationCommand,
Channel,
Client,
DiscordAPIError,
RESTJSONErrorCodes as DiscordApiErrors,
Guild,
GuildMember,
Locale,
NewsChannel,
Role,
StageChannel,
TextChannel,
User,
VoiceChannel,
} from 'discord.js';
import { PermissionUtils, RegexUtils } from './index.js';
import { Lang } from '../services/index.js';
const FETCH_MEMBER_LIMIT = 20;
const IGNORED_ERRORS = [
DiscordApiErrors.UnknownMessage,
DiscordApiErrors.UnknownChannel,
DiscordApiErrors.UnknownGuild,
DiscordApiErrors.UnknownMember,
DiscordApiErrors.UnknownUser,
DiscordApiErrors.UnknownInteraction,
DiscordApiErrors.MissingAccess,
];
export class ClientUtils {
public static async getGuild(client: Client, discordId: string): Promise<Guild> {
discordId = RegexUtils.discordId(discordId);
if (!discordId) {
return;
}
try {
return await client.guilds.fetch(discordId);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async getChannel(client: Client, discordId: string): Promise<Channel> {
discordId = RegexUtils.discordId(discordId);
if (!discordId) {
return;
}
try {
return await client.channels.fetch(discordId);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async getUser(client: Client, discordId: string): Promise<User> {
discordId = RegexUtils.discordId(discordId);
if (!discordId) {
return;
}
try {
return await client.users.fetch(discordId);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async findAppCommand(client: Client, name: string): Promise<ApplicationCommand> {
let commands = await client.application.commands.fetch();
return commands.find(command => command.name === name);
}
public static async findMember(guild: Guild, input: string): Promise<GuildMember> {
try {
let discordId = RegexUtils.discordId(input);
if (discordId) {
return await guild.members.fetch(discordId);
}
let tag = RegexUtils.tag(input);
if (tag) {
return (
await guild.members.fetch({ query: tag.username, limit: FETCH_MEMBER_LIMIT })
).find(member => member.user.discriminator === tag.discriminator);
}
return (await guild.members.fetch({ query: input, limit: 1 })).first();
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async findRole(guild: Guild, input: string): Promise<Role> {
try {
let discordId = RegexUtils.discordId(input);
if (discordId) {
return await guild.roles.fetch(discordId);
}
let search = input.trim().toLowerCase().replace(/^@/, '');
let roles = await guild.roles.fetch();
return (
roles.find(role => role.name.toLowerCase() === search) ??
roles.find(role => role.name.toLowerCase().includes(search))
);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async findTextChannel(
guild: Guild,
input: string
): Promise<NewsChannel | TextChannel> {
try {
let discordId = RegexUtils.discordId(input);
if (discordId) {
let channel = await guild.channels.fetch(discordId);
if (channel instanceof NewsChannel || channel instanceof TextChannel) {
return channel;
} else {
return;
}
}
let search = input.trim().toLowerCase().replace(/^#/, '').replaceAll(' ', '-');
let channels = [...(await guild.channels.fetch()).values()]
.filter(channel => channel instanceof NewsChannel || channel instanceof TextChannel)
.map(channel => channel as NewsChannel | TextChannel);
return (
channels.find(channel => channel.name.toLowerCase() === search) ??
channels.find(channel => channel.name.toLowerCase().includes(search))
);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async findVoiceChannel(
guild: Guild,
input: string
): Promise<VoiceChannel | StageChannel> {
try {
let discordId = RegexUtils.discordId(input);
if (discordId) {
let channel = await guild.channels.fetch(discordId);
if (channel instanceof VoiceChannel || channel instanceof StageChannel) {
return channel;
} else {
return;
}
}
let search = input.trim().toLowerCase().replace(/^#/, '');
let channels = [...(await guild.channels.fetch()).values()]
.filter(
channel => channel instanceof VoiceChannel || channel instanceof StageChannel
)
.map(channel => channel as VoiceChannel | StageChannel);
return (
channels.find(channel => channel.name.toLowerCase() === search) ??
channels.find(channel => channel.name.toLowerCase().includes(search))
);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async findNotifyChannel(
guild: Guild,
langCode: Locale
): Promise<TextChannel | NewsChannel> {
// Prefer the system channel
let systemChannel = guild.systemChannel;
if (systemChannel && PermissionUtils.canSend(systemChannel, true)) {
return systemChannel;
}
// Otherwise look for a bot channel
return (await guild.channels.fetch()).find(
channel =>
(channel instanceof TextChannel || channel instanceof NewsChannel) &&
PermissionUtils.canSend(channel, true) &&
Lang.getRegex('channelRegexes.bot', langCode).test(channel.name)
) as TextChannel | NewsChannel;
}
}
-73
View File
@@ -1,73 +0,0 @@
import {
CommandInteraction,
GuildChannel,
MessageComponentInteraction,
ModalSubmitInteraction,
ThreadChannel,
} from 'discord.js';
import { FormatUtils, InteractionUtils } from './index.js';
import { Command } from '../commands/index.js';
import { Permission } from '../models/enum-helpers/index.js';
import { EventData } from '../models/internal-models.js';
import { Lang } from '../services/index.js';
export class CommandUtils {
public static findCommand(commands: Command[], commandParts: string[]): Command {
let found = [...commands];
let closestMatch: Command;
for (let [index, commandPart] of commandParts.entries()) {
found = found.filter(command => command.names[index] === commandPart);
if (found.length === 0) {
return closestMatch;
}
if (found.length === 1) {
return found[0];
}
let exactMatch = found.find(command => command.names.length === index + 1);
if (exactMatch) {
closestMatch = exactMatch;
}
}
return closestMatch;
}
public static async runChecks(
command: Command,
intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction,
data: EventData
): Promise<boolean> {
if (command.cooldown) {
let limited = command.cooldown.take(intr.user.id);
if (limited) {
await InteractionUtils.send(
intr,
Lang.getEmbed('validationEmbeds.cooldownHit', data.lang, {
AMOUNT: command.cooldown.amount.toLocaleString(data.lang),
INTERVAL: FormatUtils.duration(command.cooldown.interval, data.lang),
})
);
return false;
}
}
if (
(intr.channel instanceof GuildChannel || intr.channel instanceof ThreadChannel) &&
!intr.channel.permissionsFor(intr.client.user).has(command.requireClientPerms)
) {
await InteractionUtils.send(
intr,
Lang.getEmbed('validationEmbeds.missingClientPerms', data.lang, {
PERMISSIONS: command.requireClientPerms
.map(perm => `**${Permission.Data[perm].displayName(data.lang)}**`)
.join(', '),
})
);
return false;
}
return true;
}
}
-57
View File
@@ -1,57 +0,0 @@
import { ApplicationCommand, Guild, Locale } from 'discord.js';
import { filesize } from 'filesize';
import { Duration } from 'luxon';
export class FormatUtils {
public static roleMention(guild: Guild, discordId: string): string {
if (discordId === '@here') {
return discordId;
}
if (discordId === guild.id) {
return '@everyone';
}
return `<@&${discordId}>`;
}
public static channelMention(discordId: string): string {
return `<#${discordId}>`;
}
public static userMention(discordId: string): string {
return `<@!${discordId}>`;
}
// TODO: Replace with ApplicationCommand#toString() once discord.js #8818 is merged
// https://github.com/discordjs/discord.js/pull/8818
public static commandMention(command: ApplicationCommand, subParts: string[] = []): string {
let name = [command.name, ...subParts].join(' ');
return `</${name}:${command.id}>`;
}
public static duration(milliseconds: number, langCode: Locale): string {
return Duration.fromObject(
Object.fromEntries(
Object.entries(
Duration.fromMillis(milliseconds, { locale: langCode })
.shiftTo(
'year',
'quarter',
'month',
'week',
'day',
'hour',
'minute',
'second'
)
.toObject()
).filter(([_, value]) => !!value) // Remove units that are 0
)
).toHuman({ maximumFractionDigits: 0 });
}
public static fileSize(bytes: number): string {
return filesize(bytes, { output: 'string', pad: true, round: 2 }).toString();
}
}
-13
View File
@@ -1,13 +0,0 @@
export { ClientUtils } from './client-utils.js';
export { CommandUtils } from './command-utils.js';
export { FormatUtils } from './format-utils.js';
export { InteractionUtils } from './interaction-utils.js';
export { MathUtils } from './math-utils.js';
export { MessageUtils } from './message-utils.js';
export { PartialUtils } from './partial-utils.js';
export { PermissionUtils } from './permission-utils.js';
export { RandomUtils } from './random-utils.js';
export { RegexUtils } from './regex-utils.js';
export { ShardUtils } from './shard-utils.js';
export { StringUtils } from './string-utils.js';
export { ThreadUtils } from './thread-utils.js';
-176
View File
@@ -1,176 +0,0 @@
import {
ApplicationCommandOptionChoiceData,
AutocompleteInteraction,
CommandInteraction,
DiscordAPIError,
RESTJSONErrorCodes as DiscordApiErrors,
EmbedBuilder,
InteractionReplyOptions,
InteractionResponse,
InteractionUpdateOptions,
Message,
MessageComponentInteraction,
ModalSubmitInteraction,
WebhookMessageEditOptions,
} from 'discord.js';
const IGNORED_ERRORS = [
DiscordApiErrors.UnknownMessage,
DiscordApiErrors.UnknownChannel,
DiscordApiErrors.UnknownGuild,
DiscordApiErrors.UnknownUser,
DiscordApiErrors.UnknownInteraction,
DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled
DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled
DiscordApiErrors.MaximumActiveThreads,
];
export class InteractionUtils {
public static async deferReply(
intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction,
hidden: boolean = false
): Promise<InteractionResponse> {
try {
return await intr.deferReply({
ephemeral: hidden,
});
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async deferUpdate(
intr: MessageComponentInteraction | ModalSubmitInteraction
): Promise<InteractionResponse> {
try {
return await intr.deferUpdate();
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async send(
intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction,
content: string | EmbedBuilder | InteractionReplyOptions,
hidden: boolean = false
): Promise<Message> {
try {
let options: InteractionReplyOptions =
typeof content === 'string'
? { content }
: content instanceof EmbedBuilder
? { embeds: [content] }
: content;
if (intr.deferred || intr.replied) {
return await intr.followUp({
...options,
ephemeral: hidden,
});
} else {
return await intr.reply({
...options,
ephemeral: hidden,
fetchReply: true,
});
}
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async respond(
intr: AutocompleteInteraction,
choices: ApplicationCommandOptionChoiceData[] = []
): Promise<void> {
try {
return await intr.respond(choices);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async editReply(
intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction,
content: string | EmbedBuilder | WebhookMessageEditOptions
): Promise<Message> {
try {
let options: WebhookMessageEditOptions =
typeof content === 'string'
? { content }
: content instanceof EmbedBuilder
? { embeds: [content] }
: content;
return await intr.editReply(options);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async update(
intr: MessageComponentInteraction,
content: string | EmbedBuilder | InteractionUpdateOptions
): Promise<Message> {
try {
let options: InteractionUpdateOptions =
typeof content === 'string'
? { content }
: content instanceof EmbedBuilder
? { embeds: [content] }
: content;
return await intr.update({
...options,
fetchReply: true,
});
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
export class MathUtils {
public static sum(numbers: number[]): number {
return numbers.reduce((a, b) => a + b, 0);
}
public static clamp(input: number, min: number, max: number): number {
return Math.min(Math.max(input, min), max);
}
public static range(start: number, size: number): number[] {
return [...Array(size).keys()].map(i => i + start);
}
public static ceilToMultiple(input: number, multiple: number): number {
return Math.ceil(input / multiple) * multiple;
}
}
-169
View File
@@ -1,169 +0,0 @@
import {
BaseMessageOptions,
DiscordAPIError,
RESTJSONErrorCodes as DiscordApiErrors,
EmbedBuilder,
EmojiResolvable,
Message,
MessageEditOptions,
MessageReaction,
StartThreadOptions,
TextBasedChannel,
ThreadChannel,
User,
} from 'discord.js';
const IGNORED_ERRORS = [
DiscordApiErrors.UnknownMessage,
DiscordApiErrors.UnknownChannel,
DiscordApiErrors.UnknownGuild,
DiscordApiErrors.UnknownUser,
DiscordApiErrors.UnknownInteraction,
DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled
DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled
DiscordApiErrors.MaximumActiveThreads,
];
export class MessageUtils {
public static async send(
target: User | TextBasedChannel,
content: string | EmbedBuilder | BaseMessageOptions
): Promise<Message> {
try {
let options: BaseMessageOptions =
typeof content === 'string'
? { content }
: content instanceof EmbedBuilder
? { embeds: [content] }
: content;
return await target.send(options);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async reply(
msg: Message,
content: string | EmbedBuilder | BaseMessageOptions
): Promise<Message> {
try {
let options: BaseMessageOptions =
typeof content === 'string'
? { content }
: content instanceof EmbedBuilder
? { embeds: [content] }
: content;
return await msg.reply(options);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async edit(
msg: Message,
content: string | EmbedBuilder | MessageEditOptions
): Promise<Message> {
try {
let options: MessageEditOptions =
typeof content === 'string'
? { content }
: content instanceof EmbedBuilder
? { embeds: [content] }
: content;
return await msg.edit(options);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async react(msg: Message, emoji: EmojiResolvable): Promise<MessageReaction> {
try {
return await msg.react(emoji);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async pin(msg: Message, pinned: boolean = true): Promise<Message> {
try {
return pinned ? await msg.pin() : await msg.unpin();
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async startThread(
msg: Message,
options: StartThreadOptions
): Promise<ThreadChannel> {
try {
return await msg.startThread(options);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async delete(msg: Message): Promise<Message> {
try {
return await msg.delete();
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
}
-88
View File
@@ -1,88 +0,0 @@
import {
DiscordAPIError,
RESTJSONErrorCodes as DiscordApiErrors,
Message,
MessageReaction,
PartialMessage,
PartialMessageReaction,
PartialUser,
User,
} from 'discord.js';
const IGNORED_ERRORS = [
DiscordApiErrors.UnknownMessage,
DiscordApiErrors.UnknownChannel,
DiscordApiErrors.UnknownGuild,
DiscordApiErrors.UnknownUser,
DiscordApiErrors.UnknownInteraction,
DiscordApiErrors.MissingAccess,
];
export class PartialUtils {
public static async fillUser(user: User | PartialUser): Promise<User> {
if (user.partial) {
try {
return await user.fetch();
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
return user as User;
}
public static async fillMessage(msg: Message | PartialMessage): Promise<Message> {
if (msg.partial) {
try {
return await msg.fetch();
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
return msg as Message;
}
public static async fillReaction(
msgReaction: MessageReaction | PartialMessageReaction
): Promise<MessageReaction> {
if (msgReaction.partial) {
try {
msgReaction = await msgReaction.fetch();
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
msgReaction.message = await this.fillMessage(msgReaction.message);
if (!msgReaction.message) {
return;
}
return msgReaction as MessageReaction;
}
}
-127
View File
@@ -1,127 +0,0 @@
import { Channel, DMChannel, GuildChannel, PermissionFlagsBits, ThreadChannel } from 'discord.js';
export class PermissionUtils {
public static canSend(channel: Channel, embedLinks: boolean = false): boolean {
if (channel instanceof DMChannel) {
return true;
} else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) {
let channelPerms = channel.permissionsFor(channel.client.user);
if (!channelPerms) {
// This can happen if the guild disconnected while a collector is running
return false;
}
// VIEW_CHANNEL - Needed to view the channel
// SEND_MESSAGES - Needed to send messages
// EMBED_LINKS - Needed to send embedded links
return channelPerms.has([
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
...(embedLinks ? [PermissionFlagsBits.EmbedLinks] : []),
]);
} else {
return false;
}
}
public static canMention(channel: Channel): boolean {
if (channel instanceof DMChannel) {
return true;
} else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) {
let channelPerms = channel.permissionsFor(channel.client.user);
if (!channelPerms) {
// This can happen if the guild disconnected while a collector is running
return false;
}
// VIEW_CHANNEL - Needed to view the channel
// MENTION_EVERYONE - Needed to mention @everyone, @here, and all roles
return channelPerms.has([
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.MentionEveryone,
]);
} else {
return false;
}
}
public static canReact(channel: Channel, removeOthers: boolean = false): boolean {
if (channel instanceof DMChannel) {
return true;
} else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) {
let channelPerms = channel.permissionsFor(channel.client.user);
if (!channelPerms) {
// This can happen if the guild disconnected while a collector is running
return false;
}
// VIEW_CHANNEL - Needed to view the channel
// ADD_REACTIONS - Needed to add new reactions to messages
// READ_MESSAGE_HISTORY - Needed to add new reactions to messages
// https://discordjs.guide/popular-topics/permissions-extended.html#implicit-permissions
// MANAGE_MESSAGES - Needed to remove others reactions
return channelPerms.has([
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.AddReactions,
PermissionFlagsBits.ReadMessageHistory,
...(removeOthers ? [PermissionFlagsBits.ManageMessages] : []),
]);
} else {
return false;
}
}
public static canPin(channel: Channel, findOld: boolean = false): boolean {
if (channel instanceof DMChannel) {
return true;
} else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) {
let channelPerms = channel.permissionsFor(channel.client.user);
if (!channelPerms) {
// This can happen if the guild disconnected while a collector is running
return false;
}
// VIEW_CHANNEL - Needed to view the channel
// MANAGE_MESSAGES - Needed to pin messages
// READ_MESSAGE_HISTORY - Needed to find old pins
return channelPerms.has([
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ManageMessages,
...(findOld ? [PermissionFlagsBits.ReadMessageHistory] : []),
]);
} else {
return false;
}
}
public static canCreateThreads(
channel: Channel,
manageThreads: boolean = false,
findOld: boolean = false
): boolean {
if (channel instanceof DMChannel) {
return false;
} else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) {
let channelPerms = channel.permissionsFor(channel.client.user);
if (!channelPerms) {
// This can happen if the guild disconnected while a collector is running
return false;
}
// VIEW_CHANNEL - Needed to view the channel
// SEND_MESSAGES_IN_THREADS - Needed to send messages in threads
// CREATE_PUBLIC_THREADS - Needed to create public threads
// MANAGE_THREADS - Needed to rename, delete, archive, unarchive, slow mode threads
// READ_MESSAGE_HISTORY - Needed to find old threads
return channelPerms.has([
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessagesInThreads,
PermissionFlagsBits.CreatePublicThreads,
...(manageThreads ? [PermissionFlagsBits.ManageThreads] : []),
...(findOld ? [PermissionFlagsBits.ReadMessageHistory] : []),
]);
} else {
return false;
}
}
}
-13
View File
@@ -1,13 +0,0 @@
export class RandomUtils {
public static intFromInterval(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1) + min);
}
public static shuffle(input: any[]): any[] {
for (let i = input.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[input[i], input[j]] = [input[j], input[i]];
}
return input;
}
}
-31
View File
@@ -1,31 +0,0 @@
export class RegexUtils {
public static regex(input: string): RegExp {
let match = input.match(/^\/(.*)\/([^/]*)$/);
if (!match) {
return;
}
return new RegExp(match[1], match[2]);
}
public static escapeRegex(input: string): string {
return input?.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
}
public static discordId(input: string): string {
return input?.match(/\b\d{17,20}\b/)?.[0];
}
public static tag(input: string): { username: string; tag: string; discriminator: string } {
let match = input.match(/\b(.+)#([\d]{4})\b/);
if (!match) {
return;
}
return {
tag: match[0],
username: match[1],
discriminator: match[2],
};
}
}
-43
View File
@@ -1,43 +0,0 @@
import { fetchRecommendedShardCount, ShardClientUtil, ShardingManager } from 'discord.js';
import { MathUtils } from './index.js';
import { DiscordLimits } from '../constants/index.js';
export class ShardUtils {
public static async requiredShardCount(token: string): Promise<number> {
return await this.recommendedShardCount(token, DiscordLimits.GUILDS_PER_SHARD);
}
public static async recommendedShardCount(
token: string,
serversPerShard: number
): Promise<number> {
return Math.ceil(
await fetchRecommendedShardCount(token, { guildsPerShard: serversPerShard })
);
}
public static shardIds(shardInterface: ShardingManager | ShardClientUtil): number[] {
if (shardInterface instanceof ShardingManager) {
return shardInterface.shards.map(shard => shard.id);
} else if (shardInterface instanceof ShardClientUtil) {
return shardInterface.ids;
}
}
public static shardId(guildId: number | string, shardCount: number): number {
// See sharding formula:
// https://discord.com/developers/docs/topics/gateway#sharding-sharding-formula
// tslint:disable-next-line:no-bitwise
return Number((BigInt(guildId) >> 22n) % BigInt(shardCount));
}
public static async serverCount(
shardInterface: ShardingManager | ShardClientUtil
): Promise<number> {
let shardGuildCounts = (await shardInterface.fetchClientValues(
'guilds.cache.size'
)) as number[];
return MathUtils.sum(shardGuildCounts);
}
}
-37
View File
@@ -1,37 +0,0 @@
import { escapeMarkdown } from 'discord.js';
import removeMarkdown from 'remove-markdown';
export class StringUtils {
public static truncate(input: string, length: number, addEllipsis: boolean = false): string {
if (input.length <= length) {
return input;
}
let output = input.substring(0, addEllipsis ? length - 3 : length);
if (addEllipsis) {
output += '...';
}
return output;
}
public static escapeMarkdown(input: string): string {
return (
escapeMarkdown(input)
// Unescapes custom emojis
// TODO: Update once discord.js update their escapeMarkdown()
// See https://github.com/discordjs/discord.js/issues/8943
.replaceAll(
/<(a?):(\S+):(\d{17,20})>/g,
(_match, animatedPrefix, emojiName, emojiId) => {
let emojiNameUnescaped = emojiName.replaceAll(/\\/g, '');
return `<${animatedPrefix}:${emojiNameUnescaped}:${emojiId}>`;
}
)
);
}
public static stripMarkdown(input: string): string {
return removeMarkdown(input);
}
}
-52
View File
@@ -1,52 +0,0 @@
import { DiscordAPIError, RESTJSONErrorCodes as DiscordApiErrors, ThreadChannel } from 'discord.js';
const IGNORED_ERRORS = [
DiscordApiErrors.UnknownMessage,
DiscordApiErrors.UnknownChannel,
DiscordApiErrors.UnknownGuild,
DiscordApiErrors.UnknownUser,
DiscordApiErrors.UnknownInteraction,
DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled
DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled
DiscordApiErrors.MaximumActiveThreads,
];
export class ThreadUtils {
public static async archive(
thread: ThreadChannel,
archived: boolean = true
): Promise<ThreadChannel> {
try {
return await thread.setArchived(archived);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
public static async lock(
thread: ThreadChannel,
locked: boolean = true
): Promise<ThreadChannel> {
try {
return await thread.setLocked(locked);
} catch (error) {
if (
error instanceof DiscordAPIError &&
typeof error.code == 'number' &&
IGNORED_ERRORS.includes(error.code)
) {
return;
} else {
throw error;
}
}
}
}