Add Avatar Customisation Commands (#1108)
Tests / Build & Lint (push) Failing after 2m30s
Tests / Unit tests (push) Successful in 2m58s
Tests / Integration tests (push) Failing after 14s
Tests / Application Service Integration tests (push) Failing after 16s
GHCR - Development Branches / ghcr-publish (push) Failing after 13m10s
Docker Hub - Develop / docker-latest (push) Failing after 14m5s

* 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
This commit is contained in:
Catalan Lover
2026-05-01 17:40:10 +02:00
committed by GitHub
parent 60eeb86415
commit f3e29fb3be
11 changed files with 218 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"draupnir": patch
---
Add Avatar customisation command for Draupnir
+5
View File
@@ -0,0 +1,5 @@
---
"draupnir": patch
---
Add avatar customisation command for Appservice mode admin bot
+5
View File
@@ -0,0 +1,5 @@
---
"@the-draupnir-project/matrix-basic-types": patch
---
Add MXC URI validation support.
+3
View File
@@ -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
@@ -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",
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// 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<ActionResult<void>> {
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,
});
@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// 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"])
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// 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<Result<void>> {
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,
});
@@ -0,0 +1,38 @@
// Copyright 2026 Catalan Lover <catalanlover@protonmail.com>
//
// 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");
});
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026 Catalan Lover <catalanlover@protonmail.com>
//
// SPDX-License-Identifier: Apache-2.0
import {
StringServerName,
StringServerNameRegexPart,
} from "./StringServerName";
export const StringMediaURIRegex = new RegExp(
`^mxc://(?<serverName>${StringServerNameRegexPart.source})/(?<mediaID>[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;
}
@@ -8,6 +8,7 @@
// </text>
export * from "./StringEventID";
export * from "./StringMediaURI";
export * from "./StringRoomAlias";
export * from "./StringRoomID";
export * from "./StringServerName";