Skip to content

Commit cac40c0

Browse files
authored
fix(matrix): move avatar setup into account config (openclaw#61437)
Merged via squash. Prepared head SHA: 4dd887a Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent bcc0e3d commit cac40c0

9 files changed

Lines changed: 201 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ Docs: https://docs.openclaw.ai
199199
- Plugins: suppress trust-warning noise during non-activating snapshot and CLI metadata loads. (#61427) Thanks @gumadeiras.
200200
- 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.
201201
- 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.
202+
- 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.
202203

203204
## 2026.4.2
204205

extensions/matrix/src/cli.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,49 @@ describe("matrix CLI verification commands", () => {
607607
);
608608
});
609609

610+
it("forwards --avatar-url through account add setup and profile sync", async () => {
611+
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
612+
const program = buildProgram();
613+
614+
await program.parseAsync(
615+
[
616+
"matrix",
617+
"account",
618+
"add",
619+
"--name",
620+
"Ops Bot",
621+
"--homeserver",
622+
"https://matrix.example.org",
623+
"--access-token",
624+
"ops-token",
625+
"--avatar-url",
626+
"mxc://example/ops-avatar",
627+
],
628+
{ from: "user" },
629+
);
630+
631+
expect(matrixSetupApplyAccountConfigMock).toHaveBeenCalledWith(
632+
expect.objectContaining({
633+
accountId: "ops-bot",
634+
input: expect.objectContaining({
635+
name: "Ops Bot",
636+
homeserver: "https://matrix.example.org",
637+
accessToken: "ops-token",
638+
avatarUrl: "mxc://example/ops-avatar",
639+
}),
640+
}),
641+
);
642+
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
643+
expect.objectContaining({
644+
accountId: "ops-bot",
645+
displayName: "Ops Bot",
646+
avatarUrl: "mxc://example/ops-avatar",
647+
}),
648+
);
649+
expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops-bot");
650+
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.ops-bot");
651+
});
652+
610653
it("sets profile name and avatar via profile set command", async () => {
611654
const program = buildProgram();
612655

extensions/matrix/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ async function addMatrixAccount(params: {
177177
throw new Error("Matrix account setup is unavailable.");
178178
}
179179

180-
const input: ChannelSetupInput & { avatarUrl?: string } = {
180+
const input: ChannelSetupInput = {
181181
name: params.name,
182182
avatarUrl: params.avatarUrl,
183183
homeserver: params.homeserver,

extensions/matrix/src/onboarding.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ describe("matrix onboarding", () => {
102102
homeserver: "https://matrix.main.example.org",
103103
userId: "@main:example.org",
104104
accessToken: "main-token",
105+
avatarUrl: "mxc://matrix.main.example.org/main-avatar",
105106
},
106107
},
107108
} as CoreConfig,
@@ -117,10 +118,12 @@ describe("matrix onboarding", () => {
117118

118119
expect(result.cfg.channels?.matrix?.homeserver).toBeUndefined();
119120
expect(result.cfg.channels?.matrix?.accessToken).toBeUndefined();
121+
expect(result.cfg.channels?.matrix?.avatarUrl).toBeUndefined();
120122
expect(result.cfg.channels?.matrix?.accounts?.default).toMatchObject({
121123
homeserver: "https://matrix.main.example.org",
122124
userId: "@main:example.org",
123125
accessToken: "main-token",
126+
avatarUrl: "mxc://matrix.main.example.org/main-avatar",
124127
});
125128
expect(result.cfg.channels?.matrix?.accounts?.ops).toMatchObject({
126129
name: "ops",

extensions/matrix/src/onboarding.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ import {
3131
hasConfiguredSecretInput,
3232
isPrivateOrLoopbackHost,
3333
mergeAllowFromEntries,
34-
moveSingleAccountChannelSectionToDefaultAccount,
3534
normalizeAccountId,
3635
promptChannelAccessConfig,
3736
promptAccountId,
3837
type RuntimeEnv,
3938
type WizardPrompter,
4039
} from "./runtime-api.js";
40+
import { moveSingleMatrixAccountConfigToNamedAccount } from "./setup-config.js";
4141
import type { CoreConfig } from "./types.js";
4242

4343
const channel = "matrix" as const;
@@ -250,10 +250,7 @@ async function runMatrixConfigure(params: {
250250
await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account");
251251
}
252252
if (accountId !== DEFAULT_ACCOUNT_ID) {
253-
next = moveSingleAccountChannelSectionToDefaultAccount({
254-
cfg: next,
255-
channelKey: channel,
256-
}) as CoreConfig;
253+
next = moveSingleMatrixAccountConfigToNamedAccount(next);
257254
}
258255
next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true });
259256
} else {

extensions/matrix/src/setup-config.ts

Lines changed: 26 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import {
77
} from "openclaw/plugin-sdk/setup";
88
import { resolveMatrixEnvAuthReadiness } from "./matrix/client/env-auth.js";
99
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
10+
import { isSupportedMatrixAvatarSource } from "./matrix/profile.js";
11+
import {
12+
matrixNamedAccountPromotionKeys,
13+
resolveSingleAccountPromotionTarget,
14+
matrixSingleAccountKeysToMove,
15+
} from "./setup-contract.js";
1016
import type { CoreConfig } from "./types.js";
1117

1218
const channel = "matrix" as const;
@@ -32,48 +38,8 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
3238
"groupAllowFrom",
3339
"defaultTo",
3440
]);
35-
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
36-
"deviceId",
37-
"avatarUrl",
38-
"initialSyncLimit",
39-
"encryption",
40-
"allowlistOnly",
41-
"allowBots",
42-
"blockStreaming",
43-
"replyToMode",
44-
"threadReplies",
45-
"textChunkLimit",
46-
"chunkMode",
47-
"responsePrefix",
48-
"ackReaction",
49-
"ackReactionScope",
50-
"reactionNotifications",
51-
"threadBindings",
52-
"startupVerification",
53-
"startupVerificationCooldownHours",
54-
"mediaMaxMb",
55-
"autoJoin",
56-
"autoJoinAllowlist",
57-
"dm",
58-
"groups",
59-
"rooms",
60-
"actions",
61-
]);
62-
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set([
63-
// When named accounts already exist, only move auth/bootstrap fields into the
64-
// promoted account. Delivery-policy fields stay at the top level so they
65-
// remain shared inherited defaults for every account.
66-
"name",
67-
"homeserver",
68-
"userId",
69-
"accessToken",
70-
"password",
71-
"deviceId",
72-
"deviceName",
73-
"avatarUrl",
74-
"initialSyncLimit",
75-
"encryption",
76-
]);
41+
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set<string>(matrixSingleAccountKeysToMove);
42+
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set<string>(matrixNamedAccountPromotionKeys);
7743

7844
function cloneIfObject<T>(value: T): T {
7945
if (value && typeof value === "object") {
@@ -82,7 +48,16 @@ function cloneIfObject<T>(value: T): T {
8248
return value;
8349
}
8450

85-
function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfig {
51+
function resolveSetupAvatarUrl(input: ChannelSetupInput): string | undefined {
52+
const avatarUrl = input.avatarUrl;
53+
if (typeof avatarUrl !== "string") {
54+
return undefined;
55+
}
56+
const trimmed = avatarUrl.trim();
57+
return trimmed || undefined;
58+
}
59+
60+
export function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfig {
8661
const channels = cfg.channels as Record<string, unknown> | undefined;
8762
const baseConfig = channels?.[channel];
8863
const base =
@@ -119,23 +94,7 @@ function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfi
11994
return cfg;
12095
}
12196

122-
const defaultAccount =
123-
typeof base.defaultAccount === "string" && base.defaultAccount.trim()
124-
? normalizeAccountId(base.defaultAccount)
125-
: undefined;
126-
const targetAccountId =
127-
defaultAccount && defaultAccount !== DEFAULT_ACCOUNT_ID
128-
? (Object.entries(accounts).find(
129-
([accountId, value]) =>
130-
accountId &&
131-
value &&
132-
typeof value === "object" &&
133-
normalizeAccountId(accountId) === defaultAccount,
134-
)?.[0] ?? DEFAULT_ACCOUNT_ID)
135-
: (defaultAccount ??
136-
(Object.keys(accounts).filter(Boolean).length === 1
137-
? Object.keys(accounts).filter(Boolean)[0]
138-
: DEFAULT_ACCOUNT_ID));
97+
const targetAccountId = resolveSingleAccountPromotionTarget({ channel: base });
13998

14099
const nextAccount: Record<string, unknown> = { ...(accounts[targetAccountId] ?? {}) };
141100
for (const key of keysToMove) {
@@ -165,6 +124,10 @@ export function validateMatrixSetupInput(params: {
165124
accountId: string;
166125
input: ChannelSetupInput;
167126
}): string | null {
127+
const avatarUrl = resolveSetupAvatarUrl(params.input);
128+
if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) {
129+
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL.";
130+
}
168131
if (params.input.useEnv) {
169132
const envReadiness = resolveMatrixEnvAuthReadiness(params.accountId, process.env);
170133
return envReadiness.ready ? null : envReadiness.missingMessage;
@@ -193,7 +156,6 @@ export function applyMatrixSetupAccountConfig(params: {
193156
cfg: CoreConfig;
194157
accountId: string;
195158
input: ChannelSetupInput;
196-
avatarUrl?: string;
197159
}): CoreConfig {
198160
const normalizedAccountId = normalizeAccountId(params.accountId);
199161
const migratedCfg =
@@ -206,6 +168,7 @@ export function applyMatrixSetupAccountConfig(params: {
206168
accountId: normalizedAccountId,
207169
name: params.input.name,
208170
}) as CoreConfig;
171+
const avatarUrl = resolveSetupAvatarUrl(params.input);
209172

210173
if (params.input.useEnv) {
211174
return updateMatrixAccountConfig(next, normalizedAccountId, {
@@ -218,6 +181,7 @@ export function applyMatrixSetupAccountConfig(params: {
218181
password: null,
219182
deviceId: null,
220183
deviceName: null,
184+
avatarUrl,
221185
});
222186
}
223187

@@ -238,7 +202,7 @@ export function applyMatrixSetupAccountConfig(params: {
238202
accessToken: accessToken || (password ? null : undefined),
239203
password: password || (accessToken ? null : undefined),
240204
deviceName: params.input.deviceName?.trim(),
241-
avatarUrl: params.avatarUrl,
205+
avatarUrl,
242206
initialSyncLimit: params.input.initialSyncLimit,
243207
});
244208
}

extensions/matrix/src/setup-contract.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
22

3-
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [
3+
export const matrixSingleAccountKeysToMove = [
44
"deviceId",
55
"avatarUrl",
66
"initialSyncLimit",
@@ -28,7 +28,7 @@ const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [
2828
"actions",
2929
] as const;
3030

31-
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [
31+
export const matrixNamedAccountPromotionKeys = [
3232
// When named accounts already exist, only move auth/bootstrap fields into the
3333
// promoted account. Shared delivery-policy fields stay at the top level.
3434
"name",
@@ -43,8 +43,8 @@ const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [
4343
"encryption",
4444
] as const;
4545

46-
export const singleAccountKeysToMove = [...MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE];
47-
export const namedAccountPromotionKeys = [...MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS];
46+
export const singleAccountKeysToMove = [...matrixSingleAccountKeysToMove];
47+
export const namedAccountPromotionKeys = [...matrixNamedAccountPromotionKeys];
4848

4949
export function resolveSingleAccountPromotionTarget(params: {
5050
channel: Record<string, unknown>;
@@ -57,19 +57,19 @@ export function resolveSingleAccountPromotionTarget(params: {
5757
typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim()
5858
? normalizeAccountId(params.channel.defaultAccount)
5959
: undefined;
60-
if (normalizedDefaultAccount) {
61-
if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) {
62-
const matchedAccountId = Object.entries(accounts).find(
60+
const matchedAccountId = normalizedDefaultAccount
61+
? Object.entries(accounts).find(
6362
([accountId, value]) =>
6463
accountId &&
6564
value &&
6665
typeof value === "object" &&
6766
normalizeAccountId(accountId) === normalizedDefaultAccount,
68-
)?.[0];
69-
if (matchedAccountId) {
70-
return matchedAccountId;
71-
}
72-
}
67+
)?.[0]
68+
: undefined;
69+
if (matchedAccountId) {
70+
return matchedAccountId;
71+
}
72+
if (normalizedDefaultAccount) {
7373
return DEFAULT_ACCOUNT_ID;
7474
}
7575
const namedAccounts = Object.entries(accounts).filter(

0 commit comments

Comments
 (0)