From 7cb74f5b76315819eeb7db6406afd7e2b630f31e Mon Sep 17 00:00:00 2001 From: Will Hunt <half-shot@molrams.com> Date: Wed, 22 Feb 2017 22:12:48 +0000 Subject: [PATCH] Add support for puppeting. Add doc explaining how puppeting works --- README.md | 2 +- docs/puppeting.md | 50 ++++++++++++ src/discordbot.ts | 153 +++++++++++++++++++++++------------- src/discordclientfactory.ts | 57 ++++++++++++++ src/matrixroomhandler.ts | 8 +- 5 files changed, 213 insertions(+), 57 deletions(-) create mode 100644 docs/puppeting.md create mode 100644 src/discordclientfactory.ts diff --git a/README.md b/README.md index ca64606..08a192b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ In a vague order of what is coming up next - [x] Rooms - [ ] Users - [ ] Puppet a user's real Discord account. - - [ ] Rooms react to Discord updates + - [x] Rooms react to Discord updates - [ ] Integrate Discord into existing rooms. - [ ] Manage channel from Matrix - [ ] Authorise admin rights from Discord to Matrix users diff --git a/docs/puppeting.md b/docs/puppeting.md new file mode 100644 index 0000000..4ca9062 --- /dev/null +++ b/docs/puppeting.md @@ -0,0 +1,50 @@ +# Puppeting + +This docs describes the method to puppet yourself with the bridge, so you can +interact with the bridge as if you were using the real Discord client. This +has the benefits of (not all of these may be implemented): + * Talking as yourself, rather than as the bot. + * DM channels + * Able to use your Discord permissions, as well as joining rooms limited to + your roles as on Discord. + +## Caveats & Disclaimer + +Discord is currently __not__ offering any way to authenticate on behalf +of a user _and_ interact on their behalf. The OAuth system does not allow +remote access beyond reading information about the users. While [developers have +expressed a wish for this](https://feedback.discordapp.com/forums/326712-discord-dream-land/suggestions/16753837-support-custom-clients) +,it is my opinion that Discord are unlikely to support this any time soon. With +all this said, Discord will not be banning users or the bridge itself for acting +on the behalf of the user. + +Therefore while I loathe to do it, we have to store login tokens for *full +permissions* on the user's account (excluding things such as changing passwords + and e-mail which require re-authenication, thankfully). + +The tokens will be stored by the bridge and are valid until the user +changes their password, so please be careful not to give the token to anything +that you wouldn't trust with your password. + +I accept no responsibility if Discord ban your IP, Account or even your details on +their system. They have never given official support on custom clients (and + by extension, puppeting bridges). If you are in any doubt, stick to the + bot which is within the rules. + +## How to Puppet an Account +*2FA does not work with bridging, please do not try it.* + +* Go to [Discord](https://discordapp.com/channels/@me) on your *browser* and log + in if you haven't. +* Open the developer console (On Firefox/Chrome this is Shift+Control+C) +* Click Storage or Application if it is not already selected. +* On the left hand side there will be an option for "Local Storage", find this + and expand it and then click on the Discord option. +* Find the option for token on the right hand side and copy the value, excluding + the `"`s +* ~~Start a conversation with ``@_discord_bot:yourdomain`` on Matrix and send + the message "account.link Your_Token"~~ +* ~~The bridge should reply once it's managed to log you in.~~ +* Bot control has not been implemented yet, for now you will need to edit the + database and fill in user_tokens with your userId and token. +* Congratulations, you are now puppeted. diff --git a/src/discordbot.ts b/src/discordbot.ts index 7d63d8c..5c1b235 100644 --- a/src/discordbot.ts +++ b/src/discordbot.ts @@ -1,19 +1,36 @@ import { DiscordBridgeConfig } from "./config"; -import * as Discord from "discord.js"; -import * as log from "npmlog"; +import { DiscordClientFactory } from "./discordclientfactory"; +import { DiscordStore } from "./discordstore"; import { MatrixUser, RemoteUser, Bridge, RemoteRoom } from "matrix-appservice-bridge"; import { Util } from "./util"; +import * as Discord from "discord.js"; +import * as log from "npmlog"; import * as Bluebird from "bluebird"; import * as mime from "mime"; import * as marked from "marked"; +// Due to messages often arriving before we get a response from the send call, +// messages get delayed from discord. +const MSG_PROCESS_DELAY = 750; + +class ChannelLookupResult { + public channel: Discord.TextChannel; + public botUser: boolean; +} + export class DiscordBot { private config: DiscordBridgeConfig; + private clientFactory: DiscordClientFactory; + private store: DiscordStore; private bot: Discord.Client; private discordUser: Discord.ClientUser; private bridge: Bridge; - constructor(config: DiscordBridgeConfig) { + private sentMessages: string[]; + constructor(config: DiscordBridgeConfig, store: DiscordStore) { this.config = config; + this.store = store; + this.sentMessages = []; + this.clientFactory = new DiscordClientFactory(config.auth, store); } public setBridge(bridge: Bridge) { @@ -21,21 +38,21 @@ export class DiscordBot { } public run (): Promise<null> { - this.bot = Bluebird.promisifyAll(new Discord.Client()); - this.bot.on("typingStart", (c, u) => { this.OnTyping(c, u, true); }); - this.bot.on("typingStop", (c, u) => { this.OnTyping(c, u, false); }); - this.bot.on("userUpdate", (_, newUser) => { this.UpdateUser(newUser); }); - this.bot.on("channelUpdate", (_, newChannel) => { this.UpdateRoom(<Discord.TextChannel> newChannel); }); - this.bot.on("presenceUpdate", (_, newMember) => { this.UpdatePresence(newMember); }); - this.bot.on("message", this.OnMessage.bind(this)); - const promise = (this.bot as any).onAsync("ready"); - this.bot.login(this.config.auth.botToken); - - return promise; - } - - public GetBot (): Discord.Client { - return this.bot; + return this.clientFactory.init().then(() => { + return this.clientFactory.getClient(); + }).then((client: any) => { + client.on("typingStart", (c, u) => { this.OnTyping(c, u, true); }); + client.on("typingStop", (c, u) => { this.OnTyping(c, u, false); }); + client.on("userUpdate", (_, newUser) => { this.UpdateUser(newUser); }); + client.on("channelUpdate", (_, newChannel) => { this.UpdateRoom(<Discord.TextChannel> newChannel); }); + client.on("presenceUpdate", (_, newMember) => { this.UpdatePresence(newMember); }); + client.on("message", (msg) => { Bluebird.delay(MSG_PROCESS_DELAY).then(() => { + this.OnMessage(msg); + }); + }); + this.bot = client; + return null; + }); } public GetGuilds(): Discord.Guild[] { @@ -67,47 +84,63 @@ export class DiscordBot { } } - public LookupRoom (server: string, room: string): Promise<Discord.TextChannel> { - const guild = this.bot.guilds.find((g) => { - return (g.id === server); - }); - if (!guild) { - return Promise.reject(`Guild "${server}" not found`); - } - - const channel = guild.channels.find((c) => { - return (c.id === room); - }); - - if (!channel) { + public LookupRoom (server: string, room: string, sender?: string): Promise<ChannelLookupResult> { + let hasSender = sender !== null; + return this.clientFactory.getClient(sender).then((client) => { + const guild = client.guilds.get(server); + if (!guild) { + return Promise.reject(`Guild "${server}" not found`); + } + const channel = guild.channels.get(room); + if (channel) { + const lookupResult = new ChannelLookupResult(); + lookupResult.channel = channel; + lookupResult.botUser = !hasSender; + return lookupResult; + } return Promise.reject(`Channel "${room}" not found`); - } - return Promise.resolve(channel); + }).catch((err) => { + log.verbose("DiscordBot", "LookupRoom => ", err); + if (hasSender) { + log.verbose("DiscordBot", `Couldn't find guild/channel under user account. Falling back.`); + return this.LookupRoom(server, room, null); + } + throw err; + }); } public ProcessMatrixMsgEvent(event, guildId: string, channelId: string): Promise<any> { let chan; let embed; + let botUser; const mxClient = this.bridge.getClientFactory().getClientAs(); - return this.LookupRoom(guildId, channelId).then((channel) => { - chan = channel; - return mxClient.getProfileInfo(event.sender); - }).then((profile) => { - if (!profile.displayname) { - profile.displayname = event.sender; + log.verbose("DiscordBot", `Looking up ${guildId}_${channelId}`); + return this.LookupRoom(guildId, channelId, event.sender).then((result) => { + log.verbose("DiscordBot", `Found channel! Looking up ${event.sender}`); + chan = result.channel; + botUser = result.botUser; + if (result.botUser) { + return mxClient.getProfileInfo(event.sender); } - if (profile.avatar_url) { - profile.avatar_url = mxClient.mxcUrlToHttp(profile.avatar_url); + return null; + }).then((profile) => { + if (botUser === true) { + if (!profile.displayname) { + profile.displayname = event.sender; + } + if (profile.avatar_url) { + profile.avatar_url = mxClient.mxcUrlToHttp(profile.avatar_url); + } + embed = new Discord.RichEmbed({ + author: { + name: profile.displayname, + icon_url: profile.avatar_url, + url: `https://matrix.to/#/${event.sender}`, + // TODO: Avatar + }, + description: event.content.body, + }); } - embed = new Discord.RichEmbed({ - author: { - name: profile.displayname, - icon_url: profile.avatar_url, - url: `https://matrix.to/#/${event.sender}`, - // TODO: Avatar - }, - description: event.content.body, - }); if (["m.image", "m.audio", "m.video", "m.file"].indexOf(event.content.msgtype) !== -1) { return Util.DownloadFile(mxClient.mxcUrlToHttp(event.content.url)); } @@ -123,7 +156,12 @@ export class DiscordBot { } return {}; }).then((opts) => { - chan.sendEmbed(embed, opts); + if (botUser) { + return chan.sendEmbed(embed, opts); + } + return chan.sendMessage(event.content.body, opts); + }).then((msg) => { + this.sentMessages.push(msg.id); }).catch((err) => { log.error("DiscordBot", "Couldn't send message. ", err); }); @@ -138,7 +176,7 @@ export class DiscordBot { discord_channel: channel.id, }).then((rooms) => { if (rooms.length === 0) { - log.warn("DiscordBot", `Got message but couldn"t find room chan id:${channel.id} for it.`); + log.verbose("DiscordBot", `Got message but couldn"t find room chan id:${channel.id} for it.`); return Promise.reject("Room not found."); } return rooms[0].matrix.getId(); @@ -240,12 +278,17 @@ export class DiscordBot { private OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { return this.GetRoomIdFromChannel(channel).then((room) => { const intent = this.bridge.getIntentFromLocalpart(`_discord_${user.id}`); - intent.sendTyping(room, isTyping); + return intent.sendTyping(room, isTyping); + }).catch((err) => { + log.verbose("DiscordBot", "Failed to send typing indicator.", err); }); } private OnMessage(msg: Discord.Message) { - if (msg.author.id === this.bot.user.id) { + const indexOfMsg = this.sentMessages.indexOf(msg.id); + if (indexOfMsg !== -1) { + log.verbose("DiscordBot", "Got repeated message, ignoring."); + delete this.sentMessages[indexOfMsg]; return; // Skip *our* messages } this.UpdateUser(msg.author).then(() => { @@ -293,6 +336,8 @@ export class DiscordBot { format: "org.matrix.custom.html", }); } + }).catch((err) => { + log.warn("DiscordBot", "Failed to send message into room.", err); }); } } diff --git a/src/discordclientfactory.ts b/src/discordclientfactory.ts new file mode 100644 index 0000000..0cc6a19 --- /dev/null +++ b/src/discordclientfactory.ts @@ -0,0 +1,57 @@ +import { DiscordBridgeConfigAuth } from "./config"; +import { DiscordStore } from "./discordstore"; +import { Client } from "discord.js"; +import * as log from "npmlog"; +import * as Bluebird from "bluebird"; + +export class DiscordClientFactory { + private config: DiscordBridgeConfigAuth; + private store: DiscordStore; + private botClient: any; + private clients: Map<string,any>; + constructor(config: DiscordBridgeConfigAuth, store: DiscordStore) { + this.config = config; + this.clients = new Map(); + this.store = store; + } + + public init(): Promise<null> { + // We just need to make sure we have a bearer token. + // Create a new Bot client. + this.botClient = Bluebird.promisifyAll(new Client({ + fetchAllMembers: true, + sync: true, + })); + return this.botClient.login(this.config.botToken).then(() => { + return null; // Strip token from promise. + }).catch((err) => { + log.error("ClientFactory", "Could not login as the bot user. This is bad!"); + }) + } + + public getClient(userId?: string): Promise<any> { + let client; + if (userId) { + if (this.clients.has(userId)) { + log.verbose("ClientFactory", "Returning cached user client."); + return Promise.resolve(this.clients.get(userId)); + } + return this.store.get_user_token(userId).then((token) => { + client = Bluebird.promisifyAll(new Client({ + fetchAllMembers: true, + sync: true, + })); + log.verbose("ClientFactory", "Got user token. Logging in..."); + return client.login(token); + }).then(() => { + log.verbose("ClientFactory", "Logged in. Storing ", userId); + this.clients.set(userId, client); + return Promise.resolve(client); + }).catch((err) => { + log.warn("ClientFactory", `Could not log ${userId} in.`, err); + }) + // Get from cache + } + return Promise.resolve(this.botClient); + } +} diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index 69219e3..a705064 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -8,6 +8,7 @@ import { thirdPartyLocationResult, } from "matrix-appservice-bridge"; import { DiscordBridgeConfig } from "./config"; +import { DiscordClientFactory } from "./discordclientfactory"; import * as Discord from "discord.js"; import * as log from "npmlog"; @@ -47,8 +48,11 @@ export class MatrixRoomHandler { public OnEvent (request, context) { const event = request.getData(); if (event.type === "m.room.message" && context.rooms.remote) { + log.verbose("MatrixRoomHandler", "Got m.room.message event"); let srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", 2); this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]); + } else { + log.verbose("MatrixRoomHandler", "Got non m.room.message event"); } } @@ -59,9 +63,9 @@ export class MatrixRoomHandler { log.warn("MatrixRoomHandler", `Alias '${aliasLocalpart}' was missing a server and/or a channel`); return; } - return this.discord.LookupRoom(srvChanPair[0], srvChanPair[1]).then((channel) => { + return this.discord.LookupRoom(srvChanPair[0], srvChanPair[1]).then((result) => { log.info("MatrixRoomHandler", "Creating #", aliasLocalpart); - return this.createMatrixRoom(channel, aliasLocalpart); + return this.createMatrixRoom(result.channel, aliasLocalpart); }).catch((err) => { log.error("MatrixRoomHandler", `Couldn't find discord room '${aliasLocalpart}'.`, err); }); -- GitLab