1
Fork 0
mirror of https://github.com/RGBCube/cinny synced 2025-07-30 16:37:46 +00:00

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
This commit is contained in:
Ajay Bura 2025-05-13 16:16:22 +05:30 committed by GitHub
parent 13f1d53191
commit 87e97eab88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 339 additions and 45 deletions

View file

@ -24,7 +24,7 @@ export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentPro
> >
<ErrorBoundary fallback={<code>{text}</code>}> <ErrorBoundary fallback={<code>{text}</code>}>
<Suspense 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> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</Text> </Text>

View file

@ -1,6 +1,6 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react'; import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Box, MenuItem, Text } from 'folds'; import { Box, config, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { Command, useCommands } from '../../hooks/useCommands'; import { Command, useCommands } from '../../hooks/useCommands';
import { import {
@ -75,9 +75,6 @@ export function CommandAutocomplete({
headerContent={ headerContent={
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween"> <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
<Text size="L400">Commands</Text> <Text size="L400">Commands</Text>
<Text size="T200" priority="300" truncate>
Begin your message with command
</Text>
</Box> </Box>
} }
requestClose={requestClose} requestClose={requestClose}
@ -87,17 +84,22 @@ export function CommandAutocomplete({
key={commandName} key={commandName}
as="button" as="button"
radii="300" radii="300"
style={{ height: 'unset' }}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName)) onTabPress(evt, () => handleAutocomplete(commandName))
} }
onClick={() => handleAutocomplete(commandName)} onClick={() => handleAutocomplete(commandName)}
> >
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween"> <Box
<Box shrink="No"> style={{ padding: `${config.space.S300} 0` }}
<Text style={{ flexGrow: 1 }} size="B400" truncate> grow="Yes"
{`/${commandName}`} direction="Column"
</Text> gap="100"
</Box> justifyContent="SpaceBetween"
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200"> <Text truncate priority="300" size="T200">
{commands[commandName].description} {commands[commandName].description}
</Text> </Text>

View file

@ -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 { 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 { hasDevices } from '../../util/matrixUtil';
import * as roomActions from '../../client/action/room'; import * as roomActions from '../../client/action/room';
import { useRoomNavigate } from './useRoomNavigate'; 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 SHRUG = '¯\\_(ツ)_/¯';
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
export const UNFLIP = '┬─┬ノ( º_º)'; export const UNFLIP = '┬─┬ノ( º_º)';
export function parseUsersAndReason(payload: string): { const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b';
users: string[]; const FLAG_REG = new RegExp(FLAG_PAT);
reason?: string; const FLAG_REG_G = new RegExp(FLAG_PAT, 'g');
} {
let reason: string | undefined;
let ids: string = payload;
const reasonMatch = payload.match(/\s-r\s/); export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => {
if (reasonMatch) { const flagMatch = payload.match(FLAG_REG);
ids = payload.slice(0, reasonMatch.index);
reason = payload.slice((reasonMatch.index ?? 0) + reasonMatch[0].length); if (!flagMatch) {
if (reason.trim() === '') reason = undefined; return [payload, undefined];
} }
const rawIds = ids.split(' '); const content = payload.slice(0, flagMatch.index);
const users = rawIds.filter((id) => isUserId(id)); const flags = payload.slice(flagMatch.index);
return {
users, return [content, flags];
reason, };
};
} 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>; export type CommandExe = (payload: string) => Promise<void>;
@ -52,6 +145,8 @@ export enum Command {
ConvertToRoom = 'converttoroom', ConvertToRoom = 'converttoroom',
TableFlip = 'tableflip', TableFlip = 'tableflip',
UnFlip = 'unflip', UnFlip = 'unflip',
Delete = 'delete',
Acl = 'acl',
} }
export type CommandContent = { export type CommandContent = {
@ -96,7 +191,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.StartDm, name: Command.StartDm,
description: 'Start direct message with user. Example: /startdm userId1', description: 'Start direct message with user. Example: /startdm userId1',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId()); const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
if (userIds.length === 0) return; if (userIds.length === 0) return;
if (userIds.length === 1) { if (userIds.length === 1) {
@ -106,7 +201,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
return; 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 isEncrypt = devices.every((hasDevice) => hasDevice);
const result = await roomActions.createDM(mx, userIds, isEncrypt); const result = await roomActions.createDM(mx, userIds, isEncrypt);
navigateRoom(result.room_id); navigateRoom(result.room_id);
@ -116,7 +211,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.Join, name: Command.Join,
description: 'Join room with address. Example: /join address1 address2', description: 'Join room with address. Example: /join address1 address2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const roomIds = rawIds.filter( const roomIds = rawIds.filter(
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
); );
@ -131,7 +226,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
mx.leave(room.roomId); mx.leave(room.roomId);
return; return;
} }
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const roomIds = rawIds.filter((id) => isRoomId(id)); const roomIds = rawIds.filter((id) => isRoomId(id));
roomIds.map((id) => mx.leave(id)); roomIds.map((id) => mx.leave(id));
}, },
@ -140,7 +235,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.Invite, name: Command.Invite,
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]', description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
exe: async (payload) => { 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)); users.map((id) => mx.invite(room.roomId, id, reason));
}, },
}, },
@ -148,7 +246,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.DisInvite, name: Command.DisInvite,
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]', description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
exe: async (payload) => { 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)); users.map((id) => mx.kick(room.roomId, id, reason));
}, },
}, },
@ -156,23 +257,53 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.Kick, name: Command.Kick,
description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]', description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
exe: async (payload) => { exe: async (payload) => {
const { users, reason } = parseUsersAndReason(payload); const [content, flags] = splitPayloadContentAndFlags(payload);
users.map((id) => mx.kick(room.roomId, id, reason)); 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]: { [Command.Ban]: {
name: 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) => { exe: async (payload) => {
const { users, reason } = parseUsersAndReason(payload); const [content, flags] = splitPayloadContentAndFlags(payload);
users.map((id) => mx.ban(room.roomId, id, reason)); 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]: { [Command.UnBan]: {
name: Command.UnBan, name: Command.UnBan,
description: 'Unban user from room. Example: /unban userId1 userId2', description: 'Unban user from room. Example: /unban userId1 userId2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const users = rawIds.filter((id) => isUserId(id)); const users = rawIds.filter((id) => isUserId(id));
users.map((id) => mx.unban(room.roomId, id)); users.map((id) => mx.unban(room.roomId, id));
}, },
@ -181,7 +312,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.Ignore, name: Command.Ignore,
description: 'Ignore user. Example: /ignore userId1 userId2', description: 'Ignore user. Example: /ignore userId1 userId2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id)); const userIds = rawIds.filter((id) => isUserId(id));
if (userIds.length > 0) roomActions.ignore(mx, userIds); if (userIds.length > 0) roomActions.ignore(mx, userIds);
}, },
@ -190,7 +321,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.UnIgnore, name: Command.UnIgnore,
description: 'Unignore user. Example: /unignore userId1 userId2', description: 'Unignore user. Example: /unignore userId1 userId2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id)); const userIds = rawIds.filter((id) => isUserId(id));
if (userIds.length > 0) roomActions.unignore(mx, userIds); 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); 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] [mx, room, navigateRoom]
); );

View 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 replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-');
export const splitWithSpace = (content: string): string[] => {
const trimmedContent = content.trim();
if (trimmedContent === '') return [];
return trimmedContent.split(' ');
};

View file

@ -13,11 +13,16 @@ import {
UploadProgress, UploadProgress,
UploadResponse, UploadResponse,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import to from 'await-to-js';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { getStateEvent } from './room'; import { getStateEvent } from './room';
import { StateEvent } from '../../types/matrix/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 matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/);
export const validMxId = (id: string): boolean => !!matchMxId(id); export const validMxId = (id: string): boolean => !!matchMxId(id);
@ -292,3 +297,35 @@ export const downloadEncryptedMedia = async (
return decryptedContent; 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);
}
};