From 87e97eab8872e091cdf24788db10037da9044edc Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 13 May 2025 16:16:22 +0530 Subject: [PATCH] Update commands (#2325) * kick-ban all members by servername * Add command for deleting multiple messages * remove console logs and improve ban command description * improve commands description * add server acl command * fix code highlight not working after editing in dev tools --- src/app/components/text-viewer/TextViewer.tsx | 2 +- src/app/features/room/CommandAutocomplete.tsx | 22 +- src/app/hooks/useCommands.ts | 319 ++++++++++++++++-- src/app/utils/common.ts | 6 + src/app/utils/matrix.ts | 37 ++ 5 files changed, 340 insertions(+), 46 deletions(-) diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index f39ef95..ec4ed0a 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -24,7 +24,7 @@ export const TextViewerContent = forwardRef {text}}> {text}}> - {(codeRef) => {text}} + {(codeRef) => {text}} diff --git a/src/app/features/room/CommandAutocomplete.tsx b/src/app/features/room/CommandAutocomplete.tsx index 31903ac..6b7ba56 100644 --- a/src/app/features/room/CommandAutocomplete.tsx +++ b/src/app/features/room/CommandAutocomplete.tsx @@ -1,6 +1,6 @@ import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react'; import { Editor } from 'slate'; -import { Box, MenuItem, Text } from 'folds'; +import { Box, config, MenuItem, Text } from 'folds'; import { Room } from 'matrix-js-sdk'; import { Command, useCommands } from '../../hooks/useCommands'; import { @@ -75,9 +75,6 @@ export function CommandAutocomplete({ headerContent={ Commands - - Begin your message with command - } requestClose={requestClose} @@ -87,17 +84,22 @@ export function CommandAutocomplete({ key={commandName} as="button" radii="300" + style={{ height: 'unset' }} onKeyDown={(evt: ReactKeyboardEvent) => onTabPress(evt, () => handleAutocomplete(commandName)) } onClick={() => handleAutocomplete(commandName)} > - - - - {`/${commandName}`} - - + + + {`/${commandName}`} + {commands[commandName].description} diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index bc7d289..c7a5358 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -1,34 +1,127 @@ -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 => { + const result: Record = {}; + 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; @@ -52,6 +145,8 @@ export enum Command { ConvertToRoom = 'converttoroom', TableFlip = 'tableflip', UnFlip = 'unflip', + Delete = 'delete', + Acl = 'acl', } export type CommandContent = { @@ -96,7 +191,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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) { @@ -106,7 +201,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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); @@ -116,7 +211,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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) ); @@ -131,7 +226,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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)); }, @@ -140,7 +235,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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)); }, }, @@ -148,7 +246,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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)); }, }, @@ -156,23 +257,53 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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)); }, @@ -181,7 +312,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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); }, @@ -190,7 +321,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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); }, @@ -227,6 +358,124 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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(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(); + + 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] ); diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index d230c6b..34e1ecb 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -125,3 +125,9 @@ export const suffixRename = (name: string, validator: (newName: string) => boole }; export const replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-'); + +export const splitWithSpace = (content: string): string[] => { + const trimmedContent = content.trim(); + if (trimmedContent === '') return []; + return trimmedContent.split(' '); +}; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index cd3c086..75430c2 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -13,11 +13,16 @@ import { UploadProgress, UploadResponse, } from 'matrix-js-sdk'; +import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { getStateEvent } from './room'; import { StateEvent } from '../../types/matrix/room'; +const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; + +export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); + export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/); export const validMxId = (id: string): boolean => !!matchMxId(id); @@ -292,3 +297,35 @@ export const downloadEncryptedMedia = async ( return decryptedContent; }; + +export const rateLimitedActions = async ( + data: T[], + callback: (item: T) => Promise, + maxRetryCount?: number +) => { + let retryCount = 0; + const performAction = async (dataItem: T) => { + const [err] = await to(callback(dataItem)); + + if (err?.httpStatus === 429) { + if (retryCount === maxRetryCount) { + return; + } + + const waitMS = err.getRetryAfterMs() ?? 200; + await new Promise((resolve) => { + setTimeout(resolve, waitMS); + }); + retryCount += 1; + + await performAction(dataItem); + } + }; + + for (let i = 0; i < data.length; i += 1) { + const dataItem = data[i]; + retryCount = 0; + // eslint-disable-next-line no-await-in-loop + await performAction(dataItem); + } +}; -- 2.34.1