diff --git a/src/bot.ts b/src/bot.ts index 48487fe31f0bdeba3948fefb37b297b782a6e0e3..a77c32ed0f1714c076dffe06fd19faa7cf69198a 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -26,7 +26,7 @@ import { DiscordMessageProcessorOpts, DiscordMessageProcessorResult, } from "./discordmessageprocessor"; -import { MatrixEventProcessor, MatrixEventProcessorOpts } from "./matrixeventprocessor"; +import { MatrixEventProcessor, MatrixEventProcessorOpts, IMatrixEventProcessorResult } from "./matrixeventprocessor"; import { PresenceHandler } from "./presencehandler"; import { Provisioner } from "./provisioner"; import { UserSyncroniser } from "./usersyncroniser"; @@ -125,6 +125,14 @@ export class DiscordBot { return this.roomHandler; } + get MxEventProcessor(): MatrixEventProcessor { + return this.mxEventProcessor; + } + + get Provisioner(): Provisioner { + return this.provisioner; + } + public GetIntentFromDiscordMember(member: Discord.GuildMember | Discord.User, webhookID?: string): Intent { if (webhookID) { // webhookID and user IDs are the same, they are unique, so no need to prefix _webhook_ @@ -336,53 +344,23 @@ export class DiscordBot { } } - public async ProcessMatrixStateEvent(event: IMatrixEvent): Promise<void> { - log.verbose(`Got state event from ${event.room_id} ${event.type}`); - const channel = await this.GetChannelFromRoomId(event.room_id) as Discord.TextChannel; - const msg = this.mxEventProcessor.StateEventToMessage(event, channel); + public async sendAsBot(msg: string, channel: Discord.TextChannel, event: IMatrixEvent): Promise<void> { if (!msg) { return; } - let res = await channel.send(msg); - if (!Array.isArray(res)) { - res = [res]; - } - await Util.AsyncForEach(res, async (m: Discord.Message) => { - log.verbose("Sent (state msg) ", m.id); - this.sentMessages.push(m.id); - this.lastEventIds[event.room_id] = event.event_id; - const evt = new DbEvent(); - evt.MatrixId = `${event.event_id};${event.room_id}`; - evt.DiscordId = m.id; - evt.GuildId = channel.guild.id; - evt.ChannelId = channel.id; - await this.store.Insert(evt); - }); - if (!this.config.bridge.disableReadReceipts) { - try { - await this.bridge.getIntent().sendReadReceipt(event.room_id, event.event_id); - } catch (err) { - log.error(`Failed to send read receipt for ${event}. `, err); - } - } + const res = await channel.send(msg); + await this.StoreMessagesSent(res, channel, event); } - public async ProcessMatrixMsgEvent(event: IMatrixEvent, guildId: string, channelId: string): Promise<void> { - const mxClient = this.bridge.getClientFactory().getClientAs(); - log.verbose(`Looking up ${guildId}_${channelId}`); - const result = await this.LookupRoom(guildId, channelId, event.sender); - const chan = result.channel; - const botUser = result.botUser; - - const embedSet = await this.mxEventProcessor.EventToEmbed(event, chan); + public async send( + embedSet: IMatrixEventProcessorResult, + opts: Discord.MessageOptions, + roomLookup: ChannelLookupResult, + event: IMatrixEvent, + ): Promise<void> { + const chan = roomLookup.channel; + const botUser = roomLookup.botUser; const embed = embedSet.messageEmbed; - const opts: Discord.MessageOptions = {}; - const file = await this.mxEventProcessor.HandleAttachment(event, mxClient); - if (typeof(file) === "string") { - embed.description += " " + file; - } else { - opts.file = file; - } let msg: Discord.Message | null | (Discord.Message | null)[] = null; let hook: Discord.Webhook | undefined; @@ -420,32 +398,10 @@ export class DiscordBot { opts.embed = embed; msg = await chan.send("", opts); } + await this.StoreMessagesSent(msg, chan, event); } catch (err) { log.error("Couldn't send message. ", err); } - if (!Array.isArray(msg)) { - msg = [msg]; - } - await Util.AsyncForEach(msg, async (m: Discord.Message) => { - log.verbose("Sent ", m.id); - this.sentMessages.push(m.id); - this.lastEventIds[event.room_id] = event.event_id; - const evt = new DbEvent(); - evt.MatrixId = `${event.event_id};${event.room_id}`; - evt.DiscordId = m.id; - // Webhooks don't send guild info. - evt.GuildId = guildId; - evt.ChannelId = channelId; - await this.store.Insert(evt); - }); - if (!this.config.bridge.disableReadReceipts) { - try { - await this.bridge.getIntent().sendReadReceipt(event.room_id, event.event_id); - } catch (err) { - log.error(`Failed to send read receipt for ${event}. `, err); - } - } - return; } public async ProcessMatrixRedact(event: IMatrixEvent) { @@ -910,4 +866,32 @@ export class DiscordBot { } } } + + private async StoreMessagesSent( + msg: Discord.Message | null | (Discord.Message | null)[], + chan: Discord.TextChannel, + event: IMatrixEvent, + ) { + if (!Array.isArray(msg)) { + msg = [msg]; + } + await Util.AsyncForEach(msg, async (m: Discord.Message) => { + if (!m) { + return; + } + log.verbose("Sent ", m.id); + this.sentMessages.push(m.id); + this.lastEventIds[event.room_id] = event.event_id; + try { + const evt = new DbEvent(); + evt.MatrixId = `${event.event_id};${event.room_id}`; + evt.DiscordId = m.id; + evt.GuildId = chan.guild.id; + evt.ChannelId = chan.id; + await this.store.Insert(evt); + } catch (err) { + log.error(`Failed to insert sent event (${event.event_id};${event.room_id}) into store`, err); + } + }); + } } diff --git a/src/discordas.ts b/src/discordas.ts index aa237f5ee1464ff18fb1c3dd1cbf6e985f2d152a..0f4b5e11d6a46c238e1e259316d23b11e06c4da2 100644 --- a/src/discordas.ts +++ b/src/discordas.ts @@ -168,11 +168,12 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) { const discordbot = new DiscordBot(botUserId, config, bridge, store); const roomhandler = discordbot.RoomHandler; + const eventProcessor = discordbot.MxEventProcessor; try { callbacks.onAliasQueried = roomhandler.OnAliasQueried.bind(roomhandler); callbacks.onAliasQuery = roomhandler.OnAliasQuery.bind(roomhandler); - callbacks.onEvent = roomhandler.OnEvent.bind(roomhandler); + callbacks.onEvent = eventProcessor.OnEvent.bind(eventProcessor); callbacks.thirdPartyLookup = async () => { return roomhandler.ThirdPartyLookup; }; diff --git a/src/matrixcommandhandler.ts b/src/matrixcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..a116d6f5f127220f2cbb347e5a6906a024b4e1ce --- /dev/null +++ b/src/matrixcommandhandler.ts @@ -0,0 +1,225 @@ +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 * as Discord from "discord.js"; +const log = new Log("MatrixCommandHandler"); + +/* tslint:disable:no-magic-numbers */ +const PROVISIONING_DEFAULT_POWER_LEVEL = 50; +const PROVISIONING_DEFAULT_USER_POWER_LEVEL = 0; +const ROOM_CACHE_MAXAGE_MS = 15 * 60 * 1000; +/* tslint:enable:no-magic-numbers */ + +export class MatrixCommandHandler { + private botJoinedRooms: Set<string> = new Set(); // roomids + private botJoinedRoomsCacheUpdatedAt = 0; + private provisioner: Provisioner; + constructor( + private discord: DiscordBot, + private bridge: Bridge, + private config: DiscordBridgeConfig, + ) { + this.provisioner = this.discord.Provisioner; + } + + public async HandleInvite(event: IMatrixEvent) { + log.info(`Received invite for ${event.state_key} in room ${event.room_id}`); + if (event.state_key === this.discord.GetBotId()) { + log.info("Accepting invite for bridge bot"); + await this.bridge.getIntent().joinRoom(event.room_id); + this.botJoinedRooms.add(event.room_id); + } + } + + public async ProcessCommand(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", + }); + } + + 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 + } + } + + private async isBotInRoom(roomId: string): Promise<boolean> { + // Update the room cache, if not done already. + if (Date.now () - this.botJoinedRoomsCacheUpdatedAt > ROOM_CACHE_MAXAGE_MS) { + log.verbose("Updating room cache for bot..."); + try { + log.verbose("Got new room cache for bot"); + this.botJoinedRoomsCacheUpdatedAt = Date.now(); + const rooms = (await this.bridge.getBot().getJoinedRooms()) as string[]; + this.botJoinedRooms = new Set(rooms); + } catch (e) { + log.error("Failed to get room cache for bot, ", e); + return false; + } + } + return this.botJoinedRooms.has(roomId); + } +} diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts index 2b23992eff3ce72f73f4ca0726cde6bbc7d3e921..28945add25b48c88d2916a52682cc2e429a99d63 100644 --- a/src/matrixeventprocessor.ts +++ b/src/matrixeventprocessor.ts @@ -21,10 +21,11 @@ import * as escapeStringRegexp from "escape-string-regexp"; import { Util } from "./util"; import * as path from "path"; import * as mime from "mime"; -import { MatrixUser, Bridge } from "matrix-appservice-bridge"; +import { MatrixUser, Bridge, BridgeContext } from "matrix-appservice-bridge"; import { Client as MatrixClient } from "matrix-js-sdk"; import { IMatrixEvent, IMatrixEventContent, IMatrixMessage } from "./matrixtypes"; import { MatrixMessageProcessor, IMatrixMessageProcessorParams } from "./matrixmessageprocessor"; +import { MatrixCommandHandler } from "./matrixcommandhandler"; import { Log } from "./log"; const log = new Log("MatrixEventProcessor"); @@ -34,6 +35,8 @@ const MIN_NAME_LENGTH = 2; const MAX_NAME_LENGTH = 32; const DISCORD_AVATAR_WIDTH = 128; const DISCORD_AVATAR_HEIGHT = 128; +const ROOM_NAME_PARTS = 2; +const AGE_LIMIT = 900000; // 15 * 60 * 1000 export class MatrixEventProcessorOpts { constructor( @@ -55,15 +58,131 @@ export class MatrixEventProcessor { private bridge: Bridge; private discord: DiscordBot; private matrixMsgProcessor: MatrixMessageProcessor; + private mxCommandHandler: MatrixCommandHandler; - constructor(opts: MatrixEventProcessorOpts) { + constructor(opts: MatrixEventProcessorOpts, cm?: MatrixCommandHandler) { this.config = opts.config; this.bridge = opts.bridge; this.discord = opts.discord; this.matrixMsgProcessor = new MatrixMessageProcessor(this.discord); + if (cm) { + this.mxCommandHandler = cm; + } else { + this.mxCommandHandler = new MatrixCommandHandler(this.discord, this.bridge, this.config); + } + } + + public async OnEvent(request, context: BridgeContext): Promise<void> { + const event = request.getData() as IMatrixEvent; + if (event.unsigned.age > AGE_LIMIT) { + log.warn(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`); + return; + } + if ( + event.type === "m.room.member" && + event.content!.membership === "invite" && + event.state_key === this.discord.GetBotId() + ) { + await this.mxCommandHandler.HandleInvite(event); + return; + } else if (event.type === "m.room.member" && this.bridge.getBot().isRemoteUser(event.state_key)) { + if (["leave", "ban"].includes(event.content!.membership!) && event.sender !== event.state_key) { + // Kick/Ban handling + let prevMembership = ""; + if (event.content!.membership === "leave") { + const intent = this.bridge.getIntent(); + prevMembership = (await intent.getEvent(event.room_id, event.replaces_state)).content.membership; + } + await this.discord.HandleMatrixKickBan( + event.room_id, + event.state_key, + event.sender, + event.content!.membership as "leave"|"ban", + prevMembership, + event.content!.reason, + ); + } + return; + } else if (["m.room.member", "m.room.name", "m.room.topic"].includes(event.type)) { + await this.ProcessStateEvent(event); + return; + } else if (event.type === "m.room.redaction" && context.rooms.remote) { + await this.discord.ProcessMatrixRedact(event); + return; + } else if (event.type === "m.room.message" || event.type === "m.sticker") { + log.verbose(`Got ${event.type} event`); + const isBotCommand = event.type === "m.room.message" && + event.content!.body && + event.content!.body!.startsWith("!discord"); + if (isBotCommand) { + await this.mxCommandHandler.ProcessCommand(event, context); + return; + } else if (context.rooms.remote) { + const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS); + try { + await this.ProcessMsgEvent(event, srvChanPair[0], srvChanPair[1]); + return; + } catch (err) { + log.warn("There was an error sending a matrix event", err); + return; + } + } + } else if (event.type === "m.room.encryption" && context.rooms.remote) { + try { + await this.HandleEncryptionWarning(event.room_id); + return; + } catch (err) { + throw new Error(`Failed to handle encrypted room, ${err}`); + } + } else { + log.verbose("Got non m.room.message event"); + } + log.verbose("Event not processed by bridge"); + } + + public async HandleEncryptionWarning(roomId: string): Promise<void> { + const intent = this.bridge.getIntent(); + log.info(`User has turned on encryption in ${roomId}, so leaving.`); + /* N.B 'status' is not specced but https://github.com/matrix-org/matrix-doc/pull/828 + has been open for over a year with no resolution. */ + const sendPromise = intent.sendMessage(roomId, { + body: "You have turned on encryption in this room, so the service will not bridge any new messages.", + msgtype: "m.notice", + status: "critical", + }); + const channel = await this.discord.GetChannelFromRoomId(roomId); + await (channel as Discord.TextChannel).send( + "Someone on Matrix has turned on encryption in this room, so the service will not bridge any new messages", + ); + await sendPromise; + await intent.leave(roomId); + await this.bridge.getRoomStore().removeEntriesByMatrixRoomId(roomId); + } + + public async ProcessMsgEvent(event: IMatrixEvent, guildId: string, channelId: string) { + const mxClient = this.bridge.getClientFactory().getClientAs(); + log.verbose(`Looking up ${guildId}_${channelId}`); + const roomLookup = await this.discord.LookupRoom(guildId, channelId, event.sender); + const chan = roomLookup.channel; + const botUser = roomLookup.botUser; + + const embedSet = await this.EventToEmbed(event, chan); + const opts: Discord.MessageOptions = {}; + const file = await this.HandleAttachment(event, mxClient); + if (typeof(file) === "string") { + embedSet.messageEmbed.description += " " + file; + } else { + opts.file = file; + } + + await this.discord.send(embedSet, opts, roomLookup, event); + await this.sendReadReceipt(event); } - public StateEventToMessage(event: IMatrixEvent, channel: Discord.TextChannel): string | undefined { + public async ProcessStateEvent(event: IMatrixEvent) { + log.verbose(`Got state event from ${event.room_id} ${event.type}`); + const channel = await this.discord.GetChannelFromRoomId(event.room_id) as Discord.TextChannel; + const SUPPORTED_EVENTS = ["m.room.member", "m.room.name", "m.room.topic"]; if (!SUPPORTED_EVENTS.includes(event.type)) { log.verbose(`${event.event_id} ${event.type} is not displayable.`); @@ -106,7 +225,8 @@ export class MatrixEventProcessor { } msg += " on Matrix."; - return msg; + await this.discord.sendAsBot(msg, channel, event); + await this.sendReadReceipt(event); } public async EventToEmbed( @@ -250,6 +370,16 @@ export class MatrixEventProcessor { return embed; } + private async sendReadReceipt(event: IMatrixEvent) { + if (!this.config.bridge.disableReadReceipts) { + try { + await this.bridge.getIntent().sendReadReceipt(event.room_id, event.event_id); + } catch (err) { + log.error(`Failed to send read receipt for ${event}. `, err); + } + } + } + private HasAttachment(event: IMatrixEvent): boolean { if (!event.content) { event.content = {}; diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index 564fdab9c08ed1d166cf3e644d76be952e3114b1..c648f6e503884cd2395bf076b76a375959c72fd9 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -22,7 +22,6 @@ import { thirdPartyProtocolResult, thirdPartyUserResult, thirdPartyLocationResult, - BridgeContext, ProvisionedRoom, Intent, } from "matrix-appservice-bridge"; @@ -40,7 +39,6 @@ const ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoES /* tslint:disable:no-magic-numbers */ const HTTP_UNSUPPORTED = 501; const ROOM_NAME_PARTS = 2; -const AGE_LIMIT = 900000; // 15 * 60 * 1000 const PROVISIONING_DEFAULT_POWER_LEVEL = 50; const PROVISIONING_DEFAULT_USER_POWER_LEVEL = 0; const USERSYNC_STATE_DELAY_MS = 5000; @@ -138,271 +136,6 @@ export class MatrixRoomHandler { await Promise.all(promiseList); } - public async OnEvent(request, context: BridgeContext): Promise<void> { - const event = request.getData() as IMatrixEvent; - if (event.unsigned.age > AGE_LIMIT) { - log.warn(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`); - return; - } - if (event.type === "m.room.member" && event.content!.membership === "invite") { - await this.HandleInvite(event); - return; - } else if (event.type === "m.room.member" && this.bridge.getBot().isRemoteUser(event.state_key)) { - if (["leave", "ban"].includes(event.content!.membership!) && event.sender !== event.state_key) { - // Kick/Ban handling - let prevMembership = ""; - if (event.content!.membership === "leave") { - const intent = this.bridge.getIntent(); - prevMembership = (await intent.getEvent(event.room_id, event.replaces_state)).content.membership; - } - await this.discord.HandleMatrixKickBan( - event.room_id, - event.state_key, - event.sender, - event.content!.membership as "leave"|"ban", - prevMembership, - event.content!.reason, - ); - } - return; - } else if (["m.room.member", "m.room.name", "m.room.topic"].includes(event.type)) { - await this.discord.ProcessMatrixStateEvent(event); - return; - } else if (event.type === "m.room.redaction" && context.rooms.remote) { - await this.discord.ProcessMatrixRedact(event); - return; - } else if (event.type === "m.room.message" || event.type === "m.sticker") { - log.verbose(`Got ${event.type} event`); - const isBotCommand = event.type === "m.room.message" && - event.content!.body && - event.content!.body!.startsWith("!discord"); - if (isBotCommand) { - await this.ProcessCommand(event, context); - return; - } else if (context.rooms.remote) { - const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS); - try { - await this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]); - return; - } catch (err) { - log.warn("There was an error sending a matrix event", err); - return; - } - } - } else if (event.type === "m.room.encryption" && context.rooms.remote) { - try { - await this.HandleEncryptionWarning(event.room_id); - return; - } catch (err) { - throw new Error(`Failed to handle encrypted room, ${err}`); - } - } else { - log.verbose("Got non m.room.message event"); - } - log.verbose("Event not processed by bridge"); - } - - public async HandleEncryptionWarning(roomId: string): Promise<void> { - const intent = this.bridge.getIntent(); - log.info(`User has turned on encryption in ${roomId}, so leaving.`); - /* N.B 'status' is not specced but https://github.com/matrix-org/matrix-doc/pull/828 - has been open for over a year with no resolution. */ - const sendPromise = intent.sendMessage(roomId, { - body: "You have turned on encryption in this room, so the service will not bridge any new messages.", - msgtype: "m.notice", - status: "critical", - }); - const channel = await this.discord.GetChannelFromRoomId(roomId); - await (channel as Discord.TextChannel).send( - "Someone on Matrix has turned on encryption in this room, so the service will not bridge any new messages", - ); - await sendPromise; - await intent.leave(roomId); - await this.roomStore.removeEntriesByMatrixRoomId(roomId); - } - - public async HandleInvite(event: IMatrixEvent) { - log.info(`Received invite for ${event.state_key} in room ${event.room_id}`); - if (event.state_key === this.botUserId) { - log.info("Accepting invite for bridge bot"); - await this.joinRoom(this.bridge.getIntent(), event.room_id); - this.botJoinedRooms.add(event.room_id); - } else { - await this.discord.ProcessMatrixStateEvent(event); - } - } - - public async ProcessCommand(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", - }); - } - - 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 - } - } - public async OnAliasQuery(alias: string, aliasLocalpart: string): Promise<ProvisionedRoom> { log.info("Got request for #", aliasLocalpart); const srvChanPair = aliasLocalpart.substr("_discord_".length).split("_", ROOM_NAME_PARTS); @@ -662,21 +395,4 @@ export class MatrixRoomHandler { creationOpts, } as ProvisionedRoom; } - - private async isBotInRoom(roomId: string): Promise<boolean> { - // Update the room cache, if not done already. - if (Date.now () - this.botJoinedRoomsCacheUpdatedAt > ROOM_CACHE_MAXAGE_MS) { - log.verbose("Updating room cache for bot..."); - try { - log.verbose("Got new room cache for bot"); - this.botJoinedRoomsCacheUpdatedAt = Date.now(); - const rooms = (await this.bridge.getBot().getJoinedRooms()) as string[]; - this.botJoinedRooms = new Set(rooms); - } catch (e) { - log.error("Failed to get room cache for bot, ", e); - return false; - } - } - return this.botJoinedRooms.has(roomId); - } } diff --git a/test/test_matrixcommandhandler.ts b/test/test_matrixcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bef1b1462b331713d18f43a834cc69eb97587b0 --- /dev/null +++ b/test/test_matrixcommandhandler.ts @@ -0,0 +1,326 @@ +import * as Chai from "chai"; +import { MatrixCommandHandler } from "../src/matrixcommandhandler"; +import { DiscordBridgeConfig } from "../src/config"; +import { MockChannel } from "./mocks/channel"; + +// 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 = {}; + +function createCH(opts: any = {}) { + USERSJOINED = 0; + USERSKICKED = 0; + USERSBANNED = 0; + USERSUNBANNED = 0; + MESSAGESENT = {}; + + const bridge = { + getBot: () => { + return { + getJoinedRooms: () => ["!123:localhost"], + isRemoteUser: (id) => { + return id !== undefined && id.startsWith("@_discord_"); + }, + }; + }, + getIntent: () => { + return { + ban: async () => { USERSBANNED++; }, + getClient: () => mxClient, + join: () => { USERSJOINED++; }, + joinRoom: async () => { USERSJOINED++; }, + kick: async () => { USERSKICKED++; }, + leave: () => { }, + sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; }, + unban: async () => { USERSUNBANNED++; }, + }; + }, + }; + + const config = new DiscordBridgeConfig(); + config.limits.roomGhostJoinDelay = 0; + if (opts.disableSS) { + config.bridge.enableSelfServiceBridging = false; + } else { + config.bridge.enableSelfServiceBridging = true; + } + const mxClient = { + getStateEvent: async () => { + return opts.powerLevels || {}; + }, + getUserId: () => "@user:localhost", + joinRoom: async () => { + USERSJOINED++; + }, + sendReadReceipt: async () => { }, + setRoomDirectoryVisibilityAppService: async () => { }, + }; + const provisioner = { + AskBridgePermission: async () => { + if (opts.denyBridgePermission) { + throw new Error("The bridge has been declined by the Discord guild"); + } + }, + BridgeMatrixRoom: () => { + if (opts.failBridgeMatrix) { + throw new Error("Test failed matrix bridge"); + } + }, + UnbridgeRoom: async () => { + if (opts.failUnbridge) { + throw new Error("Test failed unbridge"); + } + }, + }; + const bot = { + GetBotId: () => "@botuser:localhost", + LookupRoom: async (guildid, discordid) => { + if (guildid !== "123") { + throw new Error("Guild not found"); + } else if (discordid !== "456") { + throw new Error("Channel not found"); + } + const channel = new MockChannel(); + return {channel, botUser: true }; + }, + Provisioner: provisioner, + }; + return new MatrixCommandHandler(bot as any, bridge, config); +} + +describe("MatrixCommandHandler", () => { + describe("ProcessCommand", () => { + 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; + }); + 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; + }); + 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", + }); + expect(evt.body.startsWith("Available commands")).to.be.true; + }); + 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"); + }); + 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"); + }); + it("will fail to bridge if permissions were denied", 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 " + + "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."); + }); + 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"); + }); + 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"); + }); + }); + 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"); + }); + 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."); + }); + 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."); + }); + 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."); + }); + }); + }); + describe("HandleInvite", () => { + it("should accept invite for bot user", async () => { + const handler: any = createCH(); + let joinedRoom = false; + handler.joinRoom = async () => { + joinedRoom = true; + }; + await handler.HandleInvite({ + state_key: "@botuser:localhost", + }); + expect(USERSJOINED).to.equal(1); + }); + it("should deny invite for other users", async () => { + const handler: any = createCH(); + let joinedRoom = false; + handler.joinRoom = async () => { + joinedRoom = true; + }; + await handler.HandleInvite({ + state_key: "@user:localhost", + }); + expect(joinedRoom).to.be.false; + }); + }); +}); diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts index f0b718543d5f0f05aa5c8d8fb0a5b875bbc006ac..71ec84bf415758f4abb0e4b1e8a7f234c261802f 100644 --- a/test/test_matrixeventprocessor.ts +++ b/test/test_matrixeventprocessor.ts @@ -36,6 +36,14 @@ const TEST_TIMESTAMP = 1337; const expect = Chai.expect; // const assert = Chai.assert; +function buildRequest(eventData) { + if (eventData.unsigned === undefined) { + eventData.unsigned = {age: 0}; + } + return { + getData: () => eventData, + }; +} const bot = { GetIntentFromDiscordMember: (member) => { return { @@ -77,7 +85,16 @@ const mxClient = { }, }; +let STATE_EVENT_MSG = ""; +let USERSYNC_HANDLED = false; +let MESSAGE_PROCCESS = ""; +let KICKBAN_HANDLED = false; + function createMatrixEventProcessor(): MatrixEventProcessor { + USERSYNC_HANDLED = false; + STATE_EVENT_MSG = ""; + MESSAGE_PROCCESS = ""; + KICKBAN_HANDLED = true; const bridge = { getBot: () => { return { @@ -159,7 +176,9 @@ function createMatrixEventProcessor(): MatrixEventProcessor { sender: "@fox:localhost", }; } - return null; + return { + content: {}, + }; }, getProfileInfo: async (userId: string) => { if (userId !== "@doggo:localhost") { @@ -173,6 +192,12 @@ function createMatrixEventProcessor(): MatrixEventProcessor { }; }, }; + const us = { + OnMemberState: async () => { + USERSYNC_HANDLED = true; + }, + OnUpdateUser: async () => { }, + }; const config = new DiscordBridgeConfig(); const Util = Object.assign(require("../src/util").Util, { @@ -182,11 +207,34 @@ function createMatrixEventProcessor(): MatrixEventProcessor { }, }); const discordbot = { + GetBotId: () => "@botuser:localhost", + GetChannelFromRoomId: async (roomId) => { + return new MockChannel("123456"); + }, GetDiscordUserOrMember: async (s) => { return new Discord.User({ } as any, { username: "Someuser" }); }, + HandleMatrixKickBan: () => { + KICKBAN_HANDLED = true; + }, + ProcessMatrixRedact: async (evt) => { + MESSAGE_PROCCESS = "redacted"; + }, + UserSyncroniser: us, + sendAsBot: async (msg, channel, event) => { + STATE_EVENT_MSG = msg; + }, }; + const ch = Object.assign(new (require("../src/matrixcommandhandler").MatrixCommandHandler)(bot as any, config), { + HandleInvite: async (evt) => { + MESSAGE_PROCCESS = "invited"; + }, + ProcessCommand: async (evt) => { + MESSAGE_PROCCESS = "command_processed"; + }, + }); + return new (Proxyquire("../src/matrixeventprocessor", { "./util": { Util, @@ -196,34 +244,33 @@ function createMatrixEventProcessor(): MatrixEventProcessor { config, bridge, discordbot as any, - )); + ), ch); } const mockChannel = new MockChannel(); mockChannel.members.set("12345", new MockMember("12345", "testuser2")); describe("MatrixEventProcessor", () => { - describe("StateEventToMessage", () => { - it("Should ignore unhandled states", () => { + describe("ProcessStateEvent", () => { + it("Should ignore unhandled states", async () => { const processor = createMatrixEventProcessor(); const event = { + room_id: "!someroom:localhost", sender: "@user:localhost", type: "m.room.nonexistant", } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, undefined); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal(""); }); - it("Should ignore bot user states", () => { + it("Should ignore bot user states", async () => { const processor = createMatrixEventProcessor(); const event = { sender: "@botuser:localhost", type: "m.room.member", } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, undefined); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal(""); }); - it("Should echo name changes", () => { + it("Should echo name changes", async () => { const processor = createMatrixEventProcessor(); const event = { content: { @@ -232,11 +279,10 @@ describe("MatrixEventProcessor", () => { sender: "@user:localhost", type: "m.room.name", } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, "`@user:localhost` set the name to `Test Name` on Matrix."); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` set the name to `Test Name` on Matrix."); }); - it("Should echo topic changes", () => { + it("Should echo topic changes", async () => { const processor = createMatrixEventProcessor(); const event = { content: { @@ -245,11 +291,10 @@ describe("MatrixEventProcessor", () => { sender: "@user:localhost", type: "m.room.topic", } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, "`@user:localhost` set the topic to `Test Topic` on Matrix."); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` set the topic to `Test Topic` on Matrix."); }); - it("Should echo joins", () => { + it("Should echo joins", async () => { const processor = createMatrixEventProcessor(); const event = { content: { @@ -259,11 +304,10 @@ describe("MatrixEventProcessor", () => { type: "m.room.member", unsigned: {}, } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, "`@user:localhost` joined the room on Matrix."); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` joined the room on Matrix."); }); - it("Should echo invites", () => { + it("Should echo invites", async () => { const processor = createMatrixEventProcessor(); const event = { content: { @@ -274,11 +318,10 @@ describe("MatrixEventProcessor", () => { type: "m.room.member", unsigned: {}, } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, "`@user:localhost` invited `@user2:localhost` to the room on Matrix."); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` invited `@user2:localhost` to the room on Matrix."); }); - it("Should echo kicks", () => { + it("Should echo kicks", async () => { const processor = createMatrixEventProcessor(); const event = { content: { @@ -289,11 +332,10 @@ describe("MatrixEventProcessor", () => { type: "m.room.member", unsigned: {}, } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, "`@user:localhost` kicked `@user2:localhost` from the room on Matrix."); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` kicked `@user2:localhost` from the room on Matrix."); }); - it("Should echo leaves", () => { + it("Should echo leaves", async () => { const processor = createMatrixEventProcessor(); const event = { content: { @@ -304,11 +346,10 @@ describe("MatrixEventProcessor", () => { type: "m.room.member", unsigned: {}, } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, "`@user:localhost` left the room on Matrix."); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` left the room on Matrix."); }); - it("Should echo bans", () => { + it("Should echo bans", async () => { const processor = createMatrixEventProcessor(); const event = { content: { @@ -319,9 +360,8 @@ describe("MatrixEventProcessor", () => { type: "m.room.member", unsigned: {}, } as IMatrixEvent; - const channel = new MockChannel("123456"); - const msg = processor.StateEventToMessage(event, channel as any); - Chai.assert.equal(msg, "`@user:localhost` banned `@user2:localhost` from the room on Matrix."); + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` banned `@user2:localhost` from the room on Matrix."); }); }); describe("EventToEmbed", () => { @@ -767,4 +807,165 @@ This is the reply`, expect(result!.description).to.be.equal("[package.zip](https://package/localhost)"); }); }); + describe("OnEvent", () => { + it("should reject old events", async () => { + const AGE = 900001; // 15 * 60 * 1000 + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({unsigned: {age: AGE}}), null); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should reject un-processable events", async () => { + const AGE = 900000; // 15 * 60 * 1000 + const processor = createMatrixEventProcessor(); + // check if nothing is thrown + await processor.OnEvent(buildRequest({ + content: {}, + type: "m.potato", + unsigned: {age: AGE}}), null); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should handle own invites", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {membership: "invite"}, + state_key: "@botuser:localhost", + type: "m.room.member"}), null); + expect(MESSAGE_PROCCESS).to.equal("invited"); + }); + it("should handle kicks to own members", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {membership: "leave"}, + sender: "@badboy:localhost", + state_key: "@_discord_12345:localhost", + type: "m.room.member"}), null); + expect(KICKBAN_HANDLED).to.be.true; + }); + it("should handle bans to own members", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {membership: "ban"}, + sender: "@badboy:localhost", + state_key: "@_discord_12345:localhost", + type: "m.room.member"}), null); + expect(KICKBAN_HANDLED).to.be.true; + }); + it("should pass other member types to state event", async () => { + const processor = createMatrixEventProcessor(); + let stateevent = false; + processor.ProcessStateEvent = async (ev) => { + stateevent = true; + }; + await processor.OnEvent(buildRequest({ + content: {membership: "join"}, + state_key: "@bacon:localhost", + type: "m.room.member"}), null); + expect(MESSAGE_PROCCESS).to.equal(""); + expect(stateevent).to.be.true; + }); + it("should handle redactions with existing rooms", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: true, + }, + }; + await processor.OnEvent(buildRequest({ + type: "m.room.redaction"}), context); + expect(MESSAGE_PROCCESS).equals("redacted"); + }); + it("should ignore redactions with no linked room", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: null, + }, + }; + await processor.OnEvent(buildRequest({ + type: "m.room.redaction"}), context); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should process regular messages", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: { + roomId: "_discord_123_456", + }, + }, + }; + let processed = false; + processor.ProcessMsgEvent = async (evt, _, __) => { + processed = true; + }; + await processor.OnEvent(buildRequest({ + content: {body: "abc"}, + type: "m.room.message", + }), context); + expect(MESSAGE_PROCCESS).to.equal(""); + expect(processed).to.be.true; + }); + it("should alert if encryption is turned on", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: { + roomId: "_discord_123_456", + }, + }, + }; + let encrypt = false; + processor.HandleEncryptionWarning = async (evt) => { + encrypt = true; + }; + await processor.OnEvent(buildRequest({ + room_id: "!accept:localhost", + type: "m.room.encryption", + }), context); + expect(encrypt).to.be.true; + }); + it("should process !discord commands", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {body: "!discord cmd"}, + type: "m.room.message", + }), null); + expect(MESSAGE_PROCCESS).to.equal("command_processed"); + }); + it("should ignore regular messages with no linked room", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: null, + }, + }; + await processor.OnEvent(buildRequest({ + content: {body: "abc"}, + type: "m.room.message", + }), context); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should process stickers", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: { + roomId: "_discord_123_456", + }, + }, + }; + let processed = false; + processor.ProcessMsgEvent = async (evt, _, __) => { + processed = true; + }; + await processor.OnEvent(buildRequest({ + content: { + body: "abc", + url: "mxc://abc", + }, + type: "m.sticker", + }), context); + expect(processed).to.be.true; + }); + }); }); diff --git a/test/test_matrixroomhandler.ts b/test/test_matrixroomhandler.ts index d653c7c1ecff472d527bdeb1e6430aa5741729f1..4c5de12bb552fc923823a55044857f3531b394ad 100644 --- a/test/test_matrixroomhandler.ts +++ b/test/test_matrixroomhandler.ts @@ -49,15 +49,6 @@ let USERSYNC_HANDLED = false; let KICKBAN_HANDLED = false; let MESSAGE_PROCCESS = ""; -function buildRequest(eventData) { - if (eventData.unsigned === undefined) { - eventData.unsigned = {age: 0}; - } - return { - getData: () => eventData, - }; -} - function createRH(opts: any = {}) { USERSJOINED = 0; USERSKICKED = 0; @@ -225,387 +216,6 @@ describe("MatrixRoomHandler", () => { } }); }); - describe("OnEvent", () => { - it("should reject old events", async () => { - const AGE = 900001; // 15 * 60 * 1000 - const handler = createRH(); - await handler.OnEvent(buildRequest({unsigned: {age: AGE}}), null); - expect(MESSAGE_PROCCESS).equals(""); - }); - it("should reject un-processable events", async () => { - const AGE = 900000; // 15 * 60 * 1000 - const handler = createRH(); - // check if nothing is thrown - await handler.OnEvent(buildRequest({ - content: {}, - type: "m.potato", - unsigned: {age: AGE}}), null); - expect(MESSAGE_PROCCESS).equals(""); - }); - it("should handle invites", async () => { - const handler = createRH(); - let invited = false; - handler.HandleInvite = async (ev) => { - invited = true; - }; - await handler.OnEvent(buildRequest({ - content: {membership: "invite"}, - type: "m.room.member"}), null); - expect(invited).to.be.true; - }); - it("should handle kicks to own members", async () => { - const handler = createRH(); - await handler.OnEvent(buildRequest({ - content: {membership: "leave"}, - sender: "@badboy:localhost", - state_key: "@_discord_12345:localhost", - type: "m.room.member"}), null); - expect(KICKBAN_HANDLED).to.be.true; - }); - it("should handle bans to own members", async () => { - const handler = createRH(); - await handler.OnEvent(buildRequest({ - content: {membership: "ban"}, - sender: "@badboy:localhost", - state_key: "@_discord_12345:localhost", - type: "m.room.member"}), null); - expect(KICKBAN_HANDLED).to.be.true; - }); - it("should pass other member types to state event", async () => { - const handler = createRH(); - let invited = false; - handler.HandleInvite = async (ev) => { - invited = true; - }; - handler.OnEvent(buildRequest({ - content: {membership: "join"}, - state_key: "@bacon:localhost", - type: "m.room.member"}), null); - expect(invited).to.be.false; - expect(MESSAGE_PROCCESS).equals("stateevent"); - }); - it("should handle redactions with existing rooms", async () => { - const handler = createRH(); - const context = { - rooms: { - remote: true, - }, - }; - await handler.OnEvent(buildRequest({ - type: "m.room.redaction"}), context); - expect(MESSAGE_PROCCESS).equals("redacted"); - }); - it("should ignore redactions with no linked room", async () => { - const handler = createRH(); - const context = { - rooms: { - remote: null, - }, - }; - await handler.OnEvent(buildRequest({ - type: "m.room.redaction"}), context); - expect(MESSAGE_PROCCESS).equals(""); - }); - it("should process regular messages", async () => { - const handler = createRH(); - const context = { - rooms: { - remote: { - roomId: "_discord_123_456", - }, - }, - }; - await handler.OnEvent(buildRequest({ - content: {body: "abc"}, - type: "m.room.message", - }), context); - expect(MESSAGE_PROCCESS).equals("processed"); - }); - it("should alert if encryption is turned on", async () => { - const handler = createRH(); - const context = { - rooms: { - remote: { - roomId: "_discord_123_456", - }, - }, - }; - await handler.OnEvent(buildRequest({ - room_id: "!accept:localhost", - type: "m.room.encryption", - }), context); - }); - it("should process !discord commands", async () => { - const handler = createRH(); - let processedcmd = false; - handler.ProcessCommand = async (ev) => { - processedcmd = true; - }; - await handler.OnEvent(buildRequest({ - content: {body: "!discord cmd"}, - type: "m.room.message", - }), null); - expect(processedcmd).to.be.true; - }); - it("should ignore regular messages with no linked room", async () => { - const handler = createRH(); - const context = { - rooms: { - remote: null, - }, - }; - await handler.OnEvent(buildRequest({ - content: {body: "abc"}, - type: "m.room.message", - }), context); - expect(MESSAGE_PROCCESS).equals(""); - }); - it("should process stickers", async () => { - const handler = createRH(); - const context = { - rooms: { - remote: { - roomId: "_discord_123_456", - }, - }, - }; - await handler.OnEvent(buildRequest({ - content: { - body: "abc", - url: "mxc://abc", - }, - type: "m.sticker", - }), context); - expect(MESSAGE_PROCCESS).equals("processed"); - }); - }); - describe("HandleInvite", () => { - it("should accept invite for bot user", async () => { - const handler: any = createRH(); - let joinedRoom = false; - handler.joinRoom = async () => { - joinedRoom = true; - }; - await handler.HandleInvite({ - state_key: "@botuser:localhost", - }); - expect(joinedRoom).to.be.true; - }); - it("should deny invite for other users", async () => { - const handler: any = createRH(); - let joinedRoom = false; - handler.joinRoom = async () => { - joinedRoom = true; - }; - await handler.HandleInvite({ - state_key: "@user:localhost", - }); - expect(joinedRoom).to.be.false; - }); - }); - describe("ProcessCommand", () => { - it("should not process command if not in room", async () => { - const handler: any = createRH({disableSS: true}); - const ret = await handler.ProcessCommand({ - room_id: "!666:localhost", - }); - expect(ret).to.be.undefined; - }); - it("should warn if self service is disabled", async () => { - const handler: any = createRH({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 = createRH(); - 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 = createRH({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 = createRH({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; - }); - it("should allow if user is powerful enough with their own state", async () => { - const handler: any = createRH({powerLevels: { - users: { - "@user:localhost": 100, - }, - }}); - const evt = await handler.ProcessCommand({ - content: {body: "!discord help"}, - room_id: "!123:localhost", - sender: "@user:localhost", - }); - expect(evt.body.startsWith("Available commands")).to.be.true; - }); - describe("!discord bridge", () => { - it("will bridge a new room, and ask for permissions", async () => { - const handler: any = createRH({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"); - }); - it("will fail to bridge if permissions were denied", async () => { - const handler: any = createRH({ - 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"); - }); - it("will fail to bridge if permissions were denied", async () => { - const handler: any = createRH({ - 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 " + - "the guild owner approved the bridge?"); - }); - it("will not bridge if a link already exists", async () => { - const handler: any = createRH({ - 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."); - }); - it("will not bridge without required args", async () => { - const handler: any = createRH({ - 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"); - }); - it("will bridge with x/y syntax", async () => { - const handler: any = createRH({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"); - }); - }); - describe("!discord unbridge", () => { - it("will unbridge", async () => { - const handler: any = createRH({ - 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"); - }); - it("will not unbridge if a link does not exist", async () => { - const handler: any = createRH({ - 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."); - }); - it("will not unbridge non-plumbed rooms", async () => { - const handler: any = createRH({ - 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."); - }); - it("will show error if unbridge fails", async () => { - const handler: any = createRH({ - 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."); - }); - }); - }); describe("OnAliasQuery", () => { it("will create room", async () => { const handler: any = createRH({});