somratpro commited on
Commit
969345a
Β·
1 Parent(s): 544bf0f

feat: implement persistent WhatsApp credential syncing and automatic session recovery in guardian and health-server

Browse files
Files changed (5) hide show
  1. README.md +7 -4
  2. health-server.js +22 -7
  3. start.sh +19 -2
  4. wa-guardian.js +67 -5
  5. workspace-sync.py +50 -0
README.md CHANGED
@@ -44,12 +44,12 @@ license: mit
44
  - πŸ”Œ **Any LLM:** Use Claude, OpenAI GPT, Google Gemini, Grok, DeepSeek, Qwen, and 40+ providers (set `LLM_API_KEY` and `LLM_MODEL` accordingly).
45
  - ⚑ **Zero Config:** Duplicate this Space and set **just three** secrets (LLM_API_KEY, LLM_MODEL, GATEWAY_TOKEN) – no other setup needed.
46
  - 🐳 **Fast Builds:** Uses a pre-built OpenClaw Docker image to deploy in minutes.
47
- - πŸ’Ύ **Workspace Backup:** Chats and settings sync to a private HF Dataset via the `huggingface_hub` (Git fallback), preserving data automatically.
48
  - πŸ’“ **Always-On:** Built-in keep-alive pings prevent the HF Space from sleeping, so the assistant is always online.
49
  - πŸ‘₯ **Multi-User Messaging:** Support for Telegram (multi-user) and WhatsApp (pairing).
50
  - πŸ“Š **Visual Dashboard:** Beautiful Web UI to monitor uptime, sync status, and active models.
51
  - πŸ”” **Webhooks:** Get notified on restarts or backup failures via standard webhooks.
52
- - πŸ” **Flexible Auth:** Secure the Control UI with either a gateway token or password.
53
  - 🏠 **100% HF-Native:** Runs entirely on HuggingFace’s free infrastructure (2 vCPU, 16GB RAM).
54
 
55
  ## πŸŽ₯ Video Tutorial
@@ -102,6 +102,8 @@ To use WhatsApp:
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
 
107
  For persistent chat history and configuration:
@@ -109,7 +111,7 @@ For persistent chat history and configuration:
109
  - Set `HF_USERNAME` to your HuggingFace username.
110
  - Set `HF_TOKEN` to a HuggingFace token with write access.
111
 
112
- Optionally set `BACKUP_DATASET_NAME` (default: `huggingclaw-backup`) to choose the HF Dataset name. On first run, HuggingClaw will create (or use) the private Dataset repo `HF_USERNAME/SPACE-backup`, then restore your workspace on startup and sync changes every 10 minutes. The workspace is also saved on graceful shutdown. This ensures your data survives restarts.
113
 
114
  ## πŸ“Š Dashboard & Monitoring
115
 
@@ -289,7 +291,8 @@ HuggingClaw keeps the Space awake without external cron tools:
289
  - **Backup restore failing:** Make sure `HF_USERNAME` and `HF_TOKEN` are correct (token needs write access to your Dataset).
290
  - **Space keeps sleeping:** Check logs for `Keep-alive` messages. Ensure `KEEP_ALIVE_INTERVAL` isn’t set to `0`.
291
  - **Auth errors / proxy:** If you see reverse-proxy auth errors, add the logged IPs under `TRUSTED_PROXIES` (from logs `remote=x.x.x.x`).
292
- - **Control UI says too many failed authentication attempts:** Wait for the retry window to expire, then open the Space in an incognito window or clear site storage for your Space before entering the current `GATEWAY_TOKEN` again.
 
293
  - **UI blocked (CORS):** Set `ALLOWED_ORIGINS=https://your-space-name.hf.space`.
294
  - **Version mismatches:** Pin a specific OpenClaw build with the `OPENCLAW_VERSION` Variable in HF Spaces, or `--build-arg OPENCLAW_VERSION=...` locally.
295
 
 
44
  - πŸ”Œ **Any LLM:** Use Claude, OpenAI GPT, Google Gemini, Grok, DeepSeek, Qwen, and 40+ providers (set `LLM_API_KEY` and `LLM_MODEL` accordingly).
45
  - ⚑ **Zero Config:** Duplicate this Space and set **just three** secrets (LLM_API_KEY, LLM_MODEL, GATEWAY_TOKEN) – no other setup needed.
46
  - 🐳 **Fast Builds:** Uses a pre-built OpenClaw Docker image to deploy in minutes.
47
+ - πŸ’Ύ **Workspace Backup:** Chats, settings, and WhatsApp session state sync to a private HF Dataset via the `huggingface_hub` (Git fallback), preserving data automatically.
48
  - πŸ’“ **Always-On:** Built-in keep-alive pings prevent the HF Space from sleeping, so the assistant is always online.
49
  - πŸ‘₯ **Multi-User Messaging:** Support for Telegram (multi-user) and WhatsApp (pairing).
50
  - πŸ“Š **Visual Dashboard:** Beautiful Web UI to monitor uptime, sync status, and active models.
51
  - πŸ”” **Webhooks:** Get notified on restarts or backup failures via standard webhooks.
52
+ - πŸ” **Flexible Auth:** Secure the Control UI with either a gateway token or password, with automatic token redirect for the web UI.
53
  - 🏠 **100% HF-Native:** Runs entirely on HuggingFace’s free infrastructure (2 vCPU, 16GB RAM).
54
 
55
  ## πŸŽ₯ Video Tutorial
 
102
  2. In the Control UI, go to **Channels** β†’ **WhatsApp** β†’ **Login**.
103
  3. Scan the QR code with your phone. πŸ“±
104
 
105
+ The Control UI now redirects to `/?token=...` automatically, so you usually won't need to paste `GATEWAY_TOKEN` manually in the browser.
106
+
107
  ## πŸ’Ύ Workspace Backup *(Optional)*
108
 
109
  For persistent chat history and configuration:
 
111
  - Set `HF_USERNAME` to your HuggingFace username.
112
  - Set `HF_TOKEN` to a HuggingFace token with write access.
113
 
114
+ Optionally set `BACKUP_DATASET_NAME` (default: `huggingclaw-backup`) to choose the HF Dataset name. On first run, HuggingClaw will create (or use) the private Dataset repo `HF_USERNAME/SPACE-backup`, then restore your workspace on startup and sync changes every 10 minutes. The workspace is also saved on graceful shutdown. If you use WhatsApp, HuggingClaw also stores a hidden backup of the WhatsApp session credentials so paired logins can survive restarts.
115
 
116
  ## πŸ“Š Dashboard & Monitoring
117
 
 
291
  - **Backup restore failing:** Make sure `HF_USERNAME` and `HF_TOKEN` are correct (token needs write access to your Dataset).
292
  - **Space keeps sleeping:** Check logs for `Keep-alive` messages. Ensure `KEEP_ALIVE_INTERVAL` isn’t set to `0`.
293
  - **Auth errors / proxy:** If you see reverse-proxy auth errors, add the logged IPs under `TRUSTED_PROXIES` (from logs `remote=x.x.x.x`).
294
+ - **Control UI says too many failed authentication attempts:** Wait for the retry window to expire, then open the Space in an incognito window or clear site storage for your Space. The root UI now auto-injects the current `GATEWAY_TOKEN`, so a fresh browser session usually fixes stale-token lockouts.
295
+ - **WhatsApp lost its session after restart:** Make sure `HF_USERNAME` and `HF_TOKEN` are configured so the hidden session backup can be restored on boot.
296
  - **UI blocked (CORS):** Set `ALLOWED_ORIGINS=https://your-space-name.hf.space`.
297
  - **Version mismatches:** Pin a specific OpenClaw build with the `OPENCLAW_VERSION` Variable in HF Spaces, or `--build-arg OPENCLAW_VERSION=...` locally.
298
 
health-server.js CHANGED
@@ -21,11 +21,11 @@ let gatewayStatusCache = {
21
  },
22
  };
23
 
24
- function getPathname(url) {
25
  try {
26
- return new URL(url, "http://localhost").pathname;
27
  } catch {
28
- return "/";
29
  }
30
  }
31
 
@@ -33,11 +33,16 @@ function isDashboardRoute(pathname) {
33
  return pathname === "/dashboard" || pathname === "/dashboard/";
34
  }
35
 
 
 
 
 
36
  function isLocalRoute(pathname) {
37
  return (
38
  pathname === "/health" ||
39
  pathname === "/status" ||
40
- isDashboardRoute(pathname)
 
41
  );
42
  }
43
 
@@ -382,7 +387,7 @@ function renderDashboard() {
382
  <span class="stat-label">Telegram</span>
383
  <span id="tg-status">Loading...</span>
384
  </div>
385
- <a href="/" class="stat-btn">Open Control UI</a>
386
  </div>
387
 
388
  <div class="stat-card" style="width: 100%;">
@@ -545,7 +550,8 @@ function proxyUpgrade(req, socket, head) {
545
  }
546
 
547
  const server = http.createServer((req, res) => {
548
- const pathname = getPathname(req.url || "/");
 
549
  const uptime = Math.floor((Date.now() - startTime) / 1000);
550
  const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`;
551
 
@@ -585,11 +591,20 @@ const server = http.createServer((req, res) => {
585
  return;
586
  }
587
 
 
 
 
 
 
 
 
 
 
588
  proxyHttp(req, res);
589
  });
590
 
591
  server.on("upgrade", (req, socket, head) => {
592
- const pathname = getPathname(req.url || "/");
593
  if (isLocalRoute(pathname)) {
594
  socket.destroy();
595
  return;
 
21
  },
22
  };
23
 
24
+ function parseRequestUrl(url) {
25
  try {
26
+ return new URL(url, "http://localhost");
27
  } catch {
28
+ return new URL("http://localhost/");
29
  }
30
  }
31
 
 
33
  return pathname === "/dashboard" || pathname === "/dashboard/";
34
  }
35
 
36
+ function isControlRoute(pathname) {
37
+ return pathname === "/control" || pathname === "/control/";
38
+ }
39
+
40
  function isLocalRoute(pathname) {
41
  return (
42
  pathname === "/health" ||
43
  pathname === "/status" ||
44
+ isDashboardRoute(pathname) ||
45
+ isControlRoute(pathname)
46
  );
47
  }
48
 
 
387
  <span class="stat-label">Telegram</span>
388
  <span id="tg-status">Loading...</span>
389
  </div>
390
+ <a href="/control" class="stat-btn">Open Control UI</a>
391
  </div>
392
 
393
  <div class="stat-card" style="width: 100%;">
 
550
  }
551
 
552
  const server = http.createServer((req, res) => {
553
+ const parsedUrl = parseRequestUrl(req.url || "/");
554
+ const pathname = parsedUrl.pathname;
555
  const uptime = Math.floor((Date.now() - startTime) / 1000);
556
  const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`;
557
 
 
591
  return;
592
  }
593
 
594
+ if ((pathname === "/" || isControlRoute(pathname)) && req.method === "GET" && !parsedUrl.searchParams.get("token") && GATEWAY_TOKEN) {
595
+ res.writeHead(302, {
596
+ Location: `/?token=${encodeURIComponent(GATEWAY_TOKEN)}`,
597
+ "Cache-Control": "no-store",
598
+ });
599
+ res.end();
600
+ return;
601
+ }
602
+
603
  proxyHttp(req, res);
604
  });
605
 
606
  server.on("upgrade", (req, socket, head) => {
607
+ const pathname = parseRequestUrl(req.url || "/").pathname;
608
  if (isLocalRoute(pathname)) {
609
  socket.destroy();
610
  return;
start.sh CHANGED
@@ -163,6 +163,23 @@ if [ -n "$HF_USERNAME" ] && [ -n "$HF_TOKEN" ]; then
163
  cd /
164
  fi
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  # ── Build config ──
167
  CONFIG_JSON=$(cat <<'CONFIGEOF'
168
  {
@@ -243,8 +260,8 @@ CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.channels.whatsapp = {"dmPolicy": "pairi
243
  echo "$CONFIG_JSON" > "/home/node/.openclaw/openclaw.json"
244
  chmod 600 /home/node/.openclaw/openclaw.json
245
 
246
- # ── Enable Iframe Fix (Security: No Token Redirect) ──
247
- # This Node.js preload script strips X-Frame-Options to allow HF Space embedding
248
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/app/iframe-fix.cjs"
249
 
250
  # ── Startup Summary ──
 
163
  cd /
164
  fi
165
 
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..."
173
+ rm -rf "$WA_CREDS_DIR"
174
+ mkdir -p "$(dirname "$WA_CREDS_DIR")"
175
+ cp -R "$WA_BACKUP_DIR" "$WA_CREDS_DIR"
176
+ chmod -R go-rwx /home/node/.openclaw/credentials/whatsapp 2>/dev/null || true
177
+ echo " βœ… WhatsApp credentials restored"
178
+ else
179
+ echo " ⚠️ Saved WhatsApp credentials look incomplete (${WA_FILE_COUNT} files), skipping restore."
180
+ fi
181
+ fi
182
+
183
  # ── Build config ──
184
  CONFIG_JSON=$(cat <<'CONFIGEOF'
185
  {
 
260
  echo "$CONFIG_JSON" > "/home/node/.openclaw/openclaw.json"
261
  chmod 600 /home/node/.openclaw/openclaw.json
262
 
263
+ # ── Enable Gateway Preload Fixes ──
264
+ # This preload script keeps iframe embedding working on HF Spaces.
265
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/app/iframe-fix.cjs"
266
 
267
  # ── Startup Summary ──
wa-guardian.js CHANGED
@@ -7,6 +7,8 @@
7
  */
8
  "use strict";
9
 
 
 
10
  const { WebSocket } = require('/home/node/.openclaw/openclaw-app/node_modules/ws');
11
  const { randomUUID } = require('node:crypto');
12
 
@@ -15,11 +17,19 @@ const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
15
  const CHECK_INTERVAL = 5000;
16
  const WAIT_TIMEOUT = 120000;
17
  const AUTH_FAILURE_COOLDOWN = 5 * 60 * 1000;
 
 
 
 
 
 
 
18
 
19
  let isWaiting = false;
20
  let hasShownWaitMessage = false;
21
  let authFailureUntil = 0;
22
  let authFailureLogged = false;
 
23
 
24
  function extractErrorMessage(msg) {
25
  if (!msg || typeof msg !== "object") return "Unknown error";
@@ -29,6 +39,16 @@ function extractErrorMessage(msg) {
29
  return "Unknown error";
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
32
  async function createConnection() {
33
  return new Promise((resolve, reject) => {
34
  const ws = new WebSocket(GATEWAY_URL);
@@ -76,6 +96,10 @@ async function callRpc(ws, method, params) {
76
  const msg = JSON.parse(data.toString());
77
  if (msg.id === id) {
78
  ws.removeListener("message", handler);
 
 
 
 
79
  resolve(msg);
80
  }
81
  };
@@ -105,8 +129,34 @@ async function checkStatus() {
105
  return;
106
  }
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  // If connected, we are good
109
  if (wa.connected) {
 
110
  ws.close();
111
  return;
112
  }
@@ -121,17 +171,29 @@ async function checkStatus() {
121
  console.log("[guardian] Waiting for pairing completion...");
122
  const waitRes = await callRpc(ws, "web.login.wait", { timeoutMs: WAIT_TIMEOUT });
123
  const result = waitRes.payload || waitRes.result;
 
 
 
 
 
 
124
 
125
- if (result && (result.connected || (result.message && result.message.includes("515")))) {
126
- console.log("[guardian] βœ… Pairing completed! Saving session and restarting gateway...");
127
  hasShownWaitMessage = false;
128
-
129
- // Auto-reapply config to finalize pairing
 
 
 
 
 
130
  const getRes = await callRpc(ws, "config.get", {});
131
- if (getRes.ok) {
132
  await callRpc(ws, "config.apply", { raw: getRes.payload.raw, baseHash: getRes.payload.hash });
133
  console.log("[guardian] Configuration re-applied.");
134
  }
 
 
135
  }
136
 
137
  } catch (e) {
 
7
  */
8
  "use strict";
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
 
 
17
  const CHECK_INTERVAL = 5000;
18
  const WAIT_TIMEOUT = 120000;
19
  const AUTH_FAILURE_COOLDOWN = 5 * 60 * 1000;
20
+ const POST_515_NO_LOGOUT_MS = 90 * 1000;
21
+ const RESET_MARKER_PATH = path.join(
22
+ process.env.HOME || "/home/node",
23
+ ".openclaw",
24
+ "workspace",
25
+ ".reset_credentials",
26
+ );
27
 
28
  let isWaiting = false;
29
  let hasShownWaitMessage = false;
30
  let authFailureUntil = 0;
31
  let authFailureLogged = false;
32
+ let last515At = 0;
33
 
34
  function extractErrorMessage(msg) {
35
  if (!msg || typeof msg !== "object") return "Unknown error";
 
39
  return "Unknown error";
40
  }
41
 
42
+ function writeResetMarker() {
43
+ try {
44
+ fs.mkdirSync(path.dirname(RESET_MARKER_PATH), { recursive: true });
45
+ fs.writeFileSync(RESET_MARKER_PATH, "reset\n");
46
+ console.log(`[guardian] Created backup reset marker at ${RESET_MARKER_PATH}`);
47
+ } catch (error) {
48
+ console.log(`[guardian] Failed to write backup reset marker: ${error.message}`);
49
+ }
50
+ }
51
+
52
  async function createConnection() {
53
  return new Promise((resolve, reject) => {
54
  const ws = new WebSocket(GATEWAY_URL);
 
96
  const msg = JSON.parse(data.toString());
97
  if (msg.id === id) {
98
  ws.removeListener("message", handler);
99
+ if (msg.ok === false) {
100
+ reject(new Error(extractErrorMessage(msg)));
101
+ return;
102
+ }
103
  resolve(msg);
104
  }
105
  };
 
129
  return;
130
  }
131
 
132
+ const lastError = String(wa.lastError || "").toLowerCase();
133
+ const recentlySaw515 = Date.now() - last515At < POST_515_NO_LOGOUT_MS;
134
+ const needsLogout = wa.linked && !wa.connected && !recentlySaw515 &&
135
+ (
136
+ lastError.includes("401") ||
137
+ lastError.includes("unauthorized") ||
138
+ lastError.includes("logged out") ||
139
+ lastError.includes("440") ||
140
+ lastError.includes("conflict")
141
+ );
142
+
143
+ if (needsLogout) {
144
+ console.log("[guardian] Clearing invalid WhatsApp session so a fresh QR can be used...");
145
+ try {
146
+ await callRpc(ws, "channels.logout", { channel: "whatsapp" });
147
+ writeResetMarker();
148
+ hasShownWaitMessage = false;
149
+ console.log("[guardian] Logged out invalid WhatsApp session.");
150
+ } catch (error) {
151
+ console.log(`[guardian] Failed to log out invalid session: ${error.message}`);
152
+ }
153
+ ws.close();
154
+ return;
155
+ }
156
+
157
  // If connected, we are good
158
  if (wa.connected) {
159
+ hasShownWaitMessage = false;
160
  ws.close();
161
  return;
162
  }
 
171
  console.log("[guardian] Waiting for pairing completion...");
172
  const waitRes = await callRpc(ws, "web.login.wait", { timeoutMs: WAIT_TIMEOUT });
173
  const result = waitRes.payload || waitRes.result;
174
+ const message = result?.message || "";
175
+ const linkedAfter515 = !result?.connected && message.includes("515");
176
+
177
+ if (linkedAfter515) {
178
+ last515At = Date.now();
179
+ }
180
 
181
+ if (result && (result.connected || linkedAfter515)) {
 
182
  hasShownWaitMessage = false;
183
+
184
+ if (linkedAfter515) {
185
+ console.log("[guardian] 515 after scan: credentials saved, reloading config to start WhatsApp...");
186
+ } else {
187
+ console.log("[guardian] βœ… Pairing completed! Reloading config...");
188
+ }
189
+
190
  const getRes = await callRpc(ws, "config.get", {});
191
+ if (getRes.payload?.raw && getRes.payload?.hash) {
192
  await callRpc(ws, "config.apply", { raw: getRes.payload.raw, baseHash: getRes.payload.hash });
193
  console.log("[guardian] Configuration re-applied.");
194
  }
195
+ } else if (!message.includes("No active") && !message.includes("Still waiting")) {
196
+ console.log(`[guardian] Wait result: ${message}`);
197
  }
198
 
199
  } catch (e) {
workspace-sync.py CHANGED
@@ -11,10 +11,15 @@ import os
11
  import sys
12
  import time
13
  import signal
 
14
  import subprocess
15
  from pathlib import Path
16
 
17
  WORKSPACE = Path("/home/node/.openclaw/workspace")
 
 
 
 
18
  INTERVAL = int(os.environ.get("SYNC_INTERVAL", "600"))
19
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
20
  HF_USERNAME = os.environ.get("HF_USERNAME", "")
@@ -31,6 +36,47 @@ signal.signal(signal.SIGTERM, signal_handler)
31
  signal.signal(signal.SIGINT, signal_handler)
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def has_changes():
35
  """Check if workspace has uncommitted changes (git-based check)."""
36
  try:
@@ -142,6 +188,8 @@ def main():
142
 
143
  use_hf_hub = bool(HF_TOKEN and HF_USERNAME)
144
 
 
 
145
  if use_hf_hub:
146
  print(f"πŸ”„ Workspace sync started (huggingface_hub): every {INTERVAL}s β†’ {HF_USERNAME}/{BACKUP_DATASET}")
147
  else:
@@ -156,6 +204,8 @@ def main():
156
  if not running:
157
  break
158
 
 
 
159
  if not has_changes():
160
  continue
161
 
 
11
  import sys
12
  import time
13
  import signal
14
+ import shutil
15
  import subprocess
16
  from pathlib import Path
17
 
18
  WORKSPACE = Path("/home/node/.openclaw/workspace")
19
+ STATE_DIR = WORKSPACE / ".huggingclaw-state"
20
+ WHATSAPP_CREDS_DIR = Path("/home/node/.openclaw/credentials/whatsapp/default")
21
+ WHATSAPP_BACKUP_DIR = STATE_DIR / "credentials" / "whatsapp" / "default"
22
+ RESET_MARKER = WORKSPACE / ".reset_credentials"
23
  INTERVAL = int(os.environ.get("SYNC_INTERVAL", "600"))
24
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
25
  HF_USERNAME = os.environ.get("HF_USERNAME", "")
 
36
  signal.signal(signal.SIGINT, signal_handler)
37
 
38
 
39
+ def count_files(path: Path) -> int:
40
+ """Count regular files recursively under a path."""
41
+ if not path.exists():
42
+ return 0
43
+ return sum(1 for child in path.rglob("*") if child.is_file())
44
+
45
+
46
+ def snapshot_state_into_workspace() -> None:
47
+ """
48
+ Mirror persistent state into the workspace-backed dataset repo.
49
+
50
+ This keeps WhatsApp credentials in a hidden folder that is synced together
51
+ with the workspace, without changing the live credentials location.
52
+ """
53
+ try:
54
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
55
+
56
+ if RESET_MARKER.exists():
57
+ if WHATSAPP_BACKUP_DIR.exists():
58
+ shutil.rmtree(WHATSAPP_BACKUP_DIR, ignore_errors=True)
59
+ print("🧹 Removed backed-up WhatsApp credentials after reset request.")
60
+ RESET_MARKER.unlink(missing_ok=True)
61
+ return
62
+
63
+ if not WHATSAPP_CREDS_DIR.exists():
64
+ return
65
+
66
+ file_count = count_files(WHATSAPP_CREDS_DIR)
67
+ if file_count < 2:
68
+ if file_count > 0:
69
+ print(f"πŸ“¦ WhatsApp backup skipped: credentials incomplete ({file_count} files).")
70
+ return
71
+
72
+ WHATSAPP_BACKUP_DIR.parent.mkdir(parents=True, exist_ok=True)
73
+ if WHATSAPP_BACKUP_DIR.exists():
74
+ shutil.rmtree(WHATSAPP_BACKUP_DIR, ignore_errors=True)
75
+ shutil.copytree(WHATSAPP_CREDS_DIR, WHATSAPP_BACKUP_DIR)
76
+ except Exception as e:
77
+ print(f" ⚠️ Could not snapshot WhatsApp state: {e}")
78
+
79
+
80
  def has_changes():
81
  """Check if workspace has uncommitted changes (git-based check)."""
82
  try:
 
188
 
189
  use_hf_hub = bool(HF_TOKEN and HF_USERNAME)
190
 
191
+ snapshot_state_into_workspace()
192
+
193
  if use_hf_hub:
194
  print(f"πŸ”„ Workspace sync started (huggingface_hub): every {INTERVAL}s β†’ {HF_USERNAME}/{BACKUP_DATASET}")
195
  else:
 
204
  if not running:
205
  break
206
 
207
+ snapshot_state_into_workspace()
208
+
209
  if not has_changes():
210
  continue
211