Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add discord > matrix reaction support #862

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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!
160 changes: 160 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,151 @@ export class DiscordBot {
await this.OnMessage(newMsg);
}

public async OnMessageReactionAdd(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) {
const message = reaction.message;
const reactionName = reaction.emoji.name;
log.verbose(`Got message reaction add event for ${message.id} with ${reactionName}`);

const storeEvent = await this.store.Get(DbEvent, {
discord_id: message.id
});

if (!storeEvent?.Result) {
log.verbose(`Received add reaction event for untracked message. Dropping! Reaction Event: ${reaction}`);
return;
SethFalco marked this conversation as resolved.
Show resolved Hide resolved
}

const intent = this.GetIntentFromDiscordMember(user);
await intent.ensureRegistered();
this.userActivity.updateUserActivity(intent.userId);

while (storeEvent.Next()) {
const [ eventId, roomId ] = storeEvent.MatrixId.split(";");

// If the user is partial, only forward the event for rooms they're already in.
if (user.partial) {
log.warn(`Skipping reaction add for user with Discord ID ${user.id} in ${roomId}. User was partial.`);
continue;
}

await this.userSync.JoinRoom(user, roomId);

const reactionEventId = await intent.underlyingClient.unstableApis.addReactionToEvent(
roomId,
eventId,
reaction.emoji.id ? `:${reactionName}:` : reactionName
);

const event = new DbEvent();
event.MatrixId = `${reactionEventId};${roomId}`;
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.verbose(`Got message reaction remove event for ${message.id} with ${reaction.emoji.name}`);

const storeEvent = await this.store.Get(DbEvent, {
discord_id: message.id,
});

if (!storeEvent?.Result) {
log.verbose(`Received remove reaction event for untracked message. Dropping! Reaction Event: ${reaction}`);
return;
SethFalco marked this conversation as resolved.
Show resolved Hide resolved
}

const intent = this.GetIntentFromDiscordMember(user);
await intent.ensureRegistered();
this.userActivity.updateUserActivity(intent.userId);

while (storeEvent.Next()) {
const [ eventId, roomId ] = storeEvent.MatrixId.split(";");

// If the user is partial, only forward the event for rooms they're already in.
if (user.partial) {
log.warn(`Skipping reaction remove for user with Discord ID ${user.id} in ${roomId}. User was partial.`);
continue;
}

await this.userSync.JoinRoom(user, roomId);

const underlyingClient = intent.underlyingClient;

const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(
const { chunk } = await underlyingClient.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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should use reactionName here in the same way as in OnMessageReactionAdd

});

if (!event) {
log.verbose(`Received remove reaction event for tracked message where the add reaction event was not bridged. Dropping! Reaction Event: ${reaction}`);
return;
SethFalco marked this conversation as resolved.
Show resolved Hide resolved
}

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.verbose(`Got message reaction remove all event for ${message.id}`);

const storeEvent = await this.store.Get(DbEvent, {
discord_id: message.id,
});

if (!storeEvent?.Result) {
log.verbose(`Received remove all reaction event for untracked message. Dropping! Event: ${message}`);
return;
}

while (storeEvent.Next()) {
const [ eventId, roomId ] = storeEvent.MatrixId.split(";");
const underlyingClient = this.bridge.botIntent.underlyingClient;

const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent(
const { chunk } = await underlyingClient.getRelationsForEvent(

roomId,
eventId,
"m.annotation"
);

const filteredChunk = chunk.filter((event) => this.bridge.isNamespacedUser(event.sender));

await Promise.all(filteredChunk.map(async (event) => {
try {
return await underlyingClient.redactEvent(event.room_id, event.event_id);
SethFalco marked this conversation as resolved.
Show resolved Hide resolved
} 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 {
/** ID associated with the event in the format "MatrixID:RoomID". */
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);
}
Comment on lines +292 to +294

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to MatrixClientMock

}
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;
}
}
83 changes: 83 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,85 @@ 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.userSync = {
JoinRoom: async () => { },
};
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 () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not unstable anymore, using it via .unstableApis is deprecated

Suggested change
intent.underlyingClient.unstableApis.getRelationsForEvent = async () => {
intent.underlyingClient.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",
);
});
});
});