Skip to content

Commit bd15968

Browse files
committed
feat: add listusers command
Signed-off-by: Seth Falco <[email protected]>
1 parent ac5180e commit bd15968

File tree

8 files changed

+322
-18
lines changed

8 files changed

+322
-18
lines changed

changelog.d/854.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add listusers command to Discord bot to list the users on the Matrix side. Thanks to @SethFalco!

src/bot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export class DiscordBot {
144144
this.mxEventProcessor = new MatrixEventProcessor(
145145
new MatrixEventProcessorOpts(config, bridge, this, store),
146146
);
147-
this.discordCommandHandler = new DiscordCommandHandler(bridge, this);
147+
this.discordCommandHandler = new DiscordCommandHandler(bridge, this, config);
148148
// init vars
149149
this.sentMessages = [];
150150
this.discordMessageQueue = {};

src/discordcommandhandler.ts

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,29 @@ import { DiscordBot } from "./bot";
1818
import * as Discord from "better-discord.js";
1919
import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util";
2020
import { Log } from "./log";
21-
import { Appservice } from "matrix-bot-sdk";
21+
import { Appservice, Presence } from "matrix-bot-sdk";
22+
import { DiscordBridgeConfig } from './config';
2223

2324
const log = new Log("DiscordCommandHandler");
2425

2526
export class DiscordCommandHandler {
2627
constructor(
2728
private bridge: Appservice,
2829
private discord: DiscordBot,
30+
private config: DiscordBridgeConfig,
2931
) { }
3032

33+
/**
34+
* @param msg Message to process.
35+
* @returns The message the bot replied with.
36+
*/
3137
public async Process(msg: Discord.Message) {
3238
const chan = msg.channel as Discord.TextChannel;
3339
if (!chan.guild) {
34-
await msg.channel.send("**ERROR:** only available for guild channels");
35-
return;
40+
return await msg.channel.send("**ERROR:** only available for guild channels");
3641
}
3742
if (!msg.member) {
38-
await msg.channel.send("**ERROR:** could not determine message member");
39-
return;
43+
return await msg.channel.send("**ERROR:** could not determine message member");
4044
}
4145

4246
const discordMember = msg.member;
@@ -50,15 +54,15 @@ export class DiscordCommandHandler {
5054
permission: "MANAGE_WEBHOOKS",
5155
run: async () => {
5256
if (await this.discord.Provisioner.MarkApproved(chan, discordMember, true)) {
53-
return "Thanks for your response! The matrix bridge has been approved.";
57+
return "Thanks for your response! The Matrix bridge has been approved.";
5458
} else {
5559
return "Thanks for your response, however" +
5660
" it has arrived after the deadline - sorry!";
5761
}
5862
},
5963
},
6064
ban: {
61-
description: "Bans a user on the matrix side",
65+
description: "Bans a user on the Matrix side",
6266
params: ["name"],
6367
permission: "BAN_MEMBERS",
6468
run: this.ModerationActionGenerator(chan, "ban"),
@@ -69,36 +73,42 @@ export class DiscordCommandHandler {
6973
permission: "MANAGE_WEBHOOKS",
7074
run: async () => {
7175
if (await this.discord.Provisioner.MarkApproved(chan, discordMember, false)) {
72-
return "Thanks for your response! The matrix bridge has been declined.";
76+
return "Thanks for your response! The Matrix bridge has been declined.";
7377
} else {
7478
return "Thanks for your response, however" +
7579
" it has arrived after the deadline - sorry!";
7680
}
7781
},
7882
},
7983
kick: {
80-
description: "Kicks a user on the matrix side",
84+
description: "Kicks a user on the Matrix side",
8185
params: ["name"],
8286
permission: "KICK_MEMBERS",
8387
run: this.ModerationActionGenerator(chan, "kick"),
8488
},
8589
unban: {
86-
description: "Unbans a user on the matrix side",
90+
description: "Unbans a user on the Matrix side",
8791
params: ["name"],
8892
permission: "BAN_MEMBERS",
8993
run: this.ModerationActionGenerator(chan, "unban"),
9094
},
9195
unbridge: {
92-
description: "Unbridge matrix rooms from this channel",
96+
description: "Unbridge Matrix rooms from this channel",
9397
params: [],
9498
permission: ["MANAGE_WEBHOOKS", "MANAGE_CHANNELS"],
9599
run: async () => this.UnbridgeChannel(chan),
96100
},
101+
listusers: {
102+
description: "List users on the Matrix side of the bridge",
103+
params: [],
104+
permission: [],
105+
run: async () => this.ListMatrixMembers(chan)
106+
}
97107
};
98108

99109
const parameters: ICommandParameters = {
100110
name: {
101-
description: "The display name or mxid of a matrix user",
111+
description: "The display name or mxid of a Matrix user",
102112
get: async (name) => {
103113
const channelMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(msg.channel);
104114
const mxUserId = await Util.GetMxidFromName(intent, name, channelMxids);
@@ -115,7 +125,7 @@ export class DiscordCommandHandler {
115125
};
116126

117127
const reply = await Util.ParseCommand("!matrix", msg.content, actions, parameters, permissionCheck);
118-
await msg.channel.send(reply);
128+
return await msg.channel.send(reply);
119129
}
120130

121131
private ModerationActionGenerator(discordChannel: Discord.TextChannel, funcKey: "kick"|"ban"|"unban") {
@@ -156,12 +166,150 @@ export class DiscordCommandHandler {
156166
return "This channel has been unbridged";
157167
} catch (err) {
158168
if (err.message === "Channel is not bridged") {
159-
return "This channel is not bridged to a plumbed matrix room";
169+
return "This channel is not bridged to a plumbed Matrix room";
160170
}
161171
log.error("Error while unbridging room " + channel.id);
162172
log.error(err);
163173
return "There was an error unbridging this room. " +
164174
"Please try again later or contact the bridge operator.";
165175
}
166176
}
177+
178+
private async ListMatrixMembers(channel: Discord.TextChannel): Promise<string> {
179+
const chanMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(channel);
180+
const members: {
181+
mxid: string;
182+
displayName?: string;
183+
presence?: Presence;
184+
}[] = [];
185+
const errorMessages: string[] = [];
186+
187+
await Promise.all(chanMxids.map(async (chanMxid) => {
188+
const { underlyingClient } = this.bridge.botIntent;
189+
190+
try {
191+
const memberProfiles = await underlyingClient.getJoinedRoomMembersWithProfiles(chanMxid);
192+
const userProfiles = Object.keys(memberProfiles)
193+
.filter((mxid) => !this.bridge.isNamespacedUser(mxid))
194+
.map((mxid) => {
195+
return {
196+
mxid,
197+
displayName: memberProfiles[mxid].display_name
198+
};
199+
});
200+
201+
members.push(...userProfiles);
202+
} catch (e) {
203+
errorMessages.push(`Couldn't get members from ${chanMxid}`);
204+
}
205+
}));
206+
207+
if (errorMessages.length) {
208+
const errorMessage = errorMessages.join('\n');
209+
throw Error(errorMessage);
210+
}
211+
212+
if (!this.config.bridge.disablePresence) {
213+
await Promise.all(members.map(async (member) => {
214+
const { botClient } = this.bridge;
215+
try {
216+
const presence = await botClient.getPresenceStatusFor(member.mxid);
217+
member.presence = presence;
218+
return presence;
219+
} catch (e) {
220+
errorMessages.push(`Couldn't get presence for ${member.mxid}`);
221+
}
222+
}));
223+
}
224+
225+
if (errorMessages.length) {
226+
const errorMessage = errorMessages.join('\n');
227+
throw Error(errorMessage);
228+
}
229+
230+
const length = members.length;
231+
const formatter = new Intl.NumberFormat('en-US');
232+
const formattedTotalMembers = formatter.format(length);
233+
let userCount: string;
234+
235+
if (length === 1) {
236+
userCount = `is **1** user`;
237+
} else {
238+
userCount = `are **${formattedTotalMembers}** users`;
239+
}
240+
241+
const userCountMessage = `There ${userCount} on the Matrix side.`;
242+
243+
if (length === 0) {
244+
return userCountMessage;
245+
}
246+
247+
members.sort((a, b) => {
248+
const aPresenceState = a.presence?.state ?? "unknown";
249+
const bPresenceState = b.presence?.state ?? "unknown";
250+
251+
if (aPresenceState === bPresenceState) {
252+
const aDisplayName = a.displayName;
253+
const bDisplayName = b.displayName;
254+
255+
if (aDisplayName === bDisplayName) {
256+
return a.mxid.localeCompare(b.mxid);
257+
}
258+
259+
if (!aDisplayName) {
260+
return 1;
261+
}
262+
263+
if (!bDisplayName) {
264+
return -1;
265+
}
266+
267+
return aDisplayName.localeCompare(bDisplayName, 'en', { sensitivity: "base" });
268+
}
269+
270+
const presenseOrdinal = {
271+
"online": 0,
272+
"unavailable": 1,
273+
"offline": 2,
274+
"unknown": 3
275+
};
276+
277+
return presenseOrdinal[aPresenceState] - presenseOrdinal[bPresenceState];
278+
});
279+
280+
const disclaimer = `Matrix users in ${channel.toString()} may not necessarily be in the other bridged channels in the server.`;
281+
/** Reserve characters for the worst-case "and x others…" line at the end if there are too many members. */
282+
const reservedChars = `\n_and ${formattedTotalMembers} others…_`.length;
283+
284+
let message = `${userCountMessage} ${disclaimer}\n`;
285+
286+
for (let i = 0; i < length; i++) {
287+
const member = members[i];
288+
const hasDisplayName = !!member.displayName;
289+
let line = "• ";
290+
291+
if (hasDisplayName) {
292+
line += `${member.displayName} (${member.mxid})`;
293+
} else {
294+
line += member.mxid;
295+
}
296+
297+
if (!this.config.bridge.disablePresence) {
298+
const state = member.presence?.state ?? "unknown";
299+
// Use Discord terminology for Away
300+
const stateDisplay = (state === "unavailable") ? "idle" : state;
301+
line += ` - ${stateDisplay.charAt(0).toUpperCase() + stateDisplay.slice(1)}`;
302+
}
303+
304+
if (2000 - message.length - reservedChars < line.length) {
305+
const remaining = length - i;
306+
message += `\n_and ${formatter.format(remaining)} others…_`;
307+
return message;
308+
}
309+
310+
message += `\n${line}`;
311+
}
312+
313+
return message;
314+
}
167315
}

test/mocks/appservicemock.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ class MatrixClientMock extends AppserviceMockBase {
182182
super();
183183
}
184184

185+
public getPresenceStatusFor(userId: string) {
186+
this.funcCalled("getPresenceStatusFor", userId);
187+
return {
188+
state: "online"
189+
}
190+
}
191+
185192
public banUser(roomId: string, userId: string) {
186193
this.funcCalled("banUser", roomId, userId);
187194
}

test/mocks/channel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export class MockChannel {
4040
public permissionsFor(member: MockMember) {
4141
return new Permissions(Permissions.FLAGS.MANAGE_WEBHOOKS as PermissionResolvable);
4242
}
43+
44+
public toString(): string {
45+
return `<#${this.id}>`;
46+
}
4347
}
4448

4549
export class MockTextChannel extends TextChannel {

test/mocks/guild.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,8 @@ export class MockGuild {
5757
public _mockAddMember(member: MockMember) {
5858
this.members.cache.set(member.id, member);
5959
}
60+
61+
public toString(): string {
62+
return `<#${this.id}>`;
63+
}
6064
}

test/structures/test_lock.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ limitations under the License.
1616

1717
import { expect } from "chai";
1818
import { Lock } from "../../src/structures/lock";
19-
import { Util } from "../../src/util";
2019

2120
const LOCKTIMEOUT = 300;
2221

0 commit comments

Comments
 (0)