diff --git a/.gitattributes b/.gitattributes
index 94f480de9..f45f82ce1 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,3 @@
-* text=auto eol=lf
\ No newline at end of file
+* text=auto eol=lf
+src/types/schemas/command.d.ts linguist-generated
+src/types/schemas/commandList.d.ts linguist-generated
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 60018f71d..5385b4870 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,6 +13,12 @@
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
+ "json.schemas": [
+ {
+ "fileMatch": ["*.commands.json"],
+ "url": "./src/types/schemas/commandList.schema.json"
+ }
+ ],
"nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix",
"nixEnvSelector.useFlakes": true
}
diff --git a/package.json b/package.json
index 2438fcddf..bc34b8551 100644
--- a/package.json
+++ b/package.json
@@ -118,6 +118,7 @@
"buffer": "^6.0.3",
"cloudflared": "^0.7.1",
"jsdom": "^29.0.0",
+ "json-schema-to-typescript": "^15.0.4",
"knip": "5.85.0",
"oxfmt": "^0.45.0",
"oxlint": "^1.60.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ee7836185..a7c531863 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -274,6 +274,9 @@ importers:
jsdom:
specifier: ^29.0.0
version: 29.0.0
+ json-schema-to-typescript:
+ specifier: ^15.0.4
+ version: 15.0.4
knip:
specifier: 5.85.0
version: 5.85.0(@types/node@24.10.13)(typescript@5.9.3)
@@ -325,6 +328,10 @@ packages:
peerDependencies:
ajv: '>=8'
+ '@apidevtools/json-schema-ref-parser@11.9.3':
+ resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
+ engines: {node: '>= 16'}
+
'@arborium/arborium@2.16.0':
resolution: {integrity: sha512-9rz2J9Hx+nMq1qon65SbmE+XZvwr/oDqYPinj+BnYOzef7lGwzn1GtYhuB1Cz8jTZ84wIaBt+B8nTQEGYniqNg==}
@@ -1366,6 +1373,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@jsdevtools/ono@7.1.3':
+ resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
+
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
@@ -2987,6 +2997,15 @@ packages:
'@types/is-hotkey@0.1.10':
resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==}
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/json5@0.0.29':
+ resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+
+ '@types/lodash@4.17.24':
+ resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
+
'@types/node@24.10.13':
resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==}
@@ -4057,6 +4076,14 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+ json-schema-to-typescript@15.0.4:
+ resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==}
+ engines: {node: '>=16.0.0'}
+ hasBin: true
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -5270,6 +5297,12 @@ snapshots:
jsonpointer: 5.0.1
leven: 3.1.0
+ '@apidevtools/json-schema-ref-parser@11.9.3':
+ dependencies:
+ '@jsdevtools/ono': 7.1.3
+ '@types/json-schema': 7.0.15
+ js-yaml: 4.1.1
+
'@arborium/arborium@2.16.0': {}
'@asamuzakjp/css-color@5.0.1':
@@ -6334,6 +6367,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@jsdevtools/ono@7.1.3': {}
+
'@juggle/resize-observer@3.4.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@15.3.0': {}
@@ -8085,6 +8120,12 @@ snapshots:
'@types/is-hotkey@0.1.10': {}
+ '@types/json-schema@7.0.15': {}
+
+ '@types/json5@0.0.29': {}
+
+ '@types/lodash@4.17.24': {}
+
'@types/node@24.10.13':
dependencies:
undici-types: 7.16.0
@@ -9295,6 +9336,20 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
+ json-schema-to-typescript@15.0.4:
+ dependencies:
+ '@apidevtools/json-schema-ref-parser': 11.9.3
+ '@types/json-schema': 7.0.15
+ '@types/lodash': 4.17.24
+ is-glob: 4.0.3
+ js-yaml: 4.1.1
+ lodash: 4.17.23
+ minimist: 1.2.8
+ prettier: 3.8.1
+ tinyglobby: 0.2.15
+
+ json-schema-traverse@0.4.1: {}
+
json-schema-traverse@1.0.0: {}
json5@2.2.3: {}
diff --git a/scripts/generateDTSFromJSONSchema.js b/scripts/generateDTSFromJSONSchema.js
new file mode 100755
index 000000000..170917e3e
--- /dev/null
+++ b/scripts/generateDTSFromJSONSchema.js
@@ -0,0 +1,25 @@
+import { compileFromFile } from 'json-schema-to-typescript';
+import * as fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const projectRoot = dirname(__dirname);
+const schemasDir = join(projectRoot, 'src/types/schemas');
+
+const commandSchemaPath = join(schemasDir, 'command.schema.json');
+const commandDtsPath = join(schemasDir, 'command.d.ts');
+const commandListSchemaPath = join(schemasDir, 'commandList.schema.json');
+const commandListDtsPath = join(schemasDir, 'commandList.d.ts');
+
+const compileOptions = {
+ cwd: schemasDir,
+};
+
+compileFromFile(commandSchemaPath, compileOptions).then((ts) =>
+ fs.writeFileSync(commandDtsPath, ts)
+);
+
+compileFromFile(commandListSchemaPath, compileOptions).then((ts) =>
+ fs.writeFileSync(commandListDtsPath, ts)
+);
diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx
index 410d9aa98..76e3d89de 100644
--- a/src/app/components/editor/Elements.tsx
+++ b/src/app/components/editor/Elements.tsx
@@ -10,6 +10,8 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { nicknamesAtom } from '$state/nicknames';
import { BlockType } from './types';
import { getBeginCommand } from './utils';
+import { SlateInputForCommand } from '$plugins/commandHandling/slateInput';
+import { getFromCommandRegistry } from '$plugins/commandHandling/commandRegistry';
import type { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
// Put this at the start and end of an inline component to work around this Chromium bug:
@@ -56,19 +58,15 @@ function RenderCommandElement({
const selected = useSelected();
const focused = useFocused();
const editor = useSlate();
-
+
return (
-
- {`/${element.command}`}
- {children}
-
+ command={getFromCommandRegistry(element.command)}
+ />
);
}
diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts
index c4987f6ba..a111682b0 100644
--- a/src/app/hooks/useCommands.ts
+++ b/src/app/hooks/useCommands.ts
@@ -16,7 +16,6 @@ import {
Preset,
Visibility,
MsgType,
- KnownMembership,
} from '$types/matrix-sdk';
import { useMemo } from 'react';
@@ -35,12 +34,11 @@ import { getStateEvent } from '$utils/room';
import { splitWithSpace } from '$utils/common';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
-import { useOpenBugReportModal } from '$state/hooks/bugReportModal';
import { createRoomEncryptionState } from '$components/create-room';
-import { parsePronounsInput } from '$utils/pronouns';
import { sendFeedback } from '$utils/sendFeedbackToUser';
import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler';
-import { ErrorCode } from '../cs-errorcode';
+import { getFromCommandRegistry } from '$plugins/commandHandling/commandRegistry';
+import { loadBuildInCommands } from '$plugins/commandHandling/builtin/builtInCommands';
import { useRoomNavigate } from './useRoomNavigate';
import { enrichWidgetUrl } from './useRoomWidgets';
import { useUserProfile } from './useUserProfile';
@@ -302,7 +300,8 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
// helper for pkit commands
const pkitcmdHandler = useMemo(() => new PKitCommandMessageHandler(mx, room), [mx, room]);
const profile = useUserProfile(mx.getSafeUserId());
- const openBugReport = useOpenBugReportModal();
+
+ loadBuildInCommands();
const commands: CommandRecord = useMemo(
() => ({
@@ -359,15 +358,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
},
[Command.Join]: {
name: Command.Join,
- description: 'Join room with address. Example: /join address1 address2',
- exe: async (payload) => {
- const rawIds = splitWithSpace(payload);
- const roomIdOrAliases = rawIds.filter(
- (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
- );
- roomIdOrAliases.forEach(async (idOrAlias) => {
- await mx.joinRoom(idOrAlias);
- });
+ description: getFromCommandRegistry('join').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('join');
+ await cmd.execute({ mx, room });
},
},
[Command.Leave]: {
@@ -396,68 +390,34 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
},
[Command.DisInvite]: {
name: Command.DisInvite,
- description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
- exe: async (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));
+ description: getFromCommandRegistry('disinvite').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('disinvite');
+ await cmd.execute({ mx, room });
},
},
[Command.Kick]: {
name: Command.Kick,
- description: 'Kick user from room. Example: /kick userId1 userId2 servername [-r reason]',
- 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 serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
- const serverUsers = serverMembers
- ?.filter((m) => m.membership !== KnownMembership.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));
+ description: getFromCommandRegistry('kick').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('kick');
+ await cmd.execute({ mx, room });
},
},
[Command.Ban]: {
name: Command.Ban,
- description: 'Ban user from room. Example: /ban userId1 userId2 servername [-r reason]',
- 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 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));
+ description: getFromCommandRegistry('ban').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('ban');
+ await cmd.execute({ mx, room });
},
},
[Command.UnBan]: {
name: Command.UnBan,
- description: 'Unban user from room. Example: /unban userId1 userId2',
- exe: async (payload) => {
- const rawIds = splitWithSpace(payload);
- const users = rawIds.filter((id) => isUserId(id));
- users.map((id) => mx.unban(room.roomId, id));
+ description: getFromCommandRegistry('unban').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('unban');
+ await cmd.execute({ mx, room });
},
},
[Command.Ignore]: {
@@ -818,171 +778,35 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
},
[Command.Color]: {
name: Command.Color,
- description: 'Set a room-specific color. Example: /color #ff00ff | /color reset',
- exe: async (payload) => {
- const input = payload.trim().toLowerCase();
- const userId = mx.getSafeUserId();
-
- try {
- if (input === 'reset' || input === 'clear') {
- await mx.sendStateEvent(room.roomId, CustomStateEvent.RoomCosmeticsColor, {}, userId);
- sendFeedback('Room color has been reset.', room, userId);
- return;
- }
-
- if (/^#[0-9A-F]{6}$/i.test(input)) {
- await mx.sendStateEvent(
- room.roomId,
- CustomStateEvent.RoomCosmeticsColor,
- { color: input },
- userId
- );
- sendFeedback(`Room color set to ${input}.`, room, userId);
- } else {
- sendFeedback('Invalid format. Use #RRGGBB.', room, userId);
- }
- } catch (e: unknown) {
- if (e instanceof MatrixError && e.errcode === ErrorCode.M_FORBIDDEN) {
- sendFeedback(
- 'Permission Denied. An admin must enable "Room Colors" in Settings > Cosmetics in app.sable.moe or another supported client.',
- room,
- userId
- );
- }
- }
+ description: getFromCommandRegistry('color').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('color');
+ await cmd.execute({ mx, room });
},
},
[Command.SColor]: {
name: Command.SColor,
description:
'Set your color for the current Space. Example: /scolor #ff00ff | /scolor reset',
- exe: async (payload) => {
- const input = payload.trim().toLowerCase();
- const userId = mx.getSafeUserId();
-
- const parents = room
- .getLiveTimeline()
- .getState(EventTimeline.FORWARDS)
- ?.getStateEvents(EventType.SpaceParent);
-
- const targetSpaceId =
- parents && parents.length > 0 ? parents[0]!.getStateKey() : room.roomId;
-
- try {
- if (input === 'reset' || input === 'clear') {
- await mx.sendStateEvent(
- targetSpaceId as string,
- CustomStateEvent.RoomCosmeticsColor,
- {},
- userId
- );
- sendFeedback('Global space color reset.', room, userId);
- return;
- }
-
- if (/^#[0-9A-F]{6}$/i.test(input)) {
- await mx.sendStateEvent(
- targetSpaceId as string,
- CustomStateEvent.RoomCosmeticsColor,
- { color: input },
- userId
- );
- sendFeedback(`Global space color set to ${input}.`, room, userId);
- } else {
- sendFeedback('Invalid format. Use #RRGGBB.', room, userId);
- }
- } catch (e: unknown) {
- if (e instanceof MatrixError && e.errcode === ErrorCode.M_FORBIDDEN) {
- sendFeedback(
- 'Permission Denied. An admin must enable "Space-Wide Colors" in Settings > Cosmetics in app.sable.moe or another supported client.',
- room,
- userId
- );
- }
- }
+ exe: async () => {
+ const cmd = getFromCommandRegistry('scolor');
+ await cmd.execute({ mx, room });
},
},
[Command.Font]: {
name: Command.Font,
- description: 'Set a room-specific font. Example: /font Courier New | /font reset',
- exe: async (payload) => {
- const input = payload
- .trim()
- .replaceAll(/[;{}<>]/g, '')
- .slice(0, 32);
- const userId = mx.getSafeUserId();
-
- try {
- if (input.toLowerCase() === 'reset' || input === '') {
- await mx.sendStateEvent(room.roomId, CustomStateEvent.RoomCosmeticsFont, {}, userId);
- sendFeedback('Room font reset.', room, userId);
- return;
- }
-
- await mx.sendStateEvent(
- room.roomId,
- CustomStateEvent.RoomCosmeticsFont,
- { font: input },
- userId
- );
- sendFeedback(`Room font set to "${input}".`, room, userId);
- } catch (e: unknown) {
- if (e instanceof MatrixError && e.errcode === ErrorCode.M_FORBIDDEN) {
- sendFeedback(
- 'Permission Denied. An admin must enable "Room Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.',
- room,
- userId
- );
- }
- }
+ description: getFromCommandRegistry('font').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('font');
+ await cmd.execute({ mx, room });
},
},
[Command.SFont]: {
name: Command.SFont,
description: 'Set a font for the current Space. Example: /sfont Courier New | /sfont reset',
- exe: async (payload) => {
- const input = payload
- .trim()
- .replaceAll(/[;{}<>]/g, '')
- .slice(0, 32);
- const userId = mx.getSafeUserId();
-
- const parents = room
- .getLiveTimeline()
- .getState(EventTimeline.FORWARDS)
- ?.getStateEvents(EventType.SpaceParent);
-
- const targetSpaceId =
- parents && parents.length > 0 ? parents[0]!.getStateKey() : room.roomId;
-
- try {
- if (input.toLowerCase() === 'reset' || input === '') {
- await mx.sendStateEvent(
- targetSpaceId as string,
- CustomStateEvent.RoomCosmeticsFont,
- {},
- userId
- );
- sendFeedback('Space font reset.', room, userId);
- return;
- }
-
- await mx.sendStateEvent(
- targetSpaceId as string,
- CustomStateEvent.RoomCosmeticsFont,
- { font: input },
- userId
- );
- sendFeedback(`Space font set to "${input}".`, room, userId);
- } catch (e: unknown) {
- if (e instanceof MatrixError && e.errcode === ErrorCode.M_FORBIDDEN) {
- sendFeedback(
- 'Permission Denied. An admin must enable "Space-Wide Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.',
- room,
- userId
- );
- }
- }
+ exe: async () => {
+ const cmd = getFromCommandRegistry('sfont');
+ await cmd.execute({ mx, room });
},
},
[Command.AddWidget]: {
@@ -1042,94 +866,18 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
},
[Command.Pronoun]: {
name: Command.Pronoun,
- description:
- 'Set your pronouns for this room. Example: /pronoun "en:they/them, de:sie/ihr" | /pronoun reset',
- exe: async (payload) => {
- const match = payload.trim().match(/^"(.*)"$/);
- const rawInput = match ? (match[1] ?? '').trim() : payload.trim();
- const userId = mx.getSafeUserId();
-
- try {
- if (['reset', 'clear', ''].includes(rawInput.toLowerCase())) {
- await mx.sendStateEvent(
- room.roomId,
- CustomStateEvent.RoomCosmeticsPronouns,
- {},
- userId
- );
- sendFeedback('Room pronouns have been reset.', room, userId);
- return;
- }
-
- const pronounsArray = parsePronounsInput(rawInput);
-
- await mx.sendStateEvent(
- room.roomId,
- CustomStateEvent.RoomCosmeticsPronouns,
- { pronouns: pronounsArray },
- userId
- );
-
- const feedbackString = pronounsArray
- .map((p) => (p.language ? `for ${p.language} "${p.summary}" was set` : p.summary))
- .join(', ');
-
- sendFeedback(`Room pronouns set: ${feedbackString}`, room, userId);
- } catch (e: unknown) {
- if (e instanceof MatrixError && e.errcode === ErrorCode.M_FORBIDDEN) {
- sendFeedback('Permission Denied. Could not update room pronouns.', room, userId);
- }
- }
+ description: getFromCommandRegistry('pronoun').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('pronoun');
+ await cmd.execute({ mx, room });
},
},
[Command.SPronoun]: {
name: Command.SPronoun,
- description:
- 'Set your pronouns for this space. Example: /spronoun "en:they/them, de:sie/ihr" | /spronoun reset',
- exe: async (payload) => {
- const match = payload.trim().match(/^"(.*)"$/);
- const rawInput = match ? (match[1] ?? '').trim() : payload.trim();
- const userId = mx.getSafeUserId();
-
- const parents = room
- .getLiveTimeline()
- .getState(EventTimeline.FORWARDS)
- ?.getStateEvents(EventType.SpaceParent);
-
- const targetSpaceId =
- parents && parents.length > 0 ? parents[0]!.getStateKey() : room.roomId;
-
- try {
- if (['reset', 'clear', ''].includes(rawInput.toLowerCase())) {
- await mx.sendStateEvent(
- targetSpaceId as string,
- CustomStateEvent.RoomCosmeticsPronouns,
- {},
- userId
- );
- sendFeedback('Global space pronouns reset.', room, userId);
- return;
- }
-
- const pronounsArray = parsePronounsInput(rawInput);
-
- await mx.sendStateEvent(
- targetSpaceId as string,
- CustomStateEvent.RoomCosmeticsPronouns,
- { pronouns: pronounsArray },
- userId
- );
-
- const feedbackString = pronounsArray
- .map((p) => (p.language ? `for ${p.language} "${p.summary}" was set` : p.summary))
- .join(', ');
-
- sendFeedback(`Global space pronouns set: ${feedbackString}`, room, userId);
- } catch (e: unknown) {
- if (e instanceof MatrixError && e.errcode === ErrorCode.M_FORBIDDEN) {
- sendFeedback('Permission Denied. Could not update space pronouns.', room, userId);
- }
- }
+ description: getFromCommandRegistry('spronoun').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('pronoun');
+ await cmd.execute({ mx, room });
},
},
[Command.Rainbow]: {
@@ -1360,41 +1108,18 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
},
[Command.DiscardSession]: {
name: Command.DiscardSession,
- description: 'Force discard the current outbound E2EE session in this room.',
+ description: getFromCommandRegistry('discardSession').getCommandDefinition().description,
exe: async () => {
- const userId = mx.getSafeUserId();
-
- try {
- const crypto = mx.getCrypto();
- if (!crypto) {
- sendFeedback('Encryption is not enabled on this client.', room, userId);
- return;
- }
- await crypto.forceDiscardSession(room.roomId);
- sendFeedback('Outbound encryption session discarded.', room, userId);
- } catch (e: unknown) {
- sendFeedback(`Failed to discard session: ${(e as Error).message}`, room, userId);
- }
+ const cmd = getFromCommandRegistry('discardSession');
+ await cmd.execute({ mx, room });
},
},
[Command.Html]: {
name: Command.Html,
- description:
- 'Send a message with HTML content. Example: /html Red',
- exe: async (payload) => {
- await mx.sendMessage(room.roomId, {
- msgtype: MsgType.Text,
- body: payload
- .replaceAll('
', '\n')
- .replaceAll('
', '\n- ')
- .replaceAll(
- /(.*?))"(.*?)>(?(.*?))<\/a>/g,
- '[$]($)'
- )
- .replaceAll(/<[^>]*>/g, ''),
- format: 'org.matrix.custom.html',
- formatted_body: payload,
- });
+ description: getFromCommandRegistry('html').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('html');
+ await cmd.execute({ mx, room });
},
},
// Sharing E2EE History of a room with a user
@@ -1503,103 +1228,36 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
},
[Command.Headpat]: {
name: Command.Headpat,
- description: 'Send a headpat to someone. Example: /headpat [@user:example.org]',
+ description: getFromCommandRegistry('headpat').getCommandDefinition().description,
// not really like any of the other cute events, but it was too good not to include
- // using a custom msgtype to avoid confusion with the other existing cute events
- exe: async (payload) => {
- const target = payload.trim();
- await mx.sendMessage(room.roomId, {
- msgtype: 'm.emote',
- 'm.mentions': {
- user_ids: target ? [target] : [],
- },
- body: `pats ${target || 'you'}`,
- 'fyi.cisnt.headpat': true,
- } as unknown as RoomMessageEventContent);
+ exe: async () => {
+ const cmd = getFromCommandRegistry('headpat');
+ await cmd.execute({ mx, room });
},
},
// Meta commands
[Command.Report]: {
name: Command.Report,
- description: 'Report a bug or request a feature',
+ description: getFromCommandRegistry('bugreport').getCommandDefinition().description,
exe: async () => {
- openBugReport();
+ const cmd = getFromCommandRegistry('bugreport');
+ await cmd.execute({ mx, room });
},
},
[Command.Location]: {
name: Command.Location,
- description: 'Share a location as /location ',
- exe: async (payload) => {
- const target = payload
- .replace(',', ' ')
- .replace('/', ' ')
- .replace(' ', ' ')
- .trim()
- .split(' ');
-
- const mlat = target[0];
- const mlon = target[1];
- const malt = target[2];
- if (!mlat || !mlon) {
- sendFeedback(
- 'You need to specify a latitude, a longitude parameter, and optionally an altitude, as for example: /location 43.959971 -59.790623 or use the /sharemylocation to share the current location',
- room,
- mx.getSafeUserId()
- );
- return;
- }
- await mx.sendMessage(room.roomId, {
- msgtype: 'm.location',
- geo_uri: `geo:${mlat},${mlon}${malt ? `,${malt}` : ''};u=0`,
- body: `https://www.openstreetmap.org/?mlat=${mlat}&mlon=${mlon}#map=16/${mlat}/${mlon}"`,
- } as unknown as RoomMessageEventContent);
+ description: getFromCommandRegistry('location').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('location');
+ await cmd.execute({ mx, room });
},
},
[Command.ShareMyLocation]: {
name: Command.ShareMyLocation,
- description:
- 'Share current location. Requires your browser to have location permissions. Add the flag --accurate or -a for enabling the high accuracy option',
- exe: async (payload) => {
- const target = payload.trim();
- const options = {
- enableHighAccuracy:
- target === '--accurate' ||
- target === '-a' ||
- target === '--high-accuracy' ||
- target === '-h',
- timeout: 5000,
- maximumAge: 0,
- };
- function success(pos: GeolocationPosition) {
- const crd = pos.coords;
-
- const mlat = crd.latitude;
- const mlon = crd.longitude;
- const malt = crd.altitude;
- const macc = crd.accuracy;
- if (!mlat || !mlon) {
- sendFeedback(
- 'Unable to retrieve the location data for an unknown reason',
- room,
- mx.getSafeUserId()
- );
- return;
- }
- mx.sendMessage(room.roomId, {
- msgtype: 'm.location',
- geo_uri: `geo:${mlat},${mlon}${malt ? `,${malt}` : ''};u=${macc}`,
- body: `https://www.openstreetmap.org/?mlat=${mlat}&mlon=${mlon}#map=16/${mlat}/${mlon}"`,
- } as unknown as RoomMessageEventContent);
- }
-
- function error(err: GeolocationPositionError) {
- let response = `Unable to retrieve the location data, Error no. ${err.code}: ${err.message}`;
- if (err.code === 1) response = 'You have denied Sable access to you location services.';
- if (err.code === 2)
- response = 'Your device does not have a gps module, or it may not be turned on.';
- sendFeedback(response, room, mx.getSafeUserId());
- }
- navigator.geolocation.getCurrentPosition(success, error, options);
+ description: getFromCommandRegistry('sharemylocation').getCommandDefinition().description,
+ exe: async () => {
+ const cmd = getFromCommandRegistry('sharemylocation');
+ await cmd.execute({ mx, room });
},
},
}),
@@ -1611,7 +1269,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
pkitcmdHandler,
developerTools,
enableMSC4268CMD,
- openBugReport,
]
);
diff --git a/src/app/plugins/commandHandling/AbstractCommand.ts b/src/app/plugins/commandHandling/AbstractCommand.ts
new file mode 100644
index 000000000..75e7d35c1
--- /dev/null
+++ b/src/app/plugins/commandHandling/AbstractCommand.ts
@@ -0,0 +1,64 @@
+import { CommandDefinition } from '$types/schemas/command';
+import {
+ CommandExecutionContext,
+ GenericCommandExecutionArgContainer,
+ GenericCommandExecutionArgContainerConstructorParams,
+} from './CommandExecutionContext';
+
+export abstract class AbstractCommand {
+ protected commandDefinition: CommandDefinition;
+
+ protected behavior: (
+ context: CommandExecutionContext,
+ args: Map
+ ) => Promise;
+
+ protected args: Map;
+
+ constructor(
+ commandDefinition: CommandDefinition,
+ fn: (
+ context: CommandExecutionContext,
+ args: Map
+ ) => Promise
+ ) {
+ this.commandDefinition = commandDefinition;
+ this.behavior = fn;
+ this.args = new Map();
+ this.commandDefinition.attributes?.forEach((attr) => {
+ const cont: GenericCommandExecutionArgContainer = new GenericCommandExecutionArgContainer({
+ desc: attr.description,
+ required: attr.required,
+ val: '',
+ type: attr.type,
+ format: attr.format,
+ } satisfies GenericCommandExecutionArgContainerConstructorParams);
+ this.args.set(attr.id, cont);
+ });
+ }
+
+ public getCommandArgsList(): Map {
+ return this.args;
+ }
+
+ public getCommandDefinition(): CommandDefinition {
+ return this.commandDefinition;
+ }
+
+ public async execute(context: CommandExecutionContext): Promise {
+ return this.behavior(context, this.args);
+ }
+
+ public updateArgValue(argId: string, argValue: any): void {
+ const arg = this.args.get(argId);
+ if (!arg) {
+ throw new Error(`argument named ${argId} not found`);
+ }
+ if (typeof argValue === 'string' && argValue.trim() === '') {
+ return;
+ }
+ arg.val = argValue;
+ this.args.set(argId, arg);
+ console.log(`updated value of ${argId} to ${argValue}`);
+ }
+}
diff --git a/src/app/plugins/commandHandling/BuiltInCommand.ts b/src/app/plugins/commandHandling/BuiltInCommand.ts
new file mode 100644
index 000000000..7361bb326
--- /dev/null
+++ b/src/app/plugins/commandHandling/BuiltInCommand.ts
@@ -0,0 +1,7 @@
+import { AbstractCommand } from './AbstractCommand';
+
+export class BuiltInCommand extends AbstractCommand {
+ static meow() {
+ return 'meow';
+ }
+}
diff --git a/src/app/plugins/commandHandling/BuiltInCommandsUtil.ts b/src/app/plugins/commandHandling/BuiltInCommandsUtil.ts
new file mode 100644
index 000000000..0c7a054e4
--- /dev/null
+++ b/src/app/plugins/commandHandling/BuiltInCommandsUtil.ts
@@ -0,0 +1,52 @@
+import { CommandDefinition } from '$types/schemas/command';
+import { splitWithSpace } from '$utils/common';
+import { isServerName, isUserId } from '$utils/matrix';
+import * as cmdListDef from './builtIn.commands.json';
+
+export function getCmdDescription(cmdId: string): CommandDefinition {
+ return cmdListDef.commands.find((cmd) => cmd.id === cmdId) as CommandDefinition;
+}
+
+const FLAG_PAT = String.raw`(?:^|\s)-(\w+)\b`;
+const FLAG_REG = new RegExp(FLAG_PAT);
+
+export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => {
+ const flagMatch = new RegExp(FLAG_REG).exec(payload);
+
+ if (!flagMatch) {
+ return [payload, undefined];
+ }
+ const content = payload.slice(0, flagMatch.index);
+ const flags = payload.slice(flagMatch.index);
+
+ return [content, flags];
+};
+
+/**
+ * parse a list of user ids in form of a string
+ * @param payload the list of users
+ * @returns a parsed list of user ids
+ */
+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;
+};
diff --git a/src/app/plugins/commandHandling/CommandExecutionContext.ts b/src/app/plugins/commandHandling/CommandExecutionContext.ts
new file mode 100644
index 000000000..800c559f6
--- /dev/null
+++ b/src/app/plugins/commandHandling/CommandExecutionContext.ts
@@ -0,0 +1,37 @@
+import { MatrixClient, Room } from 'matrix-js-sdk';
+
+export type CommandExecutionContext = {
+ mx: MatrixClient;
+ room: Room;
+};
+
+export type GenericCommandExecutionArgContainerConstructorParams = {
+ desc?: string;
+
+ val: any;
+
+ type?: string;
+
+ format?: string;
+
+ required: boolean;
+};
+
+export class GenericCommandExecutionArgContainer {
+ desc?: string;
+
+ val: any;
+
+ type?: string;
+
+ format?: string;
+
+ required: boolean;
+
+ constructor(params: GenericCommandExecutionArgContainerConstructorParams) {
+ this.desc = params.desc;
+ this.val = params.val;
+ this.format = params.format;
+ this.required = params.required;
+ }
+}
diff --git a/src/app/plugins/commandHandling/builtIn.commands.json b/src/app/plugins/commandHandling/builtIn.commands.json
new file mode 100644
index 000000000..3a5a456fd
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtIn.commands.json
@@ -0,0 +1,810 @@
+{
+ "commands": [
+ {
+ "id": "kick",
+ "requiredPermission": "kick",
+ "builtIn": true,
+ "description": "kick a user from the room",
+ "attributes": [
+ {
+ "id": "user",
+ "type": "userMention",
+ "description": "which user to kick",
+ "exampleValue": "@alice:example.org",
+ "required": true
+ },
+ {
+ "id": "reason",
+ "type": "string",
+ "description": "public reason for the kick",
+ "exampleValue": "they make me sad",
+ "required": false
+ }
+ ],
+ "category": "moderation"
+ },
+ {
+ "id": "ban",
+ "requiredPermission": "ban",
+ "description": "ban a user",
+ "attributes": [
+ {
+ "id": "user",
+ "type": "userMention",
+ "description": "which user to ban",
+ "exampleValue": "@alice:example.org",
+ "required": true
+ },
+ {
+ "id": "reason",
+ "type": "string",
+ "description": "public reason for the ban",
+ "exampleValue": "they make me sad",
+ "required": false
+ }
+ ],
+ "builtIn": true,
+ "category": "moderation"
+ },
+ {
+ "id": "ban-list",
+ "requiredPermission": "ban",
+ "description": "ban a list of users",
+ "attributes": [
+ {
+ "id": "userList",
+ "type": "userMention",
+ "description": "which user to ban",
+ "exampleValue": "@alice:example.org",
+ "required": true
+ },
+ {
+ "id": "reason",
+ "type": "string",
+ "description": "public reason for the ban",
+ "exampleValue": "they make me sad",
+ "required": false
+ }
+ ],
+ "builtIn": true,
+ "category": "moderation"
+ },
+ {
+ "id": "unban",
+ "requiredPermission": "ban",
+ "description": "unban a user",
+ "attributes": [
+ {
+ "id": "user",
+ "type": "userMention",
+ "description": "which user to unban",
+ "exampleValue": "@alice:example.org",
+ "required": true
+ }
+ ],
+ "builtIn": true,
+ "category": "moderation"
+ },
+ {
+ "id": "unban-list",
+ "requiredPermission": "ban",
+ "description": "unban a list of user",
+ "attributes": [
+ {
+ "id": "userList",
+ "type": "userMention",
+ "description": "which users to unban",
+ "exampleValue": "@alice:example.org",
+ "required": true
+ }
+ ],
+ "builtIn": true,
+ "category": "moderation"
+ },
+ {
+ "id": "invite",
+ "description": "invite a user",
+ "requiredPermission": "invite",
+ "attributes": [
+ {
+ "id": "user",
+ "type": "userMention",
+ "description": "which user to invite",
+ "exampleValue": "@alice:example.org",
+ "required": true
+ },
+ {
+ "id": "reason",
+ "type": "string",
+ "description": "public reason for the invite",
+ "required": false
+ }
+ ],
+ "builtIn": true,
+ "category": "core"
+ },
+ {
+ "id": "disinvite",
+ "requiredPermission": "kick",
+ "description": "revoke a invite",
+ "builtIn": true,
+ "attributes": [
+ {
+ "id": "user",
+ "required": true,
+ "type": "userMention",
+ "description": "the user you want to revoke the invite for"
+ },
+ {
+ "id": "reason",
+ "description": "the public reason for revoking the invite",
+ "type": "string",
+ "required": false
+ }
+ ],
+ "category": "moderation"
+ },
+ {
+ "id": "disinvite-list",
+ "requiredPermission": "kick",
+ "description": "revoke a list of invite",
+ "builtIn": true,
+ "attributes": [
+ {
+ "id": "userList",
+ "required": true,
+ "type": "userMention",
+ "description": "list of users you want to revoke the invite for"
+ },
+ {
+ "id": "reason",
+ "description": "the public reason for revoking the invite",
+ "type": "string",
+ "required": false
+ }
+ ],
+ "category": "moderation"
+ },
+ {
+ "id": "me",
+ "builtIn": true,
+ "description": "describe yourself",
+ "attributes": [
+ {
+ "id": "msg",
+ "type": "string",
+ "description": "the message you want to send",
+ "required": true
+ }
+ ],
+ "category": "misc"
+ },
+ {
+ "id": "notice",
+ "builtIn": true,
+ "description": "send a message as notice",
+ "attributes": [
+ {
+ "id": "msg",
+ "description": "the message you want to send",
+ "type": "string",
+ "required": true
+ }
+ ],
+ "category": "misc"
+ },
+ {
+ "id": "shrug",
+ "builtIn": true,
+ "description": "shrug",
+ "category": "fun"
+ },
+ {
+ "id": "startdm",
+ "builtIn": true,
+ "description": "start a dm with someone",
+ "attributes": [
+ {
+ "id": "user",
+ "description": "who do you want to chat with",
+ "type": "userMention",
+ "required": true
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "join",
+ "description": "join a room",
+ "builtIn": true,
+ "attributes": [
+ {
+ "id": "room",
+ "description": "the id of the room you want to join",
+ "required": true,
+ "type": "roomMention"
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "join-list",
+ "description": "join a list of rooms",
+ "builtIn": true,
+ "attributes": [
+ {
+ "id": "roomList",
+ "description": "the id of the room you want to join",
+ "required": true,
+ "type": "roomMention"
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "leave",
+ "builtIn": true,
+ "description": "leave a room",
+ "category": "core"
+ },
+ {
+ "id": "ignore",
+ "builtIn": true,
+ "description": "add a user to your ignore list",
+ "attributes": [
+ {
+ "id": "user",
+ "required": true,
+ "type": "userMention"
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "unignore",
+ "builtIn": true,
+ "description": "remove a user to your ignore list",
+ "attributes": [
+ {
+ "id": "user",
+ "required": true,
+ "type": "userMention"
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "myroomnick",
+ "builtIn": true,
+ "description": "set your room local nickname",
+ "attributes": [
+ {
+ "id": "nick",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "myroomavatar",
+ "builtIn": true,
+ "description": "set your room local avatar",
+ "attributes": [
+ {
+ "id": "url",
+ "type": "matrixMediaUrl",
+ "required": true
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "convertToRoom",
+ "builtIn": true,
+ "description": "convert this room to a room (only for you)",
+ "category": "core"
+ },
+ {
+ "id": "convertToDm",
+ "builtIn": true,
+ "description": "convert this room to a dm (only for you)",
+ "attributes": [
+ {
+ "id": "user",
+ "description": "a dm with whom?",
+ "required": true,
+ "type": "userMention"
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "tableflip",
+ "description": "flip a table",
+ "builtIn": true,
+ "category": "fun"
+ },
+ {
+ "id": "unflip",
+ "description": "unflip a table",
+ "builtIn": true,
+ "category": "fun"
+ },
+ {
+ "id": "delete",
+ "description": "delete a event",
+ "builtIn": true,
+ "executableInReply": true,
+ "requiredPermission": "redactSelf",
+ "attributes": [
+ {
+ "id": "event",
+ "type": "eventMention",
+ "required": false
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "acl",
+ "description": "modify the room's server acl",
+ "requiredPermission": "acl",
+ "builtIn": true,
+ "category": "moderation"
+ },
+ {
+ "id": "knock",
+ "description": "knock at a room",
+ "builtIn": true,
+ "attributes": [
+ {
+ "id": "room",
+ "required": true,
+ "type": "roomMention",
+ "description": "the room you want to knock at"
+ },
+ {
+ "id": "reason",
+ "required": false,
+ "description": "the reason why you want to join a room"
+ }
+ ],
+ "category": "core"
+ },
+ {
+ "id": "scolor",
+ "builtIn": true,
+ "description": "set your space color",
+ "requiredPermission": "custom",
+ "customPermission": "PLACEHOLDER",
+ "attributes": [
+ {
+ "id": "color",
+ "description": "the color you want to have",
+ "exampleValue": "#ff00ff",
+ "type": "color",
+ "required": false
+ },
+ {
+ "id": "reset",
+ "description": "whether to reset the color",
+ "required": false,
+ "type": "boolean",
+ "defaultValue": "false"
+ }
+ ],
+ "category": "cosmetics"
+ },
+ {
+ "id": "color",
+ "description": "what color do you want to have in this room",
+ "builtIn": true,
+ "requiredPermission": "custom",
+ "customPermission": "PLACEHOLDER",
+ "attributes": [
+ {
+ "id": "color",
+ "description": "the color you want to have",
+ "type": "color",
+ "exampleValue": "#ff00ff",
+ "required": false
+ },
+ {
+ "id": "reset",
+ "description": "whether to reset the color",
+ "required": false,
+ "type": "boolean",
+ "defaultValue": "false"
+ }
+ ],
+ "category": "cosmetics"
+ },
+ {
+ "id": "font",
+ "description": "what font do you want to have in this room",
+ "builtIn": true,
+ "requiredPermission": "custom",
+ "customPermission": "PLACEHOLDER",
+ "attributes": [
+ {
+ "id": "font",
+ "description": "the font you want to have",
+ "required": false
+ },
+ {
+ "id": "reset",
+ "description": "whether to reset the font",
+ "required": false,
+ "type": "boolean",
+ "defaultValue": "false"
+ }
+ ],
+ "category": "cosmetics"
+ },
+ {
+ "id": "sfont",
+ "builtIn": true,
+ "description": "set your space font",
+ "requiredPermission": "custom",
+ "customPermission": "PLACEHOLDER",
+ "attributes": [
+ {
+ "id": "font",
+ "description": "the font you want to have",
+ "required": true
+ },
+ {
+ "id": "reset",
+ "description": "whether to reset the font",
+ "required": false,
+ "type": "boolean",
+ "defaultValue": "false"
+ }
+ ],
+ "category": "cosmetics"
+ },
+ {
+ "id": "addWidget",
+ "description": "add a widget",
+ "builtIn": true,
+ "category": "core"
+ },
+ {
+ "id": "addpmp",
+ "description": "add a per-message-profile to your account",
+ "requiredPermission": "none",
+ "builtIn": true,
+ "category": "persona",
+ "attributes": [
+ {
+ "id": "pmpId",
+ "description": "the id of the pmp profile",
+ "required": true,
+ "type": "custom",
+ "format": "\\S+",
+ "exampleValue": "meow"
+ },
+ {
+ "id": "name",
+ "description": "the name you want to be displayed as",
+ "required": true,
+ "type": "custom",
+ "format": "[\\w\\s]*",
+ "exampleValue": "Yumi"
+ },
+ {
+ "id": "avatarUrl",
+ "description": "the url of the avatar",
+ "required": false,
+ "type": "matrixMediaUrl",
+ "exampleValue": "mxc://example.org/mynicepic"
+ }
+ ]
+ },
+ {
+ "id": "delpmp",
+ "description": "delete a per-message-profile from your account",
+ "requiredPermission": "none",
+ "builtIn": true,
+ "category": "persona",
+ "attributes": [
+ {
+ "id": "pmpId",
+ "description": "the id of the pmp profile",
+ "required": true,
+ "type": "custom",
+ "format": "\\S+",
+ "exampleValue": "meow"
+ }
+ ]
+ },
+ {
+ "id": "usepmp",
+ "description": "use a per-message-profile",
+ "requiredPermission": "none",
+ "builtIn": true,
+ "category": "persona",
+ "attributes": [
+ {
+ "id": "pmpId",
+ "description": "the id of the pmp profile",
+ "required": true,
+ "type": "custom",
+ "format": "\\S+",
+ "exampleValue": "meow"
+ },
+ {
+ "id": "global",
+ "required": false,
+ "type": "boolean"
+ },
+ {
+ "id": "once",
+ "required": false,
+ "type": "boolean"
+ },
+ {
+ "id": "until",
+ "required": false,
+ "type": "custom",
+ "format": "\\d+"
+ }
+ ]
+ },
+ {
+ "id": "pmpproxy",
+ "description": "add a per-message-profile proxy to your account",
+ "requiredPermission": "none",
+ "builtIn": true,
+ "category": "persona"
+ },
+ {
+ "id": "pronoun",
+ "description": "set your room-local-pronouns",
+ "requiredPermission": "custom",
+ "customPermission": "PLACEHOLDER",
+ "builtIn": true,
+ "category": "cosmetics",
+ "attributes": [
+ {
+ "id": "pronouns",
+ "required": false,
+ "type": "string",
+ "exampleValue": "en:they/them, de:sie/ihr"
+ },
+ {
+ "id": "reset",
+ "required": false,
+ "type": "boolean"
+ }
+ ]
+ },
+ {
+ "id": "spronoun",
+ "description": "set your space-local-pronouns",
+ "requiredPermission": "custom",
+ "customPermission": "PLACEHOLDER",
+ "builtIn": true,
+ "category": "cosmetics",
+ "attributes": [
+ {
+ "id": "pronouns",
+ "required": false,
+ "type": "string",
+ "exampleValue": "en:they/them, de:sie/ihr"
+ },
+ {
+ "id": "reset",
+ "required": false,
+ "type": "boolean"
+ }
+ ]
+ },
+ {
+ "id": "rainbow",
+ "description": "send a message rainbow-colored",
+ "builtIn": true,
+ "requiredPermission": "message",
+ "attributes": [
+ {
+ "id": "msg",
+ "description": "the message you want to send",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "category": "misc"
+ },
+ {
+ "id": "rawmsg",
+ "builtIn": true,
+ "requiredCofigFlag": "developer",
+ "description": "send a raw message",
+ "category": "developer"
+ },
+ {
+ "id": "raw",
+ "builtIn": true,
+ "requiredCofigFlag": "developer",
+ "description": "send a raw timeline event",
+ "category": "developer"
+ },
+ {
+ "id": "rawacc",
+ "builtIn": true,
+ "requiredCofigFlag": "developer",
+ "description": "",
+ "category": "developer"
+ },
+ {
+ "id": "delacc",
+ "builtIn": true,
+ "requiredCofigFlag": "developer",
+ "description": "",
+ "category": "developer"
+ },
+ {
+ "id": "setext",
+ "builtIn": true,
+ "requiredCofigFlag": "developer",
+ "description": "",
+ "category": "developer"
+ },
+ {
+ "id": "delext",
+ "builtIn": true,
+ "requiredCofigFlag": "developer",
+ "description": "",
+ "category": "developer"
+ },
+ {
+ "id": "discardSession",
+ "builtIn": true,
+ "description": "discard the outgoing encryption session",
+ "category": "core"
+ },
+ {
+ "id": "html",
+ "builtIn": true,
+ "description": "send a html encoded message",
+ "attributes": [
+ {
+ "id": "msg",
+ "description": "the html encoded message",
+ "required": true,
+ "type": "string",
+ "exampleValue": "Red"
+ }
+ ],
+ "category": "misc"
+ },
+ {
+ "id": "hug",
+ "builtIn": true,
+ "description": "hug someone",
+ "category": "fun",
+ "attributes": [
+ {
+ "id": "user",
+ "required": false,
+ "type": "userMention"
+ }
+ ]
+ },
+ {
+ "id": "cuddle",
+ "builtIn": true,
+ "description": "cuddle someone",
+ "category": "fun",
+ "attributes": [
+ {
+ "id": "user",
+ "type": "userMention",
+ "required": false
+ }
+ ]
+ },
+ {
+ "id": "wave",
+ "builtIn": true,
+ "description": "wave at someone",
+ "category": "fun",
+ "attributes": [
+ {
+ "id": "user",
+ "required": false,
+ "type": "userMention"
+ }
+ ]
+ },
+ {
+ "id": "poke",
+ "builtIn": true,
+ "description": "poke someone",
+ "category": "fun",
+ "attributes": [
+ {
+ "id": "user",
+ "required": false,
+ "type": "userMention"
+ }
+ ]
+ },
+ {
+ "id": "headpat",
+ "builtIn": true,
+ "description": "give headpats to someone",
+ "category": "fun",
+ "attributes": [
+ {
+ "id": "user",
+ "required": false,
+ "type": "userMention"
+ }
+ ]
+ },
+ {
+ "id": "bugreport",
+ "category": "misc",
+ "builtIn": true,
+ "description": "report a bug"
+ },
+ {
+ "id": "sharehistory",
+ "category": "core",
+ "builtIn": true,
+ "description": "share historic e2ee keys with someone",
+ "attributes": [
+ {
+ "id": "user",
+ "required": true,
+ "type": "userMention"
+ }
+ ]
+ },
+ {
+ "id": "location",
+ "builtIn": true,
+ "category": "misc",
+ "description": "share a specified location",
+ "attributes": [
+ {
+ "id": "latitude",
+ "type": "custom",
+ "format": "^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?)$",
+ "exampleValue": "43.959971",
+ "required": true
+ },
+ {
+ "id": "longitude",
+ "type": "custom",
+ "format": "^\\s*[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)$",
+ "exampleValue": "-59.790623",
+ "required": true
+ },
+ {
+ "id": "altitude",
+ "type": "string",
+ "exampleValue": "0",
+ "required": false
+ }
+ ]
+ },
+ {
+ "id": "sharemylocation",
+ "builtIn": true,
+ "category": "misc",
+ "description": "Share current location. Requires your browser to have location permissions.",
+ "attributes": [
+ {
+ "id": "accurate",
+ "defaultValue": "false",
+ "description": "enabling the high accuracy option",
+ "type": "boolean",
+ "required": true
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/app/plugins/commandHandling/builtin/basicCommands.ts b/src/app/plugins/commandHandling/builtin/basicCommands.ts
new file mode 100644
index 000000000..6fd146525
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtin/basicCommands.ts
@@ -0,0 +1,7 @@
+import { BuiltInCommand } from '../BuiltInCommand';
+
+export function basicBuiltInCommands(): Array {
+ const retArr = new Array();
+
+ return retArr;
+}
diff --git a/src/app/plugins/commandHandling/builtin/builtInCommands.ts b/src/app/plugins/commandHandling/builtin/builtInCommands.ts
new file mode 100644
index 000000000..11d2f57c1
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtin/builtInCommands.ts
@@ -0,0 +1,14 @@
+import { addToCommandRegistry, CommandRegistry } from '../commandRegistry';
+import { coreBuiltInCommands } from './coreCommands';
+import { cosmeticBuiltInCommands } from './cosmeticCommands';
+import { funBuiltInCommands } from './funCommands';
+import { miscBuiltInCommands } from './miscCommands';
+import { moderationBuiltInCommands } from './moderationCommands';
+
+export function loadBuildInCommands(): void {
+ addToCommandRegistry(cosmeticBuiltInCommands(), CommandRegistry.BuiltIn);
+ addToCommandRegistry(funBuiltInCommands(), CommandRegistry.BuiltIn);
+ addToCommandRegistry(miscBuiltInCommands(), CommandRegistry.BuiltIn);
+ addToCommandRegistry(coreBuiltInCommands(), CommandRegistry.BuiltIn);
+ addToCommandRegistry(moderationBuiltInCommands(), CommandRegistry.BuiltIn);
+}
diff --git a/src/app/plugins/commandHandling/builtin/coreCommands.ts b/src/app/plugins/commandHandling/builtin/coreCommands.ts
new file mode 100644
index 000000000..367281804
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtin/coreCommands.ts
@@ -0,0 +1,46 @@
+import { sendFeedback } from '$utils/sendFeedbackToUser';
+import { BuiltInCommand } from '../BuiltInCommand';
+import { getCmdDescription } from '../BuiltInCommandsUtil';
+import {
+ CommandExecutionContext,
+ GenericCommandExecutionArgContainer,
+} from '../CommandExecutionContext';
+
+export function coreBuiltInCommands(): Array {
+ const retArr = new Array();
+ retArr.push(
+ new BuiltInCommand(
+ getCmdDescription('discardSession'),
+ async (
+ context: CommandExecutionContext,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ args: Map
+ ) => {
+ const userId = context.mx.getSafeUserId();
+
+ try {
+ const crypto = context.mx.getCrypto();
+ if (!crypto) {
+ sendFeedback('Encryption is not enabled on this client.', context.room, userId);
+ return;
+ }
+ await crypto.forceDiscardSession(context.room.roomId);
+ sendFeedback('Outbound encryption session discarded.', context.room, userId);
+ } catch (e: any) {
+ sendFeedback(`Failed to discard session: ${e.message}`, context.room, userId);
+ }
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('join'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const roomId = args.get('room')?.val.trim();
+ await context.mx.joinRoom(roomId);
+ }
+ )
+ );
+ return retArr;
+}
diff --git a/src/app/plugins/commandHandling/builtin/cosmeticCommands.ts b/src/app/plugins/commandHandling/builtin/cosmeticCommands.ts
new file mode 100644
index 000000000..8711a6410
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtin/cosmeticCommands.ts
@@ -0,0 +1,323 @@
+import { StateEvent } from '$types/matrix/room';
+import { sendFeedback } from '$utils/sendFeedbackToUser';
+import { EventTimeline } from 'matrix-js-sdk';
+import { parsePronounsInput } from '$utils/pronouns';
+import { BuiltInCommand } from '../BuiltInCommand';
+import { getCmdDescription } from '../BuiltInCommandsUtil';
+import {
+ CommandExecutionContext,
+ GenericCommandExecutionArgContainer,
+} from '../CommandExecutionContext';
+
+export function cosmeticBuiltInCommands(): Array {
+ const retArr = new Array();
+ retArr.push(
+ new BuiltInCommand(
+ getCmdDescription('color'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const color = args.get('color')?.val.trim().toLowerCase();
+ const reset: boolean = args.get('reset')?.val ?? false;
+ const userId = context.mx.getSafeUserId();
+
+ try {
+ if (reset) {
+ await context.mx.sendStateEvent(
+ context.room.roomId,
+ StateEvent.RoomCosmeticsColor as any,
+ {},
+ userId
+ );
+ sendFeedback('Room color has been reset.', context.room, userId);
+ return;
+ }
+
+ if (/^#[0-9A-F]{6}$/i.test(color)) {
+ await context.mx.sendStateEvent(
+ context.room.roomId,
+ StateEvent.RoomCosmeticsColor as any,
+ { color },
+ userId
+ );
+ sendFeedback(`Room color set to ${color}.`, context.room, userId);
+ } else {
+ sendFeedback(
+ `Invalid format (${color}). How did you mess that up?`,
+ context.room,
+ userId
+ );
+ }
+ } catch (e: any) {
+ if (e.errcode === 'M_FORBIDDEN') {
+ sendFeedback(
+ 'Permission Denied. An admin must enable "Room Colors" in Settings > Cosmetics in app.sable.moe or another supported client.',
+ context.room,
+ userId
+ );
+ }
+ }
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('scolor'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const color = args.get('color')?.val.trim().toLowerCase();
+ const reset: boolean = args.get('reset')?.val ?? false;
+ const userId = context.mx.getSafeUserId();
+
+ const parents = context.room
+ .getLiveTimeline()
+ .getState(EventTimeline.FORWARDS)
+ ?.getStateEvents(StateEvent.SpaceParent);
+
+ const targetSpaceId =
+ parents && parents.length > 0 ? parents[0].getStateKey() : context.room.roomId;
+
+ try {
+ if (reset) {
+ await context.mx.sendStateEvent(
+ targetSpaceId as any,
+ StateEvent.RoomCosmeticsColor as any,
+ {},
+ userId
+ );
+ sendFeedback('Global space color reset.', context.room, userId);
+ return;
+ }
+
+ if (/^#[0-9A-F]{6}$/i.test(color)) {
+ await context.mx.sendStateEvent(
+ targetSpaceId as any,
+ StateEvent.RoomCosmeticsColor as any,
+ { color },
+ userId
+ );
+ sendFeedback(`Global space color set to ${color}.`, context.room, userId);
+ } else {
+ sendFeedback(
+ `Invalid format (${color}). How did you mess that up?`,
+ context.room,
+ userId
+ );
+ }
+ } catch (e: any) {
+ if (e.errcode === 'M_FORBIDDEN') {
+ sendFeedback(
+ 'Permission Denied. An admin must enable "Space-Wide Colors" in Settings > Cosmetics in app.sable.moe or another supported client.',
+ context.room,
+ userId
+ );
+ }
+ }
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('font'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const font = args
+ .get('font')
+ ?.val.trim()
+ .replaceAll(/[;{}<>]/g, '')
+ .slice(0, 32);
+ const userId = context.mx.getSafeUserId();
+
+ try {
+ if (args.get('reset')?.val) {
+ await context.mx.sendStateEvent(
+ context.room.roomId,
+ StateEvent.RoomCosmeticsFont as any,
+ {},
+ userId
+ );
+ sendFeedback('Room font reset.', context.room, userId);
+ return;
+ }
+
+ await context.mx.sendStateEvent(
+ context.room.roomId,
+ StateEvent.RoomCosmeticsFont as any,
+ { font },
+ userId
+ );
+ sendFeedback(`Room font set to "${font}".`, context.room, userId);
+ } catch (e: any) {
+ if (e.errcode === 'M_FORBIDDEN') {
+ sendFeedback(
+ 'Permission Denied. An admin must enable "Room Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.',
+ context.room,
+ userId
+ );
+ }
+ }
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('sfont'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const font = args
+ .get('font')
+ ?.val.trim()
+ .replaceAll(/[;{}<>]/g, '')
+ .slice(0, 32);
+ const userId = context.mx.getSafeUserId();
+
+ const parents = context.room
+ .getLiveTimeline()
+ .getState(EventTimeline.FORWARDS)
+ ?.getStateEvents(StateEvent.SpaceParent);
+
+ const targetSpaceId =
+ parents && parents.length > 0 ? parents[0].getStateKey() : context.room.roomId;
+
+ try {
+ if (args.get('reset')?.val) {
+ await context.mx.sendStateEvent(
+ targetSpaceId as any,
+ StateEvent.RoomCosmeticsFont as any,
+ {},
+ userId
+ );
+ sendFeedback('Space font reset.', context.room, userId);
+ return;
+ }
+
+ await context.mx.sendStateEvent(
+ targetSpaceId as any,
+ StateEvent.RoomCosmeticsFont as any,
+ { font },
+ userId
+ );
+ sendFeedback(`Space font set to "${font}".`, context.room, userId);
+ } catch (e: any) {
+ if (e.errcode === 'M_FORBIDDEN') {
+ sendFeedback(
+ 'Permission Denied. An admin must enable "Space-Wide Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.',
+ context.room,
+ userId
+ );
+ }
+ }
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('pronoun'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const match = args
+ .get('pronouns')
+ ?.val.trim()
+ .match(/^"(.*)"$/);
+ const rawInput = match ? match[1].trim() : args.get('pronouns')?.val.trim();
+ const userId = context.mx.getSafeUserId();
+
+ try {
+ if (args.get('reset')?.val) {
+ await context.mx.sendStateEvent(
+ context.room.roomId,
+ StateEvent.RoomCosmeticsPronouns as any,
+ {},
+ userId
+ );
+ sendFeedback('Room pronouns have been reset.', context.room, userId);
+ return;
+ }
+
+ const pronounsArray = parsePronounsInput(rawInput);
+
+ await context.mx.sendStateEvent(
+ context.room.roomId,
+ StateEvent.RoomCosmeticsPronouns as any,
+ { pronouns: pronounsArray },
+ userId
+ );
+
+ const feedbackString = pronounsArray
+ .map((p) => (p.language ? `for ${p.language} "${p.summary}" was set` : p.summary))
+ .join(', ');
+
+ sendFeedback(`Room pronouns set: ${feedbackString}`, context.room, userId);
+ } catch (e: any) {
+ if (e.errcode === 'M_FORBIDDEN') {
+ sendFeedback(
+ 'Permission Denied. Could not update room pronouns.',
+ context.room,
+ userId
+ );
+ }
+ }
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('spronoun'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const match = args
+ .get('pronouns')
+ ?.val.trim()
+ .match(/^"(.*)"$/);
+ const rawInput = match ? match[1].trim() : args.get('pronouns')?.val.trim();
+ const userId = context.mx.getSafeUserId();
+
+ const parents = context.room
+ .getLiveTimeline()
+ .getState(EventTimeline.FORWARDS)
+ ?.getStateEvents(StateEvent.SpaceParent);
+
+ const targetSpaceId =
+ parents && parents.length > 0 ? parents[0].getStateKey() : context.room.roomId;
+
+ try {
+ if (args.get('reset')?.val) {
+ await context.mx.sendStateEvent(
+ targetSpaceId as any,
+ StateEvent.RoomCosmeticsPronouns as any,
+ {},
+ userId
+ );
+ sendFeedback('Global space pronouns reset.', context.room, userId);
+ return;
+ }
+
+ const pronounsArray = parsePronounsInput(rawInput);
+
+ await context.mx.sendStateEvent(
+ targetSpaceId as any,
+ StateEvent.RoomCosmeticsPronouns as any,
+ { pronouns: pronounsArray },
+ userId
+ );
+
+ const feedbackString = pronounsArray
+ .map((p) => (p.language ? `for ${p.language} "${p.summary}" was set` : p.summary))
+ .join(', ');
+
+ sendFeedback(`Global space pronouns set: ${feedbackString}`, context.room, userId);
+ } catch (e: any) {
+ if (e.errcode === 'M_FORBIDDEN') {
+ sendFeedback(
+ 'Permission Denied. Could not update space pronouns.',
+ context.room,
+ userId
+ );
+ }
+ }
+ }
+ )
+ );
+ return retArr;
+}
diff --git a/src/app/plugins/commandHandling/builtin/funCommands.ts b/src/app/plugins/commandHandling/builtin/funCommands.ts
new file mode 100644
index 000000000..5154267cf
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtin/funCommands.ts
@@ -0,0 +1,30 @@
+import { BuiltInCommand } from '../BuiltInCommand';
+import { getCmdDescription } from '../BuiltInCommandsUtil';
+import {
+ CommandExecutionContext,
+ GenericCommandExecutionArgContainer,
+} from '../CommandExecutionContext';
+
+export function funBuiltInCommands(): Array {
+ const retArr = new Array();
+ retArr.push(
+ new BuiltInCommand(
+ getCmdDescription('headpat'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const target = args.get('user')?.val.trim();
+ await context.mx.sendMessage(context.room.roomId, {
+ msgtype: 'm.emote',
+ 'm.mentions': {
+ user_ids: target ? [target] : [],
+ },
+ body: `pats ${target || 'you'}`,
+ 'fyi.cisnt.headpat': true,
+ } as any);
+ }
+ )
+ );
+ return retArr;
+}
diff --git a/src/app/plugins/commandHandling/builtin/miscCommands.ts b/src/app/plugins/commandHandling/builtin/miscCommands.ts
new file mode 100644
index 000000000..e1649fd97
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtin/miscCommands.ts
@@ -0,0 +1,116 @@
+import { useOpenBugReportModal } from '$state/hooks/bugReportModal';
+import { MsgType } from 'matrix-js-sdk';
+import { sendFeedback } from '$utils/sendFeedbackToUser';
+import { BuiltInCommand } from '../BuiltInCommand';
+import { getCmdDescription } from '../BuiltInCommandsUtil';
+import {
+ CommandExecutionContext,
+ GenericCommandExecutionArgContainer,
+} from '../CommandExecutionContext';
+
+export function miscBuiltInCommands(): Array {
+ const retArr = new Array();
+ retArr.push(
+ new BuiltInCommand(
+ getCmdDescription('bugreport'),
+ async (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _context: CommandExecutionContext,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _args: Map
+ ) => {
+ useOpenBugReportModal();
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('html'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ await context.mx.sendMessage(context.room.roomId, {
+ msgtype: MsgType.Text,
+ body: args
+ .get('msg')
+ ?.val.replaceAll('
', '\n')
+ .replaceAll('', '\n- ')
+ .replaceAll(
+ /(.*?))"(.*?)>(?(.*?))<\/a>/g,
+ '[$]($)'
+ )
+ .replaceAll(/<[^>]*>/g, ''),
+ format: 'org.matrix.custom.html',
+ formatted_body: args.get('msg')?.val,
+ });
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('sharemylocation'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const options = {
+ enableHighAccuracy: args.get('accurate')?.val ?? false,
+ timeout: 5000,
+ maximumAge: 0,
+ };
+ function success(pos: any) {
+ const crd = pos.coords;
+
+ const mlat = crd.latitude;
+ const mlon = crd.longitude;
+ const malt = crd.altitude;
+ const macc = crd.accuracy;
+ if (!mlat || !mlat) {
+ sendFeedback(
+ 'Unable to retrieve the location data for an unknown reason',
+ context.room,
+ context.mx.getSafeUserId()
+ );
+ return;
+ }
+ context.mx.sendMessage(context.room.roomId, {
+ msgtype: MsgType.Location,
+ geo_uri: `geo:${mlat},${mlon}${malt ? `,${malt}` : ''};u=${macc}`,
+ body: `https://www.openstreetmap.org/?mlat=${mlat}&mlon=${mlon}#map=16/${mlat}/${mlon}"`,
+ } as any);
+ }
+
+ function error(err: any) {
+ let response = `Unable to retrieve the location data, Error no. ${err.code}: ${err.message}`;
+ if (err.code === 1) response = 'You have denied Sable access to you location services.';
+ if (err.code === 2)
+ response = 'Your device does not have a gps module, or it may not be turned on.';
+ sendFeedback(response, context.room, context.mx.getSafeUserId());
+ }
+ navigator.geolocation.getCurrentPosition(success, error, options);
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('location'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const mlat = args.get('latitude')?.val;
+ const mlon = args.get('longitude')?.val;
+ const malt = args.get('altitude')?.val;
+ if (!mlat || !mlon) {
+ sendFeedback(
+ 'You need to specify a latitude, a longitude parameter, and optionally an altitude, as for example: /location 43.959971 -59.790623 or use the /sharemylocation to share the current location',
+ context.room,
+ context.mx.getSafeUserId()
+ );
+ return;
+ }
+ await context.mx.sendMessage(context.room.roomId, {
+ msgtype: MsgType.Location,
+ geo_uri: `geo:${mlat},${mlon}${malt ? `,${malt}` : ''};u=0`,
+ body: `https://www.openstreetmap.org/?mlat=${mlat}&mlon=${mlon}#map=16/${mlat}/${mlon}"`,
+ } as any);
+ }
+ )
+ );
+ return retArr;
+}
diff --git a/src/app/plugins/commandHandling/builtin/moderationCommands.ts b/src/app/plugins/commandHandling/builtin/moderationCommands.ts
new file mode 100644
index 000000000..9d616b184
--- /dev/null
+++ b/src/app/plugins/commandHandling/builtin/moderationCommands.ts
@@ -0,0 +1,67 @@
+import { BuiltInCommand } from '../BuiltInCommand';
+import { getCmdDescription, parseUsers } from '../BuiltInCommandsUtil';
+import {
+ CommandExecutionContext,
+ GenericCommandExecutionArgContainer,
+} from '../CommandExecutionContext';
+
+export function moderationBuiltInCommands(): Array {
+ const retArr = new Array();
+ retArr.push(
+ new BuiltInCommand(
+ getCmdDescription('disinvite'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const target = args.get('user')?.val.trim();
+ const reason = args.get('reason')?.val.trim();
+ context.mx.kick(context.room.roomId, target, reason);
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('disinvite-list'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const users = parseUsers(args.get('userList')?.val.trim());
+ const reason = args.get('reason')?.val.trim();
+ users.map((id) => context.mx.kick(context.room.roomId, id, reason));
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('kick'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const target = args.get('user')?.val.trim();
+ const reason = args.get('reason')?.val.trim();
+ context.mx.kick(context.room.roomId, target, reason);
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('ban'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const target = args.get('user')?.val.trim();
+ const reason = args.get('reason')?.val.trim();
+ context.mx.ban(context.room.roomId, target, reason);
+ }
+ ),
+ new BuiltInCommand(
+ getCmdDescription('unban'),
+ async (
+ context: CommandExecutionContext,
+ args: Map
+ ) => {
+ const target = args.get('user')?.val.trim();
+ context.mx.unban(context.room.roomId, target);
+ }
+ )
+ );
+ return retArr;
+}
diff --git a/src/app/plugins/commandHandling/commandRegistry.ts b/src/app/plugins/commandHandling/commandRegistry.ts
new file mode 100644
index 000000000..d42d248a4
--- /dev/null
+++ b/src/app/plugins/commandHandling/commandRegistry.ts
@@ -0,0 +1,43 @@
+import { AbstractCommand } from './AbstractCommand';
+import { BuiltInCommand } from './BuiltInCommand';
+
+const builtInCommandRegistry: Map = new Map();
+const roomCommandRegistry: Map = new Map();
+const customCommandRegistry: Map = new Map();
+
+export enum CommandRegistry {
+ BuiltIn,
+ Room,
+ Custom,
+}
+
+export function addToCommandRegistry(
+ commandList: Array,
+ registry: CommandRegistry
+): void {
+ commandList.forEach((cmd) => {
+ if (registry === CommandRegistry.BuiltIn)
+ builtInCommandRegistry.set(cmd.getCommandDefinition().id, cmd);
+ else if (registry === CommandRegistry.Room)
+ roomCommandRegistry.set(cmd.getCommandDefinition().id, cmd);
+ else if (registry === CommandRegistry.Custom)
+ customCommandRegistry.set(cmd.getCommandDefinition().id, cmd);
+ });
+}
+
+export function clearRoomCommandRegistry(): void {
+ roomCommandRegistry.clear();
+}
+
+export function getFromCommandRegistry(id: string): AbstractCommand {
+ if (customCommandRegistry.has(id)) {
+ return customCommandRegistry.get(id)!;
+ }
+ if (roomCommandRegistry.has(id)) {
+ return roomCommandRegistry.get(id)!;
+ }
+ if (builtInCommandRegistry.has(id)) {
+ return builtInCommandRegistry.get(id)!;
+ }
+ throw new Error('Command not found');
+}
diff --git a/src/app/plugins/commandHandling/slateInput.css.ts b/src/app/plugins/commandHandling/slateInput.css.ts
new file mode 100644
index 000000000..e14eac02c
--- /dev/null
+++ b/src/app/plugins/commandHandling/slateInput.css.ts
@@ -0,0 +1,60 @@
+import { globalStyle, style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const CommandInline = style({
+ display: 'inline-flex',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: config.space.S100,
+});
+
+export const CommandAttribute = style({
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: config.space.S100,
+});
+
+export const CommandAttributeLabel = style({
+ fontSize: toRem(12),
+ lineHeight: toRem(20),
+ paddingLeft: toRem(10),
+ opacity: config.opacity.Placeholder,
+ fontWeight: config.fontWeight.W500,
+});
+
+globalStyle(`${CommandAttribute} input`, {
+ minWidth: toRem(80),
+ height: toRem(24),
+ padding: `0 ${config.space.S100}`,
+ border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
+ borderRadius: config.radii.R300,
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ fontSize: toRem(13),
+});
+
+globalStyle(`${CommandAttribute} input::placeholder`, {
+ color: color.SurfaceVariant.OnContainer,
+ opacity: config.opacity.Placeholder,
+});
+
+globalStyle(`${CommandAttribute} select`, {
+ height: toRem(24),
+ padding: `0 ${config.space.S100}`,
+ border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
+ borderRadius: config.radii.R300,
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ fontSize: toRem(13),
+ cursor: 'pointer',
+});
+
+globalStyle(`${CommandAttribute} input:focus`, {
+ outline: 'none',
+ boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`,
+});
+
+globalStyle(`${CommandAttribute} select:focus`, {
+ outline: 'none',
+ boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`,
+});
diff --git a/src/app/plugins/commandHandling/slateInput.tsx b/src/app/plugins/commandHandling/slateInput.tsx
new file mode 100644
index 000000000..c9f717141
--- /dev/null
+++ b/src/app/plugins/commandHandling/slateInput.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import { generateShortId } from '$utils/shortIdGen';
+import { AbstractCommand } from './AbstractCommand';
+import * as css from './slateInput.css';
+
+type InputElementForParams = {
+ attributeType?: string;
+ helpText?: string;
+ placeHolder?: string;
+ required: boolean;
+ pattern?: string;
+ id?: string;
+ name?: string;
+ onChange: (val: any) => void;
+};
+
+function InputElementFor({
+ attributeType,
+ helpText,
+ placeHolder,
+ onChange,
+ pattern,
+ required = false,
+ id,
+ name,
+}: Readonly) {
+ if (attributeType === undefined || attributeType === 'string') {
+ return (
+ ) => {
+ onChange(e.target.value);
+ }}
+ id={id}
+ name={name}
+ />
+ );
+ }
+ if (attributeType === 'custom') {
+ return (
+ ) => {
+ onChange(e.target.value);
+ }}
+ pattern={pattern}
+ id={id}
+ name={name}
+ />
+ );
+ }
+ if (attributeType === 'color') {
+ return (
+ ) => {
+ onChange(e.target.value);
+ }}
+ id={id}
+ name={name}
+ />
+ );
+ }
+ if (attributeType === 'boolean') {
+ return (
+
+ );
+ }
+}
+
+type SlateInputForCommandProps = {
+ command: AbstractCommand;
+ commandNameClassName: string;
+};
+
+export function SlateInputForCommand({
+ command,
+ commandNameClassName,
+}: Readonly) {
+ return (
+
+
+ {`/${command.getCommandDefinition().id}`}
+
+ {command.getCommandDefinition().attributes?.map((attr) => (
+
+ {attr.id}
+ {
+ if (value === null || value === undefined || value === '') return;
+ command.updateArgValue(attr.id, value);
+ }}
+ required={attr.required}
+ pattern={attr.format}
+ id={generateShortId(5)}
+ name={generateShortId(5)}
+ />
+
+ ))}
+
+ );
+}
diff --git a/src/types/schemas/command.d.ts b/src/types/schemas/command.d.ts
new file mode 100644
index 000000000..3833631a3
--- /dev/null
+++ b/src/types/schemas/command.d.ts
@@ -0,0 +1,120 @@
+/* eslint-disable */
+/**
+ * This file was automatically generated by json-schema-to-typescript.
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
+ * and run json-schema-to-typescript to regenerate this file.
+ */
+
+export type CommandDefinition = {
+ [k: string]: unknown
+} & {
+ /**
+ * what the command should be called by
+ */
+ id: string
+ /**
+ * whether this is a built in command
+ */
+ builtIn: boolean
+ /**
+ * a short human readable description of the command
+ */
+ description: string
+ /**
+ * optional url leading to external docs
+ */
+ helpUrl?: string
+ requiredPermission?:
+ | 'invite'
+ | 'ban'
+ | 'kick'
+ | 'acl'
+ | 'founder'
+ | 'upgrade'
+ | 'none'
+ | 'message'
+ | 'sticker'
+ | 'react'
+ | 'redactOthers'
+ | 'redactSelf'
+ | 'custom'
+ checkSpacePermission?: boolean
+ /**
+ * id of the custom permission needed for this command
+ */
+ customPermission?: string
+ /**
+ * a required client config flag
+ */
+ requiredCofigFlag?: 'developer'
+ attributes?: {
+ /**
+ * id for the attribute
+ */
+ id: string
+ /**
+ * What type of attribute
+ */
+ type?:
+ | 'string'
+ | 'custom'
+ | 'number'
+ | 'boolean'
+ | 'userMention'
+ | 'roomMention'
+ | 'eventMention'
+ | 'matrixMediaUrl'
+ /**
+ * regex for the value
+ */
+ format?: string
+ /**
+ * an enum of allowed values
+ */
+ enum?: string[]
+ /**
+ * short human readable description of what the attribute is for
+ */
+ description?: string
+ /**
+ * an example of what the attribute value can be
+ */
+ exampleValue?: string
+ /**
+ * whether this attribute required
+ */
+ required: boolean
+ }[]
+ /**
+ * if this command can be called when you are currently replying to someone
+ */
+ executableInReply?: boolean
+ /**
+ * additional information if this is a bot command
+ */
+ botCommand?: {
+ /**
+ * when this command overrides a built in command
+ */
+ override?: {
+ /**
+ * the id of the builtin command it overrides
+ */
+ overrides?: string
+ [k: string]: unknown
+ }
+ /**
+ * the mxid of the bot
+ */
+ botMxId: string
+ /**
+ * the state event id which introduces/describes this command
+ */
+ stateEventId: string
+ [k: string]: unknown
+ }
+ /**
+ * in which category is this command
+ */
+ category: 'core' | 'developer' | 'moderation' | 'administration' | 'cosmetics' | 'misc' | 'fun' | 'persona' | 'bot'
+}
diff --git a/src/types/schemas/command.schema.json b/src/types/schemas/command.schema.json
new file mode 100644
index 000000000..e81f7a2ae
--- /dev/null
+++ b/src/types/schemas/command.schema.json
@@ -0,0 +1,197 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "commandDefinition",
+ "type": "object",
+ "required": ["id", "description", "builtIn", "category"],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 2,
+ "pattern": "^\\S+$",
+ "description": "what the command should be called by"
+ },
+ "builtIn": {
+ "type": "boolean",
+ "description": "whether this is a built in command"
+ },
+ "description": {
+ "maxLength": 75,
+ "type": "string",
+ "description": "a short human readable description of the command"
+ },
+ "helpUrl": {
+ "type": "string",
+ "format": "uri",
+ "description": "optional url leading to external docs"
+ },
+ "requiredPermission": {
+ "enum": [
+ "invite",
+ "ban",
+ "kick",
+ "acl",
+ "founder",
+ "upgrade",
+ "none",
+ "message",
+ "sticker",
+ "react",
+ "redactOthers",
+ "redactSelf",
+ "custom"
+ ],
+ "type": "string",
+ "default": "none"
+ },
+ "checkSpacePermission": {
+ "type": "boolean",
+ "default": false
+ },
+ "customPermission": {
+ "description": "id of the custom permission needed for this command",
+ "type": "string"
+ },
+ "requiredCofigFlag": {
+ "description": "a required client config flag",
+ "type": "string",
+ "enum": ["developer"]
+ },
+ "attributes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "required"],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^\\S+$",
+ "description": "id for the attribute"
+ },
+ "type": {
+ "enum": [
+ "string",
+ "color",
+ "custom",
+ "number",
+ "boolean",
+ "userMention",
+ "roomMention",
+ "eventMention",
+ "matrixMediaUrl"
+ ],
+ "type": "string",
+ "description": "What type of attribute",
+ "default": "string"
+ },
+ "format": {
+ "type": "string",
+ "description": "regex for the value"
+ },
+ "enum": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true,
+ "description": "an enum of allowed values"
+ },
+ "description": {
+ "maxLength": 75,
+ "type": "string",
+ "description": "short human readable description of what the attribute is for"
+ },
+ "exampleValue": {
+ "type": "string",
+ "description": "an example of what the attribute value can be"
+ },
+ "defaultValue": {
+ "type": "string",
+ "description": "the default value of the attr"
+ },
+ "required": {
+ "type": "boolean",
+ "description": "whether this attribute required"
+ }
+ }
+ }
+ },
+ "executableInReply": {
+ "type": "boolean",
+ "description": "if this command can be called when you are currently replying to someone"
+ },
+ "botCommand": {
+ "type": "object",
+ "description": "additional information if this is a bot command",
+ "properties": {
+ "override": {
+ "type": "object",
+ "description": "when this command overrides a built in command",
+ "properties": {
+ "overrides": {
+ "type": "string",
+ "pattern": "^\\S+$",
+ "description": "the id of the builtin command it overrides"
+ }
+ }
+ },
+ "botMxId": {
+ "type": "string",
+ "description": "the mxid of the bot"
+ },
+ "stateEventId": {
+ "type": "string",
+ "description": "the state event id which introduces/describes this command"
+ }
+ },
+ "required": ["botMxId", "stateEventId"]
+ },
+ "category": {
+ "description": "in which category is this command",
+ "type": "string",
+ "enum": [
+ "core",
+ "developer",
+ "moderation",
+ "administration",
+ "cosmetics",
+ "misc",
+ "fun",
+ "persona",
+ "bot"
+ ],
+ "default": "core"
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "requiredPermission": {
+ "const": "custom"
+ }
+ },
+ "required": ["requiredPermission"]
+ },
+ "then": {
+ "required": ["customPermission"]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "builtIn": {
+ "const": true
+ }
+ },
+ "required": ["builtIn"]
+ },
+ "then": {
+ "not": {
+ "required": ["botCommand"]
+ }
+ }
+ }
+ ]
+}
diff --git a/src/types/schemas/commandList.d.ts b/src/types/schemas/commandList.d.ts
new file mode 100644
index 000000000..6b91c5119
--- /dev/null
+++ b/src/types/schemas/commandList.d.ts
@@ -0,0 +1,171 @@
+/* eslint-disable */
+/**
+ * This file was automatically generated by json-schema-to-typescript.
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
+ * and run json-schema-to-typescript to regenerate this file.
+ */
+
+export type CommandDefinition = {
+ [k: string]: unknown;
+} & {
+ [k: string]: unknown;
+} & {
+ /**
+ * what the command should be called by
+ */
+ id: string;
+ /**
+ * whether this is a built in command
+ */
+ builtIn: boolean;
+ /**
+ * a short human readable description of the command
+ */
+ description: string;
+ /**
+ * optional url leading to external docs
+ */
+ helpUrl?: string;
+ requiredPermission?:
+ | "invite"
+ | "ban"
+ | "kick"
+ | "acl"
+ | "founder"
+ | "upgrade"
+ | "none"
+ | "message"
+ | "sticker"
+ | "react"
+ | "redactOthers"
+ | "redactSelf"
+ | "custom";
+ checkSpacePermission?: boolean;
+ /**
+ * id of the custom permission needed for this command
+ */
+ customPermission?: string;
+ /**
+ * a required client config flag
+ */
+ requiredCofigFlag?: "developer";
+ attributes?: {
+ [k: string]: unknown;
+ }[];
+ /**
+ * if this command can be called when you are currently replying to someone
+ */
+ executableInReply?: boolean;
+ /**
+ * additional information if this is a bot command
+ */
+ botCommand?: {
+ /**
+ * when this command overrides a built in command
+ */
+ override?: {
+ /**
+ * the id of the builtin command it overrides
+ */
+ overrides?: string;
+ [k: string]: unknown;
+ };
+ /**
+ * the mxid of the bot
+ */
+ botMxId: string;
+ /**
+ * the state event id which introduces/describes this command
+ */
+ stateEventId: string;
+ [k: string]: unknown;
+ };
+ /**
+ * in which category is this command
+ */
+ category: "core" | "developer" | "moderation" | "administration" | "cosmetics" | "misc" | "fun" | "persona" | "bot";
+} & {
+ /**
+ * what the command should be called by
+ */
+ id: string;
+ /**
+ * whether this is a built in command
+ */
+ builtIn: boolean;
+ /**
+ * a short human readable description of the command
+ */
+ description: string;
+ /**
+ * optional url leading to external docs
+ */
+ helpUrl?: string;
+ requiredPermission?:
+ | "invite"
+ | "ban"
+ | "kick"
+ | "acl"
+ | "founder"
+ | "upgrade"
+ | "none"
+ | "message"
+ | "sticker"
+ | "react"
+ | "redactOthers"
+ | "redactSelf"
+ | "custom";
+ checkSpacePermission?: boolean;
+ /**
+ * id of the custom permission needed for this command
+ */
+ customPermission?: string;
+ /**
+ * a required client config flag
+ */
+ requiredCofigFlag?: "developer";
+ attributes?: {
+ [k: string]: unknown;
+ }[];
+ /**
+ * if this command can be called when you are currently replying to someone
+ */
+ executableInReply?: boolean;
+ /**
+ * additional information if this is a bot command
+ */
+ botCommand?: {
+ /**
+ * when this command overrides a built in command
+ */
+ override?: {
+ /**
+ * the id of the builtin command it overrides
+ */
+ overrides?: string;
+ [k: string]: unknown;
+ };
+ /**
+ * the mxid of the bot
+ */
+ botMxId: string;
+ /**
+ * the state event id which introduces/describes this command
+ */
+ stateEventId: string;
+ [k: string]: unknown;
+ };
+ /**
+ * in which category is this command
+ */
+ category: "core" | "developer" | "moderation" | "administration" | "cosmetics" | "misc" | "fun" | "persona" | "bot";
+};
+
+export interface CommandList {
+ /**
+ * a command
+ *
+ * @minItems 1
+ */
+ commands: [CommandDefinition, ...CommandDefinition[]];
+}
diff --git a/src/types/schemas/commandList.schema.json b/src/types/schemas/commandList.schema.json
new file mode 100644
index 000000000..74c9822c0
--- /dev/null
+++ b/src/types/schemas/commandList.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "required": ["commands"],
+ "additionalProperties": false,
+ "properties": {
+ "commands": {
+ "type": "array",
+ "description": "a command",
+ "minItems": 1,
+ "items": {
+ "$ref": "./command.schema.json"
+ }
+ }
+ }
+}