-import { MatrixClient, Room } from 'matrix-js-sdk';
+import { Direction, IContextResponse, MatrixClient, Method, Room, RoomMember } from 'matrix-js-sdk';
+import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types';
import { useMemo } from 'react';
-import { getDMRoomFor, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
+import {
+ getDMRoomFor,
+ isRoomAlias,
+ isRoomId,
+ isServerName,
+ isUserId,
+ rateLimitedActions,
+} from '../utils/matrix';
import { hasDevices } from '../../util/matrixUtil';
import * as roomActions from '../../client/action/room';
import { useRoomNavigate } from './useRoomNavigate';
+import { Membership, StateEvent } from '../../types/matrix/room';
+import { getStateEvent } from '../utils/room';
+import { splitWithSpace } from '../utils/common';
export const SHRUG = '¯\\_(ツ)_/¯';
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
export const UNFLIP = '┬─┬ノ( º_ºノ)';
-export function parseUsersAndReason(payload: string): {
- users: string[];
- reason?: string;
-} {
- let reason: string | undefined;
- let ids: string = payload;
-
- const reasonMatch = payload.match(/\s-r\s/);
- if (reasonMatch) {
- ids = payload.slice(0, reasonMatch.index);
- reason = payload.slice((reasonMatch.index ?? 0) + reasonMatch[0].length);
- if (reason.trim() === '') reason = undefined;
+const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b';
+const FLAG_REG = new RegExp(FLAG_PAT);
+const FLAG_REG_G = new RegExp(FLAG_PAT, 'g');
+
+export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => {
+ const flagMatch = payload.match(FLAG_REG);
+
+ if (!flagMatch) {
+ return [payload, undefined];
}
- const rawIds = ids.split(' ');
- const users = rawIds.filter((id) => isUserId(id));
- return {
- users,
- reason,
- };
-}
+ const content = payload.slice(0, flagMatch.index);
+ const flags = payload.slice(flagMatch.index);
+
+ return [content, flags];
+};
+
+export const parseFlags = (flags: string | undefined): Record<string, string | undefined> => {
+ const result: Record<string, string> = {};
+ if (!flags) return result;
+
+ const matches: { key: string; index: number; match: string }[] = [];
+
+ for (let match = FLAG_REG_G.exec(flags); match !== null; match = FLAG_REG_G.exec(flags)) {
+ matches.push({ key: match[1], index: match.index, match: match[0] });
+ }
+
+ for (let i = 0; i < matches.length; i += 1) {
+ const { key, match } = matches[i];
+ const start = matches[i].index + match.length;
+ const end = i + 1 < matches.length ? matches[i + 1].index : flags.length;
+ const value = flags.slice(start, end).trim();
+ result[key] = value;
+ }
+
+ return result;
+};
+
+export const parseUsers = (payload: string): string[] => {
+ const users: string[] = [];
+
+ splitWithSpace(payload).forEach((item) => {
+ if (isUserId(item)) {
+ users.push(item);
+ }
+ });
+
+ return users;
+};
+
+export const parseServers = (payload: string): string[] => {
+ const servers: string[] = [];
+
+ splitWithSpace(payload).forEach((item) => {
+ if (isServerName(item)) {
+ servers.push(item);
+ }
+ });
+
+ return servers;
+};
+
+const getServerMembers = (room: Room, server: string): RoomMember[] => {
+ const members: RoomMember[] = room
+ .getMembers()
+ .filter((member) => member.userId.endsWith(`:${server}`));
+
+ return members;
+};
+
+export const parseTimestampFlag = (input: string): number | undefined => {
+ const match = input.match(/^(\d+(?:\.\d+)?)([dhms])$/); // supports floats like 1.5d
+
+ if (!match) {
+ return undefined;
+ }
+
+ const value = parseFloat(match[1]); // supports decimal values
+ const unit = match[2];
+
+ const now = Date.now(); // in milliseconds
+ let delta = 0;
+
+ switch (unit) {
+ case 'd':
+ delta = value * 24 * 60 * 60 * 1000;
+ break;
+ case 'h':
+ delta = value * 60 * 60 * 1000;
+ break;
+ case 'm':
+ delta = value * 60 * 1000;
+ break;
+ case 's':
+ delta = value * 1000;
+ break;
+ default:
+ return undefined;
+ }
+
+ const timestamp = now - delta;
+ return timestamp;
+};
export type CommandExe = (payload: string) => Promise<void>;
ConvertToRoom = 'converttoroom',
TableFlip = 'tableflip',
UnFlip = 'unflip',
+ Delete = 'delete',
+ Acl = 'acl',
}
export type CommandContent = {
name: Command.StartDm,
description: 'Start direct message with user. Example: /startdm userId1',
exe: async (payload) => {
- const rawIds = payload.split(' ');
+ const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
if (userIds.length === 0) return;
if (userIds.length === 1) {
return;
}
}
- const devices = await Promise.all(userIds.map(uid => hasDevices(mx, uid)));
+ const devices = await Promise.all(userIds.map((uid) => hasDevices(mx, uid)));
const isEncrypt = devices.every((hasDevice) => hasDevice);
const result = await roomActions.createDM(mx, userIds, isEncrypt);
navigateRoom(result.room_id);
name: Command.Join,
description: 'Join room with address. Example: /join address1 address2',
exe: async (payload) => {
- const rawIds = payload.split(' ');
+ const rawIds = splitWithSpace(payload);
const roomIds = rawIds.filter(
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
);
mx.leave(room.roomId);
return;
}
- const rawIds = payload.split(' ');
+ const rawIds = splitWithSpace(payload);
const roomIds = rawIds.filter((id) => isRoomId(id));
roomIds.map((id) => mx.leave(id));
},
name: Command.Invite,
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
exe: async (payload) => {
- const { users, reason } = parseUsersAndReason(payload);
+ const [content, flags] = splitPayloadContentAndFlags(payload);
+ const users = parseUsers(content);
+ const flagToContent = parseFlags(flags);
+ const reason = flagToContent.r;
users.map((id) => mx.invite(room.roomId, id, reason));
},
},
name: Command.DisInvite,
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
exe: async (payload) => {
- const { users, reason } = parseUsersAndReason(payload);
+ const [content, flags] = splitPayloadContentAndFlags(payload);
+ const users = parseUsers(content);
+ const flagToContent = parseFlags(flags);
+ const reason = flagToContent.r;
users.map((id) => mx.kick(room.roomId, id, reason));
},
},
name: Command.Kick,
description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
exe: async (payload) => {
- const { users, reason } = parseUsersAndReason(payload);
- users.map((id) => mx.kick(room.roomId, id, reason));
+ const [content, flags] = splitPayloadContentAndFlags(payload);
+ const users = parseUsers(content);
+ const servers = parseServers(content);
+ const flagToContent = parseFlags(flags);
+ const reason = flagToContent.r;
+
+ const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
+ const serverUsers = serverMembers
+ ?.filter((m) => m.membership !== Membership.Ban)
+ .map((m) => m.userId);
+
+ if (Array.isArray(serverUsers)) {
+ serverUsers.forEach((user) => {
+ if (!users.includes(user)) users.push(user);
+ });
+ }
+
+ rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason));
},
},
[Command.Ban]: {
name: Command.Ban,
- description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]',
+ description: 'Ban user from room. Example: /ban userId1 userId2 servername [-r reason]',
exe: async (payload) => {
- const { users, reason } = parseUsersAndReason(payload);
- users.map((id) => mx.ban(room.roomId, id, reason));
+ const [content, flags] = splitPayloadContentAndFlags(payload);
+ const users = parseUsers(content);
+ const servers = parseServers(content);
+ const flagToContent = parseFlags(flags);
+ const reason = flagToContent.r;
+
+ const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
+ const serverUsers = serverMembers?.map((m) => m.userId);
+
+ if (Array.isArray(serverUsers)) {
+ serverUsers.forEach((user) => {
+ if (!users.includes(user)) users.push(user);
+ });
+ }
+
+ rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason));
},
},
[Command.UnBan]: {
name: Command.UnBan,
description: 'Unban user from room. Example: /unban userId1 userId2',
exe: async (payload) => {
- const rawIds = payload.split(' ');
+ const rawIds = splitWithSpace(payload);
const users = rawIds.filter((id) => isUserId(id));
users.map((id) => mx.unban(room.roomId, id));
},
name: Command.Ignore,
description: 'Ignore user. Example: /ignore userId1 userId2',
exe: async (payload) => {
- const rawIds = payload.split(' ');
+ const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id));
if (userIds.length > 0) roomActions.ignore(mx, userIds);
},
name: Command.UnIgnore,
description: 'Unignore user. Example: /unignore userId1 userId2',
exe: async (payload) => {
- const rawIds = payload.split(' ');
+ const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id));
if (userIds.length > 0) roomActions.unignore(mx, userIds);
},
roomActions.convertToRoom(mx, room.roomId);
},
},
+ [Command.Delete]: {
+ name: Command.Delete,
+ description:
+ 'Delete messages from users. Example: /delete userId1 servername -past 1d|2h|5m|30s [-t m.room.message] [-r spam]',
+ exe: async (payload) => {
+ const [content, flags] = splitPayloadContentAndFlags(payload);
+ const users = parseUsers(content);
+ const servers = parseServers(content);
+
+ const flagToContent = parseFlags(flags);
+ const reason = flagToContent.r;
+ const pastContent = flagToContent.past ?? '';
+ const msgTypeContent = flagToContent.t;
+ const messageTypes: string[] = msgTypeContent ? splitWithSpace(msgTypeContent) : [];
+
+ const ts = parseTimestampFlag(pastContent);
+ if (!ts) return;
+
+ const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
+ const serverUsers = serverMembers?.map((m) => m.userId);
+
+ if (Array.isArray(serverUsers)) {
+ serverUsers.forEach((user) => {
+ if (!users.includes(user)) users.push(user);
+ });
+ }
+
+ const result = await mx.timestampToEvent(room.roomId, ts, Direction.Forward);
+ const startEventId = result.event_id;
+
+ const path = `/rooms/${encodeURIComponent(room.roomId)}/context/${encodeURIComponent(
+ startEventId
+ )}`;
+ const eventContext = await mx.http.authedRequest<IContextResponse>(Method.Get, path, {
+ limit: 0,
+ });
+
+ let token: string | undefined = eventContext.start;
+ while (token) {
+ // eslint-disable-next-line no-await-in-loop
+ const response = await mx.createMessagesRequest(
+ room.roomId,
+ token,
+ 20,
+ Direction.Forward,
+ undefined
+ );
+ const { end, chunk } = response;
+ // remove until the latest event;
+ token = end;
+
+ const eventsToDelete = chunk.filter(
+ (roomEvent) =>
+ (messageTypes.length > 0 ? messageTypes.includes(roomEvent.type) : true) &&
+ users.includes(roomEvent.sender) &&
+ roomEvent.unsigned?.redacted_because === undefined
+ );
+
+ const eventIds = eventsToDelete.map((roomEvent) => roomEvent.event_id);
+
+ // eslint-disable-next-line no-await-in-loop
+ await rateLimitedActions(eventIds, (eventId) =>
+ mx.redactEvent(room.roomId, eventId, undefined, { reason })
+ );
+ }
+ },
+ },
+ [Command.Acl]: {
+ name: Command.Acl,
+ description:
+ 'Manage server access control list. Example /acl [-a servername1] [-d servername2] [-ra servername1] [-rd servername2]',
+ exe: async (payload) => {
+ const [, flags] = splitPayloadContentAndFlags(payload);
+
+ const flagToContent = parseFlags(flags);
+ const allowFlag = flagToContent.a;
+ const denyFlag = flagToContent.d;
+ const removeAllowFlag = flagToContent.ra;
+ const removeDenyFlag = flagToContent.rd;
+
+ const allowList = allowFlag ? splitWithSpace(allowFlag) : [];
+ const denyList = denyFlag ? splitWithSpace(denyFlag) : [];
+ const removeAllowList = removeAllowFlag ? splitWithSpace(removeAllowFlag) : [];
+ const removeDenyList = removeDenyFlag ? splitWithSpace(removeDenyFlag) : [];
+
+ const serverAcl = getStateEvent(
+ room,
+ StateEvent.RoomServerAcl
+ )?.getContent<RoomServerAclEventContent>();
+
+ const aclContent: RoomServerAclEventContent = {
+ allow: serverAcl?.allow ? [...serverAcl.allow] : [],
+ allow_ip_literals: serverAcl?.allow_ip_literals,
+ deny: serverAcl?.deny ? [...serverAcl.deny] : [],
+ };
+
+ allowList.forEach((servername) => {
+ if (!Array.isArray(aclContent.allow) || aclContent.allow.includes(servername)) return;
+ aclContent.allow.push(servername);
+ });
+ denyList.forEach((servername) => {
+ if (!Array.isArray(aclContent.deny) || aclContent.deny.includes(servername)) return;
+ aclContent.deny.push(servername);
+ });
+
+ aclContent.allow = aclContent.allow?.filter(
+ (servername) => !removeAllowList.includes(servername)
+ );
+ aclContent.deny = aclContent.deny?.filter(
+ (servername) => !removeDenyList.includes(servername)
+ );
+
+ aclContent.allow?.sort();
+ aclContent.deny?.sort();
+
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, aclContent);
+ },
+ },
}),
[mx, room, navigateRoom]
);