175938f5a0
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
668 lines
25 KiB
JavaScript
668 lines
25 KiB
JavaScript
/*
|
|
Copyright 2015, 2016 OpenMarket Ltd
|
|
Copyright 2018 New Vector Ltd
|
|
|
|
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 React from 'react';
|
|
import MatrixClientPeg from './MatrixClientPeg';
|
|
import dis from './dispatcher';
|
|
import Tinter from './Tinter';
|
|
import sdk from './index';
|
|
import {_t, _td} from './languageHandler';
|
|
import Modal from './Modal';
|
|
import SettingsStore, {SettingLevel} from './settings/SettingsStore';
|
|
import {MATRIXTO_URL_PATTERN} from "./linkify-matrix";
|
|
import * as querystring from "querystring";
|
|
import MultiInviter from './utils/MultiInviter';
|
|
import { linkifyAndSanitizeHtml } from './HtmlUtils';
|
|
|
|
class Command {
|
|
constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) {
|
|
this.command = '/' + name;
|
|
this.args = args;
|
|
this.description = description;
|
|
this.runFn = runFn;
|
|
this.hideCompletionAfterSpace = hideCompletionAfterSpace;
|
|
}
|
|
|
|
getCommand() {
|
|
return this.command;
|
|
}
|
|
|
|
getCommandWithArgs() {
|
|
return this.getCommand() + " " + this.args;
|
|
}
|
|
|
|
run(roomId, args) {
|
|
return this.runFn.bind(this)(roomId, args);
|
|
}
|
|
|
|
getUsage() {
|
|
return _t('Usage') + ': ' + this.getCommandWithArgs();
|
|
}
|
|
}
|
|
|
|
function reject(error) {
|
|
return {error};
|
|
}
|
|
|
|
function success(promise) {
|
|
return {promise};
|
|
}
|
|
|
|
/* Disable the "unexpected this" error for these commands - all of the run
|
|
* functions are called with `this` bound to the Command instance.
|
|
*/
|
|
|
|
/* eslint-disable babel/no-invalid-this */
|
|
|
|
export const CommandMap = {
|
|
ddg: new Command({
|
|
name: 'ddg',
|
|
args: '<query>',
|
|
description: _td('Searches DuckDuckGo for results'),
|
|
runFn: function(roomId, args) {
|
|
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
|
// 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();
|
|
},
|
|
hideCompletionAfterSpace: true,
|
|
}),
|
|
|
|
upgraderoom: new Command({
|
|
name: 'upgraderoom',
|
|
args: '<new_version>',
|
|
description: _td('Upgrades a room to a new version'),
|
|
runFn: function(roomId, args) {
|
|
if (args) {
|
|
return success(MatrixClientPeg.get().upgradeRoom(roomId, args));
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
nick: new Command({
|
|
name: 'nick',
|
|
args: '<display_name>',
|
|
description: _td('Changes your display nickname'),
|
|
runFn: function(roomId, args) {
|
|
if (args) {
|
|
return success(MatrixClientPeg.get().setDisplayName(args));
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
roomnick: new Command({
|
|
name: 'roomnick',
|
|
args: '<display_name>',
|
|
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());
|
|
},
|
|
}),
|
|
|
|
tint: new Command({
|
|
name: 'tint',
|
|
args: '<color1> [<color2>]',
|
|
description: _td('Changes colour scheme of current room'),
|
|
runFn: function(roomId, args) {
|
|
if (args) {
|
|
const matches = args.match(/^(#([\da-fA-F]{3}|[\da-fA-F]{6}))( +(#([\da-fA-F]{3}|[\da-fA-F]{6})))?$/);
|
|
if (matches) {
|
|
Tinter.tint(matches[1], matches[4]);
|
|
const colorScheme = {};
|
|
colorScheme.primary_color = matches[1];
|
|
if (matches[4]) {
|
|
colorScheme.secondary_color = matches[4];
|
|
} else {
|
|
colorScheme.secondary_color = colorScheme.primary_color;
|
|
}
|
|
return success(
|
|
SettingsStore.setValue('roomColor', roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
|
|
);
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
topic: new Command({
|
|
name: 'topic',
|
|
args: '[<topic>]',
|
|
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('Bad room ID: ' + roomId);
|
|
|
|
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.');
|
|
|
|
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
|
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
|
|
title: room.name,
|
|
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
|
|
});
|
|
return success();
|
|
},
|
|
}),
|
|
|
|
roomname: new Command({
|
|
name: 'roomname',
|
|
args: '<name>',
|
|
description: _td('Sets the room name'),
|
|
runFn: function(roomId, args) {
|
|
if (args) {
|
|
return success(MatrixClientPeg.get().setRoomName(roomId, args));
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
invite: new Command({
|
|
name: 'invite',
|
|
args: '<user-id>',
|
|
description: _td('Invites user with given id to current room'),
|
|
runFn: function(roomId, args) {
|
|
if (args) {
|
|
const matches = args.match(/^(\S+)$/);
|
|
if (matches) {
|
|
// We use a MultiInviter to re-use the invite logic, even though
|
|
// we're only inviting one user.
|
|
const userId = matches[1];
|
|
const inviter = new MultiInviter(roomId);
|
|
return success(inviter.invite([userId]).then(() => {
|
|
if (inviter.getCompletionState(userId) !== "invited") {
|
|
throw new Error(inviter.getErrorText(userId));
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
join: new Command({
|
|
name: 'join',
|
|
args: '<room-alias>',
|
|
description: _td('Joins room with given alias'),
|
|
runFn: function(roomId, 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());
|
|
|
|
const matrixToMatches = params[0].match(MATRIXTO_URL_PATTERN);
|
|
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,
|
|
});
|
|
return success();
|
|
} else if (params[0][0] === '!') {
|
|
const roomId = params[0];
|
|
const viaServers = params.splice(0);
|
|
|
|
dis.dispatch({
|
|
action: 'view_room',
|
|
room_id: roomId,
|
|
opts: {
|
|
// These are passed down to the js-sdk's /join call
|
|
server_name: viaServers,
|
|
},
|
|
auto_join: true,
|
|
});
|
|
return success();
|
|
} else if (matrixToMatches) {
|
|
let entity = matrixToMatches[1];
|
|
let eventId = null;
|
|
let viaServers = [];
|
|
|
|
if (entity[0] !== '!' && entity[0] !== '#') return reject(this.getUsage());
|
|
|
|
if (entity.indexOf('?') !== -1) {
|
|
const parts = entity.split('?');
|
|
entity = parts[0];
|
|
|
|
const parsed = querystring.parse(parts[1]);
|
|
viaServers = parsed["via"];
|
|
if (typeof viaServers === 'string') viaServers = [viaServers];
|
|
}
|
|
|
|
// We quietly support event ID permalinks too
|
|
if (entity.indexOf('/$') !== -1) {
|
|
const parts = entity.split("/$");
|
|
entity = parts[0];
|
|
eventId = `$${parts[1]}`;
|
|
}
|
|
|
|
const dispatch = {
|
|
action: 'view_room',
|
|
auto_join: true,
|
|
};
|
|
|
|
if (entity[0] === '!') dispatch["room_id"] = entity;
|
|
else dispatch["room_alias"] = entity;
|
|
|
|
if (eventId) {
|
|
dispatch["event_id"] = eventId;
|
|
dispatch["highlighted"] = true;
|
|
}
|
|
|
|
if (viaServers) {
|
|
dispatch["opts"] = {
|
|
// These are passed down to the js-sdk's /join call
|
|
server_name: viaServers,
|
|
};
|
|
}
|
|
|
|
dis.dispatch(dispatch);
|
|
return success();
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
part: new Command({
|
|
name: 'part',
|
|
args: '[<room-alias>]',
|
|
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 alias:') + ' ' + roomAlias);
|
|
}
|
|
}
|
|
|
|
if (!targetRoomId) targetRoomId = roomId;
|
|
return success(
|
|
cli.leave(targetRoomId).then(function() {
|
|
dis.dispatch({action: 'view_next_room'});
|
|
}),
|
|
);
|
|
},
|
|
}),
|
|
|
|
kick: new Command({
|
|
name: 'kick',
|
|
args: '<user-id> [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());
|
|
},
|
|
}),
|
|
|
|
// Ban a user from the room with an optional reason
|
|
ban: new Command({
|
|
name: 'ban',
|
|
args: '<user-id> [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());
|
|
},
|
|
}),
|
|
|
|
// Unban a user from ythe room
|
|
unban: new Command({
|
|
name: 'unban',
|
|
args: '<user-id>',
|
|
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());
|
|
},
|
|
}),
|
|
|
|
ignore: new Command({
|
|
name: 'ignore',
|
|
args: '<user-id>',
|
|
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(() => {
|
|
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
|
Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, {
|
|
title: _t('Ignored user'),
|
|
description: <div>
|
|
<p>{ _t('You are now ignoring %(userId)s', {userId}) }</p>
|
|
</div>,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
unignore: new Command({
|
|
name: 'unignore',
|
|
args: '<user-id>',
|
|
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(() => {
|
|
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
|
Modal.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, {
|
|
title: _t('Unignored user'),
|
|
description: <div>
|
|
<p>{ _t('You are no longer ignoring %(userId)s', {userId}) }</p>
|
|
</div>,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
// Define the power level of a user
|
|
op: new Command({
|
|
name: 'op',
|
|
args: '<user-id> [<power-level>]',
|
|
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]);
|
|
}
|
|
if (!isNaN(powerLevel)) {
|
|
const cli = MatrixClientPeg.get();
|
|
const room = cli.getRoom(roomId);
|
|
if (!room) return reject('Bad room ID: ' + roomId);
|
|
|
|
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
|
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
|
|
}
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
// Reset the power level of a user
|
|
deop: new Command({
|
|
name: 'deop',
|
|
args: '<user-id>',
|
|
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('Bad room ID: ' + roomId);
|
|
|
|
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
|
return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent));
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
devtools: new Command({
|
|
name: 'devtools',
|
|
description: _td('Opens the Developer Tools dialog'),
|
|
runFn: function(roomId) {
|
|
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
|
|
Modal.createDialog(DevtoolsDialog, {roomId});
|
|
return success();
|
|
},
|
|
}),
|
|
|
|
// Verify a user, device, and pubkey tuple
|
|
verify: new Command({
|
|
name: 'verify',
|
|
args: '<user-id> <device-id> <device-signing-key>',
|
|
description: _td('Verifies a user, device, and pubkey tuple'),
|
|
runFn: function(roomId, args) {
|
|
if (args) {
|
|
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
|
|
if (matches) {
|
|
const cli = MatrixClientPeg.get();
|
|
|
|
const userId = matches[1];
|
|
const deviceId = matches[2];
|
|
const fingerprint = matches[3];
|
|
|
|
return success(
|
|
// Promise.resolve to handle transition from static result to promise; can be removed
|
|
// in future
|
|
Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => {
|
|
if (!device) {
|
|
throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`);
|
|
}
|
|
|
|
if (device.isVerified()) {
|
|
if (device.getFingerprint() === fingerprint) {
|
|
throw new Error(_t('Device already verified!'));
|
|
} else {
|
|
throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!'));
|
|
}
|
|
}
|
|
|
|
if (device.getFingerprint() !== fingerprint) {
|
|
const fprint = device.getFingerprint();
|
|
throw new Error(
|
|
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
|
|
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
|
|
'"%(fingerprint)s". This could mean your communications are being intercepted!',
|
|
{
|
|
fprint,
|
|
userId,
|
|
deviceId,
|
|
fingerprint,
|
|
}));
|
|
}
|
|
|
|
return cli.setDeviceVerified(userId, deviceId, true);
|
|
}).then(() => {
|
|
// Tell the user we verified everything
|
|
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
|
Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
|
|
title: _t('Verified key'),
|
|
description: <div>
|
|
<p>
|
|
{
|
|
_t('The signing key you provided matches the signing key you received ' +
|
|
'from %(userId)s\'s device %(deviceId)s. Device marked as verified.',
|
|
{userId, deviceId})
|
|
}
|
|
</p>
|
|
</div>,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
return reject(this.getUsage());
|
|
},
|
|
}),
|
|
|
|
// Command definitions for autocompletion ONLY:
|
|
|
|
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
|
|
me: new Command({
|
|
name: 'me',
|
|
args: '<message>',
|
|
description: _td('Displays action'),
|
|
hideCompletionAfterSpace: true,
|
|
}),
|
|
|
|
discardsession: new Command({
|
|
name: 'discardsession',
|
|
description: _td('Forces the current outbound group session in an encrypted room to be discarded'),
|
|
runFn: function(roomId) {
|
|
try {
|
|
MatrixClientPeg.get().forceDiscardSession(roomId);
|
|
} catch (e) {
|
|
return reject(e.message);
|
|
}
|
|
return success();
|
|
},
|
|
}),
|
|
};
|
|
/* eslint-enable babel/no-invalid-this */
|
|
|
|
|
|
// helpful aliases
|
|
const aliases = {
|
|
j: "join",
|
|
newballsplease: "discardsession",
|
|
goto: "join", // because it handles event permalinks magically
|
|
};
|
|
|
|
|
|
/**
|
|
* Process the given text for /commands and perform them.
|
|
* @param {string} roomId The room in which the command was performed.
|
|
* @param {string} input The raw text input by the user.
|
|
* @return {Object|null} An object with the property 'error' if there was an error
|
|
* processing the command, or 'promise' if a request was sent out.
|
|
* Returns null if the input didn't match a command.
|
|
*/
|
|
export function processCommandInput(roomId, input) {
|
|
// trim any trailing whitespace, as it can confuse the parser for
|
|
// IRC-style commands
|
|
input = input.replace(/\s+$/, '');
|
|
if (input[0] !== '/') return null; // not a command
|
|
|
|
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
|
|
let cmd;
|
|
let args;
|
|
if (bits) {
|
|
cmd = bits[1].substring(1).toLowerCase();
|
|
args = bits[3];
|
|
} else {
|
|
cmd = input;
|
|
}
|
|
|
|
if (aliases[cmd]) {
|
|
cmd = aliases[cmd];
|
|
}
|
|
if (CommandMap[cmd]) {
|
|
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
|
|
if (!CommandMap[cmd].runFn) return null;
|
|
|
|
return CommandMap[cmd].run(roomId, args);
|
|
} else {
|
|
return reject(_t('Unrecognised command:') + ' ' + input);
|
|
}
|
|
}
|