All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

320 lines
13 KiB

  1. import ChatCommand from "./chatCommand.mjs";
  2. /**
  3. * Registry for chat commands and utility methods.
  4. */
  5. class ChatCommands {
  6. /**
  7. * The map of currently registered commands and their aliases. Each alias has a separate entry that points to the
  8. * same @see ChatCommand instance.
  9. * @type {Map.<string, ChatCommand>}
  10. */
  11. commands = new Map();
  12. /**
  13. * A set of valid command start characters. These are used to quickly verify whether a command is present.
  14. * @type {Set.<string>}
  15. * @private
  16. */
  17. startChars = new Set();
  18. /**
  19. * Attaches the API to the game instance and registers a hook to handle chat messages.
  20. * @package
  21. */
  22. static initialize() {
  23. game.chatCommands = new ChatCommands();
  24. game.modules.get("_chatcommands").api = game.chatCommands;
  25. Hooks.on("chatMessage", (chat, message, data) => game.chatCommands.handleMessage(chat, message, data));
  26. Hooks.on('renderChatLog', (_, html) => {
  27. const loader = document.createElement("div");
  28. loader.id = "chatcommand-loading";
  29. loader.dataset.active = 0;
  30. html[0].querySelector("#chat-message").before(loader);
  31. });
  32. }
  33. /**
  34. * Returns the class implementing a single chat command.
  35. * @returns The @see ChatCommand class.
  36. */
  37. get commandClass() {
  38. return ChatCommand;
  39. }
  40. /**
  41. * Registers a single chat command using its data.
  42. * @see ChatCommand.constructor for valid fields.
  43. * @param {object|ChatCommand} command The command object to register.
  44. * @param {boolean=} override Force the new command to override existing entries. Defaults to false.
  45. */
  46. register(command, override = false) {
  47. if (command.commandKey) {
  48. command.name = command.commandKey;
  49. console.warn("Chat Commander | The commandKey property is deprecated. Please use the newer name property.");
  50. }
  51. if (command.iconClass) {
  52. command.icon = `<i class="fas ${command.iconClass}"></i>`;
  53. console.warn("Chat Commander | The iconClass property is deprecated. Please use the newer icon property.");
  54. }
  55. if (!command.module) {
  56. command.module = "Unknown";
  57. console.warn(`Chat Commander | Command ${command.name} does not have a module property (should be module ID).`);
  58. }
  59. if (command.gmOnly) {
  60. command.requiredRole = "GAMEMASTER";
  61. console.warn("Chat Commander | The gmOnly property is deprecated. Please use the newer requiredRole property.");
  62. }
  63. if (command.invokeOnCommand) {
  64. console.warn("Chat Commander | The invokeOnCommand property is deprecated. Please use the newer callback property.");
  65. command.callback = async (chat, parameters, messageData) => {
  66. let text = command.invokeOnCommand(chat, parameters.trimEnd(), messageData);
  67. if (text instanceof Promise) text = await text;
  68. if (!command.shouldDisplayToChat) return {};
  69. return {
  70. content: text,
  71. type: command.createdMessageType ?? messageData.createdMessageType
  72. }
  73. }
  74. }
  75. if (!(command instanceof ChatCommand)) command = new ChatCommand(command);
  76. command.names.forEach(c => {
  77. const existing = this.commands.get(c);
  78. if (existing) {
  79. if (override || existing.module === "core") {
  80. // Allow force override or replacing core commands.
  81. console.info(`Chat Commander | Overriding existing command ${c}.`);
  82. } else if (c === command.name) {
  83. if (c === existing.name) {
  84. // Both commands are original names, use a namespace to disambiguate.
  85. console.warn(`Chat Commander | Using namespace for command ${c} due to conflict.`);
  86. command.name = c = c[0] + command.module + "." + command.name.substring(1);
  87. } else {
  88. // Allow replacing aliases.
  89. console.warn(`Chat Commander | Overriding alias ${c} with new command.`);
  90. existing.removeAlias(c);
  91. }
  92. } else {
  93. // Prevent aliases from replacing commands.
  94. console.warn(`Chat Commander | Prevented alias override for command ${c}.`);
  95. command.removeAlias(c);
  96. return;
  97. }
  98. }
  99. this.commands.set(c, command);
  100. this.startChars.add(c[0]);
  101. });
  102. console.info(`Chat Commander | Module ${command.module} registered command ${command.name} with ${command.aliases.length} aliases.`);
  103. }
  104. /**
  105. * Unregisters the given chat command and its aliases.
  106. * @param {string|ChatCommand} name The name of the command or the command itself.
  107. */
  108. unregister(name) {
  109. const command = typeof (name) === "string" ? this.commands.get(name.toLowerCase()) : name;
  110. if (!command) return;
  111. command.names.forEach(c => this.commands.delete(c));
  112. console.info(`Chat Commander | Unregistered command ${command.name} with ${command.aliases.length}`);
  113. }
  114. /**
  115. * Creates a selectable list entry for a single command.
  116. * @param {string} command The command that the entry applies when it is selected.
  117. * @param {string} content An HTML string containing the displayed entry.
  118. * @returns {HTMLElement} An HTML element containing a selectable command entry.
  119. */
  120. createCommandElement(command, content) {
  121. const el = document.createElement("li");
  122. el.dataset.command = command;
  123. el.classList.add("context-item", "command");
  124. el.tabIndex = 0;
  125. el.innerHTML = content;
  126. return el;
  127. }
  128. /**
  129. * Creates a non-selectable list entry for displaying additional information.
  130. * @param {string} content An HTML string containing the displayed entry.
  131. * @returns {HTMLElement} An HTML element containing an informational entry.
  132. */
  133. createInfoElement(content) {
  134. const el = document.createElement("li");
  135. el.classList.add("context-item", "info");
  136. el.disabled = true;
  137. el.innerHTML = content;
  138. return el;
  139. }
  140. /**
  141. * Creates a list entry displaying a separator.
  142. * @returns {HTMLElement} An HTML element containing a separator entry.
  143. */
  144. createSeparatorElement() {
  145. const el = document.createElement("li");
  146. el.classList.add("context-item", "separator");
  147. el.disabled = true;
  148. el.appendChild(document.createElement("hr"));
  149. return el;
  150. }
  151. /**
  152. * Creates a list entry displaying a loading indicator.
  153. * @returns {HTMLElement} An HTML element containing a loading entry.
  154. */
  155. createLoadingElement() {
  156. const el = document.createElement("li");
  157. el.classList.add("context-item", "loading");
  158. el.disabled = true;
  159. return el;
  160. }
  161. /**
  162. * Creates an informational element indicating that more results are available.
  163. * @returns {HTMLElement} An HTML element containing the overflow hint.
  164. */
  165. createOverflowElement() {
  166. return this.createInfoElement(`<p class="notes">${game.i18n.localize("_chatcommands.extraEntries")}</p>`);
  167. }
  168. /**
  169. * Checks if the given text might contain a command. Note that this only checks if the first character is a command
  170. * character, not that a command with that input actually exists.
  171. * @param {string} text The text to check.
  172. * @returns {boolean} True if the input might be a command, false otherwise.
  173. */
  174. isCommand(text) {
  175. return text && this.startChars.has(text[0].toLowerCase());
  176. }
  177. /**
  178. * Parses the given text to find a command and its parameters.
  179. * @param {string} text The text to search for commands in.
  180. * @returns {object?} An object containing the command itself, the used alias and the parameters (or null if no
  181. * command was found).
  182. */
  183. parseCommand(text) {
  184. if (!this.isCommand(text)) return null;
  185. // Check for single character commands.
  186. const separator = game.chatCommands.commands.has(text[0].toLowerCase()) ? 1 : text.indexOf(' ');
  187. // Extract the name of the command.
  188. let alias = text.toLowerCase();
  189. if (separator !== -1) alias = alias.substring(0, separator);
  190. if (alias === "") return null;
  191. // Look for a command matching the name.
  192. const command = this.commands.get(alias);
  193. if (!command) return null;
  194. // Extract parameters.
  195. return {
  196. command,
  197. alias,
  198. parameters: separator > 0 ? text.substring(alias.length === 1 ? separator : separator + 1) : ""
  199. };
  200. }
  201. /**
  202. * Processes a chat message to check if it contains a command. If so, a permission check is performed, the command
  203. * is invoked and the invokeChatCommand hook is called. The result indicates whether a message will be sent.
  204. * @param {ChatLog} chat The chat that emitted the message.
  205. * @param {string} message The content of the message to send.
  206. * @param {object} messageData The data of the message to send.
  207. * @returns {boolean?} False if the command was handled, undefined otherwise.
  208. */
  209. handleMessage(chat, message, messageData) {
  210. const commandInfo = this.parseCommand(message);
  211. if (!commandInfo) return;
  212. if (!commandInfo.command.canInvoke()) {
  213. ui.notifications.error(game.i18n.format("_chatcommands.insufficientPermissions", { command: commandInfo.alias }));
  214. return false;
  215. }
  216. // Invoke the command with its parameters.
  217. let result = commandInfo.command.invoke(chat, commandInfo.parameters, messageData);
  218. if (result instanceof Promise) {
  219. const loader = chat.element[0].querySelector("#chatcommand-loading");
  220. loader.dataset.active++;
  221. result.then(r =>
  222. this._resumeMessageHandling(chat, commandInfo.command, commandInfo.parameters, r ?? {}, messageData))
  223. .finally(() => loader.dataset.active--);
  224. return false;
  225. } else {
  226. return this._resumeMessageHandling(chat, commandInfo.command, commandInfo.parameters, result, messageData);
  227. }
  228. }
  229. /**
  230. * Resumes asynchronous processing of a chat message that contains a command.
  231. * @param {ChatLog} chat The chat that emitted the message.
  232. * @param {ChatCommand} command The command that was invoked by the message.
  233. * @param {string} parameters The parameters of the command invocation.
  234. * @param {object?} result The result of the command invocation.
  235. * @param {object} messageData The data of the message to send.
  236. * @returns {boolean?} False if the hook listeners prevented core handling, undefined otherwise.
  237. * @private
  238. */
  239. _resumeMessageHandling(chat, command, parameters, result, messageData) {
  240. // Handle internally or forward to FoundryVTT core.
  241. const options = { handleCore: !result };
  242. result ??= {}; // Allow hook listeners to add content even if the command returned none.
  243. Hooks.callAll("invokeChatCommand", chat, command, parameters, result, options);
  244. if (result.content) {
  245. result.content = result.content.replace(/\n/g, "<br>");
  246. getDocumentClass("ChatMessage").create(foundry.utils.mergeObject(messageData, result));
  247. } else if (options.handleCore) return;
  248. return false;
  249. }
  250. /** @deprecated */
  251. registerCommand(command) {
  252. console.warn("Chat Commander | The registerCommand method is deprecated. Please use the newer register method.");
  253. this.register(command);
  254. }
  255. /** @deprecated */
  256. deregisterCommand(command) {
  257. console.warn("Chat Commander | The deregisterCommand method is deprecated."
  258. + " Please use the newer unregister method.");
  259. this.unregister(command.commandKey ?? command.name);
  260. }
  261. /** @deprecated */
  262. createCommand(commandKey, shouldDisplayToChat, invokeOnCommand, createdMessageType = 0) {
  263. console.warn("Chat Commander | The createCommand method is obsolete."
  264. + " Please create a command data object and pass it directly to the register method.");
  265. return {
  266. name: commandKey,
  267. shouldDisplayToChat,
  268. invokeOnCommand,
  269. createdMessageType,
  270. iconClass: "",
  271. description: ""
  272. };
  273. }
  274. /** @deprecated */
  275. createCommandFromData(data) {
  276. console.warn("Chat Commander | The createCommandFromData method is obsolete."
  277. + " Please pass the command data directly to the register method.");
  278. data.name ??= data.commandKey;
  279. data.shouldDisplayToChat ??= false;
  280. data.createdMessageType ??= 0;
  281. data.iconClass ??= "";
  282. data.description ??= "";
  283. data.requiredRole ??= "NONE";
  284. return data;
  285. }
  286. }
  287. export default ChatCommands;