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" + } + } + } +}