somratpro commited on
Commit
de4ed0d
·
1 Parent(s): ad768a0

feat: add optional WhatsApp integration with toggle and status monitoring

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 +36 -0
  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
@@ -14,6 +14,7 @@ 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 CHECK_INTERVAL = 5000;
18
  const WAIT_TIMEOUT = 120000;
19
  const POST_515_NO_LOGOUT_MS = 90 * 1000;
@@ -24,6 +25,7 @@ const RESET_MARKER_PATH = path.join(
24
  "workspace",
25
  ".reset_credentials",
26
  );
 
27
 
28
  let isWaiting = false;
29
  let hasShownWaitMessage = false;
@@ -49,6 +51,25 @@ function writeResetMarker() {
49
  }
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  async function createConnection() {
53
  return new Promise((resolve, reject) => {
54
  const ws = new WebSocket(GATEWAY_URL);
@@ -133,16 +154,21 @@ async function checkStatus() {
133
 
134
  if (!wa) {
135
  hasShownWaitMessage = false;
 
136
  return;
137
  }
138
 
139
  if (wa.connected) {
140
  hasShownWaitMessage = false;
141
  lastConnectedAt = Date.now();
 
 
 
142
  return;
143
  }
144
 
145
  isWaiting = true;
 
146
  if (!hasShownWaitMessage) {
147
  console.log("\n[guardian] 📱 WhatsApp pairing in progress. Please scan the QR code in the Control UI.");
148
  hasShownWaitMessage = true;
@@ -161,6 +187,7 @@ async function checkStatus() {
161
  if (result && (result.connected || linkedAfter515)) {
162
  hasShownWaitMessage = false;
163
  lastConnectedAt = Date.now();
 
164
 
165
  if (linkedAfter515) {
166
  console.log("[guardian] 515 after scan: credentials saved, reloading config to start WhatsApp...");
@@ -198,6 +225,9 @@ async function checkStatus() {
198
  console.log(`[guardian] Failed to log out invalid session: ${error.message}`);
199
  }
200
  }
 
 
 
201
  // Normal timeout or gateway starting up; retry on the next interval.
202
  } finally {
203
  isWaiting = false;
@@ -205,6 +235,12 @@ async function checkStatus() {
205
  }
206
  }
207
 
 
 
 
 
 
 
208
  console.log("[guardian] ⚔️ WhatsApp Guardian active. Monitoring pairing status...");
209
  setInterval(checkStatus, CHECK_INTERVAL);
210
  setTimeout(checkStatus, 15000);
 
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;
 
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
+
73
  async function createConnection() {
74
  return new Promise((resolve, reject) => {
75
  const ws = new WebSocket(GATEWAY_URL);
 
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;
 
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...");
 
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():