From 268086ed31f08ffebc3232d72766bee0820d6516 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 2 Mar 2026 23:29:05 -0500 Subject: [PATCH] Matrix-js: support profile name/avatar sync from config and CLI --- extensions/matrix-js/src/channel.ts | 14 ++ extensions/matrix-js/src/cli.test.ts | 48 +++++ extensions/matrix-js/src/cli.ts | 172 +++++++++++++++++- extensions/matrix-js/src/config-schema.ts | 1 + extensions/matrix-js/src/matrix/actions.ts | 1 + .../matrix-js/src/matrix/actions/profile.ts | 29 +++ .../matrix-js/src/matrix/config-update.ts | 2 + .../matrix-js/src/matrix/monitor/index.ts | 34 ++++ .../matrix-js/src/matrix/profile.test.ts | 123 +++++++++++++ extensions/matrix-js/src/matrix/profile.ts | 143 +++++++++++++++ extensions/matrix-js/src/matrix/sdk.ts | 8 + extensions/matrix-js/src/types.ts | 2 + 12 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 extensions/matrix-js/src/matrix/actions/profile.ts create mode 100644 extensions/matrix-js/src/matrix/profile.test.ts create mode 100644 extensions/matrix-js/src/matrix/profile.ts diff --git a/extensions/matrix-js/src/channel.ts b/extensions/matrix-js/src/channel.ts index 9f488fd6db2..79cd1a2a530 100644 --- a/extensions/matrix-js/src/channel.ts +++ b/extensions/matrix-js/src/channel.ts @@ -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 = { id: "matrix-js", meta, @@ -112,6 +120,7 @@ export const matrixPlugin: ChannelPlugin = { "accessToken", "password", "deviceName", + "avatarUrl", "initialSyncLimit", ], }), @@ -297,6 +306,10 @@ export const matrixPlugin: ChannelPlugin = { 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 = { accessToken: accessToken || (password ? null : undefined), password: password || (accessToken ? null : undefined), deviceName: input.deviceName?.trim(), + avatarUrl: resolveAvatarInput(input), initialSyncLimit: input.initialSyncLimit, }); }, diff --git a/extensions/matrix-js/src/cli.test.ts b/extensions/matrix-js/src/cli.test.ts index bd0c0d70751..963c55b25b2 100644 --- a/extensions/matrix-js/src/cli.test.ts +++ b/extensions/matrix-js/src/cli.test.ts @@ -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 --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(); diff --git a/extensions/matrix-js/src/cli.ts b/extensions/matrix-js/src/cli.ts index 7b2dd4036c8..2877f900d24 100644 --- a/extensions/matrix-js/src/cli.ts +++ b/extensions/matrix-js/src/cli.ts @@ -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 { + 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 ", "Account ID (default: normalized --name, else default)") .option("--name ", "Optional display name for this account") + .option("--avatar-url ", "Optional Matrix avatar URL (mxc:// or http(s) URL)") .option("--homeserver ", "Matrix homeserver URL") .option("--user-id ", "Matrix user ID") .option("--access-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__* 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 --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 ", "Account ID (for multi-account setups)") + .option("--name ", "Profile display name") + .option("--avatar-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 diff --git a/extensions/matrix-js/src/config-schema.ts b/extensions/matrix-js/src/config-schema.ts index f3113218302..32d2538de56 100644 --- a/extensions/matrix-js/src/config-schema.ts +++ b/extensions/matrix-js/src/config-schema.ts @@ -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(), diff --git a/extensions/matrix-js/src/matrix/actions.ts b/extensions/matrix-js/src/matrix/actions.ts index e5ce92d88f6..2ee53c7c16b 100644 --- a/extensions/matrix-js/src/matrix/actions.ts +++ b/extensions/matrix-js/src/matrix/actions.ts @@ -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, diff --git a/extensions/matrix-js/src/matrix/actions/profile.ts b/extensions/matrix-js/src/matrix/actions/profile.ts new file mode 100644 index 00000000000..1d3f8c924db --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/profile.ts @@ -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 { + 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", + ); +} diff --git a/extensions/matrix-js/src/matrix/config-update.ts b/extensions/matrix-js/src/matrix/config-update.ts index 974c8fff00d..4866b3ea3e0 100644 --- a/extensions/matrix-js/src/matrix/config-update.ts +++ b/extensions/matrix-js/src/matrix/config-update.ts @@ -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) { diff --git a/extensions/matrix-js/src/matrix/monitor/index.ts b/extensions/matrix-js/src/matrix/monitor/index.ts index b175de2ca5a..08de908707f 100644 --- a/extensions/matrix-js/src/matrix/monitor/index.ts +++ b/extensions/matrix-js/src/matrix/monitor/index.ts @@ -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 { diff --git a/extensions/matrix-js/src/matrix/profile.test.ts b/extensions/matrix-js/src/matrix/profile.test.ts new file mode 100644 index 00000000000..a85a96f4e5f --- /dev/null +++ b/extensions/matrix-js/src/matrix/profile.test.ts @@ -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); + }); +}); diff --git a/extensions/matrix-js/src/matrix/profile.ts b/extensions/matrix-js/src/matrix/profile.ts new file mode 100644 index 00000000000..2cee6aa5e2a --- /dev/null +++ b/extensions/matrix-js/src/matrix/profile.ts @@ -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; +}): 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; +}): Promise { + 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, + }; +} diff --git a/extensions/matrix-js/src/matrix/sdk.ts b/extensions/matrix-js/src/matrix/sdk.ts index 497a61e533d..36096cc0fa6 100644 --- a/extensions/matrix-js/src/matrix/sdk.ts +++ b/extensions/matrix-js/src/matrix/sdk.ts @@ -506,6 +506,14 @@ export class MatrixClient { return await this.client.getProfileInfo(userId); } + async setDisplayName(displayName: string): Promise { + await this.client.setDisplayName(displayName); + } + + async setAvatarUrl(avatarUrl: string): Promise { + await this.client.setAvatarUrl(avatarUrl); + } + async joinRoom(roomId: string): Promise { await this.client.joinRoom(roomId); } diff --git a/extensions/matrix-js/src/types.ts b/extensions/matrix-js/src/types.ts index 1d04e63fed8..03b2b06358b 100644 --- a/extensions/matrix-js/src/types.ts +++ b/extensions/matrix-js/src/types.ts @@ -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. */