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,75 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+
5
+ const HISTORY_DIR = path.join(os.homedir(), ".openclaw", "extensions", "badgerclaw", "history");
6
+ const MAX_MESSAGES = 200;
7
+
8
+ export interface HistoryEntry {
9
+ ts: string; // ISO timestamp
10
+ sender: string; // display name or username
11
+ text: string; // message text
12
+ role: "user" | "bot";
13
+ }
14
+
15
+ export function getHistoryPath(roomId: string): string {
16
+ // Sanitize roomId for filename: replace ! and : with safe chars
17
+ const safe = roomId.replace(/[!:]/g, "_").replace(/\./g, "-");
18
+ return path.join(HISTORY_DIR, `${safe}.md`);
19
+ }
20
+
21
+ export function ensureHistoryDir(): void {
22
+ if (!fs.existsSync(HISTORY_DIR)) {
23
+ fs.mkdirSync(HISTORY_DIR, { recursive: true });
24
+ }
25
+ }
26
+
27
+ export function readHistory(roomId: string): HistoryEntry[] {
28
+ const filePath = getHistoryPath(roomId);
29
+ if (!fs.existsSync(filePath)) return [];
30
+ try {
31
+ const content = fs.readFileSync(filePath, "utf8");
32
+ const lines = content.split("\n").filter((l: string) => l.startsWith("["));
33
+ return lines.map((line: string) => {
34
+ // Format: [2026-03-27T08:05:00Z] [user] sender: text
35
+ const match = line.match(/^\[([^\]]+)\] \[(user|bot)\] ([^:]+): (.+)$/);
36
+ if (!match) return null;
37
+ return { ts: match[1], role: match[2] as "user" | "bot", sender: match[3], text: match[4] };
38
+ }).filter(Boolean) as HistoryEntry[];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ export function appendHistory(roomId: string, entry: HistoryEntry, roomName?: string): void {
45
+ ensureHistoryDir();
46
+ const filePath = getHistoryPath(roomId);
47
+
48
+ // Read existing, append, trim to 200
49
+ let entries = readHistory(roomId);
50
+ entries.push(entry);
51
+ if (entries.length > MAX_MESSAGES) {
52
+ entries = entries.slice(entries.length - MAX_MESSAGES);
53
+ }
54
+
55
+ // Write header + entries
56
+ const header = `# Chat History — ${roomId}\n# Group: ${roomName || roomId} | Updated: ${new Date().toISOString()}\n# Last ${MAX_MESSAGES} messages (rolling)\n\n`;
57
+ const body = entries.map(e => `[${e.ts}] [${e.role}] ${e.sender}: ${e.text}`).join("\n");
58
+ fs.writeFileSync(filePath, header + body + "\n", "utf8");
59
+ }
60
+
61
+ export function formatHistoryForContext(roomId: string): string | null {
62
+ const entries = readHistory(roomId);
63
+ if (entries.length === 0) return null;
64
+ const lines = entries.map(e => `${e.sender}: ${e.text}`).join("\n");
65
+ return `## Recent conversation history (last ${entries.length} messages):\n${lines}\n\n---\n`;
66
+ }
67
+
68
+ export function initRoomHistory(roomId: string, roomName?: string): void {
69
+ ensureHistoryDir();
70
+ const filePath = getHistoryPath(roomId);
71
+ if (!fs.existsSync(filePath)) {
72
+ const header = `# Chat History — ${roomId}\n# Group: ${roomName || roomId} | Created: ${new Date().toISOString()}\n# Last ${MAX_MESSAGES} messages (rolling)\n\n`;
73
+ fs.writeFileSync(filePath, header, "utf8");
74
+ }
75
+ }
@@ -0,0 +1,152 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+
3
+ type DirectMessageCheck = {
4
+ roomId: string;
5
+ senderId?: string;
6
+ selfUserId?: string;
7
+ };
8
+
9
+ type DirectRoomTrackerOptions = {
10
+ log?: (message: string) => void;
11
+ includeMemberCountInLogs?: boolean;
12
+ };
13
+
14
+ const DM_CACHE_TTL_MS = 30_000;
15
+
16
+ /**
17
+ * Check if an error is a Matrix M_NOT_FOUND response (missing state event).
18
+ * The bot-sdk throws MatrixError with errcode/statusCode on the error object.
19
+ */
20
+ function isMatrixNotFoundError(err: unknown): boolean {
21
+ if (typeof err !== "object" || err === null) return false;
22
+ const e = err as { errcode?: string; statusCode?: number };
23
+ return e.errcode === "M_NOT_FOUND" || e.statusCode === 404;
24
+ }
25
+
26
+ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
27
+ const log = opts.log ?? (() => {});
28
+ const includeMemberCountInLogs = opts.includeMemberCountInLogs === true;
29
+ let lastDmUpdateMs = 0;
30
+ let cachedSelfUserId: string | null = null;
31
+ const memberCountCache = new Map<string, { count: number; ts: number }>();
32
+
33
+ const ensureSelfUserId = async (): Promise<string | null> => {
34
+ if (cachedSelfUserId) {
35
+ return cachedSelfUserId;
36
+ }
37
+ try {
38
+ cachedSelfUserId = await client.getUserId();
39
+ } catch {
40
+ cachedSelfUserId = null;
41
+ }
42
+ return cachedSelfUserId;
43
+ };
44
+
45
+ const refreshDmCache = async (): Promise<void> => {
46
+ const now = Date.now();
47
+ if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
48
+ return;
49
+ }
50
+ lastDmUpdateMs = now;
51
+ try {
52
+ await client.dms.update();
53
+ } catch (err) {
54
+ log(`badgerclaw: dm cache refresh failed (${String(err)})`);
55
+ }
56
+ };
57
+
58
+ const resolveMemberCount = async (roomId: string): Promise<number | null> => {
59
+ const cached = memberCountCache.get(roomId);
60
+ const now = Date.now();
61
+ if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
62
+ return cached.count;
63
+ }
64
+ try {
65
+ const members = await client.getJoinedRoomMembers(roomId);
66
+ const count = members.length;
67
+ memberCountCache.set(roomId, { count, ts: now });
68
+ return count;
69
+ } catch (err) {
70
+ log(`badgerclaw: dm member count failed room=${roomId} (${String(err)})`);
71
+ return null;
72
+ }
73
+ };
74
+
75
+ const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
76
+ const target = userId?.trim();
77
+ if (!target) {
78
+ return false;
79
+ }
80
+ try {
81
+ const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
82
+ return state?.is_direct === true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+
88
+ return {
89
+ isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
90
+ const { roomId, senderId } = params;
91
+ await refreshDmCache();
92
+
93
+ // Check m.direct account data (most authoritative)
94
+ if (client.dms.isDm(roomId)) {
95
+ log(`badgerclaw: dm detected via m.direct room=${roomId}`);
96
+ return true;
97
+ }
98
+
99
+ const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
100
+ const directViaState =
101
+ (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
102
+ if (directViaState) {
103
+ log(`badgerclaw: dm detected via member state room=${roomId}`);
104
+ return true;
105
+ }
106
+
107
+ // Conservative fallback: 2-member rooms without an explicit room name are likely
108
+ // DMs with broken m.direct / is_direct flags. This has been observed on Continuwuity
109
+ // where m.direct pointed to the wrong room and is_direct was never set on the invite.
110
+ // Unlike the removed heuristic, this requires two signals (member count + no name)
111
+ // to avoid false positives on named 2-person group rooms.
112
+ //
113
+ // Performance: member count is cached (resolveMemberCount). The room name state
114
+ // check is not cached but only runs for the subset of 2-member rooms that reach
115
+ // this fallback path (no m.direct, no is_direct). In typical deployments this is
116
+ // a small minority of rooms.
117
+ //
118
+ // Note: there is a narrow race where a room name is being set concurrently with
119
+ // this check. The consequence is a one-time misclassification that self-corrects
120
+ // on the next message (once the state event is synced). This is acceptable given
121
+ // the alternative of an additional API call on every message.
122
+ const memberCount = await resolveMemberCount(roomId);
123
+ if (memberCount === 2) {
124
+ try {
125
+ const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "");
126
+ if (!nameState?.name?.trim()) {
127
+ log(`badgerclaw: dm detected via fallback (2 members, no room name) room=${roomId}`);
128
+ return true;
129
+ }
130
+ } catch (err: unknown) {
131
+ // Missing state events (M_NOT_FOUND) are expected for unnamed rooms and
132
+ // strongly indicate a DM. Any other error (network, auth) is ambiguous,
133
+ // so we fall through to classify as group rather than guess.
134
+ if (isMatrixNotFoundError(err)) {
135
+ log(`badgerclaw: dm detected via fallback (2 members, no room name) room=${roomId}`);
136
+ return true;
137
+ }
138
+ log(
139
+ `badgerclaw: dm fallback skipped (room name check failed: ${String(err)}) room=${roomId}`,
140
+ );
141
+ }
142
+ }
143
+
144
+ if (!includeMemberCountInLogs) {
145
+ log(`badgerclaw: dm check room=${roomId} result=group`);
146
+ return false;
147
+ }
148
+ log(`badgerclaw: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
149
+ return false;
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,250 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
3
+ import type { MatrixAuth } from "../client.js";
4
+ import { sendReadReceiptMatrix } from "../send.js";
5
+ import type { MatrixRawEvent } from "./types.js";
6
+ import { EventType } from "./types.js";
7
+
8
+ const matrixMonitorListenerRegistry = (() => {
9
+ // Prevent duplicate listener registration when both bundled and extension
10
+ // paths attempt to start monitors against the same shared client.
11
+ const registeredClients = new WeakSet<object>();
12
+ return {
13
+ tryRegister(client: object): boolean {
14
+ if (registeredClients.has(client)) {
15
+ return false;
16
+ }
17
+ registeredClients.add(client);
18
+ return true;
19
+ },
20
+ };
21
+ })();
22
+
23
+ function createSelfUserIdResolver(client: Pick<MatrixClient, "getUserId">) {
24
+ let selfUserId: string | undefined;
25
+ let selfUserIdLookup: Promise<string | undefined> | undefined;
26
+
27
+ return async (): Promise<string | undefined> => {
28
+ if (selfUserId) {
29
+ return selfUserId;
30
+ }
31
+ if (!selfUserIdLookup) {
32
+ selfUserIdLookup = client
33
+ .getUserId()
34
+ .then((userId) => {
35
+ selfUserId = userId;
36
+ return userId;
37
+ })
38
+ .catch(() => undefined)
39
+ .finally(() => {
40
+ if (!selfUserId) {
41
+ selfUserIdLookup = undefined;
42
+ }
43
+ });
44
+ }
45
+ return await selfUserIdLookup;
46
+ };
47
+ }
48
+
49
+ export function registerMatrixMonitorEvents(params: {
50
+ client: MatrixClient;
51
+ auth: MatrixAuth;
52
+ logVerboseMessage: (message: string) => void;
53
+ warnedEncryptedRooms: Set<string>;
54
+ warnedCryptoMissingRooms: Set<string>;
55
+ logger: RuntimeLogger;
56
+ formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
57
+ onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
58
+ }): void {
59
+ if (!matrixMonitorListenerRegistry.tryRegister(params.client)) {
60
+ params.logVerboseMessage("badgerclaw: skipping duplicate listener registration for client");
61
+ return;
62
+ }
63
+
64
+ const {
65
+ client,
66
+ auth,
67
+ logVerboseMessage,
68
+ warnedEncryptedRooms,
69
+ warnedCryptoMissingRooms,
70
+ logger,
71
+ formatNativeDependencyHint,
72
+ onRoomMessage,
73
+ } = params;
74
+
75
+ const resolveSelfUserId = createSelfUserIdResolver(client);
76
+ client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
77
+ const eventId = event?.event_id;
78
+ const senderId = event?.sender;
79
+ if (eventId && senderId) {
80
+ void (async () => {
81
+ const currentSelfUserId = await resolveSelfUserId();
82
+ if (!currentSelfUserId || senderId === currentSelfUserId) {
83
+ return;
84
+ }
85
+ await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => {
86
+ logVerboseMessage(
87
+ `badgerclaw: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`,
88
+ );
89
+ });
90
+ })();
91
+ }
92
+
93
+ onRoomMessage(roomId, event);
94
+ });
95
+
96
+ client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
97
+ const eventId = event?.event_id ?? "unknown";
98
+ const eventType = event?.type ?? "unknown";
99
+ logVerboseMessage(`badgerclaw: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
100
+ });
101
+
102
+ client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
103
+ const eventId = event?.event_id ?? "unknown";
104
+ const eventType = event?.type ?? "unknown";
105
+ logVerboseMessage(`badgerclaw: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
106
+ });
107
+
108
+ client.on(
109
+ "room.failed_decryption",
110
+ async (roomId: string, event: MatrixRawEvent, error: Error) => {
111
+ logger.warn("Failed to decrypt message", {
112
+ roomId,
113
+ eventId: event.event_id,
114
+ error: error.message,
115
+ });
116
+ logVerboseMessage(
117
+ `badgerclaw: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
118
+ );
119
+ },
120
+ );
121
+
122
+ client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
123
+ const eventId = event?.event_id ?? "unknown";
124
+ const sender = event?.sender ?? "unknown";
125
+ const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
126
+ logVerboseMessage(
127
+ `badgerclaw: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
128
+ );
129
+ });
130
+
131
+ client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
132
+ const eventId = event?.event_id ?? "unknown";
133
+ logVerboseMessage(`badgerclaw: join room=${roomId} id=${eventId}`);
134
+ });
135
+
136
+ client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
137
+ const eventType = event?.type ?? "unknown";
138
+ if (eventType === EventType.RoomMessageEncrypted) {
139
+ logVerboseMessage(
140
+ `badgerclaw: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
141
+ );
142
+ if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
143
+ warnedEncryptedRooms.add(roomId);
144
+ const warning =
145
+ "badgerclaw: encrypted event received without encryption enabled; set channels.badgerclaw.encryption=true and verify the device to decrypt";
146
+ logger.warn(warning, { roomId });
147
+ }
148
+ if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
149
+ warnedCryptoMissingRooms.add(roomId);
150
+ const hint = formatNativeDependencyHint({
151
+ packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
152
+ manager: "pnpm",
153
+ downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
154
+ });
155
+ const warning = `badgerclaw: encryption enabled but crypto is unavailable; ${hint}`;
156
+ logger.warn(warning, { roomId });
157
+ }
158
+ return;
159
+ }
160
+ if (eventType === EventType.RoomMember) {
161
+ const membership = (event?.content as { membership?: string } | undefined)?.membership;
162
+ const stateKey = (event as { state_key?: string }).state_key ?? "";
163
+ logVerboseMessage(
164
+ `badgerclaw: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
165
+ );
166
+ }
167
+
168
+ // Auto-pair: iOS app sends this event after generating a pair code
169
+ // The plugin redeems the code and adds the bot account to OpenClaw config
170
+ if (eventType === "com.badgerclaw.autopair") {
171
+ const content = event?.content as {
172
+ pair_code?: string;
173
+ bot_name?: string;
174
+ bot_user_id?: string;
175
+ owner_matrix_id?: string;
176
+ } | undefined;
177
+
178
+ const pairCode = content?.pair_code;
179
+ const botName = content?.bot_name;
180
+ const botUserId = content?.bot_user_id;
181
+
182
+ if (!pairCode || !botUserId) {
183
+ logVerboseMessage(`badgerclaw: autopair event missing fields room=${roomId}`);
184
+ return;
185
+ }
186
+
187
+ logger.info(`badgerclaw: autopair event received — redeeming code for ${botUserId}`, { roomId });
188
+
189
+ void (async () => {
190
+ try {
191
+ const resp = await fetch("https://api.badgerclaw.ai/api/v1/pairing/redeem", {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify({ code: pairCode }),
195
+ });
196
+
197
+ if (!resp.ok) {
198
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
199
+ logger.warn(`badgerclaw: autopair redeem failed for ${botUserId}: ${err.detail || resp.status}`);
200
+ return;
201
+ }
202
+
203
+ const data = await resp.json() as {
204
+ homeserver: string;
205
+ access_token: string;
206
+ user_id: string;
207
+ bot_name: string;
208
+ device_id: string;
209
+ };
210
+
211
+ // Write new bot account to OpenClaw config
212
+ const fs = await import("fs");
213
+ const configPath = require("path").join(process.env.HOME || "/tmp", ".openclaw", "openclaw.json");
214
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
215
+
216
+ if (!config.channels) config.channels = {};
217
+ if (!config.channels.badgerclaw) config.channels.badgerclaw = {};
218
+ if (!config.channels.badgerclaw.accounts) config.channels.badgerclaw.accounts = {};
219
+
220
+ // Derive account key from bot localpart (e.g. @think_bot:... → "think")
221
+ const localpart = data.user_id.split(":")[0].replace("@", "").replace(/_bot$/, "");
222
+
223
+ config.channels.badgerclaw.accounts[localpart] = {
224
+ userId: data.user_id,
225
+ accessToken: data.access_token,
226
+ homeserver: data.homeserver,
227
+ encryption: true,
228
+ deviceId: data.device_id,
229
+ };
230
+
231
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
232
+
233
+ logger.info(`badgerclaw: ✅ autopair complete — added account "${localpart}" (${data.user_id}) to OpenClaw config. Restart gateway to activate.`);
234
+
235
+ // Notify in the room
236
+ try {
237
+ await client.sendMessage(roomId, {
238
+ msgtype: "m.text",
239
+ body: `✅ Bot **${data.bot_name}** (${data.user_id}) has been automatically paired and added to your OpenClaw instance.\n\nRestart your OpenClaw gateway to activate: \`openclaw gateway restart\``,
240
+ });
241
+ } catch {
242
+ // non-fatal
243
+ }
244
+ } catch (e) {
245
+ logger.error(`badgerclaw: autopair failed for ${botUserId}: ${String(e)}`);
246
+ }
247
+ })();
248
+ }
249
+ });
250
+ }