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