diff --git a/src/bot.ts b/src/bot.ts index 74b050beab4da15999b84e6f1da92e2264e7beb3..176d81c87f3e651836420f94ca524fafc4fcf300 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -64,7 +64,6 @@ interface IThirdPartyLookup { export class DiscordBot { private clientFactory: DiscordClientFactory; - private store: DiscordStore; private bot: Discord.Client; private presenceInterval: number; private sentMessages: string[]; @@ -72,7 +71,7 @@ export class DiscordBot { private discordMsgProcessor: DiscordMessageProcessor; private mxEventProcessor: MatrixEventProcessor; private presenceHandler: PresenceHandler; - private userSync: UserSyncroniser; + private userSync!: UserSyncroniser; private channelSync: ChannelSyncroniser; private roomHandler: MatrixRoomHandler; private provisioner: Provisioner; @@ -82,22 +81,25 @@ export class DiscordBot { /* Handles messages queued up to be sent to discord. */ private discordMessageQueue: { [channelId: string]: Promise<void> }; - constructor(private botUserId: string, private config: DiscordBridgeConfig, private bridge: Bridge) { + constructor( + private botUserId: string, + private config: DiscordBridgeConfig, + private bridge: Bridge, + private store: DiscordStore, + ) { - // create classes - this.store = new DiscordStore(config.database); - this.provisioner = new Provisioner(this.bridge); - this.clientFactory = new DiscordClientFactory(this.store, config.auth); + // create handlers + this.provisioner = new Provisioner(store.roomStore); + this.clientFactory = new DiscordClientFactory(store, config.auth); this.discordMsgProcessor = new DiscordMessageProcessor( - new DiscordMessageProcessorOpts(this.config.bridge.domain, this), + new DiscordMessageProcessorOpts(config.bridge.domain, this), ); this.presenceHandler = new PresenceHandler(this); - this.roomHandler = new MatrixRoomHandler(this, this.config, this.provisioner, this.bridge); + this.roomHandler = new MatrixRoomHandler(this, config, this.provisioner, bridge, store.roomStore); this.mxEventProcessor = new MatrixEventProcessor( - new MatrixEventProcessorOpts(this.config, this.bridge, this), + new MatrixEventProcessorOpts(config, bridge, this), ); - this.channelSync = new ChannelSyncroniser(this.bridge, this.config, this); - + this.channelSync = new ChannelSyncroniser(bridge, config, this, store.roomStore); // init vars this.sentMessages = []; this.discordMessageQueue = {}; @@ -135,10 +137,9 @@ export class DiscordBot { } public async init(): Promise<void> { - await this.store.init(); - // This uses userStore which needs to be accessed after the bridge has started. - this.userSync = new UserSyncroniser(this.bridge, this.config, this); await this.clientFactory.init(); + // This immediately pokes UserStore, so it must be created after the bridge has started. + this.userSync = new UserSyncroniser(this.bridge, this.config, this); } public async run(): Promise<void> { @@ -496,7 +497,7 @@ export class DiscordBot { } public async GetChannelFromRoomId(roomId: string, client?: Discord.Client): Promise<Discord.Channel> { - const entries = await this.bridge.getRoomStore().getEntriesByMatrixId( + const entries = await this.store.roomStore.getEntriesByMatrixId( roomId, ); @@ -509,9 +510,12 @@ export class DiscordBot { throw Error("Room(s) not found."); } const entry = entries[0]; - const guild = client.guilds.get(entry.remote.get("discord_guild")); + if (!entry.remote) { + throw Error("Room had no remote component"); + } + const guild = client.guilds.get(entry.remote!.get("discord_guild") as string); if (guild) { - const channel = client.channels.get(entry.remote.get("discord_channel")); + const channel = client.channels.get(entry.remote!.get("discord_channel") as string); if (channel) { return channel; } @@ -567,14 +571,14 @@ export class DiscordBot { this.roomIdsForGuildCache.set(`${guild.id}:${guild.member}`, {roomIds: rooms, ts: Date.now()}); return rooms; } else { - const rooms = await this.bridge.getRoomStore().getEntriesByRemoteRoomData({ + const rooms = await this.store.roomStore.getEntriesByRemoteRoomData({ discord_guild: guild.id, }); if (rooms.length === 0) { log.verbose(`Couldn't find room(s) for guild id:${guild.id}.`); throw new Error("Room(s) not found."); } - const roomIds = rooms.map((room) => room.matrix.getId()); + const roomIds = rooms.map((room) => room.matrix!.getId()); this.roomIdsForGuildCache.set(`${guild.id}:`, {roomIds, ts: Date.now()}); return roomIds; } @@ -837,7 +841,7 @@ export class DiscordBot { await afterSend(res); } catch (e) { if (e.errcode !== "M_FORBIDDEN" && e.errcode !== "M_GUEST_ACCESS_FORBIDDEN") { - log.error("DiscordBot", "Failed to send message into room.", e); + log.error("Failed to send message into room.", e); return; } if (msg.member) { diff --git a/src/channelsyncroniser.ts b/src/channelsyncroniser.ts index 74932cf6215145a1292787055f61d4fa5422bde1..9759dfa091e831ffaa11062686bb50d227e02442 100644 --- a/src/channelsyncroniser.ts +++ b/src/channelsyncroniser.ts @@ -18,8 +18,9 @@ import * as Discord from "discord.js"; import { DiscordBot } from "./bot"; import { Util } from "./util"; import { DiscordBridgeConfig } from "./config"; -import { Bridge, RoomBridgeStore, Entry } from "matrix-appservice-bridge"; +import { Bridge } from "matrix-appservice-bridge"; import { Log } from "./log"; +import { DbRoomStore, IRoomStoreEntry } from "./db/roomstore"; const log = new Log("ChannelSync"); @@ -56,13 +57,13 @@ export interface IChannelState { } export class ChannelSyncroniser { - - private roomStore: RoomBridgeStore; constructor( private bridge: Bridge, private config: DiscordBridgeConfig, - private bot: DiscordBot) { - this.roomStore = this.bridge.getRoomStore(); + private bot: DiscordBot, + private roomStore: DbRoomStore, + ) { + } public async OnUpdate(channel: Discord.Channel) { @@ -111,7 +112,7 @@ export class ChannelSyncroniser { } log.info(`Channel ${channel.id} has been deleted.`); let roomids; - let entries; + let entries: IRoomStoreEntry[]; try { roomids = await this.GetRoomIdsFromChannel(channel); entries = await this.roomStore.getEntriesByMatrixIds(roomids); @@ -139,9 +140,6 @@ export class ChannelSyncroniser { } public async GetRoomIdsFromChannel(channel: Discord.Channel): Promise<string[]> { - if (!this.roomStore) { - this.roomStore = this.bridge.getRoomStore(); - } const rooms = await this.roomStore.getEntriesByRemoteRoomData({ discord_channel: channel.id, }); @@ -149,7 +147,7 @@ export class ChannelSyncroniser { log.verbose(`Couldn't find room(s) for channel ${channel.id}.`); return Promise.reject("Room(s) not found."); } - return rooms.map((room) => room.matrix.getId() as string); + return rooms.map((room) => room.matrix!.getId() as string); } public async GetChannelUpdateState(channel: Discord.TextChannel, forceUpdate = false): Promise<IChannelState> { @@ -159,9 +157,6 @@ export class ChannelSyncroniser { mxChannels: [], }); - if (!this.roomStore) { - this.roomStore = this.bridge.getRoomStore(); - } const remoteRooms = await this.roomStore.getEntriesByRemoteRoomData({discord_channel: channel.id}); if (remoteRooms.length === 0) { log.verbose(`Could not find any channels in room store.`); @@ -183,26 +178,26 @@ export class ChannelSyncroniser { iconUrl = `https://cdn.discordapp.com/icons/${channel.guild.id}/${icon}.png`; } remoteRooms.forEach((remoteRoom) => { - const mxid = remoteRoom.matrix.getId(); + const mxid = remoteRoom.matrix!.getId(); const singleChannelState: ISingleChannelState = Object.assign({}, DEFAULT_SINGLECHANNEL_STATE, { mxid, }); - const oldName = remoteRoom.remote.get("discord_name"); - if (remoteRoom.remote.get("update_name") && (forceUpdate || oldName !== name)) { + const oldName = remoteRoom.remote!.get("discord_name"); + if (remoteRoom.remote!.get("update_name") && (forceUpdate || oldName !== name)) { log.verbose(`Channel ${mxid} name should be updated`); singleChannelState.name = name; } - const oldTopic = remoteRoom.remote.get("discord_topic"); - if (remoteRoom.remote.get("update_topic") && (forceUpdate || oldTopic !== topic)) { + const oldTopic = remoteRoom.remote!.get("discord_topic"); + if (remoteRoom.remote!.get("update_topic") && (forceUpdate || oldTopic !== topic)) { log.verbose(`Channel ${mxid} topic should be updated`); singleChannelState.topic = topic; } - const oldIconUrl = remoteRoom.remote.get("discord_iconurl"); + const oldIconUrl = remoteRoom.remote!.get("discord_iconurl"); // no force on icon update as we don't want to duplicate ALL the icons - if (remoteRoom.remote.get("update_icon") && oldIconUrl !== iconUrl) { + if (remoteRoom.remote!.get("update_icon") && oldIconUrl !== iconUrl) { log.verbose(`Channel ${mxid} icon should be updated`); if (iconUrl !== null) { singleChannelState.iconUrl = iconUrl; @@ -227,6 +222,10 @@ export class ChannelSyncroniser { for (const channelState of channelsState.mxChannels) { let roomUpdated = false; const remoteRoom = (await this.roomStore.getEntriesByMatrixId(channelState.mxid))[0]; + if (!remoteRoom.remote) { + log.warn("Remote room not set for this room"); + return; + } if (channelState.name !== null) { log.verbose(`Updating channelname for ${channelState.mxid} to "${channelState.name}"`); await intent.setRoomName(channelState.mxid, channelState.name); @@ -274,13 +273,13 @@ export class ChannelSyncroniser { private async handleChannelDeletionForRoom( channel: Discord.TextChannel, roomId: string, - entry: Entry): Promise<void> { + entry: IRoomStoreEntry): Promise<void> { log.info(`Deleting ${channel.id} from ${roomId}.`); const intent = await this.bridge.getIntent(); const options = this.config.channel.deleteOptions; - const plumbed = entry.remote.get("plumbed"); + const plumbed = entry.remote!.get("plumbed"); - this.roomStore.upsertEntry(entry); + await this.roomStore.upsertEntry(entry); if (options.ghostsLeave) { for (const member of channel.members.array()) { try { @@ -314,7 +313,7 @@ export class ChannelSyncroniser { if (plumbed !== true) { if (options.unsetRoomAlias) { try { - const alias = `#_${entry.remote.roomId}:${this.config.bridge.domain}`; + const alias = `#_${entry.remote!.roomId}:${this.config.bridge.domain}`; const canonicalAlias = await intent.getClient().getStateEvent(roomId, "m.room.canonical_alias"); if (canonicalAlias.alias === alias) { await intent.getClient().sendStateEvent(roomId, "m.room.canonical_alias", {}); diff --git a/src/db/connector.ts b/src/db/connector.ts index fc6b74fddf1ef2c721d80b07405ba51fabc26630..d247db60dcc914e95f3efb65ba3ac9b8ac84f200 100644 --- a/src/db/connector.ts +++ b/src/db/connector.ts @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +type SQLTYPES = number | boolean | string | null; + export interface ISqlCommandParameters { - [paramKey: string]: number | boolean | string | Promise<number | boolean | string>; + [paramKey: string]: SQLTYPES | Promise<SQLTYPES>; } export interface ISqlRow { - [key: string]: number | boolean | string; + [key: string]: SQLTYPES; } export interface IDatabaseConnector { diff --git a/src/db/postgres.ts b/src/db/postgres.ts index 834f841b00cd19042b7e1223c180f408e340e387..aa9116d9dc94fe0585c701b79901e334796a10bd 100644 --- a/src/db/postgres.ts +++ b/src/db/postgres.ts @@ -17,7 +17,7 @@ limitations under the License. import * as pgPromise from "pg-promise"; import { Log } from "../log"; import { IDatabaseConnector, ISqlCommandParameters, ISqlRow } from "./connector"; -const log = new Log("SQLite3"); +const log = new Log("Postgres"); const pgp: pgPromise.IMain = pgPromise({ // Initialization Options diff --git a/src/db/roomstore.ts b/src/db/roomstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..054a468cf871da86fc481ba28cd893d98a8fa5ab --- /dev/null +++ b/src/db/roomstore.ts @@ -0,0 +1,368 @@ +/* +Copyright 2019 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 { 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 extends IRemoteRoomDataLazy { + discord_guild: string; + discord_channel: string; +} + +interface IRemoteRoomDataLazy { + 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|boolean|null; + update_topic?: number|boolean|null; + update_icon?: number|boolean|null; + plumbed?: number|boolean|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>[] = []; + + 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 (mxIdDifferent || rmIdDifferent) { + if (matrixId) { + this.entriesMatrixIdCache.delete(matrixId); + } + 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) { + let remote: RemoteStoreRoom|null = null; + if (entry.remote_id) { + const remoteId = entry.remote_id as string; + const row = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $remoteId", + {remoteId}, + ); + if (row) { + // tslint:disable-next-line no-any + remote = new RemoteStoreRoom(remoteId, row as any); + } + } + if (remote) { + // Only push rooms with a remote + res.push({ + id: (entry.id as string), + matrix: new MatrixStoreRoom(matrixId), + remote, + }); + } + } + if (res.length > 0) { + this.entriesMatrixIdCache.set(matrixId, {e: res, ts: Date.now()}); + } + return res; + } + + public async getEntriesByMatrixIds(matrixIds: string[]): Promise<IRoomStoreEntry[]> { + const mxIdMap = { }; + matrixIds.forEach((mxId, i) => mxIdMap[i] = mxId); + const sql = `SELECT * FROM room_entries WHERE matrix_id IN (${matrixIds.map((_, id) => `\$${id}`).join(", ")})`; + const entries = await this.db.All(sql, mxIdMap); + const res: IRoomStoreEntry[] = []; + for (const entry of entries) { + let remote: RemoteStoreRoom|null = null; + const matrixId = entry.matrix_id as string || ""; + const remoteId = entry.remote_id as string; + if (remoteId) { + const row = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $rid", + {rid: remoteId}, + ); + if (row) { + // tslint:disable-next-line no-any + remote = new RemoteStoreRoom(remoteId, row as any); + } + } + if (remote) { + // Only push rooms with a remote + res.push({ + id: (entry.id as string), + matrix: matrixId ? new MatrixStoreRoom(matrixId) : null, + remote, + }); + } + } + 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: IRemoteRoomDataLazy): 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) { + const entries = (await this.db.All(`SELECT * room_entries WHERE matrix_id = $matrixId`, {matrixId})) || []; + entries.map((entry) => { + if (entry.remote_id) { + return this.removeEntriesByRemoteRoomId(entry.remote_id as string); + } else if (entry.matrix_id) { + return this.db.Run(`DELETE FROM room_entries WHERE matrix_id = $matrixId`, {matrixId: entry.matrix_id}); + } + }); + } + + private async upsertRoom(room: RemoteStoreRoom) { + if (!room.data) { + throw new Error("Tried to upsert a room with undefined data"); + } + + const existingRow = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $id", + {id: room.roomId}, + ); + + 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 = { } as IRemoteRoomDataLazy; + + // 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 as any, + }); + log.verbose("Upserted room " + room.roomId); + } catch (ex) { + log.error("Failed to upsert room", ex); + throw Error("Failed to upsert room"); + } + } +} diff --git a/src/db/schema/dbschema.ts b/src/db/schema/dbschema.ts index 9f215f1b6d6aa32783030406660cb3dbf36dfed9..132e7c003155f0175ddb8b8891e78d863273baea 100644 --- a/src/db/schema/dbschema.ts +++ b/src/db/schema/dbschema.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { DiscordStore } from "../../store"; +import { DiscordBridgeConfigDatabase } from "../../config"; export interface IDbSchema { description: string; run(store: DiscordStore): Promise<null|void|Error|Error[]>; diff --git a/src/db/schema/v8.ts b/src/db/schema/v8.ts new file mode 100644 index 0000000000000000000000000000000000000000..96890f97322fb1b7b407a93d9c09d6ddc977e525 --- /dev/null +++ b/src/db/schema/v8.ts @@ -0,0 +1,96 @@ +/* +Copyright 2019 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; +import { Log } from "../../log"; +import { + RoomStore, +} from "matrix-appservice-bridge"; +import { RemoteStoreRoom, MatrixStoreRoom } from "../roomstore"; +const log = new Log("SchemaV8"); + +export class Schema implements IDbSchema { + public description = "create room store tables"; + + constructor(private roomStore: RoomStore|null) { + + } + + public async run(store: DiscordStore): Promise<void> { + await store.create_table(` + CREATE TABLE remote_room_data ( + room_id TEXT NOT NULL, + discord_guild TEXT NOT NULL, + discord_channel TEXT NOT NULL, + discord_name TEXT DEFAULT NULL, + discord_topic TEXT DEFAULT NULL, + discord_type TEXT DEFAULT NULL, + discord_iconurl TEXT DEFAULT NULL, + discord_iconurl_mxc TEXT DEFAULT NULL, + update_name NUMERIC DEFAULT 0, + update_topic NUMERIC DEFAULT 0, + update_icon NUMERIC DEFAULT 0, + plumbed NUMERIC DEFAULT 0, + PRIMARY KEY(room_id) + );`, "remote_room_data"); + + await store.create_table(` + CREATE TABLE room_entries ( + id TEXT NOT NULL, + matrix_id TEXT, + remote_id TEXT, + PRIMARY KEY(id) + );`, "room_entries"); + + if (this.roomStore === null) { + log.warn("Not migrating rooms from room store, room store is null"); + return; + } + log.warn("Migrating rooms from roomstore, this may take a while..."); + const rooms = await this.roomStore.select({}); + log.info(`Found ${rooms.length} rooms in the DB`); + // Matrix room only entrys are useless. + const entrys = rooms.filter((r) => r.remote); + log.info(`Filtered out rooms without remotes. Have ${entrys.length} entries`); + let migrated = 0; + for (const e of entrys) { + const matrix = new MatrixStoreRoom(e.matrix_id); + try { + const remote = new RemoteStoreRoom(e.remote_id, e.remote); + await store.roomStore.linkRooms(matrix, remote); + log.info(`Migrated ${matrix.roomId}`); + migrated++; + } catch (ex) { + log.error(`Failed to link ${matrix.roomId}: `, ex); + } + } + if (migrated !== entrys.length) { + log.error(`Didn't migrate all rooms, ${entrys.length - migrated} failed to be migrated.`); + } else { + log.info("Migrated all rooms successfully"); + } + } + + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Run( + `DROP TABLE IF EXISTS remote_room_data;`, + ); + await store.db.Run( + `DROP TABLE IF EXISTS room_entries;`, + ); + } +} diff --git a/src/discordas.ts b/src/discordas.ts index bf15c03f0912775f2c876a42e9e80b80fdb251ad..56a8f7d24bbdf280cb368ea753bef177faad115b 100644 --- a/src/discordas.ts +++ b/src/discordas.ts @@ -20,9 +20,7 @@ import * as yaml from "js-yaml"; import * as fs from "fs"; import { DiscordBridgeConfig } from "./config"; import { DiscordBot } from "./bot"; -import { MatrixRoomHandler } from "./matrixroomhandler"; import { DiscordStore } from "./store"; -import { Provisioner } from "./provisioner"; import { Log } from "./log"; import "source-map-support/register"; @@ -77,6 +75,7 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) { token: registration.as_token, url: config.bridge.homeserverUrl, }); + const store = new DiscordStore(config.database); const callbacks: { [id: string]: callbackFn; } = {}; @@ -94,11 +93,25 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) { return await callbacks.onAliasQuery(alias, aliasLocalpart); } catch (err) { log.error("Exception thrown while handling \"onAliasQuery\" event", err); } }, - onEvent: async (request, context) => { + onEvent: async (request) => { try { + // Build our own context. + if (!store.roomStore) { + log.warn("Discord store not ready yet, dropping message"); + return; + } + const roomId = request.getData().room_id; + let context = {}; + if (roomId) { + const entries = await store.roomStore.getEntriesByMatrixId(request.getData().room_id); + context = { + rooms: entries[0], + }; + } await request.outcomeFrom(Bluebird.resolve(callbacks.onEvent(request, context))); } catch (err) { log.error("Exception thrown while handling \"onEvent\" event", err); + await request.outcomeFrom(Bluebird.reject("Failed to handle")); } }, onLog: (line, isError) => { @@ -112,6 +125,7 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) { } }, }, + disableContext: true, domain: config.bridge.domain, homeserverUrl: config.bridge.homeserverUrl, intentOptions: { @@ -119,17 +133,33 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) { dontJoin: true, // handled manually }, }, + // To avoid out of order message sending. queue: { perRequest: true, type: "per_room", }, registration, + // These must be kept for a while yet since we use them for migrations. roomStore: config.database.roomStorePath, userStore: config.database.userStorePath, - // To avoid out of order message sending. }); - // Warn and deprecate old config options. - const discordbot = new DiscordBot(botUserId, config, bridge); + + if (config.database.roomStorePath) { + log.warn("[DEPRECATED] The room store is now part of the SQL database." + + "The config option roomStorePath no longer has any use."); + } + + await bridge.run(port, config); + log.info(`Started listening on port ${port}`); + + try { + await store.init(undefined, bridge.getRoomStore()); + } catch (ex) { + log.error("Failed to init database. Exiting.", ex); + process.exit(1); + } + + const discordbot = new DiscordBot(botUserId, config, bridge, store); const roomhandler = discordbot.RoomHandler; try { @@ -144,19 +174,18 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) { process.exit(1); } - log.info("Initing bridge."); + log.info("Initing bridge"); try { log.info("Initing store."); - await bridge.run(port, config); await discordbot.init(); log.info(`Started listening on port ${port}.`); log.info("Initing bot."); await discordbot.run(); - log.info("Discordbot started successfully."); + log.info("Discordbot started successfully"); } catch (err) { log.error(err); - log.error("Failure during startup. Exiting."); + log.error("Failure during startup. Exiting"); process.exit(1); } } diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index ef1e84c5c3746b1646fab56a3625d71f88d126f7..032a77fb8ee1b556b363953ad15dc6aaf5e299ce 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -35,6 +35,7 @@ import { Provisioner } from "./provisioner"; import { Log } from "./log"; const log = new Log("MatrixRoomHandler"); import { IMatrixEvent } from "./matrixtypes"; +import { DbRoomStore, MatrixStoreRoom, RemoteStoreRoom } from "./db/roomstore"; const ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoESwIsTbJrfXyAAogrNxA"; /* tslint:disable:no-magic-numbers */ @@ -65,7 +66,8 @@ export class MatrixRoomHandler { private discord: DiscordBot, private config: DiscordBridgeConfig, private provisioner: Provisioner, - private bridge: Bridge) { + private bridge: Bridge, + private roomStore: DbRoomStore) { this.botUserId = this.discord.BotUserId; this.botJoinedRooms = new Set(); } @@ -85,6 +87,19 @@ export class MatrixRoomHandler { log.verbose(`Got OnAliasQueried for ${alias} ${roomId}`); let channel: Discord.GuildChannel; try { + // We previously stored the room as an alias. + const entry = (await this.roomStore.getEntriesByMatrixId(alias))[0]; + if (!entry) { + throw new Error("Entry was not found"); + } + // Remove the old entry + await this.roomStore.removeEntriesByMatrixRoomId( + entry.matrix!.roomId, + ); + await this.roomStore.linkRooms( + new MatrixStoreRoom(roomId), + entry.remote!, + ); channel = await this.discord.GetChannelFromRoomId(roomId) as Discord.GuildChannel; } catch (err) { log.error(`Cannot find discord channel for ${alias} ${roomId}`, err); @@ -124,7 +139,7 @@ export class MatrixRoomHandler { await Promise.all(promiseList); } - public async OnEvent(request, context): Promise<void> { + public async OnEvent(request, context: BridgeContext): Promise<void> { const event = request.getData() as IMatrixEvent; if (event.unsigned.age > AGE_LIMIT) { log.warn(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`); @@ -204,7 +219,7 @@ export class MatrixRoomHandler { ); await sendPromise; await intent.leave(roomId); - await this.bridge.getRoomStore().removeEntriesByMatrixRoomId(roomId); + await this.roomStore.removeEntriesByMatrixRoomId(roomId); } public async HandleInvite(event: IMatrixEvent) { @@ -322,7 +337,7 @@ export class MatrixRoomHandler { }); await this.provisioner.AskBridgePermission(channel, event.sender); - this.provisioner.BridgeMatrixRoom(channel, event.room_id); + await this.provisioner.BridgeMatrixRoom(channel, event.room_id); return this.bridge.getIntent().sendMessage(event.room_id, { body: "I have bridged this room to your channel", msgtype: "m.notice", @@ -399,7 +414,7 @@ export class MatrixRoomHandler { try { const result = await this.discord.LookupRoom(srvChanPair[0], srvChanPair[1]); log.info("Creating #", aliasLocalpart); - return this.createMatrixRoom(result.channel, aliasLocalpart); + return this.createMatrixRoom(result.channel, alias, aliasLocalpart); } catch (err) { log.error(`Couldn't find discord room '${aliasLocalpart}'.`, err); } @@ -616,14 +631,16 @@ export class MatrixRoomHandler { } } - private createMatrixRoom(channel: Discord.TextChannel, alias: string): ProvisionedRoom { - const remote = new RemoteRoom(`discord_${channel.guild.id}_${channel.id}`); - remote.set("discord_type", "text"); - remote.set("discord_guild", channel.guild.id); - remote.set("discord_channel", channel.id); - remote.set("update_name", true); - remote.set("update_topic", true); - remote.set("update_icon", true); + private async createMatrixRoom(channel: Discord.TextChannel, + alias: string, aliasLocalpart: string): ProvisionedRoom { + const remote = new RemoteStoreRoom(`discord_${channel.guild.id}_${channel.id}`, { + discord_channel: channel.id, + discord_guild: channel.guild.id, + discord_type: "text", + update_icon: 1, + update_name: 1, + update_topic: 1, + }); const creationOpts = { initial_state: [ { @@ -634,12 +651,16 @@ export class MatrixRoomHandler { type: "m.room.join_rules", }, ], - room_alias_name: alias, + room_alias_name: aliasLocalpart, visibility: this.config.room.defaultVisibility, }; + // We need to tempoarily store this until we know the room_id. + await this.roomStore.linkRooms( + new MatrixStoreRoom(alias), + remote, + ); return { creationOpts, - remote, } as ProvisionedRoom; } diff --git a/src/provisioner.ts b/src/provisioner.ts index 0ac84e407ce0e93dec3a4e3407d364ab021867e8..9a871a1750fae09eb2f3592b24d1ba1488ccc55d 100644 --- a/src/provisioner.ts +++ b/src/provisioner.ts @@ -20,6 +20,7 @@ import { MatrixRoom, } from "matrix-appservice-bridge"; import * as Discord from "discord.js"; +import { DbRoomStore } from "./db/roomstore"; const PERMISSION_REQUEST_TIMEOUT = 300000; // 5 minutes @@ -27,9 +28,9 @@ export class Provisioner { private pendingRequests: Map<string, (approved: boolean) => void> = new Map(); // [channelId]: resolver fn - constructor(private bridge: Bridge) { } + constructor(private roomStore: DbRoomStore) { } - public BridgeMatrixRoom(channel: Discord.TextChannel, roomId: string) { + public async BridgeMatrixRoom(channel: Discord.TextChannel, roomId: string) { const remote = new RemoteRoom(`discord_${channel.guild.id}_${channel.id}_bridged`); remote.set("discord_type", "text"); remote.set("discord_guild", channel.guild.id); @@ -37,12 +38,11 @@ export class Provisioner { remote.set("plumbed", true); const local = new MatrixRoom(roomId); - this.bridge.getRoomStore().linkRooms(local, remote); - this.bridge.getRoomStore().setMatrixRoom(local); // Needs to be done after linking + return this.roomStore.linkRooms(local, remote); } - public UnbridgeRoom(remoteRoom: RemoteRoom) { - return this.bridge.getRoomStore().removeEntriesByRemoteRoomId(remoteRoom.getId()); + public async UnbridgeRoom(remoteRoom: RemoteRoom) { + return this.roomStore.removeEntriesByRemoteRoomId(remoteRoom.getId()); } public async AskBridgePermission( diff --git a/src/store.ts b/src/store.ts index 62787dddc60ce7bb179dbad96148cf76e9be07e0..7397b3b6c765066f90a2518c5fc368ccc8af53ea 100644 --- a/src/store.ts +++ b/src/store.ts @@ -18,24 +18,26 @@ import * as fs from "fs"; import { IDbSchema } from "./db/schema/dbschema"; import { IDbData} from "./db/dbdatainterface"; import { SQLite3 } from "./db/sqlite3"; -export const CURRENT_SCHEMA = 7; +export const CURRENT_SCHEMA = 8; import { Log } from "./log"; import { DiscordBridgeConfigDatabase } from "./config"; import { Postgres } from "./db/postgres"; import { IDatabaseConnector } from "./db/connector"; +import { DbRoomStore } from "./db/roomstore"; +import { + RoomStore, +} from "matrix-appservice-bridge"; const log = new Log("DiscordStore"); /** * Stores data for specific users and data not specific to rooms. */ export class DiscordStore { - /** - * @param {string} filepath Location of the SQLite database file. - */ public db: IDatabaseConnector; private version: number; private config: DiscordBridgeConfigDatabase; - constructor(private configOrFile: DiscordBridgeConfigDatabase|string) { + private pRoomStore: DbRoomStore; + constructor(configOrFile: DiscordBridgeConfigDatabase|string) { if (typeof(configOrFile) === "string") { this.config = new DiscordBridgeConfigDatabase(); this.config.filename = configOrFile; @@ -45,6 +47,10 @@ export class DiscordStore { this.version = 0; } + get roomStore() { + return this.pRoomStore; + } + public async backup_database(): Promise<void|{}> { if (this.config.filename == null) { log.warn("Backups not supported on non-sqlite connector"); @@ -80,15 +86,22 @@ export class DiscordStore { /** * Checks the database has all the tables needed. */ - public async init(overrideSchema: number = 0): Promise<void> { + public async init(overrideSchema: number = 0, roomStore: RoomStore = null): Promise<void> { + const SCHEMA_ROOM_STORE_REQUIRED = 8; log.info("Starting DB Init"); await this.open_database(); let version = await this.getSchemaVersion(); const targetSchema = overrideSchema || CURRENT_SCHEMA; + log.info(`Database schema version is ${version}, latest version is ${targetSchema}`); while (version < targetSchema) { version++; const schemaClass = require(`./db/schema/v${version}.js`).Schema; - const schema = (new schemaClass() as IDbSchema); + let schema: IDbSchema; + if (version === SCHEMA_ROOM_STORE_REQUIRED) { // 8 requires access to the roomstore. + schema = (new schemaClass(roomStore) as IDbSchema); + } else { + schema = (new schemaClass() as IDbSchema); + } log.info(`Updating database to v${version}, "${schema.description}"`); try { await schema.run(this); @@ -327,6 +340,7 @@ export class DiscordStore { } try { this.db.Open(); + this.pRoomStore = new DbRoomStore(this.db); } catch (ex) { log.error("Error opening database:", ex); throw new Error("Couldn't open database. The appservice won't be able to continue."); diff --git a/test/db/test_roomstore.ts b/test/db/test_roomstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..fea079ebd855053468ffcb166036c80cc3b04b50 --- /dev/null +++ b/test/db/test_roomstore.ts @@ -0,0 +1,267 @@ +/* +Copyright 2019 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("RoomStore", () => { + 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.1", + matrix: new MatrixStoreRoom("test3.1_m"), + remote: new RemoteStoreRoom("test3.1_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test3.1", + matrix: new MatrixStoreRoom("test3.1_m"), + remote: new RemoteStoreRoom("test3.1_r", {discord_guild: "-100", discord_channel: "seventythousand"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3.1_m"))[0]; + expect(entry.id).to.equal("test3.1"); + expect(entry.matrix!.roomId).to.equal("test3.1_m"); + expect(entry.remote!.roomId).to.equal("test3.1_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.2", + matrix: new MatrixStoreRoom("test3.2_m"), + remote: new RemoteStoreRoom("test3.2_r", { + discord_channel: "456", discord_guild: "123", update_icon: true, + }), + }); + await store.roomStore.upsertEntry({ + id: "test3.2", + matrix: new MatrixStoreRoom("test3.2_m"), + remote: new RemoteStoreRoom("test3.2_r", {discord_guild: "123", discord_channel: "456"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3.2_m"))[0]; + expect(entry.id).to.equal("test3.2"); + expect(entry.matrix!.roomId).to.equal("test3.2_m"); + expect(entry.remote!.roomId).to.equal("test3.2_r"); + expect(entry.remote!.get("update_icon")).to.be.eq(0); + }); + }); + describe("getEntriesByMatrixIds", () => { + it("will get multiple entries", async () => { + const EXPECTED_ROOMS = 2; + await store.roomStore.upsertEntry({ + id: "test4_1", + matrix: new MatrixStoreRoom("!test_mOne:eggs.com"), + remote: new RemoteStoreRoom("test4_r", {discord_guild: "five", discord_channel: "five"}), + }); + await store.roomStore.upsertEntry({ + id: "test4_2", + matrix: new MatrixStoreRoom("!test_mTwo:eggs.com"), + remote: new RemoteStoreRoom("test4_r", {discord_guild: "nine", discord_channel: "nine"}), + }); + const entries = await store.roomStore.getEntriesByMatrixIds(["!test_mOne:eggs.com", "!test_mTwo:eggs.com"]); + expect(entries).to.have.lengthOf(EXPECTED_ROOMS); + expect(entries[0].id).to.equal("test4_1"); + expect(entries[0].matrix!.roomId).to.equal("!test_mOne:eggs.com"); + expect(entries[1].id).to.equal("test4_2"); + expect(entries[1].matrix!.roomId).to.equal("!test_mTwo:eggs.com"); + }); + }); + 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 get an entry", 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; + }); + }); +}); +describe("RoomStore.schema.v8", () => { + it("will successfully migrate rooms", async () => { + const SCHEMA_VERSION = 8; + store = new DiscordStore(":memory:"); + const roomStore = { + select: () => { + return [ + { + _id: "DGFUYs4hlXNDmmw0", + id: "123", + matrix: {extras: {}}, + matrix_id: "!badroom:localhost", + }, + { + _id: "Dd37MWDw57dAQz5p", + data: {}, + id: "!xdnLTCNErGnwsGnmnm:localhost discord_282616294245662720_514843269599985674_bridged", + matrix: { + extras: {}, + }, + matrix_id: "!bridged1:localhost", + remote: { + discord_channel: "514843269599985674", + discord_guild: "282616294245662720", + discord_type: "text", + plumbed: false, + }, + remote_id: "discord_282616294245662720_514843269599985674_bridged", + }, + { + _id: "H3XEftQWj8BZYuCe", + data: {}, + id: "!oGkfjmeNEkJdFasVRF:localhost discord_282616294245662720_520332167952334849", + matrix: { + extras: {}, + }, + matrix_id: "!bridged2:localhost", + remote: { + discord_channel: "514843269599985674", + discord_guild: "282616294245662720", + discord_type: "text", + plumbed: true, + update_icon: true, + update_name: false, + update_topic: true, + }, + remote_id: "discord_282616294245662720_520332167952334849", + }, + ]; + }, + }; + await store.init(SCHEMA_VERSION, roomStore); + expect(await store.roomStore.getEntriesByMatrixId("!badroom:localhost")).to.be.empty; + const bridge1 = (await store.roomStore.getEntriesByMatrixId("!bridged1:localhost"))[0]; + expect(bridge1).to.exist; + expect(bridge1.remote).to.not.be.null; + expect(bridge1.remote!.data.discord_channel).to.be.equal("514843269599985674"); + expect(bridge1.remote!.data.discord_guild).to.be.equal("282616294245662720"); + expect(bridge1.remote!.data.discord_type).to.be.equal("text"); + expect(!!bridge1.remote!.data.plumbed).to.be.false; + const bridge2 = (await store.roomStore.getEntriesByMatrixId("!bridged2:localhost"))[0]; + expect(bridge2).to.exist; + expect(bridge2.remote).to.not.be.null; + expect(bridge2.remote!.data.discord_channel).to.be.equal("514843269599985674"); + expect(bridge2.remote!.data.discord_guild).to.be.equal("282616294245662720"); + expect(bridge2.remote!.data.discord_type).to.be.equal("text"); + expect(!!bridge2.remote!.data.plumbed).to.be.true; + expect(!!bridge2.remote!.data.update_icon).to.be.true; + expect(!!bridge2.remote!.data.update_name).to.be.false; + expect(!!bridge2.remote!.data.update_topic).to.be.true; + }); +}); diff --git a/test/test_channelsyncroniser.ts b/test/test_channelsyncroniser.ts index e4dc61866a91474669a313fbbe8dc17b18797227..c54aa2f1cd7e4ce0e728aa33428452351f95f691 100644 --- a/test/test_channelsyncroniser.ts +++ b/test/test_channelsyncroniser.ts @@ -26,7 +26,6 @@ import { MatrixEventProcessor, MatrixEventProcessorOpts } from "../src/matrixeve import { DiscordBridgeConfig } from "../src/config"; import { MockChannel } from "./mocks/channel"; import { Bridge, MatrixRoom, RemoteRoom } from "matrix-appservice-bridge"; - // we are a test file and thus need those /* tslint:disable:no-unused-expression max-file-line-count no-any */ @@ -109,50 +108,48 @@ function CreateChannelSync(remoteChannels: any[] = []): ChannelSyncroniser { }, }; }, - getRoomStore: () => { - REMOTECHANNEL_SET = false; - REMOTECHANNEL_REMOVED = false; - return { - getEntriesByMatrixId: (roomid) => { - const entries: any[] = []; - remoteChannels.forEach((c) => { - const mxid = c.matrix.getId(); - if (roomid === mxid) { - entries.push(c); - } - }); - return entries; - }, - getEntriesByMatrixIds: (roomids) => { - const entries = {}; - remoteChannels.forEach((c) => { - const mxid = c.matrix.getId(); - if (roomids.includes(mxid)) { - if (!entries[mxid]) { - entries[mxid] = []; - } - entries[mxid].push(c); - } - }); - return entries; - }, - getEntriesByRemoteRoomData: (data) => { - return remoteChannels.filter((c) => { - for (const d of Object.keys(data)) { - if (c.remote.get(d) !== data[d]) { - return false; - } - } - return true; - }); - }, - removeEntriesByMatrixRoomId: (room) => { - REMOTECHANNEL_REMOVED = true; - }, - upsertEntry: (room) => { - REMOTECHANNEL_SET = true; - }, - }; + }; + REMOTECHANNEL_REMOVED = false; + REMOTECHANNEL_SET = false; + const roomStore = { + getEntriesByMatrixId: (roomid) => { + const entries: any[] = []; + remoteChannels.forEach((c) => { + const mxid = c.matrix.getId(); + if (roomid === mxid) { + entries.push(c); + } + }); + return entries; + }, + getEntriesByMatrixIds: (roomids) => { + const entries = {}; + remoteChannels.forEach((c) => { + const mxid = c.matrix.getId(); + if (roomids.includes(mxid)) { + if (!entries[mxid]) { + entries[mxid] = []; + } + entries[mxid].push(c); + } + }); + return entries; + }, + getEntriesByRemoteRoomData: (data) => { + return remoteChannels.filter((c) => { + for (const d of Object.keys(data)) { + if (c.remote.get(d) !== data[d]) { + return false; + } + } + return true; + }); + }, + removeEntriesByMatrixRoomId: (room) => { + REMOTECHANNEL_REMOVED = true; + }, + upsertEntry: (room) => { + REMOTECHANNEL_SET = true; }, }; const discordbot: any = { @@ -161,7 +158,8 @@ function CreateChannelSync(remoteChannels: any[] = []): ChannelSyncroniser { const config = new DiscordBridgeConfig(); config.bridge.domain = "localhost"; config.channel.namePattern = "[Discord] :guild :name"; - return new ChannelSync(bridge as Bridge, config, discordbot); + const cs = new ChannelSync(bridge as Bridge, config, discordbot, roomStore) as ChannelSyncroniser; + return cs; } describe("ChannelSyncroniser", () => { diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts index 88feb7eb4a8ad5775a338e73c5109c5029eb4b22..d6dcd1b3a2c1d9484bf60e5daced3b600cf04e14 100644 --- a/test/test_discordbot.ts +++ b/test/test_discordbot.ts @@ -92,6 +92,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); await discordBot.run(); }); @@ -103,6 +104,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); await discordBot.run(); }); @@ -144,6 +146,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); discord.bot = { user: { id: "654" } }; discord.provisioner = { @@ -298,6 +301,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); const guild: any = new MockGuild("123", []); @@ -324,6 +328,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); discordBot.store.Get = (a, b) => null; @@ -351,6 +356,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); discordBot.store.Get = (a, b) => { return { MatrixId: "$event:localhost;!room:localhost", @@ -387,6 +393,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); let expected = 0; discordBot.OnMessage = async (msg: any) => { @@ -408,6 +415,7 @@ describe("DiscordBot", () => { "", config, mockBridge, + {}, ); let expected = 0; const THROW_EVERY = 5; diff --git a/test/test_matrixroomhandler.ts b/test/test_matrixroomhandler.ts index 4639cac61ad53183a797033b48a7b056217af374..d653c7c1ecff472d527bdeb1e6430aa5741729f1 100644 --- a/test/test_matrixroomhandler.ts +++ b/test/test_matrixroomhandler.ts @@ -17,15 +17,9 @@ limitations under the License. import * as Chai from "chai"; import * as Proxyquire from "proxyquire"; import {DiscordBridgeConfig} from "../src/config"; -import {MockDiscordClient} from "./mocks/discordclient"; -import {PresenceHandler} from "../src/presencehandler"; -import {DiscordBot} from "../src/bot"; -import {MatrixRoomHandler} from "../src/matrixroomhandler"; import {MockChannel} from "./mocks/channel"; import {MockMember} from "./mocks/member"; -import * as Bluebird from "bluebird"; import {MockGuild} from "./mocks/guild"; -import {Guild} from "discord.js"; import { Util } from "../src/util"; // we are a test file and thus need those @@ -33,10 +27,6 @@ import { Util } from "../src/util"; const expect = Chai.expect; -// const DiscordClientFactory = Proxyquire("../src/clientfactory", { -// "discord.js": { Client: require("./mocks/discordclient").MockDiscordClient }, -// }).DiscordClientFactory; - const RoomHandler = (Proxyquire("../src/matrixroomhandler", { "./util": { Util: { @@ -97,13 +87,6 @@ function createRH(opts: any = {}) { unban: async () => { USERSUNBANNED++; }, }; }, - getRoomStore: () => { - return { - removeEntriesByMatrixRoomId: () => { - - }, - }; - }, }; const us = { JoinRoom: async () => { USERSJOINED++; }, @@ -202,7 +185,21 @@ function createRH(opts: any = {}) { } }, }; - const handler = new RoomHandler(bot as any, config, provisioner as any, bridge as any); + const store = { + getEntriesByMatrixId: (matrixId) => { + return [{ + matrix: {}, + remote: {}, + }]; + }, + linkRooms: () => { + + }, + removeEntriesByMatrixRoomId: () => { + + }, + }; + const handler = new RoomHandler(bot as any, config, provisioner as any, bridge as any, store); return handler; } @@ -740,12 +737,11 @@ describe("MatrixRoomHandler", () => { }); }); describe("createMatrixRoom", () => { - it("will return an object", () => { + it("will return an object", async () => { const handler: any = createRH({}); const channel = new MockChannel("123", new MockGuild("456")); - const roomOpts = handler.createMatrixRoom(channel, "#test:localhost"); + const roomOpts = await handler.createMatrixRoom(channel, "#test:localhost"); expect(roomOpts.creationOpts).to.exist; - expect(roomOpts.remote).to.exist; }); }); describe("HandleDiscordCommand", () => { diff --git a/test/test_provisioner.ts b/test/test_provisioner.ts index bafbeffaccb55cc35dfe39a5c3ff128d7f13170c..79f8f71734c2f3da382bd6a8c2093c4f7c90f235 100644 --- a/test/test_provisioner.ts +++ b/test/test_provisioner.ts @@ -29,7 +29,7 @@ const TIMEOUT_MS = 1000; describe("Provisioner", () => { describe("AskBridgePermission", () => { it("should fail to bridge a room that timed out", async () => { - const p = new Provisioner(null); + const p = new Provisioner({} as any); const startAt = Date.now(); try { await p.AskBridgePermission( @@ -47,7 +47,7 @@ describe("Provisioner", () => { } }); it("should fail to bridge a room that was declined", async () => { - const p = new Provisioner(null); + const p = new Provisioner({} as any); const promise = p.AskBridgePermission( new MockChannel("foo", "bar") as any, "Mark", @@ -63,7 +63,7 @@ describe("Provisioner", () => { }); it("should bridge a room that was approved", async () => { - const p = new Provisioner(null); + const p = new Provisioner({} as any); const promise = p.AskBridgePermission( new MockChannel("foo", "bar") as any, "Mark", diff --git a/tools/addRoomsToDirectory.ts b/tools/addRoomsToDirectory.ts index f4f4b9fbc39e4fcf2c79ea836e649d5d5ea916e3..200bebb500f7e85ece31b7cd5575334106aa2518 100644 --- a/tools/addRoomsToDirectory.ts +++ b/tools/addRoomsToDirectory.ts @@ -27,6 +27,7 @@ import * as usage from "command-line-usage"; import { DiscordBridgeConfig } from "../src/config"; import { Log } from "../src/log"; import { Util } from "../src/util"; +import { DiscordStore } from "../src/store"; const log = new Log("AddRoomsToDirectory"); const optionDefinitions = [ { @@ -89,20 +90,20 @@ const bridge = new Bridge({ domain: "rubbish", homeserverUrl: true, registration: true, - roomStore: options.store, }); +const discordstore = new DiscordStore(config.database ? config.database.filename : "discord.db"); + async function run() { try { - await bridge.loadDatabases(); + await discordstore.init(); } catch (e) { log.error(`Failed to load database`, e); } - - let rooms = await bridge.getRoomStore().getEntriesByRemoteRoomData({ + let rooms = await discordstore.roomStore.getEntriesByRemoteRoomData({ discord_type: "text", }); - rooms = rooms.filter((r) => r.remote.get("plumbed") !== true ); + rooms = rooms.filter((r) => r.remote && r.remote.get("plumbed") !== true ); const client = clientFactory.getClientAs(); log.info(`Got ${rooms.length} rooms to set`); try { diff --git a/tools/chanfix.ts b/tools/chanfix.ts index dc0c85b50664ad6c664536ca3b964759a9f2a7c3..b37b0fe9a35fdc892ed37118e19108497c359612 100644 --- a/tools/chanfix.ts +++ b/tools/chanfix.ts @@ -80,6 +80,8 @@ const clientFactory = new ClientFactory({ token: registration.as_token, url: config.bridge.homeserverUrl, }); +const discordstore = new DiscordStore(config.database ? config.database.filename : "discord.db"); +const discordbot = new DiscordBot("", config, null, discordstore); const bridge = new Bridge({ clientFactory, @@ -98,16 +100,13 @@ const bridge = new Bridge({ userStore: config.database.userStorePath, }); -const discordbot = new DiscordBot(botUserId, config, bridge); - async function run() { - try { - await bridge.loadDatabases(); - } catch (e) { } + await bridge.loadDatabases(); + await discordstore.init(); bridge._clientFactory = clientFactory; bridge._botClient = bridge._clientFactory.getClientAs(); bridge._botIntent = new Intent(bridge._botClient, bridge._botClient, { registered: true }); - await discordbot.init(); + await discordbot.ClientFactory.init(); const client = await discordbot.ClientFactory.getClient(); // first set update_icon to true if needed diff --git a/tools/ghostfix.ts b/tools/ghostfix.ts index 9d5c6bc3344e096433e457af5d634c8cb3b430e9..3e9a4ced371aba06b27820ba0b2c945b7ae85499 100644 --- a/tools/ghostfix.ts +++ b/tools/ghostfix.ts @@ -20,15 +20,11 @@ import * as fs from "fs"; import * as args from "command-line-args"; import * as usage from "command-line-usage"; import * as Bluebird from "bluebird"; -import { ChannelSyncroniser } from "../src/channelsyncroniser"; import { DiscordBridgeConfig } from "../src/config"; -import { DiscordBot } from "../src/bot"; -import { DiscordStore } from "../src/store"; -import { Provisioner } from "../src/provisioner"; -import { UserSyncroniser } from "../src/usersyncroniser"; import { Log } from "../src/log"; import { Util } from "../src/util"; -import { TextChannel } from "discord.js"; +import { DiscordBot } from "../src/bot"; +import { DiscordStore } from "../src/store"; const log = new Log("GhostFix"); @@ -112,10 +108,9 @@ const bridge = new Bridge({ }); async function run() { - try { - await bridge.loadDatabases(); - } catch (e) { } - const discordbot = new DiscordBot(botUserId, config, bridge); + await bridge.loadDatabases(); + const store = new DiscordStore(config.database); + const discordbot = new DiscordBot(botUserId, config, bridge, store); await discordbot.init(); bridge._clientFactory = clientFactory; const client = await discordbot.ClientFactory.getClient();