Move SlashCommands and SlashCommands-test into subdirs (#31979)

* Move SlashCommands into slash-commands

* Move SlashCommands test prep into a function that can be re-used from multiple files

* Move slash command tests into separate files

* Fix super-linear regexes and test some more slash commands

* Move splitAtFirstSpace tests into a separate file

* Test for parseCommandString

* Make parseCommandString able to handle leading whitespace and tabs

* Implement parseCommandString using splitAtFirstSpace

* Extract emoticons slash commands into a separate file and share their code
This commit is contained in:
Andy Balaam
2026-02-09 11:08:51 +00:00
committed by GitHub
parent 6589e0b6df
commit 562f7cc2bd
33 changed files with 904 additions and 561 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ import AutocompleteProvider from "./AutocompleteProvider";
import QueryMatcher from "./QueryMatcher";
import { TextualCompletion } from "./Components";
import { type ICompletion, type ISelectionRange } from "./Autocompleter";
import { type Command, Commands, CommandMap } from "../SlashCommands";
import { type Command, Commands, CommandMap } from "../slash-commands/SlashCommands";
import { type TimelineRenderingType } from "../contexts/RoomContext";
import { MatrixClientPeg } from "../MatrixClientPeg";
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { _t } from "../../../languageHandler";
import { type Command, CommandCategories, Commands } from "../../../SlashCommands";
import { type Command, CommandCategories, Commands } from "../../../slash-commands/SlashCommands";
import InfoDialog from "./InfoDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -29,7 +29,7 @@ import { parseEvent, parsePlainTextMessage } from "../../../editor/deserialize";
import { renderModel } from "../../../editor/render";
import SettingsStore from "../../../settings/SettingsStore";
import { IS_MAC, Key } from "../../../Keyboard";
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
import { CommandCategories, CommandMap, parseCommandString } from "../../../slash-commands/SlashCommands";
import Range from "../../../editor/range";
import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar";
import type DocumentOffset from "../../../editor/offset";
@@ -27,7 +27,7 @@ import { parseEvent } from "../../../editor/deserialize";
import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } from "../../../editor/parts";
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandCategories } from "../../../SlashCommands";
import { CommandCategories } from "../../../slash-commands/SlashCommands";
import { Action } from "../../../dispatcher/actions";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import SendHistoryManager from "../../../SendHistoryManager";
@@ -36,7 +36,7 @@ import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } from "../../../editor/parts";
import { findEditableEvent } from "../../../utils/EventUtils";
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from "../../../SlashCommands";
import { CommandCategories } from "../../../slash-commands/SlashCommands";
import ContentMessages from "../../../ContentMessages";
import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions";
@@ -29,7 +29,7 @@ import { endEditing, cancelPreviousPendingEdit } from "./editing";
import type EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { createMessageContent, EMOTE_PREFIX } from "./createMessageContent";
import { isContentModified } from "./isContentModified";
import { CommandCategories, getCommand } from "../../../../../SlashCommands";
import { CommandCategories, getCommand } from "../../../../../slash-commands/SlashCommands";
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
import { Action } from "../../../../../dispatcher/actions";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
+1 -1
View File
@@ -13,7 +13,7 @@ import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
import type EditorModel from "./model";
import { Type } from "./parts";
import { type Command, CommandCategories, getCommand } from "../SlashCommands";
import { type Command, CommandCategories, getCommand } from "../slash-commands/SlashCommands";
import { UserFriendlyError, _t, _td } from "../languageHandler";
import Modal from "../Modal";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
@@ -21,45 +21,46 @@ import {
import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types";
import dis from "./dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "./languageHandler";
import Modal from "./Modal";
import MultiInviter from "./utils/MultiInviter";
import { Linkify, topicToHtml } from "./HtmlUtils";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import WidgetUtils from "./utils/WidgetUtils";
import { textToHtmlRainbow } from "./utils/colour";
import { AddressType, getAddressType } from "./UserAddress";
import { abbreviateUrl } from "./utils/UrlUtils";
import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "./utils/IdentityServerUtils";
import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi";
import BugReportDialog from "./components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "./createRoom";
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { Action } from "./dispatcher/actions";
import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore";
import { UIComponent, UIFeature } from "./settings/UIFeature";
import { CHAT_EFFECTS } from "./effects";
import LegacyCallHandler from "./LegacyCallHandler";
import { guessAndSetDMRoom } from "./Rooms";
import DevtoolsDialog from "./components/views/dialogs/DevtoolsDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
import { TimelineRenderingType } from "./contexts/RoomContext";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { isCurrentLocalRoom, reject, singleMxcUpload, success, successSync } from "./slash-commands/utils";
import { deop, op } from "./slash-commands/op";
import { CommandCategories } from "./slash-commands/interface";
import { Command } from "./slash-commands/command";
import { goto, join } from "./slash-commands/join";
import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog";
import upgraderoom from "./slash-commands/upgraderoom/upgraderoom";
import dis from "../dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "../languageHandler";
import Modal from "../Modal";
import MultiInviter from "../utils/MultiInviter";
import { Linkify, topicToHtml } from "../HtmlUtils";
import QuestionDialog from "../components/views/dialogs/QuestionDialog";
import WidgetUtils from "../utils/WidgetUtils";
import { textToHtmlRainbow } from "../utils/colour";
import { AddressType, getAddressType } from "../UserAddress";
import { abbreviateUrl } from "../utils/UrlUtils";
import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../utils/IdentityServerUtils";
import { WidgetType } from "../widgets/WidgetType";
import { Jitsi } from "../widgets/Jitsi";
import BugReportDialog from "../components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "../createRoom";
import { type ViewUserPayload } from "../dispatcher/payloads/ViewUserPayload";
import { Action } from "../dispatcher/actions";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import { UIComponent, UIFeature } from "../settings/UIFeature";
import { CHAT_EFFECTS } from "../effects";
import LegacyCallHandler from "../LegacyCallHandler";
import { guessAndSetDMRoom } from "../Rooms";
import DevtoolsDialog from "../components/views/dialogs/DevtoolsDialog";
import InfoDialog from "../components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "../components/views/dialogs/SlashCommandHelpDialog";
import { shouldShowComponent } from "../customisations/helpers/UIComponents";
import { TimelineRenderingType } from "../contexts/RoomContext";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { htmlSerializeFromMdIfNeeded } from "../editor/serialize";
import { leaveRoomBehaviour } from "../utils/leave-behaviour";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { isCurrentLocalRoom, reject, singleMxcUpload, success, successSync } from "./utils";
import { deop, op } from "./op";
import { CommandCategories } from "./interface";
import { Command } from "./command";
import { goto, join } from "./join";
import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog";
import upgraderoom from "./upgraderoom/upgraderoom";
import { emoticon } from "./emoticon";
export { CommandCategories, Command };
@@ -73,58 +74,10 @@ export const Commands = [
},
category: CommandCategories.messages,
}),
new Command({
command: "shrug",
args: "<message>",
description: _td("slash_command|shrug"),
runFn: function (cli, roomId, threadId, args) {
let message = "¯\\_(ツ)_/¯";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: "tableflip",
args: "<message>",
description: _td("slash_command|tableflip"),
runFn: function (cli, roomId, threadId, args) {
let message = "(╯°□°)╯︵ ┻━┻";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: "unflip",
args: "<message>",
description: _td("slash_command|unflip"),
runFn: function (cli, roomId, threadId, args) {
let message = "┬──┬ ( ゜-゜ノ)";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
command: "lenny",
args: "<message>",
description: _td("slash_command|lenny"),
runFn: function (cli, roomId, threadId, args) {
let message = "( ͡° ͜ʖ ͡°)";
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
emoticon("shrug", _td("slash_command|shrug"), "¯\\_(ツ)_/¯"),
emoticon("tableflip", _td("slash_command|tableflip"), "(╯°□°)╯︵ ┻━┻"),
emoticon("unflip", _td("slash_command|unflip"), "┬──┬ ( ゜-゜ノ)"),
emoticon("lenny", _td("slash_command|lenny"), "( ͡° ͜ʖ ͡°)"),
new Command({
command: "plain",
args: "<message>",
@@ -348,7 +301,7 @@ export const Commands = [
isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const [address, reason] = args.split(/\s+(.+)/);
const [address, reason] = splitAtFirstSpace(args);
if (address) {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
@@ -460,9 +413,9 @@ export const Commands = [
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(cli.kick(roomId, matches[1], matches[3]));
const [userId, reason] = splitAtFirstSpace(args);
if (userId) {
return success(cli.kick(roomId, userId, reason));
}
}
return reject(this.getUsage());
@@ -477,9 +430,9 @@ export const Commands = [
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(cli.ban(roomId, matches[1], matches[3]));
const [userId, reason] = splitAtFirstSpace(args);
if (userId) {
return success(cli.ban(roomId, userId, reason));
}
}
return reject(this.getUsage());
@@ -784,9 +737,8 @@ export const Commands = [
runFn: function (cli, roomId, threadId, args) {
if (args) {
// matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
if (matches) {
const [userId, msg] = matches.slice(1);
const [userId, msg] = splitAtFirstSpace(args);
if (userId !== "") {
if (userId && userId.startsWith("@") && userId.includes(":")) {
return success(
(async (): Promise<void> => {
@@ -910,21 +862,24 @@ Commands.forEach((cmd) => {
});
});
/**
* If the supplied input starts with "/<command>", returns an object with these
* properties:
*
* cmd - the string following the / up to some whitespace
* args - the string (if any) after first whitespace
*
* If not, returns {}
*/
export function parseCommandString(input: string): { cmd?: string; args?: string } {
// trim any trailing whitespace, as it can confuse the parser for IRC-style commands
input = input.trimEnd();
if (!input.startsWith("/")) return {}; // not a command
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
let cmd: string;
let args: string | undefined;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[2];
} else {
cmd = input;
const trimmedInput = input.trimStart();
if (trimmedInput.charAt(0) !== "/") {
return {};
}
const withoutSlash = trimmedInput.slice(1);
const [cmd, args] = splitAtFirstSpace(withoutSlash);
return { cmd, args };
}
@@ -933,6 +888,26 @@ interface ICmd {
args?: string;
}
/**
* Split the supplied string into one or two strings separated by the first
* region of white space we can find.
*/
export function splitAtFirstSpace(args: string): [string, string?] {
const trimmedArgs = args.trim();
const i = trimmedArgs.search(/\s+/);
if (i === -1) {
return [trimmedArgs];
} else {
const first = trimmedArgs.slice(0, i);
const second = trimmedArgs.slice(i + 1).trimStart();
if (second === "") {
return [first];
} else {
return [first, second];
}
}
}
/**
* Process the given text for /commands and returns a parsed command that can be used for running the operation.
* @param {string} roomId The room ID where the command was issued.
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { ContentHelpers } from "matrix-js-sdk/src/matrix";
import { Command } from "./command";
import { successSync } from "./utils";
import { CommandCategories } from "./interface";
export function emoticon(command: string, description: TranslationKey, message: string): Command {
return new Command({
command,
args: "<message>",
description,
runFn: function (_cli, _roomId, _threadId, args) {
if (args) {
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
});
}