Some checks failed
CI / build (push) Failing after 3s
PHASE 2.3 (AUDIT-2026-Q3.md):
Task 1 — Wire-in Wave 3 (1 router mounted, 2 deferred):
- app.routers.unified_scanner_router mounted at /api/v2/scanner/* (2 routes:
POST /api/v2/scanner/token/scan, POST /api/v2/scanner/wallet/scan).
Refactored prefix from /api/v2 -> /api/v2/scanner to avoid future conflicts
with the v1 /api/v1/scanner/ stub.
- app.routers.unified_wallet_scanner DEFERRED (no router APIRouter attribute;
library module consumed by unified_scanner_router via get_wallet_scanner()).
- app.routers.admin_extensions DEFERRED (DORMANT per audit; 25 routes at
/api/v1/admin/* would shadow /api/v1/admin/alerts_webhook).
Task 2 — Archive 136 dead-code files to app/_archive/legacy_2026_07/:
- 73 routers in app/routers/ (reach graph showed zero reach into mount.py).
- 63 flat app/*.py (domain modules never imported by live code).
- 1 file RESTORED post-archive: app/routers/x402_bridge_health.py (caught by
tests/unit/test_bridge_health.py which directly imports it; reach graph
considered tests/ only as transitive reach — to be patched in next cycle).
Forced-LIVE (NOT archived per user directive):
- app/ai_pipeline_v3.py (3 importers in audit window, importers themselves DEAD)
- app/splade_bm25.py (LIVE via app.rag_service)
- app/wallet_manager_v2.py (LIVE via x402_enforcement, x402_tools, sweep_all, sweep_now)
- app/crypto_embeddings.py (NOT in audit ARCHIVE list; heavy import graph)
Verification (forward-import closure from mount.py + main.py + factory.py + lifespan.py):
- imports = 348 app.* modules
- reached = 194 files reachable from roots
- archive set = audit_dead (186) - reached - forced_live (4) - test_live (1) = 136
- Net delta: 136 files moved, 44,932 LOC reduction, 293->295 active routes (+2 from Wave 3)
pyproject.toml updates:
- setuptools.packages.find: added exclude for app._archive*
- ruff.extend-exclude: added "app/_archive/"
- mypy.exclude: added "app/_archive/"
Smoke test: pytest tests/ — 817 passed, 3 pre-existing failures unchanged
(0 new failures; 0 routes lost; all 4 forced-LIVE files still importable).
Restoration: git mv app/_archive/legacy_2026_07/<name>.py <original-path>
and add the import to app/mount.py ROUTER_MODULES.
Refs: AUDIT-2026-Q3.md /home/dev/pry/rmi-final-deadcode-2026-07-06.md
356 lines
11 KiB
Python
356 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Auto-Alerting Pipeline for RMI Platform
|
||
Send real-time alerts from detections to Telegram + webhooks
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
from dataclasses import asdict, dataclass
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import redis
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from app.core.logging import get_logger
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
class AlertLevel(Enum):
|
||
CRITICAL = "CRITICAL"
|
||
WARNING = "WARNING"
|
||
INFO = "INFO"
|
||
DEBUG = "DEBUG"
|
||
|
||
|
||
@dataclass
|
||
class Alert:
|
||
source: str
|
||
alert_type: str
|
||
level: str # "CRITICAL", "WARNING", "INFO", "DEBUG"
|
||
wallet_address: str
|
||
token_symbol: str
|
||
message: str
|
||
timestamp: str
|
||
confidence: float = 0.0
|
||
metadata: dict[str, Any] = None
|
||
|
||
def to_dict(self) -> dict:
|
||
return asdict(self)
|
||
|
||
def to_telegram_message(self) -> str:
|
||
"""Format alert as Telegram message"""
|
||
level_emoji = {"CRITICAL": "🚨", "WARNING": "⚠️", "INFO": "ℹ️", "DEBUG": "🔍"} # noqa: RUF001
|
||
return (
|
||
f"{level_emoji.get(self.level, 'ℹ️')} *{self.alert_type.upper()}* [{self.level}]\n\n" # noqa: RUF001
|
||
f" Wallet: `{self.wallet_address[:10]}...{self.wallet_address[-6:]}`\n"
|
||
f" Token: `{self.token_symbol}`\n"
|
||
f" Confidence: {self.confidence:.0%}\n\n"
|
||
f"{self.message}\n\n"
|
||
f"📅 {self.timestamp}"
|
||
)
|
||
|
||
|
||
# Configuration
|
||
REDIS_HOST = os.getenv("REDIS_HOST", "172.20.0.3")
|
||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
||
|
||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||
TELEGRAM_ALERTS_CHANNEL = os.getenv("TELEGRAM_ALERTS_CHANNEL", "") # -100xxx format
|
||
|
||
WEBHOOK_URLS = [
|
||
os.getenv("ALERT_WEBHOOK_URL", ""),
|
||
os.getenv("SLACK_ALERT_WEBHOOK", ""),
|
||
]
|
||
|
||
# Rate limiting (max alerts per minute)
|
||
RATE_LIMIT_MAX = 5
|
||
RATE_LIMIT_WINDOW = 60 # seconds
|
||
|
||
# API Router
|
||
router = APIRouter(prefix="/api/v1/alerts", tags=["alerting"])
|
||
|
||
|
||
# Rate limit tracking via Redis
|
||
def get_rate_limit_key() -> str:
|
||
"""Get Redis key for rate limiting"""
|
||
return "alerting:rate_limit:counts"
|
||
|
||
|
||
def check_and_update_rate_limit() -> bool:
|
||
"""Check rate limit and increment counter. Returns True if under limit."""
|
||
try:
|
||
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True)
|
||
key = get_rate_limit_key()
|
||
current_time = int(datetime.now().timestamp())
|
||
window_key = f"{key}:{current_time // RATE_LIMIT_WINDOW}"
|
||
|
||
count = r.incr(window_key)
|
||
r.expire(window_key, RATE_LIMIT_WINDOW * 2)
|
||
|
||
return not count > RATE_LIMIT_MAX
|
||
except Exception as e:
|
||
logger.warning(f"Rate limit check failed: {e}")
|
||
return True # Allow on error
|
||
|
||
|
||
# Send to Telegram
|
||
async def send_to_telegram(alert: Alert) -> bool:
|
||
"""Send alert to Telegram channel"""
|
||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_ALERTS_CHANNEL:
|
||
logger.info("Telegram config missing, skipping")
|
||
return False
|
||
|
||
if not check_and_update_rate_limit():
|
||
logger.info("Rate limit exceeded, alert queued")
|
||
return False
|
||
|
||
try:
|
||
import telegram
|
||
|
||
bot = telegram.Bot(token=TELEGRAM_BOT_TOKEN)
|
||
message = alert.to_telegram_message()
|
||
await bot.send_message(chat_id=TELEGRAM_ALERTS_CHANNEL, text=message, parse_mode="MarkdownV2")
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"Telegram send failed: {e}")
|
||
return False
|
||
|
||
|
||
# Send to webhooks
|
||
async def send_to_webhooks(alert: Alert) -> list[dict]:
|
||
"""Send alert to configured webhooks"""
|
||
results = []
|
||
|
||
for url in WEBHOOK_URLS:
|
||
if not url:
|
||
continue
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
response = await client.post(
|
||
url,
|
||
json={"alert": alert.to_dict(), "timestamp": datetime.now().isoformat()},
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
results.append(
|
||
{
|
||
"url": url,
|
||
"status": response.status_code,
|
||
"success": response.status_code == 200,
|
||
}
|
||
)
|
||
except Exception as e:
|
||
results.append({"url": url, "status": "error", "error": str(e), "success": False})
|
||
|
||
return results
|
||
|
||
|
||
# Alert storage in Redis
|
||
def cache_alert(alert: Alert, ttl: int = 3600) -> bool:
|
||
"""Cache alert in Redis for downstream processing"""
|
||
try:
|
||
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True)
|
||
key = f"alerts:{alert.alert_type}:{alert.wallet_address}"
|
||
alert_json = json.dumps(alert.to_dict())
|
||
r.hset(
|
||
key,
|
||
mapping={
|
||
"alert_json": alert_json,
|
||
"created_at": datetime.now().isoformat(),
|
||
"level": alert.level,
|
||
},
|
||
)
|
||
r.expire(key, ttl)
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"Cache alert failed: {e}")
|
||
return False
|
||
|
||
|
||
# Process and dispatch alert
|
||
async def process_alert(alert: Alert) -> dict[str, Any]:
|
||
"""Process alert: send to Telegram, webhooks, cache"""
|
||
results = {
|
||
"telegram": False,
|
||
"webhooks": [],
|
||
"cached": False,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}
|
||
|
||
# Send to Telegram
|
||
if alert.level == "CRITICAL":
|
||
results["telegram"] = await send_to_telegram(alert)
|
||
|
||
# Send to webhooks (always for all levels)
|
||
if alert.level in ["CRITICAL", "WARNING"]:
|
||
results["webhooks"] = await send_to_webhooks(alert)
|
||
|
||
# Cache alert
|
||
results["cached"] = cache_alert(alert)
|
||
|
||
return results
|
||
|
||
|
||
# API Endpoints
|
||
class AlertCreateRequest(BaseModel):
|
||
source: str
|
||
alert_type: str
|
||
level: str = "INFO"
|
||
wallet_address: str
|
||
token_symbol: str
|
||
message: str
|
||
confidence: float = 0.0
|
||
metadata: dict[str, Any] = None
|
||
|
||
|
||
@router.post("/create")
|
||
async def create_alert(request: AlertCreateRequest):
|
||
"""Create and dispatch a new alert"""
|
||
alert = Alert(
|
||
source=request.source,
|
||
alert_type=request.alert_type,
|
||
level=request.level,
|
||
wallet_address=request.wallet_address,
|
||
token_symbol=request.token_symbol,
|
||
message=request.message,
|
||
timestamp=datetime.now().isoformat(),
|
||
confidence=request.confidence,
|
||
metadata=request.metadata or {},
|
||
)
|
||
|
||
results = await process_alert(alert)
|
||
|
||
return {
|
||
"success": True,
|
||
"alert_id": f"{alert.alert_type}:{alert.wallet_address}",
|
||
"dispatch": results,
|
||
}
|
||
|
||
|
||
@router.post("/webhook")
|
||
async def webhook_alert(notification: dict[str, Any]):
|
||
"""Receive external webhook and convert to internal alert"""
|
||
source = notification.get("source", "external")
|
||
alert_type = notification.get("alert_type", "unknown")
|
||
level = notification.get("level", "INFO")
|
||
|
||
alert = Alert(
|
||
source=source,
|
||
alert_type=alert_type,
|
||
level=level,
|
||
wallet_address=notification.get("wallet_address", "unknown"),
|
||
token_symbol=notification.get("token_symbol", "unknown"),
|
||
message=notification.get("message", ""),
|
||
timestamp=datetime.now().isoformat(),
|
||
confidence=notification.get("confidence", 0.0),
|
||
metadata=notification.get("metadata", {}),
|
||
)
|
||
|
||
results = await process_alert(alert)
|
||
|
||
return {
|
||
"success": True,
|
||
"alert_id": f"{alert.alert_type}:{alert.wallet_address}",
|
||
"dispatch": results,
|
||
}
|
||
|
||
|
||
@router.get("/status")
|
||
async def get_alert_status():
|
||
"""Get current alerting system status"""
|
||
try:
|
||
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True)
|
||
total_alerts = r.dbsize()
|
||
|
||
# Count alerts by type
|
||
alert_counts = {}
|
||
for key in r.scan_iter("alerts:*"):
|
||
if ":" in key:
|
||
alert_type = key.split(":")[1]
|
||
alert_counts[alert_type] = alert_counts.get(alert_type, 0) + 1
|
||
|
||
return {
|
||
"status": "ok",
|
||
"redis_connected": True,
|
||
"total_alerts_cached": total_alerts,
|
||
"alerts_by_type": alert_counts,
|
||
"rate_limit": {"max_per_minute": RATE_LIMIT_MAX, "window_seconds": RATE_LIMIT_WINDOW},
|
||
"webhooks_configured": len([w for w in WEBHOOK_URLS if w]),
|
||
"telegram_channel": TELEGRAM_ALERTS_CHANNEL if TELEGRAM_ALERTS_CHANNEL else "not_configured",
|
||
}
|
||
except Exception as e:
|
||
return {"status": "error", "error": str(e)}
|
||
|
||
|
||
@router.get("/recent")
|
||
async def get_recent_alerts(limit: int = 10):
|
||
"""Get recent alerts from cache"""
|
||
try:
|
||
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True)
|
||
|
||
alerts = []
|
||
for key in r.scan_iter("alerts:*", count=limit):
|
||
alert_data = r.hgetall(key)
|
||
if alert_data and "alert_json" in alert_data:
|
||
alert = json.loads(alert_data["alert_json"])
|
||
alerts.append(alert)
|
||
|
||
# Sort by timestamp (newest first)
|
||
alerts.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||
|
||
return {"success": True, "count": len(alerts), "alerts": alerts[:limit]}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||
|
||
|
||
# Background task: monitor alert queue
|
||
async def alert_pipeline_monitor():
|
||
"""Monitor Redis for new alerts from agents"""
|
||
logger.info(f"[{datetime.now().isoformat()}] Alert pipeline monitor started")
|
||
while True:
|
||
try:
|
||
r = redis.Redis(
|
||
host=REDIS_HOST,
|
||
port=REDIS_PORT,
|
||
password=REDIS_PASSWORD or None,
|
||
decode_responses=True,
|
||
)
|
||
|
||
# Check for alerts in queue
|
||
alert_json = r.rpop("social_monitor_queue")
|
||
if alert_json:
|
||
alert = json.loads(alert_json)
|
||
logger.info(f"Processing alert from queue: {alert.get('alert_type', 'unknown')}")
|
||
# Process alert through pipeline
|
||
result = await process_alert(Alert(**alert))
|
||
logger.info(f"Dispatch result: {result}")
|
||
alert_json = r.rpop("chain_walker_queue")
|
||
if alert_json:
|
||
alert = json.loads(alert_json)
|
||
logger.info(f"Processing chain-walker alert: {alert.get('alert_type', 'unknown')}")
|
||
result = await process_alert(Alert(**alert))
|
||
logger.info(f"Dispatch result: {result}")
|
||
# Check syndicate detection alerts
|
||
alert_json = r.rpop("syndicate_alerts")
|
||
if alert_json:
|
||
alert = json.loads(alert_json)
|
||
logger.info(f"Processing syndicate alert: {alert.get('wallet_address', 'unknown')}")
|
||
result = await process_alert(Alert(**alert))
|
||
logger.info(f"Dispatch result: {result}")
|
||
await asyncio.sleep(5) # Poll every 5 seconds
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Alert pipeline monitor error: {e}")
|
||
await asyncio.sleep(10)
|
||
|
||
|
||
def get_alert_pipeline_router():
|
||
"""Get the alerting router"""
|
||
return router
|