diff --git a/config/config.sample.yaml b/config/config.sample.yaml index 40dff3da70e471a86b9d017b06d1538e92296521..5f8afd9c6d82ec142044da5c0b55642f38f26f1a 100644 --- a/config/config.sample.yaml +++ b/config/config.sample.yaml @@ -1,6 +1,7 @@ bridge: domain: "localhost" homeserverUrl: "http://localhost:8008" + presenceInterval: 500, disablePresence: false disableTypingNotifications: false disableDiscordMentions: false diff --git a/config/config.schema.yaml b/config/config.schema.yaml index 0276b025231dc86bec9cf17305f1ed8391b6f0bf..bdb2bb5cc5c1bf24ccf82adeb82037c8800dc4e2 100644 --- a/config/config.schema.yaml +++ b/config/config.schema.yaml @@ -10,6 +10,8 @@ properties: type: "string" homeserverUrl: type: "string" + presenceInterval: + type: "number" disablePresence: type: "boolean" disableTypingNotifications: diff --git a/src/bot.ts b/src/bot.ts index 748f4edc2d52b693c466f84b378d2e5656c62689..f1b9bf7717cf1b1dd5c6bf11aa6123cc0afddbf7 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -6,6 +6,7 @@ import { DbEvent } from "./db/dbdataevent"; import { MatrixUser, RemoteUser, Bridge, Entry } from "matrix-appservice-bridge"; import { Util } from "./util"; import { MessageProcessor, MessageProcessorOpts } from "./messageprocessor"; +import { PresenceHandler } from "./presencehandler"; import * as Discord from "discord.js"; import * as log from "npmlog"; import * as Bluebird from "bluebird"; @@ -15,7 +16,7 @@ import * as path from "path"; // Due to messages often arriving before we get a response from the send call, // messages get delayed from discord. const MSG_PROCESS_DELAY = 750; -const PRESENCE_UPDATE_DELAY = 55000; // Synapse updates in 55 second intervals. +const MIN_PRESENCE_UPDATE_DELAY = 250; class ChannelLookupResult { public channel: Discord.TextChannel; public botUser: boolean; @@ -30,6 +31,7 @@ export class DiscordBot { private presenceInterval: any; private sentMessages: string[]; private msgProcessor: MessageProcessor; + private presenceHandler: PresenceHandler; constructor(config: DiscordBridgeConfig, store: DiscordStore) { this.config = config; this.store = store; @@ -39,6 +41,7 @@ export class DiscordBot { new MessageProcessorOpts(this.config.bridge.domain), this, ); + this.presenceHandler = new PresenceHandler(this); } public setBridge(bridge: Bridge) { @@ -49,6 +52,10 @@ export class DiscordBot { return this.clientFactory; } + public GetIntentFromDiscordMember(member: Discord.GuildMember | Discord.User): any { + return this.bridge.getIntentFromLocalpart(`_discord_${member.id}`); + } + public run (): Promise<null> { return this.clientFactory.init().then(() => { return this.clientFactory.getClient(); @@ -58,7 +65,7 @@ export class DiscordBot { client.on("typingStop", (c, u) => { this.OnTyping(c, u, false); }); } if (!this.config.bridge.disablePresence) { - client.on("presenceUpdate", (_, newMember) => { this.UpdatePresence(newMember); }); + client.on("presenceUpdate", (_, newMember) => { this.presenceHandler.EnqueueMember(newMember); }); } client.on("userUpdate", (_, newUser) => { this.UpdateUser(newUser); }); client.on("channelUpdate", (_, newChannel) => { this.UpdateRooms(newChannel); }); @@ -74,14 +81,14 @@ export class DiscordBot { this.bot = client; if (!this.config.bridge.disablePresence) { - /* Currently synapse sadly times out presence after a minute. - * This will set the presence for each user who is not offline */ - this.presenceInterval = setInterval( - this.BulkPresenceUpdate.bind(this), - PRESENCE_UPDATE_DELAY, + this.bot.guilds.forEach((guild) => { + guild.members.forEach((member) => { + this.presenceHandler.EnqueueMember(member); + }); + }); + this.presenceHandler.Start( + Math.max(this.config.bridge.presenceInterval, MIN_PRESENCE_UPDATE_DELAY), ); - this.BulkPresenceUpdate(); - return null; } }); } @@ -236,7 +243,7 @@ export class DiscordBot { public async ProcessMatrixRedact(event: any) { if (this.config.bridge.disableDeletionForwarding) { - return + return; } log.info("DiscordBot", `Got redact request for ${event.redacts}`); log.verbose("DiscordBot", `Event:`, event); @@ -294,7 +301,7 @@ export class DiscordBot { } public InitJoinUser(member: Discord.GuildMember, roomIds: string[]): Promise<any> { - const intent = this.bridge.getIntentFromLocalpart(`_discord_${member.id}`); + const intent = this.GetIntentFromDiscordMember(member); return this.UpdateUser(member.user).then(() => { return Bluebird.each(roomIds, (roomId) => intent.join(roomId)); }).then(() => { @@ -444,47 +451,6 @@ export class DiscordBot { }); } - private BulkPresenceUpdate() { - if (this.config.bridge.disablePresence) { - return; // skip if there's nothing to do - } - - log.verbose("DiscordBot", "Bulk presence update"); - const members = []; - for (const guild of this.bot.guilds.values()) { - for (const member of guild.members.array().filter((m) => members.indexOf(m.id) === -1)) { - /* We ignore offline because they are likely to have been set - * by a 'presenceUpdate' event or will timeout. This saves - * some work on the HS */ - if (member.presence.status !== "offline") { - this.UpdatePresence(member); - } - members.push(member.id); - } - } - } - - private UpdatePresence(guildMember: Discord.GuildMember) { - if (this.config.bridge.disablePresence) { - return; // skip if there's nothing to do - } - - const intent = this.bridge.getIntentFromLocalpart(`_discord_${guildMember.id}`); - try { - const presence: any = {}; - presence.presence = guildMember.presence.status; - if (presence.presence === "idle" || presence.presence === "dnd") { - presence.presence = "unavailable"; - } - if (guildMember.presence.game) { - presence.status_msg = "Playing " + guildMember.presence.game.name; - } - intent.getClient().setPresence(presence); - } catch (err) { - log.info("DiscordBot", "Couldn't set presence ", err); - } - } - private AddGuildMember(guildMember: Discord.GuildMember) { return this.GetRoomIdsFromGuild(guildMember.guild.id).then((roomIds) => { return this.InitJoinUser(guildMember, roomIds); @@ -492,14 +458,15 @@ export class DiscordBot { } private RemoveGuildMember(guildMember: Discord.GuildMember) { - const intent = this.bridge.getIntentFromLocalpart(`_discord_${guildMember.id}`); + const intent = this.GetIntentFromDiscordMember(guildMember); return Bluebird.each(this.GetRoomIdsFromGuild(guildMember.guild.id), (roomId) => { - return intent.leave(roomId); + this.presenceHandler.DequeueMember(guildMember); + return intent.leave(roomId); }); } private UpdateGuildMember(guildMember: Discord.GuildMember, roomIds?: string[]) { - const client = this.bridge.getIntentFromLocalpart(`_discord_${guildMember.id}`).getClient(); + const client = this.GetIntentFromDiscordMember(guildMember).getClient(); const userId = client.credentials.userId; let avatar = null; log.info(`Updating nick for ${guildMember.user.username}`); @@ -520,7 +487,7 @@ export class DiscordBot { private OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { this.GetRoomIdsFromChannel(channel).then((rooms) => { - const intent = this.bridge.getIntentFromLocalpart(`_discord_${user.id}`); + const intent = this.GetIntentFromDiscordMember(user); return Promise.all(rooms.map((room) => { return intent.sendTyping(room, isTyping); })); @@ -541,7 +508,6 @@ export class DiscordBot { return; } // Update presence because sometimes discord misses people. - this.UpdatePresence(msg.member); this.UpdateUser(msg.author).then(() => { return this.GetRoomIdsFromChannel(msg.channel).catch((err) => { log.verbose("DiscordBot", "No bridged rooms to send message to. Oh well."); @@ -551,7 +517,7 @@ export class DiscordBot { if (rooms === null) { return null; } - const intent = this.bridge.getIntentFromLocalpart(`_discord_${msg.author.id}`); + const intent = this.GetIntentFromDiscordMember(msg.author); // Check Attachements msg.attachments.forEach((attachment) => { Util.UploadContentFromUrl(attachment.url, intent, attachment.filename).then((content) => { @@ -601,21 +567,18 @@ export class DiscordBot { }); } - private async DeleteDiscordMessage(msg: Discord.Message) { - if (this.config.bridge.disableDeletionForwarding) { - return; - } - log.info("DiscordBot", `Got delete event for ${msg.id}`); - const storeEvent = await this.store.Get(DbEvent, {discord_id: msg.id}); - if (!storeEvent.Result) { - log.warn("DiscordBot", `Could not redact because the event was in the store.`); - return; - } - while (storeEvent.Next()) { - log.info("DiscordBot", `Deleting discord msg ${storeEvent.DiscordId}`); - const client = this.bridge.getIntent().getClient(); - const matrixIds = storeEvent.MatrixId.split(";"); - await client.redactEvent(matrixIds[1], matrixIds[0]); + private async DeleteDiscordMessage(msg: Discord.Message) { + log.info("DiscordBot", `Got delete event for ${msg.id}`); + const storeEvent = await this.store.Get(DbEvent, {discord_id: msg.id}); + if (!storeEvent.Result) { + log.warn("DiscordBot", `Could not redact because the event was in the store.`); + return; + } + while (storeEvent.Next()) { + log.info("DiscordBot", `Deleting discord msg ${storeEvent.DiscordId}`); + const client = this.GetIntentFromDiscordMember(msg.author); + const matrixIds = storeEvent.MatrixId.split(";"); + await client.redactEvent(matrixIds[1], matrixIds[0]); + } } } -} diff --git a/src/config.ts b/src/config.ts index 6bb0025689b308be5bea0e7b62aab635873aa396..cf144ca32abef2f57339e72c4430e56bde908079 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ export class DiscordBridgeConfig { class DiscordBridgeConfigBridge { public domain: string; public homeserverUrl: string; + public presenceInterval: number = 500; public disablePresence: boolean; public disableTypingNotifications: boolean; public disableDiscordMentions: boolean; diff --git a/src/presencehandler.ts b/src/presencehandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..4db081439521a13d95ac8b2ce9de7523a490b13e --- /dev/null +++ b/src/presencehandler.ts @@ -0,0 +1,115 @@ +import * as Discord from "discord.js"; +import * as log from "npmlog"; +import { DiscordBot } from "./bot"; + +export class PresenceHandlerStatus { + /* One of: ["online", "offline", "unavailable"] */ + public Presence: string; + public StatusMsg: string; + public ShouldDrop: boolean = false; +} + +export class PresenceHandler { + private readonly bot: DiscordBot; + private presenceQueue: Discord.GuildMember[]; + private interval: number; + constructor (bot: DiscordBot) { + this.bot = bot; + this.presenceQueue = new Array(); + } + + get QueueCount (): number { + return this.presenceQueue.length; + } + + public Start(intervalTime: number) { + if (this.interval) { + log.info("PresenceHandler", "Restarting presence handler..."); + this.Stop(); + } + log.info("PresenceHandler", `Starting presence handler with new interval ${intervalTime}ms`); + this.interval = setInterval(this.processIntervalThread.bind(this), intervalTime); + } + + public Stop() { + if (!this.interval) { + log.info("PresenceHandler", "Can not stop interval, not running."); + } + log.info("PresenceHandler", "Stopping presence handler"); + clearInterval(this.interval); + this.interval = null; + } + + public EnqueueMember(member: Discord.GuildMember) { + if (!this.presenceQueue.includes(member)) { + log.info("PresenceHandler", `Adding ${member.id} (${member.user.username}) to the presence queue`); + this.presenceQueue.push(member); + } + } + + public DequeueMember(member: Discord.GuildMember) { + const index = this.presenceQueue.findIndex((item) => { + return member === item; + }); + if (index !== -1) { + this.presenceQueue.splice(index, 1); + } else { + log.warn( + "PresenceHandler", + `Tried to remove ${member.id} from the presence queue but it could not be found`, + ); + } + } + + public ProcessMember(member: Discord.GuildMember): boolean { + const status = this.getUserPresence(member.presence); + this.setMatrixPresence(member, status); + return status.ShouldDrop; + } + + private processIntervalThread() { + const member = this.presenceQueue.shift(); + if (member) { + if (!this.ProcessMember(member)) { + this.presenceQueue.push(member); + } else { + log.info("PresenceHandler", `Dropping ${member.id} from the presence queue.`); + } + } + } + + private getUserPresence(presence: Discord.Presence): PresenceHandlerStatus { + const status = new PresenceHandlerStatus(); + + if (presence.game) { + status.StatusMsg = `${presence.game.streaming ? "Streaming" : "Playing"} ${presence.game.name}`; + if (presence.game.url) { + status.StatusMsg += ` | ${presence.game.url}`; + } + } + + if (presence.status === "online") { + status.Presence = "online"; + } else if (presence.status === "dnd") { + status.Presence = "online"; + status.StatusMsg = status.StatusMsg ? "Do not disturb | " + status.StatusMsg : "Do not disturb"; + } else if (presence.status === "offline") { + status.Presence = "offline"; + status.ShouldDrop = true; // Drop until we recieve an update. + } else { // idle + status.Presence = "unavailable"; + } + return status; + } + + private setMatrixPresence(guildMember: Discord.GuildMember, status: PresenceHandlerStatus) { + const intent = this.bot.GetIntentFromDiscordMember(guildMember); + const statusObj: any = {presence: status.Presence}; + if (status.StatusMsg) { + statusObj.status_msg = status.StatusMsg; + } + intent.getClient().setPresence(statusObj).catch((ex) => { + log.warn("PresenceHandler", `Could not update Matrix presence for ${guildMember.id}`); + }); + } +} diff --git a/test/mocks/member.ts b/test/mocks/member.ts index c4f4b442edd0c02d73e3089ba0c741cf4471c2e1..b614c9650032effe5f9b855550bcaddace0942f4 100644 --- a/test/mocks/member.ts +++ b/test/mocks/member.ts @@ -1,11 +1,17 @@ import {MockUser} from "./user"; +import * as Discord from "discord.js"; export class MockMember { public id = ""; - public presence = {status: "offline"}; // TODO: Mock this + public presence: Discord.Presence; public user: MockUser; constructor(id: string, username: string) { this.id = id; + this.presence = new Discord.Presence({}); this.user = new MockUser(this.id, username); } + + public MockSetPresence(presence: Discord.Presence) { + this.presence = presence; + } } diff --git a/test/test_presencehandler.ts b/test/test_presencehandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..963397f998e091ad867eabd5fbf3bb1ab025e59c --- /dev/null +++ b/test/test_presencehandler.ts @@ -0,0 +1,172 @@ +import * as Chai from "chai"; +import * as ChaiAsPromised from "chai-as-promised"; +import * as log from "npmlog"; +import * as Discord from "discord.js"; +import * as Proxyquire from "proxyquire"; + +// import * as Proxyquire from "proxyquire"; +import { PresenceHandler } from "../src/presencehandler"; +import { DiscordBot } from "../src/bot"; +import { MockGuild } from "./mocks/guild"; +import { MockMember } from "./mocks/member"; + +Chai.use(ChaiAsPromised); +const expect = Chai.expect; +const INTERVAL = 250; +let lastStatus = null; +// const assert = Chai.assert; +const bot = { + GetIntentFromDiscordMember: (member) => { + return { + getClient: () => { + return { + setPresence: (status) => { + lastStatus = status; + return Promise.resolve(); + }, + }; + }, + }; + }, +}; + +describe("PresenceHandler", () => { + describe("init", () => { + it("constructor", () => { + const handler = new PresenceHandler(<DiscordBot> bot); + }); + }); + describe("Start", () => { + it("should start without errors", () => { + const handler = new PresenceHandler(<DiscordBot> bot); + handler.Start(INTERVAL); + }); + }); + describe("Stop", () => { + it("should stop without errors", () => { + const handler = new PresenceHandler(<DiscordBot> bot); + handler.Start(INTERVAL); + handler.Stop(); + }); + }); + describe("EnqueueMember", () => { + it("adds a user properly", () => { + const handler = new PresenceHandler(<DiscordBot> bot); + const COUNT = 2; + handler.EnqueueMember(<any> new MockMember("abc", "def")); + handler.EnqueueMember(<any> new MockMember("abc", "ghi")); + Chai.assert.equal(handler.QueueCount, COUNT); + }); + it("does not add duplicate users", () => { + const handler = new PresenceHandler(<DiscordBot> bot); + const member = <any> new MockMember("abc", "def"); + handler.EnqueueMember(member); + handler.EnqueueMember(member); + Chai.assert.equal(handler.QueueCount, 1); + }); + }); + describe("DequeueMember", () => { + it("removes users properly", () => { + const handler = new PresenceHandler(<DiscordBot> bot); + const members = [ + <any> new MockMember("abc", "def"), + <any> new MockMember("abc", "ghi"), + <any> new MockMember("abc", "wew"), + ]; + handler.EnqueueMember(members[0]); + handler.EnqueueMember(members[1]); + handler.EnqueueMember(members[members.length - 1]); + + handler.DequeueMember(members[members.length - 1]); + Chai.assert.equal(handler.QueueCount, members.length - 1); + handler.DequeueMember(members[1]); + Chai.assert.equal(handler.QueueCount, 1); + handler.DequeueMember(members[0]); + Chai.assert.equal(handler.QueueCount, 0); + }); + }); + describe("ProcessMember", () => { + it("processes an online user", () => { + lastStatus = null; + const handler = new PresenceHandler(<DiscordBot> bot); + const member = <any> new MockMember("abc", "def"); + member.MockSetPresence(new Discord.Presence({ + status: "online", + })); + handler.ProcessMember(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + }); + }); + it("processes an offline user", () => { + lastStatus = null; + const handler = new PresenceHandler(<DiscordBot> bot); + const member = <any> new MockMember("abc", "def"); + member.MockSetPresence(new Discord.Presence({ + status: "offline", + })); + handler.ProcessMember(member); + Chai.assert.deepEqual(lastStatus, { + presence: "offline", + }); + + }); + it("processes an idle user", () => { + lastStatus = null; + const handler = new PresenceHandler(<DiscordBot> bot); + const member = <any> new MockMember("abc", "def"); + member.MockSetPresence(new Discord.Presence({ + status: "idle", + })); + handler.ProcessMember(member); + Chai.assert.deepEqual(lastStatus, { + presence: "unavailable", + }); + }); + it("processes an dnd user", () => { + lastStatus = null; + const handler = new PresenceHandler(<DiscordBot> bot); + const member = <any> new MockMember("abc", "def"); + member.MockSetPresence(new Discord.Presence({ + status: "dnd", + })); + handler.ProcessMember(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Do not disturb", + }); + member.MockSetPresence(new Discord.Presence({ + status: "dnd", + game: new Discord.Game({name: "Test Game"}), + })); + handler.ProcessMember(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Do not disturb | Playing Test Game", + }); + }); + it("processes a user playing games", () => { + lastStatus = null; + const handler = new PresenceHandler(<DiscordBot> bot); + const member = <any> new MockMember("abc", "def"); + member.MockSetPresence(new Discord.Presence({ + status: "online", + game: new Discord.Game({name: "Test Game"}), + })); + handler.ProcessMember(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Playing Test Game", + }); + member.MockSetPresence(new Discord.Presence({ + status: "online", + game: new Discord.Game({name: "Test Game", type: 1}), + })); + handler.ProcessMember(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Streaming Test Game", + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9a10f43fdd90e5b671faf50168667ad0c5dcb23d..acfe5f9eac7b63efed68e5a5cbbe729b692dce3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "commonjs", "moduleResolution": "node", - "target": "ES6", + "target": "es2016", "noImplicitAny": false, "sourceMap": false, "outDir": "./build",