diff --git a/.travis.yml b/.travis.yml index 68421c53854359ae12525bf669d00091e383dd6a..3b4dc89da9d850a78c47b28d6da2be92276eea8b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,8 @@ node_js: - "9.11.1" - "10.1.0" script: - - npm test + - npm run-script build + - npm run-script coverage - npm run-script lint notifications: webhooks: diff --git a/src/config.ts b/src/config.ts index 1488f307fce28c7683a0fbc13ff928d4557e810f..0db41c76f83a6c0da6fc2e5ea7ff1dad84026952 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,11 @@ /** Type annotations for config/config.schema.yaml */ - export class DiscordBridgeConfig { public bridge: DiscordBridgeConfigBridge = new DiscordBridgeConfigBridge(); public auth: DiscordBridgeConfigAuth = new DiscordBridgeConfigAuth(); public logging: DiscordBridgeConfigLogging = new DiscordBridgeConfigLogging(); public database: DiscordBridgeConfigDatabase = new DiscordBridgeConfigDatabase(); public room: DiscordBridgeConfigRoom = new DiscordBridgeConfigRoom(); + public limits: DiscordBridgeConfigLimits = new DiscordBridgeConfigLimits(); } class DiscordBridgeConfigBridge { @@ -37,3 +37,7 @@ class DiscordBridgeConfigLogging { class DiscordBridgeConfigRoom { public defaultVisibility: string; } + +class DiscordBridgeConfigLimits { + public roomGhostJoinDelay: number = 6000; +} diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index cee2ea5aa33448a414c56c025d34d90a90942529..5c8f9d1f78b95b1849903d839626e0bda9d74335 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -17,7 +17,6 @@ import { Util } from "./util"; import { Provisioner } from "./provisioner"; const ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoESwIsTbJrfXyAAogrNxA"; -const JOIN_DELAY = 6000; const HTTP_UNSUPPORTED = 501; const ROOM_NAME_PARTS = 2; const AGE_LIMIT = 900000; // 15 * 60 * 1000 @@ -65,32 +64,34 @@ export class MatrixRoomHandler { public OnAliasQueried (alias: string, roomId: string) { // Join a whole bunch of users. let promiseChain: any = Bluebird.resolve(); - let delay = JOIN_DELAY; /* We delay the joins to give some implmentations a chance to breathe */ + /* We delay the joins to give some implementations a chance to breathe */ + let delay = this.config.limits.roomGhostJoinDelay; return this.discord.GetChannelFromRoomId(roomId).then((channel: Discord.Channel) => { - for (const member of (<Discord.TextChannel> channel).guild.members.array()) { + for (const member of (<Discord.TextChannel> channel).members.array()) { if (member.id === this.discord.GetBotId()) { continue; } promiseChain = promiseChain.return(Bluebird.delay(delay).then(() => { return this.discord.InitJoinUser(member, [roomId]); })); - delay += JOIN_DELAY; + delay += this.config.limits.roomGhostJoinDelay; } }).catch((err) => { log.verbose("OnAliasQueried => %s", err); + throw err; }); } - public OnEvent (request, context) { + public OnEvent (request, context): Promise<any> { const event = request.getData(); if (event.unsigned.age > AGE_LIMIT) { log.warn("MatrixRoomHandler", "Skipping event due to age %s > %s", event.unsigned.age, AGE_LIMIT); - return; + return Promise.reject("Event too old"); } if (event.type === "m.room.member" && event.content.membership === "invite") { - this.HandleInvite(event); + return this.HandleInvite(event); } else if (event.type === "m.room.redaction" && context.rooms.remote) { - this.discord.ProcessMatrixRedact(event); + return this.discord.ProcessMatrixRedact(event); } else if (event.type === "m.room.message") { log.verbose("MatrixRoomHandler", "Got m.room.message event"); if (event.content.body && event.content.body.startsWith("!discord")) { @@ -104,6 +105,7 @@ export class MatrixRoomHandler { } else { log.verbose("MatrixRoomHandler", "Got non m.room.message event"); } + return Promise.reject("Event not processed by bridge"); } public HandleInvite(event: any) { @@ -160,7 +162,7 @@ export class MatrixRoomHandler { if (command === "help" && args[0] === "bridge") { const link = Util.GetBotLink(this.config); - this.bridge.getIntent().sendMessage(event.room_id, { + return this.bridge.getIntent().sendMessage(event.room_id, { msgtype: "m.notice", body: "How to bridge a Discord guild:\n" + "1. Invite the bot to your Discord guild using this link: " + link + "\n" + @@ -248,7 +250,7 @@ export class MatrixRoomHandler { } catch (err) { log.error("MatrixRoomHandler", "Error while unbridging room " + event.room_id); log.error("MatrixRoomHandler", err); - return this.bridge.getItent().sendMessage(event.room_id, { + return this.bridge.getIntent().sendMessage(event.room_id, { msgtype: "m.notice", body: "There was an error unbridging this room. " + "Please try again later or contact the bridge operator.", @@ -256,7 +258,7 @@ export class MatrixRoomHandler { } } else if (command === "help") { // Unknown command or no command given to get help on, so we'll just give them the help - this.bridge.getIntent().sendMessage(event.room_id, { + return this.bridge.getIntent().sendMessage(event.room_id, { msgtype: "m.notice", body: "Available commands:\n" + "!discord bridge <guild id> <channel id> - Bridges this room to a Discord channel\n" + diff --git a/test/mocks/channel.ts b/test/mocks/channel.ts index c9669f94ec275ffd3e035b3a57074639e5816b5c..d9794702e3d4fe8177ee3ae628037e3d47588440 100644 --- a/test/mocks/channel.ts +++ b/test/mocks/channel.ts @@ -1,9 +1,8 @@ -import {MockUser} from "./user"; -import * as Discord from "discord.js"; import {MockMember} from "./member"; import {MockCollection} from "./collection"; // Mocking TextChannel export class MockChannel { public members = new MockCollection<string, MockMember>(); + constructor (public id: string = "", public guild: any = null) { } } diff --git a/test/mocks/guild.ts b/test/mocks/guild.ts index 30c85a23decfc45a89aaa5903a8ded6896cee95a..670d298392d9bfc0c105a39059a84f76a917083b 100644 --- a/test/mocks/guild.ts +++ b/test/mocks/guild.ts @@ -6,8 +6,10 @@ export class MockGuild { public channels = new MockCollection<string, Channel>(); public members = new MockCollection<string, MockMember>(); public id: string; - constructor(id: string, channels: any[]) { + public name: string; + constructor(id: string, channels: any[] = [], name: string = null) { this.id = id; + this.name = name || id; channels.forEach((item) => { this.channels.set(item.id, item); }); diff --git a/test/test_matrixroomhandler.ts b/test/test_matrixroomhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..36a0e7001b7aabc78fb6f9f2d736cc2502358783 --- /dev/null +++ b/test/test_matrixroomhandler.ts @@ -0,0 +1,536 @@ +import * as Chai from "chai"; +import * as ChaiAsPromised from "chai-as-promised"; +import * as Proxyquire from "proxyquire"; +import {DiscordBridgeConfig} from "../src/config"; +import {MockDiscordClient} from "./mocks/discordclient"; +import * as log from "npmlog"; +import {PresenceHandler} from "../src/presencehandler"; +import {DiscordBot} from "../src/bot"; +import {MatrixRoomHandler} from "../src/matrixroomhandler"; +import {MockChannel} from "./mocks/channel"; +import {MockMember} from "./mocks/member"; +import * as Bluebird from "bluebird"; +import {MockGuild} from "./mocks/guild"; +import {Guild} from "discord.js"; + +Chai.use(ChaiAsPromised); +const expect = Chai.expect; + +// const DiscordClientFactory = Proxyquire("../src/clientfactory", { +// "discord.js": { Client: require("./mocks/discordclient").MockDiscordClient }, +// }).DiscordClientFactory; + +let USERSJOINED = 0; + +function buildRequest(eventData) { + if (eventData.unsigned === undefined) { + eventData.unsigned = {age: 0}; + } + return { + getData: () => eventData, + }; +} + +function createRH(opts: any = {}) { + log.level = "silent"; + USERSJOINED = 0; + const bot = { + GetChannelFromRoomId: (roomid: string) => { + if (roomid === "!accept:localhost") { + const chan = new MockChannel(); + if (opts.createMembers) { + chan.members.set("12345", new MockMember("12345", "testuser1")); + chan.members.set("54321", new MockMember("54321", "testuser2")); + chan.members.set("bot12345", new MockMember("bot12345", "botuser")); + } + return Promise.resolve(chan); + } else { + return Promise.reject("Roomid not found"); + } + }, + InitJoinUser: (member: MockMember, roomids: string[]) => { + if (opts.failUser) { + return Promise.reject("test is rejecting joins"); + } + USERSJOINED++; + return Promise.resolve(); + + }, + GetBotId: () => "bot12345", + ProcessMatrixRedact: () => Promise.resolve("redacted"), + ProcessMatrixMsgEvent: () => Promise.resolve("processed"), + LookupRoom: (guildid, discordid) => { + if (guildid !== "123") { + return Promise.reject("Guild not found"); + } else if (discordid !== "456") { + return Promise.reject("Channel not found"); + } + const channel = new MockChannel(); + return Promise.resolve({channel, botUser: true }); + }, + GetGuilds: () => [new MockGuild("123", [])], + ThirdpartySearchForChannels: () => { + return []; + }, + }; + const config = new DiscordBridgeConfig(); + config.limits.roomGhostJoinDelay = 0; + if (opts.disableSS) { + config.bridge.enableSelfServiceBridging = false; + } else { + config.bridge.enableSelfServiceBridging = true; + } + const mxClient = { + getStateEvent: () => { + return Promise.resolve(opts.powerLevels || {}); + }, + }; + const provisioner = { + AskBridgePermission: () => { + return opts.denyBridgePermission ? + Promise.reject(new Error("The bridge has been declined by the Discord guild")) : Promise.resolve(); + }, + BridgeMatrixRoom: () => { + if (opts.failBridgeMatrix) { + throw new Error("Test failed matrix bridge"); + } + }, + UnbridgeRoom: () => { + return opts.failUnbridge ? + Promise.reject(new Error("Test failed unbridge")) : Promise.resolve(); + }, + }; + const handler = new MatrixRoomHandler(bot as any, config, "@botuser:localhost", provisioner as any); + handler.setBridge({ + getIntent: () => { return { + sendMessage: (roomId, content) => Promise.resolve(content), + getClient: () => mxClient, + }; }, + }); + return handler; +} + +describe("MatrixRoomHandler", () => { + describe("OnAliasQueried", () => { + it("should join successfully", () => { + const handler = createRH(); + return expect(handler.OnAliasQueried("#accept:localhost", "!accept:localhost")).to.be.fulfilled; + }); + it("should join successfully and create ghosts", () => { + const EXPECTEDUSERS = 2; + const TESTDELAY = 50; + const handler = createRH({createMembers: true}); + return handler.OnAliasQueried("#accept:localhost", "!accept:localhost").then(() => { + return Bluebird.delay(TESTDELAY); + }).then(() => { + expect(USERSJOINED).to.equal(EXPECTEDUSERS); + // test for something + return true; + }); + }); + it("should not join successfully", () => { + const handler = createRH(); + return expect(handler.OnAliasQueried("#reject:localhost", "!reject:localhost")).to.be.rejected; + }); + }); + describe("OnEvent", () => { + it("should reject old events", () => { + const AGE = 900001; // 15 * 60 * 1000 + const handler = createRH(); + return expect(handler.OnEvent( + buildRequest({unsigned: {age: AGE}}), null)) + .to.be.rejectedWith("Event too old"); + }); + it("should reject un-processable events", () => { + const AGE = 900000; // 15 * 60 * 1000 + const handler = createRH(); + return expect(handler.OnEvent(buildRequest({ + content: {}, + type: "m.potato", + unsigned: {age: AGE}}), null)).to.be.rejectedWith("Event not processed by bridge"); + }); + it("should handle invites", () => { + const handler = createRH(); + handler.HandleInvite = (ev) => Promise.resolve("invited"); + return expect(handler.OnEvent(buildRequest({ + content: {membership: "invite"}, + type: "m.room.member"}), null)).to.eventually.equal("invited"); + }); + it("should ignore other member types", () => { + const handler = createRH(); + handler.HandleInvite = (ev) => Promise.resolve("invited"); + return expect(handler.OnEvent(buildRequest({ + content: {membership: "join"}, + type: "m.room.member"}), null)).to.be.rejectedWith("Event not processed by bridge"); + }); + it("should handle redactions with existing rooms", () => { + const handler = createRH(); + const context = { + rooms: { + remote: true, + }, + }; + return expect(handler.OnEvent(buildRequest({ + type: "m.room.redaction"}), context)).to.eventually.equal("redacted"); + }); + it("should ignore redactions with no linked room", () => { + const handler = createRH(); + const context = { + rooms: { + remote: null, + }, + }; + return expect(handler.OnEvent(buildRequest({ + type: "m.room.redaction"}), context)).to.be.rejectedWith("Event not processed by bridge"); + }); + it("should process regular messages", () => { + const handler = createRH(); + const context = { + rooms: { + remote: { + roomId: "_discord_123_456", + }, + }, + }; + return expect(handler.OnEvent(buildRequest({ + type: "m.room.message", content: {body: "abc"}}), context)).to.eventually.equal("processed"); + }); + it("should process !discord commands", () => { + const handler = createRH(); + handler.ProcessCommand = (ev) => Promise.resolve("processedcmd"); + return expect(handler.OnEvent(buildRequest({ + type: "m.room.message", content: {body: "!discord cmd"}}), null)) + .to.eventually.equal("processedcmd"); + }); + it("should ignore regular messages with no linked room", () => { + const handler = createRH(); + const context = { + rooms: { + remote: null, + }, + }; + return expect(handler.OnEvent(buildRequest({ + type: "m.room.message", content: {body: "abc"}}), context)) + .to.be.rejectedWith("Event not processed by bridge"); + }); + }); + describe("HandleInvite", () => { + it("should accept invite for bot user", () => { + const handler: any = createRH(); + handler.joinRoom = () => Promise.resolve("joinedroom"); + return expect(handler.HandleInvite({ + state_key: "@botuser:localhost", + })).to.eventually.be.equal("joinedroom"); + }); + it("should deny invite for other users", () => { + const handler: any = createRH(); + handler.joinRoom = () => Promise.resolve("joinedroom"); + return expect(handler.HandleInvite({ + state_key: "@user:localhost", + })).to.be.undefined; + }); + }); + describe("ProcessCommand", () => { + it("should warn if self service is disabled", () => { + const handler: any = createRH({disableSS: true}); + return expect(handler.ProcessCommand({ + room_id: "!123:localhost", + })).to.eventually.be.deep.equal({ + msgtype: "m.notice", + body: "The owner of this bridge does not permit self-service bridging.", + }); + }); + it("should warn if user is not powerful enough with defaults", () => { + const handler: any = createRH(); + return expect(handler.ProcessCommand({ + room_id: "!123:localhost", + })).to.eventually.be.deep.equal({ + msgtype: "m.notice", + body: "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", () => { + const handler: any = createRH({powerLevels: { + state_default: 67, + }}); + return expect(handler.ProcessCommand({ + room_id: "!123:localhost", + })).to.eventually.be.deep.equal({ + msgtype: "m.notice", + body: "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", () => { + const handler: any = createRH({powerLevels: { + users_default: 60, + }}); + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord help"}, + }).then((evt) => { + return expect(evt.body.startsWith("Available commands")).to.be.true; + }); + }); + it("should allow if user is powerful enough with their own state", () => { + const handler: any = createRH({powerLevels: { + users: { + "@user:localhost": 100, + }, + }}); + return handler.ProcessCommand({ + room_id: "!123:localhost", + sender: "@user:localhost", + content: {body: "!discord help"}, + }).then((evt) => { + return expect(evt.body.startsWith("Available commands")).to.be.true; + }); + }); + describe("!discord bridge", () => { + it("will bridge a new room, and ask for permissions", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }}); + const context = {rooms: {}}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord bridge 123 456"}, + }, context).then((evt) => { + return expect(evt.body).to.be.eq("I have bridged this room to your channel"); + }); + }); + it("will fail to bridge if permissions were denied", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }, denyBridgePermission: true}); + const context = {rooms: {}}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord bridge 123 456"}, + }, context).then((evt) => { + return expect(evt.body).to.be.eq("The bridge has been declined by the Discord guild"); + }); + }); + it("will fail to bridge if permissions were denied", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }, failBridgeMatrix: true}); + const context = {rooms: {}}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord bridge 123 456"}, + }, context).then((evt) => { + return expect(evt.body).to.be + .eq("There was a problem bridging that channel - has the guild owner approved the bridge?"); + }); + }); + it("will not bridge if a link already exists", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }}); + const context = {rooms: { remote: true }}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord bridge"}, + }, context).then((evt) => { + return expect(evt.body).to.be.eq("This room is already bridged to a Discord guild."); + }); + }); + it("will not bridge without required args", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }}); + const context = {rooms: {}}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord bridge"}, + }, context).then((evt) => { + return expect(evt.body).to.contain("Invalid syntax"); + }); + }); + }); + describe("!discord unbridge", () => { + it("will unbridge", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }}); + const context = {rooms: { remote: { + data: { + plumbed: true, + }, + } }}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord unbridge"}, + }, context).then((evt) => { + return expect(evt.body).to.be.eq("This room has been unbridged"); + }); + }); + it("will not unbridge if a link does not exist", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }}); + const context = {rooms: { remote: undefined }}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord unbridge"}, + }, context).then((evt) => { + return expect(evt.body).to.be.eq("This room is not bridged."); + }); + }); + it("will not unbridge non-plumbed rooms", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }}); + const context = {rooms: { remote: { + data: { + plumbed: false, + }}}}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord unbridge"}, + }, context).then((evt) => { + return expect(evt.body).to.be.eq("This room cannot be unbridged."); + }); + }); + it("will show error if unbridge fails", () => { + const handler: any = createRH({powerLevels: { + users_default: 100, + }, failUnbridge: true}); + const context = {rooms: { remote: { + data: { + plumbed: true, + }}}}; + return handler.ProcessCommand({ + room_id: "!123:localhost", + content: {body: "!discord unbridge"}, + }, context).then((evt) => { + return expect(evt.body).to.contain("There was an error unbridging this room."); + }); + }); + }); + }); + describe("OnAliasQuery", () => { + it("will create room", () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + return expect(handler.OnAliasQuery( + "_discord_123_456:localhost", + "_discord_123_456")).to.eventually.be.true; + }); + it("will not create room if guild cannot be found", () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + return expect(handler.OnAliasQuery( + "_discord_111_456:localhost", + "_discord_111_456")).to.eventually.be.undefined; + }); + it("will not create room if channel cannot be found", () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + return expect(handler.OnAliasQuery( + "_discord_123_444:localhost", + "_discord_123_444")).to.eventually.be.undefined; + }); + it("will not create room if alias is wrong", () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + return expect(handler.OnAliasQuery( + "_discord_123:localhost", + "_discord_123")).to.be.undefined; + }); + }); + describe("tpGetProtocol", () => { + it("will return an object", () => { + const handler: any = createRH({}); + return handler.tpGetProtocol("").then((protocol) => { + expect(protocol).to.not.be.null; + expect(protocol.instances[0].network_id).to.equal("123"); + expect(protocol.instances[0].bot_user_id).to.equal("@botuser:localhost"); + expect(protocol.instances[0].desc).to.equal("123"); + expect(protocol.instances[0].network_id).to.equal("123"); + }); + }); + }); + describe("tpGetLocation", () => { + it("will return an array", () => { + const handler: any = createRH({}); + return handler.tpGetLocation("", { + guild_id: "", + channel_name: "", + }).then((channels) => { + expect(channels).to.be.a("array"); + }); + }); + }); + describe("tpParseLocation", () => { + it("will reject", () => { + const handler: any = createRH({}); + return expect(handler.tpParseLocation("alias")).to.eventually.be.rejected; + }); + }); + describe("tpGetUser", () => { + it("will reject", () => { + const handler: any = createRH({}); + return expect(handler.tpGetUser("", {})).to.eventually.be.rejected; + }); + }); + describe("tpParseUser", () => { + it("will reject", () => { + const handler: any = createRH({}); + return expect(handler.tpParseUser("alias")).to.eventually.be.rejected; + }); + }); + describe("joinRoom", () => { + it("will join immediately", () => { + const handler: any = createRH({}); + const intent = { + getClient: () => { + return { + joinRoom: () => { + return Promise.resolve(); + }, + }; + }, + }; + const startTime = Date.now(); + const MAXTIME = 1000; + return expect(handler.joinRoom(intent, "#test:localhost")).to.eventually.be.fulfilled.and.satisfy(() => { + return (Date.now() - startTime) < MAXTIME; + }); + }); + it("will fail first, join after", () => { + log.level = "error"; + const handler: any = createRH({}); + let shouldFail = true; + const intent = { + getClient: () => { + return { + joinRoom: () => { + if (shouldFail) { + shouldFail = false; + return Promise.reject("Test failed first time"); + } + return Promise.resolve(); + }, + getUserId: () => "@test:localhost", + }; + }, + }; + const startTime = Date.now(); + const MINTIME = 1000; + return expect(handler.joinRoom(intent, "#test:localhost")).to.eventually.be.fulfilled.and.satisfy(() => { + expect(shouldFail).to.be.false; + return (Date.now() - startTime) > MINTIME; + }); + }); + }); + describe("createMatrixRoom", () => { + it("will return an object", () => { + const handler: any = createRH({}); + const channel = new MockChannel("123", new MockGuild("456")); + const roomOpts = handler.createMatrixRoom(channel, "#test:localhost"); + expect(roomOpts.creationOpts).to.exist; + expect(roomOpts.remote).to.exist; + }); + }); +});