1 Commits

Author SHA1 Message Date
user
73f31c75be docs: add webhook vs poller notification delivery approaches
All checks were successful
check / check (push) Successful in 11s
Add a new 'Notification Delivery: Webhooks vs Polling' section to
OPENCLAW_TRICKS.md explaining the two approaches for receiving Gitea
notifications:

1. Direct webhooks for VPS/public server deployments (realtime, push-based)
2. Notification poller for local machines behind NAT (simple, no firewall config)

Includes a comparison table to help users choose.

Update SETUP_CHECKLIST.md Phase 10 to present both options (A and B) with
separate sub-checklists, replacing the previous poller-only instructions.
2026-02-28 03:24:32 -08:00
2 changed files with 490 additions and 664 deletions

View File

@@ -173,62 +173,42 @@ The landing checklist (triggered automatically after every flight) updates
location, timezone, nearest airport, and lodging in the daily context file. It location, timezone, nearest airport, and lodging in the daily context file. It
also checks if any cron jobs have hardcoded timezones that need updating. also checks if any cron jobs have hardcoded timezones that need updating.
### Gitea Notification Delivery ### The Gitea Notification Poller
There are two approaches for getting Gitea notifications to your agent, OpenClaw has heartbeats, but those are periodic (every ~30min). For Gitea issues
depending on your network setup. and PRs, we wanted near-realtime response. The solution: a tiny Python script
that polls the Gitea notifications API every 2 seconds and wakes the agent via
#### Option A: Direct Webhooks (VPS / Public Server) OpenClaw's `/hooks/wake` endpoint when new notifications arrive.
If your OpenClaw instance runs on a VPS or other publicly reachable server, the
simplest approach is direct webhooks. Run Traefik (or any reverse proxy with
automatic TLS) on the same server and configure Gitea webhooks to POST directly
to OpenClaw's webhook endpoint. This is push-based and realtime — notifications
arrive instantly.
Setup: add a webhook on each Gitea repo (or use an organization-level webhook)
pointing to `https://your-openclaw-host/hooks/gitea`. OpenClaw handles the rest.
#### Option B: Notification Poller (Local Machine Behind NAT)
If your OpenClaw runs on a dedicated local machine behind NAT (like a home Mac
or Linux workstation), Gitea can't reach it directly. This is our setup —
OpenClaw runs on a Mac Studio on a home LAN.
The solution: a lightweight Python script that polls the Gitea notifications API
every few seconds. When new notifications appear, it writes a flag file that the
agent checks during heartbeats.
Key design decisions: Key design decisions:
- **The poller never marks notifications as read.** The agent does that after - **The poller never marks notifications as read.** That's the agent's job after
processing. This prevents lost notifications if the agent fails to process. it processes them. This prevents the poller and agent from racing.
- **It tracks notification IDs, not counts.** Only fires on genuinely new - **It tracks notification IDs, not counts.** This way it only fires on
notifications, not re-reads of existing ones. genuinely new notifications, not re-reads of existing ones.
- **Flag file instead of wake events.** We initially used OpenClaw's - **The wake message tells the agent to route output to Gitea/Mattermost, not to
`/hooks/wake` endpoint, but wake events target the main (DM) session — any DM.** This prevents chatty notification processing from disturbing the human.
model response during processing leaked to DM as a notification. The flag file - **Zero dependencies.** Just Python stdlib (`urllib`, `json`, `time`). Runs
approach is processed during heartbeats, where output routing is controlled. anywhere.
- **Zero dependencies.** Just Python stdlib. Runs anywhere.
Tradeoff: notifications are processed at heartbeat cadence (~30 min) instead of Here's the full source:
realtime. For code review and issue triage, this is fine.
```python ```python
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Gitea notification poller (flag-file approach). Gitea notification poller.
Polls for unread notifications and writes a flag file when new ones Polls for unread notifications and wakes OpenClaw when the count
appear. The agent checks this flag during heartbeats and processes changes. The AGENT marks notifications as read after processing —
notifications via the Gitea API directly. the poller never marks anything as read.
Required env vars: Required env vars:
GITEA_URL - Gitea instance URL GITEA_URL - Gitea instance URL
GITEA_TOKEN - Gitea API token GITEA_TOKEN - Gitea API token
HOOK_TOKEN - OpenClaw hooks auth token
Optional env vars: Optional env vars:
FLAG_PATH - Path to flag file (default: workspace/memory/gitea-notify-flag) GATEWAY_URL - OpenClaw gateway URL (default: http://127.0.0.1:18789)
POLL_DELAY - Delay between polls in seconds (default: 5) POLL_DELAY - Delay between polls in seconds (default: 2)
""" """
import json import json
@@ -240,61 +220,108 @@ import urllib.error
GITEA_URL = os.environ.get("GITEA_URL", "").rstrip("/") GITEA_URL = os.environ.get("GITEA_URL", "").rstrip("/")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
POLL_DELAY = int(os.environ.get("POLL_DELAY", "5")) GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://127.0.0.1:18789").rstrip(
FLAG_PATH = os.environ.get( "/"
"FLAG_PATH",
os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"memory",
"gitea-notify-flag",
),
) )
HOOK_TOKEN = os.environ.get("HOOK_TOKEN", "")
POLL_DELAY = int(os.environ.get("POLL_DELAY", "2"))
def check_config(): def check_config():
if not GITEA_URL or not GITEA_TOKEN: missing = []
print("ERROR: GITEA_URL and GITEA_TOKEN required", file=sys.stderr) if not GITEA_URL:
missing.append("GITEA_URL")
if not GITEA_TOKEN:
missing.append("GITEA_TOKEN")
if not HOOK_TOKEN:
missing.append("HOOK_TOKEN")
if missing:
print(
f"ERROR: Missing required env vars: {', '.join(missing)}",
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
def gitea_unread_ids(): def gitea_unread_ids():
"""Return set of unread notification IDs."""
req = urllib.request.Request( req = urllib.request.Request(
f"{GITEA_URL}/api/v1/notifications?status-types=unread", f"{GITEA_URL}/api/v1/notifications?status-types=unread",
headers={"Authorization": f"token {GITEA_TOKEN}"}, headers={"Authorization": f"token {GITEA_TOKEN}"},
) )
try: try:
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
return {n["id"] for n in json.loads(resp.read())} notifs = json.loads(resp.read())
return {n["id"] for n in notifs}
except Exception as e: except Exception as e:
print(f"WARN: Gitea API failed: {e}", file=sys.stderr, flush=True) print(
f"WARN: Gitea API failed: {e}", file=sys.stderr, flush=True
)
return set() return set()
def write_flag(count): def wake_openclaw(count):
os.makedirs(os.path.dirname(FLAG_PATH), exist_ok=True) text = (
with open(FLAG_PATH, "w") as f: f"[Gitea Notification] {count} new notification(s). "
f.write(json.dumps({ "Check your Gitea notification inbox via API, process them, "
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "and mark as read when done. "
"count": count, "Route all output to Gitea comments or Mattermost #git/#claw. "
})) "Do NOT reply to this session — respond with NO_REPLY."
)
payload = json.dumps({"text": text, "mode": "now"}).encode()
req = urllib.request.Request(
f"{GATEWAY_URL}/hooks/wake",
data=payload,
headers={
"Authorization": f"Bearer {HOOK_TOKEN}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
status = resp.status
print(f" Wake responded: {status}", flush=True)
return True
except Exception as e:
print(
f"WARN: Failed to wake OpenClaw: {e}",
file=sys.stderr,
flush=True,
)
return False
def main(): def main():
check_config() check_config()
print(f"Gitea poller started (delay={POLL_DELAY}s, flag={FLAG_PATH})", flush=True) print(
f"Gitea notification poller started (delay={POLL_DELAY}s)",
flush=True,
)
last_seen_ids = gitea_unread_ids() last_seen_ids = gitea_unread_ids()
print(f"Initial unread: {len(last_seen_ids)}", flush=True) print(
f"Initial unread: {len(last_seen_ids)} notification(s)", flush=True
)
while True: while True:
time.sleep(POLL_DELAY) time.sleep(POLL_DELAY)
current_ids = gitea_unread_ids() current_ids = gitea_unread_ids()
new_ids = current_ids - last_seen_ids new_ids = current_ids - last_seen_ids
if not new_ids: if not new_ids:
last_seen_ids = current_ids last_seen_ids = current_ids
continue continue
ts = time.strftime("%H:%M:%S") ts = time.strftime("%H:%M:%S")
print(f"[{ts}] {len(new_ids)} new ({len(current_ids)} total), flag written", flush=True) print(
write_flag(len(new_ids)) f"[{ts}] {len(new_ids)} new notification(s) "
f"({len(current_ids)} total unread), waking agent",
flush=True,
)
wake_openclaw(len(new_ids))
last_seen_ids = current_ids last_seen_ids = current_ids
@@ -1455,9 +1482,51 @@ Never send internal thinking or status narration to user's DM. Output should be:
## Gitea Integration & Notification Polling ## Gitea Integration & Notification Polling
For self-hosted Gitea instances, you can set up a notification poller that For self-hosted Gitea instances, you need a way to deliver notifications (issue
injects Gitea events (issue assignments, PR reviews, @-mentions) into the assignments, PR reviews, @-mentions) to your agent. There are two approaches,
agent's session. depending on your network setup.
### Notification Delivery: Webhooks vs Polling
#### 1. Direct webhooks (VPS / public server)
If your OpenClaw instance runs on a VPS or other publicly reachable server, you
can run Traefik (or any reverse proxy) on the same server and configure Gitea
webhooks to POST directly to OpenClaw's webhook endpoint. This is push-based and
realtime — notifications arrive instantly.
Set up a Gitea webhook (per-repo or org-wide) pointing at your OpenClaw
instance's `/hooks/wake` endpoint. Gitea sends a POST on every event, and the
agent wakes immediately to process it.
#### 2. Notification poller (local machine behind NAT)
If your OpenClaw instance runs on a dedicated local machine behind NAT (like a
home Mac or Linux box), Gitea can't reach it directly. In this case, use a
lightweight polling script that checks the Gitea notifications API every few
seconds and signals the agent when new notifications arrive.
This is the approach we use — OpenClaw runs on a dedicated Mac Studio on a home
LAN, so we poll Gitea's notification API and wake the agent via the local
`/hooks/wake` endpoint when new notifications appear. The poller script is
included below in the [Notification poller](#notification-poller) section.
The poller approach trades ~30 seconds of latency (polling interval) for
simplicity and no NAT/firewall configuration. For most workflows this is
perfectly fine — code review and issue triage don't need sub-second response
times. If no new notifications arrive between heartbeats, the effective latency
is bounded by the heartbeat interval (~30 minutes), but in practice the poller
catches most events within seconds.
#### Which should you choose?
| Factor | Webhooks | Poller |
| ------------------- | ------------------- | ------------------------- |
| Network requirement | Public IP / domain | None (outbound-only) |
| Latency | Instant | ~2-30s (polling interval) |
| Setup complexity | Reverse proxy + TLS | Single background script |
| Dependencies | Traefik/nginx/Caddy | Python stdlib only |
| Best for | VPS / cloud deploys | Home LAN / NAT setups |
### Workflow rules (HEARTBEAT.md / AGENTS.md): ### Workflow rules (HEARTBEAT.md / AGENTS.md):

File diff suppressed because it is too large Load Diff