diff --git a/CHANGELOG.md b/CHANGELOG.md index cefffe1..3f52e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 4a2bbd5..584396c 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -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 diff --git a/src/commands/fun/modules/eco-shop.ts b/src/commands/fun/modules/eco-shop.ts index 47d0178..ca161f1 100644 --- a/src/commands/fun/modules/eco-shop.ts +++ b/src/commands/fun/modules/eco-shop.ts @@ -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" diff --git a/src/commands/system/help.ts b/src/commands/system/help.ts index 180819d..c6ec382 100644 --- a/src/commands/system/help.ts +++ b/src/commands/system/help.ts @@ -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: \`\`, \`[list/of/stuff]\`, \`(optional)\`, \`()\`, \`([optional/list/...])\`\n`; diff --git a/src/commands/utility/info.ts b/src/commands/utility/info.ts index a47e549..f3d5bb3 100644 --- a/src/commands/utility/info.ts +++ b/src/commands/utility/info.ts @@ -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); } } }) diff --git a/src/commands/utility/lsemotes.ts b/src/commands/utility/lsemotes.ts index fef7813..0d858bc 100644 --- a/src/commands/utility/lsemotes.ts +++ b/src/commands/utility/lsemotes.ts @@ -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(); 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 = ""; diff --git a/src/commands/utility/scanemotes.ts b/src/commands/utility/scanemotes.ts index 3662c57..a432147 100644 --- a/src/commands/utility/scanemotes.ts +++ b/src/commands/utility/scanemotes.ts @@ -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(); 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`."); } }) diff --git a/src/core/command.ts b/src/core/command.ts index 0e7e5e4..d38d78a 100644 --- a/src/core/command.ts +++ b/src/core/command.ts @@ -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); } diff --git a/src/core/handler.ts b/src/core/handler.ts index 560ca77..52fdc80 100644 --- a/src/core/handler.ts +++ b/src/core/handler.ts @@ -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); } } } diff --git a/src/core/libd.ts b/src/core/libd.ts index 45bf919..c9d5bcf 100644 --- a/src/core/libd.ts +++ b/src/core/libd.ts @@ -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) & + ((options: MessageOptions & {split: true | SplitOptions}) => Promise) & + ((options: MessageOptions | APIMessage) => Promise) & + ((content: StringResolvable, options: (MessageOptions & {split?: false}) | MessageAdditions) => Promise) & + ((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise) & + ((content: StringResolvable, options: MessageOptions) => Promise); + /** * 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; diff --git a/src/core/loader.ts b/src/core/loader.ts index da22a4a..cabb0fd 100644 --- a/src/core/loader.ts +++ b/src/core/loader.ts @@ -71,7 +71,7 @@ export async function loadCommands(commandsDir: string): Promise { // 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 { diff --git a/src/modules/streamNotifications.ts b/src/modules/streamNotifications.ts index f6e2843..b6e9fef 100644 --- a/src/modules/streamNotifications.ts +++ b/src/modules/streamNotifications.ts @@ -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, { diff --git a/src/structures.ts b/src/structures.ts index f9065b9..8991e3f 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -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;