From 736070d615288c017ae77dd17e6465fe43429066 Mon Sep 17 00:00:00 2001 From: WatDuhHekBro Date: Thu, 6 May 2021 08:30:51 -0500 Subject: [PATCH] Began reworking the say command --- CHANGELOG.md | 11 ++++ data/public/.gitkeep | 0 package-lock.json | 4 +- package.json | 2 +- src/commands/fun/modules/eco-core.ts | 5 +- src/commands/fun/poll.ts | 2 +- src/commands/system/admin.ts | 12 +++- src/commands/system/webhook.ts | 34 ++++++++++ src/commands/utility/emote.ts | 4 +- src/commands/utility/modules/emote-utils.ts | 59 ++++++++--------- src/commands/utility/react.ts | 5 +- src/commands/utility/say.ts | 68 ++++++++++++++++++-- src/lib.test.ts | 11 +++- src/lib.ts | 34 +++++++++- src/modules/emoteRegistry.ts | 1 + src/modules/webhookStorageManager.ts | 70 +++++++++++++++++++++ src/structures.ts | 10 +++ 17 files changed, 282 insertions(+), 50 deletions(-) delete mode 100644 data/public/.gitkeep create mode 100644 src/commands/system/webhook.ts create mode 100644 src/modules/webhookStorageManager.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 879eb14..ac84942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# 3.2.3 +- Fixed `info guild` bug on servers without an icon +- Added non-pinging mention to `whois` +- Moved location of emote registry +- Added command to set default VC name +- Added pat shop item +- Reworked `say` command making use of webhooks to replicate ac2pic's Nitroless idea (Part 1) +- Fixed `poll` duration +- Fixed `eco pay` user searching +- Fixed `admin set welcome type none` + # 3.2.2 - Moved command handler code to [Onion Lasers](https://github.com/WatDuhHekBro/OnionLasers) - Reworked `poll` diff --git a/data/public/.gitkeep b/data/public/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/package-lock.json b/package-lock.json index 68e4b12..502e24a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "travebot", - "version": "3.2.2", + "version": "3.2.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "travebot", - "version": "3.2.2", + "version": "3.2.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index bf7cee3..4434e7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "travebot", - "version": "3.2.2", + "version": "3.2.3", "description": "TravBot Discord bot.", "main": "dist/index.js", "scripts": { diff --git a/src/commands/fun/modules/eco-core.ts b/src/commands/fun/modules/eco-core.ts index e3432b2..a1fff4f 100644 --- a/src/commands/fun/modules/eco-core.ts +++ b/src/commands/fun/modules/eco-core.ts @@ -141,7 +141,7 @@ export const PayCommand = new NamedCommand({ run: "You must use the format `eco pay `!" }), any: new RestCommand({ - async run({send, args, author, channel, guild, combined}) { + async run({send, args, author, channel, guild}) { if (isAuthorized(guild, channel)) { const last = args.pop(); @@ -156,7 +156,8 @@ export const PayCommand = new NamedCommand({ else if (!guild) return send("You have to use this in a server if you want to send Mons with a username!"); - const user = await getUserByNickname(combined, guild); + // Do NOT use the combined parameter here, it won't account for args.pop() at the start. + const user = await getUserByNickname(args.join(" "), guild); if (typeof user === "string") return send(user); else if (user.id === author.id) return send("You can't send Mons to yourself!"); else if (user.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!"); diff --git a/src/commands/fun/poll.ts b/src/commands/fun/poll.ts index 3224d08..5cfd446 100644 --- a/src/commands/fun/poll.ts +++ b/src/commands/fun/poll.ts @@ -12,7 +12,7 @@ export default new NamedCommand({ any: new RestCommand({ description: "Question for the poll.", async run({send, message, author, args, combined}) { - execPoll(send, message, author, combined, args[0]); + execPoll(send, message, author, combined, args[0] * 1000); } }) }), diff --git a/src/commands/system/admin.ts b/src/commands/system/admin.ts index ba953a7..d78f2ff 100644 --- a/src/commands/system/admin.ts +++ b/src/commands/system/admin.ts @@ -89,6 +89,13 @@ export default new NamedCommand({ Storage.save(); send("Set this server's welcome type to `graphical`."); } + }), + none: new NamedCommand({ + async run({send, guild}) { + Storage.getGuild(guild!.id).welcomeType = "none"; + Storage.save(); + send("Set this server's welcome type to `none`."); + } }) } }), @@ -331,10 +338,9 @@ export default new NamedCommand({ channelType: CHANNEL_TYPE.GUILD, run: "You have to specify a nickname to set for the bot", any: new RestCommand({ - async run({send, message, guild, combined}) { + async run({send, guild, combined}) { await guild!.me?.setNickname(combined); - if (guild!.me?.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete({timeout: 5000}); - send(`Nickname set to \`${combined}\``).then((m) => m.delete({timeout: 5000})); + send(`Nickname set to \`${combined}\``); } }) }), diff --git a/src/commands/system/webhook.ts b/src/commands/system/webhook.ts new file mode 100644 index 0000000..b4f505c --- /dev/null +++ b/src/commands/system/webhook.ts @@ -0,0 +1,34 @@ +import {CHANNEL_TYPE, Command, NamedCommand} from "onion-lasers"; +import {registerWebhook, deleteWebhook} from "../../modules/webhookStorageManager"; + +// Because adding webhooks involves sending tokens, you'll want to prevent this from being used in non-private contexts. +export default new NamedCommand({ + channelType: CHANNEL_TYPE.DM, + description: "Manage webhooks stored by the bot.", + usage: "register/delete ", + run: "You need to use `register`/`delete`.", + subcommands: { + register: new NamedCommand({ + description: "Adds a webhook to the bot's storage.", + any: new Command({ + async run({send, args}) { + if (registerWebhook(args[0])) { + send("Registered webhook with bot."); + } else { + send("Invalid webhook URL."); + } + } + }) + }), + delete: new NamedCommand({ + description: "Removes a webhook from the bot's storage.", + any: new Command({ + async run({send, args}) { + if (deleteWebhook(args[0])) { + send("Deleted webhook."); + } else send("Invalid webhook URL/ID."); + } + }) + }) + } +}); diff --git a/src/commands/utility/emote.ts b/src/commands/utility/emote.ts index c312316..824868c 100644 --- a/src/commands/utility/emote.ts +++ b/src/commands/utility/emote.ts @@ -1,5 +1,5 @@ import {NamedCommand, RestCommand} from "onion-lasers"; -import {processEmoteQueryFormatted} from "./modules/emote-utils"; +import {processEmoteQuery} from "./modules/emote-utils"; export default new NamedCommand({ description: @@ -9,7 +9,7 @@ export default new NamedCommand({ description: "The emote(s) to send.", usage: "", async run({send, args}) { - const output = processEmoteQueryFormatted(args); + const output = processEmoteQuery(args, true).join(""); if (output.length > 0) send(output); } }) diff --git a/src/commands/utility/modules/emote-utils.ts b/src/commands/utility/modules/emote-utils.ts index 2c6df68..4f08921 100644 --- a/src/commands/utility/modules/emote-utils.ts +++ b/src/commands/utility/modules/emote-utils.ts @@ -65,7 +65,35 @@ const unicodeEmojiRegex = /^(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud const discordEmoteMentionRegex = /^$/; const emoteNameWithSelectorRegex = /^(.+)~(\d+)$/; -function processEmoteQuery(query: string[], isFormatted: boolean): string[] { +export function searchNearestEmote(query: string, additionalEmotes?: GuildEmoji[]): string { + // Selector number used for disambiguating multiple emotes with same name. + let selector = 0; + + // If the query has emoteName~123 format, extract the actual name and the selector number. + const queryWithSelector = query.match(emoteNameWithSelectorRegex); + if (queryWithSelector) { + query = queryWithSelector[1]; + selector = +queryWithSelector[2]; + } + + // Try to match an emote name directly if the selector is for the closest match. + if (selector == 0) { + const directMatchEmote = client.emojis.cache.find((em) => em.name === query); + if (directMatchEmote) return directMatchEmote.toString(); + } + + // Find all similar emote candidates within certian threshold and select Nth top one according to the selector. + const similarEmotes = searchSimilarEmotes(query); + if (similarEmotes.length > 0) { + selector = Math.min(selector, similarEmotes.length - 1); + return similarEmotes[selector].toString(); + } + + // Return some "missing/invalid emote" indicator. + return "❓"; +} + +export function processEmoteQuery(query: string[], isFormatted: boolean): string[] { return query.map((emote) => { emote = emote.trim(); @@ -79,33 +107,6 @@ function processEmoteQuery(query: string[], isFormatted: boolean): string[] { if (emote == "_") return "\u200b"; } - // Selector number used for disambiguating multiple emotes with same name. - let selector = 0; - - // If the query has emoteName~123 format, extract the actual name and the selector number. - const queryWithSelector = emote.match(emoteNameWithSelectorRegex); - if (queryWithSelector) { - emote = queryWithSelector[1]; - selector = +queryWithSelector[2]; - } - - // Try to match an emote name directly if the selector is for the closest match. - if (selector == 0) { - const directMatchEmote = client.emojis.cache.find((em) => em.name === emote); - if (directMatchEmote) return directMatchEmote.toString(); - } - - // Find all similar emote candidates within certian threshold and select Nth top one according to the selector. - const similarEmotes = searchSimilarEmotes(emote); - if (similarEmotes.length > 0) { - selector = Math.min(selector, similarEmotes.length - 1); - return similarEmotes[selector].toString(); - } - - // Return some "missing/invalid emote" indicator. - return "❓"; + return searchNearestEmote(emote); }); } - -export const processEmoteQueryArray = (query: string[]): string[] => processEmoteQuery(query, false); -export const processEmoteQueryFormatted = (query: string[]): string => processEmoteQuery(query, true).join(""); diff --git a/src/commands/utility/react.ts b/src/commands/utility/react.ts index 25158e7..b11609c 100644 --- a/src/commands/utility/react.ts +++ b/src/commands/utility/react.ts @@ -1,8 +1,9 @@ import {NamedCommand, RestCommand} from "onion-lasers"; import {Message, Channel, TextChannel} from "discord.js"; -import {processEmoteQueryArray} from "./modules/emote-utils"; +import {processEmoteQuery} from "./modules/emote-utils"; export default new NamedCommand({ + aliases: ["r"], description: "Reacts to the a previous message in your place. You have to react with the same emote before the bot removes that reaction.", usage: 'react ()', @@ -100,7 +101,7 @@ export default new NamedCommand({ ).last(); } - for (const emote of processEmoteQueryArray(args)) { + for (const emote of processEmoteQuery(args, false)) { // Even though the bot will always grab *some* emote, the user can choose not to keep that emote there if it isn't what they want const reaction = await target!.react(emote); diff --git a/src/commands/utility/say.ts b/src/commands/utility/say.ts index 316773d..60b5a52 100644 --- a/src/commands/utility/say.ts +++ b/src/commands/utility/say.ts @@ -1,13 +1,73 @@ -import {NamedCommand, RestCommand} from "onion-lasers"; +import {NamedCommand, RestCommand, CHANNEL_TYPE} from "onion-lasers"; +import {TextChannel, NewsChannel, Permissions} from "discord.js"; +import {searchNearestEmote} from "../utility/modules/emote-utils"; +import {resolveWebhook} from "../../modules/webhookStorageManager"; +import {parseVarsCallback} from "../../lib"; +// Description // +// This is the message-based counterpart to the react command, which replicates Nitro's ability to send emotes in messages. +// This takes advantage of webhooks' ability to change the username and avatar per request. +// Uses "@user says:" as a fallback in case no webhook is set for the channel. + +// Limitations / Points of Interest // +// - Webhooks can fetch any emote in existence and use it as long as it hasn't been deleted. +// - The emote name from <:name:id> DOES matter if the user isn't part of that guild. That's the fallback essentially, otherwise, it doesn't matter. +// - The animated flag must be correct. <:name:id> on an animated emote will make it not animated, will display an invalid image. +// - Rate limits for webhooks shouldn't be that big of an issue (5 requests every 2 seconds). export default new NamedCommand({ - description: "Repeats your message.", + aliases: ["s"], + channelType: CHANNEL_TYPE.GUILD, + description: "Repeats your message with emotes in /slashes/.", usage: "", run: "Please provide a message for me to say!", any: new RestCommand({ description: "Message to repeat.", - async run({send, author, combined}) { - send(`*${author} says:*\n${combined}`); + async run({send, channel, author, member, message, combined, guild}) { + const webhook = await resolveWebhook(channel as TextChannel | NewsChannel); + + if (webhook) { + const resolvedMessage = resolveMessageWithEmotes(combined); + + if (resolvedMessage) + webhook.send(resolvedMessage, { + username: member!.nickname ?? author.username, + // Webhooks cannot have animated avatars, so requesting the animated version is a moot point. + avatarURL: + author.avatarURL({ + format: "png" + }) || author.defaultAvatarURL, + allowedMentions: {parse: []}, // avoids double pings + // "embeds" will not be included because it messes with the default ones that generate + files: message.attachments.array() + }); + else send("Cannot send an empty message."); + } else { + const resolvedMessage = resolveMessageWithEmotes(combined); + if (resolvedMessage) send(`*${author} says:*\n${resolvedMessage}`, {allowedMentions: {parse: []}}); + else send("Cannot send an empty message."); + } + + if (guild!.me?.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete(); } }) }); + +const FETCH_EMOTE_PATTERN = /^(\d{17,})(?: ([^ ]+?))?(?: (a))?$/; + +// Send extra emotes only for webhook messages (because the bot user can't fetch any emote in existence while webhooks can). +function resolveMessageWithEmotes(text: string, extraEmotes?: null): string { + return parseVarsCallback( + text, + (variable) => { + if (FETCH_EMOTE_PATTERN.test(variable)) { + // Although I *could* make this ping the CDN to see if gif exists to see whether it's animated or not, it'd take too much time to wait on it. + // Plus, with the way this function is setup, I wouldn't be able to incorporate a search without changing the function to async. + const [_, id, name, animated] = FETCH_EMOTE_PATTERN.exec(variable)!; + return `<${animated ?? ""}:${name ?? "_"}:${id}>`; + } + + return searchNearestEmote(variable); + }, + "/" + ); +} diff --git a/src/lib.test.ts b/src/lib.test.ts index b9990b3..55671f0 100644 --- a/src/lib.test.ts +++ b/src/lib.test.ts @@ -1,5 +1,5 @@ import {strict as assert} from "assert"; -import {pluralise, pluraliseSigned, replaceAll, toTitleCase, split, parseVars} from "./lib"; +import {pluralise, pluraliseSigned, replaceAll, toTitleCase, split, parseVars, parseVarsCallback} from "./lib"; // I can't figure out a way to run the test suite while running the bot. describe("Wrappers", () => { @@ -58,6 +58,15 @@ describe("Wrappers", () => { }); }); + describe("#parseVarsCallback()", () => { + it('should replace %test% with "yeet"', () => { + assert.strictEqual( + parseVarsCallback("ya %test% the %pear%", (variable) => (variable === "test" ? "yeet" : "null")), + "ya yeet the null" + ); + }); + }); + describe("#toTitleCase()", () => { it("should capitalize the first letter of each word", () => { assert.strictEqual( diff --git a/src/lib.ts b/src/lib.ts index 06ae42e..a12c5d7 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -38,15 +38,20 @@ export function parseArgs(line: string): string[] { * - `%%` = `%` * - If the invalid token is null/undefined, nothing is changed. */ -export function parseVars(line: string, definitions: {[key: string]: string}, invalid: string | null = ""): string { +export function parseVars( + line: string, + definitions: {[key: string]: string}, + delimiter = "%", + invalid: string | null = "" +): string { let result = ""; let inVariable = false; let token = ""; for (const c of line) { - if (c === "%") { + if (c === delimiter) { if (inVariable) { - if (token === "") result += "%"; + if (token === "") result += delimiter; else { if (token in definitions) result += definitions[token]; else if (invalid === null) result += `%${token}%`; @@ -64,6 +69,29 @@ export function parseVars(line: string, definitions: {[key: string]: string}, in return result; } +export function parseVarsCallback(line: string, callback: (variable: string) => string, delimiter = "%"): string { + let result = ""; + let inVariable = false; + let token = ""; + + for (const c of line) { + if (c === delimiter) { + if (inVariable) { + if (token === "") result += delimiter; + else { + result += callback(token); + token = ""; + } + } + + inVariable = !inVariable; + } else if (inVariable) token += c; + else result += c; + } + + return result; +} + export function isType(value: any, type: any): boolean { if (value === undefined && type === undefined) return true; else if (value === null && type === null) return true; diff --git a/src/modules/emoteRegistry.ts b/src/modules/emoteRegistry.ts index 05ffa56..914ecbd 100644 --- a/src/modules/emoteRegistry.ts +++ b/src/modules/emoteRegistry.ts @@ -20,6 +20,7 @@ function updateGlobalEmoteRegistry(): void { } } + FileManager.open("data/public"); // generate folder if it doesn't exist FileManager.write("public/emote-registry", data, true); } diff --git a/src/modules/webhookStorageManager.ts b/src/modules/webhookStorageManager.ts new file mode 100644 index 0000000..245c958 --- /dev/null +++ b/src/modules/webhookStorageManager.ts @@ -0,0 +1,70 @@ +import {Webhook, TextChannel, NewsChannel, Permissions, Collection} from "discord.js"; +import {client} from ".."; +import {Config} from "../structures"; + +export const webhookStorage = new Collection(); // Channel ID: Webhook +const WEBHOOK_PATTERN = /https:\/\/discord\.com\/api\/webhooks\/(\d{17,})\/(.+)/; +const ID_PATTERN = /(\d{17,})/; + +// Resolve any available webhooks available for a selected channel. +export async function resolveWebhook(channel: TextChannel | NewsChannel): Promise { + if (channel.guild.me?.hasPermission(Permissions.FLAGS.MANAGE_WEBHOOKS)) { + const webhooksInChannel = await channel.fetchWebhooks(); + + if (webhooksInChannel.size > 0) return webhooksInChannel.first()!; + else return null; + } + + for (const [channelID, webhook] of webhookStorage.entries()) if (channel.id === channelID) return webhook; + + return null; +} + +export function registerWebhook(url: string): boolean { + if (WEBHOOK_PATTERN.test(url)) { + const [_, id, token] = WEBHOOK_PATTERN.exec(url)!; + Config.webhooks[id] = token; + Config.save(); + refreshWebhookCache(); + return true; + } else { + return false; + } +} + +export function deleteWebhook(urlOrID: string): boolean { + let id: string | null = null; + + if (WEBHOOK_PATTERN.test(urlOrID)) id = WEBHOOK_PATTERN.exec(urlOrID)![1]; + else if (ID_PATTERN.test(urlOrID)) id = ID_PATTERN.exec(urlOrID)![1]; + + if (id) { + delete Config.webhooks[id]; + Config.save(); + refreshWebhookCache(); + } + + return !!id; +} + +// This will return the target channel of a webhook create/edit/delete event. +// No permission is needed to receive this event, but since you only get the target channel, all stored webhooks must be fetched again. +// You can't rely on guilds giving the bot the manage webhooks permission. +client.on("webhookUpdate", refreshWebhookCache); +client.on("ready", refreshWebhookCache); + +// Reload webhook objects from the storage. +export async function refreshWebhookCache(): Promise { + webhookStorage.clear(); + + for (const [id, token] of Object.entries(Config.webhooks)) { + // If there are stored webhook IDs/tokens that don't work, delete those webhooks from storage. + try { + const webhook = await client.fetchWebhook(id, token); + webhookStorage.set(webhook.channelID, webhook); + } catch { + delete Config.webhooks[id]; + Config.save(); + } + } +} diff --git a/src/structures.ts b/src/structures.ts index a9fbe0c..f1b7396 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -14,6 +14,7 @@ class ConfigStructure extends GenericStructure { public admins: string[]; public support: string[]; public systemLogsChannel: string | null; + public webhooks: {[id: string]: string}; // id-token pairs constructor(data: GenericJSON) { super("config"); @@ -23,6 +24,15 @@ class ConfigStructure extends GenericStructure { this.admins = select(data.admins, [], String, true); this.support = select(data.support, [], String, true); this.systemLogsChannel = select(data.systemLogsChannel, null, String); + this.webhooks = {}; + + for (const id in data.webhooks) { + const token = data.webhooks[id]; + + if (/\d{17,}/g.test(id) && typeof token === "string") { + this.webhooks[id] = token; + } + } } }