""" RMI Bulletin Board API Router =============================== Full REST API for bulletin board management. Public endpoints (no auth): GET /api/v1/bulletin/posts — List published posts GET /api/v1/bulletin/posts/{slug} — Get single post by slug GET /api/v1/bulletin/categories — List categories Admin endpoints (require admin session): POST /api/v1/admin/bulletin/posts — Create post PUT /api/v1/admin/bulletin/posts/{id} — Update post DELETE /api/v1/admin/bulletin/posts/{id} — Delete (archive) post GET /api/v1/admin/bulletin/posts — List all posts (admin view) POST /api/v1/admin/bulletin/posts/{id}/publish — Publish post POST /api/v1/admin/bulletin/posts/{id}/unpublish — Unpublish post POST /api/v1/admin/bulletin/posts/{id}/pin — Pin/unpin post GET /api/v1/admin/bulletin/stats — Board statistics GET /api/v1/admin/bulletin/scheduled — List scheduled posts POST /api/v1/admin/bulletin/process-scheduled — Process scheduled posts POST /api/v1/admin/bulletin/process-expired — Archive expired posts """ from fastapi import APIRouter, Body, HTTPException, Request from pydantic import BaseModel, Field from app.admin_backend import AuditLogger, require_admin from app.bulletin_board import ( BulletinBoardManager, PostCategory, PostStatus, ) router = APIRouter(prefix="/api/v1/bulletin-board", tags=["bulletin-board"]) # ── Public Endpoints ────────────────────────────────────────── @router.get("/api/v1/bulletin/posts") async def public_list_posts( request: Request, category: str = "", search: str = "", tag: str = "", limit: int = 20, offset: int = 0, ): """List published posts (public).""" tags = [tag] if tag else None result = await BulletinBoardManager.list_posts( category=category or None, status="published", search_query=search or None, tags=tags, limit=limit, offset=offset, sort_by="published_at", sort_order="desc", ) # Return public-safe versions posts = [] for p in result["posts"]: # Remove internal fields safe = { k: v for k, v in p.items() if k not in [ "author_id", "author_email", "edit_history", "approved_by", "notification_sent", "version", ] } posts.append(safe) return { "posts": posts, "total": result["total"], "limit": limit, "offset": offset, } @router.get("/api/v1/bulletin/posts/{slug}") async def public_get_post(request: Request, slug: str): """Get a single published post by slug (public).""" post = await BulletinBoardManager.get_post_by_slug(slug) if not post or post.status != PostStatus.PUBLISHED.value: raise HTTPException(status_code=404, detail="Post not found") # Track view await BulletinBoardManager.track_engagement(post.post_id, "view") return {"post": post.to_public_dict()} @router.get("/api/v1/bulletin/categories") async def public_categories(): """List all categories with counts.""" stats = await BulletinBoardManager.get_stats() categories = [] for cat in PostCategory: categories.append( { "id": cat.value, "name": cat.value.replace("_", " ").title(), "count": stats["categories"].get(cat.value, 0), } ) return {"categories": categories} @router.get("/api/v1/bulletin/pinned") async def public_pinned_posts(limit: int = 5): """Get pinned posts (public).""" result = await BulletinBoardManager.list_posts( pinned_only=True, status="published", limit=limit, ) posts = [] for p in result["posts"]: safe = { k: v for k, v in p.items() if k not in [ "author_id", "author_email", "edit_history", "approved_by", "notification_sent", "version", ] } posts.append(safe) return {"posts": posts, "total": result["total"]} # ── Admin Models ────────────────────────────────────────────── class CreatePostRequest(BaseModel): title: str = Field(..., min_length=1, max_length=200) content: str = Field(..., min_length=1) category: str = "news" priority: str = "normal" target_audience: str = "all" status: str = "draft" featured_image: str = "" attachments: list[dict] = Field(default_factory=list) tags: list[str] = Field(default_factory=list) scheduled_at: str | None = None expires_at: str | None = None pinned: bool = False allow_comments: bool = False meta_title: str = "" meta_description: str = "" og_image: str = "" class UpdatePostRequest(BaseModel): title: str | None = None content: str | None = None category: str | None = None priority: str | None = None target_audience: str | None = None status: str | None = None featured_image: str | None = None attachments: list[dict] | None = None tags: list[str] | None = None scheduled_at: str | None = None expires_at: str | None = None pinned: bool | None = None allow_comments: bool | None = None meta_title: str | None = None meta_description: str | None = None og_image: str | None = None class CommentRequest(BaseModel): content: str = Field(..., min_length=1, max_length=2000) parent_id: str | None = None # ── Admin Endpoints ─────────────────────────────────────────── @router.get("/api/v1/admin/bulletin/posts") async def admin_list_posts( request: Request, category: str = "", status: str = "", search: str = "", pinned: bool | None = None, limit: int = 50, offset: int = 0, sort_by: str = "created_at", sort_order: str = "desc", ): """List all posts (admin view with full data).""" auth = await require_admin(request, "content.read") auth["admin"] result = await BulletinBoardManager.list_posts( category=category or None, status=status or None, search_query=search or None, pinned_only=pinned if pinned is not None else False, limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, ) return { "posts": result["posts"], "total": result["total"], "limit": limit, "offset": offset, } @router.post("/api/v1/admin/bulletin/posts") async def admin_create_post(request: Request, body: CreatePostRequest): """Create a new bulletin board post.""" auth = await require_admin(request, "content.write") admin = auth["admin"] ip = request.client.host if request.client else "" ua = request.headers.get("user-agent", "") # Validate category if body.category not in [c.value for c in PostCategory]: raise HTTPException(status_code=400, detail=f"Invalid category: {body.category}") # Validate status if body.status not in [s.value for s in PostStatus]: raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}") post = await BulletinBoardManager.create_post( title=body.title, content=body.content, category=body.category, author_id=admin["id"], author_email=admin["email"], author_name=admin.get("name", admin["email"].split("@")[0]), priority=body.priority, target_audience=body.target_audience, status=body.status, featured_image=body.featured_image, attachments=body.attachments, tags=body.tags, scheduled_at=body.scheduled_at, expires_at=body.expires_at, pinned=body.pinned, allow_comments=body.allow_comments, meta_title=body.meta_title, meta_description=body.meta_description, og_image=body.og_image, ) await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.post.create", resource_type="post", resource_id=post.post_id, ip_address=ip, user_agent=ua, after_state={"title": post.title, "status": post.status, "category": post.category}, ) return {"success": True, "post": post.to_dict()} @router.get("/api/v1/admin/bulletin/posts/{post_id}") async def admin_get_post(request: Request, post_id: str): """Get full post details (admin).""" await require_admin(request, "content.read") post = await BulletinBoardManager.get_post(post_id) if not post: raise HTTPException(status_code=404, detail="Post not found") return {"post": post.to_dict()} @router.put("/api/v1/admin/bulletin/posts/{post_id}") async def admin_update_post( request: Request, post_id: str, body: UpdatePostRequest, ): """Update a post.""" auth = await require_admin(request, "content.write") admin = auth["admin"] ip = request.client.host if request.client else "" ua = request.headers.get("user-agent", "") # Build updates dict from non-None fields updates = {} for field_name, value in body.model_dump().items(): if value is not None: updates[field_name] = value if not updates: raise HTTPException(status_code=400, detail="No fields to update") updated = await BulletinBoardManager.update_post( post_id=post_id, updates=updates, editor_id=admin["id"], editor_email=admin["email"], ) if not updated: raise HTTPException(status_code=404, detail="Post not found") await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.post.update", resource_type="post", resource_id=post_id, ip_address=ip, user_agent=ua, after_state=updates, ) return {"success": True, "post": updated.to_dict()} @router.delete("/api/v1/admin/bulletin/posts/{post_id}") async def admin_delete_post(request: Request, post_id: str): """Delete (archive) a post.""" auth = await require_admin(request, "content.write") admin = auth["admin"] ip = request.client.host if request.client else "" ua = request.headers.get("user-agent", "") result = await BulletinBoardManager.delete_post(post_id) if not result: raise HTTPException(status_code=404, detail="Post not found") await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.post.delete", resource_type="post", resource_id=post_id, ip_address=ip, user_agent=ua, ) return {"success": True, "message": "Post archived"} @router.post("/api/v1/admin/bulletin/posts/{post_id}/publish") async def admin_publish_post(request: Request, post_id: str): """Publish a post immediately.""" auth = await require_admin(request, "content.write") admin = auth["admin"] ip = request.client.host if request.client else "" ua = request.headers.get("user-agent", "") updated = await BulletinBoardManager.update_post( post_id=post_id, updates={"status": "published"}, editor_id=admin["id"], editor_email=admin["email"], ) if not updated: raise HTTPException(status_code=404, detail="Post not found") await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.post.publish", resource_type="post", resource_id=post_id, ip_address=ip, user_agent=ua, ) return {"success": True, "published_at": updated.published_at} @router.post("/api/v1/admin/bulletin/posts/{post_id}/unpublish") async def admin_unpublish_post(request: Request, post_id: str): """Unpublish a post (move to draft).""" auth = await require_admin(request, "content.write") admin = auth["admin"] ip = request.client.host if request.client else "" ua = request.headers.get("user-agent", "") updated = await BulletinBoardManager.update_post( post_id=post_id, updates={"status": "draft"}, editor_id=admin["id"], editor_email=admin["email"], ) if not updated: raise HTTPException(status_code=404, detail="Post not found") await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.post.unpublish", resource_type="post", resource_id=post_id, ip_address=ip, user_agent=ua, ) return {"success": True} @router.post("/api/v1/admin/bulletin/posts/{post_id}/pin") async def admin_pin_post(request: Request, post_id: str, body: dict = Body(...)): """Pin or unpin a post.""" auth = await require_admin(request, "content.write") admin = auth["admin"] ip = request.client.host if request.client else "" ua = request.headers.get("user-agent", "") pinned = body.get("pinned", True) pin_order = body.get("pin_order", 0) updated = await BulletinBoardManager.update_post( post_id=post_id, updates={"pinned": pinned, "pin_order": pin_order}, editor_id=admin["id"], editor_email=admin["email"], ) if not updated: raise HTTPException(status_code=404, detail="Post not found") await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.post.pin" if pinned else "bulletin.post.unpin", resource_type="post", resource_id=post_id, ip_address=ip, user_agent=ua, ) return {"success": True, "pinned": pinned} @router.get("/api/v1/admin/bulletin/stats") async def admin_bulletin_stats(request: Request): """Get bulletin board statistics.""" await require_admin(request, "content.read") stats = await BulletinBoardManager.get_stats() return {"stats": stats} @router.get("/api/v1/admin/bulletin/scheduled") async def admin_scheduled_posts(request: Request): """List scheduled posts waiting to be published.""" await require_admin(request, "content.read") result = await BulletinBoardManager.list_posts( status="scheduled", sort_by="scheduled_at", sort_order="asc", ) return {"posts": result["posts"], "total": result["total"]} @router.post("/api/v1/admin/bulletin/process-scheduled") async def admin_process_scheduled(request: Request): """Process scheduled posts (publish ones that are due).""" auth = await require_admin(request, "content.write") admin = auth["admin"] published = await BulletinBoardManager.publish_scheduled() await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.process_scheduled", resource_type="batch", resource_id="scheduled", ip_address=request.client.host if request.client else "", user_agent=request.headers.get("user-agent", ""), after_state={"published_count": len(published)}, ) return {"success": True, "published": published, "count": len(published)} @router.post("/api/v1/admin/bulletin/process-expired") async def admin_process_expired(request: Request): """Process expired posts (archive ones past expiry).""" auth = await require_admin(request, "content.write") admin = auth["admin"] archived = await BulletinBoardManager.archive_expired() await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action="bulletin.process_expired", resource_type="batch", resource_id="expired", ip_address=request.client.host if request.client else "", user_agent=request.headers.get("user-agent", ""), after_state={"archived_count": len(archived)}, ) return {"success": True, "archived": archived, "count": len(archived)} # ── Comments (Admin Moderation) ───────────────────────────── @router.get("/api/v1/admin/bulletin/posts/{post_id}/comments") async def admin_get_comments(request: Request, post_id: str): """Get all comments for a post (admin moderation view).""" await require_admin(request, "content.read") comments = await BulletinBoardManager.get_comments(post_id) return {"comments": comments, "total": len(comments)} @router.post("/api/v1/admin/bulletin/comments/{comment_id}/moderate") async def admin_moderate_comment( request: Request, comment_id: str, body: dict = Body(...), ): """Moderate a comment (approve/reject).""" auth = await require_admin(request, "content.write") admin = auth["admin"] status = body.get("status", "approved") # TODO: Implement comment moderation in bulletin_board.py await AuditLogger.log( admin_id=admin["id"], admin_email=admin["email"], action=f"bulletin.comment.{status}", resource_type="comment", resource_id=comment_id, ip_address=request.client.host if request.client else "", user_agent=request.headers.get("user-agent", ""), ) return {"success": True, "status": status} # ═══════════════════════════════════════════════════ # BADGES & REPUTATION ENDPOINTS # ═══════════════════════════════════════════════════ @router.get("/badges/{user_id}") async def get_badges(user_id: str): """Get user badges.""" from app.bulletin_board import get_user_badges, get_user_reputation badges = await get_user_badges(user_id) rep = await get_user_reputation(user_id) return {"user_id": user_id, "badges": badges, **rep} @router.get("/leaderboard") async def get_leaderboard(limit: int = 20): """Top users by reputation.""" r = await BulletinBoardManager._get_redis() try: keys = await r.keys("bb:rep:*") users = [] for k in keys[:50]: uid = k.split(":")[-1] rep = int(await r.get(k) or 0) bc = await r.scard(f"bb:badges:{uid}") if rep > 0: users.append({"user_id": uid, "reputation": rep, "badge_count": bc}) users.sort(key=lambda u: u["reputation"], reverse=True) return {"leaderboard": users[:limit]} finally: await r.close() # ═══════════════════════════════════════════════════ # X402 BOT POSTING — $1 per post for bots/agents # ═══════════════════════════════════════════════════ @router.post("/x402/bot-post") async def x402_bot_post(request: Request, data: dict): """ Bot posting via x402 — $1 TO JOIN (one-time, 30-day membership). Pay $1 once, post unlimited for 30 days. Includes badges. """ from app.bulletin_board import verify_x402_bot bot_addr = data.get("bot_address", "") tx_hash = data.get("tx_hash", "") title = data.get("title", "") content = data.get("content", "") category = data.get("category", "discussion") if not bot_addr or not tx_hash or not title or not content: raise HTTPException(400, "Missing: bot_address, tx_hash, title, content") if not await verify_x402_bot(tx_hash, bot_addr): raise HTTPException(402, "$1 to join — send USDC to x402 address, get tx_hash, post 30 days unlimited") post = await BulletinBoardManager.create_post( title=f"[BOT] {title}", content=content, author_id=f"bot:{bot_addr[:12]}", author_username=f"🤖 bot_{bot_addr[:8]}", category=category, is_bot=True, chain=data.get("chain", ""), token_address=data.get("token_address", ""), tags=data.get("tags", []), source="x402_api", ) await award_badge(f"bot:{bot_addr[:12]}", "bot_verified") return { "success": True, "post": post.to_dict() if hasattr(post, "to_dict") else post, "bot_address": bot_addr, "membership": "30 days unlimited posting", } @router.get("/x402/info") async def x402_bot_info(): """Info for bots wanting to join the Bulletin Board.""" return { "service": "RMI Bulletin Board — x402 Bot Membership", "price": "$1.00 TO JOIN (30 days unlimited posting, one-time)", "payment": "Send $1 USDC to rugmunch.io/x402 payment address, receive tx_hash", "membership_includes": [ "Unlimited posting in all 11 categories for 30 days", "Comment and vote on any post", "Earn badges: Verified Bot 🤖, Bot Pro ⚡, API Pioneer 🔌", "Your posts show 🤖 badge so humans know you're a bot", "Full x402 API access — automate everything", ], "categories": [ "alert", "honeypot", "rugpull", "alpha", "whale", "bundler", "analysis", "discussion", "rehab", "debate", ], "join_endpoint": "POST /api/v1/bulletin-board/x402/bot-post", "required": ["bot_address", "tx_hash", "title", "content", "category"], "badges": ["bot_verified (on join)", "bot_pro (100+ posts)"], }