openclaw-bridge 0.3.2 → 0.4.1
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.
- package/README.md +43 -16
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +843 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +64 -0
- package/dist/discovery.d.ts +4 -0
- package/dist/discovery.js +6 -0
- package/dist/file-ops.d.ts +22 -0
- package/dist/file-ops.js +253 -0
- package/dist/heartbeat.d.ts +21 -0
- package/dist/heartbeat.js +152 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +624 -0
- package/dist/manager/hub-client.d.ts +18 -0
- package/dist/manager/hub-client.js +89 -0
- package/dist/manager/local-manager.d.ts +12 -0
- package/dist/manager/local-manager.js +117 -0
- package/dist/manager/pm2-bridge.d.ts +17 -0
- package/dist/manager/pm2-bridge.js +113 -0
- package/dist/message-relay.d.ts +32 -0
- package/dist/message-relay.js +229 -0
- package/dist/permissions.d.ts +3 -0
- package/dist/permissions.js +14 -0
- package/dist/registry.d.ts +13 -0
- package/dist/registry.js +103 -0
- package/dist/restart.d.ts +15 -0
- package/dist/restart.js +107 -0
- package/dist/router.d.ts +11 -0
- package/dist/router.js +18 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +21 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.js +1 -0
- package/openclaw.plugin.json +6 -92
- package/package.json +15 -5
- package/src/cli.ts +0 -842
- package/src/config.ts +0 -72
- package/src/discovery.ts +0 -17
- package/src/file-ops.ts +0 -320
- package/src/heartbeat.ts +0 -196
- package/src/index.ts +0 -681
- package/src/manager/hub-client.ts +0 -114
- package/src/manager/local-manager.ts +0 -121
- package/src/manager/pm2-bridge.ts +0 -125
- package/src/message-relay.ts +0 -184
- package/src/permissions.ts +0 -18
- package/src/registry.ts +0 -107
- package/src/restart.ts +0 -137
- package/src/router.ts +0 -40
- package/src/session.ts +0 -33
- package/src/types.ts +0 -100
- package/tsconfig.json +0 -14
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
heartbeatIntervalMs: 30_000,
|
|
4
|
+
offlineThresholdMs: 120_000,
|
|
5
|
+
};
|
|
6
|
+
export function parseConfig(raw) {
|
|
7
|
+
if (!raw || typeof raw !== "object") {
|
|
8
|
+
throw new Error("openclaw-bridge: missing plugin config");
|
|
9
|
+
}
|
|
10
|
+
const obj = raw;
|
|
11
|
+
if (!obj.role || (obj.role !== "normal" && obj.role !== "superuser")) {
|
|
12
|
+
throw new Error('openclaw-bridge: config.role must be "normal" or "superuser"');
|
|
13
|
+
}
|
|
14
|
+
if (!obj.agentId || typeof obj.agentId !== "string") {
|
|
15
|
+
throw new Error("openclaw-bridge: config.agentId is required");
|
|
16
|
+
}
|
|
17
|
+
if (!obj.agentName || typeof obj.agentName !== "string") {
|
|
18
|
+
throw new Error("openclaw-bridge: config.agentName is required");
|
|
19
|
+
}
|
|
20
|
+
const registry = obj.registry;
|
|
21
|
+
if (!registry || !registry.baseUrl || typeof registry.baseUrl !== "string") {
|
|
22
|
+
throw new Error("openclaw-bridge: config.registry.baseUrl is required");
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
role: obj.role,
|
|
26
|
+
agentId: obj.agentId,
|
|
27
|
+
agentName: obj.agentName,
|
|
28
|
+
registry: {
|
|
29
|
+
provider: registry.provider ?? "openviking",
|
|
30
|
+
baseUrl: registry.baseUrl,
|
|
31
|
+
apiKey: registry.apiKey,
|
|
32
|
+
},
|
|
33
|
+
fileRelay: obj.fileRelay
|
|
34
|
+
? {
|
|
35
|
+
baseUrl: obj.fileRelay.baseUrl,
|
|
36
|
+
apiKey: obj.fileRelay.apiKey,
|
|
37
|
+
}
|
|
38
|
+
: undefined,
|
|
39
|
+
messageRelay: obj.messageRelay
|
|
40
|
+
? {
|
|
41
|
+
url: obj.messageRelay.url,
|
|
42
|
+
apiKey: obj.messageRelay.apiKey,
|
|
43
|
+
}
|
|
44
|
+
: undefined,
|
|
45
|
+
heartbeatIntervalMs: typeof obj.heartbeatIntervalMs === "number"
|
|
46
|
+
? obj.heartbeatIntervalMs
|
|
47
|
+
: DEFAULTS.heartbeatIntervalMs,
|
|
48
|
+
offlineThresholdMs: typeof obj.offlineThresholdMs === "number"
|
|
49
|
+
? obj.offlineThresholdMs
|
|
50
|
+
: DEFAULTS.offlineThresholdMs,
|
|
51
|
+
description: typeof obj.description === "string" ? obj.description : undefined,
|
|
52
|
+
supportsVision: typeof obj.supportsVision === "boolean" ? obj.supportsVision : undefined,
|
|
53
|
+
localManager: obj.localManager
|
|
54
|
+
? {
|
|
55
|
+
enabled: !!obj.localManager.enabled,
|
|
56
|
+
hubUrl: obj.localManager.hubUrl,
|
|
57
|
+
managerPass: obj.localManager.managerPass,
|
|
58
|
+
}
|
|
59
|
+
: undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function getMachineId() {
|
|
63
|
+
return hostname();
|
|
64
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RegistryEntry } from "./types.js";
|
|
2
|
+
import type { BridgeRegistry } from "./registry.js";
|
|
3
|
+
export declare function discoverAll(registry: BridgeRegistry, offlineThresholdMs: number): Promise<RegistryEntry[]>;
|
|
4
|
+
export declare function whois(registry: BridgeRegistry, agentId: string, offlineThresholdMs: number): Promise<RegistryEntry | null>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BridgeConfig, RegistryEntry, PluginLogger } from "./types.js";
|
|
2
|
+
export declare class BridgeFileOps {
|
|
3
|
+
private config;
|
|
4
|
+
private machineId;
|
|
5
|
+
private workspacePath;
|
|
6
|
+
private logger;
|
|
7
|
+
constructor(config: BridgeConfig, machineId: string, workspacePath: string, logger: PluginLogger);
|
|
8
|
+
private isSameMachine;
|
|
9
|
+
private validatePathWithinWorkspace;
|
|
10
|
+
private fileRelayHeaders;
|
|
11
|
+
private fileRelayUrl;
|
|
12
|
+
sendFile(target: RegistryEntry, localRelativePath: string): Promise<{
|
|
13
|
+
delivered: boolean;
|
|
14
|
+
message: string;
|
|
15
|
+
filename?: string;
|
|
16
|
+
renamed?: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
readRemoteFile(target: RegistryEntry, relativePath: string): Promise<string>;
|
|
19
|
+
writeRemoteFile(target: RegistryEntry, relativePath: string, content: string): Promise<void>;
|
|
20
|
+
processPendingFiles(): Promise<number>;
|
|
21
|
+
processPendingCommands(): Promise<number>;
|
|
22
|
+
}
|
package/dist/file-ops.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { readFile, writeFile, copyFile, mkdir, access } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, dirname, basename, extname } from "node:path";
|
|
3
|
+
/** If dest exists, append _1, _2, etc. before the extension */
|
|
4
|
+
async function deduplicatePath(destPath) {
|
|
5
|
+
let candidate = destPath;
|
|
6
|
+
let counter = 0;
|
|
7
|
+
const ext = extname(destPath);
|
|
8
|
+
const base = destPath.slice(0, destPath.length - ext.length);
|
|
9
|
+
while (true) {
|
|
10
|
+
try {
|
|
11
|
+
await access(candidate);
|
|
12
|
+
counter++;
|
|
13
|
+
candidate = `${base}_${counter}${ext}`;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return candidate; // File doesn't exist, use this path
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class BridgeFileOps {
|
|
21
|
+
config;
|
|
22
|
+
machineId;
|
|
23
|
+
workspacePath;
|
|
24
|
+
logger;
|
|
25
|
+
constructor(config, machineId, workspacePath, logger) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.machineId = machineId;
|
|
28
|
+
this.workspacePath = workspacePath;
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
}
|
|
31
|
+
isSameMachine(target) {
|
|
32
|
+
return target.machineId === this.machineId;
|
|
33
|
+
}
|
|
34
|
+
validatePathWithinWorkspace(filePath, workspace) {
|
|
35
|
+
const resolved = resolve(workspace, filePath);
|
|
36
|
+
if (!resolved.startsWith(resolve(workspace))) {
|
|
37
|
+
throw new Error(`openclaw-bridge: path escapes workspace: ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
fileRelayHeaders() {
|
|
42
|
+
const h = { "Content-Type": "application/json" };
|
|
43
|
+
if (this.config.fileRelay?.apiKey)
|
|
44
|
+
h["X-API-Key"] = this.config.fileRelay.apiKey;
|
|
45
|
+
return h;
|
|
46
|
+
}
|
|
47
|
+
fileRelayUrl(path) {
|
|
48
|
+
if (!this.config.fileRelay?.baseUrl) {
|
|
49
|
+
throw new Error("openclaw-bridge: fileRelay.baseUrl not configured");
|
|
50
|
+
}
|
|
51
|
+
return `${this.config.fileRelay.baseUrl.replace(/\/+$/, "")}${path}`;
|
|
52
|
+
}
|
|
53
|
+
async sendFile(target, localRelativePath) {
|
|
54
|
+
const sourcePath = this.validatePathWithinWorkspace(localRelativePath, this.workspacePath);
|
|
55
|
+
if (this.isSameMachine(target)) {
|
|
56
|
+
const destDir = join(target.workspacePath, "_inbox", this.config.agentId);
|
|
57
|
+
await mkdir(destDir, { recursive: true });
|
|
58
|
+
const originalFilename = localRelativePath.split(/[\\/]/).pop();
|
|
59
|
+
const rawDestPath = join(destDir, originalFilename);
|
|
60
|
+
const destPath = await deduplicatePath(rawDestPath);
|
|
61
|
+
await copyFile(sourcePath, destPath);
|
|
62
|
+
const actualFilename = basename(destPath);
|
|
63
|
+
const renamed = actualFilename !== originalFilename;
|
|
64
|
+
this.logger.info(`openclaw-bridge: sent ${localRelativePath} to ${target.agentId} (local)${renamed ? ` (renamed to ${actualFilename})` : ""}`);
|
|
65
|
+
return {
|
|
66
|
+
delivered: true,
|
|
67
|
+
message: renamed
|
|
68
|
+
? `File copied to ${target.agentId}/_inbox/ (renamed to ${actualFilename} because a file with the same name already existed)`
|
|
69
|
+
: `File copied to ${target.agentId}/_inbox/`,
|
|
70
|
+
filename: actualFilename,
|
|
71
|
+
renamed,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const content = await readFile(sourcePath);
|
|
75
|
+
const res = await fetch(this.fileRelayUrl("/api/v1/files/upload"), {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: this.fileRelayHeaders(),
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
fromAgent: this.config.agentId,
|
|
80
|
+
toAgent: target.agentId,
|
|
81
|
+
filename: localRelativePath.split(/[\\/]/).pop(),
|
|
82
|
+
content: content.toString("base64"),
|
|
83
|
+
metadata: {},
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
throw new Error(`openclaw-bridge: FileRelay upload failed: ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
this.logger.info(`openclaw-bridge: sent ${localRelativePath} to ${target.agentId} (FileRelay)`);
|
|
90
|
+
return { delivered: false, message: `File uploaded to FileRelay for ${target.agentId}` };
|
|
91
|
+
}
|
|
92
|
+
async readRemoteFile(target, relativePath) {
|
|
93
|
+
if (this.isSameMachine(target)) {
|
|
94
|
+
const fullPath = this.validatePathWithinWorkspace(relativePath, target.workspacePath);
|
|
95
|
+
return await readFile(fullPath, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
const enqueueRes = await fetch(this.fileRelayUrl("/api/v1/commands/enqueue"), {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: this.fileRelayHeaders(),
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
fromAgent: this.config.agentId,
|
|
102
|
+
toAgent: target.agentId,
|
|
103
|
+
type: "read_file",
|
|
104
|
+
payload: { path: relativePath },
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
if (!enqueueRes.ok) {
|
|
108
|
+
throw new Error(`openclaw-bridge: command enqueue failed: ${enqueueRes.status}`);
|
|
109
|
+
}
|
|
110
|
+
const { id: cmdId } = (await enqueueRes.json());
|
|
111
|
+
const deadline = Date.now() + 90_000;
|
|
112
|
+
while (Date.now() < deadline) {
|
|
113
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
114
|
+
const resultRes = await fetch(this.fileRelayUrl(`/api/v1/commands/result/${cmdId}`), {
|
|
115
|
+
headers: this.fileRelayHeaders(),
|
|
116
|
+
});
|
|
117
|
+
if (!resultRes.ok)
|
|
118
|
+
continue;
|
|
119
|
+
const result = (await resultRes.json());
|
|
120
|
+
if (result.status === "ok" && result.payload?.content) {
|
|
121
|
+
return Buffer.from(result.payload.content, "base64").toString("utf-8");
|
|
122
|
+
}
|
|
123
|
+
if (result.status === "error") {
|
|
124
|
+
throw new Error(`openclaw-bridge: remote read failed`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new Error("openclaw-bridge: remote read timed out (90s)");
|
|
128
|
+
}
|
|
129
|
+
async writeRemoteFile(target, relativePath, content) {
|
|
130
|
+
if (this.isSameMachine(target)) {
|
|
131
|
+
const fullPath = this.validatePathWithinWorkspace(relativePath, target.workspacePath);
|
|
132
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
133
|
+
await writeFile(fullPath, content, "utf-8");
|
|
134
|
+
this.logger.info(`openclaw-bridge: wrote ${relativePath} to ${target.agentId} workspace`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const res = await fetch(this.fileRelayUrl("/api/v1/files/upload"), {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: this.fileRelayHeaders(),
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
fromAgent: this.config.agentId,
|
|
142
|
+
toAgent: target.agentId,
|
|
143
|
+
filename: relativePath.split(/[\\/]/).pop(),
|
|
144
|
+
content: Buffer.from(content).toString("base64"),
|
|
145
|
+
metadata: { writeToPath: relativePath },
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
throw new Error(`openclaw-bridge: FileRelay write-upload failed: ${res.status}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async processPendingFiles() {
|
|
153
|
+
if (!this.config.fileRelay?.baseUrl)
|
|
154
|
+
return 0;
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(this.fileRelayUrl(`/api/v1/files/pending?agent=${this.config.agentId}`), { headers: this.fileRelayHeaders() });
|
|
157
|
+
if (!res.ok)
|
|
158
|
+
return 0;
|
|
159
|
+
const data = (await res.json());
|
|
160
|
+
let count = 0;
|
|
161
|
+
for (const file of data.files) {
|
|
162
|
+
const dlRes = await fetch(this.fileRelayUrl(`/api/v1/files/download/${file.id}`), {
|
|
163
|
+
headers: this.fileRelayHeaders(),
|
|
164
|
+
});
|
|
165
|
+
if (!dlRes.ok)
|
|
166
|
+
continue;
|
|
167
|
+
const dlData = (await dlRes.json());
|
|
168
|
+
const fileContent = Buffer.from(dlData.content, "base64");
|
|
169
|
+
let destPath;
|
|
170
|
+
if (file.metadata?.writeToPath) {
|
|
171
|
+
destPath = this.validatePathWithinWorkspace(file.metadata.writeToPath, this.workspacePath);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const inboxDir = join(this.workspacePath, "_inbox", file.fromAgent);
|
|
175
|
+
await mkdir(inboxDir, { recursive: true });
|
|
176
|
+
destPath = await deduplicatePath(join(inboxDir, file.filename));
|
|
177
|
+
}
|
|
178
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
179
|
+
await writeFile(destPath, fileContent);
|
|
180
|
+
await fetch(this.fileRelayUrl(`/api/v1/files/ack/${file.id}`), {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: this.fileRelayHeaders(),
|
|
183
|
+
});
|
|
184
|
+
count++;
|
|
185
|
+
}
|
|
186
|
+
if (count > 0) {
|
|
187
|
+
this.logger.info(`openclaw-bridge: processed ${count} pending file(s) from FileRelay`);
|
|
188
|
+
}
|
|
189
|
+
return count;
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
this.logger.warn(`openclaw-bridge: FileRelay file poll failed: ${String(err)}`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async processPendingCommands() {
|
|
197
|
+
if (!this.config.fileRelay?.baseUrl)
|
|
198
|
+
return 0;
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetch(this.fileRelayUrl(`/api/v1/commands/pending?agent=${this.config.agentId}`), { headers: this.fileRelayHeaders() });
|
|
201
|
+
if (!res.ok)
|
|
202
|
+
return 0;
|
|
203
|
+
const data = (await res.json());
|
|
204
|
+
let count = 0;
|
|
205
|
+
for (const cmd of data.commands) {
|
|
206
|
+
try {
|
|
207
|
+
if (cmd.type === "read_file") {
|
|
208
|
+
const path = cmd.payload.path;
|
|
209
|
+
const fullPath = this.validatePathWithinWorkspace(path, this.workspacePath);
|
|
210
|
+
const content = await readFile(fullPath);
|
|
211
|
+
await fetch(this.fileRelayUrl(`/api/v1/commands/respond/${cmd.id}`), {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: this.fileRelayHeaders(),
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
status: "ok",
|
|
216
|
+
payload: { content: content.toString("base64") },
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else if (cmd.type === "restart") {
|
|
221
|
+
// Acknowledge the command first
|
|
222
|
+
await fetch(this.fileRelayUrl(`/api/v1/commands/respond/${cmd.id}`), {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: this.fileRelayHeaders(),
|
|
225
|
+
body: JSON.stringify({ status: "ok", payload: {} }),
|
|
226
|
+
});
|
|
227
|
+
// Schedule self-restart after responding
|
|
228
|
+
setTimeout(() => {
|
|
229
|
+
this.logger.info("openclaw-bridge: executing remote restart command");
|
|
230
|
+
process.exit(0); // PM2 or run.ps1 will restart the process
|
|
231
|
+
}, 1_000);
|
|
232
|
+
}
|
|
233
|
+
count++;
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
await fetch(this.fileRelayUrl(`/api/v1/commands/respond/${cmd.id}`), {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: this.fileRelayHeaders(),
|
|
239
|
+
body: JSON.stringify({ status: "error", payload: { error: String(err) } }),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (count > 0) {
|
|
244
|
+
this.logger.info(`openclaw-bridge: processed ${count} pending command(s) from FileRelay`);
|
|
245
|
+
}
|
|
246
|
+
return count;
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
this.logger.warn(`openclaw-bridge: FileRelay command poll failed: ${String(err)}`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BridgeConfig, RegistryEntry, PluginLogger } from "./types.js";
|
|
2
|
+
import type { BridgeRegistry } from "./registry.js";
|
|
3
|
+
import type { BridgeFileOps } from "./file-ops.js";
|
|
4
|
+
export declare class BridgeHeartbeat {
|
|
5
|
+
private config;
|
|
6
|
+
private registry;
|
|
7
|
+
private fileOps;
|
|
8
|
+
private logger;
|
|
9
|
+
private entry;
|
|
10
|
+
private timer;
|
|
11
|
+
private lastConfigHash;
|
|
12
|
+
private configPath;
|
|
13
|
+
constructor(config: BridgeConfig, registry: BridgeRegistry, fileOps: BridgeFileOps, entry: RegistryEntry, logger: PluginLogger);
|
|
14
|
+
private computeEntryHash;
|
|
15
|
+
start(): Promise<void>;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
private tick;
|
|
18
|
+
private detectVisionSupport;
|
|
19
|
+
private detectConfigChanges;
|
|
20
|
+
private extractChannels;
|
|
21
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
export class BridgeHeartbeat {
|
|
5
|
+
config;
|
|
6
|
+
registry;
|
|
7
|
+
fileOps;
|
|
8
|
+
logger;
|
|
9
|
+
entry;
|
|
10
|
+
timer = null;
|
|
11
|
+
lastConfigHash = "";
|
|
12
|
+
configPath;
|
|
13
|
+
constructor(config, registry, fileOps, entry, logger) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.registry = registry;
|
|
16
|
+
this.fileOps = fileOps;
|
|
17
|
+
this.entry = entry;
|
|
18
|
+
this.logger = logger;
|
|
19
|
+
this.configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
20
|
+
this.lastConfigHash = this.computeEntryHash();
|
|
21
|
+
}
|
|
22
|
+
computeEntryHash() {
|
|
23
|
+
const data = {
|
|
24
|
+
agentId: this.entry.agentId,
|
|
25
|
+
agentName: this.entry.agentName,
|
|
26
|
+
port: this.entry.port,
|
|
27
|
+
workspacePath: this.entry.workspacePath,
|
|
28
|
+
discordId: this.entry.discordId,
|
|
29
|
+
role: this.entry.role,
|
|
30
|
+
capabilities: this.entry.capabilities,
|
|
31
|
+
};
|
|
32
|
+
return createHash("md5").update(JSON.stringify(data)).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
await this.registry.register(this.entry);
|
|
36
|
+
this.lastConfigHash = this.computeEntryHash();
|
|
37
|
+
const intervalMs = this.config.heartbeatIntervalMs ?? 30_000;
|
|
38
|
+
this.timer = setInterval(() => {
|
|
39
|
+
this.tick().catch((err) => this.logger.warn(`openclaw-bridge: heartbeat tick failed: ${String(err)}`));
|
|
40
|
+
}, intervalMs);
|
|
41
|
+
this.logger.info(`openclaw-bridge: heartbeat started (${intervalMs / 1000}s interval)`);
|
|
42
|
+
}
|
|
43
|
+
async stop() {
|
|
44
|
+
if (this.timer) {
|
|
45
|
+
clearInterval(this.timer);
|
|
46
|
+
this.timer = null;
|
|
47
|
+
}
|
|
48
|
+
await this.registry.deregister(this.entry.agentId);
|
|
49
|
+
this.logger.info("openclaw-bridge: heartbeat stopped, deregistered");
|
|
50
|
+
}
|
|
51
|
+
async tick() {
|
|
52
|
+
await this.detectConfigChanges();
|
|
53
|
+
this.entry.lastHeartbeat = new Date().toISOString();
|
|
54
|
+
this.entry.memMB = Math.round(process.memoryUsage().rss / 1024 / 1024);
|
|
55
|
+
this.entry.supportsVision = this.detectVisionSupport();
|
|
56
|
+
const currentHash = this.computeEntryHash();
|
|
57
|
+
if (currentHash !== this.lastConfigHash) {
|
|
58
|
+
this.logger.info("openclaw-bridge: config change detected, updating registry");
|
|
59
|
+
await this.registry.update(this.entry);
|
|
60
|
+
this.lastConfigHash = currentHash;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
await this.registry.update(this.entry);
|
|
64
|
+
}
|
|
65
|
+
await this.fileOps.processPendingFiles();
|
|
66
|
+
await this.fileOps.processPendingCommands();
|
|
67
|
+
}
|
|
68
|
+
detectVisionSupport() {
|
|
69
|
+
// Explicit config override takes priority
|
|
70
|
+
if (this.config.supportsVision !== undefined) {
|
|
71
|
+
return this.config.supportsVision;
|
|
72
|
+
}
|
|
73
|
+
if (!this.configPath)
|
|
74
|
+
return true; // Default to true — most models support vision
|
|
75
|
+
try {
|
|
76
|
+
const raw = readFileSync(this.configPath, "utf-8");
|
|
77
|
+
const config = JSON.parse(raw);
|
|
78
|
+
const defaultModel = (config.models?.default ?? "").toLowerCase();
|
|
79
|
+
if (!defaultModel)
|
|
80
|
+
return true;
|
|
81
|
+
// Known text-only models that do NOT support vision
|
|
82
|
+
const textOnlyPatterns = [
|
|
83
|
+
"minimax", "m2.7", "deepseek-r1", "deepseek-v2", "qwen-turbo",
|
|
84
|
+
"yi-lightning", "glm-3", "glm-4-flash", "mistral-small",
|
|
85
|
+
"codestral", "command-r", "phi-3-mini", "phi-3-small",
|
|
86
|
+
];
|
|
87
|
+
return !textOnlyPatterns.some((p) => defaultModel.includes(p));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async detectConfigChanges() {
|
|
94
|
+
if (!this.configPath)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
const raw = await readFile(this.configPath, "utf-8");
|
|
98
|
+
const config = JSON.parse(raw);
|
|
99
|
+
// Find this agent's Discord binding
|
|
100
|
+
const binding = config.bindings?.find((b) => b.agentId === this.entry.agentId && b.match.channel === "discord");
|
|
101
|
+
if (!binding)
|
|
102
|
+
return;
|
|
103
|
+
const accountId = binding.match.accountId;
|
|
104
|
+
const token = config.channels?.discord?.accounts?.[accountId]?.token;
|
|
105
|
+
if (!token)
|
|
106
|
+
return;
|
|
107
|
+
// Extract Discord user ID from token (first segment is base64-encoded user ID)
|
|
108
|
+
const firstSegment = token.split(".")[0];
|
|
109
|
+
try {
|
|
110
|
+
const decoded = Buffer.from(firstSegment, "base64").toString("utf-8");
|
|
111
|
+
if (/^\d+$/.test(decoded) && decoded !== this.entry.discordId) {
|
|
112
|
+
this.entry.discordId = decoded;
|
|
113
|
+
this.logger.info(`openclaw-bridge: discordId detected: ${decoded}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Token decode failed — skip
|
|
118
|
+
}
|
|
119
|
+
this.entry.discordConnected = !!this.entry.discordId;
|
|
120
|
+
const channels = this.extractChannels(this.configPath);
|
|
121
|
+
this.entry.channels = channels;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Config read failed — skip this cycle
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
extractChannels(configPath) {
|
|
128
|
+
try {
|
|
129
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
130
|
+
const config = JSON.parse(raw);
|
|
131
|
+
const accounts = config.channels?.discord?.accounts;
|
|
132
|
+
if (!Array.isArray(accounts))
|
|
133
|
+
return [];
|
|
134
|
+
const result = [];
|
|
135
|
+
for (const account of accounts) {
|
|
136
|
+
if (!Array.isArray(account.channels))
|
|
137
|
+
continue;
|
|
138
|
+
for (const ch of account.channels) {
|
|
139
|
+
const channelId = ch.channelId ?? ch.id ?? "";
|
|
140
|
+
const name = ch.name ?? channelId;
|
|
141
|
+
if (channelId) {
|
|
142
|
+
result.push({ type: "discord", channelId, name });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
package/dist/index.d.ts
ADDED