@badgerclaw/connect 1.0.0

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 (70) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/SETUP.md +131 -0
  3. package/index.ts +23 -0
  4. package/openclaw.plugin.json +1 -0
  5. package/package.json +32 -0
  6. package/src/actions.ts +195 -0
  7. package/src/channel.ts +461 -0
  8. package/src/config-schema.ts +62 -0
  9. package/src/connect.ts +17 -0
  10. package/src/directory-live.ts +209 -0
  11. package/src/group-mentions.ts +52 -0
  12. package/src/matrix/accounts.ts +114 -0
  13. package/src/matrix/actions/client.ts +47 -0
  14. package/src/matrix/actions/limits.ts +6 -0
  15. package/src/matrix/actions/messages.ts +126 -0
  16. package/src/matrix/actions/pins.ts +84 -0
  17. package/src/matrix/actions/reactions.ts +102 -0
  18. package/src/matrix/actions/room.ts +85 -0
  19. package/src/matrix/actions/summary.ts +75 -0
  20. package/src/matrix/actions/types.ts +85 -0
  21. package/src/matrix/actions.ts +15 -0
  22. package/src/matrix/active-client.ts +32 -0
  23. package/src/matrix/client/config.ts +245 -0
  24. package/src/matrix/client/create-client.ts +125 -0
  25. package/src/matrix/client/logging.ts +46 -0
  26. package/src/matrix/client/runtime.ts +4 -0
  27. package/src/matrix/client/shared.ts +210 -0
  28. package/src/matrix/client/startup.ts +29 -0
  29. package/src/matrix/client/storage.ts +131 -0
  30. package/src/matrix/client/types.ts +34 -0
  31. package/src/matrix/client-bootstrap.ts +47 -0
  32. package/src/matrix/client.ts +14 -0
  33. package/src/matrix/credentials.ts +125 -0
  34. package/src/matrix/deps.ts +126 -0
  35. package/src/matrix/format.ts +22 -0
  36. package/src/matrix/index.ts +11 -0
  37. package/src/matrix/monitor/access-policy.ts +126 -0
  38. package/src/matrix/monitor/allowlist.ts +94 -0
  39. package/src/matrix/monitor/auto-join.ts +72 -0
  40. package/src/matrix/monitor/direct.ts +152 -0
  41. package/src/matrix/monitor/events.ts +168 -0
  42. package/src/matrix/monitor/handler.ts +768 -0
  43. package/src/matrix/monitor/inbound-body.ts +28 -0
  44. package/src/matrix/monitor/index.ts +414 -0
  45. package/src/matrix/monitor/location.ts +100 -0
  46. package/src/matrix/monitor/media.ts +118 -0
  47. package/src/matrix/monitor/mentions.ts +62 -0
  48. package/src/matrix/monitor/replies.ts +124 -0
  49. package/src/matrix/monitor/room-info.ts +55 -0
  50. package/src/matrix/monitor/rooms.ts +47 -0
  51. package/src/matrix/monitor/threads.ts +68 -0
  52. package/src/matrix/monitor/types.ts +39 -0
  53. package/src/matrix/poll-types.ts +167 -0
  54. package/src/matrix/probe.ts +69 -0
  55. package/src/matrix/sdk-runtime.ts +18 -0
  56. package/src/matrix/send/client.ts +99 -0
  57. package/src/matrix/send/formatting.ts +93 -0
  58. package/src/matrix/send/media.ts +230 -0
  59. package/src/matrix/send/targets.ts +150 -0
  60. package/src/matrix/send/types.ts +110 -0
  61. package/src/matrix/send-queue.ts +28 -0
  62. package/src/matrix/send.ts +267 -0
  63. package/src/onboarding.ts +331 -0
  64. package/src/outbound.ts +58 -0
  65. package/src/resolve-targets.ts +125 -0
  66. package/src/runtime.ts +6 -0
  67. package/src/secret-input.ts +13 -0
  68. package/src/test-mocks.ts +53 -0
  69. package/src/tool-actions.ts +164 -0
  70. package/src/types.ts +118 -0
@@ -0,0 +1,768 @@
1
+ import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import {
3
+ DEFAULT_ACCOUNT_ID,
4
+ createScopedPairingAccess,
5
+ createReplyPrefixOptions,
6
+ createTypingCallbacks,
7
+ dispatchReplyFromConfigWithSettledDispatcher,
8
+ evaluateGroupRouteAccessForPolicy,
9
+ formatAllowlistMatchMeta,
10
+ logInboundDrop,
11
+ logTypingFailure,
12
+ resolveInboundSessionEnvelopeContext,
13
+ resolveControlCommandGate,
14
+ type PluginRuntime,
15
+ type RuntimeEnv,
16
+ type RuntimeLogger,
17
+ } from "openclaw/plugin-sdk/matrix";
18
+ import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
19
+ import { fetchEventSummary } from "../actions/summary.js";
20
+ import {
21
+ formatPollAsText,
22
+ isPollStartType,
23
+ parsePollStartContent,
24
+ type PollStartContent,
25
+ } from "../poll-types.js";
26
+ import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
27
+ import { enforceMatrixDirectMessageAccess, resolveMatrixAccessState } from "./access-policy.js";
28
+ import {
29
+ normalizeMatrixAllowList,
30
+ resolveMatrixAllowListMatch,
31
+ resolveMatrixAllowListMatches,
32
+ } from "./allowlist.js";
33
+ import {
34
+ resolveMatrixBodyForAgent,
35
+ resolveMatrixInboundSenderLabel,
36
+ resolveMatrixSenderUsername,
37
+ } from "./inbound-body.js";
38
+ import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
39
+ import { downloadMatrixMedia } from "./media.js";
40
+ import { resolveMentions } from "./mentions.js";
41
+ import { deliverMatrixReplies } from "./replies.js";
42
+ import { resolveMatrixRoomConfig } from "./rooms.js";
43
+ import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
44
+ import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
45
+ import { EventType, RelationType } from "./types.js";
46
+
47
+ export type MatrixMonitorHandlerParams = {
48
+ client: MatrixClient;
49
+ core: PluginRuntime;
50
+ cfg: CoreConfig;
51
+ runtime: RuntimeEnv;
52
+ logger: RuntimeLogger;
53
+ logVerboseMessage: (message: string) => void;
54
+ allowFrom: string[];
55
+ roomsConfig: Record<string, MatrixRoomConfig> | undefined;
56
+ mentionRegexes: ReturnType<PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]>;
57
+ groupPolicy: "open" | "allowlist" | "disabled";
58
+ replyToMode: ReplyToMode;
59
+ threadReplies: "off" | "inbound" | "always";
60
+ dmEnabled: boolean;
61
+ dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
62
+ textLimit: number;
63
+ mediaMaxBytes: number;
64
+ startupMs: number;
65
+ startupGraceMs: number;
66
+ directTracker: {
67
+ isDirectMessage: (params: {
68
+ roomId: string;
69
+ senderId: string;
70
+ selfUserId: string;
71
+ }) => Promise<boolean>;
72
+ };
73
+ getRoomInfo: (
74
+ roomId: string,
75
+ ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
76
+ getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
77
+ accountId?: string | null;
78
+ };
79
+
80
+ export function resolveMatrixBaseRouteSession(params: {
81
+ buildAgentSessionKey: (params: {
82
+ agentId: string;
83
+ channel: string;
84
+ accountId?: string | null;
85
+ peer?: { kind: "direct" | "channel"; id: string } | null;
86
+ }) => string;
87
+ baseRoute: {
88
+ agentId: string;
89
+ sessionKey: string;
90
+ mainSessionKey: string;
91
+ matchedBy?: string;
92
+ };
93
+ isDirectMessage: boolean;
94
+ roomId: string;
95
+ accountId?: string | null;
96
+ }): { sessionKey: string; lastRoutePolicy: "main" | "session" } {
97
+ const sessionKey =
98
+ params.isDirectMessage && params.baseRoute.matchedBy === "binding.peer.parent"
99
+ ? params.buildAgentSessionKey({
100
+ agentId: params.baseRoute.agentId,
101
+ channel: "badgerclaw",
102
+ accountId: params.accountId,
103
+ peer: { kind: "channel", id: params.roomId },
104
+ })
105
+ : params.baseRoute.sessionKey;
106
+ return {
107
+ sessionKey,
108
+ lastRoutePolicy: sessionKey === params.baseRoute.mainSessionKey ? "main" : "session",
109
+ };
110
+ }
111
+
112
+ export function shouldOverrideMatrixDmToGroup(params: {
113
+ isDirectMessage: boolean;
114
+ roomConfigInfo?:
115
+ | {
116
+ config?: MatrixRoomConfig;
117
+ allowed: boolean;
118
+ matchSource?: string;
119
+ }
120
+ | undefined;
121
+ }): boolean {
122
+ return (
123
+ params.isDirectMessage === true &&
124
+ params.roomConfigInfo?.config !== undefined &&
125
+ params.roomConfigInfo.allowed === true &&
126
+ params.roomConfigInfo.matchSource === "direct"
127
+ );
128
+ }
129
+
130
+ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
131
+ const {
132
+ client,
133
+ core,
134
+ cfg,
135
+ runtime,
136
+ logger,
137
+ logVerboseMessage,
138
+ allowFrom,
139
+ roomsConfig,
140
+ mentionRegexes,
141
+ groupPolicy,
142
+ replyToMode,
143
+ threadReplies,
144
+ dmEnabled,
145
+ dmPolicy,
146
+ textLimit,
147
+ mediaMaxBytes,
148
+ startupMs,
149
+ startupGraceMs,
150
+ directTracker,
151
+ getRoomInfo,
152
+ getMemberDisplayName,
153
+ accountId,
154
+ } = params;
155
+ const resolvedAccountId = accountId?.trim() || DEFAULT_ACCOUNT_ID;
156
+ const pairing = createScopedPairingAccess({
157
+ core,
158
+ channel: "badgerclaw",
159
+ accountId: resolvedAccountId,
160
+ });
161
+
162
+ return async (roomId: string, event: MatrixRawEvent) => {
163
+ try {
164
+ const eventType = event.type;
165
+ if (eventType === EventType.RoomMessageEncrypted) {
166
+ // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
167
+ return;
168
+ }
169
+
170
+ const isPollEvent = isPollStartType(eventType);
171
+ const locationContent = event.content as unknown as LocationMessageEventContent;
172
+ const isLocationEvent =
173
+ eventType === EventType.Location ||
174
+ (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
175
+ if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
176
+ return;
177
+ }
178
+ logVerboseMessage(
179
+ `badgerclaw: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
180
+ );
181
+ if (event.unsigned?.redacted_because) {
182
+ return;
183
+ }
184
+ const senderId = event.sender;
185
+ if (!senderId) {
186
+ return;
187
+ }
188
+ const selfUserId = await client.getUserId();
189
+ if (senderId === selfUserId) {
190
+ return;
191
+ }
192
+ const eventTs = event.origin_server_ts;
193
+ const eventAge = event.unsigned?.age;
194
+ if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
195
+ return;
196
+ }
197
+ if (
198
+ typeof eventTs !== "number" &&
199
+ typeof eventAge === "number" &&
200
+ eventAge > startupGraceMs
201
+ ) {
202
+ return;
203
+ }
204
+
205
+ const roomInfo = await getRoomInfo(roomId);
206
+ const roomName = roomInfo.name;
207
+ const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean);
208
+
209
+ let content = event.content as unknown as RoomMessageEventContent;
210
+ if (isPollEvent) {
211
+ const pollStartContent = event.content as unknown as PollStartContent;
212
+ const pollSummary = parsePollStartContent(pollStartContent);
213
+ if (pollSummary) {
214
+ pollSummary.eventId = event.event_id ?? "";
215
+ pollSummary.roomId = roomId;
216
+ pollSummary.sender = senderId;
217
+ const senderDisplayName = await getMemberDisplayName(roomId, senderId);
218
+ pollSummary.senderName = senderDisplayName;
219
+ const pollText = formatPollAsText(pollSummary);
220
+ content = {
221
+ msgtype: "m.text",
222
+ body: pollText,
223
+ } as unknown as RoomMessageEventContent;
224
+ } else {
225
+ return;
226
+ }
227
+ }
228
+
229
+ const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
230
+ eventType,
231
+ content: content as LocationMessageEventContent,
232
+ });
233
+
234
+ const relates = content["m.relates_to"];
235
+ if (relates && "rel_type" in relates) {
236
+ if (relates.rel_type === RelationType.Replace) {
237
+ return;
238
+ }
239
+ }
240
+
241
+ let isDirectMessage = await directTracker.isDirectMessage({
242
+ roomId,
243
+ senderId,
244
+ selfUserId,
245
+ });
246
+
247
+ // Resolve room config early so explicitly configured rooms can override DM classification.
248
+ // This ensures rooms in the groups config are always treated as groups regardless of
249
+ // member count or protocol-level DM flags. Only explicit matches (not wildcards) trigger
250
+ // the override to avoid breaking DM routing when a wildcard entry exists. (See #9106)
251
+ const roomConfigInfo = resolveMatrixRoomConfig({
252
+ rooms: roomsConfig,
253
+ roomId,
254
+ aliases: roomAliases,
255
+ name: roomName,
256
+ });
257
+ if (shouldOverrideMatrixDmToGroup({ isDirectMessage, roomConfigInfo })) {
258
+ logVerboseMessage(
259
+ `badgerclaw: overriding DM to group for configured room=${roomId} (${roomConfigInfo.matchKey})`,
260
+ );
261
+ isDirectMessage = false;
262
+ }
263
+
264
+ const isRoom = !isDirectMessage;
265
+
266
+ if (isRoom && groupPolicy === "disabled") {
267
+ return;
268
+ }
269
+ // Only expose room config for confirmed group rooms. DMs should never inherit
270
+ // group settings (skills, systemPrompt, autoReply) even when a wildcard entry exists.
271
+ const roomConfig = isRoom ? roomConfigInfo?.config : undefined;
272
+ const roomMatchMeta = roomConfigInfo
273
+ ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
274
+ roomConfigInfo.matchSource ?? "none"
275
+ }`
276
+ : "matchKey=none matchSource=none";
277
+
278
+ if (isRoom) {
279
+ const routeAccess = evaluateGroupRouteAccessForPolicy({
280
+ groupPolicy,
281
+ routeAllowlistConfigured: Boolean(roomConfigInfo?.allowlistConfigured),
282
+ routeMatched: Boolean(roomConfig),
283
+ routeEnabled: roomConfigInfo?.allowed ?? true,
284
+ });
285
+ if (!routeAccess.allowed) {
286
+ if (routeAccess.reason === "route_disabled") {
287
+ logVerboseMessage(`badgerclaw: room disabled room=${roomId} (${roomMatchMeta})`);
288
+ } else if (routeAccess.reason === "empty_allowlist") {
289
+ logVerboseMessage(`badgerclaw: drop room message (no allowlist, ${roomMatchMeta})`);
290
+ } else if (routeAccess.reason === "route_not_allowlisted") {
291
+ logVerboseMessage(`badgerclaw: drop room message (not in allowlist, ${roomMatchMeta})`);
292
+ }
293
+ return;
294
+ }
295
+ }
296
+
297
+ const senderName = await getMemberDisplayName(roomId, senderId);
298
+ const senderUsername = resolveMatrixSenderUsername(senderId);
299
+ const senderLabel = resolveMatrixInboundSenderLabel({
300
+ senderName,
301
+ senderId,
302
+ senderUsername,
303
+ });
304
+ const groupAllowFrom = cfg.channels?.badgerclaw?.groupAllowFrom ?? [];
305
+ const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } =
306
+ await resolveMatrixAccessState({
307
+ isDirectMessage,
308
+ resolvedAccountId,
309
+ dmPolicy,
310
+ groupPolicy,
311
+ allowFrom,
312
+ groupAllowFrom,
313
+ senderId,
314
+ readStoreForDmPolicy: pairing.readStoreForDmPolicy,
315
+ });
316
+
317
+ if (isDirectMessage) {
318
+ const allowedDirectMessage = await enforceMatrixDirectMessageAccess({
319
+ dmEnabled,
320
+ dmPolicy,
321
+ accessDecision: access.decision,
322
+ senderId,
323
+ senderName,
324
+ effectiveAllowFrom,
325
+ upsertPairingRequest: pairing.upsertPairingRequest,
326
+ sendPairingReply: async (text) => {
327
+ await sendMessageMatrix(`room:${roomId}`, text, { client });
328
+ },
329
+ logVerboseMessage,
330
+ });
331
+ if (!allowedDirectMessage) {
332
+ return;
333
+ }
334
+ }
335
+
336
+ const roomUsers = roomConfig?.users ?? [];
337
+ if (isRoom && roomUsers.length > 0) {
338
+ const userMatch = resolveMatrixAllowListMatch({
339
+ allowList: normalizeMatrixAllowList(roomUsers),
340
+ userId: senderId,
341
+ });
342
+ if (!userMatch.allowed) {
343
+ logVerboseMessage(
344
+ `badgerclaw: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
345
+ userMatch,
346
+ )})`,
347
+ );
348
+ return;
349
+ }
350
+ }
351
+ if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") {
352
+ const groupAllowMatch = resolveMatrixAllowListMatch({
353
+ allowList: effectiveGroupAllowFrom,
354
+ userId: senderId,
355
+ });
356
+ if (!groupAllowMatch.allowed) {
357
+ logVerboseMessage(
358
+ `badgerclaw: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
359
+ groupAllowMatch,
360
+ )})`,
361
+ );
362
+ return;
363
+ }
364
+ }
365
+ if (isRoom) {
366
+ logVerboseMessage(`badgerclaw: allow room ${roomId} (${roomMatchMeta})`);
367
+ }
368
+
369
+ const rawBody =
370
+ locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
371
+ let media: {
372
+ path: string;
373
+ contentType?: string;
374
+ placeholder: string;
375
+ } | null = null;
376
+ const contentUrl =
377
+ "url" in content && typeof content.url === "string" ? content.url : undefined;
378
+ const contentFile =
379
+ "file" in content && content.file && typeof content.file === "object"
380
+ ? content.file
381
+ : undefined;
382
+ const mediaUrl = contentUrl ?? contentFile?.url;
383
+ if (!rawBody && !mediaUrl) {
384
+ return;
385
+ }
386
+
387
+ const contentInfo =
388
+ "info" in content && content.info && typeof content.info === "object"
389
+ ? (content.info as { mimetype?: string; size?: number })
390
+ : undefined;
391
+ const contentType = contentInfo?.mimetype;
392
+ const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
393
+ if (mediaUrl?.startsWith("mxc://")) {
394
+ try {
395
+ media = await downloadMatrixMedia({
396
+ client,
397
+ mxcUrl: mediaUrl,
398
+ contentType,
399
+ sizeBytes: contentSize,
400
+ maxBytes: mediaMaxBytes,
401
+ file: contentFile,
402
+ });
403
+ } catch (err) {
404
+ logVerboseMessage(`badgerclaw: media download failed: ${String(err)}`);
405
+ }
406
+ }
407
+
408
+ const bodyText = rawBody || media?.placeholder || "";
409
+ if (!bodyText) {
410
+ return;
411
+ }
412
+
413
+ const { wasMentioned, hasExplicitMention } = resolveMentions({
414
+ content,
415
+ userId: selfUserId,
416
+ text: bodyText,
417
+ mentionRegexes,
418
+ });
419
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
420
+ cfg,
421
+ surface: "badgerclaw",
422
+ });
423
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
424
+ const senderAllowedForCommands = resolveMatrixAllowListMatches({
425
+ allowList: effectiveAllowFrom,
426
+ userId: senderId,
427
+ });
428
+ const senderAllowedForGroup = groupAllowConfigured
429
+ ? resolveMatrixAllowListMatches({
430
+ allowList: effectiveGroupAllowFrom,
431
+ userId: senderId,
432
+ })
433
+ : false;
434
+ const senderAllowedForRoomUsers =
435
+ isRoom && roomUsers.length > 0
436
+ ? resolveMatrixAllowListMatches({
437
+ allowList: normalizeMatrixAllowList(roomUsers),
438
+ userId: senderId,
439
+ })
440
+ : false;
441
+ const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
442
+ const commandGate = resolveControlCommandGate({
443
+ useAccessGroups,
444
+ authorizers: [
445
+ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
446
+ { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
447
+ { configured: groupAllowConfigured, allowed: senderAllowedForGroup },
448
+ ],
449
+ allowTextCommands,
450
+ hasControlCommand: hasControlCommandInMessage,
451
+ });
452
+ const commandAuthorized = commandGate.commandAuthorized;
453
+ if (isRoom && commandGate.shouldBlock) {
454
+ logInboundDrop({
455
+ log: logVerboseMessage,
456
+ channel: "badgerclaw",
457
+ reason: "control command (unauthorized)",
458
+ target: senderId,
459
+ });
460
+ return;
461
+ }
462
+ const shouldRequireMention = isRoom
463
+ ? roomConfig?.autoReply === true
464
+ ? false
465
+ : roomConfig?.autoReply === false
466
+ ? true
467
+ : typeof roomConfig?.requireMention === "boolean"
468
+ ? roomConfig?.requireMention
469
+ : true
470
+ : false;
471
+ const shouldBypassMention =
472
+ allowTextCommands &&
473
+ isRoom &&
474
+ shouldRequireMention &&
475
+ !wasMentioned &&
476
+ !hasExplicitMention &&
477
+ commandAuthorized &&
478
+ hasControlCommandInMessage;
479
+ const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
480
+ if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
481
+ logger.info("skipping room message", { roomId, reason: "no-mention" });
482
+ return;
483
+ }
484
+
485
+ const messageId = event.event_id ?? "";
486
+ const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
487
+ const threadRootId = resolveMatrixThreadRootId({ event, content });
488
+ const threadTarget = resolveMatrixThreadTarget({
489
+ threadReplies,
490
+ messageId,
491
+ threadRootId,
492
+ isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
493
+ });
494
+
495
+ const baseRoute = core.channel.routing.resolveAgentRoute({
496
+ cfg,
497
+ channel: "badgerclaw",
498
+ accountId,
499
+ peer: {
500
+ kind: isDirectMessage ? "direct" : "channel",
501
+ id: isDirectMessage ? senderId : roomId,
502
+ },
503
+ // For DMs, pass roomId as parentPeer so the conversation is bindable by room ID
504
+ // while preserving DM trust semantics (secure 1:1, no group restrictions).
505
+ parentPeer: isDirectMessage ? { kind: "channel", id: roomId } : undefined,
506
+ });
507
+ const baseRouteSession = resolveMatrixBaseRouteSession({
508
+ buildAgentSessionKey: core.channel.routing.buildAgentSessionKey,
509
+ baseRoute,
510
+ isDirectMessage,
511
+ roomId,
512
+ accountId,
513
+ });
514
+
515
+ const route = {
516
+ ...baseRoute,
517
+ lastRoutePolicy: baseRouteSession.lastRoutePolicy,
518
+ sessionKey: threadRootId
519
+ ? `${baseRouteSession.sessionKey}:thread:${threadRootId}`
520
+ : baseRouteSession.sessionKey,
521
+ };
522
+
523
+ let threadStarterBody: string | undefined;
524
+ let threadLabel: string | undefined;
525
+ let parentSessionKey: string | undefined;
526
+
527
+ if (threadRootId) {
528
+ const existingSession = core.channel.session.readSessionUpdatedAt({
529
+ storePath: core.channel.session.resolveStorePath(cfg.session?.store, {
530
+ agentId: baseRoute.agentId,
531
+ }),
532
+ sessionKey: route.sessionKey,
533
+ });
534
+
535
+ if (existingSession === undefined) {
536
+ try {
537
+ const rootEvent = await fetchEventSummary(client, roomId, threadRootId);
538
+ if (rootEvent?.body) {
539
+ const rootSenderName = rootEvent.sender
540
+ ? await getMemberDisplayName(roomId, rootEvent.sender)
541
+ : undefined;
542
+
543
+ threadStarterBody = core.channel.reply.formatAgentEnvelope({
544
+ channel: "BadgerClaw",
545
+ from: rootSenderName ?? rootEvent.sender ?? "Unknown",
546
+ timestamp: rootEvent.timestamp,
547
+ envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
548
+ body: rootEvent.body,
549
+ });
550
+
551
+ threadLabel = `BadgerClaw thread in ${roomName ?? roomId}`;
552
+ parentSessionKey = baseRoute.sessionKey;
553
+ }
554
+ } catch (err) {
555
+ logVerboseMessage(
556
+ `badgerclaw: failed to fetch thread root ${threadRootId}: ${String(err)}`,
557
+ );
558
+ }
559
+ }
560
+ }
561
+
562
+ const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
563
+ const textWithId = threadRootId
564
+ ? `${bodyText}\n[badgerclaw event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`
565
+ : `${bodyText}\n[badgerclaw event id: ${messageId} room: ${roomId}]`;
566
+ const { storePath, envelopeOptions, previousTimestamp } =
567
+ resolveInboundSessionEnvelopeContext({
568
+ cfg,
569
+ agentId: route.agentId,
570
+ sessionKey: route.sessionKey,
571
+ });
572
+ const body = core.channel.reply.formatInboundEnvelope({
573
+ channel: "BadgerClaw",
574
+ from: envelopeFrom,
575
+ timestamp: eventTs ?? undefined,
576
+ previousTimestamp,
577
+ envelope: envelopeOptions,
578
+ body: textWithId,
579
+ chatType: isDirectMessage ? "direct" : "channel",
580
+ senderLabel,
581
+ });
582
+
583
+ const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
584
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
585
+ Body: body,
586
+ BodyForAgent: resolveMatrixBodyForAgent({
587
+ isDirectMessage,
588
+ bodyText,
589
+ senderLabel,
590
+ }),
591
+ RawBody: bodyText,
592
+ CommandBody: bodyText,
593
+ From: isDirectMessage ? `badgerclaw:${senderId}` : `badgerclaw:channel:${roomId}`,
594
+ To: `room:${roomId}`,
595
+ SessionKey: route.sessionKey,
596
+ AccountId: route.accountId,
597
+ ChatType: threadRootId ? "thread" : isDirectMessage ? "direct" : "channel",
598
+ ConversationLabel: envelopeFrom,
599
+ SenderName: senderName,
600
+ SenderId: senderId,
601
+ SenderUsername: senderUsername,
602
+ GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
603
+ GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
604
+ GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
605
+ Provider: "badgerclaw" as const,
606
+ Surface: "badgerclaw" as const,
607
+ WasMentioned: isRoom ? wasMentioned : undefined,
608
+ MessageSid: messageId,
609
+ ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
610
+ MessageThreadId: threadTarget,
611
+ Timestamp: eventTs ?? undefined,
612
+ MediaPath: media?.path,
613
+ MediaType: media?.contentType,
614
+ MediaUrl: media?.path,
615
+ ...locationPayload?.context,
616
+ CommandAuthorized: commandAuthorized,
617
+ CommandSource: "text" as const,
618
+ OriginatingChannel: "badgerclaw" as const,
619
+ OriginatingTo: `room:${roomId}`,
620
+ ThreadStarterBody: threadStarterBody,
621
+ ThreadLabel: threadLabel,
622
+ ParentSessionKey: parentSessionKey,
623
+ });
624
+
625
+ await core.channel.session.recordInboundSession({
626
+ storePath,
627
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
628
+ ctx: ctxPayload,
629
+ updateLastRoute: isDirectMessage
630
+ ? {
631
+ sessionKey: route.mainSessionKey,
632
+ channel: "badgerclaw",
633
+ to: `room:${roomId}`,
634
+ accountId: route.accountId,
635
+ }
636
+ : undefined,
637
+ onRecordError: (err) => {
638
+ logger.warn("failed updating session meta", {
639
+ error: String(err),
640
+ storePath,
641
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
642
+ });
643
+ },
644
+ });
645
+
646
+ const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
647
+ logVerboseMessage(`badgerclaw inbound: room=${roomId} from=${senderId} preview="${preview}"`);
648
+
649
+ const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
650
+ const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
651
+ const shouldAckReaction = () =>
652
+ Boolean(
653
+ ackReaction &&
654
+ core.channel.reactions.shouldAckReaction({
655
+ scope: ackScope,
656
+ isDirect: isDirectMessage,
657
+ isGroup: isRoom,
658
+ isMentionableGroup: isRoom,
659
+ requireMention: Boolean(shouldRequireMention),
660
+ canDetectMention,
661
+ effectiveWasMentioned: wasMentioned || shouldBypassMention,
662
+ shouldBypassMention,
663
+ }),
664
+ );
665
+ if (shouldAckReaction() && messageId) {
666
+ reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
667
+ logVerboseMessage(`badgerclaw react failed for room ${roomId}: ${String(err)}`);
668
+ });
669
+ }
670
+
671
+ const replyTarget = ctxPayload.To;
672
+ if (!replyTarget) {
673
+ runtime.error?.("badgerclaw: missing reply target");
674
+ return;
675
+ }
676
+
677
+ let didSendReply = false;
678
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
679
+ cfg,
680
+ channel: "badgerclaw",
681
+ accountId: route.accountId,
682
+ });
683
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
684
+ cfg,
685
+ agentId: route.agentId,
686
+ channel: "badgerclaw",
687
+ accountId: route.accountId,
688
+ });
689
+ const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
690
+ const typingCallbacks = createTypingCallbacks({
691
+ start: () => sendTypingMatrix(roomId, true, undefined, client),
692
+ stop: () => sendTypingMatrix(roomId, false, undefined, client),
693
+ onStartError: (err) => {
694
+ logTypingFailure({
695
+ log: logVerboseMessage,
696
+ channel: "badgerclaw",
697
+ action: "start",
698
+ target: roomId,
699
+ error: err,
700
+ });
701
+ },
702
+ onStopError: (err) => {
703
+ logTypingFailure({
704
+ log: logVerboseMessage,
705
+ channel: "badgerclaw",
706
+ action: "stop",
707
+ target: roomId,
708
+ error: err,
709
+ });
710
+ },
711
+ });
712
+ const { dispatcher, replyOptions, markDispatchIdle } =
713
+ core.channel.reply.createReplyDispatcherWithTyping({
714
+ ...prefixOptions,
715
+ humanDelay,
716
+ typingCallbacks,
717
+ deliver: async (payload) => {
718
+ await deliverMatrixReplies({
719
+ replies: [payload],
720
+ roomId,
721
+ client,
722
+ runtime,
723
+ textLimit,
724
+ replyToMode,
725
+ threadId: threadTarget,
726
+ accountId: route.accountId,
727
+ tableMode,
728
+ });
729
+ didSendReply = true;
730
+ },
731
+ onError: (err, info) => {
732
+ runtime.error?.(`badgerclaw ${info.kind} reply failed: ${String(err)}`);
733
+ },
734
+ });
735
+
736
+ const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
737
+ cfg,
738
+ ctxPayload,
739
+ dispatcher,
740
+ onSettled: () => {
741
+ markDispatchIdle();
742
+ },
743
+ replyOptions: {
744
+ ...replyOptions,
745
+ skillFilter: roomConfig?.skills,
746
+ onModelSelected,
747
+ },
748
+ });
749
+ if (!queuedFinal) {
750
+ return;
751
+ }
752
+ didSendReply = true;
753
+ const finalCount = counts.final;
754
+ logVerboseMessage(
755
+ `badgerclaw: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
756
+ );
757
+ if (didSendReply) {
758
+ const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
759
+ core.system.enqueueSystemEvent(`BadgerClaw message from ${senderName}: ${previewText}`, {
760
+ sessionKey: route.sessionKey,
761
+ contextKey: `badgerclaw:message:${roomId}:${messageId || "unknown"}`,
762
+ });
763
+ }
764
+ } catch (err) {
765
+ runtime.error?.(`badgerclaw handler failed: ${String(err)}`);
766
+ }
767
+ };
768
+ }