Update commands (#2325)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Tue, 13 May 2025 10:46:22 +0000 (16:16 +0530)
committerGitHub <noreply@github.com>
Tue, 13 May 2025 10:46:22 +0000 (16:16 +0530)
* 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
src/app/features/room/CommandAutocomplete.tsx
src/app/hooks/useCommands.ts
src/app/utils/common.ts
src/app/utils/matrix.ts

index f39ef9535db8a87fd47eb1795bbd978c857e476b..ec4ed0a55c4f331c2e8fabbf4beeb88192d6a5e8 100644 (file)
@@ -24,7 +24,7 @@ export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentPro
     >
       <ErrorBoundary fallback={<code>{text}</code>}>
         <Suspense fallback={<code>{text}</code>}>
-          <ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
+          <ReactPrism key={text}>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
         </Suspense>
       </ErrorBoundary>
     </Text>
index 31903ac6ec06a8181373448b0264625d93acd5b2..6b7ba56ecfd864cb7d8483f0cdf913e6e91ddf92 100644 (file)
@@ -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={
         <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
           <Text size="L400">Commands</Text>
-          <Text size="T200" priority="300" truncate>
-            Begin your message with command
-          </Text>
         </Box>
       }
       requestClose={requestClose}
@@ -87,17 +84,22 @@ export function CommandAutocomplete({
           key={commandName}
           as="button"
           radii="300"
+          style={{ height: 'unset' }}
           onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
             onTabPress(evt, () => handleAutocomplete(commandName))
           }
           onClick={() => handleAutocomplete(commandName)}
         >
-          <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
-            <Box shrink="No">
-              <Text style={{ flexGrow: 1 }} size="B400" truncate>
-                {`/${commandName}`}
-              </Text>
-            </Box>
+          <Box
+            style={{ padding: `${config.space.S300} 0` }}
+            grow="Yes"
+            direction="Column"
+            gap="100"
+            justifyContent="SpaceBetween"
+          >
+            <Text style={{ flexGrow: 1 }} size="B400" truncate>
+              {`/${commandName}`}
+            </Text>
             <Text truncate priority="300" size="T200">
               {commands[commandName].description}
             </Text>
index bc7d2892582464725bbee4079b5613c5818efa07..c7a53580b81977f45d5c5be57cf6aacd0ec2fb99 100644 (file)
-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>;
 
@@ -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<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]
   );
index d230c6bbf7674aafa0771b06fb9112ce7003ac1c..34e1ecbf78f6b2ee1fe7a66e6ec1ca0bba6cc398 100644 (file)
@@ -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(' ');
+};
index cd3c0862d3f4a29552743bd0ff587c69b5fe3a2d..75430c2051a3c99cea36a66da481624c65bc1764 100644 (file)
@@ -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 <T, R = void>(
+  data: T[],
+  callback: (item: T) => Promise<R>,
+  maxRetryCount?: number
+) => {
+  let retryCount = 0;
+  const performAction = async (dataItem: T) => {
+    const [err] = await to<R, MatrixError>(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);
+  }
+};