Matrix: tighten verification trust and expose profile updates

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 22:39:37 -04:00
parent e5fbedf012
commit 6407cc9d2d
21 changed files with 453 additions and 78 deletions

View File

@@ -82,4 +82,27 @@ describe("matrixMessageActions account propagation", () => {
expect.any(Object),
);
});
it("forwards accountId for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "set-profile",
accountId: "ops",
params: {
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "setProfile",
accountId: "ops",
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
}),
expect.any(Object),
);
});
});

View File

@@ -66,4 +66,16 @@ describe("matrixMessageActions", () => {
expect(supportsAction!({ action: "poll" } as never)).toBe(false);
expect(supportsAction!({ action: "poll-vote" } as never)).toBe(true);
});
it("exposes and handles self-profile updates", () => {
const listActions = matrixMessageActions.listActions;
const supportsAction = matrixMessageActions.supportsAction;
const actions = listActions!({
cfg: createConfiguredMatrixConfig(),
} as never);
expect(actions).toContain("set-profile");
expect(supportsAction!({ action: "set-profile" } as never)).toBe(true);
});
});

View File

@@ -22,6 +22,7 @@ const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
"pin",
"unpin",
"list-pins",
"set-profile",
"member-info",
"channel-info",
"permissions",
@@ -53,6 +54,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
actions.add("unpin");
actions.add("list-pins");
}
if (gate("profile")) {
actions.add("set-profile");
}
if (gate("memberInfo")) {
actions.add("member-info");
}
@@ -184,6 +188,14 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
});
}
if (action === "set-profile") {
return await dispatch({
action: "setProfile",
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
avatarUrl: readStringParam(params, "avatarUrl"),
});
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await dispatch({

View File

@@ -310,6 +310,9 @@ describe("matrix CLI verification commands", () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
@@ -332,6 +335,8 @@ describe("matrix CLI verification commands", () => {
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
);
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
expect(console.log).toHaveBeenCalledWith("Locally trusted: yes");
expect(console.log).toHaveBeenCalledWith("Signed by owner: yes");
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
});
@@ -413,6 +418,9 @@ describe("matrix CLI verification commands", () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
@@ -444,6 +452,9 @@ describe("matrix CLI verification commands", () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "5256",
@@ -479,6 +490,9 @@ describe("matrix CLI verification commands", () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "5256",

View File

@@ -15,6 +15,7 @@ import {
} from "./matrix/actions/verification.js";
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
import { getMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
@@ -200,60 +201,18 @@ async function addMatrixAccount(params: {
};
}
type MatrixCliProfileSetResult = {
accountId: string;
displayName: string | null;
avatarUrl: string | null;
profile: {
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
configPath: string;
};
type MatrixCliProfileSetResult = MatrixProfileUpdateResult;
async function setMatrixProfile(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,
return await applyMatrixProfileUpdate({
account: params.account,
displayName: params.name,
avatarUrl: params.avatarUrl,
});
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: resolveMatrixConfigPath(updated, accountId),
};
}
type MatrixCliCommandConfig<TResult> = {
@@ -309,6 +268,9 @@ type MatrixCliVerificationStatus = {
verified: boolean;
userId: string | null;
deviceId: string | null;
localVerified: boolean;
crossSigningVerified: boolean;
signedByOwner: boolean;
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
recoveryKeyStored: boolean;
@@ -391,6 +353,16 @@ function printVerificationBackupStatus(status: {
printBackupStatus(resolveBackupStatus(status));
}
function printVerificationTrustDiagnostics(status: {
localVerified: boolean;
crossSigningVerified: boolean;
signedByOwner: boolean;
}): void {
console.log(`Locally trusted: ${status.localVerified ? "yes" : "no"}`);
console.log(`Cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`);
console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`);
}
function printVerificationGuidance(status: MatrixCliVerificationStatus): void {
printGuidance(buildVerificationGuidance(status));
}
@@ -525,7 +497,7 @@ function printGuidance(lines: string[]): void {
}
function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void {
console.log(`Verified: ${status.verified ? "yes" : "no"}`);
console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`);
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
printVerificationBackupSummary(status);
@@ -535,6 +507,7 @@ function printVerificationStatus(status: MatrixCliVerificationStatus, verbose =
if (verbose) {
console.log("Diagnostics:");
printVerificationIdentity(status);
printVerificationTrustDiagnostics(status);
printVerificationBackupStatus(status);
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
@@ -804,9 +777,10 @@ export function registerMatrixCli(params: { program: Command }): void {
if (result.error) {
console.log(`Error: ${result.error}`);
}
console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`);
console.log(`Verified by owner: ${result.verification.verified ? "yes" : "no"}`);
printVerificationIdentity(result.verification);
if (verbose) {
printVerificationTrustDiagnostics(result.verification);
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
);
@@ -853,6 +827,7 @@ export function registerMatrixCli(params: { program: Command }): void {
printVerificationIdentity(result);
printVerificationBackupSummary(result);
if (verbose) {
printVerificationTrustDiagnostics(result);
printVerificationBackupStatus(result);
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
printTimestamp("Verified at", result.verifiedAt);

View File

@@ -13,6 +13,7 @@ const matrixActionSchema = z
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
profile: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
verification: z.boolean().optional(),

View File

@@ -402,7 +402,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
env: process.env,
});
if (startupVerification.kind === "verified") {
logger.info("matrix: device is verified and ready for encrypted rooms");
logger.info("matrix: device is verified by its owner and ready for encrypted rooms");
} else if (
startupVerification.kind === "disabled" ||
startupVerification.kind === "cooldown" ||

View File

@@ -22,6 +22,9 @@ type VerificationSummaryLike = {
function createHarness(params?: {
verified?: boolean;
localVerified?: boolean;
crossSigningVerified?: boolean;
signedByOwner?: boolean;
requestVerification?: () => Promise<{ id: string; transactionId?: string }>;
listVerifications?: () => Promise<VerificationSummaryLike[]>;
}) {
@@ -37,9 +40,9 @@ function createHarness(params?: {
userId: "@bot:example.org",
deviceId: "DEVICE123",
verified: params?.verified === true,
localVerified: params?.verified === true,
crossSigningVerified: params?.verified === true,
signedByOwner: params?.verified === true,
localVerified: params?.localVerified ?? params?.verified === true,
crossSigningVerified: params?.crossSigningVerified ?? params?.verified === true,
signedByOwner: params?.signedByOwner ?? params?.verified === true,
recoveryKeyStored: false,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
@@ -91,6 +94,31 @@ describe("ensureMatrixStartupVerification", () => {
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
});
it("still requests startup verification when trust is only local", async () => {
const tempHome = createTempStateDir();
const harness = createHarness({
verified: false,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
});
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
expect(result.kind).toBe("requested");
expect(harness.client.crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
});
it("skips automatic requests when a self verification is already pending", async () => {
const tempHome = createTempStateDir();
const harness = createHarness({

View File

@@ -843,6 +843,34 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(status.deviceId).toBe("DEVICE123");
});
it("does not treat local-only trust as owner verification", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
await client.start();
const status = await client.getOwnDeviceVerificationStatus();
expect(status.localVerified).toBe(true);
expect(status.crossSigningVerified).toBe(false);
expect(status.signedByOwner).toBe(false);
expect(status.verified).toBe(false);
});
it("verifies with a provided recovery key and reports success", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
expect(encoded).toBeTypeOf("string");
@@ -887,6 +915,42 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(bootstrapCrossSigning).toHaveBeenCalled();
});
it("fails recovery-key verification when the device is only locally trusted", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
});
await client.start();
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.verified).toBe(false);
expect(result.error).toContain("not verified by its owner");
});
it("reports detailed room-key backup health", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
@@ -1140,6 +1204,42 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.cryptoBootstrap).not.toBeNull();
});
it("reports bootstrap failure when the device is only locally trusted", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({
userId: "@bot:example.org",
masterKeyPublished: true,
selfSigningKeyPublished: true,
userSigningKeyPublished: true,
published: true,
});
const result = await client.bootstrapOwnDeviceVerification();
expect(result.success).toBe(false);
expect(result.verification.localVerified).toBe(true);
expect(result.verification.signedByOwner).toBe(false);
expect(result.error).toContain("not verified by its owner after bootstrap");
});
it("creates a key backup during bootstrap when none exists on the server", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");

View File

@@ -27,6 +27,7 @@ import type {
MessageEventContent,
} from "./sdk/types.js";
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
import { isMatrixDeviceOwnerVerified } from "./sdk/verification-status.js";
export { ConsoleLogger, LogService };
export type {
@@ -47,6 +48,8 @@ export type MatrixOwnDeviceVerificationStatus = {
encryptionEnabled: boolean;
userId: string | null;
deviceId: string | null;
// "verified" is intentionally strict: other Matrix clients should trust messages
// from this device without showing "not verified by its owner" warnings.
verified: boolean;
localVerified: boolean;
crossSigningVerified: boolean;
@@ -102,17 +105,6 @@ export type MatrixVerificationBootstrapResult = {
cryptoBootstrap: MatrixCryptoBootstrapResult | null;
};
function isMatrixDeviceVerified(
status: MatrixDeviceVerificationStatusLike | null | undefined,
): boolean {
return (
status?.isVerified?.() === true ||
status?.localVerified === true ||
status?.crossSigningVerified === true ||
status?.signedByOwner === true
);
}
function normalizeOptionalString(value: string | null | undefined): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
@@ -659,7 +651,7 @@ export class MatrixClient {
encryptionEnabled: true,
userId,
deviceId,
verified: isMatrixDeviceVerified(deviceStatus),
verified: isMatrixDeviceOwnerVerified(deviceStatus),
localVerified: deviceStatus?.localVerified === true,
crossSigningVerified: deviceStatus?.crossSigningVerified === true,
signedByOwner: deviceStatus?.signedByOwner === true,
@@ -715,7 +707,7 @@ export class MatrixClient {
return {
success: false,
error:
"Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.",
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
...status,
};
}
@@ -901,7 +893,7 @@ export class MatrixClient {
const error = success
? undefined
: (bootstrapError ??
"Matrix verification bootstrap did not produce a verified device with published cross-signing keys");
"Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys");
return {
success,
error,

View File

@@ -230,6 +230,48 @@ describe("MatrixCryptoBootstrapper", () => {
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
});
it("does not treat local-only trust as sufficient for own-device bootstrap", async () => {
const deps = createBootstrapperDeps();
const setDeviceVerified = vi.fn(async () => {});
const crossSignDevice = vi.fn(async () => {});
const getDeviceVerificationStatus = vi
.fn<
() => Promise<{
isVerified: () => boolean;
localVerified: boolean;
crossSigningVerified: boolean;
signedByOwner: boolean;
}>
>()
.mockResolvedValueOnce({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})
.mockResolvedValueOnce({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
});
const crypto = createCryptoApi({
getDeviceVerificationStatus,
setDeviceVerified,
crossSignDevice,
isCrossSigningReady: vi.fn(async () => true),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
expect(getDeviceVerificationStatus).toHaveBeenCalledTimes(2);
});
it("auto-accepts incoming verification requests from other users", async () => {
const deps = createBootstrapperDeps();
const listeners = new Map<string, (...args: unknown[]) => void>();

View File

@@ -13,6 +13,7 @@ import type {
MatrixVerificationManager,
MatrixVerificationRequestLike,
} from "./verification-manager.js";
import { isMatrixDeviceOwnerVerified } from "./verification-status.js";
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
getUserId: () => Promise<string>;
@@ -293,11 +294,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const alreadyVerified =
deviceStatus?.isVerified?.() === true ||
deviceStatus?.localVerified === true ||
deviceStatus?.crossSigningVerified === true ||
deviceStatus?.signedByOwner === true;
const alreadyVerified = isMatrixDeviceOwnerVerified(deviceStatus);
if (alreadyVerified) {
return true;
@@ -321,13 +318,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const verified =
refreshedStatus?.isVerified?.() === true ||
refreshedStatus?.localVerified === true ||
refreshedStatus?.crossSigningVerified === true ||
refreshedStatus?.signedByOwner === true;
const verified = isMatrixDeviceOwnerVerified(refreshedStatus);
if (!verified && strict) {
throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`);
throw new Error(`Matrix own device ${deviceId} is not verified by its owner after bootstrap`);
}
return verified;
}

View File

@@ -0,0 +1,23 @@
import type { MatrixDeviceVerificationStatusLike } from "./types.js";
export function isMatrixDeviceLocallyVerified(
status: MatrixDeviceVerificationStatusLike | null | undefined,
): boolean {
return status?.localVerified === true;
}
export function isMatrixDeviceOwnerVerified(
status: MatrixDeviceVerificationStatusLike | null | undefined,
): boolean {
return status?.crossSigningVerified === true || status?.signedByOwner === true;
}
export function isMatrixDeviceVerifiedInCurrentClient(
status: MatrixDeviceVerificationStatusLike | null | undefined,
): boolean {
return (
status?.isVerified?.() === true ||
isMatrixDeviceLocallyVerified(status) ||
isMatrixDeviceOwnerVerified(status)
);
}

View File

@@ -0,0 +1,61 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import { updateMatrixAccountConfig, resolveMatrixConfigPath } from "./matrix/config-update.js";
import { getMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
export type MatrixProfileUpdateResult = {
accountId: string;
displayName: string | null;
avatarUrl: string | null;
profile: {
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
configPath: string;
};
export async function applyMatrixProfileUpdate(params: {
account?: string;
displayName?: string;
avatarUrl?: string;
}): Promise<MatrixProfileUpdateResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const accountId = normalizeAccountId(params.account);
const displayName = params.displayName?.trim() || null;
const avatarUrl = params.avatarUrl?.trim() || null;
if (!displayName && !avatarUrl) {
throw new Error("Provide name/displayName and/or avatarUrl.");
}
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: resolveMatrixConfigPath(updated, accountId),
};
}

View File

@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
listMatrixPins: vi.fn(),
getMatrixMemberInfo: vi.fn(),
getMatrixRoomInfo: vi.fn(),
applyMatrixProfileUpdate: vi.fn(),
}));
vi.mock("./matrix/actions.js", async () => {
@@ -35,6 +36,10 @@ vi.mock("./matrix/send.js", async () => {
};
});
vi.mock("./profile-update.js", () => ({
applyMatrixProfileUpdate: (...args: unknown[]) => mocks.applyMatrixProfileUpdate(...args),
}));
describe("handleMatrixAction pollVote", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -55,6 +60,18 @@ describe("handleMatrixAction pollVote", () => {
});
mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" });
mocks.getMatrixRoomInfo.mockResolvedValue({ roomId: "!room:example" });
mocks.applyMatrixProfileUpdate.mockResolvedValue({
accountId: "ops",
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
profile: {
displayNameUpdated: true,
avatarUpdated: true,
resolvedAvatarUrl: "mxc://example/avatar",
convertedAvatarFromHttp: false,
},
configPath: "channels.matrix.accounts.ops",
});
});
it("parses snake_case vote params and forwards normalized selectors", async () => {
@@ -219,4 +236,30 @@ describe("handleMatrixAction pollVote", () => {
accountId: "ops",
});
});
it("persists self-profile updates through the shared profile helper", async () => {
const result = await handleMatrixAction(
{
action: "setProfile",
account_id: "ops",
display_name: "Ops Bot",
avatar_url: "mxc://example/avatar",
},
{ channels: { matrix: { actions: { profile: true } } } } as CoreConfig,
);
expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith({
account: "ops",
displayName: "Ops Bot",
avatarUrl: "mxc://example/avatar",
});
expect(result.details).toMatchObject({
ok: true,
accountId: "ops",
profile: {
displayNameUpdated: true,
avatarUpdated: true,
},
});
});
});

View File

@@ -39,12 +39,14 @@ import {
verifyMatrixRecoveryKey,
} from "./matrix/actions.js";
import { reactMatrixMessage } from "./matrix/send.js";
import { applyMatrixProfileUpdate } from "./profile-update.js";
import type { CoreConfig } from "./types.js";
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
const reactionActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
const pollActions = new Set(["pollVote"]);
const profileActions = new Set(["setProfile"]);
const verificationActions = new Set([
"encryptionStatus",
"verificationList",
@@ -258,6 +260,18 @@ export async function handleMatrixAction(
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
}
if (profileActions.has(action)) {
if (!isActionEnabled("profile")) {
throw new Error("Matrix profile updates are disabled.");
}
const result = await applyMatrixProfileUpdate({
account: accountId,
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
avatarUrl: readStringParam(params, "avatarUrl"),
});
return jsonResult({ ok: true, ...result });
}
if (action === "memberInfo") {
if (!isActionEnabled("memberInfo")) {
throw new Error("Matrix member info is disabled.");

View File

@@ -35,6 +35,7 @@ export type MatrixActionConfig = {
reactions?: boolean;
messages?: boolean;
pins?: boolean;
profile?: boolean;
memberInfo?: boolean;
channelInfo?: boolean;
verification?: boolean;

View File

@@ -156,6 +156,14 @@ describe("message tool schema scoping", () => {
actions: ["send", "poll", "poll-vote"],
});
const matrixPlugin = createChannelPlugin({
id: "matrix",
label: "Matrix",
docsPath: "/channels/matrix",
blurb: "Matrix test plugin.",
actions: ["send", "set-profile"],
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
@@ -191,6 +199,7 @@ describe("message tool schema scoping", () => {
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
{ pluginId: "discord", source: "test", plugin: discordPlugin },
{ pluginId: "matrix", source: "test", plugin: matrixPlugin },
]),
);
@@ -235,6 +244,8 @@ describe("message tool schema scoping", () => {
expect(properties.pollId).toBeDefined();
expect(properties.pollOptionIndex).toBeDefined();
expect(properties.pollOptionId).toBeDefined();
expect(properties.avatarUrl).toBeDefined();
expect(properties.displayName).toBeDefined();
},
);

View File

@@ -421,6 +421,33 @@ function buildPresenceSchema() {
};
}
function buildProfileSchema() {
return {
displayName: Type.Optional(
Type.String({
description: "Profile display name for self-profile update actions.",
}),
),
display_name: Type.Optional(
Type.String({
description: "snake_case alias of displayName for self-profile update actions.",
}),
),
avatarUrl: Type.Optional(
Type.String({
description:
"Profile avatar URL for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
}),
),
avatar_url: Type.Optional(
Type.String({
description:
"snake_case alias of avatarUrl for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
}),
),
};
}
function buildChannelManagementSchema() {
return {
name: Type.Optional(Type.String()),
@@ -459,6 +486,7 @@ function buildMessageToolSchemaProps(options: {
...buildGatewaySchema(),
...buildChannelManagementSchema(),
...buildPresenceSchema(),
...buildProfileSchema(),
};
}

View File

@@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"kick",
"ban",
"set-presence",
"set-profile",
"download-file",
] as const;

View File

@@ -56,6 +56,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
kick: "none",
ban: "none",
"set-presence": "none",
"set-profile": "none",
"download-file": "none",
};