mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 08:07:27 +00:00
Matrix: tighten verification trust and expose profile updates
This commit is contained in:
@@ -82,4 +82,27 @@ describe("matrixMessageActions account propagation", () => {
|
|||||||
expect.any(Object),
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,4 +66,16 @@ describe("matrixMessageActions", () => {
|
|||||||
expect(supportsAction!({ action: "poll" } as never)).toBe(false);
|
expect(supportsAction!({ action: "poll" } as never)).toBe(false);
|
||||||
expect(supportsAction!({ action: "poll-vote" } as never)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
|
|||||||
"pin",
|
"pin",
|
||||||
"unpin",
|
"unpin",
|
||||||
"list-pins",
|
"list-pins",
|
||||||
|
"set-profile",
|
||||||
"member-info",
|
"member-info",
|
||||||
"channel-info",
|
"channel-info",
|
||||||
"permissions",
|
"permissions",
|
||||||
@@ -53,6 +54,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
|||||||
actions.add("unpin");
|
actions.add("unpin");
|
||||||
actions.add("list-pins");
|
actions.add("list-pins");
|
||||||
}
|
}
|
||||||
|
if (gate("profile")) {
|
||||||
|
actions.add("set-profile");
|
||||||
|
}
|
||||||
if (gate("memberInfo")) {
|
if (gate("memberInfo")) {
|
||||||
actions.add("member-info");
|
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") {
|
if (action === "member-info") {
|
||||||
const userId = readStringParam(params, "userId", { required: true });
|
const userId = readStringParam(params, "userId", { required: true });
|
||||||
return await dispatch({
|
return await dispatch({
|
||||||
|
|||||||
@@ -310,6 +310,9 @@ describe("matrix CLI verification commands", () => {
|
|||||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||||
encryptionEnabled: true,
|
encryptionEnabled: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
|
localVerified: true,
|
||||||
|
crossSigningVerified: true,
|
||||||
|
signedByOwner: true,
|
||||||
userId: "@bot:example.org",
|
userId: "@bot:example.org",
|
||||||
deviceId: "DEVICE123",
|
deviceId: "DEVICE123",
|
||||||
backupVersion: "1",
|
backupVersion: "1",
|
||||||
@@ -332,6 +335,8 @@ describe("matrix CLI verification commands", () => {
|
|||||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||||
);
|
);
|
||||||
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
|
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
|
||||||
|
expect(console.log).toHaveBeenCalledWith("Locally trusted: yes");
|
||||||
|
expect(console.log).toHaveBeenCalledWith("Signed by owner: yes");
|
||||||
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
|
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -413,6 +418,9 @@ describe("matrix CLI verification commands", () => {
|
|||||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||||
encryptionEnabled: true,
|
encryptionEnabled: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
|
localVerified: true,
|
||||||
|
crossSigningVerified: true,
|
||||||
|
signedByOwner: true,
|
||||||
userId: "@bot:example.org",
|
userId: "@bot:example.org",
|
||||||
deviceId: "DEVICE123",
|
deviceId: "DEVICE123",
|
||||||
backupVersion: "1",
|
backupVersion: "1",
|
||||||
@@ -444,6 +452,9 @@ describe("matrix CLI verification commands", () => {
|
|||||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||||
encryptionEnabled: true,
|
encryptionEnabled: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
|
localVerified: true,
|
||||||
|
crossSigningVerified: true,
|
||||||
|
signedByOwner: true,
|
||||||
userId: "@bot:example.org",
|
userId: "@bot:example.org",
|
||||||
deviceId: "DEVICE123",
|
deviceId: "DEVICE123",
|
||||||
backupVersion: "5256",
|
backupVersion: "5256",
|
||||||
@@ -479,6 +490,9 @@ describe("matrix CLI verification commands", () => {
|
|||||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||||
encryptionEnabled: true,
|
encryptionEnabled: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
|
localVerified: true,
|
||||||
|
crossSigningVerified: true,
|
||||||
|
signedByOwner: true,
|
||||||
userId: "@bot:example.org",
|
userId: "@bot:example.org",
|
||||||
deviceId: "DEVICE123",
|
deviceId: "DEVICE123",
|
||||||
backupVersion: "5256",
|
backupVersion: "5256",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./matrix/actions/verification.js";
|
} from "./matrix/actions/verification.js";
|
||||||
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
|
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
|
||||||
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
|
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||||
|
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
|
||||||
import { getMatrixRuntime } from "./runtime.js";
|
import { getMatrixRuntime } from "./runtime.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
|
|
||||||
@@ -200,60 +201,18 @@ async function addMatrixAccount(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatrixCliProfileSetResult = {
|
type MatrixCliProfileSetResult = MatrixProfileUpdateResult;
|
||||||
accountId: string;
|
|
||||||
displayName: string | null;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
profile: {
|
|
||||||
displayNameUpdated: boolean;
|
|
||||||
avatarUpdated: boolean;
|
|
||||||
resolvedAvatarUrl: string | null;
|
|
||||||
convertedAvatarFromHttp: boolean;
|
|
||||||
};
|
|
||||||
configPath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function setMatrixProfile(params: {
|
async function setMatrixProfile(params: {
|
||||||
account?: string;
|
account?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}): Promise<MatrixCliProfileSetResult> {
|
}): Promise<MatrixCliProfileSetResult> {
|
||||||
const runtime = getMatrixRuntime();
|
return await applyMatrixProfileUpdate({
|
||||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
account: params.account,
|
||||||
const accountId = normalizeAccountId(params.account);
|
displayName: params.name,
|
||||||
const displayName = params.name?.trim() || null;
|
avatarUrl: params.avatarUrl,
|
||||||
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: resolveMatrixConfigPath(updated, accountId),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatrixCliCommandConfig<TResult> = {
|
type MatrixCliCommandConfig<TResult> = {
|
||||||
@@ -309,6 +268,9 @@ type MatrixCliVerificationStatus = {
|
|||||||
verified: boolean;
|
verified: boolean;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
deviceId: string | null;
|
deviceId: string | null;
|
||||||
|
localVerified: boolean;
|
||||||
|
crossSigningVerified: boolean;
|
||||||
|
signedByOwner: boolean;
|
||||||
backupVersion: string | null;
|
backupVersion: string | null;
|
||||||
backup?: MatrixCliBackupStatus;
|
backup?: MatrixCliBackupStatus;
|
||||||
recoveryKeyStored: boolean;
|
recoveryKeyStored: boolean;
|
||||||
@@ -391,6 +353,16 @@ function printVerificationBackupStatus(status: {
|
|||||||
printBackupStatus(resolveBackupStatus(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 {
|
function printVerificationGuidance(status: MatrixCliVerificationStatus): void {
|
||||||
printGuidance(buildVerificationGuidance(status));
|
printGuidance(buildVerificationGuidance(status));
|
||||||
}
|
}
|
||||||
@@ -525,7 +497,7 @@ function printGuidance(lines: string[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): 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 backup = resolveBackupStatus(status);
|
||||||
const backupIssue = resolveBackupIssue(backup);
|
const backupIssue = resolveBackupIssue(backup);
|
||||||
printVerificationBackupSummary(status);
|
printVerificationBackupSummary(status);
|
||||||
@@ -535,6 +507,7 @@ function printVerificationStatus(status: MatrixCliVerificationStatus, verbose =
|
|||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log("Diagnostics:");
|
console.log("Diagnostics:");
|
||||||
printVerificationIdentity(status);
|
printVerificationIdentity(status);
|
||||||
|
printVerificationTrustDiagnostics(status);
|
||||||
printVerificationBackupStatus(status);
|
printVerificationBackupStatus(status);
|
||||||
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
|
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
|
||||||
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
|
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
|
||||||
@@ -804,9 +777,10 @@ export function registerMatrixCli(params: { program: Command }): void {
|
|||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.log(`Error: ${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);
|
printVerificationIdentity(result.verification);
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
|
printVerificationTrustDiagnostics(result.verification);
|
||||||
console.log(
|
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"})`,
|
`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);
|
printVerificationIdentity(result);
|
||||||
printVerificationBackupSummary(result);
|
printVerificationBackupSummary(result);
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
|
printVerificationTrustDiagnostics(result);
|
||||||
printVerificationBackupStatus(result);
|
printVerificationBackupStatus(result);
|
||||||
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
|
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
|
||||||
printTimestamp("Verified at", result.verifiedAt);
|
printTimestamp("Verified at", result.verifiedAt);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const matrixActionSchema = z
|
|||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
messages: z.boolean().optional(),
|
messages: z.boolean().optional(),
|
||||||
pins: z.boolean().optional(),
|
pins: z.boolean().optional(),
|
||||||
|
profile: z.boolean().optional(),
|
||||||
memberInfo: z.boolean().optional(),
|
memberInfo: z.boolean().optional(),
|
||||||
channelInfo: z.boolean().optional(),
|
channelInfo: z.boolean().optional(),
|
||||||
verification: z.boolean().optional(),
|
verification: z.boolean().optional(),
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
if (startupVerification.kind === "verified") {
|
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 (
|
} else if (
|
||||||
startupVerification.kind === "disabled" ||
|
startupVerification.kind === "disabled" ||
|
||||||
startupVerification.kind === "cooldown" ||
|
startupVerification.kind === "cooldown" ||
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ type VerificationSummaryLike = {
|
|||||||
|
|
||||||
function createHarness(params?: {
|
function createHarness(params?: {
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
|
localVerified?: boolean;
|
||||||
|
crossSigningVerified?: boolean;
|
||||||
|
signedByOwner?: boolean;
|
||||||
requestVerification?: () => Promise<{ id: string; transactionId?: string }>;
|
requestVerification?: () => Promise<{ id: string; transactionId?: string }>;
|
||||||
listVerifications?: () => Promise<VerificationSummaryLike[]>;
|
listVerifications?: () => Promise<VerificationSummaryLike[]>;
|
||||||
}) {
|
}) {
|
||||||
@@ -37,9 +40,9 @@ function createHarness(params?: {
|
|||||||
userId: "@bot:example.org",
|
userId: "@bot:example.org",
|
||||||
deviceId: "DEVICE123",
|
deviceId: "DEVICE123",
|
||||||
verified: params?.verified === true,
|
verified: params?.verified === true,
|
||||||
localVerified: params?.verified === true,
|
localVerified: params?.localVerified ?? params?.verified === true,
|
||||||
crossSigningVerified: params?.verified === true,
|
crossSigningVerified: params?.crossSigningVerified ?? params?.verified === true,
|
||||||
signedByOwner: params?.verified === true,
|
signedByOwner: params?.signedByOwner ?? params?.verified === true,
|
||||||
recoveryKeyStored: false,
|
recoveryKeyStored: false,
|
||||||
recoveryKeyCreatedAt: null,
|
recoveryKeyCreatedAt: null,
|
||||||
recoveryKeyId: null,
|
recoveryKeyId: null,
|
||||||
@@ -91,6 +94,31 @@ describe("ensureMatrixStartupVerification", () => {
|
|||||||
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
|
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 () => {
|
it("skips automatic requests when a self verification is already pending", async () => {
|
||||||
const tempHome = createTempStateDir();
|
const tempHome = createTempStateDir();
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
|
|||||||
@@ -843,6 +843,34 @@ describe("MatrixClient crypto bootstrapping", () => {
|
|||||||
expect(status.deviceId).toBe("DEVICE123");
|
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 () => {
|
it("verifies with a provided recovery key and reports success", async () => {
|
||||||
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||||
expect(encoded).toBeTypeOf("string");
|
expect(encoded).toBeTypeOf("string");
|
||||||
@@ -887,6 +915,42 @@ describe("MatrixClient crypto bootstrapping", () => {
|
|||||||
expect(bootstrapCrossSigning).toHaveBeenCalled();
|
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 () => {
|
it("reports detailed room-key backup health", async () => {
|
||||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||||
@@ -1140,6 +1204,42 @@ describe("MatrixClient crypto bootstrapping", () => {
|
|||||||
expect(result.cryptoBootstrap).not.toBeNull();
|
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 () => {
|
it("creates a key backup during bootstrap when none exists on the server", async () => {
|
||||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
MessageEventContent,
|
MessageEventContent,
|
||||||
} from "./sdk/types.js";
|
} from "./sdk/types.js";
|
||||||
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
|
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
|
||||||
|
import { isMatrixDeviceOwnerVerified } from "./sdk/verification-status.js";
|
||||||
|
|
||||||
export { ConsoleLogger, LogService };
|
export { ConsoleLogger, LogService };
|
||||||
export type {
|
export type {
|
||||||
@@ -47,6 +48,8 @@ export type MatrixOwnDeviceVerificationStatus = {
|
|||||||
encryptionEnabled: boolean;
|
encryptionEnabled: boolean;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
deviceId: 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;
|
verified: boolean;
|
||||||
localVerified: boolean;
|
localVerified: boolean;
|
||||||
crossSigningVerified: boolean;
|
crossSigningVerified: boolean;
|
||||||
@@ -102,17 +105,6 @@ export type MatrixVerificationBootstrapResult = {
|
|||||||
cryptoBootstrap: MatrixCryptoBootstrapResult | null;
|
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 {
|
function normalizeOptionalString(value: string | null | undefined): string | null {
|
||||||
const normalized = value?.trim();
|
const normalized = value?.trim();
|
||||||
return normalized ? normalized : null;
|
return normalized ? normalized : null;
|
||||||
@@ -659,7 +651,7 @@ export class MatrixClient {
|
|||||||
encryptionEnabled: true,
|
encryptionEnabled: true,
|
||||||
userId,
|
userId,
|
||||||
deviceId,
|
deviceId,
|
||||||
verified: isMatrixDeviceVerified(deviceStatus),
|
verified: isMatrixDeviceOwnerVerified(deviceStatus),
|
||||||
localVerified: deviceStatus?.localVerified === true,
|
localVerified: deviceStatus?.localVerified === true,
|
||||||
crossSigningVerified: deviceStatus?.crossSigningVerified === true,
|
crossSigningVerified: deviceStatus?.crossSigningVerified === true,
|
||||||
signedByOwner: deviceStatus?.signedByOwner === true,
|
signedByOwner: deviceStatus?.signedByOwner === true,
|
||||||
@@ -715,7 +707,7 @@ export class MatrixClient {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
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,
|
...status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -901,7 +893,7 @@ export class MatrixClient {
|
|||||||
const error = success
|
const error = success
|
||||||
? undefined
|
? undefined
|
||||||
: (bootstrapError ??
|
: (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 {
|
return {
|
||||||
success,
|
success,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -230,6 +230,48 @@ describe("MatrixCryptoBootstrapper", () => {
|
|||||||
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
|
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 () => {
|
it("auto-accepts incoming verification requests from other users", async () => {
|
||||||
const deps = createBootstrapperDeps();
|
const deps = createBootstrapperDeps();
|
||||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
MatrixVerificationManager,
|
MatrixVerificationManager,
|
||||||
MatrixVerificationRequestLike,
|
MatrixVerificationRequestLike,
|
||||||
} from "./verification-manager.js";
|
} from "./verification-manager.js";
|
||||||
|
import { isMatrixDeviceOwnerVerified } from "./verification-status.js";
|
||||||
|
|
||||||
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
|
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
|
||||||
getUserId: () => Promise<string>;
|
getUserId: () => Promise<string>;
|
||||||
@@ -293,11 +294,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
|||||||
typeof crypto.getDeviceVerificationStatus === "function"
|
typeof crypto.getDeviceVerificationStatus === "function"
|
||||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
const alreadyVerified =
|
const alreadyVerified = isMatrixDeviceOwnerVerified(deviceStatus);
|
||||||
deviceStatus?.isVerified?.() === true ||
|
|
||||||
deviceStatus?.localVerified === true ||
|
|
||||||
deviceStatus?.crossSigningVerified === true ||
|
|
||||||
deviceStatus?.signedByOwner === true;
|
|
||||||
|
|
||||||
if (alreadyVerified) {
|
if (alreadyVerified) {
|
||||||
return true;
|
return true;
|
||||||
@@ -321,13 +318,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
|||||||
typeof crypto.getDeviceVerificationStatus === "function"
|
typeof crypto.getDeviceVerificationStatus === "function"
|
||||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
const verified =
|
const verified = isMatrixDeviceOwnerVerified(refreshedStatus);
|
||||||
refreshedStatus?.isVerified?.() === true ||
|
|
||||||
refreshedStatus?.localVerified === true ||
|
|
||||||
refreshedStatus?.crossSigningVerified === true ||
|
|
||||||
refreshedStatus?.signedByOwner === true;
|
|
||||||
if (!verified && strict) {
|
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;
|
return verified;
|
||||||
}
|
}
|
||||||
|
|||||||
23
extensions/matrix/src/matrix/sdk/verification-status.ts
Normal file
23
extensions/matrix/src/matrix/sdk/verification-status.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
61
extensions/matrix/src/profile-update.ts
Normal file
61
extensions/matrix/src/profile-update.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
listMatrixPins: vi.fn(),
|
listMatrixPins: vi.fn(),
|
||||||
getMatrixMemberInfo: vi.fn(),
|
getMatrixMemberInfo: vi.fn(),
|
||||||
getMatrixRoomInfo: vi.fn(),
|
getMatrixRoomInfo: vi.fn(),
|
||||||
|
applyMatrixProfileUpdate: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./matrix/actions.js", async () => {
|
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", () => {
|
describe("handleMatrixAction pollVote", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -55,6 +60,18 @@ describe("handleMatrixAction pollVote", () => {
|
|||||||
});
|
});
|
||||||
mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" });
|
mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" });
|
||||||
mocks.getMatrixRoomInfo.mockResolvedValue({ roomId: "!room: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 () => {
|
it("parses snake_case vote params and forwards normalized selectors", async () => {
|
||||||
@@ -219,4 +236,30 @@ describe("handleMatrixAction pollVote", () => {
|
|||||||
accountId: "ops",
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ import {
|
|||||||
verifyMatrixRecoveryKey,
|
verifyMatrixRecoveryKey,
|
||||||
} from "./matrix/actions.js";
|
} from "./matrix/actions.js";
|
||||||
import { reactMatrixMessage } from "./matrix/send.js";
|
import { reactMatrixMessage } from "./matrix/send.js";
|
||||||
|
import { applyMatrixProfileUpdate } from "./profile-update.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
|
|
||||||
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
|
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
|
||||||
const reactionActions = new Set(["react", "reactions"]);
|
const reactionActions = new Set(["react", "reactions"]);
|
||||||
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
||||||
const pollActions = new Set(["pollVote"]);
|
const pollActions = new Set(["pollVote"]);
|
||||||
|
const profileActions = new Set(["setProfile"]);
|
||||||
const verificationActions = new Set([
|
const verificationActions = new Set([
|
||||||
"encryptionStatus",
|
"encryptionStatus",
|
||||||
"verificationList",
|
"verificationList",
|
||||||
@@ -258,6 +260,18 @@ export async function handleMatrixAction(
|
|||||||
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
|
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 (action === "memberInfo") {
|
||||||
if (!isActionEnabled("memberInfo")) {
|
if (!isActionEnabled("memberInfo")) {
|
||||||
throw new Error("Matrix member info is disabled.");
|
throw new Error("Matrix member info is disabled.");
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type MatrixActionConfig = {
|
|||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
messages?: boolean;
|
messages?: boolean;
|
||||||
pins?: boolean;
|
pins?: boolean;
|
||||||
|
profile?: boolean;
|
||||||
memberInfo?: boolean;
|
memberInfo?: boolean;
|
||||||
channelInfo?: boolean;
|
channelInfo?: boolean;
|
||||||
verification?: boolean;
|
verification?: boolean;
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ describe("message tool schema scoping", () => {
|
|||||||
actions: ["send", "poll", "poll-vote"],
|
actions: ["send", "poll", "poll-vote"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const matrixPlugin = createChannelPlugin({
|
||||||
|
id: "matrix",
|
||||||
|
label: "Matrix",
|
||||||
|
docsPath: "/channels/matrix",
|
||||||
|
blurb: "Matrix test plugin.",
|
||||||
|
actions: ["send", "set-profile"],
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setActivePluginRegistry(createTestRegistry([]));
|
setActivePluginRegistry(createTestRegistry([]));
|
||||||
});
|
});
|
||||||
@@ -191,6 +199,7 @@ describe("message tool schema scoping", () => {
|
|||||||
createTestRegistry([
|
createTestRegistry([
|
||||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||||
{ pluginId: "discord", source: "test", plugin: discordPlugin },
|
{ 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.pollId).toBeDefined();
|
||||||
expect(properties.pollOptionIndex).toBeDefined();
|
expect(properties.pollOptionIndex).toBeDefined();
|
||||||
expect(properties.pollOptionId).toBeDefined();
|
expect(properties.pollOptionId).toBeDefined();
|
||||||
|
expect(properties.avatarUrl).toBeDefined();
|
||||||
|
expect(properties.displayName).toBeDefined();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
function buildChannelManagementSchema() {
|
||||||
return {
|
return {
|
||||||
name: Type.Optional(Type.String()),
|
name: Type.Optional(Type.String()),
|
||||||
@@ -459,6 +486,7 @@ function buildMessageToolSchemaProps(options: {
|
|||||||
...buildGatewaySchema(),
|
...buildGatewaySchema(),
|
||||||
...buildChannelManagementSchema(),
|
...buildChannelManagementSchema(),
|
||||||
...buildPresenceSchema(),
|
...buildPresenceSchema(),
|
||||||
|
...buildProfileSchema(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
|||||||
"kick",
|
"kick",
|
||||||
"ban",
|
"ban",
|
||||||
"set-presence",
|
"set-presence",
|
||||||
|
"set-profile",
|
||||||
"download-file",
|
"download-file",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
kick: "none",
|
kick: "none",
|
||||||
ban: "none",
|
ban: "none",
|
||||||
"set-presence": "none",
|
"set-presence": "none",
|
||||||
|
"set-profile": "none",
|
||||||
"download-file": "none",
|
"download-file": "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user