From f3e29fb3be43bd81e976cd1eae71c38041adb4fb Mon Sep 17 00:00:00 2001 From: Catalan Lover <48515417+FSG-Cat@users.noreply.github.com> Date: Fri, 1 May 2026 17:40:10 +0200 Subject: [PATCH] Add Avatar Customisation Commands (#1108) * Add Avatar Command to bot mode and AS mode * Add changesets * Move MXC URI validation to matrix basic types * Fix MediaID being mixed case when its exempt from usual case rules. * Update changeset * Integrate Gnuxie Review Feedback * Fix linting errors * Fix having forgotten to run prettier. * Integrate Gnuxie review feedback. * Fix version.txt.tmp files --- .changeset/long-memes-allow.md | 5 ++ .changeset/moody-points-attack.md | 5 ++ .changeset/young-bees-teach.md | 5 ++ .gitignore | 3 + .../appservice/bot/AppserviceBotCommands.ts | 2 + .../src/appservice/bot/AvatarCommand.tsx | 56 +++++++++++++++++++ .../draupnir/src/commands/DraupnirCommands.ts | 3 + .../draupnir/src/commands/SetAvatarCommand.ts | 51 +++++++++++++++++ .../StringMediaURI.test.ts | 38 +++++++++++++ .../src/StringlyTypedMatrix/StringMediaURI.ts | 49 ++++++++++++++++ .../src/StringlyTypedMatrix/index.ts | 1 + 11 files changed, 218 insertions(+) create mode 100644 .changeset/long-memes-allow.md create mode 100644 .changeset/moody-points-attack.md create mode 100644 .changeset/young-bees-teach.md create mode 100644 apps/draupnir/src/appservice/bot/AvatarCommand.tsx create mode 100644 apps/draupnir/src/commands/SetAvatarCommand.ts create mode 100644 packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.test.ts create mode 100644 packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.ts diff --git a/.changeset/long-memes-allow.md b/.changeset/long-memes-allow.md new file mode 100644 index 00000000..5e4c245c --- /dev/null +++ b/.changeset/long-memes-allow.md @@ -0,0 +1,5 @@ +--- +"draupnir": patch +--- + +Add Avatar customisation command for Draupnir diff --git a/.changeset/moody-points-attack.md b/.changeset/moody-points-attack.md new file mode 100644 index 00000000..084c3d14 --- /dev/null +++ b/.changeset/moody-points-attack.md @@ -0,0 +1,5 @@ +--- +"draupnir": patch +--- + +Add avatar customisation command for Appservice mode admin bot diff --git a/.changeset/young-bees-teach.md b/.changeset/young-bees-teach.md new file mode 100644 index 00000000..c9f741cd --- /dev/null +++ b/.changeset/young-bees-teach.md @@ -0,0 +1,5 @@ +--- +"@the-draupnir-project/matrix-basic-types": patch +--- + +Add MXC URI validation support. diff --git a/.gitignore b/.gitignore index fb4533bd..b5b22357 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ venv/ /db +# temporary version file generated from npm build +# Only the tmp file for version is ignored because the branch variant doesnt get created by this part of the build failing on windows. +version.txt.tmp # version file generated from npm build version.txt # branch file generated from npm build diff --git a/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts b/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts index ffd53cd9..93a8c63b 100644 --- a/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts +++ b/apps/draupnir/src/appservice/bot/AppserviceBotCommands.ts @@ -21,6 +21,7 @@ import { import { AppserviceProvisionForUserCommand } from "./ProvisionCommand"; import { AppserviceVersionCommand } from "./VersionCommand"; import { AppserviceDisplaynameCommand } from "./DisplaynameCommand"; +import { AppserviceAvatarCommand } from "./AvatarCommand"; AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"]) .internCommand(AppserviceAllowCommand, ["admin", "allow"]) @@ -28,6 +29,7 @@ AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"]) .internCommand(AppserviceProvisionForUserCommand, ["admin", "provision"]) .internCommand(AppserviceVersionCommand, ["admin", "version"]) .internCommand(AppserviceDisplaynameCommand, ["admin", "displayname"]) + .internCommand(AppserviceAvatarCommand, ["admin", "avatar"]) .internCommand(AppserviceRestartDraupnirCommand, ["admin", "restart"]) .internCommand(AppserviceListUnstartedCommand, [ "admin", diff --git a/apps/draupnir/src/appservice/bot/AvatarCommand.tsx b/apps/draupnir/src/appservice/bot/AvatarCommand.tsx new file mode 100644 index 00000000..247f9187 --- /dev/null +++ b/apps/draupnir/src/appservice/bot/AvatarCommand.tsx @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: Apache-2.0 + +import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite"; +import { + ActionResult, + isError, + Ok, + ActionError, +} from "matrix-protection-suite"; +import { + StringPresentationType, + describeCommand, +} from "@the-draupnir-project/interface-manager"; +import { isStringMediaURI } from "@the-draupnir-project/matrix-basic-types"; +import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor"; +import { resultifyBotSDKRequestError } from "matrix-protection-suite-for-matrix-bot-sdk"; + +export const AppserviceAvatarCommand = describeCommand({ + summary: "Sets the avatar of the main appservice admin bot.", + parameters: [], + rest: { + name: "avatar url", + acceptor: StringPresentationType, + }, + async executor( + context: AppserviceAdaptorContext, + _info, + _keywords, + avatarParts + ): Promise> { + const avatarUrl = avatarParts.join(" ").trim(); + if (!avatarUrl) { + return ActionError.Result("Avatar URL cannot be empty"); + } + if (!isStringMediaURI(avatarUrl)) { + return ActionError.Result( + `Invalid MXC URI format. Expected format: mxc://server/media-id, got: ${avatarUrl}` + ); + } + const setAvatarResult = await context.client + .setAvatarUrl(avatarUrl) + .then((_) => Ok(undefined), resultifyBotSDKRequestError); + if (isError(setAvatarResult)) { + return setAvatarResult.elaborate( + `Failed to set appservice bot avatar to ${avatarUrl}` + ); + } + return setAvatarResult; + }, +}); + +AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceAvatarCommand, { + isAlwaysSupposedToUseDefaultRenderer: true, +}); diff --git a/apps/draupnir/src/commands/DraupnirCommands.ts b/apps/draupnir/src/commands/DraupnirCommands.ts index c682639b..e4ee6903 100644 --- a/apps/draupnir/src/commands/DraupnirCommands.ts +++ b/apps/draupnir/src/commands/DraupnirCommands.ts @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2024 Gnuxie +// SPDX-FileCopyrightText: 2026 Catalan Lover // // SPDX-License-Identifier: Apache-2.0 // @@ -43,6 +44,7 @@ import { DraupnirRulesMatchingMembersCommand, } from "./Rules"; import { DraupnirDisplaynameCommand } from "./SetDisplayNameCommand"; +import { DraupnirAvatarCommand } from "./SetAvatarCommand"; import { DraupnirSetPowerLevelCommand } from "./SetPowerLevelCommand"; import { SynapseAdminShutdownRoomCommand } from "./server-admin/ShutdownRoomCommand"; import { DraupnirStatusCommand } from "./StatusCommand"; @@ -127,6 +129,7 @@ const DraupnirCommands = new StandardCommandTable("draupnir") ]) .internCommand(DraupnirSafeModeCommand, ["safe", "mode"]) .internCommand(DraupnirDisplaynameCommand, ["displayname"]) + .internCommand(DraupnirAvatarCommand, ["avatar"]) .internCommand(DraupnirSetPowerLevelCommand, ["powerlevel"]) .internCommand(DraupnirStatusCommand, ["status"]) .internCommand(DraupnirTakedownCommand, ["takedown"]) diff --git a/apps/draupnir/src/commands/SetAvatarCommand.ts b/apps/draupnir/src/commands/SetAvatarCommand.ts new file mode 100644 index 00000000..e48ebe46 --- /dev/null +++ b/apps/draupnir/src/commands/SetAvatarCommand.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: Apache-2.0 + +import { isError, Ok, Result } from "@gnuxie/typescript-result"; +import { + StringPresentationType, + describeCommand, +} from "@the-draupnir-project/interface-manager"; +import { isStringMediaURI } from "@the-draupnir-project/matrix-basic-types"; +import { Draupnir } from "../Draupnir"; +import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; +import { resultifyBotSDKRequestError } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ActionError } from "matrix-protection-suite"; + +export const DraupnirAvatarCommand = describeCommand({ + summary: + "Sets the avatar of the draupnir instance to the specified MXC URI in all rooms.", + parameters: [], + rest: { + name: "avatar url", + acceptor: StringPresentationType, + }, + async executor( + draupnir: Draupnir, + _info, + _keywords, + avatarParts + ): Promise> { + const avatarUrl = avatarParts.join(" ").trim(); + if (!avatarUrl) { + return ActionError.Result("Avatar URL cannot be empty"); + } + if (!isStringMediaURI(avatarUrl)) { + return ActionError.Result( + `Invalid MXC URI format. Expected format: mxc://server/media-id, got: ${avatarUrl}` + ); + } + const setAvatarResult = await draupnir.client + .setAvatarUrl(avatarUrl) + .then((_) => Ok(undefined), resultifyBotSDKRequestError); + if (isError(setAvatarResult)) { + return setAvatarResult.elaborate(`Failed to set avatar to ${avatarUrl}`); + } + return setAvatarResult; + }, +}); + +DraupnirInterfaceAdaptor.describeRenderer(DraupnirAvatarCommand, { + isAlwaysSupposedToUseDefaultRenderer: true, +}); diff --git a/packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.test.ts b/packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.test.ts new file mode 100644 index 00000000..28664bee --- /dev/null +++ b/packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.test.ts @@ -0,0 +1,38 @@ +// Copyright 2026 Catalan Lover +// +// SPDX-License-Identifier: Apache-2.0 + +import { + StringMediaURI, + isStringMediaURI, + MediaURIMediaID, + MediaURIServerName, +} from "./StringMediaURI"; + +test("isStringMediaURI accepts valid MXC URIs", function () { + expect(isStringMediaURI("mxc://matrix.org/abc123")).toBe(true); + expect(isStringMediaURI("mxc://matrix.org:8888/abc123")).toBe(true); + expect(isStringMediaURI("mxc://1.2.3.4/abc123")).toBe(true); + expect(isStringMediaURI("mxc://1.2.3.4:1234/abc123")).toBe(true); + expect(isStringMediaURI("mxc://[1234:5678::abcd]/abc123")).toBe(true); + expect(isStringMediaURI("mxc://[1234:5678::abcd]:5678/abc123")).toBe(true); + expect(isStringMediaURI("mxc://[::1]/abc123")).toBe(true); + expect(isStringMediaURI("mxc://[::1]:8008/abc123")).toBe(true); + expect(isStringMediaURI("mxc://matrix.org/a_b-c123")).toBe(true); +}); + +test("isStringMediaURI rejects invalid MXC URIs", function () { + expect(isStringMediaURI("invalid://matrix.org/abc123")).toBe(false); + expect(isStringMediaURI("mxc://matrix.org")).toBe(false); + expect(isStringMediaURI("mxc://matrix.org/abc 123")).toBe(false); + expect(isStringMediaURI("mxc://example.com~invalid/abc123")).toBe(false); + expect(isStringMediaURI("mxc://matrix.org/abc.def")).toBe(false); + expect(isStringMediaURI("mxc://matrix.org/abc~def")).toBe(false); +}); + +test("StringMediaURI accessors", function () { + const uri = StringMediaURI("mxc://[1234:5678::abcd]:5678/abc123"); + + expect(MediaURIServerName(uri)).toBe("[1234:5678::abcd]:5678"); + expect(MediaURIMediaID(uri)).toBe("abc123"); +}); diff --git a/packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.ts b/packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.ts new file mode 100644 index 00000000..4f9e670c --- /dev/null +++ b/packages/matrix-basic-types/src/StringlyTypedMatrix/StringMediaURI.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: Apache-2.0 + +import { + StringServerName, + StringServerNameRegexPart, +} from "./StringServerName"; + +export const StringMediaURIRegex = new RegExp( + `^mxc://(?${StringServerNameRegexPart.source})/(?[A-Za-z0-9_-]+)$` +); + +export type StringMediaURIBrand = { + readonly StringMediaURI: unique symbol; +}; + +export type StringMediaURI = string & StringMediaURIBrand; + +export function isStringMediaURI(string: string): string is StringMediaURI { + return StringMediaURIRegex.test(string); +} + +export function StringMediaURI(string: unknown): StringMediaURI { + if (typeof string === "string" && isStringMediaURI(string)) { + return string; + } + throw new TypeError("Not a valid StringMediaURI"); +} + +export function MediaURIServerName(uri: StringMediaURI): StringServerName { + const match = StringMediaURIRegex.exec(uri)?.groups?.serverName; + if (match === undefined) { + throw new TypeError( + "Somehow a StringMediaURI was created that is invalid." + ); + } + return match as StringServerName; +} + +export function MediaURIMediaID(uri: StringMediaURI): string { + const match = StringMediaURIRegex.exec(uri)?.groups?.mediaID; + if (match === undefined) { + throw new TypeError( + "Somehow a StringMediaURI was created that is invalid." + ); + } + return match; +} diff --git a/packages/matrix-basic-types/src/StringlyTypedMatrix/index.ts b/packages/matrix-basic-types/src/StringlyTypedMatrix/index.ts index 141a4ad3..3ed619a4 100644 --- a/packages/matrix-basic-types/src/StringlyTypedMatrix/index.ts +++ b/packages/matrix-basic-types/src/StringlyTypedMatrix/index.ts @@ -8,6 +8,7 @@ // export * from "./StringEventID"; +export * from "./StringMediaURI"; export * from "./StringRoomAlias"; export * from "./StringRoomID"; export * from "./StringServerName";