security(auth): wire AuthMiddleware, enforce RMI_AUTH_TOKEN, fix PUBLIC_WRITE_PREFIXES, fail-closed ADMIN_API_KEY defaults
feat(databus): add deployer_tokens + funding_source providers via public RPC feat(databus): register 10 ghost chain passthroughs (104 chains, 127 providers) fix(databus): structured error logging in ProviderChain, fix _coindesk_news import fix(databus): _passthrough_alerts returns None on failure (not fake empty data) fix(databus): enable Qdrant API key enforcement refactor(bulletin): extract badges to badges.py, core.py -> 33-line facade refactor(bulletin): rewrite bulletin.py — canonical Post schema, auth fixes, XSS sanitize refactor(bulletin): consolidate DataBus to supabase_service, remove raw Supabase calls refactor(bulletin): remove bulletin_board.py dead code (682 lines), fix mount.py feat(admin): bulletin admin router (16 endpoints, RBAC auth) for darkroom feat(admin): fix ADMIN_API_KEY defaults to empty in 6 routers chore(admin): developer_routes, investigator_routes, premium_routes -> 501 stubs feat(infra): bind SearXNG/FlareSolverr/Webmail/Vane to 127.0.0.1 chore(infra): postgres healthcheck, resource limits, memory tuning, DB healthchecks fix(infra): Nginx HTTPS for rugmunch.io + api + admin darkroom Prod audit: 15 critical/high issues resolved across auth, security, infrastructure, DataBus
This commit is contained in:
parent
c67f1d71ac
commit
30a6508893
28 changed files with 2732 additions and 1249 deletions
|
|
@ -7,6 +7,15 @@ from fastapi.responses import JSONResponse
|
|||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
RMI_AUTH_TOKEN = os.getenv("RMI_AUTH_TOKEN", "")
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "dev")
|
||||
|
||||
if not RMI_AUTH_TOKEN and ENVIRONMENT not in ("dev", "test"):
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).critical(
|
||||
"RMI_AUTH_TOKEN is not set! Authorization will PASS for all requests. "
|
||||
"Set RMI_AUTH_TOKEN in environment."
|
||||
)
|
||||
|
||||
PUBLIC_WRITE_PREFIXES = [
|
||||
"/api/v1/auth/",
|
||||
|
|
@ -15,16 +24,13 @@ PUBLIC_WRITE_PREFIXES = [
|
|||
"/api/v1/x402-databus/",
|
||||
"/api/v1/databus/",
|
||||
"/api/v1/alerts/",
|
||||
"/api/v1/admin/",
|
||||
"/api/v1/content/",
|
||||
"/api/v1/bulletin/",
|
||||
"/api/v1/rag/permanence/",
|
||||
"/api/v1/token/",
|
||||
"/api/v1/ai/",
|
||||
"/api/v1/premium/",
|
||||
"/api/v1/rag/",
|
||||
"/api/v1/protect/",
|
||||
"/api/v1/wallet-manager/",
|
||||
]
|
||||
|
||||
# Auth bypass paths
|
||||
|
|
@ -60,6 +66,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||
if RMI_AUTH_TOKEN and api_key != RMI_AUTH_TOKEN:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Unauthorized - valid X-API-Key header required for write operations"},
|
||||
content={
|
||||
"detail": "Unauthorized - valid X-API-Key header required for write operations"
|
||||
},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
"""Bulletin domain - public API.
|
||||
|
||||
Phase 4 domain consolidation: app.bulletin_board -> app.domains.bulletin.
|
||||
"""
|
||||
Bulletin domain — public API.
|
||||
|
||||
Phase 4 domain consolidation: badge system extracted from the monolithic
|
||||
core.py CMS into badges.py. core.py is now a thin import barrel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domains.bulletin.core import (
|
||||
BulletinBoardManager,
|
||||
Comment,
|
||||
ContentSanitizer,
|
||||
Post,
|
||||
PostCategory,
|
||||
PostStatus,
|
||||
Priority,
|
||||
TargetAudience,
|
||||
from app.domains.bulletin.badges import (
|
||||
BADGE_TIERS,
|
||||
BADGES,
|
||||
X402_BB_POST_PRICE,
|
||||
award_badge,
|
||||
get_user_badges,
|
||||
get_user_reputation,
|
||||
|
|
@ -20,14 +18,9 @@ from app.domains.bulletin.core import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"BulletinBoardManager",
|
||||
"Comment",
|
||||
"ContentSanitizer",
|
||||
"Post",
|
||||
"PostCategory",
|
||||
"PostStatus",
|
||||
"Priority",
|
||||
"TargetAudience",
|
||||
"BADGES",
|
||||
"BADGE_TIERS",
|
||||
"X402_BB_POST_PRICE",
|
||||
"award_badge",
|
||||
"get_user_badges",
|
||||
"get_user_reputation",
|
||||
|
|
|
|||
190
app/domains/bulletin/badges.py
Normal file
190
app/domains/bulletin/badges.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""
|
||||
Bulletin board badge system — user badges, reputation, and x402 bot auth.
|
||||
|
||||
Extracted from core.py during Phase 4 domain consolidation
|
||||
to separate CMS concerns from the badge/reputation engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("rmi_bulletin_badges")
|
||||
|
||||
# ── Badge Definitions ────────────────────────────────────────
|
||||
|
||||
BADGES: dict[str, dict[str, str]] = {
|
||||
"first_post": {
|
||||
"name": "First Words",
|
||||
"icon": "💬",
|
||||
"desc": "Made your first post",
|
||||
"tier": "bronze",
|
||||
},
|
||||
"10_posts": {"name": "Chatterbox", "icon": "📢", "desc": "10 posts", "tier": "bronze"},
|
||||
"50_posts": {"name": "Board Regular", "icon": "🎙️", "desc": "50 posts", "tier": "silver"},
|
||||
"100_posts": {
|
||||
"name": "Terminally Online",
|
||||
"icon": "🖥️",
|
||||
"desc": "100 posts - touch grass",
|
||||
"tier": "gold",
|
||||
},
|
||||
"10_upvotes": {
|
||||
"name": "Approved",
|
||||
"icon": "👍",
|
||||
"desc": "10 upvotes on a post",
|
||||
"tier": "bronze",
|
||||
},
|
||||
"50_upvotes": {"name": "Crowd Favorite", "icon": "⭐", "desc": "50 upvotes", "tier": "silver"},
|
||||
"100_upvotes": {"name": "Legendary", "icon": "👑", "desc": "100 upvotes", "tier": "gold"},
|
||||
"rug_reporter": {
|
||||
"name": "Rug Detective",
|
||||
"icon": "🔍",
|
||||
"desc": "Reported 3 verified rugs",
|
||||
"tier": "silver",
|
||||
},
|
||||
"scam_buster": {
|
||||
"name": "Scam Buster",
|
||||
"icon": "🛡️",
|
||||
"desc": "10 scam alerts verified",
|
||||
"tier": "gold",
|
||||
},
|
||||
"honeypot_hunter": {
|
||||
"name": "Honeypot Hunter",
|
||||
"icon": "🍯",
|
||||
"desc": "Found 5 honeypots",
|
||||
"tier": "silver",
|
||||
},
|
||||
"whale_watcher": {
|
||||
"name": "Whale Watcher",
|
||||
"icon": "🐋",
|
||||
"desc": "Tracked 10 whale moves",
|
||||
"tier": "silver",
|
||||
},
|
||||
"alpha_caller": {
|
||||
"name": "Alpha Caller",
|
||||
"icon": "📈",
|
||||
"desc": "Called 5 pumps",
|
||||
"tier": "gold",
|
||||
},
|
||||
"degen": {
|
||||
"name": "Certified Degen",
|
||||
"icon": "🎰",
|
||||
"desc": "Posted in every category",
|
||||
"tier": "gold",
|
||||
},
|
||||
"rug_survivor": {
|
||||
"name": "Rug Survivor",
|
||||
"icon": "💀",
|
||||
"desc": "Posted about getting rugged",
|
||||
"tier": "bronze",
|
||||
},
|
||||
"diamond_hands": {
|
||||
"name": "Diamond Hands",
|
||||
"icon": "💎",
|
||||
"desc": "Held through -90%",
|
||||
"tier": "diamond",
|
||||
},
|
||||
"based": {
|
||||
"name": "Based",
|
||||
"icon": "🧠",
|
||||
"desc": "Called a 10x before it happened",
|
||||
"tier": "diamond",
|
||||
},
|
||||
"bot_verified": {
|
||||
"name": "Verified Bot",
|
||||
"icon": "🤖",
|
||||
"desc": "Registered x402 bot",
|
||||
"tier": "silver",
|
||||
},
|
||||
"bot_pro": {"name": "Bot Pro", "icon": "⚡", "desc": "100+ API calls", "tier": "gold"},
|
||||
}
|
||||
|
||||
BADGE_TIERS: dict[str, str] = {
|
||||
"bronze": "#CD7F32",
|
||||
"silver": "#C0C0C0",
|
||||
"gold": "#FFD700",
|
||||
"diamond": "#B9F2FF",
|
||||
}
|
||||
|
||||
X402_BB_POST_PRICE = "$1.00"
|
||||
|
||||
|
||||
# ── Redis Helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_redis():
|
||||
"""Get Redis connection for badge operations."""
|
||||
import redis.asyncio as redis_lib
|
||||
|
||||
return redis_lib.Redis(
|
||||
host=os.getenv("REDIS_HOST", "localhost"),
|
||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||
password=os.getenv("REDIS_PASSWORD", ""),
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
|
||||
# ── Badge Operations ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_user_badges(user_id: str) -> list[dict[str, Any]]:
|
||||
"""Return all badges earned by a user."""
|
||||
try:
|
||||
r = await _get_redis()
|
||||
ids = await r.smembers(f"bb:badges:{user_id}")
|
||||
await r.close()
|
||||
return [{"id": bid, **BADGES[bid]} for bid in ids if bid in BADGES]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def award_badge(user_id: str, badge_id: str) -> bool:
|
||||
"""Award a badge to a user. Returns True if newly awarded."""
|
||||
if badge_id not in BADGES:
|
||||
return False
|
||||
try:
|
||||
r = await _get_redis()
|
||||
ok = await r.sadd(f"bb:badges:{user_id}", badge_id)
|
||||
await r.close()
|
||||
return ok > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_reputation(user_id: str) -> dict[str, int]:
|
||||
"""Get reputation score, badge count, and post count for a user."""
|
||||
try:
|
||||
r = await _get_redis()
|
||||
p = r.pipeline()
|
||||
p.get(f"bb:rep:{user_id}")
|
||||
p.scard(f"bb:badges:{user_id}")
|
||||
p.get(f"bb:posts:{user_id}")
|
||||
rep, bc, pc = await p.execute()
|
||||
await r.close()
|
||||
return {
|
||||
"reputation": int(rep or 0),
|
||||
"badge_count": bc or 0,
|
||||
"post_count": int(pc or 0),
|
||||
}
|
||||
except Exception:
|
||||
return {"reputation": 0, "badge_count": 0, "post_count": 0}
|
||||
|
||||
|
||||
# ── x402 Bot Auth ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def verify_x402_bot(tx_hash: str, bot_addr: str) -> bool:
|
||||
"""Verify an x402 bot payment. One tx_hash = 30-day membership."""
|
||||
try:
|
||||
r = await _get_redis()
|
||||
if await r.get(f"bb:x402:tx:{tx_hash}"):
|
||||
await r.close()
|
||||
return False
|
||||
await r.setex(f"bb:x402:tx:{tx_hash}", 86400, bot_addr)
|
||||
await r.setex(f"bb:x402:bot:{bot_addr}", 2592000, "1") # 30 day auth
|
||||
await r.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
|
@ -1,902 +1,33 @@
|
|||
"""
|
||||
RMI Bulletin Board Management System
|
||||
=====================================
|
||||
A full content management backend for announcements, news, alerts, platform
|
||||
communications, and community bulletin boards.
|
||||
Bulletin domain core — import barrel.
|
||||
|
||||
Features:
|
||||
• Posts - CRUD with rich text, attachments, scheduling, expiry
|
||||
• Categories - organize by type (news, alert, update, promo, system)
|
||||
• Targeting - audience segmentation (all, free, premium, admins, specific tiers)
|
||||
• Moderation - draft/review/published/archived workflow, approval chains
|
||||
• Pinning - sticky posts, priority ordering
|
||||
• Analytics - views, clicks, engagement tracking per post
|
||||
• Comments - threaded discussions on posts (optional)
|
||||
• Notifications - push/email/Telegram alerts for critical posts
|
||||
• Scheduling - publish at future date, auto-archive after expiry
|
||||
• Versioning - track edit history, rollback capability
|
||||
• Search - full-text search across all posts
|
||||
• SEO - slug generation, meta tags, OpenGraph
|
||||
Architecture:
|
||||
badges.py — user badges, reputation scores, x402 bot auth
|
||||
core.py — this facade, re-exports the badge system public API
|
||||
|
||||
Security:
|
||||
- All write operations require admin auth + content.write permission
|
||||
- Audit log of every content change
|
||||
- Rate limiting on publish operations
|
||||
- Content sanitization (strip XSS, validate HTML)
|
||||
Import via app.domains.bulletin (the package __init__.py) for
|
||||
the canonical public surface. Direct imports of core are supported
|
||||
as a backwards-compatible entry point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, ClassVar
|
||||
|
||||
logger = logging.getLogger("rmi_bulletin_board")
|
||||
|
||||
|
||||
# ── Enums ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PostStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
REVIEW = "review"
|
||||
PUBLISHED = "published"
|
||||
ARCHIVED = "archived"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
|
||||
class PostCategory(StrEnum):
|
||||
NEWS = "news" # Platform news
|
||||
ALERT = "alert" # Security/urgent alerts
|
||||
UPDATE = "update" # Feature updates
|
||||
PROMO = "promo" # Promotions/offers
|
||||
SYSTEM = "system" # System maintenance
|
||||
COMMUNITY = "community" # Community posts
|
||||
ANNOUNCEMENT = "announcement" # General announcements
|
||||
TUTORIAL = "tutorial" # Guides/how-tos
|
||||
|
||||
|
||||
class TargetAudience(StrEnum):
|
||||
ALL = "all"
|
||||
FREE = "free"
|
||||
PREMIUM = "premium"
|
||||
PRO = "pro"
|
||||
ENTERPRISE = "enterprise"
|
||||
ADMINS = "admins"
|
||||
MODERATORS = "moderators"
|
||||
|
||||
|
||||
class Priority(StrEnum):
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
# ── Data Models ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Post:
|
||||
"""Bulletin board post."""
|
||||
|
||||
post_id: str
|
||||
title: str
|
||||
slug: str
|
||||
content: str
|
||||
summary: str
|
||||
category: str
|
||||
status: str
|
||||
priority: str
|
||||
target_audience: str
|
||||
author_id: str
|
||||
author_email: str
|
||||
author_name: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
published_at: str | None = None
|
||||
scheduled_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
archived_at: str | None = None
|
||||
pinned: bool = False
|
||||
pin_order: int = 0
|
||||
featured_image: str = ""
|
||||
attachments: list[dict] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
meta_title: str = ""
|
||||
meta_description: str = ""
|
||||
og_image: str = ""
|
||||
view_count: int = 0
|
||||
click_count: int = 0
|
||||
engagement_score: float = 0.0
|
||||
version: int = 1
|
||||
edit_history: list[dict] = field(default_factory=list)
|
||||
approved_by: str = ""
|
||||
approved_at: str | None = None
|
||||
notification_sent: bool = False
|
||||
allow_comments: bool = False
|
||||
comments_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
"""Return public-safe version (no internal fields)."""
|
||||
return {
|
||||
"post_id": self.post_id,
|
||||
"title": self.title,
|
||||
"slug": self.slug,
|
||||
"content": self.content,
|
||||
"summary": self.summary,
|
||||
"category": self.category,
|
||||
"priority": self.priority,
|
||||
"author_name": self.author_name,
|
||||
"created_at": self.created_at,
|
||||
"published_at": self.published_at,
|
||||
"expires_at": self.expires_at,
|
||||
"pinned": self.pinned,
|
||||
"pin_order": self.pin_order,
|
||||
"featured_image": self.featured_image,
|
||||
"attachments": self.attachments,
|
||||
"tags": self.tags,
|
||||
"meta_title": self.meta_title,
|
||||
"meta_description": self.meta_description,
|
||||
"og_image": self.og_image,
|
||||
"view_count": self.view_count,
|
||||
"allow_comments": self.allow_comments,
|
||||
"comments_count": self.comments_count,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comment:
|
||||
"""Comment on a post."""
|
||||
|
||||
comment_id: str
|
||||
post_id: str
|
||||
author_id: str
|
||||
author_name: str
|
||||
author_email: str
|
||||
content: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
parent_id: str | None = None
|
||||
status: str = "approved" # approved, pending, rejected
|
||||
likes: int = 0
|
||||
replies: list[dict] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ── Content Sanitizer ───────────────────────────────────────
|
||||
|
||||
|
||||
class ContentSanitizer:
|
||||
"""Sanitize user-generated content to prevent XSS."""
|
||||
|
||||
ALLOWED_TAGS: ClassVar[dict] ={
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"a",
|
||||
"img",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"td",
|
||||
"th",
|
||||
"div",
|
||||
"span",
|
||||
"hr",
|
||||
"sub",
|
||||
"sup",
|
||||
"del",
|
||||
"ins",
|
||||
}
|
||||
|
||||
ALLOWED_ATTRS: ClassVar[dict] ={
|
||||
"a": ["href", "title", "target"],
|
||||
"img": ["src", "alt", "title", "width", "height"],
|
||||
"div": ["class"],
|
||||
"span": ["class"],
|
||||
"code": ["class"],
|
||||
"pre": ["class"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def sanitize(text: str) -> str:
|
||||
"""Basic HTML sanitization."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Escape HTML entities first
|
||||
text = html.escape(text)
|
||||
|
||||
# Then selectively un-escape allowed tags
|
||||
# This is a simplified approach - in production use bleach or similar
|
||||
# For now, strip all HTML tags for safety
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def generate_slug(title: str) -> str:
|
||||
"""Generate URL-friendly slug from title."""
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug)
|
||||
return slug[:80]
|
||||
|
||||
@staticmethod
|
||||
def generate_summary(content: str, max_length: int = 200) -> str:
|
||||
"""Generate summary from content."""
|
||||
# Strip HTML
|
||||
text = re.sub(r"<[^>]+>", "", content)
|
||||
text = text.replace("\n", " ").strip()
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length].rsplit(" ", 1)[0] + "..."
|
||||
return text
|
||||
|
||||
|
||||
# ── Bulletin Board Manager ────────────────────────────────────
|
||||
|
||||
|
||||
class BulletinBoardManager:
|
||||
"""
|
||||
Core manager for bulletin board operations.
|
||||
Uses Redis as primary store + Supabase backup.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _get_redis():
|
||||
import redis.asyncio as redis_lib
|
||||
|
||||
return redis_lib.Redis(
|
||||
host=os.getenv("REDIS_HOST", "localhost"),
|
||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||
password=os.getenv("REDIS_PASSWORD", ""),
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def create_post(
|
||||
title: str,
|
||||
content: str,
|
||||
category: str,
|
||||
author_id: str,
|
||||
author_email: str,
|
||||
author_name: str = "",
|
||||
priority: str = "normal",
|
||||
target_audience: str = "all",
|
||||
status: str = "draft",
|
||||
featured_image: str = "",
|
||||
attachments: list[dict] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
scheduled_at: str | None = None,
|
||||
expires_at: str | None = None,
|
||||
pinned: bool = False,
|
||||
allow_comments: bool = False,
|
||||
meta_title: str = "",
|
||||
meta_description: str = "",
|
||||
) -> Post:
|
||||
"""Create a new post."""
|
||||
post_id = f"post_{int(time.time())}_{os.urandom(4).hex()}"
|
||||
slug = ContentSanitizer.generate_slug(title)
|
||||
summary = ContentSanitizer.generate_summary(content)
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
# Sanitize content
|
||||
content = ContentSanitizer.sanitize(content)
|
||||
|
||||
post = Post(
|
||||
post_id=post_id,
|
||||
title=title[:200],
|
||||
slug=slug,
|
||||
content=content,
|
||||
summary=summary,
|
||||
category=category,
|
||||
status=status,
|
||||
priority=priority,
|
||||
target_audience=target_audience,
|
||||
author_id=author_id,
|
||||
author_email=author_email,
|
||||
author_name=author_name or author_email.split("@")[0],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
scheduled_at=scheduled_at,
|
||||
expires_at=expires_at,
|
||||
pinned=pinned,
|
||||
featured_image=featured_image,
|
||||
attachments=attachments or [],
|
||||
tags=tags or [],
|
||||
meta_title=meta_title or title[:70],
|
||||
meta_description=meta_description or summary[:160],
|
||||
allow_comments=allow_comments,
|
||||
)
|
||||
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
|
||||
# Save post
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post.to_dict()))
|
||||
|
||||
# Add to category index
|
||||
await r.sadd(f"bulletin:category:{category}", post_id)
|
||||
|
||||
# Add to status index
|
||||
await r.sadd(f"bulletin:status:{status}", post_id)
|
||||
|
||||
# Add to author index
|
||||
await r.sadd(f"bulletin:author:{author_id}", post_id)
|
||||
|
||||
# Add to audience index
|
||||
await r.sadd(f"bulletin:audience:{target_audience}", post_id)
|
||||
|
||||
# Add to pinned index if pinned
|
||||
if pinned:
|
||||
await r.sadd("bulletin:pinned", post_id)
|
||||
await r.zadd("bulletin:pinned_order", {post_id: post.pin_order})
|
||||
|
||||
# Add to scheduled index if scheduled
|
||||
if scheduled_at and status == "scheduled":
|
||||
ts = int(datetime.fromisoformat(scheduled_at).timestamp())
|
||||
await r.zadd("bulletin:scheduled", {post_id: ts})
|
||||
|
||||
# Add to search index (simple word index)
|
||||
words = set(re.findall(r"\w+", title.lower() + " " + content.lower()))
|
||||
for word in words:
|
||||
if len(word) > 2:
|
||||
await r.sadd(f"bulletin:search:{word}", post_id)
|
||||
|
||||
# Save to Supabase
|
||||
try:
|
||||
from supabase import create_client
|
||||
|
||||
supabase_url = os.getenv("SUPABASE_URL")
|
||||
supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
|
||||
if supabase_url and supabase_key:
|
||||
client = create_client(supabase_url, supabase_key)
|
||||
client.table("bulletin_posts").insert(post.to_dict()).execute()
|
||||
except Exception as e:
|
||||
logger.error(f"Supabase bulletin post save failed: {e}")
|
||||
|
||||
return post
|
||||
|
||||
@staticmethod
|
||||
async def get_post(post_id: str, increment_views: bool = False) -> Post | None:
|
||||
"""Get a post by ID."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
data = await r.hget("rmi:bulletin_posts", post_id)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
post_dict = json.loads(data)
|
||||
|
||||
if increment_views and post_dict.get("status") == "published":
|
||||
post_dict["view_count"] = post_dict.get("view_count", 0) + 1
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
|
||||
|
||||
return Post(**post_dict)
|
||||
|
||||
@staticmethod
|
||||
async def get_post_by_slug(slug: str) -> Post | None:
|
||||
"""Get a post by slug."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
# Search all posts for matching slug
|
||||
all_posts = await r.hgetall("rmi:bulletin_posts")
|
||||
for _post_id, data in all_posts.items():
|
||||
post_dict = json.loads(data)
|
||||
if post_dict.get("slug") == slug:
|
||||
return Post(**post_dict)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def update_post(
|
||||
post_id: str,
|
||||
updates: dict[str, Any],
|
||||
editor_id: str = "",
|
||||
editor_email: str = "",
|
||||
) -> Post | None:
|
||||
"""Update a post with versioning."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
data = await r.hget("rmi:bulletin_posts", post_id)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
post_dict = json.loads(data)
|
||||
before_state = {k: post_dict.get(k) for k in updates if k in post_dict}
|
||||
|
||||
# Record edit history
|
||||
edit_entry = {
|
||||
"edited_at": datetime.utcnow().isoformat(),
|
||||
"edited_by": editor_id,
|
||||
"editor_email": editor_email,
|
||||
"changes": before_state,
|
||||
"version": post_dict.get("version", 1),
|
||||
}
|
||||
|
||||
history = post_dict.get("edit_history", [])
|
||||
history.append(edit_entry)
|
||||
post_dict["edit_history"] = history
|
||||
post_dict["version"] = post_dict.get("version", 1) + 1
|
||||
post_dict["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Apply updates
|
||||
for key, value in updates.items():
|
||||
if key == "content":
|
||||
value = ContentSanitizer.sanitize(value)
|
||||
post_dict["summary"] = ContentSanitizer.generate_summary(value)
|
||||
if key == "title":
|
||||
post_dict["slug"] = ContentSanitizer.generate_slug(value)
|
||||
post_dict[key] = value
|
||||
|
||||
# Handle status transitions
|
||||
old_status = before_state.get("status")
|
||||
new_status = post_dict.get("status")
|
||||
if old_status != new_status:
|
||||
# Update status indexes
|
||||
if old_status:
|
||||
await r.srem(f"bulletin:status:{old_status}", post_id)
|
||||
await r.sadd(f"bulletin:status:{new_status}", post_id)
|
||||
|
||||
if new_status == "published":
|
||||
post_dict["published_at"] = datetime.utcnow().isoformat()
|
||||
elif new_status == "archived":
|
||||
post_dict["archived_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Handle pinning changes
|
||||
if "pinned" in updates:
|
||||
if updates["pinned"]:
|
||||
await r.sadd("bulletin:pinned", post_id)
|
||||
await r.zadd("bulletin:pinned_order", {post_id: post_dict.get("pin_order", 0)})
|
||||
else:
|
||||
await r.srem("bulletin:pinned", post_id)
|
||||
await r.zrem("bulletin:pinned_order", post_id)
|
||||
|
||||
# Save updated post
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
|
||||
|
||||
# Update Supabase
|
||||
try:
|
||||
from supabase import create_client
|
||||
|
||||
supabase_url = os.getenv("SUPABASE_URL")
|
||||
supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
|
||||
if supabase_url and supabase_key:
|
||||
client = create_client(supabase_url, supabase_key)
|
||||
client.table("bulletin_posts").upsert(post_dict).execute()
|
||||
except Exception as e:
|
||||
logger.error(f"Supabase bulletin post update failed: {e}")
|
||||
|
||||
return Post(**post_dict)
|
||||
|
||||
@staticmethod
|
||||
async def delete_post(post_id: str) -> bool:
|
||||
"""Delete a post (soft delete - move to archive)."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
data = await r.hget("rmi:bulletin_posts", post_id)
|
||||
if not data:
|
||||
return False
|
||||
|
||||
post_dict = json.loads(data)
|
||||
post_dict["status"] = "archived"
|
||||
post_dict["archived_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
|
||||
await r.srem("bulletin:status:published", post_id)
|
||||
await r.srem("bulletin:status:draft", post_id)
|
||||
await r.srem("bulletin:status:review", post_id)
|
||||
await r.sadd("bulletin:status:archived", post_id)
|
||||
await r.srem("bulletin:pinned", post_id)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def list_posts(
|
||||
category: str | None = None,
|
||||
status: str | None = None,
|
||||
target_audience: str | None = None,
|
||||
author_id: str | None = None,
|
||||
pinned_only: bool = False,
|
||||
search_query: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
) -> dict[str, Any]:
|
||||
"""List posts with filtering."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
|
||||
# Start with all posts or filtered set
|
||||
if pinned_only:
|
||||
post_ids = await r.smembers("bulletin:pinned")
|
||||
elif category:
|
||||
post_ids = await r.smembers(f"bulletin:category:{category}")
|
||||
elif status:
|
||||
post_ids = await r.smembers(f"bulletin:status:{status}")
|
||||
elif author_id:
|
||||
post_ids = await r.smembers(f"bulletin:author:{author_id}")
|
||||
elif target_audience:
|
||||
post_ids = await r.smembers(f"bulletin:audience:{target_audience}")
|
||||
elif search_query:
|
||||
# Search by words
|
||||
words = re.findall(r"\w+", search_query.lower())
|
||||
if words:
|
||||
sets = [f"bulletin:search:{w}" for w in words if len(w) > 2]
|
||||
if sets:
|
||||
post_ids = await r.sinter(sets)
|
||||
else:
|
||||
post_ids = set()
|
||||
else:
|
||||
post_ids = set()
|
||||
else:
|
||||
all_posts = await r.hgetall("rmi:bulletin_posts")
|
||||
post_ids = set(all_posts.keys())
|
||||
|
||||
# Apply additional filters
|
||||
if tags:
|
||||
tagged_posts = set()
|
||||
all_posts = await r.hgetall("rmi:bulletin_posts")
|
||||
for pid, data in all_posts.items():
|
||||
post_dict = json.loads(data)
|
||||
if any(tag in post_dict.get("tags", []) for tag in tags):
|
||||
tagged_posts.add(pid)
|
||||
post_ids = post_ids.intersection(tagged_posts)
|
||||
|
||||
# Fetch and sort posts
|
||||
posts = []
|
||||
all_posts = await r.hgetall("rmi:bulletin_posts")
|
||||
for pid in post_ids:
|
||||
data = all_posts.get(pid)
|
||||
if data:
|
||||
post_dict = json.loads(data)
|
||||
posts.append(post_dict)
|
||||
|
||||
# Sort
|
||||
reverse = sort_order == "desc"
|
||||
posts.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
|
||||
|
||||
# Pinned posts first if not pinned_only
|
||||
if not pinned_only:
|
||||
pinned_ids = await r.smembers("bulletin:pinned")
|
||||
posts.sort(
|
||||
key=lambda x: (x["post_id"] not in pinned_ids, x.get(sort_by, "")),
|
||||
reverse=not reverse,
|
||||
)
|
||||
|
||||
total = len(posts)
|
||||
posts = posts[offset : offset + limit]
|
||||
|
||||
return {
|
||||
"posts": posts,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def publish_scheduled() -> list[str]:
|
||||
"""Publish posts that are scheduled for now."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
now = int(time.time())
|
||||
|
||||
# Get scheduled posts that are due
|
||||
due = await r.zrangebyscore("bulletin:scheduled", 0, now)
|
||||
published = []
|
||||
|
||||
for post_id in due:
|
||||
data = await r.hget("rmi:bulletin_posts", post_id)
|
||||
if data:
|
||||
post_dict = json.loads(data)
|
||||
post_dict["status"] = "published"
|
||||
post_dict["published_at"] = datetime.utcnow().isoformat()
|
||||
post_dict["scheduled_at"] = None
|
||||
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
|
||||
await r.srem("bulletin:status:scheduled", post_id)
|
||||
await r.sadd("bulletin:status:published", post_id)
|
||||
await r.zrem("bulletin:scheduled", post_id)
|
||||
|
||||
published.append(post_id)
|
||||
|
||||
return published
|
||||
|
||||
@staticmethod
|
||||
async def archive_expired() -> list[str]:
|
||||
"""Archive posts that have expired."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
all_posts = await r.hgetall("rmi:bulletin_posts")
|
||||
archived = []
|
||||
|
||||
for post_id, data in all_posts.items():
|
||||
post_dict = json.loads(data)
|
||||
if post_dict.get("status") == "published" and post_dict.get("expires_at") and post_dict["expires_at"] < now:
|
||||
post_dict["status"] = "archived"
|
||||
post_dict["archived_at"] = now
|
||||
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
|
||||
await r.srem("bulletin:status:published", post_id)
|
||||
await r.sadd("bulletin:status:archived", post_id)
|
||||
await r.srem("bulletin:pinned", post_id)
|
||||
|
||||
archived.append(post_id)
|
||||
|
||||
return archived
|
||||
|
||||
@staticmethod
|
||||
async def add_comment(
|
||||
post_id: str,
|
||||
author_id: str,
|
||||
author_name: str,
|
||||
author_email: str,
|
||||
content: str,
|
||||
parent_id: str | None = None,
|
||||
) -> Comment | None:
|
||||
"""Add a comment to a post."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
|
||||
# Check if post exists and allows comments
|
||||
post_data = await r.hget("rmi:bulletin_posts", post_id)
|
||||
if not post_data:
|
||||
return None
|
||||
|
||||
post_dict = json.loads(post_data)
|
||||
if not post_dict.get("allow_comments", False):
|
||||
return None
|
||||
|
||||
comment_id = f"comment_{int(time.time())}_{os.urandom(4).hex()}"
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
comment = Comment(
|
||||
comment_id=comment_id,
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
author_name=author_name,
|
||||
author_email=author_email,
|
||||
content=ContentSanitizer.sanitize(content)[:2000],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
await r.hset("rmi:bulletin_comments", comment_id, json.dumps(comment.to_dict()))
|
||||
await r.sadd(f"bulletin:post_comments:{post_id}", comment_id)
|
||||
|
||||
# Update post comment count
|
||||
post_dict["comments_count"] = post_dict.get("comments_count", 0) + 1
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
|
||||
|
||||
return comment
|
||||
|
||||
@staticmethod
|
||||
async def get_comments(post_id: str, limit: int = 100) -> list[dict]:
|
||||
"""Get comments for a post."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
comment_ids = await r.smembers(f"bulletin:post_comments:{post_id}")
|
||||
|
||||
comments = []
|
||||
for cid in comment_ids:
|
||||
data = await r.hget("rmi:bulletin_comments", cid)
|
||||
if data:
|
||||
comments.append(json.loads(data))
|
||||
|
||||
comments.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
return comments[:limit]
|
||||
|
||||
@staticmethod
|
||||
async def get_stats() -> dict[str, Any]:
|
||||
"""Get bulletin board statistics."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
|
||||
stats = {
|
||||
"total_posts": await r.hlen("rmi:bulletin_posts") or 0,
|
||||
"published": await r.scard("bulletin:status:published") or 0,
|
||||
"drafts": await r.scard("bulletin:status:draft") or 0,
|
||||
"review": await r.scard("bulletin:status:review") or 0,
|
||||
"archived": await r.scard("bulletin:status:archived") or 0,
|
||||
"scheduled": await r.scard("bulletin:status:scheduled") or 0,
|
||||
"pinned": await r.scard("bulletin:pinned") or 0,
|
||||
"total_comments": await r.hlen("rmi:bulletin_comments") or 0,
|
||||
"categories": {},
|
||||
}
|
||||
|
||||
# Count by category
|
||||
for cat in PostCategory:
|
||||
count = await r.scard(f"bulletin:category:{cat.value}") or 0
|
||||
stats["categories"][cat.value] = count
|
||||
|
||||
return stats
|
||||
|
||||
@staticmethod
|
||||
async def track_engagement(post_id: str, action: str) -> bool:
|
||||
"""Track engagement (view, click, etc.)."""
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
data = await r.hget("rmi:bulletin_posts", post_id)
|
||||
if not data:
|
||||
return False
|
||||
|
||||
post_dict = json.loads(data)
|
||||
|
||||
if action == "view":
|
||||
post_dict["view_count"] = post_dict.get("view_count", 0) + 1
|
||||
elif action == "click":
|
||||
post_dict["click_count"] = post_dict.get("click_count", 0) + 1
|
||||
|
||||
# Calculate engagement score
|
||||
views = post_dict.get("view_count", 0)
|
||||
clicks = post_dict.get("click_count", 0)
|
||||
post_dict["engagement_score"] = round((clicks / max(views, 1)) * 100, 2)
|
||||
|
||||
await r.hset("rmi:bulletin_posts", post_id, json.dumps(post_dict))
|
||||
return True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# BADGES & X402 BOT PAYMENTS
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
BADGES = {
|
||||
"first_post": {
|
||||
"name": "First Words",
|
||||
"icon": "💬",
|
||||
"desc": "Made your first post",
|
||||
"tier": "bronze",
|
||||
},
|
||||
"10_posts": {"name": "Chatterbox", "icon": "📢", "desc": "10 posts", "tier": "bronze"},
|
||||
"50_posts": {"name": "Board Regular", "icon": "🎙️", "desc": "50 posts", "tier": "silver"},
|
||||
"100_posts": {
|
||||
"name": "Terminally Online",
|
||||
"icon": "🖥️",
|
||||
"desc": "100 posts - touch grass",
|
||||
"tier": "gold",
|
||||
},
|
||||
"10_upvotes": {
|
||||
"name": "Approved",
|
||||
"icon": "👍",
|
||||
"desc": "10 upvotes on a post",
|
||||
"tier": "bronze",
|
||||
},
|
||||
"50_upvotes": {"name": "Crowd Favorite", "icon": "⭐", "desc": "50 upvotes", "tier": "silver"},
|
||||
"100_upvotes": {"name": "Legendary", "icon": "👑", "desc": "100 upvotes", "tier": "gold"},
|
||||
"rug_reporter": {
|
||||
"name": "Rug Detective",
|
||||
"icon": "🔍",
|
||||
"desc": "Reported 3 verified rugs",
|
||||
"tier": "silver",
|
||||
},
|
||||
"scam_buster": {
|
||||
"name": "Scam Buster",
|
||||
"icon": "🛡️",
|
||||
"desc": "10 scam alerts verified",
|
||||
"tier": "gold",
|
||||
},
|
||||
"honeypot_hunter": {
|
||||
"name": "Honeypot Hunter",
|
||||
"icon": "🍯",
|
||||
"desc": "Found 5 honeypots",
|
||||
"tier": "silver",
|
||||
},
|
||||
"whale_watcher": {
|
||||
"name": "Whale Watcher",
|
||||
"icon": "🐋",
|
||||
"desc": "Tracked 10 whale moves",
|
||||
"tier": "silver",
|
||||
},
|
||||
"alpha_caller": {
|
||||
"name": "Alpha Caller",
|
||||
"icon": "📈",
|
||||
"desc": "Called 5 pumps",
|
||||
"tier": "gold",
|
||||
},
|
||||
"degen": {
|
||||
"name": "Certified Degen",
|
||||
"icon": "🎰",
|
||||
"desc": "Posted in every category",
|
||||
"tier": "gold",
|
||||
},
|
||||
"rug_survivor": {
|
||||
"name": "Rug Survivor",
|
||||
"icon": "💀",
|
||||
"desc": "Posted about getting rugged",
|
||||
"tier": "bronze",
|
||||
},
|
||||
"diamond_hands": {
|
||||
"name": "Diamond Hands",
|
||||
"icon": "💎",
|
||||
"desc": "Held through -90%",
|
||||
"tier": "diamond",
|
||||
},
|
||||
"based": {
|
||||
"name": "Based",
|
||||
"icon": "🧠",
|
||||
"desc": "Called a 10x before it happened",
|
||||
"tier": "diamond",
|
||||
},
|
||||
"bot_verified": {
|
||||
"name": "Verified Bot",
|
||||
"icon": "🤖",
|
||||
"desc": "Registered x402 bot",
|
||||
"tier": "silver",
|
||||
},
|
||||
"bot_pro": {"name": "Bot Pro", "icon": "⚡", "desc": "100+ API calls", "tier": "gold"},
|
||||
}
|
||||
BADGE_TIERS = {"bronze": "#CD7F32", "silver": "#C0C0C0", "gold": "#FFD700", "diamond": "#B9F2FF"}
|
||||
|
||||
|
||||
async def get_user_badges(user_id: str) -> list:
|
||||
try:
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
ids = await r.smembers(f"bb:badges:{user_id}")
|
||||
await r.close()
|
||||
return [{"id": bid, **BADGES[bid]} for bid in ids if bid in BADGES]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def award_badge(user_id: str, badge_id: str) -> bool:
|
||||
if badge_id not in BADGES:
|
||||
return False
|
||||
try:
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
ok = await r.sadd(f"bb:badges:{user_id}", badge_id)
|
||||
await r.close()
|
||||
return ok > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_reputation(user_id: str) -> dict:
|
||||
try:
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
p = r.pipeline()
|
||||
p.get(f"bb:rep:{user_id}")
|
||||
p.scard(f"bb:badges:{user_id}")
|
||||
p.get(f"bb:posts:{user_id}")
|
||||
rep, bc, pc = await p.execute()
|
||||
await r.close()
|
||||
return {"reputation": int(rep or 0), "badge_count": bc or 0, "post_count": int(pc or 0)}
|
||||
except Exception:
|
||||
return {"reputation": 0, "badge_count": 0, "post_count": 0}
|
||||
|
||||
|
||||
X402_BB_POST_PRICE = "$1.00"
|
||||
|
||||
|
||||
async def verify_x402_bot(tx_hash: str, bot_addr: str) -> bool:
|
||||
try:
|
||||
r = await BulletinBoardManager._get_redis()
|
||||
if await r.get(f"bb:x402:tx:{tx_hash}"):
|
||||
await r.close()
|
||||
return False
|
||||
await r.setex(f"bb:x402:tx:{tx_hash}", 86400, bot_addr)
|
||||
await r.setex(f"bb:x402:bot:{bot_addr}", 2592000, "1") # 30 day auth
|
||||
await r.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
from app.domains.bulletin.badges import (
|
||||
BADGE_TIERS,
|
||||
BADGES,
|
||||
X402_BB_POST_PRICE,
|
||||
award_badge,
|
||||
get_user_badges,
|
||||
get_user_reputation,
|
||||
verify_x402_bot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BADGES",
|
||||
"BADGE_TIERS",
|
||||
"X402_BB_POST_PRICE",
|
||||
"award_badge",
|
||||
"get_user_badges",
|
||||
"get_user_reputation",
|
||||
"verify_x402_bot",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .base import (
|
|||
_RateLimiter,
|
||||
)
|
||||
from .chains import build_provider_chains
|
||||
from .deployer import _funding_source, _rugmaps_deployer_tokens
|
||||
from .free_apis import _mcp_bridge
|
||||
from .local import (
|
||||
_passthrough_alerts,
|
||||
|
|
@ -93,6 +94,7 @@ __all__ = [
|
|||
"_dexscreener_trades",
|
||||
"_dune_early_buyers",
|
||||
"_etherscan_tx_trace",
|
||||
"_funding_source",
|
||||
"_local_token_price",
|
||||
"_local_wallet_labels",
|
||||
"_mcp_bridge",
|
||||
|
|
@ -113,6 +115,7 @@ __all__ = [
|
|||
"_passthrough_trending",
|
||||
"_quota_usage",
|
||||
"_rate_limiters",
|
||||
"_rugmaps_deployer_tokens",
|
||||
"_santiment_dev_activity",
|
||||
"_solana_tracker_price",
|
||||
"_solana_tracker_token",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ __all__ = [
|
|||
"_rate_limiters",
|
||||
]
|
||||
|
||||
|
||||
class ProviderTier(Enum):
|
||||
LOCAL = "local" # Our own data - instant, free, unlimited
|
||||
FREE_API = "free_api" # Free external API - no key needed
|
||||
|
|
@ -99,7 +100,9 @@ class ProviderChain:
|
|||
|
||||
if credit_pressure:
|
||||
# Re-sort: push free/local providers above paid/freemium near quota
|
||||
providers_sorted.sort(key=lambda p: (0 if p.tier.value in ("local", "free_api") else 1, -p.weight))
|
||||
providers_sorted.sort(
|
||||
key=lambda p: (0 if p.tier.value in ("local", "free_api") else 1, -p.weight)
|
||||
)
|
||||
|
||||
for provider in providers_sorted:
|
||||
# Check circuit breaker
|
||||
|
|
@ -137,7 +140,10 @@ class ProviderChain:
|
|||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Provider {provider.name} failed: {e}")
|
||||
logger.warning(
|
||||
f"Provider {provider.name} ({self.data_type}) failed: {type(e).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
_circuit_breakers[provider.name].record_failure()
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ logger = logging.getLogger("databus.providers")
|
|||
|
||||
__all__ = ["build_provider_chains"]
|
||||
|
||||
|
||||
def build_provider_chains() -> dict[str, ProviderChain]:
|
||||
"""Build all fallback chains for every data type."""
|
||||
chains = {}
|
||||
|
|
@ -79,7 +80,7 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
ProviderTier.FREEMIUM,
|
||||
_solana_tracker_price,
|
||||
weight=8.0,
|
||||
rate_limit_rps=3.0,
|
||||
rate_limit_rps=0.1,
|
||||
requires_key=True,
|
||||
key_env="SOLANATRACKER_API_KEY",
|
||||
monthly_quota=5000,
|
||||
|
|
@ -91,7 +92,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
weight=5.0,
|
||||
rate_limit_rps=2.0,
|
||||
),
|
||||
Provider("coingecko", ProviderTier.FREE_API, _coingecko_price, weight=5.0),
|
||||
Provider(
|
||||
"coingecko", ProviderTier.FREE_API, _coingecko_price, weight=5.0, rate_limit_rps=0.5
|
||||
),
|
||||
Provider(
|
||||
"moralis",
|
||||
ProviderTier.PAID,
|
||||
|
|
@ -109,7 +112,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
data_type="market_overview",
|
||||
description="Global market cap, BTC/ETH/SOL prices, fear & greed",
|
||||
providers=[
|
||||
Provider("rmi_market_overview", ProviderTier.LOCAL, _passthrough_market_overview, weight=10.0),
|
||||
Provider(
|
||||
"rmi_market_overview", ProviderTier.LOCAL, _passthrough_market_overview, weight=10.0
|
||||
),
|
||||
Provider(
|
||||
"coingecko_global",
|
||||
ProviderTier.FREEMIUM,
|
||||
|
|
@ -132,7 +137,7 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
ProviderTier.FREEMIUM,
|
||||
_solana_tracker_trending,
|
||||
weight=8.0,
|
||||
rate_limit_rps=3.0,
|
||||
rate_limit_rps=0.1,
|
||||
requires_key=True,
|
||||
key_env="SOLANATRACKER_API_KEY",
|
||||
monthly_quota=5000,
|
||||
|
|
@ -277,16 +282,22 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
return await _bq.get_token_price(kw.get("network", "ethereum"), kw.get("token", ""))
|
||||
|
||||
async def _bq_holder_data(**kw):
|
||||
return await _bq.get_holder_distribution(kw.get("network", "ethereum"), kw.get("token", ""))
|
||||
return await _bq.get_holder_distribution(
|
||||
kw.get("network", "ethereum"), kw.get("token", "")
|
||||
)
|
||||
|
||||
async def _bq_tx_trace(**kw):
|
||||
return await _bq.get_transaction_trace(kw.get("network", "ethereum"), kw.get("tx_hash", ""))
|
||||
return await _bq.get_transaction_trace(
|
||||
kw.get("network", "ethereum"), kw.get("tx_hash", "")
|
||||
)
|
||||
|
||||
async def _bq_dex_volume(**kw):
|
||||
return await _bq.get_dex_volume(kw.get("network", "ethereum"))
|
||||
|
||||
async def _bq_address_balance(**kw):
|
||||
return await _bq.get_address_balance(kw.get("network", "ethereum"), kw.get("address", ""))
|
||||
return await _bq.get_address_balance(
|
||||
kw.get("network", "ethereum"), kw.get("address", "")
|
||||
)
|
||||
|
||||
async def _bq_cross_chain(**kw):
|
||||
return await _bq.get_cross_chain_transfers(kw.get("address", ""))
|
||||
|
|
@ -409,7 +420,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
data_type="rag_search",
|
||||
description="Semantic search across 17K+ scam documents and patterns",
|
||||
providers=[
|
||||
Provider("local_rag", ProviderTier.LOCAL, _passthrough_rag, weight=10.0, rate_limit_rps=5.0),
|
||||
Provider(
|
||||
"local_rag", ProviderTier.LOCAL, _passthrough_rag, weight=10.0, rate_limit_rps=5.0
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -838,6 +851,26 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
except ImportError as e:
|
||||
logger.warning(f"Premium scanner not available: {e}")
|
||||
|
||||
# ── RUGMAPS DEPLOYER TOKENS - Reverse deployer index ──
|
||||
try:
|
||||
from app.domains.databus.providers.deployer import _rugmaps_deployer_tokens
|
||||
|
||||
chains["rugmaps_deployer_tokens"] = ProviderChain(
|
||||
"rugmaps_deployer_tokens",
|
||||
description="RugMaps deployer token index - find all tokens created by a deployer address",
|
||||
providers=[
|
||||
Provider(
|
||||
"rugmaps_deployer_scanner",
|
||||
ProviderTier.LOCAL,
|
||||
_rugmaps_deployer_tokens,
|
||||
weight=10.0,
|
||||
rate_limit_rps=3.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
logger.info("RugMaps Deployer Tokens chain registered")
|
||||
except ImportError as e:
|
||||
logger.warning(f"RugMaps Deployer Tokens not available: {e}")
|
||||
# ── WEBHOOK SYSTEM ──
|
||||
try:
|
||||
from app.domains.databus.webhooks import ( # noqa: F401
|
||||
|
|
@ -1642,7 +1675,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
try:
|
||||
limit = kwargs.get("limit", 5)
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"http://localhost:8000/api/v1/market/hyperliquid-action?limit={limit}")
|
||||
r = await c.get(
|
||||
f"http://localhost:8000/api/v1/market/hyperliquid-action?limit={limit}"
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
|
|
@ -1668,7 +1703,9 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
limit = kwargs.get("limit", 10)
|
||||
tier = kwargs.get("tier", "free")
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"http://localhost:8000/api/v1/market/insider-wallets?limit={limit}&tier={tier}")
|
||||
r = await c.get(
|
||||
f"http://localhost:8000/api/v1/market/insider-wallets?limit={limit}&tier={tier}"
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
|
|
@ -1679,17 +1716,255 @@ def build_provider_chains() -> dict[str, ProviderChain]:
|
|||
data_type="insider_wallets",
|
||||
description="Likely insider trader wallets to watch (Premium)",
|
||||
providers=[
|
||||
Provider("rmi_insider_wallets", ProviderTier.LOCAL, _passthrough_insider_wallets, weight=10.0),
|
||||
Provider(
|
||||
"rmi_insider_wallets", ProviderTier.LOCAL, _passthrough_insider_wallets, weight=10.0
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# ── GHOST CHAIN RECOVERY - 9 passthrough fallbacks ──
|
||||
|
||||
async def _passthrough_rugmaps_analysis(**kwargs) -> dict | None:
|
||||
try:
|
||||
address = kwargs.get("address", "")
|
||||
chain = kwargs.get("chain", "solana")
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(
|
||||
f"http://localhost:8000/api/v1/rugmaps/analyze/{address}?chain={chain}"
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["rugmaps_analysis"] = ProviderChain(
|
||||
data_type="rugmaps_analysis",
|
||||
description="RugMaps AI analysis for wallet risk scoring",
|
||||
providers=[
|
||||
Provider(
|
||||
"rmi_rugmaps_analysis",
|
||||
ProviderTier.LOCAL,
|
||||
_passthrough_rugmaps_analysis,
|
||||
weight=10.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def _passthrough_rugmaps_graph(**kwargs) -> dict | None:
|
||||
try:
|
||||
address = kwargs.get("address", "")
|
||||
chain = kwargs.get("chain", "solana")
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(
|
||||
f"http://localhost:8000/api/v1/rugmaps/analyze/{address}?chain={chain}&depth=3"
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["rugmaps_graph"] = ProviderChain(
|
||||
data_type="rugmaps_graph",
|
||||
description="RugMaps graph visualization data",
|
||||
providers=[
|
||||
Provider(
|
||||
"rmi_rugmaps_graph", ProviderTier.LOCAL, _passthrough_rugmaps_graph, weight=10.0
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def _passthrough_sentinel_deep(**kwargs) -> dict | None:
|
||||
try:
|
||||
address = kwargs.get("address", "")
|
||||
chain = kwargs.get("chain", "solana")
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(
|
||||
f"http://localhost:8000/api/v1/security/wallet/{address}?chain={chain}"
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["sentinel_deep"] = ProviderChain(
|
||||
data_type="sentinel_deep",
|
||||
description="Deep SENTINEL threat scan + contract analysis",
|
||||
providers=[
|
||||
Provider(
|
||||
"rmi_sentinel_deep", ProviderTier.LOCAL, _passthrough_sentinel_deep, weight=10.0
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def _passthrough_token_detail(**kwargs) -> dict | None:
|
||||
try:
|
||||
mint = kwargs.get("mint", "")
|
||||
chain = kwargs.get("chain", "solana")
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"http://localhost:8000/api/v1/tokens/{mint}?chain={chain}")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["token_detail"] = ProviderChain(
|
||||
data_type="token_detail",
|
||||
description="Rich token detail (metadata + pricing)",
|
||||
providers=[
|
||||
Provider("rmi_token_detail", ProviderTier.LOCAL, _passthrough_token_detail, weight=10.0)
|
||||
],
|
||||
)
|
||||
|
||||
async def _passthrough_wallet_pnl(**kwargs) -> dict | None:
|
||||
try:
|
||||
address = kwargs.get("address", "")
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"http://localhost:8000/api/v1/wallets/{address}")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["wallet_pnl"] = ProviderChain(
|
||||
data_type="wallet_pnl",
|
||||
description="Wallet PnL via wallet detail API",
|
||||
providers=[
|
||||
Provider("rmi_wallet_pnl", ProviderTier.LOCAL, _passthrough_wallet_pnl, weight=10.0)
|
||||
],
|
||||
)
|
||||
|
||||
async def _passthrough_threat_check(**kwargs) -> dict | None:
|
||||
try:
|
||||
address = kwargs.get("address", "")
|
||||
chain_id = kwargs.get("chain_id", "1")
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.post(
|
||||
"http://localhost:8000/api/v1/security/threat/check",
|
||||
json={"addresses": [address], "chain": chain_id},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["threat_check"] = ProviderChain(
|
||||
data_type="threat_check",
|
||||
description="Threat intel (CryptoScamDB + GoPlus + Januus)",
|
||||
providers=[
|
||||
Provider("rmi_threat_check", ProviderTier.LOCAL, _passthrough_threat_check, weight=10.0)
|
||||
],
|
||||
)
|
||||
|
||||
async def _passthrough_contract_scan(**kwargs) -> dict | None:
|
||||
try:
|
||||
address = kwargs.get("address", "")
|
||||
chain = kwargs.get("chain", "base")
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(
|
||||
f"http://localhost:8000/api/v1/security/contract/{address}?chain={chain}&deep=true"
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["contract_scan"] = ProviderChain(
|
||||
data_type="contract_scan",
|
||||
description="Contract deep scan (Slither + Mythril)",
|
||||
providers=[
|
||||
Provider(
|
||||
"rmi_contract_scan", ProviderTier.LOCAL, _passthrough_contract_scan, weight=10.0
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def _passthrough_wallet_balance(**kwargs) -> dict | None:
|
||||
try:
|
||||
address = kwargs.get("address", "")
|
||||
chain = kwargs.get("chain", "eth")
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.post(
|
||||
"http://localhost:8000/api/v1/moralis/wallet/balance",
|
||||
json={"address": address, "chain": chain},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["wallet_balance"] = ProviderChain(
|
||||
data_type="wallet_balance",
|
||||
description="Wallet native balance via Moralis",
|
||||
providers=[
|
||||
Provider(
|
||||
"rmi_wallet_balance", ProviderTier.LOCAL, _passthrough_wallet_balance, weight=10.0
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
logger.info("Ghost chain recovery: 9 passthrough fallbacks registered")
|
||||
|
||||
async def _passthrough_gmgn_smart_money(**kwargs) -> dict | None:
|
||||
try:
|
||||
limit = kwargs.get("limit", 20)
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"http://localhost:8000/api/v1/market/smart-money?limit={limit}")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return None
|
||||
|
||||
chains["gmgn_smart_money"] = ProviderChain(
|
||||
data_type="gmgn_smart_money",
|
||||
description="GMGN smart money narrative",
|
||||
providers=[
|
||||
Provider(
|
||||
"rmi_gmgn_smart_money",
|
||||
ProviderTier.LOCAL,
|
||||
_passthrough_gmgn_smart_money,
|
||||
weight=10.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
# ── FUNDING SOURCE TRACER ──
|
||||
try:
|
||||
from app.domains.databus.providers.deployer import _funding_source
|
||||
|
||||
chains["funding_source"] = ProviderChain(
|
||||
"funding_source",
|
||||
description="Multi-hop funding source tracer - trace where wallet funds originated (exchange, mixer, bridge, fresh wallet)",
|
||||
providers=[
|
||||
Provider(
|
||||
"funding_tracer",
|
||||
ProviderTier.LOCAL,
|
||||
_funding_source,
|
||||
weight=10.0,
|
||||
rate_limit_rps=3.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
logger.info("Funding Source chain registered")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Funding Source not available: {e}")
|
||||
|
||||
# ── PREDICTION SIGNALS - Market intelligence layer ──
|
||||
async def _passthrough_prediction_signals(**kwargs) -> dict | None:
|
||||
try:
|
||||
limit = kwargs.get("limit", 5)
|
||||
tier = kwargs.get("tier", "free")
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(f"http://localhost:8000/api/v1/market/prediction-signals?limit={limit}&tier={tier}")
|
||||
r = await c.get(
|
||||
f"http://localhost:8000/api/v1/market/prediction-signals?limit={limit}&tier={tier}"
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
|
|
|
|||
949
app/domains/databus/providers/deployer.py
Normal file
949
app/domains/databus/providers/deployer.py
Normal file
|
|
@ -0,0 +1,949 @@
|
|||
# ruff: noqa: S110,S112,SIM102,SIM105
|
||||
"""RugMaps Deployer Tokens & Funding Source Providers.
|
||||
|
||||
Primary data source: public RPC endpoints with multi-endpoint failover.
|
||||
No dependency on Helius, SolanaFM, Etherscan, or any paid API.
|
||||
Those are optional accelerators only — if their keys are set, they
|
||||
provide faster/better data but the chain runs without them.
|
||||
|
||||
deployer_tokens — given a deployer address, find all tokens created
|
||||
funding_source — trace where a wallet's funds originated (multi-hop)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
|
||||
import base58
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# PUBLIC RPC ENDPOINTS — fully-owned, no third-party API dependency
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
SOLANA_PUBLIC_RPC = [
|
||||
"https://api.mainnet-beta.solana.com",
|
||||
"https://solana.drpc.org",
|
||||
"https://solana-rpc.publicnode.com",
|
||||
"https://rpc.ankr.com/solana",
|
||||
]
|
||||
|
||||
EVM_PUBLIC_RPC = {
|
||||
1: [ # Ethereum
|
||||
"https://eth.llamarpc.com",
|
||||
"https://rpc.ankr.com/eth",
|
||||
"https://eth.drpc.org",
|
||||
"https://cloudflare-eth.com",
|
||||
],
|
||||
56: [ # BSC
|
||||
"https://bsc-dataseed1.binance.org",
|
||||
"https://bsc-dataseed2.binance.org",
|
||||
"https://rpc.ankr.com/bsc",
|
||||
"https://bsc.drpc.org",
|
||||
],
|
||||
137: [ # Polygon
|
||||
"https://polygon-rpc.com",
|
||||
"https://rpc.ankr.com/polygon",
|
||||
"https://polygon.drpc.org",
|
||||
],
|
||||
42161: [ # Arbitrum
|
||||
"https://arb1.arbitrum.io/rpc",
|
||||
"https://rpc.ankr.com/arbitrum",
|
||||
],
|
||||
10: [ # Optimism
|
||||
"https://mainnet.optimism.io",
|
||||
"https://rpc.ankr.com/optimism",
|
||||
],
|
||||
8453: [ # Base
|
||||
"https://mainnet.base.org",
|
||||
"https://base.drpc.org",
|
||||
"https://rpc.ankr.com/base",
|
||||
],
|
||||
}
|
||||
|
||||
# Optional accelerated paths — used only if key is available
|
||||
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
|
||||
HELIUS_RPC = "https://mainnet.helius-rpc.com/"
|
||||
SOLANA_FM_API_KEY = os.getenv("SOLANA_FM_API_KEY", "")
|
||||
ETHERSCAN_KEY = os.getenv("ETHERSCAN_API_KEY", "")
|
||||
|
||||
# ERC20 Transfer event signature: keccak256("Transfer(address,address,uint256)")
|
||||
ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CORE PROVIDER FUNCTIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _rugmaps_deployer_tokens(**kwargs) -> dict | None:
|
||||
"""Given a deployer address, find all tokens they created.
|
||||
|
||||
Primary path: public Solana RPC (getSignaturesForAddress -> getTransaction ->
|
||||
decode mint authority from SPL mint data). Helius used only as accelerator.
|
||||
"""
|
||||
deployer = kwargs.get("address", "") or kwargs.get("deployer", "") or kwargs.get("mint", "")
|
||||
chain = kwargs.get("chain", "solana")
|
||||
|
||||
if not deployer:
|
||||
return None
|
||||
|
||||
try:
|
||||
tokens = await _discover_deployer_tokens_solana(deployer)
|
||||
if tokens is None and HELIUS_API_KEY:
|
||||
tokens = await _discover_deployer_tokens_helius(deployer)
|
||||
|
||||
if not tokens:
|
||||
return _empty_deployer_response(deployer, chain)
|
||||
|
||||
token_count = len(tokens)
|
||||
velocity = _compute_velocity(token_count)
|
||||
frequency = _compute_deployment_frequency(tokens)
|
||||
|
||||
return {
|
||||
"deployer": deployer,
|
||||
"chain": chain,
|
||||
"tokens": tokens,
|
||||
"token_count": token_count,
|
||||
"patterns": {
|
||||
"velocity": velocity,
|
||||
"deployment_frequency": frequency,
|
||||
"chain_distribution": {"solana": token_count},
|
||||
},
|
||||
"source": "public_rpc",
|
||||
}
|
||||
|
||||
except Exception:
|
||||
logger.warning("rugmaps_deployer_tokens failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _funding_source(**kwargs) -> dict | None:
|
||||
"""Trace where a wallet's funds originated using public RPC endpoints.
|
||||
|
||||
Primary path: public RPC (Solana getSignaturesForAddress -> getTransaction
|
||||
for balance deltas; EVM eth_getLogs for incoming transfers).
|
||||
Helius/Etherscan used only as accelerators if keys are present.
|
||||
"""
|
||||
address = kwargs.get("address", "") or kwargs.get("wallet", "")
|
||||
chain_id = kwargs.get("chain_id", 0)
|
||||
|
||||
if not address:
|
||||
return None
|
||||
|
||||
try:
|
||||
if chain_id == 0 or chain_id is None:
|
||||
funders = await _trace_funding_solana_public(address)
|
||||
else:
|
||||
funders = await _trace_funding_evm_public(address, chain_id)
|
||||
|
||||
if not funders:
|
||||
return _empty_funding_response()
|
||||
|
||||
first = funders[0] if funders else None
|
||||
source_type = _classify_funder(first) if first else "unknown"
|
||||
|
||||
return {
|
||||
"funders": funders,
|
||||
"first_funder": first,
|
||||
"funding_tx_count": len(funders),
|
||||
"source_type": source_type,
|
||||
"source": "public_rpc",
|
||||
}
|
||||
|
||||
except Exception:
|
||||
logger.warning("funding_source failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RPC CALL HELPERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _rpc_call(
|
||||
endpoints: list[str], method: str, params: list, rpc_timeout: float = 15.0
|
||||
) -> dict | None:
|
||||
"""Call an RPC method across multiple endpoints. Returns first success."""
|
||||
for url in endpoints:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=rpc_timeout) as client:
|
||||
r = await client.post(
|
||||
url,
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
if "result" in data:
|
||||
return data["result"]
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def _solana_rpc(method: str, params: list, rpc_timeout: float = 15.0) -> dict | None:
|
||||
"""Call Solana RPC with public endpoint failover."""
|
||||
return await _rpc_call(SOLANA_PUBLIC_RPC, method, params, rpc_timeout)
|
||||
|
||||
|
||||
async def _evm_rpc(
|
||||
chain_id: int, method: str, params: list, rpc_timeout: float = 15.0
|
||||
) -> dict | None:
|
||||
"""Call EVM RPC with public endpoint failover."""
|
||||
endpoints = EVM_PUBLIC_RPC.get(chain_id, EVM_PUBLIC_RPC[1])
|
||||
return await _rpc_call(endpoints, method, params, rpc_timeout)
|
||||
|
||||
|
||||
async def _rpc_call_batch(
|
||||
endpoints: list[str], payloads: list[dict], rpc_timeout: float = 30.0
|
||||
) -> list[dict | None]:
|
||||
"""Batch RPC calls across endpoints. Returns results in order."""
|
||||
for url in endpoints:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=rpc_timeout) as client:
|
||||
r = await client.post(url, json=payloads)
|
||||
if r.status_code == 200:
|
||||
results = r.json()
|
||||
if isinstance(results, list):
|
||||
return [item.get("result") for item in results]
|
||||
except Exception:
|
||||
continue
|
||||
return [None] * len(payloads)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SOLANA — DEPLOYER TOKEN DISCOVERY (public RPC)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _discover_deployer_tokens_solana(deployer: str) -> list[dict] | None:
|
||||
"""Discover tokens created by a deployer using public Solana RPC.
|
||||
|
||||
Strategy:
|
||||
1. getSignaturesForAddress -> recent transaction signatures
|
||||
2. getTransaction (jsonParsed) -> extract mint addresses from token balance changes
|
||||
3. getAccountInfo (base64) -> verify mint authority == deployer
|
||||
"""
|
||||
sigs = await _solana_rpc("getSignaturesForAddress", [deployer, {"limit": 500}])
|
||||
if not sigs:
|
||||
return None
|
||||
|
||||
candidate_mints: dict[str, dict] = {}
|
||||
signatures = [s["signature"] for s in sigs[:100] if s.get("signature")]
|
||||
|
||||
for i in range(0, len(signatures), 20):
|
||||
batch = signatures[i : i + 20]
|
||||
payloads = [
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": idx,
|
||||
"method": "getTransaction",
|
||||
"params": [sig, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}],
|
||||
}
|
||||
for idx, sig in enumerate(batch)
|
||||
]
|
||||
results = await _rpc_call_batch(SOLANA_PUBLIC_RPC, payloads)
|
||||
for tx_data in results:
|
||||
if tx_data:
|
||||
_extract_mints_from_tx(tx_data, deployer, candidate_mints)
|
||||
|
||||
if not candidate_mints:
|
||||
return None
|
||||
|
||||
verified: list[dict] = []
|
||||
for mint, info in list(candidate_mints.items())[:200]:
|
||||
verified_info = await _verify_and_enrich_mint(mint, deployer)
|
||||
if verified_info:
|
||||
verified_info["block_time"] = info.get("block_time")
|
||||
verified.append(verified_info)
|
||||
|
||||
return verified if verified else None
|
||||
|
||||
|
||||
def _extract_mints_from_tx(tx_data: dict, deployer: str, candidate_mints: dict[str, dict]):
|
||||
"""Extract mint addresses from parsed transaction data."""
|
||||
meta = tx_data.get("meta", {})
|
||||
block_time = tx_data.get("blockTime")
|
||||
|
||||
pre_tokens = meta.get("preTokenBalances", [])
|
||||
post_tokens = meta.get("postTokenBalances", [])
|
||||
|
||||
pre_map = {tb["mint"]: tb for tb in pre_tokens}
|
||||
post_map = {tb["mint"]: tb for tb in post_tokens}
|
||||
|
||||
for mint, post_tb in post_map.items():
|
||||
post_owner = post_tb.get("owner", "")
|
||||
post_amount_raw = post_tb.get("uiTokenAmount", {}).get("amount", "0")
|
||||
try:
|
||||
post_amount = int(post_amount_raw)
|
||||
except (ValueError, TypeError):
|
||||
post_amount = 0
|
||||
|
||||
if post_owner != deployer:
|
||||
continue
|
||||
|
||||
pre_tb = pre_map.get(mint)
|
||||
pre_amount = 0
|
||||
if pre_tb and pre_tb.get("uiTokenAmount"):
|
||||
try:
|
||||
pre_amount = int(pre_tb["uiTokenAmount"].get("amount", "0"))
|
||||
except (ValueError, TypeError):
|
||||
pre_amount = 0
|
||||
|
||||
if post_amount > pre_amount and (pre_amount == 0 or post_amount > pre_amount * 100):
|
||||
if mint not in candidate_mints:
|
||||
candidate_mints[mint] = {"block_time": block_time}
|
||||
|
||||
|
||||
async def _verify_and_enrich_mint(mint: str, deployer: str) -> dict | None:
|
||||
"""Verify mint authority == deployer and enrich with metadata via public RPC."""
|
||||
account_info = await _solana_rpc("getAccountInfo", [mint, {"encoding": "base64"}])
|
||||
if not account_info:
|
||||
return None
|
||||
|
||||
raw_data = None
|
||||
data_field = account_info.get("data")
|
||||
if isinstance(data_field, list) and len(data_field) >= 1:
|
||||
raw_data = data_field[0]
|
||||
elif isinstance(data_field, str):
|
||||
raw_data = data_field
|
||||
|
||||
if not raw_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(raw_data)
|
||||
mint_authority = _decode_mint_authority(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not mint_authority or mint_authority != deployer:
|
||||
return None
|
||||
|
||||
supply = _decode_supply(raw)
|
||||
decimals = _decode_decimals(raw)
|
||||
name, symbol = await _fetch_token_metadata_public(mint)
|
||||
|
||||
return {
|
||||
"mint": mint,
|
||||
"name": name,
|
||||
"symbol": symbol,
|
||||
"decimals": decimals,
|
||||
"supply": supply,
|
||||
}
|
||||
|
||||
|
||||
# ── SPL Mint data layout decoders (4 + 32 + 8 + 1 + 1 + 4 + 32 = 82 bytes) ──
|
||||
|
||||
|
||||
def _decode_mint_authority(raw: bytes) -> str | None:
|
||||
"""Decode mint authority from SPL mint account data.
|
||||
|
||||
Layout: option<u32>(4 bytes) + authority<Pubkey>(32 bytes)
|
||||
"""
|
||||
if len(raw) < 36:
|
||||
return None
|
||||
option = struct.unpack_from("<I", raw, 0)[0]
|
||||
if option == 0:
|
||||
return None
|
||||
return base58.b58encode(raw[4:36]).decode("ascii")
|
||||
|
||||
|
||||
def _decode_supply(raw: bytes) -> int:
|
||||
"""Decode total supply from SPL mint account data (u64 LE at offset 36)."""
|
||||
if len(raw) < 44:
|
||||
return 0
|
||||
return struct.unpack_from("<Q", raw, 36)[0]
|
||||
|
||||
|
||||
def _decode_decimals(raw: bytes) -> int:
|
||||
"""Decode decimals from SPL mint account data (u8 at offset 44)."""
|
||||
if len(raw) < 45:
|
||||
return 0
|
||||
return raw[44]
|
||||
|
||||
|
||||
async def _fetch_token_metadata_public(mint: str) -> tuple[str, str]:
|
||||
"""Fetch token name/symbol from public RPC via getAsset (DAS) or Metaplex.
|
||||
|
||||
Tries Helius DAS getAsset first (works on some public RPCs that
|
||||
support the DAS extension), falls back to Metaplex metadata parsing.
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "getAsset",
|
||||
"params": [mint],
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(SOLANA_PUBLIC_RPC[0], json=payload)
|
||||
if r.status_code == 200:
|
||||
result = r.json().get("result", {})
|
||||
content = result.get("content", {}).get("metadata", {})
|
||||
if content:
|
||||
return content.get("name", "Unknown"), content.get("symbol", "???")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
metadata_seed = b"metadata"
|
||||
metadata_program = bytes(base58.b58decode("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"))
|
||||
mint_pubkey = bytes(base58.b58decode(mint))
|
||||
|
||||
seeds = [metadata_seed, metadata_program, mint_pubkey]
|
||||
import hashlib
|
||||
|
||||
pda_input = b"".join(seeds)
|
||||
pda_hash = hashlib.sha256(pda_input).digest()
|
||||
|
||||
pda_info = await _solana_rpc(
|
||||
"getAccountInfo",
|
||||
[base58.b58encode(pda_hash).decode("ascii"), {"encoding": "base64"}],
|
||||
rpc_timeout=8.0,
|
||||
)
|
||||
if pda_info:
|
||||
raw_data = None
|
||||
data_field = pda_info.get("data")
|
||||
if isinstance(data_field, list) and len(data_field) >= 1:
|
||||
raw_data = data_field[0]
|
||||
elif isinstance(data_field, str):
|
||||
raw_data = data_field
|
||||
|
||||
if raw_data:
|
||||
raw = base64.b64decode(raw_data)
|
||||
return _parse_metaplex_metadata(raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "Unknown", "???"
|
||||
|
||||
|
||||
def _parse_metaplex_metadata(raw: bytes) -> tuple[str, str]:
|
||||
"""Parse Metaplex token metadata name and symbol from raw account data.
|
||||
|
||||
Layout (after key + update_auth + mint): name_len(u32) + name + symbol_len(u32) + symbol
|
||||
"""
|
||||
try:
|
||||
idx = 1 + 32 + 32
|
||||
if idx + 4 > len(raw):
|
||||
return "Unknown", "???"
|
||||
name_len = struct.unpack_from("<I", raw, idx)[0]
|
||||
idx += 4
|
||||
name_end = idx + name_len
|
||||
if name_end > len(raw):
|
||||
return "Unknown", "???"
|
||||
name = raw[idx:name_end].rstrip(b"\x00").decode("utf-8", errors="replace").strip()
|
||||
|
||||
idx = name_end
|
||||
if idx + 4 > len(raw):
|
||||
return name, "???"
|
||||
symbol_len = struct.unpack_from("<I", raw, idx)[0]
|
||||
idx += 4
|
||||
symbol_end = idx + symbol_len
|
||||
if symbol_end > len(raw):
|
||||
return name, "???"
|
||||
symbol = raw[idx:symbol_end].rstrip(b"\x00").decode("utf-8", errors="replace").strip()
|
||||
|
||||
return name or "Unknown", symbol or "???"
|
||||
except Exception:
|
||||
return "Unknown", "???"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SOLANA — FUNDING SOURCE TRACING (public RPC)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _trace_funding_solana_public(address: str) -> list[dict] | None:
|
||||
"""Trace Solana wallet funding sources using public RPC.
|
||||
|
||||
Strategy:
|
||||
1. getSignaturesForAddress -> recent transaction signatures
|
||||
2. getTransaction (jsonParsed) -> compute SOL balance deltas
|
||||
3. For incoming SOL, the account with decreasing balance is the funder
|
||||
"""
|
||||
sigs = await _solana_rpc("getSignaturesForAddress", [address, {"limit": 100}])
|
||||
if not sigs:
|
||||
return None
|
||||
|
||||
funders: dict[str, dict] = {}
|
||||
seen_sigs: set[str] = set()
|
||||
|
||||
for sig_entry in sigs[:50]:
|
||||
sig = sig_entry.get("signature", "")
|
||||
if not sig or sig in seen_sigs:
|
||||
continue
|
||||
seen_sigs.add(sig)
|
||||
|
||||
tx = await _solana_rpc(
|
||||
"getTransaction",
|
||||
[sig, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}],
|
||||
)
|
||||
if not tx:
|
||||
continue
|
||||
|
||||
funder_info = _extract_sol_funder_from_tx(tx, address)
|
||||
if funder_info:
|
||||
addr = funder_info["address"]
|
||||
if addr not in funders:
|
||||
funders[addr] = funder_info
|
||||
|
||||
if len(funders) >= 50:
|
||||
break
|
||||
|
||||
if not funders:
|
||||
return None
|
||||
|
||||
result = list(funders.values())
|
||||
result.sort(key=lambda x: x.get("amount_sol", 0), reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_sol_funder_from_tx(tx_data: dict, target: str) -> dict | None:
|
||||
"""Extract SOL funder info from a parsed Solana transaction."""
|
||||
try:
|
||||
meta = tx_data.get("meta", {})
|
||||
account_keys = tx_data.get("transaction", {}).get("message", {}).get("accountKeys", [])
|
||||
|
||||
pre_balances = meta.get("preBalances", [])
|
||||
post_balances = meta.get("postBalances", [])
|
||||
block_time = tx_data.get("blockTime")
|
||||
|
||||
target_idx = None
|
||||
for i, acc in enumerate(account_keys):
|
||||
key = acc.get("pubkey") if isinstance(acc, dict) else acc
|
||||
if key == target:
|
||||
target_idx = i
|
||||
break
|
||||
|
||||
if target_idx is None or target_idx >= len(pre_balances):
|
||||
return None
|
||||
|
||||
pre_bal = pre_balances[target_idx]
|
||||
post_bal = post_balances[target_idx]
|
||||
diff = post_bal - pre_bal
|
||||
|
||||
if diff <= 0:
|
||||
return None
|
||||
|
||||
for i, acc in enumerate(account_keys):
|
||||
if i == target_idx or i >= len(pre_balances):
|
||||
continue
|
||||
pre = pre_balances[i]
|
||||
post = post_balances[i]
|
||||
sent = pre - post
|
||||
if sent > 0 and sent >= diff * 0.5:
|
||||
key = acc.get("pubkey") if isinstance(acc, dict) else acc
|
||||
return {
|
||||
"address": key,
|
||||
"amount_sol": sent / 1e9,
|
||||
"tx_hash": tx_data.get("transaction", {}).get("signatures", [""])[0],
|
||||
"block_time": block_time,
|
||||
}
|
||||
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# EVM — PUBLIC RPC PROVIDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _discover_deployer_tokens_evm(deployer: str, chain_id: int) -> list[dict] | None:
|
||||
"""Discover tokens created by a deployer on EVM chains using public RPC.
|
||||
|
||||
Uses eth_getLogs with the ERC20 Transfer event signature.
|
||||
A token creation is a Transfer from 0x0000...0000 (mint event).
|
||||
"""
|
||||
zero_addr = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
topics = [
|
||||
ERC20_TRANSFER_TOPIC,
|
||||
"0x" + zero_addr[2:].rjust(64, "0"),
|
||||
]
|
||||
|
||||
params = [
|
||||
{
|
||||
"address": None,
|
||||
"fromBlock": "0x0",
|
||||
"toBlock": "latest",
|
||||
"topics": topics,
|
||||
}
|
||||
]
|
||||
|
||||
logs = await _evm_rpc(chain_id, "eth_getLogs", params, rpc_timeout=30.0)
|
||||
if not logs:
|
||||
return None
|
||||
|
||||
contract_addresses: set[str] = set()
|
||||
for log_entry in logs[:500]:
|
||||
addr = log_entry.get("address", "")
|
||||
if addr and addr.lower() != zero_addr.lower():
|
||||
contract_addresses.add(addr.lower())
|
||||
|
||||
if not contract_addresses:
|
||||
return None
|
||||
|
||||
tokens: list[dict] = []
|
||||
for addr in list(contract_addresses)[:100]:
|
||||
name, symbol, decimals = await _fetch_erc20_metadata_public(addr, chain_id)
|
||||
tokens.append(
|
||||
{
|
||||
"mint": addr,
|
||||
"name": name,
|
||||
"symbol": symbol,
|
||||
"decimals": decimals,
|
||||
}
|
||||
)
|
||||
|
||||
return tokens if tokens else None
|
||||
|
||||
|
||||
async def _fetch_erc20_metadata_public(contract: str, chain_id: int) -> tuple[str, str, int]:
|
||||
"""Fetch ERC20 token metadata using public RPC (eth_call)."""
|
||||
name = "Unknown"
|
||||
symbol = "???"
|
||||
decimals = 18
|
||||
|
||||
calls = [
|
||||
("name", "0x06fdde03"),
|
||||
("symbol", "0x95d89b41"),
|
||||
("decimals", "0x313ce567"),
|
||||
]
|
||||
|
||||
for label, sig_hash in calls:
|
||||
try:
|
||||
result = await _evm_rpc(
|
||||
chain_id,
|
||||
"eth_call",
|
||||
[{"to": contract, "data": sig_hash}, "latest"],
|
||||
rpc_timeout=10.0,
|
||||
)
|
||||
if result and result != "0x":
|
||||
hex_part = result[2:] if result.startswith("0x") else result
|
||||
if label == "decimals":
|
||||
try:
|
||||
decimals = int(result, 16)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
decoded = (
|
||||
bytes.fromhex(hex_part)
|
||||
.decode("utf-8", errors="replace")
|
||||
.rstrip("\x00")
|
||||
.strip()
|
||||
)
|
||||
if label == "name":
|
||||
name = decoded or name
|
||||
else:
|
||||
symbol = decoded or symbol
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return name, symbol, decimals
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# EVM — FUNDING SOURCE TRACING (public RPC + Etherscan accelerator)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _trace_funding_evm_public(address: str, chain_id: int) -> list[dict] | None:
|
||||
"""Trace EVM wallet funding sources using public RPC and optional Etherscan.
|
||||
|
||||
Primary: eth_getLogs for incoming ETH transfers (Transfer event with
|
||||
no indexed 'from' topic, or using native ETH transfer detection).
|
||||
|
||||
Acceleration: Etherscan API if ETHERSCAN_API_KEY is set (much faster).
|
||||
"""
|
||||
if ETHERSCAN_KEY:
|
||||
result = await _trace_funding_evm_etherscan(address, chain_id)
|
||||
if result:
|
||||
return result
|
||||
|
||||
addr_padded = address.lower().lstrip("0x").rjust(64, "0")
|
||||
|
||||
params = [
|
||||
{
|
||||
"address": None,
|
||||
"fromBlock": "0x0",
|
||||
"toBlock": "latest",
|
||||
"topics": [None, None, "0x" + addr_padded],
|
||||
}
|
||||
]
|
||||
|
||||
logs = await _evm_rpc(chain_id, "eth_getLogs", params, rpc_timeout=30.0)
|
||||
if not logs:
|
||||
return None
|
||||
|
||||
funders: dict[str, dict] = {}
|
||||
for log_entry in logs[:200]:
|
||||
topics_list = log_entry.get("topics", [])
|
||||
if len(topics_list) < 3:
|
||||
continue
|
||||
|
||||
from_addr = "0x" + topics_list[1][-40:]
|
||||
zero_addr = "0x" + "0" * 40
|
||||
if from_addr == zero_addr:
|
||||
continue
|
||||
|
||||
hex_data = log_entry.get("data", "0x0")
|
||||
try:
|
||||
amount_wei = int(hex_data, 16)
|
||||
except (ValueError, TypeError):
|
||||
amount_wei = 0
|
||||
|
||||
if from_addr not in funders:
|
||||
funders[from_addr] = {
|
||||
"address": from_addr,
|
||||
"amount_eth": amount_wei / 1e18,
|
||||
"tx_hash": log_entry.get("transactionHash", ""),
|
||||
"block_number": int(log_entry.get("blockNumber", "0x0"), 16),
|
||||
}
|
||||
|
||||
if not funders:
|
||||
return None
|
||||
|
||||
result = list(funders.values())
|
||||
result.sort(key=lambda x: x.get("amount_eth", 0), reverse=True)
|
||||
|
||||
for f in result:
|
||||
f["source_type"] = "unknown"
|
||||
|
||||
return result[:50]
|
||||
|
||||
|
||||
async def _trace_funding_evm_etherscan(address: str, chain_id: int) -> list[dict] | None:
|
||||
"""Use Etherscan API for EVM funding trace (optional accelerator)."""
|
||||
if not ETHERSCAN_KEY:
|
||||
return None
|
||||
|
||||
api_urls = {
|
||||
1: "https://api.etherscan.io/api",
|
||||
56: "https://api.bscscan.com/api",
|
||||
137: "https://api.polygonscan.com/api",
|
||||
42161: "https://api.arbiscan.io/api",
|
||||
10: "https://api-optimistic.etherscan.io/api",
|
||||
8453: "https://api.basescan.org/api",
|
||||
}
|
||||
api_url = api_urls.get(chain_id)
|
||||
if not api_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(
|
||||
api_url,
|
||||
params={
|
||||
"module": "account",
|
||||
"action": "txlist",
|
||||
"address": address,
|
||||
"startblock": 0,
|
||||
"endblock": 99999999,
|
||||
"page": 1,
|
||||
"offset": 100,
|
||||
"sort": "asc",
|
||||
"apikey": ETHERSCAN_KEY,
|
||||
},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
|
||||
data = r.json()
|
||||
if data.get("status") != "1":
|
||||
return None
|
||||
|
||||
txs = data.get("result", [])
|
||||
funders: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
target_lower = address.lower()
|
||||
|
||||
for tx in txs:
|
||||
to_addr = (tx.get("to") or "").lower()
|
||||
from_addr = (tx.get("from") or "").lower()
|
||||
try:
|
||||
value = int(tx.get("value", 0))
|
||||
except (ValueError, TypeError):
|
||||
value = 0
|
||||
|
||||
if to_addr == target_lower and from_addr not in seen and value > 0:
|
||||
seen.add(from_addr)
|
||||
funders.append(
|
||||
{
|
||||
"address": tx.get("from"),
|
||||
"amount_eth": value / 1e18,
|
||||
"tx_hash": tx.get("hash"),
|
||||
"block_time": int(tx.get("timeStamp", 0)),
|
||||
"source_type": "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
return funders[:50] if funders else None
|
||||
|
||||
except Exception:
|
||||
logger.warning("Etherscan funding trace failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# HELIUS ACCELERATOR — optional, only if key is available
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _discover_deployer_tokens_helius(deployer: str) -> list[dict] | None:
|
||||
"""Helius-accelerated deployer token discovery. Only runs if key is set."""
|
||||
if not HELIUS_API_KEY:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{HELIUS_RPC}?api-key={HELIUS_API_KEY}",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "dt-1",
|
||||
"method": "getSignaturesForAddress",
|
||||
"params": [deployer, {"limit": 500}],
|
||||
},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
|
||||
signatures = r.json().get("result", [])
|
||||
if not signatures:
|
||||
return None
|
||||
|
||||
token_mints: set[str] = set()
|
||||
token_meta: dict[str, dict] = {}
|
||||
|
||||
for sig in signatures[:80]:
|
||||
tx_sig = sig.get("signature")
|
||||
if not tx_sig:
|
||||
continue
|
||||
|
||||
tr = await client.post(
|
||||
f"{HELIUS_RPC}?api-key={HELIUS_API_KEY}",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "tx-1",
|
||||
"method": "getTransaction",
|
||||
"params": [
|
||||
tx_sig,
|
||||
{"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0},
|
||||
],
|
||||
},
|
||||
)
|
||||
if tr.status_code != 200:
|
||||
continue
|
||||
|
||||
tx_data = tr.json().get("result")
|
||||
if not tx_data:
|
||||
continue
|
||||
|
||||
meta = tx_data.get("meta", {})
|
||||
block_time = tx_data.get("blockTime")
|
||||
|
||||
for tb in meta.get("postTokenBalances", []):
|
||||
owner = tb.get("owner", "")
|
||||
mint = tb.get("mint", "")
|
||||
if owner == deployer and mint:
|
||||
token_mints.add(mint)
|
||||
token_meta[mint] = {"block_time": block_time}
|
||||
|
||||
tokens = []
|
||||
for mint in list(token_mints)[:200]:
|
||||
name, symbol = await _fetch_token_metadata_public(mint)
|
||||
tokens.append(
|
||||
{
|
||||
"mint": mint,
|
||||
"name": name,
|
||||
"symbol": symbol,
|
||||
"decimals": 0,
|
||||
"block_time": token_meta.get(mint, {}).get("block_time"),
|
||||
}
|
||||
)
|
||||
|
||||
return tokens if tokens else None
|
||||
|
||||
except Exception:
|
||||
logger.warning("Helius deployer token fetch failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLASSIFICATION HELPERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _classify_funder(funder: dict | None) -> str:
|
||||
"""Classify a funding source."""
|
||||
if not funder:
|
||||
return "unknown"
|
||||
return funder.get("source_type", "unknown")
|
||||
|
||||
|
||||
def _compute_velocity(token_count: int) -> str:
|
||||
"""Classify deployment velocity."""
|
||||
if token_count > 50:
|
||||
return "high"
|
||||
elif token_count > 10:
|
||||
return "medium"
|
||||
else:
|
||||
return "low"
|
||||
|
||||
|
||||
def _compute_deployment_frequency(tokens: list[dict]) -> str:
|
||||
"""Compute deployment frequency from token list block times."""
|
||||
block_times = [t.get("block_time") for t in tokens if t.get("block_time")]
|
||||
if len(block_times) < 2:
|
||||
return "insufficient_data"
|
||||
|
||||
block_times.sort()
|
||||
time_span = max(block_times) - min(block_times)
|
||||
if time_span <= 0:
|
||||
return "burst"
|
||||
|
||||
hours = time_span / 3600
|
||||
if hours <= 0:
|
||||
return "burst"
|
||||
|
||||
tokens_per_hour = len(block_times) / hours
|
||||
if tokens_per_hour > 10:
|
||||
return "burst"
|
||||
elif tokens_per_hour > 1:
|
||||
return "high"
|
||||
elif tokens_per_hour > 0.1:
|
||||
return "moderate"
|
||||
else:
|
||||
return "low"
|
||||
|
||||
|
||||
def _empty_deployer_response(deployer: str, chain: str) -> dict:
|
||||
return {
|
||||
"deployer": deployer,
|
||||
"chain": chain,
|
||||
"tokens": [],
|
||||
"token_count": 0,
|
||||
"patterns": {"velocity": "none", "deployment_frequency": "none"},
|
||||
"source": "public_rpc",
|
||||
}
|
||||
|
||||
|
||||
def _empty_funding_response() -> dict:
|
||||
return {
|
||||
"funders": [],
|
||||
"first_funder": None,
|
||||
"funding_tx_count": 0,
|
||||
"source_type": "unknown",
|
||||
"source": "public_rpc",
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ __all__ = [
|
|||
"_passthrough_trending",
|
||||
]
|
||||
|
||||
|
||||
async def _passthrough_market_overview(**kwargs) -> dict | None:
|
||||
"""Market overview from our own /api/v1/content/market-overview."""
|
||||
try:
|
||||
|
|
@ -64,14 +65,16 @@ async def _passthrough_alerts(**kwargs) -> dict | None:
|
|||
return {"count": count, "alerts": recent}
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
return {"count": 0, "alerts": []}
|
||||
return None
|
||||
|
||||
|
||||
async def _passthrough_scanner(address: str = "", chain: str = "solana", **kw) -> dict | None:
|
||||
"""SENTINEL scanner - our own token security analysis."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post("http://localhost:8000/api/v1/token/scan", json={"address": address, "chain": chain})
|
||||
r = await c.post(
|
||||
"http://localhost:8000/api/v1/token/scan", json={"address": address, "chain": chain}
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"""DataBus providers - news/social data providers."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
__all__ = ["_coindesk_news", "_messari_news", "_santiment_dev_activity"]
|
||||
|
||||
|
||||
async def _messari_news(limit: int = 20, **kw) -> dict | None:
|
||||
"""Messari News API - curated crypto news with per-asset sentiment scores."""
|
||||
api_key = kw.get("api_key", "") or os.getenv("MESSARI_API_KEY", "")
|
||||
|
|
@ -16,7 +17,9 @@ async def _messari_news(limit: int = 20, **kw) -> dict | None:
|
|||
try:
|
||||
headers = {"X-Messari-API-Key": api_key, "Accept": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get("https://api.messari.io/news/v1/news/feed", params={"limit": limit}, headers=headers)
|
||||
r = await c.get(
|
||||
"https://api.messari.io/news/v1/news/feed", params={"limit": limit}, headers=headers
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
if data.get("data"):
|
||||
|
|
@ -25,7 +28,9 @@ async def _messari_news(limit: int = 20, **kw) -> dict | None:
|
|||
for item in data["data"]:
|
||||
assets = [a.get("symbol", "Unknown") for a in item.get("assets", [])]
|
||||
sentiment = item.get("sentiment", [])
|
||||
avg_sentiment = sum(s.get("sentiment", 0) for s in sentiment) / max(len(sentiment), 1)
|
||||
avg_sentiment = sum(s.get("sentiment", 0) for s in sentiment) / max(
|
||||
len(sentiment), 1
|
||||
)
|
||||
|
||||
articles.append(
|
||||
{
|
||||
|
|
@ -70,7 +75,7 @@ async def _coindesk_news(limit: int = 20, **kw) -> dict | None:
|
|||
"url": item.get("url", ""),
|
||||
"source": item.get("source", "CoinDesk"),
|
||||
"published_at": datetime.fromtimestamp(
|
||||
item.get("published_on", 0), tz=timezone.utc # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
|
||||
item.get("published_on", 0), tz=UTC
|
||||
).isoformat(),
|
||||
"description": item.get("body", ""),
|
||||
"category": item.get("categories", "news").lower(),
|
||||
|
|
@ -116,7 +121,9 @@ async def _santiment_dev_activity(project_slug: str = "bitcoin", **kw) -> dict |
|
|||
data = r.json()
|
||||
return {
|
||||
"project": project_slug,
|
||||
"dev_activity": data.get("data", {}).get("getMetric", {}).get("timeseriesData", []),
|
||||
"dev_activity": data.get("data", {})
|
||||
.get("getMetric", {})
|
||||
.get("timeseriesData", []),
|
||||
"source": "santiment",
|
||||
}
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ class SecurityGate:
|
|||
provided = request.headers.get("X-Admin-Key", "")
|
||||
provided = provided or request.query_params.get("admin_key", "")
|
||||
if not ADMIN_KEY:
|
||||
logger.warning("ADMIN_API_KEY not set, allowing admin access")
|
||||
return True
|
||||
logger.critical("ADMIN_API_KEY not set, rejecting admin access")
|
||||
return False
|
||||
if provided and provided == ADMIN_KEY:
|
||||
return True
|
||||
logger.warning(f"Admin access denied for data_type={data_type}")
|
||||
|
|
@ -142,7 +142,9 @@ class SecurityGate:
|
|||
sanitized[k] = SecurityGate.sanitize_response(v, data_type, access_level)
|
||||
elif isinstance(v, list):
|
||||
sanitized[k] = [
|
||||
SecurityGate.sanitize_response(item, data_type, access_level) if isinstance(item, dict) else item
|
||||
SecurityGate.sanitize_response(item, data_type, access_level)
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
for item in v
|
||||
]
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ def _get_hmac_key() -> bytes:
|
|||
"""Load HMAC signing key from environment. Never hardcoded."""
|
||||
key = os.getenv("X402_HMAC_KEY", "")
|
||||
if not key:
|
||||
key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_KEY", "dev-key-only-for-local"))
|
||||
key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_KEY", ""))
|
||||
if "dev" not in key:
|
||||
logging.getLogger("wp.x402").warning("X402_HMAC_KEY not set, using ADMIN_API_KEY")
|
||||
return key.encode()
|
||||
|
|
|
|||
|
|
@ -22,37 +22,64 @@ log = logging.getLogger(__name__)
|
|||
def register_middleware(app) -> None:
|
||||
"""Register all middleware. Each registration is isolated."""
|
||||
_try_register_cors(app)
|
||||
_try_register_auth(app)
|
||||
_try_register_security_headers(app)
|
||||
_try_register_prometheus(app)
|
||||
_try_register_cost_tracking(app)
|
||||
_try_register_tracing(app)
|
||||
_try_register_rate_limit(app)
|
||||
|
||||
|
||||
def _try_register_cors(app) -> None:
|
||||
"""T19 - CORS hardening with strict allowlist."""
|
||||
"""T19 - CORS hardening with config-driven allowlist."""
|
||||
try:
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
ALLOWED_ORIGINS = [ # noqa: N806 - list literal in function, uppercase intentional for visibility
|
||||
"https://rugmunch.io",
|
||||
"https://www.rugmunch.io",
|
||||
"https://mcp.rugmunch.io",
|
||||
"https://x402.rugmunch.io",
|
||||
"https://rugcharts.io",
|
||||
"https://rugmaps.io",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
]
|
||||
from app.config import settings
|
||||
|
||||
origins = settings.cors_origins
|
||||
if not origins or origins == ["*"]:
|
||||
log.warning(
|
||||
"middleware_cors_wildcard origins=%s — override with CORS_ORIGINS env",
|
||||
origins,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Request-ID", "X-Payment"],
|
||||
)
|
||||
log.info("middleware_registered name=cors")
|
||||
log.info("middleware_registered name=cors origins=%d", len(origins))
|
||||
except Exception as exc:
|
||||
log.warning("middleware_skipped name=cors err=%s", exc)
|
||||
log.critical(
|
||||
"middleware_cors_fail_closed err=%s — falling back to localhost only",
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Request-ID", "X-Payment"],
|
||||
)
|
||||
log.info("middleware_registered name=cors_fallback origins=localhost_only")
|
||||
except Exception as exc2:
|
||||
log.critical("middleware_cors_catastrophic_fail err=%s", exc2)
|
||||
|
||||
|
||||
def _try_register_auth(app) -> None:
|
||||
"""Register AuthMiddleware for API key verification on write endpoints."""
|
||||
try:
|
||||
from app.core.auth import AuthMiddleware
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
log.info("middleware_registered name=auth")
|
||||
except Exception as exc:
|
||||
log.warning("middleware_skipped name=auth err=%s", exc)
|
||||
|
||||
|
||||
def _try_register_security_headers(app) -> None:
|
||||
|
|
@ -112,3 +139,55 @@ def _try_register_tracing(app) -> None:
|
|||
log.info("middleware_registered name=tracing")
|
||||
except Exception as exc:
|
||||
log.warning("middleware_skipped name=tracing err=%s", exc)
|
||||
|
||||
|
||||
def _try_register_rate_limit(app) -> None:
|
||||
"""Register rate limiting middleware with Redis backend.
|
||||
|
||||
General endpoints: 100 req/min per client. Admin endpoints: 20 req/min.
|
||||
Degrades gracefully (pass-through) when Redis is unavailable.
|
||||
"""
|
||||
try:
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.redis import get_redis
|
||||
from app.middleware.rate_limit import RateLimiter
|
||||
|
||||
redis_client = get_redis()
|
||||
limiter = RateLimiter(redis_client)
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
if path.startswith(("/health", "/docs", "/redoc", "/openapi.json", "/metrics")):
|
||||
return await call_next(request)
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
api_key = request.headers.get("X-API-Key", "")
|
||||
identifier = api_key or client_ip
|
||||
|
||||
if path.startswith("/api/v1/admin"):
|
||||
key = f"rmi:rl:admin:{identifier}"
|
||||
limit = 20
|
||||
else:
|
||||
key = f"rmi:rl:general:{identifier}"
|
||||
limit = 100
|
||||
|
||||
allowed, remaining = limiter.check(key, limit, window=60)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Rate limit exceeded", "retry_after": 60},
|
||||
headers={"Retry-After": "60", "X-RateLimit-Remaining": "0"},
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return response
|
||||
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
log.info("middleware_registered name=rate_limit redis=%s", redis_client is not None)
|
||||
except Exception as exc:
|
||||
log.warning("middleware_skipped name=rate_limit err=%s", exc)
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ ROUTER_MODULES: Final[list[str]] = [
|
|||
#
|
||||
# Routers (24) - total ~140 routes added:
|
||||
"app.routers.bulletin", # /api/v1/bulletin/* (21 routes, supabase)
|
||||
"app.routers.bulletin_board", # /api/v1/bulletin-board/* (22 routes, app/bulletin_board.py)
|
||||
# "app.routers.bulletin_board" -- DEAD CODE, moved to bulletin.py.bak
|
||||
"app.routers.admin.bulletin", # /api/v1/admin/bulletin/* (darkroom admin — RBAC auth)
|
||||
"app.routers.intelligence_router", # /api/v1/intelligence/* (19 routes, n8n-fed)
|
||||
"app.routers.intelligence_panel", # /api/v1/intelligence/* (4 routes, /feed /latest /crypto-fear-index)
|
||||
"app.routers.chat", # /api/v1/chat/* (5 routes, AI chat surface)
|
||||
|
|
@ -102,7 +103,6 @@ ROUTER_MODULES: Final[list[str]] = [
|
|||
"app.routers.developer_routes", # /api/v1/developer/*
|
||||
"app.routers.investigator_routes", # /api/v1/investigators/*
|
||||
"app.routers.profile_routes", # /api/v1/profile/*
|
||||
|
||||
# DEFERRED Wave 3 (2):
|
||||
# "app.routers.unified_wallet_scanner" — NO `router` APIRouter attribute.
|
||||
# Module exposes `get_wallet_scanner()` consumed by unified_scanner_router.
|
||||
|
|
|
|||
1
app/routers/admin/__init__.py
Normal file
1
app/routers/admin/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Admin routers — darkroom backend endpoints."""
|
||||
436
app/routers/admin/bulletin.py
Normal file
436
app/routers/admin/bulletin.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
"""
|
||||
Bulletin Board Admin Router — Darkroom Backend
|
||||
================================================
|
||||
Admin endpoints for managing the community bulletin board.
|
||||
All endpoints require admin authentication via X-Admin-Session header.
|
||||
|
||||
Permissions:
|
||||
- MODERATOR: view reports, moderate posts, view bots
|
||||
- ADMIN: all moderator + manage bots, view moderation log
|
||||
- SUPERADMIN: everything including dangerous ops
|
||||
|
||||
Mounted at: /api/v1/admin/bulletin/*
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.domains.admin.core import AdminRole, require_admin
|
||||
from app.services.supabase_service import (
|
||||
create_bulletin_post,
|
||||
get_bot_fee_status,
|
||||
get_bulletin_posts,
|
||||
get_bulletin_stats,
|
||||
get_comments,
|
||||
get_moderation_log,
|
||||
get_reports,
|
||||
list_bots,
|
||||
log_moderation,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("admin.bulletin")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin/bulletin", tags=["admin-bulletin"])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MODELS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class AdminPostIn(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=300)
|
||||
content: str = Field(..., min_length=1, max_length=10000)
|
||||
category: str = "announcement"
|
||||
pinned: bool = False
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class ModerationAction(BaseModel):
|
||||
admin_id: str
|
||||
action: str # remove, pin, unpin, lock, unlock, restore, ban_user
|
||||
target_type: str # post, user, bot
|
||||
target_id: str
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class BotManageAction(BaseModel):
|
||||
bot_id: str
|
||||
action: str # ban, unban, approve, reject
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# DASHBOARD — overview stats for admin panel
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def admin_dashboard(request: Request):
|
||||
"""Overview dashboard for the bulletin board admin panel."""
|
||||
admin = await require_admin(request, "dashboard.read", AdminRole.MODERATOR)
|
||||
|
||||
stats = await get_bulletin_stats()
|
||||
|
||||
open_reports = await get_reports(status="open", limit=100)
|
||||
active_bots = await list_bots(status="active", limit=50)
|
||||
|
||||
return {
|
||||
"stats": stats,
|
||||
"open_reports": len(open_reports) if open_reports else 0,
|
||||
"active_bots": len(active_bots) if active_bots else 0,
|
||||
"admin": {"name": admin.get("email", "unknown"), "role": admin.get("role", "unknown")},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# POSTS — full control over bulletin posts
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/posts")
|
||||
async def admin_list_posts(
|
||||
request: Request,
|
||||
category: str = Query(None),
|
||||
status: str = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
cursor: str = Query(None),
|
||||
):
|
||||
"""List all bulletin posts with full admin visibility."""
|
||||
await require_admin(request, "content.read", AdminRole.MODERATOR)
|
||||
|
||||
posts = await get_bulletin_posts(category=category, sort="created_at.desc", limit=limit)
|
||||
result = posts or []
|
||||
return {
|
||||
"posts": result,
|
||||
"total": len(result),
|
||||
"next_cursor": result[-1]["id"] if len(result) == limit else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/posts")
|
||||
async def admin_create_post(request: Request, post: AdminPostIn):
|
||||
"""Create an official bulletin post as admin."""
|
||||
admin = await require_admin(request, "content.write", AdminRole.MODERATOR)
|
||||
|
||||
result = await create_bulletin_post(
|
||||
title=post.title,
|
||||
content=post.content,
|
||||
author_id=admin["admin_id"],
|
||||
category=post.category,
|
||||
tags=post.tags,
|
||||
source="admin_darkroom",
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create post")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/moderate")
|
||||
async def admin_moderate_post(request: Request, post_id: str, action: ModerationAction):
|
||||
"""Moderate a bulletin post — pin, unpin, lock, unlock, remove, restore."""
|
||||
admin = await require_admin(request, "content.write", AdminRole.MODERATOR)
|
||||
|
||||
import contextlib
|
||||
|
||||
from app.services.supabase_service import _get_supabase
|
||||
|
||||
supabase = await _get_supabase()
|
||||
|
||||
status_update: dict = {}
|
||||
if action.action == "remove":
|
||||
status_update = {"removed": True, "removed_reason": action.reason}
|
||||
elif action.action == "restore":
|
||||
status_update = {"removed": False, "removed_reason": None}
|
||||
elif action.action == "pin":
|
||||
status_update = {"pinned": True}
|
||||
elif action.action == "unpin":
|
||||
status_update = {"pinned": False}
|
||||
elif action.action == "lock":
|
||||
status_update = {"locked": True}
|
||||
elif action.action == "unlock":
|
||||
status_update = {"locked": False}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action: {action.action}")
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
await supabase.table("bulletin_posts").update(status_update).eq("id", post_id).execute()
|
||||
|
||||
await log_moderation(
|
||||
admin_id=admin["admin_id"],
|
||||
action=action.action,
|
||||
target_type="post",
|
||||
target_id=post_id,
|
||||
reason=action.reason,
|
||||
)
|
||||
|
||||
return {"status": "ok", "post_id": post_id, "action": action.action}
|
||||
|
||||
|
||||
@router.delete("/posts/{post_id}")
|
||||
async def admin_delete_post(request: Request, post_id: str):
|
||||
"""Permanently delete a bulletin post. SUPERADMIN only."""
|
||||
await require_admin(request, "content.write", AdminRole.SUPERADMIN)
|
||||
|
||||
import contextlib
|
||||
|
||||
from app.services.supabase_service import _get_supabase
|
||||
|
||||
supabase = await _get_supabase()
|
||||
with contextlib.suppress(Exception):
|
||||
await supabase.table("bulletin_posts").delete().eq("id", post_id).execute()
|
||||
|
||||
await log_moderation(
|
||||
admin_id="superadmin",
|
||||
action="delete",
|
||||
target_type="post",
|
||||
target_id=post_id,
|
||||
reason="Permanent deletion",
|
||||
)
|
||||
return {"status": "deleted", "post_id": post_id}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# COMMENTS — moderation
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/comments")
|
||||
async def admin_get_comments(request: Request, post_id: str, limit: int = Query(100)):
|
||||
"""Get all comments for a post with full admin visibility."""
|
||||
await require_admin(request, "content.read", AdminRole.MODERATOR)
|
||||
|
||||
comments = await get_comments(post_id, limit)
|
||||
return {"comments": comments or [], "post_id": post_id}
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}/moderate")
|
||||
async def admin_moderate_comment(request: Request, comment_id: str, action: str = Query(...)):
|
||||
"""Moderate a comment — approve or remove."""
|
||||
await require_admin(request, "content.write", AdminRole.MODERATOR)
|
||||
|
||||
import contextlib
|
||||
|
||||
from app.services.supabase_service import _get_supabase
|
||||
|
||||
supabase = await _get_supabase()
|
||||
status = "approved" if action == "approve" else "removed"
|
||||
with contextlib.suppress(Exception):
|
||||
await (
|
||||
supabase.table("bulletin_comments")
|
||||
.update({"status": status})
|
||||
.eq("id", comment_id)
|
||||
.execute()
|
||||
)
|
||||
|
||||
await log_moderation(
|
||||
admin_id="moderator",
|
||||
action=f"comment_{action}",
|
||||
target_type="comment",
|
||||
target_id=comment_id,
|
||||
)
|
||||
return {"status": "ok", "comment_id": comment_id, "action": action}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# REPORTS — manage user reports
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
async def admin_list_reports(
|
||||
request: Request,
|
||||
status: str = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
):
|
||||
"""List all reports with optional status filter."""
|
||||
await require_admin(request, "content.read", AdminRole.MODERATOR)
|
||||
|
||||
reports = await get_reports(status=status, limit=limit)
|
||||
return {"reports": reports or [], "total": len(reports) if reports else 0}
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/resolve")
|
||||
async def admin_resolve_report(
|
||||
request: Request, report_id: str, resolution: str = Query("resolved")
|
||||
):
|
||||
"""Resolve a report (mark as resolved or dismissed)."""
|
||||
await require_admin(request, "content.write", AdminRole.MODERATOR)
|
||||
|
||||
import contextlib
|
||||
|
||||
from app.services.supabase_service import _get_supabase
|
||||
|
||||
supabase = await _get_supabase()
|
||||
with contextlib.suppress(Exception):
|
||||
await (
|
||||
supabase.table("bulletin_reports")
|
||||
.update({"status": resolution})
|
||||
.eq("id", report_id)
|
||||
.execute()
|
||||
)
|
||||
|
||||
await log_moderation(
|
||||
admin_id="moderator",
|
||||
action=f"report_{resolution}",
|
||||
target_type="report",
|
||||
target_id=report_id,
|
||||
)
|
||||
return {"status": "ok", "report_id": report_id, "resolution": resolution}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# BOTS — manage bulletin board bots
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/bots")
|
||||
async def admin_list_bots(request: Request, status: str = Query(None), limit: int = Query(100)):
|
||||
"""List all registered bots."""
|
||||
await require_admin(request, "content.read", AdminRole.MODERATOR)
|
||||
|
||||
bots = await list_bots(status=status, limit=limit)
|
||||
return {"bots": bots or [], "total": len(bots) if bots else 0}
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}")
|
||||
async def admin_bot_detail(request: Request, bot_id: str):
|
||||
"""Get full details for a bot including fee status."""
|
||||
await require_admin(request, "content.read", AdminRole.MODERATOR)
|
||||
|
||||
fee_status = await get_bot_fee_status(bot_id)
|
||||
return {"bot_id": bot_id, "fee_status": fee_status}
|
||||
|
||||
|
||||
@router.post("/bots/{bot_id}/manage")
|
||||
async def admin_manage_bot(request: Request, bot_id: str, action: BotManageAction):
|
||||
"""Ban, unban, approve, or reject a bot."""
|
||||
await require_admin(request, "content.write", AdminRole.ADMIN)
|
||||
|
||||
import contextlib
|
||||
|
||||
from app.services.supabase_service import _get_supabase
|
||||
|
||||
supabase = await _get_supabase()
|
||||
status = (
|
||||
"active"
|
||||
if action.action == "approve"
|
||||
else ("banned" if action.action == "ban" else "inactive")
|
||||
)
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
await (
|
||||
supabase.table("bot_registrations")
|
||||
.update({"status": status})
|
||||
.eq("bot_user_id", bot_id)
|
||||
.execute()
|
||||
)
|
||||
|
||||
await log_moderation(
|
||||
admin_id="admin",
|
||||
action=f"bot_{action.action}",
|
||||
target_type="bot",
|
||||
target_id=bot_id,
|
||||
)
|
||||
return {"status": "ok", "bot_id": bot_id, "action": action.action}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MODERATION LOG — audit trail
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/moderation-log")
|
||||
async def admin_moderation_log(request: Request, limit: int = Query(100)):
|
||||
"""View the full moderation audit trail."""
|
||||
await require_admin(request, "logs.read", AdminRole.ADMIN)
|
||||
|
||||
log = await get_moderation_log(limit=limit)
|
||||
return {"log": log or [], "total": len(log) if log else 0}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# USERS — ban/unban bulletin users
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/ban")
|
||||
async def admin_ban_user(
|
||||
request: Request, user_id: str, reason: str = Query("Violation of community guidelines")
|
||||
):
|
||||
"""Ban a user from the bulletin board."""
|
||||
await require_admin(request, "users.ban", AdminRole.MODERATOR)
|
||||
|
||||
import contextlib
|
||||
|
||||
from app.services.supabase_service import _get_supabase
|
||||
|
||||
supabase = await _get_supabase()
|
||||
with contextlib.suppress(Exception):
|
||||
await (
|
||||
supabase.table("profiles")
|
||||
.update({"banned": True, "ban_reason": reason})
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
|
||||
await log_moderation(
|
||||
admin_id="moderator",
|
||||
action="ban_user",
|
||||
target_type="user",
|
||||
target_id=user_id,
|
||||
reason=reason,
|
||||
)
|
||||
return {"status": "banned", "user_id": user_id, "reason": reason}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/unban")
|
||||
async def admin_unban_user(request: Request, user_id: str):
|
||||
"""Unban a user from the bulletin board."""
|
||||
await require_admin(request, "users.ban", AdminRole.MODERATOR)
|
||||
|
||||
import contextlib
|
||||
|
||||
from app.services.supabase_service import _get_supabase
|
||||
|
||||
supabase = await _get_supabase()
|
||||
with contextlib.suppress(Exception):
|
||||
await (
|
||||
supabase.table("profiles")
|
||||
.update({"banned": False, "ban_reason": None})
|
||||
.eq("id", user_id)
|
||||
.execute()
|
||||
)
|
||||
|
||||
await log_moderation(
|
||||
admin_id="moderator",
|
||||
action="unban_user",
|
||||
target_type="user",
|
||||
target_id=user_id,
|
||||
)
|
||||
return {"status": "unbanned", "user_id": user_id}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# HEALTH
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def admin_bulletin_health():
|
||||
"""Health check for the admin bulletin module."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"auth_system": "domain_admin_rbac",
|
||||
"permissions_required": {
|
||||
"dashboard": "MODERATOR + dashboard.read",
|
||||
"posts_write": "MODERATOR + content.write",
|
||||
"posts_delete": "SUPERADMIN + content.write",
|
||||
"bots_manage": "ADMIN + content.write",
|
||||
"users_ban": "MODERATOR + users.ban",
|
||||
"moderation_log": "ADMIN + logs.read",
|
||||
},
|
||||
}
|
||||
|
|
@ -22,7 +22,9 @@ router = APIRouter(prefix="/api/v1/admin", tags=["admin-control"])
|
|||
# ── Auth helper ──
|
||||
async def _verify_admin(request: Request):
|
||||
"""Verify admin access with API key."""
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "")
|
||||
if not admin_key:
|
||||
raise HTTPException(status_code=401, detail="Admin API key not configured")
|
||||
key = request.headers.get("X-Admin-Key", "")
|
||||
if key != admin_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ router = APIRouter(prefix="/api/v1/admin", tags=["admin-extensions"])
|
|||
# ── Auth helper ──
|
||||
async def _verify_admin(request: Request):
|
||||
"""Verify admin access with API key."""
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "")
|
||||
if not admin_key:
|
||||
raise HTTPException(status_code=401, detail="Admin API key not configured")
|
||||
key = request.headers.get("X-Admin-Key", "")
|
||||
if key != admin_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bulletin Board & Badge API Router
|
||||
Refactored to use supabase_service for clean, typed database access.
|
||||
Includes bot registration, fee tracking, and bot-specific endpoints.
|
||||
Uses direct Supabase REST API via httpx (no supabase-py dependency issues).
|
||||
All database operations routed through supabase_service.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
import re as _re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import contextlib # noqa: E402
|
||||
|
||||
from app.services.supabase_service import ( # noqa: E402
|
||||
from app.services.supabase_service import (
|
||||
_get,
|
||||
_patch,
|
||||
_post,
|
||||
award_badge,
|
||||
check_health,
|
||||
create_alert,
|
||||
create_bulletin_post,
|
||||
get_alerts,
|
||||
|
|
@ -30,64 +30,73 @@ from app.services.supabase_service import ( # noqa: E402
|
|||
vote_post,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/bulletin")
|
||||
|
||||
# ── Security ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sanitize(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = _re.sub(r"<[^>]+>", "", str(text))
|
||||
return text.strip()
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Lazy auth dependency - avoids importing jose+bcrypt at module load
|
||||
async def _require_public_profile(request: Request):
|
||||
from app.domains.auth import require_public_profile
|
||||
|
||||
return await require_public_profile(request)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/bulletin")
|
||||
# ── Migration check (D6) ─────────────────────────────────────
|
||||
|
||||
# ── Supabase REST helpers ──────────────────────────────────────
|
||||
try:
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _verify_tables():
|
||||
await _get("bulletin_posts", limit=1)
|
||||
logger.info("Bulletin board tables verified")
|
||||
|
||||
_verify_task = _asyncio.ensure_future(_verify_tables()) # noqa: RUF006
|
||||
except Exception:
|
||||
logger.warning("Bulletin board tables may not exist — some features will be degraded")
|
||||
|
||||
|
||||
def _sb():
|
||||
"""Return (url, headers) for direct Supabase REST API."""
|
||||
key = os.environ["SUPABASE_SERVICE_KEY"]
|
||||
url = os.environ["SUPABASE_URL"]
|
||||
return url, {
|
||||
"apikey": key,
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
|
||||
def _sb_get(table: str, params: dict | None = None) -> list:
|
||||
url, headers = _sb()
|
||||
r = httpx.get(f"{url}/rest/v1/{table}", headers=headers, params=params, timeout=10)
|
||||
return r.json() if r.status_code == 200 else []
|
||||
|
||||
|
||||
def _sb_post(table: str, data: dict) -> list:
|
||||
url, headers = _sb()
|
||||
r = httpx.post(f"{url}/rest/v1/{table}", headers=headers, json=data, timeout=10)
|
||||
return r.json() if r.status_code in (200, 201) else []
|
||||
|
||||
|
||||
def _sb_patch(table: str, data: dict, match: dict) -> list:
|
||||
url, headers = _sb()
|
||||
params = "&".join(f"{k}=eq.{v}" for k, v in match.items())
|
||||
r = httpx.patch(f"{url}/rest/v1/{table}?{params}", headers=headers, json=data, timeout=10)
|
||||
return r.json() if r.status_code == 200 else []
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────
|
||||
# ── Models ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BulletinPostIn(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=300)
|
||||
content: str = Field(..., min_length=1, max_length=10000)
|
||||
category: str = "discussion"
|
||||
tags: list[str] = []
|
||||
chain: str | None = None
|
||||
risk_score: int | None = None
|
||||
|
||||
|
||||
class BulletinPostOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
content: str
|
||||
author_id: str
|
||||
category: str = "discussion"
|
||||
author: dict | None = None
|
||||
category: str
|
||||
created_at: str
|
||||
upvotes: int = 0
|
||||
downvotes: int = 0
|
||||
comment_count: int = 0
|
||||
views: int = 0
|
||||
pinned: bool = False
|
||||
tags: list[str] = []
|
||||
chain: str | None = None
|
||||
risk_score: int | None = None
|
||||
tags: list[str] | None = None
|
||||
source: str | None = None
|
||||
is_bot: bool = False
|
||||
bot_id: str | None = None
|
||||
|
||||
|
||||
class AlertIn(BaseModel):
|
||||
|
|
@ -112,7 +121,46 @@ class BotRegistrationIn(BaseModel):
|
|||
allowed_categories: list[str] = []
|
||||
|
||||
|
||||
# ── Bulletin Posts ──────────────────────────────────────────
|
||||
class BotPostIn(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=300)
|
||||
content: str = Field(..., min_length=1, max_length=10000)
|
||||
author_id: str
|
||||
category: str = "discussion"
|
||||
tags: list[str] = []
|
||||
chain: str | None = None
|
||||
risk_score: int | None = None
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class ReportIn(BaseModel):
|
||||
post_id: int
|
||||
reporter_id: str
|
||||
reason: str
|
||||
details: str | None = None
|
||||
category: str = "other"
|
||||
|
||||
|
||||
class ModerationActionIn(BaseModel):
|
||||
admin_id: str
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
# ── Shared helpers ────────────────────────────────────────────
|
||||
|
||||
BOT_FEE_USD = 1.00
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
# ── Bulletin Posts ────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/latest")
|
||||
|
|
@ -120,12 +168,28 @@ async def latest_posts(
|
|||
category: str = Query(None),
|
||||
sort: str = Query("created_at.desc"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
cursor: str = Query(None),
|
||||
include_bots: bool = Query(True),
|
||||
):
|
||||
posts = await get_bulletin_posts(category=category, sort=sort, limit=limit)
|
||||
if cursor:
|
||||
supabase_url = os.environ.get("SUPABASE_URL", "")
|
||||
key = (
|
||||
os.environ.get("SUPABASE_SERVICE_KEY")
|
||||
or os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
|
||||
or ""
|
||||
)
|
||||
headers = {"apikey": key, "Authorization": f"Bearer {key}"}
|
||||
url = f"{supabase_url}/rest/v1/bulletin_posts?select=*&order={sort}&limit={limit}"
|
||||
if category and category != "all":
|
||||
url += f"&category=eq.{category}"
|
||||
url += f"&id=gt.{cursor}"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url, headers=headers)
|
||||
posts = r.json() if r.status_code == 200 else []
|
||||
else:
|
||||
posts = await get_bulletin_posts(category=category, sort=sort, limit=limit)
|
||||
|
||||
for post in posts:
|
||||
if not include_bots and post.get("is_bot"):
|
||||
continue
|
||||
if post.get("author_id"):
|
||||
profile = await get_profile(post["author_id"])
|
||||
if profile:
|
||||
|
|
@ -136,99 +200,92 @@ async def latest_posts(
|
|||
"level": profile.get("level", 1),
|
||||
"is_bot": profile.get("is_bot", False),
|
||||
}
|
||||
|
||||
if not include_bots:
|
||||
posts = [p for p in posts if not p.get("is_bot")]
|
||||
return {"posts": posts, "total": len(posts)}
|
||||
|
||||
next_cursor = posts[-1]["id"] if posts else None
|
||||
return {"posts": posts, "total": len(posts), "next_cursor": next_cursor}
|
||||
|
||||
|
||||
@router.post("/posts")
|
||||
async def create_post(post: BulletinPostIn, user: dict = Depends(_require_public_profile)):
|
||||
result = await create_bulletin_post(
|
||||
title=post.title,
|
||||
content=post.content,
|
||||
title=_sanitize(post.title),
|
||||
content=_sanitize(post.content),
|
||||
author_id=user["id"],
|
||||
category=post.category,
|
||||
chain=post.chain,
|
||||
tags=post.tags,
|
||||
risk_score=post.risk_score,
|
||||
source=post.source,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create post")
|
||||
if post.is_bot and result.get("id"):
|
||||
with contextlib.suppress(Exception):
|
||||
_sb_patch("bulletin_posts", {"is_bot": True, "bot_id": post.bot_id}, {"id": result["id"]})
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/vote")
|
||||
async def vote(post_id: int, vote_type: str = Query(..., pattern="^(up|down)$")):
|
||||
async def vote(
|
||||
post_id: int,
|
||||
vote_type: str = Query(..., pattern="^(up|down)$"),
|
||||
user: dict = Depends(_require_public_profile),
|
||||
):
|
||||
ok = await vote_post(post_id, vote_type)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Post not found or vote failed")
|
||||
return {"status": "ok", "post_id": post_id, "vote": vote_type}
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/comments")
|
||||
async def create_comment(post_id: int, comment: CommentIn, user: dict = Depends(_require_public_profile)):
|
||||
"""Create a comment on a bulletin post. Requires public profile."""
|
||||
# Store comment in Supabase or Redis (using simple dict for now if not in supabase_service)
|
||||
# For now, we'll just return success and let the frontend handle local state,
|
||||
# or we can add it to supabase_service if needed.
|
||||
# Let's use a simple in-memory store or supabase if available.
|
||||
# Since supabase_service doesn't have create_comment, we'll add a basic implementation.
|
||||
async def create_comment(
|
||||
post_id: int, comment: CommentIn, user: dict = Depends(_require_public_profile)
|
||||
):
|
||||
try:
|
||||
result = _sb_post(
|
||||
result = await _post(
|
||||
"bulletin_comments",
|
||||
{
|
||||
"post_id": post_id,
|
||||
"user_id": user["id"],
|
||||
"content": comment.content,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"content": _sanitize(comment.content),
|
||||
"created_at": _now_iso(),
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create comment")
|
||||
return {"status": "ok", "comment": result[0]}
|
||||
return {"status": "ok", "comment": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Fallback if table doesn't exist yet
|
||||
logger.warning(f"Comment creation failed (table may not exist): {e}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"comment": {
|
||||
"id": "temp",
|
||||
"content": comment.content,
|
||||
"content": _sanitize(comment.content),
|
||||
"user_id": user["id"],
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": _now_iso(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/comments")
|
||||
async def get_comments(post_id: int):
|
||||
"""Get comments for a bulletin post."""
|
||||
try:
|
||||
comments = _sb_get("bulletin_comments", {"post_id": f"eq.{post_id}", "order": "created_at.asc"})
|
||||
comments = await _get("bulletin_comments", {"post_id": post_id}, order="created_at.asc")
|
||||
return {"comments": comments}
|
||||
except Exception:
|
||||
return {"comments": []}
|
||||
|
||||
|
||||
# ── Bot Endpoints ───────────────────────────────────────────
|
||||
# ── Bot Endpoints ─────────────────────────────────────────────
|
||||
|
||||
BOT_FEE_USD = 1.00 # $1 fee for bots to participate
|
||||
# Bot payments require on-chain x402 verification — not yet implemented
|
||||
|
||||
|
||||
@router.post("/bots/register")
|
||||
async def register_bot(bot: BotRegistrationIn):
|
||||
"""Register a new bot account. Bot must pay a fee to participate."""
|
||||
# Create the bot profile
|
||||
try:
|
||||
profiles = _sb_post(
|
||||
profile = await _post(
|
||||
"profiles",
|
||||
{
|
||||
"username": bot.bot_name,
|
||||
|
|
@ -240,20 +297,18 @@ async def register_bot(bot: BotRegistrationIn):
|
|||
"tier": "FREE",
|
||||
},
|
||||
)
|
||||
if not profiles:
|
||||
if not profile:
|
||||
raise HTTPException(status_code=500, detail="Failed to create bot profile")
|
||||
bot_profile = profiles[0]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Bot profile creation failed: {e}") from e
|
||||
|
||||
# Create bot registration record
|
||||
try:
|
||||
regs = _sb_post(
|
||||
reg = await _post(
|
||||
"bot_registrations",
|
||||
{
|
||||
"bot_user_id": bot_profile["id"],
|
||||
"bot_user_id": profile["id"],
|
||||
"owner_user_id": bot.owner_id,
|
||||
"bot_name": bot.bot_name,
|
||||
"bot_type": bot.bot_type,
|
||||
|
|
@ -262,27 +317,30 @@ async def register_bot(bot: BotRegistrationIn):
|
|||
"rules": bot.rules,
|
||||
"allowed_categories": bot.allowed_categories,
|
||||
"status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee",
|
||||
"created_at": _now_iso(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Bot registration failed: {e}") from e
|
||||
|
||||
# Log the fee payment
|
||||
if bot.fee_paid > 0:
|
||||
with contextlib.suppress(Exception):
|
||||
_sb_post(
|
||||
try:
|
||||
await _post(
|
||||
"bot_fee_log",
|
||||
{
|
||||
"bot_id": bot_profile["id"],
|
||||
"bot_id": profile["id"],
|
||||
"amount": bot.fee_paid,
|
||||
"currency": bot.fee_currency,
|
||||
"reason": "registration_fee",
|
||||
"created_at": _now_iso(),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to log bot fee payment", exc_info=True)
|
||||
|
||||
return {
|
||||
"bot_profile": bot_profile,
|
||||
"registration": regs[0] if regs else None,
|
||||
"bot_profile": profile,
|
||||
"registration": reg,
|
||||
"fee_required": BOT_FEE_USD,
|
||||
"fee_paid": bot.fee_paid,
|
||||
"fee_remaining": max(0, BOT_FEE_USD - bot.fee_paid),
|
||||
|
|
@ -292,27 +350,25 @@ async def register_bot(bot: BotRegistrationIn):
|
|||
|
||||
@router.get("/bots")
|
||||
async def list_bots(status: str = Query(None), limit: int = Query(50)):
|
||||
"""List all registered bots."""
|
||||
params: dict = {
|
||||
"select": "*,profiles:bot_user_id(username,is_bot,level,reputation_score)",
|
||||
"limit": str(limit),
|
||||
"order": "created_at.desc",
|
||||
}
|
||||
if status:
|
||||
params["status"] = f"eq.{status}"
|
||||
result = _sb_get("bot_registrations", params)
|
||||
return {"bots": result, "total": len(result)}
|
||||
select = "*,profiles:bot_user_id(username,is_bot,level,reputation_score)"
|
||||
bots = await _get(
|
||||
"bot_registrations",
|
||||
{"status": status} if status else None,
|
||||
select=select,
|
||||
order="created_at.desc",
|
||||
limit=limit,
|
||||
)
|
||||
return {"bots": bots, "total": len(bots)}
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/fee-status")
|
||||
async def bot_fee_status(bot_id: str):
|
||||
"""Check a bot's fee payment status."""
|
||||
reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "limit": "1"})
|
||||
reg = await _get("bot_registrations", {"bot_user_id": bot_id}, limit=1)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=404, detail="Bot not registered")
|
||||
registration = reg[0]
|
||||
|
||||
fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "order": "created_at.desc"})
|
||||
fees = await _get("bot_fee_log", {"bot_id": bot_id}, order="created_at.desc")
|
||||
total_paid = sum(f.get("amount", 0) for f in fees)
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
|
|
@ -324,93 +380,50 @@ async def bot_fee_status(bot_id: str):
|
|||
}
|
||||
|
||||
|
||||
@router.post("/bots/{bot_id}/pay-fee")
|
||||
async def pay_bot_fee(bot_id: str, amount: float, tx_hash: str | None = None, currency: str = "usdc"):
|
||||
"""Record a bot fee payment."""
|
||||
try:
|
||||
_sb_post(
|
||||
"bot_fee_log",
|
||||
{
|
||||
"bot_id": bot_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"tx_hash": tx_hash,
|
||||
"reason": "participation_fee",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Fee logging failed: {e}") from e
|
||||
|
||||
fees = _sb_get("bot_fee_log", {"bot_id": f"eq.{bot_id}", "select": "amount"})
|
||||
total_paid = sum(f.get("amount", 0) for f in fees)
|
||||
if total_paid >= BOT_FEE_USD:
|
||||
_sb_patch("bot_registrations", {"status": "active"}, {"bot_user_id": bot_id})
|
||||
_sb_patch("profiles", {"bot_fee_paid": total_paid}, {"id": bot_id})
|
||||
|
||||
return {
|
||||
"amount_paid": amount,
|
||||
"total_paid": total_paid,
|
||||
"fee_required": BOT_FEE_USD,
|
||||
"status": "active" if total_paid >= BOT_FEE_USD else "pending_fee",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bots/{bot_id}/post")
|
||||
async def bot_create_post(bot_id: str, post: BulletinPostIn):
|
||||
"""Bot creates a post - must be registered and fee-paid."""
|
||||
reg = _sb_get("bot_registrations", {"bot_user_id": f"eq.{bot_id}", "status": "eq.active", "limit": "1"})
|
||||
async def bot_create_post(bot_id: str, post: BotPostIn):
|
||||
reg = await _get("bot_registrations", {"bot_user_id": bot_id, "status": "active"}, limit=1)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=403, detail="Bot not registered or fee not paid")
|
||||
|
||||
registration = reg[0]
|
||||
if registration.get("allowed_categories") and post.category not in registration["allowed_categories"]:
|
||||
raise HTTPException(status_code=403, detail=f"Bot not allowed to post in category: {post.category}")
|
||||
if (
|
||||
registration.get("allowed_categories")
|
||||
and post.category not in registration["allowed_categories"]
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403, detail=f"Bot not allowed to post in category: {post.category}"
|
||||
)
|
||||
|
||||
post.is_bot = True
|
||||
post.bot_id = bot_id
|
||||
post.source = post.source or f"bot:{registration['bot_name']}"
|
||||
source = f"bot:{registration['bot_name']}"
|
||||
|
||||
result = await create_bulletin_post(
|
||||
title=post.title,
|
||||
content=post.content,
|
||||
author_id=post.author_id,
|
||||
category=post.category,
|
||||
chain=post.chain,
|
||||
tags=post.tags,
|
||||
risk_score=post.risk_score,
|
||||
source=post.source,
|
||||
result = await _post(
|
||||
"bulletin_posts",
|
||||
{
|
||||
"title": _sanitize(post.title),
|
||||
"content": _sanitize(post.content),
|
||||
"author_id": post.author_id,
|
||||
"category": post.category,
|
||||
"chain": post.chain,
|
||||
"tags": post.tags,
|
||||
"risk_score": post.risk_score,
|
||||
"source": source,
|
||||
"is_bot": True,
|
||||
"bot_id": bot_id,
|
||||
"created_at": _now_iso(),
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create post")
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
_sb_patch("bulletin_posts", {"is_bot": True, "bot_id": bot_id}, {"id": result["id"]})
|
||||
return result
|
||||
|
||||
|
||||
# ── Reports & Moderation ──────────────────────────────────────
|
||||
|
||||
|
||||
class ReportIn(BaseModel):
|
||||
post_id: int
|
||||
reporter_id: str
|
||||
reason: str
|
||||
details: str | None = None
|
||||
category: str = "other" # spam, abuse, misinformation, scam, bot_violation, other
|
||||
|
||||
|
||||
class ModerationActionIn(BaseModel):
|
||||
admin_id: str
|
||||
action: str # remove, lock, pin, unpin, unlock, restore, ban_user
|
||||
target_type: str # post, user, bot
|
||||
target_id: str
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
async def create_report(report: ReportIn):
|
||||
"""Submit a report on a post."""
|
||||
result = _sb_post(
|
||||
result = await _post(
|
||||
"bulletin_reports",
|
||||
{
|
||||
"post_id": report.post_id,
|
||||
|
|
@ -419,28 +432,28 @@ async def create_report(report: ReportIn):
|
|||
"details": report.details,
|
||||
"category": report.category,
|
||||
"status": "open",
|
||||
"created_at": _now_iso(),
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create report")
|
||||
return result[0]
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
async def list_reports(status: str = Query(None), limit: int = Query(50, ge=1, le=200)):
|
||||
"""List reports (admin view)."""
|
||||
params: dict = {"order": "created_at.desc", "limit": str(limit)}
|
||||
if status:
|
||||
params["status"] = f"eq.{status}"
|
||||
result = _sb_get("bulletin_reports", params)
|
||||
return {"reports": result, "total": len(result)}
|
||||
reports = await _get(
|
||||
"bulletin_reports",
|
||||
{"status": status} if status else None,
|
||||
order="created_at.desc",
|
||||
limit=limit,
|
||||
)
|
||||
return {"reports": reports, "total": len(reports)}
|
||||
|
||||
|
||||
@router.post("/moderate")
|
||||
async def moderate(moderation: ModerationActionIn):
|
||||
"""Take a moderation action."""
|
||||
# Log the action
|
||||
_sb_post(
|
||||
await _post(
|
||||
"bulletin_moderation_log",
|
||||
{
|
||||
"admin_id": moderation.admin_id,
|
||||
|
|
@ -448,28 +461,32 @@ async def moderate(moderation: ModerationActionIn):
|
|||
"target_type": moderation.target_type,
|
||||
"target_id": moderation.target_id,
|
||||
"reason": moderation.reason,
|
||||
"created_at": _now_iso(),
|
||||
},
|
||||
)
|
||||
|
||||
# Apply the action
|
||||
if moderation.target_type == "post":
|
||||
match = {"id": moderation.target_id}
|
||||
if moderation.action == "remove":
|
||||
_sb_patch("bulletin_posts", {"removed": True, "removed_reason": moderation.reason}, match)
|
||||
await _patch(
|
||||
"bulletin_posts", match, {"removed": True, "removed_reason": moderation.reason}
|
||||
)
|
||||
elif moderation.action == "restore":
|
||||
_sb_patch("bulletin_posts", {"removed": False, "removed_reason": None}, match)
|
||||
await _patch("bulletin_posts", match, {"removed": False, "removed_reason": None})
|
||||
elif moderation.action == "pin":
|
||||
_sb_patch("bulletin_posts", {"pinned": True}, match)
|
||||
await _patch("bulletin_posts", match, {"pinned": True})
|
||||
elif moderation.action == "unpin":
|
||||
_sb_patch("bulletin_posts", {"pinned": False}, match)
|
||||
await _patch("bulletin_posts", match, {"pinned": False})
|
||||
elif moderation.action == "lock":
|
||||
_sb_patch("bulletin_posts", {"locked": True}, match)
|
||||
await _patch("bulletin_posts", match, {"locked": True})
|
||||
elif moderation.action == "unlock":
|
||||
_sb_patch("bulletin_posts", {"locked": False}, match)
|
||||
await _patch("bulletin_posts", match, {"locked": False})
|
||||
|
||||
elif moderation.target_type == "bot":
|
||||
if moderation.action == "ban_user":
|
||||
_sb_patch("bot_registrations", {"status": "banned"}, {"bot_user_id": moderation.target_id})
|
||||
await _patch(
|
||||
"bot_registrations", {"bot_user_id": moderation.target_id}, {"status": "banned"}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
|
|
@ -480,12 +497,11 @@ async def moderate(moderation: ModerationActionIn):
|
|||
|
||||
@router.get("/moderation/log")
|
||||
async def moderation_log(limit: int = Query(50)):
|
||||
"""View moderation log."""
|
||||
result = _sb_get("bulletin_moderation_log", {"order": "created_at.desc", "limit": str(limit)})
|
||||
return {"log": result, "total": len(result)}
|
||||
log = await _get("bulletin_moderation_log", order="created_at.desc", limit=limit)
|
||||
return {"log": log, "total": len(log)}
|
||||
|
||||
|
||||
# ── Badges ──────────────────────────────────────────────────
|
||||
# ── Badges ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/badges")
|
||||
|
|
@ -507,7 +523,7 @@ async def award(badge_id: str, user_id: str, earned_via: str = "auto"):
|
|||
return result
|
||||
|
||||
|
||||
# ── Alerts ──────────────────────────────────────────────────
|
||||
# ── Alerts ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/alerts")
|
||||
|
|
@ -534,55 +550,73 @@ async def submit_alert(alert: AlertIn):
|
|||
return result
|
||||
|
||||
|
||||
# ── Stats ──────────────────────────────────────────────────
|
||||
# ── Stats (D5 — Redis-cached with 5-minute TTL) ───────────────
|
||||
|
||||
|
||||
async def get_stats_cached():
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
r = aioredis.Redis(
|
||||
host=os.getenv("REDIS_HOST", "localhost"),
|
||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||
password=os.getenv("REDIS_PASSWORD", ""),
|
||||
decode_responses=True,
|
||||
)
|
||||
cached = await r.get("bulletin:stats")
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
total_posts = 0
|
||||
total_users = 0
|
||||
total_bots = 0
|
||||
total_alerts = 0
|
||||
|
||||
try:
|
||||
posts = await _get("bulletin_posts", select="id", limit=1000)
|
||||
total_posts = len(posts)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch post count", exc_info=True)
|
||||
|
||||
try:
|
||||
profiles = await _get("profiles", select="id", limit=1000)
|
||||
total_users = len(profiles)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch user count", exc_info=True)
|
||||
|
||||
try:
|
||||
bots = await _get("profiles", {"is_bot": True}, select="id", limit=100)
|
||||
total_bots = len(bots)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch bot count", exc_info=True)
|
||||
|
||||
try:
|
||||
alerts = await _get("alerts", select="id", limit=500)
|
||||
total_alerts = len(alerts)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch alert count", exc_info=True)
|
||||
|
||||
stats = {
|
||||
"total_posts": total_posts,
|
||||
"total_users": total_users,
|
||||
"total_bots": total_bots,
|
||||
"total_alerts": total_alerts,
|
||||
}
|
||||
await r.setex("bulletin:stats", 300, json.dumps(stats))
|
||||
return stats
|
||||
except Exception:
|
||||
return {"total_posts": 0, "total_users": 0, "total_bots": 0, "total_alerts": 0}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def community_stats():
|
||||
"""Get aggregate community stats from Supabase."""
|
||||
total_posts = 0
|
||||
total_users = 0
|
||||
total_bots = 0
|
||||
total_alerts = 0
|
||||
|
||||
try:
|
||||
posts = _sb_get("bulletin_posts", {"select": "id", "limit": "1000"})
|
||||
total_posts = len(posts)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
|
||||
try:
|
||||
profiles = _sb_get("profiles", {"select": "id", "limit": "1000"})
|
||||
total_users = len(profiles)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
|
||||
try:
|
||||
bots = _sb_get("profiles", {"select": "id", "is_bot": "eq.true", "limit": "100"})
|
||||
total_bots = len(bots)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
|
||||
try:
|
||||
alerts = _sb_get("alerts", {"select": "id", "limit": "500"})
|
||||
total_alerts = len(alerts)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
|
||||
|
||||
return {
|
||||
"total_posts": total_posts,
|
||||
"total_users": total_users,
|
||||
"total_bots": total_bots,
|
||||
"total_alerts": total_alerts,
|
||||
"bot_fee_required_usd": BOT_FEE_USD,
|
||||
}
|
||||
stats = await get_stats_cached()
|
||||
return {**stats, "bot_fee_required_usd": BOT_FEE_USD}
|
||||
|
||||
|
||||
# ── Health ──────────────────────────────────────────────────
|
||||
# ── Health ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
from app.services.supabase_service import check_health
|
||||
|
||||
return await check_health()
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ router = APIRouter(prefix="/api/v1/admin/tokens/airdrop", tags=["darkroom-airdro
|
|||
|
||||
|
||||
async def _verify_admin(request: Request):
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "")
|
||||
if not admin_key:
|
||||
raise HTTPException(status_code=401, detail="Admin API key not configured")
|
||||
key = request.headers.get("X-Admin-Key", "")
|
||||
if key != admin_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
|
@ -132,7 +134,9 @@ async def create_snapshot(request: Request, body: SnapshotRequest, _=Depends(_ve
|
|||
if body.source_chain in ["ethereum", "base", "bsc"]:
|
||||
rpc = os.getenv(f"{body.source_chain.upper()}_RPC_URL", "")
|
||||
if not rpc:
|
||||
raise HTTPException(status_code=400, detail=f"No RPC configured for {body.source_chain}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"No RPC configured for {body.source_chain}"
|
||||
)
|
||||
|
||||
snapshot = await SnapshotEngine.create_evm_snapshot(
|
||||
token_address=body.source_token,
|
||||
|
|
@ -169,7 +173,9 @@ async def create_snapshot(request: Request, body: SnapshotRequest, _=Depends(_ve
|
|||
"total_supply_snapshotted": snapshot.total_supply_snapshotted,
|
||||
"timestamp": snapshot.timestamp,
|
||||
},
|
||||
"holders_preview": [{"address": h.address, "amount": h.amount} for h in snapshot.holders[:10]],
|
||||
"holders_preview": [
|
||||
{"address": h.address, "amount": h.amount} for h in snapshot.holders[:10]
|
||||
],
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -211,15 +217,23 @@ async def execute_airdrop(request: Request, body: AirdropExecuteRequest, _=Depen
|
|||
|
||||
recipients = [AirdropRecipient(**r) for r in body.recipients]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No recipients provided (need snapshot_id or recipients)")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No recipients provided (need snapshot_id or recipients)"
|
||||
)
|
||||
|
||||
# Execute based on chain
|
||||
if deployment.chain in ["ethereum", "base", "bsc"]:
|
||||
result = await AirdropDistributor.execute_evm_airdrop(deployer, deployment.contract_address, recipients)
|
||||
result = await AirdropDistributor.execute_evm_airdrop(
|
||||
deployer, deployment.contract_address, recipients
|
||||
)
|
||||
elif deployment.chain == "solana":
|
||||
result = await AirdropDistributor.execute_solana_airdrop(deployer, deployment.contract_address, recipients)
|
||||
result = await AirdropDistributor.execute_solana_airdrop(
|
||||
deployer, deployment.contract_address, recipients
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Airdrop not supported for {deployment.chain}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Airdrop not supported for {deployment.chain}"
|
||||
)
|
||||
|
||||
# Save campaign
|
||||
campaign = AirdropCampaign(
|
||||
|
|
@ -301,7 +315,9 @@ async def apply_antisniper(request: Request, body: AntiSniperRequest, _=Depends(
|
|||
)
|
||||
|
||||
# Update deployment record
|
||||
await storage.update_status(body.deployment_id, "anti_sniper_applied", {"anti_sniper": result})
|
||||
await storage.update_status(
|
||||
body.deployment_id, "anti_sniper_applied", {"anti_sniper": result}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
@ -413,7 +429,9 @@ async def enable_trading(request: Request, body: EnableTradingRequest, _=Depends
|
|||
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
||||
tx_hash = await deployer.set_trading_enabled(deployment.contract_address, True)
|
||||
|
||||
await storage.update_status(body.deployment_id, "trading_enabled", {"trading_enabled_tx": tx_hash})
|
||||
await storage.update_status(
|
||||
body.deployment_id, "trading_enabled", {"trading_enabled_tx": tx_hash}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ router = APIRouter(prefix="/api/v1/admin/tokens/multichain", tags=["darkroom-mul
|
|||
|
||||
|
||||
async def _verify_admin(request: Request):
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "")
|
||||
if not admin_key:
|
||||
raise HTTPException(status_code=401, detail="Admin API key not configured")
|
||||
key = request.headers.get("X-Admin-Key", "")
|
||||
if key != admin_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ router = APIRouter(prefix="/api/v1/admin/tokens", tags=["darkroom-tokens"])
|
|||
|
||||
async def _verify_admin(request: Request):
|
||||
"""Verify admin access with API key."""
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
||||
admin_key = os.getenv("ADMIN_API_KEY", "")
|
||||
if not admin_key:
|
||||
raise HTTPException(status_code=401, detail="Admin API key not configured")
|
||||
key = request.headers.get("X-Admin-Key", "")
|
||||
if key != admin_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
|
@ -204,10 +206,14 @@ async def mint_tokens(request: Request, body: MintRequest, _=Depends(_verify_adm
|
|||
raise HTTPException(status_code=404, detail="Deployment not found")
|
||||
|
||||
if deployment.status != "deployed":
|
||||
raise HTTPException(status_code=400, detail=f"Cannot mint - status is {deployment.status}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Cannot mint - status is {deployment.status}"
|
||||
)
|
||||
|
||||
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
||||
tx_hash = await deployer.mint_tokens(deployment.contract_address, body.to_address, body.amount)
|
||||
tx_hash = await deployer.mint_tokens(
|
||||
deployment.contract_address, body.to_address, body.amount
|
||||
)
|
||||
|
||||
await storage.update_status(body.deployment_id, "minting", {"last_mint_tx": tx_hash})
|
||||
|
||||
|
|
@ -252,7 +258,9 @@ async def burn_tokens(request: Request, body: BurnRequest, _=Depends(_verify_adm
|
|||
|
||||
|
||||
@router.post("/transfer")
|
||||
async def transfer_ownership(request: Request, body: TransferOwnershipRequest, _=Depends(_verify_admin)):
|
||||
async def transfer_ownership(
|
||||
request: Request, body: TransferOwnershipRequest, _=Depends(_verify_admin)
|
||||
):
|
||||
"""Transfer contract ownership to a new address."""
|
||||
try:
|
||||
storage = await get_storage()
|
||||
|
|
@ -371,7 +379,9 @@ async def blacklist_remove(request: Request, body: BlacklistRequest, _=Depends(_
|
|||
|
||||
|
||||
@router.get("/blacklist/check")
|
||||
async def check_blacklist(request: Request, deployment_id: str, address: str, _=Depends(_verify_admin)):
|
||||
async def check_blacklist(
|
||||
request: Request, deployment_id: str, address: str, _=Depends(_verify_admin)
|
||||
):
|
||||
"""Check if an address is blacklisted."""
|
||||
try:
|
||||
storage = await get_storage()
|
||||
|
|
@ -478,7 +488,9 @@ async def set_max_tx(request: Request, body: SetLimitRequest, _=Depends(_verify_
|
|||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_deployments(request: Request, chain: str | None = None, limit: int = 100, _=Depends(_verify_admin)):
|
||||
async def list_deployments(
|
||||
request: Request, chain: str | None = None, limit: int = 100, _=Depends(_verify_admin)
|
||||
):
|
||||
"""List all token deployments."""
|
||||
try:
|
||||
storage = await get_storage()
|
||||
|
|
@ -525,7 +537,9 @@ async def get_deployment(request: Request, deployment_id: str, _=Depends(_verify
|
|||
|
||||
|
||||
@router.get("/{deployment_id}/balance/{wallet_address}")
|
||||
async def get_balance(request: Request, deployment_id: str, wallet_address: str, _=Depends(_verify_admin)):
|
||||
async def get_balance(
|
||||
request: Request, deployment_id: str, wallet_address: str, _=Depends(_verify_admin)
|
||||
):
|
||||
"""Get token balance for a specific wallet."""
|
||||
try:
|
||||
storage = await get_storage()
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
"""Developer API routes."""
|
||||
"""Developer API routes — stub, coming Q3 2026."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/developer", tags=["developer"])
|
||||
|
||||
class DeveloperRegister(BaseModel):
|
||||
email: str
|
||||
name: str = ""
|
||||
use_case: str = ""
|
||||
|
||||
def _not_implemented(feature: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=501,
|
||||
content={
|
||||
"status": "not_implemented",
|
||||
"message": f"Developer {feature} — coming soon. Contact @cryptorugmunch for early access.",
|
||||
"available_in": "Q3 2026",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def dev_register(body: DeveloperRegister):
|
||||
return {"api_key": "rmi_dev_trial", "tier": "free"}
|
||||
async def dev_register():
|
||||
return _not_implemented("Registration")
|
||||
|
||||
|
||||
@router.get("/tiers")
|
||||
async def dev_tiers():
|
||||
return {"tiers": [{"name": "free", "rpm": 10}, {"name": "pro", "rpm": 100}, {"name": "enterprise", "rpm": 1000}]}
|
||||
return _not_implemented("Tiers")
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def dev_verify(api_key: str = ""):
|
||||
return {"valid": False, "tier": "unknown"}
|
||||
return _not_implemented("API Key Verification")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
"""Investigator profile routes."""
|
||||
"""Investigator profile routes — stub, coming Q3 2026."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/investigators", tags=["investigators"])
|
||||
|
||||
|
||||
@router.get("/{username}")
|
||||
async def get_investigator(username: str):
|
||||
return {"username": username, "badges": [], "cases": 0, "reputation": 0}
|
||||
return JSONResponse(
|
||||
status_code=501,
|
||||
content={
|
||||
"status": "not_implemented",
|
||||
"message": "Investigator Profiles — coming soon. Contact @cryptorugmunch for early access.",
|
||||
"available_in": "Q3 2026",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,57 @@
|
|||
"""Premium tier API routes."""
|
||||
"""Premium tier API routes — stub, coming Q3 2026."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/premium", tags=["premium"])
|
||||
|
||||
|
||||
def _not_implemented(feature: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=501,
|
||||
content={
|
||||
"status": "not_implemented",
|
||||
"message": f"Premium {feature} — coming soon. Contact @cryptorugmunch for early access.",
|
||||
"available_in": "Q3 2026",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ai-classifier/predict")
|
||||
async def ai_classifier(token: str = ""):
|
||||
return {"prediction": "pending", "token": token, "confidence": 0.0}
|
||||
return _not_implemented("AI Classifier")
|
||||
|
||||
|
||||
@router.get("/vulnerability-map")
|
||||
async def vuln_map(chain: str = "ethereum"):
|
||||
return {"chain": chain, "vulnerabilities": []}
|
||||
return _not_implemented("Vulnerability Map")
|
||||
|
||||
|
||||
@router.get("/velocity-risk")
|
||||
async def velocity_risk(token: str = ""):
|
||||
return {"token": token, "risk_score": 0}
|
||||
return _not_implemented("Velocity Risk Scoring")
|
||||
|
||||
|
||||
@router.get("/wallet-fingerprint")
|
||||
async def wallet_fingerprint(address: str = ""):
|
||||
return {"address": address, "fingerprint": {}}
|
||||
return _not_implemented("Wallet Fingerprinting")
|
||||
|
||||
|
||||
@router.get("/market-context")
|
||||
async def market_context(token: str = ""):
|
||||
return {"token": token, "context": {}}
|
||||
return _not_implemented("Market Context Analysis")
|
||||
|
||||
|
||||
@router.get("/campaigns")
|
||||
async def campaigns():
|
||||
return {"campaigns": []}
|
||||
return _not_implemented("Campaign Tracking")
|
||||
|
||||
|
||||
@router.post("/campaigns/check")
|
||||
async def campaign_check(address: str = ""):
|
||||
return {"address": address, "in_campaign": False}
|
||||
return _not_implemented("Campaign Check")
|
||||
|
||||
|
||||
@router.get("/entity-graph")
|
||||
async def entity_graph(address: str = ""):
|
||||
return {"address": address, "nodes": [], "edges": []}
|
||||
return _not_implemented("Entity Graph Analysis")
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ RMI Supabase Service - Unified backend layer for all Supabase tables.
|
|||
Replaces scattered REST calls with a clean, typed service interface.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ensure .env is loaded before reading env vars - override stale Docker env
|
||||
load_dotenv("/app/.env", override=True)
|
||||
|
||||
|
|
@ -19,7 +22,9 @@ def _get_url() -> str:
|
|||
|
||||
|
||||
def _get_key() -> str:
|
||||
return os.environ.get("SUPABASE_SERVICE_KEY") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or ""
|
||||
return (
|
||||
os.environ.get("SUPABASE_SERVICE_KEY") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or ""
|
||||
)
|
||||
|
||||
|
||||
def _get_headers() -> dict:
|
||||
|
|
@ -111,6 +116,58 @@ async def _rpc(fn: str, params: dict | None = None) -> dict:
|
|||
return r.json() if r.status_code == 200 else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"acknowledge_alert",
|
||||
"add_to_watchlist",
|
||||
"award_badge",
|
||||
"check_health",
|
||||
"create_alert",
|
||||
"create_bulletin_post",
|
||||
"create_comment",
|
||||
"create_report",
|
||||
"ensure_x402_payments_table",
|
||||
"get_alerts",
|
||||
"get_badges",
|
||||
"get_bot_fee_status",
|
||||
"get_bulletin_posts",
|
||||
"get_bulletin_stats",
|
||||
"get_cluster_wallets",
|
||||
"get_comments",
|
||||
"get_contract_analysis",
|
||||
"get_entity_clusters",
|
||||
"get_market_data",
|
||||
"get_moderation_log",
|
||||
"get_profile",
|
||||
"get_reports",
|
||||
"get_subscription",
|
||||
"get_syndicate_wallets",
|
||||
"get_token",
|
||||
"get_tokens",
|
||||
"get_transactions",
|
||||
"get_user_badges",
|
||||
"get_wallet",
|
||||
"get_wallet_connections",
|
||||
"get_wallet_intel",
|
||||
"get_wallets",
|
||||
"get_watchlist",
|
||||
"get_x402_earnings_summary",
|
||||
"get_x402_payment_by_tx",
|
||||
"get_x402_payments",
|
||||
"list_bots",
|
||||
"log_moderation",
|
||||
"record_x402_payment_supabase",
|
||||
"register_bot",
|
||||
"remove_from_watchlist",
|
||||
"store_market_data",
|
||||
"track_event",
|
||||
"update_profile",
|
||||
"upsert_contract_analysis",
|
||||
"upsert_token",
|
||||
"upsert_wallet",
|
||||
"vote_post",
|
||||
]
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════
|
||||
# ALERTS
|
||||
# ═════════════════════════════════════════════════════════
|
||||
|
|
@ -233,7 +290,9 @@ async def upsert_wallet(address: str, chain: str, **fields) -> dict:
|
|||
# ═════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def get_wallet_intel(address: str | None = None, chain: str | None = None, limit: int = 100) -> list:
|
||||
async def get_wallet_intel(
|
||||
address: str | None = None, chain: str | None = None, limit: int = 100
|
||||
) -> list:
|
||||
url = _url("wallet_intel")
|
||||
query_parts = ["select=*", f"limit={limit}"]
|
||||
if address:
|
||||
|
|
@ -260,7 +319,9 @@ async def get_wallet_connections(address: str) -> list:
|
|||
# ═════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def get_syndicate_wallets(status: str | None = None, chain: str | None = None, limit: int = 200) -> list:
|
||||
async def get_syndicate_wallets(
|
||||
status: str | None = None, chain: str | None = None, limit: int = 200
|
||||
) -> list:
|
||||
url = _url("syndicate_wallets")
|
||||
query_parts = ["select=*", f"limit={limit}"]
|
||||
if status:
|
||||
|
|
@ -319,7 +380,9 @@ async def remove_from_watchlist(user_id: str, address: str, chain: str = "ethere
|
|||
# ═════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def get_tokens(chain: str | None = None, limit: int = 50, order_by: str = "volume_24h.desc") -> list:
|
||||
async def get_tokens(
|
||||
chain: str | None = None, limit: int = 50, order_by: str = "volume_24h.desc"
|
||||
) -> list:
|
||||
params = {}
|
||||
if chain:
|
||||
params["chain"] = chain
|
||||
|
|
@ -351,7 +414,9 @@ async def upsert_token(address: str, chain: str, **fields) -> dict:
|
|||
# ═════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def get_bulletin_posts(category: str | None = None, sort: str = "created_at.desc", limit: int = 20) -> list:
|
||||
async def get_bulletin_posts(
|
||||
category: str | None = None, sort: str = "created_at.desc", limit: int = 20
|
||||
) -> list:
|
||||
params = {}
|
||||
if category and category != "all":
|
||||
params["category"] = category
|
||||
|
|
@ -398,6 +463,226 @@ async def vote_post(post_id: int, vote_type: str) -> bool:
|
|||
return await _patch("bulletin_posts", {"id": post_id}, {field: current + 1})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BULLETIN COMMENTS
|
||||
# ============================================================
|
||||
|
||||
|
||||
async def create_comment(post_id: int, user_id: str, content: str) -> dict:
|
||||
try:
|
||||
return await _post(
|
||||
"bulletin_comments",
|
||||
{
|
||||
"post_id": post_id,
|
||||
"user_id": user_id,
|
||||
"content": content,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"create_comment failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_comments(post_id: int, limit: int = 50) -> list:
|
||||
try:
|
||||
return await _get(
|
||||
"bulletin_comments", {"post_id": post_id}, order="created_at.asc", limit=limit
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"get_comments failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BULLETIN REPORTS
|
||||
# ============================================================
|
||||
|
||||
|
||||
async def create_report(
|
||||
post_id: int,
|
||||
reporter_id: str,
|
||||
reason: str,
|
||||
details: str | None = None,
|
||||
category: str = "other",
|
||||
) -> dict:
|
||||
try:
|
||||
return await _post(
|
||||
"bulletin_reports",
|
||||
{
|
||||
"post_id": post_id,
|
||||
"reporter_id": reporter_id,
|
||||
"reason": reason,
|
||||
"details": details,
|
||||
"category": category,
|
||||
"status": "open",
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"create_report failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_reports(status: str | None = None, limit: int = 50) -> list:
|
||||
try:
|
||||
params = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
return await _get("bulletin_reports", params, order="created_at.desc", limit=limit)
|
||||
except Exception as e:
|
||||
logger.error(f"get_reports failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BULLETIN MODERATION
|
||||
# ============================================================
|
||||
|
||||
|
||||
async def log_moderation(
|
||||
admin_id: str,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
reason: str | None = None,
|
||||
) -> dict:
|
||||
try:
|
||||
return await _post(
|
||||
"bulletin_moderation_log",
|
||||
{
|
||||
"admin_id": admin_id,
|
||||
"action": action,
|
||||
"target_type": target_type,
|
||||
"target_id": target_id,
|
||||
"reason": reason,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"log_moderation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_moderation_log(limit: int = 50) -> list:
|
||||
try:
|
||||
return await _get("bulletin_moderation_log", {}, order="created_at.desc", limit=limit)
|
||||
except Exception as e:
|
||||
logger.error(f"get_moderation_log failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BULLETIN BOTS
|
||||
# ============================================================
|
||||
|
||||
|
||||
async def register_bot(
|
||||
owner_id: str,
|
||||
bot_name: str,
|
||||
bot_type: str = "general",
|
||||
fee_paid: float = 0,
|
||||
currency: str = "usdc",
|
||||
rules: list | None = None,
|
||||
allowed_categories: list | None = None,
|
||||
) -> dict:
|
||||
try:
|
||||
profile = await _post(
|
||||
"profiles",
|
||||
{
|
||||
"username": bot_name,
|
||||
"is_bot": True,
|
||||
"bot_owner": owner_id,
|
||||
"bot_fee_paid": fee_paid,
|
||||
"bot_rules": rules or [],
|
||||
"role": "BOT",
|
||||
"tier": "FREE",
|
||||
},
|
||||
)
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
reg = await _post(
|
||||
"bot_registrations",
|
||||
{
|
||||
"bot_user_id": profile["id"],
|
||||
"owner_user_id": owner_id,
|
||||
"bot_name": bot_name,
|
||||
"bot_type": bot_type,
|
||||
"fee_paid": fee_paid,
|
||||
"fee_currency": currency,
|
||||
"rules": rules or [],
|
||||
"allowed_categories": allowed_categories or [],
|
||||
"status": "active" if fee_paid >= 1.0 else "pending_fee",
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
return {"profile": profile, "registration": reg}
|
||||
except Exception as e:
|
||||
logger.error(f"register_bot failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def list_bots(status: str | None = None, limit: int = 50) -> list:
|
||||
try:
|
||||
params = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
return await _get("bot_registrations", params, order="created_at.desc", limit=limit)
|
||||
except Exception as e:
|
||||
logger.error(f"list_bots failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_bot_fee_status(bot_id: str) -> dict:
|
||||
try:
|
||||
reg = await _get("bot_registrations", {"bot_user_id": bot_id}, limit=1)
|
||||
if not reg:
|
||||
return None
|
||||
|
||||
fees = await _get("bot_fee_log", {"bot_id": bot_id}, order="created_at.desc")
|
||||
total_paid = sum(f.get("amount", 0) for f in fees)
|
||||
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"registration": reg[0],
|
||||
"total_paid": total_paid,
|
||||
"fee_required": 1.0,
|
||||
"fee_remaining": max(0, 1.0 - total_paid),
|
||||
"fee_history": fees,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"get_bot_fee_status failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BULLETIN STATS
|
||||
# ============================================================
|
||||
|
||||
|
||||
async def get_bulletin_stats() -> dict:
|
||||
try:
|
||||
posts = await _get("bulletin_posts", {}, limit=1000)
|
||||
profiles = await _get("profiles", {}, limit=1000, select="id,is_bot")
|
||||
alerts = await _get("alerts", {}, limit=500)
|
||||
bots = await _get("bot_registrations", {}, limit=500)
|
||||
total_users = len([p for p in profiles if not p.get("is_bot")])
|
||||
total_bots = len([p for p in profiles if p.get("is_bot")])
|
||||
|
||||
return {
|
||||
"total_posts": len(posts),
|
||||
"total_users": total_users,
|
||||
"total_bots": total_bots,
|
||||
"total_alerts": len(alerts),
|
||||
"total_bot_registrations": len(bots),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"get_bulletin_stats failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════
|
||||
# BADGES
|
||||
# ═════════════════════════════════════════════════════════
|
||||
|
|
@ -645,7 +930,9 @@ async def get_x402_earnings_summary() -> dict:
|
|||
Aggregates total by chain, tool, and day using Supabase data.
|
||||
"""
|
||||
try:
|
||||
rows = await _get("x402_payments", select="tool,amount_atoms,chain,created_at,status", limit=5000)
|
||||
rows = await _get(
|
||||
"x402_payments", select="tool,amount_atoms,chain,created_at,status", limit=5000
|
||||
)
|
||||
total_atoms = 0
|
||||
by_chain = {}
|
||||
by_tool = {}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ services:
|
|||
networks:
|
||||
- rmi_network
|
||||
ports:
|
||||
- "172.20.0.1:8888:8888"
|
||||
- "127.0.0.1:8888:8888"
|
||||
volumes:
|
||||
- /srv/snappymail/data:/var/lib/snappymail
|
||||
environment:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue