rmi-backend/app/supabase_realtime.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

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()