somratpro commited on
Commit
728bb65
·
1 Parent(s): b596ce2

Add Cloudflare keepalive worker and restyle dashboard

Browse files
Files changed (4) hide show
  1. README.md +31 -26
  2. cloudflare-proxy-setup.py +175 -35
  3. health-server.js +66 -80
  4. start.sh +1 -1
README.md CHANGED
@@ -21,14 +21,12 @@ secrets:
21
  - name: HF_TOKEN
22
  description: "Hugging Face token with write access for private Dataset backup."
23
  - name: CLOUDFLARE_WORKERS_TOKEN
24
- description: "Cloudflare API token for automatic Worker proxy setup."
25
- - name: UPTIMEROBOT_API_KEY
26
- description: "UptimeRobot Main API key for automatic keep-awake monitor setup."
27
  ---
28
 
29
  # HuggingMess
30
 
31
- HuggingMess runs [Nous Research Hermes Agent](https://github.com/NousResearch/hermes-agent) as a Hugging Face Docker Space. It follows the same practical shape as HuggingClaw: one public Space port, Telegram gateway support, Cloudflare Worker proxy setup, UptimeRobot keep-awake, and private HF Dataset backup for Hermes state.
32
 
33
  ## Quick Start
34
 
@@ -43,8 +41,7 @@ HuggingMess runs [Nous Research Hermes Agent](https://github.com/NousResearch/he
43
  | `TELEGRAM_ALLOWED_USERS` | Recommended | Comma-separated numeric Telegram user IDs |
44
  | `GATEWAY_TOKEN` | Recommended | Bearer token for `/v1/*` API routes |
45
  | `HF_TOKEN` | Optional | Enables private Dataset backup named `huggingmess-backup` |
46
- | `CLOUDFLARE_WORKERS_TOKEN` | Optional | Auto-creates a Worker proxy for Telegram Bot API traffic |
47
- | `UPTIMEROBOT_API_KEY` | Optional | Auto-creates a monitor for `/health` |
48
 
49
  ## Access Control
50
 
@@ -124,14 +121,15 @@ TELEGRAM_MODE=polling
124
 
125
  Hermes requires numeric Telegram IDs for allowlists. You can use either Hermes-native `TELEGRAM_ALLOWED_USERS` or the HuggingClaw-style aliases `TELEGRAM_USER_ID` / `TELEGRAM_USER_IDS`.
126
 
127
- ## Cloudflare Proxy
128
 
129
  Hugging Face Spaces can be restrictive for outbound bot API traffic. Add `CLOUDFLARE_WORKERS_TOKEN`, and HuggingMess will:
130
 
131
- 1. create a Cloudflare Worker,
132
  2. generate a shared proxy secret,
133
  3. set Hermes Telegram `base_url` to `https://worker.example.workers.dev/bot`,
134
- 4. set `base_file_url` to `https://worker.example.workers.dev/file/bot`.
 
135
 
136
  Manual mode is also supported:
137
 
@@ -142,6 +140,29 @@ CLOUDFLARE_PROXY_SECRET=optional-shared-secret
142
 
143
  The manual Worker source is included in `cloudflare-worker.js`.
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  ## Backup
146
 
147
  Set `HF_TOKEN` with write access to enable backup. HuggingMess syncs `/opt/data` to a private Dataset named `huggingmess-backup` every 180 seconds by default.
@@ -154,22 +175,6 @@ Set `HF_TOKEN` with write access to enable backup. HuggingMess syncs `/opt/data`
154
 
155
  By default `.env` is excluded from backups because HF Space secrets are already injected at runtime.
156
 
157
- ## Keep Awake
158
-
159
- Add `UPTIMEROBOT_API_KEY`, and HuggingMess creates or reuses a monitor for:
160
-
161
- ```text
162
- https://your-space.hf.space/health
163
- ```
164
-
165
- Optional UptimeRobot variables:
166
-
167
- | Variable | Default | Description |
168
- | :--- | :--- | :--- |
169
- | `UPTIMEROBOT_MONITOR_NAME` | `HuggingMess <space>` | Friendly monitor name |
170
- | `UPTIMEROBOT_INTERVAL` | `300` | Monitor interval in seconds |
171
- | `UPTIMEROBOT_ALERT_CONTACTS` | unset | Dash-separated alert contact IDs |
172
-
173
  ## Local Development
174
 
175
  ```bash
@@ -187,7 +192,7 @@ http://localhost:7861
187
  | Route | Purpose |
188
  | :--- | :--- |
189
  | `/` | HuggingMess dashboard |
190
- | `/health` | Health check for HF and UptimeRobot |
191
  | `/status` | JSON status |
192
  | `/app/` | Proxied Hermes dashboard/app |
193
  | `/v1/*` | Proxied Hermes OpenAI-compatible API routes |
 
21
  - name: HF_TOKEN
22
  description: "Hugging Face token with write access for private Dataset backup."
23
  - name: CLOUDFLARE_WORKERS_TOKEN
24
+ description: "Cloudflare API token for automatic Telegram proxy and keep-awake Worker setup."
 
 
25
  ---
26
 
27
  # HuggingMess
28
 
29
+ HuggingMess runs [Nous Research Hermes Agent](https://github.com/NousResearch/hermes-agent) as a Hugging Face Docker Space. It follows the same practical shape as HuggingClaw: one public Space port, Telegram gateway support, Cloudflare Worker proxy setup, Cloudflare cron keep-awake, and private HF Dataset backup for Hermes state.
30
 
31
  ## Quick Start
32
 
 
41
  | `TELEGRAM_ALLOWED_USERS` | Recommended | Comma-separated numeric Telegram user IDs |
42
  | `GATEWAY_TOKEN` | Recommended | Bearer token for `/v1/*` API routes |
43
  | `HF_TOKEN` | Optional | Enables private Dataset backup named `huggingmess-backup` |
44
+ | `CLOUDFLARE_WORKERS_TOKEN` | Optional | Auto-creates Workers for Telegram proxy and `/health` keep-awake |
 
45
 
46
  ## Access Control
47
 
 
121
 
122
  Hermes requires numeric Telegram IDs for allowlists. You can use either Hermes-native `TELEGRAM_ALLOWED_USERS` or the HuggingClaw-style aliases `TELEGRAM_USER_ID` / `TELEGRAM_USER_IDS`.
123
 
124
+ ## Cloudflare Workers
125
 
126
  Hugging Face Spaces can be restrictive for outbound bot API traffic. Add `CLOUDFLARE_WORKERS_TOKEN`, and HuggingMess will:
127
 
128
+ 1. create a Telegram proxy Worker,
129
  2. generate a shared proxy secret,
130
  3. set Hermes Telegram `base_url` to `https://worker.example.workers.dev/bot`,
131
+ 4. set `base_file_url` to `https://worker.example.workers.dev/file/bot`,
132
+ 5. create a second scheduled keep-awake Worker that pings your Space `/health` route.
133
 
134
  Manual mode is also supported:
135
 
 
140
 
141
  The manual Worker source is included in `cloudflare-worker.js`.
142
 
143
+ ### Keep Awake
144
+
145
+ When `CLOUDFLARE_WORKERS_TOKEN` and `SPACE_HOST` are present, HuggingMess creates a scheduled Worker that pings:
146
+
147
+ ```text
148
+ https://your-space.hf.space/health
149
+ ```
150
+
151
+ Default schedule:
152
+
153
+ ```text
154
+ */10 * * * *
155
+ ```
156
+
157
+ Optional variables:
158
+
159
+ | Variable | Default | Description |
160
+ | :--- | :--- | :--- |
161
+ | `CLOUDFLARE_KEEPALIVE_ENABLED` | `true` | Set `false` to skip keep-awake Worker setup |
162
+ | `CLOUDFLARE_KEEPALIVE_CRON` | `*/10 * * * *` | Cloudflare cron expression |
163
+ | `CLOUDFLARE_KEEPALIVE_URL` | `https://<SPACE_HOST>/health` | URL to ping |
164
+ | `CLOUDFLARE_KEEPALIVE_WORKER_NAME` | derived from `SPACE_HOST` | Custom Worker name |
165
+
166
  ## Backup
167
 
168
  Set `HF_TOKEN` with write access to enable backup. HuggingMess syncs `/opt/data` to a private Dataset named `huggingmess-backup` every 180 seconds by default.
 
175
 
176
  By default `.env` is excluded from backups because HF Space secrets are already injected at runtime.
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  ## Local Development
179
 
180
  ```bash
 
192
  | Route | Purpose |
193
  | :--- | :--- |
194
  | `/` | HuggingMess dashboard |
195
+ | `/health` | Health check for HF and Cloudflare keep-awake |
196
  | `/status` | JSON status |
197
  | `/app/` | Proxied Hermes dashboard/app |
198
  | `/v1/*` | Proxied Hermes OpenAI-compatible API routes |
cloudflare-proxy-setup.py CHANGED
@@ -1,16 +1,20 @@
1
  #!/usr/bin/env python3
2
- """Create or reuse a Cloudflare Worker that proxies Telegram Bot API calls."""
 
 
3
 
4
  import json
5
  import os
6
  import re
7
  import secrets
8
  import sys
 
9
  import urllib.request
10
  from pathlib import Path
11
 
12
  API_BASE = "https://api.cloudflare.com/client/v4"
13
  ENV_FILE = Path("/tmp/huggingmess-cloudflare-proxy.env")
 
14
  DEFAULT_ALLOWED = [
15
  "api.telegram.org",
16
  "discord.com",
@@ -61,6 +65,16 @@ def derive_worker_name() -> str:
61
  return "huggingmess-proxy"
62
 
63
 
 
 
 
 
 
 
 
 
 
 
64
  def render_worker(secret_value: str, allowed_targets: list[str], allow_proxy_all: bool) -> str:
65
  return f"""addEventListener("fetch", (event) => {{
66
  event.respondWith(handleRequest(event.request));
@@ -127,6 +141,61 @@ async function handleRequest(request) {{
127
  """
128
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  def write_env(proxy_url: str, proxy_secret: str) -> None:
131
  ENV_FILE.write_text(
132
  f'export CLOUDFLARE_PROXY_URL="{proxy_url}"\nexport CLOUDFLARE_PROXY_SECRET="{proxy_secret}"\n',
@@ -135,6 +204,84 @@ def write_env(proxy_url: str, proxy_secret: str) -> None:
135
  ENV_FILE.chmod(0o600)
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def main() -> int:
139
  existing_url = os.environ.get("CLOUDFLARE_PROXY_URL", "").strip()
140
  existing_secret = os.environ.get("CLOUDFLARE_PROXY_SECRET", "").strip()
@@ -142,48 +289,41 @@ def main() -> int:
142
 
143
  if existing_url:
144
  write_env(existing_url, existing_secret)
145
- return 0
146
 
147
  if not api_token:
148
  return 0
149
 
150
  try:
151
- account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
152
- if not account_id:
153
- accounts = cf_request("GET", "/accounts", api_token)
154
- if not accounts:
155
- raise RuntimeError("No Cloudflare account is available for this token.")
156
- account_id = accounts[0]["id"]
157
-
158
- subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
159
- subdomain = (subdomain_info or {}).get("subdomain", "").strip()
160
- if not subdomain:
161
- raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
162
-
163
- allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
164
- allow_proxy_all = allowed_raw == "*"
165
- extra = [] if allow_proxy_all else [v.strip() for v in allowed_raw.split(",") if v.strip()]
166
- allowed = list(dict.fromkeys(DEFAULT_ALLOWED + extra))
167
- worker_name = derive_worker_name()
168
- proxy_secret = existing_secret or secrets.token_urlsafe(24)
169
-
170
- cf_request(
171
- "PUT",
172
- f"/accounts/{account_id}/workers/scripts/{worker_name}",
173
- api_token,
174
- body=render_worker(proxy_secret, allowed, allow_proxy_all).encode("utf-8"),
175
- content_type="application/javascript",
176
- )
177
- cf_request(
178
- "POST",
179
- f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
180
- api_token,
181
- body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
182
- )
183
- write_env(f"https://{worker_name}.{subdomain}.workers.dev", proxy_secret)
184
  return 0
185
  except Exception as exc:
186
  print(f"Cloudflare proxy setup failed: {exc}", file=sys.stderr)
 
187
  return 1
188
 
189
 
 
1
  #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Create or reuse Cloudflare Workers for Telegram proxy and Space keep-awake."""
5
 
6
  import json
7
  import os
8
  import re
9
  import secrets
10
  import sys
11
+ import time
12
  import urllib.request
13
  from pathlib import Path
14
 
15
  API_BASE = "https://api.cloudflare.com/client/v4"
16
  ENV_FILE = Path("/tmp/huggingmess-cloudflare-proxy.env")
17
+ KEEPALIVE_STATUS_FILE = Path("/tmp/huggingmess-cloudflare-keepalive-status.json")
18
  DEFAULT_ALLOWED = [
19
  "api.telegram.org",
20
  "discord.com",
 
65
  return "huggingmess-proxy"
66
 
67
 
68
+ def derive_keepalive_worker_name() -> str:
69
+ explicit = os.environ.get("CLOUDFLARE_KEEPALIVE_WORKER_NAME", "").strip()
70
+ if explicit:
71
+ return slugify(explicit)
72
+ space_host = os.environ.get("SPACE_HOST", "").strip()
73
+ if space_host:
74
+ return slugify(f"{space_host.replace('.hf.space', '')}-keepalive")
75
+ return "huggingmess-keepalive"
76
+
77
+
78
  def render_worker(secret_value: str, allowed_targets: list[str], allow_proxy_all: bool) -> str:
79
  return f"""addEventListener("fetch", (event) => {{
80
  event.respondWith(handleRequest(event.request));
 
141
  """
142
 
143
 
144
+ def render_keepalive_worker(target_url: str) -> str:
145
+ return f"""addEventListener("fetch", (event) => {{
146
+ event.respondWith(handleRequest(event.request));
147
+ }});
148
+
149
+ addEventListener("scheduled", (event) => {{
150
+ event.waitUntil(ping("cron"));
151
+ }});
152
+
153
+ const TARGET_URL = {json.dumps(target_url)};
154
+
155
+ async function ping(source) {{
156
+ const startedAt = new Date().toISOString();
157
+ try {{
158
+ const response = await fetch(TARGET_URL, {{
159
+ method: "GET",
160
+ headers: {{
161
+ "user-agent": "HuggingMess Cloudflare KeepAlive",
162
+ "cache-control": "no-cache"
163
+ }},
164
+ cf: {{ cacheTtl: 0, cacheEverything: false }}
165
+ }});
166
+ return {{
167
+ ok: response.ok,
168
+ status: response.status,
169
+ source,
170
+ target: TARGET_URL,
171
+ timestamp: startedAt
172
+ }};
173
+ }} catch (error) {{
174
+ return {{
175
+ ok: false,
176
+ status: 0,
177
+ source,
178
+ target: TARGET_URL,
179
+ timestamp: startedAt,
180
+ error: error.message
181
+ }};
182
+ }}
183
+ }}
184
+
185
+ async function handleRequest(request) {{
186
+ const url = new URL(request.url);
187
+ if (url.pathname === "/" || url.pathname === "/health" || url.pathname === "/ping") {{
188
+ const result = await ping("manual");
189
+ return new Response(JSON.stringify(result, null, 2), {{
190
+ status: result.ok ? 200 : 502,
191
+ headers: {{ "content-type": "application/json; charset=utf-8" }}
192
+ }});
193
+ }}
194
+ return new Response("Not found", {{ status: 404 }});
195
+ }}
196
+ """
197
+
198
+
199
  def write_env(proxy_url: str, proxy_secret: str) -> None:
200
  ENV_FILE.write_text(
201
  f'export CLOUDFLARE_PROXY_URL="{proxy_url}"\nexport CLOUDFLARE_PROXY_SECRET="{proxy_secret}"\n',
 
204
  ENV_FILE.chmod(0o600)
205
 
206
 
207
+ def write_keepalive_status(payload: dict) -> None:
208
+ payload = {
209
+ **payload,
210
+ "timestamp": payload.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
211
+ }
212
+ KEEPALIVE_STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
213
+ try:
214
+ KEEPALIVE_STATUS_FILE.chmod(0o600)
215
+ except OSError:
216
+ pass
217
+
218
+
219
+ def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
220
+ account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
221
+ if not account_id:
222
+ accounts = cf_request("GET", "/accounts", api_token)
223
+ if not accounts:
224
+ raise RuntimeError("No Cloudflare account is available for this token.")
225
+ account_id = accounts[0]["id"]
226
+
227
+ subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
228
+ subdomain = (subdomain_info or {}).get("subdomain", "").strip()
229
+ if not subdomain:
230
+ raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
231
+ return account_id, subdomain
232
+
233
+
234
+ def setup_keepalive_worker(api_token: str, account_id: str, subdomain: str) -> None:
235
+ enabled = os.environ.get("CLOUDFLARE_KEEPALIVE_ENABLED", "true").strip().lower()
236
+ if enabled in {"0", "false", "no", "off"}:
237
+ write_keepalive_status({"configured": False, "status": "disabled", "message": "Cloudflare keep-awake is disabled."})
238
+ return
239
+
240
+ space_host = os.environ.get("SPACE_HOST", "").strip()
241
+ if not space_host:
242
+ write_keepalive_status({"configured": False, "status": "skipped", "message": "SPACE_HOST is not set."})
243
+ return
244
+
245
+ cron = os.environ.get("CLOUDFLARE_KEEPALIVE_CRON", "*/10 * * * *").strip()
246
+ space_host = space_host.removeprefix("https://").removeprefix("http://").split("/")[0]
247
+ target_url = os.environ.get("CLOUDFLARE_KEEPALIVE_URL", f"https://{space_host}/health").strip()
248
+ worker_name = derive_keepalive_worker_name()
249
+ worker_source = render_keepalive_worker(target_url)
250
+
251
+ cf_request(
252
+ "PUT",
253
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
254
+ api_token,
255
+ body=worker_source.encode("utf-8"),
256
+ content_type="application/javascript",
257
+ )
258
+ cf_request(
259
+ "POST",
260
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
261
+ api_token,
262
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
263
+ )
264
+ cf_request(
265
+ "PUT",
266
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/schedules",
267
+ api_token,
268
+ body=json.dumps([{"cron": cron}]).encode("utf-8"),
269
+ )
270
+
271
+ worker_url = f"https://{worker_name}.{subdomain}.workers.dev"
272
+ write_keepalive_status(
273
+ {
274
+ "configured": True,
275
+ "status": "configured",
276
+ "workerName": worker_name,
277
+ "workerUrl": worker_url,
278
+ "targetUrl": target_url,
279
+ "cron": cron,
280
+ "message": f"Cloudflare Worker cron pings {target_url} on {cron}.",
281
+ }
282
+ )
283
+
284
+
285
  def main() -> int:
286
  existing_url = os.environ.get("CLOUDFLARE_PROXY_URL", "").strip()
287
  existing_secret = os.environ.get("CLOUDFLARE_PROXY_SECRET", "").strip()
 
289
 
290
  if existing_url:
291
  write_env(existing_url, existing_secret)
 
292
 
293
  if not api_token:
294
  return 0
295
 
296
  try:
297
+ account_id, subdomain = resolve_account_and_subdomain(api_token)
298
+
299
+ if not existing_url:
300
+ allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
301
+ allow_proxy_all = allowed_raw == "*"
302
+ extra = [] if allow_proxy_all else [v.strip() for v in allowed_raw.split(",") if v.strip()]
303
+ allowed = list(dict.fromkeys(DEFAULT_ALLOWED + extra))
304
+ worker_name = derive_worker_name()
305
+ proxy_secret = existing_secret or secrets.token_urlsafe(24)
306
+
307
+ cf_request(
308
+ "PUT",
309
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
310
+ api_token,
311
+ body=render_worker(proxy_secret, allowed, allow_proxy_all).encode("utf-8"),
312
+ content_type="application/javascript",
313
+ )
314
+ cf_request(
315
+ "POST",
316
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
317
+ api_token,
318
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
319
+ )
320
+ write_env(f"https://{worker_name}.{subdomain}.workers.dev", proxy_secret)
321
+
322
+ setup_keepalive_worker(api_token, account_id, subdomain)
 
 
 
 
 
 
 
323
  return 0
324
  except Exception as exc:
325
  print(f"Cloudflare proxy setup failed: {exc}", file=sys.stderr)
326
+ write_keepalive_status({"configured": False, "status": "error", "message": str(exc)})
327
  return 1
328
 
329
 
health-server.js CHANGED
@@ -17,7 +17,7 @@ const LOGIN_PATH = "/login";
17
  const SESSION_COOKIE = "huggingmess_session";
18
 
19
  const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
20
- const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingmess-uptimerobot-status.json";
21
 
22
  function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
23
  return new Promise((resolve) => {
@@ -314,7 +314,7 @@ async function statusPayload() {
314
  model: process.env.MODEL_FOR_CONFIG || process.env.HERMES_MODEL || process.env.LLM_MODEL || "",
315
  provider: process.env.PROVIDER_FOR_CONFIG || process.env.HERMES_INFERENCE_PROVIDER || "auto",
316
  backup: sync,
317
- uptimerobot: readJson(UPTIMEROBOT_STATUS_FILE, null),
318
  };
319
  }
320
 
@@ -346,61 +346,46 @@ function renderDashboard(data) {
346
  const syncStatus = String(data.backup?.status || "unknown");
347
  const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral";
348
  const telegramTone = data.telegram.configured ? (data.telegram.webhookListening || !data.telegram.webhook ? "ok" : "warn") : "warn";
349
- const keepAliveTone = data.uptimerobot?.configured ? "ok" : process.env.UPTIMEROBOT_API_KEY ? "warn" : "neutral";
350
- const gatewayDetail = data.gateway
351
- ? `OpenAI-compatible API is listening on internal port <code>${data.ports.gateway}</code>.`
352
- : `Gateway API is not reachable on internal port <code>${data.ports.gateway}</code>.`;
353
- const appDetail = data.dashboard
354
- ? `Hermes dashboard is listening on internal port <code>${data.ports.dashboard}</code>.`
355
- : `Hermes dashboard is not reachable on internal port <code>${data.ports.dashboard}</code>.`;
356
- const authDetail = data.authConfigured
357
- ? `Protected by <code>GATEWAY_TOKEN</code> with a token-only login page.`
358
- : `No <code>GATEWAY_TOKEN</code> is set; public app routes are unlocked.`;
359
  const telegramDetail = data.telegram.configured
360
  ? `${data.telegram.webhook ? "Webhook mode" : "Polling mode"}${data.telegram.proxy ? ` through Cloudflare proxy` : ""}.`
361
  : "Add TELEGRAM_BOT_TOKEN to enable Telegram.";
362
  const backupDetail = data.backup?.message ? escapeHtml(data.backup.message) : "No backup status has been written yet.";
363
  const backupMeta = data.backup?.timestamp ? `Last update ${escapeHtml(data.backup.timestamp)}` : "";
364
- const keepAliveDetail = data.uptimerobot?.configured
365
- ? `Monitoring <code>${escapeHtml(data.uptimerobot.url || "/health")}</code>.`
366
- : process.env.UPTIMEROBOT_API_KEY
367
- ? "UptimeRobot setup is pending or failed; check Space logs."
368
- : "Add UPTIMEROBOT_API_KEY to create a keep-awake monitor.";
 
 
 
 
 
 
369
  const tiles = [
370
  renderTile({
371
  title: "Gateway",
372
  value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"),
373
- detail: gatewayDetail,
374
  tone: data.gateway ? "ok" : "off",
375
- meta: `API routes are protected by <code>GATEWAY_TOKEN</code>.`,
376
- }),
377
- renderTile({
378
- title: "Hermes App",
379
- value: toneBadge(data.dashboard ? "Ready" : "Starting", data.dashboard ? "ok" : "warn"),
380
- detail: appDetail,
381
- tone: data.dashboard ? "ok" : "warn",
382
- meta: `<code>/app/</code> opens in a new window.`,
383
  }),
384
  renderTile({
385
- title: "Auth",
386
- value: toneBadge(data.authConfigured ? "Token set" : "Unlocked", data.authConfigured ? "ok" : "warn"),
387
- detail: authDetail,
388
- tone: data.authConfigured ? "ok" : "warn",
389
- meta: data.authConfigured ? "Browser visits use the login page; API clients use Bearer auth." : "Set GATEWAY_TOKEN before sharing this Space.",
390
  }),
391
  renderTile({
392
  title: "Runtime",
393
  value: escapeHtml(data.uptime),
394
- detail: `Public port <code>${data.ports.public}</code>. Started <code>${escapeHtml(data.startedAt)}</code>.`,
395
  tone: "neutral",
396
- meta: `Health endpoint: <code>/health</code>`,
397
- }),
398
- renderTile({
399
- title: "Model",
400
- value: `<code>${valueOrUnset(data.model)}</code>`,
401
- detail: `Provider <code>${valueOrUnset(data.provider || "auto")}</code>.`,
402
- tone: data.model ? "ok" : "warn",
403
- meta: "For Gemini: LLM_MODEL=google/gemini-2.5-flash",
404
  }),
405
  renderTile({
406
  title: "Telegram",
@@ -418,10 +403,10 @@ function renderDashboard(data) {
418
  }),
419
  renderTile({
420
  title: "Keep Awake",
421
- value: toneBadge(data.uptimerobot?.configured ? "Monitor active" : "Not configured", keepAliveTone),
422
  detail: keepAliveDetail,
423
  tone: keepAliveTone,
424
- meta: process.env.UPTIMEROBOT_API_KEY ? "UPTIMEROBOT_API_KEY detected." : "",
425
  }),
426
  ].join("");
427
 
@@ -432,61 +417,61 @@ function renderDashboard(data) {
432
  <meta name="viewport" content="width=device-width, initial-scale=1" />
433
  <title>HuggingMess</title>
434
  <style>
435
- :root { color-scheme: dark; --bg:#101010; --panel:#171717; --panel2:#1e1e1e; --line:#303030; --text:#f3f4f6; --muted:#a1a1aa; --soft:#d4d4d8; --good:#4ade80; --warn:#fbbf24; --bad:#fb7185; --accent:#67e8f9; }
436
  * { box-sizing:border-box; }
437
- 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:14px; }
438
- main { width:min(1180px, calc(100% - 32px)); margin:0 auto; padding:24px 0 32px; }
439
- header { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; margin-bottom:18px; border-bottom:1px solid var(--line); padding-bottom:18px; }
440
- h1 { margin:0; font-size:clamp(2rem, 4vw, 3.2rem); line-height:1; letter-spacing:0; }
441
- .subtitle { margin-top:10px; color:var(--muted); max-width:700px; line-height:1.45; font-size:.95rem; }
442
- .top-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; min-width:300px; }
443
- .overview { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
444
- .tile { border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:14px; min-height:142px; display:flex; flex-direction:column; gap:10px; }
445
- .tile.ok { border-color:rgba(74,222,128,.28); }
446
- .tile.warn { border-color:rgba(251,191,36,.28); }
447
- .tile.off { border-color:rgba(251,113,133,.32); }
 
 
 
 
 
448
  .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
449
- .tile-title { color:var(--muted); font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; font-weight:800; }
450
  .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
451
  .tile.ok .tile-dot { background:var(--good); }
452
  .tile.warn .tile-dot { background:var(--warn); }
453
  .tile.off .tile-dot { background:var(--bad); }
454
- .tile-value { font-size:1.05rem; font-weight:760; overflow-wrap:anywhere; }
455
- .tile-detail { color:var(--soft); line-height:1.45; font-size:.86rem; }
456
- .tile-meta { color:var(--muted); line-height:1.4; font-size:.78rem; margin-top:auto; overflow-wrap:anywhere; }
457
- .panel { border:1px solid var(--line); background:var(--panel2); border-radius:8px; padding:14px; margin-top:10px; }
458
- .launch-panel { display:flex; align-items:center; justify-content:space-between; gap:18px; }
459
- .panel-title { color:var(--muted); font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; font-weight:800; margin-bottom:7px; }
460
- .panel-copy { color:var(--soft); line-height:1.45; font-size:.9rem; margin:0; }
461
- code { background:#0d0d0d; border:1px solid var(--line); border-radius:6px; padding:2px 5px; color:var(--text); font-size:.9em; }
462
  pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; background:#0d0d0d; border:1px solid var(--line); border-radius:7px; padding:10px; color:var(--soft); font-size:.82rem; line-height:1.45; }
463
  .row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
464
- .badge { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:.75rem; font-weight:800; line-height:1; }
465
- .badge.ok { color:var(--good); border-color:rgba(74,222,128,.36); background:rgba(74,222,128,.08); }
466
- .badge.warn { color:var(--warn); border-color:rgba(251,191,36,.34); background:rgba(251,191,36,.08); }
467
- .badge.off { color:var(--bad); border-color:rgba(251,113,133,.36); background:rgba(251,113,133,.08); }
468
  .badge.neutral { color:var(--soft); }
469
  .muted { color:var(--muted); }
470
- .button { display:inline-flex; align-items:center; justify-content:center; min-height:34px; padding:0 11px; border-radius:7px; color:#081012; background:var(--accent); text-decoration:none; font-weight:800; font-size:.86rem; }
471
  .button.secondary { color:var(--text); background:#242424; border:1px solid var(--line); }
472
- .button.subtle { color:var(--soft); background:transparent; border:1px solid var(--line); }
473
- @media (max-width: 980px) { .overview { grid-template-columns:repeat(2, minmax(0, 1fr)); } header { display:block; } .top-actions { justify-content:flex-start; margin-top:14px; min-width:0; } }
474
- @media (max-width: 760px) { .launch-panel { display:block; } .launch-panel .button { margin-top:12px; width:100%; } }
475
- @media (max-width: 620px) { main { width:min(100% - 20px, 1180px); padding-top:16px; } .overview { grid-template-columns:1fr; } h1 { font-size:2rem; } }
476
  </style>
477
  </head>
478
  <body>
479
  <main>
480
  <header>
481
- <div>
482
- <h1>HuggingMess</h1>
483
- <div class="subtitle">Hermes Agent on Hugging Face Spaces: app gateway, OpenAI-compatible API, Telegram webhook, Cloudflare proxy, backup, and keep-awake state in one place.</div>
484
- </div>
485
- <div class="top-actions">
486
- <a class="button" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open App</a>
487
- <a class="button secondary" href="/status">Status JSON</a>
488
- </div>
489
  </header>
 
 
490
  <section class="overview">
491
  ${tiles}
492
  </section>
@@ -497,6 +482,7 @@ function renderDashboard(data) {
497
  </div>
498
  <a class="button" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open Hermes Agent</a>
499
  </section>
 
500
  </main>
501
  </body>
502
  </html>`;
 
17
  const SESSION_COOKIE = "huggingmess_session";
18
 
19
  const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
20
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/huggingmess-cloudflare-keepalive-status.json";
21
 
22
  function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
23
  return new Promise((resolve) => {
 
314
  model: process.env.MODEL_FOR_CONFIG || process.env.HERMES_MODEL || process.env.LLM_MODEL || "",
315
  provider: process.env.PROVIDER_FOR_CONFIG || process.env.HERMES_INFERENCE_PROVIDER || "auto",
316
  backup: sync,
317
+ keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null),
318
  };
319
  }
320
 
 
346
  const syncStatus = String(data.backup?.status || "unknown");
347
  const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral";
348
  const telegramTone = data.telegram.configured ? (data.telegram.webhookListening || !data.telegram.webhook ? "ok" : "warn") : "warn";
349
+ const keepaliveConfigured = data.keepalive?.configured === true;
350
+ const keepaliveStatus = String(data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"));
351
+ const keepAliveTone = keepaliveConfigured ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral";
 
 
 
 
 
 
 
352
  const telegramDetail = data.telegram.configured
353
  ? `${data.telegram.webhook ? "Webhook mode" : "Polling mode"}${data.telegram.proxy ? ` through Cloudflare proxy` : ""}.`
354
  : "Add TELEGRAM_BOT_TOKEN to enable Telegram.";
355
  const backupDetail = data.backup?.message ? escapeHtml(data.backup.message) : "No backup status has been written yet.";
356
  const backupMeta = data.backup?.timestamp ? `Last update ${escapeHtml(data.backup.timestamp)}` : "";
357
+ const keepAliveDetail = keepaliveConfigured
358
+ ? `Worker <code>${escapeHtml(data.keepalive.workerName || "keepalive")}</code> pings <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>.`
359
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
360
+ ? "Cloudflare keep-awake Worker is pending or failed; check Space logs."
361
+ : "Add CLOUDFLARE_WORKERS_TOKEN to create a scheduled keep-awake Worker.";
362
+ const serviceOk = data.gateway && data.dashboard;
363
+ const topCards = [
364
+ `<div class="mini-card"><span class="mini-label">Service</span>${toneBadge(serviceOk ? "Running" : "Starting", serviceOk ? "ok" : "warn")}</div>`,
365
+ `<div class="mini-card"><span class="mini-label">Uptime</span><strong>${escapeHtml(data.uptime)}</strong></div>`,
366
+ `<div class="mini-card"><span class="mini-label">Backup</span>${toneBadge(syncStatus.toUpperCase(), syncTone)}</div>`,
367
+ ].join("");
368
  const tiles = [
369
  renderTile({
370
  title: "Gateway",
371
  value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"),
372
+ detail: data.gateway ? `OpenAI-compatible API on port <code>${data.ports.gateway}</code>.` : `Gateway API is not reachable on port <code>${data.ports.gateway}</code>.`,
373
  tone: data.gateway ? "ok" : "off",
374
+ meta: data.authConfigured ? `Protected by <code>GATEWAY_TOKEN</code>.` : `Set <code>GATEWAY_TOKEN</code> before sharing.`,
 
 
 
 
 
 
 
375
  }),
376
  renderTile({
377
+ title: "Model",
378
+ value: `<code>${valueOrUnset(data.model)}</code>`,
379
+ detail: `Provider <code>${valueOrUnset(data.provider || "auto")}</code>.`,
380
+ tone: data.model ? "ok" : "warn",
381
+ meta: "Gemini example: google/gemini-2.5-flash",
382
  }),
383
  renderTile({
384
  title: "Runtime",
385
  value: escapeHtml(data.uptime),
386
+ detail: `Public port <code>${data.ports.public}</code>; health at <code>/health</code>.`,
387
  tone: "neutral",
388
+ meta: `Started <code>${escapeHtml(data.startedAt)}</code>.`,
 
 
 
 
 
 
 
389
  }),
390
  renderTile({
391
  title: "Telegram",
 
403
  }),
404
  renderTile({
405
  title: "Keep Awake",
406
+ value: toneBadge(keepaliveConfigured ? "Cloudflare cron" : keepaliveStatus.toUpperCase(), keepAliveTone),
407
  detail: keepAliveDetail,
408
  tone: keepAliveTone,
409
+ meta: keepaliveConfigured ? `Schedule <code>${escapeHtml(data.keepalive.cron || "*/10 * * * *")}</code>.` : "",
410
  }),
411
  ].join("");
412
 
 
417
  <meta name="viewport" content="width=device-width, initial-scale=1" />
418
  <title>HuggingMess</title>
419
  <style>
420
+ :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:#6557df; --accent2:#7c6cf2; }
421
  * { box-sizing:border-box; }
422
+ 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; }
423
+ main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
424
+ header { text-align:center; margin-bottom:22px; }
425
+ h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; }
426
+ .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
427
+ .hero-action { display:flex; width:100%; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:var(--accent); color:#fff; text-decoration:none; font-weight:850; font-size:.98rem; margin:24px 0 20px; }
428
+ .hero-action:hover { background:var(--accent2); }
429
+ .mini-grid { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:10px; margin-bottom:18px; }
430
+ .mini-card { min-height:68px; border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:13px; display:flex; flex-direction:column; justify-content:center; gap:9px; }
431
+ .mini-label { color:var(--muted); font-size:.66rem; letter-spacing:.16em; text-transform:uppercase; font-weight:850; }
432
+ .mini-card strong { font-size:1.02rem; }
433
+ .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
434
+ .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; }
435
+ .tile.ok { border-color:rgba(34,197,94,.22); }
436
+ .tile.warn { border-color:rgba(245,197,66,.24); }
437
+ .tile.off { border-color:rgba(251,113,133,.28); }
438
  .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
439
+ .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
440
  .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
441
  .tile.ok .tile-dot { background:var(--good); }
442
  .tile.warn .tile-dot { background:var(--warn); }
443
  .tile.off .tile-dot { background:var(--bad); }
444
+ .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
445
+ .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
446
+ .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
447
+ .panel { border:1px solid var(--line); background:var(--panel2); border-radius:11px; padding:16px; margin-top:18px; }
448
+ .launch-panel { text-align:center; }
449
+ .panel-title { color:var(--muted); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; font-weight:850; margin-bottom:8px; }
450
+ .panel-copy { color:var(--soft); line-height:1.5; font-size:.86rem; margin:0 auto 14px; max-width:560px; }
451
+ code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
452
  pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; background:#0d0d0d; border:1px solid var(--line); border-radius:7px; padding:10px; color:var(--soft); font-size:.82rem; line-height:1.45; }
453
  .row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
454
+ .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; }
455
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
456
+ .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
457
+ .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
458
  .badge.neutral { color:var(--soft); }
459
  .muted { color:var(--muted); }
460
+ .button { display:inline-flex; align-items:center; justify-content:center; min-height:40px; padding:0 16px; border-radius:8px; color:#fff; background:var(--accent); text-decoration:none; font-weight:850; font-size:.9rem; }
461
  .button.secondary { color:var(--text); background:#242424; border:1px solid var(--line); }
462
+ footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
463
+ footer .live { color:var(--good); }
464
+ @media (max-width: 700px) { .mini-grid, .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } }
 
465
  </style>
466
  </head>
467
  <body>
468
  <main>
469
  <header>
470
+ <h1>HuggingMess</h1>
471
+ <div class="subtitle">Self-hosted - Hugging Face Spaces - Hermes Agent</div>
 
 
 
 
 
 
472
  </header>
473
+ <a class="hero-action" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open Hermes Agent -></a>
474
+ <section class="mini-grid">${topCards}</section>
475
  <section class="overview">
476
  ${tiles}
477
  </section>
 
482
  </div>
483
  <a class="button" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open Hermes Agent</a>
484
  </section>
485
+ <footer><span class="live">Live</span> status - Health endpoint: <code>/health</code></footer>
486
  </main>
487
  </body>
488
  </html>`;
start.sh CHANGED
@@ -278,7 +278,7 @@ trap graceful_shutdown SIGTERM SIGINT
278
  node "$APP_DIR/health-server.js" &
279
  HEALTH_PID=$!
280
 
281
- if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
282
  echo "Setting up UptimeRobot monitor..."
283
  bash "$APP_DIR/setup-uptimerobot.sh" "${SPACE_HOST}" || true
284
  fi
 
278
  node "$APP_DIR/health-server.js" &
279
  HEALTH_PID=$!
280
 
281
+ if [ "${UPTIMEROBOT_ENABLED:-false}" = "true" ] && [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
282
  echo "Setting up UptimeRobot monitor..."
283
  bash "$APP_DIR/setup-uptimerobot.sh" "${SPACE_HOST}" || true
284
  fi