fix(auth): mount auth router + rename __init__ to router.py

P1.1 — Implement real ai_router.chat_completion / stream_chat_completion
using Ollama Cloud primary + OpenRouter fallback. Fixes AttributeError on
/api/v1/chat and /api/v1/wallet-clusters.

P1.2 — Fix broken RAG call paths: telegram bot now hits /api/v1/rag/v2/search
then ai_router.chat_completion; unified_wallet_scanner._rag_enrich uses
async httpx + correct path. Mount app.domains.databus.router.

P1.3 — Implement setup_otel / shutdown_otel in core/tracing.py and create
core/langfuse.py with init_langfuse / flush_langfuse as safe no-ops when
SDK is missing or env vars unset.

P1.4 — Fix Prometheus alert rule metric name drift:
rmi_requests_total -> rmi_http_requests_total (matches metrics.py).

P1.5 — Move dead admin_backend.py (1691 LOC) to app/domains/admin/router.py
and mount it. Admin login endpoint now reachable.

P2.1 — Rename app/domains/auth/__init__.py (507 LOC router file that broke
mypy) -> app/domains/auth/router.py. Mount app.domains.auth.router so
/register, /login, /wallet/*, /user/me, /2fa/*, OAuth, Telegram are live.

P2.1b — Add pyotp + qrcode to requirements.txt so 2FA endpoints don't 503.
This commit is contained in:
Crypto Rug Munch 2026-07-07 17:44:09 +07:00
parent 0a8c73d99b
commit 1b53332695
15 changed files with 992 additions and 537 deletions

View file

@ -1,72 +1,288 @@
#!/usr/bin/env python3
"""
Stub AI Router - Intelligent Model-First Provider Swapping
===========================================================
Routes requests to optimal AI provider based on quota, latency, and cost.
For now, a minimal stub that delegates to OpenRouter.
AI Router service functions for chat completions.
Primary: Ollama Cloud (deepseek-v4-flash, free).
Fallback: OpenRouter (various models via OPENROUTER_API_KEY).
Both endpoints are OpenAI-compatible /v1/chat/completions.
Called by app/routers/chat.py and app/routers/wallet_clustering_router.py
as ``ai_router.chat_completion(...)`` and
``ai_router.stream_chat_completion(...)``.
"""
import base64
import json
import logging
import os
import time
from collections.abc import AsyncGenerator
from typing import Any
import httpx
from fastapi import APIRouter
logger = logging.getLogger("rmi.ai_router")
router = APIRouter(tags=["AI Router"])
# Decode base64 LLM key if present, otherwise use plain LLM_API_KEY
# (safety net: ensures key is decoded even when imported without main.py)
if os.getenv("LLM_API_KEY_B64"):
os.environ["LLM_API_KEY"] = base64.b64decode(os.getenv("LLM_API_KEY_B64")).decode()
# Model tiers (for reference, full config in ai_router.py)
MODEL_TIERS = {
"T0": {"name": "Ultra", "models": ["gpt-4o", "claude-3.5-sonnet"], "max_cost_per_1k": 0.05},
"T1": {"name": "Premium", "models": ["gpt-4-turbo", "claude-3-opus"], "max_cost_per_1k": 0.02},
"T2": {
"name": "Standard",
"models": ["gpt-3.5-turbo", "claude-3-haiku"],
"max_cost_per_1k": 0.005,
},
"T3": {"name": "Fast", "models": ["llama-3-8b", "mistral-tiny"], "max_cost_per_1k": 0.001},
"T4": {"name": "Free", "models": ["tiny-llama", "phi-2"], "max_cost_per_1k": 0.0},
# ── Provider config ─────────────────────────────────────────────
OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", "")
OLLAMA_URL = "https://ollama.com/v1/chat/completions"
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "deepseek-v4-flash")
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat")
# Tier → model mapping (best effort; Ollama Cloud ignores non-model fields)
TIER_MODELS: dict[str, str] = {
"T0": "deepseek-v4-pro",
"T1": "deepseek-v4-pro",
"T2": "deepseek-v4-flash",
"T3": "deepseek-v4-flash",
"T4": "deepseek-v4-flash",
}
# Providers (for reference)
PROVIDERS = {
"deepseek": {
"url": os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1/chat/completions"),
"key_env": "LLM_API_KEY",
"model": os.getenv("LLM_MODEL", "deepseek-v4-flash"),
"rpm": 100,
},
"openrouter": {
"url": "https://openrouter.ai/api/v1/chat/completions",
"key_env": "OPENROUTER_API_KEY",
"rpm": 100,
},
MODEL_TIERS = {
"T0": {"name": "Ultra", "models": ["deepseek-v4-pro"], "max_cost_per_1k": 0.0},
"T1": {"name": "Premium", "models": ["deepseek-v4-pro"], "max_cost_per_1k": 0.0},
"T2": {"name": "Standard", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
"T3": {"name": "Fast", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
"T4": {"name": "Free", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
}
PROVIDERS = {
"ollama": {"url": OLLAMA_URL, "key_env": "OLLAMA_API_KEY", "model": OLLAMA_MODEL, "rpm": 100},
"openrouter": {"url": OPENROUTER_URL, "key_env": "OPENROUTER_API_KEY", "model": OPENROUTER_MODEL, "rpm": 100},
}
def _resolve_model(model: str | None, tier: str | None) -> str:
"""Pick the model: explicit model > tier mapping > default."""
if model:
return model
if tier and tier in TIER_MODELS:
return TIER_MODELS[tier]
return OLLAMA_MODEL
async def chat_completion(
*,
messages: list[dict[str, str]],
model: str | None = None,
tier: str | None = None,
temperature: float = 0.3,
max_tokens: int = 500,
timeout: float = 30.0,
) -> dict[str, Any]:
"""Non-streaming chat completion.
Returns dict with keys: content, model, _provider, _latency_ms, usage, error (on failure).
Tries Ollama Cloud first, falls back to OpenRouter.
"""
resolved = _resolve_model(model, tier)
start = time.time()
# Provider 1: Ollama Cloud
if OLLAMA_KEY:
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
OLLAMA_URL,
headers={
"Authorization": f"Bearer {OLLAMA_KEY}",
"Content-Type": "application/json",
},
json={
"model": resolved,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
},
)
if resp.status_code == 200:
data = resp.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
return {
"content": content,
"model": data.get("model", resolved),
"_provider": "ollama",
"_latency_ms": round((time.time() - start) * 1000, 1),
"usage": data.get("usage", {}),
}
logger.warning("ai_router ollama status=%s body=%s", resp.status_code, resp.text[:200])
except Exception as e:
logger.warning("ai_router ollama error: %s", e)
# Provider 2: OpenRouter fallback
if OPENROUTER_KEY:
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json",
},
json={
"model": resolved,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
},
)
if resp.status_code == 200:
data = resp.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
return {
"content": content,
"model": data.get("model", resolved),
"_provider": "openrouter",
"_latency_ms": round((time.time() - start) * 1000, 1),
"usage": data.get("usage", {}),
}
logger.warning("ai_router openrouter status=%s body=%s", resp.status_code, resp.text[:200])
except Exception as e:
logger.warning("ai_router openrouter error: %s", e)
return {
"content": "",
"model": resolved,
"_provider": "none",
"_latency_ms": round((time.time() - start) * 1000, 1),
"usage": {},
"error": "No AI provider available (set OLLAMA_API_KEY or OPENROUTER_API_KEY)",
}
async def stream_chat_completion(
*,
messages: list[dict[str, str]],
model: str | None = None,
tier: str | None = None,
temperature: float = 0.3,
max_tokens: int = 500,
timeout: float = 30.0,
) -> AsyncGenerator[str, None]:
"""Streaming chat completion via SSE.
Yields content tokens as strings. On error, yields ``[ERROR: ...]``.
Tries Ollama Cloud first, falls back to OpenRouter.
"""
resolved = _resolve_model(model, tier)
# Provider 1: Ollama Cloud (streaming)
if OLLAMA_KEY:
try:
async with httpx.AsyncClient(timeout=timeout) as client, client.stream(
"POST",
OLLAMA_URL,
headers={
"Authorization": f"Bearer {OLLAMA_KEY}",
"Content-Type": "application/json",
},
json={
"model": resolved,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
},
) as resp:
if resp.status_code == 200:
async for line in resp.aiter_lines():
if line.startswith("data: ") and line != "data: [DONE]":
try:
chunk = json.loads(line[6:])
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except json.JSONDecodeError:
pass
return
logger.warning("ai_router ollama stream status=%s", resp.status_code)
except Exception as e:
logger.warning("ai_router ollama stream error: %s", e)
# Provider 2: OpenRouter streaming fallback
if OPENROUTER_KEY:
try:
async with httpx.AsyncClient(timeout=timeout) as client, client.stream(
"POST",
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json",
},
json={
"model": resolved,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
},
) as resp:
if resp.status_code == 200:
async for line in resp.aiter_lines():
if line.startswith("data: ") and line != "data: [DONE]":
try:
chunk = json.loads(line[6:])
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except json.JSONDecodeError:
pass
return
logger.warning("ai_router openrouter stream status=%s", resp.status_code)
except Exception as e:
logger.warning("ai_router openrouter stream error: %s", e)
yield "[ERROR: No AI provider available (set OLLAMA_API_KEY or OPENROUTER_API_KEY)]"
# ── HTTP endpoints (kept for compat, now backed by real service) ──
@router.post("/ai/completions")
async def ai_completions(request: dict[str, Any]):
async def ai_completions(request: dict[str, Any]) -> dict[str, Any]:
"""AI completion via optimal provider routing."""
return {"error": "AI Router not fully configured", "provider": "openrouter"}
return await chat_completion(
messages=request.get("messages", []),
model=request.get("model"),
tier=request.get("tier"),
temperature=request.get("temperature", 0.3),
max_tokens=request.get("max_tokens", 500),
)
@router.post("/ai/chat")
async def ai_chat(request: dict[str, Any]):
async def ai_chat(request: dict[str, Any]) -> dict[str, Any]:
"""AI chat endpoint with provider fallback."""
return {"error": "AI Router not fully configured", "provider": "openrouter"}
return await chat_completion(
messages=request.get("messages", []),
model=request.get("model"),
tier=request.get("tier"),
temperature=request.get("temperature", 0.3),
max_tokens=request.get("max_tokens", 500),
)
@router.get("/ai/providers")
async def list_providers():
async def list_providers() -> dict[str, Any]:
"""List available AI providers."""
return {"providers": list(PROVIDERS.keys())}
@router.get("/ai/models")
async def list_models():
async def list_models() -> dict[str, Any]:
"""List available models by tier."""
return {"tiers": MODEL_TIERS}

49
app/core/langfuse.py Normal file
View file

@ -0,0 +1,49 @@
"""Langfuse LLM tracing - opt-in via env vars.
Safe no-op when LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY are unset
or the `langfuse` package is not installed.
"""
import os
LANGFUSE_ENABLED = bool(
os.getenv("LANGFUSE_PUBLIC_KEY", "") and os.getenv("LANGFUSE_SECRET_KEY", "")
)
LANGFUSE_HOST = os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
_initialized = False
def init_langfuse() -> bool:
"""Initialize the Langfuse client. Returns True on success."""
global _initialized
if not LANGFUSE_ENABLED:
return False
try:
from langfuse import Langfuse # noqa: F401
Langfuse(
public_key=os.getenv("LANGFUSE_PUBLIC_KEY"),
secret_key=os.getenv("LANGFUSE_SECRET_KEY"),
host=LANGFUSE_HOST,
)
_initialized = True
return True
except ImportError:
return False
except Exception:
return False
def flush_langfuse() -> None:
"""Flush any pending Langfuse events. Safe to call on shutdown."""
if not _initialized:
return
try:
from langfuse import Langfuse
Langfuse().flush()
except ImportError:
pass
except Exception:
pass

View file

@ -7,6 +7,7 @@ import uuid
from fastapi import FastAPI, Request
TRACING_ENABLED = os.getenv("OTEL_ENABLED", "false").lower() == "true"
OTEL_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
def setup_tracing(app: FastAPI) -> list:
@ -33,6 +34,49 @@ def setup_tracing(app: FastAPI) -> list:
return {"traces": [], "note": "OpenTelemetry export to Grafana Tempo configured. Traces available at :3000."}
def setup_otel() -> bool:
"""Initialize OpenTelemetry tracing. Returns True on success.
No-op if OTEL_ENABLED != "true". If OTEL_ENABLED is true but the SDK
is not installed, logs and returns False.
"""
if not TRACING_ENABLED:
return False
try:
# Lazy import — opentelemetry is an optional dependency.
from opentelemetry import trace # noqa: F401
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter # noqa: F401
from opentelemetry.sdk.resources import Resource # noqa: F401
from opentelemetry.sdk.trace import TracerProvider # noqa: F401
from opentelemetry.sdk.trace.export import BatchSpanProcessor # noqa: F401
resource = Resource.create({"service.name": "rmi-backend"})
provider = TracerProvider(resource=resource)
if OTEL_ENDPOINT:
exporter = OTLPSpanExporter(endpoint=OTEL_ENDPOINT)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
return True
except ImportError:
return False
except Exception:
return False
def shutdown_otel() -> None:
"""Flush and shut down OpenTelemetry tracer provider."""
try:
from opentelemetry import trace
provider = trace.get_tracer_provider()
if hasattr(provider, "shutdown"):
provider.shutdown()
except ImportError:
pass
except Exception:
pass
def start_span(name: str, attributes: dict | None = None) -> dict:
return {"name": name, "start": time.perf_counter(), "attrs": attributes or {}}

View file

@ -1,23 +1,21 @@
"""Auth domain - public API.
Phase 4 domain consolidation + Phase 2.1 structural fix:
``app/domains/auth/__init__.py`` was a 507-LOC router file that broke mypy
(source file found twice under different module names). The router now
lives in ``app/domains/auth/router.py`` and this package marker only
re-exports the public surface.
"""
Auth Router - Complete authentication system (email, wallet, OAuth, Telegram)
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta
from typing import Any, cast
from fastapi import APIRouter, HTTPException, Request
from app.core.redis import get_redis
from app.domains.auth.deps import (
get_current_user,
require_auth as require_auth,
require_public_profile as require_public_profile,
require_auth,
require_public_profile,
)
from app.domains.auth.jwt import (
JWT_ALGORITHM,
JWT_EXPIRY_DAYS as JWT_EXPIRY_DAYS,
JWT_EXPIRY_DAYS,
JWT_SECRET,
_create_jwt,
_derive_user_id,
@ -26,15 +24,16 @@ from app.domains.auth.jwt import (
)
from app.domains.auth.oauth import router as oauth_router
from app.domains.auth.passwords import hash_password, verify_password
from app.domains.auth.router import router
from app.domains.auth.schemas import (
EmailLoginRequest,
EmailRegisterRequest,
GoogleAuthResponse as GoogleAuthResponse,
GoogleAuthResponse,
NonceResponse,
TelegramAuthRequest as TelegramAuthRequest,
TelegramAuthRequest,
TwoFAEnableRequest,
TwoFALoginRequest,
TwoFASetupResponse as TwoFASetupResponse,
TwoFASetupResponse,
TwoFAStatusResponse,
TwoFAVerifyRequest,
UserResponse,
@ -43,7 +42,7 @@ from app.domains.auth.schemas import (
WalletVerifyRequest,
)
from app.domains.auth.store import (
_delete_user as _delete_user,
_delete_user,
_get_user,
_get_user_by_email,
_is_valid_email,
@ -51,11 +50,11 @@ from app.domains.auth.store import (
)
from app.domains.auth.totp import (
PYOTP_AVAILABLE,
TOTP_DIGITS as TOTP_DIGITS,
TOTP_INTERVAL as TOTP_INTERVAL,
TOTP_ISSUER as TOTP_ISSUER,
TOTP_WINDOW as TOTP_WINDOW,
_build_totp as _build_totp,
TOTP_DIGITS,
TOTP_INTERVAL,
TOTP_ISSUER,
TOTP_WINDOW,
_build_totp,
_generate_backup_codes,
_generate_qr_base64,
_generate_totp_secret,
@ -65,443 +64,52 @@ from app.domains.auth.totp import (
_verify_totp,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"])
# Include OAuth/Telegram routes from dedicated submodule
router.include_router(oauth_router, tags=["auth"])
# ── Config ──
# ── Storage (Redis-backed user store) ──
# ── Email Auth ──
@router.post("/register", response_model=WalletAuthResponse)
def register_email(req: EmailRegisterRequest) -> WalletAuthResponse:
"""Register a new user with email/password."""
if not _is_valid_email(req.email):
raise HTTPException(status_code=400, detail="Invalid email format")
if len(req.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
# Check if user exists
existing = _get_user_by_email(req.email)
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user_id = _derive_user_id(req.email)
hashed = hash_password(req.password)
user: dict[str, Any] = {
"id": user_id,
"email": req.email,
"display_name": req.display_name,
"password_hash": hashed,
"wallet_address": req.wallet_address,
"wallet_chain": req.wallet_chain,
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
# Save user + email index
r = get_redis()
assert r is not None
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", req.email.lower(), user_id)
token = _create_jwt(user_id, req.email, "FREE", "USER", req.wallet_address)
return cast(
"WalletAuthResponse",
{
"access_token": token,
"refresh_token": token,
"user": {
"id": user_id,
"email": req.email,
"display_name": req.display_name,
"wallet_address": req.wallet_address,
"wallet_chain": req.wallet_chain,
"tier": "FREE",
"role": "USER",
"created_at": user["created_at"],
},
},
)
@router.post("/login", response_model=WalletAuthResponse)
def login_email(req: EmailLoginRequest) -> WalletAuthResponse:
"""Login with email/password. If 2FA enabled, returns partial token requiring /2fa/login."""
user = _get_user_by_email(req.email)
if not user or not user.get("password_hash"):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid credentials")
# If 2FA enabled, return a pre-auth token that requires TOTP verification
if user.get("totp_enabled") and user.get("totp_secret"):
# Generate a short-lived pre-auth token (5 min)
pre_token = _create_jwt(
user["id"],
user["email"],
user.get("tier", "FREE"),
user.get("role", "USER"),
user.get("wallet_address"),
)
# Override expiry to 5 minutes
import jose.jwt as jose_jwt
payload = jose_jwt.decode(pre_token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
payload["exp"] = datetime.utcnow() + timedelta(minutes=5)
payload["2fa_required"] = True
payload["pre_auth"] = True
pre_token = jose_jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return cast(
"WalletAuthResponse",
{
"access_token": pre_token,
"refresh_token": pre_token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
"_2fa_required": True,
},
},
)
token = _create_jwt(
user["id"],
user["email"],
user.get("tier", "FREE"),
user.get("role", "USER"),
user.get("wallet_address"),
)
return cast(
"WalletAuthResponse",
{
"access_token": token,
"refresh_token": token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
},
)
# ── Wallet Auth ──
@router.post("/wallet/nonce", response_model=NonceResponse)
async def wallet_nonce(req: WalletNonceRequest) -> NonceResponse:
"""Get a nonce for wallet signature."""
nonce = generate_nonce()
timestamp = datetime.utcnow().isoformat()
message = f"RugMunch Intelligence wants you to sign in with your {req.chain.title()} account.\n\nWallet: {req.address}\nNonce: {nonce}\nTimestamp: {timestamp}"
return cast("NonceResponse", {"nonce": nonce, "timestamp": timestamp, "message": message})
@router.post("/wallet/verify", response_model=WalletAuthResponse)
async def wallet_verify(req: WalletVerifyRequest) -> WalletAuthResponse:
"""Verify wallet signature and create session."""
from app.domains.auth.wallet import get_or_create_wallet_user, verify_wallet_signature
if not verify_wallet_signature(req.message, req.signature, req.address):
raise HTTPException(status_code=401, detail="Invalid signature")
user_data = await get_or_create_wallet_user(req.address)
return cast(
"WalletAuthResponse",
{
"access_token": user_data["access_token"],
"refresh_token": user_data["refresh_token"],
"user": {
"id": user_data["id"],
"email": user_data["email"],
"display_name": user_data.get("display_name", f"Agent {req.address[2:8].upper()}"),
"wallet_address": req.address,
"wallet_chain": req.chain,
"tier": user_data["tier"],
"role": user_data["role"],
"created_at": user_data["created_at"],
},
},
)
# ── User Profile ──
@router.get("/user/me", response_model=UserResponse)
async def user_me(request: Request) -> UserResponse:
"""Get current authenticated user profile."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing auth token")
token = auth_header[7:]
payload = _verify_jwt(token)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
user = _get_user(payload["id"])
if not user:
raise HTTPException(status_code=404, detail="User not found")
return cast(
"UserResponse",
{
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
"xp": user.get("xp", 0),
"level": user.get("level", 1),
"badges": user.get("badges", []),
},
)
# ── 2FA / TOTP Endpoints ──
@router.post("/2fa/setup")
async def twofa_setup(request: Request) -> TwoFASetupResponse:
"""Begin 2FA setup - generate secret + QR code. Returns plaintext secret + backup codes (shown once)."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
# Check if already enabled
if user.get("totp_secret"):
raise HTTPException(
status_code=400, detail="2FA already enabled. Disable first to reconfigure."
)
secret = _generate_totp_secret()
uri = _get_totp_uri(secret, user["email"])
qr_b64 = _generate_qr_base64(uri)
backup_codes = _generate_backup_codes(8)
# Store pending secret (not enabled until verified)
r = get_redis()
assert r is not None
pending = {
"secret": secret,
"backup_codes": [_hash_backup_code(c) for c in backup_codes],
"created_at": datetime.utcnow().isoformat(),
}
r.setex(f"rmi:2fa_pending:{user['id']}", timedelta(minutes=10), json.dumps(pending))
return cast("TwoFASetupResponse", {
"secret": secret,
"qr_code": qr_b64,
"uri": uri,
"backup_codes": backup_codes, # plaintext - shown once
})
@router.post("/2fa/enable")
async def twofa_enable(req: TwoFAEnableRequest, request: Request) -> dict[str, Any]:
"""Enable 2FA - verify the first TOTP code, then persist."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
r = get_redis()
assert r is not None
pending_raw = r.get(f"rmi:2fa_pending:{user['id']}")
if not pending_raw:
raise HTTPException(status_code=400, detail="No pending 2FA setup. Call /2fa/setup first.")
pending = json.loads(pending_raw)
secret = pending["secret"]
# Verify the code
if not _verify_totp(secret, req.code):
raise HTTPException(
status_code=400, detail="Invalid TOTP code. Check your authenticator app time sync."
)
# Enable 2FA on user record
user["totp_secret"] = secret
user["totp_enabled"] = True
user["totp_created_at"] = pending["created_at"]
user["totp_backup_codes"] = pending["backup_codes"] # already hashed
user["totp_last_used"] = None
_save_user(user)
# Clean up pending
r.delete(f"rmi:2fa_pending:{user['id']}")
# Audit log
logger.info(f"[2FA ENABLED] user={user['id']} email={user['email']}")
return {"status": "ok", "message": "2FA enabled successfully", "method": "totp"}
@router.post("/2fa/disable")
async def twofa_disable(request: Request) -> dict[str, Any]:
"""Disable 2FA - requires current password for security."""
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("totp_enabled"):
raise HTTPException(status_code=400, detail="2FA is not enabled")
# Remove 2FA fields
for key in [
"totp_secret",
"totp_enabled",
"totp_created_at",
"totp_backup_codes",
"totp_last_used",
]:
user.pop(key, None)
_save_user(user)
logger.info(f"[2FA DISABLED] user={user['id']} email={user['email']}")
return cast("dict[str, Any]", {"status": "ok", "message": "2FA disabled successfully"})
@router.get("/2fa/status", response_model=TwoFAStatusResponse)
async def twofa_status(request: Request) -> TwoFAStatusResponse:
"""Check 2FA status for current user."""
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
enabled = user.get("totp_enabled", False)
return cast("TwoFAStatusResponse", {
"enabled": enabled,
"method": "totp" if enabled else "none",
"created_at": user.get("totp_created_at"),
"last_used": user.get("totp_last_used"),
})
@router.post("/2fa/verify")
async def twofa_verify(req: TwoFAVerifyRequest, request: Request) -> dict[str, Any]:
"""Verify a TOTP code (for re-auth during sensitive operations)."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("totp_enabled") or not user.get("totp_secret"):
raise HTTPException(status_code=400, detail="2FA not enabled")
if not _verify_totp(user["totp_secret"], req.code):
raise HTTPException(status_code=400, detail="Invalid TOTP code")
# Update last used
user["totp_last_used"] = datetime.utcnow().isoformat()
_save_user(user)
return cast("dict[str, Any]", {"status": "ok", "verified": True})
@router.post("/2fa/login")
async def twofa_login(req: TwoFALoginRequest) -> WalletAuthResponse:
"""Login with email + password + 2FA code. Returns full JWT on success."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = _get_user_by_email(req.email)
if not user or not user.get("password_hash"):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid credentials")
# Check if 2FA is enabled
if not user.get("totp_enabled") or not user.get("totp_secret"):
raise HTTPException(
status_code=400, detail="2FA not enabled for this account. Use /login instead."
)
code = req.code.strip().replace("-", "")
# Try TOTP first
if _verify_totp(user["totp_secret"], code):
pass # valid TOTP
else:
# Try backup codes
backup_codes = user.get("totp_backup_codes", [])
matched = False
for i, hashed in enumerate(backup_codes):
if _verify_backup_code(req.code, hashed):
matched = True
# Remove used backup code
backup_codes.pop(i)
user["totp_backup_codes"] = backup_codes
break
if not matched:
raise HTTPException(status_code=401, detail="Invalid 2FA code or backup code")
# Update last used
user["totp_last_used"] = datetime.utcnow().isoformat()
_save_user(user)
token = _create_jwt(
user["id"],
user["email"],
user.get("tier", "FREE"),
user.get("role", "USER"),
user.get("wallet_address"),
)
logger.info(f"[2FA LOGIN] user={user['id']} email={user['email']}")
return cast(
"WalletAuthResponse",
{
"access_token": token,
"refresh_token": token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
},
)
__all__ = [
"EmailLoginRequest",
"EmailRegisterRequest",
"GoogleAuthResponse",
"JWT_ALGORITHM",
"JWT_EXPIRY_DAYS",
"JWT_SECRET",
"NonceResponse",
"PYOTP_AVAILABLE",
"PERMISSIONS",
"TOTP_DIGITS",
"TOTP_INTERVAL",
"TOTP_ISSUER",
"TOTP_WINDOW",
"TelegramAuthRequest",
"TwoFAEnableRequest",
"TwoFALoginRequest",
"TwoFASetupResponse",
"TwoFAStatusResponse",
"TwoFAVerifyRequest",
"UserResponse",
"WalletAuthResponse",
"WalletNonceRequest",
"WalletVerifyRequest",
"_build_totp",
"_create_jwt",
"_delete_user",
"_derive_user_id",
"_generate_backup_codes",
"_generate_qr_base64",
"_generate_totp_secret",
"_get_totp_uri",
"_get_user",
"_get_user_by_email",
"_hash_backup_code",
"_is_valid_email",
"_save_user",
"_verify_backup_code",
"_verify_jwt",
"_verify_totp",
"generate_nonce",
"get_current_user",
"hash_password",
"oauth_router",
"require_auth",
"require_public_profile",
"router",
"verify_password",
]

509
app/domains/auth/router.py Normal file
View file

@ -0,0 +1,509 @@
"""
Auth Router - Complete authentication system (email, wallet, OAuth, Telegram)
This is the FastAPI router for the auth domain. It is mounted by
``app/mount.py`` via the re-export in ``app.domains.auth.router``.
Phase 2.1: extracted from ``app/domains/auth/__init__.py`` so that the
package marker is a clean re-export shim and mypy does not see the
router file as both module and package.
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Any, cast
from fastapi import APIRouter, HTTPException, Request
from app.core.redis import get_redis
from app.domains.auth.deps import (
get_current_user,
)
from app.domains.auth.jwt import (
JWT_ALGORITHM,
JWT_SECRET,
_create_jwt,
_derive_user_id,
_verify_jwt,
generate_nonce,
)
from app.domains.auth.oauth import router as oauth_router
from app.domains.auth.passwords import hash_password, verify_password
from app.domains.auth.schemas import (
EmailLoginRequest,
EmailRegisterRequest,
NonceResponse,
TwoFAEnableRequest,
TwoFALoginRequest,
TwoFASetupResponse,
TwoFAStatusResponse,
TwoFAVerifyRequest,
UserResponse,
WalletAuthResponse,
WalletNonceRequest,
WalletVerifyRequest,
)
from app.domains.auth.store import (
_get_user,
_get_user_by_email,
_is_valid_email,
_save_user,
)
from app.domains.auth.totp import (
PYOTP_AVAILABLE,
_generate_backup_codes,
_generate_qr_base64,
_generate_totp_secret,
_get_totp_uri,
_hash_backup_code,
_verify_backup_code,
_verify_totp,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"])
# Include OAuth/Telegram routes from dedicated submodule
router.include_router(oauth_router, tags=["auth"])
# ── Config ──
# ── Storage (Redis-backed user store) ──
# ── Email Auth ──
@router.post("/register", response_model=WalletAuthResponse)
def register_email(req: EmailRegisterRequest) -> WalletAuthResponse:
"""Register a new user with email/password."""
if not _is_valid_email(req.email):
raise HTTPException(status_code=400, detail="Invalid email format")
if len(req.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
# Check if user exists
existing = _get_user_by_email(req.email)
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user_id = _derive_user_id(req.email)
hashed = hash_password(req.password)
user: dict[str, Any] = {
"id": user_id,
"email": req.email,
"display_name": req.display_name,
"password_hash": hashed,
"wallet_address": req.wallet_address,
"wallet_chain": req.wallet_chain,
"tier": "FREE",
"role": "USER",
"created_at": datetime.utcnow().isoformat(),
"xp": 0,
"level": 1,
"badges": [],
"scans_remaining": 5,
"scans_used": 0,
}
# Save user + email index
r = get_redis()
assert r is not None
r.hset("rmi:users", user_id, json.dumps(user))
r.hset("rmi:users:email", req.email.lower(), user_id)
token = _create_jwt(user_id, req.email, "FREE", "USER", req.wallet_address)
return cast(
"WalletAuthResponse",
{
"access_token": token,
"refresh_token": token,
"user": {
"id": user_id,
"email": req.email,
"display_name": req.display_name,
"wallet_address": req.wallet_address,
"wallet_chain": req.wallet_chain,
"tier": "FREE",
"role": "USER",
"created_at": user["created_at"],
},
},
)
@router.post("/login", response_model=WalletAuthResponse)
def login_email(req: EmailLoginRequest) -> WalletAuthResponse:
"""Login with email/password. If 2FA enabled, returns partial token requiring /2fa/login."""
user = _get_user_by_email(req.email)
if not user or not user.get("password_hash"):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid credentials")
# If 2FA enabled, return a pre-auth token that requires TOTP verification
if user.get("totp_enabled") and user.get("totp_secret"):
# Generate a short-lived pre-auth token (5 min)
pre_token = _create_jwt(
user["id"],
user["email"],
user.get("tier", "FREE"),
user.get("role", "USER"),
user.get("wallet_address"),
)
# Override expiry to 5 minutes
import jose.jwt as jose_jwt
payload = jose_jwt.decode(pre_token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
payload["exp"] = datetime.utcnow() + timedelta(minutes=5)
payload["2fa_required"] = True
payload["pre_auth"] = True
pre_token = jose_jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return cast(
"WalletAuthResponse",
{
"access_token": pre_token,
"refresh_token": pre_token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
"_2fa_required": True,
},
},
)
token = _create_jwt(
user["id"],
user["email"],
user.get("tier", "FREE"),
user.get("role", "USER"),
user.get("wallet_address"),
)
return cast(
"WalletAuthResponse",
{
"access_token": token,
"refresh_token": token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
},
)
# ── Wallet Auth ──
@router.post("/wallet/nonce", response_model=NonceResponse)
async def wallet_nonce(req: WalletNonceRequest) -> NonceResponse:
"""Get a nonce for wallet signature."""
nonce = generate_nonce()
timestamp = datetime.utcnow().isoformat()
message = f"RugMunch Intelligence wants you to sign in with your {req.chain.title()} account.\n\nWallet: {req.address}\nNonce: {nonce}\nTimestamp: {timestamp}"
return cast("NonceResponse", {"nonce": nonce, "timestamp": timestamp, "message": message})
@router.post("/wallet/verify", response_model=WalletAuthResponse)
async def wallet_verify(req: WalletVerifyRequest) -> WalletAuthResponse:
"""Verify wallet signature and create session."""
from app.domains.auth.wallet import get_or_create_wallet_user, verify_wallet_signature
if not verify_wallet_signature(req.message, req.signature, req.address):
raise HTTPException(status_code=401, detail="Invalid signature")
user_data = await get_or_create_wallet_user(req.address)
return cast(
"WalletAuthResponse",
{
"access_token": user_data["access_token"],
"refresh_token": user_data["refresh_token"],
"user": {
"id": user_data["id"],
"email": user_data["email"],
"display_name": user_data.get("display_name", f"Agent {req.address[2:8].upper()}"),
"wallet_address": req.address,
"wallet_chain": req.chain,
"tier": user_data["tier"],
"role": user_data["role"],
"created_at": user_data["created_at"],
},
},
)
# ── User Profile ──
@router.get("/user/me", response_model=UserResponse)
async def user_me(request: Request) -> UserResponse:
"""Get current authenticated user profile."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing auth token")
token = auth_header[7:]
payload = _verify_jwt(token)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
user = _get_user(payload["id"])
if not user:
raise HTTPException(status_code=404, detail="User not found")
return cast(
"UserResponse",
{
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
"xp": user.get("xp", 0),
"level": user.get("level", 1),
"badges": user.get("badges", []),
},
)
# ── 2FA / TOTP Endpoints ──
@router.post("/2fa/setup")
async def twofa_setup(request: Request) -> TwoFASetupResponse:
"""Begin 2FA setup - generate secret + QR code. Returns plaintext secret + backup codes (shown once)."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
# Check if already enabled
if user.get("totp_secret"):
raise HTTPException(
status_code=400, detail="2FA already enabled. Disable first to reconfigure."
)
secret = _generate_totp_secret()
uri = _get_totp_uri(secret, user["email"])
qr_b64 = _generate_qr_base64(uri)
backup_codes = _generate_backup_codes(8)
# Store pending secret (not enabled until verified)
r = get_redis()
assert r is not None
pending = {
"secret": secret,
"backup_codes": [_hash_backup_code(c) for c in backup_codes],
"created_at": datetime.utcnow().isoformat(),
}
r.setex(f"rmi:2fa_pending:{user['id']}", timedelta(minutes=10), json.dumps(pending))
return cast(
"TwoFASetupResponse",
{
"secret": secret,
"qr_code": qr_b64,
"uri": uri,
"backup_codes": backup_codes, # plaintext - shown once
},
)
@router.post("/2fa/enable")
async def twofa_enable(req: TwoFAEnableRequest, request: Request) -> dict[str, Any]:
"""Enable 2FA - verify the first TOTP code, then persist."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
r = get_redis()
assert r is not None
pending_raw = r.get(f"rmi:2fa_pending:{user['id']}")
if not pending_raw:
raise HTTPException(status_code=400, detail="No pending 2FA setup. Call /2fa/setup first.")
pending = json.loads(pending_raw)
secret = pending["secret"]
# Verify the code
if not _verify_totp(secret, req.code):
raise HTTPException(
status_code=400, detail="Invalid TOTP code. Check your authenticator app time sync."
)
# Enable 2FA on user record
user["totp_secret"] = secret
user["totp_enabled"] = True
user["totp_created_at"] = pending["created_at"]
user["totp_backup_codes"] = pending["backup_codes"] # already hashed
user["totp_last_used"] = None
_save_user(user)
# Clean up pending
r.delete(f"rmi:2fa_pending:{user['id']}")
# Audit log
logger.info(f"[2FA ENABLED] user={user['id']} email={user['email']}")
return {"status": "ok", "message": "2FA enabled successfully", "method": "totp"}
@router.post("/2fa/disable")
async def twofa_disable(request: Request) -> dict[str, Any]:
"""Disable 2FA - requires current password for security."""
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("totp_enabled"):
raise HTTPException(status_code=400, detail="2FA is not enabled")
# Remove 2FA fields
for key in [
"totp_secret",
"totp_enabled",
"totp_created_at",
"totp_backup_codes",
"totp_last_used",
]:
user.pop(key, None)
_save_user(user)
logger.info(f"[2FA DISABLED] user={user['id']} email={user['email']}")
return cast("dict[str, Any]", {"status": "ok", "message": "2FA disabled successfully"})
@router.get("/2fa/status", response_model=TwoFAStatusResponse)
async def twofa_status(request: Request) -> TwoFAStatusResponse:
"""Check 2FA status for current user."""
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
enabled = user.get("totp_enabled", False)
return cast(
"TwoFAStatusResponse",
{
"enabled": enabled,
"method": "totp" if enabled else "none",
"created_at": user.get("totp_created_at"),
"last_used": user.get("totp_last_used"),
},
)
@router.post("/2fa/verify")
async def twofa_verify(req: TwoFAVerifyRequest, request: Request) -> dict[str, Any]:
"""Verify a TOTP code (for re-auth during sensitive operations)."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = await get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("totp_enabled") or not user.get("totp_secret"):
raise HTTPException(status_code=400, detail="2FA not enabled")
if not _verify_totp(user["totp_secret"], req.code):
raise HTTPException(status_code=400, detail="Invalid TOTP code")
# Update last used
user["totp_last_used"] = datetime.utcnow().isoformat()
_save_user(user)
return cast("dict[str, Any]", {"status": "ok", "verified": True})
@router.post("/2fa/login")
async def twofa_login(req: TwoFALoginRequest) -> WalletAuthResponse:
"""Login with email + password + 2FA code. Returns full JWT on success."""
if not PYOTP_AVAILABLE:
raise HTTPException(status_code=503, detail="2FA service unavailable")
user = _get_user_by_email(req.email)
if not user or not user.get("password_hash"):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid credentials")
# Check if 2FA is enabled
if not user.get("totp_enabled") or not user.get("totp_secret"):
raise HTTPException(
status_code=400, detail="2FA not enabled for this account. Use /login instead."
)
code = req.code.strip().replace("-", "")
# Try TOTP first
if _verify_totp(user["totp_secret"], code):
pass # valid TOTP
else:
# Try backup codes
backup_codes = user.get("totp_backup_codes", [])
matched = False
for i, hashed in enumerate(backup_codes):
if _verify_backup_code(req.code, hashed):
matched = True
# Remove used backup code
backup_codes.pop(i)
user["totp_backup_codes"] = backup_codes
break
if not matched:
raise HTTPException(status_code=401, detail="Invalid 2FA code or backup code")
# Update last used
user["totp_last_used"] = datetime.utcnow().isoformat()
_save_user(user)
token = _create_jwt(
user["id"],
user["email"],
user.get("tier", "FREE"),
user.get("role", "USER"),
user.get("wallet_address"),
)
logger.info(f"[2FA LOGIN] user={user['id']} email={user['email']}")
return cast(
"WalletAuthResponse",
{
"access_token": token,
"refresh_token": token,
"user": {
"id": user["id"],
"email": user["email"],
"display_name": user.get("display_name", user["email"]),
"wallet_address": user.get("wallet_address"),
"wallet_chain": user.get("wallet_chain"),
"tier": user.get("tier", "FREE"),
"role": user.get("role", "USER"),
"created_at": user.get("created_at"),
},
},
)

View file

@ -4,7 +4,7 @@ Phase 4 domain consolidation: app.mcp -> app.domains.mcp.
"""
from __future__ import annotations
from app.domains.mcp.registry import TOOL_CATEGORIES, get_tool_by_name
from app.domains.mcp.registry import TOOL_CATEGORIES, resolve_tool
from app.domains.mcp.server import (
MCP_PROTOCOL_VERSION,
MCP_SERVER_VERSION,
@ -28,6 +28,6 @@ __all__ = [
"TOOL_VERSIONS",
"X402ToolManager",
"call_tool",
"get_tool_by_name",
"handle_mcp_call",
"resolve_tool",
]

View file

@ -1640,20 +1640,39 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.chat.send_action("typing")
# Try RMI RAG backend
# Try RAG search + LLM answer
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{BACKEND_URL}/api/v1/rag/query",
json={"query": text, "user_id": user.id, "context": "telegram_bot"},
f"{BACKEND_URL}/api/v1/rag/v2/search",
json={"query": text, "top_k": 5},
)
rag_context = ""
if r.status_code == 200:
data = r.json()
answer = data.get("answer", "")
if answer:
db.increment_usage(user.id, ai_msg=True)
await update.message.reply_text(answer, parse_mode=ParseMode.HTML, reply_markup=back_kb())
return
hits = r.json().get("hits", [])
rag_context = "\n\n".join(h.get("content", "")[:500] for h in hits[:3])
from app.ai_router import chat_completion
messages = [
{
"role": "system",
"content": (
"You are RugMunch Intelligence, a crypto scam detection assistant. "
"Answer user questions about tokens, wallets, and scams concisely. "
f"Context from knowledge base:\n{rag_context}" if rag_context else
"You are RugMunch Intelligence, a crypto scam detection assistant. "
"Answer user questions about tokens, wallets, and scams concisely."
),
},
{"role": "user", "content": text},
]
result = await chat_completion(messages=messages, tier="T4", max_tokens=400, timeout=25.0)
answer = result.get("content", "")
if answer:
db.increment_usage(user.id, ai_msg=True)
await update.message.reply_text(answer, parse_mode=ParseMode.HTML, reply_markup=back_kb())
return
except Exception:
pass

View file

@ -16,21 +16,21 @@ from app.domains.mcp import (
TOOLS,
X402ToolManager,
call_tool,
get_tool_by_name,
handle_mcp_call,
resolve_tool,
)
__all__ = [
"MCP_PROTOCOL_VERSION",
"MCP_SERVER_VERSION",
"TOOLS",
"TOOL_CATALOG",
"TOOL_CATEGORIES",
"TOOL_DEPRECATED",
"TOOL_SUCCESSORS",
"TOOL_VERSIONS",
"TOOLS",
"X402ToolManager",
"call_tool",
"get_tool_by_name",
"handle_mcp_call",
"resolve_tool",
]

View file

@ -42,6 +42,9 @@ ROUTER_MODULES: Final[list[str]] = [
"app.domains.news.admin_router", # /api/v1/news/_admin/*
"app.domains.reports", # /api/v1/reports/*
"app.domains.x402", # /api/v1/x402/*
"app.domains.databus.router", # /api/v1/databus/* (DataBus HTTP API)
"app.domains.admin.router", # /api/v1/admin/backend/* (admin login + RBAC)
"app.domains.auth.router", # /register, /login, /2fa/*, /wallet/*, /user/me, OAuth, Telegram
# x402 MCP + Discovery + Docs
"app.routers.x402_mcp_handler", # /mcp/x402, /.well-known/x402
"app.routers.x402_docs", # /x402/docs, /x402/sandbox/*

View file

@ -40,7 +40,7 @@ ai_guard = AIGuard()
# ── Auth ──
# ── AI Router ──
from app.ai_router import router as ai_router # noqa: E402
from app import ai_router # noqa: E402
from app.domains.auth import get_current_user, require_auth # noqa: E402
# ── DB path ──

View file

@ -151,7 +151,7 @@ async def get_bubble_map_data(req: BubblemapRequest):
# AI Deep Dive: If requested and cluster is large/suspicious, trigger AI analysis
if req.ai_deep_dive and data.get("nodes") and len(data["nodes"]) >= 5:
try:
from app.ai_router import router as ai_router
from app import ai_router
# Extract top suspicious wallets for AI context
suspicious_wallets = [
@ -178,7 +178,7 @@ async def get_bubble_map_data(req: BubblemapRequest):
{"role": "user", "content": prompt},
]
result = await ai_router.chat_completion( # type: ignore
result = await ai_router.chat_completion(
messages=messages, tier="T2", temperature=0.2, max_tokens=400, timeout=15.0
)
@ -217,7 +217,7 @@ async def get_ai_forensic_breakdown(req: BubblemapRequest):
# If AI deep dive is explicitly requested, enrich with LLM analysis
if req.ai_deep_dive and breakdown.get("total_wallets_analyzed", 0) > 0:
try:
from app.ai_router import router as ai_router
from app import ai_router
prompt = f"""You are an elite blockchain forensics analyst. Analyze this wallet cluster.
@ -246,7 +246,7 @@ Be direct and avoid fluff."""
{"role": "user", "content": prompt},
]
result = await ai_router.chat_completion( # type: ignore
result = await ai_router.chat_completion(
messages=messages, tier="T2", temperature=0.2, max_tokens=600, timeout=20.0
)

View file

@ -184,21 +184,26 @@ class UnifiedWalletScanner:
# ═══════════════════════════════════════════════════════════════
async def _rag_enrich(self, r):
try:
req = Request(
f"{BACKEND}/api/v1/rag/search?q={r.address} scam rug hack&limit=5",
headers={"X-RMI-Key": "rmi-internal-2026"},
)
resp = urlopen(req, timeout=8)
data = json.loads(resp.read())
r.rag_matches = data.get("results", [])
if r.rag_matches:
r.modules_run.append("rag_entity_resolution")
r.enrichment_sources.append("rag:20K_docs")
for match in r.rag_matches[:3]:
content = match.get("content", "")[:80]
if r.address.lower() in content.lower():
r.risk_flags.append("RAG_SCAMMER_MATCH")
r.risk_score += 30
import httpx
async with httpx.AsyncClient(timeout=8) as client:
resp = await client.post(
f"{BACKEND}/api/v1/rag/v2/search",
json={"query": f"{r.address} scam rug hack", "top_k": 5},
headers={"X-RMI-Key": "rmi-internal-2026"},
)
if resp.status_code != 200:
return
data = resp.json()
r.rag_matches = data.get("hits", [])
if r.rag_matches:
r.modules_run.append("rag_entity_resolution")
r.enrichment_sources.append("rag:20K_docs")
for match in r.rag_matches[:3]:
content = match.get("content", "")[:80]
if r.address.lower() in content.lower():
r.risk_flags.append("RAG_SCAMMER_MATCH")
r.risk_score += 30
except Exception as e:
logger.warning(f"RAG enrich failed: {e}")

View file

@ -16,8 +16,8 @@ groups:
- alert: HighErrorRate
expr: |
(
sum(rate(rmi_requests_total{status=~"5.."}[5m]))
/ sum(rate(rmi_requests_total[5m]))
sum(rate(rmi_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(rmi_http_requests_total[5m]))
) > 0.05
for: 5m
labels:
@ -133,8 +133,8 @@ groups:
- alert: SLOBurnRate2x
expr: |
(
sum(rate(rmi_requests_total{status=~"5.."}[1h]))
/ sum(rate(rmi_requests_total[1h]))
sum(rate(rmi_http_requests_total{status=~"5.."}[1h]))
/ sum(rate(rmi_http_requests_total[1h]))
) > 0.02
for: 5m
labels:
@ -145,8 +145,8 @@ groups:
- alert: SLOBurnRate4x
expr: |
(
sum(rate(rmi_requests_total{status=~"5.."}[1h]))
/ sum(rate(rmi_requests_total[1h]))
sum(rate(rmi_http_requests_total{status=~"5.."}[1h]))
/ sum(rate(rmi_http_requests_total[1h]))
) > 0.04
for: 2m
labels:

View file

@ -7,6 +7,8 @@ python-telegram-bot>=21.0
supabase>=2.0.0
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.17
pyotp>=2.9.0
qrcode>=7.4.0
aiohttp>=3.11.0
feedparser>=6.0.11
websockets>=13.0