Added guild subcommand type and various changes

This commit is contained in:
WatDuhHekBro 2021-04-10 07:51:32 -05:00
parent 54ce28d8d4
commit e8def0aec3
15 changed files with 113 additions and 89 deletions

View File

@ -9,6 +9,7 @@
- `urban`: Bug fixes
- Changed `help` to display a paginated embed
- Various changes to core
- Added `guild` subcommand type (only accessible when `id` is set to `guild`)
# 3.2.0 - Internal refactor, more subcommand types, and more command type guards (2021-04-09)
- The custom logger changed: `$.log` no longer exists, it's just `console.log`. Now you don't have to do `import $ from "../core/lib"` at the top of every file that uses the custom logger.

View File

@ -71,6 +71,10 @@ Boolean subcommand types won't be implemented:
For common use cases, there wouldn't be a need to go accept numbers of different bases. The only time it would be applicable is if there was some sort of base converter command, and even then, it'd be better to just implement custom logic.
## User Mention + Search by Username Type
While it's a pretty common pattern, it's probably a bit too specific for the `Command` class itself. Instead, this pattern will be comprised of two subcommands: A `user` type and an `any` type.
# The Command Handler
## The Scope of the Command Handler

View File

@ -34,7 +34,7 @@ export const ShopCommand = new NamedCommand({
const shopPages = split(ShopItems, 5);
const pageAmount = shopPages.length;
paginate(channel, author.id, pageAmount, (page, hasMultiplePages) => {
paginate(channel.send, author.id, pageAmount, (page, hasMultiplePages) => {
return getShopEmbed(
shopPages[page],
hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop"

View File

@ -20,7 +20,7 @@ export default new NamedCommand({
const commands = await getCommandList();
const categoryArray = commands.keyArray();
paginate(channel, author.id, categoryArray.length, (page, hasMultiplePages) => {
paginate(channel.send, author.id, categoryArray.length, (page, hasMultiplePages) => {
const category = categoryArray[page];
const commandList = commands.get(category)!;
let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\`\n`;

View File

@ -1,7 +1,7 @@
import {MessageEmbed, version as djsversion, Guild, User, GuildMember} from "discord.js";
import ms from "ms";
import os from "os";
import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE} from "../../core";
import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, getGuildByName} from "../../core";
import {formatBytes, trimArray} from "../../lib";
import {verificationLevels, filterLevels, regions} from "../../defs/info";
import moment, {utc} from "moment";
@ -98,30 +98,23 @@ export default new NamedCommand({
async run({message, channel, guild, author, member, client, args}) {
channel.send(await getGuildInfo(guild!, guild));
},
any: new Command({
description: "Display info about a guild by finding its name or ID.",
id: "guild",
guild: new Command({
description: "Display info about a guild by its ID.",
async run({message, channel, guild, author, member, client, args}) {
// If a guild ID is provided (avoid the "number" subcommand because of inaccuracies), search for that guild
if (args.length === 1 && /^\d{17,}$/.test(args[0])) {
const id = args[0];
const targetGuild = client.guilds.cache.get(id);
const targetGuild = args[0] as Guild;
channel.send(await getGuildInfo(targetGuild, guild));
}
}),
any: new Command({
description: "Display info about a guild by finding its name.",
async run({message, channel, guild, author, member, client, args}) {
const targetGuild = getGuildByName(args.join(" "));
if (targetGuild) {
channel.send(await getGuildInfo(targetGuild, guild));
} else {
channel.send(`None of the servers I'm in matches the guild ID \`${id}\`!`);
}
if (targetGuild instanceof Guild) {
channel.send(await getGuildInfo(targetGuild, guild));
} else {
const query: string = args.join(" ").toLowerCase();
const targetGuild = client.guilds.cache.find((guild) =>
guild.name.toLowerCase().includes(query)
);
if (targetGuild) {
channel.send(await getGuildInfo(targetGuild, guild));
} else {
channel.send(`None of the servers I'm in matches the query \`${query}\`!`);
}
channel.send(targetGuild);
}
}
})

View File

@ -35,7 +35,6 @@ export default new NamedCommand({
let emoteCollection = client.emojis.cache.array();
// Creates a sandbox to stop a regular expression if it takes too much time to search.
// To avoid passing in a giant data structure, I'll just pass in the structure {[id: string]: [name: string]}.
//let emotes: {[id: string]: string} = {};
let emotes = new Map<string, string>();
for (const emote of emoteCollection) {
@ -91,7 +90,7 @@ async function displayEmoteList(emotes: GuildEmoji[], channel: TextChannel | DMC
// Gather the first page (if it even exists, which it might not if there no valid emotes appear)
if (pages > 0) {
paginate(channel, author.id, pages, (page, hasMultiplePages) => {
paginate(channel.send, author.id, pages, (page, hasMultiplePages) => {
embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**");
let desc = "";

View File

@ -3,7 +3,7 @@ import {pluralise} from "../../lib";
import moment from "moment";
import {Collection, TextChannel} from "discord.js";
const lastUsedTimestamps: {[id: string]: number} = {};
const lastUsedTimestamps = new Collection<string, number>();
export default new NamedCommand({
description:
@ -13,7 +13,7 @@ export default new NamedCommand({
// Test if the command is on cooldown. This isn't the strictest cooldown possible, because in the event that the bot crashes, the cooldown will be reset. But for all intends and purposes, it's a good enough cooldown. It's a per-server cooldown.
const startTime = Date.now();
const cooldown = 86400000; // 24 hours
const lastUsedTimestamp = lastUsedTimestamps[guild!.id] ?? 0;
const lastUsedTimestamp = lastUsedTimestamps.get(guild!.id) ?? 0;
const difference = startTime - lastUsedTimestamp;
const howLong = moment(startTime).to(lastUsedTimestamp + cooldown);
@ -22,7 +22,7 @@ export default new NamedCommand({
return channel.send(
`This command requires a day to cooldown. You'll be able to activate this command ${howLong}.`
);
else lastUsedTimestamps[guild!.id] = startTime;
else lastUsedTimestamps.set(guild!.id, startTime);
const stats: {
[id: string]: {
@ -188,7 +188,7 @@ export default new NamedCommand({
description: "Forces the cooldown timer to reset.",
permission: PERMISSIONS.BOT_SUPPORT,
async run({message, channel, guild, author, member, client, args}) {
lastUsedTimestamps[guild!.id] = 0;
lastUsedTimestamps.set(guild!.id, 0);
channel.send("Reset the cooldown on `scanemotes`.");
}
})

View File

@ -11,7 +11,7 @@ import {
GuildChannel,
Channel
} from "discord.js";
import {getChannelByID, getMessageByID, getUserByID, SingleMessageOptions} from "./libd";
import {getChannelByID, getGuildByID, getMessageByID, getUserByID, SingleMessageOptions, SendFunction} from "./libd";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
import {getPrefix} from "./interface";
import {parseVars, requireAllCasesHandledFor} from "../lib";
@ -44,7 +44,7 @@ const patterns = {
};
// Maybe add a guild redirect... somehow?
type ID = "channel" | "role" | "emote" | "message" | "user";
type ID = "channel" | "role" | "emote" | "message" | "user" | "guild";
// Callbacks don't work with discriminated unions:
// - https://github.com/microsoft/TypeScript/issues/41759
@ -68,6 +68,7 @@ interface CommandMenu {
// According to the documentation, a message can be part of a guild while also not having a
// member object for the author. This will happen if the author of a message left the guild.
readonly member: GuildMember | null;
readonly send: SendFunction;
}
interface CommandOptionsBase {
@ -95,6 +96,7 @@ interface CommandOptionsNonEndpoint {
readonly emote?: Command;
readonly message?: Command;
readonly user?: Command;
readonly guild?: Command; // Only available if an ID is set to reroute to it.
readonly id?: ID;
readonly number?: Command;
readonly any?: Command;
@ -156,6 +158,7 @@ export class Command {
private emote: Command | null;
private message: Command | null;
private user: Command | null;
private guild: Command | null;
private id: Command | null;
private idType: ID | null;
private number: Command | null;
@ -175,6 +178,7 @@ export class Command {
this.emote = null;
this.message = null;
this.user = null;
this.guild = null;
this.id = null;
this.idType = null;
this.number = null;
@ -186,6 +190,7 @@ export class Command {
if (options?.emote) this.emote = options.emote;
if (options?.message) this.message = options.message;
if (options?.user) this.user = options.user;
if (options?.guild) this.guild = options.guild;
if (options?.number) this.number = options.number;
if (options?.any) this.any = options.any;
if (options?.id) this.idType = options.id;
@ -207,6 +212,9 @@ export class Command {
case "user":
this.id = this.user;
break;
case "guild":
this.id = this.guild;
break;
default:
requireAllCasesHandledFor(options.id);
}
@ -246,6 +254,9 @@ export class Command {
//
// Calls the resulting subcommand's execute method in order to make more modular code, basically pushing the chain of execution to the subcommand.
// For example, a numeric subcommand would accept args of [4] then execute on it.
//
// Because each Command instance is isolated from others, it becomes practically impossible to predict the total amount of subcommands when isolating the code to handle each individual layer of recursion.
// Therefore, if a Command is declared as a rest type, any typed args that come at the end must be handled manually.
public async execute(
args: string[],
menu: CommandMenu,
@ -300,7 +311,7 @@ export class Command {
if (typeof this.run === "string") {
// Although I *could* add an option in the launcher to attach arbitrary variables to this var string...
// I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string.
await menu.channel.send(
await menu.send(
parseVars(
this.run,
{
@ -490,6 +501,15 @@ export class Command {
} else {
return user;
}
case "guild":
const guild = getGuildByID(id);
if (guild instanceof Guild) {
menu.args.push(guild);
return this.id.execute(args, menu, metadata);
} else {
return guild;
}
default:
requireAllCasesHandledFor(this.idType);
}

View File

@ -37,6 +37,7 @@ export function attachMessageHandlerToClient(client: Client) {
const commands = await loadableCommands;
const {author, channel, content, guild, member} = message;
const send = channel.send.bind(channel);
const text = content;
const menu = {
author,
@ -45,7 +46,8 @@ export function attachMessageHandlerToClient(client: Client) {
guild,
member,
message,
args: []
args: [],
send
};
// Execute a dedicated block for messages in DM channels.
@ -70,10 +72,10 @@ export function attachMessageHandlerToClient(client: Client) {
// If something went wrong, let the user know (like if they don't have permission to use a command).
if (result) {
channel.send(result);
send(result);
}
} else {
channel.send(
send(
`I couldn't find the command or alias that starts with \`${header}\`. To see the list of commands, type \`help\``
);
}
@ -84,7 +86,7 @@ export function attachMessageHandlerToClient(client: Client) {
// First, test if the message is just a ping to the bot.
if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) {
channel.send(`${author}, my prefix on this server is \`${prefix}\`.`);
send(`${author}, my prefix on this server is \`${prefix}\`.`);
}
// Then check if it's a normal command.
else if (text.startsWith(prefix)) {
@ -107,7 +109,7 @@ export function attachMessageHandlerToClient(client: Client) {
// If something went wrong, let the user know (like if they don't have permission to use a command).
if (result) {
channel.send(result);
send(result);
}
}
}

View File

@ -10,13 +10,27 @@ import {
MessageOptions,
Channel,
GuildChannel,
User
User,
APIMessageContentResolvable,
MessageAdditions,
SplitOptions,
APIMessage,
StringResolvable
} from "discord.js";
import {unreactEventListeners, replyEventListeners} from "./eventListeners";
import {client} from "./interface";
export type SingleMessageOptions = MessageOptions & {split?: false};
export type SendFunction = ((
content: APIMessageContentResolvable | (MessageOptions & {split?: false}) | MessageAdditions
) => Promise<Message>) &
((options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) &
((options: MessageOptions | APIMessage) => Promise<Message | Message[]>) &
((content: StringResolvable, options: (MessageOptions & {split?: false}) | MessageAdditions) => Promise<Message>) &
((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) &
((content: StringResolvable, options: MessageOptions) => Promise<Message | Message[]>);
/**
* Tests if a bot has a certain permission in a specified guild.
*/
@ -41,14 +55,14 @@ const FIVE_FORWARDS_EMOJI = "⏩";
* Takes a message and some additional parameters and makes a reaction page with it. All the pagination logic is taken care of but nothing more, the page index is returned and you have to send a callback to do something with it.
*/
export async function paginate(
channel: TextChannel | DMChannel | NewsChannel,
send: SendFunction,
senderID: string,
total: number,
callback: (page: number, hasMultiplePages: boolean) => SingleMessageOptions,
duration = 60000
) {
const hasMultiplePages = total > 1;
const message = await channel.send(callback(0, hasMultiplePages));
const message = await send(callback(0, hasMultiplePages));
if (hasMultiplePages) {
let page = 0;

View File

@ -71,7 +71,7 @@ export async function loadCommands(commandsDir: string): Promise<Collection<stri
console.warn(`Command "${commandID}" has no default export which is a NamedCommand instance!`);
}
} catch (error) {
console.log(error);
console.error(error);
}
}

View File

@ -1,6 +1,6 @@
// Flags a user can have.
// They're basically your profile badges.
export const flags: {[index: string]: any} = {
export const flags: {[index: string]: string} = {
DISCORD_EMPLOYEE: "Discord Employee",
DISCORD_PARTNER: "Discord Partner",
BUGHUNTER_LEVEL_1: "Bug Hunter (Level 1)",
@ -16,13 +16,13 @@ export const flags: {[index: string]: any} = {
VERIFIED_DEVELOPER: "Verified Bot Developer"
};
export const filterLevels: {[index: string]: any} = {
export const filterLevels: {[index: string]: string} = {
DISABLED: "Off",
MEMBERS_WITHOUT_ROLES: "No Role",
ALL_MEMBERS: "Everyone"
};
export const verificationLevels: {[index: string]: any} = {
export const verificationLevels: {[index: string]: string} = {
NONE: "None",
LOW: "Low",
MEDIUM: "Medium",
@ -30,7 +30,7 @@ export const verificationLevels: {[index: string]: any} = {
VERY_HIGH: "┻━┻ ミヽ(ಠ益ಠ)ノ彡┻━┻"
};
export const regions: {[index: string]: any} = {
export const regions: {[index: string]: string} = {
brazil: "Brazil",
europe: "Europe",
hongkong: "Hong Kong",

View File

@ -1,7 +1,7 @@
import {client} from "../index";
import {TextChannel, APIMessage, MessageEmbed} from "discord.js";
import {Message, MessageEmbed} from "discord.js";
import {getPrefix} from "../structures";
import {DiscordAPIError} from "discord.js";
import {getMessageByID} from "../core";
client.on("message", async (message) => {
// Only execute if the message is from a user and isn't a command.
@ -10,49 +10,38 @@ client.on("message", async (message) => {
if (!messageLink) return;
const [guildID, channelID, messageID] = messageLink;
try {
const channel = client.guilds.cache.get(guildID)?.channels.cache.get(channelID) as TextChannel;
const link_message = await channel.messages.fetch(messageID);
const linkMessage = await getMessageByID(channelID, messageID);
let rtmsg: string | APIMessage = "";
if (link_message.cleanContent) {
rtmsg = new APIMessage(message.channel as TextChannel, {
content: link_message.cleanContent,
disableMentions: "all",
files: link_message.attachments.array()
});
}
const embeds = [...link_message.embeds.filter((v) => v.type == "rich"), ...link_message.attachments.values()];
/// @ts-ignore
if (!link_message.cleanContent && embeds.empty) {
const Embed = new MessageEmbed().setDescription("🚫 The message is empty.");
return message.channel.send(Embed);
}
const infoEmbed = new MessageEmbed()
.setAuthor(
link_message.author.username,
link_message.author.displayAvatarURL({format: "png", dynamic: true, size: 4096})
)
.setTimestamp(link_message.createdTimestamp)
.setDescription(
`${link_message.cleanContent}\n\nSent in **${link_message.guild?.name}** | <#${link_message.channel.id}> ([link](https://discord.com/channels/${guildID}/${channelID}/${messageID}))`
);
if (link_message.attachments.size !== 0) {
const image = link_message.attachments.first();
/// @ts-ignore
infoEmbed.setImage(image.url);
}
await message.channel.send(infoEmbed);
} catch (error) {
if (error instanceof DiscordAPIError) {
message.channel.send("I don't have access to this channel, or something else went wrong.");
}
return console.error(error);
// If it's an invalid link (or the bot doesn't have access to it).
if (!(linkMessage instanceof Message)) {
return message.channel.send("I don't have access to that channel!");
}
const embeds = [
...linkMessage.embeds.filter((embed) => embed.type === "rich"),
...linkMessage.attachments.values()
];
if (!linkMessage.cleanContent && embeds.length === 0) {
return message.channel.send(new MessageEmbed().setDescription("🚫 The message is empty."));
}
const infoEmbed = new MessageEmbed()
.setAuthor(
linkMessage.author.username,
linkMessage.author.displayAvatarURL({format: "png", dynamic: true, size: 4096})
)
.setTimestamp(linkMessage.createdTimestamp)
.setDescription(
`${linkMessage.cleanContent}\n\nSent in **${linkMessage.guild?.name}** | <#${linkMessage.channel.id}> ([link](https://discord.com/channels/${guildID}/${channelID}/${messageID}))`
);
if (linkMessage.attachments.size !== 0) {
const image = linkMessage.attachments.first();
infoEmbed.setImage(image!.url);
}
return await message.channel.send(infoEmbed);
});
export function extractFirstMessageLink(message: string): [string, string, string] | null {

View File

@ -47,6 +47,7 @@ client.on("voiceStateUpdate", async (before, after) => {
const voiceChannel = after.channel!;
const textChannel = client.channels.cache.get(streamingChannel);
// Although checking the bot's permission to send might seem like a good idea, having the error be thrown will cause it to show up in the last channel rather than just show up in the console.
if (textChannel instanceof TextChannel) {
if (isStartStreamEvent) {
streamList.set(member.id, {

View File

@ -5,6 +5,7 @@ import {watch} from "fs";
import {Guild as DiscordGuild, Snowflake} from "discord.js";
// Maybe use getters and setters to auto-save on set?
// And maybe use Collections/Maps instead of objects?
class ConfigStructure extends GenericStructure {
public token: string;