diff --git a/package-lock.json b/package-lock.json index c363c69d719e1c07f80d3767b10b453455d398b7..ec0e364b43ccb7a2920648b5cdfa18c5f81cbe5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "@types/events": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", "dev": true }, @@ -34,9 +34,17 @@ "integrity": "sha512-53ElVDSnZeFUUFIYzI8WLQ25IhWzb6vbddNp8UHlXQyU0ET2RhV5zg0NfubzU7iNMh5bBXb0htCzfvrSVNgzaQ==", "dev": true }, +<<<<<<< HEAD +======= + "@types/p-queue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/p-queue/-/p-queue-3.0.0.tgz", + "integrity": "sha512-brMFTEZiltJnAxNy07LzRVw2bdQX1ElCElHEmo5WEI70oWQe00aXew9RKmpf8MOzBMiMfNeuQoEyQCWKawPzmQ==" + }, +>>>>>>> hs/userstore-db "@types/sqlite3": { "version": "3.1.3", - "resolved": "http://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.3.tgz", + "resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.3.tgz", "integrity": "sha512-BgGToABnI/8/HnZtZz2Qac6DieU2Dm/j3rtbMmUlDVo4T/uLu8cuVfU/n2UkHowiiwXb6/7h/CmSqBIVKgcTMA==", "dev": true, "requires": { @@ -66,7 +74,7 @@ }, "acorn-jsx": { "version": "3.0.1", - "resolved": "http://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", "dev": true, "requires": { @@ -75,7 +83,7 @@ "dependencies": { "acorn": { "version": "3.3.0", - "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", "dev": true } @@ -120,7 +128,7 @@ }, "ansi-escapes": { "version": "1.4.0", - "resolved": "http://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=", "dev": true }, @@ -401,7 +409,7 @@ }, "chai": { "version": "3.5.0", - "resolved": "http://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", "integrity": "sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=", "dev": true, "requires": { @@ -595,7 +603,7 @@ }, "d": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/d/-/d-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", "dev": true, "requires": { @@ -620,7 +628,7 @@ }, "deep-eql": { "version": "0.1.3", - "resolved": "http://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", "dev": true, "requires": { @@ -737,7 +745,7 @@ }, "enabled": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", "requires": { "env-variable": "0.0.x" @@ -1064,7 +1072,7 @@ }, "fast-deep-equal": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" }, "fast-json-stable-stringify": { @@ -1085,7 +1093,7 @@ }, "fecha": { "version": "2.3.3", - "resolved": "http://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" }, "figures": { @@ -1413,7 +1421,7 @@ }, "inquirer": { "version": "0.12.0", - "resolved": "http://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", "dev": true, "requires": { @@ -1603,7 +1611,7 @@ }, "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true }, @@ -1821,7 +1829,7 @@ "dependencies": { "bluebird": { "version": "2.11.0", - "resolved": "http://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" } } @@ -1841,7 +1849,7 @@ }, "media-typer": { "version": "0.3.0", - "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "merge-descriptors": { @@ -1904,7 +1912,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -2098,7 +2106,7 @@ }, "onetime": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", "dev": true }, @@ -2140,6 +2148,11 @@ "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true }, + "p-queue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-3.0.0.tgz", + "integrity": "sha512-2tv/MRmPXfmfnjLLJAHl+DdU8p2DhZafAnlpm/C/T5BpF5L9wKz5tMin4A4N2zVpJL2YMhPlRmtO7s5EtNrjfA==" + }, "packet-reader": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz", @@ -2244,7 +2257,7 @@ }, "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true }, @@ -2310,7 +2323,7 @@ }, "progress": { "version": "1.1.8", - "resolved": "http://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", "dev": true }, @@ -2599,7 +2612,7 @@ }, "slice-ansi": { "version": "0.0.4", - "resolved": "http://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", "dev": true }, @@ -2714,7 +2727,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -2742,7 +2755,7 @@ }, "table": { "version": "3.8.3", - "resolved": "http://registry.npmjs.org/table/-/table-3.8.3.tgz", + "resolved": "https://registry.npmjs.org/table/-/table-3.8.3.tgz", "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=", "dev": true, "requires": { @@ -2890,7 +2903,7 @@ }, "through": { "version": "2.3.8", - "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "tough-cookie": { diff --git a/package.json b/package.json index 66093ca7af87515273ffc1efbe08f2d826c81736..be68f10ef09ac6414fa4491b7277cb6f14f7bf84 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "homepage": "https://github.com/Half-Shot/matrix-appservice-discord#readme", "dependencies": { + "@types/p-queue": "^3.0.0", "better-sqlite3": "^5.0.1", "bluebird": "^3.5.1", "command-line-args": "^4.0.1", @@ -47,6 +48,7 @@ "mime": "^1.6.0", "moment": "^2.22.2", "node-html-parser": "^1.1.11", + "p-queue": "^3.0.0", "pg-promise": "^8.5.1", "tslint": "^5.11.0", "typescript": "^3.1.3", diff --git a/src/bot.ts b/src/bot.ts index 1a67e3a5714abc9f8ca18c5643d480ca0b8a5603..25a2b2199929daf165cf3c35defe5aad92dc9d3b 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -139,7 +139,7 @@ export class DiscordBot { public async init(): Promise<void> { 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); + this.userSync = new UserSyncroniser(this.bridge, this.config, this, this.store.userStore); } public async run(): Promise<void> { diff --git a/src/db/postgres.ts b/src/db/postgres.ts index aa9116d9dc94fe0585c701b79901e334796a10bd..b863d6293eed999cdae258cdab2320f45b3b187f 100644 --- a/src/db/postgres.ts +++ b/src/db/postgres.ts @@ -51,7 +51,14 @@ export class Postgres implements IDatabaseConnector { public async All(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow[]> { log.silly("All:", sql); - return this.db.many(Postgres.ParameterizeSql(sql), parameters); + try { + return await this.db.many(Postgres.ParameterizeSql(sql), parameters); + } catch (ex) { + if (ex.code === pgPromise.errors.queryResultErrorCode.noData ) { + return []; + } + throw ex; + } } public async Run(sql: string, parameters?: ISqlCommandParameters): Promise<void> { diff --git a/src/db/schema/v9.ts b/src/db/schema/v9.ts new file mode 100644 index 0000000000000000000000000000000000000000..59aa65c4e2ead21845cdfd4af07b01f8ec51affb --- /dev/null +++ b/src/db/schema/v9.ts @@ -0,0 +1,120 @@ +/* +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 { + UserStore, +} from "matrix-appservice-bridge"; +import { RemoteUser } from "../userstore"; +import * as Queue from "p-queue"; +const log = new Log("SchemaV9"); + +export class Schema implements IDbSchema { + public description = "create user store tables"; + + constructor(private userStore: UserStore|null) { + + } + + public async run(store: DiscordStore): Promise<void> { + await store.create_table(` + CREATE TABLE remote_user_guild_nicks ( + remote_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + nick TEXT NOT NULL, + PRIMARY KEY(remote_id, guild_id) + );`, "remote_user_guild_nicks"); + + await store.create_table(` + CREATE TABLE remote_user_data ( + remote_id TEXT NOT NULL, + displayname TEXT, + avatarurl TEXT, + avatarurl_mxc TEXT, + PRIMARY KEY(remote_id) + );`, "remote_user_data"); + + await store.create_table(` + CREATE TABLE user_entries ( + matrix_id TEXT, + remote_id TEXT, + PRIMARY KEY(matrix_id, remote_id) + );`, "user_entries"); + + if (this.userStore === null) { + log.warn("Not migrating users from users store, users store is null"); + return; + } + log.warn("Migrating users from userstore, this may take a while..."); + const remoteUsers = await this.userStore.select({type: "remote"}); + log.info(`Found ${remoteUsers.length} remote users in the DB`); + let migrated = 0; + const processQueue = new Queue({ + autoStart: true, + concurrency: 100, + }); + for (const user of remoteUsers) { + const matrixIds = await this.userStore.getMatrixLinks(user.id); + if (!matrixIds || matrixIds.length === 0) { + log.warn(`Not migrating ${user.id}, has no linked matrix user`); + continue; + } else if (matrixIds.length > 1) { + log.warn(`Multiple matrix ids for ${user.id}, using first`); + } + const matrixId = matrixIds[0]; + try { + const remote = new RemoteUser(user.id); + remote.avatarurl = user.data.avatarurl; + remote.avatarurlMxc = user.data.avatarurl_mxc; + remote.displayname = user.data.displayname; + Object.keys(user.data).filter((k) => k.startsWith("nick_")).forEach((k) => { + remote.guildNicks.set(k.substr("nick_".length), user.data[k]); + }); + processQueue.add(async () => { + await store.userStore.linkUsers(matrixId, remote.id); + return store.userStore.setRemoteUser(remote); + }).then(() => { + log.info(`Migrated ${matrixId}, ${processQueue.pending} to go.`); + migrated++; + }).catch((err) => { + log.error(`Failed to migrate ${matrixId} ${err}`); + }); + } catch (ex) { + log.error(`Failed to link ${matrixId}: `, ex); + } + } + await processQueue.onIdle(); + if (migrated !== remoteUsers.length) { + log.error(`Didn't migrate all users, ${remoteUsers.length - migrated} failed to be migrated.`); + } else { + log.info("Migrated all users successfully"); + } + } + + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Run( + `DROP TABLE IF EXISTS remote_user_guild_nicks;`, + ); + await store.db.Run( + `DROP TABLE IF EXISTS remote_user_data;`, + ); + await store.db.Run( + `DROP TABLE IF EXISTS user_entries;`, + ); + } +} diff --git a/src/db/userstore.ts b/src/db/userstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..119931fcc616fbe9145b160011b338ccf6228997 --- /dev/null +++ b/src/db/userstore.ts @@ -0,0 +1,153 @@ +import { IDatabaseConnector } from "./connector"; +import * as uuid from "uuid/v4"; +import { Log } from "../log"; + +/** + * A UserStore compatible with + * https://github.com/matrix-org/matrix-appservice-bridge/blob/master/lib/components/user-bridge-store.js + * that accesses the database instead. + */ + +const ENTRY_CACHE_LIMETIME = 30000; + +export class RemoteUser { + public displayname: string|null = null; + public avatarurl: string|null = null; + public avatarurlMxc: string|null = null; + public guildNicks: Map<string, string> = new Map(); + constructor(public readonly id: string) { + + } +} + +const log = new Log("DbUserStore"); + +export interface IUserStoreEntry { + id: string; + matrix: string|null; + remote: RemoteUser|null; +} + +export class DbUserStore { + private remoteUserCache: Map<string, {e: RemoteUser, ts: number}>; + + constructor(private db: IDatabaseConnector) { + this.remoteUserCache = new Map(); + } + + public async getRemoteUser(remoteId: string): Promise<RemoteUser|null> { + const cached = this.remoteUserCache.get(remoteId); + if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) { + return cached.e; + } + const row = await this.db.Get( + "SELECT * FROM user_entries WHERE remote_id = $id", {id: remoteId}, + ); + if (!row) { + return null; + } + const remoteUser = new RemoteUser(remoteId); + const data = await this.db.Get( + "SELECT * FROM remote_user_data WHERE remote_id = $remoteId", + {remoteId}, + ); + if (data) { + remoteUser.avatarurl = data.avatarurl as string|null; + remoteUser.displayname = data.displayname as string|null; + remoteUser.avatarurlMxc = data.avatarurl_mxc as string|null; + } + const nicks = await this.db.All( + "SELECT guild_id, nick FROM remote_user_guild_nicks WHERE remote_id = $remoteId", + {remoteId}, + ); + if (nicks) { + nicks.forEach(({nick, guild_id}) => { + remoteUser.guildNicks.set(guild_id as string, nick as string); + }); + } + this.remoteUserCache.set(remoteId, {e: remoteUser, ts: Date.now()}); + return remoteUser; + } + + public async setRemoteUser(user: RemoteUser) { + this.remoteUserCache.delete(user.id); + const existingData = await this.db.Get( + "SELECT * FROM remote_user_data WHERE remote_id = $remoteId", + {remoteId: user.id}, + ); + if (!existingData) { + await this.db.Run( + `INSERT INTO remote_user_data VALUES ( + $remote_id, + $displayname, + $avatarurl, + $avatarurl_mxc + )`, + { + avatarurl: user.avatarurl, + avatarurl_mxc: user.avatarurlMxc, + displayname: user.displayname, + remote_id: user.id, + }); + } else { + await this.db.Run( +`UPDATE remote_user_data SET displayname = $displayname, +avatarurl = $avatarurl, +avatarurl_mxc = $avatarurl_mxc WHERE remote_id = $remote_id`, + { + avatarurl: user.avatarurl, + avatarurl_mxc: user.avatarurlMxc, + displayname: user.displayname, + remote_id: user.id, + }); + } + const existingNicks = {}; + (await this.db.All( + "SELECT guild_id, nick FROM remote_user_guild_nicks WHERE remote_id = $remoteId", + {remoteId: user.id}, + )).forEach(({g, n}) => existingNicks[g as string] = n); + for (const guildId of user.guildNicks.keys()) { + const nick = user.guildNicks.get(guildId) || null; + if (existingData) { + if (existingNicks[guildId] === nick) { + return; + } else if (existingNicks[guildId]) { + await this.db.Run( +`UPDATE remote_user_guild_nicks SET nick = $nick +WHERE remote_id = $remote_id +AND guild_id = $guild_id`, + { + guild_id: guildId, + nick, + remote_id: user.id, + }); + return; + } + } + await this.db.Run( + `INSERT INTO remote_user_guild_nicks VALUES ( + $remote_id, + $guild_id, + $nick + )`, + { + guild_id: guildId, + nick, + remote_id: user.id, + }); + } + + } + + public async linkUsers(matrixId: string, remoteId: string) { + // This is used ONCE in the bridge to link two IDs, so do not UPSURT data. + try { + await this.db.Run(`INSERT INTO user_entries VALUES ($matrixId, $remoteId)`, { + matrixId, + remoteId, + }); + } catch (ex) { + log.verbose("Failed to insert into user_entries, entry probably exists:", ex); + } + } +} diff --git a/src/discordas.ts b/src/discordas.ts index ad328a2207d723fb4e115e66bcb45495b5e2dbda..e395fef41d577bcc3e57c3062c768465bc9ca230 100644 --- a/src/discordas.ts +++ b/src/discordas.ts @@ -149,11 +149,16 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) { + "The config option roomStorePath no longer has any use."); } + if (config.database.userStorePath) { + log.warn("[DEPRECATED] The user store is now part of the SQL database." + + "The config option userStorePath no longer has any use."); + } + await bridge.run(port, config); log.info(`Started listening on port ${port}`); try { - await store.init(undefined, bridge.getRoomStore()); + await store.init(undefined, bridge.getRoomStore(), bridge.getUserStore()); } catch (ex) { log.error("Failed to init database. Exiting.", ex); process.exit(1); diff --git a/src/store.ts b/src/store.ts index 7397b3b6c765066f90a2518c5fc368ccc8af53ea..df328829e90a5a192569918b4f4645297431fba3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -18,15 +18,16 @@ 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 = 8; +export const CURRENT_SCHEMA = 9; import { Log } from "./log"; import { DiscordBridgeConfigDatabase } from "./config"; import { Postgres } from "./db/postgres"; import { IDatabaseConnector } from "./db/connector"; import { DbRoomStore } from "./db/roomstore"; +import { DbUserStore } from "./db/userstore"; import { - RoomStore, + RoomStore, UserStore, } from "matrix-appservice-bridge"; const log = new Log("DiscordStore"); /** @@ -34,9 +35,9 @@ const log = new Log("DiscordStore"); */ export class DiscordStore { public db: IDatabaseConnector; - private version: number; private config: DiscordBridgeConfigDatabase; private pRoomStore: DbRoomStore; + private pUserStore: DbUserStore; constructor(configOrFile: DiscordBridgeConfigDatabase|string) { if (typeof(configOrFile) === "string") { this.config = new DiscordBridgeConfigDatabase(); @@ -44,13 +45,16 @@ export class DiscordStore { } else { this.config = configOrFile; } - this.version = 0; } get roomStore() { return this.pRoomStore; } + get userStore() { + return this.pUserStore; + } + public async backup_database(): Promise<void|{}> { if (this.config.filename == null) { log.warn("Backups not supported on non-sqlite connector"); @@ -86,8 +90,11 @@ export class DiscordStore { /** * Checks the database has all the tables needed. */ - public async init(overrideSchema: number = 0, roomStore: RoomStore = null): Promise<void> { + public async init( + overrideSchema: number = 0, roomStore: RoomStore = null, userStore: UserStore = null, + ): Promise<void> { const SCHEMA_ROOM_STORE_REQUIRED = 8; + const SCHEMA_USER_STORE_REQUIRED = 9; log.info("Starting DB Init"); await this.open_database(); let version = await this.getSchemaVersion(); @@ -99,6 +106,8 @@ export class DiscordStore { let schema: IDbSchema; if (version === SCHEMA_ROOM_STORE_REQUIRED) { // 8 requires access to the roomstore. schema = (new schemaClass(roomStore) as IDbSchema); + } else if (version === SCHEMA_USER_STORE_REQUIRED) { + schema = (new schemaClass(userStore) as IDbSchema); } else { schema = (new schemaClass() as IDbSchema); } @@ -118,7 +127,6 @@ export class DiscordStore { } throw Error("Failure to update to latest schema."); } - this.version = version; await this.setSchemaVersion(version); } log.info("Updated database to the latest schema"); @@ -341,6 +349,7 @@ export class DiscordStore { try { this.db.Open(); this.pRoomStore = new DbRoomStore(this.db); + this.pUserStore = new DbUserStore(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/src/usersyncroniser.ts b/src/usersyncroniser.ts index 07a4dacbbae94963fa86937b5e950c60d42870ad..77428d2bc814bd795031d6a65a2ea3be6abe8f14 100644 --- a/src/usersyncroniser.ts +++ b/src/usersyncroniser.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { User, GuildMember, GuildChannel } from "discord.js"; +import { User, GuildMember } from "discord.js"; import { DiscordBot } from "./bot"; import { Util } from "./util"; -import { MatrixUser, RemoteUser, Bridge, Entry, UserBridgeStore, Intent } from "matrix-appservice-bridge"; +import { Bridge, Intent, MatrixUser } from "matrix-appservice-bridge"; import { DiscordBridgeConfig } from "./config"; -import * as Bluebird from "bluebird"; import { Log } from "./log"; import { IMatrixEvent } from "./matrixtypes"; +import { DbUserStore, RemoteUser } from "./db/userstore"; const log = new Log("UserSync"); @@ -81,12 +81,11 @@ export class UserSyncroniser { // roomId+userId => ev public userStateHold: Map<string, IMatrixEvent>; - private userStore: UserBridgeStore; constructor( private bridge: Bridge, private config: DiscordBridgeConfig, - private discord: DiscordBot) { - this.userStore = this.bridge.getUserStore(); + private discord: DiscordBot, + private userStore: DbUserStore) { this.userStateHold = new Map<string, IMatrixEvent>(); } @@ -115,18 +114,19 @@ export class UserSyncroniser { log.info(`Creating new user ${userState.mxUserId}`); remoteUser = new RemoteUser(userState.id); await this.userStore.linkUsers( - new MatrixUser(userState.mxUserId.substr("@".length)), - remoteUser, + userState.mxUserId.substr("@".length), + userState.id, ); } else { - remoteUser = await this.userStore.getRemoteUser(userState.id); + const rUser = await this.userStore.getRemoteUser(userState.id); + remoteUser = rUser ? rUser : new RemoteUser(userState.id); } if (userState.displayName !== null) { log.verbose(`Updating displayname for ${userState.mxUserId} to "${userState.displayName}"`); await intent.setDisplayName(userState.displayName); - remoteUser.set("displayname", userState.displayName); + remoteUser.displayname = userState.displayName; userUpdated = true; } @@ -138,16 +138,16 @@ export class UserSyncroniser { userState.avatarId, ); await intent.setAvatarUrl(avatarMxc.mxcUrl); - remoteUser.set("avatarurl", userState.avatarUrl); - remoteUser.set("avatarurl_mxc", avatarMxc.mxcUrl); + remoteUser.avatarurl = userState.avatarUrl; + remoteUser.avatarurlMxc = avatarMxc.mxcUrl; userUpdated = true; } if (userState.removeAvatar) { log.verbose(`Clearing avatar_url for ${userState.mxUserId} to "${userState.avatarUrl}"`); await intent.setAvatarUrl(null); - remoteUser.set("avatarurl", null); - remoteUser.set("avatarurl_mxc", null); + remoteUser.avatarurl = null; + remoteUser.avatarurlMxc = null; userUpdated = true; } @@ -198,11 +198,14 @@ export class UserSyncroniser { return; } const remoteUser = await this.userStore.getRemoteUser(memberState.id); + if (!remoteUser) { + throw Error("Remote user not found"); + } const intent = this.bridge.getIntent(memberState.mxUserId); /* The intent class tries to be smart and deny a state update for <PL50 users. Obviously a user can change their own state so we use the client instead. */ await intent.getClient().sendStateEvent(roomId, "m.room.member", { - "avatar_url": remoteUser.get("avatarurl_mxc"), + "avatar_url": remoteUser.avatarurlMxc, "displayname": memberState.displayName, "membership": "join", "uk.half-shot.discord.member": { @@ -215,8 +218,7 @@ export class UserSyncroniser { }, memberState.mxUserId); if (guildId) { - const nickKey = `nick_${guildId}`; - remoteUser.set(nickKey, memberState.displayName); + remoteUser.guildNicks.set(guildId, memberState.displayName); } await this.userStore.setRemoteUser(remoteUser); } @@ -245,13 +247,13 @@ export class UserSyncroniser { return userState; } - const oldDisplayName = remoteUser.get("displayname"); + const oldDisplayName = remoteUser.displayname; if (oldDisplayName !== displayName) { log.verbose(`User ${discordUser.id} displayname should be updated`); userState.displayName = displayName; } - const oldAvatarUrl = remoteUser.get("avatarurl"); + const oldAvatarUrl = remoteUser.avatarurl; if (oldAvatarUrl !== discordUser.avatarURL) { log.verbose(`User ${discordUser.id} avatarurl should be updated`); if (discordUser.avatarURL !== null) { @@ -370,7 +372,7 @@ export class UserSyncroniser { } public async UpdateStateForGuilds(remoteUser: RemoteUser) { - const id = remoteUser.getId(); + const id = remoteUser.id; log.info(`Got update for ${id}.`); await Util.AsyncForEach(this.discord.GetGuilds(), async (guild) => { diff --git a/test/test_usersyncroniser.ts b/test/test_usersyncroniser.ts index dacc5c7754a6f8b1ba011abd281f0307b34a25f6..c5b7b1cff0418e3f72494acf3194652745255b53 100644 --- a/test/test_usersyncroniser.ts +++ b/test/test_usersyncroniser.ts @@ -15,7 +15,7 @@ limitations under the License. */ import * as Chai from "chai"; -import { Bridge, RemoteUser } from "matrix-appservice-bridge"; +import { Bridge } from "matrix-appservice-bridge"; import {IGuildMemberState, IUserState, UserSyncroniser} from "../src/usersyncroniser"; import {MockUser} from "./mocks/user"; import {DiscordBridgeConfig} from "../src/config"; @@ -26,6 +26,7 @@ import { MockChannel } from "./mocks/channel"; import { MockRole } from "./mocks/role"; import { IMatrixEvent } from "../src/matrixtypes"; import { Util } from "../src/util"; +import { RemoteUser } from "../src/db/userstore"; // we are a test file and thus need those /* tslint:disable:no-unused-expression max-file-line-count no-any */ @@ -63,7 +64,7 @@ const UserSync = (Proxyquire("../src/usersyncroniser", { }, })).UserSyncroniser; -function CreateUserSync(remoteUsers: any[] = []): UserSyncroniser { +function CreateUserSync(remoteUsers: RemoteUser[] = []): UserSyncroniser { UTIL_UPLOADED_AVATAR = false; SEV_ROOM_ID = null; SEV_CONTENT = null; @@ -111,30 +112,6 @@ function CreateUserSync(remoteUsers: any[] = []): UserSyncroniser { }, }; }, - getUserStore: () => { - REMOTEUSER_SET = null; - LINK_RM_USER = null; - LINK_MX_USER = null; - return { - getRemoteUser: (id) => { - const user = remoteUsers.find((u) => u.id === id); - if (user === undefined) { - return null; - } - return user; - }, - getRemoteUsersFromMatrixId: (id) => { - return remoteUsers.filter((u) => u.id === id); - }, - linkUsers: (mxUser, remoteUser) => { - LINK_MX_USER = mxUser; - LINK_RM_USER = remoteUser; - }, - setRemoteUser: async (remoteUser) => { - REMOTEUSER_SET = remoteUser; - }, - }; - }, }; const discordbot: any = { GetChannelFromRoomId: (id) => { @@ -160,9 +137,23 @@ function CreateUserSync(remoteUsers: any[] = []): UserSyncroniser { return GUILD_ROOM_IDS; }, }; + REMOTEUSER_SET = null; + LINK_RM_USER = null; + LINK_MX_USER = null; + const userStore = { + getRemoteUser: (id) => remoteUsers.find((u) => u.id === id) || null, + getRemoteUsersFromMatrixId: (id) => remoteUsers.filter((u) => u.id === id), + linkUsers: (mxUser, remoteUser) => { + LINK_MX_USER = mxUser; + LINK_RM_USER = remoteUser; + }, + setRemoteUser: async (remoteUser) => { + REMOTEUSER_SET = remoteUser; + }, + }; const config = new DiscordBridgeConfig(); config.bridge.domain = "localhost"; - return new UserSync(bridge as Bridge, config, discordbot); + return new UserSync(bridge as Bridge, config, discordbot, userStore as any); } describe("UserSyncroniser", () => { @@ -185,10 +176,9 @@ describe("UserSyncroniser", () => { expect(state.avatarUrl).equals("test.jpg"); }); it("Will change display names", async () => { - const remoteUser = new RemoteUser("123456", { - avatarurl: "test.jpg", - displayname: "MrFake", - }); + const remoteUser = new RemoteUser("123456"); + remoteUser.avatarurl = "test.jpg"; + remoteUser.displayname = "TestUsername"; const userSync = CreateUserSync([remoteUser]); const user = new MockUser( @@ -207,10 +197,9 @@ describe("UserSyncroniser", () => { expect(state.avatarUrl, "AvatarUrl").is.null; }); it("Will change avatars", async () => { - const remoteUser = new RemoteUser("123456", { - avatarurl: "test.jpg", - displayname: "TestUsername#6969", - }); + const remoteUser = new RemoteUser("123456"); + remoteUser.avatarurl = "test.jpg"; + remoteUser.displayname = "TestUsername#6969"; const userSync = CreateUserSync([remoteUser]); const user = new MockUser( @@ -229,10 +218,9 @@ describe("UserSyncroniser", () => { expect(state.displayName, "DisplayName").is.null; }); it("Will remove avatars", async () => { - const remoteUser = new RemoteUser("123456", { - avatarurl: "test.jpg", - displayname: "TestUsername#6969", - }); + const remoteUser = new RemoteUser("123456"); + remoteUser.avatarurl = "test.jpg"; + remoteUser.displayname = "TestUsername#6969"; const userSync = CreateUserSync([remoteUser]); const user = new MockUser( @@ -284,9 +272,9 @@ describe("UserSyncroniser", () => { expect(LINK_RM_USER).is.not.null; expect(REMOTEUSER_SET).is.not.null; expect(DISPLAYNAME_SET).equal("123456"); - expect(REMOTEUSER_SET.data.displayname).equal("123456"); + expect(REMOTEUSER_SET.displayname).equal("123456"); expect(AVATAR_SET).is.null; - expect(REMOTEUSER_SET.data.avatarurl).is.undefined; + expect(REMOTEUSER_SET.avatarurl).is.null; }); it("Will set an avatar", async () => { const userSync = CreateUserSync(); @@ -305,8 +293,8 @@ describe("UserSyncroniser", () => { expect(AVATAR_SET).equal("avatarset"); expect(UTIL_UPLOADED_AVATAR).to.be.true; expect(REMOTEUSER_SET).is.not.null; - expect(REMOTEUSER_SET.data.avatarurl).equal("654321"); - expect(REMOTEUSER_SET.data.displayname).is.undefined; + expect(REMOTEUSER_SET.avatarurl).equal("654321"); + expect(REMOTEUSER_SET.displayname).is.null; expect(DISPLAYNAME_SET).is.null; }); it("Will remove an avatar", async () => { @@ -326,8 +314,8 @@ describe("UserSyncroniser", () => { expect(AVATAR_SET).is.null; expect(UTIL_UPLOADED_AVATAR).to.be.false; expect(REMOTEUSER_SET).is.not.null; - expect(REMOTEUSER_SET.data.avatarurl).is.null; - expect(REMOTEUSER_SET.data.displayname).is.undefined; + expect(REMOTEUSER_SET.avatarurl).is.null; + expect(REMOTEUSER_SET.displayname).is.null; expect(DISPLAYNAME_SET).is.null; }); it("will do nothing if nothing needs to be done", async () => { @@ -363,7 +351,7 @@ describe("UserSyncroniser", () => { }; await userSync.ApplyStateToRoom(state, "!abc:localhost", "123456"); expect(REMOTEUSER_SET).is.not.null; - expect(REMOTEUSER_SET.data.nick_123456).is.equal("Good Boy"); + expect(REMOTEUSER_SET.guildNicks.get("123456")).is.equal("Good Boy"); expect(SEV_ROOM_ID).is.equal("!abc:localhost"); expect(SEV_CONTENT.displayname).is.equal("Good Boy"); expect(SEV_KEY).is.equal("@_discord_123456:localhost");