From 898282becce7e6d15219caa1874885bff44ff7f8 Mon Sep 17 00:00:00 2001 From: Brian Litwin Date: Thu, 16 Apr 2020 11:35:57 -0400 Subject: [PATCH] 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 --- src/plugins/discord/models.js | 11 + src/plugins/discord/models.test.js | 18 +- src/plugins/discord/sqliteMirror.js | 500 +++++++++++++++++++++++ src/plugins/discord/sqliteMirror.test.js | 462 +++++++++++++++++++++ 4 files changed, 984 insertions(+), 7 deletions(-) create mode 100644 src/plugins/discord/sqliteMirror.js create mode 100644 src/plugins/discord/sqliteMirror.test.js diff --git a/src/plugins/discord/models.js b/src/plugins/discord/models.js index 9bd2978..7750d81 100644 --- a/src/plugins/discord/models.js +++ b/src/plugins/discord/models.js @@ -101,6 +101,17 @@ export function emojiToRef({id, name}: Emoji): EmojiRef { 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 export function isAuthoredByNonUser(rawMessage: { +webhook_id?: Snowflake, diff --git a/src/plugins/discord/models.test.js b/src/plugins/discord/models.test.js index 5f9f95a..42209fb 100644 --- a/src/plugins/discord/models.test.js +++ b/src/plugins/discord/models.test.js @@ -1,19 +1,23 @@ // @flow -import {emojiToRef, isAuthoredByNonUser} from "./models"; +import {emojiToRef, refToEmoji, isAuthoredByNonUser} from "./models"; describe("plugins/discord/models", () => { describe("model helper functions", () => { describe("emojiToRef", () => { it("returns name if id is null", () => { - expect(emojiToRef({id: null, name: "testEmojiName"})).toBe( - "testEmojiName" - ); + expect(emojiToRef({id: null, name: "🐙"})).toBe("🐙"); }); it("returns name and id if id is not null", () => { - expect(emojiToRef({id: "testEmojiId", name: "testEmojiName"})).toBe( - "testEmojiName:testEmojiId" - ); + expect(emojiToRef({id: "id", name: "name"})).toBe("name:id"); + }); + }); + 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", () => { diff --git a/src/plugins/discord/sqliteMirror.js b/src/plugins/discord/sqliteMirror.js new file mode 100644 index 0000000..d4d87dc --- /dev/null +++ b/src/plugins/discord/sqliteMirror.js @@ -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 { + 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 { + 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 { + return this._db + .prepare( + dedent`\ + SELECT + id, + type, + name + FROM channels` + ) + .all(); + } + + messages(channel: Model.Snowflake): $ReadOnlyArray { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/src/plugins/discord/sqliteMirror.test.js b/src/plugins/discord/sqliteMirror.test.js new file mode 100644 index 0000000..368a332 --- /dev/null +++ b/src/plugins/discord/sqliteMirror.test.js @@ -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()]); + }); + }); +});