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.<string, ChatCommand>}
|
|
*/
|
|
commands = new Map();
|
|
|
|
/**
|
|
* A set of valid command start characters. These are used to quickly verify whether a command is present.
|
|
* @type {Set.<string>}
|
|
* @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 = `<i class="fas ${command.iconClass}"></i>`;
|
|
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(`<p class="notes">${game.i18n.localize("_chatcommands.extraEntries")}</p>`);
|
|
}
|
|
|
|
/**
|
|
* 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, "<br>");
|
|
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;
|