Spaces:
Building
Building
Add HuggingMess Hermes Space wrapper
Browse files- .dockerignore +8 -0
- .gitignore +8 -0
- CHANGELOG.md +9 -0
- CODE_OF_CONDUCT.md +5 -0
- CONTRIBUTING.md +25 -0
- Dockerfile +37 -0
- LICENSE +21 -0
- README.md +140 -5
- SECURITY.md +14 -0
- cloudflare-proxy-setup.py +191 -0
- cloudflare-worker.js +78 -0
- docker-compose.yml +22 -0
- health-server.js +231 -0
- hermes-sync.py +303 -0
- setup-uptimerobot.sh +73 -0
- start.sh +329 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.DS_Store
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
node_modules
|
| 6 |
+
.env
|
| 7 |
+
venv
|
| 8 |
+
.venv
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.env
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
node_modules/
|
| 8 |
+
.cache/
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
## 0.1.0 - 2026-05-03
|
| 4 |
+
|
| 5 |
+
- Initial HuggingMess Docker Space wrapper for Nous Research Hermes Agent.
|
| 6 |
+
- Added HF Space dashboard, `/health`, `/status`, `/v1/*` proxy, and Telegram webhook proxy.
|
| 7 |
+
- Added Cloudflare Worker setup for Telegram Bot API base URL proxying.
|
| 8 |
+
- Added private HF Dataset backup and restore for Hermes state.
|
| 9 |
+
- Added UptimeRobot monitor setup.
|
CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Code of Conduct
|
| 2 |
+
|
| 3 |
+
Be respectful, practical, and kind. This project exists to make self-hosting Hermes on Hugging Face Spaces easier for people with different levels of infrastructure experience.
|
| 4 |
+
|
| 5 |
+
Harassment, abuse, and deliberately unsafe guidance are not welcome.
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing
|
| 2 |
+
|
| 3 |
+
Thanks for improving HuggingMess.
|
| 4 |
+
|
| 5 |
+
## Local Checks
|
| 6 |
+
|
| 7 |
+
Run these before submitting changes:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
bash -n start.sh setup-uptimerobot.sh
|
| 11 |
+
node --check health-server.js
|
| 12 |
+
python3 -m py_compile hermes-sync.py cloudflare-proxy-setup.py
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
If Docker is available:
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
docker compose up --build
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## Notes
|
| 22 |
+
|
| 23 |
+
- Keep the wrapper thin; prefer the official `nousresearch/hermes-agent` image for Hermes itself.
|
| 24 |
+
- Avoid committing secrets or generated `/opt/data` state.
|
| 25 |
+
- Preserve Hugging Face Space metadata in `README.md`.
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingMess - Hermes Agent Gateway for Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
ARG HERMES_AGENT_VERSION=latest
|
| 4 |
+
FROM nousresearch/hermes-agent:${HERMES_AGENT_VERSION}
|
| 5 |
+
|
| 6 |
+
USER root
|
| 7 |
+
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
ca-certificates \
|
| 10 |
+
curl \
|
| 11 |
+
jq \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 13 |
+
&& uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir huggingface_hub
|
| 14 |
+
|
| 15 |
+
COPY --chown=hermes:hermes start.sh /opt/huggingmess/start.sh
|
| 16 |
+
COPY --chown=hermes:hermes health-server.js /opt/huggingmess/health-server.js
|
| 17 |
+
COPY --chown=hermes:hermes hermes-sync.py /opt/huggingmess/hermes-sync.py
|
| 18 |
+
COPY --chown=hermes:hermes cloudflare-proxy-setup.py /opt/huggingmess/cloudflare-proxy-setup.py
|
| 19 |
+
COPY --chown=hermes:hermes setup-uptimerobot.sh /opt/huggingmess/setup-uptimerobot.sh
|
| 20 |
+
|
| 21 |
+
RUN chmod +x \
|
| 22 |
+
/opt/huggingmess/start.sh \
|
| 23 |
+
/opt/huggingmess/hermes-sync.py \
|
| 24 |
+
/opt/huggingmess/cloudflare-proxy-setup.py \
|
| 25 |
+
/opt/huggingmess/setup-uptimerobot.sh
|
| 26 |
+
|
| 27 |
+
ENV HERMES_HOME=/opt/data \
|
| 28 |
+
HUGGINGMESS_APP_DIR=/opt/huggingmess \
|
| 29 |
+
HERMES_AGENT_VERSION=${HERMES_AGENT_VERSION} \
|
| 30 |
+
PYTHONUNBUFFERED=1
|
| 31 |
+
|
| 32 |
+
EXPOSE 7861
|
| 33 |
+
|
| 34 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
|
| 35 |
+
CMD curl -fsS http://localhost:7861/health || exit 1
|
| 36 |
+
|
| 37 |
+
CMD ["/opt/huggingmess/start.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Somrat
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,11 +1,146 @@
|
|
| 1 |
---
|
| 2 |
title: HuggingMess
|
| 3 |
emoji: 📚
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: HuggingMess
|
| 3 |
emoji: 📚
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7861
|
| 8 |
+
pinned: true
|
| 9 |
+
license: mit
|
| 10 |
+
secrets:
|
| 11 |
+
- name: LLM_API_KEY
|
| 12 |
+
description: "Your LLM provider API key. HuggingMess maps it to the right Hermes provider env var."
|
| 13 |
+
- name: LLM_MODEL
|
| 14 |
+
description: "Model ID, e.g. openrouter/anthropic/claude-sonnet-4 or anthropic/claude-opus-4.6."
|
| 15 |
+
- name: TELEGRAM_BOT_TOKEN
|
| 16 |
+
description: "Telegram bot token from @BotFather."
|
| 17 |
+
- name: TELEGRAM_ALLOWED_USERS
|
| 18 |
+
description: "Comma-separated numeric Telegram user IDs allowed to use the bot."
|
| 19 |
+
- name: GATEWAY_TOKEN
|
| 20 |
+
description: "Bearer token for the proxied Hermes 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 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 |
+
|
| 35 |
+
1. Duplicate this Space or push this folder to a new Docker Space.
|
| 36 |
+
2. Add these secrets in Space Settings:
|
| 37 |
+
|
| 38 |
+
| Secret | Required | Notes |
|
| 39 |
+
| :--- | :--- | :--- |
|
| 40 |
+
| `LLM_MODEL` | Yes | Examples: `openrouter/anthropic/claude-sonnet-4`, `anthropic/claude-opus-4.6`, `google/gemini-2.5-flash` |
|
| 41 |
+
| `LLM_API_KEY` | Usually | Used to populate the provider-specific env var automatically |
|
| 42 |
+
| `TELEGRAM_BOT_TOKEN` | For Telegram | Bot token from BotFather |
|
| 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 |
+
## Telegram on HF Spaces
|
| 50 |
+
|
| 51 |
+
When `TELEGRAM_BOT_TOKEN` and `SPACE_HOST` are present, HuggingMess defaults Telegram to webhook mode:
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
TELEGRAM_WEBHOOK_URL=https://your-space.hf.space/telegram
|
| 55 |
+
TELEGRAM_WEBHOOK_PORT=8765
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
If you need polling instead, set:
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
TELEGRAM_MODE=polling
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
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`.
|
| 65 |
+
|
| 66 |
+
## Cloudflare Proxy
|
| 67 |
+
|
| 68 |
+
Hugging Face Spaces can be restrictive for outbound bot API traffic. Add `CLOUDFLARE_WORKERS_TOKEN`, and HuggingMess will:
|
| 69 |
+
|
| 70 |
+
1. create a Cloudflare Worker,
|
| 71 |
+
2. generate a shared proxy secret,
|
| 72 |
+
3. set Hermes Telegram `base_url` to `https://worker.example.workers.dev/bot`,
|
| 73 |
+
4. set `base_file_url` to `https://worker.example.workers.dev/file/bot`.
|
| 74 |
+
|
| 75 |
+
Manual mode is also supported:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
CLOUDFLARE_PROXY_URL=https://your-worker.workers.dev
|
| 79 |
+
CLOUDFLARE_PROXY_SECRET=optional-shared-secret
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
The manual Worker source is included in `cloudflare-worker.js`.
|
| 83 |
+
|
| 84 |
+
## Backup
|
| 85 |
+
|
| 86 |
+
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.
|
| 87 |
+
|
| 88 |
+
| Variable | Default | Description |
|
| 89 |
+
| :--- | :--- | :--- |
|
| 90 |
+
| `BACKUP_DATASET_NAME` | `huggingmess-backup` | Dataset name under your HF account |
|
| 91 |
+
| `SYNC_INTERVAL` | `180` | Backup interval in seconds |
|
| 92 |
+
| `SYNC_INCLUDE_ENV` | `false` | Include `/opt/data/.env` in backup |
|
| 93 |
+
|
| 94 |
+
By default `.env` is excluded from backups because HF Space secrets are already injected at runtime.
|
| 95 |
+
|
| 96 |
+
## Keep Awake
|
| 97 |
+
|
| 98 |
+
Add `UPTIMEROBOT_API_KEY`, and HuggingMess creates or reuses a monitor for:
|
| 99 |
+
|
| 100 |
+
```text
|
| 101 |
+
https://your-space.hf.space/health
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Optional UptimeRobot variables:
|
| 105 |
+
|
| 106 |
+
| Variable | Default | Description |
|
| 107 |
+
| :--- | :--- | :--- |
|
| 108 |
+
| `UPTIMEROBOT_MONITOR_NAME` | `HuggingMess <space>` | Friendly monitor name |
|
| 109 |
+
| `UPTIMEROBOT_INTERVAL` | `300` | Monitor interval in seconds |
|
| 110 |
+
| `UPTIMEROBOT_ALERT_CONTACTS` | unset | Dash-separated alert contact IDs |
|
| 111 |
+
|
| 112 |
+
## Local Development
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
docker compose up --build
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Then open:
|
| 119 |
+
|
| 120 |
+
```text
|
| 121 |
+
http://localhost:7861
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Useful Routes
|
| 125 |
+
|
| 126 |
+
| Route | Purpose |
|
| 127 |
+
| :--- | :--- |
|
| 128 |
+
| `/` | HuggingMess dashboard |
|
| 129 |
+
| `/health` | Health check for HF and UptimeRobot |
|
| 130 |
+
| `/status` | JSON status |
|
| 131 |
+
| `/dashboard/` | Proxied Hermes dashboard |
|
| 132 |
+
| `/v1/models` | Proxied Hermes OpenAI-compatible API server |
|
| 133 |
+
| `/telegram` | Telegram webhook endpoint |
|
| 134 |
+
|
| 135 |
+
The `/v1/*` routes require:
|
| 136 |
+
|
| 137 |
+
```text
|
| 138 |
+
Authorization: Bearer <GATEWAY_TOKEN>
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
## Links
|
| 142 |
+
|
| 143 |
+
- [Hermes Agent GitHub](https://github.com/NousResearch/hermes-agent)
|
| 144 |
+
- [Hermes Agent Docs](https://hermes-agent.nousresearch.com/docs)
|
| 145 |
+
- [Hermes Docker Docs](https://hermes-agent.nousresearch.com/docs/user-guide/docker/)
|
| 146 |
+
- [Hermes Telegram Docs](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram)
|
SECURITY.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security
|
| 2 |
+
|
| 3 |
+
HuggingMess runs a full agent gateway with tool access. Treat the Space and its secrets like a server.
|
| 4 |
+
|
| 5 |
+
## Required Hardening
|
| 6 |
+
|
| 7 |
+
- Set `GATEWAY_TOKEN`; `/v1/*` routes require `Authorization: Bearer <GATEWAY_TOKEN>`.
|
| 8 |
+
- Set `TELEGRAM_ALLOWED_USERS` to numeric Telegram user IDs.
|
| 9 |
+
- Keep your HF Dataset backup private.
|
| 10 |
+
- Do not enable `SYNC_INCLUDE_ENV=true` unless you intentionally want `/opt/data/.env` backed up.
|
| 11 |
+
|
| 12 |
+
## Reporting
|
| 13 |
+
|
| 14 |
+
Open a private issue or contact the maintainer directly with reproduction steps and affected configuration.
|
cloudflare-proxy-setup.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 17 |
+
"discordapp.com",
|
| 18 |
+
"gateway.discord.gg",
|
| 19 |
+
"status.discord.com",
|
| 20 |
+
"slack.com",
|
| 21 |
+
"api.slack.com",
|
| 22 |
+
"web.whatsapp.com",
|
| 23 |
+
"graph.facebook.com",
|
| 24 |
+
"graph.instagram.com",
|
| 25 |
+
"api.openai.com",
|
| 26 |
+
"googleapis.com",
|
| 27 |
+
"google.com",
|
| 28 |
+
"googleusercontent.com",
|
| 29 |
+
"gstatic.com",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
|
| 34 |
+
req = urllib.request.Request(
|
| 35 |
+
f"{API_BASE}{path}",
|
| 36 |
+
data=body,
|
| 37 |
+
method=method,
|
| 38 |
+
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
|
| 39 |
+
)
|
| 40 |
+
with urllib.request.urlopen(req, timeout=30) as response:
|
| 41 |
+
payload = json.loads(response.read().decode("utf-8"))
|
| 42 |
+
if not payload.get("success"):
|
| 43 |
+
errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
|
| 44 |
+
raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
|
| 45 |
+
return payload["result"]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def slugify(value: str) -> str:
|
| 49 |
+
cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
|
| 50 |
+
cleaned = re.sub(r"-{2,}", "-", cleaned)
|
| 51 |
+
return (cleaned or "huggingmess-proxy")[:63].rstrip("-")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def derive_worker_name() -> str:
|
| 55 |
+
explicit = os.environ.get("CLOUDFLARE_WORKER_NAME", "").strip()
|
| 56 |
+
if explicit:
|
| 57 |
+
return slugify(explicit)
|
| 58 |
+
space_host = os.environ.get("SPACE_HOST", "").strip()
|
| 59 |
+
if space_host:
|
| 60 |
+
return slugify(f"{space_host.replace('.hf.space', '')}-proxy")
|
| 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));
|
| 67 |
+
}});
|
| 68 |
+
|
| 69 |
+
const PROXY_SHARED_SECRET = {json.dumps(secret_value)};
|
| 70 |
+
const ALLOW_PROXY_ALL = {"true" if allow_proxy_all else "false"};
|
| 71 |
+
const ALLOWED_TARGETS = {json.dumps(allowed_targets)};
|
| 72 |
+
|
| 73 |
+
function isAllowedHost(hostname) {{
|
| 74 |
+
const normalized = String(hostname || "").trim().toLowerCase();
|
| 75 |
+
if (!normalized) return false;
|
| 76 |
+
if (ALLOW_PROXY_ALL) return true;
|
| 77 |
+
return ALLOWED_TARGETS.some((domain) => normalized === domain || normalized.endsWith(`.${{domain}}`));
|
| 78 |
+
}}
|
| 79 |
+
|
| 80 |
+
async function handleRequest(request) {{
|
| 81 |
+
const url = new URL(request.url);
|
| 82 |
+
const queryTarget = url.searchParams.get("proxy_target");
|
| 83 |
+
const targetHost = request.headers.get("x-target-host") || queryTarget;
|
| 84 |
+
|
| 85 |
+
if (PROXY_SHARED_SECRET) {{
|
| 86 |
+
const providedSecret = request.headers.get("x-proxy-key") || url.searchParams.get("proxy_key") || "";
|
| 87 |
+
const telegramStylePath = url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot");
|
| 88 |
+
if (providedSecret !== PROXY_SHARED_SECRET && !(telegramStylePath && !targetHost)) {{
|
| 89 |
+
return new Response("Unauthorized: Invalid proxy key", {{ status: 401 }});
|
| 90 |
+
}}
|
| 91 |
+
}}
|
| 92 |
+
|
| 93 |
+
let targetBase = "";
|
| 94 |
+
if (targetHost) {{
|
| 95 |
+
if (!isAllowedHost(targetHost)) {{
|
| 96 |
+
return new Response(`Forbidden: Host ${{targetHost}} is not allowed.`, {{ status: 403 }});
|
| 97 |
+
}}
|
| 98 |
+
targetBase = `https://${{targetHost}}`;
|
| 99 |
+
}} else if (url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot")) {{
|
| 100 |
+
targetBase = "https://api.telegram.org";
|
| 101 |
+
}} else {{
|
| 102 |
+
return new Response("Invalid request: No target host provided.", {{ status: 400 }});
|
| 103 |
+
}}
|
| 104 |
+
|
| 105 |
+
const cleanSearch = new URLSearchParams(url.search);
|
| 106 |
+
cleanSearch.delete("proxy_target");
|
| 107 |
+
cleanSearch.delete("proxy_key");
|
| 108 |
+
const searchStr = cleanSearch.toString();
|
| 109 |
+
const targetUrl = targetBase + url.pathname + (searchStr ? `?${{searchStr}}` : "");
|
| 110 |
+
|
| 111 |
+
const headers = new Headers(request.headers);
|
| 112 |
+
for (const header of ["cf-connecting-ip", "cf-ray", "cf-visitor", "host", "x-real-ip", "x-target-host", "x-proxy-key"]) {{
|
| 113 |
+
headers.delete(header);
|
| 114 |
+
}}
|
| 115 |
+
|
| 116 |
+
try {{
|
| 117 |
+
return await fetch(new Request(targetUrl, {{
|
| 118 |
+
method: request.method,
|
| 119 |
+
headers,
|
| 120 |
+
body: request.body,
|
| 121 |
+
redirect: "follow",
|
| 122 |
+
}}));
|
| 123 |
+
}} catch (error) {{
|
| 124 |
+
return new Response(`Proxy Error: ${{error.message}}`, {{ status: 502 }});
|
| 125 |
+
}}
|
| 126 |
+
}}
|
| 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',
|
| 133 |
+
encoding="utf-8",
|
| 134 |
+
)
|
| 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()
|
| 141 |
+
api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
|
| 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 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
raise SystemExit(main())
|
cloudflare-worker.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
addEventListener("fetch", (event) => {
|
| 2 |
+
event.respondWith(handleRequest(event.request));
|
| 3 |
+
});
|
| 4 |
+
|
| 5 |
+
const PROXY_SHARED_SECRET = "";
|
| 6 |
+
const ALLOW_PROXY_ALL = false;
|
| 7 |
+
const ALLOWED_TARGETS = [
|
| 8 |
+
"api.telegram.org",
|
| 9 |
+
"discord.com",
|
| 10 |
+
"discordapp.com",
|
| 11 |
+
"gateway.discord.gg",
|
| 12 |
+
"status.discord.com",
|
| 13 |
+
"slack.com",
|
| 14 |
+
"api.slack.com",
|
| 15 |
+
"web.whatsapp.com",
|
| 16 |
+
"graph.facebook.com",
|
| 17 |
+
"graph.instagram.com",
|
| 18 |
+
"api.openai.com",
|
| 19 |
+
"googleapis.com",
|
| 20 |
+
"google.com",
|
| 21 |
+
"googleusercontent.com",
|
| 22 |
+
"gstatic.com",
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
function isAllowedHost(hostname) {
|
| 26 |
+
const normalized = String(hostname || "").trim().toLowerCase();
|
| 27 |
+
if (!normalized) return false;
|
| 28 |
+
if (ALLOW_PROXY_ALL) return true;
|
| 29 |
+
return ALLOWED_TARGETS.some((domain) => normalized === domain || normalized.endsWith(`.${domain}`));
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
async function handleRequest(request) {
|
| 33 |
+
const url = new URL(request.url);
|
| 34 |
+
const queryTarget = url.searchParams.get("proxy_target");
|
| 35 |
+
const targetHost = request.headers.get("x-target-host") || queryTarget;
|
| 36 |
+
const telegramStylePath = url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot");
|
| 37 |
+
|
| 38 |
+
if (PROXY_SHARED_SECRET) {
|
| 39 |
+
const providedSecret = request.headers.get("x-proxy-key") || url.searchParams.get("proxy_key") || "";
|
| 40 |
+
if (providedSecret !== PROXY_SHARED_SECRET && !(telegramStylePath && !targetHost)) {
|
| 41 |
+
return new Response("Unauthorized: Invalid proxy key", { status: 401 });
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
let targetBase = "";
|
| 46 |
+
if (targetHost) {
|
| 47 |
+
if (!isAllowedHost(targetHost)) {
|
| 48 |
+
return new Response(`Forbidden: Host ${targetHost} is not allowed.`, { status: 403 });
|
| 49 |
+
}
|
| 50 |
+
targetBase = `https://${targetHost}`;
|
| 51 |
+
} else if (telegramStylePath) {
|
| 52 |
+
targetBase = "https://api.telegram.org";
|
| 53 |
+
} else {
|
| 54 |
+
return new Response("Invalid request: No target host provided.", { status: 400 });
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const cleanSearch = new URLSearchParams(url.search);
|
| 58 |
+
cleanSearch.delete("proxy_target");
|
| 59 |
+
cleanSearch.delete("proxy_key");
|
| 60 |
+
const searchStr = cleanSearch.toString();
|
| 61 |
+
const targetUrl = targetBase + url.pathname + (searchStr ? `?${searchStr}` : "");
|
| 62 |
+
|
| 63 |
+
const headers = new Headers(request.headers);
|
| 64 |
+
for (const header of ["cf-connecting-ip", "cf-ray", "cf-visitor", "host", "x-real-ip", "x-target-host", "x-proxy-key"]) {
|
| 65 |
+
headers.delete(header);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
return await fetch(new Request(targetUrl, {
|
| 70 |
+
method: request.method,
|
| 71 |
+
headers,
|
| 72 |
+
body: request.body,
|
| 73 |
+
redirect: "follow",
|
| 74 |
+
}));
|
| 75 |
+
} catch (error) {
|
| 76 |
+
return new Response(`Proxy Error: ${error.message}`, { status: 502 });
|
| 77 |
+
}
|
| 78 |
+
}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
huggingmess:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
args:
|
| 6 |
+
HERMES_AGENT_VERSION: ${HERMES_AGENT_VERSION:-latest}
|
| 7 |
+
image: huggingmess:local
|
| 8 |
+
ports:
|
| 9 |
+
- "7861:7861"
|
| 10 |
+
environment:
|
| 11 |
+
LLM_MODEL: ${LLM_MODEL:-openrouter/anthropic/claude-sonnet-4}
|
| 12 |
+
LLM_API_KEY: ${LLM_API_KEY:-}
|
| 13 |
+
GATEWAY_TOKEN: ${GATEWAY_TOKEN:-local-dev-token}
|
| 14 |
+
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
| 15 |
+
TELEGRAM_ALLOWED_USERS: ${TELEGRAM_ALLOWED_USERS:-}
|
| 16 |
+
HF_TOKEN: ${HF_TOKEN:-}
|
| 17 |
+
SPACE_HOST: ${SPACE_HOST:-localhost:7861}
|
| 18 |
+
volumes:
|
| 19 |
+
- huggingmess-data:/opt/data
|
| 20 |
+
|
| 21 |
+
volumes:
|
| 22 |
+
huggingmess-data:
|
health-server.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const http = require("http");
|
| 4 |
+
const fs = require("fs");
|
| 5 |
+
const net = require("net");
|
| 6 |
+
|
| 7 |
+
const PORT = Number(process.env.PORT || 7861);
|
| 8 |
+
const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
|
| 9 |
+
const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119);
|
| 10 |
+
const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765);
|
| 11 |
+
const GATEWAY_HOST = "127.0.0.1";
|
| 12 |
+
const startTime = Date.now();
|
| 13 |
+
const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
|
| 14 |
+
|
| 15 |
+
const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
|
| 16 |
+
const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingmess-uptimerobot-status.json";
|
| 17 |
+
|
| 18 |
+
function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
|
| 19 |
+
return new Promise((resolve) => {
|
| 20 |
+
const socket = net.createConnection({ port, host });
|
| 21 |
+
const done = (ok) => {
|
| 22 |
+
socket.removeAllListeners();
|
| 23 |
+
socket.destroy();
|
| 24 |
+
resolve(ok);
|
| 25 |
+
};
|
| 26 |
+
socket.setTimeout(timeoutMs);
|
| 27 |
+
socket.once("connect", () => done(true));
|
| 28 |
+
socket.once("timeout", () => done(false));
|
| 29 |
+
socket.once("error", () => done(false));
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function readJson(path, fallback = null) {
|
| 34 |
+
try {
|
| 35 |
+
if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
|
| 36 |
+
} catch {}
|
| 37 |
+
return fallback;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function proxyRequest(req, res, targetPort, rewritePath = (path) => path) {
|
| 41 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 42 |
+
const targetPath = rewritePath(parsed.pathname) + parsed.search;
|
| 43 |
+
const headers = {
|
| 44 |
+
...req.headers,
|
| 45 |
+
host: `${GATEWAY_HOST}:${targetPort}`,
|
| 46 |
+
"x-forwarded-host": req.headers.host || "",
|
| 47 |
+
"x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const proxy = http.request(
|
| 51 |
+
{
|
| 52 |
+
hostname: GATEWAY_HOST,
|
| 53 |
+
port: targetPort,
|
| 54 |
+
method: req.method,
|
| 55 |
+
path: targetPath,
|
| 56 |
+
headers,
|
| 57 |
+
},
|
| 58 |
+
(upstream) => {
|
| 59 |
+
res.writeHead(upstream.statusCode || 502, upstream.headers);
|
| 60 |
+
upstream.pipe(res);
|
| 61 |
+
},
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
proxy.on("error", (error) => {
|
| 65 |
+
res.writeHead(502, { "content-type": "application/json" });
|
| 66 |
+
res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
req.pipe(proxy);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function formatUptime(ms) {
|
| 73 |
+
const total = Math.floor(ms / 1000);
|
| 74 |
+
const days = Math.floor(total / 86400);
|
| 75 |
+
const hours = Math.floor((total % 86400) / 3600);
|
| 76 |
+
const minutes = Math.floor((total % 3600) / 60);
|
| 77 |
+
if (days) return `${days}d ${hours}h ${minutes}m`;
|
| 78 |
+
if (hours) return `${hours}h ${minutes}m`;
|
| 79 |
+
return `${minutes}m`;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
async function statusPayload() {
|
| 83 |
+
const gateway = await canConnect(GATEWAY_PORT);
|
| 84 |
+
const dashboard = await canConnect(DASHBOARD_PORT);
|
| 85 |
+
const telegramWebhook =
|
| 86 |
+
!!process.env.TELEGRAM_WEBHOOK_URL && (await canConnect(TELEGRAM_WEBHOOK_PORT));
|
| 87 |
+
const sync = readJson(SYNC_STATUS_FILE, process.env.HF_TOKEN
|
| 88 |
+
? { status: "configured", message: "Backup is enabled; waiting for the first sync." }
|
| 89 |
+
: { status: "disabled", message: "HF_TOKEN is not configured." });
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
ok: gateway,
|
| 93 |
+
uptime: formatUptime(Date.now() - startTime),
|
| 94 |
+
gateway,
|
| 95 |
+
dashboard,
|
| 96 |
+
telegram: {
|
| 97 |
+
configured: !!process.env.TELEGRAM_BOT_TOKEN,
|
| 98 |
+
webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
|
| 99 |
+
webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "",
|
| 100 |
+
webhookListening: telegramWebhook,
|
| 101 |
+
proxy: process.env.CLOUDFLARE_PROXY_URL || "",
|
| 102 |
+
},
|
| 103 |
+
model: process.env.MODEL_FOR_CONFIG || process.env.HERMES_MODEL || process.env.LLM_MODEL || "",
|
| 104 |
+
provider: process.env.PROVIDER_FOR_CONFIG || process.env.HERMES_INFERENCE_PROVIDER || "auto",
|
| 105 |
+
backup: sync,
|
| 106 |
+
uptimerobot: readJson(UPTIMEROBOT_STATUS_FILE, null),
|
| 107 |
+
};
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function badge(label, state) {
|
| 111 |
+
const cls = state ? "ok" : "off";
|
| 112 |
+
return `<span class="badge ${cls}">${label}</span>`;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function renderDashboard(data) {
|
| 116 |
+
const syncStatus = String(data.backup?.status || "unknown").toUpperCase();
|
| 117 |
+
const dashboardLink = data.dashboard ? `<a class="button" href="/dashboard/">Open Hermes Dashboard</a>` : "";
|
| 118 |
+
const apiLink = data.gateway ? `<a class="button secondary" href="/v1/models">API Models</a>` : "";
|
| 119 |
+
const keepAlive = data.uptimerobot?.configured
|
| 120 |
+
? `UptimeRobot is monitoring <code>${data.uptimerobot.url}</code>.`
|
| 121 |
+
: process.env.UPTIMEROBOT_API_KEY
|
| 122 |
+
? "UptimeRobot setup is pending or failed; check logs."
|
| 123 |
+
: "Add UPTIMEROBOT_API_KEY to create a keep-awake monitor.";
|
| 124 |
+
|
| 125 |
+
return `<!doctype html>
|
| 126 |
+
<html lang="en">
|
| 127 |
+
<head>
|
| 128 |
+
<meta charset="utf-8" />
|
| 129 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 130 |
+
<title>HuggingMess</title>
|
| 131 |
+
<style>
|
| 132 |
+
:root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --good:#22c55e; --warn:#f59e0b; --bad:#ef4444; --accent:#38bdf8; }
|
| 133 |
+
* { box-sizing:border-box; }
|
| 134 |
+
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); }
|
| 135 |
+
main { width:min(960px, calc(100% - 32px)); margin:0 auto; padding:36px 0; }
|
| 136 |
+
header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:28px; }
|
| 137 |
+
h1 { margin:0; font-size:clamp(2rem, 6vw, 4.4rem); line-height:.95; letter-spacing:0; }
|
| 138 |
+
.subtitle { margin-top:12px; color:var(--muted); max-width:620px; line-height:1.5; }
|
| 139 |
+
.grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:14px; }
|
| 140 |
+
.card { border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:18px; min-height:120px; }
|
| 141 |
+
.wide { grid-column:1 / -1; }
|
| 142 |
+
.label { color:var(--muted); font-size:.78rem; letter-spacing:.08em; text-transform:uppercase; margin-bottom:10px; }
|
| 143 |
+
.value { font-size:1.05rem; overflow-wrap:anywhere; }
|
| 144 |
+
code { background:#0b0f18; border:1px solid var(--line); border-radius:6px; padding:2px 6px; }
|
| 145 |
+
.row { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
|
| 146 |
+
.badge { display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.8rem; font-weight:700; }
|
| 147 |
+
.badge.ok { color:var(--good); border-color:rgba(34,197,94,.35); background:rgba(34,197,94,.08); }
|
| 148 |
+
.badge.off { color:var(--bad); border-color:rgba(239,68,68,.35); background:rgba(239,68,68,.08); }
|
| 149 |
+
.button { display:inline-flex; align-items:center; justify-content:center; min-height:42px; padding:0 14px; border-radius:7px; color:#07111f; background:var(--accent); text-decoration:none; font-weight:750; }
|
| 150 |
+
.button.secondary { color:var(--text); background:#222b3c; border:1px solid var(--line); }
|
| 151 |
+
@media (max-width: 720px) { header { display:block; } .grid { grid-template-columns:1fr; } }
|
| 152 |
+
</style>
|
| 153 |
+
</head>
|
| 154 |
+
<body>
|
| 155 |
+
<main>
|
| 156 |
+
<header>
|
| 157 |
+
<div>
|
| 158 |
+
<h1>HuggingMess</h1>
|
| 159 |
+
<div class="subtitle">Hermes Agent running as an always-on Hugging Face Docker Space, with Telegram gateway, state backup, Cloudflare proxy support, and keep-awake monitoring.</div>
|
| 160 |
+
</div>
|
| 161 |
+
<div class="row">${badge("Gateway", data.gateway)}${badge("Dashboard", data.dashboard)}${badge("Backup", data.backup?.status !== "disabled")}</div>
|
| 162 |
+
</header>
|
| 163 |
+
<section class="grid">
|
| 164 |
+
<div class="card"><div class="label">Uptime</div><div class="value">${data.uptime}</div></div>
|
| 165 |
+
<div class="card"><div class="label">Model</div><div class="value"><code>${data.model || "not set"}</code></div></div>
|
| 166 |
+
<div class="card"><div class="label">Provider</div><div class="value"><code>${data.provider}</code></div></div>
|
| 167 |
+
<div class="card"><div class="label">Telegram</div><div class="value">${data.telegram.configured ? "Configured" : "Not configured"}${data.telegram.webhook ? " via webhook" : ""}</div></div>
|
| 168 |
+
<div class="card wide"><div class="label">Backup</div><div class="value"><strong>${syncStatus}</strong><br>${data.backup?.message || ""}</div></div>
|
| 169 |
+
<div class="card wide"><div class="label">Keep Awake</div><div class="value">${keepAlive}</div></div>
|
| 170 |
+
<div class="card wide"><div class="label">Entrypoints</div><div class="row">${dashboardLink}${apiLink}<a class="button secondary" href="/status">Status JSON</a></div></div>
|
| 171 |
+
</section>
|
| 172 |
+
</main>
|
| 173 |
+
</body>
|
| 174 |
+
</html>`;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const server = http.createServer(async (req, res) => {
|
| 178 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 179 |
+
const path = parsed.pathname;
|
| 180 |
+
|
| 181 |
+
if (path === "/health" || path === "/dashboard/health") {
|
| 182 |
+
const data = await statusPayload();
|
| 183 |
+
res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
|
| 184 |
+
res.end(JSON.stringify({ ok: data.ok, gateway: data.gateway, uptime: data.uptime }));
|
| 185 |
+
return;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
if (path === "/status" || path === "/dashboard/status") {
|
| 189 |
+
const data = await statusPayload();
|
| 190 |
+
res.writeHead(200, { "content-type": "application/json" });
|
| 191 |
+
res.end(JSON.stringify(data, null, 2));
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
if (path === "/" || path === "/dashboard") {
|
| 196 |
+
const data = await statusPayload();
|
| 197 |
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
| 198 |
+
res.end(renderDashboard(data));
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if (path === "/telegram" || path.startsWith("/telegram/")) {
|
| 203 |
+
proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT);
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (path === "/dashboard/" || path.startsWith("/dashboard/")) {
|
| 208 |
+
proxyRequest(req, res, DASHBOARD_PORT, (p) => p.replace(/^\/dashboard/, "") || "/");
|
| 209 |
+
return;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
if (path === "/v1" || path.startsWith("/v1/")) {
|
| 213 |
+
if (API_SERVER_KEY) {
|
| 214 |
+
const expected = `Bearer ${API_SERVER_KEY}`;
|
| 215 |
+
if (req.headers.authorization !== expected) {
|
| 216 |
+
res.writeHead(401, { "content-type": "application/json" });
|
| 217 |
+
res.end(JSON.stringify({ error: "unauthorized", message: "Use Authorization: Bearer <GATEWAY_TOKEN>." }));
|
| 218 |
+
return;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
proxyRequest(req, res, GATEWAY_PORT);
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
| 226 |
+
res.end("Not found");
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
server.listen(PORT, "0.0.0.0", () => {
|
| 230 |
+
console.log(`HuggingMess dashboard listening on 0.0.0.0:${PORT}`);
|
| 231 |
+
});
|
hermes-sync.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""HuggingMess Hermes state backup via Hugging Face Datasets."""
|
| 3 |
+
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import shutil
|
| 9 |
+
import signal
|
| 10 |
+
import sys
|
| 11 |
+
import tempfile
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
| 17 |
+
os.environ.setdefault("HF_HUB_VERBOSITY", "error")
|
| 18 |
+
|
| 19 |
+
from huggingface_hub import HfApi, snapshot_download, upload_folder
|
| 20 |
+
from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError
|
| 21 |
+
|
| 22 |
+
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
| 23 |
+
|
| 24 |
+
HERMES_HOME = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
| 25 |
+
STATUS_FILE = Path("/tmp/huggingmess-sync-status.json")
|
| 26 |
+
INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
|
| 27 |
+
INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
|
| 28 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
|
| 29 |
+
HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
|
| 30 |
+
SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
| 31 |
+
BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "huggingmess-backup").strip()
|
| 32 |
+
INCLUDE_ENV = os.environ.get("SYNC_INCLUDE_ENV", "").strip().lower() in {"1", "true", "yes"}
|
| 33 |
+
MAX_FILE_SIZE_BYTES = int(os.environ.get("SYNC_MAX_FILE_BYTES", str(50 * 1024 * 1024)))
|
| 34 |
+
|
| 35 |
+
EXCLUDED_DIRS = {
|
| 36 |
+
".cache",
|
| 37 |
+
".git",
|
| 38 |
+
".npm",
|
| 39 |
+
".venv",
|
| 40 |
+
"__pycache__",
|
| 41 |
+
"node_modules",
|
| 42 |
+
"venv",
|
| 43 |
+
}
|
| 44 |
+
EXCLUDED_TOP_LEVEL = {"logs"}
|
| 45 |
+
if not INCLUDE_ENV:
|
| 46 |
+
EXCLUDED_TOP_LEVEL.add(".env")
|
| 47 |
+
|
| 48 |
+
HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
|
| 49 |
+
STOP_EVENT = threading.Event()
|
| 50 |
+
_REPO_ID_CACHE: str | None = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def write_status(status: str, message: str) -> None:
|
| 54 |
+
payload = {
|
| 55 |
+
"status": status,
|
| 56 |
+
"message": message,
|
| 57 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 58 |
+
}
|
| 59 |
+
tmp_path = STATUS_FILE.with_suffix(".tmp")
|
| 60 |
+
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
|
| 61 |
+
tmp_path.replace(STATUS_FILE)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def resolve_backup_repo() -> str:
|
| 65 |
+
global _REPO_ID_CACHE
|
| 66 |
+
if _REPO_ID_CACHE:
|
| 67 |
+
return _REPO_ID_CACHE
|
| 68 |
+
|
| 69 |
+
namespace = HF_USERNAME or SPACE_AUTHOR_NAME
|
| 70 |
+
if not namespace and HF_API is not None:
|
| 71 |
+
whoami = HF_API.whoami()
|
| 72 |
+
namespace = whoami.get("name") or whoami.get("user") or ""
|
| 73 |
+
|
| 74 |
+
namespace = str(namespace).strip()
|
| 75 |
+
if not namespace:
|
| 76 |
+
raise RuntimeError("Could not determine HF username. Set HF_USERNAME or use an account HF_TOKEN.")
|
| 77 |
+
|
| 78 |
+
_REPO_ID_CACHE = f"{namespace}/{BACKUP_DATASET_NAME}"
|
| 79 |
+
return _REPO_ID_CACHE
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def ensure_repo_exists() -> str:
|
| 83 |
+
repo_id = resolve_backup_repo()
|
| 84 |
+
try:
|
| 85 |
+
HF_API.repo_info(repo_id=repo_id, repo_type="dataset")
|
| 86 |
+
except RepositoryNotFoundError:
|
| 87 |
+
HF_API.create_repo(repo_id=repo_id, repo_type="dataset", private=True)
|
| 88 |
+
return repo_id
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def should_exclude(rel_posix: str, path: Path) -> bool:
|
| 92 |
+
parts = Path(rel_posix).parts
|
| 93 |
+
if not parts:
|
| 94 |
+
return False
|
| 95 |
+
if parts[0] in EXCLUDED_TOP_LEVEL:
|
| 96 |
+
return True
|
| 97 |
+
if any(part in EXCLUDED_DIRS for part in parts):
|
| 98 |
+
return True
|
| 99 |
+
if path.is_file():
|
| 100 |
+
try:
|
| 101 |
+
return path.stat().st_size > MAX_FILE_SIZE_BYTES
|
| 102 |
+
except OSError:
|
| 103 |
+
return True
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def metadata_marker(root: Path) -> tuple[int, int, int]:
|
| 108 |
+
if not root.exists():
|
| 109 |
+
return (0, 0, 0)
|
| 110 |
+
file_count = 0
|
| 111 |
+
total_size = 0
|
| 112 |
+
newest_mtime = 0
|
| 113 |
+
for path in root.rglob("*"):
|
| 114 |
+
if not path.is_file():
|
| 115 |
+
continue
|
| 116 |
+
rel = path.relative_to(root).as_posix()
|
| 117 |
+
if should_exclude(rel, path):
|
| 118 |
+
continue
|
| 119 |
+
try:
|
| 120 |
+
stat = path.stat()
|
| 121 |
+
except OSError:
|
| 122 |
+
continue
|
| 123 |
+
file_count += 1
|
| 124 |
+
total_size += int(stat.st_size)
|
| 125 |
+
newest_mtime = max(newest_mtime, int(stat.st_mtime_ns))
|
| 126 |
+
return (file_count, total_size, newest_mtime)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def fingerprint_dir(root: Path) -> str:
|
| 130 |
+
hasher = hashlib.sha256()
|
| 131 |
+
if not root.exists():
|
| 132 |
+
return hasher.hexdigest()
|
| 133 |
+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
|
| 134 |
+
rel = path.relative_to(root).as_posix()
|
| 135 |
+
if should_exclude(rel, path):
|
| 136 |
+
continue
|
| 137 |
+
hasher.update(rel.encode("utf-8"))
|
| 138 |
+
with path.open("rb") as handle:
|
| 139 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 140 |
+
hasher.update(chunk)
|
| 141 |
+
return hasher.hexdigest()
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def create_snapshot_dir(source_root: Path) -> Path:
|
| 145 |
+
staging_root = Path(tempfile.mkdtemp(prefix="huggingmess-sync-"))
|
| 146 |
+
for path in sorted(source_root.rglob("*")):
|
| 147 |
+
rel = path.relative_to(source_root)
|
| 148 |
+
rel_posix = rel.as_posix()
|
| 149 |
+
if should_exclude(rel_posix, path):
|
| 150 |
+
continue
|
| 151 |
+
target = staging_root / rel
|
| 152 |
+
if path.is_dir():
|
| 153 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 154 |
+
continue
|
| 155 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 156 |
+
shutil.copy2(path, target)
|
| 157 |
+
return staging_root
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def restore() -> bool:
|
| 161 |
+
if not HF_TOKEN:
|
| 162 |
+
write_status("disabled", "HF_TOKEN is not configured.")
|
| 163 |
+
return False
|
| 164 |
+
|
| 165 |
+
repo_id = resolve_backup_repo()
|
| 166 |
+
write_status("restoring", f"Restoring Hermes state from {repo_id}")
|
| 167 |
+
try:
|
| 168 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 169 |
+
snapshot_download(repo_id=repo_id, repo_type="dataset", token=HF_TOKEN, local_dir=tmpdir)
|
| 170 |
+
tmp_path = Path(tmpdir)
|
| 171 |
+
if not any(tmp_path.iterdir()):
|
| 172 |
+
write_status("fresh", "Backup dataset is empty. Starting fresh.")
|
| 173 |
+
return True
|
| 174 |
+
|
| 175 |
+
HERMES_HOME.mkdir(parents=True, exist_ok=True)
|
| 176 |
+
for child in tmp_path.iterdir():
|
| 177 |
+
if should_exclude(child.name, child):
|
| 178 |
+
continue
|
| 179 |
+
target = HERMES_HOME / child.name
|
| 180 |
+
if target.is_dir():
|
| 181 |
+
shutil.rmtree(target, ignore_errors=True)
|
| 182 |
+
elif target.exists():
|
| 183 |
+
target.unlink()
|
| 184 |
+
if child.is_dir():
|
| 185 |
+
shutil.copytree(child, target)
|
| 186 |
+
else:
|
| 187 |
+
shutil.copy2(child, target)
|
| 188 |
+
|
| 189 |
+
write_status("restored", f"Restored Hermes state from {repo_id}")
|
| 190 |
+
return True
|
| 191 |
+
except RepositoryNotFoundError:
|
| 192 |
+
write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
|
| 193 |
+
return True
|
| 194 |
+
except HfHubHTTPError as exc:
|
| 195 |
+
if exc.response is not None and exc.response.status_code == 404:
|
| 196 |
+
write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
|
| 197 |
+
return True
|
| 198 |
+
write_status("error", f"Restore failed: {exc}")
|
| 199 |
+
print(f"Restore failed: {exc}", file=sys.stderr)
|
| 200 |
+
return False
|
| 201 |
+
except Exception as exc:
|
| 202 |
+
write_status("error", f"Restore failed: {exc}")
|
| 203 |
+
print(f"Restore failed: {exc}", file=sys.stderr)
|
| 204 |
+
return False
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def sync_once(last_fingerprint: str | None = None, last_marker: tuple[int, int, int] | None = None):
|
| 208 |
+
if not HF_TOKEN:
|
| 209 |
+
write_status("disabled", "HF_TOKEN is not configured.")
|
| 210 |
+
return (last_fingerprint or "", last_marker or (0, 0, 0))
|
| 211 |
+
|
| 212 |
+
repo_id = ensure_repo_exists()
|
| 213 |
+
current_marker = metadata_marker(HERMES_HOME)
|
| 214 |
+
if last_marker is not None and current_marker == last_marker:
|
| 215 |
+
write_status("synced", "No Hermes state changes detected.")
|
| 216 |
+
return (last_fingerprint or "", current_marker)
|
| 217 |
+
|
| 218 |
+
current_fingerprint = fingerprint_dir(HERMES_HOME)
|
| 219 |
+
if last_fingerprint is not None and current_fingerprint == last_fingerprint:
|
| 220 |
+
write_status("synced", "No Hermes state changes detected.")
|
| 221 |
+
return (last_fingerprint, current_marker)
|
| 222 |
+
|
| 223 |
+
write_status("syncing", f"Uploading Hermes state to {repo_id}")
|
| 224 |
+
snapshot_dir = create_snapshot_dir(HERMES_HOME)
|
| 225 |
+
try:
|
| 226 |
+
try:
|
| 227 |
+
HF_API.upload_large_folder(
|
| 228 |
+
repo_id=repo_id,
|
| 229 |
+
repo_type="dataset",
|
| 230 |
+
folder_path=str(snapshot_dir),
|
| 231 |
+
num_workers=2,
|
| 232 |
+
print_report=False,
|
| 233 |
+
)
|
| 234 |
+
except AttributeError:
|
| 235 |
+
upload_folder(
|
| 236 |
+
folder_path=str(snapshot_dir),
|
| 237 |
+
repo_id=repo_id,
|
| 238 |
+
repo_type="dataset",
|
| 239 |
+
token=HF_TOKEN,
|
| 240 |
+
commit_message=f"HuggingMess sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
|
| 241 |
+
ignore_patterns=[".git/*", ".git"],
|
| 242 |
+
)
|
| 243 |
+
finally:
|
| 244 |
+
shutil.rmtree(snapshot_dir, ignore_errors=True)
|
| 245 |
+
|
| 246 |
+
write_status("success", f"Uploaded Hermes state to {repo_id}")
|
| 247 |
+
return (current_fingerprint, current_marker)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def handle_signal(_sig, _frame) -> None:
|
| 251 |
+
STOP_EVENT.set()
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def loop() -> int:
|
| 255 |
+
signal.signal(signal.SIGTERM, handle_signal)
|
| 256 |
+
signal.signal(signal.SIGINT, handle_signal)
|
| 257 |
+
try:
|
| 258 |
+
repo_id = resolve_backup_repo()
|
| 259 |
+
write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
|
| 260 |
+
except Exception as exc:
|
| 261 |
+
write_status("error", str(exc))
|
| 262 |
+
print(f"Hermes sync error: {exc}")
|
| 263 |
+
return 1
|
| 264 |
+
|
| 265 |
+
last_fingerprint = fingerprint_dir(HERMES_HOME)
|
| 266 |
+
last_marker = metadata_marker(HERMES_HOME)
|
| 267 |
+
time.sleep(INITIAL_DELAY)
|
| 268 |
+
print(f"Hermes state sync started: every {INTERVAL}s -> {repo_id}")
|
| 269 |
+
|
| 270 |
+
while not STOP_EVENT.is_set():
|
| 271 |
+
try:
|
| 272 |
+
last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
|
| 273 |
+
except Exception as exc:
|
| 274 |
+
write_status("error", f"Sync failed: {exc}")
|
| 275 |
+
print(f"Hermes sync failed: {exc}")
|
| 276 |
+
if STOP_EVENT.wait(INTERVAL):
|
| 277 |
+
break
|
| 278 |
+
return 0
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def main() -> int:
|
| 282 |
+
HERMES_HOME.mkdir(parents=True, exist_ok=True)
|
| 283 |
+
if len(sys.argv) < 2:
|
| 284 |
+
return loop()
|
| 285 |
+
command = sys.argv[1]
|
| 286 |
+
if command == "restore":
|
| 287 |
+
return 0 if restore() else 1
|
| 288 |
+
if command == "sync-once":
|
| 289 |
+
try:
|
| 290 |
+
sync_once()
|
| 291 |
+
return 0
|
| 292 |
+
except Exception as exc:
|
| 293 |
+
write_status("error", f"Shutdown sync failed: {exc}")
|
| 294 |
+
print(f"Hermes sync: shutdown sync failed: {exc}")
|
| 295 |
+
return 1
|
| 296 |
+
if command == "loop":
|
| 297 |
+
return loop()
|
| 298 |
+
print(f"Unknown command: {command}", file=sys.stderr)
|
| 299 |
+
return 1
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
if __name__ == "__main__":
|
| 303 |
+
raise SystemExit(main())
|
setup-uptimerobot.sh
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
API_URL="https://api.uptimerobot.com/v2"
|
| 5 |
+
API_KEY="${UPTIMEROBOT_API_KEY:-}"
|
| 6 |
+
SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
|
| 7 |
+
STATUS_FILE="/tmp/huggingmess-uptimerobot-status.json"
|
| 8 |
+
|
| 9 |
+
if [ -z "$API_KEY" ]; then
|
| 10 |
+
echo "Missing UPTIMEROBOT_API_KEY."
|
| 11 |
+
exit 1
|
| 12 |
+
fi
|
| 13 |
+
|
| 14 |
+
if [ -z "$SPACE_HOST_INPUT" ]; then
|
| 15 |
+
echo "Missing Space host."
|
| 16 |
+
exit 1
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
SPACE_HOST_CLEAN="${SPACE_HOST_INPUT#https://}"
|
| 20 |
+
SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN#http://}"
|
| 21 |
+
SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
|
| 22 |
+
MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
|
| 23 |
+
MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-HuggingMess ${SPACE_HOST_CLEAN}}"
|
| 24 |
+
INTERVAL="${UPTIMEROBOT_INTERVAL:-300}"
|
| 25 |
+
|
| 26 |
+
MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
|
| 27 |
+
-d "api_key=${API_KEY}" \
|
| 28 |
+
-d "format=json" \
|
| 29 |
+
-d "logs=0" \
|
| 30 |
+
-d "response_times=0" \
|
| 31 |
+
-d "response_times_limit=1")
|
| 32 |
+
|
| 33 |
+
MONITOR_ID=$(printf '%s' "$MONITORS_RESPONSE" | jq -r --arg url "$MONITOR_URL" '
|
| 34 |
+
(.monitors // []) | map(select(.url == $url)) | first | .id // empty
|
| 35 |
+
')
|
| 36 |
+
|
| 37 |
+
if [ -n "$MONITOR_ID" ]; then
|
| 38 |
+
printf '{"configured":true,"monitorId":"%s","url":"%s","alreadyExisted":true,"timestamp":"%s"}\n' \
|
| 39 |
+
"$MONITOR_ID" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
|
| 40 |
+
echo "UptimeRobot monitor already exists for ${MONITOR_URL}"
|
| 41 |
+
exit 0
|
| 42 |
+
fi
|
| 43 |
+
|
| 44 |
+
CURL_ARGS=(
|
| 45 |
+
-sS
|
| 46 |
+
-X POST "${API_URL}/newMonitor"
|
| 47 |
+
-d "api_key=${API_KEY}"
|
| 48 |
+
-d "format=json"
|
| 49 |
+
-d "type=1"
|
| 50 |
+
-d "friendly_name=${MONITOR_NAME}"
|
| 51 |
+
-d "url=${MONITOR_URL}"
|
| 52 |
+
-d "interval=${INTERVAL}"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
if [ -n "${UPTIMEROBOT_ALERT_CONTACTS:-}" ]; then
|
| 56 |
+
CURL_ARGS+=(-d "alert_contacts=${UPTIMEROBOT_ALERT_CONTACTS}")
|
| 57 |
+
fi
|
| 58 |
+
|
| 59 |
+
CREATE_RESPONSE=$(curl "${CURL_ARGS[@]}")
|
| 60 |
+
CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
|
| 61 |
+
|
| 62 |
+
if [ "$CREATE_STATUS" != "ok" ]; then
|
| 63 |
+
printf '{"configured":false,"error":"creation failed","timestamp":"%s"}\n' \
|
| 64 |
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
|
| 65 |
+
echo "Failed to create UptimeRobot monitor."
|
| 66 |
+
printf '%s\n' "$CREATE_RESPONSE"
|
| 67 |
+
exit 1
|
| 68 |
+
fi
|
| 69 |
+
|
| 70 |
+
NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
|
| 71 |
+
printf '{"configured":true,"monitorId":"%s","url":"%s","timestamp":"%s"}\n' \
|
| 72 |
+
"${NEW_ID:-}" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
|
| 73 |
+
echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
|
start.sh
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
umask 0077
|
| 5 |
+
|
| 6 |
+
APP_DIR="${HUGGINGMESS_APP_DIR:-/opt/huggingmess}"
|
| 7 |
+
HERMES_HOME="${HERMES_HOME:-/opt/data}"
|
| 8 |
+
PUBLIC_PORT="${PORT:-7861}"
|
| 9 |
+
GATEWAY_API_PORT="${API_SERVER_PORT:-8642}"
|
| 10 |
+
DASHBOARD_PORT="${DASHBOARD_PORT:-9119}"
|
| 11 |
+
TELEGRAM_WEBHOOK_PORT="${TELEGRAM_WEBHOOK_PORT:-8765}"
|
| 12 |
+
SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
|
| 13 |
+
BACKUP_DATASET="${BACKUP_DATASET_NAME:-huggingmess-backup}"
|
| 14 |
+
CF_PROXY_ENV_FILE="/tmp/huggingmess-cloudflare-proxy.env"
|
| 15 |
+
|
| 16 |
+
export HERMES_HOME
|
| 17 |
+
export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
|
| 18 |
+
export API_SERVER_HOST="${API_SERVER_HOST:-127.0.0.1}"
|
| 19 |
+
export API_SERVER_PORT="$GATEWAY_API_PORT"
|
| 20 |
+
export GATEWAY_HEALTH_URL="${GATEWAY_HEALTH_URL:-http://127.0.0.1:${GATEWAY_API_PORT}}"
|
| 21 |
+
export TELEGRAM_WEBHOOK_PORT
|
| 22 |
+
|
| 23 |
+
if [ -z "${API_SERVER_KEY:-}" ]; then
|
| 24 |
+
if [ -n "${GATEWAY_TOKEN:-}" ]; then
|
| 25 |
+
export API_SERVER_KEY="$GATEWAY_TOKEN"
|
| 26 |
+
else
|
| 27 |
+
API_SERVER_KEY="$(python - <<'PY'
|
| 28 |
+
import secrets
|
| 29 |
+
print(secrets.token_urlsafe(32))
|
| 30 |
+
PY
|
| 31 |
+
)"
|
| 32 |
+
export API_SERVER_KEY
|
| 33 |
+
echo "GATEWAY_TOKEN not set - generated an ephemeral API token for this boot."
|
| 34 |
+
fi
|
| 35 |
+
fi
|
| 36 |
+
|
| 37 |
+
echo ""
|
| 38 |
+
echo " =========================================="
|
| 39 |
+
echo " HuggingMess Hermes Gateway"
|
| 40 |
+
echo " =========================================="
|
| 41 |
+
echo ""
|
| 42 |
+
|
| 43 |
+
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home}
|
| 44 |
+
|
| 45 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 46 |
+
echo "Restoring Hermes state from HF Dataset..."
|
| 47 |
+
python "$APP_DIR/hermes-sync.py" restore || true
|
| 48 |
+
else
|
| 49 |
+
echo "HF_TOKEN not set - dataset persistence is disabled."
|
| 50 |
+
fi
|
| 51 |
+
|
| 52 |
+
CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
|
| 53 |
+
export CLOUDFLARE_WORKERS_TOKEN
|
| 54 |
+
if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
|
| 55 |
+
export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"
|
| 56 |
+
echo "Preparing Cloudflare Telegram proxy..."
|
| 57 |
+
python "$APP_DIR/cloudflare-proxy-setup.py" || true
|
| 58 |
+
if [ -f "$CF_PROXY_ENV_FILE" ]; then
|
| 59 |
+
. "$CF_PROXY_ENV_FILE"
|
| 60 |
+
fi
|
| 61 |
+
fi
|
| 62 |
+
|
| 63 |
+
if [ -n "${TELEGRAM_USER_IDS:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
|
| 64 |
+
export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_IDS"
|
| 65 |
+
elif [ -n "${TELEGRAM_USER_ID:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
|
| 66 |
+
export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_ID"
|
| 67 |
+
fi
|
| 68 |
+
|
| 69 |
+
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${SPACE_HOST:-}" ] && [ -z "${TELEGRAM_WEBHOOK_URL:-}" ]; then
|
| 70 |
+
if [ "${TELEGRAM_MODE:-webhook}" != "polling" ]; then
|
| 71 |
+
export TELEGRAM_WEBHOOK_URL="https://${SPACE_HOST}/telegram"
|
| 72 |
+
fi
|
| 73 |
+
fi
|
| 74 |
+
|
| 75 |
+
if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ] && [ -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]; then
|
| 76 |
+
SECRET_FILE="$HERMES_HOME/.huggingmess-telegram-webhook-secret"
|
| 77 |
+
if [ -f "$SECRET_FILE" ]; then
|
| 78 |
+
export TELEGRAM_WEBHOOK_SECRET
|
| 79 |
+
TELEGRAM_WEBHOOK_SECRET="$(cat "$SECRET_FILE")"
|
| 80 |
+
else
|
| 81 |
+
TELEGRAM_WEBHOOK_SECRET="$(python - <<'PY'
|
| 82 |
+
import secrets
|
| 83 |
+
print(secrets.token_hex(32))
|
| 84 |
+
PY
|
| 85 |
+
)"
|
| 86 |
+
printf '%s' "$TELEGRAM_WEBHOOK_SECRET" > "$SECRET_FILE"
|
| 87 |
+
chmod 600 "$SECRET_FILE"
|
| 88 |
+
export TELEGRAM_WEBHOOK_SECRET
|
| 89 |
+
fi
|
| 90 |
+
fi
|
| 91 |
+
|
| 92 |
+
MODEL_INPUT="${HERMES_MODEL:-${LLM_MODEL:-}}"
|
| 93 |
+
MODEL_FOR_CONFIG="$MODEL_INPUT"
|
| 94 |
+
PROVIDER_FOR_CONFIG="${HERMES_INFERENCE_PROVIDER:-auto}"
|
| 95 |
+
LLM_API_KEY="${LLM_API_KEY:-}"
|
| 96 |
+
|
| 97 |
+
if [ -n "$MODEL_INPUT" ]; then
|
| 98 |
+
MODEL_PREFIX="${MODEL_INPUT%%/*}"
|
| 99 |
+
else
|
| 100 |
+
MODEL_PREFIX=""
|
| 101 |
+
fi
|
| 102 |
+
|
| 103 |
+
case "$MODEL_PREFIX" in
|
| 104 |
+
openrouter)
|
| 105 |
+
[ -n "$LLM_API_KEY" ] && export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-$LLM_API_KEY}"
|
| 106 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="openrouter"
|
| 107 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#openrouter/}"
|
| 108 |
+
;;
|
| 109 |
+
huggingface)
|
| 110 |
+
[ -n "$LLM_API_KEY" ] && export HF_TOKEN="${HF_TOKEN:-$LLM_API_KEY}"
|
| 111 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="huggingface"
|
| 112 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#huggingface/}"
|
| 113 |
+
;;
|
| 114 |
+
vercel-ai-gateway|ai-gateway)
|
| 115 |
+
[ -n "$LLM_API_KEY" ] && export AI_GATEWAY_API_KEY="${AI_GATEWAY_API_KEY:-$LLM_API_KEY}"
|
| 116 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="ai-gateway"
|
| 117 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
|
| 118 |
+
;;
|
| 119 |
+
anthropic)
|
| 120 |
+
[ -n "$LLM_API_KEY" ] && export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$LLM_API_KEY}"
|
| 121 |
+
;;
|
| 122 |
+
openai|openai-codex)
|
| 123 |
+
[ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
|
| 124 |
+
;;
|
| 125 |
+
google|gemini)
|
| 126 |
+
[ -n "$LLM_API_KEY" ] && export GOOGLE_API_KEY="${GOOGLE_API_KEY:-$LLM_API_KEY}" GEMINI_API_KEY="${GEMINI_API_KEY:-$LLM_API_KEY}"
|
| 127 |
+
;;
|
| 128 |
+
deepseek)
|
| 129 |
+
[ -n "$LLM_API_KEY" ] && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-$LLM_API_KEY}"
|
| 130 |
+
;;
|
| 131 |
+
kimi-coding|moonshot)
|
| 132 |
+
[ -n "$LLM_API_KEY" ] && export KIMI_API_KEY="${KIMI_API_KEY:-$LLM_API_KEY}"
|
| 133 |
+
;;
|
| 134 |
+
minimax)
|
| 135 |
+
[ -n "$LLM_API_KEY" ] && export MINIMAX_API_KEY="${MINIMAX_API_KEY:-$LLM_API_KEY}"
|
| 136 |
+
;;
|
| 137 |
+
xiaomi)
|
| 138 |
+
[ -n "$LLM_API_KEY" ] && export XIAOMI_API_KEY="${XIAOMI_API_KEY:-$LLM_API_KEY}"
|
| 139 |
+
;;
|
| 140 |
+
zai|z-ai|z.ai|glm)
|
| 141 |
+
[ -n "$LLM_API_KEY" ] && export GLM_API_KEY="${GLM_API_KEY:-$LLM_API_KEY}"
|
| 142 |
+
;;
|
| 143 |
+
nvidia)
|
| 144 |
+
[ -n "$LLM_API_KEY" ] && export NVIDIA_API_KEY="${NVIDIA_API_KEY:-$LLM_API_KEY}"
|
| 145 |
+
;;
|
| 146 |
+
xai|grok)
|
| 147 |
+
[ -n "$LLM_API_KEY" ] && export XAI_API_KEY="${XAI_API_KEY:-$LLM_API_KEY}"
|
| 148 |
+
;;
|
| 149 |
+
kilocode)
|
| 150 |
+
[ -n "$LLM_API_KEY" ] && export KILOCODE_API_KEY="${KILOCODE_API_KEY:-$LLM_API_KEY}"
|
| 151 |
+
;;
|
| 152 |
+
opencode-zen)
|
| 153 |
+
[ -n "$LLM_API_KEY" ] && export OPENCODE_ZEN_API_KEY="${OPENCODE_ZEN_API_KEY:-$LLM_API_KEY}"
|
| 154 |
+
;;
|
| 155 |
+
opencode-go)
|
| 156 |
+
[ -n "$LLM_API_KEY" ] && export OPENCODE_GO_API_KEY="${OPENCODE_GO_API_KEY:-$LLM_API_KEY}"
|
| 157 |
+
;;
|
| 158 |
+
esac
|
| 159 |
+
|
| 160 |
+
if [ -n "${CUSTOM_BASE_URL:-}" ]; then
|
| 161 |
+
PROVIDER_FOR_CONFIG="${CUSTOM_PROVIDER:-custom}"
|
| 162 |
+
[ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
|
| 163 |
+
fi
|
| 164 |
+
|
| 165 |
+
if [ -z "$MODEL_FOR_CONFIG" ]; then
|
| 166 |
+
echo "Missing LLM_MODEL or HERMES_MODEL."
|
| 167 |
+
echo "Add it in HF Spaces -> Settings -> Variables or Secrets."
|
| 168 |
+
exit 1
|
| 169 |
+
fi
|
| 170 |
+
|
| 171 |
+
export MODEL_FOR_CONFIG PROVIDER_FOR_CONFIG
|
| 172 |
+
export CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"
|
| 173 |
+
export CUSTOM_API_KEY="${CUSTOM_API_KEY:-${LLM_API_KEY:-}}"
|
| 174 |
+
export CUSTOM_MODEL_CONTEXT_LENGTH="${CUSTOM_MODEL_CONTEXT_LENGTH:-131072}"
|
| 175 |
+
export CUSTOM_MODEL_MAX_TOKENS="${CUSTOM_MODEL_MAX_TOKENS:-8192}"
|
| 176 |
+
export TELEGRAM_BASE_URL="${TELEGRAM_BASE_URL:-}"
|
| 177 |
+
export TELEGRAM_BASE_FILE_URL="${TELEGRAM_BASE_FILE_URL:-}"
|
| 178 |
+
|
| 179 |
+
if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then
|
| 180 |
+
CLOUDFLARE_PROXY_URL="${CLOUDFLARE_PROXY_URL%/}"
|
| 181 |
+
export TELEGRAM_BASE_URL="${CLOUDFLARE_PROXY_URL}/bot"
|
| 182 |
+
export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
|
| 183 |
+
fi
|
| 184 |
+
|
| 185 |
+
python - <<'PY'
|
| 186 |
+
import os
|
| 187 |
+
from pathlib import Path
|
| 188 |
+
|
| 189 |
+
import yaml
|
| 190 |
+
|
| 191 |
+
home = Path(os.environ["HERMES_HOME"])
|
| 192 |
+
path = home / "config.yaml"
|
| 193 |
+
try:
|
| 194 |
+
config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
| 195 |
+
except FileNotFoundError:
|
| 196 |
+
config = {}
|
| 197 |
+
|
| 198 |
+
model = config.setdefault("model", {})
|
| 199 |
+
model["default"] = os.environ["MODEL_FOR_CONFIG"]
|
| 200 |
+
model["provider"] = os.environ["PROVIDER_FOR_CONFIG"]
|
| 201 |
+
|
| 202 |
+
custom_base = os.environ.get("CUSTOM_BASE_URL", "").strip()
|
| 203 |
+
if custom_base:
|
| 204 |
+
model["base_url"] = custom_base.rstrip("/")
|
| 205 |
+
if os.environ.get("CUSTOM_API_KEY"):
|
| 206 |
+
model["api_key"] = os.environ["CUSTOM_API_KEY"]
|
| 207 |
+
try:
|
| 208 |
+
model["context_length"] = int(os.environ.get("CUSTOM_MODEL_CONTEXT_LENGTH", "131072"))
|
| 209 |
+
model["max_tokens"] = int(os.environ.get("CUSTOM_MODEL_MAX_TOKENS", "8192"))
|
| 210 |
+
except ValueError:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
config.setdefault("terminal", {})["cwd"] = os.environ.get("MESSAGING_CWD", str(home / "workspace"))
|
| 214 |
+
config.setdefault("compression", {}).setdefault("enabled", True)
|
| 215 |
+
config.setdefault("display", {}).setdefault("background_process_notifications", os.environ.get("HERMES_BACKGROUND_NOTIFICATIONS", "result"))
|
| 216 |
+
|
| 217 |
+
platforms = config.setdefault("platforms", {})
|
| 218 |
+
|
| 219 |
+
if os.environ.get("TELEGRAM_BOT_TOKEN"):
|
| 220 |
+
telegram = platforms.setdefault("telegram", {})
|
| 221 |
+
telegram["enabled"] = True
|
| 222 |
+
extra = telegram.setdefault("extra", {})
|
| 223 |
+
if os.environ.get("TELEGRAM_BASE_URL"):
|
| 224 |
+
extra["base_url"] = os.environ["TELEGRAM_BASE_URL"]
|
| 225 |
+
extra["base_file_url"] = os.environ.get("TELEGRAM_BASE_FILE_URL") or os.environ["TELEGRAM_BASE_URL"]
|
| 226 |
+
if os.environ.get("TELEGRAM_ALLOWED_USERS"):
|
| 227 |
+
config.setdefault("telegram", {})["allow_from"] = [
|
| 228 |
+
item.strip()
|
| 229 |
+
for item in os.environ["TELEGRAM_ALLOWED_USERS"].split(",")
|
| 230 |
+
if item.strip()
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
|
| 234 |
+
path.chmod(0o600)
|
| 235 |
+
PY
|
| 236 |
+
|
| 237 |
+
echo ""
|
| 238 |
+
echo "Hermes model : ${MODEL_FOR_CONFIG}"
|
| 239 |
+
echo "Provider : ${PROVIDER_FOR_CONFIG}"
|
| 240 |
+
echo "Public port : ${PUBLIC_PORT}"
|
| 241 |
+
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
|
| 242 |
+
echo "Telegram : enabled"
|
| 243 |
+
if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ]; then
|
| 244 |
+
echo "Telegram mode: webhook (${TELEGRAM_WEBHOOK_URL})"
|
| 245 |
+
else
|
| 246 |
+
echo "Telegram mode: polling"
|
| 247 |
+
fi
|
| 248 |
+
else
|
| 249 |
+
echo "Telegram : not configured"
|
| 250 |
+
fi
|
| 251 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 252 |
+
echo "Backup : ${BACKUP_DATASET} every ${SYNC_INTERVAL}s"
|
| 253 |
+
else
|
| 254 |
+
echo "Backup : disabled"
|
| 255 |
+
fi
|
| 256 |
+
if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
|
| 257 |
+
echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
|
| 258 |
+
fi
|
| 259 |
+
if [ -n "${SPACE_HOST:-}" ]; then
|
| 260 |
+
echo "Space URL : https://${SPACE_HOST}"
|
| 261 |
+
fi
|
| 262 |
+
echo ""
|
| 263 |
+
|
| 264 |
+
graceful_shutdown() {
|
| 265 |
+
echo "Shutting down HuggingMess..."
|
| 266 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 267 |
+
python "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: shutdown sync failed."
|
| 268 |
+
fi
|
| 269 |
+
kill $(jobs -p) 2>/dev/null || true
|
| 270 |
+
exit 0
|
| 271 |
+
}
|
| 272 |
+
trap graceful_shutdown SIGTERM SIGINT
|
| 273 |
+
|
| 274 |
+
node "$APP_DIR/health-server.js" &
|
| 275 |
+
HEALTH_PID=$!
|
| 276 |
+
|
| 277 |
+
if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
|
| 278 |
+
echo "Setting up UptimeRobot monitor..."
|
| 279 |
+
bash "$APP_DIR/setup-uptimerobot.sh" "${SPACE_HOST}" || true
|
| 280 |
+
fi
|
| 281 |
+
|
| 282 |
+
if [ -n "${WEBHOOK_URL:-}" ]; then
|
| 283 |
+
python - <<'PY' >/dev/null 2>&1 &
|
| 284 |
+
import json, os, urllib.request
|
| 285 |
+
body = json.dumps({
|
| 286 |
+
"event": "restart",
|
| 287 |
+
"status": "success",
|
| 288 |
+
"message": "HuggingMess Hermes gateway has started.",
|
| 289 |
+
"model": os.environ.get("MODEL_FOR_CONFIG", ""),
|
| 290 |
+
}).encode()
|
| 291 |
+
req = urllib.request.Request(os.environ["WEBHOOK_URL"], data=body, method="POST", headers={"Content-Type": "application/json"})
|
| 292 |
+
urllib.request.urlopen(req, timeout=10).read()
|
| 293 |
+
PY
|
| 294 |
+
fi
|
| 295 |
+
|
| 296 |
+
echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
|
| 297 |
+
(hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
|
| 298 |
+
DASHBOARD_PID=$!
|
| 299 |
+
|
| 300 |
+
echo "Launching Hermes gateway..."
|
| 301 |
+
(hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
|
| 302 |
+
GATEWAY_PID=$!
|
| 303 |
+
|
| 304 |
+
GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
|
| 305 |
+
ready=false
|
| 306 |
+
for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
|
| 307 |
+
if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
|
| 308 |
+
ready=true
|
| 309 |
+
break
|
| 310 |
+
fi
|
| 311 |
+
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
| 312 |
+
break
|
| 313 |
+
fi
|
| 314 |
+
sleep 1
|
| 315 |
+
done
|
| 316 |
+
|
| 317 |
+
if [ "$ready" != "true" ]; then
|
| 318 |
+
echo ""
|
| 319 |
+
echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
|
| 320 |
+
echo "----------------------------------------"
|
| 321 |
+
tail -40 "$HERMES_HOME/logs/gateway.log" || true
|
| 322 |
+
exit 1
|
| 323 |
+
fi
|
| 324 |
+
|
| 325 |
+
if [ -n "${HF_TOKEN:-}" ]; then
|
| 326 |
+
python -u "$APP_DIR/hermes-sync.py" loop &
|
| 327 |
+
fi
|
| 328 |
+
|
| 329 |
+
wait "$GATEWAY_PID"
|