somratpro commited on
Commit
25b14fd
·
1 Parent(s): 93a1830

feat: replace UptimeRobot with an automated Cloudflare Worker for Space keep-alive functionality

Browse files
Files changed (7) hide show
  1. .env.example +3 -3
  2. Dockerfile +2 -2
  3. README.md +7 -8
  4. cloudflare-keepalive-setup.py +212 -0
  5. health-server.js +277 -792
  6. setup-uptimerobot.sh +0 -91
  7. start.sh +3 -3
.env.example CHANGED
@@ -206,9 +206,9 @@ SYNC_INTERVAL=180
206
  # Webhooks: Standard POST notifications for lifecycle events
207
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
208
 
209
- # UptimeRobot keep-alive: add your Main API key (NOT Read-only or Monitor-specific).
210
- # Monitor is created automatically at boot. Status shown on the dashboard.
211
- # UPTIMEROBOT_API_KEY=ur_your_api_key_here
212
 
213
  # Trusted proxies (comma-separated IPs)
214
  # Fixes "Proxy headers detected from untrusted address" behind reverse proxies
 
206
  # Webhooks: Standard POST notifications for lifecycle events
207
  # WEBHOOK_URL=https://your-webhook-endpoint.com/log
208
 
209
+ # Cloudflare proxy & keep-alive: add your Cloudflare API token.
210
+ # A Worker is created automatically at boot for both outbound proxying and keep-alive.
211
+ # CLOUDFLARE_WORKERS_TOKEN=your_cloudflare_token_here
212
 
213
  # Trusted proxies (comma-separated IPs)
214
  # Fixes "Proxy headers detected from untrusted address" behind reverse proxies
Dockerfile CHANGED
@@ -69,8 +69,8 @@ COPY --chown=1000:1000 health-server.js /home/node/app/health-server.js
69
  COPY --chown=1000:1000 iframe-fix.cjs /home/node/app/iframe-fix.cjs
70
  COPY --chown=1000:1000 start.sh /home/node/app/start.sh
71
  COPY --chown=1000:1000 wa-guardian.js /home/node/app/wa-guardian.js
72
- COPY --chown=1000:1000 workspace-sync.py /home/node/app/workspace-sync.py
73
- RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py
74
 
75
  USER node
76
 
 
69
  COPY --chown=1000:1000 iframe-fix.cjs /home/node/app/iframe-fix.cjs
70
  COPY --chown=1000:1000 start.sh /home/node/app/start.sh
71
  COPY --chown=1000:1000 wa-guardian.js /home/node/app/wa-guardian.js
72
+ COPY --chown=1000:1000 cloudflare-keepalive-setup.py /home/node/app/cloudflare-keepalive-setup.py
73
+ RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py /home/node/app/cloudflare-keepalive-setup.py
74
 
75
  USER node
76
 
README.md CHANGED
@@ -15,9 +15,7 @@ secrets:
15
  - name: GATEWAY_TOKEN
16
  description: "Strong token to secure your OpenClaw Control UI (generate: openssl rand -hex 32)."
17
  - name: CLOUDFLARE_WORKERS_TOKEN
18
- description: "Cloudflare API token — auto-creates a Worker proxy for Telegram, WhatsApp, and Google APIs."
19
- - name: UPTIMEROBOT_API_KEY
20
- description: UptimeRobot API key for automatic monitor setup.
21
  ---
22
 
23
  <!-- Badges -->
@@ -56,7 +54,7 @@ secrets:
56
  - 🐳 **Fast Builds:** Uses a pre-built OpenClaw Docker image to deploy in minutes.
57
  - 🌐 **Cloudflare Outbound Proxy:** HuggingClaw can automatically provision a Cloudflare Worker proxy for blocked outbound traffic such as Telegram API requests.
58
  - 💾 **Workspace Backup:** Chats, settings, and WhatsApp session state sync to a private HF Dataset via the `huggingface_hub`, preserving data automatically without storing your HF token in a git remote.
59
- - ⏰ **External Keep-Alive:** Add `UPTIMEROBOT_API_KEY` as a Space secret and the monitor is created automatically at boot — no manual setup.
60
  - 👥 **Multi-User Messaging:** Support for Telegram (multi-user) and WhatsApp (pairing).
61
  - 📊 **Visual Dashboard:** Beautiful Web UI to monitor uptime, sync status, and active models.
62
  - 🔔 **Webhooks:** Get notified on restarts or backup failures via standard webhooks.
@@ -168,7 +166,7 @@ HuggingClaw automatically syncs your workspace (chats, settings, sessions) to a
168
 
169
  ## 💓 Staying Alive *(Recommended on Free HF Spaces)*
170
 
171
- Add your [UptimeRobot](https://uptimerobot.com/) **Main API key** as a Space secret named `UPTIMEROBOT_API_KEY`. HuggingClaw will automatically create a monitor for your Space's `/health` endpoint at boot. The dashboard shows the current status (configured, setting up, or failed).
172
 
173
  ## 🔔 Webhooks *(Optional)*
174
 
@@ -187,7 +185,7 @@ Configure password access and network restrictions:
187
  | `OPENCLAW_PASSWORD` | — | Enable simple password auth instead of token |
188
  | `TRUSTED_PROXIES` | — | Comma-separated IPs of HF proxies |
189
  | `ALLOWED_ORIGINS` | — | Comma-separated allowed origins for Control UI |
190
- | `OPENCLAW_VERSION` | `latest` | Build-time pin for the OpenClaw image tag |
191
 
192
  ## 🤖 LLM Providers
193
 
@@ -308,7 +306,7 @@ HuggingClaw uses a multi-layered approach to ensure stability and persistence on
308
  - **Missing secrets:** Ensure `LLM_API_KEY`, `LLM_MODEL`, and `GATEWAY_TOKEN` are set in your Space **Settings → Secrets**.
309
  - **Telegram bot issues:** Verify your `TELEGRAM_BOT_TOKEN`. Check Space logs for lines like `📱 Enabling Telegram`.
310
  - **Backup restore failing:** Make sure `HF_TOKEN` is valid and has write access to your HF account dataset. Set `HF_USERNAME` only if auto-detection is not available in your environment.
311
- - **Space keeps sleeping:** Add `UPTIMEROBOT_API_KEY` as a Space secret to enable automatic keep-awake monitoring.
312
  - **Auth errors / proxy:** If you see reverse-proxy auth errors, add the logged IPs under `TRUSTED_PROXIES` (from logs `remote=x.x.x.x`).
313
  - **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 logging in again with `GATEWAY_TOKEN`.
314
  - **WhatsApp lost its session after restart:** Make sure `HF_TOKEN` is configured so the hidden session backup can be restored on boot.
@@ -321,8 +319,9 @@ Similar projects by [@somratpro](https://github.com/somratpro) — all free, one
321
 
322
  | Project | What it runs | HF Space | GitHub |
323
  | :--- | :--- | :--- | :--- |
324
- | **HuggingClip** | PaperclipAI agent orchestration platform | [Space](https://huggingface.co/spaces/somratpro/HuggingClip) | [Repo](https://github.com/somratpro/huggingclip) |
325
  | **Hugging8n** | n8n — workflow & automation platform | [Space](https://huggingface.co/spaces/somratpro/Hugging8n) | [Repo](https://github.com/somratpro/hugging8n) |
 
326
 
327
  ## 📚 Links
328
 
 
15
  - name: GATEWAY_TOKEN
16
  description: "Strong token to secure your OpenClaw Control UI (generate: openssl rand -hex 32)."
17
  - name: CLOUDFLARE_WORKERS_TOKEN
18
+ description: "Cloudflare API token — auto-creates a Worker proxy and KeepAlive monitor."
 
 
19
  ---
20
 
21
  <!-- Badges -->
 
54
  - 🐳 **Fast Builds:** Uses a pre-built OpenClaw Docker image to deploy in minutes.
55
  - 🌐 **Cloudflare Outbound Proxy:** HuggingClaw can automatically provision a Cloudflare Worker proxy for blocked outbound traffic such as Telegram API requests.
56
  - 💾 **Workspace Backup:** Chats, settings, and WhatsApp session state sync to a private HF Dataset via the `huggingface_hub`, preserving data automatically without storing your HF token in a git remote.
57
+ - ⏰ **Easy Keep-Alive:** Uses `CLOUDFLARE_WORKERS_TOKEN` to automatically set up a cron-triggered keep-awake worker at boot.
58
  - 👥 **Multi-User Messaging:** Support for Telegram (multi-user) and WhatsApp (pairing).
59
  - 📊 **Visual Dashboard:** Beautiful Web UI to monitor uptime, sync status, and active models.
60
  - 🔔 **Webhooks:** Get notified on restarts or backup failures via standard webhooks.
 
166
 
167
  ## 💓 Staying Alive *(Recommended on Free HF Spaces)*
168
 
169
+ Your Space will automatically be kept awake by a background Cloudflare Worker when you configure the `CLOUDFLARE_WORKERS_TOKEN` secret. The worker uses a cron trigger to regularly ping your Space's `/health` endpoint. The dashboard displays the current keep-alive worker status.
170
 
171
  ## 🔔 Webhooks *(Optional)*
172
 
 
185
  | `OPENCLAW_PASSWORD` | — | Enable simple password auth instead of token |
186
  | `TRUSTED_PROXIES` | — | Comma-separated IPs of HF proxies |
187
  | `ALLOWED_ORIGINS` | — | Comma-separated allowed origins for Control UI |
188
+ | `CLOUDFLARE_KEEPALIVE_ENABLED` | `true` | Set to `false` to disable the automatic Cloudflare KeepAlive worker |
189
 
190
  ## 🤖 LLM Providers
191
 
 
306
  - **Missing secrets:** Ensure `LLM_API_KEY`, `LLM_MODEL`, and `GATEWAY_TOKEN` are set in your Space **Settings → Secrets**.
307
  - **Telegram bot issues:** Verify your `TELEGRAM_BOT_TOKEN`. Check Space logs for lines like `📱 Enabling Telegram`.
308
  - **Backup restore failing:** Make sure `HF_TOKEN` is valid and has write access to your HF account dataset. Set `HF_USERNAME` only if auto-detection is not available in your environment.
309
+ - **Space keeps sleeping:** Add `CLOUDFLARE_WORKERS_TOKEN` as a Space secret to enable automatic keep-awake monitoring via Cloudflare Workers.
310
  - **Auth errors / proxy:** If you see reverse-proxy auth errors, add the logged IPs under `TRUSTED_PROXIES` (from logs `remote=x.x.x.x`).
311
  - **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 logging in again with `GATEWAY_TOKEN`.
312
  - **WhatsApp lost its session after restart:** Make sure `HF_TOKEN` is configured so the hidden session backup can be restored on boot.
 
319
 
320
  | Project | What it runs | HF Space | GitHub |
321
  | :--- | :--- | :--- | :--- |
322
+ | **HuggingMess** | HermesSelf-hosted agent gateway | [Space](https://huggingface.co/spaces/somratpro/HuggingMess) | [Repo](https://github.com/somratpro/huggingmess) |
323
  | **Hugging8n** | n8n — workflow & automation platform | [Space](https://huggingface.co/spaces/somratpro/Hugging8n) | [Repo](https://github.com/somratpro/hugging8n) |
324
+ | **HuggingClip** | Paperclip — AI agent orchestration platform | [Space](https://huggingface.co/spaces/somratpro/HuggingClip) | [Repo](https://github.com/somratpro/huggingclip) |
325
 
326
  ## 📚 Links
327
 
cloudflare-keepalive-setup.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Create or reuse a Cloudflare Worker for Space keep-awake."""
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ API_BASE = "https://api.cloudflare.com/client/v4"
15
+ KEEPALIVE_STATUS_FILE = Path("/tmp/huggingclaw-cloudflare-keepalive-status.json")
16
+
17
+
18
+ def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
19
+ req = urllib.request.Request(
20
+ f"{API_BASE}{path}",
21
+ data=body,
22
+ method=method,
23
+ headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
24
+ )
25
+ with urllib.request.urlopen(req, timeout=30) as response:
26
+ payload = json.loads(response.read().decode("utf-8"))
27
+ if not payload.get("success"):
28
+ errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
29
+ raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
30
+ return payload["result"]
31
+
32
+
33
+ def slugify(value: str) -> str:
34
+ cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
35
+ cleaned = re.sub(r"-{2,}", "-", cleaned)
36
+ return (cleaned or "huggingclaw-proxy")[:63].rstrip("-")
37
+
38
+
39
+ def get_space_host() -> str:
40
+ space_host = os.environ.get("SPACE_HOST", "").strip()
41
+ if space_host:
42
+ return space_host
43
+
44
+ author = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
45
+ repo = os.environ.get("SPACE_REPO_NAME", "").strip()
46
+ if author and repo:
47
+ return f"{author}-{repo}.hf.space".lower()
48
+
49
+ return ""
50
+
51
+
52
+ def derive_keepalive_worker_name() -> str:
53
+ explicit = os.environ.get("CLOUDFLARE_KEEPALIVE_WORKER_NAME", "").strip()
54
+ if explicit:
55
+ return slugify(explicit)
56
+ space_host = get_space_host()
57
+ if space_host:
58
+ return slugify(f"{space_host.replace('.hf.space', '')}-keepalive")
59
+ return "huggingclaw-keepalive"
60
+
61
+
62
+ def render_keepalive_worker(target_url: str) -> str:
63
+ return f"""addEventListener("fetch", (event) => {{
64
+ event.respondWith(handleRequest(event.request));
65
+ }});
66
+
67
+ addEventListener("scheduled", (event) => {{
68
+ event.waitUntil(ping("cron"));
69
+ }});
70
+
71
+ const TARGET_URL = {json.dumps(target_url)};
72
+
73
+ async function ping(source) {{
74
+ const startedAt = new Date().toISOString();
75
+ try {{
76
+ const response = await fetch(TARGET_URL, {{
77
+ method: "GET",
78
+ headers: {{
79
+ "user-agent": "HuggingClaw Cloudflare KeepAlive",
80
+ "cache-control": "no-cache"
81
+ }},
82
+ cf: {{ cacheTtl: 0, cacheEverything: false }}
83
+ }});
84
+ return {{
85
+ ok: response.ok,
86
+ status: response.status,
87
+ source,
88
+ target: TARGET_URL,
89
+ timestamp: startedAt
90
+ }};
91
+ }} catch (error) {{
92
+ return {{
93
+ ok: false,
94
+ status: 0,
95
+ source,
96
+ target: TARGET_URL,
97
+ timestamp: startedAt,
98
+ error: error.message
99
+ }};
100
+ }}
101
+ }}
102
+
103
+ async function handleRequest(request) {{
104
+ const url = new URL(request.url);
105
+ if (url.pathname === "/" || url.pathname === "/health" || url.pathname === "/ping") {{
106
+ const result = await ping("manual");
107
+ return new Response(JSON.stringify(result, null, 2), {{
108
+ status: result.ok ? 200 : 502,
109
+ headers: {{ "content-type": "application/json; charset=utf-8" }}
110
+ }});
111
+ }}
112
+ return new Response("Not found", {{ status: 404 }});
113
+ }}
114
+ """
115
+
116
+
117
+ def write_keepalive_status(payload: dict) -> None:
118
+ payload = {
119
+ **payload,
120
+ "timestamp": payload.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
121
+ }
122
+ KEEPALIVE_STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
123
+ try:
124
+ KEEPALIVE_STATUS_FILE.chmod(0o600)
125
+ except OSError:
126
+ pass
127
+
128
+
129
+ def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
130
+ account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
131
+ if not account_id:
132
+ accounts = cf_request("GET", "/accounts", api_token)
133
+ if not accounts:
134
+ raise RuntimeError("No Cloudflare account is available for this token.")
135
+ account_id = accounts[0]["id"]
136
+
137
+ subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
138
+ subdomain = (subdomain_info or {}).get("subdomain", "").strip()
139
+ if not subdomain:
140
+ raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
141
+ return account_id, subdomain
142
+
143
+
144
+ def setup_keepalive_worker(api_token: str, account_id: str, subdomain: str) -> None:
145
+ enabled = os.environ.get("CLOUDFLARE_KEEPALIVE_ENABLED", "true").strip().lower()
146
+ if enabled in {"0", "false", "no", "off"}:
147
+ write_keepalive_status({"configured": False, "status": "disabled", "message": "Cloudflare keep-awake is disabled."})
148
+ return
149
+
150
+ space_host = get_space_host()
151
+ if not space_host:
152
+ write_keepalive_status({"configured": False, "status": "skipped", "message": "SPACE_HOST could not be determined."})
153
+ return
154
+
155
+ cron = os.environ.get("CLOUDFLARE_KEEPALIVE_CRON", "*/10 * * * *").strip()
156
+ space_host = space_host.removeprefix("https://").removeprefix("http://").split("/")[0]
157
+ target_url = os.environ.get("CLOUDFLARE_KEEPALIVE_URL", f"https://{space_host}/health").strip()
158
+ worker_name = derive_keepalive_worker_name()
159
+ worker_source = render_keepalive_worker(target_url)
160
+
161
+ cf_request(
162
+ "PUT",
163
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
164
+ api_token,
165
+ body=worker_source.encode("utf-8"),
166
+ content_type="application/javascript",
167
+ )
168
+ cf_request(
169
+ "POST",
170
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
171
+ api_token,
172
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
173
+ )
174
+ cf_request(
175
+ "PUT",
176
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/schedules",
177
+ api_token,
178
+ body=json.dumps([{"cron": cron}]).encode("utf-8"),
179
+ )
180
+
181
+ worker_url = f"https://{worker_name}.{subdomain}.workers.dev"
182
+ write_keepalive_status(
183
+ {
184
+ "configured": True,
185
+ "status": "configured",
186
+ "workerName": worker_name,
187
+ "workerUrl": worker_url,
188
+ "targetUrl": target_url,
189
+ "cron": cron,
190
+ "message": f"Cloudflare Worker cron pings {target_url} on {cron}.",
191
+ }
192
+ )
193
+
194
+
195
+ def main() -> int:
196
+ api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
197
+
198
+ if not api_token:
199
+ return 0
200
+
201
+ try:
202
+ account_id, subdomain = resolve_account_and_subdomain(api_token)
203
+ setup_keepalive_worker(api_token, account_id, subdomain)
204
+ return 0
205
+ except Exception as exc:
206
+ print(f"Cloudflare keepalive setup failed: {exc}", file=sys.stderr)
207
+ write_keepalive_status({"configured": False, "status": "error", "message": str(exc)})
208
+ return 1
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())
health-server.js CHANGED
@@ -13,13 +13,9 @@ const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
13
  const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
14
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
15
  const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "180";
16
- const DASHBOARD_BASE = "/dashboard";
17
- const DASHBOARD_STATUS_PATH = `${DASHBOARD_BASE}/status`;
18
- const DASHBOARD_HEALTH_PATH = `${DASHBOARD_BASE}/health`;
19
- const DASHBOARD_APP_BASE = `${DASHBOARD_BASE}/app`;
20
  const APP_BASE = "/app";
21
- const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingclaw-uptimerobot-status.json";
22
- const UPTIMEROBOT_API_KEY_SET = !!process.env.UPTIMEROBOT_API_KEY;
23
 
24
  function parseRequestUrl(url) {
25
  try {
@@ -29,112 +25,21 @@ function parseRequestUrl(url) {
29
  }
30
  }
31
 
32
- function isDashboardRoute(pathname) {
33
- return (
34
- pathname === "/" ||
35
- pathname === DASHBOARD_BASE ||
36
- pathname === `${DASHBOARD_BASE}/`
37
- );
38
- }
39
-
40
- function isDashboardAppRoute(pathname) {
41
- return (
42
- pathname === DASHBOARD_APP_BASE ||
43
- pathname.startsWith(`${DASHBOARD_APP_BASE}/`)
44
- );
45
- }
46
-
47
- function isAppRoute(pathname) {
48
- return pathname === APP_BASE || pathname.startsWith(`${APP_BASE}/`);
49
- }
50
-
51
- function isLocalRoute(pathname) {
52
- return (
53
- pathname === "/health" ||
54
- pathname === "/status" ||
55
- pathname === DASHBOARD_HEALTH_PATH ||
56
- pathname === DASHBOARD_STATUS_PATH
57
- );
58
- }
59
-
60
- function mapAppProxyPath(path) {
61
- if (path === DASHBOARD_APP_BASE) return APP_BASE;
62
- if (path.startsWith(`${DASHBOARD_APP_BASE}/`)) {
63
- return `${APP_BASE}${path.slice(DASHBOARD_APP_BASE.length)}`;
64
- }
65
- if (path === APP_BASE || path.startsWith(`${APP_BASE}/`)) {
66
- return path;
67
- }
68
- return path;
69
- }
70
-
71
- function sanitizeAppProxySearch(parsedUrl) {
72
- const filtered = new URLSearchParams(parsedUrl.searchParams);
73
- // HF Space UI sometimes appends its own control params to deep links.
74
- filtered.delete("logs");
75
- const query = filtered.toString();
76
- return query ? `?${query}` : "";
77
- }
78
-
79
- function appendForwarded(existingValue, nextValue) {
80
- const cleanNext = nextValue || "";
81
- if (!existingValue) return cleanNext;
82
- if (Array.isArray(existingValue))
83
- return `${existingValue.join(", ")}, ${cleanNext}`;
84
- return `${existingValue}, ${cleanNext}`;
85
- }
86
-
87
- function getForwardedClientIp(req) {
88
- const forwardedFor = req.headers["x-forwarded-for"];
89
- if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
90
- return String(forwardedFor[0]).split(",")[0].trim();
91
- }
92
- if (typeof forwardedFor === "string" && forwardedFor.trim()) {
93
- return forwardedFor.split(",")[0].trim();
94
- }
95
- return req.socket.remoteAddress || "";
96
- }
97
-
98
- function buildProxyHeaders(headers, remoteAddress) {
99
- return {
100
- ...headers,
101
- host: `${GATEWAY_HOST}:${GATEWAY_PORT}`,
102
- "x-forwarded-for": remoteAddress || "",
103
- "x-forwarded-host": headers.host || "",
104
- "x-forwarded-proto": headers["x-forwarded-proto"] || "https",
105
- };
106
- }
107
-
108
- function getRequesterIp(req) {
109
- return (
110
- getForwardedClientIp(req) ||
111
- req.socket.remoteAddress ||
112
- "unknown"
113
- );
114
- }
115
-
116
- function readSyncStatus() {
117
  try {
118
- if (fs.existsSync("/tmp/sync-status.json")) {
119
- return JSON.parse(fs.readFileSync("/tmp/sync-status.json", "utf8"));
120
  }
121
  } catch {}
122
  if (HF_BACKUP_ENABLED) {
123
  return {
124
  status: "configured",
125
- message: `Backup is enabled. Waiting for the next sync window (${SYNC_INTERVAL}s).`,
126
  };
127
  }
128
  return { status: "unknown", message: "No sync data yet" };
129
  }
130
 
131
- function normalizeChannelStatus(channel, configured) {
132
- return {
133
- configured: configured || !!channel,
134
- connected: !!(channel && channel.connected),
135
- };
136
- }
137
-
138
  function readGuardianStatus() {
139
  if (!WHATSAPP_ENABLED) {
140
  return { configured: false, connected: false, pairing: false };
@@ -152,741 +57,321 @@ function readGuardianStatus() {
152
  return { configured: true, connected: false, pairing: false };
153
  }
154
 
155
- function getUptimeRobotStatus() {
156
  try {
157
- if (fs.existsSync(UPTIMEROBOT_STATUS_FILE)) {
158
- return JSON.parse(fs.readFileSync(UPTIMEROBOT_STATUS_FILE, "utf8"));
159
  }
160
  } catch {}
161
  return null;
162
  }
163
 
164
- function renderChannelBadge(channel, configuredLabel) {
165
- if (channel && channel.connected) {
166
- return '<div class="status-badge status-online"><div class="pulse"></div>Active</div>';
167
- }
168
- if (channel && channel.configured) {
169
- return `<div class="status-badge status-syncing">${configuredLabel}</div>`;
170
- }
171
- return '<div class="status-badge status-offline">Disabled</div>';
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
173
 
174
- function renderSyncBadge(syncData) {
175
- let badgeClass = "status-offline";
176
- let pulseHtml = "";
 
 
 
 
 
 
177
 
178
- if (syncData.status === "success" || syncData.status === "configured") {
179
- badgeClass = "status-online";
180
- pulseHtml = '<div class="pulse"></div>';
181
- } else if (syncData.status === "syncing") {
182
- badgeClass = "status-syncing";
183
- pulseHtml = '<div class="pulse" style="background:#3b82f6"></div>';
184
- }
185
 
186
- return `<div class="status-badge ${badgeClass}">${pulseHtml}${String(syncData.status || "unknown").toUpperCase()}</div>`;
 
187
  }
188
 
189
- function renderDashboard(initialData) {
190
- const controlUiHref = `${APP_BASE}/`;
191
- const urStatus = initialData.uptimerobotStatus;
192
- let keepAwakeHtml;
193
- if (urStatus?.configured) {
194
- keepAwakeHtml = `
195
- <div class="helper-summary success">
196
- <div class="status-badge status-online"><div class="pulse"></div>CONFIGURED</div>
197
- <span>UptimeRobot monitor active for <code>${urStatus.url || "your /health endpoint"}</code>.</span>
198
- </div>`;
199
- } else if (urStatus?.configured === false) {
200
- keepAwakeHtml = `
201
- <div class="helper-summary" style="background:rgba(239,68,68,0.08);">
202
- <div class="status-badge status-offline">FAILED</div>
203
- <span>Monitor setup failed. Check Space logs for details.</span>
204
- </div>`;
205
- } else if (UPTIMEROBOT_API_KEY_SET) {
206
- keepAwakeHtml = `
207
- <div class="helper-summary">
208
- <div class="status-badge status-syncing">SETTING UP&hellip;</div>
209
- <span>UptimeRobot monitor is being configured.</span>
210
- </div>`;
211
- } else {
212
- keepAwakeHtml = `
213
- <div class="helper-summary">
214
- <strong>Not configured.</strong> Add <code>UPTIMEROBOT_API_KEY</code> to Space secrets to enable keep-awake monitoring.
215
- </div>`;
216
- }
217
- return `
218
- <!DOCTYPE html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  <html lang="en">
220
  <head>
221
- <meta charset="UTF-8">
222
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
223
- <title>HuggingClaw Dashboard</title>
224
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
225
- <style>
226
- :root {
227
- --bg: #0f172a;
228
- --card-bg: rgba(30, 41, 59, 0.7);
229
- --accent: linear-gradient(135deg, #3b82f6, #8b5cf6);
230
- --text: #f8fafc;
231
- --text-dim: #94a3b8;
232
- --success: #10b981;
233
- --error: #ef4444;
234
- --warning: #f59e0b;
235
- }
236
-
237
- * { box-sizing: border-box; margin: 0; padding: 0; }
238
-
239
- body {
240
- font-family: 'Outfit', sans-serif;
241
- background-color: var(--bg);
242
- color: var(--text);
243
- display: flex;
244
- justify-content: center;
245
- align-items: flex-start;
246
- min-height: 100vh;
247
- overflow-x: hidden;
248
- overflow-y: auto;
249
- padding: 24px 0;
250
- background-image:
251
- radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%),
252
- radial-gradient(at 100% 0%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
253
- }
254
-
255
- .dashboard {
256
- width: 90%;
257
- max-width: 600px;
258
- background: var(--card-bg);
259
- backdrop-filter: blur(12px);
260
- border: 1px solid rgba(255, 255, 255, 0.1);
261
- border-radius: 24px;
262
- padding: 40px;
263
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
264
- animation: fadeIn 0.8s ease-out;
265
- margin: 24px 0;
266
- }
267
-
268
- @keyframes fadeIn {
269
- from { opacity: 0; transform: translateY(20px); }
270
- to { opacity: 1; transform: translateY(0); }
271
- }
272
-
273
- header {
274
- text-align: center;
275
- margin-bottom: 40px;
276
- }
277
-
278
- h1 {
279
- font-size: 2.5rem;
280
- margin-bottom: 8px;
281
- background: var(--accent);
282
- -webkit-background-clip: text;
283
- -webkit-text-fill-color: transparent;
284
- font-weight: 600;
285
- }
286
-
287
- .subtitle {
288
- color: var(--text-dim);
289
- font-size: 0.9rem;
290
- letter-spacing: 1px;
291
- text-transform: uppercase;
292
- }
293
-
294
- .stats-grid {
295
- display: grid;
296
- grid-template-columns: repeat(2, 1fr);
297
- gap: 20px;
298
- margin-bottom: 30px;
299
- }
300
-
301
- .stat-card {
302
- background: rgba(255, 255, 255, 0.03);
303
- border: 1px solid rgba(255, 255, 255, 0.05);
304
- padding: 20px;
305
- border-radius: 16px;
306
- transition: transform 0.3s ease, border-color 0.3s ease;
307
- }
308
-
309
- .stat-card:hover {
310
- transform: translateY(-5px);
311
- border-color: rgba(59, 130, 246, 0.3);
312
- }
313
-
314
- .stat-label {
315
- color: var(--text-dim);
316
- font-size: 0.75rem;
317
- text-transform: uppercase;
318
- margin-bottom: 8px;
319
- display: block;
320
- }
321
-
322
- .stat-value {
323
- font-size: 1.1rem;
324
- font-weight: 600;
325
- word-break: break-all;
326
- }
327
-
328
- .stat-btn {
329
- grid-column: span 2;
330
- background: var(--accent);
331
- color: #fff;
332
- padding: 16px;
333
- border-radius: 16px;
334
- text-align: center;
335
- text-decoration: none;
336
- font-weight: 600;
337
- margin-top: 10px;
338
- transition: transform 0.3s ease, box-shadow 0.3s ease;
339
- box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.4);
340
- }
341
-
342
- .stat-btn:hover {
343
- transform: scale(1.02);
344
- box-shadow: 0 15px 30px -5px rgba(59, 130, 246, 0.6);
345
- }
346
-
347
- .status-badge {
348
- display: inline-flex;
349
- align-items: center;
350
- gap: 6px;
351
- padding: 4px 12px;
352
- border-radius: 20px;
353
- font-size: 0.8rem;
354
- font-weight: 600;
355
- }
356
-
357
- .status-online { background: rgba(16, 185, 129, 0.1); color: var(--success); }
358
- .status-offline { background: rgba(239, 68, 68, 0.1); color: var(--error); }
359
- .status-syncing { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
360
-
361
- .pulse {
362
- width: 8px;
363
- height: 8px;
364
- border-radius: 50%;
365
- background: currentColor;
366
- box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
367
- animation: pulse 2s infinite;
368
- }
369
-
370
- @keyframes pulse {
371
- 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
372
- 70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
373
- 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
374
- }
375
-
376
- .footer {
377
- text-align: center;
378
- color: var(--text-dim);
379
- font-size: 0.8rem;
380
- margin-top: 20px;
381
- }
382
-
383
- .sync-info {
384
- background: rgba(255, 255, 255, 0.02);
385
- padding: 15px;
386
- border-radius: 12px;
387
- font-size: 0.85rem;
388
- color: var(--text-dim);
389
- margin-top: 10px;
390
- }
391
-
392
- #sync-msg { color: var(--text); display: block; margin-top: 4px; }
393
-
394
- .card-header {
395
- display: flex;
396
- align-items: center;
397
- justify-content: space-between;
398
- gap: 12px;
399
- margin-bottom: 8px;
400
- }
401
-
402
- .card-header .stat-label {
403
- margin-bottom: 0;
404
- }
405
-
406
- .helper-card {
407
- width: 100%;
408
- margin-top: 20px;
409
- }
410
-
411
- .helper-copy {
412
- color: var(--text-dim);
413
- font-size: 0.92rem;
414
- line-height: 1.6;
415
- margin-top: 10px;
416
- }
417
-
418
- .helper-copy strong {
419
- color: var(--text);
420
- }
421
-
422
- .helper-row {
423
- display: flex;
424
- gap: 10px;
425
- margin-top: 16px;
426
- flex-wrap: wrap;
427
- }
428
-
429
- .helper-input {
430
- flex: 1;
431
- min-width: 240px;
432
- background: rgba(255, 255, 255, 0.04);
433
- border: 1px solid rgba(255, 255, 255, 0.08);
434
- color: var(--text);
435
- border-radius: 12px;
436
- padding: 14px 16px;
437
- font: inherit;
438
- }
439
-
440
- .helper-input::placeholder {
441
- color: var(--text-dim);
442
- }
443
-
444
- .helper-button {
445
- background: var(--accent);
446
- color: #fff;
447
- border: 0;
448
- border-radius: 12px;
449
- padding: 14px 18px;
450
- font: inherit;
451
- font-weight: 600;
452
- cursor: pointer;
453
- min-width: 180px;
454
- }
455
-
456
- .helper-button:disabled {
457
- opacity: 0.6;
458
- cursor: wait;
459
- }
460
-
461
- .hidden {
462
- display: none !important;
463
- }
464
-
465
- .helper-note {
466
- margin-top: 10px;
467
- font-size: 0.82rem;
468
- color: var(--text-dim);
469
- }
470
-
471
- .helper-result {
472
- margin-top: 14px;
473
- padding: 12px 14px;
474
- border-radius: 12px;
475
- font-size: 0.9rem;
476
- display: none;
477
- }
478
-
479
- .helper-result.ok {
480
- display: block;
481
- background: rgba(16, 185, 129, 0.1);
482
- color: var(--success);
483
- }
484
-
485
- .helper-result.error {
486
- display: block;
487
- background: rgba(239, 68, 68, 0.1);
488
- color: var(--error);
489
- }
490
-
491
- .helper-shell {
492
- margin-top: 12px;
493
- }
494
-
495
- .helper-shell.hidden {
496
- display: none;
497
- }
498
-
499
- .helper-summary {
500
- margin-top: 14px;
501
- padding: 12px 14px;
502
- border-radius: 12px;
503
- background: rgba(255, 255, 255, 0.03);
504
- color: var(--text-dim);
505
- font-size: 0.9rem;
506
- line-height: 1.5;
507
- display: flex;
508
- align-items: center;
509
- gap: 10px;
510
- flex-wrap: wrap;
511
- }
512
-
513
- .helper-summary strong { color: var(--text); }
514
- .helper-summary code {
515
- background: rgba(255,255,255,0.06);
516
- padding: 2px 6px;
517
- border-radius: 6px;
518
- font-size: 0.82rem;
519
- color: var(--text);
520
- }
521
- .helper-summary.success { background: rgba(16, 185, 129, 0.08); }
522
-
523
- @media (max-width: 700px) {
524
- body {
525
- padding: 16px 0;
526
- }
527
-
528
- .dashboard {
529
- width: calc(100% - 24px);
530
- padding: 24px;
531
- border-radius: 18px;
532
- margin: 12px 0;
533
- }
534
-
535
- header {
536
- margin-bottom: 28px;
537
- }
538
-
539
- h1 {
540
- font-size: 2rem;
541
- }
542
-
543
- .stats-grid {
544
- grid-template-columns: 1fr;
545
- gap: 14px;
546
- margin-bottom: 20px;
547
- }
548
-
549
- .stat-btn {
550
- grid-column: span 1;
551
- }
552
-
553
- .stat-card {
554
- padding: 16px;
555
- }
556
-
557
- .card-header {
558
- align-items: flex-start;
559
- flex-direction: column;
560
- }
561
-
562
- .helper-row {
563
- flex-direction: column;
564
- }
565
-
566
- .helper-input,
567
- .helper-button {
568
- width: 100%;
569
- min-width: 0;
570
- }
571
- }
572
- </style>
573
  </head>
574
  <body>
575
- <div class="dashboard">
576
- <header>
577
- <h1>🦞 HuggingClaw</h1>
578
- <p class="subtitle">Space Dashboard</p>
579
- </header>
580
-
581
- <div class="stats-grid">
582
- <div class="stat-card">
583
- <span class="stat-label">Model</span>
584
- <span class="stat-value" id="model-id">${initialData.model}</span>
585
- </div>
586
- <div class="stat-card">
587
- <span class="stat-label">Uptime</span>
588
- <span class="stat-value" id="uptime">${initialData.uptime}</span>
589
- </div>
590
- <div class="stat-card">
591
- <span class="stat-label">WhatsApp</span>
592
- <span id="wa-status">${renderChannelBadge(initialData.whatsapp, "Ready to pair")}</span>
593
- </div>
594
- <div class="stat-card">
595
- <span class="stat-label">Telegram</span>
596
- <span id="tg-status">${renderChannelBadge(initialData.telegram, "Configured")}</span>
597
- </div>
598
- <a href="${controlUiHref}" id="control-ui-link" class="stat-btn" target="_blank" rel="noopener noreferrer">Open Control UI</a>
599
- </div>
600
-
601
- <div class="stat-card" style="width: 100%;">
602
- <div class="card-header">
603
- <span class="stat-label">Workspace Sync Status</span>
604
- <div id="sync-badge-container">${renderSyncBadge(initialData.sync)}</div>
605
- </div>
606
- <div class="sync-info">
607
- Last Sync Activity: <span id="sync-time">${initialData.sync.timestamp || "Never"}</span>
608
- <span id="sync-msg">${initialData.sync.message || "Waiting for first sync..."}</span>
609
- </div>
610
- </div>
611
-
612
- <div class="stat-card helper-card">
613
- <span class="stat-label">Keep Space Awake</span>
614
- ${keepAwakeHtml}
615
- </div>
616
-
617
- <div class="footer">
618
- Live updates every 10s
619
- </div>
620
- </div>
621
-
622
- <script>
623
- function getDashboardBase() {
624
- const pathname = window.location.pathname || '/';
625
- if (pathname === '/' || pathname === '') return '';
626
- if (pathname === '${DASHBOARD_BASE}' || pathname === '${DASHBOARD_BASE}/') return '${DASHBOARD_BASE}';
627
- return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
628
- }
629
-
630
- function getCurrentSearch() {
631
- return window.location.search || '';
632
- }
633
-
634
- async function updateStats() {
635
- try {
636
- const res = await fetch(getDashboardBase() + '/status' + getCurrentSearch());
637
- const data = await res.json();
638
-
639
- document.getElementById('model-id').textContent = data.model;
640
- document.getElementById('uptime').textContent = data.uptime;
641
-
642
- function renderChannelStatus(channel, configuredLabel) {
643
- if (channel && channel.connected) {
644
- return '<div class="status-badge status-online"><div class="pulse"></div>Active</div>';
645
- }
646
- if (channel && channel.configured) {
647
- return '<div class="status-badge status-syncing">' + configuredLabel + '</div>';
648
- }
649
- return '<div class="status-badge status-offline">Disabled</div>';
650
- }
651
-
652
- document.getElementById('wa-status').innerHTML = renderChannelStatus(data.whatsapp, 'Ready to pair');
653
- document.getElementById('tg-status').innerHTML = renderChannelStatus(data.telegram, 'Configured');
654
-
655
- const syncData = data.sync;
656
- let badgeClass = 'status-offline';
657
- let pulseHtml = '';
658
 
659
- if (syncData.status === 'success' || syncData.status === 'configured') {
660
- badgeClass = 'status-online';
661
- pulseHtml = '<div class="pulse"></div>';
662
- } else if (syncData.status === 'syncing') {
663
- badgeClass = 'status-syncing';
664
- pulseHtml = '<div class="pulse" style="background:#3b82f6"></div>';
665
- }
666
 
667
- document.getElementById('sync-badge-container').innerHTML =
668
- '<div class="status-badge ' + badgeClass + '">' + pulseHtml + syncData.status.toUpperCase() + '</div>';
 
 
 
 
 
 
 
 
 
 
 
 
669
 
670
- document.getElementById('sync-time').textContent = syncData.timestamp || 'Never';
671
- document.getElementById('sync-msg').textContent = syncData.message || 'Waiting for first sync...';
672
- } catch (e) {
673
- console.error("Failed to fetch status", e);
674
- }
675
- }
 
 
 
 
 
 
 
 
676
 
677
- updateStats();
678
- setInterval(updateStats, 10000);
679
- document.getElementById('control-ui-link').setAttribute('href', getDashboardBase() + '/app/' + getCurrentSearch());
680
- </script>
681
- </body>
682
- </html>
683
- `;
684
- }
 
 
 
 
 
685
 
 
 
 
 
 
 
 
 
686
 
687
- function proxyHttp(req, res, proxyPath = req.url, proxyPort = GATEWAY_PORT) {
688
- const clientIp = getForwardedClientIp(req);
689
- let upstreamStarted = false;
690
  const proxyReq = http.request(
691
  {
692
  hostname: GATEWAY_HOST,
693
- port: proxyPort,
 
694
  method: req.method,
695
- path: proxyPath,
696
- headers: buildProxyHeaders(req.headers, clientIp),
697
  },
698
  (proxyRes) => {
699
- upstreamStarted = true;
700
- res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
701
  proxyRes.pipe(res);
 
 
 
 
702
  },
703
  );
704
 
705
- proxyReq.on("error", (error) => {
706
- if (res.headersSent || upstreamStarted) {
707
- res.destroy();
708
- return;
709
- }
710
-
711
- res.writeHead(502, { "Content-Type": "application/json" });
712
- res.end(
713
- JSON.stringify({
714
- status: "error",
715
- message: "Gateway unavailable",
716
- detail: error.message,
717
- }),
718
- );
719
- });
720
-
721
- res.on("close", () => {
722
  proxyReq.destroy();
723
  });
724
 
725
- req.pipe(proxyReq);
726
- }
727
-
728
- function serializeUpgradeHeaders(req, clientIp, proxyPort) {
729
- const forwardedHeaders = [];
730
-
731
- for (let i = 0; i < req.rawHeaders.length; i += 2) {
732
- const name = req.rawHeaders[i];
733
- const value = req.rawHeaders[i + 1];
734
- const lower = name.toLowerCase();
735
-
736
- if (
737
- lower === "host" ||
738
- lower === "x-forwarded-for" ||
739
- lower === "x-forwarded-host" ||
740
- lower === "x-forwarded-proto"
741
- ) {
742
- continue;
743
- }
744
-
745
- forwardedHeaders.push(`${name}: ${value}`);
746
- }
747
-
748
- forwardedHeaders.push(
749
- `Host: ${GATEWAY_HOST}:${proxyPort}`,
750
- );
751
- forwardedHeaders.push(
752
- `X-Forwarded-For: ${clientIp || ""}`,
753
- );
754
- forwardedHeaders.push(
755
- `X-Forwarded-Host: ${req.headers.host || ""}`,
756
- );
757
- forwardedHeaders.push(
758
- `X-Forwarded-Proto: ${req.headers["x-forwarded-proto"] || "https"}`,
759
- );
760
-
761
- return forwardedHeaders;
762
- }
763
-
764
- function proxyUpgrade(
765
- req,
766
- socket,
767
- head,
768
- proxyPath = req.url,
769
- proxyPort = GATEWAY_PORT,
770
- ) {
771
- const proxySocket = net.connect(proxyPort, GATEWAY_HOST);
772
- const clientIp = getForwardedClientIp(req);
773
-
774
- proxySocket.on("connect", () => {
775
- const requestLines = [
776
- `${req.method} ${proxyPath} HTTP/${req.httpVersion}`,
777
- ...serializeUpgradeHeaders(req, clientIp, proxyPort),
778
- "",
779
- "",
780
- ];
781
-
782
- proxySocket.write(requestLines.join("\r\n"));
783
-
784
- if (head && head.length > 0) {
785
- proxySocket.write(head);
786
- }
787
-
788
- socket.pipe(proxySocket).pipe(socket);
789
- });
790
-
791
- proxySocket.on("error", () => {
792
- if (socket.writable) {
793
- socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
794
- }
795
- socket.destroy();
796
- });
797
-
798
- socket.on("error", () => {
799
- proxySocket.destroy();
800
  });
801
- }
802
 
803
- const server = http.createServer((req, res) => {
804
- const parsedUrl = parseRequestUrl(req.url || "/");
805
- const pathname = parsedUrl.pathname;
806
- const uptime = Math.floor((Date.now() - startTime) / 1000);
807
- const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`;
808
-
809
- if (pathname === "/health" || pathname === DASHBOARD_HEALTH_PATH) {
810
- res.writeHead(200, { "Content-Type": "application/json" });
811
- res.end(
812
- JSON.stringify({
813
- status: "ok",
814
- uptime,
815
- uptimeHuman,
816
- timestamp: new Date().toISOString(),
817
- }),
818
- );
819
- return;
820
- }
821
-
822
- if (pathname === "/status" || pathname === DASHBOARD_STATUS_PATH) {
823
- void (async () => {
824
- const guardianStatus = readGuardianStatus();
825
- res.writeHead(200, { "Content-Type": "application/json" });
826
  res.end(
827
  JSON.stringify({
828
- model: LLM_MODEL,
829
- whatsapp: {
830
- configured: guardianStatus.configured,
831
- connected: guardianStatus.connected,
832
- pairing: guardianStatus.pairing,
833
- },
834
- telegram: normalizeChannelStatus(null, TELEGRAM_ENABLED),
835
- sync: readSyncStatus(),
836
- uptime: uptimeHuman,
837
  }),
838
  );
839
- })();
840
- return;
841
- }
842
-
843
- if (isDashboardRoute(pathname)) {
844
- const guardianStatus = readGuardianStatus();
845
- const initialData = {
846
- model: LLM_MODEL,
847
- whatsapp: {
848
- configured: guardianStatus.configured,
849
- connected: guardianStatus.connected,
850
- pairing: guardianStatus.pairing,
851
- },
852
- telegram: normalizeChannelStatus(null, TELEGRAM_ENABLED),
853
- sync: readSyncStatus(),
854
- uptime: uptimeHuman,
855
- uptimerobotStatus: getUptimeRobotStatus(),
856
- };
857
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
858
- res.end(renderDashboard(initialData));
859
- return;
860
- }
861
-
862
- if (isDashboardAppRoute(pathname) || isAppRoute(pathname)) {
863
- const proxyPath = mapAppProxyPath(pathname) + sanitizeAppProxySearch(parsedUrl);
864
- proxyHttp(req, res, proxyPath, GATEWAY_PORT);
865
- return;
866
- }
867
 
868
- proxyHttp(req, res);
869
  });
870
 
871
  server.on("upgrade", (req, socket, head) => {
872
- const pathname = parseRequestUrl(req.url || "/").pathname;
873
- if (isLocalRoute(pathname)) {
874
- socket.destroy();
875
- return;
876
- }
877
-
878
- if (isDashboardAppRoute(pathname) || isAppRoute(pathname)) {
879
- const parsedUrl = parseRequestUrl(req.url || "/");
880
- const proxyPath = mapAppProxyPath(pathname) + sanitizeAppProxySearch(parsedUrl);
881
- proxyUpgrade(req, socket, head, proxyPath, GATEWAY_PORT);
882
- return;
883
- }
884
-
885
- proxyUpgrade(req, socket, head);
886
  });
887
 
888
- server.listen(PORT, "0.0.0.0", () => {
889
- console.log(
890
- `Health server listening on port ${PORT}; proxying gateway traffic to ${GATEWAY_HOST}:${GATEWAY_PORT}`,
891
- );
892
- });
 
13
  const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
14
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
15
  const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "180";
 
 
 
 
16
  const APP_BASE = "/app";
17
+ const SYNC_STATUS_FILE = "/tmp/sync-status.json";
18
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/huggingclaw-cloudflare-keepalive-status.json";
19
 
20
  function parseRequestUrl(url) {
21
  try {
 
25
  }
26
  }
27
 
28
+ function getSyncStatus() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  try {
30
+ if (fs.existsSync(SYNC_STATUS_FILE)) {
31
+ return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
32
  }
33
  } catch {}
34
  if (HF_BACKUP_ENABLED) {
35
  return {
36
  status: "configured",
37
+ message: `Backup is enabled. Waiting for sync window (${SYNC_INTERVAL}s).`,
38
  };
39
  }
40
  return { status: "unknown", message: "No sync data yet" };
41
  }
42
 
 
 
 
 
 
 
 
43
  function readGuardianStatus() {
44
  if (!WHATSAPP_ENABLED) {
45
  return { configured: false, connected: false, pairing: false };
 
57
  return { configured: true, connected: false, pairing: false };
58
  }
59
 
60
+ function getKeepaliveStatus() {
61
  try {
62
+ if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) {
63
+ return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
64
  }
65
  } catch {}
66
  return null;
67
  }
68
 
69
+ function probeGatewayHealth(timeoutMs = 1500) {
70
+ return new Promise((resolve) => {
71
+ const request = http.get(
72
+ {
73
+ hostname: GATEWAY_HOST,
74
+ port: GATEWAY_PORT,
75
+ path: "/health",
76
+ timeout: timeoutMs,
77
+ },
78
+ (response) => {
79
+ response.resume();
80
+ resolve(response.statusCode >= 200 && response.statusCode < 400);
81
+ },
82
+ );
83
+ request.on("timeout", () => {
84
+ request.destroy();
85
+ resolve(false);
86
+ });
87
+ request.on("error", () => resolve(false));
88
+ });
89
  }
90
 
91
+ function formatUptime(ms) {
92
+ const total = Math.floor(ms / 1000);
93
+ const days = Math.floor(total / 86400);
94
+ const hours = Math.floor((total % 86400) / 3600);
95
+ const minutes = Math.floor((total % 3600) / 60);
96
+ if (days) return `${days}d ${hours}h ${minutes}m`;
97
+ if (hours) return `${hours}h ${minutes}m`;
98
+ return `${minutes}m`;
99
+ }
100
 
101
+ function escapeHtml(value) {
102
+ return String(value)
103
+ .replace(/&/g, "&amp;")
104
+ .replace(/</g, "&lt;")
105
+ .replace(/>/g, "&gt;")
106
+ .replace(/"/g, "&quot;");
107
+ }
108
 
109
+ function toneBadge(label, tone = "neutral") {
110
+ return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
111
  }
112
 
113
+ function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
114
+ return `<article class="tile ${tone}">
115
+ <div class="tile-head">
116
+ <span class="tile-title">${escapeHtml(title)}</span>
117
+ <span class="tile-dot"></span>
118
+ </div>
119
+ <div class="tile-value">${value}</div>
120
+ ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
121
+ ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
122
+ </article>`;
123
+ }
124
+
125
+ function renderDashboard(data) {
126
+ const syncStatus = String(data.sync?.status || "unknown");
127
+ const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
128
+ ? "ok"
129
+ : syncStatus === "disabled"
130
+ ? "warn"
131
+ : "neutral";
132
+ const backupDetail = data.sync?.message ? escapeHtml(data.sync.message) : "No status yet";
133
+
134
+ const keepaliveConfigured = data.keepalive?.configured === true;
135
+ const keepaliveStatus = String(
136
+ data.keepalive?.status ||
137
+ (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
138
+ );
139
+ const keepAliveTone = keepaliveConfigured
140
+ ? "ok"
141
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
142
+ ? "warn"
143
+ : "neutral";
144
+ const keepAliveDetail = keepaliveConfigured
145
+ ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
146
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
147
+ ? "Worker pending or failed"
148
+ : "Not configured";
149
+
150
+ const tiles = [
151
+ renderTile({
152
+ title: "Gateway",
153
+ value: toneBadge(data.gatewayReady ? "Online" : "Offline", data.gatewayReady ? "ok" : "off"),
154
+ detail: `Internal Port ${GATEWAY_PORT}`,
155
+ tone: data.gatewayReady ? "ok" : "off",
156
+ }),
157
+ renderTile({
158
+ title: "Model",
159
+ value: `<code>${escapeHtml(LLM_MODEL)}</code>`,
160
+ detail: `Primary LLM Configured`,
161
+ tone: "neutral",
162
+ }),
163
+ renderTile({
164
+ title: "Runtime",
165
+ value: escapeHtml(data.uptimeHuman),
166
+ detail: `Public Port ${PORT}`,
167
+ tone: "neutral",
168
+ }),
169
+ renderTile({
170
+ title: "WhatsApp",
171
+ value: toneBadge(data.whatsapp.connected ? "Connected" : (data.whatsapp.configured ? "Ready" : "Disabled"), data.whatsapp.connected ? "ok" : (data.whatsapp.configured ? "warn" : "neutral")),
172
+ detail: data.whatsapp.connected ? "Active Session" : (data.whatsapp.pairing ? "Pairing Requested" : "Channel configured"),
173
+ tone: data.whatsapp.connected ? "ok" : (data.whatsapp.configured ? "warn" : "neutral"),
174
+ }),
175
+ renderTile({
176
+ title: "Telegram",
177
+ value: toneBadge(TELEGRAM_ENABLED ? "Enabled" : "Disabled", TELEGRAM_ENABLED ? "ok" : "neutral"),
178
+ detail: TELEGRAM_ENABLED ? "Bot Channel active" : "Not configured",
179
+ tone: TELEGRAM_ENABLED ? "ok" : "neutral",
180
+ }),
181
+ renderTile({
182
+ title: "Backup",
183
+ value: toneBadge(syncStatus.toUpperCase(), syncTone),
184
+ detail: backupDetail,
185
+ tone: syncTone,
186
+ }),
187
+ renderTile({
188
+ title: "Keep Awake",
189
+ value: toneBadge(keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), keepAliveTone),
190
+ detail: keepAliveDetail,
191
+ tone: keepAliveTone,
192
+ }),
193
+ ].join("");
194
+
195
+ return `<!doctype html>
196
  <html lang="en">
197
  <head>
198
+ <meta charset="utf-8" />
199
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
200
+ <title>HuggingClaw</title>
201
+ <style>
202
+ :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --panel2:#151421; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#3b82f6; --accent2:#8b5cf6; }
203
+ * { box-sizing:border-box; }
204
+ body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; }
205
+ main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
206
+ header { text-align:center; margin-bottom:22px; }
207
+ h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; }
208
+ .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
209
+ .hero-action { display:flex; width:100%; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:linear-gradient(135deg, var(--accent), var(--accent2)); color:#ffffff; text-decoration:none; font-weight:850; font-size:.98rem; margin:24px 0 20px; transition: opacity 0.15s ease; }
210
+ .hero-action:hover { opacity: 0.9; }
211
+ .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
212
+ .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; }
213
+ .tile.ok { border-color:rgba(34,197,94,.22); }
214
+ .tile.warn { border-color:rgba(245,197,66,.24); }
215
+ .tile.off { border-color:rgba(251,113,133,.28); }
216
+ .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
217
+ .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
218
+ .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
219
+ .tile.ok .tile-dot { background:var(--good); }
220
+ .tile.warn .tile-dot { background:var(--warn); }
221
+ .tile.off .tile-dot { background:var(--bad); }
222
+ .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
223
+ .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
224
+ .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
225
+
226
+ code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
227
+ .badge { display:inline-flex; align-items:center; width:max-content; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; }
228
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
229
+ .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
230
+ .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
231
+ .badge.neutral { color:var(--soft); }
232
+ footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
233
+ footer .live { color:var(--good); }
234
+ @media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } }
235
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  </head>
237
  <body>
238
+ <main>
239
+ <header>
240
+ <h1>🦞 HuggingClaw</h1>
241
+ <div class="subtitle">OpenClaw Gateway Dashboard</div>
242
+ </header>
243
+ <a class="hero-action" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open Control UI -></a>
244
+ <section class="overview">
245
+ ${tiles}
246
+ </section>
247
+ <footer><span class="live">Live</span> status - Health endpoint: <code>/health</code></footer>
248
+ </main>
249
+ </body>
250
+ </html>`;
251
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
253
+ const server = http.createServer(async (req, res) => {
254
+ const url = parseRequestUrl(req.url);
255
+ const pathname = url.pathname;
 
 
 
 
256
 
257
+ // 1. Dashboard Routes
258
+ if (pathname === "/health") {
259
+ const gatewayReady = await probeGatewayHealth();
260
+ res.writeHead(gatewayReady ? 200 : 503, { "Content-Type": "application/json" });
261
+ return res.end(
262
+ JSON.stringify({
263
+ status: gatewayReady ? "ok" : "degraded",
264
+ gatewayReady,
265
+ uptime: formatUptime(Date.now() - startTime),
266
+ sync: getSyncStatus(),
267
+ keepalive: getKeepaliveStatus(),
268
+ }),
269
+ );
270
+ }
271
 
272
+ if (pathname === "/status") {
273
+ const gatewayReady = await probeGatewayHealth();
274
+ res.writeHead(200, { "Content-Type": "application/json" });
275
+ return res.end(
276
+ JSON.stringify({
277
+ model: LLM_MODEL,
278
+ uptime: formatUptime(Date.now() - startTime),
279
+ gatewayReady,
280
+ sync: getSyncStatus(),
281
+ whatsapp: readGuardianStatus(),
282
+ keepalive: getKeepaliveStatus(),
283
+ }),
284
+ );
285
+ }
286
 
287
+ if (pathname === "/" || pathname === "/dashboard") {
288
+ const gatewayReady = await probeGatewayHealth();
289
+ res.writeHead(200, { "Content-Type": "text/html" });
290
+ return res.end(
291
+ renderDashboard({
292
+ uptimeHuman: formatUptime(Date.now() - startTime),
293
+ gatewayReady,
294
+ sync: getSyncStatus(),
295
+ whatsapp: readGuardianStatus(),
296
+ keepalive: getKeepaliveStatus(),
297
+ }),
298
+ );
299
+ }
300
 
301
+ // 2. OpenClaw Proxy Logic
302
+ const proxyHeaders = {
303
+ ...req.headers,
304
+ host: `${GATEWAY_HOST}:${GATEWAY_PORT}`,
305
+ "x-forwarded-for": req.socket.remoteAddress,
306
+ "x-forwarded-host": req.headers.host,
307
+ "x-forwarded-proto": "https",
308
+ };
309
 
 
 
 
310
  const proxyReq = http.request(
311
  {
312
  hostname: GATEWAY_HOST,
313
+ port: GATEWAY_PORT,
314
+ path: pathname + url.search,
315
  method: req.method,
316
+ headers: proxyHeaders,
 
317
  },
318
  (proxyRes) => {
319
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
 
320
  proxyRes.pipe(res);
321
+ proxyRes.on("error", (err) => {
322
+ console.error("proxyRes error:", err);
323
+ res.end();
324
+ });
325
  },
326
  );
327
 
328
+ req.on("error", (err) => {
329
+ console.error("req error:", err);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  proxyReq.destroy();
331
  });
332
 
333
+ res.on("error", (err) => {
334
+ console.error("res error:", err);
335
+ proxyReq.destroy();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  });
 
337
 
338
+ proxyReq.on("error", (err) => {
339
+ console.error("proxyReq error:", err);
340
+ if (!res.headersSent) {
341
+ res.writeHead(503, { "Content-Type": "application/json" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  res.end(
343
  JSON.stringify({
344
+ status: "starting",
345
+ message: "Gateway is initializing... or connection failed",
 
 
 
 
 
 
 
346
  }),
347
  );
348
+ } else {
349
+ res.end();
350
+ }
351
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
+ req.pipe(proxyReq);
354
  });
355
 
356
  server.on("upgrade", (req, socket, head) => {
357
+ const url = parseRequestUrl(req.url);
358
+ const proxyPath = url.pathname;
359
+ const proxySocket = net.connect(GATEWAY_PORT, GATEWAY_HOST, () => {
360
+ proxySocket.write(
361
+ `${req.method} ${proxyPath}${url.search} HTTP/${req.httpVersion}\r\n`,
362
+ );
363
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
364
+ proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
365
+ }
366
+ proxySocket.write("\r\n");
367
+ if (head && head.length) proxySocket.write(head);
368
+ proxySocket.pipe(socket).pipe(proxySocket);
369
+ });
370
+ proxySocket.on("error", () => socket.destroy());
371
  });
372
 
373
+ server.timeout = 0;
374
+ server.keepAliveTimeout = 65000;
375
+ server.listen(PORT, "0.0.0.0", () =>
376
+ console.log(`🦞 HuggingClaw Dashboard on ${PORT} -> Gateway on ${GATEWAY_PORT}`),
377
+ );
setup-uptimerobot.sh DELETED
@@ -1,91 +0,0 @@
1
- #!/bin/bash
2
- set -euo pipefail
3
-
4
- # Create or update a UptimeRobot monitor for this Hugging Face Space.
5
- #
6
- # Requirements:
7
- # - UPTIMEROBOT_API_KEY: Main API key from UptimeRobot
8
- # - SPACE_HOST or first CLI arg: your HF Space host, e.g. "user-space.hf.space"
9
- #
10
- # Optional:
11
- # - UPTIMEROBOT_MONITOR_NAME: friendly name for the monitor
12
- # - UPTIMEROBOT_ALERT_CONTACTS: dash-separated alert contact IDs, e.g. "123456-789012"
13
- # - UPTIMEROBOT_INTERVAL: monitoring interval in seconds (default: 300 = 5 min; min: 30)
14
-
15
- API_URL="https://api.uptimerobot.com/v2"
16
- API_KEY="${UPTIMEROBOT_API_KEY:-}"
17
- SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
18
- STATUS_FILE="/tmp/huggingclaw-uptimerobot-status.json"
19
-
20
- if [ -z "$API_KEY" ]; then
21
- echo "Missing UPTIMEROBOT_API_KEY."
22
- echo "Use the Main API key from UptimeRobot -> Integrations."
23
- echo "Do not use the Read-only API key or a Monitor-specific API key."
24
- exit 1
25
- fi
26
-
27
- if [ -z "$SPACE_HOST_INPUT" ]; then
28
- echo "Missing Space host."
29
- echo "Usage: UPTIMEROBOT_API_KEY=... ./setup-uptimerobot.sh your-space.hf.space"
30
- exit 1
31
- fi
32
-
33
- SPACE_HOST_CLEAN="${SPACE_HOST_INPUT#https://}"
34
- SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN#http://}"
35
- SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
36
-
37
- MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
38
- MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-HuggingClaw ${SPACE_HOST_CLEAN}}"
39
- INTERVAL="${UPTIMEROBOT_INTERVAL:-300}"
40
-
41
- echo "Checking existing UptimeRobot monitors for ${MONITOR_URL}..."
42
- MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
43
- -d "api_key=${API_KEY}" \
44
- -d "format=json" \
45
- -d "logs=0" \
46
- -d "response_times=0" \
47
- -d "response_times_limit=1")
48
-
49
- MONITOR_ID=$(printf '%s' "$MONITORS_RESPONSE" | jq -r --arg url "$MONITOR_URL" '
50
- (.monitors // []) | map(select(.url == $url)) | first | .id // empty
51
- ')
52
-
53
- if [ -n "$MONITOR_ID" ]; then
54
- printf '{"configured":true,"monitorId":"%s","url":"%s","alreadyExisted":true,"timestamp":"%s"}\n' \
55
- "$MONITOR_ID" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
56
- echo "Monitor already exists (id=${MONITOR_ID}) for ${MONITOR_URL}"
57
- exit 0
58
- fi
59
-
60
- echo "Creating new UptimeRobot monitor for ${MONITOR_URL}..."
61
-
62
- CURL_ARGS=(
63
- -sS
64
- -X POST "${API_URL}/newMonitor"
65
- -d "api_key=${API_KEY}"
66
- -d "format=json"
67
- -d "type=1"
68
- -d "friendly_name=${MONITOR_NAME}"
69
- -d "url=${MONITOR_URL}"
70
- -d "interval=${INTERVAL}"
71
- )
72
-
73
- if [ -n "${UPTIMEROBOT_ALERT_CONTACTS:-}" ]; then
74
- CURL_ARGS+=(-d "alert_contacts=${UPTIMEROBOT_ALERT_CONTACTS}")
75
- fi
76
-
77
- CREATE_RESPONSE=$(curl "${CURL_ARGS[@]}")
78
- CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
79
-
80
- if [ "$CREATE_STATUS" != "ok" ]; then
81
- printf '{"configured":false,"error":"creation failed","timestamp":"%s"}\n' \
82
- "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
83
- echo "Failed to create monitor."
84
- printf '%s\n' "$CREATE_RESPONSE"
85
- exit 1
86
- fi
87
-
88
- NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
89
- printf '{"configured":true,"monitorId":"%s","url":"%s","timestamp":"%s"}\n' \
90
- "${NEW_ID:-}" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
91
- echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
start.sh CHANGED
@@ -497,9 +497,9 @@ export LLM_MODEL="$LLM_MODEL"
497
  node /home/node/app/health-server.js &
498
  HEALTH_PID=$!
499
 
500
- if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
501
- echo "Setting up UptimeRobot monitor..."
502
- bash /home/node/app/setup-uptimerobot.sh "${SPACE_HOST}" || true
503
  fi
504
 
505
  # ── Launch gateway ──
 
497
  node /home/node/app/health-server.js &
498
  HEALTH_PID=$!
499
 
500
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
501
+ echo "Setting up Cloudflare KeepAlive monitor..."
502
+ python3 /home/node/app/cloudflare-keepalive-setup.py || true
503
  fi
504
 
505
  # ── Launch gateway ──