rmi-backend/app/bulletin_board.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

903 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"):
if 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