Began reworking the say command

This commit is contained in:
WatDuhHekBro 2021-05-06 08:30:51 -05:00
parent e249d4b86d
commit 736070d615
No known key found for this signature in database
GPG Key ID: E128514902DF8A05
17 changed files with 282 additions and 50 deletions

View File

@ -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`

View File

4
package-lock.json generated
View File

@ -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": {

View File

@ -1,6 +1,6 @@
{
"name": "travebot",
"version": "3.2.2",
"version": "3.2.3",
"description": "TravBot Discord bot.",
"main": "dist/index.js",
"scripts": {

View File

@ -141,7 +141,7 @@ export const PayCommand = new NamedCommand({
run: "You must use the format `eco pay <user> <amount>`!"
}),
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!");

View File

@ -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);
}
})
}),

View File

@ -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}\``);
}
})
}),

View File

@ -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 <webhook URL>",
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.");
}
})
})
}
});

View File

@ -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: "<emotes...>",
async run({send, args}) {
const output = processEmoteQueryFormatted(args);
const output = processEmoteQuery(args, true).join("");
if (output.length > 0) send(output);
}
})

View File

@ -65,7 +65,35 @@ const unicodeEmojiRegex = /^(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud
const discordEmoteMentionRegex = /^<a?:\w+:\d+>$/;
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("");

View File

@ -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 <emotes...> (<distance / message ID / "Copy ID" / "Copy Message Link">)',
@ -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);

View File

@ -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, <a:name:id> 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: "<message>",
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);
},
"/"
);
}

View File

@ -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(

View File

@ -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;

View File

@ -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);
}

View File

@ -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<string, Webhook>(); // 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<Webhook | null> {
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<void> {
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();
}
}
}

View File

@ -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;
}
}
}
}