mirror of https://github.com/openclaw/openclaw.git
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:
parent
bcc0e3de2e
commit
cac40c01e9
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export type ChannelSetupInput = {
|
|||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
avatarUrl?: string;
|
||||
initialSyncLimit?: number;
|
||||
ship?: string;
|
||||
url?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue