(Open Discord) Added support for context menu's
This commit is contained in:
@@ -76,12 +76,15 @@ export class ODClientManager {
|
||||
slashCommands: ODSlashCommandManager
|
||||
/**The text command manager is responsible for all text commands & their events inside the bot. */
|
||||
textCommands: ODTextCommandManager
|
||||
/**The context menu manager is responsible for all context menus & their events inside the bot. */
|
||||
contextMenus: ODContextMenuManager
|
||||
|
||||
constructor(debug:ODDebugger){
|
||||
this.#debug = debug
|
||||
this.activity = new ODClientActivityManager(this.#debug,this)
|
||||
this.slashCommands = new ODSlashCommandManager(this.#debug,this)
|
||||
this.textCommands = new ODTextCommandManager(this.#debug,this)
|
||||
this.contextMenus = new ODContextMenuManager(this.#debug,this)
|
||||
}
|
||||
|
||||
/**Initiate the `client` variable & add the intents & partials to the bot. */
|
||||
@@ -145,9 +148,10 @@ export class ODClientManager {
|
||||
this.client.once("ready",async () => {
|
||||
this.ready = true
|
||||
|
||||
//set slashCommandManager to client applicationCommandManager
|
||||
if (!this.client.application) throw new ODSystemError("Couldn't get client application! Unable to register slash commands!")
|
||||
//set slashCommandManager & contextMenuManager to client applicationCommandManager
|
||||
if (!this.client.application) throw new ODSystemError("Couldn't get client application for slashCommand & contextMenu managers!")
|
||||
this.slashCommands.commandManager = this.client.application.commands
|
||||
this.contextMenus.commandManager = this.client.application.commands
|
||||
|
||||
if (this.readyListener) await this.readyListener()
|
||||
resolve(true)
|
||||
@@ -522,6 +526,9 @@ export interface ODSlashCommandBuilder extends discord.ChatInputApplicationComma
|
||||
contexts:discord.InteractionContextType[]
|
||||
}
|
||||
|
||||
/**## ODSlashCommandComparator `class`
|
||||
* A utility class to compare existing slash commands with newly registered ones.
|
||||
*/
|
||||
export class ODSlashCommandComparator {
|
||||
/**Convert a `discord.ApplicationCommandOptionChoiceData<string>` to a universal Open Ticket slash command option choice object for comparison. */
|
||||
#convertOptionChoice(choice:discord.ApplicationCommandOptionChoiceData<string>): ODSlashCommandUniversalOptionChoice {
|
||||
@@ -1788,4 +1795,384 @@ export class ODTextCommandManager extends ODManager<ODTextCommand> {
|
||||
else if (!checkResult.valid && checkResult.reason == "allowspaces_not_last") throw new ODSystemError("Invalid text command '"+data.id.value+"' => string option with 'allowSpaces' is only allowed at the end of a command!")
|
||||
else return super.add(data,overwrite)
|
||||
}
|
||||
}
|
||||
|
||||
/**## ODContextMenuUniversalMenu `interface`
|
||||
* A universal template for a context menu.
|
||||
*
|
||||
* Why universal? Both **existing context menus** & **unregistered templates** can be converted to this type.
|
||||
*/
|
||||
export interface ODContextMenuUniversalMenu {
|
||||
/**The type of this context menu. (required => `Message`|`User`) */
|
||||
type:discord.ApplicationCommandType.Message|discord.ApplicationCommandType.User,
|
||||
/**The name of this context menu. */
|
||||
name:string,
|
||||
/**All localized names of this context menu. */
|
||||
nameLocalizations:readonly ODSlashCommandUniversalTranslation[],
|
||||
/**The id of the guild this context menu is registered in. */
|
||||
guildId:string|null,
|
||||
/**Is this context menu for 18+ users only? */
|
||||
nsfw:boolean,
|
||||
/**A bitfield of the user permissions required to use this context menu. */
|
||||
defaultMemberPermissions:bigint,
|
||||
/**Is this context menu available in DM? */
|
||||
dmPermission:boolean,
|
||||
/**A list of contexts where you can install this context menu. */
|
||||
integrationTypes:readonly discord.ApplicationIntegrationType[],
|
||||
/**A list of contexts where you can use this context menu. */
|
||||
contexts:readonly discord.InteractionContextType[]
|
||||
}
|
||||
|
||||
/**## ODContextMenuBuilderMessage `interface`
|
||||
* The builder for message context menus.
|
||||
*/
|
||||
export interface ODContextMenuBuilderMessage extends discord.MessageApplicationCommandData {
|
||||
/**This field is required in Open Ticket for future compatibility. */
|
||||
integrationTypes:discord.ApplicationIntegrationType[],
|
||||
/**This field is required in Open Ticket for future compatibility. */
|
||||
contexts:discord.InteractionContextType[]
|
||||
}
|
||||
|
||||
/**## ODContextMenuBuilderUser `interface`
|
||||
* The builder for user context menus.
|
||||
*/
|
||||
export interface ODContextMenuBuilderUser extends discord.UserApplicationCommandData {
|
||||
/**This field is required in Open Ticket for future compatibility. */
|
||||
integrationTypes:discord.ApplicationIntegrationType[],
|
||||
/**This field is required in Open Ticket for future compatibility. */
|
||||
contexts:discord.InteractionContextType[]
|
||||
}
|
||||
|
||||
/**## ODContextMenuBuilderUser `interface`
|
||||
* The builder for context menus.
|
||||
*/
|
||||
export type ODContextMenuBuilder = (ODContextMenuBuilderMessage|ODContextMenuBuilderUser)
|
||||
|
||||
/**## ODContextMenuComparator `class`
|
||||
* A utility class to compare existing context menu's with newly registered ones.
|
||||
*/
|
||||
export class ODContextMenuComparator {
|
||||
/**Convert a `ODContextMenuBuilder` to a universal Open Ticket context menu object for comparison. */
|
||||
convertBuilder(builder:ODContextMenuBuilder,guildId:string|null): ODContextMenuUniversalMenu|null {
|
||||
if (builder.type != discord.ApplicationCommandType.Message && builder.type != discord.ApplicationCommandType.User) return null
|
||||
const nameLoc = builder.nameLocalizations ?? {}
|
||||
|
||||
return {
|
||||
type:builder.type,
|
||||
name:builder.name,
|
||||
nameLocalizations:Object.keys(nameLoc).map((key) => {return {language:key as `${discord.Locale}`,value:nameLoc[key]}}),
|
||||
guildId:guildId,
|
||||
nsfw:builder.nsfw ?? false,
|
||||
defaultMemberPermissions:discord.PermissionsBitField.resolve(builder.defaultMemberPermissions ?? ["ViewChannel"]),
|
||||
dmPermission:(builder.contexts && builder.contexts.includes(discord.InteractionContextType.BotDM)) ?? false,
|
||||
integrationTypes:builder.integrationTypes ?? [discord.ApplicationIntegrationType.GuildInstall],
|
||||
contexts:builder.contexts ?? []
|
||||
}
|
||||
}
|
||||
/**Convert a `discord.ApplicationCommand` to a universal Open Ticket context menu object for comparison. */
|
||||
convertMenu(cmd:discord.ApplicationCommand): ODContextMenuUniversalMenu|null {
|
||||
if (cmd.type != discord.ApplicationCommandType.Message && cmd.type != discord.ApplicationCommandType.User) return null
|
||||
const nameLoc = cmd.nameLocalizations ?? {}
|
||||
|
||||
return {
|
||||
type:cmd.type,
|
||||
name:cmd.name,
|
||||
nameLocalizations:Object.keys(nameLoc).map((key) => {return {language:key as `${discord.Locale}`,value:nameLoc[key]}}),
|
||||
guildId:cmd.guildId,
|
||||
nsfw:cmd.nsfw,
|
||||
defaultMemberPermissions:discord.PermissionsBitField.resolve(cmd.defaultMemberPermissions ?? ["ViewChannel"]),
|
||||
dmPermission:(cmd.contexts && cmd.contexts.includes(discord.InteractionContextType.BotDM)) ? true : false,
|
||||
integrationTypes:cmd.integrationTypes ?? [discord.ApplicationIntegrationType.GuildInstall],
|
||||
contexts:cmd.contexts ?? []
|
||||
}
|
||||
}
|
||||
/**Returns `true` when the 2 context menus are the same. */
|
||||
compare(ctxA:ODContextMenuUniversalMenu,ctxB:ODContextMenuUniversalMenu): boolean {
|
||||
if (ctxA.name != ctxB.name) return false
|
||||
if (ctxA.type != ctxB.type) return false
|
||||
if (ctxA.nsfw != ctxB.nsfw) return false
|
||||
if (ctxA.guildId != ctxB.guildId) return false
|
||||
if (ctxA.dmPermission != ctxB.dmPermission) return false
|
||||
if (ctxA.defaultMemberPermissions != ctxB.defaultMemberPermissions) return false
|
||||
|
||||
//nameLocalizations
|
||||
if (ctxA.nameLocalizations.length != ctxB.nameLocalizations.length) return false
|
||||
if (!ctxA.nameLocalizations.every((nameA) => {
|
||||
const nameB = ctxB.nameLocalizations.find((nameB) => nameB.language == nameA.language)
|
||||
if (!nameB || nameA.value != nameB.value) return false
|
||||
else return true
|
||||
})) return false
|
||||
|
||||
//contexts
|
||||
if (ctxA.contexts.length != ctxB.contexts.length) return false
|
||||
if (!ctxA.contexts.every((contextA) => {
|
||||
return ctxB.contexts.includes(contextA)
|
||||
})) return false
|
||||
|
||||
//integrationTypes
|
||||
if (ctxA.integrationTypes.length != ctxB.integrationTypes.length) return false
|
||||
if (!ctxA.integrationTypes.every((integrationA) => {
|
||||
return ctxB.integrationTypes.includes(integrationA)
|
||||
})) return false
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**## ODContextMenuInteractionCallback `type`
|
||||
* Callback for the message context menu interaction listener.
|
||||
*/
|
||||
export type ODContextMenuInteractionCallback = (interaction:discord.ContextMenuCommandInteraction,cmd:ODContextMenu) => void
|
||||
|
||||
/**## ODContextMenuRegisteredResult `type`
|
||||
* The result which will be returned when getting all (un)registered user context menu's from the manager.
|
||||
*/
|
||||
export type ODContextMenuRegisteredResult = {
|
||||
/**A list of all registered message context menus. */
|
||||
registered:{
|
||||
/**The instance (`ODContextMenu`) from this message context menu. */
|
||||
instance:ODContextMenu,
|
||||
/**The universal object/template/builder of this message context menu. */
|
||||
menu:ODContextMenuUniversalMenu,
|
||||
/**Does this message context menu require an update? */
|
||||
requiresUpdate:boolean
|
||||
}[],
|
||||
/**A list of all unregistered message context menus. */
|
||||
unregistered:{
|
||||
/**The instance (`ODContextMenu`) from this message context menu. */
|
||||
instance:ODContextMenu,
|
||||
/**The universal object/template/builder of this message context menu. */
|
||||
menu:null,
|
||||
/**Does this message context menu require an update? */
|
||||
requiresUpdate:true
|
||||
}[],
|
||||
/**A list of all unused message context menus (not found in `ODContextMenuManager`). */
|
||||
unused:{
|
||||
/**The instance (`ODContextMenu`) from this message context menu. */
|
||||
instance:null,
|
||||
/**The universal object/template/builder of this message context menu. */
|
||||
menu:ODContextMenuUniversalMenu,
|
||||
/**Does this context menu require an update? */
|
||||
requiresUpdate:false
|
||||
}[]
|
||||
}
|
||||
|
||||
/**## ODContextMenuManager `class`
|
||||
* This is an Open Ticket client message context menu manager.
|
||||
*
|
||||
* It's responsible for managing all the message context interactions from the client.
|
||||
*
|
||||
* Here, you can add & remove message context interactions & the bot will do the (de)registering.
|
||||
*/
|
||||
export class ODContextMenuManager extends ODManager<ODContextMenu> {
|
||||
/**Alias to Open Ticket debugger. */
|
||||
#debug: ODDebugger
|
||||
|
||||
/**Refrerence to discord.js client. */
|
||||
manager: ODClientManager
|
||||
/**Discord.js application commands manager. */
|
||||
commandManager: discord.ApplicationCommandManager|null
|
||||
/**Collection of all interaction listeners. */
|
||||
#interactionListeners: {name:string|RegExp, callback:ODContextMenuInteractionCallback}[] = []
|
||||
/**Set the soft limit for maximum amount of listeners. A warning will be shown when there are more listeners than this limit. */
|
||||
listenerLimit: number = 100
|
||||
/**A utility class used to compare 2 context menus with each other. */
|
||||
comparator: ODContextMenuComparator = new ODContextMenuComparator()
|
||||
|
||||
constructor(debug:ODDebugger, manager:ODClientManager){
|
||||
super(debug,"context menu")
|
||||
this.#debug = debug
|
||||
this.manager = manager
|
||||
this.commandManager = (manager.client.application) ? manager.client.application.commands : null
|
||||
}
|
||||
|
||||
/**Get all registered & unregistered message context menu commands. */
|
||||
async getAllRegisteredMenus(guildId?:string): Promise<ODContextMenuRegisteredResult> {
|
||||
if (!this.commandManager) throw new ODSystemError("Couldn't get client application to register message context menus!")
|
||||
|
||||
const menus = (await this.commandManager.fetch({guildId})).toJSON()
|
||||
const registered: {instance:ODContextMenu, menu:ODContextMenuUniversalMenu, requiresUpdate:boolean}[] = []
|
||||
const unregistered: {instance:ODContextMenu, menu:null, requiresUpdate:true}[] = []
|
||||
const unused: {instance:null, menu:ODContextMenuUniversalMenu, requiresUpdate:false}[] = []
|
||||
|
||||
await this.loopAll((instance) => {
|
||||
if (guildId && instance.guildId != guildId) return
|
||||
|
||||
const index = menus.findIndex((menu) => menu.name == instance.name)
|
||||
const menu = menus[index]
|
||||
menus.splice(index,1)
|
||||
if (menu){
|
||||
//menu is registered (and may need to be updated)
|
||||
const universalBuilder = this.comparator.convertBuilder(instance.builder,instance.guildId)
|
||||
const universalMenu = this.comparator.convertMenu(menu)
|
||||
|
||||
//menu is not of the type 'message'|'user'
|
||||
if (!universalBuilder || !universalMenu) return
|
||||
|
||||
const didChange = !this.comparator.compare(universalBuilder,universalMenu)
|
||||
const requiresUpdate = didChange || (instance.requiresUpdate ? instance.requiresUpdate(universalMenu) : false)
|
||||
registered.push({instance,menu:universalMenu,requiresUpdate})
|
||||
|
||||
//menu is not registered
|
||||
}else unregistered.push({instance,menu:null,requiresUpdate:true})
|
||||
})
|
||||
|
||||
menus.forEach((menu) => {
|
||||
//menu does not exist in the manager (only append to unused when type == 'message'|'user')
|
||||
const universalCmd = this.comparator.convertMenu(menu)
|
||||
if (!universalCmd) return
|
||||
unused.push({instance:null,menu:universalCmd,requiresUpdate:false})
|
||||
})
|
||||
|
||||
return {registered,unregistered,unused}
|
||||
}
|
||||
/**Create all context menus that are not registered yet.*/
|
||||
async createNewMenus(instances:ODContextMenu[],progress?:ODManualProgressBar){
|
||||
if (!this.manager.ready) throw new ODSystemError("Client isn't ready yet! Unable to register context menus!")
|
||||
if (instances.length > 0 && progress){
|
||||
progress.max = instances.length
|
||||
progress.start()
|
||||
}
|
||||
|
||||
for (const instance of instances){
|
||||
await this.createMenu(instance)
|
||||
this.#debug.debug("Created new context menu",[
|
||||
{key:"id",value:instance.id.value},
|
||||
{key:"name",value:instance.name},
|
||||
{key:"type",value:(instance.builder.type == discord.ApplicationCommandType.Message) ? "message-context" : "user-context"}
|
||||
])
|
||||
if (progress) progress.increase(1)
|
||||
}
|
||||
}
|
||||
/**Update all context menus that are already registered. */
|
||||
async updateExistingMenus(instances:ODContextMenu[],progress?:ODManualProgressBar){
|
||||
if (!this.manager.ready) throw new ODSystemError("Client isn't ready yet! Unable to register context menus!")
|
||||
if (instances.length > 0 && progress){
|
||||
progress.max = instances.length
|
||||
progress.start()
|
||||
}
|
||||
|
||||
for (const instance of instances){
|
||||
await this.createMenu(instance)
|
||||
this.#debug.debug("Updated existing context menu",[
|
||||
{key:"id",value:instance.id.value},
|
||||
{key:"name",value:instance.name},
|
||||
{key:"type",value:(instance.builder.type == discord.ApplicationCommandType.Message) ? "message-context" : "user-context"}
|
||||
])
|
||||
if (progress) progress.increase(1)
|
||||
}
|
||||
}
|
||||
/**Remove all context menus that are registered but unused by Open Ticket. */
|
||||
async removeUnusedMenus(instances:ODContextMenuUniversalMenu[],guildId?:string,progress?:ODManualProgressBar){
|
||||
if (!this.manager.ready) throw new ODSystemError("Client isn't ready yet! Unable to register context menus!")
|
||||
if (!this.commandManager) throw new ODSystemError("Couldn't get client application to register context menus!")
|
||||
if (instances.length > 0 && progress){
|
||||
progress.max = instances.length
|
||||
progress.start()
|
||||
}
|
||||
|
||||
const menus = await this.commandManager.fetch({guildId})
|
||||
|
||||
for (const instance of instances){
|
||||
const menu = menus.find((menu) => menu.name == instance.name)
|
||||
if (menu){
|
||||
try {
|
||||
await menu.delete()
|
||||
this.#debug.debug("Removed existing context menu",[
|
||||
{key:"name",value:menu.name},
|
||||
{key:"guildId",value:guildId ?? "/"},
|
||||
{key:"type",value:(instance.type == discord.ApplicationCommandType.Message) ? "message-context" : "user-context"}
|
||||
])
|
||||
}catch(err){
|
||||
process.emit("uncaughtException",err)
|
||||
throw new ODSystemError("Failed to delete context menu '"+menu.name+"'!")
|
||||
}
|
||||
}
|
||||
if (progress) progress.increase(1)
|
||||
}
|
||||
}
|
||||
/**Create a context menu. **(SYSTEM ONLY)** => Use `ODContextMenuManager` for registering context menu's the default way! */
|
||||
async createMenu(menu:ODContextMenu){
|
||||
if (!this.commandManager) throw new ODSystemError("Couldn't get client application to register context menu's!")
|
||||
try {
|
||||
await this.commandManager.create(menu.builder,(menu.guildId ?? undefined))
|
||||
}catch(err){
|
||||
process.emit("uncaughtException",err)
|
||||
throw new ODSystemError("Failed to register context menu '"+menu.name+"'!")
|
||||
}
|
||||
}
|
||||
/**Start listening to the discord.js client `interactionCreate` event. */
|
||||
startListeningToInteractions(){
|
||||
this.manager.client.on("interactionCreate",(interaction) => {
|
||||
//return when not in main server or DM
|
||||
if (!this.manager.mainServer || (interaction.guild && interaction.guild.id != this.manager.mainServer.id)) return
|
||||
|
||||
if (!interaction.isContextMenuCommand()) return
|
||||
const menu = this.getFiltered((menu) => menu.name == interaction.commandName)[0]
|
||||
if (!menu) return
|
||||
|
||||
this.#interactionListeners.forEach((listener) => {
|
||||
if (typeof listener.name == "string" && (interaction.commandName != listener.name)) return
|
||||
else if (listener.name instanceof RegExp && !listener.name.test(interaction.commandName)) return
|
||||
|
||||
//this is a valid listener
|
||||
listener.callback(interaction,menu)
|
||||
})
|
||||
})
|
||||
}
|
||||
/**Callback on interaction from one or multiple context menu's. */
|
||||
onInteraction(menuName:string|RegExp, callback:ODContextMenuInteractionCallback){
|
||||
this.#interactionListeners.push({
|
||||
name:menuName,
|
||||
callback
|
||||
})
|
||||
|
||||
if (this.#interactionListeners.length > this.listenerLimit){
|
||||
this.#debug.console.log("Possible context menu interaction memory leak detected!","warning",[
|
||||
{key:"listeners",value:this.#interactionListeners.length.toString()}
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**## ODContextMenuUpdateFunction `type`
|
||||
* The function responsible for updating context menu's when they already exist.
|
||||
*/
|
||||
export type ODContextMenuUpdateFunction = (menu:ODContextMenuUniversalMenu) => boolean
|
||||
|
||||
/**## ODContextMenu `class`
|
||||
* This is an Open Ticket context menu.
|
||||
*
|
||||
* When registered, you can listen for this context menu using the `ODContextResponder`. The advantages of using this class for creating a context menu are:
|
||||
* - automatic registration in discord.js
|
||||
* - error reporting to the user when the bot fails to respond
|
||||
* - plugins can extend this context menu
|
||||
* - the bot won't re-register the context menu when it already exists (except when requested)!
|
||||
*
|
||||
* And more!
|
||||
*/
|
||||
export class ODContextMenu extends ODManagerData {
|
||||
/**The discord.js builder for this context menu. */
|
||||
builder: ODContextMenuBuilder
|
||||
/**The id of the guild this context menu is for. `null` when not set. */
|
||||
guildId: string|null
|
||||
/**Function to check if the context menu requires to be updated (when it already exists). */
|
||||
requiresUpdate: ODContextMenuUpdateFunction|null = null
|
||||
|
||||
constructor(id:ODValidId, builder:ODContextMenuBuilder, requiresUpdate?:ODContextMenuUpdateFunction, guildId?:string){
|
||||
super(id)
|
||||
if (builder.type != discord.ApplicationCommandType.Message && builder.type != discord.ApplicationCommandType.User) throw new ODSystemError("ApplicationCommandData is required to be the 'Message'|'User' type!")
|
||||
|
||||
this.builder = builder
|
||||
this.guildId = guildId ?? null
|
||||
this.requiresUpdate = requiresUpdate ?? null
|
||||
}
|
||||
|
||||
/**The name of this context menu. */
|
||||
get name(): string {
|
||||
return this.builder.name
|
||||
}
|
||||
set name(name:string){
|
||||
this.builder.name = name
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,14 @@ export interface ODDefaults {
|
||||
forceSlashCommandRegistration:boolean,
|
||||
/**When enabled, the bot is allowed to unregister all slash commands which aren't used in Open Ticket. Disable this if you don't want to use the Open Ticket `ODSlashCommand` classes. */
|
||||
allowSlashCommandRemoval:boolean,
|
||||
/**Load the default Open Ticket context menus. */
|
||||
contextMenuLoading:boolean,
|
||||
/**Load the default Open Ticket context menu registerer (register menus in discord). */
|
||||
contextMenuRegistering:boolean,
|
||||
/**When enabled, the bot is forced to re-register all context menus in the server. This can be used in case of a auto-update malfunction. */
|
||||
forceContextMenuRegistration:boolean,
|
||||
/**When enabled, the bot is allowed to unregister all context menus which aren't used in Open Ticket. Disable this if you don't want to use the Open Ticket `ODContextMenu` classes. */
|
||||
allowContextMenuRemoval:boolean,
|
||||
/**Load the default Open Ticket text commands. */
|
||||
textCommandLoading:boolean,
|
||||
|
||||
@@ -277,6 +285,10 @@ export class ODDefaultsManager {
|
||||
slashCommandRegistering:true,
|
||||
forceSlashCommandRegistration:false,
|
||||
allowSlashCommandRemoval:true,
|
||||
contextMenuLoading:true,
|
||||
contextMenuRegistering:true,
|
||||
forceContextMenuRegistration:false,
|
||||
allowContextMenuRemoval:true,
|
||||
textCommandLoading:true,
|
||||
|
||||
questionLoading:true,
|
||||
|
||||
Reference in New Issue
Block a user