Skip to content

Commit

Permalink
feat: add discord > matrix reaction support
Browse files Browse the repository at this point in the history
Signed-off-by: Seth Falco <[email protected]>
  • Loading branch information
SethFalco committed Oct 20, 2022
1 parent ac5180e commit ecc9dca
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 5 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/862.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adds one-way reaction support from Discord -> Matrix. Thanks to @SethFalco!
152 changes: 152 additions & 0 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1177,6 +1192,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});
Expand Down
2 changes: 2 additions & 0 deletions src/db/dbdataevent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 18 additions & 2 deletions test/mocks/appservicemock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
12 changes: 9 additions & 3 deletions test/mocks/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
16 changes: 16 additions & 0 deletions test/mocks/reaction.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
80 changes: 80 additions & 0 deletions test/test_discordbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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",
);
});
});
});

0 comments on commit ecc9dca

Please sign in to comment.