Discord: add Mirror class (#1732)

Adds a class to persist a local mirror of data from a Discord Guild.
Implements create and read functionality.

Adds a function to `models` which converts an Emoji string-reference
into an Emoji object.

Test plan:

Unit tests added.

Paired with: @beanow
This commit is contained in:
Brian Litwin 2020-04-16 11:35:57 -04:00 committed by GitHub
parent 28115bba96
commit 898282becc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 984 additions and 7 deletions

View File

@ -101,6 +101,17 @@ export function emojiToRef({id, name}: Emoji): EmojiRef {
return `${name}:${id}`; return `${name}:${id}`;
} }
/**
* Returns an Emoji object based on a string reference in the form:
* `${name}:${id}`
*/
export function refToEmoji(ref: EmojiRef): Emoji {
// TODO: Test that ref is in correct form
const [name, id] = ref.split(":");
if (!id) return {id: null, name};
return {id, name};
}
// Determines whether the message was created by a webhook or a Discord User // Determines whether the message was created by a webhook or a Discord User
export function isAuthoredByNonUser(rawMessage: { export function isAuthoredByNonUser(rawMessage: {
+webhook_id?: Snowflake, +webhook_id?: Snowflake,

View File

@ -1,19 +1,23 @@
// @flow // @flow
import {emojiToRef, isAuthoredByNonUser} from "./models"; import {emojiToRef, refToEmoji, isAuthoredByNonUser} from "./models";
describe("plugins/discord/models", () => { describe("plugins/discord/models", () => {
describe("model helper functions", () => { describe("model helper functions", () => {
describe("emojiToRef", () => { describe("emojiToRef", () => {
it("returns name if id is null", () => { it("returns name if id is null", () => {
expect(emojiToRef({id: null, name: "testEmojiName"})).toBe( expect(emojiToRef({id: null, name: "🐙"})).toBe("🐙");
"testEmojiName"
);
}); });
it("returns name and id if id is not null", () => { it("returns name and id if id is not null", () => {
expect(emojiToRef({id: "testEmojiId", name: "testEmojiName"})).toBe( expect(emojiToRef({id: "id", name: "name"})).toBe("name:id");
"testEmojiName:testEmojiId" });
); });
describe("refToEmoji", () => {
it("returns correct object if id is null", () => {
expect(refToEmoji("🐙")).toEqual({name: "🐙", id: null});
});
it("returns correct object if id is not null", () => {
expect(refToEmoji("name:id")).toEqual({name: "name", id: "id"});
}); });
}); });
describe("isAuthoredByNonUser", () => { describe("isAuthoredByNonUser", () => {

View File

@ -0,0 +1,500 @@
// @flow
import type Database from "better-sqlite3";
import stringify from "json-stable-stringify";
import * as Model from "./models";
import dedent from "../../util/dedent";
import * as NullUtil from "../../util/null";
const VERSION = "DISCORD_MIRROR_v1";
/**
* Persists a local copy of data from a Discord Guild.
* Implements create and read functionality.
*
* Each Mirror instance is tied to a particular Guild. Trying to use a mirror
* for multiple Discord Guilds is not permitted; use separate Mirrors.
*
* Note that Mirror persists separate Tables for Users and Guild Members.
* Members are distinguished by membership in the Guild. Non-Member
* Discord Users can participate in a Guild's activity by leaving comments,
* reactions, etc. In our model, Members have a property, User, which
* represents a full User object. Because of this, we save the User in the
* AddMember method.
*/
export class SqliteMirror {
+_db: Database;
/**
* Construct a new SqliteMirror instance.
*
* Takes a Database and GuildId.
*/
constructor(db: Database, guildId: Model.Snowflake) {
this._db = db;
this._transaction(() => {
this._initialize(guildId);
});
}
_transaction(queries: () => void) {
const db = this._db;
if (db.inTransaction) {
throw new Error("already in transaction");
}
try {
db.prepare("BEGIN").run();
queries();
if (db.inTransaction) {
db.prepare("COMMIT").run();
}
} finally {
if (db.inTransaction) {
db.prepare("ROLLBACK").run();
}
}
}
_initialize(guild: Model.Snowflake) {
const db = this._db;
// We store the config in a singleton table `meta`, whose unique row
// has PRIMARY KEY `0`. Only the first ever insert will succeed; we
// are locked into the first config.
db.prepare(
dedent`\
CREATE TABLE IF NOT EXISTS meta (
zero INTEGER PRIMARY KEY NOT NULL,
config TEXT NOT NULL
)
`
).run();
const config = stringify({
version: VERSION,
guild,
});
const existingConfig: string | void = db
.prepare("SELECT config FROM meta")
.pluck()
.get();
if (existingConfig === config) {
// Already set up; nothing to do.
return;
} else if (existingConfig !== undefined) {
throw new Error(
"Database already populated with incompatible server or version"
);
}
db.prepare("INSERT INTO meta (zero, config) VALUES (0, ?)").run(config);
const tables = [
dedent`\
CREATE TABLE channels (
id TEXT PRIMARY KEY NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL
)
`,
dedent`\
CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL,
username TEXT NOT NULL,
discriminator TEXT NOT NULL,
bot INTEGER NOT NULL
)
`,
dedent`\
CREATE TABLE members (
user_id TEXT PRIMARY KEY NOT NULL,
nick TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
)
`,
dedent`\
CREATE TABLE messages (
id TEXT PRIMARY KEY NOT NULL,
channel_id TEXT NOT NULL,
author_id TEXT NOT NULL,
non_user_author INTEGER NOT NULL,
timestamp_ms INTEGER NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY(channel_id) REFERENCES channels(id)
)
`,
dedent`
CREATE INDEX messages__chanel_id
ON messages (channel_id)
`,
dedent`\
CREATE TABLE message_reactions (
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
author_id TEXT NOT NULL,
emoji TEXT NOT NULL,
FOREIGN KEY(channel_id) REFERENCES channels(id),
FOREIGN KEY(message_id) REFERENCES messages(id),
FOREIGN KEY(author_id) REFERENCES users(id),
CONSTRAINT value_object PRIMARY KEY (channel_id, message_id, author_id, emoji)
)
`,
dedent`\
CREATE INDEX message_reactions__channel_id__message_id
ON message_reactions (channel_id, message_id)
`,
dedent`\
CREATE TABLE message_mentions (
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
FOREIGN KEY(channel_id) REFERENCES channels(id),
FOREIGN KEY(message_id) REFERENCES messages(id),
FOREIGN KEY(user_id) REFERENCES users(id),
CONSTRAINT value_object PRIMARY KEY (channel_id, message_id, user_id)
)
`,
dedent`\
CREATE INDEX message_mentions__channel_id__message_id
ON message_mentions (channel_id, message_id)
`,
];
for (const sql of tables) {
db.prepare(sql).run();
}
}
users(): $ReadOnlyArray<Model.User> {
return this._db
.prepare(
dedent`\
SELECT
id,
username,
discriminator,
bot
FROM users`
)
.all()
.map((x) => ({
id: x.id,
username: x.username,
discriminator: x.discriminator,
bot: x.bot === 1,
}));
}
user(id: Model.Snowflake): ?Model.User {
const user = this._db
.prepare(
dedent`\
SELECT
id,
username,
discriminator,
bot
FROM users
WHERE id = :id
`
)
.get({id: id});
if (!user) {
return null;
} else {
return {
id: user.id,
username: user.username,
discriminator: user.discriminator,
bot: user.bot === 1,
};
}
}
members(): $ReadOnlyArray<Model.GuildMember> {
return this._db
.prepare(
dedent`\
SELECT
user_id,
nick
FROM members`
)
.all()
.map((x) => ({
user: NullUtil.get(
this.user(x.user_id),
`No user_id found for ${x.user_id}`
),
nick: x.nick,
}));
}
member(userId: Model.Snowflake): ?Model.GuildMember {
const member = this._db
.prepare(
dedent`\
SELECT
user_id,
nick
FROM members
WHERE user_id = :user_id
`
)
.get({user_id: userId});
if (!member) {
return null;
} else {
return {
user: NullUtil.get(this.user(userId), `No user_id found for ${userId}`),
nick: member.nick,
};
}
}
channels(): $ReadOnlyArray<Model.Channel> {
return this._db
.prepare(
dedent`\
SELECT
id,
type,
name
FROM channels`
)
.all();
}
messages(channel: Model.Snowflake): $ReadOnlyArray<Model.Message> {
return this._db
.prepare(
dedent`\
SELECT
id,
channel_id,
author_id,
non_user_author,
timestamp_ms,
content
FROM messages
WHERE channel_id = :channel_id`
)
.all({channel_id: channel})
.map((m) => ({
id: m.id,
channelId: m.channel_id,
authorId: m.author_id,
nonUserAuthor: m.non_user_author === 1,
timestampMs: m.timestamp_ms,
content: m.content,
reactionEmoji: this.reactionEmoji(m.channel_id, m.id),
mentions: this.mentions(m.channel_id, m.id),
}));
}
reactions(
channel: Model.Snowflake,
message: Model.Snowflake
): $ReadOnlyArray<Model.Reaction> {
return this._db
.prepare(
dedent`\
SELECT
channel_id,
message_id,
author_id,
emoji
FROM message_reactions
WHERE channel_id = :channel_id
AND message_id = :message_id`
)
.all({channel_id: channel, message_id: message})
.map((r) => ({
channelId: r.channel_id,
messageId: r.message_id,
authorId: r.author_id,
emoji: Model.refToEmoji(r.emoji),
}));
}
mentions(
channel: Model.Snowflake,
message: Model.Snowflake
): $ReadOnlyArray<Model.Snowflake> {
return this._db
.prepare(
dedent`\
SELECT user_id
FROM message_mentions
WHERE channel_id = :channel_id
AND message_id = :message_id`
)
.all({channel_id: channel, message_id: message})
.map((res) => res.user_id);
}
addUser(user: Model.User) {
this._db
.prepare(
dedent`\
REPLACE INTO users (
id,
username,
discriminator,
bot
) VALUES (
:id,
:username,
:discriminator,
:bot
)
`
)
.run({
id: user.id,
username: user.username,
discriminator: user.discriminator,
bot: Number(user.bot),
});
}
/**
* Because a User is represented in a Member object, we save the User in
* `addMember`.
*/
addMember(member: Model.GuildMember) {
this.addUser(member.user);
this._db
.prepare(
dedent`\
REPLACE INTO members (
user_id,
nick
) VALUES (
:user_id,
:nick
)
`
)
.run({
user_id: member.user.id,
nick: member.nick,
});
}
addChannel(channel: Model.Channel) {
this._db
.prepare(
dedent`\
REPLACE INTO channels (
id,
type,
name
) VALUES (
:id,
:type,
:name
)
`
)
.run({
id: channel.id,
type: channel.type,
name: channel.name,
});
}
addMessage(message: Model.Message) {
this._db
.prepare(
dedent`\
REPLACE INTO messages (
id,
channel_id,
author_id,
non_user_author,
timestamp_ms,
content
) VALUES (
:id,
:channel_id,
:author_id,
:non_user_author,
:timestamp_ms,
:content
)
`
)
.run({
id: message.id,
channel_id: message.channelId,
author_id: message.authorId,
non_user_author: Number(message.nonUserAuthor),
timestamp_ms: message.timestampMs,
content: message.content,
});
}
addReaction(reaction: Model.Reaction) {
this._db
.prepare(
dedent`\
REPLACE INTO message_reactions (
channel_id,
message_id,
author_id,
emoji
) VALUES (
:channel_id,
:message_id,
:author_id,
:emoji
)`
)
.run({
channel_id: reaction.channelId,
message_id: reaction.messageId,
author_id: reaction.authorId,
emoji: Model.emojiToRef(reaction.emoji),
});
}
addMention(message: Model.Message, user: Model.Snowflake) {
this._db
.prepare(
dedent`\
REPLACE INTO message_mentions (
channel_id,
message_id,
user_id
) VALUES (
:channel_id,
:message_id,
:user_id
)
`
)
.run({
channel_id: message.channelId,
message_id: message.id,
user_id: user,
});
}
reactionEmoji(
channel: Model.Snowflake,
message: Model.Snowflake
): $ReadOnlyArray<Model.Emoji> {
return this._db
.prepare(
dedent`\
SELECT DISTINCT
emoji
FROM message_reactions
WHERE channel_id = :channel_id
AND message_id = :message_id`
)
.all({channel_id: channel, message_id: message})
.map((e) => Model.refToEmoji(e.emoji));
}
}

View File

@ -0,0 +1,462 @@
// @flow
import Database from "better-sqlite3";
import {SqliteMirror} from "./sqliteMirror";
import dedent from "../../util/dedent";
import {
type Channel,
type Message,
type GuildMember,
type Snowflake,
type Emoji,
type User,
} from "./models";
describe("plugins/discord/sqliteMirror", () => {
const customEmoji = (): Emoji => ({id: "id", name: "name"});
const genericEmoji = (): Emoji => ({id: null, name: "🐙"});
const testChannel = (id: Snowflake): Channel => ({
id: id,
name: "testChannelName",
type: "GUILD_TEXT",
});
const testMessage = (
id: Snowflake,
channelId: Snowflake,
authorId: Snowflake
): Message => ({
id: id,
channelId: channelId,
authorId: authorId,
timestampMs: Date.parse("2020-03-03T23:35:10.615000+00:00"),
content: "Just going to drop this here",
reactionEmoji: [customEmoji()],
nonUserAuthor: false,
mentions: ["1", "23"],
});
const testUser = (id: Snowflake): User => ({
id: id,
username: "username",
discriminator: "disc",
bot: true,
});
const testMember = (userId: Snowflake): GuildMember => ({
user: testUser(userId),
nick: "nickname",
});
describe("constructor", () => {
it("initializes a new database succsessfully", () => {
const db = new Database(":memory:");
expect(() => new SqliteMirror(db, "0")).not.toThrow();
});
it("rejects a different config", () => {
const db = new Database(":memory:");
const _ = new SqliteMirror(db, "0");
expect(() => new SqliteMirror(db, "1")).toThrow(
"Database already populated with incompatible server or version"
);
});
it("creates the right set of tables", () => {
const db = new Database(":memory:");
new SqliteMirror(db, "0");
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.pluck()
.all();
expect(tables.sort()).toEqual(
[
"meta",
"channels",
"users",
"members",
"messages",
"message_reactions",
"message_mentions",
].sort()
);
});
});
describe("users", () => {
it("inserts users", async () => {
const user = testUser("1");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(user);
const result = db
.prepare(
dedent`\
SELECT
id,
username,
discriminator,
bot
FROM users
`
)
.get();
expect(result).toEqual({
id: user.id,
username: user.username,
discriminator: user.discriminator,
bot: 1,
});
});
describe("user", () => {
it("retrieves user", () => {
const user: User = testUser("1");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(user);
const result = sqliteMirror.user(user.id);
expect(result).toEqual(user);
});
it("returns null if user not in table", () => {
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
const result = sqliteMirror.user("1");
expect(result).toEqual(null);
});
});
it("retrieves users", async () => {
const user1: User = testUser("1");
const user2: User = testUser("2");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(user1);
sqliteMirror.addUser(user2);
const result = sqliteMirror.users();
expect(result).toEqual([user1, user2]);
});
});
describe("members", () => {
it("inserts members", async () => {
const member = testMember("1");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addMember(member);
const result = db
.prepare(
dedent`\
SELECT
user_id,
nick
FROM members`
)
.get();
expect(result).toEqual({
user_id: member.user.id,
nick: member.nick,
});
});
it("inserts user", () => {
const member = testMember("1");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addMember(member);
expect(sqliteMirror.user(member.user.id)).toEqual(member.user);
});
it("retrieves members", async () => {
const mem1: GuildMember = testMember("1");
const mem2: GuildMember = testMember("2");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addMember(mem1);
sqliteMirror.addMember(mem2);
const result = sqliteMirror.members();
expect(result).toEqual([mem1, mem2]);
});
});
describe("member", () => {
it("retrieves member", () => {
const member: GuildMember = testMember("1");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addMember(member);
const result = sqliteMirror.member(member.user.id);
expect(result).toEqual(member);
});
it("returns null if user not in table", () => {
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
const result = sqliteMirror.member("1");
expect(result).toEqual(null);
});
});
describe("channels", () => {
it("inserts channels", async () => {
const channel = testChannel("1");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addChannel(channel);
const result = await db
.prepare("SELECT id, type, name FROM channels")
.get();
expect(result).toEqual(channel);
});
it("retrieves channels", () => {
const ch1: Channel = testChannel("1");
const ch2: Channel = testChannel("2");
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addChannel(ch1);
sqliteMirror.addChannel(ch2);
const result = sqliteMirror.channels();
expect(result).toEqual([ch1, ch2]);
});
});
describe("messages", () => {
it("inserts messages", async () => {
const messageId = "1";
const channelId = "2";
const authorId = "3";
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(testUser(authorId));
sqliteMirror.addChannel(testChannel(channelId));
sqliteMirror.addMessage(testMessage(messageId, channelId, authorId));
const result = await db
.prepare(
dedent`\
SELECT
id,
channel_id,
author_id,
non_user_author,
timestamp_ms,
content
FROM messages`
)
.get();
expect(result).toEqual({
id: "1",
channel_id: "2",
author_id: "3",
non_user_author: 0,
timestamp_ms: 1583278510615,
content: "Just going to drop this here",
});
});
it("retrieves messages", () => {
const messageId1 = "1";
const messageId2 = "2";
const channelId = "3";
const authorId = "4";
const mes1: Message = testMessage(messageId1, channelId, authorId);
const mes2: Message = testMessage(messageId2, channelId, authorId);
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(testUser(authorId));
sqliteMirror.addChannel(testChannel(channelId));
sqliteMirror.addMessage(mes1);
sqliteMirror.addMessage(mes2);
for (const user of mes1.mentions) {
sqliteMirror.addUser(testUser(user));
sqliteMirror.addMention(mes1, user);
}
for (const user of mes2.mentions) {
sqliteMirror.addUser(testUser(user));
sqliteMirror.addMention(mes2, user);
}
for (const emoji of mes1.reactionEmoji) {
sqliteMirror.addReaction({
emoji,
channelId: mes1.channelId,
messageId: mes1.id,
authorId: mes1.authorId,
});
}
for (const emoji of mes2.reactionEmoji) {
sqliteMirror.addReaction({
emoji,
channelId: mes2.channelId,
messageId: mes2.id,
authorId: mes2.authorId,
});
}
const result = sqliteMirror.messages(channelId);
expect(result).toEqual([mes1, mes2]);
});
});
describe("reactions", () => {
it("inserts reactions", async () => {
const channelId: Snowflake = "2";
const messageId: Snowflake = "3";
const authorId: Snowflake = "4";
const reaction = {
emoji: customEmoji(),
channelId: channelId,
messageId: messageId,
authorId: authorId,
};
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(testUser(authorId));
sqliteMirror.addChannel(testChannel(channelId));
sqliteMirror.addMessage(testMessage(messageId, channelId, authorId));
sqliteMirror.addReaction(reaction);
const result = await db
.prepare(
dedent`\
SELECT
channel_id,
author_id,
message_id,
emoji
FROM message_reactions`
)
.get();
expect(result).toEqual({
emoji: "name:id",
channel_id: channelId,
message_id: messageId,
author_id: authorId,
});
});
it("retrieves reactions", () => {
const channelId: Snowflake = "1";
const messageId: Snowflake = "2";
const authorId1 = "3";
const authorId2 = "4";
const reaction1 = {
emoji: customEmoji(),
channelId: channelId,
messageId: messageId,
authorId: authorId1,
};
const reaction2 = {
emoji: genericEmoji(),
channelId: channelId,
messageId: messageId,
authorId: authorId2,
};
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(testUser(authorId1));
sqliteMirror.addUser(testUser(authorId2));
sqliteMirror.addChannel(testChannel(channelId));
sqliteMirror.addMessage(testMessage(messageId, channelId, authorId1));
sqliteMirror.addReaction(reaction1);
sqliteMirror.addReaction(reaction2);
const result = sqliteMirror.reactions(channelId, messageId);
expect(result).toEqual([reaction1, reaction2]);
});
});
describe("mentions", () => {
it("inserts mentions", async () => {
const messageId: Snowflake = "1";
const channelId: Snowflake = "2";
const authorId: Snowflake = "3";
const message: Message = testMessage(messageId, channelId, authorId);
const userId: Snowflake = "45";
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(testUser(userId));
sqliteMirror.addUser(testUser(authorId));
sqliteMirror.addChannel(testChannel(channelId));
sqliteMirror.addMessage(testMessage(messageId, channelId, authorId));
sqliteMirror.addMention(message, userId);
const result = await db
.prepare(
`\
SELECT
channel_id,
message_id,
user_id
FROM message_mentions`
)
.get();
expect(result).toEqual({
channel_id: channelId,
message_id: messageId,
user_id: userId,
});
});
it("retrieves mentions", () => {
const messageId: Snowflake = "1";
const channelId = "2";
const authorId = "3";
const message: Message = testMessage(messageId, channelId, authorId);
const [userId1, userId2] = message.mentions;
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(testUser(userId1));
sqliteMirror.addUser(testUser(userId2));
sqliteMirror.addUser(testUser(authorId));
sqliteMirror.addChannel(testChannel(channelId));
sqliteMirror.addMessage(testMessage(messageId, channelId, authorId));
sqliteMirror.addMention(message, userId1);
sqliteMirror.addMention(message, userId2);
const result = sqliteMirror.mentions(message.channelId, messageId);
expect(result).toEqual([userId1, userId2]);
});
});
describe("reactionEmoji", () => {
it("retrieves emojis", () => {
const channelId: Snowflake = "1";
const messageId: Snowflake = "2";
const authorId1: Snowflake = "3";
const authorId2: Snowflake = "4";
const messageAuthorId: Snowflake = "5";
const reaction1 = {
emoji: customEmoji(),
channelId: channelId,
messageId: messageId,
authorId: authorId1,
};
const reaction2 = {
emoji: genericEmoji(),
channelId: channelId,
messageId: messageId,
authorId: authorId2,
};
const db = new Database(":memory:");
const sqliteMirror = new SqliteMirror(db, "0");
sqliteMirror.addUser(testUser(authorId1));
sqliteMirror.addUser(testUser(authorId2));
sqliteMirror.addChannel(testChannel(channelId));
sqliteMirror.addMessage(
testMessage(messageId, channelId, messageAuthorId)
);
sqliteMirror.addReaction(reaction1);
sqliteMirror.addReaction(reaction2);
const result = sqliteMirror.reactionEmoji(channelId, messageId);
expect(result).toEqual([customEmoji(), genericEmoji()]);
});
});
});