diff --git a/package-lock.json b/package-lock.json index 9f1c288cd4118bc710250aa0f7e454ce954828d3..7abcd97a9160b6036722103fbf4de323d51ddd7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1875,8 +1875,8 @@ } }, "matrix-appservice-bridge": { - "version": "github:matrix-org/matrix-appservice-bridge#7abb1228759829500ed7718209b28e197d898dd0", - "from": "github:matrix-org/matrix-appservice-bridge#7abb1228759829500ed7718209b28e197d898dd0", + "version": "github:matrix-org/matrix-appservice-bridge#8a7288edf1d1d1d1395a83d330d836d9c9bf1e76", + "from": "github:matrix-org/matrix-appservice-bridge#8a7288edf1d1d1d1395a83d330d836d9c9bf1e76", "requires": { "bluebird": "^2.9.34", "chalk": "^2.4.1", @@ -1894,9 +1894,9 @@ } }, "matrix-js-sdk": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-1.0.4.tgz", - "integrity": "sha512-FPx7U1a0SmLbDXhXlR4XHlC+FVKTnK2/+ZBtyOWGLi3nxw4x8hCSSzJ82gzStya1qvhHvbf/y7eblYFVE1l7SQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-1.1.0.tgz", + "integrity": "sha512-ECoMN6DkwPdKiMa/jSoMkSDngFCo6x7oH84rLd1NtD7lBPl3Ejj6ARa0iIELE7u0OUO6J0FzdWh7Hd0ZnVTmww==", "requires": { "another-json": "^0.2.0", "babel-runtime": "^6.26.0", @@ -2456,9 +2456,9 @@ "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, "range-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", - "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, "raw-body": { "version": "2.4.0", diff --git a/src/bot.ts b/src/bot.ts index a77c32ed0f1714c076dffe06fd19faa7cf69198a..563cc31b98defdb2741267c453bcf800ad5c2c79 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -36,6 +36,7 @@ import { Log } from "./log"; import * as Discord from "discord.js"; import * as mime from "mime"; import { IMatrixEvent, IMatrixMediaInfo } from "./matrixtypes"; +import { DiscordCommandHandler } from "./discordcommandhandler"; const log = new Log("DiscordBot"); @@ -74,6 +75,7 @@ export class DiscordBot { private channelSync: ChannelSyncroniser; private roomHandler: MatrixRoomHandler; private provisioner: Provisioner; + private discordCommandHandler: DiscordCommandHandler; /* Caches */ private roomIdsForGuildCache: Map<string, {roomIds: string[], ts: number}> = new Map(); @@ -99,6 +101,7 @@ export class DiscordBot { new MatrixEventProcessorOpts(config, bridge, this), ); this.channelSync = new ChannelSyncroniser(bridge, config, this, store.roomStore); + this.discordCommandHandler = new DiscordCommandHandler(bridge, this); // init vars this.sentMessages = []; this.discordMessageQueue = {}; @@ -684,34 +687,9 @@ export class DiscordBot { } } - // Check if there's an ongoing bridge request - if ((msg.content === "!approve" || msg.content === "!deny") && this.provisioner.HasPendingRequest(chan)) { - try { - const isApproved = msg.content === "!approve"; - const successfullyBridged = await this.provisioner.MarkApproved(chan, msg.member, isApproved); - if (successfullyBridged && isApproved) { - await msg.channel.sendMessage("Thanks for your response! The matrix bridge has been approved"); - } else if (successfullyBridged && !isApproved) { - await msg.channel.sendMessage("Thanks for your response! The matrix bridge has been declined"); - } else { - await msg.channel.sendMessage("Thanks for your response, however" + - "the time for responses has expired - sorry!"); - } - } catch (err) { - if (err.message === "You do not have permission to manage webhooks in this channel") { - await msg.channel.sendMessage(err.message); - } else { - log.error("Error processing room approval"); - log.error(err); - } - } - - return; // stop processing - we're approving/declining the bridge request - } - // check if it is a command to process by the bot itself if (msg.content.startsWith("!matrix")) { - await this.roomHandler.HandleDiscordCommand(msg); + await this.discordCommandHandler.Process(msg); return; } diff --git a/src/discordcommandhandler.ts b/src/discordcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f3804c640c82665c946dfd64adf5a0642ae7b2d --- /dev/null +++ b/src/discordcommandhandler.ts @@ -0,0 +1,134 @@ +/* +Copyright 2019 matrix-appservice-discord + +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 { DiscordBot } from "./bot"; +import * as Discord from "discord.js"; +import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util"; +import { Bridge } from "matrix-appservice-bridge"; +export class DiscordCommandHandler { + constructor( + private bridge: Bridge, + private discord: DiscordBot, + ) { } + + public async Process(msg: Discord.Message) { + const chan = msg.channel as Discord.TextChannel; + if (!chan.guild) { + await msg.channel.send("**ERROR:** only available for guild channels"); + return; + } + + const intent = this.bridge.getIntent(); + + const actions: ICommandActions = { + approve: { + description: "Approve a pending bridge request", + params: [], + permission: "MANAGE_WEBHOOKS", + run: async () => { + if (await this.discord.Provisioner.MarkApproved(chan, msg.member, true)) { + return "Thanks for your response! The matrix bridge has been approved"; + } else { + return "Thanks for your response, however" + + "the time for responses has expired - sorry!"; + } + }, + }, + ban: { + description: "Bans a user on the matrix side", + params: ["name"], + permission: "BAN_MEMBERS", + run: this.ModerationActionGenerator(chan, "ban"), + }, + deny: { + description: "Deny a pending bridge request", + params: [], + permission: "MANAGE_WEBHOOKS", + run: async () => { + if (await this.discord.Provisioner.MarkApproved(chan, msg.member, false)) { + return "Thanks for your response! The matrix bridge has been declined"; + } else { + return "Thanks for your response, however" + + "the time for responses has expired - sorry!"; + } + }, + }, + kick: { + description: "Kicks a user on the matrix side", + params: ["name"], + permission: "KICK_MEMBERS", + run: this.ModerationActionGenerator(chan, "kick"), + }, + unban: { + description: "Unbans a user on the matrix side", + params: ["name"], + permission: "BAN_MEMBERS", + run: this.ModerationActionGenerator(chan, "unban"), + }, + }; + + const parameters: ICommandParameters = { + name: { + description: "The display name or mxid of a matrix user", + get: async (name) => { + const channelMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(msg.channel); + const mxUserId = await Util.GetMxidFromName(intent, name, channelMxids); + return mxUserId; + }, + }, + }; + + const permissionCheck: CommandPermissonCheck = async (permission) => { + return msg.member.hasPermission(permission as Discord.PermissionResolvable); + }; + + const reply = await Util.ParseCommand("!matrix", msg.content, actions, parameters, permissionCheck); + await msg.channel.send(reply); + } + + private ModerationActionGenerator(discordChannel: Discord.TextChannel, funcKey: "kick"|"ban"|"unban") { + return async ({name}) => { + let allChannelMxids: string[] = []; + await Promise.all(discordChannel.guild.channels.map(async (chan) => { + try { + const chanMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(chan); + allChannelMxids = allChannelMxids.concat(chanMxids); + } catch (e) { + // pass, non-text-channel + } + })); + let errorMsg = ""; + await Promise.all(allChannelMxids.map(async (chanMxid) => { + const intent = this.bridge.getIntent(); + try { + await intent[funcKey](chanMxid, name); + } catch (e) { + // maybe we don't have permission to kick/ban/unban...? + errorMsg += `\nCouldn't ${funcKey} ${name} from ${chanMxid}`; + } + })); + if (errorMsg) { + throw Error(errorMsg); + } + const action = { + ban: "Banned", + kick: "Kicked", + unban: "Unbanned", + }[funcKey]; + return `${action} ${name}`; + }; + } +} diff --git a/src/matrixcommandhandler.ts b/src/matrixcommandhandler.ts index a116d6f5f127220f2cbb347e5a6906a024b4e1ce..b9e00a9cbfa7237d11361aa19c512b761105bd3c 100644 --- a/src/matrixcommandhandler.ts +++ b/src/matrixcommandhandler.ts @@ -1,11 +1,28 @@ +/* +Copyright 2019 matrix-appservice-discord + +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 { DiscordBot } from "./bot"; import { Log } from "./log"; import { DiscordBridgeConfig } from "./config"; import { Bridge, BridgeContext } from "matrix-appservice-bridge"; import { IMatrixEvent } from "./matrixtypes"; import { Provisioner } from "./provisioner"; -import { Util } from "./util"; +import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util"; import * as Discord from "discord.js"; +import * as markdown from "discord-markdown"; const log = new Log("MatrixCommandHandler"); /* tslint:disable:no-magic-numbers */ @@ -35,175 +52,148 @@ export class MatrixCommandHandler { } } - public async ProcessCommand(event: IMatrixEvent, context: BridgeContext) { + public async Process(event: IMatrixEvent, context: BridgeContext) { const intent = this.bridge.getIntent(); if (!(await this.isBotInRoom(event.room_id))) { log.warn(`Bot is not in ${event.room_id}. Ignoring command`); return; } - if (!this.config.bridge.enableSelfServiceBridging) { - // We can do this here because the only commands we support are self-service bridging - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "The owner of this bridge does not permit self-service bridging.", - msgtype: "m.notice", - }); - } - - // Check to make sure the user has permission to do anything in the room. We can do this here - // because the only commands we support are self-service commands (which therefore require some - // level of permissions) - const plEvent = await this.bridge.getIntent().getClient() - .getStateEvent(event.room_id, "m.room.power_levels", ""); - let userLevel = PROVISIONING_DEFAULT_USER_POWER_LEVEL; - let requiredLevel = PROVISIONING_DEFAULT_POWER_LEVEL; - if (plEvent && plEvent.state_default) { - requiredLevel = plEvent.state_default; - } - if (plEvent && plEvent.users_default) { - userLevel = plEvent.users_default; - } - if (plEvent && plEvent.users && plEvent.users[event.sender]) { - userLevel = plEvent.users[event.sender]; - } - - if (userLevel < requiredLevel) { - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "You do not have the required power level in this room to create a bridge to a Discord channel.", - msgtype: "m.notice", - }); - } - - const {command, args} = Util.MsgToArgs(event.content!.body as string, "!discord"); - - if (command === "help" && args[0] === "bridge") { - const link = Util.GetBotLink(this.config); - // tslint:disable prefer-template - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "How to bridge a Discord guild:\n" + - "1. Invite the bot to your Discord guild using this link: " + link + "\n" + - "2. Invite me to the matrix room you'd like to bridge\n" + - "3. Open the Discord channel you'd like to bridge in a web browser\n" + - "4. In the matrix room, send the message `!discord bridge <guild id> <channel id>` " + - "(without the backticks)\n" + - " Note: The Guild ID and Channel ID can be retrieved from the URL in your web browser.\n" + - " The URL is formatted as https://discordapp.com/channels/GUILD_ID/CHANNEL_ID\n" + - "5. Enjoy your new bridge!", - msgtype: "m.notice", - }); - // tslint:enable prefer-template - } else if (command === "bridge") { - if (context.rooms.remote) { - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "This room is already bridged to a Discord guild.", - msgtype: "m.notice", - }); - } - - const MAXARGS = 2; - if (args.length > MAXARGS || args.length < 1) { - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "Invalid syntax. For more information try !discord help bridge", - msgtype: "m.notice", - }); - } - - let guildId: string; - let channelId: string; - - const AMOUNT_OF_IDS_DISCORD_IDENTIFIES_ROOMS_BY = 2; - - if (args.length === AMOUNT_OF_IDS_DISCORD_IDENTIFIES_ROOMS_BY) { // "x y" syntax - guildId = args[0]; - channelId = args[1]; - } else if (args.length === 1 && args[0].includes("/")) { // "x/y" syntax - const split = args[0].split("/"); - guildId = split[0]; - channelId = split[1]; - } else { - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "Invalid syntax: See `!discord help`", - formatted_body: "Invalid syntax: See <code>!discord help</code>", - msgtype: "m.notice", - }); - } - - try { - const discordResult = await this.discord.LookupRoom(guildId, channelId); - const channel = discordResult.channel as Discord.TextChannel; - - log.info(`Bridging matrix room ${event.room_id} to ${guildId}/${channelId}`); - this.bridge.getIntent().sendMessage(event.room_id, { - body: "I'm asking permission from the guild administrators to make this bridge.", - msgtype: "m.notice", - }); - - await this.provisioner.AskBridgePermission(channel, event.sender); - await this.provisioner.BridgeMatrixRoom(channel, event.room_id); - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "I have bridged this room to your channel", - msgtype: "m.notice", - }); - } catch (err) { - if (err.message === "Timed out waiting for a response from the Discord owners" - || err.message === "The bridge has been declined by the Discord guild") { - return this.bridge.getIntent().sendMessage(event.room_id, { - body: err.message, - msgtype: "m.notice", - }); - } - - log.error(`Error bridging ${event.room_id} to ${guildId}/${channelId}`); - log.error(err); - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "There was a problem bridging that channel - has the guild owner approved the bridge?", - msgtype: "m.notice", - }); - } - } else if (command === "unbridge") { - const remoteRoom = context.rooms.remote; - - if (!remoteRoom) { - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "This room is not bridged.", - msgtype: "m.notice", - }); + const actions: ICommandActions = { + bridge: { + description: "Bridges this room to a Discord channel", + // tslint:disable prefer-template + help: "How to bridge a Discord guild:\n" + + "1. Invite the bot to your Discord guild using this link: " + Util.GetBotLink(this.config) + "\n" + + "2. Invite me to the matrix room you'd like to bridge\n" + + "3. Open the Discord channel you'd like to bridge in a web browser\n" + + "4. In the matrix room, send the message `!discord bridge <guild id> <channel id>` " + + "(without the backticks)\n" + + " Note: The Guild ID and Channel ID can be retrieved from the URL in your web browser.\n" + + " The URL is formatted as https://discordapp.com/channels/GUILD_ID/CHANNEL_ID\n" + + "5. Enjoy your new bridge!", + // tslint:enable prefer-template + params: ["guildId", "channelId"], + permission: { + cat: "events", + level: PROVISIONING_DEFAULT_POWER_LEVEL, + selfService: true, + subcat: "m.room.power_levels", + }, + run: async ({guildId, channelId}) => { + if (context.rooms.remote) { + return "This room is already bridged to a Discord guild."; + } + if (!guildId || !channelId) { + return "Invalid syntax. For more information try `!discord help bridge`"; + } + try { + const discordResult = await this.discord.LookupRoom(guildId, channelId); + const channel = discordResult.channel as Discord.TextChannel; + + log.info(`Bridging matrix room ${event.room_id} to ${guildId}/${channelId}`); + this.bridge.getIntent().sendMessage(event.room_id, { + body: "I'm asking permission from the guild administrators to make this bridge.", + msgtype: "m.notice", + }); + + await this.provisioner.AskBridgePermission(channel, event.sender); + await this.provisioner.BridgeMatrixRoom(channel, event.room_id); + return "I have bridged this room to your channel"; + } catch (err) { + if (err.message === "Timed out waiting for a response from the Discord owners" + || err.message === "The bridge has been declined by the Discord guild") { + return err.message; + } + + log.error(`Error bridging ${event.room_id} to ${guildId}/${channelId}`); + log.error(err); + return "There was a problem bridging that channel - has the guild owner approved the bridge?"; + } + }, + }, + unbridge: { + description: "Unbridges a Discord channel from this room", + params: [], + permission: { + cat: "events", + level: PROVISIONING_DEFAULT_POWER_LEVEL, + selfService: true, + subcat: "m.room.power_levels", + }, + run: async () => { + const remoteRoom = context.rooms.remote; + if (!remoteRoom) { + return "This room is not bridged."; + } + if (!remoteRoom.data.plumbed) { + return "This room cannot be unbridged."; + } + try { + await this.provisioner.UnbridgeRoom(remoteRoom); + return "This room has been unbridged"; + } catch (err) { + log.error("Error while unbridging room " + event.room_id); + log.error(err); + return "There was an error unbridging this room. " + + "Please try again later or contact the bridge operator."; + } + }, + }, + }; + + /* + We hack together that "guildId/channelId" is the same as "guildId channelId". + We do this by assuming that guildId is parsed first, and split at "/" + The first element is returned, the second one is passed on to channelId, if applicable. + */ + let guildIdRemainder: string | undefined; + const parameters: ICommandParameters = { + channelId: { + description: "The ID of a channel on discord", + get: async (s) => { + if (!s && guildIdRemainder) { + return guildIdRemainder; + } + return s; + }, + }, + guildId: { + description: "The ID of a guild/server on discord", + get: async (s) => { + if (!s) { + return s; + } + const parts = s.split("/"); + guildIdRemainder = parts[1]; + return parts[0]; + }, + }, + }; + + const permissionCheck: CommandPermissonCheck = async (permission) => { + if (permission.selfService && !this.config.bridge.enableSelfServiceBridging) { + return "The owner of this bridge does not permit self-service bridging."; } - - if (!remoteRoom.data.plumbed) { - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "This room cannot be unbridged.", - msgtype: "m.notice", - }); - } - - try { - await this.provisioner.UnbridgeRoom(remoteRoom); - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "This room has been unbridged", - msgtype: "m.notice", - }); - } catch (err) { - log.error("Error while unbridging room " + event.room_id); - log.error(err); - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "There was an error unbridging this room. " + - "Please try again later or contact the bridge operator.", - msgtype: "m.notice", - }); - } - } else if (command === "help") { - // Unknown command or no command given to get help on, so we'll just give them the help - // tslint:disable prefer-template - return this.bridge.getIntent().sendMessage(event.room_id, { - body: "Available commands:\n" + - "!discord bridge <guild id> <channel id> - Bridges this room to a Discord channel\n" + - "!discord unbridge - Unbridges a Discord channel from this room\n" + - "!discord help <command> - Help menu for another command. Eg: !discord help bridge\n", - msgtype: "m.notice", - }); - // tslint:enable prefer-template - } + return await Util.CheckMatrixPermission( + this.bridge.getIntent().getClient(), + event.sender, + event.room_id, + permission.level, + permission.cat, + permission.subcat, + ); + }; + + const reply = await Util.ParseCommand("!discord", event.content!.body!, actions, parameters, permissionCheck); + const formattedReply = markdown.toHTML(reply); + + await this.bridge.getIntent().sendMessage(event.room_id, { + body: reply, + format: "org.matrix.custom.html", + formatted_body: formattedReply, + msgtype: "m.notice", + }); } private async isBotInRoom(roomId: string): Promise<boolean> { diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts index 28945add25b48c88d2916a52682cc2e429a99d63..7c7f6a516ccfd7114329f177280c2429eefb76ce 100644 --- a/src/matrixeventprocessor.ts +++ b/src/matrixeventprocessor.ts @@ -115,7 +115,7 @@ export class MatrixEventProcessor { event.content!.body && event.content!.body!.startsWith("!discord"); if (isBotCommand) { - await this.mxCommandHandler.ProcessCommand(event, context); + await this.mxCommandHandler.Process(event, context); return; } else if (context.rooms.remote) { const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS); diff --git a/src/matrixmessageprocessor.ts b/src/matrixmessageprocessor.ts index 124978d7689fb22620c6ac60d9bf76e8d7217b70..c455aac924af09f6cd5f94f1aecfab1df512202c 100644 --- a/src/matrixmessageprocessor.ts +++ b/src/matrixmessageprocessor.ts @@ -80,19 +80,14 @@ export class MatrixMessageProcessor { if (!this.params || !this.params.mxClient || !this.params.roomId || !this.params.userId) { return false; } - - const res: IMatrixEvent = await this.params.mxClient.getStateEvent( - this.params.roomId, "m.room.power_levels"); - - // Some rooms may not have notifications.room set if the value hasn't - // been changed from the default. If so, use our hardcoded power level. - const requiredPowerLevel = res && res.notifications && res.notifications.room - ? res.notifications.room - : DEFAULT_ROOM_NOTIFY_POWER_LEVEL; - - return res && res.users - && res.users[this.params.userId] !== undefined - && res.users[this.params.userId] >= requiredPowerLevel; + return await Util.CheckMatrixPermission( + this.params.mxClient, + this.params.userId, + this.params.roomId, + DEFAULT_ROOM_NOTIFY_POWER_LEVEL, + "notifications", + "room", + ); } private async escapeDiscord(msg: string): Promise<string> { diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index c648f6e503884cd2395bf076b76a375959c72fd9..a91d4c76eb22b7e366a73a8e8ee093f76d85783e 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -28,7 +28,7 @@ import { import { DiscordBridgeConfig } from "./config"; import * as Discord from "discord.js"; -import { Util, ICommandActions, ICommandParameters } from "./util"; +import { Util } from "./util"; import { Provisioner } from "./provisioner"; import { Log } from "./log"; const log = new Log("MatrixRoomHandler"); @@ -218,117 +218,6 @@ export class MatrixRoomHandler { throw {err: "Unsupported", code: HTTP_UNSUPPORTED}; } - public async HandleDiscordCommand(msg: Discord.Message) { - if (!(msg.channel as Discord.TextChannel).guild) { - await msg.channel.send("**ERROR:** only available for guild channels"); - } - - const {command, args} = Util.MsgToArgs(msg.content, "!matrix"); - - const intent = this.bridge.getIntent(); - - const actions: ICommandActions = { - ban: { - description: "Bans a user on the matrix side", - params: ["name"], - permission: "BAN_MEMBERS", - run: this.DiscordModerationActionGenerator(msg.channel as Discord.TextChannel, "ban", "Banned"), - }, - kick: { - description: "Kicks a user on the matrix side", - params: ["name"], - permission: "KICK_MEMBERS", - run: this.DiscordModerationActionGenerator(msg.channel as Discord.TextChannel, "kick", "Kicked"), - }, - unban: { - description: "Unbans a user on the matrix side", - params: ["name"], - permission: "BAN_MEMBERS", - run: this.DiscordModerationActionGenerator(msg.channel as Discord.TextChannel, "unban", "Unbanned"), - }, - }; - - const parameters: ICommandParameters = { - name: { - description: "The display name or mxid of a matrix user", - get: async (name) => { - const channelMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(msg.channel); - const mxUserId = await Util.GetMxidFromName(intent, name, channelMxids); - return mxUserId; - }, - }, - }; - - if (command === "help") { - let replyHelpMessage = "Available Commands:\n"; - for (const actionKey of Object.keys(actions)) { - const action = actions[actionKey]; - if (!msg.member.hasPermission(action.permission as Discord.PermissionResolvable)) { - continue; - } - replyHelpMessage += " - `!matrix " + actionKey; - for (const param of action.params) { - replyHelpMessage += ` <${param}>`; - } - replyHelpMessage += `\`: ${action.description}\n`; - } - replyHelpMessage += "\nParameters:\n"; - for (const parameterKey of Object.keys(parameters)) { - const parameter = parameters[parameterKey]; - replyHelpMessage += ` - \`<${parameterKey}>\`: ${parameter.description}\n`; - } - await msg.channel.send(replyHelpMessage); - return; - } - - if (!actions[command]) { - await msg.channel.send("**Error:** unknown command. Try `!matrix help` to see all commands"); - return; - } - - if (!msg.member.hasPermission(actions[command].permission as Discord.PermissionResolvable)) { - await msg.channel.send("**ERROR:** insufficiant permissions to use this matrix command"); - return; - } - - let replyMessage = ""; - try { - replyMessage = await Util.ParseCommand(actions[command], parameters, args); - } catch (e) { - replyMessage = "**ERROR:** " + e.message; - } - - await msg.channel.send(replyMessage); - } - - private DiscordModerationActionGenerator(discordChannel: Discord.TextChannel, funcKey: string, action: string) { - return async ({name}) => { - let allChannelMxids: string[] = []; - await Promise.all(discordChannel.guild.channels.map(async (chan) => { - try { - const chanMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(chan); - allChannelMxids = allChannelMxids.concat(chanMxids); - } catch (e) { - // pass, non-text-channel - } - })); - let errorMsg = ""; - await Promise.all(allChannelMxids.map(async (chanMxid) => { - const intent = this.bridge.getIntent(); - try { - await intent[funcKey](chanMxid, name); - } catch (e) { - // maybe we don't have permission to kick/ban/unban...? - errorMsg += `\nCouldn't ${funcKey} ${name} from ${chanMxid}`; - } - })); - if (errorMsg) { - throw Error(errorMsg); - } - return `${action} ${name}`; - }; - } - private async joinRoom(intent: Intent, roomIdOrAlias: string, member?: Discord.GuildMember): Promise<void> { let currentSchedule = JOIN_ROOM_SCHEDULE[0]; const doJoin = async () => { diff --git a/src/matrixtypes.ts b/src/matrixtypes.ts index 3d54c2d5641aa0ab9f22262ed30a3ef3e4014db1..7535b59a89063a02e1f1946321550287f483dd94 100644 --- a/src/matrixtypes.ts +++ b/src/matrixtypes.ts @@ -42,6 +42,7 @@ export interface IMatrixEvent { unsigned?: any; // tslint:disable-line no-any origin_server_ts?: number; users?: any; // tslint:disable-line no-any + users_default?: any; // tslint:disable-line no-any notifications?: any; // tslint:disable-line no-any } diff --git a/src/provisioner.ts b/src/provisioner.ts index 9a871a1750fae09eb2f3592b24d1ba1488ccc55d..c5b53068274d89b3e6644dce70104cfb4bd4e98b 100644 --- a/src/provisioner.ts +++ b/src/provisioner.ts @@ -78,7 +78,7 @@ export class Provisioner { setTimeout(() => approveFn(false, true), timeout); await channel.send(`${requestor} on matrix would like to bridge this channel. Someone with permission` + - " to manage webhooks please reply with !approve or !deny in the next 5 minutes"); + " to manage webhooks please reply with `!matrix approve` or `!matrix deny` in the next 5 minutes"); return await deferP; } diff --git a/src/util.ts b/src/util.ts index aca4d651a2eb9780ff8d1a5666b389799edb10db..6edce065c7dcc17bc6830f89226b681726367d8e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 matrix-appservice-discord +Copyright 2018, 2019 matrix-appservice-discord Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,16 +21,21 @@ import { Buffer } from "buffer"; import * as mime from "mime"; import { Permissions } from "discord.js"; import { DiscordBridgeConfig } from "./config"; +import { Client as MatrixClient } from "matrix-js-sdk"; +import { IMatrixEvent } from "./matrixtypes"; const HTTP_OK = 200; import { Log } from "./log"; const log = new Log("Util"); +type PERMISSIONTYPES = any; // tslint:disable-line no-any + export interface ICommandAction { - params: string[]; description?: string; - permission?: string; + help?: string; + params: string[]; + permission?: PERMISSIONTYPES; run(params: any): Promise<any>; // tslint:disable-line no-any } @@ -40,13 +45,15 @@ export interface ICommandActions { export interface ICommandParameter { description?: string; - get(param: string): Promise<any>; // tslint:disable-line no-any + get?(param: string): Promise<any>; // tslint:disable-line no-any } export interface ICommandParameters { [index: string]: ICommandParameter; } +export type CommandPermissonCheck = (permission: PERMISSIONTYPES) => Promise<boolean | string>; + export interface IPatternMap { [index: string]: string; } @@ -228,19 +235,111 @@ export class Util { return Object.keys(matrixUsers)[0]; } - public static async ParseCommand(action: ICommandAction, parameters: ICommandParameters, args: string[]) { + public static async HandleHelpCommand( + prefix: string, + actions: ICommandActions, + parameters: ICommandParameters, + args: string[], + permissionCheck?: CommandPermissonCheck, + ): Promise<string> { + let reply = ""; + if (args[0]) { + const actionKey = args[0]; + const action = actions[actionKey]; + if (!actions[actionKey]) { + return `**ERROR:** unknown command! Try \`${prefix} help\` to see all commands`; + } + if (action.permission !== undefined && permissionCheck) { + const permCheck = await permissionCheck(action.permission); + if (typeof permCheck === "string") { + return `**ERROR:** ${permCheck}`; + } + if (!permCheck) { + return `**ERROR:** permission denied! Try \`${prefix} help\` to see all available commands`; + } + } + reply += `\`${prefix} ${actionKey}`; + for (const param of action.params) { + reply += ` <${param}>`; + } + reply += `\`: ${action.description}\n`; + if (action.help) { + reply += action.help; + } + return reply; + } + reply += "Available Commands:\n"; + for (const actionKey of Object.keys(actions)) { + const action = actions[actionKey]; + if (action.permission !== undefined && permissionCheck) { + const permCheck = await permissionCheck(action.permission); + if (typeof permCheck === "string" || !permCheck) { + continue; + } + } + reply += ` - \`${prefix} ${actionKey}`; + for (const param of action.params) { + reply += ` <${param}>`; + } + reply += `\`: ${action.description}\n`; + } + reply += "\nParameters:\n"; + for (const parameterKey of Object.keys(parameters)) { + const parameter = parameters[parameterKey]; + reply += ` - \`<${parameterKey}>\`: ${parameter.description}\n`; + } + return reply; + } + + public static async ParseCommand( + prefix: string, + msg: string, + actions: ICommandActions, + parameters: ICommandParameters, + permissionCheck?: CommandPermissonCheck, + ): Promise<string> { + const {command, args} = Util.MsgToArgs(msg, prefix); + + if (command === "help") { + return await Util.HandleHelpCommand(prefix, actions, parameters, args, permissionCheck); + } + + if (!actions[command]) { + return `**ERROR:** unknown command. Try \`${prefix} help\` to see all commands`; + } + const action = actions[command]; + if (action.permission !== undefined && permissionCheck) { + const permCheck = await permissionCheck(action.permission); + if (typeof permCheck === "string") { + return `**ERROR:** ${permCheck}`; + } + if (!permCheck) { + return `**ERROR:** insufficiant permissions to use this command! ` + + `Try \`${prefix} help\` to see all available commands`; + } + } if (action.params.length === 1) { args[0] = args.join(" "); } - const params = {}; - let i = 0; - for (const param of action.params) { - params[param] = await parameters[param].get(args[i]); - i++; - } + try { + const params = {}; + let i = 0; + for (const param of action.params) { + if (parameters[param].get !== undefined) { + params[param] = await parameters[param].get!(args[i]); + } else { + params[param] = args[i]; + } + i++; + } - const retStr = await action.run(params); - return retStr; + const retStr = await action.run(params); + return retStr; + } catch (e) { + return `**ERROR:** ${e.message}`; + log.error("Error processing command"); + log.error(e); + } } public static MsgToArgs(msg: string, prefix: string) { @@ -285,6 +384,38 @@ export class Util { } return str; } + + public static async CheckMatrixPermission( + mxClient: MatrixClient, + userId: string, + roomId: string, + defaultLevel: number, + cat: string, + subcat?: string, + ) { + const res: IMatrixEvent = await mxClient.getStateEvent(roomId, "m.room.power_levels"); + let requiredLevel = defaultLevel; + if (res && (res[cat] || !subcat)) { + if (subcat) { + if (res[cat][subcat] !== undefined) { + requiredLevel = res[cat][subcat]; + } + } else { + if (res[cat] !== undefined) { + requiredLevel = res[cat]; + } + } + } + + let haveLevel = 0; + if (res && res.users_default) { + haveLevel = res.users_default; + } + if (res && res.users && res.users[userId] !== undefined) { + haveLevel = res.users[userId]; + } + return haveLevel >= requiredLevel; + } } interface IUploadResult { diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts index c50c76f502d4310e552499025979f66c3287777a..2eeccfc065c3506ed596029794b071f94654f6af 100644 --- a/test/test_discordbot.ts +++ b/test/test_discordbot.ts @@ -1,5 +1,5 @@ /* -Copyright 2017, 2018 matrix-appservice-discord +Copyright 2017 - 2019 matrix-appservice-discord Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -133,13 +133,11 @@ describe("DiscordBot", () => { }); describe("OnMessage()", () => { let SENT_MESSAGE = false; - let MARKED = -1; let HANDLE_COMMAND = false; let ATTACHMENT = {} as any; let MSGTYPE = ""; function getDiscordBot() { SENT_MESSAGE = false; - MARKED = -1; HANDLE_COMMAND = false; ATTACHMENT = {}; MSGTYPE = ""; @@ -150,13 +148,6 @@ describe("DiscordBot", () => { {}, ); discord.bot = { user: { id: "654" } }; - discord.provisioner = { - HasPendingRequest: (chan) => true, - MarkApproved: async (chan, member, approved) => { - MARKED = approved ? 1 : 0; - return approved; - }, - }; discord.GetIntentFromDiscordMember = (_) => {return { sendMessage: async (room, msg) => { SENT_MESSAGE = true; @@ -175,8 +166,8 @@ describe("DiscordBot", () => { discord.channelSync = { GetRoomIdsFromChannel: async (chan) => ["!asdf:localhost"], }; - discord.roomHandler = { - HandleDiscordCommand: async (msg) => { HANDLE_COMMAND = true; }, + discord.discordCommandHandler = { + Process: async (msg) => { HANDLE_COMMAND = true; }, }; discord.store = { Insert: async (_) => { }, @@ -195,22 +186,6 @@ describe("DiscordBot", () => { await discordBot.OnMessage(msg); Chai.assert.equal(SENT_MESSAGE, false); }); - it("accepts !approve", async () => { - discordBot = getDiscordBot(); - const channel = new Discord.TextChannel({} as any, {} as any); - const msg = new MockMessage(channel) as any; - msg.content = "!approve"; - await discordBot.OnMessage(msg); - Chai.assert.equal(MARKED, 1); - }); - it("denies !deny", async () => { - discordBot = getDiscordBot(); - const channel = new Discord.TextChannel({} as any, {} as any); - const msg = new MockMessage(channel) as any; - msg.content = "!deny"; - await discordBot.OnMessage(msg); - Chai.assert.equal(MARKED, 0); - }); it("Passes on !matrix commands", async () => { discordBot = getDiscordBot(); const channel = new Discord.TextChannel({} as any, {} as any); diff --git a/test/test_discordcommandhandler.ts b/test/test_discordcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5851f4f4e1a5fb5bbdaa1f2632f61475ee3c89f --- /dev/null +++ b/test/test_discordcommandhandler.ts @@ -0,0 +1,205 @@ +/* +Copyright 2019 matrix-appservice-discord + +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 Chai from "chai"; +import * as Proxyquire from "proxyquire"; + +import { DiscordCommandHandler } from "../src/discordcommandhandler"; +import { MockChannel } from "./mocks/channel"; +import { MockMember } from "./mocks/member"; +import { MockGuild } from "./mocks/guild"; +import { Util } from "../src/util"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +let USERSJOINED = 0; +let USERSKICKED = 0; +let USERSBANNED = 0; +let USERSUNBANNED = 0; +let MESSAGESENT: any = {}; +let MARKED = -1; +function createCH(opts: any = {}) { + USERSJOINED = 0; + USERSKICKED = 0; + USERSBANNED = 0; + USERSUNBANNED = 0; + MESSAGESENT = {}; + MARKED = -1; + const bridge = { + getIntent: () => { + return { + ban: async () => { USERSBANNED++; }, + getEvent: () => ({ content: { } }), + join: () => { USERSJOINED++; }, + kick: async () => { USERSKICKED++; }, + leave: () => { }, + sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; }, + unban: async () => { USERSUNBANNED++; }, + }; + }, + }; + const cs = { + GetRoomIdsFromChannel: async (chan) => { + return [`#${chan.id}:localhost`]; + }, + }; + const discord = { + ChannelSyncroniser: cs, + Provisioner: { + HasPendingRequest: (chan) => true, + MarkApproved: async (chan, member, approved) => { + MARKED = approved ? 1 : 0; + return approved; + }, + }, + }; + const discordCommandHndlr = (Proxyquire("../src/discordcommandhandler", { + "./util": { + Util: { + GetMxidFromName: () => { + return "@123456:localhost"; + }, + ParseCommand: Util.ParseCommand, + }, + }, + })).DiscordCommandHandler; + return new discordCommandHndlr(bridge as any, discord as any); +} + +describe("DiscordCommandHandler", () => { + it("will kick a member", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix kick someuser", + member, + }; + await handler.Process(message); + expect(USERSKICKED).equals(1); + }); + it("will kick a member in all guild rooms", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel, (new MockChannel("456"))]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix kick someuser", + member, + }; + await handler.Process(message); + // tslint:disable-next-line:no-magic-numbers + expect(USERSKICKED).equals(2); + }); + it("will deny permission", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return false; + }; + const message = { + channel, + content: "!matrix kick someuser", + member, + }; + await handler.Process(message); + expect(USERSKICKED).equals(0); + }); + it("will ban a member", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix ban someuser", + member, + }; + await handler.Process(message); + expect(USERSBANNED).equals(1); + }); + it("will unban a member", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix unban someuser", + member, + }; + await handler.Process(message); + expect(USERSUNBANNED).equals(1); + }); + it("handles !matrix approve", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix approve", + member, + }; + await handler.Process(message); + expect(MARKED).equals(1); + }); + it("handles !matrix deny", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix deny", + member, + }; + await handler.Process(message); + expect(MARKED).equals(0); + }); +}); diff --git a/test/test_matrixcommandhandler.ts b/test/test_matrixcommandhandler.ts index 8bef1b1462b331713d18f43a834cc69eb97587b0..70359d79ea92db5e15064a97fed075edc9589f11 100644 --- a/test/test_matrixcommandhandler.ts +++ b/test/test_matrixcommandhandler.ts @@ -1,7 +1,24 @@ +/* +Copyright 2019 matrix-appservice-discord + +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 Chai from "chai"; -import { MatrixCommandHandler } from "../src/matrixcommandhandler"; +import { Util } from "../src/util"; import { DiscordBridgeConfig } from "../src/config"; import { MockChannel } from "./mocks/channel"; +import * as Proxyquire from "proxyquire"; // we are a test file and thus need those /* tslint:disable:no-unused-expression max-file-line-count no-any */ @@ -52,9 +69,6 @@ function createCH(opts: any = {}) { config.bridge.enableSelfServiceBridging = true; } const mxClient = { - getStateEvent: async () => { - return opts.powerLevels || {}; - }, getUserId: () => "@user:localhost", joinRoom: async () => { USERSJOINED++; @@ -92,210 +106,121 @@ function createCH(opts: any = {}) { }, Provisioner: provisioner, }; - return new MatrixCommandHandler(bot as any, bridge, config); + + const MatrixCommandHndl = (Proxyquire("../src/matrixcommandhandler", { + "./util": { + Util: { + CheckMatrixPermission: async () => { + return opts.power !== undefined ? opts.power : true; + }, + GetBotLink: Util.GetBotLink, + ParseCommand: Util.ParseCommand, + }, + }, + })).MatrixCommandHandler; + return new MatrixCommandHndl(bot as any, bridge, config); +} + +function createEvent(msg: string, room?: string, userId?: string) { + return { + content: { + body: msg, + }, + room_id: room ? room : "!123:localhost", + sender: userId, + }; +} + +function createContext(remoteData?: any) { + return { + rooms: { + remote: remoteData, + }, + }; } describe("MatrixCommandHandler", () => { - describe("ProcessCommand", () => { + describe("Process", () => { it("should not process command if not in room", async () => { const handler: any = createCH({disableSS: true}); - const ret = await handler.ProcessCommand({ - room_id: "!666:localhost", - }); - expect(ret).to.be.undefined; + await handler.Process(createEvent("", "!666:localhost"), createContext()); + expect(MESSAGESENT.body).to.equal(undefined); }); it("should warn if self service is disabled", async () => { const handler: any = createCH({disableSS: true}); - await handler.ProcessCommand({ - room_id: "!123:localhost", - }); - expect(MESSAGESENT.body).equals("The owner of this bridge does not permit self-service bridging."); - }); - it("should warn if user is not powerful enough with defaults", async () => { - const handler: any = createCH(); - await handler.ProcessCommand({ - room_id: "!123:localhost", - }); - expect(MESSAGESENT.body).equals("You do not have the required power level in this room to " + - "create a bridge to a Discord channel."); - }); - it("should warn if user is not powerful enough with custom state default", async () => { - const handler: any = createCH({powerLevels: { - state_default: 67, - }}); - await handler.ProcessCommand({ - room_id: "!123:localhost", - }); - expect(MESSAGESENT.body).equals("You do not have the required power level in this room to " + - "create a bridge to a Discord channel."); - }); - it("should allow if user is powerful enough with defaults", async () => { - const handler: any = createCH({powerLevels: { - users_default: 60, - }}); - const evt = await handler.ProcessCommand({ - content: {body: "!discord help"}, - room_id: "!123:localhost", - }); - expect(evt.body.startsWith("Available commands")).to.be.true; + await handler.Process(createEvent("!discord bridge"), createContext()); + expect(MESSAGESENT.body).to.equal("**ERROR:** The owner of this bridge does " + + "not permit self-service bridging."); }); - it("should allow if user is powerful enough with their own state", async () => { - const handler: any = createCH({powerLevels: { - users: { - "@user:localhost": 100, - }, - }}); - const evt = await handler.ProcessCommand({ - content: {body: "!discord help"}, - room_id: "!123:localhost", - sender: "@user:localhost", + it("should warn if user is not powerful enough", async () => { + const handler: any = createCH({ + power: false, }); - expect(evt.body.startsWith("Available commands")).to.be.true; + await handler.Process(createEvent("!discord bridge"), createContext()); + expect(MESSAGESENT.body).to.equal("**ERROR:** insufficiant permissions to use this " + + "command! Try `!discord help` to see all available commands"); }); describe("!discord bridge", () => { it("will bridge a new room, and ask for permissions", async () => { - const handler: any = createCH({powerLevels: { - users_default: 100, - }}); - const context = {rooms: {}}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord bridge 123 456"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("I have bridged this room to your channel"); + const handler: any = createCH(); + await handler.Process(createEvent("!discord bridge 123 456"), createContext()); + expect(MESSAGESENT.body).to.equal("I have bridged this room to your channel"); }); it("will fail to bridge if permissions were denied", async () => { const handler: any = createCH({ denyBridgePermission: true, - powerLevels: { - users_default: 100, - }, }); - const context = {rooms: {}}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord bridge 123 456"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("The bridge has been declined by the Discord guild"); + await handler.Process(createEvent("!discord bridge 123 456"), createContext()); + expect(MESSAGESENT.body).to.equal("The bridge has been declined by the Discord guild"); }); - it("will fail to bridge if permissions were denied", async () => { + it("will fail to bridge if permissions were failed", async () => { const handler: any = createCH({ failBridgeMatrix: true, - powerLevels: { - users_default: 100, - }, }); - const context = {rooms: {}}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord bridge 123 456"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("There was a problem bridging that channel - has " + + const evt = await handler.Process(createEvent("!discord bridge 123 456"), createContext()); + expect(MESSAGESENT.body).to.equal("There was a problem bridging that channel - has " + "the guild owner approved the bridge?"); }); it("will not bridge if a link already exists", async () => { - const handler: any = createCH({ - powerLevels: { - users_default: 100, - }, - }); - const context = {rooms: { remote: true }}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord bridge"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("This room is already bridged to a Discord guild."); + const handler: any = createCH(); + const evt = await handler.Process(createEvent("!discord bridge 123 456"), createContext(true)); + expect(MESSAGESENT.body).to.equal("This room is already bridged to a Discord guild."); }); it("will not bridge without required args", async () => { - const handler: any = createCH({ - powerLevels: { - users_default: 100, - }, - }); - const context = {rooms: {}}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord bridge"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).to.contain("Invalid syntax"); + const handler: any = createCH(); + const evt = await handler.Process(createEvent("!discord bridge"), createContext()); + expect(MESSAGESENT.body).to.contain("Invalid syntax"); }); it("will bridge with x/y syntax", async () => { const handler: any = createCH({powerLevels: { users_default: 100, }}); - const context = {rooms: {}}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord bridge 123/456"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("I have bridged this room to your channel"); + const evt = await handler.Process(createEvent("!discord bridge 123/456"), createContext()); + expect(MESSAGESENT.body).equals("I have bridged this room to your channel"); }); }); describe("!discord unbridge", () => { it("will unbridge", async () => { - const handler: any = createCH({ - powerLevels: { - users_default: 100, - }, - }); - const context = {rooms: { remote: { - data: { - plumbed: true, - }, - } }}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord unbridge"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("This room has been unbridged"); + const handler: any = createCH(); + await handler.Process(createEvent("!discord unbridge"), createContext({data: {plumbed: true}})); + expect(MESSAGESENT.body).equals("This room has been unbridged"); }); it("will not unbridge if a link does not exist", async () => { - const handler: any = createCH({ - powerLevels: { - users_default: 100, - }, - }); - const context = {rooms: { remote: undefined }}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord unbridge"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("This room is not bridged."); + const handler: any = createCH(); + const evt = await handler.Process(createEvent("!discord unbridge"), createContext()); + expect(MESSAGESENT.body).equals("This room is not bridged."); }); it("will not unbridge non-plumbed rooms", async () => { - const handler: any = createCH({ - powerLevels: { - users_default: 100, - }, - }); - const context = {rooms: { remote: { - data: { - plumbed: false, - }, - }}}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord unbridge"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).equals("This room cannot be unbridged."); + const handler: any = createCH(); + await handler.Process(createEvent("!discord unbridge"), createContext({data: {plumbed: false}})); + expect(MESSAGESENT.body).equals("This room cannot be unbridged."); }); it("will show error if unbridge fails", async () => { const handler: any = createCH({ failUnbridge: true, - powerLevels: { - users_default: 100, - }, }); - const context = {rooms: { remote: { - data: { - plumbed: true, - }, - }}}; - const evt = await handler.ProcessCommand({ - content: {body: "!discord unbridge"}, - room_id: "!123:localhost", - }, context); - expect(evt.body).to.contain("There was an error unbridging this room."); + await handler.Process(createEvent("!discord unbridge"), createContext({data: {plumbed: true}})); + expect(MESSAGESENT.body).to.contain("There was an error unbridging this room."); }); }); }); diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts index 71ec84bf415758f4abb0e4b1e8a7f234c261802f..2c836b295847504637a4fac4361557bc47be5595 100644 --- a/test/test_matrixeventprocessor.ts +++ b/test/test_matrixeventprocessor.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 matrix-appservice-discord +Copyright 2018, 2019 matrix-appservice-discord Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -94,7 +94,7 @@ function createMatrixEventProcessor(): MatrixEventProcessor { USERSYNC_HANDLED = false; STATE_EVENT_MSG = ""; MESSAGE_PROCCESS = ""; - KICKBAN_HANDLED = true; + KICKBAN_HANDLED = false; const bridge = { getBot: () => { return { @@ -230,7 +230,7 @@ function createMatrixEventProcessor(): MatrixEventProcessor { HandleInvite: async (evt) => { MESSAGE_PROCCESS = "invited"; }, - ProcessCommand: async (evt) => { + Process: async (evt) => { MESSAGE_PROCCESS = "command_processed"; }, }); diff --git a/test/test_matrixroomhandler.ts b/test/test_matrixroomhandler.ts index 4c5de12bb552fc923823a55044857f3531b394ad..d8fa8d04ab87923b35586e366cb775d55d3fe894 100644 --- a/test/test_matrixroomhandler.ts +++ b/test/test_matrixroomhandler.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 matrix-appservice-discord +Copyright 2018, 2019 matrix-appservice-discord Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,10 +16,10 @@ limitations under the License. import * as Chai from "chai"; import * as Proxyquire from "proxyquire"; -import {DiscordBridgeConfig} from "../src/config"; -import {MockChannel} from "./mocks/channel"; -import {MockMember} from "./mocks/member"; -import {MockGuild} from "./mocks/guild"; +import { DiscordBridgeConfig } from "../src/config"; +import { MockChannel } from "./mocks/channel"; +import { MockMember } from "./mocks/member"; +import { MockGuild } from "./mocks/guild"; import { Util } from "../src/util"; // we are a test file and thus need those @@ -54,6 +54,7 @@ function createRH(opts: any = {}) { USERSKICKED = 0; USERSBANNED = 0; USERSUNBANNED = 0; + MESSAGESENT = {}; USERSYNC_HANDLED = false; KICKBAN_HANDLED = false; MESSAGE_PROCCESS = ""; @@ -354,92 +355,4 @@ describe("MatrixRoomHandler", () => { expect(roomOpts.creationOpts).to.exist; }); }); - describe("HandleDiscordCommand", () => { - it("will kick a member", async () => { - const handler: any = createRH({}); - const channel = new MockChannel("123"); - const guild = new MockGuild("456", [channel]); - channel.guild = guild; - const member: any = new MockMember("123456", "blah"); - member.hasPermission = () => { - return true; - }; - const message = { - channel, - content: "!matrix kick someuser", - member, - }; - await handler.HandleDiscordCommand(message); - expect(USERSKICKED).equals(1); - }); - it("will kick a member in all guild rooms", async () => { - const handler: any = createRH({}); - const channel = new MockChannel("123"); - const guild = new MockGuild("456", [channel, (new MockChannel("456"))]); - channel.guild = guild; - const member: any = new MockMember("123456", "blah"); - member.hasPermission = () => { - return true; - }; - const message = { - channel, - content: "!matrix kick someuser", - member, - }; - await handler.HandleDiscordCommand(message); - // tslint:disable-next-line:no-magic-numbers - expect(USERSKICKED).equals(2); - }); - it("will deny permission", async () => { - const handler: any = createRH({}); - const channel = new MockChannel("123"); - const guild = new MockGuild("456", [channel]); - channel.guild = guild; - const member: any = new MockMember("123456", "blah"); - member.hasPermission = () => { - return false; - }; - const message = { - channel, - content: "!matrix kick someuser", - member, - }; - await handler.HandleDiscordCommand(message); - expect(USERSKICKED).equals(0); - }); - it("will ban a member", async () => { - const handler: any = createRH({}); - const channel = new MockChannel("123"); - const guild = new MockGuild("456", [channel]); - channel.guild = guild; - const member: any = new MockMember("123456", "blah"); - member.hasPermission = () => { - return true; - }; - const message = { - channel, - content: "!matrix ban someuser", - member, - }; - await handler.HandleDiscordCommand(message); - expect(USERSBANNED).equals(1); - }); - it("will unban a member", async () => { - const handler: any = createRH({}); - const channel = new MockChannel("123"); - const guild = new MockGuild("456", [channel]); - channel.guild = guild; - const member: any = new MockMember("123456", "blah"); - member.hasPermission = () => { - return true; - }; - const message = { - channel, - content: "!matrix unban someuser", - member, - }; - await handler.HandleDiscordCommand(message); - expect(USERSUNBANNED).equals(1); - }); - }); }); diff --git a/test/test_util.ts b/test/test_util.ts index 17664269950452f36138dacec1f8ad3e0ce13a97..2e4ca1a3f0e8421f78e5400a6a2a587a1b6ec00d 100644 --- a/test/test_util.ts +++ b/test/test_util.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 matrix-appservice-discord +Copyright 2018, 2019 matrix-appservice-discord Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,7 +16,7 @@ limitations under the License. import * as Chai from "chai"; -import { Util, ICommandAction, ICommandParameters } from "../src/util"; +import { Util, ICommandActions, ICommandParameters } from "../src/util"; // we are a test file and thus need those /* tslint:disable:no-unused-expression max-file-line-count no-any */ @@ -60,28 +60,72 @@ describe("Util", () => { Chai.assert.equal(args[1], "arg2"); }); }); - describe("ParseCommand", () => { - it("parses commands", async () => { - const action: ICommandAction = { + describe("Command Stuff", () => { + const actions: ICommandActions = { + action: { + description: "floof", + help: "Fox goes floof!", params: ["param1", "param2"], run: async ({param1, param2}) => { return `param1: ${param1}\nparam2: ${param2}`; }, - }; - const parameters: ICommandParameters = { - param1: { - get: async (param: string) => { - return "param1_" + param; - }, + }, + }; + const parameters: ICommandParameters = { + param1: { + description: "1", + get: async (param: string) => { + return "param1_" + param; }, - param2: { - get: async (param: string) => { - return "param2_" + param; - }, + }, + param2: { + description: "2", + get: async (param: string) => { + return "param2_" + param; }, - }; - const retStr = await Util.ParseCommand(action, parameters, ["hello", "world"]); - expect(retStr).equal("param1: param1_hello\nparam2: param2_world"); + }, + }; + describe("HandleHelpCommand", () => { + it("parses general help message", async () => { + const {command, args} = Util.MsgToArgs("!fox help", "!fox"); + const retStr = await Util.HandleHelpCommand( + "!fox", + actions, + parameters, + args, + ); + expect(retStr).to.equal( +`Available Commands: + - \`!fox action <param1> <param2>\`: floof + +Parameters: + - \`<param1>\`: 1 + - \`<param2>\`: 2 +`); + }); + it("parses specific help message", async () => { + const {command, args} = Util.MsgToArgs("!fox help action", "!fox"); + const retStr = await Util.HandleHelpCommand( + "!fox", + actions, + parameters, + args, + ); + expect(retStr).to.equal( +`\`!fox action <param1> <param2>\`: floof +Fox goes floof!`); + }); + }); + describe("ParseCommand", () => { + it("parses commands", async () => { + const retStr = await Util.ParseCommand( + "!fox", + "!fox action hello world", + actions, + parameters, + ); + expect(retStr).equal("param1: param1_hello\nparam2: param2_world"); + }); }); }); describe("GetMxidFromName", () => { @@ -187,4 +231,84 @@ describe("Util", () => { expect(Date.now()).to.be.greaterThan(t + DELAY_FOR - 1); }); }); + describe("CheckMatrixPermission", () => { + const PERM_LEVEL = 50; + it("should deny", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: { + blubb: PERM_LEVEL, + }, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + "blubb", + ); + expect(ret).to.be.false; + }); + it("should allow cat/subcat", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: { + blubb: PERM_LEVEL, + }, + users: { + "@user:localhost": PERM_LEVEL, + }, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + "blubb", + ); + expect(ret).to.be.true; + }); + it("should allow cat", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: PERM_LEVEL, + users: { + "@user:localhost": PERM_LEVEL, + }, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + ); + expect(ret).to.be.true; + }); + it("should allow based on default", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: PERM_LEVEL, + users_default: PERM_LEVEL, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + ); + expect(ret).to.be.true; + }); + }); });