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
|
# 3.2.2
|
||||||
- Moved command handler code to [Onion Lasers](https://github.com/WatDuhHekBro/OnionLasers)
|
- Moved command handler code to [Onion Lasers](https://github.com/WatDuhHekBro/OnionLasers)
|
||||||
- Reworked `poll`
|
- Reworked `poll`
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "travebot",
|
"name": "travebot",
|
||||||
"version": "3.2.2",
|
"version": "3.2.3",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "travebot",
|
"name": "travebot",
|
||||||
"version": "3.2.2",
|
"version": "3.2.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "travebot",
|
"name": "travebot",
|
||||||
"version": "3.2.2",
|
"version": "3.2.3",
|
||||||
"description": "TravBot Discord bot.",
|
"description": "TravBot Discord bot.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -141,7 +141,7 @@ export const PayCommand = new NamedCommand({
|
||||||
run: "You must use the format `eco pay <user> <amount>`!"
|
run: "You must use the format `eco pay <user> <amount>`!"
|
||||||
}),
|
}),
|
||||||
any: new RestCommand({
|
any: new RestCommand({
|
||||||
async run({send, args, author, channel, guild, combined}) {
|
async run({send, args, author, channel, guild}) {
|
||||||
if (isAuthorized(guild, channel)) {
|
if (isAuthorized(guild, channel)) {
|
||||||
const last = args.pop();
|
const last = args.pop();
|
||||||
|
|
||||||
|
@ -156,7 +156,8 @@ export const PayCommand = new NamedCommand({
|
||||||
else if (!guild)
|
else if (!guild)
|
||||||
return send("You have to use this in a server if you want to send Mons with a username!");
|
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);
|
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.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!");
|
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({
|
any: new RestCommand({
|
||||||
description: "Question for the poll.",
|
description: "Question for the poll.",
|
||||||
async run({send, message, author, args, combined}) {
|
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();
|
Storage.save();
|
||||||
send("Set this server's welcome type to `graphical`.");
|
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,
|
channelType: CHANNEL_TYPE.GUILD,
|
||||||
run: "You have to specify a nickname to set for the bot",
|
run: "You have to specify a nickname to set for the bot",
|
||||||
any: new RestCommand({
|
any: new RestCommand({
|
||||||
async run({send, message, guild, combined}) {
|
async run({send, guild, combined}) {
|
||||||
await guild!.me?.setNickname(combined);
|
await guild!.me?.setNickname(combined);
|
||||||
if (guild!.me?.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete({timeout: 5000});
|
send(`Nickname set to \`${combined}\``);
|
||||||
send(`Nickname set to \`${combined}\``).then((m) => m.delete({timeout: 5000}));
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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 {NamedCommand, RestCommand} from "onion-lasers";
|
||||||
import {processEmoteQueryFormatted} from "./modules/emote-utils";
|
import {processEmoteQuery} from "./modules/emote-utils";
|
||||||
|
|
||||||
export default new NamedCommand({
|
export default new NamedCommand({
|
||||||
description:
|
description:
|
||||||
|
@ -9,7 +9,7 @@ export default new NamedCommand({
|
||||||
description: "The emote(s) to send.",
|
description: "The emote(s) to send.",
|
||||||
usage: "<emotes...>",
|
usage: "<emotes...>",
|
||||||
async run({send, args}) {
|
async run({send, args}) {
|
||||||
const output = processEmoteQueryFormatted(args);
|
const output = processEmoteQuery(args, true).join("");
|
||||||
if (output.length > 0) send(output);
|
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 discordEmoteMentionRegex = /^<a?:\w+:\d+>$/;
|
||||||
const emoteNameWithSelectorRegex = /^(.+)~(\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) => {
|
return query.map((emote) => {
|
||||||
emote = emote.trim();
|
emote = emote.trim();
|
||||||
|
|
||||||
|
@ -79,33 +107,6 @@ function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
|
||||||
if (emote == "_") return "\u200b";
|
if (emote == "_") return "\u200b";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selector number used for disambiguating multiple emotes with same name.
|
return searchNearestEmote(emote);
|
||||||
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 "❓";
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {NamedCommand, RestCommand} from "onion-lasers";
|
||||||
import {Message, Channel, TextChannel} from "discord.js";
|
import {Message, Channel, TextChannel} from "discord.js";
|
||||||
import {processEmoteQueryArray} from "./modules/emote-utils";
|
import {processEmoteQuery} from "./modules/emote-utils";
|
||||||
|
|
||||||
export default new NamedCommand({
|
export default new NamedCommand({
|
||||||
|
aliases: ["r"],
|
||||||
description:
|
description:
|
||||||
"Reacts to the a previous message in your place. You have to react with the same emote before the bot removes that reaction.",
|
"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">)',
|
usage: 'react <emotes...> (<distance / message ID / "Copy ID" / "Copy Message Link">)',
|
||||||
|
@ -100,7 +101,7 @@ export default new NamedCommand({
|
||||||
).last();
|
).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
|
// 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);
|
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({
|
export default new NamedCommand({
|
||||||
description: "Repeats your message.",
|
aliases: ["s"],
|
||||||
|
channelType: CHANNEL_TYPE.GUILD,
|
||||||
|
description: "Repeats your message with emotes in /slashes/.",
|
||||||
usage: "<message>",
|
usage: "<message>",
|
||||||
run: "Please provide a message for me to say!",
|
run: "Please provide a message for me to say!",
|
||||||
any: new RestCommand({
|
any: new RestCommand({
|
||||||
description: "Message to repeat.",
|
description: "Message to repeat.",
|
||||||
async run({send, author, combined}) {
|
async run({send, channel, author, member, message, combined, guild}) {
|
||||||
send(`*${author} says:*\n${combined}`);
|
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 {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.
|
// I can't figure out a way to run the test suite while running the bot.
|
||||||
describe("Wrappers", () => {
|
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()", () => {
|
describe("#toTitleCase()", () => {
|
||||||
it("should capitalize the first letter of each word", () => {
|
it("should capitalize the first letter of each word", () => {
|
||||||
assert.strictEqual(
|
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.
|
* - 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 result = "";
|
||||||
let inVariable = false;
|
let inVariable = false;
|
||||||
let token = "";
|
let token = "";
|
||||||
|
|
||||||
for (const c of line) {
|
for (const c of line) {
|
||||||
if (c === "%") {
|
if (c === delimiter) {
|
||||||
if (inVariable) {
|
if (inVariable) {
|
||||||
if (token === "") result += "%";
|
if (token === "") result += delimiter;
|
||||||
else {
|
else {
|
||||||
if (token in definitions) result += definitions[token];
|
if (token in definitions) result += definitions[token];
|
||||||
else if (invalid === null) result += `%${token}%`;
|
else if (invalid === null) result += `%${token}%`;
|
||||||
|
@ -64,6 +69,29 @@ export function parseVars(line: string, definitions: {[key: string]: string}, in
|
||||||
return result;
|
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 {
|
export function isType(value: any, type: any): boolean {
|
||||||
if (value === undefined && type === undefined) return true;
|
if (value === undefined && type === undefined) return true;
|
||||||
else if (value === null && type === null) 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);
|
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 admins: string[];
|
||||||
public support: string[];
|
public support: string[];
|
||||||
public systemLogsChannel: string | null;
|
public systemLogsChannel: string | null;
|
||||||
|
public webhooks: {[id: string]: string}; // id-token pairs
|
||||||
|
|
||||||
constructor(data: GenericJSON) {
|
constructor(data: GenericJSON) {
|
||||||
super("config");
|
super("config");
|
||||||
|
@ -23,6 +24,15 @@ class ConfigStructure extends GenericStructure {
|
||||||
this.admins = select(data.admins, [], String, true);
|
this.admins = select(data.admins, [], String, true);
|
||||||
this.support = select(data.support, [], String, true);
|
this.support = select(data.support, [], String, true);
|
||||||
this.systemLogsChannel = select(data.systemLogsChannel, null, String);
|
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