badgerclaw 0.1.7 → 1.4.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.

Potentially problematic release.


This version of badgerclaw might be problematic. Click here for more details.

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