@openclaw/matrix 2026.2.24 → 2026.3.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.26
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.2.25
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
3
21
  ## 2026.2.24
4
22
 
5
23
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/matrix",
3
- "version": "2026.2.24",
3
+ "version": "2026.3.1",
4
4
  "description": "OpenClaw Matrix channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -37,6 +37,8 @@ const matrixRoomSchema = z
37
37
  export const MatrixConfigSchema = z.object({
38
38
  name: z.string().optional(),
39
39
  enabled: z.boolean().optional(),
40
+ defaultAccount: z.string().optional(),
41
+ accounts: z.record(z.string(), z.unknown()).optional(),
40
42
  markdown: MarkdownConfigSchema,
41
43
  homeserver: z.string().optional(),
42
44
  userId: z.string().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,8 @@
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";
2
6
  import type { CoreConfig, MatrixConfig } from "../types.js";
3
7
  import { resolveMatrixConfigForAccount } from "./client.js";
4
8
  import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
@@ -16,6 +20,7 @@ function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixCo
16
20
  }
17
21
  // Don't propagate the accounts map into the merged per-account config
18
22
  delete (merged as Record<string, unknown>).accounts;
23
+ delete (merged as Record<string, unknown>).defaultAccount;
19
24
  return merged;
20
25
  }
21
26
 
@@ -54,6 +59,13 @@ export function listMatrixAccountIds(cfg: CoreConfig): string[] {
54
59
  }
55
60
 
56
61
  export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
62
+ const preferred = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
63
+ if (
64
+ preferred &&
65
+ listMatrixAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
66
+ ) {
67
+ return preferred;
68
+ }
57
69
  const ids = listMatrixAccountIds(cfg);
58
70
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
59
71
  return DEFAULT_ACCOUNT_ID;
@@ -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
+ });
@@ -4,6 +4,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
4
4
  import type { CoreConfig } from "../../types.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
 
@@ -84,7 +85,13 @@ async function ensureSharedClientStarted(params: {
84
85
  }
85
86
  }
86
87
 
87
- await client.start();
88
+ await startMatrixClientWithGrace({
89
+ client,
90
+ onError: (err: unknown) => {
91
+ params.state.started = false;
92
+ LogService.error("MatrixClientLite", "client.start() error:", err);
93
+ },
94
+ });
88
95
  params.state.started = true;
89
96
  })();
90
97
  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
+ });
@@ -0,0 +1,29 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+
3
+ export const MATRIX_CLIENT_STARTUP_GRACE_MS = 2000;
4
+
5
+ export async function startMatrixClientWithGrace(params: {
6
+ client: Pick<MatrixClient, "start">;
7
+ graceMs?: number;
8
+ onError?: (err: unknown) => void;
9
+ }): Promise<void> {
10
+ const graceMs = params.graceMs ?? MATRIX_CLIENT_STARTUP_GRACE_MS;
11
+ let startFailed = false;
12
+ let startError: unknown = undefined;
13
+ let startPromise: Promise<unknown>;
14
+ try {
15
+ startPromise = params.client.start();
16
+ } catch (err) {
17
+ params.onError?.(err);
18
+ throw err;
19
+ }
20
+ void startPromise.catch((err: unknown) => {
21
+ startFailed = true;
22
+ startError = err;
23
+ params.onError?.(err);
24
+ });
25
+ await new Promise((resolve) => setTimeout(resolve, graceMs));
26
+ if (startFailed) {
27
+ throw startError;
28
+ }
29
+ }
@@ -1,4 +1,6 @@
1
- import { createMatrixClient } from "./client.js";
1
+ import { LogService } from "@vector-im/matrix-bot-sdk";
2
+ import { createMatrixClient } from "./client/create-client.js";
3
+ import { startMatrixClientWithGrace } from "./client/startup.js";
2
4
 
3
5
  type MatrixClientBootstrapAuth = {
4
6
  homeserver: string;
@@ -34,6 +36,11 @@ export async function createPreparedMatrixClient(opts: {
34
36
  // Ignore crypto prep failures for one-off requests.
35
37
  }
36
38
  }
37
- await client.start();
39
+ await startMatrixClientWithGrace({
40
+ client,
41
+ onError: (err: unknown) => {
42
+ LogService.error("MatrixClientBootstrap", "client.start() error:", err);
43
+ },
44
+ });
38
45
  return client;
39
46
  }
@@ -0,0 +1,127 @@
1
+ import {
2
+ formatAllowlistMatchMeta,
3
+ issuePairingChallenge,
4
+ readStoreAllowFromForDmPolicy,
5
+ resolveDmGroupAccessWithLists,
6
+ } from "openclaw/plugin-sdk";
7
+ import {
8
+ normalizeMatrixAllowList,
9
+ resolveMatrixAllowListMatch,
10
+ resolveMatrixAllowListMatches,
11
+ } from "./allowlist.js";
12
+
13
+ type MatrixDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
14
+ type MatrixGroupPolicy = "open" | "allowlist" | "disabled";
15
+
16
+ export async function resolveMatrixAccessState(params: {
17
+ isDirectMessage: boolean;
18
+ resolvedAccountId: string;
19
+ dmPolicy: MatrixDmPolicy;
20
+ groupPolicy: MatrixGroupPolicy;
21
+ allowFrom: string[];
22
+ groupAllowFrom: Array<string | number>;
23
+ senderId: string;
24
+ readStoreForDmPolicy: (provider: string, accountId: string) => Promise<string[]>;
25
+ }) {
26
+ const storeAllowFrom = params.isDirectMessage
27
+ ? await readStoreAllowFromForDmPolicy({
28
+ provider: "matrix",
29
+ accountId: params.resolvedAccountId,
30
+ dmPolicy: params.dmPolicy,
31
+ readStore: params.readStoreForDmPolicy,
32
+ })
33
+ : [];
34
+ const normalizedGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom);
35
+ const senderGroupPolicy =
36
+ params.groupPolicy === "disabled"
37
+ ? "disabled"
38
+ : normalizedGroupAllowFrom.length > 0
39
+ ? "allowlist"
40
+ : "open";
41
+ const access = resolveDmGroupAccessWithLists({
42
+ isGroup: !params.isDirectMessage,
43
+ dmPolicy: params.dmPolicy,
44
+ groupPolicy: senderGroupPolicy,
45
+ allowFrom: params.allowFrom,
46
+ groupAllowFrom: normalizedGroupAllowFrom,
47
+ storeAllowFrom,
48
+ groupAllowFromFallbackToAllowFrom: false,
49
+ isSenderAllowed: (allowFrom) =>
50
+ resolveMatrixAllowListMatches({
51
+ allowList: normalizeMatrixAllowList(allowFrom),
52
+ userId: params.senderId,
53
+ }),
54
+ });
55
+ const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom);
56
+ const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom);
57
+ return {
58
+ access,
59
+ effectiveAllowFrom,
60
+ effectiveGroupAllowFrom,
61
+ groupAllowConfigured: effectiveGroupAllowFrom.length > 0,
62
+ };
63
+ }
64
+
65
+ export async function enforceMatrixDirectMessageAccess(params: {
66
+ dmEnabled: boolean;
67
+ dmPolicy: MatrixDmPolicy;
68
+ accessDecision: "allow" | "block" | "pairing";
69
+ senderId: string;
70
+ senderName: string;
71
+ effectiveAllowFrom: string[];
72
+ upsertPairingRequest: (input: {
73
+ id: string;
74
+ meta?: Record<string, string | undefined>;
75
+ }) => Promise<{
76
+ code: string;
77
+ created: boolean;
78
+ }>;
79
+ sendPairingReply: (text: string) => Promise<void>;
80
+ logVerboseMessage: (message: string) => void;
81
+ }): Promise<boolean> {
82
+ if (!params.dmEnabled) {
83
+ return false;
84
+ }
85
+ if (params.accessDecision === "allow") {
86
+ return true;
87
+ }
88
+ const allowMatch = resolveMatrixAllowListMatch({
89
+ allowList: params.effectiveAllowFrom,
90
+ userId: params.senderId,
91
+ });
92
+ const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
93
+ if (params.accessDecision === "pairing") {
94
+ await issuePairingChallenge({
95
+ channel: "matrix",
96
+ senderId: params.senderId,
97
+ senderIdLine: `Matrix user id: ${params.senderId}`,
98
+ meta: { name: params.senderName },
99
+ upsertPairingRequest: params.upsertPairingRequest,
100
+ buildReplyText: ({ code }) =>
101
+ [
102
+ "OpenClaw: access not configured.",
103
+ "",
104
+ `Pairing code: ${code}`,
105
+ "",
106
+ "Ask the bot owner to approve with:",
107
+ "openclaw pairing approve matrix <code>",
108
+ ].join("\n"),
109
+ sendPairingReply: params.sendPairingReply,
110
+ onCreated: () => {
111
+ params.logVerboseMessage(
112
+ `matrix pairing request sender=${params.senderId} name=${params.senderName ?? "unknown"} (${allowMatchMeta})`,
113
+ );
114
+ },
115
+ onReplyError: (err) => {
116
+ params.logVerboseMessage(
117
+ `matrix pairing reply failed for ${params.senderId}: ${String(err)}`,
118
+ );
119
+ },
120
+ });
121
+ return false;
122
+ }
123
+ params.logVerboseMessage(
124
+ `matrix: blocked dm sender ${params.senderId} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`,
125
+ );
126
+ return false;
127
+ }
@@ -0,0 +1,65 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createDirectRoomTracker } from "./direct.js";
4
+
5
+ function createMockClient(params: {
6
+ isDm?: boolean;
7
+ senderDirect?: boolean;
8
+ selfDirect?: boolean;
9
+ members?: string[];
10
+ }) {
11
+ const members = params.members ?? ["@alice:example.org", "@bot:example.org"];
12
+ return {
13
+ dms: {
14
+ update: vi.fn().mockResolvedValue(undefined),
15
+ isDm: vi.fn().mockReturnValue(params.isDm === true),
16
+ },
17
+ getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
18
+ getJoinedRoomMembers: vi.fn().mockResolvedValue(members),
19
+ getRoomStateEvent: vi
20
+ .fn()
21
+ .mockImplementation(async (_roomId: string, _event: string, stateKey: string) => {
22
+ if (stateKey === "@alice:example.org") {
23
+ return { is_direct: params.senderDirect === true };
24
+ }
25
+ if (stateKey === "@bot:example.org") {
26
+ return { is_direct: params.selfDirect === true };
27
+ }
28
+ return {};
29
+ }),
30
+ } as unknown as MatrixClient;
31
+ }
32
+
33
+ describe("createDirectRoomTracker", () => {
34
+ it("treats m.direct rooms as DMs", async () => {
35
+ const tracker = createDirectRoomTracker(createMockClient({ isDm: true }));
36
+ await expect(
37
+ tracker.isDirectMessage({
38
+ roomId: "!room:example.org",
39
+ senderId: "@alice:example.org",
40
+ }),
41
+ ).resolves.toBe(true);
42
+ });
43
+
44
+ it("does not classify 2-member rooms as DMs without direct flags", async () => {
45
+ const client = createMockClient({ isDm: false });
46
+ const tracker = createDirectRoomTracker(client);
47
+ await expect(
48
+ tracker.isDirectMessage({
49
+ roomId: "!room:example.org",
50
+ senderId: "@alice:example.org",
51
+ }),
52
+ ).resolves.toBe(false);
53
+ expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("uses is_direct member flags when present", async () => {
57
+ const tracker = createDirectRoomTracker(createMockClient({ senderDirect: true }));
58
+ await expect(
59
+ tracker.isDirectMessage({
60
+ roomId: "!room:example.org",
61
+ senderId: "@alice:example.org",
62
+ }),
63
+ ).resolves.toBe(true);
64
+ });
65
+ });
@@ -8,15 +8,19 @@ type DirectMessageCheck = {
8
8
 
9
9
  type DirectRoomTrackerOptions = {
10
10
  log?: (message: string) => void;
11
+ includeMemberCountInLogs?: boolean;
11
12
  };
12
13
 
13
14
  const DM_CACHE_TTL_MS = 30_000;
14
15
 
15
16
  export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
16
17
  const log = opts.log ?? (() => {});
18
+ const includeMemberCountInLogs = opts.includeMemberCountInLogs === true;
17
19
  let lastDmUpdateMs = 0;
18
20
  let cachedSelfUserId: string | null = null;
19
- const memberCountCache = new Map<string, { count: number; ts: number }>();
21
+ const memberCountCache = includeMemberCountInLogs
22
+ ? new Map<string, { count: number; ts: number }>()
23
+ : undefined;
20
24
 
21
25
  const ensureSelfUserId = async (): Promise<string | null> => {
22
26
  if (cachedSelfUserId) {
@@ -44,6 +48,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
44
48
  };
45
49
 
46
50
  const resolveMemberCount = async (roomId: string): Promise<number | null> => {
51
+ if (!memberCountCache) {
52
+ return null;
53
+ }
47
54
  const cached = memberCountCache.get(roomId);
48
55
  const now = Date.now();
49
56
  if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
@@ -78,17 +85,13 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
78
85
  const { roomId, senderId } = params;
79
86
  await refreshDmCache();
80
87
 
88
+ // Check m.direct account data (most authoritative)
81
89
  if (client.dms.isDm(roomId)) {
82
90
  log(`matrix: dm detected via m.direct room=${roomId}`);
83
91
  return true;
84
92
  }
85
93
 
86
- const memberCount = await resolveMemberCount(roomId);
87
- if (memberCount === 2) {
88
- log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
89
- return true;
90
- }
91
-
94
+ // Check m.room.member state for is_direct flag
92
95
  const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
93
96
  const directViaState =
94
97
  (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
@@ -97,6 +100,16 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
97
100
  return true;
98
101
  }
99
102
 
103
+ // Member count alone is NOT a reliable DM indicator.
104
+ // Explicitly configured group rooms with 2 members (e.g. bot + one user)
105
+ // were being misclassified as DMs, causing messages to be routed through
106
+ // DM policy instead of group policy and silently dropped.
107
+ // See: https://github.com/openclaw/openclaw/issues/20145
108
+ if (!includeMemberCountInLogs) {
109
+ log(`matrix: dm check room=${roomId} result=group`);
110
+ return false;
111
+ }
112
+ const memberCount = await resolveMemberCount(roomId);
100
113
  log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
101
114
  return false;
102
115
  },
@@ -138,4 +138,35 @@ describe("registerMatrixMonitorEvents", () => {
138
138
  expect(getUserId).toHaveBeenCalledTimes(1);
139
139
  expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
140
140
  });
141
+
142
+ it("skips duplicate listener registration for the same client", () => {
143
+ const handlers = new Map<string, (...args: unknown[]) => void>();
144
+ const onMock = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
145
+ handlers.set(event, handler);
146
+ });
147
+ const client = {
148
+ on: onMock,
149
+ getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
150
+ crypto: undefined,
151
+ } as unknown as MatrixClient;
152
+ const params = {
153
+ client,
154
+ auth: { encryption: false } as MatrixAuth,
155
+ logVerboseMessage: vi.fn(),
156
+ warnedEncryptedRooms: new Set<string>(),
157
+ warnedCryptoMissingRooms: new Set<string>(),
158
+ logger: { warn: vi.fn() } as unknown as RuntimeLogger,
159
+ formatNativeDependencyHint: (() =>
160
+ "") as PluginRuntime["system"]["formatNativeDependencyHint"],
161
+ onRoomMessage: vi.fn(),
162
+ };
163
+ registerMatrixMonitorEvents(params);
164
+ const initialCallCount = onMock.mock.calls.length;
165
+ registerMatrixMonitorEvents(params);
166
+
167
+ expect(onMock).toHaveBeenCalledTimes(initialCallCount);
168
+ expect(params.logVerboseMessage).toHaveBeenCalledWith(
169
+ "matrix: skipping duplicate listener registration for client",
170
+ );
171
+ });
141
172
  });