Initial commit: Hermes Agent skills collection
- Trading skills (OKX, dividend, lottery, quantitative) - Creative skills (ASCII art, diagrams, video) - Development skills (GitHub, debugging, TDD) - Research skills (arXiv, blog monitoring) - Productivity skills (email, documents, notes) - MCP integration skills - Custom user skills
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
---
|
||||
name: clash-docker-workflow
|
||||
description: Deploy and manage a high-performance proxy (Clash/Mihomo) — standalone binary (preferred) or Docker. Includes Web UI dashboards and Hermes Telegram proxy integration.
|
||||
tags: [proxy, clash, mihomo, networking]
|
||||
---
|
||||
# Clash / Mihomo Proxy Workflow
|
||||
|
||||
Deploy and manage a high-performance proxy (Clash/Mihomo) for routing traffic through China's GFW.
|
||||
|
||||
Two deployment modes: **standalone binary** (preferred by user) or **Docker**.
|
||||
|
||||
---
|
||||
|
||||
## Mode 1: Standalone Binary (preferred)
|
||||
|
||||
### Install
|
||||
```bash
|
||||
# Download mihomo — use GitHub mirror in China (direct github.com times out)
|
||||
cd ~/clash
|
||||
curl -L "https://ghfast.top/https://github.com/MetaCubeX/mihomo/releases/download/v1.19.8/mihomo-linux-amd64-v1.19.8.gz" -o mihomo.gz
|
||||
gunzip -f mihomo.gz && chmod +x mihomo
|
||||
./mihomo -v # verify
|
||||
```
|
||||
|
||||
### Configure
|
||||
```bash
|
||||
# 1. Create config dir
|
||||
mkdir -p ~/clash/config
|
||||
|
||||
# 2. Download subscription — MUST set User-Agent or WAF blocks (403)
|
||||
curl -L -H "User-Agent: clash-verge/v2.0.0" \
|
||||
"SUBSCRIPTION_URL" -o ~/clash/config/config.yaml
|
||||
|
||||
# 3. Inject required params if missing
|
||||
# Raw subscriptions often omit these:
|
||||
cat >> ~/clash/config/config.yaml << 'EOF'
|
||||
mixed-port: 7890
|
||||
allow-lan: true
|
||||
mode: rule
|
||||
log-level: info
|
||||
external-controller: 0.0.0.0:9090
|
||||
EOF
|
||||
```
|
||||
|
||||
### Pre-download GeoIP data (critical for China servers)
|
||||
mihomo downloads GeoIP/Geosite DBs from GitHub on first start. If GitHub is blocked, it **hangs forever** at "Can't find MMDB, start download". Pre-download via mirror:
|
||||
```bash
|
||||
cd ~/clash/config
|
||||
curl -L "https://ghfast.top/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.db" -o geoip.db
|
||||
curl -L "https://ghfast.top/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb" -o geoip.metadb
|
||||
curl -L "https://ghfast.top/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.db" -o geosite.db
|
||||
```
|
||||
|
||||
### Run
|
||||
```bash
|
||||
# Foreground (test, Ctrl+C to stop)
|
||||
~/clash/mihomo -d ~/clash/config
|
||||
|
||||
# Background — use Hermes terminal background mode:
|
||||
# terminal(background=true, command="cd ~/clash && exec ./mihomo -d config 2>&1")
|
||||
# Then check: terminal(command="ss -tlnp | grep -E '7890|9090'")
|
||||
```
|
||||
|
||||
**⚠️ `terminal(background=true)` is NOT persistent.** The mihomo process dies when:
|
||||
- The gateway session ends or restarts
|
||||
- The Hermes turn that started it finishes and gets garbage-collected
|
||||
- The system reboots
|
||||
|
||||
For production use, **always use systemd** (see references/systemd-setup.md). Only use `terminal(background=true)` for quick tests.
|
||||
|
||||
### Verify
|
||||
```bash
|
||||
curl -x http://127.0.0.1:7890 https://api.telegram.org # should return HTML/JSON (not timeout)
|
||||
curl http://127.0.0.1:9090/version # {"meta":true,"version":"v1.19.8"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mode 2: Docker
|
||||
|
||||
### docker-compose.yml
|
||||
```yaml
|
||||
services:
|
||||
clash:
|
||||
image: orzly/mihomo:latest
|
||||
container_name: clash
|
||||
restart: always
|
||||
volumes:
|
||||
- ./config:/root/.config/mihomo
|
||||
ports:
|
||||
- "7890:7890"
|
||||
- "7891:7891"
|
||||
- "9090:9090"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
clash-ui:
|
||||
image: yaknet/yacd:latest
|
||||
container_name: clash-ui
|
||||
restart: always
|
||||
ports:
|
||||
- "3412:80"
|
||||
environment:
|
||||
- API_URL=http://clash:9090
|
||||
depends_on:
|
||||
- clash
|
||||
```
|
||||
|
||||
### Workflow
|
||||
1. `mkdir -p ~/clash/config`
|
||||
2. Place `docker-compose.yml` in `~/clash/`
|
||||
3. Download subscription to `~/clash/config/config.yaml`
|
||||
4. `cd ~/clash && docker compose up -d`
|
||||
5. UI at `http://localhost:3412`
|
||||
|
||||
---
|
||||
|
||||
## Web UI (Dashboard)
|
||||
|
||||
### Option A: Online dashboard (zero setup, LAN only)
|
||||
Use a hosted dashboard pointing to your mihomo API:
|
||||
- **YACD**: https://yacd.haishan.me/ → API: `http://<server-ip>:9090`
|
||||
- **metacubexd**: https://metacubex.github.io/metacubexd/ → API: `http://<server-ip>:9090`
|
||||
|
||||
**⚠️ Mixed content blocker**: Online dashboards are served over HTTPS. If your mihomo API is HTTP (typical for LAN), browsers block the fetch due to mixed-content policy. The dashboard loads but shows an empty/disconnected state. Fix: use Option B (local deployment).
|
||||
|
||||
### Option B: Local static deployment (recommended for LAN)
|
||||
Download yacd static files and serve locally over HTTP:
|
||||
```bash
|
||||
cd ~/clash
|
||||
curl -L "https://ghfast.top/https://github.com/haishanh/yacd/releases/download/v0.3.8/yacd.tar.xz" -o yacd.tar.xz
|
||||
mkdir -p yacd && tar xf yacd.tar.xz -C yacd --strip-components=1
|
||||
# Serve on port 9091 (avoids conflict with mihomo API on 9090)
|
||||
python3 -m http.server 9091 --bind 0.0.0.0 --directory ~/clash/yacd &
|
||||
```
|
||||
Access with API pre-filled: `http://<server-ip>:9091/?host=http://<server-ip>:9090`
|
||||
|
||||
For persistence, add the python HTTP server to the same systemd service as mihomo (see references/systemd-setup.md).
|
||||
|
||||
### Option C: Docker container (see Mode 2 above)
|
||||
YACD at `http://localhost:3412`.
|
||||
|
||||
---
|
||||
|
||||
### Connecting Applications
|
||||
|
||||
Set proxy env vars in `~/.bashrc` (Hermes terminal auto-sources it via `auto_source_bashrc: true`):
|
||||
```bash
|
||||
# Proxy for all HTTP/HTTPS traffic
|
||||
export HTTP_PROXY=http://127.0.0.1:7890
|
||||
export HTTPS_PROXY=http://127.0.0.1:7890
|
||||
export http_proxy=http://127.0.0.1:7890
|
||||
export https_proxy=http://127.0.0.1:7890
|
||||
export ALL_PROXY=socks5://127.0.0.1:7890
|
||||
|
||||
# NO_PROXY: local/internal services that must NOT go through proxy
|
||||
# Include: localhost, local model servers, WireGuard subnet, .local mDNS
|
||||
export NO_PROXY=localhost,127.0.0.1,192.168.199.100,10.8.0.0/24,*.local
|
||||
```
|
||||
|
||||
**Important**: Set BOTH uppercase AND lowercase variants (`HTTP_PROXY` + `http_proxy`). Some libraries only read one form.
|
||||
|
||||
### Routing Specific Services via Mihomo Rules
|
||||
|
||||
When you need certain API services to **always** go through the proxy (even if MATCH rule changes), add explicit `DOMAIN-SUFFIX` rules to the top of the `rules:` section in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
# LongPort / LongBridge API
|
||||
- 'DOMAIN-SUFFIX,longportapp.com,BiXin Network'
|
||||
- 'DOMAIN-SUFFIX,longport.com,BiXin Network'
|
||||
- 'DOMAIN-SUFFIX,longbridge.com,BiXin Network'
|
||||
- 'DOMAIN-SUFFIX,longbridge.hk,BiXin Network'
|
||||
# OKX 交易所
|
||||
- 'DOMAIN-SUFFIX,okx.com,BiXin Network'
|
||||
- 'DOMAIN-SUFFIX,okx.me,BiXin Network'
|
||||
- 'DOMAIN-SUFFIX,okx.co,BiXin Network'
|
||||
- 'DOMAIN-SUFFIX,oklink.com,BiXin Network'
|
||||
- 'DOMAIN-SUFFIX,oklink.org,BiXin Network'
|
||||
# ... existing rules below ...
|
||||
```
|
||||
|
||||
Place service-specific rules **before** the large DIRECT rule block for Chinese services — Mihomo evaluates rules top-down, first match wins.
|
||||
|
||||
### Mihomo API Hot-Reload (no restart needed)
|
||||
|
||||
After editing `config.yaml`, reload without killing the process:
|
||||
```bash
|
||||
# Method 1: REST API reload
|
||||
curl -s -X PUT http://127.0.0.1:9090/configs \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"path":"/home/openclaw/clash/config/config.yaml"}'
|
||||
|
||||
# Method 2: SIGHUP (simpler, same effect)
|
||||
pkill -HUP -f mihomo
|
||||
|
||||
# Method 3: Full restart (only if hot-reload fails)
|
||||
bash ~/clash/startup.sh restart mihomo
|
||||
```
|
||||
|
||||
### Hermes Proxy Configuration
|
||||
|
||||
Hermes supports Telegram proxy via config.yaml:
|
||||
|
||||
```bash
|
||||
hermes config set telegram.proxy_url http://127.0.0.1:7890
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
`load_gateway_config()` bridges `telegram.proxy_url` to `TELEGRAM_PROXY` env var at startup. Verified working.
|
||||
|
||||
For full detail see: `skill_view(name="clash-docker-workflow", file_path="references/hermes-telegram-proxy.md")`/...`. Other mirrors: `ghproxy.com`, `mirror.ghproxy.com`.
|
||||
- **Subscription URL returns 403 (WAF)**: Many subscription providers block requests without a browser-like User-Agent. Add `-H "User-Agent: clash-verge/v2.0.0"` to curl. This was the fix for BiXin (sub-route.bxy.re).
|
||||
- **mihomo hangs at "Can't find MMDB, start download"**: GeoIP/Geosite DBs need to be pre-downloaded when GitHub is blocked. See "Pre-download GeoIP data" section above. Without these files, mihomo starts but never binds ports.
|
||||
- **mihomo.gz corrupted on failed download**: If `file mihomo.gz` shows `ASCII text` instead of gzip, the download failed (GitHub returned 404/HTML). Re-download using mirror.
|
||||
- **Missing Control Params**: A raw subscription file often lacks `external-controller` or `mode`. Always ensure these are present in `config.yaml` for the API/UI to work.
|
||||
- **Container Communication (Docker only)**: The UI container uses `API_URL=http://clash:9090`. This relies on the service name `clash` in the Docker network.
|
||||
- **Subscription config format**: mihomo (MetaCubeX) uses a slightly different config format than original Clash. Ensure your subscription provider supports mihomo/clash-meta format, or convert.
|
||||
- **external-controller binding**: Default `127.0.0.1:9090` only allows local access. Use `0.0.0.0:9090` if you need remote Web UI access from another machine (e.g., Mac → Linux server).
|
||||
- **Background process output lost**: When running mihomo with `terminal(background=true)`, stdout/stderr go to pipes. Use a wrapper script with `exec ./mihomo -d config 2>&1` or redirect to a log file to capture output.
|
||||
- **Online dashboard shows empty/disconnected**: Browser blocks HTTP API requests from HTTPS dashboard pages (mixed-content policy). Deploy yacd locally instead — see Web UI Option B.
|
||||
- **`{"hello":"mihomo"}` when opening API URL**: This is the mihomo API root response, NOT the Web UI. Users sometimes confuse this. The Web UI is a separate static app (yacd) that connects to the API.
|
||||
- **Hot-reload preferred over restart**: Editing `config.yaml` then calling `PUT /configs` or `pkill -HUP` reloads rules without dropping active connections. Only do a full restart if hot-reload fails or the binary itself needs updating.
|
||||
- **NO_PROXY must include internal IPs**: If you route all traffic through proxy, local services (Ollama on 192.168.x.x, WireGuard 10.8.0.x, localhost) will break. Always set NO_PROXY with internal subnets. Also include `*.local` for mDNS.
|
||||
- **Lowercase proxy vars**: Some Python libraries (requests, httpx, aiohttp) read `http_proxy`/`https_proxy` (lowercase) while others read `HTTP_PROXY`/`HTTPS_PROXY` (uppercase). Set both to be safe.
|
||||
- **Mihomo rule order matters**: Rules are evaluated top-down, first match wins. Place specific service rules (e.g., `DOMAIN-SUFFIX,okx.com`) BEFORE broad DIRECT rules (e.g., `DOMAIN-SUFFIX,qq.com`). If a broad DIRECT rule matches first, your specific proxy rule is never reached.
|
||||
- **Proxy down = ALL platforms fail**: When `127.0.0.1:7890` is unreachable, Telegram, QQBot, and WeChat all fail simultaneously. Gateway log pattern: `Connect attempt N/8 failed: httpx.ConnectError: All connection attempts failed` + `Proxy detected; passing explicitly to HTTPXRequest`. Quick diagnosis: read `~/.hermes/logs/gateway.log` via `read_file()` (last 100 lines). For full diagnostic checklist: `skill_view(name="clash-docker-workflow", file_path="references/proxy-diagnostics.md")`.
|
||||
- **mihomo systemd service**: The binary runs as a foreground process. For production, create a systemd service to survive reboots. Full recipe: `skill_view(name="clash-docker-workflow", file_path="references/systemd-setup.md")`.
|
||||
- **Security scanner blocks sudo writes to /etc/systemd/**: Hermes's security layer intercepts `sudo tee`, `sudo cp`, and `write_file` targeting `/etc/systemd/system/`. Workaround: write `.service` files to `/tmp/` via `write_file()`, then instruct the user to manually `sudo cp` and `sudo systemctl`. Do not retry blocked commands — they will keep failing.
|
||||
- **startup.sh vs systemd conflict**: If both `startup.sh` and systemd manage the same services, they can fight over PIDs. After enabling systemd, stop using `startup.sh` for start/stop — use `sudo systemctl` exclusively.
|
||||
- **startup-service-manager skill**: For generic service management (not just Clash), see the `startup-service-manager` skill — same `~/startup/` directory, manages mihomo + yacd + any other service.
|
||||
- **Subscription update MUST NOT use proxy (circular dependency)**: The proxy depends on the subscription for node configs. If subscription updates go through the proxy, you get a dead-lock: proxy needs subscription → subscription update needs proxy. **Always use direct connection (no `-x` flag) for subscription downloads.** This may be slower or fail on some networks, but it avoids the circular dependency. If direct download also fails (e.g., the subscription provider is blocked by ISP), the only escape is fetching from an alternate network (mobile hotspot, VPN) and manually placing the config file.
|
||||
- **Duplicate mihomo processes**: If mihomo is started multiple times (e.g., both `startup.sh start` and a manual `./mihomo -d config`), duplicate PIDs compete for the same ports. Check with `ps aux | grep mihomo | grep -v grep` and kill extras. The `scripts/update-sub.sh` uses `flock` to prevent concurrent updates, but the mihomo binary itself has no singleton guard.
|
||||
- **`hermes gateway restart` fails when run from inside the gateway**: Hermes blocks self-restart from within a gateway session (SIGTERM propagation). Instruct the user to run it from a separate shell. The agent cannot do this via terminal(). Workaround: `systemctl --user restart hermes-gateway` from a separate shell.
|
||||
- **mihomo crash can permanently kill Telegram adapter (reconnect watcher bug)**: When mihomo goes down, the Telegram adapter hits 10 network retries → sets fatal error → gateway's `_platform_reconnect_watcher` is supposed to auto-reconnect with exponential backoff (30s→300s cap). BUT the watcher task can silently die (asyncio task exit with no log), leaving the adapter permanently offline even after mihomo recovers. Symptoms: (1) zero `[Telegram]` logs in journalctl after the crash, (2) no "Reconnecting telegram" or "Reconnect telegram error" entries, (3) DMs may still work (existing session) but group messages are lost. Diagnosis: `journalctl --user -u hermes-gateway --since '1 hour ago' | grep -i 'telegram' | grep -v Traceback`. If only crash/reconnect-failed logs exist with no subsequent "Reconnecting" attempts, the watcher is dead. Fix: restart gateway from external shell. This is a Hermes upstream bug — the watcher's `while self._running` loop exits silently under certain conditions. Until fixed upstream, consider a mihomo watchdog (see `references/mihomo-watchdog.md`) to prevent the initial crash.
|
||||
- **Dual defense for proxy — set BOTH config bridge AND systemd env vars**: `telegram.proxy_url` in config.yaml IS bridged to `TELEGRAM_PROXY` by `load_gateway_config()`. But if the bridge fails (manual start, config parse error), the gateway falls back to direct connection. Always also set `HTTPS_PROXY` in the systemd service file for belt-and-suspenders reliability. Full diagnostic checklist: `skill_view(name="clash-docker-workflow", file_path="references/proxy-diagnostics.md")`.
|
||||
- **channel_prompts are platform-agnostic in config.yaml**: The same group ID prompt configured under `telegram.channel_prompts`, `discord.channel_prompts`, and `slack.channel_prompts` is redundant — only configure it under platforms actually in use. Check which platforms are connected in `gateway_state.json` before auditing.
|
||||
- **Old gateway services linger**: `~/.config/systemd/user/` may contain services from previous tools (e.g. `openclaw-gateway.service`). If the service is no longer needed, disable it: `systemctl --user disable --now <name>`. Otherwise it wastes resources on boot.
|
||||
- **Proxy works but Telegram still times out**: Two fixes available — (1) `hermes config set telegram.proxy_url http://127.0.0.1:7890` then `hermes gateway restart` (simpler — `load_gateway_config()` bridges this to `TELEGRAM_PROXY` env var), OR (2) add `Environment="HTTPS_PROXY=http://127.0.0.1:7890"` to `~/.config/systemd/user/hermes-gateway.service`, then `systemctl --user daemon-reload && hermes gateway restart`. Symptom: curl through proxy passes, but gateway logs show connection failures. Full diagnostic checklist: `skill_view(name="clash-docker-workflow", file_path="references/proxy-diagnostics.md")`.
|
||||
|
||||
---
|
||||
|
||||
- **Mihomo watchdog to prevent cascading failures**: mihomo crashes can silently kill the Telegram adapter's reconnect watcher. A lightweight watchdog script monitors mihomo health and restarts it before downstream services notice. Full recipe: `skill_view(name="clash-docker-workflow", file_path="references/mihomo-watchdog.md")`.
|
||||
- **⚠️ CRITICAL: systemd oneshot cgroup kills mihomo on watchdog exit**: If the watchdog runs as a systemd user service (`Type=oneshot`) and calls `startup.sh restart mihomo`, the mihomo process becomes a child of the watchdog's cgroup. When the watchdog service exits, **systemd kills all processes in the cgroup — including the just-started mihomo**. This creates a death loop: watchdog starts mihomo → watchdog exits → systemd kills mihomo → next watchdog cycle finds mihomo dead → repeat every 2 minutes. Symptoms: mihomo log shows `Mihomo shutting down` 3-6 seconds after each startup, watchdog.log shows repeated restarts. **Fix**: (1) mihomo MUST run as its own dedicated systemd service (not via startup.sh inside the watchdog), (2) watchdog calls `systemctl --user restart mihomo`, (3) watchdog must NOT check proxy connectivity — proxy node issues ≠ mihomo issues, unnecessary restarts break stable connections. See `references/mihomo-watchdog.md`.
|
||||
- **Telegram adapter diagnostics**: When the bot stops processing group messages, use the diagnostic checklist in `references/telegram-adapter-diagnostics.md` — covers adapter health, proxy checks, bot membership, config verification, forwarder DB inspection, and the reconnect watcher architecture.
|
||||
|
||||
## Subscription URL Management\n\nHardcoding the subscription URL in multiple scripts creates a maintenance headache — change one place and the other drifts. Use a single-source-of-truth file:\n\n**Create `~/clash/sub-url.conf`:**\n```bash\n# Clash 订阅地址 — 改这一行,所有脚本自动读取\nSUB_URL=\"https://sub-route.bxy.re/api/v1/client/subscribe?token=YOUR_TOKEN_HERE\"\n```\n\n**Have all scripts source it:**\n```bash\n# In startup.sh, update-sub.sh, or any other script:\nif [ -f \"$CLASH_DIR/sub-url.conf\" ]; then\n source \"$CLASH_DIR/sub-url.conf\"\nfi\n# Now $SUB_URL is available everywhere\n```\n\nThe `scripts/update-sub.sh` in this skill can also accept the URL as a CLI argument (`bash update-sub.sh \"URL\"`), which overrides the default for one-off tests.\n\n---\n\n## Subscription Auto-Update (Cron)
|
||||
|
||||
Proxy nodes rotate periodically. Set up a recurring cron job to keep the config fresh:
|
||||
|
||||
1. **Direct-connection update script**: `scripts/update-sub.sh` in this skill — downloads the subscription **directly (no proxy)**, injects required params (`external-controller`, `mixed-port`, etc.), and hot-reloads mihomo via `PUT /configs`. No restart needed unless hot-reload fails.
|
||||
|
||||
**Why no proxy?** The proxy itself depends on the subscription for node configs. If the update script routes through the proxy, you get a circular dependency: proxy needs subscription → update needs proxy → deadlock. Always download subscriptions via direct connection.
|
||||
|
||||
2. **Cron job**: Run every 30 minutes. Only notify on failure:
|
||||
```bash
|
||||
# Hermes cronjob approach:
|
||||
cronjob(action='create', schedule='30m', repeat=forever, name='Clash 订阅更新',
|
||||
prompt='bash ~/clash/update-sub.sh. Report only on failure.',
|
||||
enabled_toolsets=['terminal', 'file'])
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# Hermes Telegram Proxy Configuration
|
||||
|
||||
How to route Hermes Agent's Telegram gateway through a local proxy (Clash/Mihomo).
|
||||
|
||||
## Why needed
|
||||
Telegram API (`api.telegram.org`) is blocked in China. The Hermes gateway needs a proxy to connect.
|
||||
|
||||
## How the Telegram Adapter Reads Proxy
|
||||
|
||||
The Telegram adapter reads proxy from environment variables at the network layer.
|
||||
|
||||
Source code:
|
||||
- `gateway/platforms/telegram_network.py` → `_resolve_proxy_url()` → calls `resolve_proxy_url("TELEGRAM_PROXY", ...)` from `gateway/platforms/base.py`
|
||||
- `gateway/platforms/telegram.py` line 2068 → same `resolve_proxy_url()` call
|
||||
|
||||
`resolve_proxy_url()` checks (in order):
|
||||
1. `TELEGRAM_PROXY` env var (Telegram-specific, highest priority)
|
||||
2. `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` env vars (and lowercase variants)
|
||||
3. macOS system proxy auto-detection
|
||||
|
||||
**Key: `telegram.proxy_url` in config.yaml IS bridged to `TELEGRAM_PROXY` env var** by `load_gateway_config()` at startup. So both config.yaml and direct env vars work.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Option A — `telegram.proxy_url` in config.yaml (simpler, recommended)
|
||||
|
||||
```bash
|
||||
hermes config set telegram.proxy_url http://127.0.0.1:7890
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
Verified working — `load_gateway_config()` reads `telegram.proxy_url` and sets `TELEGRAM_PROXY` env var.
|
||||
|
||||
### Option B — System env vars in systemd service file
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import pathlib
|
||||
p = pathlib.Path.home() / '.config/systemd/user/hermes-gateway.service'
|
||||
lines = p.read_text().splitlines()
|
||||
# Remove any existing proxy lines first
|
||||
lines = [l for l in lines if 'HTTPS_PROXY' not in l and 'HTTP_PROXY' not in l]
|
||||
out = []
|
||||
for l in lines:
|
||||
out.append(l)
|
||||
# Insert after HERMES_HOME line
|
||||
if 'HERMES_HOME' in l and l.strip().startswith('Environment'):
|
||||
out.append('Environment=\"HTTPS_PROXY=http://127.0.0.1:7890\"')
|
||||
out.append('Environment=\"HTTP_PROXY=http://127.0.0.1:7890\"')
|
||||
p.write_text('\n'.join(out) + '\n')
|
||||
"
|
||||
|
||||
systemctl --user daemon-reload
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
Or use the quick-fix script: `bash ~/clash/fix-proxy.sh` → option 6 → option 1.
|
||||
|
||||
### Option C — Dual defense (recommended)
|
||||
|
||||
Set BOTH config.yaml bridge AND systemd env vars. This ensures proxy works whether the config bridge runs or not:
|
||||
|
||||
```bash
|
||||
# 1. Config bridge
|
||||
hermes config set telegram.proxy_url http://127.0.0.1:7890
|
||||
|
||||
# 2. Systemd env vars (see Option B above)
|
||||
|
||||
# 3. Restart
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
## Applying changes
|
||||
```bash
|
||||
hermes gateway restart
|
||||
# ⚠️ Cannot restart from inside the gateway — instruct user to run from separate shell
|
||||
```
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
# Check config
|
||||
grep proxy_url ~/.hermes/config.yaml
|
||||
|
||||
# Check process env vars
|
||||
PID=$(pgrep -f "hermes_cli.main gateway" | head -1)
|
||||
tr '\0' '\n' < /proc/$PID/environ | grep -i proxy
|
||||
# Expected: TELEGRAM_PROXY=http://127.0.0.1:7890 (from config bridge)
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890 (from systemd, if set)
|
||||
|
||||
# Check logs
|
||||
journalctl --user -u hermes-gateway -n 50 | grep -i telegram
|
||||
# Good: "connected" state
|
||||
# Bad: "connection failed" / "Timed out"
|
||||
|
||||
# Check gateway state
|
||||
cat ~/.hermes/gateway_state.json | python3 -m json.tool | grep -A3 telegram
|
||||
```
|
||||
|
||||
## Supported proxy types
|
||||
- HTTP proxy: `http://127.0.0.1:7890`
|
||||
- SOCKS5 proxy: `socks5://127.0.0.1:1080`
|
||||
|
||||
## Notes
|
||||
- The proxy applies to both the main Telegram connection and the fallback IP transport.
|
||||
- Mihomo/Clash mixed-port (7890) supports both HTTP and SOCKS5 on the same port.
|
||||
- The bot token is configured separately under `telegram.token` in config.yaml.
|
||||
- For full diagnostic checklist: `skill_view(name="clash-docker-workflow", file_path="references/proxy-diagnostics.md")`
|
||||
@@ -0,0 +1,229 @@
|
||||
# Mihomo Watchdog — Prevent Proxy Crashes from Killing Downstream Services
|
||||
|
||||
## Problem
|
||||
|
||||
mihomo periodically crashes or becomes unresponsive. When this happens, Hermes's Telegram adapter loses connectivity, hits 10 retries, sets a fatal error, and the `_platform_reconnect_watcher` may silently die — leaving Telegram permanently offline even after mihomo recovers.
|
||||
|
||||
## Solution: Watchdog Script
|
||||
|
||||
A lightweight watchdog that monitors mihomo's health and restarts it before downstream services notice.
|
||||
|
||||
### ⚠️ CRITICAL BUG: systemd cgroup kills mihomo on watchdog exit
|
||||
|
||||
**Root cause**: When the watchdog runs as a systemd user service (`Type=oneshot`) and calls `startup.sh restart mihomo`, the mihomo process becomes a child of the watchdog's service cgroup. When the watchdog service exits, systemd kills all processes in the cgroup — **including the just-started mihomo**.
|
||||
|
||||
**Death loop**: watchdog starts mihomo → watchdog exits → systemd kills mihomo → next watchdog cycle finds mihomo dead → repeat every 2 minutes.
|
||||
|
||||
**Symptoms**:
|
||||
- mihomo log shows `Mihomo shutting down` 3-6 seconds after each startup
|
||||
- watchdog.log shows repeated "mihomo 进程不存在,正在重启" entries
|
||||
- mihomo PID file is stale (process already dead)
|
||||
- Ports 7890/9090 never stay up
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Check if watchdog is restarting in a loop
|
||||
tail -20 ~/startup/logs/watchdog.log
|
||||
# Check mihomo uptime pattern (should be minutes/hours, not seconds)
|
||||
grep "Mihomo shutting down" ~/startup/logs/mihomo.log | tail -10
|
||||
# Check if watchdog timer is active
|
||||
systemctl --user status mihomo-watchdog.timer
|
||||
```
|
||||
|
||||
**Fix — Option A (recommended): mihomo as dedicated systemd service**
|
||||
|
||||
mihomo MUST have its own systemd service so it runs in its own cgroup, independent of the watchdog. Full recipe: `skill_view(name="clash-docker-workflow", file_path="references/systemd-setup.md")`.
|
||||
|
||||
Then update the watchdog to call `systemctl --user restart mihomo` instead of `startup.sh restart mihomo`:
|
||||
**⚠️ Do NOT check proxy connectivity in the watchdog.** Proxy node issues (upstream provider downtime, node rotation) are NOT mihomo's fault — restarting mihomo won't fix them. Only check process existence + API responsiveness. Proxy connectivity checks cause unnecessary restarts that break stable connections (user lesson: 2026-06-23).
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# mihomo watchdog — uses systemctl, NO proxy connectivity check
|
||||
HEALTH_URL="http://127.0.0.1:9090/version"
|
||||
LOG="/home/openclaw/startup/logs/watchdog.log"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; }
|
||||
|
||||
# 1. Process alive?
|
||||
if ! pgrep -f "mihomo -d" > /dev/null 2>&1; then
|
||||
log "mihomo 进程不存在,正在重启..."
|
||||
systemctl --user restart mihomo >> "$LOG" 2>&1; sleep 3
|
||||
fi
|
||||
|
||||
# 2. API responsive?
|
||||
if ! curl -s --max-time 3 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
log "mihomo API 无响应,正在重启..."
|
||||
systemctl --user restart mihomo >> "$LOG" 2>&1
|
||||
fi
|
||||
```
|
||||
|
||||
**Fix — Option B: Use `systemd-run` to escape cgroup** (if mihomo can't be a service)
|
||||
|
||||
```bash
|
||||
# In the watchdog script, replace:
|
||||
# $STARTUP restart mihomo
|
||||
# With:
|
||||
systemd-run --user --scope --slice=mihomo.slice bash -c "cd ~/clash && exec ./mihomo -d config"
|
||||
```
|
||||
|
||||
**NEVER do this**: Run `startup.sh restart mihomo` from inside a systemd oneshot service. The spawned process inherits the service's cgroup and gets killed on exit.
|
||||
|
||||
---
|
||||
|
||||
## Deployed Script: `~/startup/mihomo-watchdog.sh`
|
||||
|
||||
Uses `systemctl --user restart mihomo` (requires mihomo as a dedicated systemd service). **No proxy connectivity check** — proxy node issues ≠ mihomo issues.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# mihomo watchdog — 每 2 分钟检查一次,挂了自动拉起
|
||||
# ⚠️ 必须配合 mihomo 专用 systemd service 使用(不能用 startup.sh)
|
||||
HEALTH_URL="http://127.0.0.1:9090/version"
|
||||
LOG="/home/openclaw/startup/logs/watchdog.log"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; }
|
||||
|
||||
# 1. Process alive?
|
||||
if ! pgrep -f "mihomo -d" > /dev/null 2>&1; then
|
||||
log "mihomo 进程不存在,正在重启..."
|
||||
systemctl --user restart mihomo >> "$LOG" 2>&1; sleep 3
|
||||
fi
|
||||
|
||||
# 2. API responsive?
|
||||
if ! curl -s --max-time 3 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
log "mihomo API 无响应,正在重启..."
|
||||
systemctl --user restart mihomo >> "$LOG" 2>&1
|
||||
fi
|
||||
```
|
||||
|
||||
**systemd timer** (runs every 2 minutes, 30s after boot):
|
||||
```ini
|
||||
# ~/.config/systemd/user/mihomo-watchdog.timer
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=2min
|
||||
AccuracySec=10s
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/mihomo-watchdog.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/home/openclaw/startup/mihomo-watchdog.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now mihomo-watchdog.timer
|
||||
```
|
||||
|
||||
Silent when healthy (no log output). Only writes to `watchdog.log` on failure/restart.
|
||||
|
||||
### Alternative Script: `~/clash/watchdog.sh` (standalone)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Mihomo watchdog — checks proxy health every 60s, restarts if dead
|
||||
# Run via systemd timer or cron
|
||||
|
||||
MIHOMO_API="http://127.0.0.1:9090"
|
||||
MIHOMO_BIN="$HOME/clash/mihomo"
|
||||
MIHOMO_DIR="$HOME/clash/config"
|
||||
LOG="$HOME/clash/watchdog.log"
|
||||
MAX_LOG_SIZE=1048576 # 1MB
|
||||
|
||||
# Rotate log if too big
|
||||
if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG" 2>/dev/null || echo 0)" -gt "$MAX_LOG_SIZE" ]; then
|
||||
mv "$LOG" "${LOG}.old"
|
||||
fi
|
||||
|
||||
check_health() {
|
||||
# Method 1: API health check
|
||||
if curl -s --max-time 5 "$MIHOMO_API/version" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Method 2: Check if process is alive
|
||||
if pgrep -f "mihomo -d" >/dev/null 2>&1; then
|
||||
# Process exists but API unresponsive — stuck
|
||||
return 1
|
||||
fi
|
||||
# Process dead
|
||||
return 1
|
||||
}
|
||||
|
||||
restart_mihomo() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WATCHDOG: mihomo unhealthy, restarting..." >> "$LOG"
|
||||
# Kill stale processes
|
||||
pkill -f "mihomo -d" 2>/dev/null
|
||||
sleep 2
|
||||
# Start fresh
|
||||
cd "$MIHOMO_DIR" && exec "$MIHOMO_BIN" -d "$MIHOMO_DIR" &
|
||||
sleep 5
|
||||
if curl -s --max-time 5 "$MIHOMO_API/version" >/dev/null 2>&1; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WATCHDOG: mihomo restarted successfully" >> "$LOG"
|
||||
else
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WATCHDOG: mihomo restart FAILED" >> "$LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
if ! check_health; then
|
||||
restart_mihomo
|
||||
fi
|
||||
```
|
||||
|
||||
### Deployment Options
|
||||
|
||||
**Option A: Cron (simplest)**
|
||||
```bash
|
||||
# Every 2 minutes
|
||||
*/2 * * * * bash ~/clash/watchdog.sh
|
||||
```
|
||||
|
||||
**Option B: Hermes cronjob (delivers alert on failure)**
|
||||
```python
|
||||
cronjob(action='create', schedule='2m', name='mihomo-watchdog',
|
||||
prompt='Run: bash ~/clash/watchdog.sh. Only report if restart was needed.',
|
||||
deliver='local', enabled_toolsets=['terminal'])
|
||||
```
|
||||
|
||||
**Option C: systemd timer (most robust)**
|
||||
```ini
|
||||
# /etc/systemd/system/mihomo-watchdog.timer
|
||||
[Unit]
|
||||
Description=Mihomo watchdog timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30
|
||||
OnUnitActiveSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/mihomo-watchdog.service
|
||||
[Unit]
|
||||
Description=Mihomo watchdog
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/home/openclaw/clash/watchdog.sh
|
||||
User=openclaw
|
||||
```
|
||||
|
||||
## Why Not Just Restart the Gateway?
|
||||
|
||||
Restarting the gateway:
|
||||
- Kills all active agent sessions
|
||||
- Interrupts running cron jobs
|
||||
- Takes 10-20 seconds to fully initialize
|
||||
- Doesn't fix the underlying mihomo instability
|
||||
|
||||
A mihomo watchdog:
|
||||
- Restarts only the proxy (1-2 seconds)
|
||||
- No impact on gateway sessions or cron jobs
|
||||
- Prevents the cascade before it starts
|
||||
- Is the correct layer to fix (proxy stability, not gateway resilience)
|
||||
@@ -0,0 +1,188 @@
|
||||
# Proxy Diagnostics: "Is the proxy working?"
|
||||
|
||||
Systematic checklist when the user asks whether the proxy is healthy, or when Hermes services show connection timeouts despite the proxy running.
|
||||
|
||||
## Key Insight
|
||||
**Proxy works ≠ Hermes is using it.** These are independent checks. A curl test passing through the proxy proves the proxy is healthy, but Hermes may not be routing through it.
|
||||
|
||||
## Critical: Telegram Proxy Source
|
||||
|
||||
The Telegram adapter reads proxy from environment variables at the network layer.
|
||||
|
||||
Source code path:
|
||||
- `gateway/platforms/telegram_network.py` → `_resolve_proxy_url()` → `resolve_proxy_url("TELEGRAM_PROXY", ...)` from `gateway/platforms/base.py`
|
||||
- `gateway/platforms/telegram.py` line 2068 → same `resolve_proxy_url()` call
|
||||
|
||||
`resolve_proxy_url()` checks (in order):
|
||||
1. `TELEGRAM_PROXY` env var (Telegram-specific, highest priority)
|
||||
2. `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` env vars (and lowercase variants)
|
||||
3. macOS system proxy auto-detection
|
||||
|
||||
**Key: `telegram.proxy_url` in config.yaml IS bridged to `TELEGRAM_PROXY` env var** by `load_gateway_config()` at startup. So both config.yaml and direct env vars work. However, if `load_gateway_config()` fails to run (e.g. gateway started manually without the config bridge), only direct env vars work.
|
||||
|
||||
**Dual defense recommended**: set BOTH `telegram.proxy_url` in config.yaml AND `HTTPS_PROXY` in the systemd service file. This ensures proxy works whether the config bridge runs or not.
|
||||
|
||||
## Quick Diagnosis: Read Gateway Logs Directly
|
||||
|
||||
When `terminal()` is blocked (consent mechanism, security scanner, etc.), diagnose via log files:
|
||||
|
||||
```python
|
||||
# 1. Find log files
|
||||
search_files(pattern="*.log", path="~/.hermes/logs", target="files")
|
||||
|
||||
# 2. Read recent gateway log tail
|
||||
read_file(path="~/.hermes/logs/gateway.log", offset=-100) # last 100 lines
|
||||
|
||||
# 3. Search for Telegram errors
|
||||
search_files(pattern="telegram.*failed|Connect attempt|All connection",
|
||||
path="~/.hermes/logs/gateway.log", target="content")
|
||||
```
|
||||
|
||||
**Key log patterns to look for:**
|
||||
|
||||
| Log Pattern | Meaning |
|
||||
|---|---|
|
||||
| `Proxy detected; passing explicitly to HTTPXRequest: http://127.0.0.1:7890` | Proxy URL is configured in Hermes ✓ |
|
||||
| `Connect attempt N/8 failed: httpx.ConnectError: All connection attempts failed` | Proxy port unreachable (proxy down or wrong port) |
|
||||
| `connect timed out after 30s` | All retry attempts exhausted, will retry in N seconds |
|
||||
| `Reconnect telegram error: ... next retry in 300s` | Exponential backoff — retries increase: 60→120→240→300s |
|
||||
| `Primary api.telegram.org connection failed (); trying fallback IPs` | DNS resolution worked but TCP connection failed |
|
||||
| `DoH discovery yielded no usable IPs` | DNS-over-HTTPS failed, using hardcoded fallback IPs |
|
||||
|
||||
**Common failure: proxy itself is DOWN** (simplest case):
|
||||
- Log shows: `Proxy detected` + `All connection attempts failed` on every attempt
|
||||
- This means Hermes knows about the proxy (config bridge works), but the proxy port is not responding
|
||||
- Fix: restart mihomo (`systemctl --user restart mihomo` or `bash ~/clash/startup.sh restart mihomo`)
|
||||
|
||||
**This is different from**: proxy running but Hermes not routing through it (see "Common Failure Pattern" below).
|
||||
|
||||
## Diagnostic Steps (in order)
|
||||
|
||||
### 1. Proxy process alive?
|
||||
```bash
|
||||
ss -tlnp | grep 7890 # mihomo should be LISTEN
|
||||
# Expected: users:(("mihomo",pid=NNNN,fd=3))
|
||||
```
|
||||
|
||||
### 2. Proxy actually works? (curl test)
|
||||
```bash
|
||||
curl -x http://127.0.0.1:7890 -I https://www.google.com --connect-timeout 5
|
||||
curl -x http://127.0.0.1:7890 -I https://api.telegram.org --connect-timeout 5
|
||||
```
|
||||
Both should return HTTP 200/302, not timeout.
|
||||
|
||||
### 3. Gateway process has proxy env vars?
|
||||
```bash
|
||||
PID=$(pgrep -f "hermes_cli.main gateway" | head -1)
|
||||
tr '\0' '\n' < /proc/$PID/environ | grep -i proxy
|
||||
```
|
||||
If empty — the gateway process has NO proxy env vars and relies solely on the config bridge (`telegram.proxy_url` → `TELEGRAM_PROXY`). If the bridge fails, Telegram connects DIRECT.
|
||||
|
||||
### 4. Check systemd service file
|
||||
```bash
|
||||
grep -i proxy ~/.config/systemd/user/hermes-gateway.service
|
||||
```
|
||||
If no `HTTPS_PROXY` / `HTTP_PROXY` lines — the service file needs updating for dual defense.
|
||||
|
||||
### 5. Check config.yaml bridge
|
||||
```bash
|
||||
grep proxy_url ~/.hermes/config.yaml
|
||||
```
|
||||
Should show `proxy_url: http://127.0.0.1:7890` under `telegram:`.
|
||||
|
||||
### 6. Gateway status + logs
|
||||
```bash
|
||||
hermes gateway status
|
||||
journalctl --user -u hermes-gateway -n 50 | grep -i telegram
|
||||
```
|
||||
Look for: "Proxy detected" (good) or "connection failed" / "Timed out" (bad).
|
||||
|
||||
### 7. Run hermes doctor
|
||||
```bash
|
||||
hermes doctor
|
||||
```
|
||||
Shows API connectivity, missing configs, auth issues.
|
||||
|
||||
## Common Failure Pattern
|
||||
|
||||
**Symptom**: Proxy is running, curl tests pass, but gateway shows:
|
||||
```
|
||||
[Telegram] Primary api.telegram.org connection failed
|
||||
[Telegram] Fallback IP 149.154.167.220 failed
|
||||
[Telegram] Connect attempt N/8 failed: Timed out
|
||||
```
|
||||
|
||||
**Root cause**: Gateway systemd service file has no proxy environment variables AND the config bridge failed (or `telegram.proxy_url` not set). The Telegram adapter falls back to direct connection, which fails behind GFW.
|
||||
|
||||
**Fix** (dual defense — set both):
|
||||
```bash
|
||||
# 1. Set config bridge
|
||||
hermes config set telegram.proxy_url http://127.0.0.1:7890
|
||||
|
||||
# 2. Add proxy env vars to systemd service
|
||||
python3 -c "
|
||||
import pathlib
|
||||
p = pathlib.Path.home() / '.config/systemd/user/hermes-gateway.service'
|
||||
lines = p.read_text().splitlines()
|
||||
lines = [l for l in lines if 'HTTPS_PROXY' not in l and 'HTTP_PROXY' not in l]
|
||||
out = []
|
||||
for l in lines:
|
||||
out.append(l)
|
||||
if 'HERMES_HOME' in l and l.strip().startswith('Environment'):
|
||||
out.append('Environment=\"HTTPS_PROXY=http://127.0.0.1:7890\"')
|
||||
out.append('Environment=\"HTTP_PROXY=http://127.0.0.1:7890\"')
|
||||
p.write_text('\n'.join(out) + '\n')
|
||||
"
|
||||
systemctl --user daemon-reload
|
||||
hermes gateway restart
|
||||
```
|
||||
|
||||
**Quick fix**: `bash ~/clash/fix-proxy.sh --fix`
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
PID=$(pgrep -f "hermes_cli.main gateway" | head -1)
|
||||
tr '\0' '\n' < /proc/$PID/environ | grep -i proxy
|
||||
# Expected: TELEGRAM_PROXY=http://127.0.0.1:7890 (from config bridge)
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890 (from systemd service)
|
||||
```
|
||||
|
||||
## Network/Proxy Audit Checklist
|
||||
|
||||
When the user asks "check my network/proxy config" or "any redundant configs?", cover these dimensions:
|
||||
|
||||
1. **代理进程**: mihomo running? Port 7890 listening?
|
||||
2. **代理连通性**: curl through proxy works? (Google, Telegram)
|
||||
3. **Gateway 代理模式**: Both config.yaml `proxy_url` AND systemd `HTTPS_PROXY` set? (dual defense)
|
||||
4. **旧服务残留**: Any old gateway services (e.g. `openclaw-gateway.service`) still in `~/.config/systemd/user/`? Disable with `systemctl --user disable --now <name>`
|
||||
5. **重复启动脚本**: Both `startup.sh` and systemd managing the same service? Pick one — systemd preferred. The other is dead code.
|
||||
6. **WireGuard 残留**: Check `/etc/wireguard/` for leftover `.conf` files. WireGuard can intercept ALL traffic if misconfigured.
|
||||
7. **环境变量代理**: Check `~/.bashrc` for `HTTP_PROXY`/`HTTPS_PROXY` — needed for terminal sessions but NOT sufficient for systemd services (they don't source .bashrc).
|
||||
8. **channel_prompts 重复**: Same prompt in slack/discord/telegram sections of config.yaml — only needed in platforms actually used.
|
||||
|
||||
## Quick Status Summary Template
|
||||
When reporting proxy status to the user, cover these four dimensions:
|
||||
1. **代理进程**: mihomo running? Port 7890 listening?
|
||||
2. **代理连通性**: curl through proxy works? (Google, Telegram)
|
||||
3. **Gateway 代理模式**: systemd service has proxy env vars? Config bridge set? Gateway process has them?
|
||||
4. **网关状态**: Gateway running? Telegram connected? No timeout errors in logs?
|
||||
|
||||
- **Gateway log path**: `~/.hermes/logs/gateway.log`, NOT `~/.hermes/gateway.log`. Also check `~/.hermes/logs/errors.log` for historical errors. Use `search_files(pattern="*.log", path="~/.hermes/logs", target="files")` to discover all logs.
|
||||
- **Telegram reconnect backoff**: When Telegram fails to connect, the gateway retries with exponential backoff: 60s → 120s → 240s → 300s (cap). After fixing the proxy, you may need to `hermes gateway restart` to reset the backoff instead of waiting for the next retry window.
|
||||
- **QQBot also fails when proxy is down**: The QQBot adapter routes through the same proxy. If `127.0.0.1:7890` is down, both Telegram AND QQ will show connection failures. Check QQBot logs for `Failed to get QQ Bot access token: All connection attempts failed`.
|
||||
|
||||
## Quick-Fix Script
|
||||
|
||||
`~/clash/fix-proxy.sh` — interactive menu with 8 functions:
|
||||
1. 📊 查看状态 (full status overview)
|
||||
2. 🔧 一键诊断修复 (auto-diagnose and fix)
|
||||
3. 🔄 重启 Mihomo
|
||||
4. 📡 更新/更换订阅
|
||||
5. 🔀 切换代理节点 (via Mihomo API)
|
||||
6. 🌐 Gateway 代理/直连切换
|
||||
7. 🔁 重启 Hermes Gateway
|
||||
8. 📋 查看日志
|
||||
|
||||
Usage:
|
||||
- Interactive: `bash ~/clash/fix-proxy.sh`
|
||||
- One-click fix: `bash ~/clash/fix-proxy.sh --fix`
|
||||
@@ -0,0 +1,153 @@
|
||||
# Systemd Service for Mihomo + YACD
|
||||
|
||||
Two approaches: **system-level** (recommended for always-on servers) and **user-level**.
|
||||
|
||||
---
|
||||
|
||||
## Option A: System-Level Services (recommended for servers)
|
||||
|
||||
System-level services run as root, survive logout without extra config, and start early in boot.
|
||||
|
||||
### Create service files
|
||||
|
||||
**Pitfall**: The Hermes security scanner blocks `sudo tee /etc/systemd/system/` and `sudo cp` into system paths. Workaround: write to `/tmp` first using `write_file`, then ask the user to manually run `sudo cp` + `sudo systemctl`.
|
||||
|
||||
```bash
|
||||
# Step 1: Agent writes to /tmp (allowed by write_file tool)
|
||||
# /tmp/mihomo.service:
|
||||
[Unit]
|
||||
Description=Mihomo (Clash Meta) Proxy
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/openclaw/clash
|
||||
ExecStart=/home/openclaw/clash/mihomo -d /home/openclaw/clash/config
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
StandardOutput=append:/tmp/mihomo.log
|
||||
StandardError=append:/tmp/mihomo.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
# /tmp/yacd.service:
|
||||
[Unit]
|
||||
Description=YACD - Clash Dashboard
|
||||
After=network-online.target mihomo.service
|
||||
Wants=network-online.target
|
||||
Requires=mihomo.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/openclaw/clash/yacd
|
||||
ExecStart=/usr/bin/python3 -m http.server 9091 --bind 0.0.0.0
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
StandardOutput=append:/tmp/yacd.log
|
||||
StandardError=append:/tmp/yacd.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Step 2: User manually runs
|
||||
```bash
|
||||
sudo cp /tmp/mihomo.service /etc/systemd/system/
|
||||
sudo cp /tmp/yacd.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable mihomo yacd
|
||||
sudo systemctl start mihomo yacd
|
||||
sudo systemctl status mihomo yacd
|
||||
```
|
||||
|
||||
### Key design choices
|
||||
- `Requires=mihomo.service` on yacd: yacd won't start if mihomo fails
|
||||
- `After=network-online.target`: wait for network before starting
|
||||
- `Restart=on-failure`: auto-restart on crash
|
||||
- `LimitNOFILE=65535`: mihomo handles many connections
|
||||
- Logs to `/tmp/` via `append:` (matches startup.sh convention)
|
||||
|
||||
### Migration from startup.sh
|
||||
Once systemd manages the services, **disable** the startup.sh service definitions to prevent conflicts:
|
||||
```bash
|
||||
# Disable (don't delete — keep as backup)
|
||||
mv ~/startup/services/mihomo.sh ~/startup/services/mihomo.sh.disabled
|
||||
mv ~/startup/services/yacd.sh ~/startup/services/yacd.sh.disabled # if applicable
|
||||
|
||||
# Verify startup.sh no longer manages these
|
||||
bash ~/startup/startup.sh status # should not show mihomo/yacd
|
||||
|
||||
# Use systemd exclusively
|
||||
systemctl --user start|stop|restart|status mihomo # user-level
|
||||
# or: sudo systemctl start|stop|restart|status mihomo # system-level
|
||||
```
|
||||
|
||||
**Why disable instead of delete**: If systemd service has issues, you can quickly re-enable by renaming back. Also keeps the service definition as documentation.
|
||||
|
||||
**Pitfall**: If you forget to disable startup.sh definitions AND the watchdog still calls `startup.sh restart`, the two managers fight over PIDs. Always migrate atomically: create systemd service → enable → disable startup.sh definition → update watchdog.
|
||||
|
||||
---
|
||||
|
||||
## Option B: User-Level Services (for non-root setups)
|
||||
|
||||
User services run as the logged-in user, no root needed for service files.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/mihomo.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Mihomo (Clash Meta) Proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/clash
|
||||
ExecStart=%h/clash/mihomo -d %h/clash/config
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
cat > ~/.config/systemd/user/yacd-dashboard.service << 'EOF'
|
||||
[Unit]
|
||||
Description=YACD Dashboard (static HTTP)
|
||||
After=network.target mihomo.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/clash/yacd
|
||||
ExecStart=/usr/bin/python3 -m http.server 9091 --bind 0.0.0.0 --directory %h/clash/yacd
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable mihomo yacd-dashboard
|
||||
systemctl --user start mihomo yacd-dashboard
|
||||
```
|
||||
|
||||
**Critical**: Without linger, user services die on logout:
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verify (both options)
|
||||
```bash
|
||||
# System-level
|
||||
sudo systemctl status mihomo yacd
|
||||
# User-level
|
||||
systemctl --user status mihomo yacd-dashboard
|
||||
|
||||
curl -s http://127.0.0.1:9090/version
|
||||
curl -s http://127.0.0.1:9091/ | head -3
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
# Telegram Adapter Diagnostics
|
||||
|
||||
## Quick Health Check
|
||||
|
||||
```bash
|
||||
# 1. Is the adapter connected?
|
||||
journalctl --user -u hermes-gateway --since '10 min ago' --no-pager | grep -i "telegram" | tail -5
|
||||
|
||||
# 2. Is the proxy working?
|
||||
curl -s -o /dev/null -w "%{http_code}" --proxy http://127.0.0.1:7890 https://api.telegram.org
|
||||
|
||||
# 3. Is the bot alive?
|
||||
# (replace TOKEN with actual bot token from ~/.hermes/.env)
|
||||
curl -s "https://api.telegram.org/bot<TOKEN>/getMe" --proxy http://127.0.0.1:7890
|
||||
|
||||
# 4. Is the bot in the target group?
|
||||
curl -s "https://api.telegram.org/bot<TOKEN>/getChatMember?chat_id=<GROUP_ID>&user_id=<BOT_ID>" --proxy http://127.0.0.1:7890
|
||||
```
|
||||
|
||||
## Diagnosing "Bot Not Processing Group Messages"
|
||||
|
||||
### Step 1: Check if adapter is alive
|
||||
```bash
|
||||
journalctl --user -u hermes-gateway --no-pager --since '1 hour ago' | grep "\[Telegram\]" | tail -10
|
||||
```
|
||||
- If only crash/reconnect-failed logs → adapter is dead, restart gateway
|
||||
- If "Reconnecting telegram" entries → watcher is alive, proxy may be down
|
||||
- If no Telegram logs at all → either adapter connected silently or watcher died
|
||||
|
||||
### Step 2: Check if proxy is the issue
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" --proxy http://127.0.0.1:7890 https://api.telegram.org
|
||||
# 302 = proxy working, timeout/ConnectError = proxy down
|
||||
```
|
||||
|
||||
### Step 3: Check bot group membership
|
||||
```bash
|
||||
# Get bot info
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
with open('/home/openclaw/.hermes/.env') as f:
|
||||
token = [l.split('=',1)[1].strip() for l in f if l.startswith('TELEGRAM_BOT_TOKEN')][0]
|
||||
proxy = urllib.request.ProxyHandler({'https': 'http://127.0.0.1:7890'})
|
||||
opener = urllib.request.build_opener(proxy)
|
||||
resp = opener.open(f'https://api.telegram.org/bot{token}/getChatMember?chat_id=<GROUP_ID>&user_id=<BOT_ID>')
|
||||
print(json.loads(resp.read()))
|
||||
"
|
||||
```
|
||||
|
||||
### Step 4: Check config
|
||||
In `~/.hermes/config.yaml`, under `telegram:`:
|
||||
- `free_response_chats` or `free_response_channels` must include the group ID
|
||||
- `channel_prompts` can be set per group for custom behavior
|
||||
- `allowed_chats` when set acts as a whitelist (empty = no restriction)
|
||||
- `require_mention: true` means bot only responds when @mentioned (unless in `free_response_chats`)
|
||||
|
||||
**Editing channel_prompts**: Values are unicode-escaped with YAML line continuations (`\` at line end). Don't use `patch()` — it breaks on multi-line unicode. Use Python regex instead:
|
||||
```python
|
||||
import re
|
||||
with open('/home/openclaw/.hermes/config.yaml') as f:
|
||||
content = f.read()
|
||||
# Match across line breaks with [\s\\]* for YAML continuations
|
||||
pattern = re.compile(r'\\uXXXX[\s\\]*\\uYYYY')
|
||||
content = pattern.sub(r'\\uNEW1\\uNEW2', content)
|
||||
with open('/home/openclaw/.hermes/config.yaml', 'w') as f:
|
||||
f.write(content)
|
||||
# Always verify YAML validity after
|
||||
import yaml; yaml.safe_load(open('/home/openclaw/.hermes/config.yaml'))
|
||||
```
|
||||
The same prompt string is duplicated across `slack`, `discord`, `mattermost`, and `telegram` sections — replace in all 4.
|
||||
|
||||
### Step 5: Check forwarder
|
||||
```bash
|
||||
# Is the forwarder running?
|
||||
docker ps | grep forwarder
|
||||
|
||||
# Check forwarder config
|
||||
sqlite3 ~/TelegramForwarder/db/forward.db "SELECT * FROM chats;"
|
||||
sqlite3 ~/TelegramForwarder/db/forward.db "SELECT * FROM forward_rules;"
|
||||
sqlite3 ~/TelegramForwarder/db/forward.db "SELECT * FROM keywords;"
|
||||
|
||||
# Check recent forwarding activity
|
||||
docker logs telegram-forwarder --tail 20 | grep "已发送"
|
||||
```
|
||||
|
||||
## Gateway Restart Constraints
|
||||
|
||||
- **Cannot restart from inside the gateway**: `hermes gateway restart` is blocked when run from a gateway session (SIGTERM propagation prevention)
|
||||
- **`kill <PID>` also blocked**: Security scanner intercepts `kill` commands targeting the gateway PID. Terminal returns `Blocked: cannot restart or stop the gateway from inside the gateway process.`
|
||||
- **Workaround 1**: User runs `hermes gateway restart` from a separate SSH shell (recommended)
|
||||
- **Workaround 2**: `systemctl --user restart hermes-gateway` from a separate shell
|
||||
- **Workaround 3**: Write a small helper script to `/tmp/` and ask user to run it manually
|
||||
|
||||
## Reconnect Watcher Architecture
|
||||
|
||||
When a platform adapter hits a fatal error:
|
||||
1. `_set_fatal_error()` → sets `adapter._running = False`
|
||||
2. `_handle_adapter_fatal_error()` → disconnects adapter, removes from `self.adapters`
|
||||
3. If `fatal_error_retryable=True` → adds to `self._failed_platforms` with backoff
|
||||
4. `_platform_reconnect_watcher()` → background asyncio task, retries with exponential backoff (30s→300s cap)
|
||||
|
||||
**Known bug**: The watcher task can silently die. No upstream fix yet. Workaround: external gateway restart.
|
||||
|
||||
**Root cause (observed 2026-06-23)**: When multiple platforms crash simultaneously (Telegram + WeChat + QQBot), the watcher may die. Evidence: `journalctl` shows the fatal error at 09:27 but ZERO "Reconnecting telegram" or "Reconnect telegram error" entries afterward — the watcher's `while self._running` loop exited without logging. On a previous crash (2026-06-22) with only Telegram affected, the watcher worked correctly and kept retrying with backoff.
|
||||
|
||||
**Edge case — already-queued platform**: `_handle_adapter_fatal_error()` checks `adapter.platform not in self._failed_platforms` before queueing. If Telegram was already in the queue from a prior failure, the new fatal error doesn't re-queue or reset the backoff. The existing entry's `next_retry` timer may be at the 300s cap, causing long silent gaps between reconnection attempts even when the watcher IS alive.
|
||||
|
||||
**Definitive diagnosis**: If after a crash you see zero reconnection attempt logs for >10 minutes, the watcher is dead. Restart the gateway from an external shell.
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Clash 订阅自动更新脚本
|
||||
# 直连下载订阅(不走代理,避免循环依赖)→ 注入 required params → 热重载
|
||||
# Usage: bash update-sub.sh [SUBSCRIPTION_URL]
|
||||
#
|
||||
# Customize these variables for your deployment:
|
||||
# SUB_URL — subscription URL (default: reads from ~/clash/sub-url.conf)
|
||||
# CONFIG — path to mihomo config.yaml
|
||||
|
||||
set -e
|
||||
|
||||
CLASH_DIR="${CLASH_DIR:-$HOME/clash}"
|
||||
|
||||
# Read subscription URL from conf file or CLI arg
|
||||
if [ -n "$1" ]; then
|
||||
SUB_URL="$1"
|
||||
elif [ -f "$CLASH_DIR/sub-url.conf" ]; then
|
||||
source "$CLASH_DIR/sub-url.conf"
|
||||
else
|
||||
echo "错误: 找不到 $CLASH_DIR/sub-url.conf,也未传入 URL 参数"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIG="${CONFIG:-$CLASH_DIR/config/config.yaml}"
|
||||
TMP_FILE=$(mktemp)
|
||||
LOCK_FILE="/tmp/clash-update.lock"
|
||||
|
||||
# 防止并发
|
||||
exec 200>"$LOCK_FILE"
|
||||
flock -n 200 || { echo "[$(date -Iseconds)] Already running, skipping"; exit 0; }
|
||||
|
||||
echo "[$(date -Iseconds)] Starting subscription update (direct, no proxy)..."
|
||||
|
||||
# 直连下载,不走代理
|
||||
# 原因:代理本身依赖订阅配置,走代理更新 = 循环依赖死锁
|
||||
HTTP_CODE=$(curl -L --connect-timeout 10 --max-time 30 \
|
||||
-H "User-Agent: clash-verge/v2.0.0" \
|
||||
-w "%{http_code}" -o "$TMP_FILE" \
|
||||
"$SUB_URL" 2>/dev/null)
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo " FAIL: Subscription download failed (HTTP $HTTP_CODE)"
|
||||
rm -f "$TMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Inject required params that raw subscriptions often omit
|
||||
sed -i "/external-controller:/c\external-controller: '0.0.0.0:9090'" "$TMP_FILE"
|
||||
grep -q "^mixed-port:" "$TMP_FILE" || sed -i "1i mixed-port: 7890" "$TMP_FILE"
|
||||
grep -q "^allow-lan:" "$TMP_FILE" || sed -i "1a allow-lan: true" "$TMP_FILE"
|
||||
grep -q "^mode:" "$TMP_FILE" || sed -i "1a mode: rule" "$TMP_FILE"
|
||||
grep -q "^log-level:" "$TMP_FILE" || sed -i "1a log-level: info" "$TMP_FILE"
|
||||
|
||||
cp "$TMP_FILE" "$CONFIG"
|
||||
rm -f "$TMP_FILE"
|
||||
|
||||
NODE_COUNT=$(grep -c 'name:' "$CONFIG" 2>/dev/null || echo 0)
|
||||
echo " OK: Subscription updated, $NODE_COUNT nodes"
|
||||
|
||||
# Hot-reload via mihomo REST API (preferred over restart — preserves connections)
|
||||
if curl -s -o /dev/null http://127.0.0.1:9090/version 2>/dev/null; then
|
||||
curl -s -X PUT http://127.0.0.1:9090/configs \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"path\":\"$CONFIG\"}" > /dev/null 2>&1
|
||||
echo " OK: Hot-reload complete"
|
||||
else
|
||||
echo " WARN: mihomo not running, skipped hot-reload"
|
||||
fi
|
||||
|
||||
echo "[$(date -Iseconds)] Update complete"
|
||||
@@ -0,0 +1,293 @@
|
||||
---
|
||||
name: cron-job-management
|
||||
triggers:
|
||||
- cron job transient error broken pipe
|
||||
- cron output logs debugging
|
||||
- cron agent fallback prompts
|
||||
description: >
|
||||
Manage Hermes cron jobs: script deployment, path verification, environment
|
||||
variable setup, and debugging. Covers the key differences between no_agent
|
||||
scripts and agent-based cron tasks, plus the common .bashrc guard pitfall
|
||||
that silently breaks env var loading for cron-based scripts.
|
||||
trigger:
|
||||
- 检查定时任务
|
||||
- cron job broken
|
||||
- script path wrong
|
||||
- environment variable not found in cron
|
||||
- 定时任务脚本路径
|
||||
- cron env vars
|
||||
- 检查cron脚本
|
||||
- cron script check
|
||||
- cron job verification
|
||||
---
|
||||
|
||||
# Cron Job Management
|
||||
|
||||
Hermes cron jobs run in minimal shell environments — no interactive bash, no
|
||||
`.bashrc`/`.bash_profile` sourcing, no inherited PATH or env vars. This skill
|
||||
covers how to verify, deploy, and debug them.
|
||||
|
||||
---
|
||||
|
||||
## 1. Checking All Cron Jobs
|
||||
|
||||
```bash
|
||||
hermes cron list # List all jobs (use cronjob action='list' in tool mode)
|
||||
hermes cron logs <job_id> # View last run logs
|
||||
hermes cron run <job_id> # Manual one-shot run for testing
|
||||
```
|
||||
|
||||
### Systematic Script Path Verification
|
||||
|
||||
When asked to verify cron job scripts, follow this chain:
|
||||
|
||||
```
|
||||
cronjob list → get ALL jobs with their script/skill fields
|
||||
→ check each script path → stat ~/.hermes/scripts/<name>
|
||||
→ check shell wrappers → are they cd + python3? Do they use absolute paths?
|
||||
→ check Python imports → can the modules actually import?
|
||||
→ check data files → e.g., dca_positions.json, config files
|
||||
→ factor mining skills → verify skill path + Python import paths
|
||||
```
|
||||
|
||||
**Batch verification command**:
|
||||
```bash
|
||||
cd ~/.hermes/scripts && for f in script1.py script2.sh ...; do
|
||||
[ -f "$f" ] && echo "✅ $f" || echo "❌ $f"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 🔴 Critical: `.bashrc` Non-Interactive Guard Blocks Cron
|
||||
|
||||
**The single most common cron env var failure.** Most Linux distros ship with:
|
||||
|
||||
```bash
|
||||
# ~/.bashrc
|
||||
# If not running interactively, don't do anything
|
||||
case $- in
|
||||
*i*) ;;
|
||||
*) return;;
|
||||
esac
|
||||
```
|
||||
|
||||
**Impact**: When a cron job runs, bash starts as a non-interactive shell →
|
||||
`.bashrc` returns immediately at the `return;` statement → **none of the
|
||||
`export VAR=...` lines below the guard are ever executed**.
|
||||
|
||||
**Signs this is happening**:
|
||||
- Manual test (`source ~/.bashrc && python script.py`) works fine
|
||||
- Cron job fails with `missing environment variable: XXX`
|
||||
- Cron job fails with auth/permission errors (LongPort 401003, etc.)
|
||||
- The `.bashrc` file has the `case $- in` guard
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Test: does .bashrc actually export the vars?
|
||||
bash -c "source ~/.bashrc && env | grep LONGPORT"
|
||||
# If empty → guard is blocking, OR vars are below interactive-only sections
|
||||
```
|
||||
|
||||
**Fixes (pick one)**:
|
||||
|
||||
| Approach | Method | Pros | Cons |
|
||||
|----------|--------|------|------|
|
||||
| **Extract to separate file** | Move exports to `~/.env` or `~/.config/hermes/env` | Clean, portable | One-time move |
|
||||
| **Use /etc/environment** | Add `VAR=val` lines there | System-wide, read by all users | Needs sudo |
|
||||
| **Inline in script** | Add `source ~/.bashrc` after the guard | Quick fix | Repetitive |
|
||||
| **Patch .bashrc** | Move exports ABOVE the guard | Simplest | Only works for interactive sourcing |
|
||||
|
||||
**Recommended**: Create `~/.hermes/env` (referenced by cron scripts) or use
|
||||
`/etc/environment` for system-wide availability.
|
||||
|
||||
---
|
||||
|
||||
## 3. Two Cron Job Modes
|
||||
|
||||
### `no_agent: True` — Pure Script Mode
|
||||
|
||||
The script IS the job. The scheduler runs it as a subprocess, captures stdout,
|
||||
and delivers it verbatim.
|
||||
|
||||
```yaml
|
||||
script: "hk_intraday_scanner.py" # relative to ~/.hermes/scripts/
|
||||
no_agent: true
|
||||
```
|
||||
|
||||
**Environment rules**:
|
||||
- Runs in a **clean shell** — no `.bashrc`, no `.profile`
|
||||
- Only sees: `PATH=/usr/bin:/bin`, `HOME=/home/user`, `USER`, `HERMES_CONFIG_DIR`
|
||||
- Must self-bootstrap: parse `.bashrc` manually (Python), or source env file
|
||||
- Empty stdout = silent (no notification sent)
|
||||
- Non-zero exit → error alert delivered
|
||||
|
||||
**Common pattern (shell wrapper)**:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd /home/user/.hermes/scripts
|
||||
python3 my_script.py "$@"
|
||||
```
|
||||
|
||||
### `no_agent: False` — Agent Mode
|
||||
|
||||
The cron prompt is fed to a Hermes agent session each tick. The agent can use
|
||||
tools (terminal, web, file) to execute tasks.
|
||||
|
||||
- Inherits Hermes's own env vars (those from the gateway process)
|
||||
- Can `source ~/.bashrc` in terminal tool calls
|
||||
- Can use `enabled_toolsets` to limit tool availability (reduces token cost)
|
||||
|
||||
---
|
||||
|
||||
## 4. Shell Wrapper Pattern (for no_agent scripts)
|
||||
|
||||
All `no_agent: True` scripts under `~/.hermes/scripts/` follow this pattern:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd /home/openclaw/.hermes/scripts # absolute path
|
||||
python3 actual_script.py [args] # run the real script
|
||||
```
|
||||
|
||||
**Why this pattern**:
|
||||
- `cd` ensures relative imports and file paths work
|
||||
- Absolute `cd` path avoids PWD ambiguity
|
||||
- Shell wrapper is stateless and re-runnable
|
||||
|
||||
---
|
||||
|
||||
## 5. Python Script: Manual Env Load Pattern
|
||||
|
||||
For `no_agent` Python scripts that need LongPort credentials:
|
||||
|
||||
```python
|
||||
import os, re
|
||||
|
||||
# Parse ~/.bashrc manually (cron can't source it)
|
||||
config = {}
|
||||
with open(os.path.expanduser('~/.bashrc')) as f:
|
||||
for line in f:
|
||||
if line.startswith('export LONGBRIDGE_') or line.startswith('export LONGPORT_'):
|
||||
key, value = line.strip().split('=', 1)
|
||||
config[key.replace('export ', '')] = value
|
||||
|
||||
os.environ['LONGPORT_APP_KEY'] = config.get('LONGBRIDGE_APP_KEY', '')
|
||||
os.environ['LONGPORT_APP_SECRET'] = config.get('LONGBRIDGE_APP_SECRET', '')
|
||||
os.environ['LONGPORT_ACCESS_TOKEN'] = config.get('LONGBRIDGE_ACCESS_TOKEN', '')
|
||||
```
|
||||
|
||||
**⚠️ Pitfall**: This only works if `.bashrc` values don't reference `${OTHER_VAR}`
|
||||
syntax. If they do, use `re.sub(r'\$\{(\w+)\}', ...)` to resolve them.
|
||||
|
||||
---
|
||||
|
||||
## 6. Common Pitfalls
|
||||
|
||||
### 🔴 `.bashrc` Guard (most frequent — see §2)
|
||||
Symptom: "works when I run it, fails in cron"
|
||||
|
||||
### 🔴 Script uses `os.environ` directly
|
||||
Python scripts that do `os.environ['VAR']` will crash if the cron shell hasn't
|
||||
exported the variable. Always use `.get('VAR', '')` with a fallback.
|
||||
|
||||
### 🔴 Relative paths in no_agent scripts
|
||||
The cron scheduler's CWD may not be what you expect. Always use absolute paths
|
||||
or `cd` to `~/.hermes/scripts/` first.
|
||||
|
||||
### 🔴 LongPort token expiry (401003 vs 401004)
|
||||
LongPort access tokens expire every ~180 days. All scripts using LongPort API
|
||||
simultaneously failing with the same error code points to token expiry.
|
||||
|
||||
**Two different error codes — different root causes:**
|
||||
|
||||
| Error | Code | Meaning | Action |
|
||||
|-------|------|---------|--------|
|
||||
| `token expired` | 401003 | Token was valid but expired | Go to [LongPort console](https://open.longportapp.com/) → Personal Access Token → regenerate |
|
||||
| `token invalid` / missing env | 401004 | Bashrc has truncated placeholder (e.g. `m_eyJh...jb-k` with literal `...`) | Restore full token from `.env` |
|
||||
|
||||
**Diagnosis**: Run any script that uses LongPort manually:
|
||||
```bash
|
||||
cd ~/.hermes/scripts && bash scan_hk.sh
|
||||
# If 401003 → needs refresh at LongPort console
|
||||
# If 401004 or "missing env" → check .bashrc for truncated tokens
|
||||
```
|
||||
|
||||
```python
|
||||
from longport import openapi
|
||||
try:
|
||||
ctx = openapi.QuoteContext(config=openapi.Config.from_env())
|
||||
ctx.quote(['700.HK'])
|
||||
except openapi.OpenApiException as e:
|
||||
code = str(e)
|
||||
if '401003' in code:
|
||||
print("Token expired — needs refresh via LongPort developer console")
|
||||
elif '401004' in code:
|
||||
print("Token invalid/truncated — check .bashrc for placeholder values")
|
||||
```
|
||||
|
||||
### 🔴 QQBot WebSocket session timeout (code=4009)
|
||||
Symptom: Delivery error says `Not connected` / `All connection attempts failed`,
|
||||
but the script itself ran fine. Gateway logs show:
|
||||
```
|
||||
[QQBot:102921794] WebSocket closed: code=4009 reason=Session timed out
|
||||
```
|
||||
Tencent's QQ Bot platform terminates idle WebSocket sessions after ~30 min.
|
||||
The gateway auto-reconnects, but there's a window where delivery fails.
|
||||
|
||||
**Diagnosis**: Check gateway logs:
|
||||
```bash
|
||||
journalctl --user -u hermes-gateway --no-pager -n 50 | grep -iE "qqbot|websocket|closed"
|
||||
```
|
||||
|
||||
**Mitigation**: The session timeout is server-side (Tencent), not configurable.
|
||||
The main fix is ensuring `Not connected` delivery errors are NOT confused with
|
||||
script failures — check `last_delivery_error` in `cronjob list` output first.
|
||||
|
||||
### 🔴 Delivery failures vs Script failures
|
||||
Distinguish between:
|
||||
- **Script failure** (`last_status=error`, no `last_delivery_error`): non-zero
|
||||
exit, stderr output occurred. Fix the script. **Diagnosis**: run the script
|
||||
manually via `bash ~/.hermes/scripts/<script>` or `python3 <script.py>`.
|
||||
- **Delivery failure** (`last_status=error`, `last_delivery_error` present with
|
||||
DNS/adapter error): script succeeded, but output couldn't be sent. Fix the
|
||||
delivery pipeline, script is fine.
|
||||
- `Temporary failure in name resolution` → DNS issue (check `nslookup` / `dig`)
|
||||
- `Not connected` / `All connection attempts failed` → QQBot disconnected
|
||||
(see QQBot section above)
|
||||
- `Connection refused` → gateway/adapter not running
|
||||
|
||||
---
|
||||
|
||||
## 7. Transient Cron Errors: Broken Pipe
|
||||
|
||||
### What It Means
|
||||
|
||||
`[Errno 32] Broken pipe` in a cron delivery error does NOT mean the Python script crashed. It signals a pipe-level abort — typically the agent tried a blocked tool (Tirith-blocked terminal/execute_code) and the pipe was torn down before output could be written.
|
||||
|
||||
### Diagnosis
|
||||
|
||||
1. Re-run: `cronjob action=run job_id=<id>` — if it succeeds, it was transient
|
||||
2. For agent-based cron jobs: check if the prompt tells the agent to run Python. Rewrite it to skip execution and use pre-computed data fallback instead.
|
||||
3. For no_agent scripts: `bash ~/.hermes/scripts/<script>` manually
|
||||
|
||||
### Reading Cron Output Files
|
||||
|
||||
Output logs at `~/.hermes/cron/output/<job_id>/<timestamp>.md` contain the full prompt + skill injection + agent response. For no_agent scripts, they contain stdout.
|
||||
|
||||
**Common transient causes:**
|
||||
- Tirith block on Python execution in cron (most common)
|
||||
- CLS/Sina browser rate limits (policy news tasks)
|
||||
- LongPort API rate limiting (429002) — add retry
|
||||
- DNS/network blip
|
||||
|
||||
## 8. Quick Debugging Checklist
|
||||
|
||||
1. `cronjob action=list` → note all last_status values
|
||||
2. For error jobs: check last_delivery_error field
|
||||
3. For no_agent scripts: manually `bash ~/.hermes/scripts/<script>`
|
||||
4. For agent scripts: `cronjob action=run job_id=<id>` to test
|
||||
5. **If re-run fixes it** → transient error (blocked tool / network glitch)
|
||||
6. **If re-run fails** → check output: `~/.hermes/cron/output/<job_id>/`
|
||||
7. Check env loading: `bash -c "source ~/.bashrc && env | grep KEY"`
|
||||
8. Check QQBot/Telegram DNS: `nslookup api.telegram.org`
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: cross-platform-push
|
||||
description: "跨平台消息推送:从当前会话推送消息到Telegram/QQ/微信任一端。用cronjob deliver机制实现。"
|
||||
version: 1.0.0
|
||||
tags: [messaging, cross-platform, telegram, qq, weixin, push]
|
||||
---
|
||||
|
||||
# 跨平台消息推送
|
||||
|
||||
从当前会话推送任意消息到其他平台(Telegram/QQ/微信)。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 当前在 Telegram DM/群,需要推送到 QQ 或微信
|
||||
- 当前在 QQ,需要推送到 Telegram
|
||||
- 交易结果、告警、通知等需要多端同步
|
||||
- **channel_prompts 中推送交易确认到 QQ**(常见场景)
|
||||
|
||||
## 平台地址
|
||||
|
||||
| 平台 | hermes send 目标 | cronjob deliver 地址 |
|
||||
|------|-----------------|---------------------|
|
||||
| Telegram DM | `telegram` | `telegram:656685714` |
|
||||
| QQ DM | `qqbot` | `qqbot:B1EF50442496D57C1B4F3890501C34C2` |
|
||||
| 微信 | `weixin` | `weixin:o9cq8023LU5LqpUAGHMaMe5uscds@im.wechat` |
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方法 A:hermes send(推荐、最简单、当前会话直接调用)
|
||||
|
||||
```bash
|
||||
# 推送到 QQ 私信
|
||||
hermes send -t qqbot "消息内容"
|
||||
|
||||
# 推送到 Telegram 私信
|
||||
hermes send -t telegram "消息内容"
|
||||
```
|
||||
|
||||
**在 channel_prompts 中使用:**
|
||||
```yaml
|
||||
channel_prompts:
|
||||
'-1003966251111': '解析信号...然后通过 terminal("hermes send -t qqbot '"'"'信号摘要+操作建议'"'"'") 推送到QQ'
|
||||
```
|
||||
|
||||
**从 execute_code 中使用:**
|
||||
```python
|
||||
from hermes_tools import terminal
|
||||
terminal('hermes send -t qqbot "消息内容"')
|
||||
```
|
||||
|
||||
### 方法 B:cronjob deliver(DM 会话无 terminal 权限时)
|
||||
|
||||
```python
|
||||
# 1. 创建一次性任务
|
||||
cronjob(action='create',
|
||||
deliver='qqbot:B1EF50442496D57C1B4F3890501C34C2',
|
||||
prompt='直接原样输出以下内容:\n\n<消息正文>',
|
||||
schedule='2099-01-01T00:00:00')
|
||||
# 返回 job_id
|
||||
|
||||
# 2. 立即执行
|
||||
cronjob(action='run', job_id='xxx')
|
||||
|
||||
# 3. 清理
|
||||
cronjob(action='remove', job_id='xxx')
|
||||
```
|
||||
|
||||
### 推送到多个平台(并行创建)
|
||||
|
||||
```python
|
||||
job_qq = cronjob(deliver='qqbot:...', ...)
|
||||
job_wx = cronjob(deliver='weixin:...', ...)
|
||||
cronjob(action='run', job_id=job_qq)
|
||||
cronjob(action='run', job_id=job_wx)
|
||||
cronjob(action='remove', job_id=job_qq)
|
||||
cronjob(action='remove', job_id=job_wx)
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **方法 A(hermes send)优先** — 比 cronjob 更简单、无残留、消息原文发送
|
||||
2. **cronjob prompt 开头加"直接原样输出"** — 防止 LLM 二次加工
|
||||
3. **cronjob 用完即删** — 执行完 run 后立即 remove
|
||||
4. **hermes send 必须用 -t 参数** — 正确:`hermes send -t qqbot "msg"`,错误:`hermes send qqbot "msg"`
|
||||
|
||||
## 在 channel_prompts 中使用(TG 到 QQ 推送桥)
|
||||
|
||||
channel_prompts 中让 agent 通过 terminal 调用 hermes send:
|
||||
```yaml
|
||||
-1003966251111: '你是交易信号处理器。解析【币种】【方向】【仓位】等字段。然后通过 terminal("hermes send -t qqbot '"'"'信号摘要+建议操作'"'"'") 推送到QQ。不要在群里回复。'
|
||||
```
|
||||
|
||||
### 注意点
|
||||
- hermes send 用单引号包裹消息即可,无需复杂转义
|
||||
- 消息保持在 200 字以内
|
||||
- 明确要求"不要在群里回复"
|
||||
- 非交易消息直接忽略,不推不回复
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **send_message 工具不可用** — send_message 是 Hermes 内部工具,有意不注册为 agent 可调用工具。旧版 channel_prompts 引用 send_message 无效。必须用 hermes send(通过 terminal)或 cronjob deliver 替代。
|
||||
- cronjob 的 LLM 可能改写消息内容,prompt 要明确说"原样输出"
|
||||
- 一次性 cronjob 不会自动删除,必须手动 remove
|
||||
- deliver 格式必须正确,拼错不会报错但消息丢失
|
||||
- **hermes send 必须用 -t 参数**,不是位置参数
|
||||
- **config.yaml 编辑安全**:hermes config set 存嵌套 dict 会转成 JSON 字符串(YAML 格式错误)。正确的嵌套 dict 写法:用 Python 的 yaml.dump 或 sed 做行级替换。patch/write_file 被 config.yaml 安全拦截。
|
||||
|
||||
## 故障诊断
|
||||
|
||||
当推送失败时,参阅 references/qqbot-delivery-diagnostics.md 按序排查。
|
||||
|
||||
常见诊断路径:
|
||||
1. `hermes send -t qqbot "测试"` — 最简单连通性测试
|
||||
2. `cronjob list` — 看 last_delivery_error
|
||||
3. `cat ~/.hermes/gateway_state.json` — 检查 qqbot.state
|
||||
4. `systemctl --user restart hermes-gateway` — 需外部 shell
|
||||
@@ -0,0 +1,113 @@
|
||||
# QQ Bot / Hermes Gateway 推送故障诊断
|
||||
|
||||
当 QQ Bot 推送失败(cron job 或 send_message 报错)时,按以下顺序排查。
|
||||
|
||||
## 1. 从 Cron Job 错误入手
|
||||
|
||||
```bash
|
||||
cronjob(action='list') # 查看 last_delivery_error
|
||||
```
|
||||
|
||||
典型错误模式:
|
||||
|
||||
| 错误 | 根因 |
|
||||
|------|------|
|
||||
| `Not connected` | QQ Bot 适配器未连接(离线/WebSocket断连) |
|
||||
| `Temporary failure in name resolution` | DNS 解析失败(本地 DNS 问题) |
|
||||
| `Name or service not known` | 同上,DNS 不通 |
|
||||
| `WebSocket closed` | QQ API WebSocket 连接断开 |
|
||||
|
||||
## 2. 检查 Gateway 状态
|
||||
|
||||
```bash
|
||||
cat ~/.hermes/gateway_state.json
|
||||
# 关注 platforms.qqbot.state 字段
|
||||
# state=connected → 连接正常
|
||||
# state=disconnected/reconnecting → 出问题了
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```json
|
||||
{
|
||||
"gateway_state": "running",
|
||||
"platforms": {
|
||||
"qqbot": {
|
||||
"state": "connected",
|
||||
"updated_at": "2026-06-26T11:30:37.726746+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 检查 Gateway 日志
|
||||
|
||||
```bash
|
||||
strings ~/.hermes/logs/gateway.log | grep -i "qqbot"
|
||||
# 或筛选更全面
|
||||
strings ~/.hermes/logs/gateway.log | grep -iE "qqbot|error|disconnect"
|
||||
```
|
||||
|
||||
关键日志标记(按时间序):
|
||||
|
||||
| 日志 | 含义 |
|
||||
|------|------|
|
||||
| `Not connected` | QQ Bot 刚启但尚未就绪 |
|
||||
| `Still not connected after 15s` | 连接超时 |
|
||||
| `Disconnected` | WebSocket 已断开 |
|
||||
| `Access token refreshed` | Token 刷新成功(正常) |
|
||||
| `WebSocket connected` | WebSocket 长连建立 ✅ |
|
||||
| `Connected` + `Identify sent` | 适配器登录完成 ✅ |
|
||||
| `Ready, session_id=...` | 完全就绪,可收发消息 ✅ |
|
||||
|
||||
## 4. 检查进程/容器
|
||||
|
||||
```bash
|
||||
# QQ Bot 是 Hermes gateway 内部适配器,没有独立进程
|
||||
# 检查 gateway 本身运行状态
|
||||
systemctl --user status hermes-gateway | head -10
|
||||
```
|
||||
|
||||
## 5. 常见根因与修复
|
||||
|
||||
### DNS 故障(最常见)
|
||||
- 错误: `Temporary failure in name resolution`
|
||||
- 影响: QQ Bot 和微信同时断连
|
||||
- 修复: 重启 gateway 让适配器重新解析 DNS
|
||||
```bash
|
||||
# 注意:agent 内可能被拦截,需外部 shell 执行
|
||||
systemctl --user restart hermes-gateway
|
||||
```
|
||||
- 验证: 重启后检查 `gateway_state.json` 中 qqbot.state=connected
|
||||
|
||||
### Gateway 重启
|
||||
- 错误: `Not connected` 后紧跟 `Disconnected`
|
||||
- 修复: 等待 gateway 自动恢复(~30s)
|
||||
- 验证: qqbot 日志出现 `Ready, session_id=...` 即恢复
|
||||
|
||||
### WebSocket 异常断开
|
||||
- 错误: `WebSocket closed` 无重连
|
||||
- 修复: 重启 gateway
|
||||
- 验证: 同上
|
||||
|
||||
## 6. 验证推送
|
||||
|
||||
修复后立即测试:
|
||||
|
||||
```python
|
||||
cronjob(action='create',
|
||||
deliver='qqbot:B1EF50442496D57C1B4F3890501C34C2',
|
||||
prompt='直接原样输出:✅ QQ Bot 推送测试 - ' + __import__('datetime').datetime.now().strftime('%H:%M:%S'),
|
||||
schedule='2099-01-01T00:00:00')
|
||||
cronjob(action='remove', job_id='<返回的job_id>')
|
||||
```
|
||||
|
||||
## 7. 系统级网络健康
|
||||
|
||||
如果 DNS 反复故障:
|
||||
```bash
|
||||
# 检查 DNS 配置
|
||||
cat /etc/resolv.conf
|
||||
# 测试 QQ API DNS 解析
|
||||
nslookup api.sgroup.qq.com
|
||||
# 或换公共 DNS(8.8.8.8 / 114.114.114.114)
|
||||
```
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
name: docker
|
||||
description: Docker容器管理技能 - 镜像构建、容器运行、编排管理、日志查看、网络配置。当用户提到"docker"、"容器"、"镜像"、"docker-compose"时触发。
|
||||
version: 1.0.0
|
||||
tags: [docker, container, devops, infrastructure]
|
||||
---
|
||||
|
||||
# Docker 管理技能
|
||||
|
||||
Docker容器管理技能 - 镜像构建、容器运行、编排管理、日志查看、网络配置。
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 用户提到 "docker"、"容器"、"镜像"、"docker-compose"
|
||||
- 用户要求 "部署"、"运行容器"、"构建镜像"
|
||||
- 用户需要 "查看日志"、"容器状态"、"网络配置"
|
||||
|
||||
## 快速参考
|
||||
|
||||
### 镜像管理
|
||||
|
||||
```bash
|
||||
# 列出镜像
|
||||
docker images
|
||||
|
||||
# 拉取镜像
|
||||
docker pull <image>:<tag>
|
||||
|
||||
# 构建镜像
|
||||
docker build -t <name>:<tag> .
|
||||
|
||||
# 删除镜像
|
||||
docker rmi <image>
|
||||
|
||||
# 清理未使用镜像
|
||||
docker image prune -a
|
||||
```
|
||||
|
||||
### 容器管理
|
||||
|
||||
```bash
|
||||
# 运行容器
|
||||
docker run -d --name <name> -p <host>:<container> <image>
|
||||
|
||||
# 列出运行中的容器
|
||||
docker ps
|
||||
|
||||
# 列出所有容器
|
||||
docker ps -a
|
||||
|
||||
# 停止容器
|
||||
docker stop <container>
|
||||
|
||||
# 启动容器
|
||||
docker start <container>
|
||||
|
||||
# 重启容器
|
||||
docker restart <container>
|
||||
|
||||
# 删除容器
|
||||
docker rm <container>
|
||||
|
||||
# 进入容器
|
||||
docker exec -it <container> /bin/bash
|
||||
|
||||
# 查看日志
|
||||
docker logs -f <container>
|
||||
|
||||
# 查看容器详情
|
||||
docker inspect <container>
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 停止所有服务
|
||||
docker-compose down
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 重建并启动
|
||||
docker-compose up -d --build
|
||||
|
||||
# 扩展服务
|
||||
docker-compose up -d --scale <service>=<count>
|
||||
```
|
||||
|
||||
### 网络管理
|
||||
|
||||
```bash
|
||||
# 列出网络
|
||||
docker network ls
|
||||
|
||||
# 创建网络
|
||||
docker network create <name>
|
||||
|
||||
# 查看网络详情
|
||||
docker network inspect <name>
|
||||
|
||||
# 连接容器到网络
|
||||
docker network connect <network> <container>
|
||||
|
||||
# 断开网络
|
||||
docker network disconnect <network> <container>
|
||||
```
|
||||
|
||||
### 数据卷管理
|
||||
|
||||
```bash
|
||||
# 列出卷
|
||||
docker volume ls
|
||||
|
||||
# 创建卷
|
||||
docker volume create <name>
|
||||
|
||||
# 查看卷详情
|
||||
docker volume inspect <name>
|
||||
|
||||
# 删除卷
|
||||
docker volume rm <name>
|
||||
|
||||
# 清理未使用卷
|
||||
docker volume prune
|
||||
```
|
||||
|
||||
### 系统管理
|
||||
|
||||
```bash
|
||||
# 查看Docker信息
|
||||
docker info
|
||||
|
||||
# 查看磁盘使用
|
||||
docker system df
|
||||
|
||||
# 清理所有未使用资源
|
||||
docker system prune -a
|
||||
|
||||
# 查看Docker版本
|
||||
docker version
|
||||
```
|
||||
|
||||
## 常见场景
|
||||
|
||||
### 1. 部署Web应用
|
||||
|
||||
```bash
|
||||
# 运行Nginx
|
||||
docker run -d --name web -p 80:80 -v ./html:/usr/share/nginx/html nginx:alpine
|
||||
|
||||
# 运行Node.js应用
|
||||
docker run -d --name app -p 3000:3000 -e NODE_ENV=production node-app
|
||||
```
|
||||
|
||||
### 2. 运行数据库
|
||||
|
||||
```bash
|
||||
# MySQL
|
||||
docker run -d --name mysql \
|
||||
-e MYSQL_ROOT_PASSWORD=root \
|
||||
-e MYSQL_DATABASE=mydb \
|
||||
-p 3306:3306 \
|
||||
-v mysql-data:/var/lib/mysql \
|
||||
mysql:8
|
||||
|
||||
# PostgreSQL
|
||||
docker run -d --name postgres \
|
||||
-e POSTGRES_PASSWORD=root \
|
||||
-e POSTGRES_DB=mydb \
|
||||
-p 5432:5432 \
|
||||
-v pg-data:/var/lib/postgresql/data \
|
||||
postgres:15
|
||||
|
||||
# Redis
|
||||
docker run -d --name redis -p 6379:6379 redis:alpine
|
||||
```
|
||||
|
||||
### 3. 运行OctoBot(量化交易)
|
||||
|
||||
```bash
|
||||
docker run -d --name octobot \
|
||||
-p 80:80 \
|
||||
-v octobot-data:/octobot/user \
|
||||
drakkarsoftware/octobot:stable
|
||||
```
|
||||
|
||||
### 4. 运行Freqtrade
|
||||
|
||||
```bash
|
||||
# 下载配置
|
||||
mkdir freqtrade && cd freqtrade
|
||||
curl https://raw.githubusercontent.com/freqtrade/freqtrade/main/docker-compose.yml -o docker-compose.yml
|
||||
|
||||
# 启动
|
||||
docker-compose up -d
|
||||
|
||||
# 进入容器
|
||||
docker exec -it freqtrade bash
|
||||
```
|
||||
|
||||
## Dockerfile 最佳实践
|
||||
|
||||
```dockerfile
|
||||
# 使用多阶段构建
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
## ghcr.io 镜像拉取(中国服务器)
|
||||
|
||||
ghcr.io (GitHub Container Registry) 在中国服务器上经常超时。`registry-mirrors` in `daemon.json` **只加速 Docker Hub,不加速 ghcr.io**。
|
||||
|
||||
### 方案:NJU 镜像代理
|
||||
|
||||
```bash
|
||||
# 直接用 NJU 镜像拉取(替换 ghcr.io 前缀)
|
||||
docker pull ghcr.nju.edu.cn/<owner>/<image>:<tag>
|
||||
|
||||
# Tag 回原始名称(让 docker-compose 识别)
|
||||
docker tag ghcr.nju.edu.cn/<owner>/<image>:<tag> ghcr.io/<owner>/<image>:<tag>
|
||||
```
|
||||
|
||||
### 其他 ghcr.io 镜像
|
||||
|
||||
- `ghcr.nju.edu.cn` — 南京大学镜像(已验证可用)
|
||||
- `ghcr.mirrors.ustc.edu.cn` — 中科大镜像
|
||||
- 不保证长期可用,失效时换一个试试
|
||||
|
||||
### Docker Daemon 代理(拉 Docker Hub 镜像)
|
||||
|
||||
如果 Docker Hub 也拉不动,配置 daemon 级代理:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/system/docker.service.d
|
||||
sudo tee /etc/systemd/system/docker.service.d/proxy.conf << 'EOF'
|
||||
[Service]
|
||||
Environment="HTTP_PROXY=http://127.0.0.1:7890"
|
||||
Environment="HTTPS_PROXY=http://127.0.0.1:7890"
|
||||
Environment="NO_PROXY=localhost,127.0.0.1"
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
⚠️ 代理配置会影响 ghcr.io 镜像拉取(可能变慢),拉完 ghcr.io 后建议移除代理配置再拉 Docker Hub 镜像。
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 容器无法启动
|
||||
```bash
|
||||
# 查看日志
|
||||
docker logs <container>
|
||||
|
||||
# 查看容器状态
|
||||
docker inspect <container>
|
||||
|
||||
# 检查端口占用
|
||||
netstat -tulpn | grep <port>
|
||||
```
|
||||
|
||||
### 磁盘空间不足
|
||||
```bash
|
||||
# 查看磁盘使用
|
||||
docker system df
|
||||
|
||||
# 清理未使用资源
|
||||
docker system prune -a
|
||||
|
||||
# 清理构建缓存
|
||||
docker builder prune
|
||||
```
|
||||
|
||||
### 网络问题
|
||||
```bash
|
||||
# 检查容器网络
|
||||
docker network inspect <network>
|
||||
|
||||
# 测试容器连通性
|
||||
docker exec -it <container> ping <target>
|
||||
|
||||
# 查看端口映射
|
||||
docker port <container>
|
||||
```
|
||||
|
||||
## 快捷操作
|
||||
|
||||
```bash
|
||||
# 停止所有容器
|
||||
docker stop $(docker ps -q)
|
||||
|
||||
# 删除所有容器
|
||||
docker rm $(docker ps -aq)
|
||||
|
||||
# 删除所有镜像
|
||||
docker rmi $(docker images -q)
|
||||
|
||||
# 进入最近创建的容器
|
||||
docker exec -it $(docker ps -lq) /bin/bash
|
||||
```
|
||||
|
||||
## 免责声明
|
||||
|
||||
⚠️ 生产环境操作前请确认影响范围,谨慎使用 `prune` 和批量删除命令。
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: hermes-gateway-troubleshooting
|
||||
description: >
|
||||
Diagnose and fix Hermes Gateway platform adapter connection issues.
|
||||
Covers checking gateway state, common adapter errors (is_reconnect,
|
||||
credential failures, dependency missing), safe restart procedures,
|
||||
and log inspection.
|
||||
trigger:
|
||||
- gateway not connecting
|
||||
- platform disconnected
|
||||
- adapter error
|
||||
- QQ收不到消息
|
||||
- gateway retrying
|
||||
- connect() unexpected keyword argument
|
||||
- is_reconnect
|
||||
- gateway state check
|
||||
- 网关连不上
|
||||
---
|
||||
|
||||
# Hermes Gateway Troubleshooting
|
||||
|
||||
Diagnose why a Hermes Gateway platform adapter won't connect.
|
||||
|
||||
## 1. Check Gateway State
|
||||
|
||||
The gateway state file is the single source of truth for platform connection status:
|
||||
|
||||
```bash
|
||||
cat ~/.hermes/gateway_state.json | python3 -m json.tool
|
||||
```
|
||||
|
||||
Look at the `platforms` dict. Each platform has:
|
||||
- `state`: `"connected"` ✅, `"disconnected"`, `"retrying"` ❌
|
||||
- `error_code`: machine-readable error type or `null`
|
||||
- `error_message`: human-readable error or `null`
|
||||
|
||||
Also check if the gateway process is alive:
|
||||
```bash
|
||||
ps aux | grep -i gateway | grep -v grep
|
||||
systemctl --user is-active hermes-gateway
|
||||
```
|
||||
|
||||
## 2. Categorize the Error
|
||||
|
||||
### Common Error Patterns
|
||||
|
||||
| Error message pattern | Likely cause | Fix |
|
||||
|-----------------------|-------------|-----|
|
||||
| `X.connect() got an unexpected keyword argument 'is_reconnect'` | Adapter `connect()` signature doesn't match base class | Add `*, is_reconnect: bool = False` to adapter's `connect()` |
|
||||
| `failed to get access token` / `qq_missing_credentials` | Missing or wrong env vars/config | Check `.env` or `config.yaml` for the platform's API credentials |
|
||||
| `aiohttp not installed` / `httpx not installed` | Missing Python dependency | `pip install aiohttp httpx` (or `uv pip install ...`) |
|
||||
| `WebSocket closed (code=4914/4915)` | Bot offline or banned | Check bot status on platform's developer portal |
|
||||
| `database is locked` (Telethon/SQLite) | Container still running with session DB | Stop container, edit DB, restart |
|
||||
| `getUpdates returned 0` (Telegram) | No adapter polling / stale gateway | Kill old gateway process, restart fresh |
|
||||
| `Telegram polling conflict` | Two processes sharing same bot token | `systemctl --user stop hermes-gateway`, wait 30s, `start` |
|
||||
| `Connect timeout` / `Connection refused` | Outbound connectivity issue | Check proxy/VPN, firewal, or WSS_PROXY setting |
|
||||
|
||||
## 3. The `is_reconnect` Parameter Mismatch (Common Bug)
|
||||
|
||||
### Symptom
|
||||
|
||||
Gateway state shows the platform as `"retrying"` with error:
|
||||
```
|
||||
XAdapter.connect() got an unexpected keyword argument 'is_reconnect'
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
|
||||
The base class `BasePlatformAdapter` defines:
|
||||
```python
|
||||
@abstractmethod
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
```
|
||||
|
||||
The gateway's reconnect watcher calls `adapter.connect(is_reconnect=True)`. If the adapter's `connect()` doesn't accept the `is_reconnect` keyword-only arg, Python throws `TypeError`.
|
||||
|
||||
### Fix
|
||||
|
||||
Add the missing parameter to the adapter's `connect()` method:
|
||||
|
||||
```python
|
||||
# Before (broken):
|
||||
async def connect(self) -> bool:
|
||||
...
|
||||
|
||||
# After (fixed):
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
...
|
||||
```
|
||||
|
||||
The adapter doesn't need to *use* `is_reconnect` if it doesn't care about the distinction — just accepting the kwarg is enough to stop the TypeError. For adapters that do care (e.g., Telegram's cold boot drops the stale event queue vs reconnect preserves it), use `is_reconnect` to branch behavior.
|
||||
|
||||
### Verification
|
||||
|
||||
After fixing, restart the gateway and check state again:
|
||||
|
||||
```bash
|
||||
systemctl --user stop hermes-gateway
|
||||
sleep 30 # critical: let Telegram session expire
|
||||
systemctl --user start hermes-gateway
|
||||
sleep 10 # wait for startup
|
||||
cat ~/.hermes/gateway_state.json | python3 -c "import sys,json; s=json.load(sys.stdin); [print(f' {k}: {v[\"state\"]}') for k,v in s['platforms'].items()]"
|
||||
```
|
||||
|
||||
Also run the adapter's tests:
|
||||
```bash
|
||||
cd ~/.hermes/hermes-agent
|
||||
source ./venv/bin/activate
|
||||
python -m pytest tests/gateway/test_<adapter>.py -x -v --tb=short
|
||||
```
|
||||
|
||||
### Pattern: All Adapters Must Match
|
||||
|
||||
Every Hermes gateway platform adapter must declare `connect()` with `*, is_reconnect: bool = False`. When adding a new adapter or updating an old one, verify this signature matches the base class.
|
||||
|
||||
## 4. Log Inspection
|
||||
|
||||
```bash
|
||||
# Gateway log (general platform issues)
|
||||
tail -100 ~/.hermes/logs/gateway.log | grep -iE "error|fail|disconnect|connect|retry"
|
||||
|
||||
# Filter by platform tag (e.g., QQBot, Telegram, Weixin)
|
||||
tail -100 ~/.hermes/logs/gateway.log | grep -i "QQBot\|Telegram\|Weixin"
|
||||
|
||||
# Check gateway update timestamps
|
||||
grep -E "state.*connected|state.*retrying" ~/.hermes/logs/gateway.log | tail -20
|
||||
```
|
||||
|
||||
## 5. Safe Gateway Restart
|
||||
|
||||
**Do NOT use `hermes gateway restart`** — it kills the Telegram polling session too quickly. The new adapter starts before the old one's Telegram session expires, causing a permanent polling conflict.
|
||||
|
||||
### Safe Sequence
|
||||
|
||||
```bash
|
||||
# Step 1: Stop
|
||||
systemctl --user stop hermes-gateway
|
||||
|
||||
# Step 2: Wait for Telegram's session lock to expire (30 seconds minimum)
|
||||
sleep 30
|
||||
|
||||
# Step 3: Start fresh
|
||||
systemctl --user start hermes-gateway
|
||||
|
||||
# Step 4: Wait for startup
|
||||
sleep 10
|
||||
|
||||
# Step 5: Verify
|
||||
cat ~/.hermes/gateway_state.json | python3 -m json.tool
|
||||
```
|
||||
|
||||
## 6. Platform-Specific Diagnostics
|
||||
|
||||
### QQ Bot
|
||||
- Check `QQ_APP_ID` and `QQ_CLIENT_SECRET` in `.env`
|
||||
- QQ WebSocket needs proxy env var `WSS_PROXY` or `HTTPS_PROXY` (set in `_open_ws`)
|
||||
- Error 4914 = bot offline/sandbox (fatal, stop reconnecting)
|
||||
- Error 4915 = bot banned (fatal, stop reconnecting)
|
||||
- Verify `adapter.py`'s `connect()` accepts `is_reconnect`
|
||||
|
||||
### Telegram
|
||||
- Only one bot token can poll at a time. Stale processes block new sessions.
|
||||
- Disable bot privacy mode in @BotFather for group message reading (kick + re-add bot after changing)
|
||||
- `free_response_chats` in config must be inside `telegram.extra` (NOT top-level)
|
||||
|
||||
### Weixin
|
||||
- Requires `aiohttp` and `cryptography` packages
|
||||
- Check `WX_APP_ID`, `WX_TOKEN`, `WX_AES_KEY` in `.env`
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **🔴 Gateway state file can be stale.** If `gateway_state.json` shows an old error even after restart, the new gateway process may be still starting up. Check `ps aux` for PID changes and wait.
|
||||
- **🔴 sleep 30 is not optional** on Telegram restarts. Without it, the new adapter gets 409 Conflict and never connects.
|
||||
- **🔴 `is_reconnect` in adapter but not in base class** — the reverse check is also worth making when adding a new adapter: if the adapter uses `is_reconnect` internally but the base class doesn't pass it (hypothetical), it'll silently default to `False` during reconnect. The gateway always passes `is_reconnect=True` on watcher-initiated reconnects.
|
||||
- **Test mocks need the same signature.** If a test creates a fake adapter with `async def connect(self):` (no `is_reconnect`), it won't catch the real runtime error.
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: hermes-voice-setup
|
||||
description: "Configure Hermes Agent voice mode — STT (speech-to-text) and TTS (text-to-speech) for CLI, Telegram, and Discord. Covers faster-whisper, Edge TTS, Groq Whisper, ElevenLabs, and Chinese/multilingual voice selection."
|
||||
tags: [hermes, voice, stt, tts, whisper, telegram, speech]
|
||||
---
|
||||
|
||||
# Hermes Voice Mode Setup
|
||||
|
||||
Configure Hermes Agent for voice interaction: speech-to-text (STT) input and text-to-speech (TTS) output across CLI, Telegram, and Discord.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Component | Free Option | Premium Option |
|
||||
|-----------|-------------|----------------|
|
||||
| **STT** | faster-whisper (local) | Groq Whisper / OpenAI Whisper |
|
||||
| **TTS** | Edge TTS (free, no key) | ElevenLabs / OpenAI TTS |
|
||||
|
||||
## Step 1: Install Dependencies
|
||||
|
||||
```bash
|
||||
# System deps (Ubuntu/Debian)
|
||||
sudo apt install portaudio19-dev ffmpeg libopus0 espeak-ng
|
||||
|
||||
# Python deps — in Hermes venv
|
||||
source ~/.hermes/hermes-agent/venv/bin/activate
|
||||
pip install faster-whisper
|
||||
pip install edge-tts
|
||||
```
|
||||
|
||||
**Mac:**
|
||||
```bash
|
||||
brew install portaudio ffmpeg opus espeak-ng
|
||||
```
|
||||
|
||||
## Step 2: Configure STT
|
||||
|
||||
```bash
|
||||
# Local Whisper (free, no API key — recommended)
|
||||
hermes config set stt.enabled true
|
||||
hermes config set stt.provider local
|
||||
hermes config set stt.local.model base # tiny/base/small/medium/large-v3
|
||||
```
|
||||
|
||||
**Model sizes for faster-whisper:**
|
||||
| Model | Size | Speed | Accuracy | Best For |
|
||||
|-------|------|-------|----------|----------|
|
||||
| tiny | ~75MB | Fastest | Low | Quick tests |
|
||||
| base | ~150MB | Fast | Good | Default, balances speed/quality |
|
||||
| small | ~500MB | Medium | Better | Multilingual |
|
||||
| medium | ~1.5GB | Slow | High | Production Chinese/English |
|
||||
| large-v3 | ~3GB | Slowest | Best | Maximum accuracy |
|
||||
|
||||
For Chinese voice input, `medium` or `large-v3` significantly outperform `base`.
|
||||
|
||||
## Step 3: Configure TTS
|
||||
|
||||
```bash
|
||||
# Edge TTS (free, no API key)
|
||||
hermes config set tts.provider edge
|
||||
```
|
||||
|
||||
### Chinese Voice Options (Edge TTS)
|
||||
|
||||
| Voice | Gender | Style | Best For |
|
||||
|-------|--------|-------|----------|
|
||||
| `zh-CN-XiaoxiaoNeural` | Female | Warm | Default Chinese voice |
|
||||
| `zh-CN-XiaoyiNeural` | Female | Lively | Casual/fun |
|
||||
| `zh-CN-YunxiNeural` | Male | Sunshine | General male voice |
|
||||
| `zh-CN-YunyangNeural` | Male | Professional | News/formal |
|
||||
| `zh-CN-YunjianNeural` | Male | Passionate | Sports/energetic |
|
||||
| `zh-CN-YunxiaNeural` | Male | Cute | Light content |
|
||||
|
||||
```bash
|
||||
hermes config set tts.edge.voice "zh-CN-XiaoxiaoNeural"
|
||||
```
|
||||
|
||||
### List Available Voices
|
||||
|
||||
```bash
|
||||
edge-tts --list-voices | grep zh-CN # Chinese
|
||||
edge-tts --list-voices | grep en-US # English
|
||||
edge-tts --list-voices # All languages
|
||||
```
|
||||
|
||||
## Step 4: Usage
|
||||
|
||||
### CLI
|
||||
```
|
||||
/voice on # Enable voice mode
|
||||
/voice off # Disable
|
||||
/voice tts # Toggle TTS output (hear spoken replies)
|
||||
/voice status # Show current state
|
||||
# Ctrl+B to start/stop recording
|
||||
```
|
||||
|
||||
### Telegram
|
||||
Voice messages sent to the bot are auto-transcribed. No special config needed — STT handles it.
|
||||
|
||||
To make the bot reply with voice:
|
||||
```
|
||||
/voice tts # In Telegram chat
|
||||
```
|
||||
|
||||
### Discord
|
||||
- Voice messages in text channels: auto-transcribed
|
||||
- Voice channel: `/voice join` to have bot participate in VC
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **faster-whisper model downloads on first use**: The model (~150MB for `base`) downloads from HuggingFace on first transcription. If HuggingFace is blocked (China), pre-download or use a mirror. First transcription will be slow.
|
||||
- **PortAudio not needed for Telegram**: `portaudio19-dev` is only required for CLI microphone input (Ctrl+B). Telegram voice messages work without it since they receive pre-recorded audio.
|
||||
- **Edge TTS requires internet**: Edge TTS calls Microsoft's cloud API. It's free but needs network access. For fully offline TTS, use NeuTTS: `pip install neutts[all]`.
|
||||
- **Chinese STT accuracy**: `base` model works for English but struggles with Chinese. Upgrade to `medium` or `large-v3` for better Chinese recognition: `hermes config set stt.local.model medium`.
|
||||
- **TTS voice change needs gateway restart**: After changing `tts.edge.voice`, restart the gateway for Telegram/Discord: `hermes gateway restart`. CLI picks it up on next session.
|
||||
- **edge-tts vs edge_tts**: The pip package is `edge-tts` (with hyphen). The Python import is `edge_tts` (with underscore). Don't confuse them.
|
||||
- **Groq Whisper for fast cloud STT**: If local Whisper is too slow, set `GROQ_API_KEY` in `~/.hermes/.env` and use `hermes config set stt.provider groq`. Free tier has generous limits.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: kanban-orchestrator
|
||||
description: Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role.
|
||||
version: 3.0.0
|
||||
platforms: [linux, macos, windows]
|
||||
environments: [kanban]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [kanban, multi-agent, orchestration, routing]
|
||||
related_skills: [kanban-worker]
|
||||
---
|
||||
|
||||
# Kanban Orchestrator — Decomposition Playbook
|
||||
|
||||
> The **core worker lifecycle** (including the `kanban_create` fan-out pattern and the "decompose, don't execute" rule) is auto-injected into every kanban process via the `KANBAN_GUIDANCE` system-prompt block. This skill is the deeper playbook when you're an orchestrator profile whose whole job is routing.
|
||||
|
||||
## Profiles are user-configured — not a fixed roster
|
||||
|
||||
Hermes setups vary widely. Some users run a single profile that does everything; some run a small fleet (`docker-worker`, `cron-worker`); some run a curated specialist team they've named themselves. There is **no default specialist roster** — the orchestrator skill does not know what profiles exist on this machine.
|
||||
|
||||
Before fanning out, you must ground the decomposition in the profiles that actually exist. The dispatcher silently fails to spawn unknown assignee names — it doesn't autocorrect, doesn't suggest, doesn't fall back. So a card assigned to `researcher` on a setup that only has `docker-worker` just sits in `ready` forever.
|
||||
|
||||
**Step 0: discover available profiles before planning.**
|
||||
|
||||
Use one of these:
|
||||
|
||||
- `hermes profile list` — prints the table of profiles configured on this machine. Run it through your terminal tool if you have one; otherwise ask the user.
|
||||
- `kanban_list(assignee="<some-name>")` — sanity-check a single name. Returns an empty list (rather than an error) for an unknown assignee, so this only confirms a name you're already considering.
|
||||
- **Just ask the user.** "What profiles do you have set up?" is a fine first turn when the goal needs more than one specialist.
|
||||
|
||||
Cache the result in your working memory for the rest of the conversation. Re-asking every turn wastes a tool call.
|
||||
|
||||
## When to use the board (vs. just doing the work)
|
||||
|
||||
Create Kanban tasks when any of these are true:
|
||||
|
||||
1. **Multiple specialists are needed.** Research + analysis + writing is three profiles.
|
||||
2. **The work should survive a crash or restart.** Long-running, recurring, or important.
|
||||
3. **The user might want to interject.** Human-in-the-loop at any step.
|
||||
4. **Multiple subtasks can run in parallel.** Fan-out for speed.
|
||||
5. **Review / iteration is expected.** A reviewer profile loops on drafter output.
|
||||
6. **The audit trail matters.** Board rows persist in SQLite forever.
|
||||
|
||||
If *none* of those apply — it's a small one-shot reasoning task — use `delegate_task` instead or answer the user directly.
|
||||
|
||||
## The anti-temptation rules
|
||||
|
||||
Your job description says "route, don't execute." The rules that enforce that:
|
||||
|
||||
- **Do not execute the work yourself.** Your restricted toolset usually doesn't even include terminal/file/code/web for implementation. If you find yourself "just fixing this quickly" — stop and create a task for the right specialist.
|
||||
- **For any concrete task, create a Kanban task and assign it.** Every single time.
|
||||
- **Split multi-lane requests before creating cards.** A user prompt can contain several independent workstreams. Extract those lanes first, then create one card per lane instead of bundling unrelated work into a single implementer card.
|
||||
- **Run independent lanes in parallel.** If two cards do not need each other's output, leave them unlinked so the dispatcher can fan them out. Link only true data dependencies.
|
||||
- **Never create dependent work as independent ready cards.** If a card must wait for another card, pass `parents=[...]` in the original `kanban_create` call. Do not create it first and link it later, and do not rely on prose like "wait for T1" inside the body.
|
||||
- **If no specialist fits the available profiles, ask the user which profile to create or which existing profile to use.** Do not invent profile names; the dispatcher will silently drop unknown assignees.
|
||||
- **Decompose, route, and summarize — that's the whole job.**
|
||||
|
||||
## Decomposition playbook
|
||||
|
||||
### Step 1 — Understand the goal
|
||||
|
||||
Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to spawn the wrong fleet.
|
||||
|
||||
### Step 2 — Sketch the task graph
|
||||
|
||||
Before creating anything, draft the graph out loud (in your response to the user). Treat every concrete workstream as a candidate card:
|
||||
|
||||
1. Extract the lanes from the request.
|
||||
2. Map each lane to one of the profiles you discovered in Step 0. If a lane doesn't fit any existing profile, ask the user which to use or create.
|
||||
3. Decide whether each lane is independent or gated by another lane.
|
||||
4. Create independent lanes as parallel cards with no parent links.
|
||||
5. Create synthesis/review/integration cards with parent links to the lanes they depend on. A child created with unfinished parents starts in `todo`; the dispatcher promotes it to `ready` only after every parent is done.
|
||||
|
||||
Examples of prompts that should fan out (using placeholder profile names — substitute whatever exists on the user's setup):
|
||||
|
||||
- "Build an app" → one card to a design-oriented profile for product/UI direction, one or two cards to engineering profiles for implementation, plus a later integration/review card if the user has a reviewer profile.
|
||||
- "Fix blockers and check model variants" → one implementation card for the blocker fixes plus one discovery/research card for config/source verification. A final reviewer card can depend on both.
|
||||
- "Research docs and implement" → a docs-research card can run in parallel with a codebase-discovery card; implementation waits only if it truly needs those findings.
|
||||
- "Analyze this screenshot and find the related code" → one card to a vision-capable profile for the visual analysis while another searches the codebase.
|
||||
|
||||
Words like "also," "finally," or "and" do not automatically imply a dependency. They often mean "make sure this is covered before reporting back." Only link tasks when one card cannot start until another card's output exists.
|
||||
|
||||
Show the graph to the user before creating cards. Let them correct it — including which actual profile name should own each lane.
|
||||
|
||||
### Step 3 — Create tasks and link
|
||||
|
||||
Use the profile names from Step 0. The example below uses placeholders `<profile-A>`, `<profile-B>`, `<profile-C>` — replace them with what the user actually has.
|
||||
|
||||
```python
|
||||
t1 = kanban_create(
|
||||
title="research: Postgres cost vs current",
|
||||
assignee="<profile-A>", # whichever profile handles research on this setup
|
||||
body="Compare estimated infrastructure costs, migration costs, and ongoing ops costs over a 3-year window. Sources: AWS/GCP pricing, team time estimates, current Postgres bills from peers.",
|
||||
tenant=os.environ.get("HERMES_TENANT"),
|
||||
)["task_id"]
|
||||
|
||||
t2 = kanban_create(
|
||||
title="research: Postgres performance vs current",
|
||||
assignee="<profile-A>", # same profile, run in parallel
|
||||
body="Compare query latency, throughput, and scaling characteristics at our expected data volume (~500GB, 10k QPS peak). Sources: benchmark papers, public case studies, pgbench results if easy.",
|
||||
)["task_id"]
|
||||
|
||||
t3 = kanban_create(
|
||||
title="synthesize migration recommendation",
|
||||
assignee="<profile-B>", # whichever profile does synthesis/analysis
|
||||
body="Read the findings from T1 (cost) and T2 (performance). Produce a 1-page recommendation with explicit trade-offs and a go/no-go call.",
|
||||
parents=[t1, t2],
|
||||
)["task_id"]
|
||||
|
||||
t4 = kanban_create(
|
||||
title="draft decision memo",
|
||||
assignee="<profile-C>", # whichever profile drafts user-facing prose
|
||||
body="Turn the analyst's recommendation into a 2-page memo for the CTO. Match the tone of previous decision memos in the team's knowledge base.",
|
||||
parents=[t3],
|
||||
)["task_id"]
|
||||
```
|
||||
|
||||
`parents=[...]` gates promotion — children stay in `todo` until every parent reaches `done`, then auto-promote to `ready`. No manual coordination needed; the dispatcher and dependency engine handle it.
|
||||
|
||||
If the task graph has dependencies, create the parent cards first, capture their returned ids, and include those ids in the child card's `parents` list during the child `kanban_create` call. Avoid creating all cards in parallel and linking them afterward; that creates a window where the dispatcher can claim a child before its inputs exist.
|
||||
|
||||
### Step 4 — Complete your own task
|
||||
|
||||
If you were spawned as a task yourself (e.g. a planner profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created:
|
||||
|
||||
```python
|
||||
kanban_complete(
|
||||
summary="decomposed into T1-T4: 2 research lanes in parallel, 1 synthesis on their outputs, 1 prose draft on the recommendation",
|
||||
metadata={
|
||||
"task_graph": {
|
||||
"T1": {"assignee": "<profile-A>", "parents": []},
|
||||
"T2": {"assignee": "<profile-A>", "parents": []},
|
||||
"T3": {"assignee": "<profile-B>", "parents": ["T1", "T2"]},
|
||||
"T4": {"assignee": "<profile-C>", "parents": ["T3"]},
|
||||
},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Step 5 — Report back to the user
|
||||
|
||||
Tell them what you created in plain prose, naming the actual profiles you used:
|
||||
|
||||
> I've queued 4 tasks:
|
||||
> - **T1** (`<profile-A>`): cost comparison
|
||||
> - **T2** (`<profile-A>`): performance comparison, in parallel with T1
|
||||
> - **T3** (`<profile-B>`): synthesizes T1 + T2 into a recommendation
|
||||
> - **T4** (`<profile-C>`): turns T3 into a CTO memo
|
||||
>
|
||||
> The dispatcher will pick up T1 and T2 now. T3 starts when both finish. You'll get a gateway ping when T4 completes. Use the dashboard or `hermes kanban tail <id>` to follow along.
|
||||
|
||||
## Common patterns
|
||||
|
||||
**Fan-out + fan-in (research → synthesize):** N research-style cards with no parents, one synthesis card with all of them as parents.
|
||||
|
||||
**Parallel implementation + validation:** one implementer card makes the change while one explorer/researcher card verifies config, docs, or source mapping. A reviewer card can depend on both. Do not make the implementer own unrelated verification just because the user mentioned both in one sentence.
|
||||
|
||||
**Pipeline with gates:** `planner → implementer → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns.
|
||||
|
||||
**Same-profile queue:** N tasks, all assigned to the same profile, no dependencies between them. Dispatcher serializes — that profile processes them in priority order, accumulating experience in its own memory.
|
||||
|
||||
**Human-in-the-loop:** Any task can `kanban_block()` to wait for input. Dispatcher respawns after `/unblock`. The comment thread carries the full context.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
**Inventing profile names that don't exist.** The dispatcher silently fails to spawn unknown assignees — the card just sits in `ready` forever. Always assign to a profile from your Step 0 discovery; ask the user if you're unsure.
|
||||
|
||||
**Bundling independent lanes into one card.** If the user asks for two independent outcomes, create two cards. Example: "fix blockers and check model variants" is not one fixer task; create a fixer/engineer card for the fixes and an explorer/researcher card for the variant check, then optionally gate review on both.
|
||||
|
||||
**Over-linking because of wording.** "Finally check X" may still be parallel with implementation if X is static config, docs, or source discovery. Link it after implementation only when the check depends on the implementation result.
|
||||
|
||||
**Forgetting dependency links.** If the task graph says `research -> implement -> review`, do not create all tasks as independent ready cards. Use parent links so implement/review cannot run before their inputs exist.
|
||||
|
||||
**Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile.
|
||||
|
||||
**Argument order for links.** `kanban_link(parent_id=..., child_id=...)` — parent first. Mixing them up demotes the wrong task to `todo`.
|
||||
|
||||
**Don't pre-create the whole graph if the shape depends on intermediate findings.** If T3's structure depends on what T1 and T2 find, let T3 exist as a "synthesize findings" task whose own first step is to read parent handoffs and plan the rest. Orchestrators can spawn orchestrators.
|
||||
|
||||
**Tenant inheritance.** If `HERMES_TENANT` is set in your env, pass `tenant=os.environ.get("HERMES_TENANT")` on every `kanban_create` call so child tasks stay in the same namespace.
|
||||
|
||||
## Goal-mode cards (persistent workers)
|
||||
|
||||
By default a dispatched worker gets **one shot** at its card: it does its work, calls `kanban_complete`/`kanban_block`, and exits. For open-ended cards where one turn rarely finishes the job, pass `goal_mode=True` to wrap that worker in a Ralph-style goal loop — the same engine behind the `/goal` slash command:
|
||||
|
||||
```python
|
||||
kanban_create(
|
||||
title="Translate the full docs site to French",
|
||||
body="Acceptance: every page translated, no English left, links intact.",
|
||||
assignee="<translator-profile>",
|
||||
goal_mode=True, # judge re-checks the card after each turn
|
||||
goal_max_turns=15, # optional budget (default 20)
|
||||
)["task_id"]
|
||||
```
|
||||
|
||||
How it behaves:
|
||||
- After each worker turn, an auxiliary judge evaluates the worker's response against the card's **title + body** (treated as the acceptance criteria).
|
||||
- Not done + budget remains → the worker keeps going **in the same session** (full context retained — not a fresh respawn).
|
||||
- Worker calls `kanban_complete`/`kanban_block` itself → loop stops, normal lifecycle.
|
||||
- Budget exhausted without completion → the card is **blocked** for human review (sticky), never a silent exit.
|
||||
|
||||
When to use it: long, multi-step, or "keep going until X is true" cards. When NOT to: cheap one-shot cards (translation of a single string, a quick lookup) — the judge overhead isn't worth it, and the dispatcher's existing retry/circuit-breaker already handles transient worker failures.
|
||||
|
||||
Write the body as **explicit acceptance criteria** — the judge is only as good as the goal text. "Translate the README" is weaker than "Translate every section of the README to French; no English sentences remain."
|
||||
|
||||
## Recovering stuck workers
|
||||
|
||||
When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions:
|
||||
|
||||
1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out.
|
||||
2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile (one that exists on this setup) and let the dispatcher pick it up with a fresh worker.
|
||||
3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model.
|
||||
|
||||
Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging.
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: kanban-worker
|
||||
description: Pitfalls, examples, and edge cases for Hermes Kanban workers. The lifecycle itself is auto-injected into every worker's system prompt as KANBAN_GUIDANCE (from agent/prompt_builder.py); this skill is what you load when you want deeper detail on specific scenarios.
|
||||
version: 2.0.0
|
||||
platforms: [linux, macos, windows]
|
||||
environments: [kanban]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [kanban, multi-agent, collaboration, workflow, pitfalls]
|
||||
related_skills: [kanban-orchestrator]
|
||||
---
|
||||
|
||||
# Kanban Worker — Pitfalls and Examples
|
||||
|
||||
> You're seeing this skill because the Hermes Kanban dispatcher spawned you as a worker with `--skills kanban-worker` — it's loaded automatically for every dispatched worker. The **lifecycle** (6 steps: orient → work → heartbeat → block/complete) also lives in the `KANBAN_GUIDANCE` block that's auto-injected into your system prompt. This skill is the deeper detail: good handoff shapes, retry diagnostics, edge cases.
|
||||
|
||||
## Workspace handling
|
||||
|
||||
Your workspace kind determines how you should behave inside `$HERMES_KANBAN_WORKSPACE`:
|
||||
|
||||
| Kind | What it is | How to work |
|
||||
|---|---|---|
|
||||
| `scratch` | Fresh tmp dir, yours alone | Read/write freely; it gets GC'd when the task is archived. |
|
||||
| `dir:<path>` | Shared persistent directory | Other runs will read what you write. Treat it like long-lived state. Path is guaranteed absolute (the kernel rejects relative paths). |
|
||||
| `worktree` | Git worktree at the resolved path | If `.git` doesn't exist, run `git worktree add <path> ${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo first, then cd and work normally. Commit work here. |
|
||||
|
||||
## Tenant isolation
|
||||
|
||||
If `$HERMES_TENANT` is set, the task belongs to a tenant namespace. When reading or writing persistent memory, prefix memory entries with the tenant so context doesn't leak across tenants:
|
||||
|
||||
- Good: `business-a: Acme is our biggest customer`
|
||||
- Bad (leaks): `Acme is our biggest customer`
|
||||
|
||||
## Good summary + metadata shapes
|
||||
|
||||
The `kanban_complete(summary=..., metadata=...)` handoff is how downstream workers read what you did. Patterns that work:
|
||||
|
||||
**Coding task:**
|
||||
```python
|
||||
kanban_complete(
|
||||
summary="shipped rate limiter — token bucket, keys on user_id with IP fallback, 14 tests pass",
|
||||
metadata={
|
||||
"changed_files": ["rate_limiter.py", "tests/test_rate_limiter.py"],
|
||||
"tests_run": 14,
|
||||
"tests_passed": 14,
|
||||
"decisions": ["user_id primary, IP fallback for unauthenticated requests"],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Coding task that needs human review (review-required):**
|
||||
|
||||
For most code-changing tasks, the work isn't truly *done* until a human reviewer has eyes on it. Block instead of complete, with `reason` prefixed `review-required: ` so the dashboard surfaces the row as needing review. Drop the structured metadata (changed files, test counts, diff/PR url) into a comment first, since `kanban_block` only carries the human-readable reason — comments are the durable annotation channel. Reviewer either approves and runs `hermes kanban unblock <id>` (which re-spawns you with the comment thread for any follow-ups) or asks for changes via another comment.
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
kanban_comment(
|
||||
body="review-required handoff:\n" + json.dumps({
|
||||
"changed_files": ["rate_limiter.py", "tests/test_rate_limiter.py"],
|
||||
"tests_run": 14,
|
||||
"tests_passed": 14,
|
||||
"diff_path": "/path/to/worktree", # or PR url if pushed
|
||||
"decisions": ["user_id primary, IP fallback for unauthenticated requests"],
|
||||
}, indent=2),
|
||||
)
|
||||
kanban_block(
|
||||
reason="review-required: rate limiter shipped, 14/14 tests pass — needs eyes on the user_id/IP fallback choice before merging",
|
||||
)
|
||||
```
|
||||
|
||||
Use `kanban_complete` only when the task is genuinely terminal — e.g. a one-line typo fix, a docs change with no functional consequences, or a research task where the artifact IS the writeup itself.
|
||||
|
||||
**Research task:**
|
||||
```python
|
||||
kanban_complete(
|
||||
summary="3 competing libraries reviewed; vLLM wins on throughput, SGLang on latency, Tensorrt-LLM on memory efficiency",
|
||||
metadata={
|
||||
"sources_read": 12,
|
||||
"recommendation": "vLLM",
|
||||
"benchmarks": {"vllm": 1.0, "sglang": 0.87, "trtllm": 0.72},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Review task:**
|
||||
```python
|
||||
kanban_complete(
|
||||
summary="reviewed PR #123; 2 blocking issues found (SQL injection in /search, missing CSRF on /settings)",
|
||||
metadata={
|
||||
"pr_number": 123,
|
||||
"findings": [
|
||||
{"severity": "critical", "file": "api/search.py", "line": 42, "issue": "raw SQL concat"},
|
||||
{"severity": "high", "file": "api/settings.py", "issue": "missing CSRF middleware"},
|
||||
],
|
||||
"approved": False,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Shape `metadata` so downstream parsers (reviewers, aggregators, schedulers) can use it without re-reading your prose.
|
||||
|
||||
## Claiming cards you actually created
|
||||
|
||||
If your run produced new kanban tasks (via `kanban_create`), pass the ids in `created_cards` on `kanban_complete`. The kernel verifies each id exists and was created by your profile; any phantom id blocks the completion with an error listing what went wrong, and the rejected attempt is permanently recorded on the task's event log. **Only list ids you captured from a successful `kanban_create` return value — never invent ids from prose, never paste ids from earlier runs, never claim cards another worker created.**
|
||||
|
||||
```python
|
||||
# GOOD — capture return values, then claim them.
|
||||
c1 = kanban_create(title="remediate SQL injection", assignee="security-worker")
|
||||
c2 = kanban_create(title="fix CSRF middleware", assignee="web-worker")
|
||||
|
||||
kanban_complete(
|
||||
summary="Review done; spawned remediations for both findings.",
|
||||
metadata={"pr_number": 123, "approved": False},
|
||||
created_cards=[c1["task_id"], c2["task_id"]],
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# BAD — claiming ids you don't have captured return values for.
|
||||
kanban_complete(
|
||||
summary="Created remediation cards t_a1b2c3d4, t_deadbeef", # hallucinated
|
||||
created_cards=["t_a1b2c3d4", "t_deadbeef"], # → gate rejects
|
||||
)
|
||||
```
|
||||
|
||||
If a `kanban_create` call fails (exception, tool_error), the card was NOT created — do not include a phantom id for it. Retry the create, or omit the id and mention the failure in your summary. The prose-scan pass also catches `t_<hex>` references in your free-form summary that don't resolve; these don't block the completion but show up as advisory warnings on the task in the dashboard.
|
||||
|
||||
## Block reasons that get answered fast
|
||||
|
||||
Bad: `"stuck"` — the human has no context.
|
||||
|
||||
Good: one sentence naming the specific decision you need. Leave longer context as a comment instead.
|
||||
|
||||
```python
|
||||
kanban_comment(
|
||||
task_id=os.environ["HERMES_KANBAN_TASK"],
|
||||
body="Full context: I have user IPs from Cloudflare headers but some users are behind NATs with thousands of peers. Keying on IP alone causes false positives.",
|
||||
)
|
||||
kanban_block(reason="Rate limit key choice: IP (simple, NAT-unsafe) or user_id (requires auth, skips anonymous endpoints)?")
|
||||
```
|
||||
|
||||
The block message is what appears in the dashboard / gateway notifier. The comment is the deeper context a human reads when they open the task.
|
||||
|
||||
## Heartbeats worth sending
|
||||
|
||||
Good heartbeats name progress: `"epoch 12/50, loss 0.31"`, `"scanned 1.2M/2.4M rows"`, `"uploaded 47/120 videos"`.
|
||||
|
||||
Bad heartbeats: `"still working"`, empty notes, sub-second intervals. Every few minutes max; skip entirely for tasks under ~2 minutes.
|
||||
|
||||
## Retry scenarios
|
||||
|
||||
If you open the task and `kanban_show` returns `runs: [...]` with one or more closed runs, you're a retry. The prior runs' `outcome` / `summary` / `error` tell you what didn't work. Don't repeat that path. Typical retry diagnostics:
|
||||
|
||||
- `outcome: "timed_out"` — the previous attempt hit `max_runtime_seconds`. You may need to chunk the work or shorten it.
|
||||
- `outcome: "crashed"` — OOM or segfault. Reduce memory footprint.
|
||||
- `outcome: "spawn_failed"` + `error: "..."` — usually a profile config issue (missing credential, bad PATH). Ask the human via `kanban_block` instead of retrying blindly.
|
||||
- `outcome: "reclaimed"` + `summary: "task archived..."` — operator archived the task out from under the previous run; you probably shouldn't be running at all, check status carefully.
|
||||
- `outcome: "blocked"` — a previous attempt blocked; the unblock comment should be in the thread by now.
|
||||
|
||||
## Notification routing
|
||||
|
||||
You can configure the gateway to receive cross-profile Kanban task notifications by adding `notification_sources` to `~/.hermes/config.yaml`.
|
||||
- `notification_sources: ['*']` accepts subscriptions from all profiles.
|
||||
- `notification_sources: ['default', 'zilor-ppt']` or `"default,zilor-ppt"` restricts subscriptions to specified profiles.
|
||||
- Omitting the key keeps the default behavior (profile isolation).
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Call `delegate_task` as a substitute for `kanban_create`. `delegate_task` is for short reasoning subtasks inside YOUR run; `kanban_create` is for cross-agent handoffs that outlive one API loop.
|
||||
- Call `clarify` to ask the human a question. You are running headless — there is no live user to answer. The call will time out (default ~120s) and the task will sit silently in `running` with no signal that it needs input. Use `kanban_comment` (context) + `kanban_block(reason=...)` (decision needed) instead — the task surfaces on the board as blocked, the operator sees it, unblocks with their answer in a comment, and you respawn with the thread.
|
||||
- Modify files outside `$HERMES_KANBAN_WORKSPACE` unless the task body says to.
|
||||
- Create follow-up tasks assigned to yourself — assign to the right specialist.
|
||||
- Complete a task you didn't actually finish. Block it instead.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
**Task state can change between dispatch and your startup.** Between when the dispatcher claimed and when your process actually booted, the task may have been blocked, reassigned, or archived. Always `kanban_show` first. If it reports `blocked` or `archived`, stop — you shouldn't be running.
|
||||
|
||||
**Workspace may have stale artifacts.** Especially `dir:` and `worktree` workspaces can have files from previous runs. Read the comment thread — it usually explains why you're running again and what state the workspace is in.
|
||||
|
||||
**Don't rely on the CLI when the guidance is available.** The `kanban_*` tools work across all terminal backends (Docker, Modal, SSH). `hermes kanban <verb>` from your terminal tool will fail in containerized backends because the CLI isn't installed there. When in doubt, use the tool.
|
||||
|
||||
## CLI fallback (for scripting)
|
||||
|
||||
Every tool has a CLI equivalent for human operators and scripts:
|
||||
- `kanban_show` ↔ `hermes kanban show <id> --json`
|
||||
- `kanban_complete` ↔ `hermes kanban complete <id> --summary "..." --metadata '{...}'`
|
||||
- `kanban_block` ↔ `hermes kanban block <id> "reason"`
|
||||
- `kanban_create` ↔ `hermes kanban create "title" --assignee <profile> [--parent <id>]`
|
||||
- etc.
|
||||
|
||||
Use the tools from inside an agent; the CLI exists for the human at the terminal.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: network-failover
|
||||
description: Automatic network failover and proxy/VPN switching. Detect connectivity loss → activate backup route (WireGuard VPN, proxy, etc.) → deactivate when primary recovers. Use when user says "自动切换代理", "网络受限", "auto switch VPN", "network failover", "proxy auto switch".
|
||||
origin: user
|
||||
---
|
||||
|
||||
# Network Failover & Auto-Switch
|
||||
|
||||
Automatically detect network restrictions and switch to a backup route (WireGuard VPN, SOCKS/HTTP proxy, etc.), then switch back when the primary network recovers.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User has a WireGuard VPN and wants it activated only when needed
|
||||
- User wants automatic proxy switching when direct connectivity fails
|
||||
- Environments where the network is intermittently restricted (GFW, corporate firewalls, etc.)
|
||||
- Reducing VPN overhead by only using it when necessary
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Monitor (systemd timer / cron) │
|
||||
│ Every 30s: ping/curl to multiple targets │
|
||||
│ │
|
||||
│ ┌─ Success? ──────────────────────────────┐│
|
||||
│ │ VPN up + N consecutive recoveries ││
|
||||
│ │ → disconnect VPN ││
|
||||
│ └─────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─ Failure? ──────────────────────────────┐│
|
||||
│ │ VPN down + M consecutive failures ││
|
||||
│ │ → activate VPN ││
|
||||
│ └─────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Hysteresis (anti-flap)**: Require N consecutive failures before activating, M consecutive successes before deactivating. Never switch on a single check.
|
||||
2. **Multi-target probing**: Test multiple IPs (8.8.8.8, 1.1.1.1, 208.67.222.222) with both curl and ping. DNS can break before ICMP, so probe at multiple layers.
|
||||
3. **Logging**: Always log switch events to a file for post-mortem debugging.
|
||||
4. **State files**: Use `/tmp/` files for counters (ephemeral, reset on reboot = safe default).
|
||||
|
||||
## WireGuard Auto-Switch
|
||||
|
||||
See `references/wireguard-auto-switch.md` for the full implementation script and systemd timer setup.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **⚠️ TEST BEFORE EXECUTE — network changes can kill connectivity instantly.** WireGuard with `AllowedIPs = 0.0.0.0/0` intercepts ALL traffic the moment `wg-quick up` runs. If the VPN endpoint is unreachable or misconfigured, the machine loses all network access — including SSH. **Always**: (1) check current state first (`ip route`, `wg show`, `ping`), (2) test VPN endpoint reachability before activating, (3) verify connectivity after each change, (4) have a fallback plan (e.g., a second terminal with a pre-existing SSH session, or physical console access). Never blindly execute `wg-quick up/down` or route changes.
|
||||
- **Single-ping switching causes flapping** — always use hysteresis (3 failures to activate, 2 recoveries to deactivate).
|
||||
- **DNS failure ≠ network failure** — if DNS breaks first, `curl http://8.8.8.8` still works (IP literal). Test both DNS-dependent and DNS-independent targets.
|
||||
- **Full tunnel kills DNS** — when WireGuard `AllowedIPs = 0.0.0.0/0`, ALL traffic (including DNS) goes through VPN. If VPN has DNS issues, the system is blind. Consider setting `DNS = 8.8.8.8` in the WireGuard config.
|
||||
- **sudo without password** — the script needs `wg-quick up/down` which requires root. Either add NOPASSWD sudoers entry or run the systemd service as root.
|
||||
- **Log rotation** — the switch log grows unbounded. Add logrotate or truncate periodically.
|
||||
- **PersistentKeepalive = 25** — mandatory on mobile/NAT clients, otherwise idle tunnels get dropped by NAT and the VPN appears "connected" but is actually dead.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `homelab-wireguard-vpn` — WireGuard server/client setup fundamentals
|
||||
- `homelab-wireguard-vpn` > references/ — DDNS, key generation, split vs full tunnel
|
||||
@@ -0,0 +1,120 @@
|
||||
# WireGuard Auto-Switch — Full Implementation
|
||||
|
||||
Complete script + systemd timer for automatic WireGuard failover.
|
||||
|
||||
## Auto-Switch Script
|
||||
|
||||
Path: `~/.hermes/scripts/wg-auto-switch.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
WG_IF="wg0"
|
||||
LOG="/var/log/wg-auto-switch.log"
|
||||
TARGETS=("8.8.8.8" "1.1.1.1" "208.67.222.222")
|
||||
FAIL_THRESHOLD=3
|
||||
RECOVER_THRESHOLD=2
|
||||
FAIL_FILE="/tmp/wg-auto-fail-count"
|
||||
RECOVER_FILE="/tmp/wg-auto-recover-count"
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG"; }
|
||||
|
||||
check_network() {
|
||||
for target in "${TARGETS[@]}"; do
|
||||
if curl -s --max-time 3 --head "http://$target" > /dev/null 2>&1; then return 0; fi
|
||||
if ping -c 1 -W 2 "$target" > /dev/null 2>&1; then return 0; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wg_is_up() { ip link show "$WG_IF" > /dev/null 2>&1; }
|
||||
get_count() { [ -f "$1" ] && cat "$1" || echo 0; }
|
||||
|
||||
if check_network; then
|
||||
if wg_is_up; then
|
||||
count=$(($(get_count "$RECOVER_FILE") + 1))
|
||||
echo "$count" > "$RECOVER_FILE"
|
||||
[ "$count" -ge "$RECOVER_THRESHOLD" ] && {
|
||||
log "✅ Network recovered, disconnecting WireGuard"
|
||||
sudo wg-quick down "$WG_IF" 2>>"$LOG"
|
||||
echo 0 > "$FAIL_FILE"; echo 0 > "$RECOVER_FILE"
|
||||
}
|
||||
else
|
||||
echo 0 > "$FAIL_FILE"; echo 0 > "$RECOVER_FILE"
|
||||
fi
|
||||
else
|
||||
if ! wg_is_up; then
|
||||
count=$(($(get_count "$FAIL_FILE") + 1))
|
||||
echo "$count" > "$FAIL_FILE"
|
||||
[ "$count" -ge "$FAIL_THRESHOLD" ] && {
|
||||
log "❌ Network restricted (${count} failures), starting WireGuard"
|
||||
sudo wg-quick up "$WG_IF" 2>>"$LOG"
|
||||
echo 0 > "$FAIL_FILE"; echo 0 > "$RECOVER_FILE"
|
||||
}
|
||||
else
|
||||
log "⚠️ WireGuard connected but still no network"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
## Systemd Timer Setup
|
||||
|
||||
```bash
|
||||
# Make script executable
|
||||
chmod +x ~/.hermes/scripts/wg-auto-switch.sh
|
||||
|
||||
# Create service unit
|
||||
sudo tee /etc/systemd/system/wg-auto-switch.service << 'EOF'
|
||||
[Unit]
|
||||
Description=WireGuard Auto Switch - Network Check
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/home/openclaw/.hermes/scripts/wg-auto-switch.sh
|
||||
User=openclaw
|
||||
EOF
|
||||
|
||||
# Create timer unit
|
||||
sudo tee /etc/systemd/system/wg-auto-switch.timer << 'EOF'
|
||||
[Unit]
|
||||
Description=Run WireGuard auto switch every 30 seconds
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30
|
||||
OnUnitActiveSec=30
|
||||
AccuracySec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
# Enable and start
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now wg-auto-switch.timer
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
```bash
|
||||
sudo systemctl status wg-auto-switch.timer
|
||||
tail -f /var/log/wg-auto-switch.log
|
||||
sudo wg show
|
||||
```
|
||||
|
||||
## Tuning
|
||||
|
||||
| Parameter | Default | Effect |
|
||||
|-----------|---------|--------|
|
||||
| `FAIL_THRESHOLD` | 3 | Consecutive failures before VPN activates (higher = slower to react, less flapping) |
|
||||
| `RECOVER_THRESHOLD` | 2 | Consecutive successes before VPN deactivates |
|
||||
| Timer interval | 30s | How often to check (in `/etc/systemd/system/wg-auto-switch.timer`) |
|
||||
| `curl --max-time` | 3s | Per-target timeout |
|
||||
| `ping -W` | 2s | Ping timeout |
|
||||
|
||||
For stricter environments (GFW deep packet inspection), add DNS-over-HTTPS check:
|
||||
```bash
|
||||
# Add to check_network() as a third test type
|
||||
if curl -s --max-time 5 "https://dns.google/resolve?name=google.com&type=A" | grep -q '"Answer"'; then
|
||||
return 0
|
||||
fi
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
[Interface]
|
||||
Address = 10.7.0.3/24
|
||||
DNS = 8.8.8.8, 8.8.4.4
|
||||
PrivateKey = <CLIENT_PRIVATE_KEY>
|
||||
|
||||
[Peer]
|
||||
PublicKey = <SERVER_PUBLIC_KEY>
|
||||
PresharedKey = <PRESHARED_KEY>
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = <SERVER_IP>:51820
|
||||
PersistentKeepalive = 25
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: startup-service-manager
|
||||
description: "Generic Linux service manager script — start/stop/restart/status any service via simple .sh definitions. No systemd required."
|
||||
tags: [devops, services, startup, process-management, linux]
|
||||
---
|
||||
|
||||
# Startup - Generic Service Manager
|
||||
|
||||
A lightweight bash-based service manager at `~/startup/`. Manages any process (mihomo, yacd, custom apps) via simple `.sh` definition files. No systemd, no Docker dependency.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User wants to manage multiple background services from one place
|
||||
- Services don't have their own systemd units
|
||||
- Quick prototyping before committing to systemd
|
||||
- User explicitly asks for a startup/service management script
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
~/startup/
|
||||
├── startup.sh # Main script
|
||||
├── startup # Symlink/wrapper for convenience
|
||||
├── services/ # One .sh per service (the registry)
|
||||
│ ├── mihomo.sh
|
||||
│ └── yacd.sh
|
||||
├── pids/ # Auto-managed PID files
|
||||
└── logs/ # Per-service stdout/stderr logs
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bash ~/startup/startup.sh status # Show all services (running/stopped + PID)
|
||||
bash ~/startup/startup.sh list # List all registered services (name + port + desc)
|
||||
bash ~/startup/startup.sh start # Start all
|
||||
bash ~/startup/startup.sh stop # Stop all
|
||||
bash ~/startup/startup.sh restart mihomo # Restart one
|
||||
bash ~/startup/startup.sh log mihomo # View logs
|
||||
bash ~/startup/startup.sh register nginx # Create template
|
||||
bash ~/startup/startup.sh remove nginx # Delete service
|
||||
bash ~/startup/startup.sh help # Show help
|
||||
```
|
||||
|
||||
`status` vs `list`: `status` shows runtime state (PID, running/stopped), `list` shows a clean registry view (name, port, description).
|
||||
|
||||
If alias is set (`alias startup="bash ~/startup/startup.sh"`), just type `startup status`.
|
||||
|
||||
## Service Definition Format
|
||||
|
||||
Each service is a `.sh` file in `~/startup/services/`. Example (`mihomo.sh`):
|
||||
|
||||
```bash
|
||||
SVC_DESC="Clash/Mihomo 代理引擎"
|
||||
SVC_PORT="7890 (代理) / 9090 (API)"
|
||||
SVC_PID_PATTERN="mihomo -d"
|
||||
SVC_START="cd ~/clash && exec ./mihomo -d config"
|
||||
SVC_HEALTH="curl -s http://127.0.0.1:9090/version"
|
||||
```
|
||||
|
||||
**Required:** `SVC_START` — the command to launch the service (must background itself or use `exec` with the script's `&`).
|
||||
|
||||
**Optional:**
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `SVC_DESC` | Human-readable description (shown in `status`) |
|
||||
| `SVC_PORT` | Port info string (display only) |
|
||||
| `SVC_PID_PATTERN` | `pkill -f` pattern for cleanup of stale processes |
|
||||
| `SVC_HEALTH` | Command that exits 0 when service is ready (used for startup wait) |
|
||||
| `SVC_STOP` | Custom stop command (before SIGTERM) |
|
||||
|
||||
## Registering a New Service
|
||||
|
||||
```bash
|
||||
# Method 1: Use register command (creates template)
|
||||
bash ~/startup/startup.sh register myapp
|
||||
# Then edit ~/startup/services/myapp.sh
|
||||
|
||||
# Method 2: Write directly
|
||||
cat > ~/startup/services/myapp.sh << 'EOF'
|
||||
SVC_DESC="My Application"
|
||||
SVC_PORT="8080"
|
||||
SVC_PID_PATTERN="myapp"
|
||||
SVC_START="cd ~/myapp && exec node server.js"
|
||||
SVC_HEALTH="curl -s http://127.0.0.1:8080/health"
|
||||
EOF
|
||||
```
|
||||
|
||||
## Quick-Start Service Definitions
|
||||
|
||||
Ready-to-use definitions for common services: `skill_view(name="startup-service-manager", file_path="references/common-services.md")`
|
||||
|
||||
## Auto-Start with systemd
|
||||
|
||||
To make startup services auto-start on boot, create a systemd unit:
|
||||
|
||||
```bash
|
||||
cat > /tmp/startup-services.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Startup Services (mihomo, yacd, hermes-dashboard)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=openclaw
|
||||
ExecStart=/home/openclaw/startup/startup.sh start all
|
||||
ExecStop=/home/openclaw/startup/startup.sh stop all
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo cp /tmp/startup-services.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable startup-services.service
|
||||
```
|
||||
|
||||
**Important:** `startup.sh` must have execute permission: `chmod +x ~/startup/startup.sh`
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **PID file stale after crash**: If a service crashes without cleanup, the PID file remains. The script checks `kill -0` so it detects dead PIDs, but `start` will refuse. Fix: `rm ~/startup/pids/<name>.pid` then start again, or just `startup restart <name>` which stops (cleans up) then starts.
|
||||
- **systemd permission denied**: If systemd fails with "Permission denied" when executing startup.sh, run `chmod +x ~/startup/startup.sh`. The script needs execute permission for systemd to run it.
|
||||
- **Multiple processes on same pattern**: `SVC_PID_PATTERN` is used with `pkill -f`, which kills ALL matching processes. If the pattern is too broad (e.g., just `python`), it will kill unrelated processes. Use specific patterns like `http.server 9091`.
|
||||
- **Health check timeout**: Default wait is 10 seconds. If your service takes longer to start, the script still succeeds (PID is alive) but logs "启动成功" before health check passes. For slow-starting services, increase the loop or accept the delay.
|
||||
- **Log rotation**: Logs append (`>>`), never rotate. For long-running production, either add logrotate or periodically truncate: `> ~/startup/logs/mihomo.log`.
|
||||
- **startup.sh vs systemd**: Don't use both for the same service — they'll fight over PIDs. Pick one management system per service.
|
||||
- **⚠️ CRITICAL: systemd cgroup kills child processes on exit**: If `startup.sh restart <service>` is called from inside a systemd oneshot service (e.g., a watchdog timer), the spawned process inherits the service's cgroup. When the oneshot service exits, **systemd kills all processes in the cgroup — including the just-started service**. This creates a death loop: watchdog starts service → watchdog exits → systemd kills service → next watchdog cycle finds service dead → repeat. **Fix**: Services managed by a systemd-timer watchdog MUST run as their own dedicated systemd service. The watchdog should call `systemctl --user restart <service>` instead of `startup.sh restart <service>`. See `skill_view(name="clash-docker-workflow", file_path="references/mihomo-watchdog.md")` for the full diagnosis and fix.
|
||||
- **Alias not available in new terminals**: After adding `alias startup=...` to `.bashrc`, existing terminals need `source ~/.bashrc`. New terminals get it automatically.
|
||||
- **Tracking existing processes**: When a service was started outside startup.sh (manually, or by another system), the PID file won't exist. The script will show "已停止" even though the process is running. Fix: `echo $(pgrep -f "pattern") > ~/startup/pids/<name>.pid`. Or restart through startup.sh to take over management.
|
||||
- **Duplicate instances**: If you manually start a service that's already running via startup.sh, you get two processes competing for the same port. Always check `startup status` before starting. The `stop` command uses both PID file AND `pkill -f SVC_PID_PATTERN` to catch stragglers.
|
||||
- **SVC_HEALTH with curl -w format**: In `eval` context, `curl -w '%{http_code}'` can misbehave because `%` and `{}` are interpreted by the shell. Use double quotes inside: `curl -s ... -o /dev/null -w '"'"'%{http_code}'"'"'` or test with a simpler check like `ss -tlnp | grep -q PORT`.
|
||||
- **Alias in .bashrc**: When adding `alias startup=...` to `.bashrc`, the line `grep -q || echo >> ~/.bashrc` pattern needs the user's approval for writing to .bashrc. Use `write_file` or ask user consent.
|
||||
- **Health watchdog vs service manager**: startup.sh manages start/stop lifecycle but doesn't monitor ongoing health. For services that need continuous health monitoring (e.g., mihomo proxy whose crash cascades to Telegram adapter), use a watchdog pattern — see `skill_view(name="clash-docker-workflow", file_path="references/mihomo-watchdog.md")`.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Common Services
|
||||
|
||||
## mihomo (Clash proxy)
|
||||
```bash
|
||||
SVC_DESC="Clash/Mihomo 代理引擎"
|
||||
SVC_PORT="7890 (代理) / 9090 (API)"
|
||||
SVC_PID_PATTERN="mihomo -d"
|
||||
SVC_START="cd ~/clash && exec ./mihomo -d config"
|
||||
SVC_HEALTH="curl -s http://127.0.0.1:9090/version"
|
||||
```
|
||||
|
||||
## yacd (Clash Web UI)
|
||||
```bash
|
||||
SVC_DESC="Clash Web 管理面板"
|
||||
SVC_PORT="9091"
|
||||
SVC_PID_PATTERN="http.server 9091"
|
||||
SVC_START="cd ~/clash/yacd && exec python3 -m http.server 9091 --bind 0.0.0.0"
|
||||
SVC_HEALTH="curl -s http://127.0.0.1:9091/ -o /dev/null -w '%{http_code}'"
|
||||
```
|
||||
|
||||
## Hermes Gateway
|
||||
```bash
|
||||
SVC_DESC="Hermes 网关 (Telegram/QQ/Webhook)"
|
||||
SVC_PORT="8644"
|
||||
SVC_PID_PATTERN="hermes_cli.main gateway run"
|
||||
SVC_START="cd ~ && exec hermes gateway run 2>&1"
|
||||
SVC_HEALTH="ss -tlnp | grep -q 8644"
|
||||
```
|
||||
|
||||
## Hermes Dashboard
|
||||
```bash
|
||||
SVC_DESC="Hermes 仪表盘 (Web UI)"
|
||||
SVC_PORT="9119"
|
||||
SVC_PID_PATTERN="hermes dashboard"
|
||||
SVC_START="cd ~ && exec hermes dashboard --host 192.168.199.241 --insecure 2>&1"
|
||||
SVC_HEALTH="curl -s http://192.168.199.241:9119/ -o /dev/null -w '%{http_code}'"
|
||||
```
|
||||
|
||||
## OpenClaw Gateway
|
||||
```bash
|
||||
SVC_DESC="OpenClaw 旧网关"
|
||||
SVC_PORT="18789"
|
||||
SVC_PID_PATTERN="openclaw/dist/index.js gateway"
|
||||
SVC_START="/www/server/nodejs/v22.20.0/bin/node /home/openclaw/.npm-global/lib/node_modules/openclaw/dist/index.js gateway --port 18789 2>&1"
|
||||
SVC_HEALTH="curl -s http://127.0.0.1:18789/ -o /dev/null -w '%{http_code}'"
|
||||
```
|
||||
|
||||
## Node.js app
|
||||
```bash
|
||||
SVC_DESC="My Node App"
|
||||
SVC_PORT="3000"
|
||||
SVC_PID_PATTERN="node.*server.js"
|
||||
SVC_START="cd ~/myapp && exec node server.js"
|
||||
SVC_HEALTH="curl -s http://127.0.0.1:3000/health"
|
||||
```
|
||||
|
||||
## Python Flask/FastAPI
|
||||
```bash
|
||||
SVC_DESC="Python Web App"
|
||||
SVC_PORT="5000"
|
||||
SVC_PID_PATTERN="uvicorn main:app"
|
||||
SVC_START="cd ~/myapp && exec uvicorn main:app --host 0.0.0.0 --port 5000"
|
||||
SVC_HEALTH="curl -s http://127.0.0.1:5000/docs"
|
||||
```
|
||||
@@ -0,0 +1,557 @@
|
||||
---
|
||||
name: telegram-message-forwarder
|
||||
description: >
|
||||
Deploy and manage Telegram message forwarding between channels/groups.
|
||||
Use when user asks to forward messages from one TG channel to another,
|
||||
set up a message relay, monitor channels, or says "消息转发器", "bind频道",
|
||||
"转发到我的群".
|
||||
trigger:
|
||||
- telegram forwarder
|
||||
- 消息转发器
|
||||
- bind频道
|
||||
- 转发频道
|
||||
- channel forwarder
|
||||
- message relay
|
||||
---
|
||||
|
||||
# Telegram Message Forwarder
|
||||
|
||||
Deploy Heavrnl/TelegramForwarder — the most feature-rich open-source TG forwarder (1144+ ⭐).
|
||||
|
||||
## Why This One
|
||||
|
||||
| Project | Stars | Bind Command | GUI | Filters | AI | Docker |
|
||||
|---------|-------|-------------|-----|---------|-----|--------|
|
||||
| **Heavrnl/TelegramForwarder** | 1144 | ✅ `/bind` | ✅ Web + Inline KB | ✅ Keyword/Regex | ✅ Multi-provider | ✅ |
|
||||
| aahnik/tgcf | 1600 | ❌ Config-only | ✅ Web | ✅ Plugin-based | ❌ | ✅ |
|
||||
| MrMissx/Telegram_Forwarder | 636 | ❌ JSON config | ❌ | ✅ Basic | ❌ | ❌ |
|
||||
| GentlemanHu/TelegramForwarder | 36 | ❌ Inline KB only | ✅ Inline KB | ✅ Basic | ❌ | ❌ |
|
||||
|
||||
**Heavrnl wins** because it has `/bind` command, inline keyboard GUI, regex replace, AI processing, RSS, and multi-platform push (Apprise).
|
||||
|
||||
## Quick Deploy (Docker)
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/Heavrnl/TelegramForwarder.git
|
||||
cd TelegramForwarder
|
||||
cp .env.example .env
|
||||
# Edit .env with required values (see below)
|
||||
docker-compose run -it telegram-forwarder # first run: interactive for phone verification
|
||||
# CTRL+C after verification
|
||||
# Edit docker-compose.yml: set stdin_open: false, tty: false
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**⚠️ ghcr.io pulls often fail with "unexpected EOF"** on this server. Use Chinese Docker mirrors:
|
||||
- `docker.1ms.run` — 1ms acceleration (already configured in `/etc/docker/daemon.json`)
|
||||
- `ghcr.nju.edu.cn` — NJU mirror for ghcr.io images
|
||||
If pulls still fail, retry once (transient network issues) or use `network_mode: host` with Mihomo proxy.
|
||||
|
||||
## Required .env Values
|
||||
|
||||
| Variable | Source | Notes |
|
||||
|----------|--------|-------|
|
||||
| `API_ID` | https://my.telegram.org/apps | Same as TG_API_ID in .bashrc |
|
||||
| `API_HASH` | https://my.telegram.org/apps | Same as TG_API_HASH in .bashrc |
|
||||
| `BOT_TOKEN` | @BotFather `/newbot` | Must be DIFFERENT from Hermes bot token |
|
||||
| `PHONE_NUMBER` | User's TG phone | Format: `+86...` |
|
||||
| `USER_ID` | @userinfobot | Numeric user ID |
|
||||
|
||||
**⚠️ BOT_TOKEN conflict**: Cannot reuse Hermes's bot token. Need a separate bot from @BotFather.
|
||||
|
||||
## Usage (Bot Commands)
|
||||
|
||||
```
|
||||
/bind https://t.me/channel_name # Bind source channel to current group
|
||||
/bind "频道名称" # Bind by name
|
||||
/settings # Open settings GUI (inline keyboard)
|
||||
/add keyword1 keyword2 # Add filter keywords
|
||||
/remove_keyword keyword1 # Remove keywords
|
||||
/replace regex_pattern # Add regex replacement
|
||||
/list_keyword # List current keywords
|
||||
/list_replace # List current replacements
|
||||
```
|
||||
|
||||
### Workflow
|
||||
1. Create a Telegram group/channel as the destination
|
||||
2. Add the bot to that group as admin
|
||||
3. In the group, send `/bind https://t.me/source_channel`
|
||||
4. Configure filters via `/settings` or `/add`
|
||||
5. Messages auto-forward with filters applied
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-source**: Bind multiple sources to one destination
|
||||
- **Keyword filter**: Whitelist/blacklist mode
|
||||
- **Regex replace**: Clean up formatting (e.g., remove `**`, fix links)
|
||||
- **Media filter**: Filter by type (photo, video, audio, document)
|
||||
- **AI processing**: Use OpenAI/Claude/Gemini/DeepSeek/Qwen for message rewriting
|
||||
- **RSS**: Generate RSS feed from forwarded messages
|
||||
- **Apprise push**: Forward to email, Slack, Discord, webhooks, etc.
|
||||
- **Sync rules**: Mirror settings across multiple bind rules
|
||||
- **Time-based filter**: Forward only during specific hours
|
||||
|
||||
## Proxy Setup (Required for This Server)
|
||||
|
||||
This server **cannot reach Telegram directly**. Telethon (used by TelegramForwarder) needs explicit SOCKS5 proxy configuration — standard `HTTP_PROXY` env vars are NOT enough for MTProto connections.
|
||||
|
||||
### Step-by-step
|
||||
|
||||
1. **Patch `main.py`** to add SOCKS5 proxy support for Telethon:
|
||||
|
||||
```python
|
||||
# Add after 'from telethon import TelegramClient, types':
|
||||
try:
|
||||
import socks
|
||||
HAS_SOCKS = True
|
||||
except ImportError:
|
||||
HAS_SOCKS = False
|
||||
|
||||
# Replace client creation:
|
||||
proxy_config = None
|
||||
if os.getenv('HTTP_PROXY') or os.getenv('http_proxy'):
|
||||
proxy_addr = os.getenv('HTTP_PROXY') or os.getenv('http_proxy')
|
||||
import re
|
||||
m = re.match(r'https?://([\d.]+):(\d+)', proxy_addr)
|
||||
if m and HAS_SOCKS:
|
||||
proxy_host, proxy_port = m.group(1), int(m.group(2))
|
||||
proxy_config = (socks.SOCKS5, proxy_host, proxy_port)
|
||||
|
||||
user_client = TelegramClient('./sessions/user', api_id, api_hash, proxy=proxy_config)
|
||||
bot_client = TelegramClient('./sessions/bot', api_id, api_hash, proxy=proxy_config)
|
||||
```
|
||||
|
||||
2. **Create `Dockerfile.custom`** (base image lacks PySocks):
|
||||
|
||||
```dockerfile
|
||||
FROM heavrnl/telegramforwarder:latest
|
||||
RUN pip install --no-cache-dir PySocks
|
||||
COPY main.py /app/main.py
|
||||
```
|
||||
|
||||
3. **Update `docker-compose.yml`** — use `network_mode: host` + proxy env:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
telegram-forwarder:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.custom
|
||||
container_name: telegram-forwarder
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
- HTTP_PROXY=http://127.0.0.1:7890
|
||||
- HTTPS_PROXY=http://127.0.0.1:7890
|
||||
- http_proxy=http://127.0.0.1:7890
|
||||
- https_proxy=http://127.0.0.1:7890
|
||||
- NO_PROXY=localhost,127.0.0.1
|
||||
volumes:
|
||||
- ./db:/app/db
|
||||
- ./.env:/app/.env
|
||||
- ./logs:/app/logs
|
||||
- ./sessions:/app/sessions
|
||||
- ./temp:/app/temp
|
||||
- ./ufb/config:/app/ufb/config
|
||||
- ./config:/app/config
|
||||
- ./rss/data:/app/rss/data
|
||||
- ./rss/media:/app/rss/media
|
||||
stdin_open: true
|
||||
tty: true
|
||||
```
|
||||
|
||||
4. **Build and run**:
|
||||
```bash
|
||||
docker-compose build
|
||||
docker-compose run --rm telegram-forwarder # interactive for phone code
|
||||
# After verification, set stdin_open: false, tty: false
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**⚠️ Phone verification should be done by the user via SSH**, not by the agent. Verification codes expire in ~30 seconds, and agent latency through `process(action='submit')` is too slow. Tell the user to SSH in and run the interactive command themselves. If 2FA is enabled, they'll also need their Telegram two-step verification password.
|
||||
|
||||
### Pitfalls
|
||||
|
||||
- **🔴 CRITICAL: Competing gateway processes steal bot token polling** — If another process (old OpenClaw Gateway, duplicate Hermes instance, test script) is using the same Telegram bot token for `getUpdates`, the Hermes adapter will get stuck in permanent polling conflict. **Symptom**: Gateway shows `telegram: connected` in state, but `getUpdates` via API returns 0 results WITHOUT a 409 conflict (meaning no one is actually polling). No "✓ telegram connected" in logs. Repeated `Telegram polling conflict (1/5)` warnings. Group messages NEVER processed. **Root cause**: Two processes share the same bot token — Telegram only allows ONE `getUpdates` session per token. **Diagnosis**: `ps aux | grep gateway | grep -v grep` — look for stale processes (e.g., old `openclaw ... gateway --port 18789` from before migration to Hermes). **Fix**: Kill the stale process first, THEN restart Hermes: `kill <stale_pid> && systemctl --user restart hermes-gateway`. **Also check**: `getUpdates` returning 0 without 409 conflict means NO adapter is polling — this is the key diagnostic signal. If you get 409, the adapter IS polling (normal). If you get 0, nobody is polling (broken).
|
||||
- **🔴 CRITICAL: Gateway restart causes Telegram polling conflict** — When `hermes gateway restart` is called, the old Telegram polling session may NOT release on Telegram's servers. The new adapter gets stuck in a permanent conflict loop: `Conflict: terminated by other getUpdates request; make sure that only one bot instance is running`. **Symptom**: Gateway restarts but group messages are never processed, zero Telegram adapter logs. **Root cause**: `hermes gateway restart` sends SIGTERM → systemd restarts immediately → old polling session still active → new adapter can't connect. **Fix**: NEVER use `hermes gateway restart`. Instead, use the three-step process:
|
||||
```bash
|
||||
systemctl --user stop hermes-gateway # stop
|
||||
sleep 30 # wait for Telegram session to expire
|
||||
systemctl --user start hermes-gateway # start fresh
|
||||
```
|
||||
Or from inside the gateway, kill the PID to trigger systemd auto-restart:
|
||||
```bash
|
||||
kill <gateway_pid> # SIGTERM, systemd Restart=always kicks in
|
||||
```
|
||||
But even the kill approach may hit the conflict if systemd restarts too fast. The safest approach is stop → wait → start. **This bug means group messages may have NEVER worked** — every time the user restarted the gateway, the conflict silently killed the adapter.
|
||||
- **Group migrated to supergroup (silent failure)**: Telegram sometimes migrates a basic group to a supergroup, changing its ID. The old chat ID becomes `deactivated=True` and forwarding fails silently with `An invalid Peer was used... bots cannot start conversations`. To diagnose: `bot.get_entity(old_id)` — the returned object will have `deactivated=True` and `migrated_to=InputChannel(channel_id=NEW_ID, access_hash=...)`. The new supergroup ID in Telethon format is `-100` + `channel_id` (e.g., channel_id=3966251111 → `-1003966251111`). Update BOTH the forwarder DB AND Hermes's `free_response_channels` in `config.yaml`. If `hermes gateway restart` is refused from inside the gateway, ask the user to run it from a separate shell.
|
||||
- **Bot-to-bot messaging FAILS**: If the forwarding target is a bot (e.g., Hermes Bot), Telethon throws `Bots can't send messages to other bots`. Telegram prohibits bot→bot messaging. **Fix**: Forward to the GROUP where the Hermes bot resides, NOT directly to the bot. The bot reads messages from the group as a member. If there's no group yet, create one, add both bots, and forward there. The `chats` table column is `telegram_chat_id` (NOT `chat_id`).
|
||||
- **Bot Privacy Mode blocks group messages (CRITICAL)**: Telegram bots have **privacy mode ON by default**. A bot with privacy mode can only see: (1) commands (/start), (2) messages that @mention it, (3) replies to its messages. It CANNOT see regular group messages — even with `free_response_channels` configured in Hermes. **Symptom**: Forwarder sends messages to the group successfully, but Hermes never responds. **Fix**: Open @BotFather → `/mybots` → select the Hermes bot → **Bot Settings** → **Group Privacy** → **Turn off**. **After turning off privacy mode, kick the bot from the group and re-add it** — the change doesn't apply to bots already in a group. Verify with API: `can_read_all_group_messages` must be `true`. Must be done for BOTH the Hermes bot AND the forwarder bot if both need to read group messages.
|
||||
- **Testing: use human account, not bot**: When testing the forwarding → Hermes chain, send test messages from a **human Telegram account**, NOT from the forwarder bot. Hermes may filter or ignore messages sent by other bots in groups. Use the Telethon **user client** (`sessions/user`) to send test messages to the source channel, or have the user manually post in the group. Bot-sent test messages can falsely appear as "Hermes not receiving" when the real issue is bot-message filtering.
|
||||
- **Telethon ignores HTTP_PROXY**: Telethon uses raw TCP sockets for MTProto, not HTTP. You MUST configure proxy at the Telethon client level via the `proxy` parameter. The `HTTP_PROXY` env var alone does NOT work.
|
||||
- **`network_mode: host`** lets the container reach `127.0.0.1:7890` on the host. Without it, you'd need `extra_hosts: ["host.docker.internal:host-gateway"]` and use that hostname instead.
|
||||
- **Mihomo mixed-port 7890** supports both HTTP and SOCKS5 on the same port. Use SOCKS5 for Telethon.
|
||||
- **Session database locked**: If you see `sqlite3.OperationalError: database is locked`, clean `./sessions/*.session*` files and retry. Caused by unclean shutdown.
|
||||
- **git clone needs proxy**: `http_proxy=http://127.0.0.1:7890 https_proxy=http://127.0.0.1:7890 git clone ...`
|
||||
- **Phone verification**: First run MUST be interactive (`docker-compose run --rm`, not `up -d`). The code is sent to the user's Telegram. After verification, session file persists in `./sessions/`.
|
||||
- **Custom image required**: Modifying `requirements.txt` locally doesn't affect the pre-built Docker image. You must build a custom image with `Dockerfile.custom` that installs PySocks AND copies the patched `main.py`.
|
||||
|
||||
### Hermes Bot Integration (Forwarded Messages → Agent Processing)
|
||||
|
||||
When the user wants forwarded messages to be **processed by Hermes** (e.g., auto-trade on signals), set up the forwarding chain:
|
||||
|
||||
1. **Create a destination group** (e.g., "Telegram消息转发") — this is the meeting point
|
||||
2. **Add the forwarder bot** (MsgForwarder) to the group as admin
|
||||
3. **Add the Hermes bot** to the group as admin
|
||||
4. **⚠️ DISABLE PRIVACY MODE on the Hermes bot** via @BotFather → `/mybots` → select bot → Bot Settings → Group Privacy → Turn off. **This step is mandatory** — without it, Hermes cannot see group messages and `free_response_channels` has no effect. **After disabling privacy mode, you MUST kick the bot from the group and re-add it** — the privacy mode change does not take effect for bots already in a group. Verify with `getMe` API: `can_read_all_group_messages` must be `true`.
|
||||
5. **Configure `free_response_chats`** in `~/.hermes/config.yaml`:
|
||||
```yaml
|
||||
telegram:
|
||||
extra:
|
||||
free_response_chats: '<destination_group_chat_id>'
|
||||
```
|
||||
This lets Hermes respond to messages in that group **without requiring @mention**.
|
||||
6. **Restart Hermes** for config to take effect. Run `hermes gateway restart` from a separate shell (not from inside the gateway).
|
||||
|
||||
**⚠️ CRITICAL PITFALL**: The config key is `free_response_chats` inside `telegram.extra`, NOT `free_response_channels` at the top level! The Hermes source code reads `self.config.extra.get("free_response_chats")`. Wrong key = silently ignored, bot only responds to @mentions.
|
||||
|
||||
```yaml
|
||||
telegram:
|
||||
extra:
|
||||
free_response_chats: '-100XXXXXXXXXX' # ← correct key
|
||||
# free_response_channels: '...' ← WRONG, ignored by code
|
||||
```
|
||||
|
||||
**⚠️ Pitfall**: `require_mention: true` (default) means Hermes ignores group messages unless @mentioned. Without correct `free_response_chats`, forwarded messages are silently ignored.
|
||||
|
||||
**⚠️ Pitfall**: After changing bot privacy mode in BotFather, you must kick the bot from the group and re-add it for the change to take effect.
|
||||
|
||||
**⚠️ Pitfall**: Don't reuse the Hermes bot token as the forwarder's BOT_TOKEN — they'll conflict. Use a separate @BotFather bot for the forwarder.
|
||||
|
||||
**⚠️ Pitfall**: If the destination group was ever migrated from a basic group to a supergroup, the chat ID changed. See "Group migrated to supergroup" pitfall above. Update BOTH the forwarder DB and `free_response_channels`.
|
||||
|
||||
### End-to-End Verification
|
||||
|
||||
After setting up the forwarding → Hermes chain, verify each link:
|
||||
|
||||
1. **Forwarder listening**: `docker logs telegram-forwarder 2>&1 | grep "收到新消息"` — should show messages from source channel
|
||||
2. **Forwarder sending**: `docker logs telegram-forwarder 2>&1 | grep "已发送"` — should show successful sends to target group
|
||||
3. **Hermes receiving**: `hermes logs 2>&1 | grep "inbound message"` — should show messages from the group (chat ID should match the group, not the user DM)
|
||||
4. **Hermes responding**: Check the group for Hermes's reply
|
||||
|
||||
**Common failure chain**:
|
||||
- Forwarder logs show "收到新消息" but no "已发送" → keyword filter issue or invalid Peer (check group ID)
|
||||
- Forwarder logs show "已发送" but Hermes shows no inbound from group → privacy mode still on, or `free_response_channels` wrong
|
||||
- Hermes shows inbound but no response → check `require_mention` and `free_response_channels`
|
||||
|
||||
**⚠️ For testing, use a human account** (not the forwarder bot) to send messages to the source channel or directly to the group. Bot-sent messages may be filtered by Hermes.
|
||||
|
||||
**⚠️ STRICT REGEX SILENTLY BLOCKS ALL SIGNALS (2026-06-25 discovered)**: If the whitelist keyword is a strict regex like `【币种】[\\s\\S]*【方向】[\\s\\S]*【仓位】`, ANY message that doesn't contain ALL those keywords in order gets silently dropped. The forwarder logs show "未匹配到普通白名单关键词,不转发" but there's NO alert to the user. **This happened in production** — 39 signals were blocked over 8 hours before the user noticed. **Root cause**: the source channel changed its message format (added `【field】: value` with colons, changed trader name format), but the forwarder filter still required the old keyword pattern. **Lesson**: Always use `.*` for agent-side filtering. If you must filter at the forwarder level, add monitoring: periodically check `docker logs telegram-forwarder | grep -c "未匹配"` to detect mass-blocked messages.
|
||||
|
||||
**⚠️ Agent-Side Filtering Pattern (Verified 2026-06-25 in production)**: Set forwarder to pass ALL messages (`.*` whitelist) and let the Hermes agent classify via `channel_prompts`. This session proved this pattern works: forwarder passes everything, agent prompt classifies messages into A(交易信号)/B(确认取消)/C(平仓)/D(非交易) and only processes A/B/C. If source format changes, only the agent prompt needs updating, not the forwarder database. Full template in `okx-auto-position` skill's `references/channel-prompts-template.md`.
|
||||
|
||||
**⚠️ Privacy mode off but bot still can't see group messages**: If `getMe` shows `can_read_all_group_messages: true` but Hermes still doesn't receive group messages after re-adding the bot and restarting the gateway, try these steps in order:
|
||||
1. Verify the bot is actually IN the group: use `iter_participants` via Telethon or `getChatMember` API
|
||||
2. Check `allowed_chats` in Hermes config — if set, the group ID must be listed
|
||||
3. Have a **human** (not a bot) send a message in the group — bot-to-bot messages are filtered by Telegram itself
|
||||
4. Try `@Mike94_HermesBot hello` (explicit @mention) to test if the bot receives mentioned messages at all
|
||||
5. If @mentions work but regular messages don't, the issue is likely a Telegram API propagation delay for the privacy mode change — wait 5-10 minutes and try again
|
||||
6. As a last resort, create a brand new bot via @BotFather with privacy mode off from the start
|
||||
|
||||
### Direct Database Keyword Management
|
||||
|
||||
When bot commands aren't accessible (e.g., from agent), manage keywords directly in SQLite.
|
||||
|
||||
**⚠️ `sqlite3` CLI is NOT available in the container.** Always use Python's `sqlite3` module inside the container, OR copy the DB out to host:
|
||||
|
||||
**Option A: Edit from host (recommended for complex changes)**:
|
||||
```bash
|
||||
docker cp telegram-forwarder:/app/db/forward.db /tmp/forward.db
|
||||
sqlite3 /tmp/forward.db "DELETE FROM keywords WHERE rule_id=1 AND is_blacklist=0;"
|
||||
sqlite3 /tmp/forward.db "INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, '.*', 1, 0);"
|
||||
docker cp /tmp/forward.db telegram-forwarder:/app/db/forward.db
|
||||
docker restart telegram-forwarder
|
||||
```
|
||||
|
||||
**Option B: Edit inside container (Python)**:
|
||||
```bash
|
||||
docker exec telegram-forwarder python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/db/forward.db')
|
||||
c = conn.cursor()
|
||||
c.execute('INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, \\\"未知\\\", 0, 1)')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
"
|
||||
docker restart telegram-forwarder
|
||||
```
|
||||
|
||||
**⚠️ Option A pitfall**: `docker cp` out while container is running works for READING, but WRITING back may give `attempt to write a readonly database` if SQLite WAL mode is active. Safer to stop the container first, copy back, then start. Or use `docker exec` with Python (Option B) which handles the lock correctly.
|
||||
|
||||
**⚠️ The DB file is locked while the container runs.** If you need to edit from the host (not via `docker exec`), stop the container first: `docker stop telegram-forwarder`, edit `./db/forward.db`, then `docker start telegram-forwarder`. Writing from host while container is up gives `attempt to write a readonly database`.
|
||||
|
||||
**DB Schema**:
|
||||
- `forward_rules` — source→dest mappings, filter modes, templates
|
||||
- `keywords` — `(id, rule_id, keyword, is_regex, is_blacklist)` where `is_blacklist=1` means skip matching messages, `is_regex=1` enables regex mode
|
||||
- `replace_rules` — regex find/replace patterns
|
||||
- `chats` — `(id, telegram_chat_id, name, current_add_id)` — column is `telegram_chat_id`, NOT `chat_id`
|
||||
- **Multiple white keywords = OR logic** (any match triggers forwarding). For AND (all must match), use a single regex. See "Whitelist Regex" section below.
|
||||
|
||||
### Whitelist Regex: Let Everything Through (Recommended for Agent-Side Filtering)
|
||||
|
||||
**When the downstream agent (Hermes) handles message classification**, set the forwarder to pass ALL messages and let the agent decide what to process. This is more robust than maintaining regex patterns in the forwarder — if the source channel's message format changes, you only need to update the agent's prompt, not the forwarder's database.
|
||||
|
||||
```bash
|
||||
# Set whitelist regex to .*=match everything
|
||||
docker cp telegram-forwarder:/app/db/forward.db /tmp/forward.db
|
||||
sqlite3 /tmp/forward.db "DELETE FROM keywords WHERE rule_id=1 AND is_blacklist=0;"
|
||||
sqlite3 /tmp/forward.db "INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, '.*', 1, 0);"
|
||||
docker cp /tmp/forward.db telegram-forwarder:/app/db/forward.db
|
||||
docker restart telegram-forwarder
|
||||
```
|
||||
|
||||
**Agent-Side Filtering** via `channel_prompts` in Hermes config.yaml:
|
||||
```
|
||||
A类(交易信号) → 处理
|
||||
B类(确认/取消) → 处理
|
||||
C类(平仓) → 处理
|
||||
D类(非交易消息) → 忽略,不回复
|
||||
```
|
||||
|
||||
**Cross-platform push from channel_prompts** (`send_message` is NOT agent-callable — use `hermes send` instead):
|
||||
|
||||
The Hermes internal `send_message` tool is **intentionally NOT registered as an agent-callable model tool**. Old channel_prompts that reference `send_message` will silently fail. The correct approach is to use `terminal("hermes send -t qqbot 'message'")` in the prompt:
|
||||
|
||||
```yaml
|
||||
telegram:
|
||||
channel_prompts:
|
||||
'-1003966251111': '你是交易信号处理器。解析【币种】【方向】等字段。然后通过 terminal("hermes send -t qqbot '"'"'信号摘要+建议操作'"'"'") 推送到QQ。不要在群里回复。'
|
||||
```
|
||||
|
||||
See the `cross-platform-push` skill for full details on `hermes send` vs cronjob deliver methods.
|
||||
|
||||
### Whitelist Regex: Require Multiple Keywords (AND Logic — Only If Filtering at Forwarder Level)
|
||||
|
||||
**⚠️ Only use this approach if you need to filter at the forwarder level.** For agent-side filtering, use `.*` above.
|
||||
|
||||
To forward ONLY messages containing ALL of several keywords (e.g., 【币种】【方向】【仓位】):
|
||||
|
||||
**⚠️ CRITICAL PITFALL: Do NOT use lookahead syntax `(?=.*A)(?=.*B)(?=.*C)` with Chinese brackets `【】`!**
|
||||
|
||||
The TelegramForwarder regex engine fails silently on lookahead patterns with Chinese full-width brackets. The log shows "未匹配到普通白名单关键词" even though the keywords are clearly present. Use **sequential matching** instead:
|
||||
|
||||
```bash
|
||||
docker exec telegram-forwarder python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/db/forward.db')
|
||||
c = conn.cursor()
|
||||
|
||||
# Switch rule to WHITELIST mode
|
||||
c.execute(\"UPDATE forward_rules SET forward_mode='WHITELIST', add_mode='WHITELIST' WHERE id=1\")
|
||||
|
||||
# Clear old keywords
|
||||
c.execute('DELETE FROM keywords WHERE rule_id = 1')
|
||||
|
||||
# ✅ CORRECT: Sequential match with [\s\S]* (matches newlines)
|
||||
regex = '【币种】[\\s\\S]*【方向】[\\s\\S]*【仓位】'
|
||||
c.execute('INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, ?, 1, 0)', (regex,))
|
||||
|
||||
# Add blacklist: exclude messages with '未知'
|
||||
c.execute(\"INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (1, '未知', 0, 1)\")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
\"
|
||||
docker restart telegram-forwarder
|
||||
```
|
||||
|
||||
**Key rules**:
|
||||
|
||||
| Approach | Pattern | Result |
|
||||
|----------|---------|--------|
|
||||
| ❌ Lookahead (broken) | `(?=.*【币种】)(?=.*【方向】)(?=.*【仓位】)` | 未匹配,不转发 |
|
||||
| ✅ Sequential (works) | `【币种】[\s\S]*【方向】[\s\S]*【仓位】` | 匹配成功 |
|
||||
| ❌ Dot-star (newline issue) | `【币种】.*【方向】.*【仓位】` | 不匹配跨行消息 |
|
||||
|
||||
**Why `[\s\S]*` not `.*`**: Messages have newlines between fields. `.` doesn't match `\n` by default. `[\s\S]` matches any character including newlines.
|
||||
|
||||
**Why not separate keywords**: Multiple white keywords in the `keywords` table are evaluated with **OR logic** — matching ANY one keyword triggers forwarding. To require ALL keywords, you MUST use a single regex pattern. See the filter chain source at `/app/utils/common.py` lines ~428-460.
|
||||
|
||||
**Keyword order matters**: The sequential pattern assumes keywords appear in a fixed order (e.g., 币种→方向→仓位). If the source channel always formats messages in the same order (which trading signal channels typically do), this is fine. If order varies, you'd need multiple regex patterns joined with `|` for each permutation.
|
||||
|
||||
## Adding a New Source Channel (Post-Deploy)
|
||||
|
||||
When you need to bind an additional Telegram source to an existing forwarder (same target group), here is the complete process:
|
||||
|
||||
### Step-by-Step
|
||||
|
||||
**1. Resolve the entity** (find its Telegram chat ID):
|
||||
|
||||
Run INSIDE the container while it is running (entity resolution does not need the DB):
|
||||
|
||||
```bash
|
||||
docker exec telegram-forwarder python3 -c "
|
||||
import os, asyncio, socks
|
||||
os.environ['API_ID'] = '37679203'
|
||||
os.environ['API_HASH'] = '514a2ba8b7898365c9070da4739e8deb'
|
||||
from telethon import TelegramClient
|
||||
from telethon.tl.functions.channels import JoinChannelRequest
|
||||
|
||||
async def resolve():
|
||||
client = TelegramClient('./sessions/user', int(os.environ['API_ID']), os.environ['API_HASH'],
|
||||
proxy=(socks.SOCKS5, '127.0.0.1', 7890))
|
||||
await client.start()
|
||||
entity = await client.get_entity('shipanbtc') # ← replace with target username
|
||||
print(f'type={type(entity).__name__} id={entity.id} title={getattr(entity, \"title\", \"\")}')
|
||||
await client(JoinChannelRequest(entity))
|
||||
print('Joined OK')
|
||||
await client.disconnect()
|
||||
|
||||
asyncio.run(resolve())
|
||||
"
|
||||
```
|
||||
|
||||
Output example: `type=Channel id=3432963646 title=链上监控 | 魏神 麻吉 内幕哥`
|
||||
|
||||
**⚠️ Proxy format**: Telethon needs `(socks.SOCKS5, '127.0.0.1', 7890)` — use `socks.SOCKS5`, NOT the raw number `5`.
|
||||
|
||||
**2. Stop container** (to release session DB lock):
|
||||
|
||||
```bash
|
||||
docker stop telegram-forwarder
|
||||
```
|
||||
|
||||
**⚠️ Session DB lock**: Trying to create a Telethon client that uses the same session file while the container is running fails with `database is locked`. Always stop the container before making DB changes, or resolve the entity first (which can run while the container is up).
|
||||
|
||||
**3. Add chat entry + forward rule**:
|
||||
|
||||
```bash
|
||||
docker start telegram-forwarder # needed so docker exec works
|
||||
docker exec telegram-forwarder python3 -c "
|
||||
import os, asyncio, socks, sqlite3
|
||||
os.environ['API_ID'] = '37679203'
|
||||
os.environ['API_HASH'] = '514a2ba8b7898365c9070da4739e8deb'
|
||||
from telethon import TelegramClient
|
||||
from telethon.tl.functions.channels import JoinChannelRequest
|
||||
|
||||
async def resolve_and_bind():
|
||||
client = TelegramClient('./sessions/user', int(os.environ['API_ID']), os.environ['API_HASH'],
|
||||
proxy=(socks.SOCKS5, '127.0.0.1', 7890))
|
||||
await client.start()
|
||||
|
||||
entity = await client.get_entity('shipanbtc') # ← replace
|
||||
try: await client(JoinChannelRequest(entity))
|
||||
except: pass
|
||||
entity2 = await client.get_entity('shipanbtc')
|
||||
entity_id_str = str(entity2.id)
|
||||
print(f'Entity: id={entity_id_str} title={getattr(entity2, \"title\", \"\")}')
|
||||
|
||||
# Update DB
|
||||
conn = sqlite3.connect('/app/db/forward.db')
|
||||
c = conn.cursor()
|
||||
existing = c.execute('SELECT id FROM chats WHERE telegram_chat_id=?', (entity_id_str,)).fetchone()
|
||||
if existing:
|
||||
new_chat_id = existing[0]
|
||||
print(f'Chat exists: id={new_chat_id}')
|
||||
else:
|
||||
max_id = c.execute('SELECT MAX(id) FROM chats').fetchone()[0] or 0
|
||||
new_chat_id = max_id + 1
|
||||
c.execute('INSERT INTO chats (id, telegram_chat_id, name) VALUES (?, ?, ?)',
|
||||
(new_chat_id, entity_id_str, getattr(entity2, 'title', 'New Source')))
|
||||
print(f'Inserted chat: id={new_chat_id}')
|
||||
|
||||
existing_rule = c.execute('SELECT id FROM forward_rules WHERE source_chat_id=? AND target_chat_id=2', (new_chat_id,)).fetchone()
|
||||
if existing_rule:
|
||||
print(f'Rule exists: id={existing_rule[0]}')
|
||||
else:
|
||||
c.execute('PRAGMA table_info(forward_rules)')
|
||||
cols = [r[1] for r in c.fetchall()]
|
||||
existing = dict(zip(cols, c.execute('SELECT * FROM forward_rules WHERE id=1').fetchone()))
|
||||
insert_cols = [k for k in cols if k != 'id']
|
||||
vals = [existing[k] if k != 'source_chat_id' else new_chat_id for k in insert_cols]
|
||||
col_str = ', '.join(insert_cols); ph = ', '.join(['?'] * len(vals))
|
||||
c.execute(f'INSERT INTO forward_rules ({col_str}) VALUES ({ph})', vals)
|
||||
new_rule_id = c.lastrowid
|
||||
for kw in c.execute('SELECT keyword, is_regex, is_blacklist FROM keywords WHERE rule_id=1').fetchall():
|
||||
c.execute('INSERT INTO keywords (rule_id, keyword, is_regex, is_blacklist) VALUES (?, ?, ?, ?)',
|
||||
(new_rule_id, kw[0], kw[1], kw[2]))
|
||||
print(f'Created rule id={new_rule_id}')
|
||||
|
||||
conn.commit(); conn.close()
|
||||
print('Done')
|
||||
await client.disconnect()
|
||||
|
||||
asyncio.run(resolve_and_bind())
|
||||
"
|
||||
docker stop telegram-forwarder
|
||||
```
|
||||
|
||||
**4. Restart**:
|
||||
|
||||
```bash
|
||||
docker start telegram-forwarder
|
||||
sleep 3
|
||||
docker logs telegram-forwarder --tail 5
|
||||
# Expect: "用户客户端已启动" + "机器人客户端已启动"
|
||||
```
|
||||
|
||||
**5. Verify**:
|
||||
|
||||
```bash
|
||||
docker exec telegram-forwarder python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/db/forward.db')
|
||||
c = conn.cursor()
|
||||
for r in c.execute('''SELECT fr.id, c1.name as source, c2.name as target, fr.enable_rule
|
||||
FROM forward_rules fr LEFT JOIN chats c1 ON fr.source_chat_id=c1.id
|
||||
LEFT JOIN chats c2 ON fr.target_chat_id=c2.id''').fetchall():
|
||||
print(f'{\"✅\" if r[3] else \"❌\"} Rule#{r[0]}: {r[1]} -> {r[2]}')
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
### DB Schema (Forward Rules)
|
||||
|
||||
```sql
|
||||
chats: (id INTEGER, telegram_chat_id VARCHAR, name VARCHAR, current_add_id VARCHAR)
|
||||
forward_rules: (id INTEGER, source_chat_id INTEGER->chats.id, target_chat_id INTEGER->chats.id,
|
||||
forward_mode VARCHAR, add_mode VARCHAR, enable_rule BOOLEAN, ...)
|
||||
keywords: (id INTEGER, rule_id INTEGER->forward_rules.id, keyword TEXT,
|
||||
is_regex BOOLEAN, is_blacklist BOOLEAN)
|
||||
```
|
||||
|
||||
Key relationships:
|
||||
- `forward_rules.source_chat_id` → `chats.id` (not `telegram_chat_id` directly)
|
||||
- `forward_rules.target_chat_id` → `chats.id`
|
||||
- `keywords.rule_id` → `forward_rules.id`
|
||||
- `chats.telegram_chat_id` holds the resolved numeric entity ID (e.g. `3805472665`)
|
||||
- Public channels: positive integers, Groups/supergroups: negative (e.g. `-1003966251111`)
|
||||
|
||||
### Alternative: Direct DB Edit (Container Stopped)
|
||||
|
||||
When you already know the entity ID:
|
||||
|
||||
```bash
|
||||
docker stop telegram-forwarder
|
||||
docker cp telegram-forwarder:/app/db/forward.db /tmp/forward.db
|
||||
# Edit /tmp/forward.db with sqlite3
|
||||
docker cp /tmp/forward.db telegram-forwarder:/app/db/forward.db
|
||||
docker start telegram-forwarder
|
||||
```
|
||||
|
||||
**⚠️ Copy the DB while container is stopped**. Writing from host while container runs gives `attempt to write a readonly database` (SQLite WAL lock).
|
||||
|
||||
### Pitfalls
|
||||
|
||||
- **Session DB lock**: The forwarder keeps `sessions/user` locked while running. Any Telethon client using the same session file fails with `database is locked`. Resolve entity first (which does not need the DB), or stop the container before connecting.
|
||||
- **JoinChannelRequest required**: `get_entity` gives you the entity ID, but the user client does not receive messages until it joins via `JoinChannelRequest`. For public channels this subscribes the client.
|
||||
- **Multiple white keywords = OR logic**: Separate keyword rows are evaluated as OR. To require ALL keywords, use a single regex row with `[\\s\\S]*` between terms (sequential match, not lookahead).
|
||||
- **Proxy format**: Telethon proxy parameter is `(socks.SOCKS5, '127.0.0.1', 7890)`. Using raw `5` gives `ValueError: Unknown proxy protocol type: 5`.
|
||||
|
||||
## References
|
||||
|
||||
- Repo: https://github.com/Heavrnl/TelegramForwarder
|
||||
- Docker Hub: https://hub.docker.com/r/heavrnl/telegramforwarder
|
||||
- **Diagnostic script**: `scripts/diagnose_chain.py` — run inside container to check DB config, entity resolution, send permissions, and Hermes config in one shot.
|
||||
- **Debugging guide**: `references/debugging-guide.md` — systematic step-by-step for "信号没收到" complaints. Follow the chain: source → forwarder → group → Hermes → user.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Signal Forwarding Debugging Guide
|
||||
|
||||
Systematic approach to diagnose "信号没收到" (signal not received) complaints.
|
||||
|
||||
## The Chain
|
||||
|
||||
```
|
||||
Source Channel → TelegramForwarder → Target Group → Hermes → User DM
|
||||
(1) (2) (3) (4) (5)
|
||||
```
|
||||
|
||||
Failure at ANY link breaks the chain. Debug from left to right.
|
||||
|
||||
## Step 1: Forwarder Receiving Messages?
|
||||
|
||||
```bash
|
||||
docker logs telegram-forwarder 2>&1 | grep "收到新消息.*实盘监控" | tail -5
|
||||
```
|
||||
|
||||
- **Has entries** → Forwarder is receiving from source. Go to Step 2.
|
||||
- **No entries** → Source channel not bound, or Telethon session expired. Check DB:
|
||||
```bash
|
||||
docker exec telegram-forwarder python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/db/forward.db')
|
||||
for r in conn.execute('SELECT id, telegram_chat_id, name FROM chats'): print(r)
|
||||
conn.close()"
|
||||
```
|
||||
|
||||
## Step 2: Keyword Matching?
|
||||
|
||||
```bash
|
||||
docker logs telegram-forwarder 2>&1 | grep -E "正则匹配成功|未匹配" | tail -5
|
||||
```
|
||||
|
||||
- `正则匹配成功` → Match succeeded. Go to Step 3.
|
||||
- `未匹配到普通白名单关键词` → Signal format changed, or regex doesn't match.
|
||||
- Check the actual signal text: `docker logs telegram-forwarder 2>&1 | grep "内容:" | tail -5`
|
||||
- Common issue: signal uses different brackets (e.g., `[币种]` vs `【币种】`)
|
||||
- Common issue: multi-line signals — `.*` doesn't match `\n`, use `[\s\S]*` instead
|
||||
|
||||
## Step 3: Forwarder Sending to Group?
|
||||
|
||||
```bash
|
||||
docker logs telegram-forwarder 2>&1 | grep -E "已发送|成功获取目标" | tail -5
|
||||
```
|
||||
|
||||
- `消息已发送到: XXX` → Forward succeeded. Go to Step 4.
|
||||
- No send logs after match → Check for errors:
|
||||
```bash
|
||||
docker logs telegram-forwarder 2>&1 | grep -iE "error|invalid Peer|banned|forbidden" | tail -5
|
||||
```
|
||||
- `An invalid Peer was used` → Group migrated to supergroup, ID changed
|
||||
- `Bots can't send messages` → Trying to send to a bot, not a group
|
||||
|
||||
## Step 4: Hermes Receiving from Group?
|
||||
|
||||
**FIRST: Check for competing gateway processes** (most common hidden cause):
|
||||
```bash
|
||||
ps aux | grep "gateway\|openclaw" | grep -v grep
|
||||
```
|
||||
If you see an old `openclaw ... gateway` or duplicate `hermes gateway` process → **KILL IT FIRST**. Two processes sharing one bot token = permanent polling failure. See "Polling Conflict — Scenario A" below.
|
||||
|
||||
Then check polling status:
|
||||
```bash
|
||||
# Check for polling conflict
|
||||
journalctl --user -u hermes-gateway --since "10 min ago" --no-pager | grep -i "conflict"
|
||||
# If found → see "Polling Conflict" section below
|
||||
|
||||
# Check if "✓ telegram connected" appears in logs
|
||||
journalctl --user -u hermes-gateway --since "5 min ago" --no-pager | grep "✓ telegram"
|
||||
# If NOT found → adapter didn't connect (check for competing processes or conflict)
|
||||
|
||||
# Check gateway.log for inbound messages from the group
|
||||
grep "inbound message" ~/.hermes/logs/gateway.log | grep "1003966251111" | tail -5
|
||||
```
|
||||
|
||||
- **Polling conflict found** → Stop gateway, wait 30s, start fresh (see "Polling Conflict" section)
|
||||
- **Has entries** → Hermes receives group messages. Go to Step 5.
|
||||
- **No entries, no conflict** → Hermes can't see group messages. Check:
|
||||
1. Bot privacy mode: `curl -s --proxy http://127.0.0.1:7890 "https://api.telegram.org/bot<TOKEN>/getMe" | grep can_read_all_group_messages`
|
||||
- Must be `true`. If `false`, disable in @BotFather and **kick+re-add** bot to group.
|
||||
2. `free_response_chats` config (NOT `free_response_channels`):
|
||||
```bash
|
||||
grep -A5 "extra:" ~/.hermes/config.yaml | grep free_response
|
||||
```
|
||||
Must be under `telegram.extra.free_response_chats`, not top-level.
|
||||
3. Check if bot is actually in the group.
|
||||
|
||||
## Step 5: Hermes Processing and Responding?
|
||||
|
||||
```bash
|
||||
grep "1003966251111" ~/.hermes/logs/gateway.log | grep -E "response ready|Suppressing" | tail -5
|
||||
```
|
||||
|
||||
- `response ready` → Hermes processed and responded. Check delivery.
|
||||
- `inbound` but no `response` → Agent failed or timed out. Check errors.log:
|
||||
```bash
|
||||
tail -20 ~/.hermes/logs/errors.log
|
||||
```
|
||||
|
||||
## Quick One-Liner Diagnostic
|
||||
|
||||
```bash
|
||||
echo "=== Forwarder ===" && \
|
||||
docker logs telegram-forwarder 2>&1 | grep -cE "正则匹配成功|已发送" && \
|
||||
echo "=== Hermes ===" && \
|
||||
grep -c "inbound.*1003966251111" ~/.hermes/logs/gateway.log 2>/dev/null && \
|
||||
echo "=== Errors ===" && \
|
||||
tail -3 ~/.hermes/logs/errors.log 2>/dev/null
|
||||
```
|
||||
|
||||
## Log Pattern Reference
|
||||
|
||||
| Pattern | Meaning | Where |
|
||||
|---------|---------|-------|
|
||||
| `收到新消息 来自聊天: XXX` | Forwarder received message | forwarder logs |
|
||||
| `正则匹配成功` | Keyword regex matched | forwarder logs |
|
||||
| `未匹配到普通白名单关键词` | Keyword didn't match | forwarder logs |
|
||||
| `所有白名单条件都满足,允许转发` | Forwarding allowed | forwarder logs |
|
||||
| `消息已发送到: XXX` | Message sent to target | forwarder logs |
|
||||
| `过滤器识别到机器人消息,忽略处理` | Bot message filtered (normal) | forwarder logs |
|
||||
| `inbound message: platform=telegram chat=XXX` | Hermes received message | gateway.log |
|
||||
| `response ready: platform=telegram chat=XXX` | Hermes responded | gateway.log |
|
||||
| `Suppressing normal final send` | Response already delivered via stream | gateway.log |
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### "昨天能收到,今天收不到"
|
||||
- Check if source channel stopped sending (forwarder logs)
|
||||
- Check if Telethon session expired (container restart fixes this)
|
||||
- Check if signal format changed
|
||||
|
||||
### "有信号进来但没收到你的消息"
|
||||
- Could be timing: signal hasn't arrived yet, or arrived but user expected Hermes response
|
||||
- Quick check: `docker logs telegram-forwarder 2>&1 | grep "收到新消息.*实盘监控" | tail -1` — compare timestamp to current time. If >30min stale, the signal may not have reached the forwarder.
|
||||
- If forwarder received and forwarded but no Hermes response: check `tail -20 ~/.hermes/logs/gateway.log | grep inbound` — if no recent group messages, the bot may not be receiving (privacy mode).
|
||||
- **Bot message filtering**: Hermes may ignore messages sent by other bots. Always test with a human account, not the forwarder bot.
|
||||
- **⚠️ Check for competing gateway processes**: `ps aux | grep "gateway\|openclaw" | grep -v grep` — if an old OpenClaw Gateway or duplicate Hermes process is running, it steals the bot token polling session and Hermes NEVER receives any group messages. This was the actual root cause for a user whose group messages had NEVER worked for over a week.
|
||||
|
||||
### "信号在频道里能看到,但没转发到群"
|
||||
- Step 2 failure: keyword mismatch
|
||||
- Check actual signal text vs regex pattern
|
||||
|
||||
### "信号转发到群了,但Hermes没回复"
|
||||
- Step 4 failure: privacy mode or config
|
||||
- Most common: bot privacy mode is ON
|
||||
|
||||
### "重启了 gateway 还是不处理群消息"(Polling Conflict — 两种原因)
|
||||
|
||||
**This is the #1 hidden cause.** There are TWO distinct polling conflict scenarios:
|
||||
|
||||
#### Scenario A: Competing gateway processes (most dangerous)
|
||||
|
||||
Another process is using the same bot token for `getUpdates`. This causes PERMANENT failure — no amount of restarting Hermes will help until the other process is killed.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# 1. Check for competing processes
|
||||
ps aux | grep "gateway\|openclaw" | grep -v grep
|
||||
# Look for: node .../openclaw/dist/index.js gateway --port 18789
|
||||
# Or: another hermes gateway run
|
||||
|
||||
# 2. Test getUpdates directly (bypass adapter)
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
with open('/home/openclaw/.hermes/.env') as f:
|
||||
for line in f:
|
||||
if line.startswith('TELEGRAM_BOT_TOKEN'):
|
||||
token = line.strip().split('=',1)[1]
|
||||
break
|
||||
proxy = urllib.request.ProxyHandler({'http': 'http://127.0.0.1:7890', 'https': 'http://127.0.0.1:7890'})
|
||||
opener = urllib.request.build_opener(proxy)
|
||||
try:
|
||||
req = urllib.request.Request(f'https://api.telegram.org/bot{token}/getUpdates?timeout=1')
|
||||
resp = opener.open(req, timeout=5)
|
||||
data = json.loads(resp.read())
|
||||
print(f'getUpdates: {len(data.get(\"result\", []))} updates (no conflict = nobody polling)')
|
||||
except Exception as e:
|
||||
if '409' in str(e):
|
||||
print('409 Conflict — adapter IS polling (normal)')
|
||||
else:
|
||||
print(f'Error: {e}')
|
||||
"
|
||||
```
|
||||
|
||||
**Key diagnostic signal**:
|
||||
- `getUpdates` returns 0 without 409 conflict → **NOBODY is polling** → competing process consumed and dropped updates
|
||||
- `getUpdates` returns 409 → adapter IS polling (conflict is expected, this is normal)
|
||||
- `getUpdates` returns updates → adapter is NOT polling AND competing process dropped off → restart Hermes
|
||||
|
||||
**Fix**: Kill the stale process FIRST, then restart Hermes:
|
||||
```bash
|
||||
kill <stale_pid>
|
||||
systemctl --user restart hermes-gateway
|
||||
```
|
||||
|
||||
**Verify**: `journalctl --user -u hermes-gateway --since "1 min ago" | grep "✓ telegram connected"` — must appear after restart.
|
||||
|
||||
#### Scenario B: `hermes gateway restart` race condition
|
||||
|
||||
The restart command sends SIGTERM and immediately starts a new process. Telegram's servers keep the old polling session alive for ~30 seconds. The new process tries to poll → conflict → retry loop.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
journalctl --user -u hermes-gateway --since "5 min ago" --no-pager | grep -i "conflict"
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
```bash
|
||||
systemctl --user stop hermes-gateway
|
||||
sleep 30 # critical: wait for Telegram session to expire
|
||||
systemctl --user start hermes-gateway
|
||||
```
|
||||
|
||||
#### How to tell which scenario
|
||||
|
||||
```bash
|
||||
# Check how long the gateway has been running
|
||||
systemctl --user status hermes-gateway | grep "Active:"
|
||||
# If running for days/weeks but group messages NEVER worked → Scenario A
|
||||
# If just restarted and immediately getting conflicts → Scenario B
|
||||
|
||||
# Check for stale processes
|
||||
ps aux | grep "gateway" | grep -v grep | grep -v $(pgrep -f "hermes_cli.main gateway")
|
||||
# If you see old processes → Scenario A
|
||||
```
|
||||
|
||||
## Gateway Logs (Binary Format — Use strings!)
|
||||
|
||||
Gateway logs at `~/.hermes/logs/gateway.log` are **binary format**. Use `strings` to extract:
|
||||
|
||||
```bash
|
||||
strings ~/.hermes/logs/gateway.log | tail -30
|
||||
strings ~/.hermes/logs/gateway.log | grep "inbound message" | tail -10
|
||||
strings ~/.hermes/logs/gateway.log | grep "<group_chat_id>" | tail -10
|
||||
strings ~/.hermes/logs/gateway.log | grep "Connected to"
|
||||
```
|
||||
|
||||
## ⚠️ Testing: Use Human Account
|
||||
|
||||
**Never test with bot-sent messages.** Bots cannot see their own messages via getUpdates. Always use a human Telegram account to send test messages to the group or source channel.
|
||||
|
||||
## Agent-Side Filtering Architecture
|
||||
|
||||
When the user says "收所有的消息,在处理消息这边来处理规则过滤":
|
||||
1. Forwarder: `.*` whitelist (pass everything)
|
||||
2. Agent channel_prompts: classify messages as A(signal)/B(confirm)/C(close)/D(ignore)
|
||||
3. Only process A/B/C, silently ignore D
|
||||
|
||||
Benefits: source format changes only require updating the agent prompt, not the forwarder database.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Diagnostic: check if a Telegram group has been migrated to a supergroup
|
||||
# Usage: docker exec telegram-forwarder python3 <this-script>
|
||||
#
|
||||
# When a group migrates to a supergroup, the old ID becomes deactivated.
|
||||
# The new ID is the channel_id from migrated_to, prefixed with -100.
|
||||
|
||||
from telethon import TelegramClient
|
||||
import asyncio, socks
|
||||
|
||||
async def check_migration(chat_id: int):
|
||||
proxy = (socks.SOCKS5, '127.0.0.1', 7890)
|
||||
# Use bot client for checking
|
||||
bot = TelegramClient('./sessions/bot', API_ID, API_HASH, proxy=proxy)
|
||||
await bot.start()
|
||||
|
||||
try:
|
||||
entity = await bot.get_entity(chat_id)
|
||||
print(f'Entity type: {type(entity).__name__}')
|
||||
print(f'ID: {entity.id}')
|
||||
print(f'Title: {getattr(entity, "title", "N/A")}')
|
||||
if hasattr(entity, 'deactivated') and entity.deactivated:
|
||||
print(f'⚠️ DEACTIVATED - group was migrated!')
|
||||
if hasattr(entity, 'migrated_to') and entity.migrated_to:
|
||||
new_id = entity.migrated_to.channel_id
|
||||
print(f'New channel_id: {new_id}')
|
||||
print(f'New Telethon ID: -100{new_id}')
|
||||
print(f'Hermes free_response_channels should be: "-100{new_id}"')
|
||||
else:
|
||||
print('✅ Active - no migration detected')
|
||||
except Exception as e:
|
||||
print(f'Error: {e}')
|
||||
|
||||
await bot.disconnect()
|
||||
|
||||
# Example: check old group ID
|
||||
# asyncio.run(check_migration(5586190970))
|
||||
@@ -0,0 +1,145 @@
|
||||
# Hermes + TelegramForwarder Group Integration
|
||||
|
||||
## Problem
|
||||
Forwarder sends messages to a group, but Hermes bot (also in the group) ignores them.
|
||||
|
||||
## Root Causes (check ALL of these)
|
||||
|
||||
| # | Cause | Symptom | Fix |
|
||||
|---|-------|---------|-----|
|
||||
| 0 | **Competing gateway process** (old OpenClaw, duplicate Hermes) | Adapter shows "connected" but `getUpdates` returns 0 without 409 conflict. No "✓ telegram connected" in logs. Group messages NEVER worked. | `ps aux \| grep gateway` → kill stale process → restart Hermes |
|
||||
| 1 | Bot Privacy Mode is ON | Bot receives 0 group messages | @BotFather → /mybots → Bot Settings → Group Privacy → Turn off |
|
||||
| 2 | Wrong config key `free_response_channels` | Bot sees @mentions but ignores regular messages | Use `extra.free_response_chats` (NOT top-level `free_response_channels`) |
|
||||
| 3 | Group migrated to supergroup | `An invalid Peer was used` in forwarder logs | Update chat ID (see below) |
|
||||
| 4 | Privacy mode not propagated | `can_read_all_group_messages=true` but bot still can't see messages | Kick bot from group, re-add it |
|
||||
|
||||
**⚠️ Check #0 FIRST.** A stale gateway process is invisible — the state file says "connected", no errors appear, but the adapter never actually polls. This was the actual root cause for a user whose group messages had NEVER worked for over a week (old OpenClaw Gateway PID 900 running since June 22). Always run `ps aux | grep gateway | grep -v grep` before investigating other causes.
|
||||
|
||||
**⚠️ Privacy mode is the #1 cause.** Even with `free_response_chats` correctly configured, if privacy mode is ON, the bot literally cannot see group messages. Fix privacy mode FIRST.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
### 0. Check for Competing Gateway Processes (DO THIS FIRST)
|
||||
```bash
|
||||
ps aux | grep "gateway\|openclaw" | grep -v grep
|
||||
```
|
||||
If you see any `openclaw ... gateway` or duplicate `hermes gateway` processes → **kill them immediately**. They steal the bot token polling session.
|
||||
|
||||
Verify nobody else is polling:
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
with open('/home/openclaw/.hermes/.env') as f:
|
||||
for line in f:
|
||||
if line.startswith('TELEGRAM_BOT_TOKEN'):
|
||||
token = line.strip().split('=',1)[1]
|
||||
break
|
||||
proxy = urllib.request.ProxyHandler({'http': 'http://127.0.0.1:7890', 'https': 'http://127.0.0.1:7890'})
|
||||
opener = urllib.request.build_opener(proxy)
|
||||
try:
|
||||
req = urllib.request.Request(f'https://api.telegram.org/bot{token}/getUpdates?timeout=1')
|
||||
resp = opener.open(req, timeout=5)
|
||||
print(f'getUpdates: {len(json.loads(resp.read()).get(\"result\",[]))} updates — nobody polling!' )
|
||||
except Exception as e:
|
||||
print(f'409 Conflict — adapter IS polling (normal)' if '409' in str(e) else f'Error: {e}')
|
||||
"
|
||||
```
|
||||
- 0 updates without 409 → broken (check for competing processes)
|
||||
- 409 Conflict → normal (adapter is polling)
|
||||
|
||||
### 1. Disable Bot Privacy Mode (MANDATORY)
|
||||
Open Telegram → @BotFather:
|
||||
```
|
||||
/mybots → select Hermes bot → Bot Settings → Group Privacy → Turn off
|
||||
```
|
||||
Do this for the Hermes bot. If the forwarder bot also needs to read messages in the group, do it for that bot too.
|
||||
|
||||
**After disabling, kick the bot from the group and re-add it.** The change does not apply to bots already in a group.
|
||||
|
||||
Verify: `curl -s --proxy http://127.0.0.1:7890 "https://api.telegram.org/bot<TOKEN>/getMe"` → `can_read_all_group_messages` must be `true`.
|
||||
|
||||
### 2. Find the destination group's chat_id
|
||||
```bash
|
||||
cd ~/TelegramForwarder
|
||||
docker exec telegram-forwarder python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/db/forward.db')
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT id, telegram_chat_id, name FROM chats')
|
||||
for ch in c.fetchall():
|
||||
print(ch)
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
The column is `telegram_chat_id` (NOT `chat_id`).
|
||||
|
||||
**⚠️ Supergroup IDs**: If the group was migrated, the old ID is stale. Use `-100` + `channel_id` format. To find the real channel_id:
|
||||
```python
|
||||
entity = await bot.get_entity(old_id)
|
||||
# If entity has migrated_to:
|
||||
new_id = -100 * entity.migrated_to.channel_id # e.g., -1003966251111
|
||||
```
|
||||
|
||||
### 3. Edit Hermes config (CORRECT KEY!)
|
||||
`~/.hermes/config.yaml` — add inside the `telegram:` → `extra:` section:
|
||||
```yaml
|
||||
telegram:
|
||||
extra:
|
||||
free_response_chats: '-1003966251111' # ← CORRECT key
|
||||
# free_response_channels: '...' ← WRONG — code ignores this!
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL**: The Hermes source code reads `self.config.extra.get("free_response_chats")`. The top-level `free_response_channels` is a DIFFERENT field that is NOT read by the group-message processing logic. Using the wrong key = silently ignored, bot only responds to @mentions.
|
||||
|
||||
Multiple groups: comma-separated `'-1003966251111,-1001234567890'`
|
||||
|
||||
### 4. Restart Hermes
|
||||
```bash
|
||||
hermes gateway restart # from a separate shell, NOT inside the gateway
|
||||
```
|
||||
If refused ("Refusing to restart from inside the gateway"), ask the user to SSH in and run it.
|
||||
|
||||
## Config Field Reference
|
||||
| Field | Location | Default | Purpose |
|
||||
|-------|----------|---------|---------|
|
||||
| `require_mention` | `telegram:` | `true` | If true, bot only responds when @mentioned in groups |
|
||||
| `free_response_chats` | `telegram:extra:` | `''` | Comma-separated chat_ids exempt from @mention requirement |
|
||||
| `free_response_channels` | `telegram:` | `''` | **NOT used by group message processing** — legacy field, ignore |
|
||||
| `allowed_chats` | `telegram:` | `''` | If set, ONLY these chats can interact with the bot (whitelist) |
|
||||
|
||||
## Signal Forwarding with channel_prompts
|
||||
|
||||
When you want Hermes to process group messages (e.g., trading signals) and forward results to a user DM instead of replying in the group:
|
||||
|
||||
```yaml
|
||||
telegram:
|
||||
channel_prompts:
|
||||
"-1003966251111": "你是交易信号处理器。收到群里的交易信号后,解析【币种】【方向】【仓位】,然后用send_message工具发送到telegram:656685714(用户DM),格式简洁:信号摘要+当前价格+建议操作+风险提示。不要在群里回复。"
|
||||
```
|
||||
|
||||
This configures a per-group system prompt. The agent in that group session will follow these instructions instead of the default behavior.
|
||||
|
||||
## End-to-End Verification
|
||||
|
||||
After setting up the forwarding → Hermes chain, verify each link:
|
||||
|
||||
1. **Forwarder listening**: `docker logs telegram-forwarder 2>&1 | grep "收到新消息"` — should show messages from source channel
|
||||
2. **Forwarder sending**: `docker logs telegram-forwarder 2>&1 | grep "已发送"` — should show successful sends to target group
|
||||
3. **Hermes receiving**: `hermes logs 2>&1 | grep "inbound message"` — should show messages from the group (chat ID should match the group, not the user DM)
|
||||
4. **Hermes responding**: Check the group for Hermes's reply
|
||||
|
||||
**Common failure chain**:
|
||||
- Forwarder logs show "收到新消息" but no "已发送" → keyword filter issue or invalid Peer (check group ID)
|
||||
- Forwarder logs show "已发送" but Hermes shows no inbound from group → privacy mode still on, or `free_response_chats` wrong key
|
||||
- Hermes shows inbound but no response → check `require_mention` and `free_response_chats`
|
||||
|
||||
**⚠️ For testing, use a human account** (not the forwarder bot) to send messages to the source channel or directly to the group. Bot-sent messages may be filtered by Hermes.
|
||||
|
||||
## Pitfalls
|
||||
- `free_response_chats` takes string values, not integers: `'-1003966251111'` not `-1003966251111`
|
||||
- Config changes require Hermes restart to take effect
|
||||
- If `allowed_chats` is set (non-empty), the group must also be in that list
|
||||
- The forwarder's `handle_mode: FORWARD` uses Telegram's native forward (shows "Forwarded from"). If user wants clean copies, change to `COPY` mode.
|
||||
- **Don't test by sending directly to the target group** — this bypasses the forwarding chain and proves nothing. Test by sending to the SOURCE group and verifying the full pipeline: source → forwarder → target → Hermes response.
|
||||
- **Bot-to-bot messaging FAILS**: Telegram prohibits bot→bot messaging. Forward to a GROUP where both bots reside, not directly to a bot.
|
||||
- **Session database locked**: If you see `sqlite3.OperationalError: database is locked`, stop the container first, edit DB, then restart.
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diagnose TelegramForwarder → Hermes forwarding chain.
|
||||
|
||||
Run inside the container:
|
||||
docker exec telegram-forwarder python3 /app/scripts/diagnose_chain.py
|
||||
|
||||
Or from host (container must be stopped):
|
||||
cd ~/TelegramForwarder && docker run --rm -v $(pwd)/sessions:/app/sessions \
|
||||
-v $(pwd)/db:/app/db --network host \
|
||||
-e HTTP_PROXY=http://127.0.0.1:7890 \
|
||||
telegramforwarder-telegram-forwarder python3 /app/scripts/diagnose_chain.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import asyncio
|
||||
import socks
|
||||
|
||||
DB_PATH = '/app/db/forward.db'
|
||||
|
||||
def check_db():
|
||||
"""Check forwarder database configuration."""
|
||||
print("=" * 50)
|
||||
print("1. DATABASE CONFIG")
|
||||
print("=" * 50)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
|
||||
print("\n--- Chats ---")
|
||||
for row in c.execute('SELECT id, telegram_chat_id, name FROM chats'):
|
||||
print(f" {row}")
|
||||
|
||||
print("\n--- Forward Rules ---")
|
||||
for row in c.execute('SELECT id, source_chat_id, target_chat_id, forward_mode, enable_rule FROM forward_rules'):
|
||||
print(f" Rule {row[0]}: source={row[1]} → target={row[2]} | mode={row[3]} | enabled={row[4]}")
|
||||
|
||||
print("\n--- Keywords ---")
|
||||
for row in c.execute('SELECT * FROM keywords'):
|
||||
print(f" {row}")
|
||||
|
||||
conn.close()
|
||||
|
||||
async def check_entity_resolution():
|
||||
"""Test if bot can resolve the target group entity."""
|
||||
print("\n" + "=" * 50)
|
||||
print("2. ENTITY RESOLUTION TEST")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from telethon import TelegramClient
|
||||
except ImportError:
|
||||
print(" SKIP: telethon not available")
|
||||
return
|
||||
|
||||
import os
|
||||
api_id = os.getenv('API_ID')
|
||||
api_hash = os.getenv('API_HASH')
|
||||
if not api_id or not api_hash:
|
||||
print(" SKIP: API_ID/API_HASH not set")
|
||||
return
|
||||
|
||||
proxy = (socks.SOCKS5, '127.0.0.1', 7890)
|
||||
bot = TelegramClient('./sessions/bot', int(api_id), api_hash, proxy=proxy)
|
||||
await bot.start()
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
targets = c.execute('SELECT id, telegram_chat_id, name FROM chats').fetchall()
|
||||
conn.close()
|
||||
|
||||
for chat_id, tg_id, name in targets:
|
||||
try:
|
||||
entity = await bot.get_entity(int(tg_id) if not tg_id.startswith('-') else tg_id)
|
||||
deactivated = getattr(entity, 'deactivated', False)
|
||||
migrated = getattr(entity, 'migrated_to', None)
|
||||
if deactivated and migrated:
|
||||
new_id = f"-100{migrated.channel_id}"
|
||||
print(f" ❌ {name} ({tg_id}): DEACTIVATED → migrated to {new_id}")
|
||||
else:
|
||||
print(f" ✅ {name} ({tg_id}): OK - {type(entity).__name__}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {name} ({tg_id}): {e}")
|
||||
|
||||
await bot.disconnect()
|
||||
|
||||
async def check_send_permission():
|
||||
"""Test if bot can send to the target group."""
|
||||
print("\n" + "=" * 50)
|
||||
print("3. SEND PERMISSION TEST")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from telethon import TelegramClient
|
||||
except ImportError:
|
||||
print(" SKIP: telethon not available")
|
||||
return
|
||||
|
||||
import os
|
||||
api_id = os.getenv('API_ID')
|
||||
api_hash = os.getenv('API_HASH')
|
||||
if not api_id or not api_hash:
|
||||
print(" SKIP: API_ID/API_HASH not set")
|
||||
return
|
||||
|
||||
proxy = (socks.SOCKS5, '127.0.0.1', 7890)
|
||||
bot = TelegramClient('./sessions/bot', int(api_id), api_hash, proxy=proxy)
|
||||
await bot.start()
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
rules = c.execute('SELECT id, target_chat_id FROM forward_rules WHERE enable_rule=1').fetchall()
|
||||
conn.close()
|
||||
|
||||
for rule_id, target_id in rules:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
tg_id = c.execute('SELECT telegram_chat_id FROM chats WHERE id=?', (target_id,)).fetchone()
|
||||
conn.close()
|
||||
if not tg_id:
|
||||
print(f" ❌ Rule {rule_id}: target chat {target_id} not found in chats table")
|
||||
continue
|
||||
|
||||
tg_id = tg_id[0]
|
||||
try:
|
||||
await bot.send_message(int(tg_id) if not tg_id.startswith('-') else tg_id, '🤖 Forward chain diagnostic test')
|
||||
print(f" ✅ Rule {rule_id}: can send to {tg_id}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Rule {rule_id}: cannot send to {tg_id}: {e}")
|
||||
|
||||
await bot.disconnect()
|
||||
|
||||
def check_hermes_config():
|
||||
"""Check if Hermes free_response_chats matches."""
|
||||
print("\n" + "=" * 50)
|
||||
print("4. HERMES CONFIG CHECK")
|
||||
print("=" * 50)
|
||||
|
||||
import os
|
||||
config_path = os.path.expanduser('~/.hermes/config.yaml')
|
||||
if not os.path.exists(config_path):
|
||||
print(" SKIP: ~/.hermes/config.yaml not found")
|
||||
return
|
||||
|
||||
with open(config_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract telegram section
|
||||
in_telegram = False
|
||||
in_extra = False
|
||||
found_correct_key = False
|
||||
found_wrong_key = False
|
||||
|
||||
for line in content.split('\n'):
|
||||
stripped = line.strip()
|
||||
if stripped == 'telegram:':
|
||||
in_telegram = True
|
||||
continue
|
||||
if in_telegram and line and not line.startswith(' ') and not line.startswith('\t'):
|
||||
in_telegram = False
|
||||
in_extra = False
|
||||
if in_telegram:
|
||||
if stripped == 'extra:':
|
||||
in_extra = True
|
||||
continue
|
||||
if in_extra and not line.startswith(' ') and not line.startswith('\t') and stripped:
|
||||
in_extra = False
|
||||
|
||||
# CORRECT key: telegram.extra.free_response_chats
|
||||
if in_extra and 'free_response_chats' in line:
|
||||
val = line.split(':')[-1].strip().strip("'\"")
|
||||
found_correct_key = True
|
||||
if val:
|
||||
print(f" ✅ free_response_chats = {val}")
|
||||
else:
|
||||
print(f" ❌ free_response_chats is EMPTY — Hermes will ignore group messages")
|
||||
|
||||
# WRONG key: telegram.free_response_channels (legacy, ignored by code)
|
||||
if not in_extra and 'free_response_channels' in line:
|
||||
val = line.split(':')[-1].strip().strip("'\"")
|
||||
found_wrong_key = True
|
||||
if val:
|
||||
print(f" ⚠️ free_response_channels = {val} (WRONG KEY — ignored by Hermes code!)")
|
||||
else:
|
||||
print(f" ⚠️ free_response_channels is empty (also wrong key)")
|
||||
|
||||
if not found_correct_key:
|
||||
print(f" ❌ free_response_chats NOT FOUND in telegram.extra — add it!")
|
||||
if found_wrong_key and not found_correct_key:
|
||||
print(f" 💡 Move free_response_channels value to telegram.extra.free_response_chats")
|
||||
|
||||
async def main():
|
||||
check_db()
|
||||
await check_entity_resolution()
|
||||
await check_send_permission()
|
||||
check_hermes_config()
|
||||
print("\n" + "=" * 50)
|
||||
print("DIAGNOSIS COMPLETE")
|
||||
print("=" * 50)
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: telethon-proxy-docker
|
||||
description: "Deploy Telethon-based Telegram bots/forwarders behind a SOCKS5 proxy (Mihomo/Clash) on a server with no direct Telegram access. Covers Docker networking, proxy injection, and phone verification workflow."
|
||||
tags: [telethon, telegram, proxy, docker, mihomo, socks5, forwarder]
|
||||
---
|
||||
|
||||
# Telethon + Proxy + Docker Deployment
|
||||
|
||||
Deploy Telethon-based Telegram bots/forwarders on a server that requires a proxy (Mihomo/Clash) to reach Telegram API servers.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks to deploy a Telegram bot/forwarder that uses Telethon
|
||||
- Server has no direct route to Telegram (Mihomo proxy required)
|
||||
- Telethon-based app needs to run in Docker behind a proxy
|
||||
- Keywords: "Telegram 转发器", "Telethon proxy", "消息转发", "Telegram forwarder"
|
||||
|
||||
## Core Problem
|
||||
|
||||
Telethon uses MTProto (raw TCP), NOT HTTP. Standard `HTTP_PROXY` / `HTTPS_PROXY` environment variables do **NOT** work for Telethon connections. You must pass a `proxy` parameter explicitly to `TelegramClient()`.
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Verify proxy is a mixed-port (supports SOCKS5)
|
||||
|
||||
Mihomo/Clash `mixed-port` supports both HTTP and SOCKS5 on the same port:
|
||||
|
||||
```bash
|
||||
# Check config
|
||||
grep "mixed-port" /home/openclaw/clash/config/config.yaml
|
||||
# Output: mixed-port: 7890
|
||||
```
|
||||
|
||||
### 2. Patch Telethon client creation
|
||||
|
||||
Find where `TelegramClient()` is instantiated (usually `main.py`) and add proxy:
|
||||
|
||||
```python
|
||||
import os
|
||||
try:
|
||||
import socks
|
||||
HAS_SOCKS = True
|
||||
except ImportError:
|
||||
HAS_SOCKS = False
|
||||
|
||||
# Parse proxy from env
|
||||
proxy_config = None
|
||||
proxy_addr = os.getenv('HTTP_PROXY') or os.getenv('http_proxy')
|
||||
if proxy_addr and HAS_SOCKS:
|
||||
import re
|
||||
m = re.match(r'https?://([\d.]+):(\d+)', proxy_addr)
|
||||
if m:
|
||||
proxy_config = (socks.SOCKS5, m.group(1), int(m.group(2)))
|
||||
|
||||
# Pass to TelegramClient
|
||||
user_client = TelegramClient('./sessions/user', api_id, api_hash, proxy=proxy_config)
|
||||
bot_client = TelegramClient('./sessions/bot', api_id, api_hash, proxy=proxy_config)
|
||||
```
|
||||
|
||||
### 3. Docker setup with network_mode: host
|
||||
|
||||
Docker containers can't reach `127.0.0.1:7890` on the host by default. Use `network_mode: host`:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
telegram-forwarder:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.custom
|
||||
container_name: telegram-forwarder
|
||||
restart: unless-stopped
|
||||
network_mode: host # Required for 127.0.0.1 proxy access
|
||||
environment:
|
||||
- HTTP_PROXY=http://127.0.0.1:7890
|
||||
- HTTPS_PROXY=http://127.0.0.1:7890
|
||||
- http_proxy=http://127.0.0.1:7890
|
||||
- https_proxy=http://127.0.0.1:7890
|
||||
- NO_PROXY=localhost,127.0.0.1
|
||||
volumes:
|
||||
- ./db:/app/db
|
||||
- ./.env:/app/.env
|
||||
- ./sessions:/app/sessions
|
||||
# ... other volumes
|
||||
stdin_open: true # Required for phone verification
|
||||
tty: true # Required for phone verification
|
||||
```
|
||||
|
||||
### 4. Custom Dockerfile (adds PySocks)
|
||||
|
||||
```dockerfile
|
||||
FROM heavrnl/telegramforwarder:latest # or whatever base image
|
||||
RUN pip install --no-cache-dir PySocks
|
||||
COPY main.py /app/main.py # patched version with proxy support
|
||||
```
|
||||
|
||||
### 5. Phone verification (first run)
|
||||
|
||||
First run requires interactive phone verification:
|
||||
|
||||
```bash
|
||||
# Run interactively for first-time auth
|
||||
docker-compose run --rm telegram-forwarder
|
||||
# → Enter phone code when prompted
|
||||
# → Session saved to ./sessions/*.session
|
||||
```
|
||||
|
||||
Use `terminal(background=true, pty=true)` and `process(action='submit')` to handle verification code input.
|
||||
|
||||
**Pitfall**: Verification codes expire FAST (~30 seconds). Agent-based submission via `process(action='submit')` is unreliable due to latency — the code often expires before the agent can submit it. **Recommend user SSH directly** and run `docker-compose run --rm telegram-forwarder` themselves for the verification step.
|
||||
|
||||
**Pitfall**: If 2FA is enabled, Telethon will prompt for password AFTER the code. User needs to enter their Telegram two-step verification password (set in TG Settings → Privacy & Security → Two-Step Verification). If forgotten, it can be reset but requires a 7-day wait.
|
||||
|
||||
**Pitfall**: If code expires, clean session files and restart:
|
||||
```bash
|
||||
rm -f ./sessions/*.session ./sessions/*.session-journal
|
||||
docker-compose run --rm telegram-forwarder
|
||||
```
|
||||
|
||||
### 6. After verification, run as daemon
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`HTTP_PROXY` alone does NOT work for Telethon** — Telethon uses raw TCP (MTProto), not HTTP. Must pass `proxy=(socks.SOCKS5, host, port)` to `TelegramClient()`.
|
||||
- **`PySocks` must be installed** — add to Dockerfile if not in base image's requirements.
|
||||
- **`network_mode: host`** is required for `127.0.0.1` proxy access. Without it, the container can't reach the host's Mihomo. Alternative: use `extra_hosts: ["host.docker.internal:host-gateway"]` and change proxy to `host.docker.internal:7890`.
|
||||
- **Session database lock** — if previous run crashed, `*.session` files may be locked. Delete them before retrying.
|
||||
- **Don't use same bot token as Hermes** — the forwarder bot needs its own BotFather token. Using the same token causes conflicts.
|
||||
- **Verification code timeout** — Telegram codes expire in ~30s. The interactive Docker session may time out if user is slow to respond. Have the code ready before starting.
|
||||
|
||||
## Reference: Heavrnl/TelegramForwarder
|
||||
|
||||
Best full-featured Telegram forwarder (1144+ stars):
|
||||
- Repo: `https://github.com/Heavrnl/TelegramForwarder`
|
||||
- Features: `/bind` command, keyword filtering, regex replace, AI processing, RSS, multi-platform push
|
||||
- Commands: `/bind`, `/settings`, `/add`, `/replace`, `/list_keyword`, etc.
|
||||
- Docker image: `heavrnl/telegramforwarder:latest`
|
||||
|
||||
## Server Context
|
||||
|
||||
- Server: Linux (NYC), no direct route to Telegram servers
|
||||
- Proxy: Mihomo at `127.0.0.1:7890` (mixed-port, SOCKS5 + HTTP)
|
||||
- Telegram API creds in `~/.bashrc`: `TG_API_ID`, `TG_API_HASH`
|
||||
- User prefers Chinese UI/commands
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Telethon proxy injection snippet — copy into any Telethon-based project."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_proxy_config():
|
||||
"""Parse HTTP_PROXY env var into Telethon-compatible SOCKS5 proxy tuple.
|
||||
|
||||
Returns:
|
||||
tuple: (socks.SOCKS5, host, port) or None if no proxy configured
|
||||
"""
|
||||
try:
|
||||
import socks
|
||||
except ImportError:
|
||||
logger.warning("PySocks not installed — proxy disabled. Install with: pip install PySocks")
|
||||
return None
|
||||
|
||||
proxy_addr = os.getenv('HTTP_PROXY') or os.getenv('http_proxy')
|
||||
if not proxy_addr:
|
||||
return None
|
||||
|
||||
m = re.match(r'https?://([\d.]+):(\d+)', proxy_addr)
|
||||
if m:
|
||||
host, port = m.group(1), int(m.group(2))
|
||||
logger.info(f"Using SOCKS5 proxy: {host}:{port}")
|
||||
return (socks.SOCKS5, host, port)
|
||||
|
||||
logger.warning(f"Could not parse proxy URL: {proxy_addr}")
|
||||
return None
|
||||
|
||||
|
||||
# Usage:
|
||||
# proxy = get_proxy_config()
|
||||
# client = TelegramClient('session', api_id, api_hash, proxy=proxy)
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM <base-image>:latest
|
||||
RUN pip install --no-cache-dir PySocks
|
||||
COPY main.py /app/main.py
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
telegram-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.custom
|
||||
container_name: telegram-app
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
- HTTP_PROXY=http://127.0.0.1:7890
|
||||
- HTTPS_PROXY=http://127.0.0.1:7890
|
||||
- http_proxy=http://127.0.0.1:7890
|
||||
- https_proxy=http://127.0.0.1:7890
|
||||
- NO_PROXY=localhost,127.0.0.1
|
||||
volumes:
|
||||
- ./sessions:/app/sessions
|
||||
- ./.env:/app/.env
|
||||
- ./db:/app/db
|
||||
- ./logs:/app/logs
|
||||
- ./temp:/app/temp
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
compress: "true"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: vpn-management
|
||||
description: Manage WireGuard VPN connections via wg-quick.
|
||||
---
|
||||
|
||||
# VPN Management (WireGuard)
|
||||
|
||||
This skill provides a way to manage WireGuard VPN connections using `wg-quick`. It includes scripts for turning the connection on and off, as well as handling sudo requirements.
|
||||
|
||||
For complete WireGuard removal (when switching to Clash/proxy), see `references/wireguard-removal.md`.
|
||||
|
||||
## Usage
|
||||
- **Turn ON**: `bash ~/wg-on.sh` (Runs `sudo wg-quick up wg0`)
|
||||
- **turn OFF**: `bash ~/wg-off.sh` (Runs `sudo wg-quick down wg0`)
|
||||
|
||||
## Pitfalls
|
||||
- **⚠️ TEST BEFORE EXECUTE — WireGuard can kill all connectivity instantly.** `wg-quick up wg0` with full-tunnel config (`AllowedIPs = 0.0.0.0/0`) routes ALL traffic through the VPN the moment it activates. If the VPN endpoint is unreachable or misconfigured, the machine loses all network access (including SSH). **Always verify before acting**: (1) `ip route` — check current routing, (2) `wg show` — check if interface already exists, (3) `ping <vpn-endpoint>` — confirm the WireGuard server is reachable, (4) after activating, immediately `ping 8.8.8.8` to confirm connectivity. If you lose access, you need physical console or a pre-existing SSH session to run `wg-quick down wg0`.
|
||||
- **Sudo requirement**: Both commands require `sudo`. If running in a non-interactive terminal, ensure your environment handles the password prompt or use `sudo -S` if you modify the scripts.
|
||||
- **Interface name**: These scripts target `wg0`. If your interface name is different, update the scripts or use the appropriate command.
|
||||
- **Command not found**: If `wg-on` or `wg-off` (without `.sh`) is preferred, ensure they are added to your shell's `PATH` or set as aliases.
|
||||
@@ -0,0 +1,40 @@
|
||||
# WireGuard Complete Removal Guide
|
||||
|
||||
When WireGuard is no longer needed (e.g., switched to Clash/Mihomo proxy), remove it completely to prevent it from intercepting traffic on reboot.
|
||||
|
||||
## Step-by-step removal
|
||||
|
||||
```bash
|
||||
# 1. Stop the interface immediately
|
||||
sudo wg-quick down wg0
|
||||
|
||||
# 2. Disable the systemd service (prevents auto-start on boot)
|
||||
sudo systemctl disable wg-quick@wg0
|
||||
|
||||
# 3. Verify no WireGuard interfaces remain
|
||||
ip addr show wg0 2>/dev/null || echo "wg0 removed"
|
||||
sudo wg show # should show nothing
|
||||
|
||||
# 4. Verify routing table is clean (no wg0 routes)
|
||||
ip route show | grep wg0 # should show nothing
|
||||
|
||||
# 5. Test connectivity
|
||||
ping -c 3 8.8.8.8
|
||||
```
|
||||
|
||||
## If you want to fully uninstall WireGuard
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt remove wireguard wireguard-tools
|
||||
|
||||
# Remove config files
|
||||
sudo rm -rf /etc/wireguard/
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`wg-quick down` alone is NOT enough** — the systemd service will restart wg0 on next boot. Always `systemctl disable` as well.
|
||||
- **Check routing after removal** — WireGuard may have added custom routes or iptables rules. Verify with `ip route show` and `sudo iptables -L -n`.
|
||||
- **DNS leaks** — WireGuard may have modified `/etc/resolv.conf`. Check and restore if needed.
|
||||
- **Clash/Mihomo coexistence** — If switching from WireGuard to Clash, ensure WireGuard is fully stopped BEFORE starting Clash to avoid route conflicts.
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: webhook-subscriptions
|
||||
description: "Webhook subscriptions: event-driven agent runs."
|
||||
version: 1.1.0
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [webhook, events, automation, integrations, notifications, push]
|
||||
---
|
||||
|
||||
# Webhook Subscriptions
|
||||
|
||||
Create dynamic webhook subscriptions so external services (GitHub, GitLab, Stripe, CI/CD, IoT sensors, monitoring tools) can trigger Hermes agent runs by POSTing events to a URL.
|
||||
|
||||
## Setup (Required First)
|
||||
|
||||
The webhook platform must be enabled before subscriptions can be created. Check with:
|
||||
```bash
|
||||
hermes webhook list
|
||||
```
|
||||
|
||||
If it says "Webhook platform is not enabled", set it up:
|
||||
|
||||
### Option 1: Setup wizard
|
||||
```bash
|
||||
hermes gateway setup
|
||||
```
|
||||
Follow the prompts to enable webhooks, set the port, and set a global HMAC secret.
|
||||
|
||||
### Option 2: Manual config
|
||||
Add to `~/.hermes/config.yaml`:
|
||||
```yaml
|
||||
platforms:
|
||||
webhook:
|
||||
enabled: true
|
||||
extra:
|
||||
host: "0.0.0.0"
|
||||
port: 8644
|
||||
secret: "generate-a-strong-secret-here"
|
||||
```
|
||||
|
||||
### Option 3: Environment variables
|
||||
Add to `~/.hermes/.env`:
|
||||
```bash
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_PORT=8644
|
||||
WEBHOOK_SECRET=generate-a-strong-secret-here
|
||||
```
|
||||
|
||||
After configuration, start (or restart) the gateway:
|
||||
```bash
|
||||
hermes gateway run
|
||||
# Or if using systemd:
|
||||
systemctl --user restart hermes-gateway
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
```bash
|
||||
curl http://localhost:8644/health
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
All management is via the `hermes webhook` CLI command:
|
||||
|
||||
### Create a subscription
|
||||
```bash
|
||||
hermes webhook subscribe <name> \
|
||||
--prompt "Prompt template with {payload.fields}" \
|
||||
--events "event1,event2" \
|
||||
--description "What this does" \
|
||||
--skills "skill1,skill2" \
|
||||
--deliver telegram \
|
||||
--deliver-chat-id "12345" \
|
||||
--secret "optional-custom-secret"
|
||||
```
|
||||
|
||||
Returns the webhook URL and HMAC secret. The user configures their service to POST to that URL.
|
||||
|
||||
### List subscriptions
|
||||
```bash
|
||||
hermes webhook list
|
||||
```
|
||||
|
||||
### Remove a subscription
|
||||
```bash
|
||||
hermes webhook remove <name>
|
||||
```
|
||||
|
||||
### Test a subscription
|
||||
```bash
|
||||
hermes webhook test <name>
|
||||
hermes webhook test <name> --payload '{"key": "value"}'
|
||||
```
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
Prompts support `{dot.notation}` for accessing nested payload fields:
|
||||
|
||||
- `{issue.title}` — GitHub issue title
|
||||
- `{pull_request.user.login}` — PR author
|
||||
- `{data.object.amount}` — Stripe payment amount
|
||||
- `{sensor.temperature}` — IoT sensor reading
|
||||
|
||||
If no prompt is specified, the full JSON payload is dumped into the agent prompt.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### GitHub: new issues
|
||||
```bash
|
||||
hermes webhook subscribe github-issues \
|
||||
--events "issues" \
|
||||
--prompt "New GitHub issue #{issue.number}: {issue.title}\n\nAction: {action}\nAuthor: {issue.user.login}\nBody:\n{issue.body}\n\nPlease triage this issue." \
|
||||
--deliver telegram \
|
||||
--deliver-chat-id "-100123456789"
|
||||
```
|
||||
|
||||
Then in GitHub repo Settings → Webhooks → Add webhook:
|
||||
- Payload URL: the returned webhook_url
|
||||
- Content type: application/json
|
||||
- Secret: the returned secret
|
||||
- Events: "Issues"
|
||||
|
||||
### GitHub: PR reviews
|
||||
```bash
|
||||
hermes webhook subscribe github-prs \
|
||||
--events "pull_request" \
|
||||
--prompt "PR #{pull_request.number} {action}: {pull_request.title}\nBy: {pull_request.user.login}\nBranch: {pull_request.head.ref}\n\n{pull_request.body}" \
|
||||
--skills "github-code-review" \
|
||||
--deliver github_comment
|
||||
```
|
||||
|
||||
### Stripe: payment events
|
||||
```bash
|
||||
hermes webhook subscribe stripe-payments \
|
||||
--events "payment_intent.succeeded,payment_intent.payment_failed" \
|
||||
--prompt "Payment {data.object.status}: {data.object.amount} cents from {data.object.receipt_email}" \
|
||||
--deliver telegram \
|
||||
--deliver-chat-id "-100123456789"
|
||||
```
|
||||
|
||||
### CI/CD: build notifications
|
||||
```bash
|
||||
hermes webhook subscribe ci-builds \
|
||||
--events "pipeline" \
|
||||
--prompt "Build {object_attributes.status} on {project.name} branch {object_attributes.ref}\nCommit: {commit.message}" \
|
||||
--deliver discord \
|
||||
--deliver-chat-id "1234567890"
|
||||
```
|
||||
|
||||
### Generic monitoring alert
|
||||
```bash
|
||||
hermes webhook subscribe alerts \
|
||||
--prompt "Alert: {alert.name}\nSeverity: {alert.severity}\nMessage: {alert.message}\n\nPlease investigate and suggest remediation." \
|
||||
--deliver origin
|
||||
```
|
||||
|
||||
### Direct delivery (no agent, zero LLM cost)
|
||||
|
||||
For use cases where you just want to push a notification through to a user's chat — no reasoning, no agent loop — add `--deliver-only`. The rendered `--prompt` template becomes the literal message body and is dispatched directly to the target adapter.
|
||||
|
||||
Use this for:
|
||||
- External service push notifications (Supabase/Firebase webhooks → Telegram)
|
||||
- Monitoring alerts that should forward verbatim
|
||||
- Inter-agent pings where one agent is telling another agent's user something
|
||||
- Any webhook where an LLM round trip would be wasted effort
|
||||
|
||||
```bash
|
||||
hermes webhook subscribe antenna-matches \
|
||||
--deliver telegram \
|
||||
--deliver-chat-id "123456789" \
|
||||
--deliver-only \
|
||||
--prompt "🎉 New match: {match.user_name} matched with you!" \
|
||||
--description "Antenna match notifications"
|
||||
```
|
||||
|
||||
The POST returns `200 OK` on successful delivery, `502` on target failure — so upstream services can retry intelligently. HMAC auth, rate limits, and idempotency still apply.
|
||||
|
||||
Requires `--deliver` to be a real target (telegram, discord, slack, github_comment, etc.) — `--deliver log` is rejected because log-only direct delivery is pointless.
|
||||
|
||||
## Security
|
||||
|
||||
- Each subscription gets an auto-generated HMAC-SHA256 secret (or provide your own with `--secret`)
|
||||
- The webhook adapter validates signatures on every incoming POST
|
||||
- Static routes from config.yaml cannot be overwritten by dynamic subscriptions
|
||||
- Subscriptions persist to `~/.hermes/webhook_subscriptions.json`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. `hermes webhook subscribe` writes to `~/.hermes/webhook_subscriptions.json`
|
||||
2. The webhook adapter hot-reloads this file on each incoming request (mtime-gated, negligible overhead)
|
||||
3. When a POST arrives matching a route, the adapter formats the prompt and triggers an agent run
|
||||
4. The agent's response is delivered to the configured target (Telegram, Discord, GitHub comment, etc.)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If webhooks aren't working:
|
||||
|
||||
1. **Is the gateway running?** Check with `systemctl --user status hermes-gateway` or `ps aux | grep gateway`
|
||||
2. **Is the webhook server listening?** `curl http://localhost:8644/health` should return `{"status": "ok"}`
|
||||
3. **Check gateway logs:** `grep webhook ~/.hermes/logs/gateway.log | tail -20`
|
||||
4. **Signature mismatch?** Verify the secret in your service matches the one from `hermes webhook list`. GitHub sends `X-Hub-Signature-256`, GitLab sends `X-Gitlab-Token`.
|
||||
5. **Firewall/NAT?** The webhook URL must be reachable from the service. For local development, use a tunnel (ngrok, cloudflared).
|
||||
6. **Wrong event type?** Check `--events` filter matches what the service sends. Use `hermes webhook test <name>` to verify the route works.
|
||||
Reference in New Issue
Block a user