(Open Discord) Added support for context menu's

This commit is contained in:
DJj123dj
2025-06-20 18:20:27 +02:00
parent b2f2ef3405
commit 2ea38cd7a5
9 changed files with 513 additions and 4 deletions
+389 -2
View File
@@ -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
}
}
+12
View File
@@ -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,