@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.
@@ -5,6 +5,21 @@ import { sendReadReceiptMatrix } from "../send.js";
5
5
  import type { MatrixRawEvent } from "./types.js";
6
6
  import { EventType } from "./types.js";
7
7
 
8
+ const matrixMonitorListenerRegistry = (() => {
9
+ // Prevent duplicate listener registration when both bundled and extension
10
+ // paths attempt to start monitors against the same shared client.
11
+ const registeredClients = new WeakSet<object>();
12
+ return {
13
+ tryRegister(client: object): boolean {
14
+ if (registeredClients.has(client)) {
15
+ return false;
16
+ }
17
+ registeredClients.add(client);
18
+ return true;
19
+ },
20
+ };
21
+ })();
22
+
8
23
  function createSelfUserIdResolver(client: Pick<MatrixClient, "getUserId">) {
9
24
  let selfUserId: string | undefined;
10
25
  let selfUserIdLookup: Promise<string | undefined> | undefined;
@@ -41,6 +56,11 @@ export function registerMatrixMonitorEvents(params: {
41
56
  formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
42
57
  onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
43
58
  }): void {
59
+ if (!matrixMonitorListenerRegistry.tryRegister(params.client)) {
60
+ params.logVerboseMessage("matrix: skipping duplicate listener registration for client");
61
+ return;
62
+ }
63
+
44
64
  const {
45
65
  client,
46
66
  auth,
@@ -0,0 +1,142 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createMatrixRoomMessageHandler } from "./handler.js";
5
+ import { EventType, type MatrixRawEvent } from "./types.js";
6
+
7
+ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
8
+ it("stores sender-labeled BodyForAgent for group thread messages", async () => {
9
+ const recordInboundSession = vi.fn().mockResolvedValue(undefined);
10
+ const formatInboundEnvelope = vi
11
+ .fn()
12
+ .mockImplementation((params: { senderLabel?: string; body: string }) => params.body);
13
+ const finalizeInboundContext = vi
14
+ .fn()
15
+ .mockImplementation((ctx: Record<string, unknown>) => ctx);
16
+
17
+ const core = {
18
+ channel: {
19
+ pairing: {
20
+ readAllowFromStore: vi.fn().mockResolvedValue([]),
21
+ },
22
+ routing: {
23
+ resolveAgentRoute: vi.fn().mockReturnValue({
24
+ agentId: "main",
25
+ accountId: undefined,
26
+ sessionKey: "agent:main:matrix:channel:!room:example.org",
27
+ mainSessionKey: "agent:main:main",
28
+ }),
29
+ },
30
+ session: {
31
+ resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"),
32
+ readSessionUpdatedAt: vi.fn().mockReturnValue(123),
33
+ recordInboundSession,
34
+ },
35
+ reply: {
36
+ resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}),
37
+ formatInboundEnvelope,
38
+ formatAgentEnvelope: vi
39
+ .fn()
40
+ .mockImplementation((params: { body: string }) => params.body),
41
+ finalizeInboundContext,
42
+ resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined),
43
+ createReplyDispatcherWithTyping: vi.fn().mockReturnValue({
44
+ dispatcher: {},
45
+ replyOptions: {},
46
+ markDispatchIdle: vi.fn(),
47
+ }),
48
+ withReplyDispatcher: vi
49
+ .fn()
50
+ .mockResolvedValue({ queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } }),
51
+ },
52
+ commands: {
53
+ shouldHandleTextCommands: vi.fn().mockReturnValue(true),
54
+ },
55
+ text: {
56
+ hasControlCommand: vi.fn().mockReturnValue(false),
57
+ resolveMarkdownTableMode: vi.fn().mockReturnValue("code"),
58
+ },
59
+ },
60
+ system: {
61
+ enqueueSystemEvent: vi.fn(),
62
+ },
63
+ } as unknown as PluginRuntime;
64
+
65
+ const runtime = {
66
+ error: vi.fn(),
67
+ } as unknown as RuntimeEnv;
68
+ const logger = {
69
+ info: vi.fn(),
70
+ warn: vi.fn(),
71
+ } as unknown as RuntimeLogger;
72
+ const logVerboseMessage = vi.fn();
73
+
74
+ const client = {
75
+ getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"),
76
+ } as unknown as MatrixClient;
77
+
78
+ const handler = createMatrixRoomMessageHandler({
79
+ client,
80
+ core,
81
+ cfg: {},
82
+ runtime,
83
+ logger,
84
+ logVerboseMessage,
85
+ allowFrom: [],
86
+ roomsConfig: undefined,
87
+ mentionRegexes: [],
88
+ groupPolicy: "open",
89
+ replyToMode: "first",
90
+ threadReplies: "inbound",
91
+ dmEnabled: true,
92
+ dmPolicy: "open",
93
+ textLimit: 4000,
94
+ mediaMaxBytes: 5 * 1024 * 1024,
95
+ startupMs: Date.now(),
96
+ startupGraceMs: 60_000,
97
+ directTracker: {
98
+ isDirectMessage: vi.fn().mockResolvedValue(false),
99
+ },
100
+ getRoomInfo: vi.fn().mockResolvedValue({
101
+ name: "Dev Room",
102
+ canonicalAlias: "#dev:matrix.example.org",
103
+ altAliases: [],
104
+ }),
105
+ getMemberDisplayName: vi.fn().mockResolvedValue("Bu"),
106
+ accountId: undefined,
107
+ });
108
+
109
+ const event = {
110
+ type: EventType.RoomMessage,
111
+ event_id: "$event1",
112
+ sender: "@bu:matrix.example.org",
113
+ origin_server_ts: Date.now(),
114
+ content: {
115
+ msgtype: "m.text",
116
+ body: "show me my commits",
117
+ "m.mentions": { user_ids: ["@bot:matrix.example.org"] },
118
+ "m.relates_to": {
119
+ rel_type: "m.thread",
120
+ event_id: "$thread-root",
121
+ },
122
+ },
123
+ } as unknown as MatrixRawEvent;
124
+
125
+ await handler("!room:example.org", event);
126
+
127
+ expect(formatInboundEnvelope).toHaveBeenCalledWith(
128
+ expect.objectContaining({
129
+ chatType: "channel",
130
+ senderLabel: "Bu (bu)",
131
+ }),
132
+ );
133
+ expect(recordInboundSession).toHaveBeenCalledWith(
134
+ expect.objectContaining({
135
+ ctx: expect.objectContaining({
136
+ ChatType: "thread",
137
+ BodyForAgent: "Bu (bu): show me my commits",
138
+ }),
139
+ }),
140
+ );
141
+ });
142
+ });
@@ -1,5 +1,7 @@
1
1
  import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
2
2
  import {
3
+ DEFAULT_ACCOUNT_ID,
4
+ createScopedPairingAccess,
3
5
  createReplyPrefixOptions,
4
6
  createTypingCallbacks,
5
7
  formatAllowlistMatchMeta,
@@ -19,11 +21,17 @@ import {
19
21
  type PollStartContent,
20
22
  } from "../poll-types.js";
21
23
  import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
24
+ import { enforceMatrixDirectMessageAccess, resolveMatrixAccessState } from "./access-policy.js";
22
25
  import {
23
26
  normalizeMatrixAllowList,
24
27
  resolveMatrixAllowListMatch,
25
28
  resolveMatrixAllowListMatches,
26
29
  } from "./allowlist.js";
30
+ import {
31
+ resolveMatrixBodyForAgent,
32
+ resolveMatrixInboundSenderLabel,
33
+ resolveMatrixSenderUsername,
34
+ } from "./inbound-body.js";
27
35
  import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
28
36
  import { downloadMatrixMedia } from "./media.js";
29
37
  import { resolveMentions } from "./mentions.js";
@@ -91,6 +99,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
91
99
  getMemberDisplayName,
92
100
  accountId,
93
101
  } = params;
102
+ const resolvedAccountId = accountId?.trim() || DEFAULT_ACCOUNT_ID;
103
+ const pairing = createScopedPairingAccess({
104
+ core,
105
+ channel: "matrix",
106
+ accountId: resolvedAccountId,
107
+ });
94
108
 
95
109
  return async (roomId: string, event: MatrixRawEvent) => {
96
110
  try {
@@ -213,62 +227,42 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
213
227
  }
214
228
 
215
229
  const senderName = await getMemberDisplayName(roomId, senderId);
216
- const storeAllowFrom =
217
- dmPolicy === "allowlist"
218
- ? []
219
- : await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
220
- const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
230
+ const senderUsername = resolveMatrixSenderUsername(senderId);
231
+ const senderLabel = resolveMatrixInboundSenderLabel({
232
+ senderName,
233
+ senderId,
234
+ senderUsername,
235
+ });
221
236
  const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
222
- const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
223
- const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
237
+ const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } =
238
+ await resolveMatrixAccessState({
239
+ isDirectMessage,
240
+ resolvedAccountId,
241
+ dmPolicy,
242
+ groupPolicy,
243
+ allowFrom,
244
+ groupAllowFrom,
245
+ senderId,
246
+ readStoreForDmPolicy: pairing.readStoreForDmPolicy,
247
+ });
224
248
 
225
249
  if (isDirectMessage) {
226
- if (!dmEnabled || dmPolicy === "disabled") {
250
+ const allowedDirectMessage = await enforceMatrixDirectMessageAccess({
251
+ dmEnabled,
252
+ dmPolicy,
253
+ accessDecision: access.decision,
254
+ senderId,
255
+ senderName,
256
+ effectiveAllowFrom,
257
+ upsertPairingRequest: pairing.upsertPairingRequest,
258
+ sendPairingReply: async (text) => {
259
+ await sendMessageMatrix(`room:${roomId}`, text, { client });
260
+ },
261
+ logVerboseMessage,
262
+ });
263
+ if (!allowedDirectMessage) {
227
264
  return;
228
265
  }
229
- if (dmPolicy !== "open") {
230
- const allowMatch = resolveMatrixAllowListMatch({
231
- allowList: effectiveAllowFrom,
232
- userId: senderId,
233
- });
234
- const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
235
- if (!allowMatch.allowed) {
236
- if (dmPolicy === "pairing") {
237
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
238
- channel: "matrix",
239
- id: senderId,
240
- meta: { name: senderName },
241
- });
242
- if (created) {
243
- logVerboseMessage(
244
- `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
245
- );
246
- try {
247
- await sendMessageMatrix(
248
- `room:${roomId}`,
249
- [
250
- "OpenClaw: access not configured.",
251
- "",
252
- `Pairing code: ${code}`,
253
- "",
254
- "Ask the bot owner to approve with:",
255
- "openclaw pairing approve matrix <code>",
256
- ].join("\n"),
257
- { client },
258
- );
259
- } catch (err) {
260
- logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
261
- }
262
- }
263
- }
264
- if (dmPolicy !== "pairing") {
265
- logVerboseMessage(
266
- `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
267
- );
268
- }
269
- return;
270
- }
271
- }
272
266
  }
273
267
 
274
268
  const roomUsers = roomConfig?.users ?? [];
@@ -286,7 +280,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
286
280
  return;
287
281
  }
288
282
  }
289
- if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
283
+ if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") {
290
284
  const groupAllowMatch = resolveMatrixAllowListMatch({
291
285
  allowList: effectiveGroupAllowFrom,
292
286
  userId: senderId,
@@ -498,19 +492,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
498
492
  storePath,
499
493
  sessionKey: route.sessionKey,
500
494
  });
501
- const body = core.channel.reply.formatAgentEnvelope({
495
+ const body = core.channel.reply.formatInboundEnvelope({
502
496
  channel: "Matrix",
503
497
  from: envelopeFrom,
504
498
  timestamp: eventTs ?? undefined,
505
499
  previousTimestamp,
506
500
  envelope: envelopeOptions,
507
501
  body: textWithId,
502
+ chatType: isDirectMessage ? "direct" : "channel",
503
+ senderLabel,
508
504
  });
509
505
 
510
506
  const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
511
507
  const ctxPayload = core.channel.reply.finalizeInboundContext({
512
508
  Body: body,
513
- BodyForAgent: bodyText,
509
+ BodyForAgent: resolveMatrixBodyForAgent({
510
+ isDirectMessage,
511
+ bodyText,
512
+ senderLabel,
513
+ }),
514
514
  RawBody: bodyText,
515
515
  CommandBody: bodyText,
516
516
  From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
@@ -521,7 +521,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
521
521
  ConversationLabel: envelopeFrom,
522
522
  SenderName: senderName,
523
523
  SenderId: senderId,
524
- SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
524
+ SenderUsername: senderUsername,
525
525
  GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
526
526
  GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
527
527
  GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
@@ -655,17 +655,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
655
655
  },
656
656
  });
657
657
 
658
- const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
659
- ctx: ctxPayload,
660
- cfg,
658
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
661
659
  dispatcher,
662
- replyOptions: {
663
- ...replyOptions,
664
- skillFilter: roomConfig?.skills,
665
- onModelSelected,
660
+ onSettled: () => {
661
+ markDispatchIdle();
666
662
  },
663
+ run: () =>
664
+ core.channel.reply.dispatchReplyFromConfig({
665
+ ctx: ctxPayload,
666
+ cfg,
667
+ dispatcher,
668
+ replyOptions: {
669
+ ...replyOptions,
670
+ skillFilter: roomConfig?.skills,
671
+ onModelSelected,
672
+ },
673
+ }),
667
674
  });
668
- markDispatchIdle();
669
675
  if (!queuedFinal) {
670
676
  return;
671
677
  }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveMatrixBodyForAgent,
4
+ resolveMatrixInboundSenderLabel,
5
+ resolveMatrixSenderUsername,
6
+ } from "./inbound-body.js";
7
+
8
+ describe("resolveMatrixSenderUsername", () => {
9
+ it("extracts localpart without leading @", () => {
10
+ expect(resolveMatrixSenderUsername("@bu:matrix.example.org")).toBe("bu");
11
+ });
12
+ });
13
+
14
+ describe("resolveMatrixInboundSenderLabel", () => {
15
+ it("uses provided senderUsername when present", () => {
16
+ expect(
17
+ resolveMatrixInboundSenderLabel({
18
+ senderName: "Bu",
19
+ senderId: "@bu:matrix.example.org",
20
+ senderUsername: "BU_CUSTOM",
21
+ }),
22
+ ).toBe("Bu (BU_CUSTOM)");
23
+ });
24
+
25
+ it("includes sender username when it differs from display name", () => {
26
+ expect(
27
+ resolveMatrixInboundSenderLabel({
28
+ senderName: "Bu",
29
+ senderId: "@bu:matrix.example.org",
30
+ }),
31
+ ).toBe("Bu (bu)");
32
+ });
33
+
34
+ it("falls back to sender username when display name is blank", () => {
35
+ expect(
36
+ resolveMatrixInboundSenderLabel({
37
+ senderName: " ",
38
+ senderId: "@zhang:matrix.example.org",
39
+ }),
40
+ ).toBe("zhang");
41
+ });
42
+
43
+ it("falls back to sender id when username cannot be parsed", () => {
44
+ expect(
45
+ resolveMatrixInboundSenderLabel({
46
+ senderName: "",
47
+ senderId: "matrix-user-without-colon",
48
+ }),
49
+ ).toBe("matrix-user-without-colon");
50
+ });
51
+ });
52
+
53
+ describe("resolveMatrixBodyForAgent", () => {
54
+ it("keeps direct message body unchanged", () => {
55
+ expect(
56
+ resolveMatrixBodyForAgent({
57
+ isDirectMessage: true,
58
+ bodyText: "show me my commits",
59
+ senderLabel: "Bu (bu)",
60
+ }),
61
+ ).toBe("show me my commits");
62
+ });
63
+
64
+ it("prefixes non-direct message body with sender label", () => {
65
+ expect(
66
+ resolveMatrixBodyForAgent({
67
+ isDirectMessage: false,
68
+ bodyText: "show me my commits",
69
+ senderLabel: "Bu (bu)",
70
+ }),
71
+ ).toBe("Bu (bu): show me my commits");
72
+ });
73
+ });
@@ -0,0 +1,28 @@
1
+ export function resolveMatrixSenderUsername(senderId: string): string | undefined {
2
+ const username = senderId.split(":")[0]?.replace(/^@/, "").trim();
3
+ return username ? username : undefined;
4
+ }
5
+
6
+ export function resolveMatrixInboundSenderLabel(params: {
7
+ senderName: string;
8
+ senderId: string;
9
+ senderUsername?: string;
10
+ }): string {
11
+ const senderName = params.senderName.trim();
12
+ const senderUsername = params.senderUsername ?? resolveMatrixSenderUsername(params.senderId);
13
+ if (senderName && senderUsername && senderName !== senderUsername) {
14
+ return `${senderName} (${senderUsername})`;
15
+ }
16
+ return senderName || senderUsername || params.senderId;
17
+ }
18
+
19
+ export function resolveMatrixBodyForAgent(params: {
20
+ isDirectMessage: boolean;
21
+ bodyText: string;
22
+ senderLabel: string;
23
+ }): string {
24
+ if (params.isDirectMessage) {
25
+ return params.bodyText;
26
+ }
27
+ return `${params.senderLabel}: ${params.bodyText}`;
28
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DEFAULT_STARTUP_GRACE_MS, isConfiguredMatrixRoomEntry } from "./index.js";
3
+
4
+ describe("monitorMatrixProvider helpers", () => {
5
+ it("treats !-prefixed room IDs as configured room entries", () => {
6
+ expect(isConfiguredMatrixRoomEntry("!abc123")).toBe(true);
7
+ expect(isConfiguredMatrixRoomEntry("!RoomMixedCase")).toBe(true);
8
+ });
9
+
10
+ it("requires a homeserver suffix for # aliases", () => {
11
+ expect(isConfiguredMatrixRoomEntry("#alias:example.org")).toBe(true);
12
+ expect(isConfiguredMatrixRoomEntry("#alias")).toBe(false);
13
+ });
14
+
15
+ it("uses a non-zero startup grace window", () => {
16
+ expect(DEFAULT_STARTUP_GRACE_MS).toBe(5000);
17
+ });
18
+ });