mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 18:57:03 +02:00
Initial commit
This commit is contained in:
110 files changed
+11734
No files matched your search
@@ -0,0 +1,17 @@
|
||||
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',
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Button, ButtonDeferType } from './button.js';
|
||||
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { HelpCommand } from './help-command.js';
|
||||
export { InfoCommand } from './info-command.js';
|
||||
export { TestCommand } from './test-command.js';
|
||||
@@ -0,0 +1,117 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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',
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Args } from './args.js';
|
||||
export { Command, CommandDeferType } from './command.js';
|
||||
export { ChatCommandMetadata, MessageCommandMetadata, UserCommandMetadata } from './metadata.js';
|
||||
@@ -0,0 +1 @@
|
||||
export { ViewDateSent } from './view-date-sent.js';
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ViewDateJoined } from './view-date-joined.js';
|
||||
@@ -0,0 +1,32 @@
|
||||
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),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DiscordLimits } from './discord-limits.js';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
export interface Controller {
|
||||
path: string;
|
||||
router: Router;
|
||||
authToken?: string;
|
||||
register(): void;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Controller } from './controller.js';
|
||||
export { GuildsController } from './guilds-controller.js';
|
||||
export { ShardsController } from './shards-controller.js';
|
||||
export { RootController } from './root-controller.js';
|
||||
@@ -0,0 +1,17 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum HelpOption {
|
||||
CONTACT_SUPPORT = 'CONTACT_SUPPORT',
|
||||
COMMANDS = 'COMMANDS',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { HelpOption } from './help-option.js';
|
||||
export { InfoOption } from './info-option.js';
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum InfoOption {
|
||||
ABOUT = 'ABOUT',
|
||||
TRANSLATE = 'TRANSLATE',
|
||||
DEV = 'DEV',
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface EventHandler {
|
||||
process(...args: any[]): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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';
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomClient } from './custom-client.js';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Job } from './job.js';
|
||||
export { UpdateServerCountJob } from './update-server-count-job.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Job {
|
||||
name: string;
|
||||
log: boolean;
|
||||
schedule: string;
|
||||
run(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
export function checkAuth(token: string): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
if (req.headers.authorization !== token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { checkAuth } from './check-auth.js';
|
||||
export { handleError } from './handle-error.js';
|
||||
export { mapClass } from './map-class.js';
|
||||
@@ -0,0 +1,40 @@
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GetGuildsResponse {
|
||||
guilds: string[];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GetGuildsResponse } from './guilds.js';
|
||||
export { GetShardsResponse, ShardInfo, ShardStats, SetShardPresencesRequest } from './shards.js';
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface BotSite {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
authorization: string;
|
||||
body: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Language } from './language.js';
|
||||
export { Permission } from './permission.js';
|
||||
@@ -0,0 +1,110 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
RegisterClusterRequest,
|
||||
RegisterClusterResponse,
|
||||
LoginClusterResponse,
|
||||
} from './clusters.js';
|
||||
@@ -0,0 +1 @@
|
||||
export { Reaction } from './reaction.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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';
|
||||
@@ -0,0 +1,37 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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')),
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { Trigger } from './trigger.js';
|
||||
@@ -0,0 +1,9 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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';
|
||||
@@ -0,0 +1,176 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user