diff --git a/README.md b/README.md
index 5179f01af39f1e9fb7db84b3d0da24763082221f..089ce78951e32646d572ec25f0689a66d503194f 100644
--- a/README.md
+++ b/README.md
@@ -144,6 +144,7 @@ In a vague order of what is coming up next
      - [x] Audio/Video content
      - [ ] Typing notifs (**Not supported, requires syncing**)
      - [x] User Profiles
+     - [ ] Reactions
  - Discord -> Matrix
      - [x] Text content
      - [x] Image content
@@ -152,6 +153,7 @@ In a vague order of what is coming up next
      - [x] User Profiles
      - [x] Presence
      - [x] Per-guild display names.
+     - [x] Reactions
  - [x] Group messages
  - [ ] Third Party Lookup
     - [x] Rooms
diff --git a/changelog.d/862.feature b/changelog.d/862.feature
new file mode 100644
index 0000000000000000000000000000000000000000..a693baafcfc131dce751aa5750e545c27ccc9aee
--- /dev/null
+++ b/changelog.d/862.feature
@@ -0,0 +1 @@
+Adds one-way reaction support from Discord -> Matrix. Thanks to @SethFalco!
diff --git a/src/bot.ts b/src/bot.ts
index 8ebafa24ec7a79b2a30df9a89ae6960ff90623b2..575f7f177f7fa5560b91697b39e22e754a651416 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -263,6 +263,21 @@ export class DiscordBot {
                 await this.channelSync.OnGuildDelete(guild);
             } catch (err) { log.error("Exception thrown while handling \"guildDelete\" event", err); }
         });
+        client.on("messageReactionAdd", async (reaction, user) => {
+            try {
+                await this.OnMessageReactionAdd(reaction, user);
+            } catch (err) { log.error("Exception thrown while handling \"messageReactionAdd\" event", err); }
+        });
+        client.on("messageReactionRemove", async (reaction, user) => {
+            try {
+                await this.OnMessageReactionRemove(reaction, user);
+            } catch (err) { log.error("Exception thrown while handling \"messageReactionRemove\" event", err); }
+        });
+        client.on("messageReactionRemoveAll", async (message) => {
+            try {
+                await this.OnMessageReactionRemoveAll(message);
+            } catch (err) { log.error("Exception thrown while handling \"messageReactionRemoveAll\" event", err); }
+        });
 
         // Due to messages often arriving before we get a response from the send call,
         // messages get delayed from discord. We use Util.DelayedPromise to handle this.
@@ -1191,6 +1206,143 @@ export class DiscordBot {
         await this.OnMessage(newMsg);
     }
 
+    public async OnMessageReactionAdd(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) {
+        const message = reaction.message;
+        log.info(`Got message reaction add event for ${message.id} with ${reaction.emoji.name}`);
+
+        let rooms: string[];
+
+        try {
+            rooms = await this.channelSync.GetRoomIdsFromChannel(message.channel);
+
+            if (rooms === null) {
+                throw Error();
+            }
+        } catch (err) {
+            log.verbose("No bridged rooms to send message to. Oh well.");
+            MetricPeg.get.requestOutcome(message.id, true, "dropped");
+            return;
+        }
+
+        const intent = this.GetIntentFromDiscordMember(user);
+        await intent.ensureRegistered();
+        this.userActivity.updateUserActivity(intent.userId);
+
+        const storeEvent = await this.store.Get(DbEvent, {
+            discord_id: message.id
+        });
+
+        if (!storeEvent?.Result) {
+            return;
+        }
+
+        while (storeEvent.Next()) {
+            const matrixIds = storeEvent.MatrixId.split(";");
+
+            for (const room of rooms) {
+                const reactionEventId = await intent.underlyingClient.unstableApis.addReactionToEvent(
+                    room,
+                    matrixIds[0],
+                    reaction.emoji.id ? `:${reaction.emoji.name}:` : reaction.emoji.name
+                );
+
+                const event = new DbEvent();
+                event.MatrixId = `${reactionEventId};${room}`;
+                event.DiscordId = message.id;
+                event.ChannelId = message.channel.id;
+                if (message.guild) {
+                    event.GuildId = message.guild.id;
+                }
+
+                await this.store.Insert(event);
+            }
+        }
+    }
+
+    public async OnMessageReactionRemove(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) {
+        const message = reaction.message;
+        log.info(`Got message reaction remove event for ${message.id} with ${reaction.emoji.name}`);
+
+        const intent = this.GetIntentFromDiscordMember(user);
+        await intent.ensureRegistered();
+        this.userActivity.updateUserActivity(intent.userId);
+
+        const storeEvent = await this.store.Get(DbEvent, {
+            discord_id: message.id,
+        });
+
+        if (!storeEvent?.Result) {
+            return;
+        }
+
+        while (storeEvent.Next()) {
+            const [ eventId, roomId ] = storeEvent.MatrixId.split(";");
+            const underlyingClient = intent.underlyingClient;
+
+            const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(
+                roomId,
+                eventId,
+                "m.annotation"
+            );
+
+            const event = chunk.find((event) => {
+                if (event.sender !== intent.userId) {
+                    return false;
+                }
+
+                return event.content["m.relates_to"].key === reaction.emoji.name;
+            });
+
+            if (!event) {
+                return;
+            }
+
+            const { room_id, event_id } = event;
+
+            try {
+                await underlyingClient.redactEvent(room_id, event_id);
+            } catch (ex) {
+                log.warn(`Failed to delete ${storeEvent.DiscordId}, retrying as bot`);
+                try {
+                    await this.bridge.botIntent.underlyingClient.redactEvent(room_id, event_id);
+                } catch (ex) {
+                    log.warn(`Failed to delete ${event_id}, giving up`);
+                }
+            }
+        }
+    }
+
+    public async OnMessageReactionRemoveAll(message: Discord.Message | Discord.PartialMessage) {
+        log.info(`Got message reaction remove all event for ${message.id}`);
+
+        const storeEvent = await this.store.Get(DbEvent, {
+            discord_id: message.id,
+        });
+
+        if (!storeEvent?.Result) {
+            return;
+        }
+
+        while (storeEvent.Next()) {
+            const [ eventId, roomId ] = storeEvent.MatrixId.split(";");
+            const underlyingClient = this.bridge.botIntent.underlyingClient;
+
+            const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(
+                roomId,
+                eventId,
+                "m.annotation"
+            );
+
+            await Promise.all(chunk.map(async (event) => {
+                try {
+                    return await underlyingClient.redactEvent(event.room_id, event.event_id);
+                } catch (ex) {
+                    log.warn(`Failed to delete ${event.event_id}, giving up`);
+                }
+            }));
+        }
+    }
+
     private async DeleteDiscordMessage(msg: Discord.Message) {
         log.info(`Got delete event for ${msg.id}`);
         const storeEvent = await this.store.Get(DbEvent, {discord_id: msg.id});
diff --git a/src/db/dbdataevent.ts b/src/db/dbdataevent.ts
index d3fc58f7c90154017884e63bc015b8f386d41174..38738a55352c64a9d4d90c75acc1f896713402cb 100644
--- a/src/db/dbdataevent.ts
+++ b/src/db/dbdataevent.ts
@@ -19,7 +19,9 @@ import { IDbDataMany } from "./dbdatainterface";
 import { ISqlCommandParameters } from "./connector";
 
 export class DbEvent implements IDbDataMany {
+    /** Matrix ID of event. */
     public MatrixId: string;
+    /** Discord ID of the relevant message associated with this event. */
     public DiscordId: string;
     public GuildId: string;
     public ChannelId: string;
diff --git a/test/mocks/appservicemock.ts b/test/mocks/appservicemock.ts
index 28eb3935b9a4fa5e66f89604278a8b6f11bcf74b..d7820e2d18d70fc4cdeb62828a9b7fa0872f3e2f 100644
--- a/test/mocks/appservicemock.ts
+++ b/test/mocks/appservicemock.ts
@@ -146,7 +146,7 @@ export class AppserviceMock extends AppserviceMockBase {
 
 class IntentMock extends AppserviceMockBase {
     public readonly underlyingClient: MatrixClientMock;
-    constructor(private opts: IAppserviceMockOpts = {}, private id: string) {
+    constructor(private opts: IAppserviceMockOpts = {}, public userId: string) {
         super();
         this.underlyingClient = new MatrixClientMock(opts);
     }
@@ -177,9 +177,10 @@ class IntentMock extends AppserviceMockBase {
 }
 
 class MatrixClientMock extends AppserviceMockBase {
-
+    public readonly unstableApis: UnstableApis;;
     constructor(private opts: IAppserviceMockOpts = {}) {
         super();
+        this.unstableApis = new UnstableApis();
     }
 
     public banUser(roomId: string, userId: string) {
@@ -276,4 +277,19 @@ class MatrixClientMock extends AppserviceMockBase {
     public async setPresenceStatus(presence: string, status: string) {
         this.funcCalled("setPresenceStatus", presence, status);
     }
+
+    public async redactEvent(roomId: string, eventId: string, reason?: string | null) {
+        this.funcCalled("redactEvent", roomId, eventId, reason);
+    }
+}
+
+class UnstableApis extends AppserviceMockBase {
+
+    public async addReactionToEvent(roomId: string, eventId: string, emoji: string) {
+        this.funcCalled("addReactionToEvent", roomId, eventId, emoji);
+    }
+
+    public async getRelationsForEvent(roomId: string, eventId: string, relationType?: string, eventType?: string): Promise<any> {
+        this.funcCalled("getRelationsForEvent", roomId, eventId, relationType, eventType);
+    }
 }
diff --git a/test/mocks/message.ts b/test/mocks/message.ts
index e937c85b5c501df40cc14f6da6f0f310424551f8..c9ec0cbbb60eaf4efa94e28d0fbc8129e61af44c 100644
--- a/test/mocks/message.ts
+++ b/test/mocks/message.ts
@@ -24,17 +24,23 @@ import { MockCollection } from "./collection";
 export class MockMessage {
     public attachments = new MockCollection<string, any>();
     public embeds: any[] = [];
-    public content = "";
+    public content: string;
     public channel: Discord.TextChannel | undefined;
     public guild: Discord.Guild | undefined;
     public author: MockUser;
     public mentions: any = {};
-    constructor(channel?: Discord.TextChannel) {
+
+    constructor(
+        channel?: Discord.TextChannel,
+        content: string = "",
+        author: MockUser = new MockUser("123456"),
+    ) {
         this.mentions.everyone = false;
         this.channel = channel;
         if (channel && channel.guild) {
             this.guild = channel.guild;
         }
-        this.author = new MockUser("123456");
+        this.content = content;
+        this.author = author;
     }
 }
diff --git a/test/mocks/reaction.ts b/test/mocks/reaction.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7ca85555fa4f7322956ca2f15c2b1cc6d28a05fc
--- /dev/null
+++ b/test/mocks/reaction.ts
@@ -0,0 +1,16 @@
+import { MockTextChannel } from './channel';
+import { MockEmoji } from './emoji';
+import { MockMessage } from './message';
+
+/* tslint:disable:no-unused-expression max-file-line-count no-any */
+export class MockReaction {
+    public message: MockMessage;
+    public emoji: MockEmoji;
+    public channel: MockTextChannel;
+
+    constructor(message: MockMessage, emoji: MockEmoji, channel: MockTextChannel) {
+        this.message = message;
+        this.emoji = emoji;
+        this.channel = channel;
+    }
+}
diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts
index 9d91dfeff10e44279db359729853358d5b1daa41..c89a0cd0dce04c38bc305ee548c0ca136ac226de 100644
--- a/test/test_discordbot.ts
+++ b/test/test_discordbot.ts
@@ -25,6 +25,8 @@ import { Util } from "../src/util";
 import { AppserviceMock } from "./mocks/appservicemock";
 import { MockUser } from "./mocks/user";
 import { MockTextChannel } from "./mocks/channel";
+import { MockReaction } from './mocks/reaction';
+import { MockEmoji } from './mocks/emoji';
 
 // we are a test file and thus need those
 /* tslint:disable:no-unused-expression max-file-line-count no-any */
@@ -442,4 +444,82 @@ describe("DiscordBot", () => {
             expect(expected).to.eq(ITERATIONS);
         });
     });
+    describe("OnMessageReactionAdd", () => {
+        const channel = new MockTextChannel();
+        const author = new MockUser("11111");
+        const message = new MockMessage(channel, "Hello, World!", author);
+        const emoji = new MockEmoji("", "🤔");
+        const reaction = new MockReaction(message, emoji, channel);
+
+        function getDiscordBot() {
+            mockBridge.cleanup();
+            const discord = new modDiscordBot.DiscordBot(
+                config,
+                mockBridge,
+                {},
+            );
+            discord.channelSync = {
+                GetRoomIdsFromChannel: async () => ["!asdf:localhost"],
+            };
+            discord.store = {
+                Get: async () => {
+                    let storeMockResults = 0;
+
+                    return {
+                        Result: true,
+                        MatrixId: "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po;!asdf:localhost",
+                        Next: () => storeMockResults++ === 0
+                    }
+                },
+                Insert: async () => { },
+            };
+            discord.userActivity = {
+                updateUserActivity: () => { }
+            };
+            discord.GetIntentFromDiscordMember = () => {
+                return mockBridge.getIntent(author.id);
+            }
+            return discord;
+        }
+
+        it("Adds reaction from Discord → Matrix", async () => {
+            discordBot = getDiscordBot();
+            await discordBot.OnMessageReactionAdd(reaction, author);
+            mockBridge.getIntent(author.id).underlyingClient.unstableApis.wasCalled(
+                "addReactionToEvent",
+                true,
+                "!asdf:localhost",
+                "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po",
+                "🤔"
+            );
+        });
+
+        it("Removes reaction from Discord → Matrix", async () => {
+            discordBot = getDiscordBot();
+            const intent = mockBridge.getIntent(author.id);
+
+            intent.underlyingClient.unstableApis.getRelationsForEvent = async () => {
+                return {
+                    chunk: [
+                        {
+                            sender: "11111",
+                            room_id: "!asdf:localhost",
+                            event_id: "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po",
+                            content: {
+                                "m.relates_to": { key: "🤔" }
+                            }
+                        }
+                    ]
+                }
+            }
+
+            await discordBot.OnMessageReactionRemove(reaction, author);
+            intent.underlyingClient.wasCalled(
+                "redactEvent",
+                false,
+                "!asdf:localhost",
+                "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po",
+            );
+        });
+    });
 });