Began reworking the say command
This commit is contained in:
parent
e249d4b86d
commit
736070d615
11
CHANGELOG.md
11
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`
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "travebot",
|
||||
"version": "3.2.2",
|
||||
"version": "3.2.3",
|
||||
"description": "TravBot Discord bot.",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -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!");
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
|
|
@ -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}\``);
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
|
|
@ -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.");
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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("");
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
"/"
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
34
src/lib.ts
34
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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue