rmi-backend/app/routers/bulletin.py

588 lines
19 KiB
Python

#!/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).
"""
import logging
import os
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
logger = logging.getLogger(__name__)
import contextlib # noqa: E402
from app.services.supabase_service import ( # noqa: E402
award_badge,
create_alert,
create_bulletin_post,
get_alerts,
get_badges,
get_bulletin_posts,
get_profile,
get_user_badges,
vote_post,
)
# 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")
# ── Supabase REST helpers ──────────────────────────────────────
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 ──────────────────────────────────────────────────
class BulletinPostIn(BaseModel):
title: str
content: str
author_id: str
category: str = "discussion"
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):
title: str
description: str
severity: str = "medium"
alert_type: str = "security"
chain: str | None = None
token: str | None = None
wallet: str | None = None
amount_usd: float | None = None
user_id: str | None = None
class BotRegistrationIn(BaseModel):
owner_id: str
bot_name: str
bot_type: str = "general"
fee_paid: float = 0
fee_currency: str = "usdc"
rules: list[str] = []
allowed_categories: list[str] = []
# ── Bulletin Posts ──────────────────────────────────────────
@router.get("/latest")
async def latest_posts(
category: str = Query(None),
sort: str = Query("created_at.desc"),
limit: int = Query(20, ge=1, le=100),
include_bots: bool = Query(True),
):
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:
post["author"] = {
"username": profile.get("username", profile.get("display_name", "Unknown")),
"avatar_url": profile.get("avatar_url", ""),
"reputation": profile.get("reputation_score", 0),
"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)}
@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,
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)$")):
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.
try:
result = _sb_post(
"bulletin_comments",
{
"post_id": post_id,
"user_id": user["id"],
"content": comment.content,
"created_at": datetime.utcnow().isoformat(),
},
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create comment")
return {"status": "ok", "comment": result[0]}
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,
"user_id": user["id"],
"created_at": datetime.utcnow().isoformat(),
},
}
@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"})
return {"comments": comments}
except Exception:
return {"comments": []}
# ── Bot Endpoints ───────────────────────────────────────────
BOT_FEE_USD = 1.00 # $1 fee for bots to participate
@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(
"profiles",
{
"username": bot.bot_name,
"is_bot": True,
"bot_owner": bot.owner_id,
"bot_fee_paid": bot.fee_paid,
"bot_rules": bot.rules,
"role": "BOT",
"tier": "FREE",
},
)
if not profiles:
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(
"bot_registrations",
{
"bot_user_id": bot_profile["id"],
"owner_user_id": bot.owner_id,
"bot_name": bot.bot_name,
"bot_type": bot.bot_type,
"fee_paid": bot.fee_paid,
"fee_currency": bot.fee_currency,
"rules": bot.rules,
"allowed_categories": bot.allowed_categories,
"status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee",
},
)
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(
"bot_fee_log",
{
"bot_id": bot_profile["id"],
"amount": bot.fee_paid,
"currency": bot.fee_currency,
"reason": "registration_fee",
},
)
return {
"bot_profile": bot_profile,
"registration": regs[0] if regs else None,
"fee_required": BOT_FEE_USD,
"fee_paid": bot.fee_paid,
"fee_remaining": max(0, BOT_FEE_USD - bot.fee_paid),
"status": "active" if bot.fee_paid >= BOT_FEE_USD else "pending_fee",
}
@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)}
@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"})
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"})
total_paid = sum(f.get("amount", 0) for f in fees)
return {
"bot_id": bot_id,
"total_paid": total_paid,
"fee_required": BOT_FEE_USD,
"fee_remaining": max(0, BOT_FEE_USD - total_paid),
"status": registration["status"],
"fee_history": fees,
}
@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"})
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}")
post.is_bot = True
post.bot_id = bot_id
post.source = post.source or 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,
)
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(
"bulletin_reports",
{
"post_id": report.post_id,
"reporter_id": report.reporter_id,
"reason": report.reason,
"details": report.details,
"category": report.category,
"status": "open",
},
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create report")
return result[0]
@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)}
@router.post("/moderate")
async def moderate(moderation: ModerationActionIn):
"""Take a moderation action."""
# Log the action
_sb_post(
"bulletin_moderation_log",
{
"admin_id": moderation.admin_id,
"action": moderation.action,
"target_type": moderation.target_type,
"target_id": moderation.target_id,
"reason": moderation.reason,
},
)
# 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)
elif moderation.action == "restore":
_sb_patch("bulletin_posts", {"removed": False, "removed_reason": None}, match)
elif moderation.action == "pin":
_sb_patch("bulletin_posts", {"pinned": True}, match)
elif moderation.action == "unpin":
_sb_patch("bulletin_posts", {"pinned": False}, match)
elif moderation.action == "lock":
_sb_patch("bulletin_posts", {"locked": True}, match)
elif moderation.action == "unlock":
_sb_patch("bulletin_posts", {"locked": False}, match)
elif moderation.target_type == "bot":
if moderation.action == "ban_user":
_sb_patch("bot_registrations", {"status": "banned"}, {"bot_user_id": moderation.target_id})
return {
"status": "ok",
"action": moderation.action,
"target": f"{moderation.target_type}:{moderation.target_id}",
}
@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)}
# ── Badges ──────────────────────────────────────────────────
@router.get("/badges")
async def list_badges(rarity: str = Query(None)):
return await get_badges(rarity=rarity)
@router.get("/badges/{user_id}")
async def user_badges(user_id: str):
badges = await get_user_badges(user_id)
return badges
@router.post("/badges/award")
async def award(badge_id: str, user_id: str, earned_via: str = "auto"):
result = await award_badge(user_id, badge_id, earned_via)
if not result:
raise HTTPException(status_code=500, detail="Failed to award badge")
return result
# ── Alerts ──────────────────────────────────────────────────
@router.get("/alerts")
async def list_alerts(severity: str = Query(None), limit: int = Query(50, ge=1, le=200)):
alerts = await get_alerts(severity=severity, limit=limit)
return {"alerts": alerts, "total": len(alerts)}
@router.post("/alerts")
async def submit_alert(alert: AlertIn):
result = await create_alert(
title=alert.title,
description=alert.description,
severity=alert.severity,
alert_type=alert.alert_type,
chain=alert.chain,
token=alert.token,
wallet=alert.wallet,
amount_usd=alert.amount_usd,
user_id=alert.user_id,
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create alert")
return result
# ── Stats ──────────────────────────────────────────────────
@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:
pass
try:
profiles = _sb_get("profiles", {"select": "id", "limit": "1000"})
total_users = len(profiles)
except Exception:
pass
try:
bots = _sb_get("profiles", {"select": "id", "is_bot": "eq.true", "limit": "100"})
total_bots = len(bots)
except Exception:
pass
try:
alerts = _sb_get("alerts", {"select": "id", "limit": "500"})
total_alerts = len(alerts)
except Exception:
pass
return {
"total_posts": total_posts,
"total_users": total_users,
"total_bots": total_bots,
"total_alerts": total_alerts,
"bot_fee_required_usd": BOT_FEE_USD,
}
# ── Health ──────────────────────────────────────────────────
@router.get("/health")
async def health():
from app.services.supabase_service import check_health
return await check_health()