- 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.
902 lines
29 KiB
Python
902 lines
29 KiB
Python
"""
|
|
RMI Bulletin Board Management System
|
|
=====================================
|
|
A full content management backend for announcements, news, alerts, platform
|
|
communications, and community bulletin boards.
|
|
|
|
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
|
|
|
|
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)
|
|
"""
|
|
|
|
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
|