Matrix-js: add legacy config migration and bind integration coverage

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 19:29:53 -05:00
parent 6405871a9d
commit 1a2d446788
6 changed files with 173 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "./channel.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
@@ -153,7 +154,11 @@ describe("matrix directory", () => {
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accessToken).toBe("default-token");
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
accessToken: "default-token",
homeserver: "https://default.example.org",
});
expect(updated.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
@@ -182,7 +187,7 @@ describe("matrix directory", () => {
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.homeserver).toBe("https://legacy.example.org");
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
@@ -191,6 +196,32 @@ describe("matrix directory", () => {
});
});
it("migrates legacy top-level matrix-js credentials into accounts.default", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
encryption: true,
},
},
} as unknown as CoreConfig;
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.deviceName).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.encryption).toBe(true);
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
});
});
it("rejects useEnv for non-default matrix-js accounts", () => {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,

View File

@@ -13,6 +13,7 @@ import {
type ChannelPlugin,
} from "openclaw/plugin-sdk";
import { matrixMessageActions } from "./actions.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import {
@@ -357,8 +358,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const migratedConfig = migrateMatrixLegacyCredentialsToDefaultAccount(cfg as CoreConfig);
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
cfg: migratedConfig,
channelKey: "matrix-js",
accountId,
name: input.name,

View File

@@ -0,0 +1,70 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "./types.js";
type LegacyAccountField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName"
| "initialSyncLimit";
const LEGACY_ACCOUNT_FIELDS: ReadonlyArray<LegacyAccountField> = [
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"initialSyncLimit",
];
export function migrateMatrixLegacyCredentialsToDefaultAccount(cfg: CoreConfig): CoreConfig {
const matrix = cfg.channels?.["matrix-js"];
if (!matrix) {
return cfg;
}
const defaultAccount = {
...(matrix.accounts?.[DEFAULT_ACCOUNT_ID] ?? {}),
} as MatrixAccountConfig;
let changed = false;
for (const field of LEGACY_ACCOUNT_FIELDS) {
const legacyValue = matrix[field] as MatrixAccountConfig[LegacyAccountField] | undefined;
if (legacyValue === undefined) {
continue;
}
if (defaultAccount[field] === undefined) {
(
defaultAccount as Record<
LegacyAccountField,
MatrixAccountConfig[LegacyAccountField] | undefined
>
)[field] = legacyValue;
}
changed = true;
}
if (!changed) {
return cfg;
}
const nextMatrix = { ...matrix } as MatrixConfig;
for (const field of LEGACY_ACCOUNT_FIELDS) {
delete nextMatrix[field];
}
nextMatrix.accounts = {
...matrix.accounts,
[DEFAULT_ACCOUNT_ID]: defaultAccount,
};
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": nextMatrix,
},
};
}

View File

@@ -189,6 +189,15 @@ describe("registerAgentCommands", () => {
);
});
it("documents bind accountId resolution behavior in help text", () => {
const program = new Command();
registerAgentCommands(program, { agentChannelOptions: "last|telegram|discord" });
const agents = program.commands.find((command) => command.name() === "agents");
const bind = agents?.commands.find((command) => command.name() === "bind");
const help = bind?.helpInformation() ?? "";
expect(help).toContain("accountId is resolved by channel defaults/hooks");
});
it("forwards agents unbind options", async () => {
await runCli(["agents", "unbind", "--agent", "ops", "--all", "--json"]);
expect(agentsUnbindCommandMock).toHaveBeenCalledWith(

View File

@@ -126,7 +126,12 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
.command("bind")
.description("Add routing bindings for an agent")
.option("--agent <id>", "Agent id (defaults to current default agent)")
.option("--bind <channel[:accountId]>", "Binding to add (repeatable)", collectOption, [])
.option(
"--bind <channel[:accountId]>",
"Binding to add (repeatable). If omitted, accountId is resolved by channel defaults/hooks.",
collectOption,
[],
)
.option("--json", "Output JSON summary", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {

View File

@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "../../extensions/matrix-js/src/channel.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { agentsBindCommand } from "./agents.js";
import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: readConfigFileSnapshotMock,
writeConfigFile: writeConfigFileMock,
}));
describe("agents bind matrix-js integration", () => {
const runtime = createTestRuntime();
beforeEach(() => {
readConfigFileSnapshotMock.mockClear();
writeConfigFileMock.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
setActivePluginRegistry(
createTestRegistry([{ pluginId: "matrix-js", plugin: matrixPlugin, source: "test" }]),
);
});
afterEach(() => {
setDefaultChannelPluginRegistryForTests();
});
it("uses matrix-js plugin binding resolver when accountId is omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
});