- Make app/domains/auth/ and app/core/redis.py mypy-clean under strict. - Add mypy-gate.ini and Makefile mypy-gate target; promote typecheck-gate in CI. - Consolidate domains into app/domains/: bulletin, admin, intelligence, markets. - Extract referral domain incl. DeFi partner DEX ref links; keep Telegram bot wired. - Move app/mcp/ package and app/api/v1/mcp/router into app/domains/mcp/. - Archive dead app/mcp_router.py.
682 lines
22 KiB
Python
682 lines
22 KiB
Python
"""
|
|
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.domains.admin import AuditLogger, require_admin
|
|
from app.domains.bulletin import (
|
|
BulletinBoardManager,
|
|
PostCategory,
|
|
PostStatus,
|
|
award_badge,
|
|
)
|
|
|
|
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.domains.bulletin 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.domains.bulletin 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)"],
|
|
}
|