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.
- package/CHANGELOG.md +104 -0
- package/SETUP.md +291 -0
- package/index.ts +47 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +32 -34
- package/scripts/postinstall.js +34 -0
- package/src/actions.ts +195 -0
- package/src/channel.ts +461 -0
- package/src/config-schema.ts +62 -0
- package/src/connect.ts +17 -0
- package/src/directory-live.ts +209 -0
- package/src/group-mentions.ts +103 -0
- package/src/matrix/accounts.ts +114 -0
- package/src/matrix/actions/client.ts +47 -0
- package/src/matrix/actions/limits.ts +6 -0
- package/src/matrix/actions/messages.ts +126 -0
- package/src/matrix/actions/pins.ts +84 -0
- package/src/matrix/actions/reactions.ts +102 -0
- package/src/matrix/actions/room.ts +85 -0
- package/src/matrix/actions/summary.ts +75 -0
- package/src/matrix/actions/types.ts +85 -0
- package/src/matrix/actions.ts +15 -0
- package/src/matrix/active-client.ts +32 -0
- package/src/matrix/client/backup.ts +91 -0
- package/src/matrix/client/config.ts +274 -0
- package/src/matrix/client/create-client.ts +125 -0
- package/src/matrix/client/logging.ts +46 -0
- package/src/matrix/client/runtime.ts +4 -0
- package/src/matrix/client/shared.ts +223 -0
- package/src/matrix/client/startup.ts +29 -0
- package/src/matrix/client/storage.ts +131 -0
- package/src/matrix/client/types.ts +34 -0
- package/src/matrix/client-bootstrap.ts +47 -0
- package/src/matrix/client.ts +14 -0
- package/src/matrix/credentials.ts +125 -0
- package/src/matrix/deps.ts +126 -0
- package/src/matrix/format.ts +22 -0
- package/src/matrix/index.ts +11 -0
- package/src/matrix/monitor/access-policy.ts +126 -0
- package/src/matrix/monitor/allowlist.ts +94 -0
- package/src/matrix/monitor/auto-join.ts +126 -0
- package/src/matrix/monitor/bot-commands.ts +431 -0
- package/src/matrix/monitor/chat-history.ts +75 -0
- package/src/matrix/monitor/direct.ts +152 -0
- package/src/matrix/monitor/events.ts +250 -0
- package/src/matrix/monitor/handler.ts +847 -0
- package/src/matrix/monitor/inbound-body.ts +28 -0
- package/src/matrix/monitor/index.ts +414 -0
- package/src/matrix/monitor/location.ts +100 -0
- package/src/matrix/monitor/media.ts +118 -0
- package/src/matrix/monitor/mentions.ts +62 -0
- package/src/matrix/monitor/replies.ts +124 -0
- package/src/matrix/monitor/room-info.ts +55 -0
- package/src/matrix/monitor/rooms.ts +47 -0
- package/src/matrix/monitor/threads.ts +68 -0
- package/src/matrix/monitor/types.ts +39 -0
- package/src/matrix/poll-types.ts +167 -0
- package/src/matrix/probe.ts +69 -0
- package/src/matrix/sdk-runtime.ts +18 -0
- package/src/matrix/send/client.ts +99 -0
- package/src/matrix/send/formatting.ts +93 -0
- package/src/matrix/send/media.ts +230 -0
- package/src/matrix/send/targets.ts +150 -0
- package/src/matrix/send/types.ts +110 -0
- package/src/matrix/send-queue.ts +28 -0
- package/src/matrix/send.ts +267 -0
- package/src/onboarding.ts +350 -0
- package/src/outbound.ts +58 -0
- package/src/resolve-targets.ts +125 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/test-mocks.ts +53 -0
- package/src/tool-actions.ts +164 -0
- package/src/types.ts +121 -0
- package/README.md +0 -32
- package/dist/commands/autopair.d.ts +0 -3
- package/dist/commands/autopair.js +0 -102
- package/dist/commands/autopair.js.map +0 -1
- package/dist/commands/bot.d.ts +0 -2
- package/dist/commands/bot.js +0 -94
- package/dist/commands/bot.js.map +0 -1
- package/dist/commands/login.d.ts +0 -2
- package/dist/commands/login.js +0 -88
- package/dist/commands/login.js.map +0 -1
- package/dist/commands/logout.d.ts +0 -2
- package/dist/commands/logout.js +0 -36
- package/dist/commands/logout.js.map +0 -1
- package/dist/commands/status.d.ts +0 -2
- package/dist/commands/status.js +0 -23
- package/dist/commands/status.js.map +0 -1
- package/dist/commands/watch.d.ts +0 -2
- package/dist/commands/watch.js +0 -29
- package/dist/commands/watch.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -23
- package/dist/index.js.map +0 -1
- package/dist/lib/api.d.ts +0 -4
- package/dist/lib/api.js +0 -37
- package/dist/lib/api.js.map +0 -1
- package/dist/lib/auth.d.ts +0 -11
- package/dist/lib/auth.js +0 -48
- package/dist/lib/auth.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -2
- package/dist/lib/pkce.js +0 -15
- package/dist/lib/pkce.js.map +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compileAllowlist,
|
|
3
|
+
normalizeStringEntries,
|
|
4
|
+
resolveCompiledAllowlistMatch,
|
|
5
|
+
type AllowlistMatch,
|
|
6
|
+
} from "openclaw/plugin-sdk/matrix";
|
|
7
|
+
|
|
8
|
+
function normalizeAllowList(list?: Array<string | number>) {
|
|
9
|
+
return normalizeStringEntries(list);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeMatrixUser(raw?: string | null): string {
|
|
13
|
+
const value = (raw ?? "").trim();
|
|
14
|
+
if (!value) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
if (!value.startsWith("@") || !value.includes(":")) {
|
|
18
|
+
return value.toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
const withoutAt = value.slice(1);
|
|
21
|
+
const splitIndex = withoutAt.indexOf(":");
|
|
22
|
+
if (splitIndex === -1) {
|
|
23
|
+
return value.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
|
|
26
|
+
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
|
|
27
|
+
if (!server) {
|
|
28
|
+
return value.toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
return `@${localpart}:${server.toLowerCase()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeMatrixUserId(raw?: string | null): string {
|
|
34
|
+
const trimmed = (raw ?? "").trim();
|
|
35
|
+
if (!trimmed) {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
const lowered = trimmed.toLowerCase();
|
|
39
|
+
if (lowered.startsWith("badgerclaw:")) {
|
|
40
|
+
return normalizeMatrixUser(trimmed.slice("badgerclaw:".length));
|
|
41
|
+
}
|
|
42
|
+
if (lowered.startsWith("user:")) {
|
|
43
|
+
return normalizeMatrixUser(trimmed.slice("user:".length));
|
|
44
|
+
}
|
|
45
|
+
return normalizeMatrixUser(trimmed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeMatrixAllowListEntry(raw: string): string {
|
|
49
|
+
const trimmed = raw.trim();
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
if (trimmed === "*") {
|
|
54
|
+
return trimmed;
|
|
55
|
+
}
|
|
56
|
+
const lowered = trimmed.toLowerCase();
|
|
57
|
+
if (lowered.startsWith("badgerclaw:")) {
|
|
58
|
+
return `badgerclaw:${normalizeMatrixUser(trimmed.slice("badgerclaw:".length))}`;
|
|
59
|
+
}
|
|
60
|
+
if (lowered.startsWith("user:")) {
|
|
61
|
+
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
|
|
62
|
+
}
|
|
63
|
+
return normalizeMatrixUser(trimmed);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizeMatrixAllowList(list?: Array<string | number>) {
|
|
67
|
+
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type MatrixAllowListMatch = AllowlistMatch<
|
|
71
|
+
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
|
|
72
|
+
>;
|
|
73
|
+
type MatrixAllowListSource = Exclude<MatrixAllowListMatch["matchSource"], undefined>;
|
|
74
|
+
|
|
75
|
+
export function resolveMatrixAllowListMatch(params: {
|
|
76
|
+
allowList: string[];
|
|
77
|
+
userId?: string;
|
|
78
|
+
}): MatrixAllowListMatch {
|
|
79
|
+
const compiledAllowList = compileAllowlist(params.allowList);
|
|
80
|
+
const userId = normalizeMatrixUser(params.userId);
|
|
81
|
+
const candidates: Array<{ value?: string; source: MatrixAllowListSource }> = [
|
|
82
|
+
{ value: userId, source: "id" },
|
|
83
|
+
{ value: userId ? `badgerclaw:${userId}` : "", source: "prefixed-id" },
|
|
84
|
+
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
|
|
85
|
+
];
|
|
86
|
+
return resolveCompiledAllowlistMatch({
|
|
87
|
+
compiledAllowlist: compiledAllowList,
|
|
88
|
+
candidates,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
|
|
93
|
+
return resolveMatrixAllowListMatch(params).allowed;
|
|
94
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
|
3
|
+
import { getMatrixRuntime } from "../../runtime.js";
|
|
4
|
+
import type { CoreConfig } from "../../types.js";
|
|
5
|
+
import { loadMatrixSdk } from "../sdk-runtime.js";
|
|
6
|
+
import { initRoomHistory } from "./chat-history.js";
|
|
7
|
+
|
|
8
|
+
// Track clients that already have auto-join registered to prevent duplicate listeners
|
|
9
|
+
const autoJoinRegistered = new WeakSet<object>();
|
|
10
|
+
|
|
11
|
+
export function registerMatrixAutoJoin(params: {
|
|
12
|
+
client: MatrixClient;
|
|
13
|
+
cfg: CoreConfig;
|
|
14
|
+
runtime: RuntimeEnv;
|
|
15
|
+
}) {
|
|
16
|
+
const { client, cfg, runtime } = params;
|
|
17
|
+
const core = getMatrixRuntime();
|
|
18
|
+
const logVerbose = (message: string) => {
|
|
19
|
+
if (!core.logging.shouldLogVerbose()) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
runtime.log?.(message);
|
|
23
|
+
};
|
|
24
|
+
const autoJoin = cfg.channels?.badgerclaw?.autoJoin ?? "always";
|
|
25
|
+
const autoJoinAllowlist = cfg.channels?.badgerclaw?.autoJoinAllowlist ?? [];
|
|
26
|
+
|
|
27
|
+
if (autoJoin === "off") {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Prevent duplicate listener registration on the same client instance
|
|
32
|
+
if (autoJoinRegistered.has(client)) {
|
|
33
|
+
logVerbose("badgerclaw: auto-join already registered for this client, skipping");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
autoJoinRegistered.add(client);
|
|
37
|
+
|
|
38
|
+
// After joining a room, send a notice in encrypted rooms to force Megolm session rotation.
|
|
39
|
+
// When the bot joins, other clients may use a cached outbound Megolm session that doesn't
|
|
40
|
+
// include the bot. Sending a message causes compliant clients to detect the new member and
|
|
41
|
+
// rotate their session so subsequent messages are decryptable by the bot.
|
|
42
|
+
async function postJoinEncryptionHandshake(roomId: string) {
|
|
43
|
+
try {
|
|
44
|
+
const encryptionState = await client
|
|
45
|
+
.getRoomStateEvent(roomId, "m.room.encryption", "")
|
|
46
|
+
.catch(() => null);
|
|
47
|
+
|
|
48
|
+
if (!encryptionState) {
|
|
49
|
+
logVerbose(`badgerclaw: room ${roomId} is not encrypted, skipping handshake`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
logVerbose(`badgerclaw: room ${roomId} is encrypted, sending notice to trigger key rotation`);
|
|
54
|
+
await client.sendNotice(
|
|
55
|
+
roomId,
|
|
56
|
+
"🔐 Connected — encryption active.\n\n" +
|
|
57
|
+
"I'll respond when @mentioned. To toggle auto-reply:\n" +
|
|
58
|
+
"• /bot talk on — I'll respond to every message\n" +
|
|
59
|
+
"• /bot talk off — I'll only respond when @mentioned\n" +
|
|
60
|
+
"• /bot help — see all commands",
|
|
61
|
+
);
|
|
62
|
+
logVerbose(`badgerclaw: sent encryption handshake + hint notice in room ${roomId}`);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
runtime.log?.(`badgerclaw: encryption handshake failed for room ${roomId}: ${String(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (autoJoin === "always") {
|
|
69
|
+
// Use the built-in autojoin mixin for "always" mode
|
|
70
|
+
const { AutojoinRoomsMixin } = loadMatrixSdk();
|
|
71
|
+
AutojoinRoomsMixin.setupOnClient(client);
|
|
72
|
+
logVerbose("badgerclaw: auto-join enabled for all invites");
|
|
73
|
+
|
|
74
|
+
// AutojoinRoomsMixin handles the join, so listen for room.join to run post-join logic
|
|
75
|
+
client.on("room.join", async (roomId: string, _joinEvent: unknown) => {
|
|
76
|
+
logVerbose(`badgerclaw: bot joined room ${roomId} (always mode), running post-join handshake`);
|
|
77
|
+
if (cfg.chatHistory?.enabled) {
|
|
78
|
+
initRoomHistory(roomId);
|
|
79
|
+
}
|
|
80
|
+
await postJoinEncryptionHandshake(roomId);
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// For "allowlist" mode, handle invites manually
|
|
86
|
+
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
|
87
|
+
if (autoJoin !== "allowlist") {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Get room alias if available
|
|
92
|
+
let alias: string | undefined;
|
|
93
|
+
let altAliases: string[] = [];
|
|
94
|
+
try {
|
|
95
|
+
const aliasState = await client
|
|
96
|
+
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
|
97
|
+
.catch(() => null);
|
|
98
|
+
alias = aliasState?.alias;
|
|
99
|
+
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
|
|
100
|
+
} catch {
|
|
101
|
+
// Ignore errors
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const allowed =
|
|
105
|
+
autoJoinAllowlist.includes("*") ||
|
|
106
|
+
autoJoinAllowlist.includes(roomId) ||
|
|
107
|
+
(alias ? autoJoinAllowlist.includes(alias) : false) ||
|
|
108
|
+
altAliases.some((value) => autoJoinAllowlist.includes(value));
|
|
109
|
+
|
|
110
|
+
if (!allowed) {
|
|
111
|
+
logVerbose(`badgerclaw: invite ignored (not in allowlist) room=${roomId}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await client.joinRoom(roomId);
|
|
117
|
+
logVerbose(`badgerclaw: joined room ${roomId}`);
|
|
118
|
+
if (cfg.chatHistory?.enabled) {
|
|
119
|
+
initRoomHistory(roomId);
|
|
120
|
+
}
|
|
121
|
+
await postJoinEncryptionHandshake(roomId);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
runtime.error?.(`badgerclaw: failed to join room ${roomId}: ${String(err)}`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
const ROOM_CONFIG_PATH = path.join(
|
|
6
|
+
process.env.HOME || "/tmp",
|
|
7
|
+
".openclaw/extensions/badgerclaw/room-config.json"
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
// Session store path for core groupActivation
|
|
11
|
+
const SESSION_STORE_PATH = path.join(
|
|
12
|
+
process.env.HOME || "/tmp",
|
|
13
|
+
".openclaw/agents/main/sessions/sessions.json"
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
type RoomConfig = {
|
|
17
|
+
autoReply?: boolean; // legacy — kept for migration
|
|
18
|
+
activeBots?: string[]; // per-bot auto-reply list
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function loadRoomConfig(): Record<string, RoomConfig> {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(ROOM_CONFIG_PATH, "utf-8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function saveRoomConfig(config: Record<string, RoomConfig>): void {
|
|
30
|
+
fs.writeFileSync(ROOM_CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setGroupActivation(roomId: string, activation: "always" | "mention"): void {
|
|
34
|
+
try {
|
|
35
|
+
const store = JSON.parse(fs.readFileSync(SESSION_STORE_PATH, "utf-8"));
|
|
36
|
+
const sessionKey = `agent:main:badgerclaw:channel:${roomId.toLowerCase()}`;
|
|
37
|
+
if (!store[sessionKey]) {
|
|
38
|
+
store[sessionKey] = {};
|
|
39
|
+
}
|
|
40
|
+
store[sessionKey].groupActivation = activation;
|
|
41
|
+
fs.writeFileSync(SESSION_STORE_PATH, JSON.stringify(store, null, 2));
|
|
42
|
+
console.log(`[badgerclaw] set groupActivation=${activation} for ${sessionKey}`);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`[badgerclaw] failed to set groupActivation: ${err}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if a specific bot has auto-reply enabled in a room.
|
|
50
|
+
* Returns true if the bot is in the activeBots list.
|
|
51
|
+
* Migrates legacy autoReply boolean to activeBots format.
|
|
52
|
+
*/
|
|
53
|
+
export function isBotAutoReplyEnabled(roomId: string, botUserId: string): boolean | undefined {
|
|
54
|
+
const config = loadRoomConfig();
|
|
55
|
+
const normalizedRoomId = roomId.toLowerCase();
|
|
56
|
+
const keys = Object.keys(config);
|
|
57
|
+
const matchedKey = keys.find((k) => k.toLowerCase() === normalizedRoomId);
|
|
58
|
+
if (!matchedKey) return undefined;
|
|
59
|
+
|
|
60
|
+
const room = config[matchedKey];
|
|
61
|
+
|
|
62
|
+
// New format: check activeBots list
|
|
63
|
+
if (Array.isArray(room.activeBots)) {
|
|
64
|
+
return room.activeBots.some(
|
|
65
|
+
(b) => b.toLowerCase() === botUserId.toLowerCase()
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Legacy migration: if autoReply is set but no activeBots, treat as legacy
|
|
70
|
+
if (typeof room.autoReply === "boolean") {
|
|
71
|
+
return room.autoReply;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Legacy compat export
|
|
78
|
+
export function getRoomAutoReply(roomId: string): boolean | undefined {
|
|
79
|
+
const config = loadRoomConfig();
|
|
80
|
+
const normalizedRoomId = roomId.toLowerCase();
|
|
81
|
+
const keys = Object.keys(config);
|
|
82
|
+
const matchedKey = keys.find((k) => k.toLowerCase() === normalizedRoomId);
|
|
83
|
+
if (!matchedKey) return undefined;
|
|
84
|
+
|
|
85
|
+
const room = config[matchedKey];
|
|
86
|
+
if (Array.isArray(room.activeBots) && room.activeBots.length > 0) {
|
|
87
|
+
return true; // At least one bot is active
|
|
88
|
+
}
|
|
89
|
+
return room.autoReply;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function addBotToActive(roomId: string, botUserId: string): void {
|
|
93
|
+
const config = loadRoomConfig();
|
|
94
|
+
const normalizedRoomId = roomId.toLowerCase();
|
|
95
|
+
if (!config[normalizedRoomId]) {
|
|
96
|
+
config[normalizedRoomId] = {};
|
|
97
|
+
}
|
|
98
|
+
const room = config[normalizedRoomId];
|
|
99
|
+
if (!Array.isArray(room.activeBots)) {
|
|
100
|
+
room.activeBots = [];
|
|
101
|
+
}
|
|
102
|
+
const botLower = botUserId.toLowerCase();
|
|
103
|
+
if (!room.activeBots.some((b) => b.toLowerCase() === botLower)) {
|
|
104
|
+
room.activeBots.push(botUserId);
|
|
105
|
+
}
|
|
106
|
+
// Clear legacy field
|
|
107
|
+
delete room.autoReply;
|
|
108
|
+
saveRoomConfig(config);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeAllActiveBots(roomId: string): void {
|
|
112
|
+
const config = loadRoomConfig();
|
|
113
|
+
const normalizedRoomId = roomId.toLowerCase();
|
|
114
|
+
if (!config[normalizedRoomId]) {
|
|
115
|
+
config[normalizedRoomId] = {};
|
|
116
|
+
}
|
|
117
|
+
config[normalizedRoomId].activeBots = [];
|
|
118
|
+
delete config[normalizedRoomId].autoReply;
|
|
119
|
+
saveRoomConfig(config);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getDisplayName(userId: string): string {
|
|
123
|
+
return userId.split(":")[0];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function handleBotCommand(params: {
|
|
127
|
+
client: MatrixClient;
|
|
128
|
+
roomId: string;
|
|
129
|
+
senderId: string;
|
|
130
|
+
body: string;
|
|
131
|
+
selfUserId: string;
|
|
132
|
+
}): Promise<boolean> {
|
|
133
|
+
const { client, roomId, senderId, body, selfUserId } = params;
|
|
134
|
+
const trimmed = body.trim();
|
|
135
|
+
|
|
136
|
+
if (!trimmed.startsWith("/bot")) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const parts = trimmed.split(/\s+/);
|
|
141
|
+
const command = parts[1]?.toLowerCase();
|
|
142
|
+
const arg = parts[2]?.toLowerCase();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
switch (command) {
|
|
146
|
+
case "help": {
|
|
147
|
+
await client.sendMessage(roomId, {
|
|
148
|
+
msgtype: "m.text",
|
|
149
|
+
body: [
|
|
150
|
+
"🦡 BadgerClaw Bot Commands",
|
|
151
|
+
"",
|
|
152
|
+
"━━━ Available Everywhere ━━━",
|
|
153
|
+
"",
|
|
154
|
+
"/bot help",
|
|
155
|
+
" Show this help message with all available commands.",
|
|
156
|
+
"",
|
|
157
|
+
"/bot talk on",
|
|
158
|
+
" Enable auto-reply for this bot. It will respond to",
|
|
159
|
+
" every message without needing an @mention.",
|
|
160
|
+
" If multiple bots are in the room, specify which one:",
|
|
161
|
+
" /bot talk on @botname",
|
|
162
|
+
"",
|
|
163
|
+
"/bot talk off",
|
|
164
|
+
" Disable auto-reply for ALL bots in this room.",
|
|
165
|
+
" All bots go back to mention-only mode.",
|
|
166
|
+
"",
|
|
167
|
+
"/bot add <botname>",
|
|
168
|
+
" Invite a bot to the current room.",
|
|
169
|
+
" Example: /bot add jarvis → invites @jarvis_bot",
|
|
170
|
+
" Also accepts: /bot add @jarvis_bot",
|
|
171
|
+
"",
|
|
172
|
+
"/bot list",
|
|
173
|
+
" List all bots in this room and their auto-reply status.",
|
|
174
|
+
"",
|
|
175
|
+
"━━━ BotBadger DM Only ━━━",
|
|
176
|
+
"",
|
|
177
|
+
"/bot new — Create a new bot",
|
|
178
|
+
"/bot pair <name> — Get a pairing code",
|
|
179
|
+
"/bot delete <name> — Permanently delete a bot",
|
|
180
|
+
].join("\n"),
|
|
181
|
+
});
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case "talk": {
|
|
186
|
+
// Resolve bot members in the room
|
|
187
|
+
const botSuffix = "_bot:badger.signout.io";
|
|
188
|
+
let roomBots: string[] = [];
|
|
189
|
+
try {
|
|
190
|
+
const members = await client.getJoinedRoomMembers(roomId);
|
|
191
|
+
roomBots = members.filter((m: string) => m.includes(botSuffix) && m !== selfUserId);
|
|
192
|
+
} catch {
|
|
193
|
+
// If we can't list members, proceed as single-bot
|
|
194
|
+
}
|
|
195
|
+
const multipleBots = roomBots.length > 0;
|
|
196
|
+
|
|
197
|
+
// Check if a specific bot was mentioned: /bot talk on @botname
|
|
198
|
+
const mentionArg = parts.slice(3).join(" ").trim();
|
|
199
|
+
const mentionedBot = mentionArg
|
|
200
|
+
? mentionArg.startsWith("@") ? mentionArg : `@${mentionArg}`
|
|
201
|
+
: null;
|
|
202
|
+
|
|
203
|
+
// If a bot was mentioned and it's not us, ignore the command (let that bot handle it)
|
|
204
|
+
if (mentionedBot) {
|
|
205
|
+
const mentionLower = mentionedBot.toLowerCase();
|
|
206
|
+
const selfLower = selfUserId.toLowerCase();
|
|
207
|
+
const selfLocalpart = selfLower.split(":")[0];
|
|
208
|
+
if (!selfLower.startsWith(mentionLower) && !mentionLower.startsWith(selfLocalpart)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (arg === "on") {
|
|
214
|
+
// Multiple bots + no specific mention → ask which bot
|
|
215
|
+
if (multipleBots && !mentionedBot) {
|
|
216
|
+
const allBots = [selfUserId, ...roomBots];
|
|
217
|
+
const config = loadRoomConfig();
|
|
218
|
+
const normalizedRoomId = roomId.toLowerCase();
|
|
219
|
+
const active = config[normalizedRoomId]?.activeBots ?? [];
|
|
220
|
+
|
|
221
|
+
const botList = allBots
|
|
222
|
+
.map((b, i) => {
|
|
223
|
+
const name = getDisplayName(b);
|
|
224
|
+
const isActive = active.some((a) => a.toLowerCase() === b.toLowerCase());
|
|
225
|
+
return ` ${i + 1}. ${name} ${isActive ? "🟢 auto-reply on" : "⚪ mention-only"}`;
|
|
226
|
+
})
|
|
227
|
+
.join("\n");
|
|
228
|
+
|
|
229
|
+
await client.sendMessage(roomId, {
|
|
230
|
+
msgtype: "m.text",
|
|
231
|
+
body: [
|
|
232
|
+
"🦡 Multiple bots in this room:",
|
|
233
|
+
"",
|
|
234
|
+
botList,
|
|
235
|
+
"",
|
|
236
|
+
"Specify which bot: /bot talk on @botname",
|
|
237
|
+
].join("\n"),
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Add THIS bot to activeBots
|
|
243
|
+
addBotToActive(roomId, selfUserId);
|
|
244
|
+
setGroupActivation(roomId, "always");
|
|
245
|
+
await client.sendMessage(roomId, {
|
|
246
|
+
msgtype: "m.text",
|
|
247
|
+
body: `✅ Auto-reply enabled for ${getDisplayName(selfUserId)} — I'll respond to every message in this room.`,
|
|
248
|
+
});
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (arg === "off") {
|
|
253
|
+
// Kill switch — remove ALL bots from activeBots
|
|
254
|
+
removeAllActiveBots(roomId);
|
|
255
|
+
setGroupActivation(roomId, "mention");
|
|
256
|
+
await client.sendMessage(roomId, {
|
|
257
|
+
msgtype: "m.text",
|
|
258
|
+
body: "✅ Auto-reply disabled for all bots — bots will only respond when @mentioned.",
|
|
259
|
+
});
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await client.sendMessage(roomId, {
|
|
264
|
+
msgtype: "m.text",
|
|
265
|
+
body: "Usage: /bot talk on [@botname] or /bot talk off",
|
|
266
|
+
});
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case "add": {
|
|
271
|
+
let rawName = parts.slice(2).join(" ").trim();
|
|
272
|
+
if (!rawName) {
|
|
273
|
+
await client.sendMessage(roomId, {
|
|
274
|
+
msgtype: "m.text",
|
|
275
|
+
body: "Usage: /bot add <botname>\nExample: /bot add jarvis or /bot add @jarvis_bot",
|
|
276
|
+
});
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
rawName = rawName.replace(/^@/, "").split(":")[0].replace(/_bot$/i, "").toLowerCase();
|
|
280
|
+
const addBotUserId = `@${rawName}_bot:badger.signout.io`;
|
|
281
|
+
const addBotDisplay = `@${rawName}_bot`;
|
|
282
|
+
try {
|
|
283
|
+
await client.inviteUser(addBotUserId, roomId);
|
|
284
|
+
await client.sendMessage(roomId, {
|
|
285
|
+
msgtype: "m.text",
|
|
286
|
+
body: `✅ Invited ${addBotDisplay} to this room.`,
|
|
287
|
+
});
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
+
const cleanMsg = msg.replace(/:badger\.signout\.io/g, "");
|
|
291
|
+
await client.sendMessage(roomId, {
|
|
292
|
+
msgtype: "m.text",
|
|
293
|
+
body: `❌ Failed to invite ${addBotDisplay}: ${cleanMsg}`,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case "list": {
|
|
300
|
+
const botSuffix2 = "_bot:badger.signout.io";
|
|
301
|
+
let botsInRoom: string[] = [];
|
|
302
|
+
try {
|
|
303
|
+
const members = await client.getJoinedRoomMembers(roomId);
|
|
304
|
+
botsInRoom = members.filter((m: string) => m.includes(botSuffix2));
|
|
305
|
+
} catch {
|
|
306
|
+
botsInRoom = [selfUserId];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (botsInRoom.length === 0) {
|
|
310
|
+
await client.sendMessage(roomId, {
|
|
311
|
+
msgtype: "m.text",
|
|
312
|
+
body: "No bots in this room.",
|
|
313
|
+
});
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const config = loadRoomConfig();
|
|
318
|
+
const normalizedRoomId = roomId.toLowerCase();
|
|
319
|
+
const active = config[normalizedRoomId]?.activeBots ?? [];
|
|
320
|
+
// Legacy: if autoReply is true and no activeBots, all bots are active
|
|
321
|
+
const legacyAllActive = !Array.isArray(config[normalizedRoomId]?.activeBots) && config[normalizedRoomId]?.autoReply === true;
|
|
322
|
+
|
|
323
|
+
const botList = botsInRoom
|
|
324
|
+
.map((b) => {
|
|
325
|
+
const displayName = getDisplayName(b);
|
|
326
|
+
const isMe = b === selfUserId;
|
|
327
|
+
const isActive = legacyAllActive || active.some((a) => a.toLowerCase() === b.toLowerCase());
|
|
328
|
+
return ` ${isActive ? "🟢" : "⚪"} ${displayName}${isMe ? " (me)" : ""} — ${isActive ? "auto-reply on" : "mention-only"}`;
|
|
329
|
+
})
|
|
330
|
+
.join("\n");
|
|
331
|
+
|
|
332
|
+
await client.sendMessage(roomId, {
|
|
333
|
+
msgtype: "m.text",
|
|
334
|
+
body: [
|
|
335
|
+
"🦡 Bots in this room:",
|
|
336
|
+
"",
|
|
337
|
+
botList,
|
|
338
|
+
"",
|
|
339
|
+
`${botsInRoom.length} bot${botsInRoom.length === 1 ? "" : "s"} total`,
|
|
340
|
+
].join("\n"),
|
|
341
|
+
});
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case "delete": {
|
|
346
|
+
const nameArg = parts.slice(2).join(" ").trim().replace(/^@/, "").split(":")[0].replace(/_bot$/i, "").toLowerCase();
|
|
347
|
+
if (!nameArg) {
|
|
348
|
+
await client.sendMessage(roomId, {
|
|
349
|
+
msgtype: "m.text",
|
|
350
|
+
body: "Usage: /bot delete <name>\nExample: /bot delete jarvis\n\n⚠️ This permanently deletes the bot from the database and Matrix.",
|
|
351
|
+
});
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const botUserId = `@${nameArg}_bot:badger.signout.io`;
|
|
356
|
+
|
|
357
|
+
// Load JWT token from ~/.badgerclaw/auth.json
|
|
358
|
+
let apiToken: string | null = null;
|
|
359
|
+
try {
|
|
360
|
+
const authPath = require("path").join(process.env.HOME || "/tmp", ".badgerclaw", "auth.json");
|
|
361
|
+
const authData = JSON.parse(require("fs").readFileSync(authPath, "utf-8"));
|
|
362
|
+
apiToken = authData.access_token || null;
|
|
363
|
+
} catch {
|
|
364
|
+
// no token
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!apiToken) {
|
|
368
|
+
await client.sendMessage(roomId, {
|
|
369
|
+
msgtype: "m.text",
|
|
370
|
+
body: "❌ Not authenticated. Run `badgerclaw login` first.",
|
|
371
|
+
});
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Find the bot by user_id via API
|
|
376
|
+
try {
|
|
377
|
+
const listResp = await fetch("https://api.badgerclaw.ai/api/v1/bots", {
|
|
378
|
+
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
|
|
379
|
+
});
|
|
380
|
+
if (!listResp.ok) throw new Error(`Failed to list bots: ${listResp.status}`);
|
|
381
|
+
const bots: Array<{ id: string; bot_user_id: string; bot_name: string }> = await listResp.json();
|
|
382
|
+
const bot = bots.find((b) => b.bot_user_id.toLowerCase() === botUserId.toLowerCase());
|
|
383
|
+
|
|
384
|
+
if (!bot) {
|
|
385
|
+
await client.sendMessage(roomId, {
|
|
386
|
+
msgtype: "m.text",
|
|
387
|
+
body: `❌ Bot @${nameArg}_bot not found. Check the name and try again.`,
|
|
388
|
+
});
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Delete permanently
|
|
393
|
+
const delResp = await fetch(`https://api.badgerclaw.ai/api/v1/bots/${bot.id}`, {
|
|
394
|
+
method: "DELETE",
|
|
395
|
+
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (delResp.ok) {
|
|
399
|
+
await client.sendMessage(roomId, {
|
|
400
|
+
msgtype: "m.text",
|
|
401
|
+
body: `🗑️ Bot **${bot.bot_name}** (@${nameArg}_bot) permanently deleted.`,
|
|
402
|
+
});
|
|
403
|
+
} else {
|
|
404
|
+
const err = await delResp.json().catch(() => ({ detail: delResp.statusText }));
|
|
405
|
+
await client.sendMessage(roomId, {
|
|
406
|
+
msgtype: "m.text",
|
|
407
|
+
body: `❌ Failed to delete bot: ${err.detail || delResp.status}`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
} catch (e) {
|
|
411
|
+
await client.sendMessage(roomId, {
|
|
412
|
+
msgtype: "m.text",
|
|
413
|
+
body: `❌ Error deleting bot: ${e}`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
default: {
|
|
420
|
+
await client.sendMessage(roomId, {
|
|
421
|
+
msgtype: "m.text",
|
|
422
|
+
body: "Unknown command. Type /bot help for available commands.",
|
|
423
|
+
});
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch (err) {
|
|
428
|
+
console.error("badgerclaw: bot command error:", err);
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
}
|