@openclaw/matrix 2026.2.15 → 2026.2.19

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 (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -2
  3. package/src/actions.ts +1 -1
  4. package/src/channel.directory.test.ts +17 -7
  5. package/src/channel.ts +1 -1
  6. package/src/directory-live.test.ts +20 -0
  7. package/src/directory-live.ts +51 -33
  8. package/src/group-mentions.ts +1 -1
  9. package/src/matrix/actions/client.ts +7 -25
  10. package/src/matrix/actions/limits.test.ts +15 -0
  11. package/src/matrix/actions/limits.ts +6 -0
  12. package/src/matrix/actions/messages.ts +2 -4
  13. package/src/matrix/actions/pins.test.ts +74 -0
  14. package/src/matrix/actions/pins.ts +36 -28
  15. package/src/matrix/actions/reactions.test.ts +109 -0
  16. package/src/matrix/actions/reactions.ts +23 -17
  17. package/src/matrix/client/config.ts +2 -2
  18. package/src/matrix/client/create-client.ts +1 -1
  19. package/src/matrix/client/shared.ts +1 -1
  20. package/src/matrix/client/storage.ts +1 -1
  21. package/src/matrix/client-bootstrap.ts +39 -0
  22. package/src/matrix/deps.ts +83 -3
  23. package/src/matrix/monitor/auto-join.ts +2 -2
  24. package/src/matrix/monitor/handler.ts +1 -1
  25. package/src/matrix/monitor/index.ts +1 -1
  26. package/src/matrix/monitor/media.test.ts +7 -23
  27. package/src/matrix/monitor/mentions.test.ts +154 -0
  28. package/src/matrix/monitor/mentions.ts +31 -0
  29. package/src/matrix/monitor/replies.test.ts +132 -0
  30. package/src/matrix/monitor/replies.ts +11 -8
  31. package/src/matrix/send/client.ts +6 -25
  32. package/src/matrix/send.test.ts +7 -5
  33. package/src/onboarding.ts +3 -7
  34. package/src/resolve-targets.test.ts +20 -1
  35. package/src/resolve-targets.ts +20 -29
  36. package/src/tool-actions.ts +1 -1
@@ -0,0 +1,132 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
6
+
7
+ vi.mock("../send.js", () => ({
8
+ sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
9
+ sendMessageMatrixMock(to, message, opts),
10
+ }));
11
+
12
+ import { setMatrixRuntime } from "../../runtime.js";
13
+ import { deliverMatrixReplies } from "./replies.js";
14
+
15
+ describe("deliverMatrixReplies", () => {
16
+ const loadConfigMock = vi.fn(() => ({}));
17
+ const resolveMarkdownTableModeMock = vi.fn(() => "code");
18
+ const convertMarkdownTablesMock = vi.fn((text: string) => text);
19
+ const resolveChunkModeMock = vi.fn(() => "length");
20
+ const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
21
+
22
+ const runtimeStub = {
23
+ config: {
24
+ loadConfig: () => loadConfigMock(),
25
+ },
26
+ channel: {
27
+ text: {
28
+ resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
29
+ convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
30
+ resolveChunkMode: () => resolveChunkModeMock(),
31
+ chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
32
+ },
33
+ },
34
+ logging: {
35
+ shouldLogVerbose: () => false,
36
+ },
37
+ } as unknown as PluginRuntime;
38
+
39
+ const runtimeEnv: RuntimeEnv = {
40
+ log: vi.fn(),
41
+ error: vi.fn(),
42
+ } as unknown as RuntimeEnv;
43
+
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ setMatrixRuntime(runtimeStub);
47
+ chunkMarkdownTextWithModeMock.mockImplementation((text: string) => [text]);
48
+ });
49
+
50
+ it("keeps replyToId on first reply only when replyToMode=first", async () => {
51
+ chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
52
+
53
+ await deliverMatrixReplies({
54
+ replies: [
55
+ { text: "first-a|first-b", replyToId: "reply-1" },
56
+ { text: "second", replyToId: "reply-2" },
57
+ ],
58
+ roomId: "room:1",
59
+ client: {} as MatrixClient,
60
+ runtime: runtimeEnv,
61
+ textLimit: 4000,
62
+ replyToMode: "first",
63
+ });
64
+
65
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
66
+ expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
67
+ expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
68
+ );
69
+ expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
70
+ expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
71
+ );
72
+ expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
73
+ expect.objectContaining({ replyToId: undefined, threadId: undefined }),
74
+ );
75
+ });
76
+
77
+ it("keeps replyToId on every reply when replyToMode=all", async () => {
78
+ await deliverMatrixReplies({
79
+ replies: [
80
+ {
81
+ text: "caption",
82
+ mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
83
+ replyToId: "reply-media",
84
+ audioAsVoice: true,
85
+ },
86
+ { text: "plain", replyToId: "reply-text" },
87
+ ],
88
+ roomId: "room:2",
89
+ client: {} as MatrixClient,
90
+ runtime: runtimeEnv,
91
+ textLimit: 4000,
92
+ replyToMode: "all",
93
+ });
94
+
95
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
96
+ expect(sendMessageMatrixMock.mock.calls[0]).toEqual([
97
+ "room:2",
98
+ "caption",
99
+ expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }),
100
+ ]);
101
+ expect(sendMessageMatrixMock.mock.calls[1]).toEqual([
102
+ "room:2",
103
+ "",
104
+ expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }),
105
+ ]);
106
+ expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
107
+ expect.objectContaining({ replyToId: "reply-text" }),
108
+ );
109
+ });
110
+
111
+ it("suppresses replyToId when threadId is set", async () => {
112
+ chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
113
+
114
+ await deliverMatrixReplies({
115
+ replies: [{ text: "hello|thread", replyToId: "reply-thread" }],
116
+ roomId: "room:3",
117
+ client: {} as MatrixClient,
118
+ runtime: runtimeEnv,
119
+ textLimit: 4000,
120
+ replyToMode: "all",
121
+ threadId: "thread-77",
122
+ });
123
+
124
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
125
+ expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
126
+ expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
127
+ );
128
+ expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
129
+ expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
130
+ );
131
+ });
132
+ });
@@ -53,8 +53,10 @@ export async function deliverMatrixReplies(params: {
53
53
 
54
54
  const shouldIncludeReply = (id?: string) =>
55
55
  Boolean(id) && (params.replyToMode === "all" || !hasReplied);
56
+ const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
56
57
 
57
58
  if (mediaList.length === 0) {
59
+ let sentTextChunk = false;
58
60
  for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
59
61
  text,
60
62
  chunkLimit,
@@ -66,13 +68,14 @@ export async function deliverMatrixReplies(params: {
66
68
  }
67
69
  await sendMessageMatrix(params.roomId, trimmed, {
68
70
  client: params.client,
69
- replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
71
+ replyToId: replyToIdForReply,
70
72
  threadId: params.threadId,
71
73
  accountId: params.accountId,
72
74
  });
73
- if (shouldIncludeReply(replyToId)) {
74
- hasReplied = true;
75
- }
75
+ sentTextChunk = true;
76
+ }
77
+ if (replyToIdForReply && !hasReplied && sentTextChunk) {
78
+ hasReplied = true;
76
79
  }
77
80
  continue;
78
81
  }
@@ -83,15 +86,15 @@ export async function deliverMatrixReplies(params: {
83
86
  await sendMessageMatrix(params.roomId, caption, {
84
87
  client: params.client,
85
88
  mediaUrl,
86
- replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
89
+ replyToId: replyToIdForReply,
87
90
  threadId: params.threadId,
88
91
  audioAsVoice: reply.audioAsVoice,
89
92
  accountId: params.accountId,
90
93
  });
91
- if (shouldIncludeReply(replyToId)) {
92
- hasReplied = true;
93
- }
94
94
  first = false;
95
95
  }
96
+ if (replyToIdForReply && !hasReplied) {
97
+ hasReplied = true;
98
+ }
96
99
  }
97
100
  }
@@ -1,14 +1,10 @@
1
1
  import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
- import type { CoreConfig } from "../../types.js";
4
3
  import { getMatrixRuntime } from "../../runtime.js";
4
+ import type { CoreConfig } from "../../types.js";
5
5
  import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
6
- import {
7
- createMatrixClient,
8
- isBunRuntime,
9
- resolveMatrixAuth,
10
- resolveSharedMatrixClient,
11
- } from "../client.js";
6
+ import { createPreparedMatrixClient } from "../client-bootstrap.js";
7
+ import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
12
8
 
13
9
  const getCore = () => getMatrixRuntime();
14
10
 
@@ -92,25 +88,10 @@ export async function resolveMatrixClient(opts: {
92
88
  return { client, stopOnDone: false };
93
89
  }
94
90
  const auth = await resolveMatrixAuth({ accountId });
95
- const client = await createMatrixClient({
96
- homeserver: auth.homeserver,
97
- userId: auth.userId,
98
- accessToken: auth.accessToken,
99
- encryption: auth.encryption,
100
- localTimeoutMs: opts.timeoutMs,
91
+ const client = await createPreparedMatrixClient({
92
+ auth,
93
+ timeoutMs: opts.timeoutMs,
101
94
  accountId,
102
95
  });
103
- if (auth.encryption && client.crypto) {
104
- try {
105
- const joinedRooms = await client.getJoinedRooms();
106
- await (client.crypto as { prepare: (rooms?: string[]) => Promise<void> }).prepare(
107
- joinedRooms,
108
- );
109
- } catch {
110
- // Ignore crypto prep failures for one-off sends; normal sync will retry.
111
- }
112
- }
113
- // @vector-im/matrix-bot-sdk uses start() instead of startClient()
114
- await client.start();
115
96
  return { client, stopOnDone: true };
116
97
  }
@@ -40,11 +40,13 @@ const runtimeStub = {
40
40
  loadConfig: () => ({}),
41
41
  },
42
42
  media: {
43
- loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
44
- mediaKindFromMime: (...args: unknown[]) => mediaKindFromMimeMock(...args),
45
- isVoiceCompatibleAudio: (...args: unknown[]) => isVoiceCompatibleAudioMock(...args),
46
- getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
47
- resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
43
+ loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"],
44
+ mediaKindFromMime:
45
+ mediaKindFromMimeMock as unknown as PluginRuntime["media"]["mediaKindFromMime"],
46
+ isVoiceCompatibleAudio:
47
+ isVoiceCompatibleAudioMock as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
48
+ getImageMetadata: getImageMetadataMock as unknown as PluginRuntime["media"]["getImageMetadata"],
49
+ resizeToJpeg: resizeToJpegMock as unknown as PluginRuntime["media"]["resizeToJpeg"],
48
50
  },
49
51
  channel: {
50
52
  text: {
package/src/onboarding.ts CHANGED
@@ -2,16 +2,17 @@ import type { DmPolicy } from "openclaw/plugin-sdk";
2
2
  import {
3
3
  addWildcardAllowFrom,
4
4
  formatDocsLink,
5
+ mergeAllowFromEntries,
5
6
  promptChannelAccessConfig,
6
7
  type ChannelOnboardingAdapter,
7
8
  type ChannelOnboardingDmPolicy,
8
9
  type WizardPrompter,
9
10
  } from "openclaw/plugin-sdk";
10
- import type { CoreConfig } from "./types.js";
11
11
  import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
12
12
  import { resolveMatrixAccount } from "./matrix/accounts.js";
13
13
  import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
14
14
  import { resolveMatrixTargets } from "./resolve-targets.js";
15
+ import type { CoreConfig } from "./types.js";
15
16
 
16
17
  const channel = "matrix" as const;
17
18
 
@@ -118,12 +119,7 @@ async function promptMatrixAllowFrom(params: {
118
119
  continue;
119
120
  }
120
121
 
121
- const unique = [
122
- ...new Set([
123
- ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
124
- ...resolvedIds,
125
- ]),
126
- ];
122
+ const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
127
123
  return {
128
124
  ...cfg,
129
125
  channels: {
@@ -1,6 +1,6 @@
1
1
  import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
2
2
  import { describe, expect, it, vi, beforeEach } from "vitest";
3
- import { listMatrixDirectoryPeersLive } from "./directory-live.js";
3
+ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
4
4
  import { resolveMatrixTargets } from "./resolve-targets.js";
5
5
 
6
6
  vi.mock("./directory-live.js", () => ({
@@ -11,6 +11,7 @@ vi.mock("./directory-live.js", () => ({
11
11
  describe("resolveMatrixTargets (users)", () => {
12
12
  beforeEach(() => {
13
13
  vi.mocked(listMatrixDirectoryPeersLive).mockReset();
14
+ vi.mocked(listMatrixDirectoryGroupsLive).mockReset();
14
15
  });
15
16
 
16
17
  it("resolves exact unique display name matches", async () => {
@@ -45,4 +46,22 @@ describe("resolveMatrixTargets (users)", () => {
45
46
  expect(result?.resolved).toBe(false);
46
47
  expect(result?.note).toMatch(/use full Matrix ID/i);
47
48
  });
49
+
50
+ it("prefers exact group matches over first partial result", async () => {
51
+ const matches: ChannelDirectoryEntry[] = [
52
+ { kind: "group", id: "!one:example.org", name: "General", handle: "#general" },
53
+ { kind: "group", id: "!two:example.org", name: "Team", handle: "#team" },
54
+ ];
55
+ vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue(matches);
56
+
57
+ const [result] = await resolveMatrixTargets({
58
+ cfg: {},
59
+ inputs: ["#team"],
60
+ kind: "group",
61
+ });
62
+
63
+ expect(result?.resolved).toBe(true);
64
+ expect(result?.id).toBe("!two:example.org");
65
+ expect(result?.note).toBe("multiple matches; chose first");
66
+ });
48
67
  });
@@ -6,6 +6,22 @@ import type {
6
6
  } from "openclaw/plugin-sdk";
7
7
  import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
8
8
 
9
+ function findExactDirectoryMatches(
10
+ matches: ChannelDirectoryEntry[],
11
+ query: string,
12
+ ): ChannelDirectoryEntry[] {
13
+ const normalized = query.trim().toLowerCase();
14
+ if (!normalized) {
15
+ return [];
16
+ }
17
+ return matches.filter((match) => {
18
+ const id = match.id.trim().toLowerCase();
19
+ const name = match.name?.trim().toLowerCase();
20
+ const handle = match.handle?.trim().toLowerCase();
21
+ return normalized === id || normalized === name || normalized === handle;
22
+ });
23
+ }
24
+
9
25
  function pickBestGroupMatch(
10
26
  matches: ChannelDirectoryEntry[],
11
27
  query: string,
@@ -13,19 +29,8 @@ function pickBestGroupMatch(
13
29
  if (matches.length === 0) {
14
30
  return undefined;
15
31
  }
16
- const normalized = query.trim().toLowerCase();
17
- if (normalized) {
18
- const exact = matches.find((match) => {
19
- const name = match.name?.trim().toLowerCase();
20
- const handle = match.handle?.trim().toLowerCase();
21
- const id = match.id.trim().toLowerCase();
22
- return name === normalized || handle === normalized || id === normalized;
23
- });
24
- if (exact) {
25
- return exact;
26
- }
27
- }
28
- return matches[0];
32
+ const [exact] = findExactDirectoryMatches(matches, query);
33
+ return exact ?? matches[0];
29
34
  }
30
35
 
31
36
  function pickBestUserMatch(
@@ -35,16 +40,7 @@ function pickBestUserMatch(
35
40
  if (matches.length === 0) {
36
41
  return undefined;
37
42
  }
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
- });
43
+ const exact = findExactDirectoryMatches(matches, query);
48
44
  if (exact.length === 1) {
49
45
  return exact[0];
50
46
  }
@@ -59,12 +55,7 @@ function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: strin
59
55
  if (!normalized) {
60
56
  return "empty input";
61
57
  }
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
- });
58
+ const exact = findExactDirectoryMatches(matches, normalized);
68
59
  if (exact.length === 0) {
69
60
  return "no exact match; use full Matrix ID";
70
61
  }
@@ -6,7 +6,6 @@ import {
6
6
  readReactionParams,
7
7
  readStringParam,
8
8
  } from "openclaw/plugin-sdk";
9
- import type { CoreConfig } from "./types.js";
10
9
  import {
11
10
  deleteMatrixMessage,
12
11
  editMatrixMessage,
@@ -21,6 +20,7 @@ import {
21
20
  unpinMatrixMessage,
22
21
  } from "./matrix/actions.js";
23
22
  import { reactMatrixMessage } from "./matrix/send.js";
23
+ import type { CoreConfig } from "./types.js";
24
24
 
25
25
  const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
26
26
  const reactionActions = new Set(["react", "reactions"]);