Matrix-js: support profile name/avatar sync from config and CLI

This commit is contained in:
Gustavo Madeira Santana
2026-03-02 23:29:05 -05:00
parent c9ae52dd0f
commit 268086ed31
12 changed files with 576 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelSetupInput,
type ChannelPlugin,
} from "openclaw/plugin-sdk";
import { matrixMessageActions } from "./actions.js";
@@ -36,6 +37,7 @@ import {
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { probeMatrix } from "./matrix/probe.js";
import { isSupportedMatrixAvatarSource } from "./matrix/profile.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
@@ -69,6 +71,12 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined {
return stripped || undefined;
}
function resolveAvatarInput(input: ChannelSetupInput): string | undefined {
const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl;
const trimmed = avatarUrl?.trim();
return trimmed ? trimmed : undefined;
}
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix-js",
meta,
@@ -112,6 +120,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
"accessToken",
"password",
"deviceName",
"avatarUrl",
"initialSyncLimit",
],
}),
@@ -297,6 +306,10 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
alwaysUseAccounts: true,
}),
validateInput: ({ accountId, input }) => {
const avatarUrl = resolveAvatarInput(input);
if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) {
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL";
}
if (input.useEnv) {
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
@@ -355,6 +368,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
accessToken: accessToken || (password ? null : undefined),
password: password || (accessToken ? null : undefined),
deviceName: input.deviceName?.trim(),
avatarUrl: resolveAvatarInput(input),
initialSyncLimit: input.initialSyncLimit,
});
},

View File

@@ -11,6 +11,7 @@ const matrixRuntimeLoadConfigMock = vi.fn();
const matrixRuntimeWriteConfigFileMock = vi.fn();
const restoreMatrixRoomKeyBackupMock = vi.fn();
const setMatrixSdkLogModeMock = vi.fn();
const updateMatrixOwnProfileMock = vi.fn();
const verifyMatrixRecoveryKeyMock = vi.fn();
vi.mock("./matrix/actions/verification.js", () => ({
@@ -25,6 +26,10 @@ vi.mock("./matrix/client/logging.js", () => ({
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
}));
vi.mock("./matrix/actions/profile.js", () => ({
updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args),
}));
vi.mock("./channel.js", () => ({
matrixPlugin: {
setup: {
@@ -67,6 +72,13 @@ describe("matrix-js CLI verification commands", () => {
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
matrixRuntimeLoadConfigMock.mockReturnValue({});
matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined);
updateMatrixOwnProfileMock.mockResolvedValue({
skipped: false,
displayNameUpdated: true,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
});
});
afterEach(() => {
@@ -222,11 +234,47 @@ describe("matrix-js CLI verification commands", () => {
);
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: main-bot");
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.main-bot");
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main-bot",
displayName: "Main Bot",
}),
);
expect(console.log).toHaveBeenCalledWith(
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:main-bot",
);
});
it("sets profile name and avatar via profile set command", async () => {
const program = buildProgram();
await program.parseAsync(
[
"matrix-js",
"profile",
"set",
"--account",
"alerts",
"--name",
"Alerts Bot",
"--avatar-url",
"mxc://example/avatar",
],
{ from: "user" },
);
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "alerts",
displayName: "Alerts Bot",
avatarUrl: "mxc://example/avatar",
}),
);
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith("Account: alerts");
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.alerts");
});
it("returns JSON errors for invalid account setup input", async () => {
matrixSetupValidateInputMock.mockReturnValue("Matrix requires --homeserver");
const program = buildProgram();

View File

@@ -5,6 +5,7 @@ import {
type ChannelSetupInput,
} from "openclaw/plugin-sdk";
import { matrixPlugin } from "./channel.js";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import {
bootstrapMatrixVerification,
getMatrixRoomKeyBackupStatus,
@@ -13,6 +14,7 @@ import {
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { getMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
@@ -83,11 +85,20 @@ type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
useEnv: boolean;
profile: {
attempted: boolean;
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
error?: string;
};
};
async function addMatrixJsAccount(params: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
@@ -103,8 +114,9 @@ async function addMatrixJsAccount(params: {
throw new Error("Matrix-js account setup is unavailable.");
}
const input: ChannelSetupInput = {
const input: ChannelSetupInput & { avatarUrl?: string } = {
name: params.name,
avatarUrl: params.avatarUrl,
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
@@ -136,10 +148,111 @@ async function addMatrixJsAccount(params: {
}) as CoreConfig;
await runtime.config.writeConfigFile(updated as never);
const desiredDisplayName = input.name?.trim();
const desiredAvatarUrl = input.avatarUrl?.trim();
let profile: MatrixCliAccountAddResult["profile"] = {
attempted: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
};
if (desiredDisplayName || desiredAvatarUrl) {
try {
const synced = await updateMatrixOwnProfile({
accountId,
displayName: desiredDisplayName,
avatarUrl: desiredAvatarUrl,
});
let resolvedAvatarUrl = synced.resolvedAvatarUrl;
if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) {
const latestCfg = runtime.config.loadConfig() as CoreConfig;
const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, {
avatarUrl: synced.resolvedAvatarUrl,
});
await runtime.config.writeConfigFile(withAvatar as never);
resolvedAvatarUrl = synced.resolvedAvatarUrl;
}
profile = {
attempted: true,
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
};
} catch (err) {
profile = {
attempted: true,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
error: toErrorMessage(err),
};
}
}
return {
accountId,
configPath: `channels.matrix-js.accounts.${accountId}`,
useEnv: input.useEnv === true,
profile,
};
}
type MatrixCliProfileSetResult = {
accountId: string;
displayName: string | null;
avatarUrl: string | null;
profile: {
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
configPath: string;
};
async function setMatrixJsProfile(params: {
account?: string;
name?: string;
avatarUrl?: string;
}): Promise<MatrixCliProfileSetResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const accountId = normalizeAccountId(params.account);
const displayName = params.name?.trim() || null;
const avatarUrl = params.avatarUrl?.trim() || null;
if (!displayName && !avatarUrl) {
throw new Error("Provide --name and/or --avatar-url.");
}
const synced = await updateMatrixOwnProfile({
accountId,
displayName: displayName ?? undefined,
avatarUrl: avatarUrl ?? undefined,
});
const persistedAvatarUrl =
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
? synced.resolvedAvatarUrl
: avatarUrl;
const updated = updateMatrixAccountConfig(cfg, accountId, {
name: displayName ?? undefined,
avatarUrl: persistedAvatarUrl ?? undefined,
});
await runtime.config.writeConfigFile(updated as never);
return {
accountId,
displayName,
avatarUrl: persistedAvatarUrl ?? null,
profile: {
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl: synced.resolvedAvatarUrl,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
},
configPath: `channels.matrix-js.accounts.${accountId}`,
};
}
@@ -445,6 +558,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
.description("Add or update a matrix-js account (wrapper around channel setup)")
.option("--account <id>", "Account ID (default: normalized --name, else default)")
.option("--name <name>", "Optional display name for this account")
.option("--avatar-url <url>", "Optional Matrix avatar URL (mxc:// or http(s) URL)")
.option("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")
@@ -461,6 +575,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
@@ -478,6 +593,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
await addMatrixJsAccount({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
homeserver: options.homeserver,
userId: options.userId,
accessToken: options.accessToken,
@@ -492,6 +608,18 @@ export function registerMatrixJsCli(params: { program: Command }): void {
console.log(
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
);
if (result.profile.attempted) {
if (result.profile.error) {
console.error(`Profile sync warning: ${result.profile.error}`);
} else {
console.log(
`Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) {
console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`);
}
}
}
const bindHint = `openclaw agents bind --agent <id> --bind matrix-js:${result.accountId}`;
console.log(`Bind this account to an agent: ${bindHint}`);
},
@@ -500,6 +628,48 @@ export function registerMatrixJsCli(params: { program: Command }): void {
},
);
const profile = root.command("profile").description("Manage Matrix-js bot profile");
profile
.command("set")
.description("Update Matrix profile display name and/or avatar")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--name <name>", "Profile display name")
.option("--avatar-url <url>", "Profile avatar URL (mxc:// or http(s) URL)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await setMatrixJsProfile({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
}),
onText: (result) => {
printAccountLabel(result.accountId);
console.log(`Config path: ${result.configPath}`);
console.log(
`Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.avatarUrl) {
console.log(`Avatar converted and saved as: ${result.avatarUrl}`);
}
},
errorPrefix: "Profile update failed",
});
},
);
const verify = root.command("verify").description("Device verification for Matrix E2EE");
verify

View File

@@ -45,6 +45,7 @@ export const MatrixConfigSchema = z.object({
password: z.string().optional(),
deviceId: z.string().optional(),
deviceName: z.string().optional(),
avatarUrl: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),

View File

@@ -12,6 +12,7 @@ export {
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
export { updateMatrixOwnProfile } from "./actions/profile.js";
export {
bootstrapMatrixVerification,
acceptMatrixVerification,

View File

@@ -0,0 +1,29 @@
import { getMatrixRuntime } from "../../runtime.js";
import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js";
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
export async function updateMatrixOwnProfile(
opts: MatrixActionClientOpts & {
displayName?: string;
avatarUrl?: string;
} = {},
): Promise<MatrixProfileSyncResult> {
const displayName = opts.displayName?.trim();
const avatarUrl = opts.avatarUrl?.trim();
const runtime = getMatrixRuntime();
return await withResolvedActionClient(
opts,
async (client) => {
const userId = await client.getUserId();
return await syncMatrixOwnProfile({
client,
userId,
displayName: displayName || undefined,
avatarUrl: avatarUrl || undefined,
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
});
},
"persist",
);
}

View File

@@ -9,6 +9,7 @@ export type MatrixAccountPatch = {
accessToken?: string | null;
password?: string | null;
deviceName?: string | null;
avatarUrl?: string | null;
encryption?: boolean | null;
initialSyncLimit?: number | null;
};
@@ -66,6 +67,7 @@ export function updateMatrixAccountConfig(
applyNullableStringField(nextAccount, "accessToken", patch.accessToken);
applyNullableStringField(nextAccount, "password", patch.password);
applyNullableStringField(nextAccount, "deviceName", patch.deviceName);
applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl);
if (patch.initialSyncLimit !== undefined) {
if (patch.initialSyncLimit === null) {

View File

@@ -19,6 +19,8 @@ import {
resolveSharedMatrixClient,
stopSharedClientForAccount,
} from "../client.js";
import { updateMatrixAccountConfig } from "../config-update.js";
import { syncMatrixOwnProfile } from "../profile.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { createDirectRoomTracker } from "./direct.js";
@@ -329,6 +331,38 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
try {
const profileSync = await syncMatrixOwnProfile({
client,
userId: auth.userId,
displayName: accountConfig.name,
avatarUrl: accountConfig.avatarUrl,
loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes),
});
if (profileSync.displayNameUpdated) {
logger.info(`matrix: profile display name updated for ${auth.userId}`);
}
if (profileSync.avatarUpdated) {
logger.info(`matrix: profile avatar updated for ${auth.userId}`);
}
if (
profileSync.convertedAvatarFromHttp &&
profileSync.resolvedAvatarUrl &&
accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl
) {
const latestCfg = core.config.loadConfig() as CoreConfig;
const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, {
avatarUrl: profileSync.resolvedAvatarUrl,
});
await core.config.writeConfigFile(updatedCfg as never);
logVerboseMessage(
`matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`,
);
}
} catch (err) {
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
}
// If E2EE is enabled, report device verification status and guidance.
if (auth.encryption && client.crypto) {
try {

View File

@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import {
isSupportedMatrixAvatarSource,
syncMatrixOwnProfile,
type MatrixProfileSyncResult,
} from "./profile.js";
function createClientStub() {
return {
getUserProfile: vi.fn(async () => ({})),
setDisplayName: vi.fn(async () => {}),
setAvatarUrl: vi.fn(async () => {}),
uploadContent: vi.fn(async () => "mxc://example/avatar"),
};
}
function expectNoUpdates(result: MatrixProfileSyncResult) {
expect(result.displayNameUpdated).toBe(false);
expect(result.avatarUpdated).toBe(false);
}
describe("matrix profile sync", () => {
it("skips when no desired profile values are provided", async () => {
const client = createClientStub();
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
});
expect(result.skipped).toBe(true);
expectNoUpdates(result);
expect(client.setDisplayName).not.toHaveBeenCalled();
expect(client.setAvatarUrl).not.toHaveBeenCalled();
});
it("updates display name when desired name differs", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Old Name",
avatar_url: "mxc://example/existing",
});
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
displayName: "New Name",
});
expect(result.skipped).toBe(false);
expect(result.displayNameUpdated).toBe(true);
expect(result.avatarUpdated).toBe(false);
expect(client.setDisplayName).toHaveBeenCalledWith("New Name");
});
it("does not update when name and avatar already match", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Bot",
avatar_url: "mxc://example/avatar",
});
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
displayName: "Bot",
avatarUrl: "mxc://example/avatar",
});
expect(result.skipped).toBe(false);
expectNoUpdates(result);
expect(client.setDisplayName).not.toHaveBeenCalled();
expect(client.setAvatarUrl).not.toHaveBeenCalled();
});
it("converts http avatar URL by uploading and then updates profile avatar", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Bot",
avatar_url: "mxc://example/old",
});
client.uploadContent.mockResolvedValue("mxc://example/new-avatar");
const loadAvatarFromUrl = vi.fn(async () => ({
buffer: Buffer.from("avatar-bytes"),
contentType: "image/png",
fileName: "avatar.png",
}));
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
avatarUrl: "https://cdn.example.org/avatar.png",
loadAvatarFromUrl,
});
expect(result.convertedAvatarFromHttp).toBe(true);
expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar");
expect(result.avatarUpdated).toBe(true);
expect(loadAvatarFromUrl).toHaveBeenCalledWith(
"https://cdn.example.org/avatar.png",
10 * 1024 * 1024,
);
expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar");
});
it("rejects unsupported avatar URL schemes", async () => {
const client = createClientStub();
await expect(
syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
avatarUrl: "file:///tmp/avatar.png",
}),
).rejects.toThrow("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
});
it("recognizes supported avatar sources", () => {
expect(isSupportedMatrixAvatarSource("mxc://example/avatar")).toBe(true);
expect(isSupportedMatrixAvatarSource("https://example.org/avatar.png")).toBe(true);
expect(isSupportedMatrixAvatarSource("http://example.org/avatar.png")).toBe(true);
expect(isSupportedMatrixAvatarSource("ftp://example.org/avatar.png")).toBe(false);
});
});

View File

@@ -0,0 +1,143 @@
import type { MatrixClient } from "./sdk.js";
export const MATRIX_PROFILE_AVATAR_MAX_BYTES = 10 * 1024 * 1024;
type MatrixProfileClient = Pick<
MatrixClient,
"getUserProfile" | "setDisplayName" | "setAvatarUrl" | "uploadContent"
>;
type MatrixProfileLoadResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
export type MatrixProfileSyncResult = {
skipped: boolean;
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
function normalizeOptionalText(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function isMatrixMxcUri(value: string): boolean {
return value.trim().toLowerCase().startsWith("mxc://");
}
export function isMatrixHttpAvatarUri(value: string): boolean {
const normalized = value.trim().toLowerCase();
return normalized.startsWith("https://") || normalized.startsWith("http://");
}
export function isSupportedMatrixAvatarSource(value: string): boolean {
return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value);
}
async function resolveAvatarUrl(params: {
client: MatrixProfileClient;
avatarUrl: string | null;
avatarMaxBytes: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<{ resolvedAvatarUrl: string | null; convertedAvatarFromHttp: boolean }> {
const avatarUrl = normalizeOptionalText(params.avatarUrl);
if (!avatarUrl) {
return {
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
};
}
if (isMatrixMxcUri(avatarUrl)) {
return {
resolvedAvatarUrl: avatarUrl,
convertedAvatarFromHttp: false,
};
}
if (!isMatrixHttpAvatarUri(avatarUrl)) {
throw new Error("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
}
if (!params.loadAvatarFromUrl) {
throw new Error("Matrix avatar URL conversion requires a media loader.");
}
const media = await params.loadAvatarFromUrl(avatarUrl, params.avatarMaxBytes);
const uploadedMxc = await params.client.uploadContent(
media.buffer,
media.contentType,
media.fileName || "avatar",
);
return {
resolvedAvatarUrl: uploadedMxc,
convertedAvatarFromHttp: true,
};
}
export async function syncMatrixOwnProfile(params: {
client: MatrixProfileClient;
userId: string;
displayName?: string | null;
avatarUrl?: string | null;
avatarMaxBytes?: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<MatrixProfileSyncResult> {
const desiredDisplayName = normalizeOptionalText(params.displayName);
const avatar = await resolveAvatarUrl({
client: params.client,
avatarUrl: params.avatarUrl ?? null,
avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES,
loadAvatarFromUrl: params.loadAvatarFromUrl,
});
const desiredAvatarUrl = avatar.resolvedAvatarUrl;
if (!desiredDisplayName && !desiredAvatarUrl) {
return {
skipped: true,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}
let currentDisplayName: string | undefined;
let currentAvatarUrl: string | undefined;
try {
const currentProfile = await params.client.getUserProfile(params.userId);
currentDisplayName = normalizeOptionalText(currentProfile.displayname) ?? undefined;
currentAvatarUrl = normalizeOptionalText(currentProfile.avatar_url) ?? undefined;
} catch {
// If profile fetch fails, attempt writes directly.
}
let displayNameUpdated = false;
let avatarUpdated = false;
if (desiredDisplayName && currentDisplayName !== desiredDisplayName) {
await params.client.setDisplayName(desiredDisplayName);
displayNameUpdated = true;
}
if (desiredAvatarUrl && currentAvatarUrl !== desiredAvatarUrl) {
await params.client.setAvatarUrl(desiredAvatarUrl);
avatarUpdated = true;
}
return {
skipped: false,
displayNameUpdated,
avatarUpdated,
resolvedAvatarUrl: desiredAvatarUrl,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}

View File

@@ -506,6 +506,14 @@ export class MatrixClient {
return await this.client.getProfileInfo(userId);
}
async setDisplayName(displayName: string): Promise<void> {
await this.client.setDisplayName(displayName);
}
async setAvatarUrl(avatarUrl: string): Promise<void> {
await this.client.setAvatarUrl(avatarUrl);
}
async joinRoom(roomId: string): Promise<void> {
await this.client.joinRoom(roomId);
}

View File

@@ -62,6 +62,8 @@ export type MatrixConfig = {
deviceId?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Optional desired Matrix avatar source (mxc:// or http(s) URL). */
avatarUrl?: string;
/** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */