Matrix: extract startup maintenance flow

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 04:38:54 -04:00
parent bd642ece96
commit 7db294ff43
3 changed files with 361 additions and 126 deletions

View File

@@ -19,18 +19,14 @@ import {
resolveSharedMatrixClient,
stopSharedClientForAccount,
} from "../client.js";
import { updateMatrixAccountConfig } from "../config-update.js";
import { summarizeMatrixDeviceHealth } from "../device-health.js";
import { syncMatrixOwnProfile } from "../profile.js";
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { resolveMatrixMonitorConfig } from "./config.js";
import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
import { ensureMatrixStartupVerification } from "./startup-verification.js";
import { runMatrixStartupMaintenance } from "./startup.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
@@ -235,127 +231,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
try {
const profileSync = await syncMatrixOwnProfile({
client,
userId: auth.userId,
displayName: accountConfig.name,
avatarUrl: accountConfig.avatarUrl,
loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes),
});
if (profileSync.displayNameUpdated) {
logger.info(`matrix: profile display name updated for ${auth.userId}`);
}
if (profileSync.avatarUpdated) {
logger.info(`matrix: profile avatar updated for ${auth.userId}`);
}
if (
profileSync.convertedAvatarFromHttp &&
profileSync.resolvedAvatarUrl &&
accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl
) {
const latestCfg = core.config.loadConfig() as CoreConfig;
const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, {
avatarUrl: profileSync.resolvedAvatarUrl,
});
await core.config.writeConfigFile(updatedCfg as never);
logVerboseMessage(
`matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`,
);
}
} catch (err) {
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
}
// If E2EE is enabled, report device verification status and request self-verification
// when configured and the device is still unverified.
if (auth.encryption && client.crypto) {
try {
const deviceHealth = summarizeMatrixDeviceHealth(await client.listOwnDevices());
if (deviceHealth.staleOpenClawDevices.length > 0) {
logger.warn(
`matrix: stale OpenClaw devices detected for ${auth.userId}: ${deviceHealth.staleOpenClawDevices.map((device) => device.deviceId).join(", ")}. Run 'openclaw matrix devices prune-stale --account ${effectiveAccountId}' to keep encrypted-room trust healthy.`,
);
}
} catch (err) {
logger.debug?.("Failed to inspect matrix device hygiene (non-fatal)", {
error: String(err),
});
}
try {
const startupVerification = await ensureMatrixStartupVerification({
client,
auth,
accountConfig,
env: process.env,
});
if (startupVerification.kind === "verified") {
logger.info("matrix: device is verified by its owner and ready for encrypted rooms");
} else if (
startupVerification.kind === "disabled" ||
startupVerification.kind === "cooldown" ||
startupVerification.kind === "pending" ||
startupVerification.kind === "request-failed"
) {
logger.info(
"matrix: device not verified — run 'openclaw matrix verify device <key>' to enable E2EE",
);
if (startupVerification.kind === "pending") {
logger.info(
"matrix: startup verification request is already pending; finish it in another Matrix client",
);
} else if (startupVerification.kind === "cooldown") {
logVerboseMessage(
`matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`,
);
} else if (startupVerification.kind === "request-failed") {
logger.debug?.("Matrix startup verification request failed (non-fatal)", {
error: startupVerification.error ?? "unknown",
});
}
} else if (startupVerification.kind === "requested") {
logger.info(
"matrix: device not verified — requested verification in another Matrix client",
);
}
} catch (err) {
logger.debug?.("Failed to resolve matrix verification status (non-fatal)", {
error: String(err),
});
}
try {
const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({
client,
auth,
env: process.env,
});
if (legacyCryptoRestore.kind === "restored") {
logger.info(
`matrix: restored ${legacyCryptoRestore.imported}/${legacyCryptoRestore.total} room key(s) from legacy encrypted-state backup`,
);
if (legacyCryptoRestore.localOnlyKeys > 0) {
logger.warn(
`matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and could not be restored automatically`,
);
}
} else if (legacyCryptoRestore.kind === "failed") {
logger.warn(
`matrix: failed restoring room keys from legacy encrypted-state backup: ${legacyCryptoRestore.error}`,
);
if (legacyCryptoRestore.localOnlyKeys > 0) {
logger.warn(
`matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and may remain unavailable until manually recovered`,
);
}
}
} catch (err) {
logger.warn("matrix: failed restoring legacy encrypted-state backup", {
error: String(err),
});
}
}
await runMatrixStartupMaintenance({
client,
auth,
accountId: account.accountId,
effectiveAccountId,
accountConfig,
logger,
logVerboseMessage,
loadConfig: () => core.config.loadConfig() as CoreConfig,
writeConfigFile: async (nextCfg) => await core.config.writeConfigFile(nextCfg),
loadWebMedia: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes),
env: process.env,
});
await new Promise<void>((resolve) => {
const onAbort = () => {

View File

@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runMatrixStartupMaintenance } from "./startup.js";
const hoisted = vi.hoisted(() => ({
maybeRestoreLegacyMatrixBackup: vi.fn(async () => ({ kind: "skipped" as const })),
summarizeMatrixDeviceHealth: vi.fn(() => ({
staleOpenClawDevices: [] as Array<{ deviceId: string }>,
})),
syncMatrixOwnProfile: vi.fn(async () => ({
skipped: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
uploadedAvatarSource: null,
convertedAvatarFromHttp: false,
})),
ensureMatrixStartupVerification: vi.fn(async () => ({ kind: "verified" as const })),
updateMatrixAccountConfig: vi.fn((cfg: unknown) => cfg),
}));
vi.mock("../config-update.js", () => ({
updateMatrixAccountConfig: (...args: unknown[]) => hoisted.updateMatrixAccountConfig(...args),
}));
vi.mock("../device-health.js", () => ({
summarizeMatrixDeviceHealth: (...args: unknown[]) => hoisted.summarizeMatrixDeviceHealth(...args),
}));
vi.mock("../profile.js", () => ({
syncMatrixOwnProfile: (...args: unknown[]) => hoisted.syncMatrixOwnProfile(...args),
}));
vi.mock("./legacy-crypto-restore.js", () => ({
maybeRestoreLegacyMatrixBackup: (...args: unknown[]) =>
hoisted.maybeRestoreLegacyMatrixBackup(...args),
}));
vi.mock("./startup-verification.js", () => ({
ensureMatrixStartupVerification: (...args: unknown[]) =>
hoisted.ensureMatrixStartupVerification(...args),
}));
describe("runMatrixStartupMaintenance", () => {
beforeEach(() => {
hoisted.maybeRestoreLegacyMatrixBackup.mockClear().mockResolvedValue({ kind: "skipped" });
hoisted.summarizeMatrixDeviceHealth.mockClear().mockReturnValue({ staleOpenClawDevices: [] });
hoisted.syncMatrixOwnProfile.mockClear().mockResolvedValue({
skipped: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
uploadedAvatarSource: null,
convertedAvatarFromHttp: false,
});
hoisted.ensureMatrixStartupVerification.mockClear().mockResolvedValue({ kind: "verified" });
hoisted.updateMatrixAccountConfig.mockClear().mockImplementation((cfg: unknown) => cfg);
});
function createParams() {
return {
client: {
crypto: {},
listOwnDevices: vi.fn(async () => []),
} as never,
auth: {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: false,
},
accountId: "ops",
effectiveAccountId: "ops",
accountConfig: {
name: "Ops Bot",
avatarUrl: "https://example.org/avatar.png",
},
logger: {
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
},
logVerboseMessage: vi.fn(),
loadConfig: vi.fn(() => ({ channels: { matrix: {} } })),
writeConfigFile: vi.fn(async () => {}),
loadWebMedia: vi.fn(async () => ({
buffer: Buffer.from("avatar"),
contentType: "image/png",
fileName: "avatar.png",
})),
env: {},
} as const;
}
it("persists converted avatar URLs after profile sync", async () => {
const params = createParams();
const updatedCfg = { channels: { matrix: { avatarUrl: "mxc://avatar" } } };
hoisted.syncMatrixOwnProfile.mockResolvedValue({
skipped: false,
displayNameUpdated: false,
avatarUpdated: true,
resolvedAvatarUrl: "mxc://avatar",
uploadedAvatarSource: "http",
convertedAvatarFromHttp: true,
});
hoisted.updateMatrixAccountConfig.mockReturnValue(updatedCfg);
await runMatrixStartupMaintenance(params);
expect(hoisted.syncMatrixOwnProfile).toHaveBeenCalledWith(
expect.objectContaining({
userId: "@bot:example.org",
displayName: "Ops Bot",
avatarUrl: "https://example.org/avatar.png",
}),
);
expect(hoisted.updateMatrixAccountConfig).toHaveBeenCalledWith(
{ channels: { matrix: {} } },
"ops",
{ avatarUrl: "mxc://avatar" },
);
expect(params.writeConfigFile).toHaveBeenCalledWith(updatedCfg as never);
expect(params.logVerboseMessage).toHaveBeenCalledWith(
"matrix: persisted converted avatar URL for account ops (mxc://avatar)",
);
});
it("reports stale devices, pending verification, and restored legacy backups", async () => {
const params = createParams();
params.auth.encryption = true;
hoisted.summarizeMatrixDeviceHealth.mockReturnValue({
staleOpenClawDevices: [{ deviceId: "DEV123" }],
});
hoisted.ensureMatrixStartupVerification.mockResolvedValue({ kind: "pending" });
hoisted.maybeRestoreLegacyMatrixBackup.mockResolvedValue({
kind: "restored",
imported: 2,
total: 3,
localOnlyKeys: 1,
});
await runMatrixStartupMaintenance(params);
expect(params.logger.warn).toHaveBeenCalledWith(
"matrix: stale OpenClaw devices detected for @bot:example.org: DEV123. Run 'openclaw matrix devices prune-stale --account ops' to keep encrypted-room trust healthy.",
);
expect(params.logger.info).toHaveBeenCalledWith(
"matrix: device not verified — run 'openclaw matrix verify device <key>' to enable E2EE",
);
expect(params.logger.info).toHaveBeenCalledWith(
"matrix: startup verification request is already pending; finish it in another Matrix client",
);
expect(params.logger.info).toHaveBeenCalledWith(
"matrix: restored 2/3 room key(s) from legacy encrypted-state backup",
);
expect(params.logger.warn).toHaveBeenCalledWith(
"matrix: 1 legacy local-only room key(s) were never backed up and could not be restored automatically",
);
});
it("logs cooldown and request-failure verification outcomes without throwing", async () => {
const params = createParams();
params.auth.encryption = true;
hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce({
kind: "cooldown",
retryAfterMs: 321,
});
await runMatrixStartupMaintenance(params);
expect(params.logVerboseMessage).toHaveBeenCalledWith(
"matrix: skipped startup verification request due to cooldown (retryAfterMs=321)",
);
hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce({
kind: "request-failed",
error: "boom",
});
await runMatrixStartupMaintenance(params);
expect(params.logger.debug).toHaveBeenCalledWith(
"Matrix startup verification request failed (non-fatal)",
{ error: "boom" },
);
});
});

View File

@@ -0,0 +1,159 @@
import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix";
import type { CoreConfig, MatrixConfig } from "../../types.js";
import type { MatrixAuth } from "../client.js";
import { updateMatrixAccountConfig } from "../config-update.js";
import { summarizeMatrixDeviceHealth } from "../device-health.js";
import { syncMatrixOwnProfile } from "../profile.js";
import type { MatrixClient } from "../sdk.js";
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";
import { ensureMatrixStartupVerification } from "./startup-verification.js";
type MatrixStartupClient = Pick<
MatrixClient,
| "crypto"
| "getUserProfile"
| "listOwnDevices"
| "restoreRoomKeyBackup"
| "setAvatarUrl"
| "setDisplayName"
| "uploadContent"
>;
export async function runMatrixStartupMaintenance(params: {
client: MatrixStartupClient;
auth: MatrixAuth;
accountId: string;
effectiveAccountId: string;
accountConfig: MatrixConfig;
logger: RuntimeLogger;
logVerboseMessage: (message: string) => void;
loadConfig: () => CoreConfig;
writeConfigFile: (cfg: never) => Promise<void>;
loadWebMedia: (
url: string,
maxBytes: number,
) => Promise<{ buffer: Buffer; contentType?: string; fileName?: string }>;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
try {
const profileSync = await syncMatrixOwnProfile({
client: params.client,
userId: params.auth.userId,
displayName: params.accountConfig.name,
avatarUrl: params.accountConfig.avatarUrl,
loadAvatarFromUrl: async (url, maxBytes) => await params.loadWebMedia(url, maxBytes),
});
if (profileSync.displayNameUpdated) {
params.logger.info(`matrix: profile display name updated for ${params.auth.userId}`);
}
if (profileSync.avatarUpdated) {
params.logger.info(`matrix: profile avatar updated for ${params.auth.userId}`);
}
if (
profileSync.convertedAvatarFromHttp &&
profileSync.resolvedAvatarUrl &&
params.accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl
) {
const latestCfg = params.loadConfig();
const updatedCfg = updateMatrixAccountConfig(latestCfg, params.accountId, {
avatarUrl: profileSync.resolvedAvatarUrl,
});
await params.writeConfigFile(updatedCfg as never);
params.logVerboseMessage(
`matrix: persisted converted avatar URL for account ${params.accountId} (${profileSync.resolvedAvatarUrl})`,
);
}
} catch (err) {
params.logger.warn("matrix: failed to sync profile from config", { error: String(err) });
}
if (!(params.auth.encryption && params.client.crypto)) {
return;
}
try {
const deviceHealth = summarizeMatrixDeviceHealth(await params.client.listOwnDevices());
if (deviceHealth.staleOpenClawDevices.length > 0) {
params.logger.warn(
`matrix: stale OpenClaw devices detected for ${params.auth.userId}: ${deviceHealth.staleOpenClawDevices.map((device) => device.deviceId).join(", ")}. Run 'openclaw matrix devices prune-stale --account ${params.effectiveAccountId}' to keep encrypted-room trust healthy.`,
);
}
} catch (err) {
params.logger.debug?.("Failed to inspect matrix device hygiene (non-fatal)", {
error: String(err),
});
}
try {
const startupVerification = await ensureMatrixStartupVerification({
client: params.client,
auth: params.auth,
accountConfig: params.accountConfig,
env: params.env,
});
if (startupVerification.kind === "verified") {
params.logger.info("matrix: device is verified by its owner and ready for encrypted rooms");
} else if (
startupVerification.kind === "disabled" ||
startupVerification.kind === "cooldown" ||
startupVerification.kind === "pending" ||
startupVerification.kind === "request-failed"
) {
params.logger.info(
"matrix: device not verified — run 'openclaw matrix verify device <key>' to enable E2EE",
);
if (startupVerification.kind === "pending") {
params.logger.info(
"matrix: startup verification request is already pending; finish it in another Matrix client",
);
} else if (startupVerification.kind === "cooldown") {
params.logVerboseMessage(
`matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`,
);
} else if (startupVerification.kind === "request-failed") {
params.logger.debug?.("Matrix startup verification request failed (non-fatal)", {
error: startupVerification.error ?? "unknown",
});
}
} else if (startupVerification.kind === "requested") {
params.logger.info(
"matrix: device not verified — requested verification in another Matrix client",
);
}
} catch (err) {
params.logger.debug?.("Failed to resolve matrix verification status (non-fatal)", {
error: String(err),
});
}
try {
const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({
client: params.client,
auth: params.auth,
env: params.env,
});
if (legacyCryptoRestore.kind === "restored") {
params.logger.info(
`matrix: restored ${legacyCryptoRestore.imported}/${legacyCryptoRestore.total} room key(s) from legacy encrypted-state backup`,
);
if (legacyCryptoRestore.localOnlyKeys > 0) {
params.logger.warn(
`matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and could not be restored automatically`,
);
}
} else if (legacyCryptoRestore.kind === "failed") {
params.logger.warn(
`matrix: failed restoring room keys from legacy encrypted-state backup: ${legacyCryptoRestore.error}`,
);
if (legacyCryptoRestore.localOnlyKeys > 0) {
params.logger.warn(
`matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and may remain unavailable until manually recovered`,
);
}
}
} catch (err) {
params.logger.warn("matrix: failed restoring legacy encrypted-state backup", {
error: String(err),
});
}
}