Files
winning-wallet-finder/live/sync_webhook.py
T

92 lines
3.6 KiB
Python
Raw Normal View History

#!/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 sync_one(token, wh_id, want, label):
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] {label} in sync — {len(want)} addresses on {wh_id}")
return
call("PATCH", "/update-webhook-addresses", token,
{"webhook_id": wh_id, "addresses_to_add": add, "addresses_to_remove": drop})
print(f"[webhook-sync] {label} {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 ""))
def main():
try:
cfg = json.load(open(os.path.join(HERE, "..", "config.json")))
except Exception:
cfg = {}
token = cfg.get("alchemy_notify_token")
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"]}
wh_id = cfg.get("alchemy_webhook_id")
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"]
sync_one(token, wh_id, want, "paper")
# the live app's own webhook (2026-07-10) — same follow set, separate
# webhook + signing key so the two books share nothing
wh_live = cfg.get("alchemy_webhook_id_live")
if wh_live:
sync_one(token, wh_live, want, "live")
return 0
if __name__ == "__main__":
sys.exit(main())