openclaw-bridge 0.1.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.
@@ -0,0 +1,165 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import type { BridgeConfig, RegistryEntry, ChannelInfo, PluginLogger } from "./types.js";
5
+ import type { BridgeRegistry } from "./registry.js";
6
+ import type { BridgeFileOps } from "./file-ops.js";
7
+
8
+ export class BridgeHeartbeat {
9
+ private config: BridgeConfig;
10
+ private registry: BridgeRegistry;
11
+ private fileOps: BridgeFileOps;
12
+ private logger: PluginLogger;
13
+ private entry: RegistryEntry;
14
+ private timer: ReturnType<typeof setInterval> | null = null;
15
+ private lastConfigHash: string = "";
16
+ private configPath: string | undefined;
17
+
18
+ constructor(
19
+ config: BridgeConfig,
20
+ registry: BridgeRegistry,
21
+ fileOps: BridgeFileOps,
22
+ entry: RegistryEntry,
23
+ logger: PluginLogger,
24
+ ) {
25
+ this.config = config;
26
+ this.registry = registry;
27
+ this.fileOps = fileOps;
28
+ this.entry = entry;
29
+ this.logger = logger;
30
+ this.configPath = process.env.OPENCLAW_CONFIG_PATH;
31
+ this.lastConfigHash = this.computeEntryHash();
32
+ }
33
+
34
+ private computeEntryHash(): string {
35
+ const data = {
36
+ agentId: this.entry.agentId,
37
+ agentName: this.entry.agentName,
38
+ port: this.entry.port,
39
+ workspacePath: this.entry.workspacePath,
40
+ discordId: this.entry.discordId,
41
+ role: this.entry.role,
42
+ capabilities: this.entry.capabilities,
43
+ };
44
+ return createHash("md5").update(JSON.stringify(data)).digest("hex");
45
+ }
46
+
47
+ async start(): Promise<void> {
48
+ await this.registry.register(this.entry);
49
+ this.lastConfigHash = this.computeEntryHash();
50
+
51
+ const intervalMs = this.config.heartbeatIntervalMs ?? 30_000;
52
+ this.timer = setInterval(() => {
53
+ this.tick().catch((err) =>
54
+ this.logger.warn(`openclaw-bridge: heartbeat tick failed: ${String(err)}`),
55
+ );
56
+ }, intervalMs);
57
+
58
+ this.logger.info(
59
+ `openclaw-bridge: heartbeat started (${intervalMs / 1000}s interval)`,
60
+ );
61
+ }
62
+
63
+ async stop(): Promise<void> {
64
+ if (this.timer) {
65
+ clearInterval(this.timer);
66
+ this.timer = null;
67
+ }
68
+ await this.registry.deregister(this.entry.agentId);
69
+ this.logger.info("openclaw-bridge: heartbeat stopped, deregistered");
70
+ }
71
+
72
+ private async tick(): Promise<void> {
73
+ await this.detectConfigChanges();
74
+
75
+ this.entry.lastHeartbeat = new Date().toISOString();
76
+
77
+ const currentHash = this.computeEntryHash();
78
+ if (currentHash !== this.lastConfigHash) {
79
+ this.logger.info("openclaw-bridge: config change detected, updating registry");
80
+ await this.registry.update(this.entry);
81
+ this.lastConfigHash = currentHash;
82
+ } else {
83
+ await this.registry.update(this.entry);
84
+ }
85
+
86
+ await this.fileOps.processPendingFiles();
87
+ await this.fileOps.processPendingCommands();
88
+ }
89
+
90
+ private async detectConfigChanges(): Promise<void> {
91
+ if (!this.configPath) return;
92
+
93
+ try {
94
+ const raw = await readFile(this.configPath, "utf-8");
95
+ const config = JSON.parse(raw) as {
96
+ bindings?: Array<{ agentId: string; match: { channel: string; accountId: string } }>;
97
+ channels?: {
98
+ discord?: {
99
+ accounts?: Record<string, { token?: string; channels?: Array<{ id?: string; channelId?: string; name?: string }> }>;
100
+ };
101
+ };
102
+ };
103
+
104
+ // Find this agent's Discord binding
105
+ const binding = config.bindings?.find(
106
+ (b) => b.agentId === this.entry.agentId && b.match.channel === "discord",
107
+ );
108
+ if (!binding) return;
109
+
110
+ const accountId = binding.match.accountId;
111
+ const token = config.channels?.discord?.accounts?.[accountId]?.token;
112
+ if (!token) return;
113
+
114
+ // Extract Discord user ID from token (first segment is base64-encoded user ID)
115
+ const firstSegment = token.split(".")[0];
116
+ try {
117
+ const decoded = Buffer.from(firstSegment, "base64").toString("utf-8");
118
+ if (/^\d+$/.test(decoded) && decoded !== this.entry.discordId) {
119
+ this.entry.discordId = decoded;
120
+ this.logger.info(`openclaw-bridge: discordId detected: ${decoded}`);
121
+ }
122
+ } catch {
123
+ // Token decode failed — skip
124
+ }
125
+
126
+ const channels = this.extractChannels(this.configPath);
127
+ this.entry.channels = channels;
128
+ } catch {
129
+ // Config read failed — skip this cycle
130
+ }
131
+ }
132
+
133
+ private extractChannels(configPath: string): ChannelInfo[] {
134
+ try {
135
+ const raw = readFileSync(configPath, "utf-8");
136
+ const config = JSON.parse(raw) as {
137
+ channels?: {
138
+ discord?: {
139
+ accounts?: Array<{
140
+ channels?: Array<{ id?: string; channelId?: string; name?: string }>;
141
+ }>;
142
+ };
143
+ };
144
+ };
145
+
146
+ const accounts = config.channels?.discord?.accounts;
147
+ if (!Array.isArray(accounts)) return [];
148
+
149
+ const result: ChannelInfo[] = [];
150
+ for (const account of accounts) {
151
+ if (!Array.isArray(account.channels)) continue;
152
+ for (const ch of account.channels) {
153
+ const channelId = ch.channelId ?? ch.id ?? "";
154
+ const name = ch.name ?? channelId;
155
+ if (channelId) {
156
+ result.push({ type: "discord", channelId, name });
157
+ }
158
+ }
159
+ }
160
+ return result;
161
+ } catch {
162
+ return [];
163
+ }
164
+ }
165
+ }