mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 08:58:37 +00:00
Matrix: tighten fallback resolution and ACP lookup
This commit is contained in:
@@ -30,10 +30,19 @@ describe("matrix client storage paths", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupStateDir(): string {
|
function setupStateDir(
|
||||||
|
cfg: Record<string, unknown> = {
|
||||||
|
channels: {
|
||||||
|
matrix: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
): string {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-storage-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-storage-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
setMatrixRuntime({
|
setMatrixRuntime({
|
||||||
|
config: {
|
||||||
|
loadConfig: () => cfg,
|
||||||
|
},
|
||||||
logging: {
|
logging: {
|
||||||
getChildLogger: () => ({
|
getChildLogger: () => ({
|
||||||
info: () => {},
|
info: () => {},
|
||||||
@@ -157,6 +166,74 @@ describe("matrix client storage paths", () => {
|
|||||||
expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(true);
|
expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses fallback migration when multiple Matrix accounts need explicit selection", async () => {
|
||||||
|
const stateDir = setupStateDir({
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
accounts: {
|
||||||
|
ops: {},
|
||||||
|
work: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const storagePaths = resolveMatrixStoragePaths({
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
userId: "@bot:example.org",
|
||||||
|
accessToken: "secret-token",
|
||||||
|
accountId: "ops",
|
||||||
|
env: {},
|
||||||
|
});
|
||||||
|
const legacyRoot = path.join(stateDir, "matrix");
|
||||||
|
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
maybeMigrateLegacyStorage({
|
||||||
|
storagePaths,
|
||||||
|
env: {},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/defaultAccount is not set/i);
|
||||||
|
expect(maybeCreateMatrixMigrationSnapshotMock).not.toHaveBeenCalled();
|
||||||
|
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses fallback migration for a non-selected Matrix account", async () => {
|
||||||
|
const stateDir = setupStateDir({
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
defaultAccount: "ops",
|
||||||
|
homeserver: "https://matrix.default.example.org",
|
||||||
|
accessToken: "default-token",
|
||||||
|
accounts: {
|
||||||
|
ops: {
|
||||||
|
homeserver: "https://matrix.ops.example.org",
|
||||||
|
accessToken: "ops-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const storagePaths = resolveMatrixStoragePaths({
|
||||||
|
homeserver: "https://matrix.default.example.org",
|
||||||
|
userId: "@default:example.org",
|
||||||
|
accessToken: "default-token",
|
||||||
|
env: {},
|
||||||
|
});
|
||||||
|
const legacyRoot = path.join(stateDir, "matrix");
|
||||||
|
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
maybeMigrateLegacyStorage({
|
||||||
|
storagePaths,
|
||||||
|
env: {},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/targets account "ops"/i);
|
||||||
|
expect(maybeCreateMatrixMigrationSnapshotMock).not.toHaveBeenCalled();
|
||||||
|
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("reuses an existing token-hash storage root after the access token changes", () => {
|
it("reuses an existing token-hash storage root after the access token changes", () => {
|
||||||
const stateDir = setupStateDir();
|
const stateDir = setupStateDir();
|
||||||
const oldStoragePaths = resolveMatrixStoragePaths({
|
const oldStoragePaths = resolveMatrixStoragePaths({
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
maybeCreateMatrixMigrationSnapshot,
|
maybeCreateMatrixMigrationSnapshot,
|
||||||
|
normalizeAccountId,
|
||||||
|
requiresExplicitMatrixDefaultAccount,
|
||||||
resolveMatrixAccountStorageRoot,
|
resolveMatrixAccountStorageRoot,
|
||||||
|
resolveMatrixDefaultOrOnlyAccountId,
|
||||||
resolveMatrixLegacyFlatStoragePaths,
|
resolveMatrixLegacyFlatStoragePaths,
|
||||||
} from "openclaw/plugin-sdk/matrix";
|
} from "openclaw/plugin-sdk/matrix";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
@@ -31,6 +34,26 @@ function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
|||||||
return { storagePath: legacy.storagePath, cryptoPath: legacy.cryptoPath };
|
return { storagePath: legacy.storagePath, cryptoPath: legacy.cryptoPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertLegacyMigrationAccountSelection(params: { accountKey: string }): void {
|
||||||
|
const cfg = getMatrixRuntime().config.loadConfig();
|
||||||
|
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||||
|
throw new Error(
|
||||||
|
"Legacy Matrix client storage cannot be migrated automatically because multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedAccountId = normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||||
|
const currentAccountId = normalizeAccountId(params.accountKey);
|
||||||
|
if (selectedAccountId !== currentAccountId) {
|
||||||
|
throw new Error(
|
||||||
|
`Legacy Matrix client storage targets account "${selectedAccountId}", but the current client is starting account "${currentAccountId}". Start the selected account first so flat legacy storage is not migrated into the wrong account directory.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scoreStorageRoot(rootDir: string): number {
|
function scoreStorageRoot(rootDir: string): number {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (fs.existsSync(path.join(rootDir, "bot-storage.json"))) {
|
if (fs.existsSync(path.join(rootDir, "bot-storage.json"))) {
|
||||||
@@ -175,6 +198,10 @@ export async function maybeMigrateLegacyStorage(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertLegacyMigrationAccountSelection({
|
||||||
|
accountKey: params.storagePaths.accountKey,
|
||||||
|
});
|
||||||
|
|
||||||
const logger = getMatrixRuntime().logging.getChildLogger({ module: "matrix-storage" });
|
const logger = getMatrixRuntime().logging.getChildLogger({ module: "matrix-storage" });
|
||||||
await maybeCreateMatrixMigrationSnapshot({
|
await maybeCreateMatrixMigrationSnapshot({
|
||||||
trigger: "matrix-client-fallback",
|
trigger: "matrix-client-fallback",
|
||||||
|
|||||||
@@ -121,4 +121,31 @@ describe("updateMatrixAccountConfig", () => {
|
|||||||
});
|
});
|
||||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.rooms).toBeUndefined();
|
expect(updated.channels?.["matrix"]?.accounts?.ops?.rooms).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses and canonicalizes non-normalized account entries when updating", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
accounts: {
|
||||||
|
Ops: {
|
||||||
|
homeserver: "https://matrix.ops.example.org",
|
||||||
|
accessToken: "ops-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as CoreConfig;
|
||||||
|
|
||||||
|
const updated = updateMatrixAccountConfig(cfg, "ops", {
|
||||||
|
deviceName: "Ops Bot",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.channels?.["matrix"]?.accounts?.Ops).toBeUndefined();
|
||||||
|
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
|
||||||
|
homeserver: "https://matrix.ops.example.org",
|
||||||
|
accessToken: "ops-token",
|
||||||
|
deviceName: "Ops Bot",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||||
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix";
|
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix";
|
||||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||||
|
import { findMatrixAccountConfig } from "./account-config.js";
|
||||||
|
|
||||||
export type MatrixAccountPatch = {
|
export type MatrixAccountPatch = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -113,7 +114,7 @@ export function updateMatrixAccountConfig(
|
|||||||
): CoreConfig {
|
): CoreConfig {
|
||||||
const matrix = cfg.channels?.matrix ?? {};
|
const matrix = cfg.channels?.matrix ?? {};
|
||||||
const normalizedAccountId = normalizeAccountId(accountId);
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
const existingAccount = (matrix.accounts?.[normalizedAccountId] ??
|
const existingAccount = (findMatrixAccountConfig(cfg, normalizedAccountId) ??
|
||||||
(normalizedAccountId === DEFAULT_ACCOUNT_ID ? matrix : {})) as MatrixConfig;
|
(normalizedAccountId === DEFAULT_ACCOUNT_ID ? matrix : {})) as MatrixConfig;
|
||||||
const nextAccount: Record<string, unknown> = { ...existingAccount };
|
const nextAccount: Record<string, unknown> = { ...existingAccount };
|
||||||
|
|
||||||
@@ -191,6 +192,14 @@ export function updateMatrixAccountConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextAccounts = Object.fromEntries(
|
||||||
|
Object.entries(matrix.accounts ?? {}).filter(
|
||||||
|
([rawAccountId]) =>
|
||||||
|
rawAccountId === normalizedAccountId ||
|
||||||
|
normalizeAccountId(rawAccountId) !== normalizedAccountId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
|
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
|
||||||
const { accounts: _ignoredAccounts, defaultAccount, ...baseMatrix } = matrix;
|
const { accounts: _ignoredAccounts, defaultAccount, ...baseMatrix } = matrix;
|
||||||
return {
|
return {
|
||||||
@@ -215,7 +224,7 @@ export function updateMatrixAccountConfig(
|
|||||||
...matrix,
|
...matrix,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
...matrix.accounts,
|
...nextAccounts,
|
||||||
[normalizedAccountId]: nextAccount as MatrixConfig,
|
[normalizedAccountId]: nextAccount as MatrixConfig,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -122,6 +122,31 @@ describe("registerMatrixAutoJoin", () => {
|
|||||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries alias resolution after an unresolved lookup", async () => {
|
||||||
|
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||||
|
resolveRoom.mockResolvedValueOnce(null).mockResolvedValueOnce("!room:example.org");
|
||||||
|
|
||||||
|
registerMatrixAutoJoin({
|
||||||
|
client,
|
||||||
|
accountConfig: {
|
||||||
|
autoJoin: "allowlist",
|
||||||
|
autoJoinAllowlist: ["#allowed:example.org"],
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteHandler = getInviteHandler();
|
||||||
|
expect(inviteHandler).toBeTruthy();
|
||||||
|
await inviteHandler!("!room:example.org", {});
|
||||||
|
await inviteHandler!("!room:example.org", {});
|
||||||
|
|
||||||
|
expect(resolveRoom).toHaveBeenCalledTimes(2);
|
||||||
|
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not trust room-provided alias claims for allowlist joins", async () => {
|
it("does not trust room-provided alias claims for allowlist joins", async () => {
|
||||||
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||||
resolveRoom.mockResolvedValue("!different-room:example.org");
|
resolveRoom.mockResolvedValue("!different-room:example.org");
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
const autoJoinAllowlist = new Set(rawAllowlist);
|
const autoJoinAllowlist = new Set(rawAllowlist);
|
||||||
const allowedRoomIds = new Set(rawAllowlist.filter((entry) => entry.startsWith("!")));
|
const allowedRoomIds = new Set(rawAllowlist.filter((entry) => entry.startsWith("!")));
|
||||||
const allowedAliases = rawAllowlist.filter((entry) => entry.startsWith("#"));
|
const allowedAliases = rawAllowlist.filter((entry) => entry.startsWith("#"));
|
||||||
const resolvedAliasRoomIds = new Map<string, string | null>();
|
const resolvedAliasRoomIds = new Map<string, string>();
|
||||||
|
|
||||||
if (autoJoin === "off") {
|
if (autoJoin === "off") {
|
||||||
return;
|
return;
|
||||||
@@ -40,7 +40,9 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
return resolvedAliasRoomIds.get(alias) ?? null;
|
return resolvedAliasRoomIds.get(alias) ?? null;
|
||||||
}
|
}
|
||||||
const resolved = await params.client.resolveRoom(alias);
|
const resolved = await params.client.resolveRoom(alias);
|
||||||
resolvedAliasRoomIds.set(alias, resolved);
|
if (resolved) {
|
||||||
|
resolvedAliasRoomIds.set(alias, resolved);
|
||||||
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -83,11 +83,6 @@ function normalizeMatrixThreadTarget(raw: string): string | undefined {
|
|||||||
return normalized || undefined;
|
return normalized || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMatrixDirectUserTarget(raw: string): string | undefined {
|
|
||||||
const normalized = normalizeMatrixThreadTarget(raw);
|
|
||||||
return normalized?.startsWith("@") ? normalized : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveMatrixAutoThreadId(params: {
|
export function resolveMatrixAutoThreadId(params: {
|
||||||
to: string;
|
to: string;
|
||||||
toolContext?: ChannelThreadingToolContext;
|
toolContext?: ChannelThreadingToolContext;
|
||||||
@@ -101,15 +96,11 @@ export function resolveMatrixAutoThreadId(params: {
|
|||||||
if (!target || !currentChannel) {
|
if (!target || !currentChannel) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
// Matrix user:@ targets resolve to a DM room at send time, which can differ
|
||||||
|
// from the current room after DM recreation or stale m.direct ordering.
|
||||||
|
// Only auto-thread when the explicit room target already matches.
|
||||||
if (target.toLowerCase() !== currentChannel.toLowerCase()) {
|
if (target.toLowerCase() !== currentChannel.toLowerCase()) {
|
||||||
const directTarget = normalizeMatrixDirectUserTarget(params.to);
|
return undefined;
|
||||||
const currentDirectUserId = normalizeMatrixDirectUserTarget(context.currentDirectUserId ?? "");
|
|
||||||
if (!directTarget || !currentDirectUserId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (directTarget.toLowerCase() !== currentDirectUserId.toLowerCase()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return context.currentThreadTs;
|
return context.currentThreadTs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ describe("runMessageAction threading auto-injection", () => {
|
|||||||
expect(call?.ctx?.params?.threadId).toBe("$explicit");
|
expect(call?.ctx?.params?.threadId).toBe("$explicit");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("injects threadId for matching Matrix dm user target", async () => {
|
it("skips threadId for Matrix dm user targets until the resolved room matches", async () => {
|
||||||
mockHandledSendAction();
|
mockHandledSendAction();
|
||||||
|
|
||||||
const call = await runThreadingAction({
|
const call = await runThreadingAction({
|
||||||
@@ -322,8 +322,8 @@ describe("runMessageAction threading auto-injection", () => {
|
|||||||
toolContext: defaultMatrixDmToolContext,
|
toolContext: defaultMatrixDmToolContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(call?.threadId).toBe("$thread");
|
expect(call?.threadId).toBeUndefined();
|
||||||
expect(call?.ctx?.params?.threadId).toBe("$thread");
|
expect(call?.ctx?.params?.threadId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips threadId for different Matrix dm user target", async () => {
|
it("skips threadId for different Matrix dm user target", async () => {
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ export {
|
|||||||
resolveMatrixLegacyFlatStoragePaths,
|
resolveMatrixLegacyFlatStoragePaths,
|
||||||
sanitizeMatrixPathSegment,
|
sanitizeMatrixPathSegment,
|
||||||
} from "../infra/matrix-storage-paths.js";
|
} from "../infra/matrix-storage-paths.js";
|
||||||
|
export {
|
||||||
|
requiresExplicitMatrixDefaultAccount,
|
||||||
|
resolveMatrixDefaultOrOnlyAccountId,
|
||||||
|
} from "../infra/matrix-account-selection.js";
|
||||||
export {
|
export {
|
||||||
hasActionableMatrixMigration,
|
hasActionableMatrixMigration,
|
||||||
hasPendingMatrixMigration,
|
hasPendingMatrixMigration,
|
||||||
|
|||||||
Reference in New Issue
Block a user