Matrix: fix typecheck and boundary drift

This commit is contained in:
Gustavo Madeira Santana 2026-03-19 07:59:01 -04:00
parent c4a4050ce4
commit f69450b170
No known key found for this signature in database
24 changed files with 170 additions and 95 deletions

View File

@ -59,7 +59,7 @@ describe("matrixMessageActions", () => {
const discovery = describeMessageTool!({
cfg: createConfiguredMatrixConfig(),
} as never);
} as never) ?? { actions: [] };
const actions = discovery.actions;
expect(actions).toContain("poll");
@ -74,7 +74,7 @@ describe("matrixMessageActions", () => {
const discovery = describeMessageTool!({
cfg: createConfiguredMatrixConfig(),
} as never);
} as never) ?? { actions: [], schema: null };
const actions = discovery.actions;
const properties =
(discovery.schema as { properties?: Record<string, unknown> } | null)?.properties ?? {};
@ -87,64 +87,66 @@ describe("matrixMessageActions", () => {
});
it("hides gated actions when the default Matrix account disables them", () => {
const actions = matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
actions: {
messages: true,
reactions: true,
pins: true,
profile: true,
memberInfo: true,
channelInfo: true,
verification: true,
},
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
actions: {
messages: false,
reactions: false,
pins: false,
profile: false,
memberInfo: false,
channelInfo: false,
verification: false,
const actions =
matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
actions: {
messages: true,
reactions: true,
pins: true,
profile: true,
memberInfo: true,
channelInfo: true,
verification: true,
},
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
actions: {
messages: false,
reactions: false,
pins: false,
profile: false,
memberInfo: false,
channelInfo: false,
verification: false,
},
},
},
},
},
},
} as CoreConfig,
} as never).actions;
} as CoreConfig,
} as never)?.actions ?? [];
expect(actions).toEqual(["poll", "poll-vote"]);
});
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
const actions = matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
accessToken: "assistant-token",
},
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
const actions =
matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
accessToken: "assistant-token",
},
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
},
} as CoreConfig,
} as never).actions;
} as CoreConfig,
} as never)?.actions ?? [];
expect(actions).toEqual([]);
});

View File

@ -2,11 +2,13 @@ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./d
import { resolveMatrixAuth } from "./matrix/client.js";
import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
export const matrixChannelRuntime = {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
matrixOutbound,
probeMatrix,
resolveMatrixAuth,
resolveMatrixTargets,

View File

@ -15,8 +15,8 @@ import {
createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
@ -47,7 +47,6 @@ import {
import { getMatrixRuntime } from "./runtime.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { matrixSetupWizard } from "./setup-surface.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
@ -190,7 +189,6 @@ function matchMatrixAcpConversation(params: {
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
meta,
setupWizard: matrixSetupWizard,
pairing: createTextPairingAdapter({
idLabel: "matrixUserId",
message: PAIRING_APPROVED_MESSAGE,

View File

@ -521,7 +521,9 @@ describe("matrix CLI verification commands", () => {
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
const jsonOutput = console.log.mock.calls.at(-1)?.[0];
const jsonOutput = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(
-1,
)?.[0];
expect(typeof jsonOutput).toBe("string");
expect(JSON.parse(String(jsonOutput))).toEqual(
expect.objectContaining({

View File

@ -12,7 +12,7 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
rooms: {
join: {
"!room:example.org": {
summary: {},
summary: { "m.heroes": [] },
state: { events: [] },
timeline: {
events: [
@ -34,6 +34,9 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
unread_notifications: {},
},
},
invite: {},
leave: {},
knock: {},
},
account_data: {
events: [

View File

@ -52,7 +52,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
nextBatch: value.nextBatch,
accountData: value.accountData,
roomsData: value.roomsData,
} as ISyncData;
} as unknown as ISyncData;
}
// Older Matrix state files stored the raw /sync-shaped payload directly.
@ -64,7 +64,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
? value.account_data.events
: [],
roomsData: isRecord(value.rooms) ? value.rooms : {},
} as ISyncData;
} as unknown as ISyncData;
}
return null;

View File

@ -0,0 +1 @@
export { monitorMatrixProvider } from "./monitor/index.js";

View File

@ -62,7 +62,7 @@ function createHarness(params?: {
const ensureVerificationDmTracked = vi.fn(
params?.ensureVerificationDmTracked ?? (async () => null),
);
const sendMessage = vi.fn(async () => "$notice");
const sendMessage = vi.fn(async (_roomId: string, _payload: { body?: string }) => "$notice");
const invalidateRoom = vi.fn();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const formatNativeDependencyHint = vi.fn(() => "install hint");

View File

@ -100,6 +100,7 @@ function createHandlerHarness() {
mediaMaxBytes: 5 * 1024 * 1024,
startupMs: Date.now() - 120_000,
startupGraceMs: 60_000,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: vi.fn().mockResolvedValue(true),
},

View File

@ -588,11 +588,13 @@ describe("matrix monitor handler pairing account scope", () => {
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: async () => false,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
needsRoomAliasesForConfig: false,
});
await handler(

View File

@ -115,6 +115,7 @@ describe("createMatrixRoomMessageHandler thread root media", () => {
mediaMaxBytes: 5 * 1024 * 1024,
startupMs: Date.now() - 120_000,
startupGraceMs: 60_000,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: vi.fn().mockResolvedValue(true),
},

View File

@ -7,7 +7,6 @@ const hoisted = vi.hoisted(() => {
hasPersistedSyncState: vi.fn(() => false),
};
const createMatrixRoomMessageHandler = vi.fn(() => vi.fn());
let startClientError: Error | null = null;
const resolveTextChunkLimit = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => number
>(() => 4000);
@ -27,7 +26,7 @@ const hoisted = vi.hoisted(() => {
logger,
resolveTextChunkLimit,
setActiveMatrixClient,
startClientError,
startClientError: null as Error | null,
stopSharedClientInstance,
stopThreadBindingManager,
};

View File

@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../../src/config/config.js";
import {
__testing as sessionBindingTesting,
createTestRegistry,
type OpenClawConfig,
resolveAgentRoute,
registerSessionBindingAdapter,
} from "../../../../../src/infra/outbound/session-binding-service.js";
import { setActivePluginRegistry } from "../../../../../src/plugins/runtime.js";
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
import { createTestRegistry } from "../../../../../src/test-utils/channel-plugins.js";
sessionBindingTesting,
setActivePluginRegistry,
} from "../../../../../test/helpers/extensions/matrix-route-test.js";
import { matrixPlugin } from "../../channel.js";
import { resolveMatrixInboundRoute } from "./route.js";

View File

@ -222,7 +222,10 @@ describe("MatrixClient request hardening", () => {
it("prefers authenticated client media downloads", async () => {
const payload = Buffer.from([1, 2, 3, 4]);
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
const fetchMock = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(payload, { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");

View File

@ -4,6 +4,7 @@ import { EventEmitter } from "node:events";
import {
ClientEvent,
MatrixEventEvent,
Preset,
createClient as createMatrixJsClient,
type MatrixClient as MatrixJsClient,
type MatrixEvent,
@ -547,7 +548,7 @@ export class MatrixClient {
const result = await this.client.createRoom({
invite: [remoteUserId],
is_direct: true,
preset: "trusted_private_chat",
preset: Preset.TrustedPrivateChat,
initial_state: initialState,
});
return result.room_id;

View File

@ -621,14 +621,6 @@ export async function createMatrixThreadBindingManager(params: {
});
return record ? toSessionBindingRecord(record, defaults) : null;
},
setIdleTimeoutBySession: ({ targetSessionKey, idleTimeoutMs }) =>
manager
.setIdleTimeoutBySessionKey({ targetSessionKey, idleTimeoutMs })
.map((record) => toSessionBindingRecord(record, defaults)),
setMaxAgeBySession: ({ targetSessionKey, maxAgeMs }) =>
manager
.setMaxAgeBySessionKey({ targetSessionKey, maxAgeMs })
.map((record) => toSessionBindingRecord(record, defaults)),
touch: (bindingId, at) => {
manager.touchBinding(bindingId, at);
},

View File

@ -1,8 +1,5 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
type ChannelSetupDmPolicy,
type ChannelSetupWizardAdapter,
} from "openclaw/plugin-sdk/setup";
import { type ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import {
@ -36,6 +33,54 @@ import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
type MatrixOnboardingStatus = {
channel: typeof channel;
configured: boolean;
statusLines: string[];
selectionHint?: string;
quickstartScore?: number;
};
type MatrixAccountOverrides = Partial<Record<typeof channel, string>>;
type MatrixOnboardingConfigureContext = {
cfg: CoreConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: unknown;
forceAllowFrom: boolean;
accountOverrides: MatrixAccountOverrides;
shouldPromptAccountIds: boolean;
};
type MatrixOnboardingInteractiveContext = MatrixOnboardingConfigureContext & {
configured: boolean;
label?: string;
};
type MatrixOnboardingAdapter = {
channel: typeof channel;
getStatus: (ctx: {
cfg: CoreConfig;
options?: unknown;
accountOverrides: MatrixAccountOverrides;
}) => Promise<MatrixOnboardingStatus>;
configure: (
ctx: MatrixOnboardingConfigureContext,
) => Promise<{ cfg: CoreConfig; accountId?: string }>;
configureInteractive?: (
ctx: MatrixOnboardingInteractiveContext,
) => Promise<{ cfg: CoreConfig; accountId?: string } | "skip">;
afterConfigWritten?: (ctx: {
previousCfg: CoreConfig;
cfg: CoreConfig;
accountId: string;
runtime: RuntimeEnv;
}) => Promise<void> | void;
dmPolicy?: ChannelSetupDmPolicy;
disable?: (cfg: CoreConfig) => CoreConfig;
};
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
return normalizeAccountId(
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
@ -473,7 +518,7 @@ async function runMatrixConfigure(params: {
return { cfg: next, accountId };
}
export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
export const matrixOnboardingAdapter: MatrixOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const resolvedCfg = cfg as CoreConfig;

View File

@ -12,6 +12,7 @@ import * as heartbeatWake from "../infra/heartbeat-wake.js";
import {
__testing as sessionBindingServiceTesting,
registerSessionBindingAdapter,
type SessionBindingPlacement,
type SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
import * as acpSpawnParentStream from "./acp-spawn-parent-stream.js";
@ -104,7 +105,7 @@ function createSessionBindingCapabilities() {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] as const,
placements: ["current", "child"] satisfies SessionBindingPlacement[],
};
}
@ -179,8 +180,8 @@ describe("spawnAcpDirect", () => {
metaCleared: false,
});
getAcpSessionManagerSpy.mockReset().mockReturnValue({
initializeSession: async (params) => await hoisted.initializeSessionMock(params),
closeSession: async (params) => await hoisted.closeSessionMock(params),
initializeSession: async (params: unknown) => await hoisted.initializeSessionMock(params),
closeSession: async (params: unknown) => await hoisted.closeSessionMock(params),
} as unknown as ReturnType<typeof acpSessionManager.getAcpSessionManager>);
hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
const args = argsUnknown as {
@ -1039,7 +1040,7 @@ describe("spawnAcpDirect", () => {
...hoisted.state.cfg.channels,
telegram: {
threadBindings: {
spawnAcpSessions: true,
enabled: true,
},
},
},

View File

@ -68,8 +68,8 @@ const readLatestAssistantReplyMock = vi.fn(
const embeddedRunMock = {
isEmbeddedPiRunActive: vi.fn(() => false),
isEmbeddedPiRunStreaming: vi.fn(() => false),
queueEmbeddedPiMessage: vi.fn(() => false),
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
queueEmbeddedPiMessage: vi.fn((_: string, __: string) => false),
waitForEmbeddedPiRunEnd: vi.fn(async (_: string, __?: number) => true),
};
const { subagentRegistryMock } = vi.hoisted(() => ({
subagentRegistryMock: {
@ -131,11 +131,17 @@ function setConfigOverride(next: OpenClawConfig): void {
setRuntimeConfigSnapshot(configOverride);
}
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
return new Proxy(sessionStore, {
function loadSessionStoreFixture(): ReturnType<typeof configSessions.loadSessionStore> {
return new Proxy(sessionStore as ReturnType<typeof configSessions.loadSessionStore>, {
get(target, key: string | symbol) {
if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) {
return { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
return {
sessionId: key,
updatedAt: Date.now(),
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
};
}
return target[key as keyof typeof target];
},
@ -207,7 +213,11 @@ describe("subagent announce formatting", () => {
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");
resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json");
resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main");
getGlobalHookRunnerSpy.mockReset().mockImplementation(() => hookRunnerMock);
getGlobalHookRunnerSpy
.mockReset()
.mockImplementation(
() => hookRunnerMock as unknown as ReturnType<typeof hookRunnerGlobal.getGlobalHookRunner>,
);
readLatestAssistantReplySpy
.mockReset()
.mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey));

View File

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

View File

@ -350,14 +350,15 @@ export async function channelsAddCommand(
await writeConfigFile(nextConfig);
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
if (plugin.setup.afterAccountConfigWritten) {
const setup = plugin.setup;
if (setup?.afterAccountConfigWritten) {
await runCollectedChannelOnboardingPostWriteHooks({
hooks: [
{
channel,
accountId,
run: async ({ cfg: writtenCfg, runtime: hookRuntime }) =>
await plugin.setup.afterAccountConfigWritten?.({
await setup.afterAccountConfigWritten?.({
previousCfg: cfg,
cfg: writtenCfg,
accountId,

View File

@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import {
isMatrixLegacyCryptoInspectorAvailable,
loadMatrixLegacyCryptoInspector,
@ -89,13 +90,13 @@ describe("matrix plugin helper resolution", () => {
].join("\n"),
);
const cfg = {
const cfg: OpenClawConfig = {
plugins: {
load: {
paths: [customRoot],
},
},
} as const;
};
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true);
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
@ -160,13 +161,13 @@ describe("matrix plugin helper resolution", () => {
return;
}
const cfg = {
const cfg: OpenClawConfig = {
plugins: {
load: {
paths: [customRoot],
},
},
} as const;
};
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false);
await expect(

View File

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

View File

@ -0,0 +1,8 @@
export type { OpenClawConfig } from "../../../src/config/config.js";
export {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "../../../src/infra/outbound/session-binding-service.js";
export { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
export { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";