diff --git a/config/config.sample.yaml b/config/config.sample.yaml
index 10f6d2fb97e7f9ca24fff97f962683894245c11b..d000b2d3902c8bcc25326d68c1ac6426b613a4f1 100644
--- a/config/config.sample.yaml
+++ b/config/config.sample.yaml
@@ -31,6 +31,7 @@ bridge:
   disableJoinLeaveNotifications: false
 # Authentication configuration for the discord bot.
 auth:
+  # This MUST be a string (wrapped in quotes)
   clientID: "12345"
   botToken: "foobar"
 logging:
@@ -85,10 +86,12 @@ channel:
 limits:
     # Delay in milliseconds between discord users joining a room.
     roomGhostJoinDelay: 6000
-    # Delay in milliseconds before sending messages to discord to avoid echos.
-    # (Copies of a sent message may arrive from discord before we've
+    # Lock timeout in milliseconds before seinding messages to discord to avoid
+    # echos. Default is rather high as the lock will most likely time out
+    # before anyways.
+    # echos = (Copies of a sent message may arrive from discord before we've
     # fininished handling it, causing us to echo it back to the room)
-    discordSendDelay: 750
+    discordSendDelay: 1500
 ghosts:
     # Pattern for the ghosts nick, available is :nick, :username, :tag and :id
     nickPattern: ":nick"
diff --git a/docs/howto.md b/docs/howto.md
index cbdee791fc771018dbbf0481cb07da5d15c3357b..f43902f5b683228d426dabb541354ff0b4538d32 100644
--- a/docs/howto.md
+++ b/docs/howto.md
@@ -15,7 +15,7 @@ is formatted as https://discordapp.com/channels/``guildid``/``channelid``
 
 * The ``adminme`` script is provided to set Admin/Moderator or any other custom power level to a specific user.
 * e.g. To set Alice to Admin on her ``example.com`` HS on default config. (``config.yaml``)
-  * ``npm run adminme -- -r '!AbcdefghijklmnopqR:example.com' -u '@Alice:example.com' -p '100'``
+  * ``npm run adminme -- -m '!AbcdefghijklmnopqR:example.com' -u '@Alice:example.com' -p '100'``
   * Run ``npm run adminme -- -h`` for usage.
 
 Please note that `!AbcdefghijklmnopqR:example.com` is the internal room id and will always begin with `!`.
diff --git a/package-lock.json b/package-lock.json
index 5b4d459714685ae6345e3623bf35dd2cdd120b2e..739385918256357755dbf2bcc3febc36e1b1751c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "matrix-appservice-discord",
-  "version": "0.5.0-rc2",
+  "version": "0.5.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -164,7 +164,7 @@
     },
     "@types/events": {
       "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz",
+      "resolved": "http://registry.npmjs.org/@types/events/-/events-1.2.0.tgz",
       "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==",
       "dev": true
     },
@@ -182,7 +182,7 @@
     },
     "@types/sqlite3": {
       "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.3.tgz",
+      "resolved": "http://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.3.tgz",
       "integrity": "sha512-BgGToABnI/8/HnZtZz2Qac6DieU2Dm/j3rtbMmUlDVo4T/uLu8cuVfU/n2UkHowiiwXb6/7h/CmSqBIVKgcTMA==",
       "dev": true,
       "requires": {
@@ -212,7 +212,7 @@
     },
     "acorn-jsx": {
       "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz",
+      "resolved": "http://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz",
       "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=",
       "dev": true,
       "requires": {
@@ -221,7 +221,7 @@
       "dependencies": {
         "acorn": {
           "version": "3.3.0",
-          "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
+          "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
           "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=",
           "dev": true
         }
@@ -266,7 +266,7 @@
     },
     "ansi-escapes": {
       "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz",
+      "resolved": "http://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz",
       "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=",
       "dev": true
     },
@@ -367,7 +367,7 @@
     },
     "async": {
       "version": "0.2.10",
-      "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
+      "resolved": "http://registry.npmjs.org/async/-/async-0.2.10.tgz",
       "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E="
     },
     "async-limiter": {
@@ -602,7 +602,7 @@
     },
     "chai": {
       "version": "3.5.0",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz",
+      "resolved": "http://registry.npmjs.org/chai/-/chai-3.5.0.tgz",
       "integrity": "sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=",
       "dev": true,
       "requires": {
@@ -888,7 +888,7 @@
     },
     "d": {
       "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz",
+      "resolved": "http://registry.npmjs.org/d/-/d-1.0.0.tgz",
       "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=",
       "dev": true,
       "requires": {
@@ -919,7 +919,7 @@
     },
     "deep-eql": {
       "version": "0.1.3",
-      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz",
+      "resolved": "http://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz",
       "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=",
       "dev": true,
       "requires": {
@@ -1000,12 +1000,11 @@
       "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA=="
     },
     "discord-markdown": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/discord-markdown/-/discord-markdown-2.0.0.tgz",
-      "integrity": "sha512-uiDNjFrMjAFK50Q+z7qI4siF6U3XBx6gZ6GywjrQnoiBmWK5d/wJDoIT051bOD4xXBHuSENXsReF9zBvbCjDGQ==",
+      "version": "git://github.com/Sorunome/discord-markdown.git#5086d4ccb10a90bcdc7656606f5b3f5430bffee9",
+      "from": "git://github.com/Sorunome/discord-markdown.git#5086d4ccb10a90bcdc7656606f5b3f5430bffee9",
       "requires": {
         "highlight.js": "^9.13.1",
-        "simple-markdown": "^0.4.2"
+        "simple-markdown": "^0.4.4"
       }
     },
     "discord.js": {
@@ -1051,7 +1050,7 @@
     },
     "enabled": {
       "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz",
+      "resolved": "http://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz",
       "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=",
       "requires": {
         "env-variable": "0.0.x"
@@ -1528,7 +1527,7 @@
     },
     "fecha": {
       "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz",
+      "resolved": "http://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz",
       "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg=="
     },
     "figures": {
@@ -1571,7 +1570,7 @@
     },
     "finalhandler": {
       "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
+      "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
       "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==",
       "requires": {
         "debug": "2.6.9",
@@ -1835,9 +1834,9 @@
       "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
     },
     "highlight.js": {
-      "version": "9.13.1",
-      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.13.1.tgz",
-      "integrity": "sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A=="
+      "version": "9.15.8",
+      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.8.tgz",
+      "integrity": "sha512-RrapkKQWwE+wKdF73VsOa2RQdIoO3mxwJ4P8mhbI6KYJUraUHRKM5w5zQQKXNk0xNL4UVRdulV9SBJcmzJNzVA=="
     },
     "hosted-git-info": {
       "version": "2.7.1",
@@ -1908,7 +1907,7 @@
     },
     "inquirer": {
       "version": "0.12.0",
-      "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz",
+      "resolved": "http://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz",
       "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=",
       "dev": true,
       "requires": {
@@ -2104,7 +2103,7 @@
         },
         "async": {
           "version": "1.5.2",
-          "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
+          "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
           "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
           "dev": true
         },
@@ -2573,7 +2572,7 @@
     },
     "media-typer": {
       "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
       "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
     },
     "mem": {
@@ -2670,7 +2669,7 @@
     },
     "mkdirp": {
       "version": "0.5.1",
-      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+      "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
       "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
       "requires": {
         "minimist": "0.0.8"
@@ -2817,9 +2816,9 @@
       "dev": true
     },
     "node-html-parser": {
-      "version": "1.1.11",
-      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.1.11.tgz",
-      "integrity": "sha512-KOjvmbk0yWuy/cN8uqk6bVYS0Lue+jVWcLO/zmnCtz8FPXhj00apBN376FoM6QmFMMbJwXQdKf5ko6G1S6bnrw==",
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.1.15.tgz",
+      "integrity": "sha512-wFlLCOdUrnMTXrc/70dBg1G3YCunQZzsLXshaqQfGmT8Ffu6z9PeoaPIK5QvmDY/6yfBjOP1SDFASWq0ELBtDA==",
       "requires": {
         "he": "1.1.1"
       }
@@ -2964,7 +2963,7 @@
     },
     "onetime": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
+      "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
       "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=",
       "dev": true
     },
@@ -3230,7 +3229,7 @@
     },
     "pify": {
       "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+      "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
       "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
       "dev": true
     },
@@ -3305,7 +3304,7 @@
     },
     "progress": {
       "version": "1.1.8",
-      "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz",
+      "resolved": "http://registry.npmjs.org/progress/-/progress-1.1.8.tgz",
       "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=",
       "dev": true
     },
@@ -3702,7 +3701,7 @@
     },
     "slice-ansi": {
       "version": "0.0.4",
-      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz",
+      "resolved": "http://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz",
       "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=",
       "dev": true
     },
@@ -3863,7 +3862,7 @@
     },
     "strip-ansi": {
       "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+      "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
       "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
       "requires": {
         "ansi-regex": "^2.0.0"
@@ -3897,7 +3896,7 @@
     },
     "table": {
       "version": "3.8.3",
-      "resolved": "https://registry.npmjs.org/table/-/table-3.8.3.tgz",
+      "resolved": "http://registry.npmjs.org/table/-/table-3.8.3.tgz",
       "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=",
       "dev": true,
       "requires": {
@@ -4057,7 +4056,7 @@
     },
     "through": {
       "version": "2.3.8",
-      "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+      "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz",
       "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
     },
     "to-fast-properties": {
diff --git a/package.json b/package.json
index 60894a217ea7ef37461fb92a331d9ba5775a8a8d..8ad5a1e7ee9012cdc52cfcd82a1e913f794831f1 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "coverage": "tsc && nyc mocha",
     "build": "tsc",
     "start": "npm run-script build && node ./build/src/discordas.js -p 9005 -c config.yaml",
+    "debug": "npm run-script build && node --inspect ./build/src/discordas.js -p 9005 -c config.yaml",
     "addbot": "node ./build/tools/addbot.js",
     "adminme": "node ./build/tools/adminme.js",
     "usertool": "node ./build/tools/userClientTools.js",
@@ -37,14 +38,14 @@
     "better-sqlite3": "^5.0.1",
     "command-line-args": "^4.0.1",
     "command-line-usage": "^4.1.0",
-    "discord-markdown": "^2.0.0",
+    "discord-markdown": "git://github.com/Sorunome/discord-markdown#5086d4ccb10a90bcdc7656606f5b3f5430bffee9",
     "discord.js": "^11.5.1",
     "escape-html": "^1.0.3",
     "escape-string-regexp": "^1.0.5",
     "js-yaml": "^3.13.1",
     "matrix-appservice-bridge": "matrix-org/matrix-appservice-bridge#8a7288edf1d1d1d1395a83d330d836d9c9bf1e76",
     "mime": "^1.6.0",
-    "node-html-parser": "^1.1.11",
+    "node-html-parser": "^1.1.15",
     "p-queue": "^6.0.1",
     "pg-promise": "^8.5.1",
     "prom-client": "^11.3.0",
diff --git a/src/bot.ts b/src/bot.ts
index 7e4d5b5e5db9d2e49a77e7f1a40b009f4d16e17c..2cfc3c06d773c591be8d8778ecf1914d986437a8 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -45,6 +45,7 @@ import * as mime from "mime";
 import { IMatrixEvent, IMatrixMediaInfo } from "./matrixtypes";
 import { DiscordCommandHandler } from "./discordcommandhandler";
 import { MetricPeg } from "./metrics";
+import { Lock } from "./structures/lock";
 
 const log = new Log("DiscordBot");
 
@@ -56,6 +57,7 @@ const MATRIX_ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org
 class ChannelLookupResult {
     public channel: Discord.TextChannel;
     public botUser: boolean;
+    public canSendEmbeds: boolean;
 }
 
 interface IThirdPartyLookupField {
@@ -89,8 +91,7 @@ export class DiscordBot {
 
     /* Handles messages queued up to be sent to matrix from discord. */
     private discordMessageQueue: { [channelId: string]: Promise<void> };
-    private channelLocks: Map<string, {i: NodeJS.Timeout|null, r: (() => void)|null}>;
-    private channelLockPromises: Map<string, Promise<{}>>;
+    private channelLock: Lock<string>;
     constructor(
         private botUserId: string,
         private config: DiscordBridgeConfig,
@@ -114,8 +115,7 @@ export class DiscordBot {
         // init vars
         this.sentMessages = [];
         this.discordMessageQueue = {};
-        this.channelLocks = new Map();
-        this.channelLockPromises = new Map();
+        this.channelLock = new Lock(this.config.limits.discordSendDelay);
         this.lastEventIds = {};
     }
 
@@ -147,43 +147,6 @@ export class DiscordBot {
         return this.provisioner;
     }
 
-    public lockChannel(channel: Discord.Channel) {
-        if (this.channelLocks.has(channel.id)) {
-            return;
-        }
-
-        this.channelLocks[channel.id] = {i: null, r: null, p: null};
-        const p = new Promise<{}>((resolve) => {
-            const i = setTimeout(() => {
-                log.warn(`Lock on channel ${channel.id} expired. Discord is lagging behind?`);
-                this.unlockChannel(channel);
-            }, this.config.limits.discordSendDelay);
-            const o = Object.assign({r: resolve, i, p: null}, this.channelLocks.get(channel.id) || {});
-            this.channelLocks.set(channel.id, o);
-        });
-        this.channelLockPromises.set(channel.id, p);
-    }
-
-    public unlockChannel(channel: Discord.Channel) {
-        if (!this.channelLocks.has(channel.id)) {
-            return;
-        }
-        const lock = this.channelLocks.get(channel.id)!;
-        if (lock.i !== null) {
-            lock.r!();
-            clearTimeout(lock.i);
-        }
-        this.channelLocks.delete(channel.id);
-        this.channelLockPromises.delete(channel.id);
-    }
-
-    public async waitUnlock(channel: Discord.Channel) {
-        const promise = this.channelLockPromises.get(channel.id);
-        if (promise) {
-            await promise;
-        }
-    }
-
     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_
@@ -247,7 +210,7 @@ export class DiscordBot {
 
         client.on("messageDelete", async (msg: Discord.Message) => {
             try {
-                await this.waitUnlock(msg.channel);
+                await this.channelLock.wait(msg.channel.id);
                 this.clientFactory.bindMetricsToChannel(msg.channel as Discord.TextChannel);
                 this.discordMessageQueue[msg.channel.id] = (async () => {
                     await (this.discordMessageQueue[msg.channel.id] || Promise.resolve());
@@ -268,7 +231,7 @@ export class DiscordBot {
                 msgs.forEach((msg) => {
                     promiseArr.push(async () => {
                         try {
-                            await this.waitUnlock(msg.channel);
+                            await this.channelLock.wait(msg.channel.id);
                             this.clientFactory.bindMetricsToChannel(msg.channel as Discord.TextChannel);
                             await this.DeleteDiscordMessage(msg);
                         } catch (err) {
@@ -283,7 +246,7 @@ export class DiscordBot {
         });
         client.on("messageUpdate", async (oldMessage: Discord.Message, newMessage: Discord.Message) => {
             try {
-                await this.waitUnlock(newMessage.channel);
+                await this.channelLock.wait(newMessage.channel.id);
                 this.clientFactory.bindMetricsToChannel(newMessage.channel as Discord.TextChannel);
                 this.discordMessageQueue[newMessage.channel.id] = (async () => {
                     await (this.discordMessageQueue[newMessage.channel.id] || Promise.resolve());
@@ -300,7 +263,7 @@ export class DiscordBot {
         client.on("message", async (msg: Discord.Message) => {
             try {
                 MetricPeg.get.registerRequest(msg.id);
-                await this.waitUnlock(msg.channel);
+                await this.channelLock.wait(msg.channel.id);
                 this.clientFactory.bindMetricsToChannel(msg.channel as Discord.TextChannel);
                 this.discordMessageQueue[msg.channel.id] = (async () => {
                     await (this.discordMessageQueue[msg.channel.id] || Promise.resolve());
@@ -407,6 +370,7 @@ export class DiscordBot {
                 const lookupResult = new ChannelLookupResult();
                 lookupResult.channel = channel as Discord.TextChannel;
                 lookupResult.botUser = this.bot.user.id === client.user.id;
+                lookupResult.canSendEmbeds = client.user.bot; // only bots can send embeds
                 return lookupResult;
             }
             throw new Error(`Channel "${room}" not found`);
@@ -424,10 +388,10 @@ export class DiscordBot {
         if (!msg) {
             return;
         }
-        this.lockChannel(channel);
+        this.channelLock.set(channel.id);
         const res = await channel.send(msg);
         await this.StoreMessagesSent(res, channel, event);
-        this.unlockChannel(channel);
+        this.channelLock.release(channel.id);
     }
 
     /**
@@ -462,19 +426,56 @@ export class DiscordBot {
             }
         }
         try {
-            this.lockChannel(chan);
-            if (!botUser) {
-                // NOTE: Don't send replies to discord if we are a puppet.
+            this.channelLock.set(chan.id);
+            if (!roomLookup.canSendEmbeds) {
+                // NOTE: Don't send replies to discord if we are a puppet user.
+                let addText = "";
+                if (embedSet.replyEmbed) {
+                    for (const line of embedSet.replyEmbed.description!.split("\n")) {
+                        addText += "\n> " + line;
+                    }
+                }
+                msg = await chan.send(embed.description + addText, opts);
+            } else if (!botUser) {
+                if (embedSet.imageEmbed || embedSet.replyEmbed) {
+                    let sendEmbed = new Discord.RichEmbed();
+                    if (embedSet.imageEmbed) {
+                        if (!embedSet.replyEmbed) {
+                            sendEmbed = embedSet.imageEmbed;
+                        } else {
+                            sendEmbed.setImage(embedSet.imageEmbed.image!.url);
+                        }
+                    }
+                    if (embedSet.replyEmbed) {
+                        if (!embedSet.imageEmbed) {
+                            sendEmbed = embedSet.replyEmbed;
+                        } else {
+                            sendEmbed.addField("Replying to", embedSet.replyEmbed!.author!.name);
+                            sendEmbed.addField("Reply text", embedSet.replyEmbed.description);
+                        }
+                    }
+                    opts.embed = sendEmbed;
+                }
                 msg = await chan.send(embed.description, opts);
             } else if (hook) {
                 MetricPeg.get.remoteCall("hook.send");
+                const embeds: Discord.RichEmbed[] = [];
+                if (embedSet.imageEmbed) {
+                    embeds.push(embedSet.imageEmbed);
+                }
+                if (embedSet.replyEmbed) {
+                    embeds.push(embedSet.replyEmbed);
+                }
                 msg = await hook.send(embed.description, {
                     avatarURL: embed!.author!.icon_url,
-                    embeds: embedSet.replyEmbed ? [embedSet.replyEmbed] : undefined,
+                    embeds,
                     files: opts.file ? [opts.file] : undefined,
                     username: embed!.author!.name,
                 } as Discord.WebhookMessageOptions);
             } else {
+                if (embedSet.imageEmbed) {
+                    embed.setImage(embedSet.imageEmbed.image!.url);
+                }
                 if (embedSet.replyEmbed) {
                     embed.addField("Replying to", embedSet.replyEmbed!.author!.name);
                     embed.addField("Reply text", embedSet.replyEmbed.description);
@@ -482,8 +483,13 @@ export class DiscordBot {
                 opts.embed = embed;
                 msg = await chan.send("", opts);
             }
-            await this.StoreMessagesSent(msg, chan, event);
-            this.unlockChannel(chan);
+            // Don't block on this.
+            this.StoreMessagesSent(msg, chan, event).then(() => {
+                this.channelLock.release(chan.id);
+            }).catch(() => {
+                log.warn("Failed to store sent message for ", event.event_id);
+            });
+
         } catch (err) {
             throw wrapError(err, Unstable.ForeignNetworkError, "Couldn't send message");
         }
@@ -510,9 +516,9 @@ export class DiscordBot {
 
             const msg = await chan.fetchMessage(storeEvent.DiscordId);
             try {
-                this.lockChannel(msg.channel);
+                this.channelLock.set(msg.channel.id);
                 await msg.delete();
-                this.unlockChannel(msg.channel);
+                this.channelLock.release(msg.channel.id);
                 log.info(`Deleted message`);
             } catch (ex) {
                 log.warn(`Failed to delete message`, ex);
@@ -664,12 +670,12 @@ export class DiscordBot {
                   /* tslint:disable-next-line no-any */
               } as any, // XXX: Discord.js typings are wrong.
                 `Unbanned.`);
-            this.lockChannel(botChannel);
+            this.channelLock.set(botChannel.id);
             res = await botChannel.send(
                 `${kickee} was unbanned from this channel by ${kicker}.`,
             ) as Discord.Message;
             this.sentMessages.push(res.id);
-            this.unlockChannel(botChannel);
+            this.channelLock.release(botChannel.id);
             return;
         }
         const existingPerms = tchan.memberPermissions(kickee);
@@ -678,13 +684,13 @@ export class DiscordBot {
             return;
         }
         const word = `${kickban === "ban" ? "banned" : "kicked"}`;
-        this.lockChannel(botChannel);
+        this.channelLock.set(botChannel.id);
         res = await botChannel.send(
             `${kickee} was ${word} from this channel by ${kicker}.`
             + (reason ? ` Reason: ${reason}` : ""),
         ) as Discord.Message;
         this.sentMessages.push(res.id);
-        this.unlockChannel(botChannel);
+        this.channelLock.release(botChannel.id);
         log.info(`${word} ${kickee}`);
 
         await tchan.overwritePermissions(kickee,
@@ -787,7 +793,7 @@ export class DiscordBot {
         }
 
         // Update presence because sometimes discord misses people.
-        await this.userSync.OnUpdateUser(msg.author, msg.webhookID);
+        await this.userSync.OnUpdateUser(msg.author, Boolean(msg.webhookID));
         let rooms;
         try {
             rooms = await this.channelSync.GetRoomIdsFromChannel(msg.channel);
@@ -871,10 +877,10 @@ export class DiscordBot {
                         log.error("Failed to send message into room.", e);
                         return;
                     }
-                    if (msg.member) {
+                    if (msg.member && !msg.webhookID) {
                         await this.userSync.JoinRoom(msg.member, room);
                     } else {
-                        await this.userSync.JoinRoom(msg.author, room, msg.webhookID);
+                        await this.userSync.JoinRoom(msg.author, room, Boolean(msg.webhookID));
                     }
                     res = await trySend();
                     await afterSend(res);
diff --git a/src/config.ts b/src/config.ts
index 2da5b7734e721eca3d77126750cf16957349deb4..9d966b544b8f4dee85f80121e30da15c1bec477b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -133,7 +133,7 @@ export class DiscordBridgeConfigChannelDeleteOptions {
 
 class DiscordBridgeConfigLimits {
     public roomGhostJoinDelay: number = 6000;
-    public discordSendDelay: number = 750;
+    public discordSendDelay: number = 1500;
 }
 
 export class LoggingFile {
diff --git a/src/db/roomstore.ts b/src/db/roomstore.ts
index 2cb9d9f0747244c069a3538a9480d966601233f8..ed311a8ca450258f1aeb83fd2d7fb33ba37cc293 100644
--- a/src/db/roomstore.ts
+++ b/src/db/roomstore.ts
@@ -18,8 +18,8 @@ import { IDatabaseConnector } from "./connector";
 import { Util } from "../util";
 
 import * as uuid from "uuid/v4";
-import { Postgres } from "./postgres";
 import { MetricPeg } from "../metrics";
+import { TimedCache } from "../structures/timedcache";
 
 const log = new Log("DbRoomStore");
 
@@ -93,9 +93,9 @@ const ENTRY_CACHE_LIMETIME = 30000;
 // XXX: This only implements functions used in the bridge at the moment.
 export class DbRoomStore {
 
-    private entriesMatrixIdCache: Map<string, {e: IRoomStoreEntry[], ts: number}>;
+    private entriesMatrixIdCache: TimedCache<string, IRoomStoreEntry[]>;
     constructor(private db: IDatabaseConnector) {
-        this.entriesMatrixIdCache = new Map();
+        this.entriesMatrixIdCache = new TimedCache(ENTRY_CACHE_LIMETIME);
     }
 
     public async upsertEntry(entry: IRoomStoreEntry) {
@@ -155,9 +155,9 @@ export class DbRoomStore {
 
     public async getEntriesByMatrixId(matrixId: string): Promise<IRoomStoreEntry[]> {
         const cached = this.entriesMatrixIdCache.get(matrixId);
-        if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) {
+        if (cached) {
             MetricPeg.get.storeCall("RoomStore.getEntriesByMatrixId", true);
-            return cached.e;
+            return cached;
         }
         MetricPeg.get.storeCall("RoomStore.getEntriesByMatrixId", false);
         const entries = await this.db.All(
@@ -187,7 +187,7 @@ export class DbRoomStore {
             }
         }
         if (res.length > 0) {
-            this.entriesMatrixIdCache.set(matrixId, {e: res, ts: Date.now()});
+            this.entriesMatrixIdCache.set(matrixId, res);
         }
         return res;
     }
diff --git a/src/db/userstore.ts b/src/db/userstore.ts
index 0f307d6158e527ea79dde544fd9dea8e2771add6..7e84dc0a84faf4767a188349e11236ef34586d9c 100644
--- a/src/db/userstore.ts
+++ b/src/db/userstore.ts
@@ -17,6 +17,7 @@ limitations under the License.
 import { IDatabaseConnector } from "./connector";
 import { Log } from "../log";
 import { MetricPeg } from "../metrics";
+import { TimedCache } from "../structures/timedcache";
 
 /**
  * A UserStore compatible with
@@ -45,17 +46,17 @@ export interface IUserStoreEntry {
 }
 
 export class DbUserStore {
-    private remoteUserCache: Map<string, {e: RemoteUser, ts: number}>;
+    private remoteUserCache: TimedCache<string, RemoteUser>;
 
     constructor(private db: IDatabaseConnector) {
-        this.remoteUserCache = new Map();
+        this.remoteUserCache = new TimedCache(ENTRY_CACHE_LIMETIME);
     }
 
     public async getRemoteUser(remoteId: string): Promise<RemoteUser|null> {
         const cached = this.remoteUserCache.get(remoteId);
-        if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) {
+        if (cached) {
             MetricPeg.get.storeCall("UserStore.getRemoteUser", true);
-            return cached.e;
+            return cached;
         }
         MetricPeg.get.storeCall("UserStore.getRemoteUser", false);
 
@@ -84,7 +85,7 @@ export class DbUserStore {
                 remoteUser.guildNicks.set(guild_id as string, nick as string);
             });
         }
-        this.remoteUserCache.set(remoteId, {e: remoteUser, ts: Date.now()});
+        this.remoteUserCache.set(remoteId, remoteUser);
         return remoteUser;
     }
 
diff --git a/src/discordmessageprocessor.ts b/src/discordmessageprocessor.ts
index 8e9dd555e7004de1bc79192ebdd165b0ef094573..f5da03b52eec659b652b5471ee5c86288995dbe6 100644
--- a/src/discordmessageprocessor.ts
+++ b/src/discordmessageprocessor.ts
@@ -50,6 +50,10 @@ export class DiscordMessageProcessorResult {
     public msgtype: string;
 }
 
+interface ISpoilerNode {
+    content: string;
+}
+
 interface IDiscordNode {
     id: string;
 }
@@ -79,6 +83,7 @@ export class DiscordMessageProcessor {
         // as else it'll HTML escape the result of the discord syntax
         let contentPostmark = markdown.toHTML(content, {
             discordCallback: this.getDiscordParseCallbacksHTML(msg),
+            noExtraSpanTags: true,
         });
 
         // parse the plain text stuff
@@ -86,6 +91,7 @@ export class DiscordMessageProcessor {
             discordCallback: this.getDiscordParseCallbacks(msg),
             discordOnly: true,
             escapeHTML: false,
+            noExtraSpanTags: true,
         });
         content = this.InsertEmbeds(content, msg);
         content = await this.InsertMxcImages(content, msg);
@@ -145,6 +151,7 @@ export class DiscordMessageProcessor {
                     discordCallback: this.getDiscordParseCallbacks(msg),
                     discordOnly: true,
                     escapeHTML: false,
+                    noExtraSpanTags: true,
                 });
             }
             if (embed.fields) {
@@ -154,6 +161,7 @@ export class DiscordMessageProcessor {
                         discordCallback: this.getDiscordParseCallbacks(msg),
                         discordOnly: true,
                         escapeHTML: false,
+                        noExtraSpanTags: true,
                     });
                 }
             }
@@ -165,6 +173,7 @@ export class DiscordMessageProcessor {
                     discordCallback: this.getDiscordParseCallbacks(msg),
                     discordOnly: true,
                     escapeHTML: false,
+                    noExtraSpanTags: true,
                 });
             }
             content += embedContent;
@@ -192,6 +201,7 @@ export class DiscordMessageProcessor {
                 embedContent += markdown.toHTML(embed.description, {
                     discordCallback: this.getDiscordParseCallbacksHTML(msg),
                     embed: true,
+                    noExtraSpanTags: true,
                 }) + "</p>";
             }
             if (embed.fields) {
@@ -200,6 +210,7 @@ export class DiscordMessageProcessor {
                     embedContent += markdown.toHTML(field.value, {
                         discordCallback: this.getDiscordParseCallbacks(msg),
                         embed: true,
+                        noExtraSpanTags: true,
                     }) + "</p>";
                 }
             }
@@ -212,6 +223,7 @@ export class DiscordMessageProcessor {
                 embedContent += markdown.toHTML(embed.footer.text, {
                     discordCallback: this.getDiscordParseCallbacksHTML(msg),
                     embed: true,
+                    noExtraSpanTags: true,
                 }) + "</p>";
             }
             content += embedContent;
@@ -230,6 +242,15 @@ export class DiscordMessageProcessor {
         return `<a href="${MATRIX_TO_LINK}${escapeHtml(memberId)}">${escapeHtml(memberName)}</a>`;
     }
 
+    public InsertSpoiler(node: ISpoilerNode, html: boolean = false): string {
+        // matrix spoilers are still in MSC stage
+        // see https://github.com/matrix-org/matrix-doc/pull/2010
+        if (!html) {
+            return `(Spoiler: ${node.content})`;
+        }
+        return `<span data-mx-spoiler>${node.content}</span>`;
+    }
+
     public InsertChannel(node: IDiscordNode): string {
         // unfortunately these callbacks are sync, so we flag our channel with some special stuff
         // and later on grab the real channel pill async
@@ -333,6 +354,7 @@ export class DiscordMessageProcessor {
             everyone: (_) => this.InsertRoom(msg, "@everyone"),
             here: (_) => this.InsertRoom(msg, "@here"),
             role: (node) => this.InsertRole(node, msg),
+            spoiler: (node) => this.InsertSpoiler(node),
             user: (node) => this.InsertUser(node, msg),
         };
     }
@@ -344,6 +366,7 @@ export class DiscordMessageProcessor {
             everyone: (_) => this.InsertRoom(msg, "@everyone"),
             here: (_) => this.InsertRoom(msg, "@here"),
             role: (node) => this.InsertRole(node, msg, true),
+            spoiler: (node) => this.InsertSpoiler(node, true),
             user: (node) => this.InsertUser(node, msg, true),
         };
     }
diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts
index 025d353f06c81b62bc7ef9872af0e6ea3c1c9bb2..5c9efc3816eb4f1c30b8cc3987640e6189d7c0e3 100644
--- a/src/matrixeventprocessor.ts
+++ b/src/matrixeventprocessor.ts
@@ -35,6 +35,8 @@ import { MatrixMessageProcessor, IMatrixMessageProcessorParams } from "./matrixm
 import { MatrixCommandHandler } from "./matrixcommandhandler";
 
 import { Log } from "./log";
+import { TimedCache } from "./structures/timedcache";
+import { MetricPeg } from "./metrics";
 const log = new Log("MatrixEventProcessor");
 
 const MaxFileSize = 8000000;
@@ -44,6 +46,7 @@ const DISCORD_AVATAR_WIDTH = 128;
 const DISCORD_AVATAR_HEIGHT = 128;
 const ROOM_NAME_PARTS = 2;
 const AGE_LIMIT = 900000; // 15 * 60 * 1000
+const PROFILE_CACHE_LIFETIME = 900000;
 
 export class MatrixEventProcessorOpts {
     constructor(
@@ -58,6 +61,7 @@ export class MatrixEventProcessorOpts {
 export interface IMatrixEventProcessorResult {
     messageEmbed: Discord.RichEmbed;
     replyEmbed?: Discord.RichEmbed;
+    imageEmbed?: Discord.RichEmbed;
 }
 
 export class MatrixEventProcessor {
@@ -66,12 +70,14 @@ export class MatrixEventProcessor {
     private discord: DiscordBot;
     private matrixMsgProcessor: MatrixMessageProcessor;
     private mxCommandHandler: MatrixCommandHandler;
+    private mxUserProfileCache: TimedCache<string, {displayname: string, avatar_url: string|undefined}>;
 
     constructor(opts: MatrixEventProcessorOpts, cm?: MatrixCommandHandler) {
         this.config = opts.config;
         this.bridge = opts.bridge;
         this.discord = opts.discord;
         this.matrixMsgProcessor = new MatrixMessageProcessor(this.discord);
+        this.mxUserProfileCache = new TimedCache(PROFILE_CACHE_LIFETIME);
         if (cm) {
             this.mxCommandHandler = cm;
         } else {
@@ -185,21 +191,24 @@ export class MatrixEventProcessor {
         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);
+        const file = await this.HandleAttachment(event, mxClient, roomLookup.canSendEmbeds);
         if (typeof(file) === "string") {
             embedSet.messageEmbed.description += " " + file;
+        } else if ((file as Discord.FileOptions).name && (file as Discord.FileOptions).attachment) {
+            opts.file = file as Discord.FileOptions;
         } else {
-            opts.file = file;
+            embedSet.imageEmbed = file as Discord.RichEmbed;
         }
 
         // Throws an `Unstable.ForeignNetworkError` when sending the message fails.
         await this.discord.send(embedSet, opts, roomLookup, event);
 
-        await this.sendReadReceipt(event);
+        this.sendReadReceipt(event).catch((ex) => {
+            log.verbose("Failed to send read reciept for ", event.event_id, ex);
+        });
     }
 
     public async ProcessStateEvent(event: IMatrixEvent) {
@@ -219,7 +228,6 @@ export class MatrixEventProcessor {
 
         let msg = `\`${event.sender}\` `;
 
-        const isNew = event.unsigned === undefined || event.unsigned.prev_content === undefined;
         const allowJoinLeave = !this.config.bridge.disableJoinLeaveNotifications;
 
         if (event.type === "m.room.name") {
@@ -228,7 +236,22 @@ export class MatrixEventProcessor {
             msg += `set the topic to \`${event.content!.topic}\``;
         } else if (event.type === "m.room.member") {
             const membership = event.content!.membership;
-            if (membership === "join" && isNew && allowJoinLeave) {
+            const intent = this.bridge.getIntent();
+            const isNewJoin = event.unsigned.replaces_state === undefined ? true : (
+                await intent.getEvent(event.room_id, event.unsigned.replaces_state)).content.membership !== "join";
+            if (membership === "join") {
+                this.mxUserProfileCache.delete(`${event.room_id}:${event.sender}`);
+                this.mxUserProfileCache.delete(event.sender);
+                if (event.content!.displayname) {
+                    this.mxUserProfileCache.set(`${event.room_id}:${event.sender}`, {
+                        avatar_url: event.content!.avatar_url,
+                        displayname: event.content!.displayname!,
+                    });
+                }
+                // We don't know if the user also updated their profile, but to be safe..
+                this.mxUserProfileCache.delete(event.sender);
+            }
+            if (membership === "join" && isNewJoin && allowJoinLeave) {
                 msg += "joined the room";
             } else if (membership === "invite") {
                 msg += `invited \`${event.state_key}\` to the room`;
@@ -253,19 +276,7 @@ export class MatrixEventProcessor {
         event: IMatrixEvent, channel: Discord.TextChannel, getReply: boolean = true,
     ): Promise<IMatrixEventProcessorResult> {
         const mxClient = this.bridge.getClientFactory().getClientAs();
-        let profile: IMatrixEvent | null = null;
-        try {
-            profile = await mxClient.getStateEvent(event.room_id, "m.room.member", event.sender);
-            if (!profile) {
-                profile = await mxClient.getProfileInfo(event.sender);
-            }
-            if (!profile) {
-                log.warn(`User ${event.sender} has no member state and no profile. That's odd.`);
-            }
-        } catch (err) {
-            log.warn(`Trying to fetch member state or profile for ${event.sender} failed`, err);
-        }
-
+        const profile = await this.GetUserProfileForRoom(event.room_id, event.sender);
         const params = {
             mxClient,
             roomId: event.room_id,
@@ -300,7 +311,11 @@ export class MatrixEventProcessor {
         };
     }
 
-    public async HandleAttachment(event: IMatrixEvent, mxClient: MatrixClient): Promise<string|Discord.FileOptions> {
+    public async HandleAttachment(
+        event: IMatrixEvent,
+        mxClient: MatrixClient,
+        sendEmbeds: boolean = false,
+    ): Promise<string|Discord.FileOptions|Discord.RichEmbed> {
         if (!this.HasAttachment(event)) {
             return "";
         }
@@ -312,7 +327,7 @@ export class MatrixEventProcessor {
         if (!event.content.info) {
             // Fractal sends images without an info, which is technically allowed
             // but super unhelpful:  https://gitlab.gnome.org/World/fractal/issues/206
-            event.content.info = {size: 0};
+            event.content.info = {mimetype: "", size: 0};
         }
 
         if (!event.content.url) {
@@ -333,6 +348,10 @@ export class MatrixEventProcessor {
                 } as Discord.FileOptions;
             }
         }
+        if (sendEmbeds && event.content.info.mimetype.split("/")[0] === "image") {
+            return new Discord.RichEmbed()
+                .setImage(url);
+        }
         return `[${name}](${url})`;
     }
 
@@ -390,6 +409,45 @@ export class MatrixEventProcessor {
         return embed;
     }
 
+    private async GetUserProfileForRoom(roomId: string, userId: string) {
+        const mxClient = this.bridge.getClientFactory().getClientAs();
+        const intent = this.bridge.getIntent();
+        let profile: {displayname: string, avatar_url: string|undefined} | undefined;
+        try {
+            // First try to pull out the room-specific profile from the cache.
+            profile = this.mxUserProfileCache.get(`${roomId}:${userId}`);
+            if (profile) {
+                return profile;
+            }
+            log.verbose(`Profile ${userId}:${roomId} not cached`);
+
+            // Failing that, try fetching the state.
+            profile = await mxClient.getStateEvent(roomId, "m.room.member", userId);
+            if (profile) {
+                this.mxUserProfileCache.set(`${roomId}:${userId}`, profile);
+                return profile;
+            }
+
+            // Try fetching the users profile from the cache
+            profile = this.mxUserProfileCache.get(userId);
+            if (profile) {
+                return profile;
+            }
+
+            // Failing that, try fetching the profile.
+            log.verbose(`Profile ${userId} not cached`);
+            profile = await intent.getProfileInfo(userId);
+            if (profile) {
+                this.mxUserProfileCache.set(userId, profile);
+                return profile;
+            }
+            log.warn(`User ${userId} has no member state and no profile. That's odd.`);
+        } catch (err) {
+            log.warn(`Trying to fetch member state or profile for ${userId} failed`, err);
+        }
+        return undefined;
+    }
+
     private async sendReadReceipt(event: IMatrixEvent) {
         if (!this.config.bridge.disableReadReceipts) {
             try {
@@ -417,8 +475,9 @@ export class MatrixEventProcessor {
         return hasAttachment;
     }
 
-    private async SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile?: IMatrixEvent | null) {
-        const intent = this.bridge.getIntent();
+    private async SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile?: {
+        displayname: string,
+        avatar_url: string|undefined }) {
         let displayName = sender;
         let avatarUrl;
 
@@ -441,13 +500,6 @@ export class MatrixEventProcessor {
             }
             // Let it fall through.
         }
-        if (!profile) {
-            try {
-                profile = await intent.getProfileInfo(sender);
-            } catch (ex) {
-                log.warn(`Failed to fetch profile for ${sender}`, ex);
-            }
-        }
 
         if (profile) {
             if (profile.displayname &&
@@ -469,13 +521,17 @@ export class MatrixEventProcessor {
     }
 
     private GetFilenameForMediaEvent(content: IMatrixEventContent): string {
+        let ext = "";
+        try {
+            ext = "." + mime.extension(content.info.mimetype);
+        } catch (err) { } // pass, we don't have an extension
         if (content.body) {
             if (path.extname(content.body) !== "") {
                 return content.body;
             }
-            return `${path.basename(content.body)}.${mime.extension(content.info.mimetype)}`;
+            return path.basename(content.body) + ext;
         }
-        return "matrix-media." + mime.extension(content.info.mimetype);
+        return "matrix-media" + ext;
     }
 }
 
diff --git a/src/matrixmessageprocessor.ts b/src/matrixmessageprocessor.ts
index ff95b4588d3c221e75970a6fb70d7d05ec6eb90c..abace672562091218214c9af8dba02e50150589f 100644
--- a/src/matrixmessageprocessor.ts
+++ b/src/matrixmessageprocessor.ts
@@ -243,6 +243,21 @@ export class MatrixMessageProcessor {
         return msg;
     }
 
+    private async parseSpanContent(node: Parser.HTMLElement): Promise<string> {
+        const content = await this.walkChildNodes(node);
+        const attrs = node.attributes;
+        // matrix spoilers are still in MSC stage
+        // see https://github.com/matrix-org/matrix-doc/pull/2010
+        if (attrs["data-mx-spoiler"] !== undefined) {
+            const spoilerReason = attrs["data-mx-spoiler"];
+            if (spoilerReason) {
+                return `(${spoilerReason})||${content}||`;
+            }
+            return `||${content}||`;
+        }
+        return content;
+    }
+
     private async parseUlContent(node: Parser.HTMLElement): Promise<string> {
         this.listDepth++;
         const entries = await this.arrayChildNodes(node, ["li"]);
@@ -350,6 +365,8 @@ export class MatrixMessageProcessor {
                 case "h6":
                     const level = parseInt(nodeHtml.tagName[1], 10);
                     return `**${"#".repeat(level)} ${await this.walkChildNodes(nodeHtml)}**\n`;
+                case "span":
+                    return await this.parseSpanContent(nodeHtml);
                 default:
                     return await this.walkChildNodes(nodeHtml);
             }
diff --git a/src/matrixtypes.ts b/src/matrixtypes.ts
index 7535b59a89063a02e1f1946321550287f483dd94..f08ae1301c571fdbc866e7aafff94eed21e8229f 100644
--- a/src/matrixtypes.ts
+++ b/src/matrixtypes.ts
@@ -23,6 +23,7 @@ export interface IMatrixEventContent {
     msgtype?: string;
     url?: string;
     displayname?: string;
+    avatar_url?: string;
     reason?: string;
     "m.relates_to"?: any; // tslint:disable-line no-any
 }
diff --git a/src/metrics.ts b/src/metrics.ts
index db1556f3482c7526ec613e7fac7fe9490ef02e06..fce2a989ccb28d8e96d33b2c44d9abdad79a750b 100644
--- a/src/metrics.ts
+++ b/src/metrics.ts
@@ -133,11 +133,10 @@ export class PrometheusBridgeMetrics implements IBridgeMetrics {
 
     public requestOutcome(id: string, isRemote: boolean, outcome: string) {
         const startTime = this.requestsInFlight.get(id);
-        this.requestsInFlight.delete(id);
         if (!startTime) {
-            log.verbose(`Got "requestOutcome" for ${id}, but this request was never started`);
             return;
         }
+        this.requestsInFlight.delete(id);
         const duration = Date.now() - startTime;
         (isRemote ? this.remoteRequest : this.matrixRequest).observe({outcome}, duration / 1000);
     }
diff --git a/src/store.ts b/src/store.ts
index 31764b28266f2f71ea5e5c1ba60a9cd91db88a4a..83551c14193752add4f339f3b3cef93393f52ab6 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -172,23 +172,30 @@ export class DiscordStore {
         }
     }
 
-    public async deleteUserToken(discordId: string): Promise<void> {
+    public async deleteUserToken(mxid: string): Promise<void> {
+        const res = await this.db.Get("SELECT * from user_id_discord_id WHERE user_id = $id", {
+            id: mxid,
+        });
+        if (!res) {
+            return;
+        }
+        const discordId = res.discord_id;
         log.silly("SQL", "deleteUserToken => ", discordId);
         try {
             await Promise.all([
                 this.db.Run(
                     `
-                    DELETE FROM user_id_discord_id WHERE discord_id = $id;
+                    DELETE FROM user_id_discord_id WHERE discord_id = $id
                     `
                 , {
-                    $id: discordId,
+                    id: discordId,
                 }),
                 this.db.Run(
                     `
-                    DELETE FROM discord_id_token WHERE discord_id = $id;
+                    DELETE FROM discord_id_token WHERE discord_id = $id
                     `
                 , {
-                    $id: discordId,
+                    id: discordId,
                 }),
             ]);
         } catch (err) {
diff --git a/src/structures/lock.ts b/src/structures/lock.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f400aab092a3a6f49381d9c47b43112d0500be9d
--- /dev/null
+++ b/src/structures/lock.ts
@@ -0,0 +1,60 @@
+export class Lock<T> {
+    private locks: Map<T, {i: NodeJS.Timeout|null, r: (() => void)|null}>;
+    private lockPromises: Map<T, Promise<{}>>;
+    constructor(
+        private timeout: number,
+    ) {
+        this.locks = new Map();
+        this.lockPromises = new Map();
+    }
+
+    public set(key: T) {
+        // if there is a lock set.....we don't set a second one ontop
+        if (this.locks.has(key)) {
+            return;
+        }
+
+        // set a dummy lock so that if we re-set again before releasing it won't do anthing
+        this.locks.set(key, {i: null, r: null});
+
+        const p = new Promise<{}>((resolve) => {
+            // first we check if the lock has the key....if not, e.g. if it
+            // got released too quickly, we still want to resolve our promise
+            if (!this.locks.has(key)) {
+                resolve();
+                return;
+            }
+            // create the interval that will release our promise after the timeout
+            const i = setTimeout(() => {
+                this.release(key);
+            }, this.timeout);
+            // aaand store to our lock
+            this.locks.set(key, {r: resolve, i});
+        });
+        this.lockPromises.set(key, p);
+    }
+
+    public release(key: T) {
+        // if there is nothing to release then there is nothing to release
+        if (!this.locks.has(key)) {
+            return;
+        }
+        const lock = this.locks.get(key)!;
+        if (lock.r !== null) {
+            lock.r();
+        }
+        if (lock.i !== null) {
+            clearTimeout(lock.i);
+        }
+        this.locks.delete(key);
+        this.lockPromises.delete(key);
+    }
+
+    public async wait(key: T) {
+        // we wait for a lock release only if a promise is present
+        const promise = this.lockPromises.get(key);
+        if (promise) {
+            await promise;
+        }
+    }
+}
diff --git a/src/structures/timedcache.ts b/src/structures/timedcache.ts
new file mode 100644
index 0000000000000000000000000000000000000000..93424af9d488d493439bd4294dc969297f79eeee
--- /dev/null
+++ b/src/structures/timedcache.ts
@@ -0,0 +1,106 @@
+interface ITimedValue<V> {
+    value: V;
+    ts: number;
+}
+
+export class TimedCache<K, V> implements Map<K, V> {
+    private readonly  map: Map<K, ITimedValue<V>>;
+
+    public constructor(private readonly liveFor: number) {
+        this.map = new Map();
+    }
+
+    public clear(): void {
+        this.map.clear();
+    }
+
+    public delete(key: K): boolean {
+        return this.map.delete(key);
+    }
+
+    public forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void|Promise<void>): void {
+        for (const item of this) {
+            callbackfn(item[1], item[0], this);
+        }
+    }
+
+    public get(key: K): V | undefined {
+        const v = this.map.get(key);
+        if (v === undefined) {
+            return;
+        }
+        const val = this.filterV(v);
+        if (val !== undefined) {
+            return val;
+        }
+        // Cleanup expired key
+        this.map.delete(key);
+    }
+
+    public has(key: K): boolean {
+        return this.get(key) !== undefined;
+    }
+
+    public set(key: K, value: V): this {
+        this.map.set(key, {
+            ts: Date.now(),
+            value,
+        });
+        return this;
+    }
+
+    public get size(): number {
+        return this.map.size;
+    }
+
+    public [Symbol.iterator](): IterableIterator<[K, V]> {
+        let iterator: IterableIterator<[K, ITimedValue<V>]>;
+        return {
+            next: () => {
+                if (!iterator) {
+                    iterator = this.map.entries();
+                }
+                let item: IteratorResult<[K, ITimedValue<V>]>|undefined;
+                let filteredValue: V|undefined;
+                // Loop if we have no item, or the item has expired.
+                while (!item || filteredValue === undefined) {
+                    item = iterator.next();
+                    // No more items in map. Bye bye.
+                    if (item.done) {
+                        break;
+                    }
+                    filteredValue = this.filterV(item.value[1]);
+                }
+                if (item.done) {
+                    // Typscript doesn't like us returning undefined for value, which is dumb.
+                    // tslint:disable-next-line: no-any
+                    return {done: true, value: undefined} as any as IteratorResult<[K, V]>;
+                }
+                return {done: false, value: [item.value[0], filteredValue]} as IteratorResult<[K, V]>;
+            },
+            [Symbol.iterator]: () => this[Symbol.iterator](),
+        };
+    }
+
+    public entries(): IterableIterator<[K, V]> {
+        return this[Symbol.iterator]();
+    }
+
+    public keys(): IterableIterator<K> {
+        throw new Error("Method not implemented.");
+    }
+
+    public values(): IterableIterator<V> {
+        throw new Error("Method not implemented.");
+    }
+
+    get [Symbol.toStringTag](): "Map" {
+        return "Map";
+    }
+
+    private filterV(v: ITimedValue<V>): V|undefined {
+        if (Date.now() - v.ts < this.liveFor) {
+            return v.value;
+        }
+    }
+}
diff --git a/src/usersyncroniser.ts b/src/usersyncroniser.ts
index f34dd2e887ba20cf324e4853973d6c2ae592432b..42bc083537b652da379606dc9174f559c5a70429 100644
--- a/src/usersyncroniser.ts
+++ b/src/usersyncroniser.ts
@@ -96,8 +96,8 @@ export class UserSyncroniser {
      * @returns {Promise<void>}
      * @constructor
      */
-    public async OnUpdateUser(discordUser: User, webhookID?: string) {
-        const userState = await this.GetUserUpdateState(discordUser, webhookID);
+    public async OnUpdateUser(discordUser: User, isWebhook: boolean = false) {
+        const userState = await this.GetUserUpdateState(discordUser, isWebhook);
         try {
             await this.ApplyStateToProfile(userState);
         } catch (e) {
@@ -157,10 +157,10 @@ export class UserSyncroniser {
         }
     }
 
-    public async JoinRoom(member: GuildMember | User, roomId: string, webhookID?: string) {
+    public async JoinRoom(member: GuildMember | User, roomId: string, isWebhook: boolean = false) {
         let state: IGuildMemberState;
         if (member instanceof User) {
-            state = await this.GetUserStateForDiscordUser(member, webhookID);
+            state = await this.GetUserStateForDiscordUser(member, isWebhook);
         } else {
             state = await this.GetUserStateForGuildMember(member);
         }
@@ -228,15 +228,18 @@ export class UserSyncroniser {
         }
     }
 
-    public async GetUserUpdateState(discordUser: User, webhookID?: string): Promise<IUserState> {
+    public async GetUserUpdateState(discordUser: User, isWebhook: boolean = false): Promise<IUserState> {
         log.verbose(`State update requested for ${discordUser.id}`);
         let mxidExtra = "";
-        if (webhookID) {
+        if (isWebhook) {
+            // for webhooks we append the username to the mxid, as webhooks with the same
+            // id can have multiple different usernames set. This way we don't spam
+            // userstate changes
             // no need to escape as this mxid is only used to create an intent
-            mxidExtra = `_${new MatrixUser(`@${webhookID}`).localpart}`;
+            mxidExtra = `_${new MatrixUser(`@${discordUser.username}`).localpart}`;
         }
         const userState: IUserState = Object.assign({}, DEFAULT_USER_STATE, {
-            id: discordUser.id,
+            id: discordUser.id + mxidExtra,
             mxUserId: `@_discord_${discordUser.id}${mxidExtra}:${this.config.bridge.domain}`,
         });
         const displayName = Util.ApplyPatternString(this.config.ghosts.usernamePattern, {
@@ -303,17 +306,20 @@ export class UserSyncroniser {
 
     public async GetUserStateForDiscordUser(
         user: User,
-        webhookID?: string,
+        isWebhook: boolean = false,
     ): Promise<IGuildMemberState> {
         let mxidExtra = "";
-        if (webhookID) {
+        if (isWebhook) {
+            // for webhooks we append the username to the mxid, as webhooks with the same
+            // id can have multiple different usernames set. This way we don't spam
+            // userstate changes
             // no need to escape as this mxid is only used to create an Intent
             mxidExtra = `_${new MatrixUser(`@${user.username}`).localpart}`;
         }
         const guildState: IGuildMemberState = Object.assign({}, DEFAULT_GUILD_STATE, {
             bot: user.bot,
             displayName: user.username,
-            id: user.id,
+            id: user.id + mxidExtra,
             mxUserId: `@_discord_${user.id}${mxidExtra}:${this.config.bridge.domain}`,
             roles: [],
             username: user.tag,
diff --git a/src/util.ts b/src/util.ts
index 8f4b04347f8db21072be889a296bd555ef837d2a..8f24a7f57aa9cf3207fda444048440fba01732f8 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -268,7 +268,11 @@ export class Util {
             }
             return reply;
         }
+        if (Object.keys(actions).length === 0) {
+            return "No commands found";
+        }
         reply += "Available Commands:\n";
+        let commandsHavePermission = 0;
         for (const actionKey of Object.keys(actions)) {
             const action = actions[actionKey];
             if (action.permission !== undefined && permissionCheck) {
@@ -277,12 +281,16 @@ export class Util {
                     continue;
                 }
             }
+            commandsHavePermission++;
             reply += ` - \`${prefix} ${actionKey}`;
             for (const param of action.params) {
                 reply += ` <${param}>`;
             }
             reply += `\`: ${action.description}\n`;
         }
+        if (!commandsHavePermission) {
+            return "No commands found";
+        }
         reply += "\nParameters:\n";
         for (const parameterKey of Object.keys(parameters)) {
             const parameter = parameters[parameterKey];
diff --git a/test/config.ts b/test/config.ts
index b6d7f447d8cf09471281abc3d3c84107f5fe1b2b..13b2ea444cf91e349167587d68f836afcf5252c1 100644
--- a/test/config.ts
+++ b/test/config.ts
@@ -18,11 +18,6 @@ import { argv } from "process";
 import { Log } from "../src/log";
 import * as WhyRunning from "why-is-node-running";
 
-const logger = new Log("MessageProcessor");
-
-// we are a test file and thus need those
-/* tslint:disable:no-unused-expression max-file-line-count */
-
 if (!argv.includes("--noisy")) {
     Log.ForceSilent();
 }
diff --git a/test/structures/test_lock.ts b/test/structures/test_lock.ts
new file mode 100644
index 0000000000000000000000000000000000000000..114ddb9065f0f11a9acb288b43f71c58227ba827
--- /dev/null
+++ b/test/structures/test_lock.ts
@@ -0,0 +1,45 @@
+/*
+Copyright 2019 matrix-appservice-discord
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect } from "chai";
+import { Lock } from "../../src/structures/lock";
+import { Util } from "../../src/util";
+
+const LOCKTIMEOUT = 300;
+
+describe("Lock", () => {
+    it("should lock and unlock", async () => {
+        const lock = new Lock<string>(LOCKTIMEOUT);
+        const t = Date.now();
+        lock.set("bunny");
+        await lock.wait("bunny");
+        const diff = Date.now() - t;
+        expect(diff).to.be.greaterThan(LOCKTIMEOUT - 1);
+    });
+    it("should lock and unlock early, if unlocked", async () => {
+        const SHORTDELAY = 100;
+        const DELAY_ACCURACY = 5;
+        const lock = new Lock<string>(LOCKTIMEOUT);
+        setTimeout(() => lock.release("fox"), SHORTDELAY);
+        const t = Date.now();
+        lock.set("fox");
+        await lock.wait("fox");
+        const diff = Date.now() - t;
+        // accuracy can be off by a few ms soemtimes
+        expect(diff).to.be.greaterThan(SHORTDELAY - DELAY_ACCURACY);
+        expect(diff).to.be.lessThan(SHORTDELAY + DELAY_ACCURACY);
+    });
+});
diff --git a/test/structures/test_timedcache.ts b/test/structures/test_timedcache.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f002fc77ee182cb14f839c52a39f3228ce02ef67
--- /dev/null
+++ b/test/structures/test_timedcache.ts
@@ -0,0 +1,124 @@
+/*
+Copyright 2019 matrix-appservice-discord
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { expect } from "chai";
+import { TimedCache } from "../../src/structures/timedcache";
+import { Util } from "../../src/util";
+
+// we are a test file and thus need those
+/* tslint:disable:no-unused-expression max-file-line-count no-any */
+
+describe("TimedCache", () => {
+    it("should construct", () => {
+        const timedCache = new TimedCache<string, number>(1000);
+        expect(timedCache.size).to.equal(0);
+    });
+
+    it("should add and get values", () => {
+        const timedCache = new TimedCache<string, number>(1000);
+        timedCache.set("foo", 1);
+        timedCache.set("bar", -1);
+        timedCache.set("baz", 0);
+        expect(timedCache.get("foo")).to.equal(1);
+        expect(timedCache.get("bar")).to.equal(-1);
+        expect(timedCache.get("baz")).to.equal(0);
+    });
+
+    it("should be able to overwrite values", () => {
+        const timedCache = new TimedCache<string, number>(1000);
+        timedCache.set("foo", 1);
+        expect(timedCache.get("foo")).to.equal(1);
+        timedCache.set("bar", 0);
+        timedCache.set("foo", -1);
+        expect(timedCache.get("bar")).to.equal(0);
+        expect(timedCache.get("foo")).to.equal(-1);
+    });
+
+    it("should be able to check if a value exists", () => {
+        const timedCache = new TimedCache<string, number>(1000);
+        expect(timedCache.has("foo")).to.be.false;
+        timedCache.set("foo", 1);
+        expect(timedCache.has("foo")).to.be.true;
+        timedCache.set("bar", 1);
+        expect(timedCache.has("bar")).to.be.true;
+    });
+
+    it("should be able to delete a value", () => {
+        const timedCache = new TimedCache<string, number>(1000);
+        timedCache.set("foo", 1);
+        expect(timedCache.has("foo")).to.be.true;
+        timedCache.delete("foo");
+        expect(timedCache.has("foo")).to.be.false;
+        expect(timedCache.get("foo")).to.be.undefined;
+    });
+
+    it("should expire a value", async () => {
+        const LIVE_FOR = 50;
+        const timedCache = new TimedCache<string, number>(LIVE_FOR);
+        timedCache.set("foo", 1);
+        expect(timedCache.has("foo")).to.be.true;
+        expect(timedCache.get("foo")).to.equal(1);
+        await Util.DelayedPromise(LIVE_FOR);
+        expect(timedCache.has("foo")).to.be.false;
+        expect(timedCache.get("foo")).to.be.undefined;
+    });
+
+    it("should be able to iterate around a long-lasting collection", () => {
+        const timedCache = new TimedCache<string, number>(1000);
+        timedCache.set("foo", 1);
+        timedCache.set("bar", -1);
+        timedCache.set("baz", 0);
+        let i = 0;
+        for (const iterator of timedCache) {
+            if (i === 0) {
+                expect(iterator[0]).to.equal("foo");
+                expect(iterator[1]).to.equal(1);
+            } else if (i === 1) {
+                expect(iterator[0]).to.equal("bar");
+                expect(iterator[1]).to.equal(-1);
+            } else {
+                expect(iterator[0]).to.equal("baz");
+                expect(iterator[1]).to.equal(0);
+            }
+            i++;
+        }
+    });
+
+    it("should be able to iterate around a short-term collection", async () => {
+        const LIVE_FOR = 100;
+        const timedCache = new TimedCache<string, number>(LIVE_FOR);
+        timedCache.set("foo", 1);
+        timedCache.set("bar", -1);
+        timedCache.set("baz", 0);
+        let i = 0;
+        for (const iterator of timedCache) {
+            if (i === 0) {
+                expect(iterator[0]).to.equal("foo");
+                expect(iterator[1]).to.equal(1);
+            } else if (i === 1) {
+                expect(iterator[0]).to.equal("bar");
+                expect(iterator[1]).to.equal(-1);
+            } else {
+                expect(iterator[0]).to.equal("baz");
+                expect(iterator[1]).to.equal(0);
+            }
+            i++;
+        }
+        await Util.DelayedPromise(LIVE_FOR);
+        const vals = [...timedCache.entries()];
+        expect(vals).to.be.empty;
+    });
+});
diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts
index 918ff46863b19b6f70a106d96228576ec1cab580..2cfa4cacbe818f0a03577e83d292aba6f38cdf7c 100644
--- a/test/test_discordbot.ts
+++ b/test/test_discordbot.ts
@@ -416,48 +416,6 @@ describe("DiscordBot", () => {
             assert.equal(expected, ITERATIONS);
         });
     });
-    describe("locks", () => {
-        it("should lock and unlock a channel", async () => {
-            const bot = new modDiscordBot.DiscordBot(
-                "",
-                config,
-                mockBridge,
-                {},
-            ) as DiscordBot;
-            const chan = new MockChannel("123") as any;
-            const t = Date.now();
-            bot.lockChannel(chan);
-            await bot.waitUnlock(chan);
-            const diff = Date.now() - t;
-            expect(diff).to.be.greaterThan(config.limits.discordSendDelay - 1);
-        });
-        it("should lock and unlock a channel early, if unlocked", async () => {
-            const discordSendDelay = 500;
-            const SHORTDELAY = 100;
-            const MINEXPECTEDDELAY = 95;
-            const bot = new modDiscordBot.DiscordBot(
-                "",
-                {
-                    bridge: {
-                        domain: "localhost",
-                    },
-                    limits: {
-                        discordSendDelay,
-                    },
-                },
-                mockBridge,
-                {},
-            ) as DiscordBot;
-            const chan = new MockChannel("123") as any;
-            setTimeout(() => bot.unlockChannel(chan), SHORTDELAY);
-            const t = Date.now();
-            bot.lockChannel(chan);
-            await bot.waitUnlock(chan);
-            const diff = Date.now() - t;
-            // Date accuracy can be off by a few ms sometimes.
-            expect(diff).to.be.greaterThan(MINEXPECTEDDELAY);
-        });
-    });
   // });
     // describe("ProcessMatrixMsgEvent()", () => {
     //
diff --git a/test/test_discordmessageprocessor.ts b/test/test_discordmessageprocessor.ts
index d66dd949e2cbe76da2780a1853e5acd6775ee4b6..2938472011f76dedcae9df1de1a2c32f3962289e 100644
--- a/test/test_discordmessageprocessor.ts
+++ b/test/test_discordmessageprocessor.ts
@@ -391,6 +391,18 @@ describe("DiscordMessageProcessor", () => {
             Chai.assert.equal(reply, "<span data-mx-color=\"#dead88\"><strong>@role</strong></span>");
         });
     });
+    describe("InsertSpoiler / HTML", () => {
+        it("parses spoilers", () => {
+            const processor = new DiscordMessageProcessor(
+                new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
+            const content = { content: "foxies" };
+            let reply = processor.InsertSpoiler(content);
+            Chai.assert.equal(reply, "(Spoiler: foxies)");
+
+            reply = processor.InsertSpoiler(content, true);
+            Chai.assert.equal(reply, "<span data-mx-spoiler>foxies</span>");
+        });
+    });
     describe("InsertEmoji", () => {
         it("inserts static emojis to their post-parse flag", () => {
             const processor = new DiscordMessageProcessor(
diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts
index 26080fbcd3cb65ef6d0405388a40c4b4c51c32c0..107f5273a05c10a88dd4fa3cee6c942630192008 100644
--- a/test/test_matrixeventprocessor.ts
+++ b/test/test_matrixeventprocessor.ts
@@ -604,6 +604,22 @@ describe("MatrixEventProcessor", () => {
             } as IMatrixEvent, mxClient);
             expect(ret).equals("[filename.webm](https://localhost/8000000)");
         });
+        it("Should reply embeds on large info.size images if set", async () => {
+            const LARGE_FILE = 8000000;
+            const processor = createMatrixEventProcessor();
+            const ret = await processor.HandleAttachment({
+                content: {
+                    body: "filename.jpg",
+                    info: {
+                        mimetype: "image/jpeg",
+                        size: LARGE_FILE,
+                    },
+                    msgtype: "m.image",
+                    url: "mxc://localhost/8000000",
+                },
+            } as IMatrixEvent, mxClient, true);
+            expect((ret as Discord.RichEmbed).image!.url).equals("https://localhost/8000000");
+        });
         it("Should handle stickers.", async () => {
             const processor = createMatrixEventProcessor();
             const attachment = (await processor.HandleAttachment({
@@ -643,6 +659,7 @@ describe("MatrixEventProcessor", () => {
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -664,6 +681,7 @@ This is where the reply goes`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -685,6 +703,7 @@ This is where the reply goes`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -706,6 +725,7 @@ This is the second reply`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -727,6 +747,7 @@ This is the reply`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -746,6 +767,7 @@ This is the reply`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -763,6 +785,7 @@ This is the reply`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -787,6 +810,7 @@ This is the reply`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
@@ -804,6 +828,7 @@ This is the reply`,
                         },
                     },
                 },
+                room_id: "!fakeroom:localhost",
                 sender: "@test:localhost",
                 type: "m.room.message",
             } as IMatrixEvent, mockChannel as any);
diff --git a/test/test_matrixmessageprocessor.ts b/test/test_matrixmessageprocessor.ts
index d154bfa98c55de25c00b4510d49ff08b487d3bca..bca8537e83c9d0f1c4a9d0777196493821863872 100644
--- a/test/test_matrixmessageprocessor.ts
+++ b/test/test_matrixmessageprocessor.ts
@@ -194,6 +194,13 @@ code
 **##### tail**
 **###### foxies**`);
         });
+        it("strips simple span tags", async () => {
+            const mp = new MatrixMessageProcessor(bot);
+            const guild = new MockGuild("1234");
+            const msg = getHtmlMessage("<span>bunny</span>");
+            const result = await mp.FormatMessage(msg, guild as any);
+            expect(result).is.equal("bunny");
+        });
     });
     describe("FormatMessage / formatted_body / complex", () => {
         it("html unescapes stuff inside of code", async () => {
@@ -342,6 +349,20 @@ code
             const result = await mp.FormatMessage(msg, guild as any);
             expect(result).is.equal("[*yay?*](http://example.com)");
         });
+        it("Handles spoilers", async () => {
+            const mp = new MatrixMessageProcessor(bot);
+            const guild = new MockGuild("1234");
+            const msg = getHtmlMessage("<span data-mx-spoiler>foxies</span>");
+            const result = await mp.FormatMessage(msg, guild as any);
+            expect(result).is.equal("||foxies||");
+        });
+        it("Handles spoilers with reason", async () => {
+            const mp = new MatrixMessageProcessor(bot);
+            const guild = new MockGuild("1234");
+            const msg = getHtmlMessage("<span data-mx-spoiler=\"floof\">foxies</span>");
+            const result = await mp.FormatMessage(msg, guild as any);
+            expect(result).is.equal("(floof)||foxies||");
+        });
     });
     describe("FormatMessage / formatted_body / emoji", () => {
         it("Inserts emoji by name", async () => {
diff --git a/test/test_usersyncroniser.ts b/test/test_usersyncroniser.ts
index 531f3e86e1ded68fb2848882d750c9e195f128b9..00dfa17d5587d03cc77f7a743037997c16a05652 100644
--- a/test/test_usersyncroniser.ts
+++ b/test/test_usersyncroniser.ts
@@ -539,7 +539,7 @@ describe("UserSyncroniser", () => {
                 "123456",
                 "username",
                 "1234");
-            const state = await userSync.GetUserStateForDiscordUser(member as any, "654321");
+            const state = await userSync.GetUserStateForDiscordUser(member as any, true);
             expect(state.displayName).to.be.equal("username");
             expect(state.mxUserId).to.be.equal("@_discord_123456_username:localhost");
         });
diff --git a/test/test_util.ts b/test/test_util.ts
index 2e4ca1a3f0e8421f78e5400a6a2a587a1b6ec00d..30b4301450f4dce6152a249c606dc34846193f2f 100644
--- a/test/test_util.ts
+++ b/test/test_util.ts
@@ -86,6 +86,16 @@ describe("Util", () => {
             },
         };
         describe("HandleHelpCommand", () => {
+            it("handles empty commands", async () => {
+                const {command, args} = Util.MsgToArgs("!fox help", "!fox");
+                const retStr = await Util.HandleHelpCommand(
+                    "!fox",
+                    {} as any,
+                    {} as any,
+                    args,
+                );
+                expect(retStr).to.equal("No commands found");
+            });
             it("parses general help message", async () => {
                 const {command, args} = Util.MsgToArgs("!fox help", "!fox");
                 const retStr = await Util.HandleHelpCommand(
diff --git a/tools/addRoomsToDirectory.ts b/tools/addRoomsToDirectory.ts
index 200bebb500f7e85ece31b7cd5575334106aa2518..aeb037e7507c89a6598b9d1eb4f45e96c0774d69 100644
--- a/tools/addRoomsToDirectory.ts
+++ b/tools/addRoomsToDirectory.ts
@@ -44,6 +44,14 @@ const optionDefinitions = [
         type: String,
         typeLabel: "<config.yaml>",
     },
+    {
+        alias: "r",
+        defaultValue: "discord-registration.yaml",
+        description: "The AS registration file.",
+        name: "registration",
+        type: String,
+        typeLabel: "<discord-registration.yaml>",
+    },
     {
         alias: "s",
         defaultValue: "room-store.db",
@@ -69,7 +77,7 @@ if (options.help) {
     ]));
     process.exit(0);
 }
-const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8"));
+const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8"));
 const registration = AppServiceRegistration.fromObject(yamlConfig);
 const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig;
 
diff --git a/tools/addbot.ts b/tools/addbot.ts
index b8ef769b7ad9c5e5e9fe4e372f370531290d81cd..2dda777537df95d53e6cbb8bdaed47175e89b2f6 100644
--- a/tools/addbot.ts
+++ b/tools/addbot.ts
@@ -20,9 +20,45 @@ limitations under the License.
  */
 import * as yaml from "js-yaml";
 import * as fs from "fs";
+import * as args from "command-line-args";
+import * as usage from "command-line-usage";
 import { Util } from "../src/util";
 
-const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8"));
+const optionDefinitions = [
+  {
+      alias: "h",
+      description: "Display this usage guide.",
+      name: "help",
+      type: Boolean,
+  },
+  {
+      alias: "c",
+      defaultValue: "config.yaml",
+      description: "The AS config file.",
+      name: "config",
+      type: String,
+      typeLabel: "<config.yaml>",
+  },
+];
+
+const options = args(optionDefinitions);
+
+if (options.help) {
+  /* tslint:disable:no-console */
+  console.log(usage([
+  {
+      content: "A tool to obtain the Discord bot invitation URL.",
+      header: "Add bot",
+  },
+  {
+      header: "Options",
+      optionList: optionDefinitions,
+  },
+  ]));
+  process.exit(0);
+}
+
+const yamlConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8"));
 if (yamlConfig === null) {
   console.error("You have an error in your discord config.");
 }
diff --git a/tools/adminme.ts b/tools/adminme.ts
index a45887968beffc0bee9d2039680531f1f3b90506..234c161fa34e36d39475e8825548d59102bd9df5 100644
--- a/tools/adminme.ts
+++ b/tools/adminme.ts
@@ -43,6 +43,14 @@ const optionDefinitions = [
     },
     {
         alias: "r",
+        defaultValue: "discord-registration.yaml",
+        description: "The AS registration file.",
+        name: "registration",
+        type: String,
+        typeLabel: "<discord-registration.yaml>",
+    },
+    {
+        alias: "m",
         description: "The roomid to modify",
         name: "roomid",
         type: String,
@@ -90,7 +98,7 @@ if (!options.userid) {
     process.exit(1);
 }
 
-const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8"));
+const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8"));
 const registration = AppServiceRegistration.fromObject(yamlConfig);
 const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig;
 
diff --git a/tools/chanfix.ts b/tools/chanfix.ts
index a3f5095231f489dc78aea5c87d4ed20911b9d2f2..5607454c12c0e0b159ff048ca2e8ab13f8c6118b 100644
--- a/tools/chanfix.ts
+++ b/tools/chanfix.ts
@@ -44,6 +44,14 @@ const optionDefinitions = [
         type: String,
         typeLabel: "<config.yaml>",
     },
+    {
+        alias: "r",
+        defaultValue: "discord-registration.yaml",
+        description: "The AS registration file.",
+        name: "registration",
+        type: String,
+        typeLabel: "<discord-registration.yaml>",
+    },
 ];
 
 const options = args(optionDefinitions);
@@ -64,7 +72,7 @@ if (options.help) {
     process.exit(0);
 }
 
-const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8"));
+const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8"));
 const registration = AppServiceRegistration.fromObject(yamlConfig);
 const config = new DiscordBridgeConfig();
 config.applyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig);
diff --git a/tools/ghostfix.ts b/tools/ghostfix.ts
index b4e99e9dc95eaf4aab792f9ad39c4f99c2bc7588..59e34aa63103e6a1c9cc5f6fa602c23f6d065ef3 100644
--- a/tools/ghostfix.ts
+++ b/tools/ghostfix.ts
@@ -53,6 +53,14 @@ const optionDefinitions = [
         type: String,
         typeLabel: "<config.yaml>",
     },
+    {
+        alias: "r",
+        defaultValue: "discord-registration.yaml",
+        description: "The AS registration file.",
+        name: "registration",
+        type: String,
+        typeLabel: "<discord-registration.yaml>",
+    },
 ];
 
 const options = args(optionDefinitions);
@@ -73,7 +81,7 @@ if (options.help) {
     process.exit(0);
 }
 
-const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8"));
+const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8"));
 const registration = AppServiceRegistration.fromObject(yamlConfig);
 const config = new DiscordBridgeConfig();
 config.applyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig);