2026-07-06 01:12:18 -04:00
|
|
|
|
#!/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())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 01:32:22 -04:00
|
|
|
|
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 ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 01:12:18 -04:00
|
|
|
|
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"]}
|
|
|
|
|
|
|
2026-07-10 01:32:22 -04:00
|
|
|
|
wh_id = cfg.get("alchemy_webhook_id")
|
2026-07-06 01:12:18 -04:00
|
|
|
|
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"]
|
2026-07-10 01:32:22 -04:00
|
|
|
|
sync_one(token, wh_id, want, "paper")
|
2026-07-06 01:12:18 -04:00
|
|
|
|
|
2026-07-10 01:32:22 -04:00
|
|
|
|
# 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")
|
2026-07-06 01:12:18 -04:00
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|