2021-03-30 10:25:07 +00:00
// Library for Discord-specific functions
2021-03-30 12:16:31 +00:00
import {
Message ,
Guild ,
GuildMember ,
TextChannel ,
DMChannel ,
NewsChannel ,
2021-04-10 11:41:48 +00:00
MessageOptions ,
Channel ,
GuildChannel ,
2021-04-10 12:51:32 +00:00
User ,
APIMessageContentResolvable ,
MessageAdditions ,
SplitOptions ,
APIMessage ,
2021-04-11 08:02:56 +00:00
StringResolvable ,
EmojiIdentifierResolvable ,
2021-04-11 10:45:50 +00:00
MessageReaction ,
PartialUser
2021-03-30 12:16:31 +00:00
} from "discord.js" ;
2021-04-11 10:45:50 +00:00
import { reactEventListeners , emptyReactEventListeners , replyEventListeners } from "./eventListeners" ;
2021-04-10 11:41:48 +00:00
import { client } from "./interface" ;
2021-03-30 10:25:07 +00:00
2021-04-01 10:44:44 +00:00
export type SingleMessageOptions = MessageOptions & { split? : false } ;
2021-03-30 10:25:07 +00:00
2021-04-10 12:51:32 +00:00
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 [ ] > ) ;
2021-04-11 10:45:50 +00:00
interface PaginateOptions {
multiPageSize? : number ;
idleTimeout? : number ;
}
2021-04-10 04:06:16 +00:00
2021-04-10 11:41:48 +00:00
// 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 .
2021-04-11 10:45:50 +00:00
*
* 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
* /
2021-04-11 10:45:50 +00:00
export function paginate (
2021-04-10 12:51:32 +00:00
send : SendFunction ,
2021-04-11 10:45:50 +00:00
listenTo : string | null ,
2021-04-11 08:02:56 +00:00
totalPages : number ,
2021-04-11 10:45:50 +00:00
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. ` ) ;
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
return new Promise ( async ( resolve ) = > {
const hasMultiplePages = totalPages > 1 ;
const message = await send ( onTurnPage ( 0 , hasMultiplePages ) ) ;
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
if ( hasMultiplePages ) {
const multiPageSize = options ? . multiPageSize ? ? 5 ;
const idleTimeout = options ? . idleTimeout ? ? 60000 ;
let page = 0 ;
2021-03-30 12:16:31 +00:00
2021-04-11 10:45:50 +00:00
const turn = ( amount : number ) = > {
page += amount ;
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
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 ;
}
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
message . edit ( onTurnPage ( page , true ) ) ;
} ;
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
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 ;
2021-04-11 10:45:50 +00:00
if ( emote in stack ) turn ( stack [ emote ] ) ;
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
// Reset the timer
client . clearTimeout ( timeout ) ;
timeout = client . setTimeout ( destroy , idleTimeout ) ;
2021-03-30 10:25:07 +00:00
}
2021-04-11 10:45:50 +00:00
} ;
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
// 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 ) ;
} ;
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
// 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 ) ;
2021-03-30 10:25:07 +00:00
}
} ) ;
}
2021-04-11 10:45:50 +00:00
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 ) {
2021-04-11 10:45:50 +00:00
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
}
}
2021-04-11 10:45:50 +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
) ;
}
2021-03-30 10:25:07 +00:00
// 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 ,
2021-03-30 10:25:07 +00:00
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-03-30 10:25:07 +00:00
) ;
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-03-30 10:25:07 +00:00
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-03-30 10:25:07 +00:00
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-03-30 10:25:07 +00:00
}
2021-04-11 08:02:56 +00:00
} ) ;
2021-03-30 10:25:07 +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 ) ;
}
} ) ;
}
2021-03-30 10:25:07 +00:00
2021-04-11 10:45:50 +00:00
// 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 ) ;
2021-03-30 10:25:07 +00:00
}
2021-04-10 11:41:48 +00:00
// 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 {
2021-04-10 11:41:48 +00:00
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. ` ;
2021-04-10 11:41:48 +00:00
} else {
2021-04-11 08:02:56 +00:00
return ` No guild found by the ID of \` ${ id } \` ! ` ;
2021-04-10 11:41:48 +00:00
}
2021-03-30 10:25:07 +00:00
}
2021-04-11 08:02:56 +00:00
export function getGuildByName ( name : string ) : Guild | string {
2021-04-10 11:41:48 +00:00
const query = name . toLowerCase ( ) ;
const guild = client . guilds . cache . find ( ( guild ) = > guild . name . toLowerCase ( ) . includes ( query ) ) ;
2021-03-30 10:25:07 +00:00
if ( guild ) {
2021-04-10 11:41:48 +00:00
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. ` ;
2021-04-10 11:41:48 +00:00
} else {
2021-04-11 08:02:56 +00:00
return ` No guild found by the name of \` ${ name } \` ! ` ;
2021-04-10 11:41:48 +00:00
}
}
2021-04-11 08:02:56 +00:00
export async function getChannelByID ( id : string ) : Promise < Channel | string > {
2021-04-10 11:41:48 +00:00
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 } \` ! ` ;
2021-04-10 11:41:48 +00:00
}
}
2021-03-30 10:25:07 +00:00
2021-04-10 11:41:48 +00:00
// 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 {
2021-04-10 11:41:48 +00:00
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-03-30 10:25:07 +00:00
}
2021-04-07 09:58:13 +00:00
2021-04-10 11:41:48 +00:00
export async function getMessageByID (
channel : TextChannel | DMChannel | NewsChannel | string ,
id : string
2021-04-11 08:02:56 +00:00
) : Promise < Message | string > {
2021-04-10 11:41:48 +00:00
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! ` ;
2021-04-10 11:41:48 +00:00
else return targetChannel ;
}
2021-04-07 09:58:13 +00:00
2021-04-10 11:41:48 +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-10 11:41:48 +00:00
}
}
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 > {
2021-04-10 11:41:48 +00:00
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 } \` ! ` ;
2021-04-10 11:41:48 +00:00
}
}
// Also check tags (if provided) to narrow down users.
2021-04-11 08:02:56 +00:00
export function getUserByName ( name : string ) : User | string {
2021-04-10 11:41:48 +00:00
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-10 11:41:48 +00:00
}
2021-04-11 08:02:56 +00:00
export async function getMemberByID ( guild : Guild , id : string ) : Promise < GuildMember | string > {
2021-04-10 11:41:48 +00:00
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 } \` ! ` ;
2021-04-10 11:41:48 +00:00
}
}
// 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 > {
2021-04-10 11:41:48 +00:00
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! ` ;
2021-04-10 11:41:48 +00:00
} else {
2021-04-11 08:02:56 +00:00
return ` No member found by the name of \` ${ name } \` ! ` ;
2021-04-10 11:41:48 +00:00
}
}
}