mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix-js: support profile name/avatar sync from config and CLI
This commit is contained in:
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
extensions/matrix-js/src/matrix/actions/profile.ts
Normal file
29
extensions/matrix-js/src/matrix/actions/profile.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
123
extensions/matrix-js/src/matrix/profile.test.ts
Normal file
123
extensions/matrix-js/src/matrix/profile.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
143
extensions/matrix-js/src/matrix/profile.ts
Normal file
143
extensions/matrix-js/src/matrix/profile.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user