diff --git a/src/db/roomstore.ts b/src/db/roomstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..26e467aeb296b80fa89d5a2298aab982f8ceb5e9 --- /dev/null +++ b/src/db/roomstore.ts @@ -0,0 +1,323 @@ +import { Log } from "../log"; +import { IDatabaseConnector } from "./connector"; + +import * as uuid from "uuid/v4"; + +const log = new Log("DbRoomStore"); + +/** + * A RoomStore compatible with + * https://github.com/matrix-org/matrix-appservice-bridge/blob/master/lib/components/room-bridge-store.js + * that accesses the database instead. + */ + +interface IRemoteRoomData { + discord_guild: string; + discord_channel: string; + discord_name?: string|null; + discord_topic?: string|null; + discord_type?: string|null; + discord_iconurl?: string|null; + discord_iconurl_mxc?: string|null; + update_name?: number|null; + update_topic?: number|null; + update_icon?: number|null; + plumbed?: number|null; +} + +export class RemoteStoreRoom { + public data: IRemoteRoomData; + constructor(public readonly roomId: string, data: IRemoteRoomData) { + for (const k of ["discord_guild", "discord_channel", "discord_name", + "discord_topic", "discord_iconurl", "discord_iconurl_mxc", "discord_type"]) { + data[k] = typeof(data[k]) === "number" ? String(data[k]) : data[k] || null; + } + for (const k of ["update_name", "update_topic", "update_icon", "plumbed"]) { + data[k] = Number(data[k]) || 0; + } + this.data = data; + } + + public getId() { + return this.roomId; + } + + public get(key: string): string|boolean|null { + return this.data[key]; + } + + public set(key: string, value: string|boolean|null) { + this.data[key] = typeof(value) === "boolean" ? Number(value) : value; + } +} + +export class MatrixStoreRoom { + constructor(public readonly roomId: string) { } + + public getId() { + return this.roomId; + } +} + +export interface IRoomStoreEntry { + id: string; + matrix: MatrixStoreRoom|null; + remote: RemoteStoreRoom|null; +} + +const ENTRY_CACHE_LIMETIME = 30000; + +// XXX: This only implements functions used in the bridge at the moment. +export class DbRoomStore { + + private entriesMatrixIdCache: Map<string, {e: IRoomStoreEntry[], ts: number}>; + + constructor(private db: IDatabaseConnector) { + this.entriesMatrixIdCache = new Map(); + } + + public async upsertEntry(entry: IRoomStoreEntry) { + const promises: Promise<void>[] = []; + // N.b. Sqlite and postgres don't really have a easy way to do upserts. + const row = (await this.db.Get("SELECT * FROM room_entries WHERE id = $id", {id: entry.id})) || {}; + + if (!row.id) { + // Doesn't exist at all, create the room_entries row. + const values = { + id: entry.id, + matrix: entry.matrix ? entry.matrix.roomId : null, + remote: entry.remote ? entry.remote.roomId : null, + }; + try { + await this.db.Run(`INSERT INTO room_entries VALUES ($id, $matrix, $remote)`, values); + log.verbose("Created new entry " + entry.id); + } catch (ex) { + log.error("Failed to insert room entry", ex); + throw Error("Failed to insert room entry"); + } + } + + const matrixId = entry.matrix ? entry.matrix.roomId : null; + const remoteId = entry.remote ? entry.remote.roomId : null; + const mxIdDifferent = matrixId !== row.matrix_id; + const rmIdDifferent = remoteId !== row.remote_id; + // Did the room ids change? + if (row.id && (mxIdDifferent || rmIdDifferent)) { + const items: string[] = []; + + if (mxIdDifferent) { + items.push("matrix_id = $matrixId"); + } + + if (rmIdDifferent) { + items.push("remote_id = $remoteId"); + } + + await this.db.Run(`UPDATE room_entries SET ${items.join(", ")} WHERE id = $id`, + { + id: entry.id, + matrixId: matrixId as string|null, + remoteId: remoteId as string|null, + }, + ); + } + + // Matrix room doesn't store any data. + + if (entry.remote) { + await this.upsertRoom(entry.remote); + } + } + + public async getEntriesByMatrixId(matrixId: string): Promise<IRoomStoreEntry[]> { + const cached = this.entriesMatrixIdCache.get(matrixId); + if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) { + return cached.e; + } + const entries = await this.db.All( + "SELECT * FROM room_entries WHERE matrix_id = $id", {id: matrixId}, + ); + const res: IRoomStoreEntry[] = []; + for (const entry of entries) { + const remoteId = entry.remote_id as string || ""; + const row = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $rid", + {rid: remoteId}, + ); + if (!row) { continue; } + + res.push({ + id: (entry.id as string), + matrix: matrixId ? new MatrixStoreRoom(matrixId) : null, + // tslint:disable-next-line no-any + remote: remoteId ? new RemoteStoreRoom(remoteId, row as any) : null, + }); + } + this.entriesMatrixIdCache.set(matrixId, {e: res, ts: Date.now()}); + return res; + } + + public async getEntriesByMatrixIds(matrixIds: string[]): Promise<IRoomStoreEntry[]> { + const entries = await this.db.All( + `SELECT * FROM room_entries WHERE matrix_id IN ('${matrixIds.join("','")}')`, + ); + const res: IRoomStoreEntry[] = []; + for (const entry of entries) { + const matrixId = entry.matrix_id as string || ""; + const remoteId = entry.remote_id as string || ""; + const row = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $rid", + {rid: remoteId}, + ); + if (!row) { continue; } + + res.push({ + id: (entry.id as string), + matrix: matrixId ? new MatrixStoreRoom(matrixId) : null, + // tslint:disable-next-line no-any + remote: remoteId ? new RemoteStoreRoom(remoteId, row as any) : null, + }); + } + return res; + } + + public async linkRooms(matrixRoom: MatrixStoreRoom, remoteRoom: RemoteStoreRoom) { + await this.upsertRoom(remoteRoom); + + const values = { + id: uuid(), + matrix: matrixRoom.roomId, + remote: remoteRoom.roomId, + }; + + try { + await this.db.Run(`INSERT INTO room_entries VALUES ($id, $matrix, $remote)`, values); + log.verbose("Created new entry " + values.id); + } catch (ex) { + log.error("Failed to insert room entry", ex); + throw Error("Failed to insert room entry"); + } + } + + public async setMatrixRoom(matrixRoom: MatrixStoreRoom) { + // This no-ops, because we don't store anything interesting. + } + + public async getEntriesByRemoteRoomData(data: {[key: string]: string}): Promise<IRoomStoreEntry[]> { + const whereClaues = Object.keys(data).map((key) => { + return `${key} = $${key}`; + }).join(" AND "); + const sql = ` + SELECT * FROM remote_room_data + INNER JOIN room_entries ON remote_room_data.room_id = room_entries.remote_id + WHERE ${whereClaues}`; + // tslint:disable-next-line no-any + return (await this.db.All(sql, data as any)).map((row) => { + const id = row.id as string; + const matrixId = row.matrix_id; + const remoteId = row.room_id; + return { + id, + matrix: matrixId ? new MatrixStoreRoom(matrixId as string) : null, + // tslint:disable-next-line no-any + remote: matrixId ? new RemoteStoreRoom(remoteId as string, row as any) : null, + }; + }); + } + + public async removeEntriesByRemoteRoomId(remoteId: string) { + await this.db.Run(`DELETE FROM room_entries WHERE remote_id = $remoteId`, {remoteId}); + await this.db.Run(`DELETE FROM remote_room_data WHERE room_id = $remoteId`, {remoteId}); + } + + public async removeEntriesByMatrixRoomId(matrixId: string) { + await this.db.Run(`DELETE FROM room_entries WHERE matrix_id = $matrixId`, {matrixId}); + await this.db.Run(`DELETE FROM remote_room_data WHERE room_id = $matrixId`, {matrixId}); + } + + private async upsertRoom(room: RemoteStoreRoom) { + const existingRow = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $id", + {id: room.roomId}, + ); + + if (!room.data) { + throw new Error("Tried to upsert a room with undefined data"); + } + + const data = { + discord_channel: room.data.discord_channel, + discord_guild: room.data.discord_guild, + discord_iconurl: room.data.discord_iconurl, + discord_iconurl_mxc: room.data.discord_iconurl_mxc, + discord_name: room.data.discord_name, + discord_topic: room.data.discord_topic, + discord_type: room.data.discord_type, + plumbed: room.data.plumbed || 0, + update_icon: room.data.update_icon || 0, + update_name: room.data.update_name || 0, + update_topic: room.data.update_topic || 0, + } as IRemoteRoomData; + + if (!existingRow) { + // Insert new data. + await this.db.Run( + `INSERT INTO remote_room_data VALUES ( + $id, + $discord_guild, + $discord_channel, + $discord_name, + $discord_topic, + $discord_type, + $discord_iconurl, + $discord_iconurl_mxc, + $update_name, + $update_topic, + $update_icon, + $plumbed + ) + `, + { + id: room.roomId, + // tslint:disable-next-line no-any + ...data as any, + }); + return; + } + + const keysToUpdate = { }; + + // New keys + Object.keys(room.data).filter( + (k: string) => existingRow[k] === null).forEach((key) => { + keysToUpdate[key] = room.data[key]; + }); + + // Updated keys + Object.keys(room.data).filter( + (k: string) => existingRow[k] !== room.data[k]).forEach((key) => { + keysToUpdate[key] = room.data[key]; + }); + + if (Object.keys(keysToUpdate).length === 0) { + return; + } + + const setStatement = Object.keys(keysToUpdate).map((k) => { + return `${k} = $${k}`; + }).join(", "); + + try { + await this.db.Run(`UPDATE remote_room_data SET ${setStatement} WHERE room_id = $id`, + { + id: room.roomId, + // tslint:disable-next-line no-any + ...keysToUpdate, + }); + log.verbose("Upserted room " + room.roomId); + } catch (ex) { + log.error("Failed to upsert room", ex); + throw Error("Failed to upsert room"); + } + } +} diff --git a/test/db/test_roomstore.ts b/test/db/test_roomstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..734c6fcfc1c3eee60027f1f20f85ff2db67e846d --- /dev/null +++ b/test/db/test_roomstore.ts @@ -0,0 +1,193 @@ +/* +Copyright 2017, 2018 matrix-appservice-discord + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as Chai from "chai"; +// import * as Proxyquire from "proxyquire"; +import { DiscordStore, CURRENT_SCHEMA } from "../../src/store"; +import { RemoteStoreRoom, MatrixStoreRoom } from "../../src/db/roomstore"; + +// we are a test file and thus need those +/* tslint:disable: no-any no-unused-expression */ + +const expect = Chai.expect; + +// const assert = Chai.assert; +let store: DiscordStore; +describe("DiscordStore", () => { + before(async () => { + store = new DiscordStore(":memory:"); + await store.init(); + }); + describe("upsertEntry|getEntriesByMatrixId", () => { + it("will create a new entry", async () => { + await store.roomStore.upsertEntry({ + id: "test1", + matrix: new MatrixStoreRoom("!abc:def.com"), + remote: new RemoteStoreRoom("123456_789", {discord_guild: "123", discord_channel: "456"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("!abc:def.com"))[0]; + expect(entry.id).to.equal("test1"); + expect(entry.matrix!.roomId).to.equal("!abc:def.com"); + expect(entry.remote!.roomId).to.equal("123456_789"); + expect(entry.remote!.get("discord_guild")).to.equal("123"); + expect(entry.remote!.get("discord_channel")).to.equal("456"); + }); + it("will update an existing entry's rooms", async () => { + await store.roomStore.upsertEntry({ + id: "test2", + matrix: new MatrixStoreRoom("test2_m"), + remote: new RemoteStoreRoom("test2_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test2", + matrix: new MatrixStoreRoom("test2_2m"), + remote: new RemoteStoreRoom("test2_2r", {discord_guild: "555", discord_channel: "999"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test2_2m"))[0]; + expect(entry.id).to.equal("test2"); + expect(entry.matrix!.roomId).to.equal("test2_2m"); + expect(entry.remote!.roomId).to.equal("test2_2r"); + expect(entry.remote!.get("discord_guild")).to.equal("555"); + expect(entry.remote!.get("discord_channel")).to.equal("999"); + }); + it("will add new data to an existing entry", async () => { + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "123", discord_channel: "456", update_topic: 1}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3_m"))[0]; + expect(entry.id).to.equal("test3"); + expect(entry.matrix!.roomId).to.equal("test3_m"); + expect(entry.remote!.roomId).to.equal("test3_r"); + expect(entry.remote!.get("update_topic")).to.equal(1); + }); + it("will replace data on an existing entry", async () => { + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "-100", discord_channel: "seventythousand"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3_m"))[0]; + expect(entry.id).to.equal("test3"); + expect(entry.matrix!.roomId).to.equal("test3_m"); + expect(entry.remote!.roomId).to.equal("test3_r"); + expect(entry.remote!.get("discord_guild")).to.equal("-100"); + expect(entry.remote!.get("discord_channel")).to.equal("seventythousand"); + }); + it("will delete data on an existing entry", async () => { + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "123", discord_channel: "456"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3_m"))[0]; + expect(entry.id).to.equal("test3"); + expect(entry.matrix!.roomId).to.equal("test3_m"); + expect(entry.remote!.roomId).to.equal("test3_r"); + expect(entry.remote!.get("baz")).to.be.undefined; + }); + }); + describe("getEntriesByMatrixIds", () => { + it("will get multiple entries", async () => { + const EXPECTED_ROOMS = 2; + await store.roomStore.upsertEntry({ + id: "test4_1", + matrix: new MatrixStoreRoom("test4_m1"), + remote: new RemoteStoreRoom("test4_r", {discord_guild: "five", discord_channel: "five"}), + }); + await store.roomStore.upsertEntry({ + id: "test4_2", + matrix: new MatrixStoreRoom("test4_m2"), + remote: new RemoteStoreRoom("test4_r", {discord_guild: "nine", discord_channel: "nine"}), + }); + const entries = await store.roomStore.getEntriesByMatrixIds(["test4_m1", "test4_m2"]); + expect(entries).to.have.lengthOf(EXPECTED_ROOMS); + expect(entries[0].id).to.equal("test4_1"); + expect(entries[0].matrix!.roomId).to.equal("test4_m1"); + expect(entries[1].id).to.equal("test4_2"); + expect(entries[1].matrix!.roomId).to.equal("test4_m2"); + }); + }); + describe("linkRooms", () => { + it("will link a room", async () => { + const matrix = new MatrixStoreRoom("test5_m"); + const remote = new RemoteStoreRoom("test5_r", {discord_guild: "five", discord_channel: "five"}); + await store.roomStore.linkRooms(matrix, remote); + const entries = await store.roomStore.getEntriesByMatrixId("test5_m"); + expect(entries[0].matrix!.roomId).to.equal("test5_m"); + expect(entries[0].remote!.roomId).to.equal("test5_r"); + expect(entries[0].remote!.get("discord_guild")).to.equal("five"); + expect(entries[0].remote!.get("discord_channel")).to.equal("five"); + }); + }); + describe("getEntriesByRemoteRoomData", () => { + it("will link a room", async () => { + await store.roomStore.upsertEntry({ + id: "test6", + matrix: new MatrixStoreRoom("test6_m"), + remote: new RemoteStoreRoom("test6_r", {discord_guild: "find", discord_channel: "this"}), + }); + const entries = await store.roomStore.getEntriesByRemoteRoomData({ + discord_channel: "this", + discord_guild: "find", + }); + expect(entries[0].matrix!.roomId).to.equal("test6_m"); + expect(entries[0].remote!.roomId).to.equal("test6_r"); + expect(entries[0].remote!.get("discord_guild")).to.equal("find"); + expect(entries[0].remote!.get("discord_channel")).to.equal("this"); + }); + }); + describe("removeEntriesByRemoteRoomId", () => { + it("will remove a room", async () => { + await store.roomStore.upsertEntry({ + id: "test7", + matrix: new MatrixStoreRoom("test7_m"), + remote: new RemoteStoreRoom("test7_r", {discord_guild: "find", discord_channel: "this"}), + }); + await store.roomStore.removeEntriesByRemoteRoomId("test7_r"); + const entries = await store.roomStore.getEntriesByMatrixId("test7_m"); + expect(entries).to.be.empty; + }); + }); + describe("removeEntriesByMatrixRoomId", () => { + it("will remove a room", async () => { + await store.roomStore.upsertEntry({ + id: "test8", + matrix: new MatrixStoreRoom("test8_m"), + remote: new RemoteStoreRoom("test8_r", {discord_guild: "find", discord_channel: "this"}), + }); + await store.roomStore.removeEntriesByRemoteRoomId("test8_m"); + const entries = await store.roomStore.getEntriesByMatrixId("test8_r"); + expect(entries).to.be.empty; + }); + }); +});