somratpro commited on
Commit
bcbf1ad
Β·
1 Parent(s): 6a8bffa

feat: add optional WhatsApp integration with toggle support and status tracking

Browse files
Files changed (6) hide show
  1. .env.example +4 -0
  2. README.md +12 -4
  3. health-server.js +22 -123
  4. start.sh +17 -7
  5. wa-guardian.js +72 -78
  6. workspace-sync.py +4 -0
.env.example CHANGED
@@ -133,6 +133,10 @@ GATEWAY_TOKEN=your_gateway_token_here
133
  # OPENCLAW_PASSWORD=your_password_here
134
 
135
  # ── OPTIONAL: Chat Integrations ──
 
 
 
 
136
  # Get bot token from: https://t.me/BotFather
137
  TELEGRAM_BOT_TOKEN=your_bot_token_here
138
 
 
133
  # OPENCLAW_PASSWORD=your_password_here
134
 
135
  # ── OPTIONAL: Chat Integrations ──
136
+ # Enable WhatsApp pairing flow
137
+ # Set to true only if you want WhatsApp enabled
138
+ WHATSAPP_ENABLED=false
139
+
140
  # Get bot token from: https://t.me/BotFather
141
  TELEGRAM_BOT_TOKEN=your_bot_token_here
142
 
README.md CHANGED
@@ -98,10 +98,9 @@ After restarting, the bot should appear online on Telegram.
98
 
99
  To use WhatsApp:
100
 
101
- 1. Visit your Space URL. It opens the dashboard at `/dashboard` by default, then click **Open Control UI**.
102
- 2. Enter your `GATEWAY_TOKEN` when the Control UI prompts you to log in.
103
- 3. In the Control UI, go to **Channels** β†’ **WhatsApp** β†’ **Login**.
104
- 4. Scan the QR code with your phone. πŸ“±
105
 
106
  ## πŸ’Ύ Workspace Backup *(Optional)*
107
 
@@ -139,6 +138,15 @@ See `.env.example` for runtime settings. Key configuration values:
139
  | `LLM_MODEL` | Model ID (prefix `<provider>/`, auto-detected from prefix) |
140
  | `GATEWAY_TOKEN` | Gateway token for Control UI access (required) |
141
 
 
 
 
 
 
 
 
 
 
142
  ### Background Services
143
 
144
  | Variable | Default | Description |
 
98
 
99
  To use WhatsApp:
100
 
101
+ 1. Add `WHATSAPP_ENABLED=true` in Hugging Face Space Variables or Secrets.
102
+ 2. In the Control UI, go to **Channels** β†’ **WhatsApp** β†’ **Login**.
103
+ 3. Scan the QR code with your phone. πŸ“±
 
104
 
105
  ## πŸ’Ύ Workspace Backup *(Optional)*
106
 
 
138
  | `LLM_MODEL` | Model ID (prefix `<provider>/`, auto-detected from prefix) |
139
  | `GATEWAY_TOKEN` | Gateway token for Control UI access (required) |
140
 
141
+ ### Chat Integrations
142
+
143
+ | Variable | Default | Description |
144
+ |-----------------------|---------|----------------------------------------------------|
145
+ | `WHATSAPP_ENABLED` | `false` | Enable WhatsApp pairing/login support |
146
+ | `TELEGRAM_BOT_TOKEN` | β€” | Telegram bot token from BotFather |
147
+ | `TELEGRAM_USER_ID` | β€” | Single Telegram user ID allowlist |
148
+ | `TELEGRAM_USER_IDS` | β€” | Comma-separated Telegram user IDs for team access |
149
+
150
  ### Background Services
151
 
152
  | Variable | Default | Description |
health-server.js CHANGED
@@ -2,24 +2,15 @@
2
  const http = require("http");
3
  const fs = require("fs");
4
  const net = require("net");
5
- const { randomUUID } = require("node:crypto");
6
 
7
  const PORT = 7861;
8
  const GATEWAY_PORT = 7860;
9
  const GATEWAY_HOST = "127.0.0.1";
10
  const startTime = Date.now();
11
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
12
- const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "";
13
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
14
- const GATEWAY_STATUS_CACHE_MS = 5000;
15
-
16
- let gatewayStatusCache = {
17
- expiresAt: 0,
18
- value: {
19
- whatsapp: { configured: true, connected: false },
20
- telegram: { configured: TELEGRAM_ENABLED, connected: false },
21
- },
22
- };
23
 
24
  function parseRequestUrl(url) {
25
  try {
@@ -74,117 +65,21 @@ function normalizeChannelStatus(channel, configured) {
74
  };
75
  }
76
 
77
- function extractErrorMessage(msg) {
78
- if (!msg || typeof msg !== "object") return "Unknown error";
79
- if (typeof msg.error === "string") return msg.error;
80
- if (msg.error && typeof msg.error.message === "string") return msg.error.message;
81
- if (typeof msg.message === "string") return msg.message;
82
- return "Unknown error";
83
- }
84
-
85
- function createGatewayConnection() {
86
- return new Promise((resolve, reject) => {
87
- const { WebSocket } = require("/home/node/.openclaw/openclaw-app/node_modules/ws");
88
- const ws = new WebSocket(`ws://${GATEWAY_HOST}:${GATEWAY_PORT}`);
89
- let resolved = false;
90
-
91
- ws.on("message", (data) => {
92
- const msg = JSON.parse(data.toString());
93
-
94
- if (msg.type === "event" && msg.event === "connect.challenge") {
95
- ws.send(JSON.stringify({
96
- type: "req",
97
- id: randomUUID(),
98
- method: "connect",
99
- params: {
100
- minProtocol: 3,
101
- maxProtocol: 3,
102
- client: {
103
- id: "health-server",
104
- version: "1.0.0",
105
- platform: "linux",
106
- mode: "backend",
107
- },
108
- caps: [],
109
- auth: { token: GATEWAY_TOKEN },
110
- role: "operator",
111
- scopes: ["operator.read"],
112
- },
113
- }));
114
- return;
115
- }
116
-
117
- if (!resolved && msg.type === "res" && msg.ok === false) {
118
- resolved = true;
119
- ws.close();
120
- reject(new Error(extractErrorMessage(msg)));
121
- return;
122
- }
123
-
124
- if (!resolved && msg.type === "res" && msg.ok) {
125
- resolved = true;
126
- resolve(ws);
127
- }
128
- });
129
-
130
- ws.on("error", (error) => {
131
- if (!resolved) reject(error);
132
- });
133
-
134
- setTimeout(() => {
135
- if (!resolved) {
136
- ws.close();
137
- reject(new Error("Timeout"));
138
- }
139
- }, 10000);
140
- });
141
- }
142
-
143
- function callGatewayRpc(ws, method, params) {
144
- return new Promise((resolve, reject) => {
145
- const id = randomUUID();
146
- const handler = (data) => {
147
- const msg = JSON.parse(data.toString());
148
- if (msg.id === id) {
149
- ws.removeListener("message", handler);
150
- resolve(msg);
151
- }
152
- };
153
-
154
- ws.on("message", handler);
155
- ws.send(JSON.stringify({ type: "req", id, method, params }));
156
-
157
- setTimeout(() => {
158
- ws.removeListener("message", handler);
159
- reject(new Error("RPC Timeout"));
160
- }, 15000);
161
- });
162
- }
163
-
164
- async function getGatewayChannelStatus() {
165
- if (Date.now() < gatewayStatusCache.expiresAt) {
166
- return gatewayStatusCache.value;
167
  }
168
-
169
- let ws;
170
  try {
171
- ws = await createGatewayConnection();
172
- const statusRes = await callGatewayRpc(ws, "channels.status", {});
173
- const channels = (statusRes.payload || statusRes.result)?.channels || {};
174
- const value = {
175
- whatsapp: normalizeChannelStatus(channels.whatsapp, true),
176
- telegram: normalizeChannelStatus(channels.telegram, TELEGRAM_ENABLED),
177
- };
178
- gatewayStatusCache = {
179
- expiresAt: Date.now() + GATEWAY_STATUS_CACHE_MS,
180
- value,
181
- };
182
- return value;
183
- } catch {
184
- return gatewayStatusCache.value;
185
- } finally {
186
- if (ws) ws.close();
187
- }
188
  }
189
 
190
  function renderDashboard() {
@@ -570,13 +465,17 @@ const server = http.createServer((req, res) => {
570
 
571
  if (pathname === "/status") {
572
  void (async () => {
573
- const channelStatus = await getGatewayChannelStatus();
574
  res.writeHead(200, { "Content-Type": "application/json" });
575
  res.end(
576
  JSON.stringify({
577
  model: LLM_MODEL,
578
- whatsapp: channelStatus.whatsapp,
579
- telegram: channelStatus.telegram,
 
 
 
 
580
  sync: readSyncStatus(),
581
  uptime: uptimeHuman,
582
  }),
 
2
  const http = require("http");
3
  const fs = require("fs");
4
  const net = require("net");
 
5
 
6
  const PORT = 7861;
7
  const GATEWAY_PORT = 7860;
8
  const GATEWAY_HOST = "127.0.0.1";
9
  const startTime = Date.now();
10
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
 
11
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
12
+ const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
13
+ const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
 
 
 
 
 
 
 
14
 
15
  function parseRequestUrl(url) {
16
  try {
 
65
  };
66
  }
67
 
68
+ function readGuardianStatus() {
69
+ if (!WHATSAPP_ENABLED) {
70
+ return { configured: false, connected: false, pairing: false };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
 
 
72
  try {
73
+ if (fs.existsSync(WHATSAPP_STATUS_FILE)) {
74
+ const parsed = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8"));
75
+ return {
76
+ configured: parsed.configured !== false,
77
+ connected: parsed.connected === true,
78
+ pairing: parsed.pairing === true,
79
+ };
80
+ }
81
+ } catch {}
82
+ return { configured: true, connected: false, pairing: false };
 
 
 
 
 
 
 
83
  }
84
 
85
  function renderDashboard() {
 
465
 
466
  if (pathname === "/status") {
467
  void (async () => {
468
+ const guardianStatus = readGuardianStatus();
469
  res.writeHead(200, { "Content-Type": "application/json" });
470
  res.end(
471
  JSON.stringify({
472
  model: LLM_MODEL,
473
+ whatsapp: {
474
+ configured: guardianStatus.configured,
475
+ connected: guardianStatus.connected,
476
+ pairing: guardianStatus.pairing,
477
+ },
478
+ telegram: normalizeChannelStatus(null, TELEGRAM_ENABLED),
479
  sync: readSyncStatus(),
480
  uptime: uptimeHuman,
481
  }),
start.sh CHANGED
@@ -7,6 +7,8 @@ set -e
7
 
8
  # ── Startup Banner ──
9
  OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
 
 
10
  echo ""
11
  echo " ╔══════════════════════════════════════════╗"
12
  echo " β•‘ 🦞 HuggingClaw Gateway β•‘"
@@ -166,7 +168,7 @@ fi
166
  # ── Restore persisted WhatsApp credentials (if present) ──
167
  WA_BACKUP_DIR="/home/node/.openclaw/workspace/.huggingclaw-state/credentials/whatsapp/default"
168
  WA_CREDS_DIR="/home/node/.openclaw/credentials/whatsapp/default"
169
- if [ -d "$WA_BACKUP_DIR" ]; then
170
  WA_FILE_COUNT=$(find "$WA_BACKUP_DIR" -type f | wc -l | tr -d ' ')
171
  if [ "$WA_FILE_COUNT" -ge 2 ]; then
172
  echo "πŸ“± Restoring WhatsApp credentials..."
@@ -252,9 +254,11 @@ if [ -n "$TELEGRAM_BOT_TOKEN" ]; then
252
  fi
253
  fi
254
 
255
- # WhatsApp
256
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.whatsapp = {"enabled": true}')
257
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.channels.whatsapp = {"dmPolicy": "pairing"}')
 
 
258
 
259
  # Write config
260
  echo "$CONFIG_JSON" > "/home/node/.openclaw/openclaw.json"
@@ -311,7 +315,11 @@ printf " β”‚ %-40s β”‚\n" "Telegram: βœ… enabled"
311
  else
312
  printf " β”‚ %-40s β”‚\n" "Telegram: ❌ not configured"
313
  fi
 
314
  printf " β”‚ %-40s β”‚\n" "WhatsApp: βœ… enabled"
 
 
 
315
  if [ -n "$HF_USERNAME" ] && [ -n "$HF_TOKEN" ]; then
316
  printf " β”‚ %-40s β”‚\n" "Backup: βœ… ${HF_USERNAME}/${BACKUP_DATASET:-huggingclaw-backup}"
317
  else
@@ -408,9 +416,11 @@ if ! kill -0 $GATEWAY_PID 2>/dev/null; then
408
  fi
409
 
410
  # 12. Start WhatsApp Guardian after the gateway is accepting connections
411
- node /home/node/app/wa-guardian.js &
412
- GUARDIAN_PID=$!
413
- echo "πŸ›‘οΈ WhatsApp Guardian started (PID: $GUARDIAN_PID)"
 
 
414
 
415
  # 13. Start Workspace Sync after startup settles
416
  python3 -u /home/node/app/workspace-sync.py &
 
7
 
8
  # ── Startup Banner ──
9
  OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
10
+ WHATSAPP_ENABLED="${WHATSAPP_ENABLED:-false}"
11
+ WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '[:lower:]')
12
  echo ""
13
  echo " ╔══════════════════════════════════════════╗"
14
  echo " β•‘ 🦞 HuggingClaw Gateway β•‘"
 
168
  # ── Restore persisted WhatsApp credentials (if present) ──
169
  WA_BACKUP_DIR="/home/node/.openclaw/workspace/.huggingclaw-state/credentials/whatsapp/default"
170
  WA_CREDS_DIR="/home/node/.openclaw/credentials/whatsapp/default"
171
+ if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ] && [ -d "$WA_BACKUP_DIR" ]; then
172
  WA_FILE_COUNT=$(find "$WA_BACKUP_DIR" -type f | wc -l | tr -d ' ')
173
  if [ "$WA_FILE_COUNT" -ge 2 ]; then
174
  echo "πŸ“± Restoring WhatsApp credentials..."
 
254
  fi
255
  fi
256
 
257
+ # WhatsApp (optional)
258
+ if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
259
+ CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.whatsapp = {"enabled": true}')
260
+ CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.channels.whatsapp = {"dmPolicy": "pairing"}')
261
+ fi
262
 
263
  # Write config
264
  echo "$CONFIG_JSON" > "/home/node/.openclaw/openclaw.json"
 
315
  else
316
  printf " β”‚ %-40s β”‚\n" "Telegram: ❌ not configured"
317
  fi
318
+ if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
319
  printf " β”‚ %-40s β”‚\n" "WhatsApp: βœ… enabled"
320
+ else
321
+ printf " β”‚ %-40s β”‚\n" "WhatsApp: ❌ disabled"
322
+ fi
323
  if [ -n "$HF_USERNAME" ] && [ -n "$HF_TOKEN" ]; then
324
  printf " β”‚ %-40s β”‚\n" "Backup: βœ… ${HF_USERNAME}/${BACKUP_DATASET:-huggingclaw-backup}"
325
  else
 
416
  fi
417
 
418
  # 12. Start WhatsApp Guardian after the gateway is accepting connections
419
+ if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
420
+ node /home/node/app/wa-guardian.js &
421
+ GUARDIAN_PID=$!
422
+ echo "πŸ›‘οΈ WhatsApp Guardian started (PID: $GUARDIAN_PID)"
423
+ fi
424
 
425
  # 13. Start Workspace Sync after startup settles
426
  python3 -u /home/node/app/workspace-sync.py &
wa-guardian.js CHANGED
@@ -9,13 +9,12 @@
9
 
10
  const fs = require("fs");
11
  const path = require("path");
12
- const {
13
- WebSocket,
14
- } = require("/home/node/.openclaw/openclaw-app/node_modules/ws");
15
- const { randomUUID } = require("node:crypto");
16
 
17
  const GATEWAY_URL = "ws://127.0.0.1:7860";
18
  const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
 
19
  const CHECK_INTERVAL = 5000;
20
  const WAIT_TIMEOUT = 120000;
21
  const POST_515_NO_LOGOUT_MS = 90 * 1000;
@@ -26,6 +25,7 @@ const RESET_MARKER_PATH = path.join(
26
  "workspace",
27
  ".reset_credentials",
28
  );
 
29
 
30
  let isWaiting = false;
31
  let hasShownWaitMessage = false;
@@ -36,8 +36,7 @@ let shouldStop = false;
36
  function extractErrorMessage(msg) {
37
  if (!msg || typeof msg !== "object") return "Unknown error";
38
  if (typeof msg.error === "string") return msg.error;
39
- if (msg.error && typeof msg.error.message === "string")
40
- return msg.error.message;
41
  if (typeof msg.message === "string") return msg.message;
42
  return "Unknown error";
43
  }
@@ -46,13 +45,28 @@ function writeResetMarker() {
46
  try {
47
  fs.mkdirSync(path.dirname(RESET_MARKER_PATH), { recursive: true });
48
  fs.writeFileSync(RESET_MARKER_PATH, "reset\n");
49
- console.log(
50
- `[guardian] Created backup reset marker at ${RESET_MARKER_PATH}`,
51
- );
52
  } catch (error) {
53
- console.log(
54
- `[guardian] Failed to write backup reset marker: ${error.message}`,
55
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
  }
58
 
@@ -65,32 +79,25 @@ async function createConnection() {
65
  const msg = JSON.parse(data.toString());
66
 
67
  if (msg.type === "event" && msg.event === "connect.challenge") {
68
- ws.send(
69
- JSON.stringify({
70
- type: "req",
71
- id: randomUUID(),
72
- method: "connect",
73
- params: {
74
- minProtocol: 3,
75
- maxProtocol: 3,
76
- client: {
77
- id: "gateway-client",
78
- version: "1.0.0",
79
- platform: "linux",
80
- mode: "backend",
81
- },
82
- caps: [],
83
- auth: { token: GATEWAY_TOKEN },
84
- role: "operator",
85
- scopes: [
86
- "operator.read",
87
- "operator.write",
88
- "operator.admin",
89
- "operator.pairing",
90
- ],
91
  },
92
- }),
93
- );
 
 
 
 
94
  return;
95
  }
96
 
@@ -107,15 +114,8 @@ async function createConnection() {
107
  }
108
  });
109
 
110
- ws.on("error", (e) => {
111
- if (!resolved) reject(e);
112
- });
113
- setTimeout(() => {
114
- if (!resolved) {
115
- ws.close();
116
- reject(new Error("Timeout"));
117
- }
118
- }, 10000);
119
  });
120
  }
121
 
@@ -135,18 +135,14 @@ async function callRpc(ws, method, params) {
135
  };
136
  ws.on("message", handler);
137
  ws.send(JSON.stringify({ type: "req", id, method, params }));
138
- setTimeout(() => {
139
- ws.removeListener("message", handler);
140
- reject(new Error("RPC Timeout"));
141
- }, WAIT_TIMEOUT + 5000);
142
  });
143
  }
144
 
145
  async function checkStatus() {
146
  if (shouldStop) return;
147
  if (isWaiting) return;
148
- if (lastConnectedAt && Date.now() - lastConnectedAt < SUCCESS_COOLDOWN_MS)
149
- return;
150
 
151
  let ws;
152
  try {
@@ -158,27 +154,28 @@ async function checkStatus() {
158
 
159
  if (!wa) {
160
  hasShownWaitMessage = false;
 
161
  return;
162
  }
163
 
164
  if (wa.connected) {
165
  hasShownWaitMessage = false;
166
  lastConnectedAt = Date.now();
 
 
 
167
  return;
168
  }
169
 
170
  isWaiting = true;
 
171
  if (!hasShownWaitMessage) {
172
- console.log(
173
- "\n[guardian] πŸ“± WhatsApp pairing in progress. Please scan the QR code in the Control UI.",
174
- );
175
  hasShownWaitMessage = true;
176
  }
177
 
178
  console.log("[guardian] Waiting for pairing completion...");
179
- const waitRes = await callRpc(ws, "web.login.wait", {
180
- timeoutMs: WAIT_TIMEOUT,
181
- });
182
  const result = waitRes.payload || waitRes.result;
183
  const message = result?.message || "";
184
  const linkedAfter515 = !result?.connected && message.includes("515");
@@ -190,41 +187,33 @@ async function checkStatus() {
190
  if (result && (result.connected || linkedAfter515)) {
191
  hasShownWaitMessage = false;
192
  lastConnectedAt = Date.now();
 
193
 
194
  if (linkedAfter515) {
195
- console.log(
196
- "[guardian] 515 after scan: credentials saved, reloading config to start WhatsApp...",
197
- );
198
  } else {
199
  console.log("[guardian] βœ… Pairing completed! Reloading config...");
200
  }
201
 
202
  const getRes = await callRpc(ws, "config.get", {});
203
  if (getRes.payload?.raw && getRes.payload?.hash) {
204
- await callRpc(ws, "config.apply", {
205
- raw: getRes.payload.raw,
206
- baseHash: getRes.payload.hash,
207
- });
208
  console.log("[guardian] Configuration re-applied.");
209
  }
210
 
211
  shouldStop = true;
212
  setTimeout(() => process.exit(0), 1000);
213
- } else if (
214
- !message.includes("No active") &&
215
- !message.includes("Still waiting")
216
- ) {
217
  console.log(`[guardian] Wait result: ${message}`);
218
  }
 
219
  } catch (e) {
220
  const message = e && e.message ? e.message : "";
221
  if (
222
  /401|unauthorized|logged out|440|conflict/i.test(message) &&
223
  Date.now() - last515At >= POST_515_NO_LOGOUT_MS
224
  ) {
225
- console.log(
226
- "[guardian] Clearing invalid WhatsApp session so a fresh QR can be used...",
227
- );
228
  try {
229
  if (ws) {
230
  await callRpc(ws, "channels.logout", { channel: "whatsapp" });
@@ -233,11 +222,12 @@ async function checkStatus() {
233
  console.log("[guardian] Logged out invalid WhatsApp session.");
234
  }
235
  } catch (error) {
236
- console.log(
237
- `[guardian] Failed to log out invalid session: ${error.message}`,
238
- );
239
  }
240
  }
 
 
 
241
  // Normal timeout or gateway starting up; retry on the next interval.
242
  } finally {
243
  isWaiting = false;
@@ -245,8 +235,12 @@ async function checkStatus() {
245
  }
246
  }
247
 
248
- console.log(
249
- "[guardian] βš”οΈ WhatsApp Guardian active. Monitoring pairing status...",
250
- );
 
 
 
 
251
  setInterval(checkStatus, CHECK_INTERVAL);
252
  setTimeout(checkStatus, 15000);
 
9
 
10
  const fs = require("fs");
11
  const path = require("path");
12
+ const { WebSocket } = require('/home/node/.openclaw/openclaw-app/node_modules/ws');
13
+ const { randomUUID } = require('node:crypto');
 
 
14
 
15
  const GATEWAY_URL = "ws://127.0.0.1:7860";
16
  const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
17
+ const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
18
  const CHECK_INTERVAL = 5000;
19
  const WAIT_TIMEOUT = 120000;
20
  const POST_515_NO_LOGOUT_MS = 90 * 1000;
 
25
  "workspace",
26
  ".reset_credentials",
27
  );
28
+ const STATUS_FILE_PATH = "/tmp/huggingclaw-wa-status.json";
29
 
30
  let isWaiting = false;
31
  let hasShownWaitMessage = false;
 
36
  function extractErrorMessage(msg) {
37
  if (!msg || typeof msg !== "object") return "Unknown error";
38
  if (typeof msg.error === "string") return msg.error;
39
+ if (msg.error && typeof msg.error.message === "string") return msg.error.message;
 
40
  if (typeof msg.message === "string") return msg.message;
41
  return "Unknown error";
42
  }
 
45
  try {
46
  fs.mkdirSync(path.dirname(RESET_MARKER_PATH), { recursive: true });
47
  fs.writeFileSync(RESET_MARKER_PATH, "reset\n");
48
+ console.log(`[guardian] Created backup reset marker at ${RESET_MARKER_PATH}`);
 
 
49
  } catch (error) {
50
+ console.log(`[guardian] Failed to write backup reset marker: ${error.message}`);
51
+ }
52
+ }
53
+
54
+ function writeStatus(partial) {
55
+ try {
56
+ const current = fs.existsSync(STATUS_FILE_PATH)
57
+ ? JSON.parse(fs.readFileSync(STATUS_FILE_PATH, "utf8"))
58
+ : {};
59
+ const next = {
60
+ configured: true,
61
+ connected: false,
62
+ pairing: false,
63
+ updatedAt: new Date().toISOString(),
64
+ ...current,
65
+ ...partial,
66
+ };
67
+ fs.writeFileSync(STATUS_FILE_PATH, JSON.stringify(next));
68
+ } catch (error) {
69
+ console.log(`[guardian] Failed to write status file: ${error.message}`);
70
  }
71
  }
72
 
 
79
  const msg = JSON.parse(data.toString());
80
 
81
  if (msg.type === "event" && msg.event === "connect.challenge") {
82
+ ws.send(JSON.stringify({
83
+ type: "req",
84
+ id: randomUUID(),
85
+ method: "connect",
86
+ params: {
87
+ minProtocol: 3,
88
+ maxProtocol: 3,
89
+ client: {
90
+ id: "gateway-client",
91
+ version: "1.0.0",
92
+ platform: "linux",
93
+ mode: "backend",
 
 
 
 
 
 
 
 
 
 
 
94
  },
95
+ caps: [],
96
+ auth: { token: GATEWAY_TOKEN },
97
+ role: "operator",
98
+ scopes: ["operator.read", "operator.write", "operator.admin", "operator.pairing"],
99
+ },
100
+ }));
101
  return;
102
  }
103
 
 
114
  }
115
  });
116
 
117
+ ws.on("error", (e) => { if (!resolved) reject(e); });
118
+ setTimeout(() => { if (!resolved) { ws.close(); reject(new Error("Timeout")); } }, 10000);
 
 
 
 
 
 
 
119
  });
120
  }
121
 
 
135
  };
136
  ws.on("message", handler);
137
  ws.send(JSON.stringify({ type: "req", id, method, params }));
138
+ setTimeout(() => { ws.removeListener("message", handler); reject(new Error("RPC Timeout")); }, WAIT_TIMEOUT + 5000);
 
 
 
139
  });
140
  }
141
 
142
  async function checkStatus() {
143
  if (shouldStop) return;
144
  if (isWaiting) return;
145
+ if (lastConnectedAt && Date.now() - lastConnectedAt < SUCCESS_COOLDOWN_MS) return;
 
146
 
147
  let ws;
148
  try {
 
154
 
155
  if (!wa) {
156
  hasShownWaitMessage = false;
157
+ writeStatus({ configured: true, connected: false, pairing: false });
158
  return;
159
  }
160
 
161
  if (wa.connected) {
162
  hasShownWaitMessage = false;
163
  lastConnectedAt = Date.now();
164
+ writeStatus({ configured: true, connected: true, pairing: false });
165
+ shouldStop = true;
166
+ setTimeout(() => process.exit(0), 1000);
167
  return;
168
  }
169
 
170
  isWaiting = true;
171
+ writeStatus({ configured: true, connected: false, pairing: true });
172
  if (!hasShownWaitMessage) {
173
+ console.log("\n[guardian] πŸ“± WhatsApp pairing in progress. Please scan the QR code in the Control UI.");
 
 
174
  hasShownWaitMessage = true;
175
  }
176
 
177
  console.log("[guardian] Waiting for pairing completion...");
178
+ const waitRes = await callRpc(ws, "web.login.wait", { timeoutMs: WAIT_TIMEOUT });
 
 
179
  const result = waitRes.payload || waitRes.result;
180
  const message = result?.message || "";
181
  const linkedAfter515 = !result?.connected && message.includes("515");
 
187
  if (result && (result.connected || linkedAfter515)) {
188
  hasShownWaitMessage = false;
189
  lastConnectedAt = Date.now();
190
+ writeStatus({ configured: true, connected: true, pairing: false });
191
 
192
  if (linkedAfter515) {
193
+ console.log("[guardian] 515 after scan: credentials saved, reloading config to start WhatsApp...");
 
 
194
  } else {
195
  console.log("[guardian] βœ… Pairing completed! Reloading config...");
196
  }
197
 
198
  const getRes = await callRpc(ws, "config.get", {});
199
  if (getRes.payload?.raw && getRes.payload?.hash) {
200
+ await callRpc(ws, "config.apply", { raw: getRes.payload.raw, baseHash: getRes.payload.hash });
 
 
 
201
  console.log("[guardian] Configuration re-applied.");
202
  }
203
 
204
  shouldStop = true;
205
  setTimeout(() => process.exit(0), 1000);
206
+ } else if (!message.includes("No active") && !message.includes("Still waiting")) {
 
 
 
207
  console.log(`[guardian] Wait result: ${message}`);
208
  }
209
+
210
  } catch (e) {
211
  const message = e && e.message ? e.message : "";
212
  if (
213
  /401|unauthorized|logged out|440|conflict/i.test(message) &&
214
  Date.now() - last515At >= POST_515_NO_LOGOUT_MS
215
  ) {
216
+ console.log("[guardian] Clearing invalid WhatsApp session so a fresh QR can be used...");
 
 
217
  try {
218
  if (ws) {
219
  await callRpc(ws, "channels.logout", { channel: "whatsapp" });
 
222
  console.log("[guardian] Logged out invalid WhatsApp session.");
223
  }
224
  } catch (error) {
225
+ console.log(`[guardian] Failed to log out invalid session: ${error.message}`);
 
 
226
  }
227
  }
228
+ if (!/RPC Timeout/i.test(message)) {
229
+ writeStatus({ configured: true, connected: false, pairing: false });
230
+ }
231
  // Normal timeout or gateway starting up; retry on the next interval.
232
  } finally {
233
  isWaiting = false;
 
235
  }
236
  }
237
 
238
+ if (!WHATSAPP_ENABLED) {
239
+ writeStatus({ configured: false, connected: false, pairing: false });
240
+ process.exit(0);
241
+ }
242
+
243
+ writeStatus({ configured: true, connected: false, pairing: false });
244
+ console.log("[guardian] βš”οΈ WhatsApp Guardian active. Monitoring pairing status...");
245
  setInterval(checkStatus, CHECK_INTERVAL);
246
  setTimeout(checkStatus, 15000);
workspace-sync.py CHANGED
@@ -26,6 +26,7 @@ HF_TOKEN = os.environ.get("HF_TOKEN", "")
26
  HF_USERNAME = os.environ.get("HF_USERNAME", "")
27
  BACKUP_DATASET = os.environ.get("BACKUP_DATASET_NAME", "huggingclaw-backup")
28
  WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "")
 
29
 
30
  running = True
31
 
@@ -52,6 +53,9 @@ def snapshot_state_into_workspace() -> None:
52
  with the workspace, without changing the live credentials location.
53
  """
54
  try:
 
 
 
55
  STATE_DIR.mkdir(parents=True, exist_ok=True)
56
 
57
  if RESET_MARKER.exists():
 
26
  HF_USERNAME = os.environ.get("HF_USERNAME", "")
27
  BACKUP_DATASET = os.environ.get("BACKUP_DATASET_NAME", "huggingclaw-backup")
28
  WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "")
29
+ WHATSAPP_ENABLED = os.environ.get("WHATSAPP_ENABLED", "").strip().lower() == "true"
30
 
31
  running = True
32
 
 
53
  with the workspace, without changing the live credentials location.
54
  """
55
  try:
56
+ if not WHATSAPP_ENABLED:
57
+ return
58
+
59
  STATE_DIR.mkdir(parents=True, exist_ok=True)
60
 
61
  if RESET_MARKER.exists():