@openclaw/matrix 2026.1.29

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 (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/index.ts +18 -0
  3. package/openclaw.plugin.json +11 -0
  4. package/package.json +36 -0
  5. package/src/actions.ts +185 -0
  6. package/src/channel.directory.test.ts +56 -0
  7. package/src/channel.ts +417 -0
  8. package/src/config-schema.ts +62 -0
  9. package/src/directory-live.ts +175 -0
  10. package/src/group-mentions.ts +61 -0
  11. package/src/matrix/accounts.test.ts +83 -0
  12. package/src/matrix/accounts.ts +63 -0
  13. package/src/matrix/actions/client.ts +53 -0
  14. package/src/matrix/actions/messages.ts +120 -0
  15. package/src/matrix/actions/pins.ts +70 -0
  16. package/src/matrix/actions/reactions.ts +84 -0
  17. package/src/matrix/actions/room.ts +88 -0
  18. package/src/matrix/actions/summary.ts +77 -0
  19. package/src/matrix/actions/types.ts +84 -0
  20. package/src/matrix/actions.ts +15 -0
  21. package/src/matrix/active-client.ts +11 -0
  22. package/src/matrix/client/config.ts +165 -0
  23. package/src/matrix/client/create-client.ts +127 -0
  24. package/src/matrix/client/logging.ts +35 -0
  25. package/src/matrix/client/runtime.ts +4 -0
  26. package/src/matrix/client/shared.ts +169 -0
  27. package/src/matrix/client/storage.ts +131 -0
  28. package/src/matrix/client/types.ts +34 -0
  29. package/src/matrix/client.test.ts +57 -0
  30. package/src/matrix/client.ts +9 -0
  31. package/src/matrix/credentials.ts +103 -0
  32. package/src/matrix/deps.ts +57 -0
  33. package/src/matrix/format.test.ts +34 -0
  34. package/src/matrix/format.ts +22 -0
  35. package/src/matrix/index.ts +11 -0
  36. package/src/matrix/monitor/allowlist.ts +58 -0
  37. package/src/matrix/monitor/auto-join.ts +68 -0
  38. package/src/matrix/monitor/direct.ts +105 -0
  39. package/src/matrix/monitor/events.ts +103 -0
  40. package/src/matrix/monitor/handler.ts +645 -0
  41. package/src/matrix/monitor/index.ts +279 -0
  42. package/src/matrix/monitor/location.ts +83 -0
  43. package/src/matrix/monitor/media.test.ts +103 -0
  44. package/src/matrix/monitor/media.ts +113 -0
  45. package/src/matrix/monitor/mentions.ts +31 -0
  46. package/src/matrix/monitor/replies.ts +96 -0
  47. package/src/matrix/monitor/room-info.ts +58 -0
  48. package/src/matrix/monitor/rooms.ts +43 -0
  49. package/src/matrix/monitor/threads.ts +64 -0
  50. package/src/matrix/monitor/types.ts +39 -0
  51. package/src/matrix/poll-types.test.ts +22 -0
  52. package/src/matrix/poll-types.ts +157 -0
  53. package/src/matrix/probe.ts +70 -0
  54. package/src/matrix/send/client.ts +63 -0
  55. package/src/matrix/send/formatting.ts +92 -0
  56. package/src/matrix/send/media.ts +220 -0
  57. package/src/matrix/send/targets.test.ts +102 -0
  58. package/src/matrix/send/targets.ts +144 -0
  59. package/src/matrix/send/types.ts +109 -0
  60. package/src/matrix/send.test.ts +172 -0
  61. package/src/matrix/send.ts +255 -0
  62. package/src/onboarding.ts +432 -0
  63. package/src/outbound.ts +53 -0
  64. package/src/resolve-targets.ts +89 -0
  65. package/src/runtime.ts +14 -0
  66. package/src/tool-actions.ts +160 -0
  67. package/src/types.ts +95 -0
@@ -0,0 +1,172 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
4
+ import { setMatrixRuntime } from "../runtime.js";
5
+
6
+ vi.mock("@vector-im/matrix-bot-sdk", () => ({
7
+ ConsoleLogger: class {
8
+ trace = vi.fn();
9
+ debug = vi.fn();
10
+ info = vi.fn();
11
+ warn = vi.fn();
12
+ error = vi.fn();
13
+ },
14
+ LogService: {
15
+ setLogger: vi.fn(),
16
+ },
17
+ MatrixClient: vi.fn(),
18
+ SimpleFsStorageProvider: vi.fn(),
19
+ RustSdkCryptoStorageProvider: vi.fn(),
20
+ }));
21
+
22
+ const loadWebMediaMock = vi.fn().mockResolvedValue({
23
+ buffer: Buffer.from("media"),
24
+ fileName: "photo.png",
25
+ contentType: "image/png",
26
+ kind: "image",
27
+ });
28
+ const getImageMetadataMock = vi.fn().mockResolvedValue(null);
29
+ const resizeToJpegMock = vi.fn();
30
+
31
+ const runtimeStub = {
32
+ config: {
33
+ loadConfig: () => ({}),
34
+ },
35
+ media: {
36
+ loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
37
+ mediaKindFromMime: () => "image",
38
+ isVoiceCompatibleAudio: () => false,
39
+ getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
40
+ resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
41
+ },
42
+ channel: {
43
+ text: {
44
+ resolveTextChunkLimit: () => 4000,
45
+ resolveChunkMode: () => "length",
46
+ chunkMarkdownText: (text: string) => (text ? [text] : []),
47
+ chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
48
+ resolveMarkdownTableMode: () => "code",
49
+ convertMarkdownTables: (text: string) => text,
50
+ },
51
+ },
52
+ } as unknown as PluginRuntime;
53
+
54
+ let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
55
+
56
+ const makeClient = () => {
57
+ const sendMessage = vi.fn().mockResolvedValue("evt1");
58
+ const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
59
+ const client = {
60
+ sendMessage,
61
+ uploadContent,
62
+ getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
63
+ } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
64
+ return { client, sendMessage, uploadContent };
65
+ };
66
+
67
+ describe("sendMessageMatrix media", () => {
68
+ beforeAll(async () => {
69
+ setMatrixRuntime(runtimeStub);
70
+ ({ sendMessageMatrix } = await import("./send.js"));
71
+ });
72
+
73
+ beforeEach(() => {
74
+ vi.clearAllMocks();
75
+ setMatrixRuntime(runtimeStub);
76
+ });
77
+
78
+ it("uploads media with url payloads", async () => {
79
+ const { client, sendMessage, uploadContent } = makeClient();
80
+
81
+ await sendMessageMatrix("room:!room:example", "caption", {
82
+ client,
83
+ mediaUrl: "file:///tmp/photo.png",
84
+ });
85
+
86
+ const uploadArg = uploadContent.mock.calls[0]?.[0];
87
+ expect(Buffer.isBuffer(uploadArg)).toBe(true);
88
+
89
+ const content = sendMessage.mock.calls[0]?.[1] as {
90
+ url?: string;
91
+ msgtype?: string;
92
+ format?: string;
93
+ formatted_body?: string;
94
+ };
95
+ expect(content.msgtype).toBe("m.image");
96
+ expect(content.format).toBe("org.matrix.custom.html");
97
+ expect(content.formatted_body).toContain("caption");
98
+ expect(content.url).toBe("mxc://example/file");
99
+ });
100
+
101
+ it("uploads encrypted media with file payloads", async () => {
102
+ const { client, sendMessage, uploadContent } = makeClient();
103
+ (client as { crypto?: object }).crypto = {
104
+ isRoomEncrypted: vi.fn().mockResolvedValue(true),
105
+ encryptMedia: vi.fn().mockResolvedValue({
106
+ buffer: Buffer.from("encrypted"),
107
+ file: {
108
+ key: {
109
+ kty: "oct",
110
+ key_ops: ["encrypt", "decrypt"],
111
+ alg: "A256CTR",
112
+ k: "secret",
113
+ ext: true,
114
+ },
115
+ iv: "iv",
116
+ hashes: { sha256: "hash" },
117
+ v: "v2",
118
+ },
119
+ }),
120
+ };
121
+
122
+ await sendMessageMatrix("room:!room:example", "caption", {
123
+ client,
124
+ mediaUrl: "file:///tmp/photo.png",
125
+ });
126
+
127
+ const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
128
+ expect(uploadArg?.toString()).toBe("encrypted");
129
+
130
+ const content = sendMessage.mock.calls[0]?.[1] as {
131
+ url?: string;
132
+ file?: { url?: string };
133
+ };
134
+ expect(content.url).toBeUndefined();
135
+ expect(content.file?.url).toBe("mxc://example/file");
136
+ });
137
+ });
138
+
139
+ describe("sendMessageMatrix threads", () => {
140
+ beforeAll(async () => {
141
+ setMatrixRuntime(runtimeStub);
142
+ ({ sendMessageMatrix } = await import("./send.js"));
143
+ });
144
+
145
+ beforeEach(() => {
146
+ vi.clearAllMocks();
147
+ setMatrixRuntime(runtimeStub);
148
+ });
149
+
150
+ it("includes thread relation metadata when threadId is set", async () => {
151
+ const { client, sendMessage } = makeClient();
152
+
153
+ await sendMessageMatrix("room:!room:example", "hello thread", {
154
+ client,
155
+ threadId: "$thread",
156
+ });
157
+
158
+ const content = sendMessage.mock.calls[0]?.[1] as {
159
+ "m.relates_to"?: {
160
+ rel_type?: string;
161
+ event_id?: string;
162
+ "m.in_reply_to"?: { event_id?: string };
163
+ };
164
+ };
165
+
166
+ expect(content["m.relates_to"]).toMatchObject({
167
+ rel_type: "m.thread",
168
+ event_id: "$thread",
169
+ "m.in_reply_to": { event_id: "$thread" },
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,255 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+
3
+ import type { PollInput } from "openclaw/plugin-sdk";
4
+ import { getMatrixRuntime } from "../runtime.js";
5
+ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
6
+ import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
7
+ import {
8
+ buildReplyRelation,
9
+ buildTextContent,
10
+ buildThreadRelation,
11
+ resolveMatrixMsgType,
12
+ resolveMatrixVoiceDecision,
13
+ } from "./send/formatting.js";
14
+ import {
15
+ buildMediaContent,
16
+ prepareImageInfo,
17
+ resolveMediaDurationMs,
18
+ uploadMediaMaybeEncrypted,
19
+ } from "./send/media.js";
20
+ import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js";
21
+ import {
22
+ EventType,
23
+ MsgType,
24
+ RelationType,
25
+ type MatrixOutboundContent,
26
+ type MatrixSendOpts,
27
+ type MatrixSendResult,
28
+ type ReactionEventContent,
29
+ } from "./send/types.js";
30
+
31
+ const MATRIX_TEXT_LIMIT = 4000;
32
+ const getCore = () => getMatrixRuntime();
33
+
34
+ export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js";
35
+ export { resolveMatrixRoomId } from "./send/targets.js";
36
+
37
+ export async function sendMessageMatrix(
38
+ to: string,
39
+ message: string,
40
+ opts: MatrixSendOpts = {},
41
+ ): Promise<MatrixSendResult> {
42
+ const trimmedMessage = message?.trim() ?? "";
43
+ if (!trimmedMessage && !opts.mediaUrl) {
44
+ throw new Error("Matrix send requires text or media");
45
+ }
46
+ const { client, stopOnDone } = await resolveMatrixClient({
47
+ client: opts.client,
48
+ timeoutMs: opts.timeoutMs,
49
+ });
50
+ try {
51
+ const roomId = await resolveMatrixRoomId(client, to);
52
+ const cfg = getCore().config.loadConfig();
53
+ const tableMode = getCore().channel.text.resolveMarkdownTableMode({
54
+ cfg,
55
+ channel: "matrix",
56
+ accountId: opts.accountId,
57
+ });
58
+ const convertedMessage = getCore().channel.text.convertMarkdownTables(
59
+ trimmedMessage,
60
+ tableMode,
61
+ );
62
+ const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
63
+ const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
64
+ const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
65
+ const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
66
+ convertedMessage,
67
+ chunkLimit,
68
+ chunkMode,
69
+ );
70
+ const threadId = normalizeThreadId(opts.threadId);
71
+ const relation = threadId
72
+ ? buildThreadRelation(threadId, opts.replyToId)
73
+ : buildReplyRelation(opts.replyToId);
74
+ const sendContent = async (content: MatrixOutboundContent) => {
75
+ // @vector-im/matrix-bot-sdk uses sendMessage differently
76
+ const eventId = await client.sendMessage(roomId, content);
77
+ return eventId;
78
+ };
79
+
80
+ let lastMessageId = "";
81
+ if (opts.mediaUrl) {
82
+ const maxBytes = resolveMediaMaxBytes();
83
+ const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
84
+ const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
85
+ contentType: media.contentType,
86
+ filename: media.fileName,
87
+ });
88
+ const durationMs = await resolveMediaDurationMs({
89
+ buffer: media.buffer,
90
+ contentType: media.contentType,
91
+ fileName: media.fileName,
92
+ kind: media.kind,
93
+ });
94
+ const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
95
+ const { useVoice } = resolveMatrixVoiceDecision({
96
+ wantsVoice: opts.audioAsVoice === true,
97
+ contentType: media.contentType,
98
+ fileName: media.fileName,
99
+ });
100
+ const msgtype = useVoice ? MsgType.Audio : baseMsgType;
101
+ const isImage = msgtype === MsgType.Image;
102
+ const imageInfo = isImage
103
+ ? await prepareImageInfo({ buffer: media.buffer, client })
104
+ : undefined;
105
+ const [firstChunk, ...rest] = chunks;
106
+ const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
107
+ const content = buildMediaContent({
108
+ msgtype,
109
+ body,
110
+ url: uploaded.url,
111
+ file: uploaded.file,
112
+ filename: media.fileName,
113
+ mimetype: media.contentType,
114
+ size: media.buffer.byteLength,
115
+ durationMs,
116
+ relation,
117
+ isVoice: useVoice,
118
+ imageInfo,
119
+ });
120
+ const eventId = await sendContent(content);
121
+ lastMessageId = eventId ?? lastMessageId;
122
+ const textChunks = useVoice ? chunks : rest;
123
+ const followupRelation = threadId ? relation : undefined;
124
+ for (const chunk of textChunks) {
125
+ const text = chunk.trim();
126
+ if (!text) continue;
127
+ const followup = buildTextContent(text, followupRelation);
128
+ const followupEventId = await sendContent(followup);
129
+ lastMessageId = followupEventId ?? lastMessageId;
130
+ }
131
+ } else {
132
+ for (const chunk of chunks.length ? chunks : [""]) {
133
+ const text = chunk.trim();
134
+ if (!text) continue;
135
+ const content = buildTextContent(text, relation);
136
+ const eventId = await sendContent(content);
137
+ lastMessageId = eventId ?? lastMessageId;
138
+ }
139
+ }
140
+
141
+ return {
142
+ messageId: lastMessageId || "unknown",
143
+ roomId,
144
+ };
145
+ } finally {
146
+ if (stopOnDone) {
147
+ client.stop();
148
+ }
149
+ }
150
+ }
151
+
152
+ export async function sendPollMatrix(
153
+ to: string,
154
+ poll: PollInput,
155
+ opts: MatrixSendOpts = {},
156
+ ): Promise<{ eventId: string; roomId: string }> {
157
+ if (!poll.question?.trim()) {
158
+ throw new Error("Matrix poll requires a question");
159
+ }
160
+ if (!poll.options?.length) {
161
+ throw new Error("Matrix poll requires options");
162
+ }
163
+ const { client, stopOnDone } = await resolveMatrixClient({
164
+ client: opts.client,
165
+ timeoutMs: opts.timeoutMs,
166
+ });
167
+
168
+ try {
169
+ const roomId = await resolveMatrixRoomId(client, to);
170
+ const pollContent = buildPollStartContent(poll);
171
+ const threadId = normalizeThreadId(opts.threadId);
172
+ const pollPayload = threadId
173
+ ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
174
+ : pollContent;
175
+ // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
176
+ const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
177
+
178
+ return {
179
+ eventId: eventId ?? "unknown",
180
+ roomId,
181
+ };
182
+ } finally {
183
+ if (stopOnDone) {
184
+ client.stop();
185
+ }
186
+ }
187
+ }
188
+
189
+ export async function sendTypingMatrix(
190
+ roomId: string,
191
+ typing: boolean,
192
+ timeoutMs?: number,
193
+ client?: MatrixClient,
194
+ ): Promise<void> {
195
+ const { client: resolved, stopOnDone } = await resolveMatrixClient({
196
+ client,
197
+ timeoutMs,
198
+ });
199
+ try {
200
+ const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
201
+ await resolved.setTyping(roomId, typing, resolvedTimeoutMs);
202
+ } finally {
203
+ if (stopOnDone) {
204
+ resolved.stop();
205
+ }
206
+ }
207
+ }
208
+
209
+ export async function sendReadReceiptMatrix(
210
+ roomId: string,
211
+ eventId: string,
212
+ client?: MatrixClient,
213
+ ): Promise<void> {
214
+ if (!eventId?.trim()) return;
215
+ const { client: resolved, stopOnDone } = await resolveMatrixClient({
216
+ client,
217
+ });
218
+ try {
219
+ const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
220
+ await resolved.sendReadReceipt(resolvedRoom, eventId.trim());
221
+ } finally {
222
+ if (stopOnDone) {
223
+ resolved.stop();
224
+ }
225
+ }
226
+ }
227
+
228
+ export async function reactMatrixMessage(
229
+ roomId: string,
230
+ messageId: string,
231
+ emoji: string,
232
+ client?: MatrixClient,
233
+ ): Promise<void> {
234
+ if (!emoji.trim()) {
235
+ throw new Error("Matrix reaction requires an emoji");
236
+ }
237
+ const { client: resolved, stopOnDone } = await resolveMatrixClient({
238
+ client,
239
+ });
240
+ try {
241
+ const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
242
+ const reaction: ReactionEventContent = {
243
+ "m.relates_to": {
244
+ rel_type: RelationType.Annotation,
245
+ event_id: messageId,
246
+ key: emoji,
247
+ },
248
+ };
249
+ await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
250
+ } finally {
251
+ if (stopOnDone) {
252
+ resolved.stop();
253
+ }
254
+ }
255
+ }