@openclaw/matrix 2026.1.29 → 2026.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/index.ts +0 -1
  3. package/openclaw.plugin.json +1 -3
  4. package/package.json +12 -12
  5. package/src/actions.ts +16 -6
  6. package/src/channel.directory.test.ts +13 -5
  7. package/src/channel.ts +61 -39
  8. package/src/directory-live.ts +21 -8
  9. package/src/group-mentions.ts +10 -5
  10. package/src/matrix/accounts.test.ts +0 -1
  11. package/src/matrix/accounts.ts +4 -2
  12. package/src/matrix/actions/client.ts +8 -4
  13. package/src/matrix/actions/messages.ts +17 -9
  14. package/src/matrix/actions/pins.ts +12 -6
  15. package/src/matrix/actions/reactions.ts +24 -12
  16. package/src/matrix/actions/room.ts +10 -13
  17. package/src/matrix/actions/summary.ts +4 -6
  18. package/src/matrix/client/config.ts +4 -9
  19. package/src/matrix/client/create-client.ts +12 -16
  20. package/src/matrix/client/logging.ts +17 -16
  21. package/src/matrix/client/shared.ts +6 -5
  22. package/src/matrix/client/storage.ts +12 -12
  23. package/src/matrix/client.test.ts +0 -1
  24. package/src/matrix/client.ts +1 -5
  25. package/src/matrix/credentials.ts +7 -5
  26. package/src/matrix/deps.ts +8 -5
  27. package/src/matrix/format.test.ts +0 -1
  28. package/src/matrix/monitor/allowlist.test.ts +45 -0
  29. package/src/matrix/monitor/allowlist.ts +62 -17
  30. package/src/matrix/monitor/auto-join.ts +7 -4
  31. package/src/matrix/monitor/direct.ts +11 -12
  32. package/src/matrix/monitor/events.ts +1 -3
  33. package/src/matrix/monitor/handler.ts +69 -53
  34. package/src/matrix/monitor/index.ts +118 -59
  35. package/src/matrix/monitor/location.ts +27 -10
  36. package/src/matrix/monitor/media.test.ts +1 -2
  37. package/src/matrix/monitor/media.ts +8 -8
  38. package/src/matrix/monitor/replies.ts +4 -3
  39. package/src/matrix/monitor/room-info.ts +5 -8
  40. package/src/matrix/monitor/rooms.test.ts +39 -0
  41. package/src/matrix/monitor/rooms.ts +7 -3
  42. package/src/matrix/monitor/threads.ts +6 -2
  43. package/src/matrix/poll-types.test.ts +0 -1
  44. package/src/matrix/poll-types.ts +16 -7
  45. package/src/matrix/send/client.ts +7 -4
  46. package/src/matrix/send/formatting.ts +14 -17
  47. package/src/matrix/send/media.ts +17 -8
  48. package/src/matrix/send/targets.test.ts +7 -11
  49. package/src/matrix/send/targets.ts +19 -27
  50. package/src/matrix/send.test.ts +1 -2
  51. package/src/matrix/send.ts +9 -4
  52. package/src/onboarding.ts +24 -14
  53. package/src/outbound.ts +1 -2
  54. package/src/resolve-targets.test.ts +48 -0
  55. package/src/resolve-targets.ts +55 -9
  56. package/src/tool-actions.ts +15 -11
  57. package/src/types.ts +5 -5
@@ -1,5 +1,5 @@
1
- import { markdownToMatrixHtml } from "../format.js";
2
1
  import { getMatrixRuntime } from "../../runtime.js";
2
+ import { markdownToMatrixHtml } from "../format.js";
3
3
  import {
4
4
  MsgType,
5
5
  RelationType,
@@ -13,10 +13,7 @@ import {
13
13
 
14
14
  const getCore = () => getMatrixRuntime();
15
15
 
16
- export function buildTextContent(
17
- body: string,
18
- relation?: MatrixRelation,
19
- ): MatrixTextContent {
16
+ export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
20
17
  const content: MatrixTextContent = relation
21
18
  ? {
22
19
  msgtype: MsgType.Text,
@@ -33,34 +30,32 @@ export function buildTextContent(
33
30
 
34
31
  export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
35
32
  const formatted = markdownToMatrixHtml(body ?? "");
36
- if (!formatted) return;
33
+ if (!formatted) {
34
+ return;
35
+ }
37
36
  content.format = "org.matrix.custom.html";
38
37
  content.formatted_body = formatted;
39
38
  }
40
39
 
41
40
  export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
42
41
  const trimmed = replyToId?.trim();
43
- if (!trimmed) return undefined;
42
+ if (!trimmed) {
43
+ return undefined;
44
+ }
44
45
  return { "m.in_reply_to": { event_id: trimmed } };
45
46
  }
46
47
 
47
- export function buildThreadRelation(
48
- threadId: string,
49
- replyToId?: string,
50
- ): MatrixThreadRelation {
48
+ export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
51
49
  const trimmed = threadId.trim();
52
50
  return {
53
51
  rel_type: RelationType.Thread,
54
52
  event_id: trimmed,
55
53
  is_falling_back: true,
56
- "m.in_reply_to": { event_id: (replyToId?.trim() || trimmed) },
54
+ "m.in_reply_to": { event_id: replyToId?.trim() || trimmed },
57
55
  };
58
56
  }
59
57
 
60
- export function resolveMatrixMsgType(
61
- contentType?: string,
62
- _fileName?: string,
63
- ): MatrixMediaMsgType {
58
+ export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType {
64
59
  const kind = getCore().media.mediaKindFromMime(contentType ?? "");
65
60
  switch (kind) {
66
61
  case "image":
@@ -79,7 +74,9 @@ export function resolveMatrixVoiceDecision(opts: {
79
74
  contentType?: string;
80
75
  fileName?: string;
81
76
  }): { useVoice: boolean } {
82
- if (!opts.wantsVoice) return { useVoice: false };
77
+ if (!opts.wantsVoice) {
78
+ return { useVoice: false };
79
+ }
83
80
  if (
84
81
  getCore().media.isVoiceCompatibleAudio({
85
82
  contentType: opts.contentType,
@@ -7,8 +7,8 @@ import type {
7
7
  VideoFileInfo,
8
8
  } from "@vector-im/matrix-bot-sdk";
9
9
  import { parseBuffer, type IFileInfo } from "music-metadata";
10
-
11
10
  import { getMatrixRuntime } from "../../runtime.js";
11
+ import { applyMatrixFormatting } from "./formatting.js";
12
12
  import {
13
13
  type MatrixMediaContent,
14
14
  type MatrixMediaInfo,
@@ -16,7 +16,6 @@ import {
16
16
  type MatrixRelation,
17
17
  type MediaKind,
18
18
  } from "./types.js";
19
- import { applyMatrixFormatting } from "./formatting.js";
20
19
 
21
20
  const getCore = () => getMatrixRuntime();
22
21
 
@@ -54,7 +53,9 @@ export function buildMatrixMediaInfo(params: {
54
53
  };
55
54
  return timedInfo;
56
55
  }
57
- if (Object.keys(base).length === 0) return undefined;
56
+ if (Object.keys(base).length === 0) {
57
+ return undefined;
58
+ }
58
59
  return base;
59
60
  }
60
61
 
@@ -113,8 +114,12 @@ export async function prepareImageInfo(params: {
113
114
  buffer: Buffer;
114
115
  client: MatrixClient;
115
116
  }): Promise<DimensionalFileInfo | undefined> {
116
- const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
117
- if (!meta) return undefined;
117
+ const meta = await getCore()
118
+ .media.getImageMetadata(params.buffer)
119
+ .catch(() => null);
120
+ if (!meta) {
121
+ return undefined;
122
+ }
118
123
  const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
119
124
  const maxDim = Math.max(meta.width, meta.height);
120
125
  if (maxDim > THUMBNAIL_MAX_SIDE) {
@@ -125,7 +130,9 @@ export async function prepareImageInfo(params: {
125
130
  quality: THUMBNAIL_QUALITY,
126
131
  withoutEnlargement: true,
127
132
  });
128
- const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
133
+ const thumbMeta = await getCore()
134
+ .media.getImageMetadata(thumbBuffer)
135
+ .catch(() => null);
129
136
  const thumbUri = await params.client.uploadContent(
130
137
  thumbBuffer,
131
138
  "image/jpeg",
@@ -153,7 +160,9 @@ export async function resolveMediaDurationMs(params: {
153
160
  fileName?: string;
154
161
  kind: MediaKind;
155
162
  }): Promise<number | undefined> {
156
- if (params.kind !== "audio" && params.kind !== "video") return undefined;
163
+ if (params.kind !== "audio" && params.kind !== "video") {
164
+ return undefined;
165
+ }
157
166
  try {
158
167
  const fileInfo: IFileInfo | string | undefined =
159
168
  params.contentType || params.fileName
@@ -201,7 +210,7 @@ export async function uploadMediaMaybeEncrypted(
201
210
  },
202
211
  ): Promise<{ url: string; file?: EncryptedFile }> {
203
212
  // Check if room is encrypted and crypto is available
204
- const isEncrypted = client.crypto && await client.crypto.isRoomEncrypted(roomId);
213
+ const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
205
214
 
206
215
  if (isEncrypted && client.crypto) {
207
216
  // Encrypt the media before uploading
@@ -1,6 +1,5 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
1
  import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
3
  import { EventType } from "./types.js";
5
4
 
6
5
  let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
@@ -26,7 +25,9 @@ describe("resolveMatrixRoomId", () => {
26
25
  const roomId = await resolveMatrixRoomId(client, userId);
27
26
 
28
27
  expect(roomId).toBe("!room:example.org");
28
+ // oxlint-disable-next-line typescript/unbound-method
29
29
  expect(client.getJoinedRooms).not.toHaveBeenCalled();
30
+ // oxlint-disable-next-line typescript/unbound-method
30
31
  expect(client.setAccountData).not.toHaveBeenCalled();
31
32
  });
32
33
 
@@ -37,10 +38,7 @@ describe("resolveMatrixRoomId", () => {
37
38
  const client = {
38
39
  getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
39
40
  getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
40
- getJoinedRoomMembers: vi.fn().mockResolvedValue([
41
- "@bot:example.org",
42
- userId,
43
- ]),
41
+ getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]),
44
42
  setAccountData,
45
43
  } as unknown as MatrixClient;
46
44
 
@@ -80,11 +78,9 @@ describe("resolveMatrixRoomId", () => {
80
78
  const client = {
81
79
  getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
82
80
  getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
83
- getJoinedRoomMembers: vi.fn().mockResolvedValue([
84
- "@bot:example.org",
85
- userId,
86
- "@extra:example.org",
87
- ]),
81
+ getJoinedRoomMembers: vi
82
+ .fn()
83
+ .mockResolvedValue(["@bot:example.org", userId, "@extra:example.org"]),
88
84
  setAccountData: vi.fn().mockResolvedValue(undefined),
89
85
  } as unknown as MatrixClient;
90
86
 
@@ -1,5 +1,4 @@
1
1
  import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
-
3
2
  import { EventType, type MatrixDirectAccountData } from "./types.js";
4
3
 
5
4
  function normalizeTarget(raw: string): string {
@@ -11,7 +10,9 @@ function normalizeTarget(raw: string): string {
11
10
  }
12
11
 
13
12
  export function normalizeThreadId(raw?: string | number | null): string | null {
14
- if (raw === undefined || raw === null) return null;
13
+ if (raw === undefined || raw === null) {
14
+ return null;
15
+ }
15
16
  const trimmed = String(raw).trim();
16
17
  return trimmed ? trimmed : null;
17
18
  }
@@ -25,16 +26,15 @@ async function persistDirectRoom(
25
26
  ): Promise<void> {
26
27
  let directContent: MatrixDirectAccountData | null = null;
27
28
  try {
28
- directContent = (await client.getAccountData(
29
- EventType.Direct,
30
- )) as MatrixDirectAccountData | null;
29
+ directContent = await client.getAccountData(EventType.Direct);
31
30
  } catch {
32
31
  // Ignore fetch errors and fall back to an empty map.
33
32
  }
34
- const existing =
35
- directContent && !Array.isArray(directContent) ? directContent : {};
33
+ const existing = directContent && !Array.isArray(directContent) ? directContent : {};
36
34
  const current = Array.isArray(existing[userId]) ? existing[userId] : [];
37
- if (current[0] === roomId) return;
35
+ if (current[0] === roomId) {
36
+ return;
37
+ }
38
38
  const next = [roomId, ...current.filter((id) => id !== roomId)];
39
39
  try {
40
40
  await client.setAccountData(EventType.Direct, {
@@ -46,28 +46,21 @@ async function persistDirectRoom(
46
46
  }
47
47
  }
48
48
 
49
- async function resolveDirectRoomId(
50
- client: MatrixClient,
51
- userId: string,
52
- ): Promise<string> {
49
+ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
53
50
  const trimmed = userId.trim();
54
51
  if (!trimmed.startsWith("@")) {
55
- throw new Error(
56
- `Matrix user IDs must be fully qualified (got "${trimmed}")`,
57
- );
52
+ throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
58
53
  }
59
54
 
60
55
  const cached = directRoomCache.get(trimmed);
61
- if (cached) return cached;
56
+ if (cached) {
57
+ return cached;
58
+ }
62
59
 
63
60
  // 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
64
61
  try {
65
- const directContent = (await client.getAccountData(
66
- EventType.Direct,
67
- )) as MatrixDirectAccountData | null;
68
- const list = Array.isArray(directContent?.[trimmed])
69
- ? directContent[trimmed]
70
- : [];
62
+ const directContent = await client.getAccountData(EventType.Direct);
63
+ const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
71
64
  if (list.length > 0) {
72
65
  directRoomCache.set(trimmed, list[0]);
73
66
  return list[0];
@@ -88,7 +81,9 @@ async function resolveDirectRoomId(
88
81
  } catch {
89
82
  continue;
90
83
  }
91
- if (!members.includes(trimmed)) continue;
84
+ if (!members.includes(trimmed)) {
85
+ continue;
86
+ }
92
87
  // Prefer classic 1:1 rooms, but allow larger rooms if requested.
93
88
  if (members.length === 2) {
94
89
  directRoomCache.set(trimmed, roomId);
@@ -112,10 +107,7 @@ async function resolveDirectRoomId(
112
107
  throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
113
108
  }
114
109
 
115
- export async function resolveMatrixRoomId(
116
- client: MatrixClient,
117
- raw: string,
118
- ): Promise<string> {
110
+ export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise<string> {
119
111
  const target = normalizeTarget(raw);
120
112
  const lowered = target.toLowerCase();
121
113
  if (lowered.startsWith("matrix:")) {
@@ -1,6 +1,5 @@
1
- import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
3
  import { setMatrixRuntime } from "../runtime.js";
5
4
 
6
5
  vi.mock("@vector-im/matrix-bot-sdk", () => ({
@@ -1,5 +1,4 @@
1
1
  import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
-
3
2
  import type { PollInput } from "openclaw/plugin-sdk";
4
3
  import { getMatrixRuntime } from "../runtime.js";
5
4
  import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
@@ -123,7 +122,9 @@ export async function sendMessageMatrix(
123
122
  const followupRelation = threadId ? relation : undefined;
124
123
  for (const chunk of textChunks) {
125
124
  const text = chunk.trim();
126
- if (!text) continue;
125
+ if (!text) {
126
+ continue;
127
+ }
127
128
  const followup = buildTextContent(text, followupRelation);
128
129
  const followupEventId = await sendContent(followup);
129
130
  lastMessageId = followupEventId ?? lastMessageId;
@@ -131,7 +132,9 @@ export async function sendMessageMatrix(
131
132
  } else {
132
133
  for (const chunk of chunks.length ? chunks : [""]) {
133
134
  const text = chunk.trim();
134
- if (!text) continue;
135
+ if (!text) {
136
+ continue;
137
+ }
135
138
  const content = buildTextContent(text, relation);
136
139
  const eventId = await sendContent(content);
137
140
  lastMessageId = eventId ?? lastMessageId;
@@ -211,7 +214,9 @@ export async function sendReadReceiptMatrix(
211
214
  eventId: string,
212
215
  client?: MatrixClient,
213
216
  ): Promise<void> {
214
- if (!eventId?.trim()) return;
217
+ if (!eventId?.trim()) {
218
+ return;
219
+ }
215
220
  const { client: resolved, stopOnDone } = await resolveMatrixClient({
216
221
  client,
217
222
  });
package/src/onboarding.ts CHANGED
@@ -6,16 +6,17 @@ import {
6
6
  type ChannelOnboardingDmPolicy,
7
7
  type WizardPrompter,
8
8
  } from "openclaw/plugin-sdk";
9
+ import type { CoreConfig, DmPolicy } from "./types.js";
9
10
  import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
10
11
  import { listMatrixDirectoryPeersLive } from "./directory-live.js";
11
12
  import { resolveMatrixAccount } from "./matrix/accounts.js";
12
13
  import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
13
- import type { CoreConfig, DmPolicy } from "./types.js";
14
14
 
15
15
  const channel = "matrix" as const;
16
16
 
17
17
  function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
18
- const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
18
+ const allowFrom =
19
+ policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
19
20
  return {
20
21
  ...cfg,
21
22
  channels: {
@@ -248,8 +249,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
248
249
  initialValue: existing.homeserver ?? envHomeserver,
249
250
  validate: (value) => {
250
251
  const raw = String(value ?? "").trim();
251
- if (!raw) return "Required";
252
- if (!/^https?:\/\//i.test(raw)) return "Use a full URL (https://...)";
252
+ if (!raw) {
253
+ return "Required";
254
+ }
255
+ if (!/^https?:\/\//i.test(raw)) {
256
+ return "Use a full URL (https://...)";
257
+ }
253
258
  return undefined;
254
259
  },
255
260
  }),
@@ -273,13 +278,13 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
273
278
 
274
279
  if (!accessToken && !password) {
275
280
  // Ask auth method FIRST before asking for user ID
276
- const authMode = (await prompter.select({
281
+ const authMode = await prompter.select({
277
282
  message: "Matrix auth method",
278
283
  options: [
279
284
  { value: "token", label: "Access token (user ID fetched automatically)" },
280
285
  { value: "password", label: "Password (requires user ID)" },
281
286
  ],
282
- })) as "token" | "password";
287
+ });
283
288
 
284
289
  if (authMode === "token") {
285
290
  accessToken = String(
@@ -299,9 +304,15 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
299
304
  initialValue: existing.userId ?? envUserId,
300
305
  validate: (value) => {
301
306
  const raw = String(value ?? "").trim();
302
- if (!raw) return "Required";
303
- if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
304
- if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)";
307
+ if (!raw) {
308
+ return "Required";
309
+ }
310
+ if (!raw.startsWith("@")) {
311
+ return "Matrix user IDs should start with @";
312
+ }
313
+ if (!raw.includes(":")) {
314
+ return "Matrix user IDs should include a server (:server)";
315
+ }
305
316
  return undefined;
306
317
  },
307
318
  }),
@@ -369,7 +380,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
369
380
  const unresolved: string[] = [];
370
381
  for (const entry of accessConfig.entries) {
371
382
  const trimmed = entry.trim();
372
- if (!trimmed) continue;
383
+ if (!trimmed) {
384
+ continue;
385
+ }
373
386
  const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
374
387
  if (cleaned.startsWith("!") && cleaned.includes(":")) {
375
388
  resolvedIds.push(cleaned);
@@ -390,10 +403,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
390
403
  unresolved.push(entry);
391
404
  }
392
405
  }
393
- roomKeys = [
394
- ...resolvedIds,
395
- ...unresolved.map((entry) => entry.trim()).filter(Boolean),
396
- ];
406
+ roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
397
407
  if (resolvedIds.length > 0 || unresolved.length > 0) {
398
408
  await prompter.note(
399
409
  [
package/src/outbound.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
2
-
3
- import { getMatrixRuntime } from "./runtime.js";
4
2
  import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
3
+ import { getMatrixRuntime } from "./runtime.js";
5
4
 
6
5
  export const matrixOutbound: ChannelOutboundAdapter = {
7
6
  deliveryMode: "direct",
@@ -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
+ });
@@ -4,17 +4,15 @@ import type {
4
4
  ChannelResolveResult,
5
5
  RuntimeEnv,
6
6
  } from "openclaw/plugin-sdk";
7
-
8
- import {
9
- listMatrixDirectoryGroupsLive,
10
- listMatrixDirectoryPeersLive,
11
- } from "./directory-live.js";
7
+ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
12
8
 
13
9
  function pickBestGroupMatch(
14
10
  matches: ChannelDirectoryEntry[],
15
11
  query: string,
16
12
  ): ChannelDirectoryEntry | undefined {
17
- if (matches.length === 0) return undefined;
13
+ if (matches.length === 0) {
14
+ return undefined;
15
+ }
18
16
  const normalized = query.trim().toLowerCase();
19
17
  if (normalized) {
20
18
  const exact = matches.find((match) => {
@@ -23,11 +21,59 @@ function pickBestGroupMatch(
23
21
  const id = match.id.trim().toLowerCase();
24
22
  return name === normalized || handle === normalized || id === normalized;
25
23
  });
26
- if (exact) return exact;
24
+ if (exact) {
25
+ return exact;
26
+ }
27
27
  }
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)}`);
@@ -1,5 +1,11 @@
1
1
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2
-
2
+ import {
3
+ createActionGate,
4
+ jsonResult,
5
+ readNumberParam,
6
+ readReactionParams,
7
+ readStringParam,
8
+ } from "openclaw/plugin-sdk";
3
9
  import type { CoreConfig } from "./types.js";
4
10
  import {
5
11
  deleteMatrixMessage,
@@ -15,13 +21,6 @@ import {
15
21
  unpinMatrixMessage,
16
22
  } from "./matrix/actions.js";
17
23
  import { reactMatrixMessage } from "./matrix/send.js";
18
- import {
19
- createActionGate,
20
- jsonResult,
21
- readNumberParam,
22
- readReactionParams,
23
- readStringParam,
24
- } from "openclaw/plugin-sdk";
25
24
 
26
25
  const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
27
26
  const reactionActions = new Set(["react", "reactions"]);
@@ -29,8 +28,12 @@ const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
29
28
 
30
29
  function readRoomId(params: Record<string, unknown>, required = true): string {
31
30
  const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
32
- if (direct) return direct;
33
- if (!required) return readStringParam(params, "to") ?? "";
31
+ if (direct) {
32
+ return direct;
33
+ }
34
+ if (!required) {
35
+ return readStringParam(params, "to") ?? "";
36
+ }
34
37
  return readStringParam(params, "to", { required: true });
35
38
  }
36
39
 
@@ -76,7 +79,8 @@ export async function handleMatrixAction(
76
79
  allowEmpty: true,
77
80
  });
78
81
  const mediaUrl = readStringParam(params, "mediaUrl");
79
- const replyToId = readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo");
82
+ const replyToId =
83
+ readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo");
80
84
  const threadId = readStringParam(params, "threadId");
81
85
  const result = await sendMatrixMessage(to, content, {
82
86
  mediaUrl: mediaUrl ?? undefined,
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;
@@ -79,9 +79,9 @@ export type MatrixConfig = {
79
79
  autoJoinAllowlist?: Array<string | number>;
80
80
  /** Direct message policy + allowlist overrides. */
81
81
  dm?: MatrixDmConfig;
82
- /** Room config allowlist keyed by room ID, alias, or name. */
82
+ /** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */
83
83
  groups?: Record<string, MatrixRoomConfig>;
84
- /** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */
84
+ /** Room config allowlist keyed by room ID or alias. Legacy; use groups. */
85
85
  rooms?: Record<string, MatrixRoomConfig>;
86
86
  /** Per-action tool gating (default: true for all). */
87
87
  actions?: MatrixActionConfig;