/* 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 } from "matrix-js-sdk/src/models/user"; import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; import { _t, _td } from './languageHandler'; import Modal from './Modal'; import MultiInviter from './utils/MultiInviter'; import { linkifyAndSanitizeHtml } from './HtmlUtils'; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; import { textToHtmlRainbow } from "./utils/colour"; import { getAddressType } from './UserAddress'; import { abbreviateUrl } from './utils/UrlUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks"; import { inviteUsersToRoom } from "./RoomInvite"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; import { Action } from "./dispatcher/actions"; import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership"; import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; import { CHAT_EFFECTS } from "./effects"; import CallHandler from "./CallHandler"; import { guessAndSetDMRoom } from "./Rooms"; import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; import ErrorDialog from './components/views/dialogs/ErrorDialog'; 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"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { target: HTMLInputElement & EventTarget; } const singleMxcUpload = async (): Promise => { return new Promise((resolve) => { const fileSelector = document.createElement('input'); fileSelector.setAttribute('type', 'file'); fileSelector.onchange = (ev: HTMLInputEvent) => { const file = ev.target.files[0]; Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { file, onFinished: (shouldContinue) => { resolve(shouldContinue ? MatrixClientPeg.get().uploadContent(file) : null); }, }); }; fileSelector.click(); }); }; export const CommandCategories = { "messages": _td("Messages"), "actions": _td("Actions"), "admin": _td("Admin"), "advanced": _td("Advanced"), "effects": _td("Effects"), "other": _td("Other"), }; type RunFn = ((roomId: string, args: string, cmd: string) => {error: any} | {promise: Promise}); interface ICommandOpts { command: string; aliases?: string[]; args?: string; description: string; runFn?: RunFn; category: string; hideCompletionAfterSpace?: boolean; isEnabled?(): boolean; } export class Command { command: string; aliases: string[]; args: undefined | string; description: string; runFn: undefined | RunFn; category: string; hideCompletionAfterSpace: boolean; _isEnabled?: () => boolean; constructor(opts: ICommandOpts) { this.command = opts.command; this.aliases = opts.aliases || []; this.args = opts.args || ""; this.description = opts.description; this.runFn = opts.runFn; this.category = opts.category || CommandCategories.other; this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false; this._isEnabled = opts.isEnabled; } getCommand() { return `/${this.command}`; } getCommandWithArgs() { return this.getCommand() + " " + this.args; } run(roomId: string, args: string) { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` if (!this.runFn) return reject(_t("Command error")); return this.runFn.bind(this)(roomId, args); } getUsage() { return _t('Usage') + ': ' + this.getCommandWithArgs(); } isEnabled() { return this._isEnabled ? this._isEnabled() : true; } } function reject(error) { return { error }; } function success(promise?: Promise) { return { promise }; } function successSync(value: any) { return success(Promise.resolve(value)); } /* Disable the "unexpected this" error for these commands - all of the run * functions are called with `this` bound to the Command instance. */ export const Commands = [ new Command({ command: 'spoiler', args: '', description: _td('Sends the given message as a spoiler'), runFn: function(roomId, message) { return successSync(ContentHelpers.makeHtmlMessage( message, `${message}`, )); }, category: CommandCategories.messages, }), new Command({ command: 'shrug', args: '', description: _td('Prepends ¯\\_(ツ)_/¯ to a plain-text message'), runFn: function(roomId, args) { let message = '¯\\_(ツ)_/¯'; if (args) { message = message + ' ' + args; } return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), new Command({ command: 'tableflip', args: '', description: _td('Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message'), runFn: function(roomId, args) { let message = '(╯°□°)╯︵ ┻━┻'; if (args) { message = message + ' ' + args; } return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), new Command({ command: 'unflip', args: '', description: _td('Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message'), runFn: function(roomId, args) { let message = '┬──┬ ノ( ゜-゜ノ)'; if (args) { message = message + ' ' + args; } return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), new Command({ command: 'lenny', args: '', description: _td('Prepends ( ͡° ͜ʖ ͡°) to a plain-text message'), runFn: function(roomId, args) { let message = '( ͡° ͜ʖ ͡°)'; if (args) { message = message + ' ' + args; } return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), new Command({ command: 'plain', args: '', description: _td('Sends a message as plain text, without interpreting it as markdown'), runFn: function(roomId, messages) { return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, }), new Command({ command: 'html', args: '', description: _td('Sends a message as html, without interpreting it as markdown'), runFn: function(roomId, messages) { return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, }), new Command({ command: 'ddg', args: '', description: _td('Searches DuckDuckGo for results'), runFn: function() { // TODO Don't explain this away, actually show a search UI here. Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { title: _t('/ddg is not a command'), description: _t('To use it, just wait for autocomplete results to load and tab through them.'), }); return success(); }, category: CommandCategories.actions, hideCompletionAfterSpace: true, }), new Command({ command: 'upgraderoom', args: '', description: _td('Upgrades a room to a new version'), runFn: function(roomId, args) { if (args) { const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { return reject(_t("You do not have the required permissions to use this command.")); } const { finished } = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); return success(finished.then(async ([resp]) => { if (!resp.continue) return; let checkForUpgradeFn; try { const upgradePromise = cli.upgradeRoom(roomId, args); // We have to wait for the js-sdk to give us the room back so // we can more effectively abuse the MultiInviter behaviour // which heavily relies on the Room object being available. if (resp.invite) { checkForUpgradeFn = async (newRoom) => { // The upgradePromise should be done by the time we await it here. const { replacement_room: newRoomId } = await upgradePromise; if (newRoom.roomId !== newRoomId) return; const toInvite = [ ...room.getMembersWithMembership("join"), ...room.getMembersWithMembership("invite"), ].map(m => m.userId).filter(m => m !== cli.getUserId()); if (toInvite.length > 0) { // Errors are handled internally to this function await inviteUsersToRoom(newRoomId, toInvite); } cli.removeListener('Room', checkForUpgradeFn); }; cli.on('Room', checkForUpgradeFn); } // We have to await after so that the checkForUpgradesFn has a proper reference // to the new room's ID. await upgradePromise; } catch (e) { console.error(e); if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn); Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, { title: _t('Error upgrading room'), description: _t( 'Double check that your server supports the room version chosen and try again.'), }); } })); } return reject(this.getUsage()); }, category: CommandCategories.admin, }), new Command({ command: 'nick', args: '', description: _td('Changes your display nickname'), runFn: function(roomId, args) { if (args) { return success(MatrixClientPeg.get().setDisplayName(args)); } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: 'myroomnick', aliases: ['roomnick'], args: '', description: _td('Changes your display nickname in the current room only'), runFn: function(roomId, args) { if (args) { const cli = MatrixClientPeg.get(); const ev = cli.getRoom(roomId).currentState.getStateEvents('m.room.member', cli.getUserId()); const content = { ...ev ? ev.getContent() : { membership: 'join' }, displayname: args, }; return success(cli.sendStateEvent(roomId, 'm.room.member', content, cli.getUserId())); } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: 'roomavatar', args: '[]', description: _td('Changes the avatar of the current room'), runFn: function(roomId, args) { let promise = Promise.resolve(args); if (!args) { promise = singleMxcUpload(); } return success(promise.then((url) => { if (!url) return; return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', { url }, ''); })); }, category: CommandCategories.actions, }), new Command({ command: 'myroomavatar', args: '[]', description: _td('Changes your avatar in this current room only'), runFn: function(roomId, args) { const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); const userId = cli.getUserId(); let promise = Promise.resolve(args); if (!args) { promise = singleMxcUpload(); } 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, }), new Command({ command: 'myavatar', args: '[]', description: _td('Changes your avatar in all rooms'), runFn: function(roomId, args) { let promise = Promise.resolve(args); if (!args) { promise = singleMxcUpload(); } return success(promise.then((url) => { if (!url) return; return MatrixClientPeg.get().setAvatarUrl(url); })); }, category: CommandCategories.actions, }), new Command({ command: 'topic', args: '[]', description: _td('Gets or sets the room topic'), runFn: function(roomId, args) { const cli = MatrixClientPeg.get(); if (args) { return success(cli.setRoomTopic(roomId, args)); } const room = cli.getRoom(roomId); if (!room) return reject(_t("Failed to set topic")); const topicEvents = room.currentState.getStateEvents('m.room.topic', ''); const topic = topicEvents && topicEvents.getContent().topic; const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.'); Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, { title: room.name, description:
, hasCloseButton: true, }); return success(); }, category: CommandCategories.admin, }), new Command({ command: 'roomname', args: '', description: _td('Sets the room name'), runFn: function(roomId, args) { if (args) { return success(MatrixClientPeg.get().setRoomName(roomId, args)); } return reject(this.getUsage()); }, category: CommandCategories.admin, }), new Command({ command: 'invite', args: ' []', description: _td('Invites user with given id to current room'), runFn: function(roomId, 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) === 'email' && !MatrixClientPeg.get().getIdentityServerUrl() ) { const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); if (defaultIdentityServerUrl) { const { finished } = Modal.createTrackedDialog<[boolean]>( 'Slash Commands', 'Identity server', QuestionDialog, { title: _t("Use an identity server"), description:

{_t( "Use an identity server to invite by email. " + "Click continue to use the default identity server " + "(%(defaultIdentityServerName)s) or manage in Settings.", { defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), }, )}

, button: _t("Continue"), }, ); prom = finished.then(([useDefault]) => { if (useDefault) { useDefaultIdentityServer(); return; } throw new Error(_t("Use an identity server to invite by email. Manage in Settings.")); }); } else { return reject(_t("Use an identity server to invite by email. Manage in Settings.")); } } const inviter = new MultiInviter(roomId); return success(prom.then(() => { return inviter.invite([address], reason); }).then(() => { if (inviter.getCompletionState(address) !== "invited") { throw new Error(inviter.getErrorText(address)); } })); } } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: 'join', aliases: ['j', 'goto'], args: '', description: _td('Joins room with given address'), runFn: function(_, args) { if (args) { // Note: we support 2 versions of this command. The first is // the public-facing one for most users and the other is a // power-user edition where someone may join via permalink or // room ID with optional servers. Practically, this results // in the following variations: // /join #example:example.org // /join !example:example.org // /join !example:example.org altserver.com elsewhere.ca // /join https://matrix.to/#/!example:example.org?via=altserver.com // The command also supports event permalinks transparently: // /join https://matrix.to/#/!example:example.org/$something:example.org // /join https://matrix.to/#/!example:example.org/$something:example.org?via=altserver.com const params = args.split(' '); if (params.length < 1) return reject(this.getUsage()); let isPermalink = false; if (params[0].startsWith("http:") || params[0].startsWith("https:")) { // It's at least a URL - try and pull out a hostname to check against the // permalink handler const parsedUrl = new URL(params[0]); const hostname = parsedUrl.host || parsedUrl.hostname; // takes first non-falsey value // if we're using a Element permalink handler, this will catch it before we get much further. // see below where we make assumptions about parsing the URL. if (isPermalinkHost(hostname)) { isPermalink = true; } } if (params[0][0] === '#') { let roomAlias = params[0]; if (!roomAlias.includes(':')) { roomAlias += ':' + MatrixClientPeg.get().getDomain(); } dis.dispatch({ action: 'view_room', room_alias: roomAlias, auto_join: true, _type: "slash_command", // instrumentation }); return success(); } else if (params[0][0] === '!') { const [roomId, ...viaServers] = params; dis.dispatch({ action: 'view_room', room_id: roomId, opts: { // These are passed down to the js-sdk's /join call viaServers: viaServers, }, via_servers: viaServers, // for the rejoin button auto_join: true, _type: "slash_command", // instrumentation }); return success(); } else if (isPermalink) { const permalinkParts = parsePermalink(params[0]); // This check technically isn't needed because we already did our // safety checks up above. However, for good measure, let's be sure. if (!permalinkParts) { return reject(this.getUsage()); } // If for some reason someone wanted to join a group or user, we should // stop them now. if (!permalinkParts.roomIdOrAlias) { return reject(this.getUsage()); } const entity = permalinkParts.roomIdOrAlias; const viaServers = permalinkParts.viaServers; const eventId = permalinkParts.eventId; const dispatch = { action: 'view_room', auto_join: true, _type: "slash_command", // instrumentation }; if (entity[0] === '!') dispatch["room_id"] = entity; else dispatch["room_alias"] = entity; if (eventId) { dispatch["event_id"] = eventId; dispatch["highlighted"] = true; } if (viaServers) { // For the join dispatch["opts"] = { // These are passed down to the js-sdk's /join call viaServers: viaServers, }; // For if the join fails (rejoin button) dispatch['via_servers'] = viaServers; } dis.dispatch(dispatch); return success(); } } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: 'part', args: '[]', description: _td('Leave room'), runFn: function(roomId, args) { const cli = MatrixClientPeg.get(); let targetRoomId; 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(); for (let i = 0; i < rooms.length; i++) { const aliasEvents = rooms[i].currentState.getStateEvents('m.room.aliases'); for (let j = 0; j < aliasEvents.length; j++) { const aliases = aliasEvents[j].getContent().aliases || []; for (let k = 0; k < aliases.length; k++) { if (aliases[k] === roomAlias) { targetRoomId = rooms[i].roomId; break; } } if (targetRoomId) break; } if (targetRoomId) break; } if (!targetRoomId) return reject(_t('Unrecognised room address:') + ' ' + roomAlias); } } if (!targetRoomId) targetRoomId = roomId; return success(leaveRoomBehaviour(targetRoomId)); }, category: CommandCategories.actions, }), new Command({ command: 'kick', args: ' [reason]', description: _td('Kicks user with given id'), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { return success(MatrixClientPeg.get().kick(roomId, matches[1], matches[3])); } } return reject(this.getUsage()); }, category: CommandCategories.admin, }), new Command({ command: 'ban', args: ' [reason]', description: _td('Bans user with given id'), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { return success(MatrixClientPeg.get().ban(roomId, matches[1], matches[3])); } } return reject(this.getUsage()); }, category: CommandCategories.admin, }), new Command({ command: 'unban', args: '', description: _td('Unbans user with given ID'), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { // Reset the user membership to "leave" to unban him return success(MatrixClientPeg.get().unban(roomId, matches[1])); } } return reject(this.getUsage()); }, category: CommandCategories.admin, }), new Command({ command: 'ignore', args: '', description: _td('Ignores a user, hiding their messages from you'), runFn: function(roomId, args) { if (args) { const cli = MatrixClientPeg.get(); 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.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, { title: _t('Ignored user'), description:

{ _t('You are now ignoring %(userId)s', { userId }) }

, }); }), ); } } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: 'unignore', args: '', description: _td('Stops ignoring a user, showing their messages going forward'), runFn: function(roomId, args) { if (args) { const cli = MatrixClientPeg.get(); 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.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, { title: _t('Unignored user'), description:

{ _t('You are no longer ignoring %(userId)s', { userId }) }

, }); }), ); } } return reject(this.getUsage()); }, category: CommandCategories.actions, }), new Command({ command: 'op', args: ' []', description: _td('Define the power level of a user'), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(-?\d+))?$/); let powerLevel = 50; // default power level for op if (matches) { const userId = matches[1]; if (matches.length === 4 && undefined !== matches[3]) { powerLevel = parseInt(matches[3], 10); } if (!isNaN(powerLevel)) { const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room) return reject(_t("Command failed")); const member = room.getMember(userId); if (!member || getEffectiveMembership(member.membership) === EffectiveMembership.Leave) { return reject(_t("Could not find user in room")); } const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', ''); return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent)); } } } return reject(this.getUsage()); }, category: CommandCategories.admin, }), new Command({ command: 'deop', args: '', description: _td('Deops user with given id'), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (!room) return reject(_t("Command failed")); const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', ''); if (!powerLevelEvent.getContent().users[args]) return reject(_t("Could not find user in room")); return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent)); } } return reject(this.getUsage()); }, category: CommandCategories.admin, }), new Command({ command: 'devtools', description: _td('Opens the Developer Tools dialog'), runFn: function(roomId) { Modal.createDialog(DevtoolsDialog, { roomId }); return success(); }, category: CommandCategories.advanced, }), new Command({ command: 'addwidget', args: '', description: _td('Adds a custom widget by URL to the room'), isEnabled: () => SettingsStore.getValue(UIFeature.Widgets), runFn: function(roomId, widgetUrl) { if (!widgetUrl) { return reject(_t("Please supply a widget URL or embed code")); } // Try and parse out a widget URL from iframes if (widgetUrl.toLowerCase().startsWith("