@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,645 @@
1
+ import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+
3
+ import {
4
+ createReplyPrefixContext,
5
+ createTypingCallbacks,
6
+ formatAllowlistMatchMeta,
7
+ logInboundDrop,
8
+ logTypingFailure,
9
+ resolveControlCommandGate,
10
+ type RuntimeEnv,
11
+ } from "openclaw/plugin-sdk";
12
+ import type { CoreConfig, ReplyToMode } from "../../types.js";
13
+ import {
14
+ formatPollAsText,
15
+ isPollStartType,
16
+ parsePollStartContent,
17
+ type PollStartContent,
18
+ } from "../poll-types.js";
19
+ import { reactMatrixMessage, sendMessageMatrix, sendReadReceiptMatrix, sendTypingMatrix } from "../send.js";
20
+ import {
21
+ resolveMatrixAllowListMatch,
22
+ resolveMatrixAllowListMatches,
23
+ normalizeAllowListLower,
24
+ } from "./allowlist.js";
25
+ import { downloadMatrixMedia } from "./media.js";
26
+ import { resolveMentions } from "./mentions.js";
27
+ import { deliverMatrixReplies } from "./replies.js";
28
+ import { resolveMatrixRoomConfig } from "./rooms.js";
29
+ import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
30
+ import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
31
+ import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
32
+ import { EventType, RelationType } from "./types.js";
33
+
34
+ export type MatrixMonitorHandlerParams = {
35
+ client: MatrixClient;
36
+ core: {
37
+ logging: {
38
+ shouldLogVerbose: () => boolean;
39
+ };
40
+ channel: typeof import("openclaw/plugin-sdk")["channel"];
41
+ system: {
42
+ enqueueSystemEvent: (
43
+ text: string,
44
+ meta: { sessionKey?: string | null; contextKey?: string | null },
45
+ ) => void;
46
+ };
47
+ };
48
+ cfg: CoreConfig;
49
+ runtime: RuntimeEnv;
50
+ logger: {
51
+ info: (message: string | Record<string, unknown>, ...meta: unknown[]) => void;
52
+ warn: (meta: Record<string, unknown>, message: string) => void;
53
+ };
54
+ logVerboseMessage: (message: string) => void;
55
+ allowFrom: string[];
56
+ roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig }
57
+ ? MatrixConfig extends { groups?: infer Groups }
58
+ ? Groups
59
+ : Record<string, unknown> | undefined
60
+ : Record<string, unknown> | undefined;
61
+ mentionRegexes: ReturnType<
62
+ typeof import("openclaw/plugin-sdk")["channel"]["mentions"]["buildMentionRegexes"]
63
+ >;
64
+ groupPolicy: "open" | "allowlist" | "disabled";
65
+ replyToMode: ReplyToMode;
66
+ threadReplies: "off" | "inbound" | "always";
67
+ dmEnabled: boolean;
68
+ dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
69
+ textLimit: number;
70
+ mediaMaxBytes: number;
71
+ startupMs: number;
72
+ startupGraceMs: number;
73
+ directTracker: {
74
+ isDirectMessage: (params: {
75
+ roomId: string;
76
+ senderId: string;
77
+ selfUserId: string;
78
+ }) => Promise<boolean>;
79
+ };
80
+ getRoomInfo: (roomId: string) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
81
+ getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
82
+ };
83
+
84
+ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
85
+ const {
86
+ client,
87
+ core,
88
+ cfg,
89
+ runtime,
90
+ logger,
91
+ logVerboseMessage,
92
+ allowFrom,
93
+ roomsConfig,
94
+ mentionRegexes,
95
+ groupPolicy,
96
+ replyToMode,
97
+ threadReplies,
98
+ dmEnabled,
99
+ dmPolicy,
100
+ textLimit,
101
+ mediaMaxBytes,
102
+ startupMs,
103
+ startupGraceMs,
104
+ directTracker,
105
+ getRoomInfo,
106
+ getMemberDisplayName,
107
+ } = params;
108
+
109
+ return async (roomId: string, event: MatrixRawEvent) => {
110
+ try {
111
+ const eventType = event.type;
112
+ if (eventType === EventType.RoomMessageEncrypted) {
113
+ // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
114
+ return;
115
+ }
116
+
117
+ const isPollEvent = isPollStartType(eventType);
118
+ const locationContent = event.content as LocationMessageEventContent;
119
+ const isLocationEvent =
120
+ eventType === EventType.Location ||
121
+ (eventType === EventType.RoomMessage &&
122
+ locationContent.msgtype === EventType.Location);
123
+ if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return;
124
+ logVerboseMessage(
125
+ `matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
126
+ );
127
+ if (event.unsigned?.redacted_because) return;
128
+ const senderId = event.sender;
129
+ if (!senderId) return;
130
+ const selfUserId = await client.getUserId();
131
+ if (senderId === selfUserId) return;
132
+ const eventTs = event.origin_server_ts;
133
+ const eventAge = event.unsigned?.age;
134
+ if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
135
+ return;
136
+ }
137
+ if (
138
+ typeof eventTs !== "number" &&
139
+ typeof eventAge === "number" &&
140
+ eventAge > startupGraceMs
141
+ ) {
142
+ return;
143
+ }
144
+
145
+ const roomInfo = await getRoomInfo(roomId);
146
+ const roomName = roomInfo.name;
147
+ const roomAliases = [
148
+ roomInfo.canonicalAlias ?? "",
149
+ ...roomInfo.altAliases,
150
+ ].filter(Boolean);
151
+
152
+ let content = event.content as RoomMessageEventContent;
153
+ if (isPollEvent) {
154
+ const pollStartContent = event.content as PollStartContent;
155
+ const pollSummary = parsePollStartContent(pollStartContent);
156
+ if (pollSummary) {
157
+ pollSummary.eventId = event.event_id ?? "";
158
+ pollSummary.roomId = roomId;
159
+ pollSummary.sender = senderId;
160
+ const senderDisplayName = await getMemberDisplayName(roomId, senderId);
161
+ pollSummary.senderName = senderDisplayName;
162
+ const pollText = formatPollAsText(pollSummary);
163
+ content = {
164
+ msgtype: "m.text",
165
+ body: pollText,
166
+ } as unknown as RoomMessageEventContent;
167
+ } else {
168
+ return;
169
+ }
170
+ }
171
+
172
+ const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
173
+ eventType,
174
+ content: content as LocationMessageEventContent,
175
+ });
176
+
177
+ const relates = content["m.relates_to"];
178
+ if (relates && "rel_type" in relates) {
179
+ if (relates.rel_type === RelationType.Replace) return;
180
+ }
181
+
182
+ const isDirectMessage = await directTracker.isDirectMessage({
183
+ roomId,
184
+ senderId,
185
+ selfUserId,
186
+ });
187
+ const isRoom = !isDirectMessage;
188
+
189
+ if (isRoom && groupPolicy === "disabled") return;
190
+
191
+ const roomConfigInfo = isRoom
192
+ ? resolveMatrixRoomConfig({
193
+ rooms: roomsConfig,
194
+ roomId,
195
+ aliases: roomAliases,
196
+ name: roomName,
197
+ })
198
+ : undefined;
199
+ const roomConfig = roomConfigInfo?.config;
200
+ const roomMatchMeta = roomConfigInfo
201
+ ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
202
+ roomConfigInfo.matchSource ?? "none"
203
+ }`
204
+ : "matchKey=none matchSource=none";
205
+
206
+ if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
207
+ logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
208
+ return;
209
+ }
210
+ if (isRoom && groupPolicy === "allowlist") {
211
+ if (!roomConfigInfo?.allowlistConfigured) {
212
+ logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
213
+ return;
214
+ }
215
+ if (!roomConfig) {
216
+ logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
217
+ return;
218
+ }
219
+ }
220
+
221
+ const senderName = await getMemberDisplayName(roomId, senderId);
222
+ const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
223
+ const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
224
+ const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
225
+ const effectiveGroupAllowFrom = normalizeAllowListLower([
226
+ ...groupAllowFrom,
227
+ ...storeAllowFrom,
228
+ ]);
229
+ const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
230
+
231
+ if (isDirectMessage) {
232
+ if (!dmEnabled || dmPolicy === "disabled") return;
233
+ if (dmPolicy !== "open") {
234
+ const allowMatch = resolveMatrixAllowListMatch({
235
+ allowList: effectiveAllowFrom,
236
+ userId: senderId,
237
+ userName: senderName,
238
+ });
239
+ const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
240
+ if (!allowMatch.allowed) {
241
+ if (dmPolicy === "pairing") {
242
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
243
+ channel: "matrix",
244
+ id: senderId,
245
+ meta: { name: senderName },
246
+ });
247
+ if (created) {
248
+ logVerboseMessage(
249
+ `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
250
+ );
251
+ try {
252
+ await sendMessageMatrix(
253
+ `room:${roomId}`,
254
+ [
255
+ "OpenClaw: access not configured.",
256
+ "",
257
+ `Pairing code: ${code}`,
258
+ "",
259
+ "Ask the bot owner to approve with:",
260
+ "openclaw pairing approve matrix <code>",
261
+ ].join("\n"),
262
+ { client },
263
+ );
264
+ } catch (err) {
265
+ logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
266
+ }
267
+ }
268
+ }
269
+ if (dmPolicy !== "pairing") {
270
+ logVerboseMessage(
271
+ `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
272
+ );
273
+ }
274
+ return;
275
+ }
276
+ }
277
+ }
278
+
279
+ const roomUsers = roomConfig?.users ?? [];
280
+ if (isRoom && roomUsers.length > 0) {
281
+ const userMatch = resolveMatrixAllowListMatch({
282
+ allowList: normalizeAllowListLower(roomUsers),
283
+ userId: senderId,
284
+ userName: senderName,
285
+ });
286
+ if (!userMatch.allowed) {
287
+ logVerboseMessage(
288
+ `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
289
+ userMatch,
290
+ )})`,
291
+ );
292
+ return;
293
+ }
294
+ }
295
+ if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
296
+ const groupAllowMatch = resolveMatrixAllowListMatch({
297
+ allowList: effectiveGroupAllowFrom,
298
+ userId: senderId,
299
+ userName: senderName,
300
+ });
301
+ if (!groupAllowMatch.allowed) {
302
+ logVerboseMessage(
303
+ `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
304
+ groupAllowMatch,
305
+ )})`,
306
+ );
307
+ return;
308
+ }
309
+ }
310
+ if (isRoom) {
311
+ logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
312
+ }
313
+
314
+ const rawBody = locationPayload?.text
315
+ ?? (typeof content.body === "string" ? content.body.trim() : "");
316
+ let media: {
317
+ path: string;
318
+ contentType?: string;
319
+ placeholder: string;
320
+ } | null = null;
321
+ const contentUrl =
322
+ "url" in content && typeof content.url === "string" ? content.url : undefined;
323
+ const contentFile =
324
+ "file" in content && content.file && typeof content.file === "object"
325
+ ? content.file
326
+ : undefined;
327
+ const mediaUrl = contentUrl ?? contentFile?.url;
328
+ if (!rawBody && !mediaUrl) {
329
+ return;
330
+ }
331
+
332
+ const contentInfo =
333
+ "info" in content && content.info && typeof content.info === "object"
334
+ ? (content.info as { mimetype?: string; size?: number })
335
+ : undefined;
336
+ const contentType = contentInfo?.mimetype;
337
+ const contentSize =
338
+ typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
339
+ if (mediaUrl?.startsWith("mxc://")) {
340
+ try {
341
+ media = await downloadMatrixMedia({
342
+ client,
343
+ mxcUrl: mediaUrl,
344
+ contentType,
345
+ sizeBytes: contentSize,
346
+ maxBytes: mediaMaxBytes,
347
+ file: contentFile,
348
+ });
349
+ } catch (err) {
350
+ logVerboseMessage(`matrix: media download failed: ${String(err)}`);
351
+ }
352
+ }
353
+
354
+ const bodyText = rawBody || media?.placeholder || "";
355
+ if (!bodyText) return;
356
+
357
+ const { wasMentioned, hasExplicitMention } = resolveMentions({
358
+ content,
359
+ userId: selfUserId,
360
+ text: bodyText,
361
+ mentionRegexes,
362
+ });
363
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
364
+ cfg,
365
+ surface: "matrix",
366
+ });
367
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
368
+ const senderAllowedForCommands = resolveMatrixAllowListMatches({
369
+ allowList: effectiveAllowFrom,
370
+ userId: senderId,
371
+ userName: senderName,
372
+ });
373
+ const senderAllowedForGroup = groupAllowConfigured
374
+ ? resolveMatrixAllowListMatches({
375
+ allowList: effectiveGroupAllowFrom,
376
+ userId: senderId,
377
+ userName: senderName,
378
+ })
379
+ : false;
380
+ const senderAllowedForRoomUsers =
381
+ isRoom && roomUsers.length > 0
382
+ ? resolveMatrixAllowListMatches({
383
+ allowList: normalizeAllowListLower(roomUsers),
384
+ userId: senderId,
385
+ userName: senderName,
386
+ })
387
+ : false;
388
+ const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
389
+ const commandGate = resolveControlCommandGate({
390
+ useAccessGroups,
391
+ authorizers: [
392
+ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
393
+ { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
394
+ { configured: groupAllowConfigured, allowed: senderAllowedForGroup },
395
+ ],
396
+ allowTextCommands,
397
+ hasControlCommand: hasControlCommandInMessage,
398
+ });
399
+ const commandAuthorized = commandGate.commandAuthorized;
400
+ if (isRoom && commandGate.shouldBlock) {
401
+ logInboundDrop({
402
+ log: logVerboseMessage,
403
+ channel: "matrix",
404
+ reason: "control command (unauthorized)",
405
+ target: senderId,
406
+ });
407
+ return;
408
+ }
409
+ const shouldRequireMention = isRoom
410
+ ? roomConfig?.autoReply === true
411
+ ? false
412
+ : roomConfig?.autoReply === false
413
+ ? true
414
+ : typeof roomConfig?.requireMention === "boolean"
415
+ ? roomConfig?.requireMention
416
+ : true
417
+ : false;
418
+ const shouldBypassMention =
419
+ allowTextCommands &&
420
+ isRoom &&
421
+ shouldRequireMention &&
422
+ !wasMentioned &&
423
+ !hasExplicitMention &&
424
+ commandAuthorized &&
425
+ hasControlCommandInMessage;
426
+ const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
427
+ if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
428
+ logger.info({ roomId, reason: "no-mention" }, "skipping room message");
429
+ return;
430
+ }
431
+
432
+ const messageId = event.event_id ?? "";
433
+ const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
434
+ const threadRootId = resolveMatrixThreadRootId({ event, content });
435
+ const threadTarget = resolveMatrixThreadTarget({
436
+ threadReplies,
437
+ messageId,
438
+ threadRootId,
439
+ isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
440
+ });
441
+
442
+ const route = core.channel.routing.resolveAgentRoute({
443
+ cfg,
444
+ channel: "matrix",
445
+ peer: {
446
+ kind: isDirectMessage ? "dm" : "channel",
447
+ id: isDirectMessage ? senderId : roomId,
448
+ },
449
+ });
450
+ const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
451
+ const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
452
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
453
+ agentId: route.agentId,
454
+ });
455
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
456
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
457
+ storePath,
458
+ sessionKey: route.sessionKey,
459
+ });
460
+ const body = core.channel.reply.formatAgentEnvelope({
461
+ channel: "Matrix",
462
+ from: envelopeFrom,
463
+ timestamp: eventTs ?? undefined,
464
+ previousTimestamp,
465
+ envelope: envelopeOptions,
466
+ body: textWithId,
467
+ });
468
+
469
+ const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
470
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
471
+ Body: body,
472
+ RawBody: bodyText,
473
+ CommandBody: bodyText,
474
+ From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
475
+ To: `room:${roomId}`,
476
+ SessionKey: route.sessionKey,
477
+ AccountId: route.accountId,
478
+ ChatType: isDirectMessage ? "direct" : "channel",
479
+ ConversationLabel: envelopeFrom,
480
+ SenderName: senderName,
481
+ SenderId: senderId,
482
+ SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
483
+ GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
484
+ GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
485
+ GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
486
+ Provider: "matrix" as const,
487
+ Surface: "matrix" as const,
488
+ WasMentioned: isRoom ? wasMentioned : undefined,
489
+ MessageSid: messageId,
490
+ ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
491
+ MessageThreadId: threadTarget,
492
+ Timestamp: eventTs ?? undefined,
493
+ MediaPath: media?.path,
494
+ MediaType: media?.contentType,
495
+ MediaUrl: media?.path,
496
+ ...(locationPayload?.context ?? {}),
497
+ CommandAuthorized: commandAuthorized,
498
+ CommandSource: "text" as const,
499
+ OriginatingChannel: "matrix" as const,
500
+ OriginatingTo: `room:${roomId}`,
501
+ });
502
+
503
+ await core.channel.session.recordInboundSession({
504
+ storePath,
505
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
506
+ ctx: ctxPayload,
507
+ updateLastRoute: isDirectMessage
508
+ ? {
509
+ sessionKey: route.mainSessionKey,
510
+ channel: "matrix",
511
+ to: `room:${roomId}`,
512
+ accountId: route.accountId,
513
+ }
514
+ : undefined,
515
+ onRecordError: (err) => {
516
+ logger.warn(
517
+ { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
518
+ "failed updating session meta",
519
+ );
520
+ },
521
+ });
522
+
523
+ const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
524
+ logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
525
+
526
+ const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
527
+ const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
528
+ const shouldAckReaction = () =>
529
+ Boolean(
530
+ ackReaction &&
531
+ core.channel.reactions.shouldAckReaction({
532
+ scope: ackScope,
533
+ isDirect: isDirectMessage,
534
+ isGroup: isRoom,
535
+ isMentionableGroup: isRoom,
536
+ requireMention: Boolean(shouldRequireMention),
537
+ canDetectMention,
538
+ effectiveWasMentioned: wasMentioned || shouldBypassMention,
539
+ shouldBypassMention,
540
+ }),
541
+ );
542
+ if (shouldAckReaction() && messageId) {
543
+ reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
544
+ logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
545
+ });
546
+ }
547
+
548
+ const replyTarget = ctxPayload.To;
549
+ if (!replyTarget) {
550
+ runtime.error?.("matrix: missing reply target");
551
+ return;
552
+ }
553
+
554
+ if (messageId) {
555
+ sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
556
+ logVerboseMessage(
557
+ `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
558
+ );
559
+ });
560
+ }
561
+
562
+ let didSendReply = false;
563
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
564
+ cfg,
565
+ channel: "matrix",
566
+ accountId: route.accountId,
567
+ });
568
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
569
+ const typingCallbacks = createTypingCallbacks({
570
+ start: () => sendTypingMatrix(roomId, true, undefined, client),
571
+ stop: () => sendTypingMatrix(roomId, false, undefined, client),
572
+ onStartError: (err) => {
573
+ logTypingFailure({
574
+ log: logVerboseMessage,
575
+ channel: "matrix",
576
+ action: "start",
577
+ target: roomId,
578
+ error: err,
579
+ });
580
+ },
581
+ onStopError: (err) => {
582
+ logTypingFailure({
583
+ log: logVerboseMessage,
584
+ channel: "matrix",
585
+ action: "stop",
586
+ target: roomId,
587
+ error: err,
588
+ });
589
+ },
590
+ });
591
+ const { dispatcher, replyOptions, markDispatchIdle } =
592
+ core.channel.reply.createReplyDispatcherWithTyping({
593
+ responsePrefix: prefixContext.responsePrefix,
594
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
595
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
596
+ deliver: async (payload) => {
597
+ await deliverMatrixReplies({
598
+ replies: [payload],
599
+ roomId,
600
+ client,
601
+ runtime,
602
+ textLimit,
603
+ replyToMode,
604
+ threadId: threadTarget,
605
+ accountId: route.accountId,
606
+ tableMode,
607
+ });
608
+ didSendReply = true;
609
+ },
610
+ onError: (err, info) => {
611
+ runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
612
+ },
613
+ onReplyStart: typingCallbacks.onReplyStart,
614
+ onIdle: typingCallbacks.onIdle,
615
+ });
616
+
617
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
618
+ ctx: ctxPayload,
619
+ cfg,
620
+ dispatcher,
621
+ replyOptions: {
622
+ ...replyOptions,
623
+ skillFilter: roomConfig?.skills,
624
+ onModelSelected: prefixContext.onModelSelected,
625
+ },
626
+ });
627
+ markDispatchIdle();
628
+ if (!queuedFinal) return;
629
+ didSendReply = true;
630
+ const finalCount = counts.final;
631
+ logVerboseMessage(
632
+ `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
633
+ );
634
+ if (didSendReply) {
635
+ const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
636
+ core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
637
+ sessionKey: route.sessionKey,
638
+ contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
639
+ });
640
+ }
641
+ } catch (err) {
642
+ runtime.error?.(`matrix handler failed: ${String(err)}`);
643
+ }
644
+ };
645
+ }