Skip to content

Commit 9b3533f

Browse files
committed
feat: add discord > matrix reaction support
Signed-off-by: Seth Falco <[email protected]>
1 parent ece29f4 commit 9b3533f

File tree

8 files changed

+280
-5
lines changed

8 files changed

+280
-5
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ In a vague order of what is coming up next
144144
- [x] Audio/Video content
145145
- [ ] Typing notifs (**Not supported, requires syncing**)
146146
- [x] User Profiles
147+
- [ ] Reactions
147148
- Discord -> Matrix
148149
- [x] Text content
149150
- [x] Image content
@@ -152,6 +153,7 @@ In a vague order of what is coming up next
152153
- [x] User Profiles
153154
- [x] Presence
154155
- [x] Per-guild display names.
156+
- [x] Reactions
155157
- [x] Group messages
156158
- [ ] Third Party Lookup
157159
- [x] Rooms

changelog.d/862.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Adds one-way reaction support from Discord -> Matrix. Thanks to @SethFalco!

src/bot.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,21 @@ export class DiscordBot {
263263
await this.channelSync.OnGuildDelete(guild);
264264
} catch (err) { log.error("Exception thrown while handling \"guildDelete\" event", err); }
265265
});
266+
client.on("messageReactionAdd", async (reaction, user) => {
267+
try {
268+
await this.OnMessageReactionAdd(reaction, user);
269+
} catch (err) { log.error("Exception thrown while handling \"messageReactionAdd\" event", err); }
270+
});
271+
client.on("messageReactionRemove", async (reaction, user) => {
272+
try {
273+
await this.OnMessageReactionRemove(reaction, user);
274+
} catch (err) { log.error("Exception thrown while handling \"messageReactionRemove\" event", err); }
275+
});
276+
client.on("messageReactionRemoveAll", async (message) => {
277+
try {
278+
await this.OnMessageReactionRemoveAll(message);
279+
} catch (err) { log.error("Exception thrown while handling \"messageReactionRemoveAll\" event", err); }
280+
});
266281

267282
// Due to messages often arriving before we get a response from the send call,
268283
// messages get delayed from discord. We use Util.DelayedPromise to handle this.
@@ -1177,6 +1192,143 @@ export class DiscordBot {
11771192
await this.OnMessage(newMsg);
11781193
}
11791194

1195+
public async OnMessageReactionAdd(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) {
1196+
const message = reaction.message;
1197+
log.info(`Got message reaction add event for ${message.id} with ${reaction.emoji.name}`);
1198+
1199+
let rooms: string[];
1200+
1201+
try {
1202+
rooms = await this.channelSync.GetRoomIdsFromChannel(message.channel);
1203+
1204+
if (rooms === null) {
1205+
throw Error();
1206+
}
1207+
} catch (err) {
1208+
log.verbose("No bridged rooms to send message to. Oh well.");
1209+
MetricPeg.get.requestOutcome(message.id, true, "dropped");
1210+
return;
1211+
}
1212+
1213+
const intent = this.GetIntentFromDiscordMember(user);
1214+
await intent.ensureRegistered();
1215+
this.userActivity.updateUserActivity(intent.userId);
1216+
1217+
const storeEvent = await this.store.Get(DbEvent, {
1218+
discord_id: message.id
1219+
});
1220+
1221+
if (!storeEvent?.Result) {
1222+
return;
1223+
}
1224+
1225+
while (storeEvent.Next()) {
1226+
const matrixIds = storeEvent.MatrixId.split(";");
1227+
1228+
for (const room of rooms) {
1229+
const reactionEventId = await intent.underlyingClient.unstableApis.addReactionToEvent(
1230+
room,
1231+
matrixIds[0],
1232+
reaction.emoji.id ? `:${reaction.emoji.name}:` : reaction.emoji.name
1233+
);
1234+
1235+
const event = new DbEvent();
1236+
event.MatrixId = `${reactionEventId};${room}`;
1237+
event.DiscordId = message.id;
1238+
event.ChannelId = message.channel.id;
1239+
if (message.guild) {
1240+
event.GuildId = message.guild.id;
1241+
}
1242+
1243+
await this.store.Insert(event);
1244+
}
1245+
}
1246+
}
1247+
1248+
public async OnMessageReactionRemove(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) {
1249+
const message = reaction.message;
1250+
log.info(`Got message reaction remove event for ${message.id} with ${reaction.emoji.name}`);
1251+
1252+
const intent = this.GetIntentFromDiscordMember(user);
1253+
await intent.ensureRegistered();
1254+
this.userActivity.updateUserActivity(intent.userId);
1255+
1256+
const storeEvent = await this.store.Get(DbEvent, {
1257+
discord_id: message.id,
1258+
});
1259+
1260+
if (!storeEvent?.Result) {
1261+
return;
1262+
}
1263+
1264+
while (storeEvent.Next()) {
1265+
const [ eventId, roomId ] = storeEvent.MatrixId.split(";");
1266+
const underlyingClient = intent.underlyingClient;
1267+
1268+
const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(
1269+
roomId,
1270+
eventId,
1271+
"m.annotation"
1272+
);
1273+
1274+
const event = chunk.find((event) => {
1275+
if (event.sender !== intent.userId) {
1276+
return false;
1277+
}
1278+
1279+
return event.content["m.relates_to"].key === reaction.emoji.name;
1280+
});
1281+
1282+
if (!event) {
1283+
return;
1284+
}
1285+
1286+
const { room_id, event_id } = event;
1287+
1288+
try {
1289+
await underlyingClient.redactEvent(room_id, event_id);
1290+
} catch (ex) {
1291+
log.warn(`Failed to delete ${storeEvent.DiscordId}, retrying as bot`);
1292+
try {
1293+
await this.bridge.botIntent.underlyingClient.redactEvent(room_id, event_id);
1294+
} catch (ex) {
1295+
log.warn(`Failed to delete ${event_id}, giving up`);
1296+
}
1297+
}
1298+
}
1299+
}
1300+
1301+
public async OnMessageReactionRemoveAll(message: Discord.Message | Discord.PartialMessage) {
1302+
log.info(`Got message reaction remove all event for ${message.id}`);
1303+
1304+
const storeEvent = await this.store.Get(DbEvent, {
1305+
discord_id: message.id,
1306+
});
1307+
1308+
if (!storeEvent?.Result) {
1309+
return;
1310+
}
1311+
1312+
while (storeEvent.Next()) {
1313+
const [ eventId, roomId ] = storeEvent.MatrixId.split(";");
1314+
const underlyingClient = this.bridge.botIntent.underlyingClient;
1315+
1316+
const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(
1317+
roomId,
1318+
eventId,
1319+
"m.annotation"
1320+
);
1321+
1322+
await Promise.all(chunk.map(async (event) => {
1323+
try {
1324+
return await underlyingClient.redactEvent(event.room_id, event.event_id);
1325+
} catch (ex) {
1326+
log.warn(`Failed to delete ${event.event_id}, giving up`);
1327+
}
1328+
}));
1329+
}
1330+
}
1331+
11801332
private async DeleteDiscordMessage(msg: Discord.Message) {
11811333
log.info(`Got delete event for ${msg.id}`);
11821334
const storeEvent = await this.store.Get(DbEvent, {discord_id: msg.id});

src/db/dbdataevent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import { IDbDataMany } from "./dbdatainterface";
1919
import { ISqlCommandParameters } from "./connector";
2020

2121
export class DbEvent implements IDbDataMany {
22+
/** Matrix ID of event. */
2223
public MatrixId: string;
24+
/** Discord ID of the relevant message associated with this event. */
2325
public DiscordId: string;
2426
public GuildId: string;
2527
public ChannelId: string;

test/mocks/appservicemock.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export class AppserviceMock extends AppserviceMockBase {
146146

147147
class IntentMock extends AppserviceMockBase {
148148
public readonly underlyingClient: MatrixClientMock;
149-
constructor(private opts: IAppserviceMockOpts = {}, private id: string) {
149+
constructor(private opts: IAppserviceMockOpts = {}, public userId: string) {
150150
super();
151151
this.underlyingClient = new MatrixClientMock(opts);
152152
}
@@ -177,9 +177,10 @@ class IntentMock extends AppserviceMockBase {
177177
}
178178

179179
class MatrixClientMock extends AppserviceMockBase {
180-
180+
public readonly unstableApis: UnstableApis;;
181181
constructor(private opts: IAppserviceMockOpts = {}) {
182182
super();
183+
this.unstableApis = new UnstableApis();
183184
}
184185

185186
public banUser(roomId: string, userId: string) {
@@ -276,4 +277,19 @@ class MatrixClientMock extends AppserviceMockBase {
276277
public async setPresenceStatus(presence: string, status: string) {
277278
this.funcCalled("setPresenceStatus", presence, status);
278279
}
280+
281+
public async redactEvent(roomId: string, eventId: string, reason?: string | null) {
282+
this.funcCalled("redactEvent", roomId, eventId, reason);
283+
}
284+
}
285+
286+
class UnstableApis extends AppserviceMockBase {
287+
288+
public async addReactionToEvent(roomId: string, eventId: string, emoji: string) {
289+
this.funcCalled("addReactionToEvent", roomId, eventId, emoji);
290+
}
291+
292+
public async getRelationsForEvent(roomId: string, eventId: string, relationType?: string, eventType?: string): Promise<any> {
293+
this.funcCalled("getRelationsForEvent", roomId, eventId, relationType, eventType);
294+
}
279295
}

test/mocks/message.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,23 @@ import { MockCollection } from "./collection";
2424
export class MockMessage {
2525
public attachments = new MockCollection<string, any>();
2626
public embeds: any[] = [];
27-
public content = "";
27+
public content: string;
2828
public channel: Discord.TextChannel | undefined;
2929
public guild: Discord.Guild | undefined;
3030
public author: MockUser;
3131
public mentions: any = {};
32-
constructor(channel?: Discord.TextChannel) {
32+
33+
constructor(
34+
channel?: Discord.TextChannel,
35+
content: string = "",
36+
author: MockUser = new MockUser("123456"),
37+
) {
3338
this.mentions.everyone = false;
3439
this.channel = channel;
3540
if (channel && channel.guild) {
3641
this.guild = channel.guild;
3742
}
38-
this.author = new MockUser("123456");
43+
this.content = content;
44+
this.author = author;
3945
}
4046
}

test/mocks/reaction.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { MockTextChannel } from './channel';
2+
import { MockEmoji } from './emoji';
3+
import { MockMessage } from './message';
4+
5+
/* tslint:disable:no-unused-expression max-file-line-count no-any */
6+
export class MockReaction {
7+
public message: MockMessage;
8+
public emoji: MockEmoji;
9+
public channel: MockTextChannel;
10+
11+
constructor(message: MockMessage, emoji: MockEmoji, channel: MockTextChannel) {
12+
this.message = message;
13+
this.emoji = emoji;
14+
this.channel = channel;
15+
}
16+
}

test/test_discordbot.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { Util } from "../src/util";
2525
import { AppserviceMock } from "./mocks/appservicemock";
2626
import { MockUser } from "./mocks/user";
2727
import { MockTextChannel } from "./mocks/channel";
28+
import { MockReaction } from './mocks/reaction';
29+
import { MockEmoji } from './mocks/emoji';
2830

2931
// we are a test file and thus need those
3032
/* tslint:disable:no-unused-expression max-file-line-count no-any */
@@ -442,4 +444,82 @@ describe("DiscordBot", () => {
442444
expect(expected).to.eq(ITERATIONS);
443445
});
444446
});
447+
describe("OnMessageReactionAdd", () => {
448+
const channel = new MockTextChannel();
449+
const author = new MockUser("11111");
450+
const message = new MockMessage(channel, "Hello, World!", author);
451+
const emoji = new MockEmoji("", "🤔");
452+
const reaction = new MockReaction(message, emoji, channel);
453+
454+
function getDiscordBot() {
455+
mockBridge.cleanup();
456+
const discord = new modDiscordBot.DiscordBot(
457+
config,
458+
mockBridge,
459+
{},
460+
);
461+
discord.channelSync = {
462+
GetRoomIdsFromChannel: async () => ["!asdf:localhost"],
463+
};
464+
discord.store = {
465+
Get: async () => {
466+
let storeMockResults = 0;
467+
468+
return {
469+
Result: true,
470+
MatrixId: "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po;!asdf:localhost",
471+
Next: () => storeMockResults++ === 0
472+
}
473+
},
474+
Insert: async () => { },
475+
};
476+
discord.userActivity = {
477+
updateUserActivity: () => { }
478+
};
479+
discord.GetIntentFromDiscordMember = () => {
480+
return mockBridge.getIntent(author.id);
481+
}
482+
return discord;
483+
}
484+
485+
it("Adds reaction from Discord → Matrix", async () => {
486+
discordBot = getDiscordBot();
487+
await discordBot.OnMessageReactionAdd(reaction, author);
488+
mockBridge.getIntent(author.id).underlyingClient.unstableApis.wasCalled(
489+
"addReactionToEvent",
490+
true,
491+
"!asdf:localhost",
492+
"$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po",
493+
"🤔"
494+
);
495+
});
496+
497+
it("Removes reaction from Discord → Matrix", async () => {
498+
discordBot = getDiscordBot();
499+
const intent = mockBridge.getIntent(author.id);
500+
501+
intent.underlyingClient.unstableApis.getRelationsForEvent = async () => {
502+
return {
503+
chunk: [
504+
{
505+
sender: "11111",
506+
room_id: "!asdf:localhost",
507+
event_id: "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po",
508+
content: {
509+
"m.relates_to": { key: "🤔" }
510+
}
511+
}
512+
]
513+
}
514+
}
515+
516+
await discordBot.OnMessageReactionRemove(reaction, author);
517+
intent.underlyingClient.wasCalled(
518+
"redactEvent",
519+
false,
520+
"!asdf:localhost",
521+
"$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po",
522+
);
523+
});
524+
});
445525
});

0 commit comments

Comments
 (0)