/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import * as React from "react"; import { User, IContent, Direction, ContentHelpers, MRoomTopicEventContent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; 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 { 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 { 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"; import { TimelineRenderingType } from "./contexts/RoomContext"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import VoipUserMapper from "./VoipUserMapper"; import { htmlSerializeFromMdIfNeeded } from "./editor/serialize"; 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 }; export const Commands = [ new Command({ command: "spoiler", args: "", description: _td("slash_command|spoiler"), runFn: function (cli, roomId, threadId, message = "") { return successSync(ContentHelpers.makeHtmlMessage(message, `${message}`)); }, category: CommandCategories.messages, }), new Command({ command: "shrug", args: "", 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: "", 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: "", 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: "", 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, }), new Command({ command: "plain", args: "", description: _td("slash_command|plain"), runFn: function (cli, roomId, threadId, messages = "") { return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, }), new Command({ command: "html", args: "", description: _td("slash_command|html"), runFn: function (cli, roomId, threadId, messages = "") { return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, }), new Command({ command: "upgraderoom", args: "", 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( RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/ undefined, /*isPriority=*/ false, /*isStatic=*/ true, ); return success( finished.then(async ([resp]): Promise => { if (!resp?.continue) return; await upgradeRoom(room, args, resp.invite); }), ); } return reject(this.getUsage()); }, category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "jumptodate", args: "", description: _td("slash_command|jumptodate"), isEnabled: () => SettingsStore.getValue("feature_jump_to_date"), runFn: function (cli, roomId, threadId, args) { if (args) { return success( (async (): Promise => { const unixTimestamp = Date.parse(args); if (!unixTimestamp) { throw new UserFriendlyError("slash_command|jumptodate_invalid_input", { inputDate: args, cause: undefined, }); } 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({ action: Action.ViewRoom, event_id: eventId, highlighted: true, room_id: roomId, metricsTrigger: "SlashCommand", metricsViaKeyboard: true, }); })(), ); } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: "nick", args: "", 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({ command: "myroomnick", aliases: ["roomnick"], args: "", description: _td("slash_command|myroomnick"), isEnabled: (cli) => !isCurrentLocalRoom(cli), runFn: function (cli, roomId, threadId, args) { if (args) { const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getSafeUserId()); const content = { ...(ev ? ev.getContent() : { membership: "join" }), displayname: args, }; return success(cli.sendStateEvent(roomId, "m.room.member", content, cli.getSafeUserId())); } return reject(this.getUsage()); }, category: CommandCategories.actions, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "roomavatar", args: "[]", 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); } return success( promise.then((url) => { if (!url) return; return cli.sendStateEvent(roomId, "m.room.avatar", { url }, ""); }), ); }, category: CommandCategories.actions, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "myroomavatar", args: "[]", 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); } return success( promise.then((url) => { if (!url) return; const ev = room?.currentState.getStateEvents("m.room.member", userId); const content = { ...(ev ? ev.getContent() : { membership: "join" }), avatar_url: url, }; return cli.sendStateEvent(roomId, "m.room.member", content, userId); }), ); }, category: CommandCategories.actions, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "myavatar", args: "[]", description: _td("slash_command|myavatar"), runFn: function (cli, roomId, threadId, args) { let promise = Promise.resolve(args ?? null); if (!args) { promise = singleMxcUpload(cli); } return success( promise.then((url) => { if (!url) return; return cli.setAvatarUrl(url); }), ); }, category: CommandCategories.actions, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "topic", args: "[]", 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(); 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: {body}, hasCloseButton: true, className: "markdown-body", }); return success(); }, category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "roomname", args: "", 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({ command: "invite", args: " []", 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"), description: (

{_t("slash_command|invite_3pid_use_default_is_title_description", { defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), })}

), 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); return success( prom .then(() => { return inviter.invite([address], reason, true); }) .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, }); } } }), ); } } return reject(this.getUsage()); }, category: CommandCategories.actions, renderingTypes: [TimelineRenderingType.Room], }), goto, join, new Command({ command: "part", args: "[]", 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]; if (roomAlias[0] !== "#") return reject(this.getUsage()); if (!roomAlias.includes(":")) { roomAlias += ":" + cli.getDomain(); } // Try to find a room with this alias const rooms = cli.getRooms(); 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, }), ); } } } if (!targetRoomId) targetRoomId = roomId; return success(leaveRoomBehaviour(cli, targetRoomId)); }, category: CommandCategories.actions, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "remove", aliases: ["kick"], args: " [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])); } } return reject(this.getUsage()); }, category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "ban", args: " [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])); } } return reject(this.getUsage()); }, category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "unban", args: "", 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])); } } return reject(this.getUsage()); }, category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), new Command({ command: "ignore", args: "", 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"), description: (

{_t("slash_command|ignore_dialog_description", { userId })}

), }); }), ); } } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: "unignore", args: "", 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"), description: (

{_t("slash_command|unignore_dialog_description", { userId })}

), }); }), ); } } return reject(this.getUsage()); }, category: CommandCategories.actions, }), op, deop, new Command({ 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, }), new Command({ command: "addwidget", args: "", description: _td("slash_command|addwidget"), isEnabled: (cli) => 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("