TravBot-v3/src/core/libd.ts

391 lines
14 KiB
TypeScript
Raw Normal View History

// Library for Discord-specific functions
2021-03-30 12:16:31 +00:00
import {
Message,
Guild,
GuildMember,
TextChannel,
DMChannel,
NewsChannel,
MessageOptions,
Channel,
GuildChannel,
User,
APIMessageContentResolvable,
MessageAdditions,
SplitOptions,
APIMessage,
2021-04-11 08:02:56 +00:00
StringResolvable,
EmojiIdentifierResolvable,
MessageReaction,
PartialUser
2021-03-30 12:16:31 +00:00
} from "discord.js";
import {reactEventListeners, emptyReactEventListeners, replyEventListeners} from "./eventListeners";
import {client} from "./interface";
export type SingleMessageOptions = MessageOptions & {split?: false};
export type SendFunction = ((
content: APIMessageContentResolvable | (MessageOptions & {split?: false}) | MessageAdditions
) => Promise<Message>) &
((options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) &
((options: MessageOptions | APIMessage) => Promise<Message | Message[]>) &
((content: StringResolvable, options: (MessageOptions & {split?: false}) | MessageAdditions) => Promise<Message>) &
((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) &
((content: StringResolvable, options: MessageOptions) => Promise<Message | Message[]>);
interface PaginateOptions {
multiPageSize?: number;
idleTimeout?: number;
}
// Pagination function that allows for customization via a callback.
// Define your own pages outside the function because this only manages the actual turning of pages.
2021-04-07 10:58:09 +00:00
/**
* 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.
*
* Returns the page number the user left off on in case you want to implement a return to page function.
2021-04-07 10:58:09 +00:00
*/
export function paginate(
send: SendFunction,
listenTo: string | null,
2021-04-11 08:02:56 +00:00
totalPages: number,
onTurnPage: (page: number, hasMultiplePages: boolean) => SingleMessageOptions,
options?: PaginateOptions
): Promise<number> {
if (totalPages < 1) throw new Error(`totalPages on paginate() must be 1 or more, ${totalPages} given.`);
return new Promise(async (resolve) => {
const hasMultiplePages = totalPages > 1;
const message = await send(onTurnPage(0, hasMultiplePages));
if (hasMultiplePages) {
const multiPageSize = options?.multiPageSize ?? 5;
const idleTimeout = options?.idleTimeout ?? 60000;
let page = 0;
2021-03-30 12:16:31 +00:00
const turn = (amount: number) => {
page += amount;
if (page >= totalPages) {
page %= totalPages;
} else if (page < 0) {
// Assuming 3 total pages, it's a bit tricker, but if we just take the modulo of the absolute value (|page| % total), we get (1 2 0 ...), and we just need the pattern (2 1 0 ...). It needs to reverse order except for when it's 0. I want to find a better solution, but for the time being... total - (|page| % total) unless (|page| % total) = 0, then return 0.
const flattened = Math.abs(page) % totalPages;
if (flattened !== 0) page = totalPages - flattened;
}
message.edit(onTurnPage(page, true));
};
let stack: {[emote: string]: number} = {
"⬅️": -1,
"➡️": 1
};
if (totalPages > multiPageSize) {
stack = {
"⏪": -multiPageSize,
...stack,
"⏩": multiPageSize
};
}
const handle = (reaction: MessageReaction, user: User | PartialUser) => {
if (user.id === listenTo || (listenTo === null && user.id !== client.user!.id)) {
// Turn the page
2021-04-11 08:02:56 +00:00
const emote = reaction.emoji.name;
if (emote in stack) turn(stack[emote]);
// Reset the timer
client.clearTimeout(timeout);
timeout = client.setTimeout(destroy, idleTimeout);
}
};
// When time's up, remove the bot's own reactions.
const destroy = () => {
reactEventListeners.delete(message.id);
for (const emote of message.reactions.cache.values()) emote.users.remove(message.author);
resolve(page);
};
// Start the reactions and call the handler.
reactInOrder(message, Object.keys(stack));
reactEventListeners.set(message.id, handle);
emptyReactEventListeners.set(message.id, destroy);
let timeout = client.setTimeout(destroy, idleTimeout);
}
});
}
export async function poll(message: Message, emotes: string[], duration = 60000): Promise<{[emote: string]: number}> {
if (emotes.length === 0) throw new Error("poll() was called without any emotes.");
reactInOrder(message, emotes);
const reactions = await message.awaitReactions(
(reaction: MessageReaction) => emotes.includes(reaction.emoji.name),
{time: duration}
);
const reactionsByCount: {[emote: string]: number} = {};
2021-04-11 08:02:56 +00:00
for (const emote of emotes) {
const reaction = reactions.get(emote);
if (reaction) {
const hasBot = reaction.users.cache.has(client.user!.id); // Apparently, reaction.me doesn't work properly.
if (reaction.count !== null) {
const difference = hasBot ? 1 : 0;
reactionsByCount[emote] = reaction.count - difference;
} else {
reactionsByCount[emote] = 0;
}
} else {
reactionsByCount[emote] = 0;
2021-04-11 08:02:56 +00:00
}
}
return reactionsByCount;
2021-04-11 08:02:56 +00:00
}
export function confirm(message: Message, senderID: string, timeout = 30000): Promise<boolean | null> {
return generateOneTimePrompt(
message,
{
"✅": true,
"❌": false
},
senderID,
timeout
);
}
// This MUST be split into an array. These emojis are made up of several characters each, adding up to 29 in length.
const multiNumbers = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟"];
// This will bring up an option to let the user choose between one option out of many.
// This definitely needs a single callback alternative, because using the numerical version isn't actually that uncommon of a pattern.
export async function askMultipleChoice(
message: Message,
senderID: string,
2021-04-11 08:02:56 +00:00
choices: number,
timeout = 90000
2021-04-11 08:02:56 +00:00
): Promise<number | null> {
if (choices > multiNumbers.length)
throw new Error(
`askMultipleChoice only accepts up to ${multiNumbers.length} options, ${choices} was provided.`
);
2021-04-11 08:02:56 +00:00
const numbers: {[emote: string]: number} = {};
for (let i = 0; i < choices; i++) numbers[multiNumbers[i]] = i;
return generateOneTimePrompt(message, numbers, senderID, timeout);
}
2021-04-11 08:02:56 +00:00
// Asks the user for some input using the inline reply feature. The message here is a message you send beforehand.
// If the reply is rejected, reply with an error message (when stable support comes from discord.js).
export function askForReply(message: Message, listenTo: string, timeout?: number): Promise<Message | null> {
return new Promise((resolve) => {
const referenceID = `${message.channel.id}-${message.id}`;
2021-04-11 08:02:56 +00:00
replyEventListeners.set(referenceID, (reply) => {
if (reply.author.id === listenTo) {
message.delete();
replyEventListeners.delete(referenceID);
resolve(reply);
}
2021-04-11 08:02:56 +00:00
});
2021-04-11 08:02:56 +00:00
if (timeout) {
client.setTimeout(() => {
if (!message.deleted) message.delete();
replyEventListeners.delete(referenceID);
resolve(null);
}, timeout);
}
});
}
// Returns null if timed out, otherwise, returns the value.
export function generateOneTimePrompt<T>(
message: Message,
stack: {[emote: string]: T},
listenTo: string | null = null,
duration = 60000
): Promise<T | null> {
return new Promise(async (resolve) => {
// First, start reacting to the message in order.
reactInOrder(message, Object.keys(stack));
// Then setup the reaction listener in parallel.
await message.awaitReactions(
(reaction: MessageReaction, user: User) => {
if (user.id === listenTo || listenTo === null) {
const emote = reaction.emoji.name;
if (emote in stack) {
resolve(stack[emote]);
message.delete();
}
}
// CollectorFilter requires a boolean to be returned.
// My guess is that the return value of awaitReactions can be altered by making a boolean filter.
// However, because that's not my concern with this command, I don't have to worry about it.
// May as well just set it to false because I'm not concerned with collecting any reactions.
return false;
},
{time: duration}
);
if (!message.deleted) {
message.delete();
resolve(null);
}
});
}
// Start a parallel chain of ordered reactions, allowing a collector to end early.
// Check if the collector ended early by seeing if the message is already deleted.
// Though apparently, message.deleted doesn't seem to update fast enough, so just put a try catch block on message.react().
async function reactInOrder(message: Message, emotes: EmojiIdentifierResolvable[]): Promise<void> {
for (const emote of emotes) {
try {
await message.react(emote);
} catch {
return;
}
}
}
2021-04-11 08:02:56 +00:00
/**
* Tests if a bot has a certain permission in a specified guild.
*/
export function botHasPermission(guild: Guild | null, permission: number): boolean {
return !!guild?.me?.hasPermission(permission);
}
// For "get x by y" methods:
// Caching: All guilds, channels, and roles are fully cached, while the caches for messages, users, and members aren't complete.
// It's more reliable to get users/members by fetching their IDs. fetch() will searching through the cache anyway.
// For guilds, do an extra check to make sure there isn't an outage (guild.available).
2021-04-11 08:02:56 +00:00
export function getGuildByID(id: string): Guild | string {
const guild = client.guilds.cache.get(id);
if (guild) {
if (guild.available) return guild;
2021-04-11 08:02:56 +00:00
else return `The guild \`${guild.name}\` (ID: \`${id}\`) is unavailable due to an outage.`;
} else {
2021-04-11 08:02:56 +00:00
return `No guild found by the ID of \`${id}\`!`;
}
}
2021-04-11 08:02:56 +00:00
export function getGuildByName(name: string): Guild | string {
const query = name.toLowerCase();
const guild = client.guilds.cache.find((guild) => guild.name.toLowerCase().includes(query));
if (guild) {
if (guild.available) return guild;
2021-04-11 08:02:56 +00:00
else return `The guild \`${guild.name}\` (ID: \`${guild.id}\`) is unavailable due to an outage.`;
} else {
2021-04-11 08:02:56 +00:00
return `No guild found by the name of \`${name}\`!`;
}
}
2021-04-11 08:02:56 +00:00
export async function getChannelByID(id: string): Promise<Channel | string> {
try {
return await client.channels.fetch(id);
} catch {
2021-04-11 08:02:56 +00:00
return `No channel found by the ID of \`${id}\`!`;
}
}
// Only go through the cached channels (non-DM channels). Plus, searching DM channels by name wouldn't really make sense, nor do they have names to search anyway.
2021-04-11 08:02:56 +00:00
export function getChannelByName(name: string): GuildChannel | string {
const query = name.toLowerCase();
const channel = client.channels.cache.find(
(channel) => channel instanceof GuildChannel && channel.name.toLowerCase().includes(query)
) as GuildChannel | undefined;
if (channel) return channel;
2021-04-11 08:02:56 +00:00
else return `No channel found by the name of \`${name}\`!`;
}
2021-04-07 09:58:13 +00:00
export async function getMessageByID(
channel: TextChannel | DMChannel | NewsChannel | string,
id: string
2021-04-11 08:02:56 +00:00
): Promise<Message | string> {
if (typeof channel === "string") {
const targetChannel = await getChannelByID(channel);
if (targetChannel instanceof TextChannel || targetChannel instanceof DMChannel) channel = targetChannel;
2021-04-11 08:02:56 +00:00
else if (targetChannel instanceof Channel) return `\`${id}\` isn't a valid text-based channel!`;
else return targetChannel;
}
2021-04-07 09:58:13 +00:00
try {
return await channel.messages.fetch(id);
} catch {
2021-04-11 08:02:56 +00:00
return `\`${id}\` isn't a valid message of the channel ${channel}!`;
}
}
2021-04-07 09:58:13 +00:00
2021-04-11 08:02:56 +00:00
export async function getUserByID(id: string): Promise<User | string> {
try {
return await client.users.fetch(id);
} catch {
2021-04-11 08:02:56 +00:00
return `No user found by the ID of \`${id}\`!`;
}
}
// Also check tags (if provided) to narrow down users.
2021-04-11 08:02:56 +00:00
export function getUserByName(name: string): User | string {
let query = name.toLowerCase();
const tagMatch = /^(.+?)#(\d{4})$/.exec(name);
let tag: string | null = null;
if (tagMatch) {
query = tagMatch[1].toLowerCase();
tag = tagMatch[2];
}
const user = client.users.cache.find((user) => {
const hasUsernameMatch = user.username.toLowerCase().includes(query);
if (tag) return hasUsernameMatch && user.discriminator === tag;
else return hasUsernameMatch;
});
if (user) return user;
2021-04-11 08:02:56 +00:00
else return `No user found by the name of \`${name}\`!`;
}
2021-04-11 08:02:56 +00:00
export async function getMemberByID(guild: Guild, id: string): Promise<GuildMember | string> {
try {
return await guild.members.fetch(id);
} catch {
2021-04-11 08:02:56 +00:00
return `No member found by the ID of \`${id}\`!`;
}
}
// First checks if a member can be found by that nickname, then check if a member can be found by that username.
2021-04-11 08:02:56 +00:00
export async function getMemberByName(guild: Guild, name: string): Promise<GuildMember | string> {
const member = (
await guild.members.fetch({
query: name,
limit: 1
})
).first();
// Search by username if no member is found, then resolve the user into a member if possible.
if (member) {
return member;
} else {
const user = getUserByName(name);
if (user instanceof User) {
const member = guild.members.resolve(user);
if (member) return member;
2021-04-11 08:02:56 +00:00
else return `The user \`${user.tag}\` isn't in this guild!`;
} else {
2021-04-11 08:02:56 +00:00
return `No member found by the name of \`${name}\`!`;
}
}
}