import ChatCommand from "./chatCommand.mjs"; /** * Registry for chat commands and utility methods. */ class ChatCommands { /** * The map of currently registered commands and their aliases. Each alias has a separate entry that points to the * same @see ChatCommand instance. * @type {Map.} */ commands = new Map(); /** * A set of valid command start characters. These are used to quickly verify whether a command is present. * @type {Set.} * @private */ startChars = new Set(); /** * Attaches the API to the game instance and registers a hook to handle chat messages. * @package */ static initialize() { game.chatCommands = new ChatCommands(); game.modules.get("_chatcommands").api = game.chatCommands; Hooks.on("chatMessage", (chat, message, data) => game.chatCommands.handleMessage(chat, message, data)); Hooks.on('renderChatLog', (_, html) => { const loader = document.createElement("div"); loader.id = "chatcommand-loading"; loader.dataset.active = 0; html[0].querySelector("#chat-message").before(loader); }); } /** * Returns the class implementing a single chat command. * @returns The @see ChatCommand class. */ get commandClass() { return ChatCommand; } /** * Registers a single chat command using its data. * @see ChatCommand.constructor for valid fields. * @param {object|ChatCommand} command The command object to register. * @param {boolean=} override Force the new command to override existing entries. Defaults to false. */ register(command, override = false) { if (command.commandKey) { command.name = command.commandKey; console.warn("Chat Commander | The commandKey property is deprecated. Please use the newer name property."); } if (command.iconClass) { command.icon = ``; console.warn("Chat Commander | The iconClass property is deprecated. Please use the newer icon property."); } if (!command.module) { command.module = "Unknown"; console.warn(`Chat Commander | Command ${command.name} does not have a module property (should be module ID).`); } if (command.gmOnly) { command.requiredRole = "GAMEMASTER"; console.warn("Chat Commander | The gmOnly property is deprecated. Please use the newer requiredRole property."); } if (command.invokeOnCommand) { console.warn("Chat Commander | The invokeOnCommand property is deprecated. Please use the newer callback property."); command.callback = async (chat, parameters, messageData) => { let text = command.invokeOnCommand(chat, parameters.trimEnd(), messageData); if (text instanceof Promise) text = await text; if (!command.shouldDisplayToChat) return {}; return { content: text, type: command.createdMessageType ?? messageData.createdMessageType } } } if (!(command instanceof ChatCommand)) command = new ChatCommand(command); command.names.forEach(c => { const existing = this.commands.get(c); if (existing) { if (override || existing.module === "core") { // Allow force override or replacing core commands. console.info(`Chat Commander | Overriding existing command ${c}.`); } else if (c === command.name) { if (c === existing.name) { // Both commands are original names, use a namespace to disambiguate. console.warn(`Chat Commander | Using namespace for command ${c} due to conflict.`); command.name = c = c[0] + command.module + "." + command.name.substring(1); } else { // Allow replacing aliases. console.warn(`Chat Commander | Overriding alias ${c} with new command.`); existing.removeAlias(c); } } else { // Prevent aliases from replacing commands. console.warn(`Chat Commander | Prevented alias override for command ${c}.`); command.removeAlias(c); return; } } this.commands.set(c, command); this.startChars.add(c[0]); }); console.info(`Chat Commander | Module ${command.module} registered command ${command.name} with ${command.aliases.length} aliases.`); } /** * Unregisters the given chat command and its aliases. * @param {string|ChatCommand} name The name of the command or the command itself. */ unregister(name) { const command = typeof (name) === "string" ? this.commands.get(name.toLowerCase()) : name; if (!command) return; command.names.forEach(c => this.commands.delete(c)); console.info(`Chat Commander | Unregistered command ${command.name} with ${command.aliases.length}`); } /** * Creates a selectable list entry for a single command. * @param {string} command The command that the entry applies when it is selected. * @param {string} content An HTML string containing the displayed entry. * @returns {HTMLElement} An HTML element containing a selectable command entry. */ createCommandElement(command, content) { const el = document.createElement("li"); el.dataset.command = command; el.classList.add("context-item", "command"); el.tabIndex = 0; el.innerHTML = content; return el; } /** * Creates a non-selectable list entry for displaying additional information. * @param {string} content An HTML string containing the displayed entry. * @returns {HTMLElement} An HTML element containing an informational entry. */ createInfoElement(content) { const el = document.createElement("li"); el.classList.add("context-item", "info"); el.disabled = true; el.innerHTML = content; return el; } /** * Creates a list entry displaying a separator. * @returns {HTMLElement} An HTML element containing a separator entry. */ createSeparatorElement() { const el = document.createElement("li"); el.classList.add("context-item", "separator"); el.disabled = true; el.appendChild(document.createElement("hr")); return el; } /** * Creates a list entry displaying a loading indicator. * @returns {HTMLElement} An HTML element containing a loading entry. */ createLoadingElement() { const el = document.createElement("li"); el.classList.add("context-item", "loading"); el.disabled = true; return el; } /** * Creates an informational element indicating that more results are available. * @returns {HTMLElement} An HTML element containing the overflow hint. */ createOverflowElement() { return this.createInfoElement(`

${game.i18n.localize("_chatcommands.extraEntries")}

`); } /** * Checks if the given text might contain a command. Note that this only checks if the first character is a command * character, not that a command with that input actually exists. * @param {string} text The text to check. * @returns {boolean} True if the input might be a command, false otherwise. */ isCommand(text) { return text && this.startChars.has(text[0].toLowerCase()); } /** * Parses the given text to find a command and its parameters. * @param {string} text The text to search for commands in. * @returns {object?} An object containing the command itself, the used alias and the parameters (or null if no * command was found). */ parseCommand(text) { if (!this.isCommand(text)) return null; // Check for single character commands. const separator = game.chatCommands.commands.has(text[0].toLowerCase()) ? 1 : text.indexOf(' '); // Extract the name of the command. let alias = text.toLowerCase(); if (separator !== -1) alias = alias.substring(0, separator); if (alias === "") return null; // Look for a command matching the name. const command = this.commands.get(alias); if (!command) return null; // Extract parameters. return { command, alias, parameters: separator > 0 ? text.substring(alias.length === 1 ? separator : separator + 1) : "" }; } /** * Processes a chat message to check if it contains a command. If so, a permission check is performed, the command * is invoked and the invokeChatCommand hook is called. The result indicates whether a message will be sent. * @param {ChatLog} chat The chat that emitted the message. * @param {string} message The content of the message to send. * @param {object} messageData The data of the message to send. * @returns {boolean?} False if the command was handled, undefined otherwise. */ handleMessage(chat, message, messageData) { const commandInfo = this.parseCommand(message); if (!commandInfo) return; if (!commandInfo.command.canInvoke()) { ui.notifications.error(game.i18n.format("_chatcommands.insufficientPermissions", { command: commandInfo.alias })); return false; } // Invoke the command with its parameters. let result = commandInfo.command.invoke(chat, commandInfo.parameters, messageData); if (result instanceof Promise) { const loader = chat.element[0].querySelector("#chatcommand-loading"); loader.dataset.active++; result.then(r => this._resumeMessageHandling(chat, commandInfo.command, commandInfo.parameters, r ?? {}, messageData)) .finally(() => loader.dataset.active--); return false; } else { return this._resumeMessageHandling(chat, commandInfo.command, commandInfo.parameters, result, messageData); } } /** * Resumes asynchronous processing of a chat message that contains a command. * @param {ChatLog} chat The chat that emitted the message. * @param {ChatCommand} command The command that was invoked by the message. * @param {string} parameters The parameters of the command invocation. * @param {object?} result The result of the command invocation. * @param {object} messageData The data of the message to send. * @returns {boolean?} False if the hook listeners prevented core handling, undefined otherwise. * @private */ _resumeMessageHandling(chat, command, parameters, result, messageData) { // Handle internally or forward to FoundryVTT core. const options = { handleCore: !result }; result ??= {}; // Allow hook listeners to add content even if the command returned none. Hooks.callAll("invokeChatCommand", chat, command, parameters, result, options); if (result.content) { result.content = result.content.replace(/\n/g, "
"); getDocumentClass("ChatMessage").create(foundry.utils.mergeObject(messageData, result)); } else if (options.handleCore) return; return false; } /** @deprecated */ registerCommand(command) { console.warn("Chat Commander | The registerCommand method is deprecated. Please use the newer register method."); this.register(command); } /** @deprecated */ deregisterCommand(command) { console.warn("Chat Commander | The deregisterCommand method is deprecated." + " Please use the newer unregister method."); this.unregister(command.commandKey ?? command.name); } /** @deprecated */ createCommand(commandKey, shouldDisplayToChat, invokeOnCommand, createdMessageType = 0) { console.warn("Chat Commander | The createCommand method is obsolete." + " Please create a command data object and pass it directly to the register method."); return { name: commandKey, shouldDisplayToChat, invokeOnCommand, createdMessageType, iconClass: "", description: "" }; } /** @deprecated */ createCommandFromData(data) { console.warn("Chat Commander | The createCommandFromData method is obsolete." + " Please pass the command data directly to the register method."); data.name ??= data.commandKey; data.shouldDisplayToChat ??= false; data.createdMessageType ??= 0; data.iconClass ??= ""; data.description ??= ""; data.requiredRole ??= "NONE"; return data; } } export default ChatCommands;