diff --git a/config/config.sample.yaml b/config/config.sample.yaml index fe0400060516ecb60e0b108f88a001d554bfe89a..59700ac4b307482296abd8b0981c2df6bf66e2c5 100644 --- a/config/config.sample.yaml +++ b/config/config.sample.yaml @@ -6,6 +6,7 @@ bridge: disableTypingNotifications: false disableDiscordMentions: false disableDeletionForwarding: false + enableSelfServiceBridging: false auth: clientID: "12345" # Get from discord secret: "blah" diff --git a/config/config.schema.yaml b/config/config.schema.yaml index bdb2bb5cc5c1bf24ccf82adeb82037c8800dc4e2..bb3b893a72df29e8dd9515a4c1699d1b2872a237 100644 --- a/config/config.schema.yaml +++ b/config/config.schema.yaml @@ -20,6 +20,8 @@ properties: type: "boolean" disableDeletionForwarding: type: "boolean" + enableSelfServiceBridging: + type: "boolean" auth: type: "object" required: ["botToken"] diff --git a/src/bot.ts b/src/bot.ts index ed6868460851cf3fc92f2902b19d41274a2533c0..15203be96af08399b56ca7c930092b89e63a4b93 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -12,6 +12,7 @@ import * as log from "npmlog"; import * as Bluebird from "bluebird"; import * as mime from "mime"; import * as path from "path"; +import { Provisioner } from "./provisioner"; // Due to messages often arriving before we get a response from the send call, // messages get delayed from discord. @@ -34,7 +35,7 @@ export class DiscordBot { private sentMessages: string[]; private msgProcessor: MessageProcessor; private presenceHandler: PresenceHandler; - constructor(config: DiscordBridgeConfig, store: DiscordStore) { + constructor(config: DiscordBridgeConfig, store: DiscordStore, private provisioner: Provisioner) { this.config = config; this.store = store; this.sentMessages = []; @@ -526,6 +527,7 @@ export class DiscordBot { private async OnMessage(msg: Discord.Message) { const indexOfMsg = this.sentMessages.indexOf(msg.id); + const chan = <Discord.TextChannel> msg.channel; if (indexOfMsg !== -1) { log.verbose("DiscordBot", "Got repeated message, ignoring."); delete this.sentMessages[indexOfMsg]; @@ -537,7 +539,7 @@ export class DiscordBot { } // Issue #57: Detect webhooks if (msg.webhookID != null) { - const webhook = (await (<Discord.TextChannel> msg.channel).fetchWebhooks()) + const webhook = (await chan.fetchWebhooks()) .filterArray((h) => h.name === "_matrix").pop(); if (webhook != null && msg.webhookID === webhook.id) { // Filter out our own webhook messages. @@ -545,6 +547,30 @@ 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) { + msg.channel.sendMessage("Thanks for your response! The matrix bridge has been approved"); + } else if (successfullyBridged && !isApproved) { + msg.channel.sendMessage("Thanks for your response! The matrix bridge has been declined"); + } else { + 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") { + msg.channel.sendMessage(err.message); + } else { + log.error("DiscordBot", "Error processing room approval"); + log.error("DiscordBot", err); + } + } + + return; // stop processing - we're approving/declining the bridge request + } + // Update presence because sometimes discord misses people. this.UpdateUser(msg.author).then(() => { return this.GetRoomIdsFromChannel(msg.channel).catch((err) => { diff --git a/src/config.ts b/src/config.ts index cf144ca32abef2f57339e72c4430e56bde908079..3730acf6ba642c58cd712d797e6eaf1f76bfad22 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ class DiscordBridgeConfigBridge { public disableTypingNotifications: boolean; public disableDiscordMentions: boolean; public disableDeletionForwarding: boolean; + public enableSelfServiceBridging: boolean; } class DiscordBridgeConfigDatabase { diff --git a/src/discordas.ts b/src/discordas.ts index 7ef64b6a009b91ddd030f7c992a5cd4a496113e9..8629fc8d545332019a9bb38581e929b1af04a133 100644 --- a/src/discordas.ts +++ b/src/discordas.ts @@ -6,6 +6,7 @@ import { DiscordBridgeConfig } from "./config"; import { DiscordBot } from "./bot"; import { MatrixRoomHandler } from "./matrixroomhandler"; import { DiscordStore } from "./store"; +import { Provisioner } from "./provisioner"; const cli = new Cli({ bridgeConfig: { @@ -48,9 +49,10 @@ function run (port: number, config: DiscordBridgeConfig) { token: registration.as_token, url: config.bridge.homeserverUrl, }); + const provisioner = new Provisioner(); const discordstore = new DiscordStore(config.database ? config.database.filename : "discord.db"); - const discordbot = new DiscordBot(config, discordstore); - const roomhandler = new MatrixRoomHandler(discordbot, config, botUserId); + const discordbot = new DiscordBot(config, discordstore, provisioner); + const roomhandler = new MatrixRoomHandler(discordbot, config, botUserId, provisioner); const bridge = new Bridge({ clientFactory, @@ -68,6 +70,7 @@ function run (port: number, config: DiscordBridgeConfig) { homeserverUrl: config.bridge.homeserverUrl, registration, }); + provisioner.setBridge(bridge); roomhandler.setBridge(bridge); discordbot.setBridge(bridge); log.info("discordas", "Initing bridge."); diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index e397a68d33baa09aa57405bd75bf9a45ddc416cd..465750e461c99f250c4a06ab6dcdc1ca07ae23df 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -2,6 +2,7 @@ import { DiscordBot } from "./bot"; import { Bridge, RemoteRoom, + MatrixRoom, thirdPartyLookup, thirdPartyProtocolResult, thirdPartyUserResult, @@ -12,19 +13,35 @@ import { DiscordBridgeConfig } from "./config"; import * as Discord from "discord.js"; import * as log from "npmlog"; import * as Bluebird from "bluebird"; +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 +const PROVISIONING_DEFAULT_POWER_LEVEL = 50; +const PROVISIONING_DEFAULT_USER_POWER_LEVEL = 0; + +// Note: The schedule must not have duplicate values to avoid problems in positioning. +/* tslint:disable:no-magic-numbers */ // Disabled because it complains about the values in the array +const JOIN_ROOM_SCHEDULE = [ + 0, // Right away + 1000, // 1 second + 30000, // 30 seconds + 300000, // 5 minutes + 900000, // 15 minutes +]; +/* tslint:enable:no-magic-numbers */ export class MatrixRoomHandler { + private config: DiscordBridgeConfig; private bridge: Bridge; private discord: DiscordBot; private botUserId: string; - constructor (discord: DiscordBot, config: DiscordBridgeConfig, botUserId: string) { + constructor (discord: DiscordBot, config: DiscordBridgeConfig, botUserId: string, private provisioner: Provisioner) { this.discord = discord; this.config = config; this.botUserId = botUserId; @@ -74,12 +91,16 @@ export class MatrixRoomHandler { this.HandleInvite(event); } else if (event.type === "m.room.redaction" && context.rooms.remote) { this.discord.ProcessMatrixRedact(event); - } else if (event.type === "m.room.message" && context.rooms.remote) { + } else if (event.type === "m.room.message") { log.verbose("MatrixRoomHandler", "Got m.room.message event"); - const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS); - return this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]).catch((err) => { - log.warn("MatrixRoomHandler", "There was an error sending a matrix event", err); - }); + if (event.content.body && event.content.body.startsWith("!discord")) { + return this.ProcessCommand(event, context); + } else if (context.rooms.remote) { + const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS); + return this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]).catch((err) => { + log.warn("MatrixRoomHandler", "There was an error sending a matrix event", err); + }); + } } else { log.verbose("MatrixRoomHandler", "Got non m.room.message event"); } @@ -88,15 +109,161 @@ export class MatrixRoomHandler { public HandleInvite(event: any) { log.info("MatrixRoomHandler", "Received invite for " + event.state_key + " in room " + event.room_id); if (event.state_key === this.botUserId) { - this.bridge.getIntent().getClient().joinRoom(event.room_id).catch(err => { - log.error("MatrixRoomHandler", err); - setTimeout(() => { - this.bridge.getIntent().join(event.room_id).catch(err => log.error("MatrixRoomHandler", err)); - }, 5000); // Retry the join in 5 seconds if it failed the first time - }); + log.info("MatrixRoomHandler", "Accepting invite for bridge bot"); + return this.joinRoom(this.bridge.getIntent(), event.room_id); } } + public async ProcessCommand(event: any, context: any) { + 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, { + msgtype: "m.notice", + body: "The owner of this bridge does not permit self-service bridging.", + }); + } + + // 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, { + msgtype: "m.notice", + body: "You do not have the required power level in this room to create a bridge to a Discord channel.", + }); + } + + const prefix = "!discord "; + let command = "help"; + let args = []; + if (event.content.body.length >= prefix.length) { + const allArgs = event.content.body.substring(prefix.length).split(" "); + if (allArgs.length && allArgs[0] !== "") { + command = allArgs[0]; + allArgs.splice(0, 1); + args = allArgs; + } + } + + if (command === "help" && args[0] === "bridge") { + const link = Util.GetBotLink(this.config); + 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" + + "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!", + }); + } else if (command === "bridge") { + if (context.rooms.remote) { + return this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "This room is already bridged to a Discord guild.", + }); + } + + const minArgs = 2; + if (args.length < minArgs) { + return this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "Invalid syntax. For more information try !discord help bridge", + }); + } + + const guildId = args[0]; + const channelId = args[1]; + let channel: Discord.TextChannel = null; + return this.discord.LookupRoom(guildId, channelId).then((result) => { + log.info("MatrixRoomHandler", `Bridging matrix room ${event.room_id} to ${guildId}/${channelId}`); + channel = result.channel; + this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "I'm asking permission from the guild administrators to make this bridge.", + }); + return this.provisioner.askBridgePermission(channel, event.sender); + }).then(() => { + return this.provisioner.bridgeMatrixRoom(channel, event.room_id); + }).then(() => { + return this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "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 this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: err.message, + }); + } + + log.error("MatrixRoomHandler", `Error bridging ${event.room_id} to ${guildId}/${channelId}`); + log.error("MatrixRoomHandler", err); + this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "There was a problem bridging that channel - has the guild owner approved the bridge?", + }); + }); + } else if (command === "unbridge") { + const remoteRoom = context.rooms.remote; + + if (!remoteRoom) { + return this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "This room is not bridged.", + }); + } + + if (!remoteRoom.data.plumbed) { + return this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "This room cannot be unbridged.", + }); + } + + this.provisioner.unbridgeRoom(remoteRoom).then(() => { + this.bridge.getIntent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "This room has been unbridged", + }); + }).catch((err) => { + log.error("MatrixRoomHandler", "Error while unbridging room " + event.room_id); + log.error("MatrixRoomHandler", err); + this.bridge.getItent().sendMessage(event.room_id, { + msgtype: "m.notice", + body: "There was an error unbridging this room. Please try again later or contact the bridge operator.", + }); + }); + } 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, { + msgtype: "m.notice", + 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", + }); + } + } + public OnAliasQuery (alias: string, aliasLocalpart: string): Promise<any> { log.info("MatrixRoomHandler", "Got request for #", aliasLocalpart); const srvChanPair = aliasLocalpart.substr("_discord_".length).split("_", ROOM_NAME_PARTS); @@ -175,6 +342,25 @@ export class MatrixRoomHandler { return Promise.reject({err: "Unsupported", code: HTTP_UNSUPPORTED}); } + private joinRoom(intent: any, roomIdOrAlias: string): Promise<string> { + let currentSchedule = JOIN_ROOM_SCHEDULE[0]; + const doJoin = () => Util.DelayedPromise(currentSchedule).then(() => intent.getClient().joinRoom(roomIdOrAlias)); + const errorHandler = (err) => { + log.error("MatrixRoomHandler", `Error joining room ${roomIdOrAlias} as ${intent.getClient().getUserId()}`); + log.error("MatrixRoomHandler", err); + const idx = JOIN_ROOM_SCHEDULE.indexOf(currentSchedule); + if (idx === JOIN_ROOM_SCHEDULE.length - 1) { + log.warn("MatrixRoomHandler", `Cannot join ${roomIdOrAlias} as ${intent.getClient().getUserId()}`); + return Promise.reject(err); + } else { + currentSchedule = JOIN_ROOM_SCHEDULE[idx + 1]; + return doJoin().catch(errorHandler); + } + }; + + return doJoin().catch(errorHandler); + } + private createMatrixRoom (channel: Discord.TextChannel, alias: string) { const remote = new RemoteRoom(`discord_${channel.guild.id}_${channel.id}`); remote.set("discord_type", "text"); diff --git a/src/provisioner.ts b/src/provisioner.ts new file mode 100644 index 0000000000000000000000000000000000000000..849ae85e99d0464a3fa92a9c8c87b333cd42c61e --- /dev/null +++ b/src/provisioner.ts @@ -0,0 +1,87 @@ +import { + Bridge, + RemoteRoom, + MatrixRoom, +} from "matrix-appservice-bridge"; +import * as Discord from "discord.js"; +import { Permissions } from "discord.js"; + +const PERMISSION_REQUEST_TIMEOUT = 300000; // 5 minutes + +export class Provisioner { + + private bridge: Bridge; + private pendingRequests: { [channelId: string]: (approved: boolean) => void } = {}; // [channelId]: resolver fn + + public setBridge(bridge: Bridge): void { + this.bridge = bridge; + } + + public bridgeMatrixRoom(channel: Discord.TextChannel, roomId: string) { + const remote = new RemoteRoom(`discord_${channel.guild.id}_${channel.id}_bridged`); + remote.set("discord_type", "text"); + remote.set("discord_guild", channel.guild.id); + remote.set("discord_channel", channel.id); + remote.set("plumbed", true); + + const local = new MatrixRoom(roomId); + this.bridge.getRoomStore().linkRooms(local, remote); + this.bridge.getRoomStore().setMatrixRoom(local); // Needs to be done after linking + } + + public unbridgeRoom(remoteRoom: RemoteRoom) { + return this.bridge.getRoomStore().removeEntriesByRemoteRoomId(remoteRoom); + } + + public askBridgePermission(channel: Discord.TextChannel, requestor: string): Promise<any> { + return new Promise((resolve, reject) => { + const channelId = channel.guild.id + "/" + channel.id; + + let responded = false; + const approveFn = (approved: boolean, expired = false) => { + if (responded) { + return; + } + + responded = true; + delete this.pendingRequests[channelId]; + if (approved) { + resolve(); + } else { + if (expired) { + reject(new Error("Timed out waiting for a response from the Discord owners")); + } else { + reject(new Error("The bridge has been declined by the Discord guild")); + } + } + }; + + this.pendingRequests[channelId] = approveFn; + setTimeout(() => approveFn(false, true), PERMISSION_REQUEST_TIMEOUT); + + channel.sendMessage(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"); + }); + } + + public hasPendingRequest(channel: Discord.TextChannel): boolean { + const channelId = channel.guild.id + "/" + channel.id; + return !!this.pendingRequests[channelId]; + } + + public markApproved(channel: Discord.TextChannel, member: Discord.GuildMember, allow: boolean): Promise<boolean> { + const channelId = channel.guild.id + "/" + channel.id; + if (!this.pendingRequests[channelId]) { + return Promise.resolve(false); // no change, so false + } + + const perms = channel.permissionsFor(member); + if (!perms.hasPermission(Permissions.FLAGS.MANAGE_WEBHOOKS)) { + // Missing permissions, so just reject it + return Promise.reject(new Error("You do not have permission to manage webhooks in this channel")); + } + + this.pendingRequests[channelId](allow); + return Promise.resolve(true); // replied, so true + } +} diff --git a/src/util.ts b/src/util.ts index 0b15c98f637081ee9cad0662a506223517ae806a..fb2a69a7b9c2acf5b06bc95ae31ba3ac42cbfb43 100644 --- a/src/util.ts +++ b/src/util.ts @@ -4,6 +4,7 @@ import { Intent } from "matrix-appservice-bridge"; import { Buffer } from "buffer"; import * as log from "npmlog"; import * as mime from "mime"; +import { Permissions } from "discord.js"; const HTTP_OK = 200; @@ -100,6 +101,35 @@ export class Util { throw reason; }); } + + /** + * Gets a promise that will resolve after the given number of milliseconds + * @param {number} duration The number of milliseconds to wait + * @returns {Promise<any>} The promise + */ + public static DelayedPromise(duration: number): Promise<any> { + return new Promise<any>((resolve, reject) => { + setTimeout(resolve, duration); + }); + } + + public static GetBotLink(config: any): string { + /* tslint:disable:no-bitwise */ + const perms = Permissions.FLAGS.READ_MESSAGES | + Permissions.FLAGS.SEND_MESSAGES | + Permissions.FLAGS.CHANGE_NICKNAME | + Permissions.FLAGS.CONNECT | + Permissions.FLAGS.SPEAK | + Permissions.FLAGS.EMBED_LINKS | + Permissions.FLAGS.ATTACH_FILES | + Permissions.FLAGS.READ_MESSAGE_HISTORY | + Permissions.FLAGS.MANAGE_WEBHOOKS; + /* tslint:enable:no-bitwise */ + + const clientId = config.auth.clientID; + + return `https://discordapp.com/api/oauth2/authorize?client_id=${clientId}&scope=bot&permissions=${perms}`; + } } interface IUploadResult { diff --git a/tools/addbot.ts b/tools/addbot.ts index 425b83e81025b045c287c54cf23e2a4bf04229d6..6339d848af149e19a8954918e64eb29b66c16664 100644 --- a/tools/addbot.ts +++ b/tools/addbot.ts @@ -4,24 +4,12 @@ */ import * as yaml from "js-yaml"; import * as fs from "fs"; -import { Permissions } from "discord.js"; +import { Util } from "../src/util"; -const flags = Permissions.FLAGS; const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8")); if (yamlConfig === null) { console.error("You have an error in your discord config."); } -const clientId = yamlConfig.auth.clientID; -const perms = flags.READ_MESSAGES | - flags.SEND_MESSAGES | - flags.CHANGE_NICKNAME | - flags.CONNECT | - flags.SPEAK | - flags.EMBED_LINKS | - flags.ATTACH_FILES | - flags.READ_MESSAGE_HISTORY | - flags.MANAGE_WEBHOOKS; - -const url = `https://discordapp.com/api/oauth2/authorize?client_id=${clientId}&scope=bot&permissions=${perms}`; +const url = Util.GetBotLink(yamlConfig); console.log(`Go to ${url} to invite the bot into a guild.`);