@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 +18 -0
- package/package.json +1 -1
- package/src/config-schema.ts +2 -0
- package/src/directory-live.test.ts +11 -0
- package/src/directory-live.ts +2 -1
- package/src/matrix/accounts.test.ts +50 -1
- package/src/matrix/accounts.ts +13 -1
- package/src/matrix/client/shared.test.ts +85 -0
- package/src/matrix/client/shared.ts +8 -1
- package/src/matrix/client/startup.test.ts +49 -0
- package/src/matrix/client/startup.ts +29 -0
- package/src/matrix/client-bootstrap.ts +9 -2
- package/src/matrix/monitor/access-policy.ts +127 -0
- package/src/matrix/monitor/direct.test.ts +65 -0
- package/src/matrix/monitor/direct.ts +20 -7
- package/src/matrix/monitor/events.test.ts +31 -0
- package/src/matrix/monitor/events.ts +20 -0
- package/src/matrix/monitor/handler.body-for-agent.test.ts +142 -0
- package/src/matrix/monitor/handler.ts +69 -63
- package/src/matrix/monitor/inbound-body.test.ts +73 -0
- package/src/matrix/monitor/inbound-body.ts +28 -0
- package/src/matrix/monitor/index.test.ts +18 -0
- package/src/matrix/monitor/index.ts +204 -147
- package/src/types.ts +3 -1
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
package/src/config-schema.ts
CHANGED
|
@@ -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
|
});
|
package/src/directory-live.ts
CHANGED
|
@@ -174,7 +174,8 @@ export async function listMatrixDirectoryGroupsLive(
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
if (query.startsWith("!")) {
|
|
177
|
-
|
|
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
|
+
});
|
package/src/matrix/accounts.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
});
|