@openclaw/matrix 2026.2.1 → 2026.2.3

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,6 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 2026.2.1
3
+ ## 2026.2.3
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.2
4
10
 
5
11
  ### Changes
6
12
 
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@openclaw/matrix",
3
- "version": "2026.2.1",
3
+ "version": "2026.2.3",
4
4
  "description": "OpenClaw Matrix channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
8
8
  "@vector-im/matrix-bot-sdk": "0.8.0-element.3",
9
9
  "markdown-it": "14.1.0",
10
- "music-metadata": "^11.11.1",
10
+ "music-metadata": "^11.11.2",
11
11
  "zod": "^4.3.6"
12
12
  },
13
13
  "devDependencies": {
package/src/channel.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  type ResolvedMatrixAccount,
25
25
  } from "./matrix/accounts.js";
26
26
  import { resolveMatrixAuth } from "./matrix/client.js";
27
- import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js";
27
+ import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
28
28
  import { probeMatrix } from "./matrix/probe.js";
29
29
  import { sendMessageMatrix } from "./matrix/send.js";
30
30
  import { matrixOnboardingAdapter } from "./onboarding.js";
@@ -144,7 +144,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
144
144
  }),
145
145
  resolveAllowFrom: ({ cfg }) =>
146
146
  ((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)),
147
- formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom),
147
+ formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
148
148
  },
149
149
  security: {
150
150
  resolveDmPolicy: ({ account }) => ({
@@ -153,11 +153,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
153
153
  policyPath: "channels.matrix.dm.policy",
154
154
  allowFromPath: "channels.matrix.dm.allowFrom",
155
155
  approveHint: formatPairingApproveHint("matrix"),
156
- normalizeEntry: (raw) =>
157
- raw
158
- .replace(/^matrix:/i, "")
159
- .trim()
160
- .toLowerCase(),
156
+ normalizeEntry: (raw) => normalizeMatrixUserId(raw),
161
157
  }),
162
158
  collectWarnings: ({ account, cfg }) => {
163
159
  const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
@@ -51,6 +51,7 @@ export const MatrixConfigSchema = z.object({
51
51
  threadReplies: z.enum(["off", "inbound", "always"]).optional(),
52
52
  textChunkLimit: z.number().optional(),
53
53
  chunkMode: z.enum(["length", "newline"]).optional(),
54
+ responsePrefix: z.string().optional(),
54
55
  mediaMaxMb: z.number().optional(),
55
56
  autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
56
57
  autoJoinAllowlist: z.array(allowFromEntry).optional(),
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
3
+
4
+ describe("resolveMatrixAllowListMatch", () => {
5
+ it("matches full user IDs and prefixes", () => {
6
+ const userId = "@Alice:Example.org";
7
+ const direct = resolveMatrixAllowListMatch({
8
+ allowList: normalizeMatrixAllowList(["@alice:example.org"]),
9
+ userId,
10
+ });
11
+ expect(direct.allowed).toBe(true);
12
+ expect(direct.matchSource).toBe("id");
13
+
14
+ const prefixedMatrix = resolveMatrixAllowListMatch({
15
+ allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
16
+ userId,
17
+ });
18
+ expect(prefixedMatrix.allowed).toBe(true);
19
+ expect(prefixedMatrix.matchSource).toBe("prefixed-id");
20
+
21
+ const prefixedUser = resolveMatrixAllowListMatch({
22
+ allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
23
+ userId,
24
+ });
25
+ expect(prefixedUser.allowed).toBe(true);
26
+ expect(prefixedUser.matchSource).toBe("prefixed-user");
27
+ });
28
+
29
+ it("ignores display names and localparts", () => {
30
+ const match = resolveMatrixAllowListMatch({
31
+ allowList: normalizeMatrixAllowList(["alice", "Alice"]),
32
+ userId: "@alice:example.org",
33
+ });
34
+ expect(match.allowed).toBe(false);
35
+ });
36
+
37
+ it("matches wildcard", () => {
38
+ const match = resolveMatrixAllowListMatch({
39
+ allowList: normalizeMatrixAllowList(["*"]),
40
+ userId: "@alice:example.org",
41
+ });
42
+ expect(match.allowed).toBe(true);
43
+ expect(match.matchSource).toBe("wildcard");
44
+ });
45
+ });
@@ -4,22 +4,71 @@ function normalizeAllowList(list?: Array<string | number>) {
4
4
  return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
5
5
  }
6
6
 
7
- export function normalizeAllowListLower(list?: Array<string | number>) {
8
- return normalizeAllowList(list).map((entry) => entry.toLowerCase());
7
+ function normalizeMatrixUser(raw?: string | null): string {
8
+ const value = (raw ?? "").trim();
9
+ if (!value) {
10
+ return "";
11
+ }
12
+ if (!value.startsWith("@") || !value.includes(":")) {
13
+ return value.toLowerCase();
14
+ }
15
+ const withoutAt = value.slice(1);
16
+ const splitIndex = withoutAt.indexOf(":");
17
+ if (splitIndex === -1) {
18
+ return value.toLowerCase();
19
+ }
20
+ const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
21
+ const server = withoutAt.slice(splitIndex + 1).toLowerCase();
22
+ if (!server) {
23
+ return value.toLowerCase();
24
+ }
25
+ return `@${localpart}:${server.toLowerCase()}`;
9
26
  }
10
27
 
11
- function normalizeMatrixUser(raw?: string | null): string {
12
- return (raw ?? "").trim().toLowerCase();
28
+ export function normalizeMatrixUserId(raw?: string | null): string {
29
+ const trimmed = (raw ?? "").trim();
30
+ if (!trimmed) {
31
+ return "";
32
+ }
33
+ const lowered = trimmed.toLowerCase();
34
+ if (lowered.startsWith("matrix:")) {
35
+ return normalizeMatrixUser(trimmed.slice("matrix:".length));
36
+ }
37
+ if (lowered.startsWith("user:")) {
38
+ return normalizeMatrixUser(trimmed.slice("user:".length));
39
+ }
40
+ return normalizeMatrixUser(trimmed);
41
+ }
42
+
43
+ function normalizeMatrixAllowListEntry(raw: string): string {
44
+ const trimmed = raw.trim();
45
+ if (!trimmed) {
46
+ return "";
47
+ }
48
+ if (trimmed === "*") {
49
+ return trimmed;
50
+ }
51
+ const lowered = trimmed.toLowerCase();
52
+ if (lowered.startsWith("matrix:")) {
53
+ return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
54
+ }
55
+ if (lowered.startsWith("user:")) {
56
+ return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
57
+ }
58
+ return normalizeMatrixUser(trimmed);
59
+ }
60
+
61
+ export function normalizeMatrixAllowList(list?: Array<string | number>) {
62
+ return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
13
63
  }
14
64
 
15
65
  export type MatrixAllowListMatch = AllowlistMatch<
16
- "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart"
66
+ "wildcard" | "id" | "prefixed-id" | "prefixed-user"
17
67
  >;
18
68
 
19
69
  export function resolveMatrixAllowListMatch(params: {
20
70
  allowList: string[];
21
71
  userId?: string;
22
- userName?: string;
23
72
  }): MatrixAllowListMatch {
24
73
  const allowList = params.allowList;
25
74
  if (allowList.length === 0) {
@@ -29,14 +78,10 @@ export function resolveMatrixAllowListMatch(params: {
29
78
  return { allowed: true, matchKey: "*", matchSource: "wildcard" };
30
79
  }
31
80
  const userId = normalizeMatrixUser(params.userId);
32
- const userName = normalizeMatrixUser(params.userName);
33
- const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
34
81
  const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
35
82
  { value: userId, source: "id" },
36
83
  { value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
37
84
  { value: userId ? `user:${userId}` : "", source: "prefixed-user" },
38
- { value: userName, source: "name" },
39
- { value: localPart, source: "localpart" },
40
85
  ];
41
86
  for (const candidate of candidates) {
42
87
  if (!candidate.value) {
@@ -53,10 +98,6 @@ export function resolveMatrixAllowListMatch(params: {
53
98
  return { allowed: false };
54
99
  }
55
100
 
56
- export function resolveMatrixAllowListMatches(params: {
57
- allowList: string[];
58
- userId?: string;
59
- userName?: string;
60
- }) {
101
+ export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
61
102
  return resolveMatrixAllowListMatch(params).allowed;
62
103
  }
@@ -1,6 +1,6 @@
1
1
  import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
2
2
  import {
3
- createReplyPrefixContext,
3
+ createReplyPrefixOptions,
4
4
  createTypingCallbacks,
5
5
  formatAllowlistMatchMeta,
6
6
  logInboundDrop,
@@ -23,9 +23,9 @@ import {
23
23
  sendTypingMatrix,
24
24
  } from "../send.js";
25
25
  import {
26
+ normalizeMatrixAllowList,
26
27
  resolveMatrixAllowListMatch,
27
28
  resolveMatrixAllowListMatches,
28
- normalizeAllowListLower,
29
29
  } from "./allowlist.js";
30
30
  import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
31
31
  import { downloadMatrixMedia } from "./media.js";
@@ -236,12 +236,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
236
236
  const storeAllowFrom = await core.channel.pairing
237
237
  .readAllowFromStore("matrix")
238
238
  .catch(() => []);
239
- const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
239
+ const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
240
240
  const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
241
- const effectiveGroupAllowFrom = normalizeAllowListLower([
242
- ...groupAllowFrom,
243
- ...storeAllowFrom,
244
- ]);
241
+ const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
245
242
  const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
246
243
 
247
244
  if (isDirectMessage) {
@@ -252,7 +249,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
252
249
  const allowMatch = resolveMatrixAllowListMatch({
253
250
  allowList: effectiveAllowFrom,
254
251
  userId: senderId,
255
- userName: senderName,
256
252
  });
257
253
  const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
258
254
  if (!allowMatch.allowed) {
@@ -297,9 +293,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
297
293
  const roomUsers = roomConfig?.users ?? [];
298
294
  if (isRoom && roomUsers.length > 0) {
299
295
  const userMatch = resolveMatrixAllowListMatch({
300
- allowList: normalizeAllowListLower(roomUsers),
296
+ allowList: normalizeMatrixAllowList(roomUsers),
301
297
  userId: senderId,
302
- userName: senderName,
303
298
  });
304
299
  if (!userMatch.allowed) {
305
300
  logVerboseMessage(
@@ -314,7 +309,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
314
309
  const groupAllowMatch = resolveMatrixAllowListMatch({
315
310
  allowList: effectiveGroupAllowFrom,
316
311
  userId: senderId,
317
- userName: senderName,
318
312
  });
319
313
  if (!groupAllowMatch.allowed) {
320
314
  logVerboseMessage(
@@ -387,21 +381,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
387
381
  const senderAllowedForCommands = resolveMatrixAllowListMatches({
388
382
  allowList: effectiveAllowFrom,
389
383
  userId: senderId,
390
- userName: senderName,
391
384
  });
392
385
  const senderAllowedForGroup = groupAllowConfigured
393
386
  ? resolveMatrixAllowListMatches({
394
387
  allowList: effectiveGroupAllowFrom,
395
388
  userId: senderId,
396
- userName: senderName,
397
389
  })
398
390
  : false;
399
391
  const senderAllowedForRoomUsers =
400
392
  isRoom && roomUsers.length > 0
401
393
  ? resolveMatrixAllowListMatches({
402
- allowList: normalizeAllowListLower(roomUsers),
394
+ allowList: normalizeMatrixAllowList(roomUsers),
403
395
  userId: senderId,
404
- userName: senderName,
405
396
  })
406
397
  : false;
407
398
  const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
@@ -588,7 +579,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
588
579
  channel: "matrix",
589
580
  accountId: route.accountId,
590
581
  });
591
- const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
582
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
583
+ cfg,
584
+ agentId: route.agentId,
585
+ channel: "matrix",
586
+ accountId: route.accountId,
587
+ });
592
588
  const typingCallbacks = createTypingCallbacks({
593
589
  start: () => sendTypingMatrix(roomId, true, undefined, client),
594
590
  stop: () => sendTypingMatrix(roomId, false, undefined, client),
@@ -613,8 +609,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
613
609
  });
614
610
  const { dispatcher, replyOptions, markDispatchIdle } =
615
611
  core.channel.reply.createReplyDispatcherWithTyping({
616
- responsePrefix: prefixContext.responsePrefix,
617
- responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
612
+ ...prefixOptions,
618
613
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
619
614
  deliver: async (payload) => {
620
615
  await deliverMatrixReplies({
@@ -644,7 +639,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
644
639
  replyOptions: {
645
640
  ...replyOptions,
646
641
  skillFilter: roomConfig?.skills,
647
- onModelSelected: prefixContext.onModelSelected,
642
+ onModelSelected,
648
643
  },
649
644
  });
650
645
  markDispatchIdle();
@@ -10,6 +10,7 @@ import {
10
10
  resolveSharedMatrixClient,
11
11
  stopSharedClient,
12
12
  } from "../client.js";
13
+ import { normalizeMatrixUserId } from "./allowlist.js";
13
14
  import { registerMatrixAutoJoin } from "./auto-join.js";
14
15
  import { createDirectRoomTracker } from "./direct.js";
15
16
  import { registerMatrixMonitorEvents } from "./events.js";
@@ -68,68 +69,94 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
68
69
  .replace(/^(room|channel):/i, "")
69
70
  .trim();
70
71
  const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
71
-
72
- const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
73
- let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
74
- let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
75
-
76
- if (allowFrom.length > 0) {
77
- const entries = allowFrom
72
+ const resolveUserAllowlist = async (
73
+ label: string,
74
+ list?: Array<string | number>,
75
+ ): Promise<string[]> => {
76
+ let allowList = list ?? [];
77
+ if (allowList.length === 0) {
78
+ return allowList;
79
+ }
80
+ const entries = allowList
78
81
  .map((entry) => normalizeUserEntry(String(entry)))
79
82
  .filter((entry) => entry && entry !== "*");
80
- if (entries.length > 0) {
81
- const mapping: string[] = [];
82
- const unresolved: string[] = [];
83
- const additions: string[] = [];
84
- const pending: string[] = [];
85
- for (const entry of entries) {
86
- if (isMatrixUserId(entry)) {
87
- additions.push(entry);
88
- continue;
89
- }
90
- pending.push(entry);
83
+ if (entries.length === 0) {
84
+ return allowList;
85
+ }
86
+ const mapping: string[] = [];
87
+ const unresolved: string[] = [];
88
+ const additions: string[] = [];
89
+ const pending: string[] = [];
90
+ for (const entry of entries) {
91
+ if (isMatrixUserId(entry)) {
92
+ additions.push(normalizeMatrixUserId(entry));
93
+ continue;
91
94
  }
92
- if (pending.length > 0) {
93
- const resolved = await resolveMatrixTargets({
94
- cfg,
95
- inputs: pending,
96
- kind: "user",
97
- runtime,
98
- });
99
- for (const entry of resolved) {
100
- if (entry.resolved && entry.id) {
101
- additions.push(entry.id);
102
- mapping.push(`${entry.input}→${entry.id}`);
103
- } else {
104
- unresolved.push(entry.input);
105
- }
95
+ pending.push(entry);
96
+ }
97
+ if (pending.length > 0) {
98
+ const resolved = await resolveMatrixTargets({
99
+ cfg,
100
+ inputs: pending,
101
+ kind: "user",
102
+ runtime,
103
+ });
104
+ for (const entry of resolved) {
105
+ if (entry.resolved && entry.id) {
106
+ const normalizedId = normalizeMatrixUserId(entry.id);
107
+ additions.push(normalizedId);
108
+ mapping.push(`${entry.input}→${normalizedId}`);
109
+ } else {
110
+ unresolved.push(entry.input);
106
111
  }
107
112
  }
108
- allowFrom = mergeAllowlist({ existing: allowFrom, additions });
109
- summarizeMapping("matrix users", mapping, unresolved, runtime);
110
113
  }
111
- }
114
+ allowList = mergeAllowlist({ existing: allowList, additions });
115
+ summarizeMapping(label, mapping, unresolved, runtime);
116
+ if (unresolved.length > 0) {
117
+ runtime.log?.(
118
+ `${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
119
+ );
120
+ }
121
+ return allowList;
122
+ };
123
+
124
+ const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
125
+ let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
126
+ let groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
127
+ let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
128
+
129
+ allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
130
+ groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
112
131
 
113
132
  if (roomsConfig && Object.keys(roomsConfig).length > 0) {
114
- const entries = Object.keys(roomsConfig).filter((key) => key !== "*");
115
133
  const mapping: string[] = [];
116
134
  const unresolved: string[] = [];
117
- const nextRooms = { ...roomsConfig };
118
- const pending: Array<{ input: string; query: string }> = [];
119
- for (const entry of entries) {
135
+ const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
136
+ if (roomsConfig["*"]) {
137
+ nextRooms["*"] = roomsConfig["*"];
138
+ }
139
+ const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
140
+ [];
141
+ for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
142
+ if (entry === "*") {
143
+ continue;
144
+ }
120
145
  const trimmed = entry.trim();
121
146
  if (!trimmed) {
122
147
  continue;
123
148
  }
124
149
  const cleaned = normalizeRoomEntry(trimmed);
125
- if (cleaned.startsWith("!") && cleaned.includes(":")) {
150
+ if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
126
151
  if (!nextRooms[cleaned]) {
127
- nextRooms[cleaned] = roomsConfig[entry];
152
+ nextRooms[cleaned] = roomConfig;
153
+ }
154
+ if (cleaned !== entry) {
155
+ mapping.push(`${entry}→${cleaned}`);
128
156
  }
129
- mapping.push(`${entry}→${cleaned}`);
130
157
  continue;
131
158
  }
132
- pending.push({ input: entry, query: trimmed });
159
+ pending.push({ input: entry, query: trimmed, config: roomConfig });
133
160
  }
134
161
  if (pending.length > 0) {
135
162
  const resolved = await resolveMatrixTargets({
@@ -145,7 +172,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
145
172
  }
146
173
  if (entry.resolved && entry.id) {
147
174
  if (!nextRooms[entry.id]) {
148
- nextRooms[entry.id] = roomsConfig[source.input];
175
+ nextRooms[entry.id] = source.config;
149
176
  }
150
177
  mapping.push(`${source.input}→${entry.id}`);
151
178
  } else {
@@ -155,6 +182,25 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
155
182
  }
156
183
  roomsConfig = nextRooms;
157
184
  summarizeMapping("matrix rooms", mapping, unresolved, runtime);
185
+ if (unresolved.length > 0) {
186
+ runtime.log?.(
187
+ "matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
188
+ );
189
+ }
190
+ }
191
+ if (roomsConfig && Object.keys(roomsConfig).length > 0) {
192
+ const nextRooms = { ...roomsConfig };
193
+ for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
194
+ const users = roomConfig?.users ?? [];
195
+ if (users.length === 0) {
196
+ continue;
197
+ }
198
+ const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
199
+ if (resolvedUsers !== users) {
200
+ nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
201
+ }
202
+ }
203
+ roomsConfig = nextRooms;
158
204
  }
159
205
 
160
206
  cfg = {
@@ -167,6 +213,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
167
213
  ...cfg.channels?.matrix?.dm,
168
214
  allowFrom,
169
215
  },
216
+ ...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}),
170
217
  ...(roomsConfig ? { groups: roomsConfig } : {}),
171
218
  },
172
219
  },
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveMatrixRoomConfig } from "./rooms.js";
3
+
4
+ describe("resolveMatrixRoomConfig", () => {
5
+ it("matches room IDs and aliases, not names", () => {
6
+ const rooms = {
7
+ "!room:example.org": { allow: true },
8
+ "#alias:example.org": { allow: true },
9
+ "Project Room": { allow: true },
10
+ };
11
+
12
+ const byId = resolveMatrixRoomConfig({
13
+ rooms,
14
+ roomId: "!room:example.org",
15
+ aliases: [],
16
+ name: "Project Room",
17
+ });
18
+ expect(byId.allowed).toBe(true);
19
+ expect(byId.matchKey).toBe("!room:example.org");
20
+
21
+ const byAlias = resolveMatrixRoomConfig({
22
+ rooms,
23
+ roomId: "!other:example.org",
24
+ aliases: ["#alias:example.org"],
25
+ name: "Other Room",
26
+ });
27
+ expect(byAlias.allowed).toBe(true);
28
+ expect(byAlias.matchKey).toBe("#alias:example.org");
29
+
30
+ const byName = resolveMatrixRoomConfig({
31
+ rooms: { "Project Room": { allow: true } },
32
+ roomId: "!different:example.org",
33
+ aliases: [],
34
+ name: "Project Room",
35
+ });
36
+ expect(byName.allowed).toBe(false);
37
+ expect(byName.config).toBeUndefined();
38
+ });
39
+ });
@@ -22,7 +22,6 @@ export function resolveMatrixRoomConfig(params: {
22
22
  params.roomId,
23
23
  `room:${params.roomId}`,
24
24
  ...params.aliases,
25
- params.name ?? "",
26
25
  );
27
26
  const {
28
27
  entry: matched,
package/src/onboarding.ts CHANGED
@@ -8,9 +8,9 @@ import {
8
8
  } from "openclaw/plugin-sdk";
9
9
  import type { CoreConfig, DmPolicy } from "./types.js";
10
10
  import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
11
- import { listMatrixDirectoryPeersLive } from "./directory-live.js";
12
11
  import { resolveMatrixAccount } from "./matrix/accounts.js";
13
12
  import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
13
+ import { resolveMatrixTargets } from "./resolve-targets.js";
14
14
 
15
15
  const channel = "matrix" as const;
16
16
 
@@ -65,14 +65,16 @@ async function promptMatrixAllowFrom(params: {
65
65
 
66
66
  while (true) {
67
67
  const entry = await prompter.text({
68
- message: "Matrix allowFrom (username or user id)",
68
+ message: "Matrix allowFrom (full @user:server; display name only if unique)",
69
69
  placeholder: "@user:server",
70
70
  initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
71
71
  validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
72
72
  });
73
73
  const parts = parseInput(String(entry));
74
74
  const resolvedIds: string[] = [];
75
- let unresolved: string[] = [];
75
+ const pending: string[] = [];
76
+ const unresolved: string[] = [];
77
+ const unresolvedNotes: string[] = [];
76
78
 
77
79
  for (const part of parts) {
78
80
  if (isFullUserId(part)) {
@@ -83,28 +85,33 @@ async function promptMatrixAllowFrom(params: {
83
85
  unresolved.push(part);
84
86
  continue;
85
87
  }
86
- const results = await listMatrixDirectoryPeersLive({
88
+ pending.push(part);
89
+ }
90
+
91
+ if (pending.length > 0) {
92
+ const results = await resolveMatrixTargets({
87
93
  cfg,
88
- query: part,
89
- limit: 5,
94
+ inputs: pending,
95
+ kind: "user",
90
96
  }).catch(() => []);
91
- const match = results.find((result) => result.id);
92
- if (match?.id) {
93
- resolvedIds.push(match.id);
94
- if (results.length > 1) {
95
- await prompter.note(
96
- `Multiple matches for "${part}", using ${match.id}.`,
97
- "Matrix allowlist",
98
- );
97
+ for (const result of results) {
98
+ if (result?.resolved && result.id) {
99
+ resolvedIds.push(result.id);
100
+ continue;
101
+ }
102
+ if (result?.input) {
103
+ unresolved.push(result.input);
104
+ if (result.note) {
105
+ unresolvedNotes.push(`${result.input}: ${result.note}`);
106
+ }
99
107
  }
100
- } else {
101
- unresolved.push(part);
102
108
  }
103
109
  }
104
110
 
105
111
  if (unresolved.length > 0) {
112
+ const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved;
106
113
  await prompter.note(
107
- `Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
114
+ `Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`,
108
115
  "Matrix allowlist",
109
116
  );
110
117
  continue;
@@ -0,0 +1,48 @@
1
+ import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
2
+ import { describe, expect, it, vi, beforeEach } from "vitest";
3
+ import { listMatrixDirectoryPeersLive } from "./directory-live.js";
4
+ import { resolveMatrixTargets } from "./resolve-targets.js";
5
+
6
+ vi.mock("./directory-live.js", () => ({
7
+ listMatrixDirectoryPeersLive: vi.fn(),
8
+ listMatrixDirectoryGroupsLive: vi.fn(),
9
+ }));
10
+
11
+ describe("resolveMatrixTargets (users)", () => {
12
+ beforeEach(() => {
13
+ vi.mocked(listMatrixDirectoryPeersLive).mockReset();
14
+ });
15
+
16
+ it("resolves exact unique display name matches", async () => {
17
+ const matches: ChannelDirectoryEntry[] = [
18
+ { kind: "user", id: "@alice:example.org", name: "Alice" },
19
+ ];
20
+ vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
21
+
22
+ const [result] = await resolveMatrixTargets({
23
+ cfg: {},
24
+ inputs: ["Alice"],
25
+ kind: "user",
26
+ });
27
+
28
+ expect(result?.resolved).toBe(true);
29
+ expect(result?.id).toBe("@alice:example.org");
30
+ });
31
+
32
+ it("does not resolve ambiguous or non-exact matches", async () => {
33
+ const matches: ChannelDirectoryEntry[] = [
34
+ { kind: "user", id: "@alice:example.org", name: "Alice" },
35
+ { kind: "user", id: "@alice:evil.example", name: "Alice" },
36
+ ];
37
+ vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
38
+
39
+ const [result] = await resolveMatrixTargets({
40
+ cfg: {},
41
+ inputs: ["Alice"],
42
+ kind: "user",
43
+ });
44
+
45
+ expect(result?.resolved).toBe(false);
46
+ expect(result?.note).toMatch(/use full Matrix ID/i);
47
+ });
48
+ });
@@ -28,6 +28,52 @@ function pickBestGroupMatch(
28
28
  return matches[0];
29
29
  }
30
30
 
31
+ function pickBestUserMatch(
32
+ matches: ChannelDirectoryEntry[],
33
+ query: string,
34
+ ): ChannelDirectoryEntry | undefined {
35
+ if (matches.length === 0) {
36
+ return undefined;
37
+ }
38
+ const normalized = query.trim().toLowerCase();
39
+ if (!normalized) {
40
+ return undefined;
41
+ }
42
+ const exact = matches.filter((match) => {
43
+ const id = match.id.trim().toLowerCase();
44
+ const name = match.name?.trim().toLowerCase();
45
+ const handle = match.handle?.trim().toLowerCase();
46
+ return normalized === id || normalized === name || normalized === handle;
47
+ });
48
+ if (exact.length === 1) {
49
+ return exact[0];
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string {
55
+ if (matches.length === 0) {
56
+ return "no matches";
57
+ }
58
+ const normalized = query.trim().toLowerCase();
59
+ if (!normalized) {
60
+ return "empty input";
61
+ }
62
+ const exact = matches.filter((match) => {
63
+ const id = match.id.trim().toLowerCase();
64
+ const name = match.name?.trim().toLowerCase();
65
+ const handle = match.handle?.trim().toLowerCase();
66
+ return normalized === id || normalized === name || normalized === handle;
67
+ });
68
+ if (exact.length === 0) {
69
+ return "no exact match; use full Matrix ID";
70
+ }
71
+ if (exact.length > 1) {
72
+ return "multiple exact matches; use full Matrix ID";
73
+ }
74
+ return "no exact match; use full Matrix ID";
75
+ }
76
+
31
77
  export async function resolveMatrixTargets(params: {
32
78
  cfg: unknown;
33
79
  inputs: string[];
@@ -52,13 +98,13 @@ export async function resolveMatrixTargets(params: {
52
98
  query: trimmed,
53
99
  limit: 5,
54
100
  });
55
- const best = matches[0];
101
+ const best = pickBestUserMatch(matches, trimmed);
56
102
  results.push({
57
103
  input,
58
104
  resolved: Boolean(best?.id),
59
105
  id: best?.id,
60
106
  name: best?.name,
61
- note: matches.length > 1 ? "multiple matches; chose first" : undefined,
107
+ note: best ? undefined : describeUserMatchFailure(matches, trimmed),
62
108
  });
63
109
  } catch (err) {
64
110
  params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
package/src/types.ts CHANGED
@@ -7,7 +7,7 @@ export type MatrixDmConfig = {
7
7
  enabled?: boolean;
8
8
  /** Direct message access policy (default: pairing). */
9
9
  policy?: DmPolicy;
10
- /** Allowlist for DM senders (matrix user IDs, localparts, or "*"). */
10
+ /** Allowlist for DM senders (matrix user IDs or "*"). */
11
11
  allowFrom?: Array<string | number>;
12
12
  };
13
13
 
@@ -22,7 +22,7 @@ export type MatrixRoomConfig = {
22
22
  tools?: { allow?: string[]; deny?: string[] };
23
23
  /** If true, reply without mention requirements. */
24
24
  autoReply?: boolean;
25
- /** Optional allowlist for room senders (user IDs or localparts). */
25
+ /** Optional allowlist for room senders (matrix user IDs). */
26
26
  users?: Array<string | number>;
27
27
  /** Optional skill filter for this room. */
28
28
  skills?: string[];
@@ -61,7 +61,7 @@ export type MatrixConfig = {
61
61
  allowlistOnly?: boolean;
62
62
  /** Group message policy (default: allowlist). */
63
63
  groupPolicy?: GroupPolicy;
64
- /** Allowlist for group senders (user IDs or localparts). */
64
+ /** Allowlist for group senders (matrix user IDs). */
65
65
  groupAllowFrom?: Array<string | number>;
66
66
  /** Control reply threading when reply tags are present (off|first|all). */
67
67
  replyToMode?: ReplyToMode;
@@ -71,6 +71,8 @@ export type MatrixConfig = {
71
71
  textChunkLimit?: number;
72
72
  /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
73
73
  chunkMode?: "length" | "newline";
74
+ /** Outbound response prefix override for this channel/account. */
75
+ responsePrefix?: string;
74
76
  /** Max outbound media size in MB. */
75
77
  mediaMaxMb?: number;
76
78
  /** Auto-join invites (always|allowlist|off). Default: always. */
@@ -79,9 +81,9 @@ export type MatrixConfig = {
79
81
  autoJoinAllowlist?: Array<string | number>;
80
82
  /** Direct message policy + allowlist overrides. */
81
83
  dm?: MatrixDmConfig;
82
- /** Room config allowlist keyed by room ID, alias, or name. */
84
+ /** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */
83
85
  groups?: Record<string, MatrixRoomConfig>;
84
- /** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */
86
+ /** Room config allowlist keyed by room ID or alias. Legacy; use groups. */
85
87
  rooms?: Record<string, MatrixRoomConfig>;
86
88
  /** Per-action tool gating (default: true for all). */
87
89
  actions?: MatrixActionConfig;