diff --git a/changelog.d/854.feature b/changelog.d/854.feature new file mode 100644 index 00000000..6413d949 --- /dev/null +++ b/changelog.d/854.feature @@ -0,0 +1 @@ +Add listusers command to Discord bot to list the users on the Matrix side. Thanks to @SethFalco! diff --git a/src/bot.ts b/src/bot.ts index 0ca0b768..7d7df041 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -144,7 +144,7 @@ export class DiscordBot { this.mxEventProcessor = new MatrixEventProcessor( new MatrixEventProcessorOpts(config, bridge, this, store), ); - this.discordCommandHandler = new DiscordCommandHandler(bridge, this); + this.discordCommandHandler = new DiscordCommandHandler(bridge, this, config); // init vars this.sentMessages = []; this.discordMessageQueue = {}; diff --git a/src/discordcommandhandler.ts b/src/discordcommandhandler.ts index f1d5fda0..a348405a 100644 --- a/src/discordcommandhandler.ts +++ b/src/discordcommandhandler.ts @@ -18,7 +18,8 @@ import { DiscordBot } from "./bot"; import * as Discord from "better-discord.js"; import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util"; import { Log } from "./log"; -import { Appservice } from "matrix-bot-sdk"; +import { Appservice, Presence } from "matrix-bot-sdk"; +import { DiscordBridgeConfig } from './config'; const log = new Log("DiscordCommandHandler"); @@ -26,17 +27,20 @@ export class DiscordCommandHandler { constructor( private bridge: Appservice, private discord: DiscordBot, + private config: DiscordBridgeConfig, ) { } + /** + * @param msg Message to process. + * @returns The message the bot replied with. + */ public async Process(msg: Discord.Message) { const chan = msg.channel as Discord.TextChannel; if (!chan.guild) { - await msg.channel.send("**ERROR:** only available for guild channels"); - return; + return await msg.channel.send("**ERROR:** only available for guild channels"); } if (!msg.member) { - await msg.channel.send("**ERROR:** could not determine message member"); - return; + return await msg.channel.send("**ERROR:** could not determine message member"); } const discordMember = msg.member; @@ -50,7 +54,7 @@ export class DiscordCommandHandler { permission: "MANAGE_WEBHOOKS", run: async () => { if (await this.discord.Provisioner.MarkApproved(chan, discordMember, true)) { - return "Thanks for your response! The matrix bridge has been approved."; + return "Thanks for your response! The Matrix bridge has been approved."; } else { return "Thanks for your response, however" + " it has arrived after the deadline - sorry!"; @@ -58,7 +62,7 @@ export class DiscordCommandHandler { }, }, ban: { - description: "Bans a user on the matrix side", + description: "Bans a user on the Matrix side", params: ["name"], permission: "BAN_MEMBERS", run: this.ModerationActionGenerator(chan, "ban"), @@ -69,7 +73,7 @@ export class DiscordCommandHandler { permission: "MANAGE_WEBHOOKS", run: async () => { if (await this.discord.Provisioner.MarkApproved(chan, discordMember, false)) { - return "Thanks for your response! The matrix bridge has been declined."; + return "Thanks for your response! The Matrix bridge has been declined."; } else { return "Thanks for your response, however" + " it has arrived after the deadline - sorry!"; @@ -77,28 +81,34 @@ export class DiscordCommandHandler { }, }, kick: { - description: "Kicks a user on the matrix side", + description: "Kicks a user on the Matrix side", params: ["name"], permission: "KICK_MEMBERS", run: this.ModerationActionGenerator(chan, "kick"), }, unban: { - description: "Unbans a user on the matrix side", + description: "Unbans a user on the Matrix side", params: ["name"], permission: "BAN_MEMBERS", run: this.ModerationActionGenerator(chan, "unban"), }, unbridge: { - description: "Unbridge matrix rooms from this channel", + description: "Unbridge Matrix rooms from this channel", params: [], permission: ["MANAGE_WEBHOOKS", "MANAGE_CHANNELS"], run: async () => this.UnbridgeChannel(chan), }, + listusers: { + description: "List users on the Matrix side of the bridge", + params: [], + permission: [], + run: async () => this.ListMatrixMembers(chan) + } }; const parameters: ICommandParameters = { name: { - description: "The display name or mxid of a matrix user", + description: "The display name or mxid of a Matrix user", get: async (name) => { const channelMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(msg.channel); const mxUserId = await Util.GetMxidFromName(intent, name, channelMxids); @@ -115,7 +125,7 @@ export class DiscordCommandHandler { }; const reply = await Util.ParseCommand("!matrix", msg.content, actions, parameters, permissionCheck); - await msg.channel.send(reply); + return await msg.channel.send(reply); } private ModerationActionGenerator(discordChannel: Discord.TextChannel, funcKey: "kick"|"ban"|"unban") { @@ -156,7 +166,7 @@ export class DiscordCommandHandler { return "This channel has been unbridged"; } catch (err) { if (err.message === "Channel is not bridged") { - return "This channel is not bridged to a plumbed matrix room"; + return "This channel is not bridged to a plumbed Matrix room"; } log.error("Error while unbridging room " + channel.id); log.error(err); @@ -164,4 +174,125 @@ export class DiscordCommandHandler { "Please try again later or contact the bridge operator."; } } + + private async ListMatrixMembers(channel: Discord.TextChannel): Promise { + const chanMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(channel); + const members: { + mxid: string; + displayName?: string; + presence?: Presence; + }[] = []; + const errorMessages: string[] = []; + + await Promise.all(chanMxids.map(async (chanMxid) => { + const { underlyingClient } = this.bridge.botIntent; + + try { + const memberProfiles = await underlyingClient.getJoinedRoomMembersWithProfiles(chanMxid); + const userProfiles = Object.keys(memberProfiles) + .filter((mxid) => !this.bridge.isNamespacedUser(mxid)) + .map((mxid) => ({ mxid, displayName: memberProfiles[mxid].display_name })); + + members.push(...userProfiles); + } catch (e) { + errorMessages.push(`Couldn't get members from ${chanMxid}`); + } + })); + + if (errorMessages.length) { + throw Error(errorMessages.join('\n')); + } + + if (!this.config.bridge.disablePresence) { + + await Promise.all(members.map(async (member) => { + try { + const presence = await this.bridge.botClient.getPresenceStatusFor(member.mxid); + member.presence = presence; + } catch (e) { + errorMessages.push(`Couldn't get presence for ${member.mxid}`); + } + })); + } + + if (errorMessages.length) { + throw Error(errorMessages.join('\n')); + } + + const length = members.length; + const formatter = new Intl.NumberFormat('en-US'); + const formattedTotalMembers = formatter.format(length); + let userCount: string; + + if (length === 1) { + userCount = `is **1** user`; + } else { + userCount = `are **${formattedTotalMembers}** users`; + } + + const userCountMessage = `There ${userCount} on the Matrix side.`; + + if (length === 0) { + return userCountMessage; + } + + members.sort((a, b) => { + const aPresenceState = a.presence?.state ?? "unknown"; + const bPresenceState = b.presence?.state ?? "unknown"; + + if (aPresenceState === bPresenceState) { + const aDisplayName = a.displayName; + const bDisplayName = b.displayName; + + if (aDisplayName === bDisplayName) { + return a.mxid.localeCompare(b.mxid); + } + + if (!aDisplayName) { + return 1; + } + + if (!bDisplayName) { + return -1; + } + + return aDisplayName.localeCompare(bDisplayName, 'en', { sensitivity: "base" }); + } + + const presenseOrdinal = { + "online": 0, + "unavailable": 1, + "offline": 2, + "unknown": 3 + }; + + return presenseOrdinal[aPresenceState] - presenseOrdinal[bPresenceState]; + }); + + /** Reserve characters for the worst-case "and x others…" line at the end if there are too many members. */ + const reservedChars = `\n_and ${formattedTotalMembers} others…_`.length; + let message = `${userCountMessage} Matrix users in ${channel.toString()} may not necessarily be in the other bridged channels in the server.\n`; + + for (let i = 0; i < length; i++) { + const { mxid, displayName, presence } = members[i]; + let line = "• "; + line += (displayName) ? `${displayName} (${mxid})` : mxid; + + if (!this.config.bridge.disablePresence) { + const state = presence?.state ?? "unknown"; + // Use Discord terminology for Away + const stateDisplay = (state === "unavailable") ? "idle" : state; + line += ` - ${stateDisplay.charAt(0).toUpperCase() + stateDisplay.slice(1)}`; + } + + if (2000 - message.length - reservedChars < line.length) { + const remaining = length - i; + return message + `\n_and ${formatter.format(remaining)} others…_`; + } + + message += `\n${line}`; + } + + return message; + } } diff --git a/test/mocks/appservicemock.ts b/test/mocks/appservicemock.ts index 28eb3935..95c5bb74 100644 --- a/test/mocks/appservicemock.ts +++ b/test/mocks/appservicemock.ts @@ -182,6 +182,13 @@ class MatrixClientMock extends AppserviceMockBase { super(); } + public getPresenceStatusFor(userId: string) { + this.funcCalled("getPresenceStatusFor", userId); + return { + state: "online" + } + } + public banUser(roomId: string, userId: string) { this.funcCalled("banUser", roomId, userId); } diff --git a/test/mocks/channel.ts b/test/mocks/channel.ts index 20ec2d7d..7c34acbf 100644 --- a/test/mocks/channel.ts +++ b/test/mocks/channel.ts @@ -40,6 +40,10 @@ export class MockChannel { public permissionsFor(member: MockMember) { return new Permissions(Permissions.FLAGS.MANAGE_WEBHOOKS as PermissionResolvable); } + + public toString(): string { + return `<#${this.id}>`; + } } export class MockTextChannel extends TextChannel { diff --git a/test/mocks/guild.ts b/test/mocks/guild.ts index c2237827..8f4cab34 100644 --- a/test/mocks/guild.ts +++ b/test/mocks/guild.ts @@ -57,4 +57,8 @@ export class MockGuild { public _mockAddMember(member: MockMember) { this.members.cache.set(member.id, member); } + + public toString(): string { + return `<#${this.id}>`; + } } diff --git a/test/structures/test_lock.ts b/test/structures/test_lock.ts index 114ddb90..870f15a5 100644 --- a/test/structures/test_lock.ts +++ b/test/structures/test_lock.ts @@ -16,7 +16,6 @@ limitations under the License. import { expect } from "chai"; import { Lock } from "../../src/structures/lock"; -import { Util } from "../../src/util"; const LOCKTIMEOUT = 300; diff --git a/test/test_discordcommandhandler.ts b/test/test_discordcommandhandler.ts index d29ee9b0..cb4ea773 100644 --- a/test/test_discordcommandhandler.ts +++ b/test/test_discordcommandhandler.ts @@ -30,7 +30,7 @@ let MARKED = -1; function createCH(opts: any = {}) { ROOMSUNBRIDGED = 0; MARKED = -1; - const bridge = new AppserviceMock(); + const bridge = new AppserviceMock(opts); const cs = { GetRoomIdsFromChannel: async (chan) => { return [`#${chan.id}:localhost`]; @@ -59,7 +59,13 @@ function createCH(opts: any = {}) { }, }, })).DiscordCommandHandler; - return {handler: new discordCommandHndlr(bridge as any, discord as any), bridge}; + + const config = { + bridge: { + disablePresence: false + } + } + return {handler: new discordCommandHndlr(bridge as any, discord as any, config), bridge}; } describe("DiscordCommandHandler", () => { @@ -80,6 +86,7 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); bridge.botIntent.underlyingClient.wasCalled("kickUser", true, "#123:localhost", "@123456:localhost"); }); + it("will kick a member in all guild rooms", async () => { const {handler, bridge} = createCH(); const channel = new MockChannel("123"); @@ -97,6 +104,7 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(bridge.botIntent.underlyingClient.wasCalled("kickUser")).to.equal(2); }); + it("will deny permission", async () => { const {handler, bridge} = createCH(); const channel = new MockChannel("123"); @@ -114,6 +122,7 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(bridge.botIntent.underlyingClient.wasCalled("kickUser", false)).to.equal(0); }); + it("will ban a member", async () => { const {handler, bridge} = createCH(); const channel = new MockChannel("123"); @@ -131,6 +140,7 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(bridge.botIntent.underlyingClient.wasCalled("banUser")).to.equal(1); }); + it("will unban a member", async () => { const {handler, bridge} = createCH(); const channel = new MockChannel("123"); @@ -148,6 +158,7 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(bridge.botIntent.underlyingClient.wasCalled("unbanUser")).to.equal(1); }); + it("handles !matrix approve", async () => { const {handler} = createCH(); const channel = new MockChannel("123"); @@ -165,6 +176,7 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(MARKED).equals(1); }); + it("handles !matrix deny", async () => { const {handler} = createCH(); const channel = new MockChannel("123"); @@ -182,6 +194,7 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(MARKED).equals(0); }); + it("handles !matrix unbridge", async () => { const {handler} = createCH(); const channel = new MockChannel("123"); @@ -199,4 +212,132 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(ROOMSUNBRIDGED).equals(1); }); + + it("!matrix listusers with 0 Matrix users", async () => { + const {handler} = createCH(); + handler.bridge.botIntent.underlyingClient.getJoinedRoomMembersWithProfiles = () => { + return {}; + }; + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = (): boolean => { + return true; + }; + const message = { + channel, + content: "!matrix listusers", + member, + }; + const sentMessage = await handler.Process(message); + + expect(sentMessage).equals( + "There are **0** users on the Matrix side." + ); + }); + + it("!matrix listusers with 3 Matrix users with presence enabled", async () => { + const {handler} = createCH({ + userIdPrefix: "@_discord_" + }); + handler.bridge.botIntent.underlyingClient.getJoinedRoomMembersWithProfiles = () => { + return { + "@abc:one.ems.host": { display_name: "ABC" }, + "@def:matrix.org": { display_name: "DEF" }, + "@ghi:mozilla.org": {}, + }; + }; + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = (): boolean => { + return true; + }; + const message = { + channel, + content: "!matrix listusers", + member, + }; + const sentMessage = await handler.Process(message); + + expect(sentMessage).equals( + "There are **3** users on the Matrix side. Matrix users in <#123> may not necessarily be in the other bridged channels in the server.\n\n• ABC (@abc:one.ems.host) - Online\n• DEF (@def:matrix.org) - Online\n• @ghi:mozilla.org - Online" + ); + }); + + it("assert that !matrix listusers ignores users with namespaced userIdPrefix", async () => { + const {handler} = createCH({ + userIdPrefix: "@_discord_" + }); + handler.bridge.botIntent.underlyingClient.getJoinedRoomMembersWithProfiles = () => { + return { + "@abc:one.ems.host": { display_name: "ABC" }, + "@_discord_123456:bridge.org": { display_name: "DEF" } + }; + }; + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = (): boolean => { + return true; + }; + const message = { + channel, + content: "!matrix listusers", + member, + }; + const sentMessage = await handler.Process(message); + + expect(sentMessage).equals( + "There is **1** user on the Matrix side. Matrix users in <#123> may not necessarily be in the other bridged channels in the server.\n\n• ABC (@abc:one.ems.host) - Online" + ); + }); + + it("assert that !matrix listusers users are displayed in order of presence, display name, then mxid, case insensitive", async () => { + const {handler} = createCH({ + userIdPrefix: "@_discord_" + }); + + handler.bridge.botClient.getPresenceStatusFor = (userId) => { + switch (userId) { + case "@jelly:matrix.org": + case "@toast:mozilla.org": + return { state: "online" }; + case "@jen:matrix.org": + return { state: "offline" }; + default: + return { state: "unavailable" }; + } + }; + + handler.bridge.botIntent.underlyingClient.getJoinedRoomMembersWithProfiles = () => { + return { + "@seth:one.ems.host": { display_name: "Seth" }, + "@sam:one.ems.host": { display_name: "sam" }, + "@jen:matrix.org": { display_name: "Jen" }, + "@toast:mozilla.org": {}, + "@jelly:matrix.org": { display_name: "jelly" } + }; + }; + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = (): boolean => { + return true; + }; + const message = { + channel, + content: "!matrix listusers", + member, + }; + const sentMessage = await handler.Process(message); + + expect(sentMessage).equals( + "There are **5** users on the Matrix side. Matrix users in <#123> may not necessarily be in the other bridged channels in the server.\n\n• jelly (@jelly:matrix.org) - Online\n• @toast:mozilla.org - Online\n• sam (@sam:one.ems.host) - Idle\n• Seth (@seth:one.ems.host) - Idle\n• Jen (@jen:matrix.org) - Offline" + ); + }); });