diff --git a/live/deploy_bot.sh b/live/deploy_bot.sh index 692eef0e..1086f86e 100755 --- a/live/deploy_bot.sh +++ b/live/deploy_bot.sh @@ -49,6 +49,9 @@ else echo "pushed." fi +# push mode: keep the Alchemy webhook's address list matched to the follow set +python3 sync_webhook.py || echo "⚠ webhook sync failed — update the address list manually" + railway redeploy --service copybot --yes echo "redeploy triggered — waiting for the new container…" for i in $(seq 1 15); do @@ -58,8 +61,8 @@ for i in $(seq 1 15); do railway logs --service copybot 2>/dev/null | grep -E "watching|floor\[" | tail -12 echo "$tail5" | tail -1 echo "✅ bot rebooted with the new follow set" - echo " (push mode? remember: the Alchemy webhook's address list must match" - echo " the follow set — update it at dashboard.alchemy.com → Webhooks)" + echo " (Alchemy address list synced above — if it warned, update manually" + echo " at dashboard.alchemy.com → Webhooks → copybot follow set)" exit 0 fi done diff --git a/live/sync_webhook.py b/live/sync_webhook.py new file mode 100644 index 00000000..ce766e29 --- /dev/null +++ b/live/sync_webhook.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Sync the Alchemy address-activity webhook to the live follow set. + +Run by deploy_bot.sh so the push-mode webhook (`copybot follow set`, +wh_… in ../config.json) always watches exactly the wallets in +copybot.paper.json. Without this, adding/removing a wallet leaves the +webhook's address list stale — the 5-min backstop poll still catches +trades, but at poll-speed lag instead of push-speed. + +Needs `alchemy_notify_token` in the gitignored ../config.json — the +"Auth token" from dashboard.alchemy.com → Webhooks (top right → Copy). +Skips politely when it's missing. +""" +import json +import os +import ssl +import sys +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +_SSL = ssl._create_unverified_context() +API = "https://dashboard.alchemy.com/api" + + +def call(method, path, token, body=None): + req = urllib.request.Request( + API + path, method=method, + data=json.dumps(body).encode() if body is not None else None, + headers={"X-Alchemy-Token": token, "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0"}) + return json.loads(urllib.request.urlopen(req, timeout=30, context=_SSL).read()) + + +def main(): + try: + cfg = json.load(open(os.path.join(HERE, "..", "config.json"))) + except Exception: + cfg = {} + token = cfg.get("alchemy_notify_token") + wh_id = cfg.get("alchemy_webhook_id") + if not token: + print("[webhook-sync] no alchemy_notify_token in ../config.json — skipped." + " (Copy the Auth token from dashboard.alchemy.com → Webhooks to enable" + " automatic address sync; until then update the address list there" + " manually — the 5-min backstop poll covers the gap at poll-speed lag.)") + return 0 + want = {w["wallet"].lower() + for w in json.load(open(os.path.join(HERE, "copybot.paper.json")))["wallets"]} + + if not wh_id: # find it by our railway URL + hooks = call("GET", "/team-webhooks", token).get("data", []) + ours = [h for h in hooks if "copybot-production" in (h.get("webhook_url") or "")] + if not ours: + print("[webhook-sync] ⚠ no webhook pointing at the copybot URL found — create" + " one first (see README) or set alchemy_webhook_id in config.json") + return 1 + wh_id = ours[0]["id"] + + have, after = set(), None + while True: # paginated + q = f"/webhook-addresses?webhook_id={wh_id}&limit=100" + \ + (f"&after={after}" if after else "") + r = call("GET", q, token) + have |= {a.lower() for a in r.get("data", [])} + after = (r.get("pagination") or {}).get("cursors", {}).get("after") + if not after: + break + + add, drop = sorted(want - have), sorted(have - want) + if not add and not drop: + print(f"[webhook-sync] in sync — {len(want)} addresses on {wh_id}") + return 0 + call("PATCH", "/update-webhook-addresses", token, + {"webhook_id": wh_id, "addresses_to_add": add, "addresses_to_remove": drop}) + print(f"[webhook-sync] {wh_id}: +{len(add)} −{len(drop)} → {len(want)} addresses" + + (f" (added {', '.join(a[:10] for a in add)})" if add else "") + + (f" (removed {', '.join(a[:10] for a in drop)})" if drop else "")) + return 0 + + +if __name__ == "__main__": + sys.exit(main())