@openclaw/matrix 2026.2.25 → 2026.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/index.ts +5 -0
  3. package/package.json +1 -1
  4. package/src/channel.ts +6 -12
  5. package/src/config-schema.test.ts +26 -0
  6. package/src/config-schema.ts +4 -1
  7. package/src/directory-live.test.ts +11 -0
  8. package/src/directory-live.ts +2 -1
  9. package/src/matrix/accounts.test.ts +50 -1
  10. package/src/matrix/accounts.ts +15 -2
  11. package/src/matrix/client/config.ts +55 -29
  12. package/src/matrix/client/create-client.ts +7 -5
  13. package/src/matrix/client/logging.ts +17 -7
  14. package/src/matrix/client/shared.test.ts +85 -0
  15. package/src/matrix/client/shared.ts +11 -2
  16. package/src/matrix/client/startup.test.ts +49 -0
  17. package/src/matrix/client/startup.ts +29 -0
  18. package/src/matrix/client-bootstrap.ts +10 -2
  19. package/src/matrix/deps.test.ts +74 -0
  20. package/src/matrix/deps.ts +66 -0
  21. package/src/matrix/monitor/access-policy.ts +127 -0
  22. package/src/matrix/monitor/allowlist.ts +4 -15
  23. package/src/matrix/monitor/auto-join.ts +2 -1
  24. package/src/matrix/monitor/direct.test.ts +65 -0
  25. package/src/matrix/monitor/direct.ts +20 -7
  26. package/src/matrix/monitor/events.test.ts +31 -0
  27. package/src/matrix/monitor/events.ts +20 -0
  28. package/src/matrix/monitor/handler.body-for-agent.test.ts +142 -0
  29. package/src/matrix/monitor/handler.ts +69 -63
  30. package/src/matrix/monitor/inbound-body.test.ts +73 -0
  31. package/src/matrix/monitor/inbound-body.ts +28 -0
  32. package/src/matrix/monitor/index.test.ts +18 -0
  33. package/src/matrix/monitor/index.ts +204 -147
  34. package/src/matrix/sdk-runtime.ts +18 -0
  35. package/src/matrix/send-queue.ts +7 -23
  36. package/src/matrix/send.test.ts +4 -0
  37. package/src/onboarding.ts +36 -23
  38. package/src/secret-input.ts +19 -0
  39. package/src/types.ts +5 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.2
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.1
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.2.26
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
3
21
  ## 2026.2.25
4
22
 
5
23
  ### Changes
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
3
  import { matrixPlugin } from "./src/channel.js";
4
+ import { ensureMatrixCryptoRuntime } from "./src/matrix/deps.js";
4
5
  import { setMatrixRuntime } from "./src/runtime.js";
5
6
 
6
7
  const plugin = {
@@ -10,6 +11,10 @@ const plugin = {
10
11
  configSchema: emptyPluginConfigSchema(),
11
12
  register(api: OpenClawPluginApi) {
12
13
  setMatrixRuntime(api.runtime);
14
+ void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err) => {
15
+ const message = err instanceof Error ? err.message : String(err);
16
+ api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
17
+ });
13
18
  api.registerChannel({ plugin: matrixPlugin });
14
19
  },
15
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/matrix",
3
- "version": "2026.2.25",
3
+ "version": "2026.3.2",
4
4
  "description": "OpenClaw Matrix channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/channel.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  applyAccountNameToChannelSection,
3
3
  buildChannelConfigSchema,
4
+ buildProbeChannelStatusSummary,
4
5
  DEFAULT_ACCOUNT_ID,
5
6
  deleteAccountFromConfigSection,
6
7
  formatPairingApproveHint,
@@ -32,6 +33,7 @@ import { sendMessageMatrix } from "./matrix/send.js";
32
33
  import { matrixOnboardingAdapter } from "./onboarding.js";
33
34
  import { matrixOutbound } from "./outbound.js";
34
35
  import { resolveMatrixTargets } from "./resolve-targets.js";
36
+ import { normalizeSecretInputString } from "./secret-input.js";
35
37
  import type { CoreConfig } from "./types.js";
36
38
 
37
39
  // Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
@@ -325,7 +327,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
325
327
  return "Matrix requires --homeserver";
326
328
  }
327
329
  const accessToken = input.accessToken?.trim();
328
- const password = input.password?.trim();
330
+ const password = normalizeSecretInputString(input.password);
329
331
  const userId = input.userId?.trim();
330
332
  if (!accessToken && !password) {
331
333
  return "Matrix requires --access-token or --password";
@@ -363,7 +365,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
363
365
  homeserver: input.homeserver?.trim(),
364
366
  userId: input.userId?.trim(),
365
367
  accessToken: input.accessToken?.trim(),
366
- password: input.password?.trim(),
368
+ password: normalizeSecretInputString(input.password),
367
369
  deviceName: input.deviceName?.trim(),
368
370
  initialSyncLimit: input.initialSyncLimit,
369
371
  });
@@ -393,16 +395,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
393
395
  },
394
396
  ];
395
397
  }),
396
- buildChannelSummary: ({ snapshot }) => ({
397
- configured: snapshot.configured ?? false,
398
- baseUrl: snapshot.baseUrl ?? null,
399
- running: snapshot.running ?? false,
400
- lastStartAt: snapshot.lastStartAt ?? null,
401
- lastStopAt: snapshot.lastStopAt ?? null,
402
- lastError: snapshot.lastError ?? null,
403
- probe: snapshot.probe,
404
- lastProbeAt: snapshot.lastProbeAt ?? null,
405
- }),
398
+ buildChannelSummary: ({ snapshot }) =>
399
+ buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
406
400
  probeAccount: async ({ account, timeoutMs, cfg }) => {
407
401
  try {
408
402
  const auth = await resolveMatrixAuth({
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MatrixConfigSchema } from "./config-schema.js";
3
+
4
+ describe("MatrixConfigSchema SecretInput", () => {
5
+ it("accepts SecretRef password at top-level", () => {
6
+ const result = MatrixConfigSchema.safeParse({
7
+ homeserver: "https://matrix.example.org",
8
+ userId: "@bot:example.org",
9
+ password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
10
+ });
11
+ expect(result.success).toBe(true);
12
+ });
13
+
14
+ it("accepts SecretRef password on account", () => {
15
+ const result = MatrixConfigSchema.safeParse({
16
+ accounts: {
17
+ work: {
18
+ homeserver: "https://matrix.example.org",
19
+ userId: "@bot:example.org",
20
+ password: { source: "env", provider: "default", id: "MATRIX_WORK_PASSWORD" },
21
+ },
22
+ },
23
+ });
24
+ expect(result.success).toBe(true);
25
+ });
26
+ });
@@ -1,5 +1,6 @@
1
1
  import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
2
2
  import { z } from "zod";
3
+ import { buildSecretInputSchema } from "./secret-input.js";
3
4
 
4
5
  const allowFromEntry = z.union([z.string(), z.number()]);
5
6
 
@@ -37,11 +38,13 @@ const matrixRoomSchema = z
37
38
  export const MatrixConfigSchema = z.object({
38
39
  name: z.string().optional(),
39
40
  enabled: z.boolean().optional(),
41
+ defaultAccount: z.string().optional(),
42
+ accounts: z.record(z.string(), z.unknown()).optional(),
40
43
  markdown: MarkdownConfigSchema,
41
44
  homeserver: z.string().optional(),
42
45
  userId: z.string().optional(),
43
46
  accessToken: z.string().optional(),
44
- password: z.string().optional(),
47
+ password: buildSecretInputSchema().optional(),
45
48
  deviceName: z.string().optional(),
46
49
  initialSyncLimit: z.number().optional(),
47
50
  encryption: z.boolean().optional(),
@@ -71,4 +71,15 @@ describe("matrix directory live", () => {
71
71
  expect(result).toEqual([]);
72
72
  expect(resolveMatrixAuth).not.toHaveBeenCalled();
73
73
  });
74
+
75
+ it("preserves original casing for room IDs without :server suffix", async () => {
76
+ const mixedCaseId = "!EonMPPbOuhntHEHgZ2dnBO-c_EglMaXlIh2kdo8cgiA";
77
+ const result = await listMatrixDirectoryGroupsLive({
78
+ cfg,
79
+ query: mixedCaseId,
80
+ });
81
+
82
+ expect(result).toHaveLength(1);
83
+ expect(result[0].id).toBe(mixedCaseId);
84
+ });
74
85
  });
@@ -174,7 +174,8 @@ export async function listMatrixDirectoryGroupsLive(
174
174
  }
175
175
 
176
176
  if (query.startsWith("!")) {
177
- return [createGroupDirectoryEntry({ id: query, name: query })];
177
+ const originalId = params.query?.trim() ?? query;
178
+ return [createGroupDirectoryEntry({ id: originalId, name: originalId })];
178
179
  }
179
180
 
180
181
  const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import type { CoreConfig } from "../types.js";
3
- import { resolveMatrixAccount } from "./accounts.js";
3
+ import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./accounts.js";
4
4
 
5
5
  vi.mock("./credentials.js", () => ({
6
6
  loadMatrixCredentials: () => null,
@@ -80,3 +80,52 @@ describe("resolveMatrixAccount", () => {
80
80
  expect(account.configured).toBe(true);
81
81
  });
82
82
  });
83
+
84
+ describe("resolveDefaultMatrixAccountId", () => {
85
+ it("prefers channels.matrix.defaultAccount when it matches a configured account", () => {
86
+ const cfg: CoreConfig = {
87
+ channels: {
88
+ matrix: {
89
+ defaultAccount: "alerts",
90
+ accounts: {
91
+ default: { homeserver: "https://matrix.example.org", accessToken: "tok-default" },
92
+ alerts: { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
93
+ },
94
+ },
95
+ },
96
+ };
97
+
98
+ expect(resolveDefaultMatrixAccountId(cfg)).toBe("alerts");
99
+ });
100
+
101
+ it("normalizes channels.matrix.defaultAccount before lookup", () => {
102
+ const cfg: CoreConfig = {
103
+ channels: {
104
+ matrix: {
105
+ defaultAccount: "Team Alerts",
106
+ accounts: {
107
+ "team-alerts": { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
108
+ },
109
+ },
110
+ },
111
+ };
112
+
113
+ expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-alerts");
114
+ });
115
+
116
+ it("falls back when channels.matrix.defaultAccount is not configured", () => {
117
+ const cfg: CoreConfig = {
118
+ channels: {
119
+ matrix: {
120
+ defaultAccount: "missing",
121
+ accounts: {
122
+ default: { homeserver: "https://matrix.example.org", accessToken: "tok-default" },
123
+ alerts: { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
124
+ },
125
+ },
126
+ },
127
+ };
128
+
129
+ expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
130
+ });
131
+ });
@@ -1,4 +1,9 @@
1
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
1
+ import {
2
+ DEFAULT_ACCOUNT_ID,
3
+ normalizeAccountId,
4
+ normalizeOptionalAccountId,
5
+ } from "openclaw/plugin-sdk/account-id";
6
+ import { hasConfiguredSecretInput } from "../secret-input.js";
2
7
  import type { CoreConfig, MatrixConfig } from "../types.js";
3
8
  import { resolveMatrixConfigForAccount } from "./client.js";
4
9
  import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
@@ -16,6 +21,7 @@ function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixCo
16
21
  }
17
22
  // Don't propagate the accounts map into the merged per-account config
18
23
  delete (merged as Record<string, unknown>).accounts;
24
+ delete (merged as Record<string, unknown>).defaultAccount;
19
25
  return merged;
20
26
  }
21
27
 
@@ -54,6 +60,13 @@ export function listMatrixAccountIds(cfg: CoreConfig): string[] {
54
60
  }
55
61
 
56
62
  export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
63
+ const preferred = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
64
+ if (
65
+ preferred &&
66
+ listMatrixAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
67
+ ) {
68
+ return preferred;
69
+ }
57
70
  const ids = listMatrixAccountIds(cfg);
58
71
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
59
72
  return DEFAULT_ACCOUNT_ID;
@@ -94,7 +107,7 @@ export function resolveMatrixAccount(params: {
94
107
  const hasUserId = Boolean(resolved.userId);
95
108
  const hasAccessToken = Boolean(resolved.accessToken);
96
109
  const hasPassword = Boolean(resolved.password);
97
- const hasPasswordAuth = hasUserId && hasPassword;
110
+ const hasPasswordAuth = hasUserId && (hasPassword || hasConfiguredSecretInput(base.password));
98
111
  const stored = loadMatrixCredentials(process.env, accountId);
99
112
  const hasStored =
100
113
  stored && resolved.homeserver
@@ -1,12 +1,17 @@
1
- import { MatrixClient } from "@vector-im/matrix-bot-sdk";
1
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
3
  import { getMatrixRuntime } from "../../runtime.js";
4
+ import {
5
+ normalizeResolvedSecretInputString,
6
+ normalizeSecretInputString,
7
+ } from "../../secret-input.js";
4
8
  import type { CoreConfig } from "../../types.js";
9
+ import { loadMatrixSdk } from "../sdk-runtime.js";
5
10
  import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
6
11
  import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
7
12
 
8
- function clean(value?: string): string {
9
- return value?.trim() ?? "";
13
+ function clean(value: unknown, path: string): string {
14
+ return normalizeResolvedSecretInputString({ value, path }) ?? "";
10
15
  }
11
16
 
12
17
  /** Shallow-merge known nested config sub-objects so partial overrides inherit base values. */
@@ -52,11 +57,23 @@ export function resolveMatrixConfigForAccount(
52
57
  // nested object inheritance (dm, actions, groups) so partial overrides work.
53
58
  const matrix = accountConfig ? deepMergeConfig(matrixBase, accountConfig) : matrixBase;
54
59
 
55
- const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
56
- const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
57
- const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
58
- const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
59
- const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
60
+ const homeserver =
61
+ clean(matrix.homeserver, "channels.matrix.homeserver") ||
62
+ clean(env.MATRIX_HOMESERVER, "MATRIX_HOMESERVER");
63
+ const userId =
64
+ clean(matrix.userId, "channels.matrix.userId") || clean(env.MATRIX_USER_ID, "MATRIX_USER_ID");
65
+ const accessToken =
66
+ clean(matrix.accessToken, "channels.matrix.accessToken") ||
67
+ clean(env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN") ||
68
+ undefined;
69
+ const password =
70
+ clean(matrix.password, "channels.matrix.password") ||
71
+ clean(env.MATRIX_PASSWORD, "MATRIX_PASSWORD") ||
72
+ undefined;
73
+ const deviceName =
74
+ clean(matrix.deviceName, "channels.matrix.deviceName") ||
75
+ clean(env.MATRIX_DEVICE_NAME, "MATRIX_DEVICE_NAME") ||
76
+ undefined;
60
77
  const initialSyncLimit =
61
78
  typeof matrix.initialSyncLimit === "number"
62
79
  ? Math.max(0, Math.floor(matrix.initialSyncLimit))
@@ -119,6 +136,7 @@ export async function resolveMatrixAuth(params?: {
119
136
  if (!userId) {
120
137
  // Fetch userId from access token via whoami
121
138
  ensureMatrixSdkLoggingConfigured();
139
+ const { MatrixClient } = loadMatrixSdk();
122
140
  const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
123
141
  const whoami = await tempClient.getUserId();
124
142
  userId = whoami;
@@ -167,28 +185,36 @@ export async function resolveMatrixAuth(params?: {
167
185
  );
168
186
  }
169
187
 
170
- // Login with password using HTTP API
171
- const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
172
- method: "POST",
173
- headers: { "Content-Type": "application/json" },
174
- body: JSON.stringify({
175
- type: "m.login.password",
176
- identifier: { type: "m.id.user", user: resolved.userId },
177
- password: resolved.password,
178
- initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
179
- }),
188
+ // Login with password using HTTP API.
189
+ const { response: loginResponse, release: releaseLoginResponse } = await fetchWithSsrFGuard({
190
+ url: `${resolved.homeserver}/_matrix/client/v3/login`,
191
+ init: {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify({
195
+ type: "m.login.password",
196
+ identifier: { type: "m.id.user", user: resolved.userId },
197
+ password: resolved.password,
198
+ initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
199
+ }),
200
+ },
201
+ auditContext: "matrix.login",
180
202
  });
181
-
182
- if (!loginResponse.ok) {
183
- const errorText = await loginResponse.text();
184
- throw new Error(`Matrix login failed: ${errorText}`);
185
- }
186
-
187
- const login = (await loginResponse.json()) as {
188
- access_token?: string;
189
- user_id?: string;
190
- device_id?: string;
191
- };
203
+ const login = await (async () => {
204
+ try {
205
+ if (!loginResponse.ok) {
206
+ const errorText = await loginResponse.text();
207
+ throw new Error(`Matrix login failed: ${errorText}`);
208
+ }
209
+ return (await loginResponse.json()) as {
210
+ access_token?: string;
211
+ user_id?: string;
212
+ device_id?: string;
213
+ };
214
+ } finally {
215
+ await releaseLoginResponse();
216
+ }
217
+ })();
192
218
 
193
219
  const accessToken = login.access_token?.trim();
194
220
  if (!accessToken) {
@@ -1,11 +1,10 @@
1
1
  import fs from "node:fs";
2
- import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
3
- import {
4
- LogService,
2
+ import type {
3
+ IStorageProvider,
4
+ ICryptoStorageProvider,
5
5
  MatrixClient,
6
- SimpleFsStorageProvider,
7
- RustSdkCryptoStorageProvider,
8
6
  } from "@vector-im/matrix-bot-sdk";
7
+ import { loadMatrixSdk } from "../sdk-runtime.js";
9
8
  import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
10
9
  import {
11
10
  maybeMigrateLegacyStorage,
@@ -14,6 +13,7 @@ import {
14
13
  } from "./storage.js";
15
14
 
16
15
  function sanitizeUserIdList(input: unknown, label: string): string[] {
16
+ const LogService = loadMatrixSdk().LogService;
17
17
  if (input == null) {
18
18
  return [];
19
19
  }
@@ -44,6 +44,8 @@ export async function createMatrixClient(params: {
44
44
  localTimeoutMs?: number;
45
45
  accountId?: string | null;
46
46
  }): Promise<MatrixClient> {
47
+ const { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, LogService } =
48
+ loadMatrixSdk();
47
49
  ensureMatrixSdkLoggingConfigured();
48
50
  const env = process.env;
49
51
 
@@ -1,7 +1,15 @@
1
- import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
1
+ import { loadMatrixSdk } from "../sdk-runtime.js";
2
2
 
3
3
  let matrixSdkLoggingConfigured = false;
4
- const matrixSdkBaseLogger = new ConsoleLogger();
4
+ let matrixSdkBaseLogger:
5
+ | {
6
+ trace: (module: string, ...messageOrObject: unknown[]) => void;
7
+ debug: (module: string, ...messageOrObject: unknown[]) => void;
8
+ info: (module: string, ...messageOrObject: unknown[]) => void;
9
+ warn: (module: string, ...messageOrObject: unknown[]) => void;
10
+ error: (module: string, ...messageOrObject: unknown[]) => void;
11
+ }
12
+ | undefined;
5
13
 
6
14
  function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
7
15
  if (module !== "MatrixHttpClient") {
@@ -19,18 +27,20 @@ export function ensureMatrixSdkLoggingConfigured(): void {
19
27
  if (matrixSdkLoggingConfigured) {
20
28
  return;
21
29
  }
30
+ const { ConsoleLogger, LogService } = loadMatrixSdk();
31
+ matrixSdkBaseLogger = new ConsoleLogger();
22
32
  matrixSdkLoggingConfigured = true;
23
33
 
24
34
  LogService.setLogger({
25
- trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
26
- debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
27
- info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
28
- warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
35
+ trace: (module, ...messageOrObject) => matrixSdkBaseLogger?.trace(module, ...messageOrObject),
36
+ debug: (module, ...messageOrObject) => matrixSdkBaseLogger?.debug(module, ...messageOrObject),
37
+ info: (module, ...messageOrObject) => matrixSdkBaseLogger?.info(module, ...messageOrObject),
38
+ warn: (module, ...messageOrObject) => matrixSdkBaseLogger?.warn(module, ...messageOrObject),
29
39
  error: (module, ...messageOrObject) => {
30
40
  if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
31
41
  return;
32
42
  }
33
- matrixSdkBaseLogger.error(module, ...messageOrObject);
43
+ matrixSdkBaseLogger?.error(module, ...messageOrObject);
34
44
  },
35
45
  });
36
46
  }
@@ -0,0 +1,85 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { resolveSharedMatrixClient, stopSharedClient } from "./shared.js";
4
+ import type { MatrixAuth } from "./types.js";
5
+
6
+ const createMatrixClientMock = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("./create-client.js", () => ({
9
+ createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
10
+ }));
11
+
12
+ function makeAuth(suffix: string): MatrixAuth {
13
+ return {
14
+ homeserver: "https://matrix.example.org",
15
+ userId: `@bot-${suffix}:example.org`,
16
+ accessToken: `token-${suffix}`,
17
+ encryption: false,
18
+ };
19
+ }
20
+
21
+ function createMockClient(startImpl: () => Promise<void>): MatrixClient {
22
+ return {
23
+ start: vi.fn(startImpl),
24
+ stop: vi.fn(),
25
+ getJoinedRooms: vi.fn().mockResolvedValue([]),
26
+ crypto: undefined,
27
+ } as unknown as MatrixClient;
28
+ }
29
+
30
+ describe("resolveSharedMatrixClient startup behavior", () => {
31
+ afterEach(() => {
32
+ stopSharedClient();
33
+ createMatrixClientMock.mockReset();
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ it("propagates the original start error during initialization", async () => {
38
+ vi.useFakeTimers();
39
+ const startError = new Error("bad token");
40
+ const client = createMockClient(
41
+ () =>
42
+ new Promise<void>((_resolve, reject) => {
43
+ setTimeout(() => reject(startError), 1);
44
+ }),
45
+ );
46
+ createMatrixClientMock.mockResolvedValue(client);
47
+
48
+ const startPromise = resolveSharedMatrixClient({
49
+ auth: makeAuth("start-error"),
50
+ });
51
+ const startExpectation = expect(startPromise).rejects.toBe(startError);
52
+
53
+ await vi.advanceTimersByTimeAsync(2001);
54
+ await startExpectation;
55
+ });
56
+
57
+ it("retries start after a late start-loop failure", async () => {
58
+ vi.useFakeTimers();
59
+ let rejectFirstStart: ((err: unknown) => void) | undefined;
60
+ const firstStart = new Promise<void>((_resolve, reject) => {
61
+ rejectFirstStart = reject;
62
+ });
63
+ const secondStart = new Promise<void>(() => {});
64
+ const startMock = vi.fn().mockReturnValueOnce(firstStart).mockReturnValueOnce(secondStart);
65
+ const client = createMockClient(startMock);
66
+ createMatrixClientMock.mockResolvedValue(client);
67
+
68
+ const firstResolve = resolveSharedMatrixClient({
69
+ auth: makeAuth("late-failure"),
70
+ });
71
+ await vi.advanceTimersByTimeAsync(2000);
72
+ await expect(firstResolve).resolves.toBe(client);
73
+ expect(startMock).toHaveBeenCalledTimes(1);
74
+
75
+ rejectFirstStart?.(new Error("late failure"));
76
+ await Promise.resolve();
77
+
78
+ const secondResolve = resolveSharedMatrixClient({
79
+ auth: makeAuth("late-failure"),
80
+ });
81
+ await vi.advanceTimersByTimeAsync(2000);
82
+ await expect(secondResolve).resolves.toBe(client);
83
+ expect(startMock).toHaveBeenCalledTimes(2);
84
+ });
85
+ });
@@ -1,9 +1,10 @@
1
1
  import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
- import { LogService } from "@vector-im/matrix-bot-sdk";
3
2
  import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
4
3
  import type { CoreConfig } from "../../types.js";
4
+ import { getMatrixLogService } from "../sdk-runtime.js";
5
5
  import { resolveMatrixAuth } from "./config.js";
6
6
  import { createMatrixClient } from "./create-client.js";
7
+ import { startMatrixClientWithGrace } from "./startup.js";
7
8
  import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
8
9
  import type { MatrixAuth } from "./types.js";
9
10
 
@@ -80,11 +81,19 @@ async function ensureSharedClientStarted(params: {
80
81
  params.state.cryptoReady = true;
81
82
  }
82
83
  } catch (err) {
84
+ const LogService = getMatrixLogService();
83
85
  LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
84
86
  }
85
87
  }
86
88
 
87
- await client.start();
89
+ await startMatrixClientWithGrace({
90
+ client,
91
+ onError: (err: unknown) => {
92
+ params.state.started = false;
93
+ const LogService = getMatrixLogService();
94
+ LogService.error("MatrixClientLite", "client.start() error:", err);
95
+ },
96
+ });
88
97
  params.state.started = true;
89
98
  })();
90
99
  sharedClientStartPromises.set(key, startPromise);
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { MATRIX_CLIENT_STARTUP_GRACE_MS, startMatrixClientWithGrace } from "./startup.js";
3
+
4
+ describe("startMatrixClientWithGrace", () => {
5
+ it("resolves after grace when start loop keeps running", async () => {
6
+ vi.useFakeTimers();
7
+ const client = {
8
+ start: vi.fn().mockReturnValue(new Promise<void>(() => {})),
9
+ };
10
+ const startPromise = startMatrixClientWithGrace({ client });
11
+ await vi.advanceTimersByTimeAsync(MATRIX_CLIENT_STARTUP_GRACE_MS);
12
+ await expect(startPromise).resolves.toBeUndefined();
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ it("rejects when startup fails during grace", async () => {
17
+ vi.useFakeTimers();
18
+ const startError = new Error("invalid token");
19
+ const client = {
20
+ start: vi.fn().mockRejectedValue(startError),
21
+ };
22
+ const startPromise = startMatrixClientWithGrace({ client });
23
+ const startupExpectation = expect(startPromise).rejects.toBe(startError);
24
+ await vi.advanceTimersByTimeAsync(MATRIX_CLIENT_STARTUP_GRACE_MS);
25
+ await startupExpectation;
26
+ vi.useRealTimers();
27
+ });
28
+
29
+ it("calls onError for late failures after startup returns", async () => {
30
+ vi.useFakeTimers();
31
+ const lateError = new Error("late disconnect");
32
+ let rejectStart: ((err: unknown) => void) | undefined;
33
+ const startLoop = new Promise<void>((_resolve, reject) => {
34
+ rejectStart = reject;
35
+ });
36
+ const onError = vi.fn();
37
+ const client = {
38
+ start: vi.fn().mockReturnValue(startLoop),
39
+ };
40
+ const startPromise = startMatrixClientWithGrace({ client, onError });
41
+ await vi.advanceTimersByTimeAsync(MATRIX_CLIENT_STARTUP_GRACE_MS);
42
+ await expect(startPromise).resolves.toBeUndefined();
43
+
44
+ rejectStart?.(lateError);
45
+ await Promise.resolve();
46
+ expect(onError).toHaveBeenCalledWith(lateError);
47
+ vi.useRealTimers();
48
+ });
49
+ });