element-web/src/SlashCommands.tsx

1034 lines
42 KiB
TypeScript
Raw Normal View History

2015-09-18 12:54:20 +00:00
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
2015-09-18 12:54:20 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
2015-09-18 12:54:20 +00:00
*/
2022-12-12 11:24:14 +00:00
import * as React from "react";
import { ContentHelpers, Direction, EventType, IContent, MRoomTopicEventContent, User } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { KnownMembership, RoomMemberEventContent } from "matrix-js-sdk/src/types";
2022-12-12 11:24:14 +00:00
import dis from "./dispatcher/dispatcher";
import { _t, _td, UserFriendlyError } from "./languageHandler";
2022-12-12 11:24:14 +00:00
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";
2021-06-29 12:11:58 +00:00
import { textToHtmlRainbow } from "./utils/colour";
2022-12-12 11:24:14 +00:00
import { AddressType, getAddressType } from "./UserAddress";
import { abbreviateUrl } from "./utils/UrlUtils";
import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "./utils/IdentityServerUtils";
2020-04-09 21:25:11 +00:00
import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi";
import BugReportDialog from "./components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "./createRoom";
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { Action } from "./dispatcher/actions";
import SdkConfig from "./SdkConfig";
2020-08-24 08:43:41 +00:00
import SettingsStore from "./settings/SettingsStore";
import { UIComponent, UIFeature } from "./settings/UIFeature";
2021-06-29 12:11:58 +00:00
import { CHAT_EFFECTS } from "./effects";
Prepare for Element Call integration (#9224) * Improve accessibility and testability of Tooltip Adding a role to Tooltip was motivated by React Testing Library's reliance on accessibility-related attributes to locate elements. * Make the ReadyWatchingStore constructor safer The ReadyWatchingStore constructor previously had a chance to immediately call onReady, which was dangerous because it was potentially calling the derived class's onReady at a point when the derived class hadn't even finished construction yet. In normal usage, I guess this never was a problem, but it was causing some of the tests I was writing to crash. This is solved by separating out the onReady call into a start method. * Rename 1:1 call components to 'LegacyCall' to reflect the fact that they're slated for removal, and to not clash with the new Call code. * Refactor VideoChannelStore into Call and CallStore Call is an abstract class that currently only has a Jitsi implementation, but this will make it easy to later add an Element Call implementation. * Remove WidgetReady, ClientReady, and ForceHangupCall hacks These are no longer used by the new Jitsi call implementation, and can be removed. * yarn i18n * Delete call map entries instead of inserting nulls * Allow multiple active calls and consolidate call listeners * Fix a race condition when creating a video room * Un-hardcode the media device fallback labels * Apply misc code review fixes * yarn i18n * Disconnect from calls more politely on logout * Fix some strict mode errors * Fix another updateRoom race condition
2022-08-30 19:13:39 +00:00
import LegacyCallHandler from "./LegacyCallHandler";
2021-06-29 12:11:58 +00:00
import { guessAndSetDMRoom } from "./Rooms";
2022-12-12 11:24:14 +00:00
import { upgradeRoom } from "./utils/RoomUpgrade";
import DevtoolsDialog from "./components/views/dialogs/DevtoolsDialog";
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
2022-12-12 11:24:14 +00:00
import { TimelineRenderingType } from "./contexts/RoomContext";
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
2022-12-12 11:24:14 +00:00
import VoipUserMapper from "./VoipUserMapper";
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
2022-03-24 21:25:44 +00:00
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { getDeviceCryptoInfo } from "./utils/crypto/deviceInfo";
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";
export { CommandCategories, Command };
2017-05-23 08:44:11 +00:00
export const Commands = [
new Command({
2022-12-12 11:24:14 +00:00
command: "spoiler",
args: "<message>",
description: _td("slash_command|spoiler"),
runFn: function (cli, roomId, threadId, message = "") {
2022-12-12 11:24:14 +00:00
return successSync(ContentHelpers.makeHtmlMessage(message, `<span data-mx-spoiler>${message}</span>`));
},
category: CommandCategories.messages,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "shrug",
args: "<message>",
description: _td("slash_command|shrug"),
runFn: function (cli, roomId, threadId, args) {
2022-12-12 11:24:14 +00:00
let message = "¯\\_(ツ)_/¯";
if (args) {
2022-12-12 11:24:14 +00:00
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
2019-02-20 05:46:17 +00:00
},
category: CommandCategories.messages,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "tableflip",
args: "<message>",
description: _td("slash_command|tableflip"),
runFn: function (cli, roomId, threadId, args) {
2022-12-12 11:24:14 +00:00
let message = "(╯°□°)╯︵ ┻━┻";
if (args) {
2022-12-12 11:24:14 +00:00
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "unflip",
args: "<message>",
description: _td("slash_command|unflip"),
runFn: function (cli, roomId, threadId, args) {
2022-12-12 11:24:14 +00:00
let message = "┬──┬ ( ゜-゜ノ)";
if (args) {
2022-12-12 11:24:14 +00:00
message = message + " " + args;
}
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
2020-08-29 11:29:43 +00:00
new Command({
2022-12-12 11:24:14 +00:00
command: "lenny",
args: "<message>",
description: _td("slash_command|lenny"),
runFn: function (cli, roomId, threadId, args) {
2022-12-12 11:24:14 +00:00
let message = "( ͡° ͜ʖ ͡°)";
2020-08-29 11:29:43 +00:00
if (args) {
2022-12-12 11:24:14 +00:00
message = message + " " + args;
2020-08-29 11:29:43 +00:00
}
return successSync(ContentHelpers.makeTextMessage(message));
2020-08-29 11:29:43 +00:00
},
category: CommandCategories.messages,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "plain",
args: "<message>",
description: _td("slash_command|plain"),
runFn: function (cli, roomId, threadId, messages = "") {
return successSync(ContentHelpers.makeTextMessage(messages));
},
category: CommandCategories.messages,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "html",
args: "<message>",
description: _td("slash_command|html"),
runFn: function (cli, roomId, threadId, messages = "") {
return successSync(ContentHelpers.makeHtmlMessage(messages, messages));
},
category: CommandCategories.messages,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "upgraderoom",
args: "<new_version>",
description: _td("slash_command|upgraderoom"),
isEnabled: (cli) => !isCurrentLocalRoom(cli) && SettingsStore.getValue("developerMode"),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const room = cli.getRoom(roomId);
if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) {
return reject(new UserFriendlyError("slash_command|upgraderoom_permission_error"));
}
const { finished } = Modal.createDialog(
2022-12-12 11:24:14 +00:00
RoomUpgradeWarningDialog,
{ roomId: roomId, targetVersion: args },
/*className=*/ undefined,
2022-12-12 11:24:14 +00:00
/*isPriority=*/ false,
/*isStatic=*/ true,
);
2022-12-12 11:24:14 +00:00
return success(
finished.then(async ([resp]): Promise<void> => {
2022-12-12 11:24:14 +00:00
if (!resp?.continue) return;
await upgradeRoom(room, args, resp.invite);
}),
);
}
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "jumptodate",
args: "<YYYY-MM-DD>",
description: _td("slash_command|jumptodate"),
isEnabled: () => SettingsStore.getValue("feature_jump_to_date"),
runFn: function (cli, roomId, threadId, args) {
if (args) {
2022-12-12 11:24:14 +00:00
return success(
(async (): Promise<void> => {
2022-12-12 11:24:14 +00:00
const unixTimestamp = Date.parse(args);
if (!unixTimestamp) {
throw new UserFriendlyError("slash_command|jumptodate_invalid_input", {
inputDate: args,
cause: undefined,
});
2022-12-12 11:24:14 +00:00
}
2022-12-12 11:24:14 +00:00
const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent(
roomId,
unixTimestamp,
Direction.Forward,
);
logger.log(
`/timestamp_to_event: found ${eventId} (${originServerTs}) for timestamp=${unixTimestamp}`,
);
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: eventId,
highlighted: true,
room_id: roomId,
metricsTrigger: "SlashCommand",
metricsViaKeyboard: true,
});
})(),
);
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "nick",
args: "<display_name>",
description: _td("slash_command|nick"),
runFn: function (cli, roomId, threadId, args) {
if (args) {
return success(cli.setDisplayName(args));
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "myroomnick",
aliases: ["roomnick"],
args: "<display_name>",
description: _td("slash_command|myroomnick"),
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const ev = cli.getRoom(roomId)?.currentState.getStateEvents(EventType.RoomMember, cli.getSafeUserId());
const content: RoomMemberEventContent = {
...(ev ? ev.getContent() : { membership: KnownMembership.Join }),
displayname: args,
};
return success(cli.sendStateEvent(roomId, EventType.RoomMember, content, cli.getSafeUserId()));
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "roomavatar",
args: "[<mxc_url>]",
description: _td("slash_command|roomavatar"),
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
let promise = Promise.resolve(args ?? null);
if (!args) {
promise = singleMxcUpload(cli);
}
2022-12-12 11:24:14 +00:00
return success(
promise.then((url) => {
if (!url) return;
return cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, "");
2022-12-12 11:24:14 +00:00
}),
);
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "myroomavatar",
args: "[<mxc_url>]",
description: _td("slash_command|myroomavatar"),
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
const room = cli.getRoom(roomId);
const userId = cli.getSafeUserId();
let promise = Promise.resolve(args ?? null);
if (!args) {
promise = singleMxcUpload(cli);
}
2022-12-12 11:24:14 +00:00
return success(
promise.then((url) => {
if (!url) return;
const ev = room?.currentState.getStateEvents(EventType.RoomMember, userId);
const content: RoomMemberEventContent = {
...(ev ? ev.getContent() : { membership: KnownMembership.Join }),
2022-12-12 11:24:14 +00:00
avatar_url: url,
};
return cli.sendStateEvent(roomId, EventType.RoomMember, content, userId);
2022-12-12 11:24:14 +00:00
}),
);
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "myavatar",
args: "[<mxc_url>]",
description: _td("slash_command|myavatar"),
runFn: function (cli, roomId, threadId, args) {
let promise = Promise.resolve(args ?? null);
if (!args) {
promise = singleMxcUpload(cli);
}
2022-12-12 11:24:14 +00:00
return success(
promise.then((url) => {
if (!url) return;
return cli.setAvatarUrl(url);
2022-12-12 11:24:14 +00:00
}),
);
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "topic",
args: "[<topic>]",
description: _td("slash_command|topic"),
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false });
return success(cli.setRoomTopic(roomId, args, html));
}
const room = cli.getRoom(roomId);
if (!room) {
return reject(
new UserFriendlyError("slash_command|topic_room_error", {
roomId,
cause: undefined,
}),
);
}
const content = room.currentState.getStateEvents("m.room.topic", "")?.getContent<MRoomTopicEventContent>();
const topic = !!content
? ContentHelpers.parseTopicContent(content)
: { text: _t("slash_command|topic_none") };
const body = topicToHtml(topic.text, topic.html, undefined, true);
Modal.createDialog(InfoDialog, {
title: room.name,
description: <Linkify>{body}</Linkify>,
hasCloseButton: true,
className: "markdown-body",
});
return success();
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "roomname",
args: "<name>",
description: _td("slash_command|roomname"),
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
return success(cli.setRoomName(roomId, args));
}
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "invite",
args: "<user-id> [<reason>]",
description: _td("slash_command|invite"),
analyticsName: "Invite",
isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const [address, reason] = args.split(/\s+(.+)/);
if (address) {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
// If we need an identity server but don't have one, things
// get a bit more complex here, but we try to show something
// meaningful.
let prom = Promise.resolve();
if (getAddressType(address) === AddressType.Email && !cli.getIdentityServerUrl()) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("slash_command|invite_3pid_use_default_is_title"),
2022-12-12 11:24:14 +00:00
description: (
<p>
{_t("slash_command|invite_3pid_use_default_is_title_description", {
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
})}
2022-12-12 11:24:14 +00:00
</p>
),
button: _t("action|continue"),
});
prom = finished.then(([useDefault]) => {
if (useDefault) {
setToDefaultIdentityServer(cli);
return;
}
throw new UserFriendlyError("slash_command|invite_3pid_needs_is_error");
});
} else {
return reject(new UserFriendlyError("slash_command|invite_3pid_needs_is_error"));
}
}
const inviter = new MultiInviter(cli, roomId);
2022-12-12 11:24:14 +00:00
return success(
prom
.then(() => {
return inviter.invite([address], reason);
2022-12-12 11:24:14 +00:00
})
.then(() => {
if (inviter.getCompletionState(address) !== "invited") {
const errorStringFromInviterUtility = inviter.getErrorText(address);
if (errorStringFromInviterUtility) {
throw new Error(errorStringFromInviterUtility);
} else {
throw new UserFriendlyError("slash_command|invite_failed", {
user: address,
roomId,
cause: undefined,
});
}
2022-12-12 11:24:14 +00:00
}
}),
);
}
2015-09-18 12:54:20 +00:00
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
goto,
join,
new Command({
2022-12-12 11:24:14 +00:00
command: "part",
args: "[<room-address>]",
description: _td("action|leave_room"),
analyticsName: "Part",
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
let targetRoomId: string | undefined;
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
2022-12-12 11:24:14 +00:00
if (roomAlias[0] !== "#") return reject(this.getUsage());
2022-12-12 11:24:14 +00:00
if (!roomAlias.includes(":")) {
roomAlias += ":" + cli.getDomain();
}
2015-09-18 12:54:20 +00:00
// Try to find a room with this alias
const rooms = cli.getRooms();
2022-12-12 11:24:14 +00:00
targetRoomId = rooms.find((room) => {
return room.getCanonicalAlias() === roomAlias || room.getAltAliases().includes(roomAlias);
})?.roomId;
if (!targetRoomId) {
return reject(
new UserFriendlyError("slash_command|part_unknown_alias", {
roomAlias,
cause: undefined,
}),
);
}
2017-05-23 08:44:11 +00:00
}
2015-09-18 12:54:20 +00:00
}
if (!targetRoomId) targetRoomId = roomId;
return success(leaveRoomBehaviour(cli, targetRoomId));
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "remove",
aliases: ["kick"],
2022-12-12 11:24:14 +00:00
args: "<user-id> [reason]",
description: _td("slash_command|remove"),
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]));
}
2015-09-18 12:54:20 +00:00
}
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "ban",
args: "<user-id> [reason]",
description: _td("slash_command|ban"),
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]));
}
2015-09-18 12:54:20 +00:00
}
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "unban",
args: "<user-id>",
description: _td("slash_command|unban"),
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
// Reset the user membership to "leave" to unban him
return success(cli.unban(roomId, matches[1]));
}
2015-09-18 12:54:20 +00:00
}
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "ignore",
args: "<user-id>",
description: _td("slash_command|ignore"),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/^(@[^:]+:\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
Modal.createDialog(InfoDialog, {
title: _t("slash_command|ignore_dialog_title"),
2022-12-12 11:24:14 +00:00
description: (
<div>
<p>{_t("slash_command|ignore_dialog_description", { userId })}</p>
2022-12-12 11:24:14 +00:00
</div>
),
});
}),
);
}
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "unignore",
args: "<user-id>",
description: _td("slash_command|unignore"),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/(^@[^:]+:\S+$)/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
const index = ignoredUsers.indexOf(userId);
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
Modal.createDialog(InfoDialog, {
title: _t("slash_command|unignore_dialog_title"),
2022-12-12 11:24:14 +00:00
description: (
<div>
<p>{_t("slash_command|unignore_dialog_description", { userId })}</p>
2022-12-12 11:24:14 +00:00
</div>
),
});
}),
);
}
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
}),
op,
deop,
new Command({
2022-12-12 11:24:14 +00:00
command: "devtools",
description: _td("slash_command|devtools"),
runFn: function (cli, roomId, threadRootId) {
Modal.createDialog(DevtoolsDialog, { roomId, threadRootId }, "mx_DevtoolsDialog_wrapper");
return success();
},
category: CommandCategories.advanced,
2017-07-31 11:08:28 +00:00
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "addwidget",
args: "<url | embed code | Jitsi url>",
description: _td("slash_command|addwidget"),
isEnabled: (cli) =>
2022-12-12 11:24:14 +00:00
SettingsStore.getValue(UIFeature.Widgets) &&
shouldShowComponent(UIComponent.AddIntegrations) &&
!isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, widgetUrl) {
if (!widgetUrl) {
return reject(new UserFriendlyError("slash_command|addwidget_missing_url"));
}
// Try and parse out a widget URL from iframes
if (widgetUrl.toLowerCase().startsWith("<iframe ")) {
const embed = new DOMParser().parseFromString(widgetUrl, "text/html").body;
if (embed?.childNodes?.length === 1) {
const iframe = embed.firstElementChild;
if (iframe?.tagName.toLowerCase() === "iframe") {
logger.log("Pulling URL out of iframe (embed code)");
if (!iframe.hasAttribute("src")) {
return reject(new UserFriendlyError("slash_command|addwidget_iframe_missing_src"));
}
widgetUrl = iframe.getAttribute("src")!;
}
}
}
if (!widgetUrl.startsWith("https://") && !widgetUrl.startsWith("http://")) {
return reject(new UserFriendlyError("slash_command|addwidget_invalid_protocol"));
}
if (WidgetUtils.canUserModifyWidgets(cli, roomId)) {
const userId = cli.getUserId();
2022-12-12 11:24:14 +00:00
const nowMs = new Date().getTime();
const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`);
let type = WidgetType.CUSTOM;
let name = "Custom";
let data = {};
// Make the widget a Jitsi widget if it looks like a Jitsi widget
const jitsiData = Jitsi.getInstance().parsePreferredConferenceUrl(widgetUrl);
if (jitsiData) {
logger.log("Making /addwidget widget a Jitsi conference");
type = WidgetType.JITSI;
name = "Jitsi";
data = jitsiData;
widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
}
return success(WidgetUtils.setRoomWidget(cli, roomId, widgetId, type, widgetUrl, name, data));
} else {
return reject(new UserFriendlyError("slash_command|addwidget_no_permissions"));
}
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "verify",
args: "<user-id> <device-id> <device-signing-key>",
description: _td("slash_command|verify"),
runFn: function (cli, roomId, threadId, args) {
if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
if (matches) {
const userId = matches[1];
const deviceId = matches[2];
const fingerprint = matches[3];
2022-12-12 11:24:14 +00:00
return success(
(async (): Promise<void> => {
const device = await getDeviceCryptoInfo(cli, userId, deviceId);
2022-12-12 11:24:14 +00:00
if (!device) {
throw new UserFriendlyError("slash_command|verify_unknown_pair", {
userId,
deviceId,
cause: undefined,
});
2022-12-12 11:24:14 +00:00
}
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
2022-12-12 11:24:14 +00:00
if (deviceTrust?.isVerified()) {
2022-12-12 11:24:14 +00:00
if (device.getFingerprint() === fingerprint) {
throw new UserFriendlyError("slash_command|verify_nop");
2022-12-12 11:24:14 +00:00
} else {
throw new UserFriendlyError("slash_command|verify_nop_warning_mismatch");
2022-12-12 11:24:14 +00:00
}
}
2022-12-12 11:24:14 +00:00
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new UserFriendlyError("slash_command|verify_mismatch", {
fprint,
userId,
deviceId,
fingerprint,
cause: undefined,
});
2022-12-12 11:24:14 +00:00
}
await cli.setDeviceVerified(userId, deviceId, true);
// Tell the user we verified everything
Modal.createDialog(InfoDialog, {
title: _t("slash_command|verify_success_title"),
2022-12-12 11:24:14 +00:00
description: (
<div>
<p>{_t("slash_command|verify_success_description", { userId, deviceId })}</p>
2022-12-12 11:24:14 +00:00
</div>
),
});
})(),
);
}
}
return reject(this.getUsage());
},
category: CommandCategories.advanced,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
2022-12-12 11:24:14 +00:00
command: "discardsession",
description: _td("slash_command|discardsession"),
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId) {
try {
cli.forceDiscardSession(roomId);
} catch (e) {
return reject(e instanceof Error ? e.message : e);
}
return success();
},
category: CommandCategories.advanced,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "rainbow",
description: _td("slash_command|rainbow"),
2022-12-12 11:24:14 +00:00
args: "<message>",
runFn: function (cli, roomId, threadId, args) {
if (!args) return reject(this.getUsage());
return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
new Command({
command: "rainbowme",
description: _td("slash_command|rainbowme"),
2022-12-12 11:24:14 +00:00
args: "<message>",
runFn: function (cli, roomId, threadId, args) {
if (!args) return reject(this.getUsage());
return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
new Command({
command: "help",
description: _td("slash_command|help"),
2022-12-12 11:24:14 +00:00
runFn: function () {
Modal.createDialog(SlashCommandHelpDialog);
return success();
},
category: CommandCategories.advanced,
}),
new Command({
command: "whois",
description: _td("slash_command|whois"),
args: "<user-id>",
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage());
}
const member = cli.getRoom(roomId)?.getMember(userId);
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
2021-06-18 15:21:46 +00:00
// XXX: We should be using a real member object and not assuming what the receiver wants.
2022-12-12 11:24:14 +00:00
member: member || ({ userId } as User),
});
return success();
},
category: CommandCategories.advanced,
}),
new Command({
command: "rageshake",
aliases: ["bugreport"],
description: _td("slash_command|rageshake"),
isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url,
args: "<description>",
runFn: function (cli, roomId, threadId, args) {
return success(
Modal.createDialog(BugReportDialog, {
initialText: args,
}).finished,
);
},
category: CommandCategories.advanced,
}),
new Command({
command: "tovirtual",
description: _td("slash_command|tovirtual"),
category: CommandCategories.advanced,
isEnabled(cli): boolean {
return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(cli);
},
runFn: (cli, roomId) => {
2022-12-12 11:24:14 +00:00
return success(
(async (): Promise<void> => {
2022-12-12 11:24:14 +00:00
const room = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(roomId);
if (!room) throw new UserFriendlyError("slash_command|tovirtual_not_found");
2022-12-12 11:24:14 +00:00
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "SlashCommand",
metricsViaKeyboard: true,
});
})(),
);
},
}),
new Command({
command: "query",
description: _td("slash_command|query"),
args: "<user-id>",
runFn: function (cli, roomId, threadId, userId) {
// easter-egg for now: look up phone numbers through the thirdparty API
// (very dumb phone number detection...)
const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId);
2022-12-12 11:24:14 +00:00
if (!userId || ((!userId.startsWith("@") || !userId.includes(":")) && !isPhoneNumber)) {
return reject(this.getUsage());
}
2022-12-12 11:24:14 +00:00
return success(
(async (): Promise<void> => {
2022-12-12 11:24:14 +00:00
if (isPhoneNumber) {
const results = await LegacyCallHandler.instance.pstnLookup(userId);
if (!results || results.length === 0 || !results[0].userid) {
throw new UserFriendlyError("slash_command|query_not_found_phone_number");
2022-12-12 11:24:14 +00:00
}
userId = results[0].userid;
}
const roomId = await ensureDMExists(cli, userId);
if (!roomId) throw new Error("Failed to ensure DM exists");
2022-12-12 11:24:14 +00:00
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: "SlashCommand",
metricsViaKeyboard: true,
});
})(),
);
},
category: CommandCategories.actions,
}),
new Command({
command: "msg",
description: _td("slash_command|msg"),
args: "<user-id> [<message>]",
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);
if (userId && userId.startsWith("@") && userId.includes(":")) {
2022-12-12 11:24:14 +00:00
return success(
(async (): Promise<void> => {
2022-12-12 11:24:14 +00:00
const roomId = await ensureDMExists(cli, userId);
if (!roomId) throw new Error("Failed to ensure DM exists");
2022-12-12 11:24:14 +00:00
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: "SlashCommand",
metricsViaKeyboard: true,
});
if (msg) {
cli.sendTextMessage(roomId, msg);
}
})(),
);
}
}
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
}),
new Command({
command: "holdcall",
description: _td("slash_command|holdcall"),
category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
Prepare for Element Call integration (#9224) * Improve accessibility and testability of Tooltip Adding a role to Tooltip was motivated by React Testing Library's reliance on accessibility-related attributes to locate elements. * Make the ReadyWatchingStore constructor safer The ReadyWatchingStore constructor previously had a chance to immediately call onReady, which was dangerous because it was potentially calling the derived class's onReady at a point when the derived class hadn't even finished construction yet. In normal usage, I guess this never was a problem, but it was causing some of the tests I was writing to crash. This is solved by separating out the onReady call into a start method. * Rename 1:1 call components to 'LegacyCall' to reflect the fact that they're slated for removal, and to not clash with the new Call code. * Refactor VideoChannelStore into Call and CallStore Call is an abstract class that currently only has a Jitsi implementation, but this will make it easy to later add an Element Call implementation. * Remove WidgetReady, ClientReady, and ForceHangupCall hacks These are no longer used by the new Jitsi call implementation, and can be removed. * yarn i18n * Delete call map entries instead of inserting nulls * Allow multiple active calls and consolidate call listeners * Fix a race condition when creating a video room * Un-hardcode the media device fallback labels * Apply misc code review fixes * yarn i18n * Disconnect from calls more politely on logout * Fix some strict mode errors * Fix another updateRoom race condition
2022-08-30 19:13:39 +00:00
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(new UserFriendlyError("slash_command|no_active_call"));
}
call.setRemoteOnHold(true);
return success();
},
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "unholdcall",
description: _td("slash_command|unholdcall"),
category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
Prepare for Element Call integration (#9224) * Improve accessibility and testability of Tooltip Adding a role to Tooltip was motivated by React Testing Library's reliance on accessibility-related attributes to locate elements. * Make the ReadyWatchingStore constructor safer The ReadyWatchingStore constructor previously had a chance to immediately call onReady, which was dangerous because it was potentially calling the derived class's onReady at a point when the derived class hadn't even finished construction yet. In normal usage, I guess this never was a problem, but it was causing some of the tests I was writing to crash. This is solved by separating out the onReady call into a start method. * Rename 1:1 call components to 'LegacyCall' to reflect the fact that they're slated for removal, and to not clash with the new Call code. * Refactor VideoChannelStore into Call and CallStore Call is an abstract class that currently only has a Jitsi implementation, but this will make it easy to later add an Element Call implementation. * Remove WidgetReady, ClientReady, and ForceHangupCall hacks These are no longer used by the new Jitsi call implementation, and can be removed. * yarn i18n * Delete call map entries instead of inserting nulls * Allow multiple active calls and consolidate call listeners * Fix a race condition when creating a video room * Un-hardcode the media device fallback labels * Apply misc code review fixes * yarn i18n * Disconnect from calls more politely on logout * Fix some strict mode errors * Fix another updateRoom race condition
2022-08-30 19:13:39 +00:00
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(new UserFriendlyError("slash_command|no_active_call"));
}
call.setRemoteOnHold(false);
return success();
},
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "converttodm",
description: _td("slash_command|converttodm"),
category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
const room = cli.getRoom(roomId);
if (!room) return reject(new UserFriendlyError("slash_command|could_not_find_room"));
return success(guessAndSetDMRoom(room, true));
},
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "converttoroom",
description: _td("slash_command|converttoroom"),
category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, threadId, args) {
const room = cli.getRoom(roomId);
if (!room) return reject(new UserFriendlyError("slash_command|could_not_find_room"));
return success(guessAndSetDMRoom(room, false));
},
renderingTypes: [TimelineRenderingType.Room],
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
new Command({
command: "me",
2022-12-12 11:24:14 +00:00
args: "<message>",
description: _td("slash_command|me"),
category: CommandCategories.messages,
hideCompletionAfterSpace: true,
}),
2020-11-26 17:27:35 +00:00
...CHAT_EFFECTS.map((effect) => {
return new Command({
command: effect.command,
description: effect.description(),
2022-12-12 11:24:14 +00:00
args: "<message>",
runFn: function (cli, roomId, threadId, args) {
let content: IContent;
if (!args) {
content = ContentHelpers.makeEmoteMessage(effect.fallbackMessage());
} else {
content = {
msgtype: effect.msgType,
body: args,
};
}
dis.dispatch({ action: `effects.${effect.command}` });
return successSync(content);
},
category: CommandCategories.effects,
renderingTypes: [TimelineRenderingType.Room],
2021-06-29 12:11:58 +00:00
});
2020-08-18 15:57:51 +00:00
}),
];
// build a map from names and aliases to the Command objects.
export const CommandMap = new Map<string, Command>();
2022-12-12 11:24:14 +00:00
Commands.forEach((cmd) => {
CommandMap.set(cmd.command, cmd);
2022-12-12 11:24:14 +00:00
cmd.aliases.forEach((alias) => {
CommandMap.set(alias, cmd);
});
});
2015-09-18 12:54:20 +00:00
2022-12-12 11:24:14 +00:00
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();
2022-12-12 11:24:14 +00:00
if (input[0] !== "/") 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;
}
2021-06-29 12:11:58 +00:00
return { cmd, args };
}
interface ICmd {
cmd?: Command;
args?: string;
}
/**
* Process the given text for /commands and returns a parsed command that can be used for running the operation.
* @param {string} input The raw text input by the user.
* @return {ICmd} The parsed command object.
* Returns an empty object if the input didn't match a command.
*/
export function getCommand(input: string): ICmd {
2021-06-29 12:11:58 +00:00
const { cmd, args } = parseCommandString(input);
if (cmd && CommandMap.has(cmd) && CommandMap.get(cmd)!.isEnabled(MatrixClientPeg.get())) {
return {
cmd: CommandMap.get(cmd),
args,
};
}
return {};
}