202 lines
7.3 KiB
Python
202 lines
7.3 KiB
Python
"""
|
|
Supabase Realtime Bridge — live PostgreSQL changes → Redis pub/sub → WebSocket clients.
|
|
Uses proper WebSocket protocol via 'websockets' library (httpx doesn't support WS upgrades).
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
|
|
import redis.asyncio as aioredis
|
|
import websockets
|
|
|
|
logger = logging.getLogger("supabase.realtime")
|
|
|
|
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
|
SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_SERVICE_ROLE_KEY") or ""
|
|
|
|
TABLE_CHANNELS = {
|
|
"security_alerts": "rmi:ws:alerts",
|
|
"whale_alerts": "rmi:ws:alerts",
|
|
"intelligence_alerts": "rmi:ws:alerts",
|
|
"rugpull_alerts": "rmi:ws:alerts",
|
|
"scam_alerts": "rmi:ws:alerts",
|
|
"scan_results": "rmi:ws:scans",
|
|
"market_data": "rmi:ws:market",
|
|
"market_global_metrics": "rmi:ws:market",
|
|
"market_trending_tokens": "rmi:ws:market",
|
|
"notifications": "rmi:ws:notifications",
|
|
"system_health_log": "rmi:ws:health",
|
|
"activity_feed": "rmi:ws:activity",
|
|
}
|
|
|
|
|
|
class SupabaseRealtimeBridge:
|
|
def __init__(self):
|
|
self.redis = None
|
|
self._running = False
|
|
self._reconnect_delay = 1
|
|
|
|
async def start(self):
|
|
if not SUPABASE_URL or not SUPABASE_KEY:
|
|
logger.warning("Supabase Realtime: no URL/key")
|
|
return
|
|
redis_host = os.getenv("REDIS_HOST", "rmi-redis")
|
|
redis_port = int(os.getenv("REDIS_PORT", "6379"))
|
|
redis_pass = os.getenv("REDIS_PASSWORD", "")
|
|
self.redis = await aioredis.from_url(
|
|
f"redis://{redis_host}:{redis_port}",
|
|
password=redis_pass or None,
|
|
decode_responses=True,
|
|
)
|
|
self._running = True
|
|
asyncio.create_task(self._realtime_loop())
|
|
logger.info("Supabase Realtime bridge started — %d tables → Redis", len(TABLE_CHANNELS))
|
|
|
|
async def stop(self):
|
|
self._running = False
|
|
if self.redis:
|
|
await self.redis.close()
|
|
|
|
async def _realtime_loop(self):
|
|
ws_url = SUPABASE_URL.replace("https://", "wss://") + "/realtime/v1/websocket"
|
|
params = f"?apikey={SUPABASE_KEY}"
|
|
|
|
while self._running:
|
|
try:
|
|
async with websockets.connect(
|
|
ws_url + params,
|
|
ping_interval=30,
|
|
ping_timeout=10,
|
|
max_size=2**20,
|
|
) as ws:
|
|
logger.info("Supabase Realtime WebSocket connected")
|
|
self._reconnect_delay = 1
|
|
|
|
# Subscribe to all tables
|
|
for table, _channel in TABLE_CHANNELS.items():
|
|
join_msg = {
|
|
"topic": f"realtime:public:{table}",
|
|
"event": "phx_join",
|
|
"payload": {
|
|
"config": {
|
|
"broadcast": {"self": True},
|
|
"postgres_changes": [{"event": "*", "schema": "public", "table": table}],
|
|
}
|
|
},
|
|
"ref": f"join_{table}",
|
|
}
|
|
await ws.send(json.dumps(join_msg))
|
|
logger.debug("Subscribed to %s", table)
|
|
|
|
# Read messages
|
|
while self._running:
|
|
try:
|
|
raw = await asyncio.wait_for(ws.recv(), timeout=60)
|
|
await self._handle_message(raw)
|
|
except TimeoutError:
|
|
await ws.ping()
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.warning("Realtime WS disconnected: %s — reconnecting in %ds", e, self._reconnect_delay)
|
|
await asyncio.sleep(self._reconnect_delay)
|
|
self._reconnect_delay = min(self._reconnect_delay * 2, 60)
|
|
|
|
async def _handle_message(self, raw: str):
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return
|
|
|
|
# Phoenix channels format
|
|
topic = msg.get("topic", "")
|
|
event = msg.get("event", "")
|
|
payload = msg.get("payload", {})
|
|
|
|
if not topic.startswith("realtime:public:"):
|
|
return
|
|
|
|
table = topic.replace("realtime:public:", "")
|
|
channel = TABLE_CHANNELS.get(table)
|
|
if not channel:
|
|
return
|
|
|
|
# Extract change data
|
|
if event == "phx_reply":
|
|
return # Join acknowledgement, skip
|
|
|
|
data = payload.get("data") or payload.get("record") or {}
|
|
if isinstance(data, dict):
|
|
record = data
|
|
elif isinstance(data, list) and data:
|
|
record = data[0] if isinstance(data[0], dict) else {}
|
|
else:
|
|
record = {"raw": str(data)[:500]}
|
|
|
|
out = {
|
|
"type": payload.get("eventType", event).lower(),
|
|
"table": table,
|
|
"record": record,
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
if self.redis:
|
|
await self.redis.publish(channel, json.dumps(out, default=str))
|
|
|
|
|
|
# ── HTTP polling fallback ──
|
|
async def poll_realtime_changes():
|
|
"""Poll Supabase REST API for recent changes (falls back when WebSocket unavailable)."""
|
|
import httpx
|
|
|
|
if not SUPABASE_URL or not SUPABASE_KEY:
|
|
return
|
|
headers = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}"}
|
|
redis = await aioredis.from_url(
|
|
f"redis://{os.getenv('REDIS_HOST', 'rmi-redis')}:{os.getenv('REDIS_PORT', '6379')}",
|
|
password=os.getenv("REDIS_PASSWORD", "") or None,
|
|
decode_responses=True,
|
|
)
|
|
last_seen = {}
|
|
while True:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
for table, channel in [
|
|
("security_alerts", "rmi:ws:alerts"),
|
|
("whale_alerts", "rmi:ws:alerts"),
|
|
("scan_results", "rmi:ws:scans"),
|
|
("market_data", "rmi:ws:market"),
|
|
("notifications", "rmi:ws:notifications"),
|
|
]:
|
|
last = last_seen.get(table, datetime.now(UTC).isoformat())
|
|
r = await c.get(
|
|
f"{SUPABASE_URL}/rest/v1/{table}",
|
|
params={
|
|
"select": "*",
|
|
"created_at": f"gt.{last}",
|
|
"order": "created_at.desc",
|
|
"limit": "20",
|
|
},
|
|
headers=headers,
|
|
)
|
|
if r.status_code == 200:
|
|
rows = r.json()
|
|
if rows:
|
|
for row in rows:
|
|
msg = {
|
|
"type": "poll",
|
|
"table": table,
|
|
"record": row,
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
}
|
|
await redis.publish(channel, json.dumps(msg, default=str))
|
|
last_seen[table] = datetime.now(UTC).isoformat()
|
|
except Exception as e:
|
|
logger.warning("Realtime poll error: %s", e)
|
|
await asyncio.sleep(2)
|
|
|
|
|
|
bridge = SupabaseRealtimeBridge()
|