@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.
- package/CHANGELOG.md +59 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +36 -0
- package/src/actions.ts +185 -0
- package/src/channel.directory.test.ts +56 -0
- package/src/channel.ts +417 -0
- package/src/config-schema.ts +62 -0
- package/src/directory-live.ts +175 -0
- package/src/group-mentions.ts +61 -0
- package/src/matrix/accounts.test.ts +83 -0
- package/src/matrix/accounts.ts +63 -0
- package/src/matrix/actions/client.ts +53 -0
- package/src/matrix/actions/messages.ts +120 -0
- package/src/matrix/actions/pins.ts +70 -0
- package/src/matrix/actions/reactions.ts +84 -0
- package/src/matrix/actions/room.ts +88 -0
- package/src/matrix/actions/summary.ts +77 -0
- package/src/matrix/actions/types.ts +84 -0
- package/src/matrix/actions.ts +15 -0
- package/src/matrix/active-client.ts +11 -0
- package/src/matrix/client/config.ts +165 -0
- package/src/matrix/client/create-client.ts +127 -0
- package/src/matrix/client/logging.ts +35 -0
- package/src/matrix/client/runtime.ts +4 -0
- package/src/matrix/client/shared.ts +169 -0
- package/src/matrix/client/storage.ts +131 -0
- package/src/matrix/client/types.ts +34 -0
- package/src/matrix/client.test.ts +57 -0
- package/src/matrix/client.ts +9 -0
- package/src/matrix/credentials.ts +103 -0
- package/src/matrix/deps.ts +57 -0
- package/src/matrix/format.test.ts +34 -0
- package/src/matrix/format.ts +22 -0
- package/src/matrix/index.ts +11 -0
- package/src/matrix/monitor/allowlist.ts +58 -0
- package/src/matrix/monitor/auto-join.ts +68 -0
- package/src/matrix/monitor/direct.ts +105 -0
- package/src/matrix/monitor/events.ts +103 -0
- package/src/matrix/monitor/handler.ts +645 -0
- package/src/matrix/monitor/index.ts +279 -0
- package/src/matrix/monitor/location.ts +83 -0
- package/src/matrix/monitor/media.test.ts +103 -0
- package/src/matrix/monitor/media.ts +113 -0
- package/src/matrix/monitor/mentions.ts +31 -0
- package/src/matrix/monitor/replies.ts +96 -0
- package/src/matrix/monitor/room-info.ts +58 -0
- package/src/matrix/monitor/rooms.ts +43 -0
- package/src/matrix/monitor/threads.ts +64 -0
- package/src/matrix/monitor/types.ts +39 -0
- package/src/matrix/poll-types.test.ts +22 -0
- package/src/matrix/poll-types.ts +157 -0
- package/src/matrix/probe.ts +70 -0
- package/src/matrix/send/client.ts +63 -0
- package/src/matrix/send/formatting.ts +92 -0
- package/src/matrix/send/media.ts +220 -0
- package/src/matrix/send/targets.test.ts +102 -0
- package/src/matrix/send/targets.ts +144 -0
- package/src/matrix/send/types.ts +109 -0
- package/src/matrix/send.test.ts +172 -0
- package/src/matrix/send.ts +255 -0
- package/src/onboarding.ts +432 -0
- package/src/outbound.ts +53 -0
- package/src/resolve-targets.ts +89 -0
- package/src/runtime.ts +14 -0
- package/src/tool-actions.ts +160 -0
- package/src/types.ts +95 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { format } from "node:util";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
mergeAllowlist,
|
|
5
|
+
summarizeMapping,
|
|
6
|
+
type RuntimeEnv,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
|
9
|
+
import { setActiveMatrixClient } from "../active-client.js";
|
|
10
|
+
import {
|
|
11
|
+
isBunRuntime,
|
|
12
|
+
resolveMatrixAuth,
|
|
13
|
+
resolveSharedMatrixClient,
|
|
14
|
+
stopSharedClient,
|
|
15
|
+
} from "../client.js";
|
|
16
|
+
import { registerMatrixAutoJoin } from "./auto-join.js";
|
|
17
|
+
import { createDirectRoomTracker } from "./direct.js";
|
|
18
|
+
import { registerMatrixMonitorEvents } from "./events.js";
|
|
19
|
+
import { createMatrixRoomMessageHandler } from "./handler.js";
|
|
20
|
+
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
|
21
|
+
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
|
22
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
23
|
+
|
|
24
|
+
export type MonitorMatrixOpts = {
|
|
25
|
+
runtime?: RuntimeEnv;
|
|
26
|
+
abortSignal?: AbortSignal;
|
|
27
|
+
mediaMaxMb?: number;
|
|
28
|
+
initialSyncLimit?: number;
|
|
29
|
+
replyToMode?: ReplyToMode;
|
|
30
|
+
accountId?: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DEFAULT_MEDIA_MAX_MB = 20;
|
|
34
|
+
|
|
35
|
+
export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
|
|
36
|
+
if (isBunRuntime()) {
|
|
37
|
+
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
|
38
|
+
}
|
|
39
|
+
const core = getMatrixRuntime();
|
|
40
|
+
let cfg = core.config.loadConfig() as CoreConfig;
|
|
41
|
+
if (cfg.channels?.matrix?.enabled === false) return;
|
|
42
|
+
|
|
43
|
+
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
|
44
|
+
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
|
45
|
+
const runtime: RuntimeEnv = opts.runtime ?? {
|
|
46
|
+
log: (...args) => {
|
|
47
|
+
logger.info(formatRuntimeMessage(...args));
|
|
48
|
+
},
|
|
49
|
+
error: (...args) => {
|
|
50
|
+
logger.error(formatRuntimeMessage(...args));
|
|
51
|
+
},
|
|
52
|
+
exit: (code: number): never => {
|
|
53
|
+
throw new Error(`exit ${code}`);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const logVerboseMessage = (message: string) => {
|
|
57
|
+
if (!core.logging.shouldLogVerbose()) return;
|
|
58
|
+
logger.debug(message);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const normalizeUserEntry = (raw: string) =>
|
|
62
|
+
raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim();
|
|
63
|
+
const normalizeRoomEntry = (raw: string) =>
|
|
64
|
+
raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim();
|
|
65
|
+
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
|
66
|
+
|
|
67
|
+
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
|
68
|
+
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
|
|
69
|
+
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
|
|
70
|
+
|
|
71
|
+
if (allowFrom.length > 0) {
|
|
72
|
+
const entries = allowFrom
|
|
73
|
+
.map((entry) => normalizeUserEntry(String(entry)))
|
|
74
|
+
.filter((entry) => entry && entry !== "*");
|
|
75
|
+
if (entries.length > 0) {
|
|
76
|
+
const mapping: string[] = [];
|
|
77
|
+
const unresolved: string[] = [];
|
|
78
|
+
const additions: string[] = [];
|
|
79
|
+
const pending: string[] = [];
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (isMatrixUserId(entry)) {
|
|
82
|
+
additions.push(entry);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
pending.push(entry);
|
|
86
|
+
}
|
|
87
|
+
if (pending.length > 0) {
|
|
88
|
+
const resolved = await resolveMatrixTargets({
|
|
89
|
+
cfg,
|
|
90
|
+
inputs: pending,
|
|
91
|
+
kind: "user",
|
|
92
|
+
runtime,
|
|
93
|
+
});
|
|
94
|
+
for (const entry of resolved) {
|
|
95
|
+
if (entry.resolved && entry.id) {
|
|
96
|
+
additions.push(entry.id);
|
|
97
|
+
mapping.push(`${entry.input}→${entry.id}`);
|
|
98
|
+
} else {
|
|
99
|
+
unresolved.push(entry.input);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
|
104
|
+
summarizeMapping("matrix users", mapping, unresolved, runtime);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
|
|
109
|
+
const entries = Object.keys(roomsConfig).filter((key) => key !== "*");
|
|
110
|
+
const mapping: string[] = [];
|
|
111
|
+
const unresolved: string[] = [];
|
|
112
|
+
const nextRooms = { ...roomsConfig };
|
|
113
|
+
const pending: Array<{ input: string; query: string }> = [];
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const trimmed = entry.trim();
|
|
116
|
+
if (!trimmed) continue;
|
|
117
|
+
const cleaned = normalizeRoomEntry(trimmed);
|
|
118
|
+
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
|
119
|
+
if (!nextRooms[cleaned]) {
|
|
120
|
+
nextRooms[cleaned] = roomsConfig[entry];
|
|
121
|
+
}
|
|
122
|
+
mapping.push(`${entry}→${cleaned}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
pending.push({ input: entry, query: trimmed });
|
|
126
|
+
}
|
|
127
|
+
if (pending.length > 0) {
|
|
128
|
+
const resolved = await resolveMatrixTargets({
|
|
129
|
+
cfg,
|
|
130
|
+
inputs: pending.map((entry) => entry.query),
|
|
131
|
+
kind: "group",
|
|
132
|
+
runtime,
|
|
133
|
+
});
|
|
134
|
+
resolved.forEach((entry, index) => {
|
|
135
|
+
const source = pending[index];
|
|
136
|
+
if (!source) return;
|
|
137
|
+
if (entry.resolved && entry.id) {
|
|
138
|
+
if (!nextRooms[entry.id]) {
|
|
139
|
+
nextRooms[entry.id] = roomsConfig[source.input];
|
|
140
|
+
}
|
|
141
|
+
mapping.push(`${source.input}→${entry.id}`);
|
|
142
|
+
} else {
|
|
143
|
+
unresolved.push(source.input);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
roomsConfig = nextRooms;
|
|
148
|
+
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
cfg = {
|
|
152
|
+
...cfg,
|
|
153
|
+
channels: {
|
|
154
|
+
...cfg.channels,
|
|
155
|
+
matrix: {
|
|
156
|
+
...cfg.channels?.matrix,
|
|
157
|
+
dm: {
|
|
158
|
+
...cfg.channels?.matrix?.dm,
|
|
159
|
+
allowFrom,
|
|
160
|
+
},
|
|
161
|
+
...(roomsConfig ? { groups: roomsConfig } : {}),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const auth = await resolveMatrixAuth({ cfg });
|
|
167
|
+
const resolvedInitialSyncLimit =
|
|
168
|
+
typeof opts.initialSyncLimit === "number"
|
|
169
|
+
? Math.max(0, Math.floor(opts.initialSyncLimit))
|
|
170
|
+
: auth.initialSyncLimit;
|
|
171
|
+
const authWithLimit =
|
|
172
|
+
resolvedInitialSyncLimit === auth.initialSyncLimit
|
|
173
|
+
? auth
|
|
174
|
+
: { ...auth, initialSyncLimit: resolvedInitialSyncLimit };
|
|
175
|
+
const client = await resolveSharedMatrixClient({
|
|
176
|
+
cfg,
|
|
177
|
+
auth: authWithLimit,
|
|
178
|
+
startClient: false,
|
|
179
|
+
accountId: opts.accountId,
|
|
180
|
+
});
|
|
181
|
+
setActiveMatrixClient(client);
|
|
182
|
+
|
|
183
|
+
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
|
184
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
185
|
+
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
186
|
+
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
|
|
187
|
+
const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off";
|
|
188
|
+
const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound";
|
|
189
|
+
const dmConfig = cfg.channels?.matrix?.dm;
|
|
190
|
+
const dmEnabled = dmConfig?.enabled ?? true;
|
|
191
|
+
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
|
192
|
+
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
|
193
|
+
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
|
|
194
|
+
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
195
|
+
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
|
196
|
+
const startupMs = Date.now();
|
|
197
|
+
const startupGraceMs = 0;
|
|
198
|
+
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
|
|
199
|
+
registerMatrixAutoJoin({ client, cfg, runtime });
|
|
200
|
+
const warnedEncryptedRooms = new Set<string>();
|
|
201
|
+
const warnedCryptoMissingRooms = new Set<string>();
|
|
202
|
+
|
|
203
|
+
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
|
|
204
|
+
const handleRoomMessage = createMatrixRoomMessageHandler({
|
|
205
|
+
client,
|
|
206
|
+
core,
|
|
207
|
+
cfg,
|
|
208
|
+
runtime,
|
|
209
|
+
logger,
|
|
210
|
+
logVerboseMessage,
|
|
211
|
+
allowFrom,
|
|
212
|
+
roomsConfig,
|
|
213
|
+
mentionRegexes,
|
|
214
|
+
groupPolicy,
|
|
215
|
+
replyToMode,
|
|
216
|
+
threadReplies,
|
|
217
|
+
dmEnabled,
|
|
218
|
+
dmPolicy,
|
|
219
|
+
textLimit,
|
|
220
|
+
mediaMaxBytes,
|
|
221
|
+
startupMs,
|
|
222
|
+
startupGraceMs,
|
|
223
|
+
directTracker,
|
|
224
|
+
getRoomInfo,
|
|
225
|
+
getMemberDisplayName,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
registerMatrixMonitorEvents({
|
|
229
|
+
client,
|
|
230
|
+
auth,
|
|
231
|
+
logVerboseMessage,
|
|
232
|
+
warnedEncryptedRooms,
|
|
233
|
+
warnedCryptoMissingRooms,
|
|
234
|
+
logger,
|
|
235
|
+
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
|
|
236
|
+
onRoomMessage: handleRoomMessage,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
logVerboseMessage("matrix: starting client");
|
|
240
|
+
await resolveSharedMatrixClient({
|
|
241
|
+
cfg,
|
|
242
|
+
auth: authWithLimit,
|
|
243
|
+
accountId: opts.accountId,
|
|
244
|
+
});
|
|
245
|
+
logVerboseMessage("matrix: client started");
|
|
246
|
+
|
|
247
|
+
// @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
|
|
248
|
+
logger.info(`matrix: logged in as ${auth.userId}`);
|
|
249
|
+
|
|
250
|
+
// If E2EE is enabled, trigger device verification
|
|
251
|
+
if (auth.encryption && client.crypto) {
|
|
252
|
+
try {
|
|
253
|
+
// Request verification from other sessions
|
|
254
|
+
const verificationRequest = await client.crypto.requestOwnUserVerification();
|
|
255
|
+
if (verificationRequest) {
|
|
256
|
+
logger.info("matrix: device verification requested - please verify in another client");
|
|
257
|
+
}
|
|
258
|
+
} catch (err) {
|
|
259
|
+
logger.debug({ error: String(err) }, "Device verification request failed (may already be verified)");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await new Promise<void>((resolve) => {
|
|
264
|
+
const onAbort = () => {
|
|
265
|
+
try {
|
|
266
|
+
logVerboseMessage("matrix: stopping client");
|
|
267
|
+
stopSharedClient();
|
|
268
|
+
} finally {
|
|
269
|
+
setActiveMatrixClient(null);
|
|
270
|
+
resolve();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
if (opts.abortSignal?.aborted) {
|
|
274
|
+
onAbort();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatLocationText,
|
|
5
|
+
toLocationContext,
|
|
6
|
+
type NormalizedLocation,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
import { EventType } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export type MatrixLocationPayload = {
|
|
11
|
+
text: string;
|
|
12
|
+
context: ReturnType<typeof toLocationContext>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type GeoUriParams = {
|
|
16
|
+
latitude: number;
|
|
17
|
+
longitude: number;
|
|
18
|
+
accuracy?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function parseGeoUri(value: string): GeoUriParams | null {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (!trimmed) return null;
|
|
24
|
+
if (!trimmed.toLowerCase().startsWith("geo:")) return null;
|
|
25
|
+
const payload = trimmed.slice(4);
|
|
26
|
+
const [coordsPart, ...paramParts] = payload.split(";");
|
|
27
|
+
const coords = coordsPart.split(",");
|
|
28
|
+
if (coords.length < 2) return null;
|
|
29
|
+
const latitude = Number.parseFloat(coords[0] ?? "");
|
|
30
|
+
const longitude = Number.parseFloat(coords[1] ?? "");
|
|
31
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
|
32
|
+
|
|
33
|
+
const params = new Map<string, string>();
|
|
34
|
+
for (const part of paramParts) {
|
|
35
|
+
const segment = part.trim();
|
|
36
|
+
if (!segment) continue;
|
|
37
|
+
const eqIndex = segment.indexOf("=");
|
|
38
|
+
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
|
39
|
+
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
|
40
|
+
const key = rawKey.trim().toLowerCase();
|
|
41
|
+
if (!key) continue;
|
|
42
|
+
const valuePart = rawValue.trim();
|
|
43
|
+
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const accuracyRaw = params.get("u");
|
|
47
|
+
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
latitude,
|
|
51
|
+
longitude,
|
|
52
|
+
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveMatrixLocation(params: {
|
|
57
|
+
eventType: string;
|
|
58
|
+
content: LocationMessageEventContent;
|
|
59
|
+
}): MatrixLocationPayload | null {
|
|
60
|
+
const { eventType, content } = params;
|
|
61
|
+
const isLocation =
|
|
62
|
+
eventType === EventType.Location ||
|
|
63
|
+
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
|
64
|
+
if (!isLocation) return null;
|
|
65
|
+
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
|
66
|
+
if (!geoUri) return null;
|
|
67
|
+
const parsed = parseGeoUri(geoUri);
|
|
68
|
+
if (!parsed) return null;
|
|
69
|
+
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
|
70
|
+
const location: NormalizedLocation = {
|
|
71
|
+
latitude: parsed.latitude,
|
|
72
|
+
longitude: parsed.longitude,
|
|
73
|
+
accuracy: parsed.accuracy,
|
|
74
|
+
caption: caption || undefined,
|
|
75
|
+
source: "pin",
|
|
76
|
+
isLive: false,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
text: formatLocationText(location),
|
|
81
|
+
context: toLocationContext(location),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
4
|
+
import { setMatrixRuntime } from "../../runtime.js";
|
|
5
|
+
import { downloadMatrixMedia } from "./media.js";
|
|
6
|
+
|
|
7
|
+
describe("downloadMatrixMedia", () => {
|
|
8
|
+
const saveMediaBuffer = vi.fn().mockResolvedValue({
|
|
9
|
+
path: "/tmp/media",
|
|
10
|
+
contentType: "image/png",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const runtimeStub = {
|
|
14
|
+
channel: {
|
|
15
|
+
media: {
|
|
16
|
+
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
} as unknown as PluginRuntime;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
setMatrixRuntime(runtimeStub);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("decrypts encrypted media when file payloads are present", async () => {
|
|
27
|
+
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
|
28
|
+
|
|
29
|
+
const client = {
|
|
30
|
+
crypto: { decryptMedia },
|
|
31
|
+
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
|
32
|
+
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
|
33
|
+
|
|
34
|
+
const file = {
|
|
35
|
+
url: "mxc://example/file",
|
|
36
|
+
key: {
|
|
37
|
+
kty: "oct",
|
|
38
|
+
key_ops: ["encrypt", "decrypt"],
|
|
39
|
+
alg: "A256CTR",
|
|
40
|
+
k: "secret",
|
|
41
|
+
ext: true,
|
|
42
|
+
},
|
|
43
|
+
iv: "iv",
|
|
44
|
+
hashes: { sha256: "hash" },
|
|
45
|
+
v: "v2",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const result = await downloadMatrixMedia({
|
|
49
|
+
client,
|
|
50
|
+
mxcUrl: "mxc://example/file",
|
|
51
|
+
contentType: "image/png",
|
|
52
|
+
maxBytes: 1024,
|
|
53
|
+
file,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// decryptMedia should be called with just the file object (it handles download internally)
|
|
57
|
+
expect(decryptMedia).toHaveBeenCalledWith(file);
|
|
58
|
+
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
|
59
|
+
Buffer.from("decrypted"),
|
|
60
|
+
"image/png",
|
|
61
|
+
"inbound",
|
|
62
|
+
1024,
|
|
63
|
+
);
|
|
64
|
+
expect(result?.path).toBe("/tmp/media");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
|
|
68
|
+
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
|
69
|
+
|
|
70
|
+
const client = {
|
|
71
|
+
crypto: { decryptMedia },
|
|
72
|
+
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
|
73
|
+
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
|
74
|
+
|
|
75
|
+
const file = {
|
|
76
|
+
url: "mxc://example/file",
|
|
77
|
+
key: {
|
|
78
|
+
kty: "oct",
|
|
79
|
+
key_ops: ["encrypt", "decrypt"],
|
|
80
|
+
alg: "A256CTR",
|
|
81
|
+
k: "secret",
|
|
82
|
+
ext: true,
|
|
83
|
+
},
|
|
84
|
+
iv: "iv",
|
|
85
|
+
hashes: { sha256: "hash" },
|
|
86
|
+
v: "v2",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
downloadMatrixMedia({
|
|
91
|
+
client,
|
|
92
|
+
mxcUrl: "mxc://example/file",
|
|
93
|
+
contentType: "image/png",
|
|
94
|
+
sizeBytes: 2048,
|
|
95
|
+
maxBytes: 1024,
|
|
96
|
+
file,
|
|
97
|
+
}),
|
|
98
|
+
).rejects.toThrow("Matrix media exceeds configured size limit");
|
|
99
|
+
|
|
100
|
+
expect(decryptMedia).not.toHaveBeenCalled();
|
|
101
|
+
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
|
|
3
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
4
|
+
|
|
5
|
+
// Type for encrypted file info
|
|
6
|
+
type EncryptedFile = {
|
|
7
|
+
url: string;
|
|
8
|
+
key: {
|
|
9
|
+
kty: string;
|
|
10
|
+
key_ops: string[];
|
|
11
|
+
alg: string;
|
|
12
|
+
k: string;
|
|
13
|
+
ext: boolean;
|
|
14
|
+
};
|
|
15
|
+
iv: string;
|
|
16
|
+
hashes: Record<string, string>;
|
|
17
|
+
v: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function fetchMatrixMediaBuffer(params: {
|
|
21
|
+
client: MatrixClient;
|
|
22
|
+
mxcUrl: string;
|
|
23
|
+
maxBytes: number;
|
|
24
|
+
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
|
25
|
+
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
|
|
26
|
+
const url = params.client.mxcToHttp(params.mxcUrl);
|
|
27
|
+
if (!url) return null;
|
|
28
|
+
|
|
29
|
+
// Use the client's download method which handles auth
|
|
30
|
+
try {
|
|
31
|
+
const buffer = await params.client.downloadContent(params.mxcUrl);
|
|
32
|
+
if (buffer.byteLength > params.maxBytes) {
|
|
33
|
+
throw new Error("Matrix media exceeds configured size limit");
|
|
34
|
+
}
|
|
35
|
+
return { buffer: Buffer.from(buffer) };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`Matrix media download failed: ${String(err)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Download and decrypt encrypted media from a Matrix room.
|
|
43
|
+
* Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
|
|
44
|
+
*/
|
|
45
|
+
async function fetchEncryptedMediaBuffer(params: {
|
|
46
|
+
client: MatrixClient;
|
|
47
|
+
file: EncryptedFile;
|
|
48
|
+
maxBytes: number;
|
|
49
|
+
}): Promise<{ buffer: Buffer } | null> {
|
|
50
|
+
if (!params.client.crypto) {
|
|
51
|
+
throw new Error("Cannot decrypt media: crypto not enabled");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// decryptMedia handles downloading and decrypting the encrypted content internally
|
|
55
|
+
const decrypted = await params.client.crypto.decryptMedia(params.file);
|
|
56
|
+
|
|
57
|
+
if (decrypted.byteLength > params.maxBytes) {
|
|
58
|
+
throw new Error("Matrix media exceeds configured size limit");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { buffer: decrypted };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function downloadMatrixMedia(params: {
|
|
65
|
+
client: MatrixClient;
|
|
66
|
+
mxcUrl: string;
|
|
67
|
+
contentType?: string;
|
|
68
|
+
sizeBytes?: number;
|
|
69
|
+
maxBytes: number;
|
|
70
|
+
file?: EncryptedFile;
|
|
71
|
+
}): Promise<{
|
|
72
|
+
path: string;
|
|
73
|
+
contentType?: string;
|
|
74
|
+
placeholder: string;
|
|
75
|
+
} | null> {
|
|
76
|
+
let fetched: { buffer: Buffer; headerType?: string } | null;
|
|
77
|
+
if (
|
|
78
|
+
typeof params.sizeBytes === "number" &&
|
|
79
|
+
params.sizeBytes > params.maxBytes
|
|
80
|
+
) {
|
|
81
|
+
throw new Error("Matrix media exceeds configured size limit");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (params.file) {
|
|
85
|
+
// Encrypted media
|
|
86
|
+
fetched = await fetchEncryptedMediaBuffer({
|
|
87
|
+
client: params.client,
|
|
88
|
+
file: params.file,
|
|
89
|
+
maxBytes: params.maxBytes,
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
// Unencrypted media
|
|
93
|
+
fetched = await fetchMatrixMediaBuffer({
|
|
94
|
+
client: params.client,
|
|
95
|
+
mxcUrl: params.mxcUrl,
|
|
96
|
+
maxBytes: params.maxBytes,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!fetched) return null;
|
|
101
|
+
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
|
102
|
+
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
|
103
|
+
fetched.buffer,
|
|
104
|
+
headerType,
|
|
105
|
+
"inbound",
|
|
106
|
+
params.maxBytes,
|
|
107
|
+
);
|
|
108
|
+
return {
|
|
109
|
+
path: saved.path,
|
|
110
|
+
contentType: saved.contentType,
|
|
111
|
+
placeholder: "[matrix media]",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
2
|
+
|
|
3
|
+
// Type for room message content with mentions
|
|
4
|
+
type MessageContentWithMentions = {
|
|
5
|
+
msgtype: string;
|
|
6
|
+
body: string;
|
|
7
|
+
"m.mentions"?: {
|
|
8
|
+
user_ids?: string[];
|
|
9
|
+
room?: boolean;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function resolveMentions(params: {
|
|
14
|
+
content: MessageContentWithMentions;
|
|
15
|
+
userId?: string | null;
|
|
16
|
+
text?: string;
|
|
17
|
+
mentionRegexes: RegExp[];
|
|
18
|
+
}) {
|
|
19
|
+
const mentions = params.content["m.mentions"];
|
|
20
|
+
const mentionedUsers = Array.isArray(mentions?.user_ids)
|
|
21
|
+
? new Set(mentions.user_ids)
|
|
22
|
+
: new Set<string>();
|
|
23
|
+
const wasMentioned =
|
|
24
|
+
Boolean(mentions?.room) ||
|
|
25
|
+
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
|
26
|
+
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
|
|
27
|
+
params.text ?? "",
|
|
28
|
+
params.mentionRegexes,
|
|
29
|
+
);
|
|
30
|
+
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
|
31
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
|
|
3
|
+
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
4
|
+
import { sendMessageMatrix } from "../send.js";
|
|
5
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
6
|
+
|
|
7
|
+
export async function deliverMatrixReplies(params: {
|
|
8
|
+
replies: ReplyPayload[];
|
|
9
|
+
roomId: string;
|
|
10
|
+
client: MatrixClient;
|
|
11
|
+
runtime: RuntimeEnv;
|
|
12
|
+
textLimit: number;
|
|
13
|
+
replyToMode: "off" | "first" | "all";
|
|
14
|
+
threadId?: string;
|
|
15
|
+
accountId?: string;
|
|
16
|
+
tableMode?: MarkdownTableMode;
|
|
17
|
+
}): Promise<void> {
|
|
18
|
+
const core = getMatrixRuntime();
|
|
19
|
+
const cfg = core.config.loadConfig();
|
|
20
|
+
const tableMode =
|
|
21
|
+
params.tableMode ??
|
|
22
|
+
core.channel.text.resolveMarkdownTableMode({
|
|
23
|
+
cfg,
|
|
24
|
+
channel: "matrix",
|
|
25
|
+
accountId: params.accountId,
|
|
26
|
+
});
|
|
27
|
+
const logVerbose = (message: string) => {
|
|
28
|
+
if (core.logging.shouldLogVerbose()) {
|
|
29
|
+
params.runtime.log?.(message);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const chunkLimit = Math.min(params.textLimit, 4000);
|
|
33
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
|
|
34
|
+
let hasReplied = false;
|
|
35
|
+
for (const reply of params.replies) {
|
|
36
|
+
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
|
37
|
+
if (!reply?.text && !hasMedia) {
|
|
38
|
+
if (reply?.audioAsVoice) {
|
|
39
|
+
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
params.runtime.error?.("matrix reply missing text/media");
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const replyToIdRaw = reply.replyToId?.trim();
|
|
46
|
+
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
|
47
|
+
const rawText = reply.text ?? "";
|
|
48
|
+
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
|
|
49
|
+
const mediaList = reply.mediaUrls?.length
|
|
50
|
+
? reply.mediaUrls
|
|
51
|
+
: reply.mediaUrl
|
|
52
|
+
? [reply.mediaUrl]
|
|
53
|
+
: [];
|
|
54
|
+
|
|
55
|
+
const shouldIncludeReply = (id?: string) =>
|
|
56
|
+
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
|
57
|
+
|
|
58
|
+
if (mediaList.length === 0) {
|
|
59
|
+
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
|
|
60
|
+
text,
|
|
61
|
+
chunkLimit,
|
|
62
|
+
chunkMode,
|
|
63
|
+
)) {
|
|
64
|
+
const trimmed = chunk.trim();
|
|
65
|
+
if (!trimmed) continue;
|
|
66
|
+
await sendMessageMatrix(params.roomId, trimmed, {
|
|
67
|
+
client: params.client,
|
|
68
|
+
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
|
69
|
+
threadId: params.threadId,
|
|
70
|
+
accountId: params.accountId,
|
|
71
|
+
});
|
|
72
|
+
if (shouldIncludeReply(replyToId)) {
|
|
73
|
+
hasReplied = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let first = true;
|
|
80
|
+
for (const mediaUrl of mediaList) {
|
|
81
|
+
const caption = first ? text : "";
|
|
82
|
+
await sendMessageMatrix(params.roomId, caption, {
|
|
83
|
+
client: params.client,
|
|
84
|
+
mediaUrl,
|
|
85
|
+
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
|
86
|
+
threadId: params.threadId,
|
|
87
|
+
audioAsVoice: reply.audioAsVoice,
|
|
88
|
+
accountId: params.accountId,
|
|
89
|
+
});
|
|
90
|
+
if (shouldIncludeReply(replyToId)) {
|
|
91
|
+
hasReplied = true;
|
|
92
|
+
}
|
|
93
|
+
first = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|