fix(matrix): move avatar setup into account config (#61437)

Merged via squash.

Prepared head SHA: 4dd887a474
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana 2026-04-05 14:57:44 -04:00 committed by GitHub
parent bcc0e3de2e
commit cac40c01e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 201 additions and 80 deletions

View File

@ -199,6 +199,7 @@ Docs: https://docs.openclaw.ai
- Plugins: suppress trust-warning noise during non-activating snapshot and CLI metadata loads. (#61427) Thanks @gumadeiras.
- Agents/video generation: accept `agents.defaults.videoGenerationModel` in strict config validation and `openclaw config set/get`, so gateways using `video_generate` no longer fail to boot after enabling a video model.
- Discord/image generation: persist volatile workspace-generated media into durable outbound media before final reply delivery so generated image replies stop failing with missing local workspace paths.
- Matrix: move legacy top-level `avatarUrl` into the default account during multi-account promotion and keep env-backed account setup avatar config persisted. (#61437) Thanks @gumadeiras.
## 2026.4.2

View File

@ -607,6 +607,49 @@ describe("matrix CLI verification commands", () => {
);
});
it("forwards --avatar-url through account add setup and profile sync", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"account",
"add",
"--name",
"Ops Bot",
"--homeserver",
"https://matrix.example.org",
"--access-token",
"ops-token",
"--avatar-url",
"mxc://example/ops-avatar",
],
{ from: "user" },
);
expect(matrixSetupApplyAccountConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops-bot",
input: expect.objectContaining({
name: "Ops Bot",
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
avatarUrl: "mxc://example/ops-avatar",
}),
}),
);
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops-bot",
displayName: "Ops Bot",
avatarUrl: "mxc://example/ops-avatar",
}),
);
expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops-bot");
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.ops-bot");
});
it("sets profile name and avatar via profile set command", async () => {
const program = buildProgram();

View File

@ -177,7 +177,7 @@ async function addMatrixAccount(params: {
throw new Error("Matrix account setup is unavailable.");
}
const input: ChannelSetupInput & { avatarUrl?: string } = {
const input: ChannelSetupInput = {
name: params.name,
avatarUrl: params.avatarUrl,
homeserver: params.homeserver,

View File

@ -102,6 +102,7 @@ describe("matrix onboarding", () => {
homeserver: "https://matrix.main.example.org",
userId: "@main:example.org",
accessToken: "main-token",
avatarUrl: "mxc://matrix.main.example.org/main-avatar",
},
},
} as CoreConfig,
@ -117,10 +118,12 @@ describe("matrix onboarding", () => {
expect(result.cfg.channels?.matrix?.homeserver).toBeUndefined();
expect(result.cfg.channels?.matrix?.accessToken).toBeUndefined();
expect(result.cfg.channels?.matrix?.avatarUrl).toBeUndefined();
expect(result.cfg.channels?.matrix?.accounts?.default).toMatchObject({
homeserver: "https://matrix.main.example.org",
userId: "@main:example.org",
accessToken: "main-token",
avatarUrl: "mxc://matrix.main.example.org/main-avatar",
});
expect(result.cfg.channels?.matrix?.accounts?.ops).toMatchObject({
name: "ops",

View File

@ -31,13 +31,13 @@ import {
hasConfiguredSecretInput,
isPrivateOrLoopbackHost,
mergeAllowFromEntries,
moveSingleAccountChannelSectionToDefaultAccount,
normalizeAccountId,
promptChannelAccessConfig,
promptAccountId,
type RuntimeEnv,
type WizardPrompter,
} from "./runtime-api.js";
import { moveSingleMatrixAccountConfigToNamedAccount } from "./setup-config.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
@ -250,10 +250,7 @@ async function runMatrixConfigure(params: {
await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account");
}
if (accountId !== DEFAULT_ACCOUNT_ID) {
next = moveSingleAccountChannelSectionToDefaultAccount({
cfg: next,
channelKey: channel,
}) as CoreConfig;
next = moveSingleMatrixAccountConfigToNamedAccount(next);
}
next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true });
} else {

View File

@ -7,6 +7,12 @@ import {
} from "openclaw/plugin-sdk/setup";
import { resolveMatrixEnvAuthReadiness } from "./matrix/client/env-auth.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { isSupportedMatrixAvatarSource } from "./matrix/profile.js";
import {
matrixNamedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
matrixSingleAccountKeysToMove,
} from "./setup-contract.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
@ -32,48 +38,8 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
"groupAllowFrom",
"defaultTo",
]);
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
"deviceId",
"avatarUrl",
"initialSyncLimit",
"encryption",
"allowlistOnly",
"allowBots",
"blockStreaming",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"ackReaction",
"ackReactionScope",
"reactionNotifications",
"threadBindings",
"startupVerification",
"startupVerificationCooldownHours",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
]);
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set([
// When named accounts already exist, only move auth/bootstrap fields into the
// promoted account. Delivery-policy fields stay at the top level so they
// remain shared inherited defaults for every account.
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"avatarUrl",
"initialSyncLimit",
"encryption",
]);
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set<string>(matrixSingleAccountKeysToMove);
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set<string>(matrixNamedAccountPromotionKeys);
function cloneIfObject<T>(value: T): T {
if (value && typeof value === "object") {
@ -82,7 +48,16 @@ function cloneIfObject<T>(value: T): T {
return value;
}
function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfig {
function resolveSetupAvatarUrl(input: ChannelSetupInput): string | undefined {
const avatarUrl = input.avatarUrl;
if (typeof avatarUrl !== "string") {
return undefined;
}
const trimmed = avatarUrl.trim();
return trimmed || undefined;
}
export function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfig {
const channels = cfg.channels as Record<string, unknown> | undefined;
const baseConfig = channels?.[channel];
const base =
@ -119,23 +94,7 @@ function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfi
return cfg;
}
const defaultAccount =
typeof base.defaultAccount === "string" && base.defaultAccount.trim()
? normalizeAccountId(base.defaultAccount)
: undefined;
const targetAccountId =
defaultAccount && defaultAccount !== DEFAULT_ACCOUNT_ID
? (Object.entries(accounts).find(
([accountId, value]) =>
accountId &&
value &&
typeof value === "object" &&
normalizeAccountId(accountId) === defaultAccount,
)?.[0] ?? DEFAULT_ACCOUNT_ID)
: (defaultAccount ??
(Object.keys(accounts).filter(Boolean).length === 1
? Object.keys(accounts).filter(Boolean)[0]
: DEFAULT_ACCOUNT_ID));
const targetAccountId = resolveSingleAccountPromotionTarget({ channel: base });
const nextAccount: Record<string, unknown> = { ...(accounts[targetAccountId] ?? {}) };
for (const key of keysToMove) {
@ -165,6 +124,10 @@ export function validateMatrixSetupInput(params: {
accountId: string;
input: ChannelSetupInput;
}): string | null {
const avatarUrl = resolveSetupAvatarUrl(params.input);
if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) {
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL.";
}
if (params.input.useEnv) {
const envReadiness = resolveMatrixEnvAuthReadiness(params.accountId, process.env);
return envReadiness.ready ? null : envReadiness.missingMessage;
@ -193,7 +156,6 @@ export function applyMatrixSetupAccountConfig(params: {
cfg: CoreConfig;
accountId: string;
input: ChannelSetupInput;
avatarUrl?: string;
}): CoreConfig {
const normalizedAccountId = normalizeAccountId(params.accountId);
const migratedCfg =
@ -206,6 +168,7 @@ export function applyMatrixSetupAccountConfig(params: {
accountId: normalizedAccountId,
name: params.input.name,
}) as CoreConfig;
const avatarUrl = resolveSetupAvatarUrl(params.input);
if (params.input.useEnv) {
return updateMatrixAccountConfig(next, normalizedAccountId, {
@ -218,6 +181,7 @@ export function applyMatrixSetupAccountConfig(params: {
password: null,
deviceId: null,
deviceName: null,
avatarUrl,
});
}
@ -238,7 +202,7 @@ export function applyMatrixSetupAccountConfig(params: {
accessToken: accessToken || (password ? null : undefined),
password: password || (accessToken ? null : undefined),
deviceName: params.input.deviceName?.trim(),
avatarUrl: params.avatarUrl,
avatarUrl,
initialSyncLimit: params.input.initialSyncLimit,
});
}

View File

@ -1,6 +1,6 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [
export const matrixSingleAccountKeysToMove = [
"deviceId",
"avatarUrl",
"initialSyncLimit",
@ -28,7 +28,7 @@ const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [
"actions",
] as const;
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [
export const matrixNamedAccountPromotionKeys = [
// When named accounts already exist, only move auth/bootstrap fields into the
// promoted account. Shared delivery-policy fields stay at the top level.
"name",
@ -43,8 +43,8 @@ const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [
"encryption",
] as const;
export const singleAccountKeysToMove = [...MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE];
export const namedAccountPromotionKeys = [...MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS];
export const singleAccountKeysToMove = [...matrixSingleAccountKeysToMove];
export const namedAccountPromotionKeys = [...matrixNamedAccountPromotionKeys];
export function resolveSingleAccountPromotionTarget(params: {
channel: Record<string, unknown>;
@ -57,19 +57,19 @@ export function resolveSingleAccountPromotionTarget(params: {
typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim()
? normalizeAccountId(params.channel.defaultAccount)
: undefined;
if (normalizedDefaultAccount) {
if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) {
const matchedAccountId = Object.entries(accounts).find(
const matchedAccountId = normalizedDefaultAccount
? Object.entries(accounts).find(
([accountId, value]) =>
accountId &&
value &&
typeof value === "object" &&
normalizeAccountId(accountId) === normalizedDefaultAccount,
)?.[0];
if (matchedAccountId) {
return matchedAccountId;
}
}
)?.[0]
: undefined;
if (matchedAccountId) {
return matchedAccountId;
}
if (normalizedDefaultAccount) {
return DEFAULT_ACCOUNT_ID;
}
const namedAccounts = Object.entries(accounts).filter(

View File

@ -44,6 +44,52 @@ describe("matrixSetupAdapter", () => {
});
});
it("reuses an existing raw default-account key during promotion", () => {
const cfg = {
channels: {
matrix: {
defaultAccount: "default",
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accessToken: "default-token",
avatarUrl: "mxc://example.org/default-avatar",
accounts: {
Default: {
enabled: true,
deviceName: "Legacy raw key",
},
},
},
},
} as CoreConfig;
const next = matrixSetupAdapter.applyAccountConfig({
cfg,
accountId: "ops",
input: {
name: "Ops",
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
expect(next.channels?.matrix?.accounts?.Default).toMatchObject({
enabled: true,
deviceName: "Legacy raw key",
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accessToken: "default-token",
avatarUrl: "mxc://example.org/default-avatar",
});
expect(next.channels?.matrix?.accounts?.default).toBeUndefined();
expect(next.channels?.matrix?.accounts?.ops).toMatchObject({
name: "Ops",
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
});
});
it("clears stored auth fields when switching an account to env-backed auth", () => {
const cfg = {
channels: {
@ -86,6 +132,40 @@ describe("matrixSetupAdapter", () => {
expect(next.channels?.matrix?.accounts?.ops?.deviceName).toBeUndefined();
});
it("keeps avatarUrl when switching an account to env-backed auth", () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
name: "Ops",
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const next = matrixSetupAdapter.applyAccountConfig({
cfg,
accountId: "ops",
input: {
name: "Ops",
useEnv: true,
avatarUrl: " mxc://example.org/ops-avatar ",
},
}) as CoreConfig;
expect(next.channels?.matrix?.accounts?.ops).toMatchObject({
name: "Ops",
enabled: true,
avatarUrl: "mxc://example.org/ops-avatar",
});
expect(next.channels?.matrix?.accounts?.ops?.homeserver).toBeUndefined();
expect(next.channels?.matrix?.accounts?.ops?.accessToken).toBeUndefined();
});
it("stores proxy in account setup updates", () => {
const next = matrixSetupAdapter.applyAccountConfig({
cfg: {} as CoreConfig,
@ -105,6 +185,38 @@ describe("matrixSetupAdapter", () => {
});
});
it("stores avatarUrl from setup input on the target account", () => {
const next = matrixSetupAdapter.applyAccountConfig({
cfg: {} as CoreConfig,
accountId: "ops",
input: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
avatarUrl: " mxc://example.org/ops-avatar ",
},
}) as CoreConfig;
expect(next.channels?.matrix?.accounts?.ops).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
avatarUrl: "mxc://example.org/ops-avatar",
});
});
it("rejects unsupported avatar URL schemes during setup validation", () => {
const validationError = matrixSetupAdapter.validateInput?.({
accountId: "ops",
input: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
avatarUrl: "file:///tmp/avatar.png",
},
});
expect(validationError).toBe("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
});
it("stores canonical dangerous private-network opt-in from setup input", () => {
const next = matrixSetupAdapter.applyAccountConfig({
cfg: {} as CoreConfig,

View File

@ -92,6 +92,7 @@ export type ChannelSetupInput = {
accessToken?: string;
password?: string;
deviceName?: string;
avatarUrl?: string;
initialSyncLimit?: number;
ship?: string;
url?: string;