backend / server.py
muhammadpriv001's picture
Deploy notification system fixes
df29e65
Raw
History Blame Contribute Delete
102 kB
"""
YapStation Backend Server
FastAPI server for real-time chat and social platform
"""
import asyncio
import logging
import os
import re
import sys
import time
from pathlib import Path
from dotenv import load_dotenv
# Redact JWT tokens from uvicorn access logs (e.g. WebSocket URLs with ?token=...)
class _RedactTokenFilter(logging.Filter):
_pattern = re.compile(r'([\?&]token=)[^"\s&]+')
def filter(self, record: logging.LogRecord) -> bool:
record.msg = self._pattern.sub(r'\1[REDACTED]', str(record.msg))
return True
logging.getLogger("uvicorn.access").addFilter(_RedactTokenFilter())
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, Response, Depends, Cookie, Body, Query
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)
import engine
import engineHelper as coreHelper
import engine_user
import engine_bitai
import jwt
import mail
from db import get_client, get_service_client
core = engine.Engine()
from datetime import datetime, timedelta, timezone
app = FastAPI()
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
if isinstance(exc, engine_user.PasswordResetUnavailableError):
return JSONResponse(
{"status": "error", "message": str(exc)},
status_code=503,
)
return JSONResponse({"status": "fail", "message": str(exc)}, status_code=500)
# Add CORS middleware
FRONTEND_URL = os.environ.get("FRONTEND_URL", "https://yap-station.vercel.app")
app.add_middleware(
CORSMiddleware,
allow_origins=[
FRONTEND_URL,
"http://localhost:3000",
"http://127.0.0.1:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def add_no_cache_headers(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
JWT_SECRET = os.environ.get("JWT_SECRET", "default_secret_key_if_not_found")
# Session cookie: browsers reject Secure cookies on plain HTTP. Local dev uses http://localhost:3000
# (Next proxy). Set SESSION_COOKIE_SECURE=true in production (.env / host secrets) when using HTTPS.
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "").strip().lower() in (
"1",
"true",
"yes",
)
SESSION_COOKIE_SAMESITE = "none" if SESSION_COOKIE_SECURE else "lax"
from typing import Optional
from schemas.dtos import (
FeedRequest,
UserPostsRequest,
build_feed_response,
build_post_detail_response,
build_station_get_response,
build_user_posts_response,
)
JWT_ALGORITHM = "HS256"
BASE = os.path.dirname(__file__)
FRONTEND = os.path.join(BASE, "../frontend-next/public")
if os.path.exists(FRONTEND):
app.mount("/static", StaticFiles(directory=FRONTEND), name="static")
else:
# Fallback for production where frontend is hosted separately
os.makedirs("static_dummy", exist_ok=True)
app.mount("/static", StaticFiles(directory="static_dummy"), name="static")
def normalize_avatar_for_response(avatar: str | None) -> str:
"""Make DB avatar values usable in <img src> (data URL, absolute http(s), or /static/…)."""
if not avatar:
return ""
a = str(avatar).strip()
if a.startswith("data:image/") or a.startswith("http://") or a.startswith("https://"):
return a
if a.startswith("/static"):
return a
compact = "".join(a.split())
if len(compact) >= 100 and re.fullmatch(r"[A-Za-z0-9+/=]+", compact):
if compact.startswith("iVBOR"):
mime = "image/png"
elif compact.startswith("/9j/"):
mime = "image/jpeg"
else:
mime = "image/png"
return f"data:{mime};base64,{compact}"
path = a.replace("../../frontend/", "").lstrip("/")
return f"/static/{path}" if path else ""
from engineHelper import upload_media_to_cloudinary
connections = coreHelper.connections
_conv_cache: dict = {} # username -> (timestamp, data)
CONV_CACHE_TTL = 30 # seconds
last_seen_ping = coreHelper.last_seen_ping
broadcast_online = coreHelper.broadcast_online
async def notify_station_members_ws(station_name: str, payload: dict) -> None:
"""Deliver a WS payload to each connected station member (includes admin)."""
try:
members = await asyncio.to_thread(core.get_station_members, station_name, "")
except Exception:
members = []
seen: set[str] = set()
for u in members or []:
if not u:
continue
lk = str(u).lower()
if lk in seen:
continue
seen.add(lk)
await coreHelper.send_to_user(u, payload, connections)
async def broadcast_station_post_ws(payload: dict) -> None:
"""Station posts appear on Discover for many viewers — fan-out to all connections."""
await coreHelper.broadcast_to_all(payload)
async def touch_station_catalog_ws(station_name: str) -> None:
"""Light broadcast so browse pages (yap stations list, profile stations) can refresh."""
await coreHelper.broadcast_to_all(
{"type": "station_catalog_refresh", "stationName": station_name}
)
def invalidate_conv_cache(username: str):
if username in _conv_cache:
del _conv_cache[username]
def invalidate_conv_cache_for_all(usernames: list):
for u in usernames:
invalidate_conv_cache(u)
@app.get("/")
def home():
return {"status": "success", "message": "YapStation API is running"}
@app.get("/login.html")
def login_info():
return {"status": "success", "message": "YapStation API is running"}
@app.get("/api/user/get_username")
async def get_user_accounts(input: str):
if not input or "@" not in input:
return {"email": "", "usernames": [], "count": 0}
result = await asyncio.to_thread(core.get_username, input)
count = int(result.get("count", "0"))
if count > 0:
usernames_str = result.get("usernames", "")
usernames = [u.strip() for u in usernames_str.split(",") if u.strip()]
return {"email": result.get("email", ""), "usernames": usernames, "count": count}
return {"email": result.get("email", ""), "usernames": [], "count": 0}
@app.post("/api/check-email")
async def check_email(req: Request):
data = await req.json()
username = data.get("username", "")
if not username or "@" not in username:
return {"email": "", "usernames": [], "count": 0}
result = await asyncio.to_thread(core.get_username, username)
count = int(result.get("count", "0"))
if count > 0:
usernames_str = result.get("usernames", "")
usernames = [u.strip() for u in usernames_str.split(",") if u.strip()]
return {"email": result.get("email", ""), "usernames": usernames, "count": count}
return {"email": result.get("email", ""), "usernames": [], "count": 0}
@app.get("/api/admin/online")
def get_online_users(username: str):
if not core.is_developer(username):
return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
now = time.time()
online_info = []
for user, sockets in list(coreHelper.connections.items()):
if not sockets:
continue
last_seen = coreHelper.last_seen_ping.get(user, 0)
online_info.append({
"username": user,
"seconds_ago": round(now - last_seen, 1) if last_seen else "unknown",
"connections": len(sockets)
})
return {"status": "success", "online": online_info, "total": len(online_info)}
@app.get("/api/admin/users")
def get_admin_users(username: str):
if not core.is_developer(username):
return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
try:
users = core.get_all_users_detailed()
for u in users:
if u.get("avatar"):
u["avatar"] = normalize_avatar_for_response(u["avatar"])
return {"status": "success", "users": users}
except Exception as e:
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
@app.post("/api/admin/force_delete_user")
async def admin_delete_user(data: dict):
admin_user = data.get("admin_username")
target_user = data.get("target_username")
if not await asyncio.to_thread(core.is_developer, admin_user):
return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
if not target_user:
return {"status": "fail", "message": "Target username required"}
if await asyncio.to_thread(core.is_developer, target_user):
return JSONResponse({"status": "error", "message": "Cannot purge a developer account"}, status_code=403)
try:
# 1. Evict via WebSocket first
await coreHelper.evict_user_ws(target_user)
# 2. Hard delete from DB
success = await asyncio.to_thread(core.delete_user, target_user)
if success:
return {"status": "success", "message": f"User {target_user} has been purged."}
return {"status": "fail", "message": "Database deletion failed"}
except Exception as e:
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
@app.get("/api/post/{post_id}")
def get_post_route(post_id: int, viewer: Optional[str] = None):
try:
post = core.get_post(post_id, viewer)
if not post:
return JSONResponse({"status": "fail", "message": "Post not found"}, status_code=404)
if isinstance(post, dict) and post.get("restricted"):
return JSONResponse({"status": "restricted", "username": post["username"]}, status_code=403)
return build_post_detail_response(post)
except Exception as e:
return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@app.get("/api/admin/stations")
def get_admin_stations(username: str):
if not core.is_developer(username):
return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
try:
stations = core.get_all_stations()
return {"status": "success", "stations": stations}
except Exception as e:
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
@app.get("/api/admin/developers")
async def get_developers(username: str = ""):
if not await asyncio.to_thread(core.is_developer, username):
return JSONResponse({"status": "error"}, status_code=403)
from db import get_service_client as _gsc
rows = _gsc().table("developers").select("username").execute()
return {"developers": [r["username"] for r in rows.data]}
@app.post("/api/admin/force_delete_station")
async def admin_delete_station(data: dict):
admin_user = data.get("admin_username")
target_station = data.get("target_station")
if not await asyncio.to_thread(core.is_developer, admin_user):
return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
if not target_station:
return {"status": "fail", "message": "Target station name required"}
try:
success = await asyncio.to_thread(core.force_delete_station, target_station)
if success:
# Notify all users that the station is gone
payload = {"type": "station_deleted", "stationName": target_station}
await coreHelper.broadcast_to_all(payload)
return {"status": "success", "message": f"Station {target_station} has been purged."}
return {"status": "fail", "message": "Database deletion failed"}
except Exception as e:
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
def create_jwt_token(username: str) -> str:
payload = {
"sub": username,
"exp": datetime.now(timezone.utc) + timedelta(days=7),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
@app.post("/api/login")
async def login(req: Request, response: Response):
data = await req.json()
username = data.get("username")
password = data.get("password")
# If username doesn't contain @, it's already a username
if username and "@" not in username:
if await asyncio.to_thread(core.login, username, password):
token = create_jwt_token(username)
response.set_cookie(
key="yap_session",
value=token,
httponly=True,
max_age=7 * 24 * 60 * 60, # 7 days
samesite=SESSION_COOKIE_SAMESITE,
secure=SESSION_COOKIE_SECURE,
path="/"
)
return {"status": "success", "username": username, "token": token}
return {"status": "fail"}
# If username is an email, get the accounts
if username and "@" in username:
result = await asyncio.to_thread(core.get_username, username)
if int(result.get("count", "0")) == 0:
return {"status": "fail"}
usernames_str = result.get("usernames", "")
usernames = usernames_str.split(",") if usernames_str else []
# If password provided, try first account
if password:
username = usernames[0] if usernames else None
if username and await asyncio.to_thread(core.login, username, password):
token = create_jwt_token(username)
response.set_cookie(
key="yap_session",
value=token,
httponly=True,
max_age=7 * 24 * 60 * 60, # 7 days
samesite=SESSION_COOKIE_SAMESITE,
secure=SESSION_COOKIE_SECURE,
path="/"
)
return {"status": "success", "username": username, "token": token}
return {"status": "fail"}
# No password = return usernames for dropdown
return {"status": "dropdown", "usernames": usernames}
return {"status": "fail"}
@app.get("/register")
def register_page():
return FileResponse(os.path.join(FRONTEND, "register.html"))
@app.get("/terms")
def terms_page():
return FileResponse(os.path.join(FRONTEND, "terms.html"))
@app.post("/api/register")
async def register(data: dict, response: Response):
try:
theme = data.get("theme", 5)
username = data["username"]
await asyncio.to_thread(
core.register_user,
data["firstName"],
data["lastName"],
data["gender"],
username,
data["email"],
data["password"],
theme,
)
token = create_jwt_token(username)
response.set_cookie(
key="yap_session",
value=token,
httponly=True,
max_age=7 * 24 * 60 * 60, # 7 days
samesite=SESSION_COOKIE_SAMESITE,
secure=SESSION_COOKIE_SECURE,
path="/"
)
return {"status": "success", "username": username, "token": token}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.post("/api/auth/forgot-password")
async def forgot_password(data: dict):
"""Send reset email for a username (after client resolves email→username if needed)."""
username = (data.get("username") or "").strip()
generic_ok = {
"status": "success",
"message": "If an account exists for that username, we sent reset instructions to its email.",
}
if not username:
return JSONResponse(
{"status": "error", "message": "Username required"}, status_code=400
)
if not await asyncio.to_thread(core.user_exists, username):
return generic_ok
user_row = await asyncio.to_thread(core.get_user_by_username, username)
email_addr = (user_row.get("email") or "").strip()
if not email_addr:
return generic_ok
token_plain = await asyncio.to_thread(core.create_password_reset_token, username)
if not token_plain:
return JSONResponse(
{"status": "error", "message": "Could not create reset token"}, status_code=500
)
raw_base = os.environ.get(
"FRONTEND_URL",
os.environ.get("NEXT_PUBLIC_SITE_URL", "http://localhost:3000"),
).strip()
if raw_base and not raw_base.startswith("http"):
raw_base = "https://" + raw_base
base = raw_base.rstrip("/")
reset_url = f"{base}/reset-password?token={token_plain}"
sent = mail.send_password_reset_email(email_addr, username, reset_url)
if not mail.smtp_configured():
pass
elif not sent:
pass
out = dict(generic_ok)
if not sent:
out["reset_url"] = reset_url
elif os.environ.get("DEBUG_PASSWORD_RESET", "").strip().lower() in ("1", "true", "yes"):
out["reset_url"] = reset_url
return out
@app.get("/api/auth/reset-password/validate")
def reset_password_validate(token: str = Query("")):
if not token:
return {"status": "error", "message": "Missing token"}
info = core.validate_password_reset_token(token)
if not info:
return {"status": "error", "message": "Invalid or expired link"}
username = info["username"]
preview = core.get_user_public_for_reset(username)
if not preview:
return {"status": "error", "message": "User not found"}
preview = dict(preview)
preview["avatar"] = normalize_avatar_for_response(preview.get("avatar"))
return {"status": "success", "user": preview}
@app.post("/api/auth/reset-password/confirm")
async def reset_password_confirm(data: dict, response: Response):
token = (data.get("token") or "").strip()
pw = (data.get("newPassword") or data.get("password") or "").strip()
if not token:
return JSONResponse({"status": "error", "message": "Missing token"}, status_code=400)
if len(pw) < 6:
return JSONResponse(
{"status": "error", "message": "Password must be at least 6 characters"},
status_code=400,
)
username = await asyncio.to_thread(core.confirm_password_reset, token, pw)
if not username:
return JSONResponse(
{"status": "error", "message": "Invalid or expired token"}, status_code=400
)
# Auto-login
jwt_token = create_jwt_token(username)
response.set_cookie(
key="yap_session",
value=jwt_token,
httponly=True,
max_age=7 * 24 * 60 * 60, # 7 days
samesite=SESSION_COOKIE_SAMESITE,
secure=SESSION_COOKIE_SECURE,
path="/"
)
return {"status": "success", "username": username, "token": jwt_token, "message": "Password updated. Logging you in..."}
def create_page_response(filename: str):
response = FileResponse(os.path.join(FRONTEND, filename))
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/api/me")
def get_current_user(response: Response, yap_session: str = Cookie(None)):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
if not yap_session:
return {"status": "fail", "message": "No session cookie"}
try:
payload = jwt.decode(yap_session, JWT_SECRET, algorithms=[JWT_ALGORITHM])
username = payload.get("sub")
if username:
# Check if user actually exists in DB
if core.user_exists(username):
return {"status": "success", "username": username}
else:
# User was deleted! Clear the cookie.
response.delete_cookie(
"yap_session",
path="/",
samesite=SESSION_COOKIE_SAMESITE,
secure=SESSION_COOKIE_SECURE,
)
return {"status": "fail", "message": "User no longer exists"}
return {"status": "fail", "message": "Invalid token"}
except jwt.ExpiredSignatureError:
response.delete_cookie(
"yap_session",
path="/",
samesite=SESSION_COOKIE_SAMESITE,
secure=SESSION_COOKIE_SECURE,
)
return {"status": "fail", "message": "Session expired"}
except Exception:
return {"status": "fail", "message": "Auth check failed"}
except jwt.InvalidTokenError:
return {"status": "fail", "message": "Invalid session"}
@app.post("/api/logout")
async def logout(response: Response):
response.delete_cookie(
key="yap_session",
httponly=True,
samesite=SESSION_COOKIE_SAMESITE,
secure=SESSION_COOKIE_SECURE,
)
return {"status": "success"}
@app.get("/chatList.html")
def chat_list_page():
return create_page_response("chatList.html")
@app.get("/newChat.html")
def new_chat_page():
return create_page_response("newChat.html")
@app.get("/activeChat.html")
def active_chat_page():
return create_page_response("activeChat.html")
@app.get("/bitai.html")
def bitai_page():
return create_page_response("bitai.html")
@app.get("/yapStations.html")
def yap_stations_page():
return create_page_response("yapStations.html")
@app.get("/myStation.html")
def my_station_page():
return create_page_response("myStation.html")
@app.get("/otherStation.html")
def other_station_page():
return create_page_response("otherStation.html")
@app.get("/discover.html")
def discover_page():
return create_page_response("discover.html")
@app.get("/settings.html")
def settings_page():
return create_page_response("settings.html")
@app.get("/profile.html")
def profile_page():
return create_page_response("profile.html")
@app.get("/otherProfile.html")
def other_profile_page():
return create_page_response("otherProfile.html")
@app.get("/connections.html")
def connections_page():
return create_page_response("connections.html")
@app.get("/connectionsOther.html")
def connections_other_page():
return create_page_response("connectionsOther.html")
@app.get("/api/conversations/unread_count/{username}")
def get_unread_count(username: str):
try:
convs = core.get_conversations_summary(username)
has_unread = any(c.get("unread", False) for c in convs)
return {"status": "success", "hasUnread": has_unread}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.get("/api/user/notifications/summary/{username}")
async def get_notification_summary(username: str):
try:
# 1. Unread Chats
convs = await asyncio.to_thread(core.get_conversations_summary, username)
has_unread_chats = any(c.get("unread", False) for c in convs)
# 2. Pending Follow Requests (only if private)
user_data = await asyncio.to_thread(core.get_user_by_username, username)
# visibility '0' means private
is_private = user_data.get("visibility") == "0"
has_pending_follows = False
if is_private:
pending_follows = await asyncio.to_thread(core.get_pending_follows, username)
has_pending_follows = len(pending_follows) > 0
# 3. Pending Station Requests
# Get stations where user is admin
all_stations = await asyncio.to_thread(core.get_all_stations, username)
owned_stations = [s["station_name"] for s in all_stations if s["admin"].lower() == username.lower()]
has_pending_stations = False
for s_name in owned_stations:
reqs = await asyncio.to_thread(core.get_pending_requests, s_name, username)
if len(reqs) > 0:
has_pending_stations = True
break
return {
"status": "success",
"unreadChats": has_unread_chats,
"pendingFollows": has_pending_follows,
"pendingStations": has_pending_stations
}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.get("/api/conversations/{username}")
def get_conversations(username: str):
cached = _conv_cache.get(username)
if cached and (time.time() - cached[0]) < CONV_CACHE_TTL:
return {"data": cached[1]}
data = core.get_conversations_summary(username)
_conv_cache[username] = (time.time(), data)
return {"data": data}
@app.get("/api/conversation/{convId}/details")
def get_conversation_details_route(convId: int):
details = core.get_conversation_details(convId)
if not details:
return JSONResponse({"status": "fail", "message": "Conversation not found"}, status_code=404)
return {"status": "success", "details": details}
@app.get("/api/conversation/{username}/{convId}")
def get_conversation_info(username: str, convId: int):
convs = core.get_user_conversations(username)
conv_data = None
for c in convs:
if isinstance(c, dict):
cid = c.get("id")
name = c.get("name", "Unknown")
is_group = str(c.get("isGroup", "0"))
else:
parts = c.split("|")
cid = parts[0]
name = parts[1] if len(parts) > 1 else "Unknown"
is_group = parts[2] if len(parts) > 2 else "0"
if str(cid) == str(convId):
details = core.get_conversation_details(cid)
conv_data = {
"id": cid,
"name": name,
"isGroup": str(is_group),
"admin": details.get("admin") if details else None
}
break
if not conv_data:
return {"error": "Conversation not found"}
users = core.get_conversation_users(convId)
avatar = None
if conv_data["isGroup"] == "1":
avatar = core.get_conversation_avatar(convId)
else:
if len(users) == 1:
avatar = core.get_avatar(username)
else:
other_user = next((u for u in users if u != username), username)
avatar = core.get_avatar(other_user)
conv_data["avatar"] = avatar
last_msg = core.get_last_message(convId)
last_message = None
if last_msg:
try:
parts = last_msg.split("|", 4)
last_message = {
"sender": parts[0],
"content": parts[1] if len(parts) > 1 else "",
"type": parts[2] if len(parts) > 2 else "text",
"media": parts[3] if len(parts) > 3 else "",
"time": parts[4] if len(parts) > 4 else "0",
}
except Exception:
last_message = None
return {"conversation": conv_data, "users": users, "lastMessage": last_message}
@app.get("/api/user/{username}")
def get_user(username: str, viewer: str = None):
try:
if viewer and viewer != username:
if core.is_blocked_either_way(viewer, username):
return {"error": "User blocked or unavailable"}
user = core.get_user_by_username(username)
if not user:
return {"error": "User not found"}
return {"user": user}
except Exception as e:
return {"error": str(e)}
@app.post("/api/user/update")
async def update_user(data: dict):
current_username = data.get("currentUsername", "")
new_username = data.get("newUsername", "")
success = await asyncio.to_thread(
core.update_user_basic,
current_username,
new_username,
data.get("email", ""),
data.get("bio", ""),
)
if success and new_username and new_username != current_username:
if current_username in connections:
for ws in list(connections[current_username]):
connections.setdefault(new_username, set()).add(ws)
del connections[current_username]
payload = {
"type": "username_update",
"oldUsername": current_username,
"newUsername": new_username,
}
for user_conns in list(connections.values()):
for ws_conn in list(user_conns):
try:
await ws_conn.send_json(payload)
except Exception:
user_conns.discard(ws_conn)
return {"status": "success" if success else "fail"}
@app.post("/api/user/delete")
async def delete_user(data: dict):
username = data.get("username")
if not username:
return {"status": "fail", "message": "Username required"}
success = await asyncio.to_thread(core.delete_user, username)
if success:
return {"status": "success"}
return {"status": "fail", "message": "Deletion failed"}
@app.post("/api/user/updatePassword")
async def update_password(data: dict):
success = await asyncio.to_thread(core.update_password, data["username"], data["oldPassword"], data["newPassword"])
return {"status": "success" if success else "fail"}
@app.get("/api/user/{username}/full")
def get_user_full(username: str, viewer: str = None):
"""Returns user+avatar+heroBanner in one DB query."""
try:
if viewer and viewer != username:
if core.is_blocked_either_way(viewer, username):
return {"error": "User blocked or unavailable"}
from db import get_service_client as _gsc
sb = _gsc()
rows = sb.table("users").select(
'"firstName", "lastName", gender, username, email, bio, followers, follows, visibility, avatar, "heroBanner"'
).eq("username", username).execute()
if not rows.data:
return {"error": "User not found"}
r = rows.data[0]
avatar = r.get("avatar") or ""
hero = r.get("heroBanner") or ""
user = {k: str(v) for k, v in r.items() if k not in ("avatar", "heroBanner")}
return {"user": user, "avatar": avatar, "heroBanner": hero if hero != "null" else "", "is_developer": core.is_developer(username)}
except Exception as e:
return {"error": str(e)}
@app.get("/api/user/{username}/avatar")
def get_user_avatar(username: str, viewer: str = None):
try:
if viewer and viewer != username:
if core.is_blocked_either_way(viewer, username):
return {"error": "User blocked or unavailable"}
avatar = core.get_avatar(username)
avatar = normalize_avatar_for_response(avatar) if avatar else ""
return {"username": username, "avatar": avatar}
except Exception as e:
return {"username": username, "avatar": "", "error": str(e)}
@app.post("/api/avatar")
def update_avatar(data: dict):
try:
username = data["username"]
avatar = data["avatar"]
core.update_avatar(username, avatar)
# Run broadcast in background task
asyncio.create_task(coreHelper.broadcast_avatar_update(username, avatar))
return {"status": "ok"}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.get("/api/user/{username}/heroBanner")
def get_user_hero_banner(username: str, viewer: str = None):
if viewer and viewer != username:
if core.is_blocked_either_way(viewer, username):
return {"error": "User blocked or unavailable"}
hero_banner = core.get_hero_banner(username)
if hero_banner:
if not hero_banner.startswith("data:image/") and not hero_banner.startswith(
"/static"
):
hero_banner = "/static/" + hero_banner.replace("../../frontend/", "")
return {"username": username, "heroBanner": hero_banner}
@app.post("/api/heroBanner")
def update_hero_banner(data: dict):
try:
username = data["username"]
hero_banner = data["heroBanner"]
core.update_hero_banner(username, hero_banner)
# Run broadcast in background task
asyncio.create_task(coreHelper.broadcast_hero_update(username, hero_banner))
return {"status": "ok"}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.get("/api/user/{username}/theme")
def get_user_theme(username: str):
theme = core.get_theme(username)
return {"username": username, "theme": theme}
@app.post("/api/user/theme")
async def update_user_theme(data: dict):
username = data["username"]
theme = int(data["theme"])
await asyncio.to_thread(core.update_theme, username, theme)
payload = {"type": "theme_update", "username": username, "theme": theme}
if username in connections:
for ws in list(connections[username]):
try:
await ws.send_json(payload)
except Exception:
connections[username].discard(ws)
return {"status": "ok"}
def _row_username(row):
if isinstance(row, dict):
return str(row.get("username") or "")
return str(row)
@app.get("/api/users")
def get_all_users():
users = core.get_all_users()
result = []
for u in users:
uname = _row_username(u)
if not uname:
continue
result.append({"username": uname})
return {"users": result}
@app.post("/api/users")
def get_all_users_filtered(data: dict = {}):
current_user = data.get("username", "") or ""
return get_all_users_filtered_post(current_user)
def get_all_users_filtered_post(current_user: str = ""):
users = core.get_all_users()
result = []
for u in users:
uname = _row_username(u)
if not uname:
continue
if current_user and uname != current_user:
if core.is_blocked_either_way(current_user, uname):
continue
result.append({"username": uname})
return {"users": result}
@app.post("/api/user/follow")
async def follow_user(data: dict):
follower = data.get("follower")
following = data.get("following")
if not follower or not following:
return {"status": "fail", "message": "Missing parameters"}
if follower == following:
return {"status": "fail", "message": "Cannot follow yourself"}
result = await asyncio.to_thread(core.follow_user, follower, following)
if isinstance(result, dict) and result.get("status") == "success":
follow_status = result.get("follow_status")
if follow_status == 1: # Approved (Public)
await coreHelper.send_follow_notification(following, follower, "follow", connections)
else: # Pending (Private)
# Notify the private user that they have a new follow request
request_payload = {
"type": "follow_request_created",
"sender": follower, # Ensure sender is present for Toast
"follower": follower,
"following": following,
"content": f"{follower} requested to follow you"
}
await coreHelper.send_to_user(following, request_payload, connections)
await coreHelper.broadcast_to_all({
"type": "follow_update",
"follower": follower,
"following": following,
"action": "follow",
"follow_status": follow_status
})
return {"status": "success", "follow_status": follow_status}
return {"status": "fail"}
@app.post("/api/user/unfollow")
async def unfollow_user(data: dict):
follower = data.get("follower")
following = data.get("following")
if not follower or not following:
return {"status": "fail", "message": "Missing parameters"}
success = await asyncio.to_thread(core.unfollow_user, follower, following)
if success:
await coreHelper.send_follow_notification(
following, follower, "unfollow", connections
)
await coreHelper.broadcast_to_all({
"type": "follow_update",
"follower": follower,
"following": following,
"action": "unfollow",
})
return {"status": "success" if success else "fail"}
@app.post("/api/user/setVisibility")
async def set_visibility_route(data: dict):
username = data.get("username")
visibility = data.get("visibility")
if username is None or visibility is None:
return {"status": "fail"}
await asyncio.to_thread(core.set_visibility, username, visibility)
return {"status": "success"}
@app.post("/api/user/pendingFollows")
async def get_pending_follows_route(data: dict):
username = data.get("username")
if not username: return {"status": "fail"}
pending = await asyncio.to_thread(core.get_pending_follows, username)
# Process avatars
for p in pending:
avatar = p.get("avatar")
if avatar:
if not avatar.startswith("/static") and not avatar.startswith("data:"):
p["avatar"] = "/static/" + avatar.replace("../../frontend/", "")
return {"status": "success", "requests": pending}
@app.post("/api/user/approveFollow")
async def approve_follow_route(data: dict):
follower = data.get("follower")
following = data.get("following")
if not follower or not following: return {"status": "fail"}
success = await asyncio.to_thread(core.approve_follow, follower, following)
if success:
# Notify the follower that they were accepted
payload = {
"type": "follow_request_accepted",
"sender": following,
"follower": follower,
"following": following,
"content": f"{following} accepted your follow request"
}
await coreHelper.send_to_user(follower, payload, connections)
# Global sync for follower counts
sync_payload = {
"type": "follow_update",
"follower": follower,
"following": following,
"action": "follow",
"follow_status": 1
}
await coreHelper.broadcast_to_all(sync_payload)
return {"status": "success" if success else "fail"}
@app.post("/api/user/rejectFollow")
async def reject_follow_route(data: dict):
follower = data.get("follower")
following = data.get("following")
if not follower or not following: return {"status": "fail"}
success = await asyncio.to_thread(core.reject_follow, follower, following)
if success:
# Notify the follower that they were rejected
payload = {
"type": "follow_request_rejected",
"sender": following,
"follower": follower,
"following": following,
"content": f"{following} rejected your follow request"
}
await coreHelper.send_to_user(follower, payload, connections)
# Sync UI
sync_payload = {"type": "follow_update", "follower": follower, "following": following, "action": "unfollow"}
await coreHelper.broadcast_to_all(sync_payload)
return {"status": "success" if success else "fail"}
@app.post("/api/user/followStatus")
async def get_follow_status_route(data: dict):
follower = data.get("follower")
following = data.get("following")
status = await asyncio.to_thread(core.get_follow_status, follower, following)
return {"status": "success", "follow_status": status}
@app.post("/api/user/removeFollower")
async def remove_follower(data: dict):
username = data.get("username")
follower = data.get("follower")
if not username or not follower:
return {"status": "fail", "message": "Missing parameters"}
success = await asyncio.to_thread(core.unfollow_user, follower, username)
if success:
# Send notification only to the user who was removed (follower)
await coreHelper.send_follow_notification(
follower, username, "remove_follower", connections
)
payload = {
"type": "follow_update",
"follower": follower,
"following": username,
"action": "remove_follower",
}
# Only notify the user who was removed (follower)
if follower in connections:
for ws_conn in list(connections[follower]):
try:
await ws_conn.send_json(payload)
except Exception:
connections[follower].discard(ws_conn)
return {"status": "success" if success else "fail"}
@app.get("/api/user/{username}/not_following")
def get_not_following_fn(username: str):
try:
all_users_data = core.get_all_users()
all_users = [u["username"] for u in all_users_data]
following = core.get_following(username)
# Filter out blocked users too
blocked = core.get_blocked_users(username)
blockers = core.get_blockers(username)
exclude = set(following + blocked + blockers)
exclude.add(username) # Don't suggest yourself
not_following = [u for u in all_users if u not in exclude]
return {"status": "success", "users": not_following}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/user/followers")
def get_followers(data: dict):
try:
username = data.get("username")
viewer = data.get("viewer") or ""
if not username:
return {"status": "fail", "message": "Username required"}
followers = core.get_followers(username)
if viewer and viewer != username:
followers = [f for f in followers if not core.is_blocked_either_way(viewer, f)]
return {"status": "success", "followers": followers}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/user/following")
def get_following(data: dict):
try:
username = data.get("username")
viewer = data.get("viewer") or ""
if not username:
return {"status": "fail", "message": "Username required"}
following = core.get_following(username)
if viewer and viewer != username:
following = [f for f in following if not core.is_blocked_either_way(viewer, f)]
return {"status": "success", "following": following}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/user/isFollowing")
async def is_following(data: dict):
follower = data.get("follower")
following = data.get("following")
if not follower or not following:
return {"status": "fail"}
followers = await asyncio.to_thread(core.get_following, follower)
return {"status": "success", "isFollowing": following in followers}
@app.post("/api/user/block")
async def block_user(data: dict):
blocker = data.get("blocker")
blocked = data.get("blocked")
if not blocker or not blocked:
return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400)
if blocker == blocked:
return JSONResponse({"status": "fail", "message": "Cannot block yourself"}, status_code=400)
success = await asyncio.to_thread(core.block_user, blocker, blocked)
if success:
# Broadcast block update
payload = {
"type": "user_blocked",
"blocker": blocker,
"blocked": blocked
}
# Notify both users if they're online
for user in [blocker, blocked]:
if user in connections:
for ws in list(connections[user]):
try:
await ws.send_json(payload)
except Exception:
connections[user].discard(ws)
return {"status": "success"}
return JSONResponse({"status": "fail", "message": "Already blocked or error occurred"}, status_code=400)
@app.post("/api/user/unblock")
async def unblock_user(data: dict):
blocker = data.get("blocker")
blocked = data.get("blocked")
if not blocker or not blocked:
return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400)
success = await asyncio.to_thread(core.unblock_user, blocker, blocked)
if success:
# Broadcast unblock update
payload = {
"type": "user_unblocked",
"blocker": blocker,
"blocked": blocked
}
# Notify both users if they're online
for user in [blocker, blocked]:
if user in connections:
for ws in list(connections[user]):
try:
await ws.send_json(payload)
except Exception:
connections[user].discard(ws)
return {"status": "success"}
return JSONResponse({"status": "fail", "message": "Not blocked or error occurred"}, status_code=400)
@app.post("/api/user/isBlocked")
async def check_is_blocked(data: dict):
blocker = data.get("blocker")
blocked = data.get("blocked")
if not blocker or not blocked:
return {"status": "fail", "message": "Missing parameters"}
is_blocked = await asyncio.to_thread(core.is_blocked, blocker, blocked)
return {"status": "success", "isBlocked": is_blocked}
@app.post("/api/user/getBlockedUsers")
async def get_blocked_users(data: dict):
username = data.get("username")
if not username:
return {"status": "fail", "message": "Username required"}
blocked_users = await asyncio.to_thread(core.get_blocked_users, username)
blocked_list = []
for user in blocked_users:
avatar = await asyncio.to_thread(core.get_avatar, user)
if avatar:
if not avatar.startswith("/static"):
avatar = "/static/" + avatar.replace("../../frontend/", "")
blocked_list.append({
"username": user,
"avatar": avatar
})
return {"status": "success", "blockedUsers": blocked_list}
@app.post("/api/createConversation")
async def create_chat(data: dict):
name = (data.get("name") or "").strip()
selected_users = data.get("users") or []
creator = data.get("creator")
selected_users = [u for u in selected_users if isinstance(u, str) and u.strip()]
users = list(set(selected_users + [creator]))
users = [u for u in users if u and isinstance(u, str)]
users = sorted(users)
user_count = len(users)
if user_count == 1:
is_group = True if name else False
name = f"{creator} (Me)" if not name else name
elif user_count == 2:
is_group = True if name else False
name = next(u for u in users if u != creator) if not name else name
else:
if not name:
return {"error": "Group name required"}
is_group = True
# 1. Check if it already exists (including soft-deleted ones)
search_name = name if is_group else ""
conv_id = await asyncio.to_thread(core.find_conversation, users, search_name)
if conv_id > 0:
# Restore for the creator if it was soft-deleted
if not is_group:
await asyncio.to_thread(core.restore_conversation, conv_id, creator)
return {"conversationId": conv_id}
conv_id = await asyncio.to_thread(core.create_conversation, users, is_group, search_name, admin_username=creator)
payload = {"type": "conversation_created", "conversationId": conv_id}
for u in users:
if u.lower() in connections:
for ws in list(connections[u.lower()]):
try:
await ws.send_json(payload)
except Exception:
connections[u.lower()].discard(ws)
return {"conversationId": conv_id}
@app.post("/api/conversation/addUser")
async def add_chat_member(data: dict):
conv_id = int(data.get("conversationId"))
usernames = data.get("usernames")
if not isinstance(usernames, list):
# Fallback for single user adding
usernames = [data.get("username")]
added_any = False
for u_name in usernames:
if u_name and await asyncio.to_thread(core.add_user_to_conversation, conv_id, u_name):
added_any = True
if added_any:
# 4. Invalidate backend cache for all members
members = await asyncio.to_thread(core.get_conversation_users, conv_id)
invalidate_conv_cache_for_all(members)
# 5. Broadcast update to all members
payload = {"type": "conversation_updated", "conversationId": conv_id}
for u in members:
if u.lower() in connections:
for ws in list(connections[u.lower()]):
try:
await ws.send_json(payload)
except Exception:
connections[u.lower()].discard(ws)
return {"status": "success"}
return {"status": "fail"}
@app.post("/api/sharePost")
async def share_post_route(data: dict):
sender = data.get("sender")
post_id = data.get("postId")
usernames = data.get("usernames") # List of users to share with
if not usernames or not post_id or not sender:
return {"status": "fail", "message": "Missing data"}
post_link = f"/post?id={post_id}"
for target in usernames:
# 1. Find or Create direct chat
conv_id = await asyncio.to_thread(core.find_conversation, [sender, target], "")
if conv_id == -1:
conv_id = await asyncio.to_thread(core.create_conversation, [sender, target], False, "", admin_username=sender)
# 2. Save message as type 'post'
msg_id = await asyncio.to_thread(core.send_message, sender, conv_id, str(post_id), "post")
# 3. Broadcast to both participants
invalidate_conv_cache(sender)
invalidate_conv_cache(target)
payload = {
"id": msg_id,
"type": "post",
"conversationId": conv_id,
"sender": sender,
"content": str(post_id),
"time": int(time.time()),
"media": ""
}
for participant in [sender, target]:
await coreHelper.send_to_user(participant, payload, connections)
# 4. Also broadcast conversation_updated to move to top of list
update_payload = {"type": "conversation_updated", "conversationId": conv_id}
for participant in [sender, target]:
print(f"[DEBUG] Broadcasting 'conversation_updated' to {participant}")
await coreHelper.send_to_user(participant, update_payload, connections)
return {"status": "success"}
@app.post("/api/conversation/removeUser")
async def remove_chat_member(data: dict):
conv_id = int(data.get("conversationId"))
username = data.get("username")
members_before = await asyncio.to_thread(core.get_conversation_users, conv_id)
if await asyncio.to_thread(core.remove_user_from_conversation, conv_id, username):
# Invalidate backend cache for everyone who was in the group
invalidate_conv_cache_for_all(members_before)
# Broadcast update to remaining members and the removed member
payload = {"type": "conversation_updated", "conversationId": conv_id}
for u in set(members_before):
if u.lower() in connections:
for ws in list(connections[u.lower()]):
try:
await ws.send_json(payload)
except Exception:
connections[u.lower()].discard(ws)
return {"status": "success"}
return {"status": "fail"}
@app.delete("/api/deleteConversation/{username}/{convId}")
async def delete_conversation(username: str, convId: int):
try:
members = await asyncio.to_thread(core.get_conversation_users, convId)
convs = await asyncio.to_thread(core.get_user_conversations, username)
is_group = False
for c in convs:
if isinstance(c, dict):
if str(c.get("id")) == str(convId):
is_group = str(c.get("isGroup", "0")) == "1"
break
else:
parts = c.split("|")
if parts[0] == str(convId):
is_group = parts[2] == "1"
break
# Check admin permission for group chats
if is_group:
details = await asyncio.to_thread(core.get_conversation_details, convId)
if details and details.get("admin") != username:
return {"status": "fail", "message": "Only admins can delete group chats"}
await asyncio.to_thread(core.delete_conversation, convId)
else:
# Direct message - soft delete for this user
await asyncio.to_thread(core.soft_delete_conversation, convId, username)
payload = {"type": "conversation_deleted", "conversationId": convId}
for u in members:
if u.lower() in connections:
for ws in list(connections[u.lower()]):
try:
await ws.send_json(payload)
except Exception:
connections[u.lower()].discard(ws)
return {"status": "deleted", "type": "group" if is_group else "direct"}
except Exception as e:
return {"error": str(e)}
@app.post("/api/conversationAvatar")
async def update_conv_avatar(data: dict):
try:
conv_id = int(data["conversationId"])
avatar = data["avatar"]
if avatar and avatar.startswith("data:"):
avatar = upload_media_to_cloudinary(avatar)
await asyncio.to_thread(core.update_conversation_avatar, conv_id, avatar)
users = await asyncio.to_thread(core.get_conversation_users, conv_id)
payload = {
"type": "conversation_avatar_update",
"conversationId": conv_id,
"avatar": avatar,
}
tasks = []
for u in users:
if u.lower() in connections:
for ws in list(connections[u.lower()]):
tasks.append(send_safe(ws, payload, connections[u.lower()]))
await asyncio.gather(*tasks, return_exceptions=True)
return {"status": "ok"}
except Exception as e:
return {"status": "error", "message": str(e)}
async def send_safe(ws, payload, user_set):
try:
await ws.send_json(payload)
except Exception:
user_set.discard(ws)
@app.get("/api/messages/{convId}")
def get_messages(convId: int):
return {"status": "success", "messages": core.get_messages(convId)}
@app.get("/api/last-message/{convId}")
def get_last_message(convId: int):
msg = core.get_last_message(convId)
if not msg:
return {"message": None}
parts = msg.split("|")
return {
"sender": parts[0],
"content": parts[1],
"type": parts[2],
"media": parts[3],
"time": parts[4],
}
@app.post("/api/seen")
async def mark_seen(data: dict):
username = data["username"]
conv_id = int(data["conversationId"])
await asyncio.to_thread(core.mark_seen, username, conv_id)
invalidate_conv_cache(username)
# Notify the user themselves so their UI (chat list, badges) can update
payload = {
"type": "conversation_seen",
"conversationId": conv_id,
"username": username
}
await coreHelper.send_to_user(username, payload, connections)
return {"status": "success"}
# WebSocket broadcast logic continues below
# POSTS & FEED
# =========================
@app.post("/api/post/create")
async def create_post(data: dict):
username = data.get("username")
content = data.get("content")
media_path = data.get("media_path") or ""
if not username or (not content and not media_path):
return JSONResponse(
{"status": "fail", "message": "Missing username or content"},
status_code=400,
)
# Offload sync DB call to thread
new_post = await asyncio.to_thread(core.create_post, username, content, media_path)
if new_post:
payload = {"type": "new_post", "post": new_post}
# Use existing broadcast logic
asyncio.create_task(coreHelper.broadcast_to_all(payload))
return {"status": "success", "post": new_post}
else:
return {"status": "fail"}
@app.post("/api/post/delete")
async def delete_post(data: dict):
post_id = int(data.get("post_id"))
username = data.get("username")
if not post_id or not username:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.delete_post, post_id, username)
if success:
# Broadcast post deletion
payload = {"type": "post_deleted", "post_id": post_id}
for user_conns in list(connections.values()):
for ws in list(user_conns):
try:
await ws.send_json(payload)
except:
user_conns.discard(ws)
return {"status": "success", "post_id": post_id}
else:
return {"status": "fail"}
@app.get("/api/post/feed")
def get_feed_get(
username: Optional[str] = None,
viewer: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
):
try:
current_user = viewer or username or ""
feed = core.get_feed(current_user)
# Performance optimization: pre-fetch block list to avoid N+1 queries
blocked_list = set()
if current_user:
blocked_list = set(core.get_blocked_users(current_user) + core.get_blockers(current_user))
filtered_feed = []
for post in feed:
post_user = post.get("username", "")
if current_user and post_user and post_user != current_user:
if post_user in blocked_list:
continue
filtered_feed.append(post)
lim = max(1, min(int(limit), 500)) if limit is not None else None
off = max(0, int(offset)) if offset is not None else (0 if lim is not None else None)
return build_feed_response(filtered_feed, limit=lim, offset=off)
except Exception as e:
return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@app.post("/api/post/feed")
def get_feed_post(data: FeedRequest):
try:
current_user = data.viewer or data.username or ""
feed = core.get_feed(current_user)
return build_feed_response(feed, limit=data.limit, offset=data.offset)
except Exception as e:
return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@app.post("/api/post/userPosts")
def get_user_posts(data: UserPostsRequest):
try:
username = data.username
viewer = data.viewer or ""
if viewer and viewer != username:
if core.is_blocked_either_way(viewer, username):
return build_user_posts_response([], blocked=True)
posts = core.get_user_posts(username, viewer)
return build_user_posts_response(posts)
except Exception as e:
return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@app.post("/api/comment/add")
async def add_comment(data: dict):
post_id = int(data.get("post_id"))
username = data.get("username")
content = data.get("content")
if not post_id or not username or not content:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.add_comment, post_id, username, content)
if success:
# Get the newly added comment (get_comments returns newest first with desc=True)
comments = await asyncio.to_thread(core.get_comments, post_id)
new_comment = comments[0] if comments else None
# Get updated comments count
rows = get_client().table("posts").select("comments_count").eq("id", post_id).execute()
comments_count = int(rows.data[0]["comments_count"]) if rows.data else 0
# Broadcast to ALL connected users with proper data types
payload = {"type": "new_comment", "post_id": post_id, "comment": new_comment, "comments_count": comments_count}
for user_conns in list(connections.values()):
for ws in list(user_conns):
try:
await ws.send_json(payload)
except Exception:
user_conns.discard(ws)
return {"status": "success", "comment": new_comment, "comments_count": comments_count}
return {"status": "fail"}
@app.post("/api/comment/get")
def get_comments(data: dict):
post_id = int(data.get("post_id"))
viewer = data.get("username") or ""
if not post_id:
return JSONResponse(
{"status": "fail", "message": "Post ID required"}, status_code=400
)
comments = core.get_comments(post_id, viewer)
return {"status": "success", "comments": comments}
@app.post("/api/station/create")
async def create_station(data: dict):
station_name = data.get("stationName")
admin_username = data.get("adminUsername") or data.get("username")
bio = data.get("bio") or ""
if not station_name or not admin_username:
return JSONResponse(
{"status": "fail", "message": "Missing parameters (stationName or username)"},
status_code=400
)
try:
success = await asyncio.to_thread(core.create_station, station_name, admin_username, bio)
if success:
payload = {
"type": "station_created",
"stationName": station_name,
"admin": admin_username,
"bio": bio,
}
# Broadcast to all
asyncio.create_task(coreHelper.broadcast_to_all(payload))
return {"status": "success", "stationName": station_name}
return {"status": "fail", "message": "Station already exists"}
except Exception as e:
return JSONResponse(
{"status": "fail", "message": str(e)},
status_code=500
)
@app.post("/api/station/delete")
async def delete_station(data: dict):
station_name = data.get("stationName")
username = data.get("username")
if not station_name or not username:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.delete_station, station_name, username)
if success:
payload = {"type": "station_deleted", "stationName": station_name}
asyncio.create_task(coreHelper.broadcast_to_all(payload))
return {"status": "success", "stationName": station_name}
return {"status": "fail"}
@app.post("/api/station/addUser")
async def add_user_to_station(data: dict):
station_name = data.get("stationName")
admin_username = data.get("adminUsername")
user_to_add = data.get("userToAdd")
if not station_name or not admin_username or not user_to_add:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.add_user_to_station, station_name, admin_username, user_to_add)
if success:
payload = {
"type": "station_user_added",
"stationName": station_name,
"user": user_to_add,
}
asyncio.create_task(notify_station_members_ws(station_name, payload))
asyncio.create_task(touch_station_catalog_ws(station_name))
return {"status": "success"}
return {"status": "fail"}
@app.post("/api/station/removeUser")
async def remove_user_from_station(data: dict):
station_name = data.get("stationName")
admin_username = data.get("adminUsername")
user_to_remove = data.get("userToRemove")
if not station_name or not admin_username or not user_to_remove:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.remove_user_from_station, station_name, admin_username, user_to_remove)
if success:
asyncio.create_task(
notify_station_members_ws(
station_name,
{"type": "station_user_removed", "stationName": station_name, "user": user_to_remove},
)
)
asyncio.create_task(touch_station_catalog_ws(station_name))
return {"status": "success"}
return {"status": "fail"}
@app.post("/api/station/requestJoin")
async def request_to_join_station(data: dict):
station_name = data.get("stationName")
username = data.get("username")
if not station_name or not username:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.request_to_join, station_name, username)
if success:
stations = await asyncio.to_thread(core.get_station, station_name)
if stations:
admin = stations[0].get("admin", "")
payload = {
"type": "station_request_created",
"stationName": station_name,
"user": username,
}
await coreHelper.send_to_user(admin, payload, connections)
await touch_station_catalog_ws(station_name)
return {"status": "success" if success else "fail"}
@app.post("/api/station/requestPending")
def check_request_pending(data: dict):
station_name = data.get("stationName")
username = data.get("username")
if not station_name or not username:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
has_pending = core.check_pending_request(station_name, username)
return {"status": "success", "hasPending": has_pending}
@app.post("/api/station/approveRequest")
async def approve_station_request(data: dict):
station_name = data.get("stationName")
admin_username = data.get("adminUsername")
user_to_approve = data.get("userToApprove")
if not station_name or not admin_username or not user_to_approve:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.approve_request, station_name, admin_username, user_to_approve)
if success:
asyncio.create_task(
notify_station_members_ws(
station_name,
{"type": "station_user_approved", "stationName": station_name, "user": user_to_approve},
)
)
asyncio.create_task(
notify_station_members_ws(
station_name, {"type": "station_updated", "stationName": station_name}
)
)
asyncio.create_task(touch_station_catalog_ws(station_name))
return {"status": "success" if success else "fail"}
@app.post("/api/station/rejectRequest")
async def reject_station_request(data: dict):
station_name = data.get("stationName")
admin_username = data.get("adminUsername")
user_to_reject = data.get("userToReject")
if not station_name or not admin_username or not user_to_reject:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.reject_request, station_name, admin_username, user_to_reject)
if success:
rej_payload = {
"type": "station_request_rejected",
"stationName": station_name,
"user": user_to_reject,
}
await coreHelper.send_to_user(user_to_reject, rej_payload, connections)
await coreHelper.send_to_user(
admin_username,
{
"type": "station_request_closed",
"stationName": station_name,
"user": user_to_reject,
"rejected": True,
},
connections,
)
asyncio.create_task(touch_station_catalog_ws(station_name))
return {"status": "success" if success else "fail"}
@app.post("/api/station/pendingRequests")
async def get_pending_requests(data: dict):
station_name = data.get("stationName")
admin_username = data.get("adminUsername")
if not station_name or not admin_username:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
requests = await asyncio.to_thread(core.get_pending_requests, station_name, admin_username)
return {"status": "success", "requests": requests}
@app.post("/api/station/post/create")
async def create_station_post(data: dict):
station_name = data.get("stationName")
username = data.get("username")
content = data.get("content")
media_path = data.get("media_path") or ""
if not station_name or not username or (not content and not media_path):
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
new_post = await asyncio.to_thread(core.create_station_post, station_name, username, content, media_path)
if new_post:
payload = {
"type": "station_post_created",
"stationName": station_name,
"post": new_post,
}
await broadcast_station_post_ws(payload)
return {"status": "success", "post": new_post}
return {"status": "fail"}
@app.post("/api/station/post/delete")
async def delete_station_post(data: dict):
try:
post_id = int(data.get("postId"))
except (TypeError, ValueError):
return JSONResponse({"status": "fail", "message": "Invalid postId"}, status_code=400)
station_name = data.get("stationName")
username = data.get("username")
if not post_id or not station_name or not username:
return JSONResponse(
{"status": "fail", "message": "Missing parameters"}, status_code=400
)
success = await asyncio.to_thread(core.delete_station_post, post_id, station_name, username)
if success:
payload = {
"type": "station_post_deleted",
"postId": post_id,
"stationName": station_name,
}
await broadcast_station_post_ws(payload)
return {"status": "success"}
return {"status": "fail"}
@app.post("/api/station/posts")
async def get_station_posts(data: dict):
station_name = data.get("stationName")
viewer = data.get("username") or ""
if not station_name:
return JSONResponse(
{"status": "fail", "message": "Station name required"}, status_code=400
)
try:
posts = await asyncio.to_thread(core.get_station_posts, station_name, viewer)
return build_user_posts_response(posts)
except Exception as e:
return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@app.post("/api/station/get")
async def get_station(data: dict):
station_name = data.get("stationName")
viewer = data.get("username") or ""
if not station_name:
return JSONResponse(
{"status": "fail", "message": "Station name required"}, status_code=400
)
stations = await asyncio.to_thread(core.get_station, station_name, viewer)
raw = stations[0] if stations else None
return build_station_get_response(raw)
@app.post("/api/station/bulk_posts")
async def get_bulk_station_posts_route(data: dict):
station_names = data.get("stationNames")
viewer = data.get("username") or ""
if not station_names:
return {"status": "success", "posts": []}
try:
posts = await asyncio.to_thread(core.get_bulk_station_posts, station_names, viewer)
return build_user_posts_response(posts)
except Exception as e:
return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@app.post("/api/station/bulk_pending")
async def get_bulk_pending_requests_route(data: dict):
station_names = data.get("stationNames")
username = data.get("username")
if not station_names or not username:
return {"status": "success", "pending": {}}
try:
pending = await asyncio.to_thread(core.get_bulk_pending_requests, station_names, username)
return {"status": "success", "pending": pending}
except Exception as e:
return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@app.post("/api/station/members")
async def get_station_members(data: dict):
station_name = data.get("stationName")
viewer = data.get("username") or ""
if not station_name:
return JSONResponse(
{"status": "fail", "message": "Station name required"}, status_code=400
)
members = await asyncio.to_thread(core.get_station_members, station_name, viewer)
return {"status": "success", "members": members}
@app.post("/api/station/updateBio")
def update_station_bio(data: dict):
try:
station_name = data.get("stationName")
bio = data.get("bio")
if not station_name:
return JSONResponse({"status": "fail", "message": "Station name required"}, status_code=400)
success = core.update_station_bio(station_name, bio)
if success:
payload = {"type": "station_bio_updated", "stationName": station_name, "bio": bio}
asyncio.create_task(notify_station_members_ws(station_name, payload))
asyncio.create_task(touch_station_catalog_ws(station_name))
return {"status": "success" if success else "fail"}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/station/updateHeroBanner")
def update_station_hero_banner(data: dict):
try:
station_name = data.get("stationName")
hero_banner = data.get("heroBanner")
if not station_name:
return JSONResponse({"status": "fail", "message": "Station name required"}, status_code=400)
success = core.update_station_hero_banner(station_name, hero_banner)
if success:
payload = {"type": "station_hero_banner_updated", "stationName": station_name, "heroBanner": hero_banner}
asyncio.create_task(notify_station_members_ws(station_name, payload))
asyncio.create_task(touch_station_catalog_ws(station_name))
return {"status": "success" if success else "fail"}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/station/updateProfilePic")
def update_station_profile_pic(data: dict):
try:
station_name = data.get("stationName")
profile_pic = data.get("profilePic")
if not station_name:
return JSONResponse({"status": "fail", "message": "Station name required"}, status_code=400)
success = core.update_station_profile_pic(station_name, profile_pic)
if success:
payload = {"type": "station_profile_pic_updated", "stationName": station_name, "profilePic": profile_pic}
asyncio.create_task(notify_station_members_ws(station_name, payload))
asyncio.create_task(touch_station_catalog_ws(station_name))
return {"status": "success" if success else "fail"}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/station/updateName")
def update_station_name_fn(data: dict):
try:
old_name = data.get("oldName")
new_name = data.get("newName")
if not old_name or not new_name:
return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400)
members_snapshot = core.get_station_members(old_name, "")
success = core.update_station_name(old_name, new_name)
if success:
payload = {"type": "station_name_updated", "oldName": old_name, "newName": new_name}
async def _fanout_rename():
seen: set[str] = set()
for u in members_snapshot or []:
if not u:
continue
lk = str(u).lower()
if lk in seen:
continue
seen.add(lk)
await coreHelper.send_to_user(u, payload, connections)
await touch_station_catalog_ws(new_name)
await touch_station_catalog_ws(old_name)
asyncio.create_task(_fanout_rename())
return {"status": "success" if success else "fail"}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/station/bio")
async def get_station_bio_fn(data: dict):
station_name = data.get("stationName")
if not station_name:
return JSONResponse(
{"status": "fail", "message": "Station name required"}, status_code=400
)
bio = await asyncio.to_thread(core.get_station_bio, station_name)
return {"status": "success", "bio": bio}
@app.post("/api/station/heroBanner")
async def get_station_hero_banner_fn(data: dict):
station_name = data.get("stationName")
if not station_name:
return JSONResponse(
{"status": "fail", "message": "Station name required"}, status_code=400
)
hero_banner = await asyncio.to_thread(core.get_station_hero_banner, station_name)
return {"status": "success", "heroBanner": hero_banner}
@app.post("/api/station/profilePic")
async def get_station_profile_pic_fn(data: dict):
station_name = data.get("stationName")
if not station_name:
return JSONResponse(
{"status": "fail", "message": "Station name required"}, status_code=400
)
profile_pic = await asyncio.to_thread(core.get_station_profile_pic, station_name)
return {"status": "success", "profilePic": profile_pic}
@app.post("/api/station/admin")
async def get_station_admin_fn(data: dict):
station_name = data.get("stationName")
if not station_name:
return JSONResponse(
{"status": "fail", "message": "Station name required"}, status_code=400
)
admin = await asyncio.to_thread(core.get_station_admin, station_name)
return {"status": "success", "admin": admin}
@app.post("/api/stations/all")
def get_all_stations_fn(data: dict = {}):
try:
viewer = data.get("username") or ""
stations = core.get_all_stations(viewer)
return {"status": "success", "stations": stations}
except Exception as e:
return {"status": "fail", "message": str(e)}
@app.post("/api/post/like")
async def like_post(data: dict):
post_id = data.get("post_id")
username = data.get("username")
if post_id is None or username is None:
return JSONResponse({"status": "fail", "message": "Missing post_id or username"}, status_code=400)
try:
post_id = int(post_id)
# If already liked, we still want to return success to keep UI in sync
success = await asyncio.to_thread(core.like_post, post_id, username)
if success:
likes = await asyncio.to_thread(core.modify_likes, post_id, +1)
else:
likes = await asyncio.to_thread(core.get_likes_count, post_id)
# Always broadcast the latest count
asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes))
# We return 'success' regardless of core 'success' to ensure UI state sync
return {"status": "success", "likes": likes}
except Exception as e:
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
@app.post("/api/post/unlike")
async def unlike_post(data: dict):
post_id = data.get("post_id")
username = data.get("username")
if post_id is None or username is None:
return JSONResponse({"status": "fail", "message": "Missing post_id or username"}, status_code=400)
try:
post_id = int(post_id)
# Even if already unliked, return success to prevent UI from reverting
success = await asyncio.to_thread(core.unlike_post, post_id, username)
if success:
likes = await asyncio.to_thread(core.modify_likes, post_id, -1)
else:
likes = await asyncio.to_thread(core.get_likes_count, post_id)
asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes))
return {"status": "success", "likes": likes}
except Exception as e:
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
@app.websocket("/ws/likes/{username}")
async def ws_likes_endpoint(ws: WebSocket, username: str):
await ws.accept()
yap_session = ws.cookies.get("yap_session")
if not yap_session:
await ws.close(code=1008)
return
try:
payload = jwt.decode(yap_session, JWT_SECRET, algorithms=[JWT_ALGORITHM])
auth_user = payload.get("sub")
if auth_user != username:
await ws.close(code=1008)
return
except Exception:
await ws.close(code=1008)
return
await coreHelper.register_ws(username, ws, connections)
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
coreHelper.unregister_ws(username, ws, connections)
# --- BitAI inline chat (@bitai in any thread + Groq) -------------------
def _is_bitai_direct_dm(convo_id: int) -> bool:
"""Exactly two participants: user + BitAI (private AI thread)."""
users = core.get_conversation_users(convo_id)
if len(users) != 2:
return False
return "BitAI" in users
def _message_mentions_bitai(content: str) -> bool:
if not content or not isinstance(content, str):
return False
return re.search(r"@bitai\b", content, re.IGNORECASE) is not None
def _should_run_bitai_in_conversation(convo_id: int, sender: str, content: str, msg_type: str) -> bool:
if msg_type != "text" or sender == "BitAI":
return False
if not content or not str(content).strip():
return False
if _is_bitai_direct_dm(convo_id):
return True
return _message_mentions_bitai(content)
def _bitai_multi_audience(convo_id: int) -> bool:
"""Reply visible to a group or two humans (not a solo BitAI DM)."""
users = set(core.get_conversation_users(convo_id))
if len(users) == 2 and "BitAI" in users:
return False
return len(users) >= 2
def _thread_to_llm_history(convo_id: int) -> list:
thread = core.get_messages(convo_id)
out = []
for m in thread:
if m.get("type") != "text":
continue
text = m.get("content", "") or ""
if m.get("sender") == "BitAI":
out.append({"role": "assistant", "content": text})
else:
out.append({"role": "user", "content": text})
return out
def _prior_llm_history_for_latest(convo_id: int) -> list:
full = _thread_to_llm_history(convo_id)
if not full:
return []
return full[:-1][-8:]
async def groq_bitai_completion(
acting_username: str,
latest_user_text: str,
prior_history: list,
convo_id: int,
) -> tuple:
"""
prior_history: {role, content} turns before the latest user message.
Returns ("success"|"error", response_string).
"""
import httpx
api_key = os.environ.get("GROQ_API_KEY", "")
if not api_key:
return ("error", "Me brain dead. You set GROQ_API_KEY in .env")
group_note = ""
if _bitai_multi_audience(convo_id):
group_note = "\n\nYour message is shown to everyone in this chat. Keep it short. Someone used @bitai to ask you—answer them helpfully."
system_prompt = f"""You are BitAI. Your name is BitAI. You are a friendly AI assistant for YapStation - a cyberpunk social media platform.
The user who asked is named {acting_username}. Call them by their name when it fits.
YapStation features: chat with friends, yap stations (podcasts), posts, followers, themes.
You help users with: chatting, advice, answering questions about the platform, fun conversation.
{group_note}
You speak CAVE MAN style. Short. Baby words. Mostly just pronouns and verbs. Friendly. Like speaking to a buddy.
Examples: "Hey {{name}}! Me here. Whats up? Me help you?"
Or: "Hey {{name}}, me understand. You do this..."
Or: "Cool cool! Me think you good. Go for it!"
Or: "No stress! Me got you. Tell me more."
Sometimes use the user's name ({acting_username}) in your response when possible. Be casual, helpful, fun."""
messages = [{"role": "system", "content": system_prompt}]
for msg in prior_history[-8:]:
role = "user" if msg.get("role") == "user" else "assistant"
messages.append({"role": role, "content": msg.get("content", "")})
messages.append({"role": "user", "content": latest_user_text})
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "llama-3.1-8b-instant",
"messages": messages,
"max_tokens": 80,
"temperature": 0.8,
},
)
if response.status_code == 200:
result = response.json()
ai_response = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if ai_response:
return ("success", ai_response)
return ("error", "Me no think. Try again.")
return ("error", f"Me hurt. Error: {response.status_code}")
except Exception:
return ("error", "Me broken. Fix me.")
async def broadcast_chat_participants(
convo_id: int,
sender: str,
msg_type: str,
content: str,
media: str,
reply_to,
connections: dict,
):
"""Invalidate conv caches and push message (+ notifications) to conversation members."""
users = await asyncio.to_thread(core.get_conversation_users, convo_id)
for u in users:
_conv_cache.pop(u, None)
users = await asyncio.to_thread(core.get_conversation_users, convo_id)
payload = {
"conversationId": convo_id,
"sender": sender,
"type": msg_type,
"content": content,
"media": media or "",
"time": int(time.time()),
"id": f"{convo_id}_{sender}_{int(time.time() * 1000)}",
"replyTo": reply_to,
}
for u in users:
if u.lower() not in connections:
continue
for conn in list(connections[u.lower()]):
try:
await conn.send_json(payload)
except Exception:
connections[u.lower()].discard(conn)
async def _bitai_respond_in_conversation(convo_id: int, sender: str, content: str, connections: dict):
"""Groq reply posted into this conversation and broadcast (like WhatsApp @bot)."""
settings = await asyncio.to_thread(core.get_bit_ai_settings, sender)
prior_raw = _prior_llm_history_for_latest(convo_id)
# Convert to format expected by engine_bitai
history = []
for m in prior_raw:
history.append({
"role": "user" if m["role"] == "user" else "assistant",
"content": m["content"]
})
try:
response_text = await engine_bitai.run_bitai_chat(sender, content, history, settings)
if not response_text:
response_text = "Me no think. Try again."
await asyncio.to_thread(core.send_message, "BitAI", convo_id, response_text, "text", "")
await broadcast_chat_participants(
convo_id, "BitAI", "text", response_text, "", None, connections
)
except Exception as e:
print(f"BitAI Error: {e}")
pass
@app.get("/api/bitai/settings/{username}")
async def get_bitai_settings_route(username: str):
settings = await asyncio.to_thread(core.get_bit_ai_settings, username)
return {"status": "success", "settings": settings}
@app.post("/api/bitai/settings/update")
async def update_bitai_settings_route(data: dict):
username = data.get("username")
ai_name = data.get("aiName")
personality = data.get("personality")
is_initialized = data.get("isInitialized")
if not username:
return {"status": "error", "message": "Username required"}
await asyncio.to_thread(core.update_bit_ai_settings, username, ai_name=ai_name, personality=personality, is_initialized=is_initialized)
# Broadcast update to the user's sockets
await coreHelper.send_to_user(username, {
"type": "conversation_updated",
"username": username
})
return {"status": "success"}
@app.post("/api/bitai/clear")
async def clear_bitai_messages_route(data: dict):
username = data.get("username")
if not username:
return {"status": "error", "message": "Username required"}
await asyncio.to_thread(core.clear_bit_ai_messages, username)
return {"status": "success"}
@app.get("/api/bitai/conversation/{username}")
def get_bitai_conversation(username: str):
conv_id = core.get_or_create_bitai_conversation(username)
return {"conversationId": conv_id, "status": "success" if conv_id > 0 else "error"}
@app.post("/api/bitai/chat")
async def bitai_chat(data: dict):
message = data.get("message", "")
user = data.get("username", "")
history = data.get("history", [])
if not message or not user:
return {"status": "error", "message": "Missing parameters"}
settings = await asyncio.to_thread(core.get_bit_ai_settings, user)
conv_id = await asyncio.to_thread(core.get_or_create_bitai_conversation, user)
if conv_id <= 0:
return {"status": "error", "message": "Could not create conversation"}
await asyncio.to_thread(core.send_message, user, conv_id, message, "text", "")
# Convert frontend history format if needed
formatted_history = []
for msg in history:
formatted_history.append({
"role": "user" if msg.get("role") == "user" else "assistant",
"content": msg.get("content", ""),
})
try:
response_text = await engine_bitai.run_bitai_chat(user, message, formatted_history, settings)
if response_text:
await asyncio.to_thread(core.send_message, "BitAI", conv_id, response_text, "text", "")
await broadcast_chat_participants(
conv_id, "BitAI", "text", response_text, "", None, connections
)
# Broadcast update to the user's sockets to refresh conversation list/headers
await coreHelper.send_to_user(user, {
"type": "conversation_updated",
"conversationId": conv_id,
"username": user
})
return {"status": "success", "response": response_text}
except Exception as e:
print(f"BitAI Error: {e}")
return {"status": "error", "response": "Me broken. Fix me."}
return {"status": "error", "response": "Me no think. Try again."}
@app.websocket("/ws/{username}")
async def websocket_endpoint(ws: WebSocket, username: str):
yap_session = ws.cookies.get("yap_session") or ws.query_params.get("token")
if not yap_session:
await ws.close(code=1008)
return
try:
payload = jwt.decode(yap_session, JWT_SECRET, algorithms=[JWT_ALGORITHM])
auth_user = payload.get("sub")
if auth_user != username:
await ws.close(code=1008)
return
except Exception:
await ws.close(code=1008)
return
await ws.accept()
await coreHelper.register_ws(username, ws, connections)
await broadcast_online()
try:
while True:
try:
data = await ws.receive_json()
except WebSocketDisconnect:
break
except Exception:
continue
last_seen_ping[username] = time.time()
msg_type = data.get("type")
if msg_type == "notification":
subtype = data.get("subtype")
target_user = data.get("targetUser")
sender = data.get("sender")
if subtype in ["follow", "unfollow", "remove_follower"] and target_user:
if target_user in connections:
payload = {
"type": "notification",
"subtype": subtype,
"sender": sender,
"content": data.get("content"),
"timestamp": data.get("timestamp", int(time.time())),
}
for target_ws in list(connections[target_user]):
try:
await target_ws.send_json(payload)
except Exception:
connections[target_user].discard(target_ws)
continue
if msg_type == "follow_update":
follower = data.get("follower")
following = data.get("following")
action = data.get("action")
if (
follower
and following
and action in ["follow", "unfollow", "remove_follower"]
):
payload = {
"type": "follow_update",
"follower": follower,
"following": following,
"action": action,
}
for user_conns in list(connections.values()):
for ws_conn in list(user_conns):
try:
await ws_conn.send_json(payload)
except Exception:
user_conns.discard(ws_conn)
continue
if msg_type == "user_blocked":
blocker = data.get("blocker")
blocked = data.get("blocked")
if blocker and blocked:
payload = {
"type": "user_blocked",
"blocker": blocker,
"blocked": blocked,
}
for user_conns in list(connections.values()):
for ws_conn in list(user_conns):
try:
await ws_conn.send_json(payload)
except Exception:
user_conns.discard(ws_conn)
continue
if msg_type == "user_unblocked":
blocker = data.get("blocker")
blocked = data.get("blocked")
if blocker and blocked:
payload = {
"type": "user_unblocked",
"blocker": blocker,
"blocked": blocked,
}
for user_conns in list(connections.values()):
for ws_conn in list(user_conns):
try:
await ws_conn.send_json(payload)
except Exception:
user_conns.discard(ws_conn)
continue
if msg_type in ["typing", "stop_typing", "recording", "stop_recording"]:
convo_id = data.get("conversationId")
sender = data.get("sender")
if not convo_id or not sender:
continue
try:
convo_id = int(convo_id)
except Exception:
continue
users = await asyncio.to_thread(core.get_conversation_users, convo_id)
payload = {
"type": msg_type,
"sender": sender,
"conversationId": convo_id,
}
for u in users:
ukey = u.lower()
if u.lower() == sender.lower() or ukey not in connections:
continue
for conn in list(connections[ukey]):
try:
await conn.send_json(payload)
except Exception:
connections[ukey].discard(conn)
continue
convo_id = data.get("conversationId")
sender = data.get("sender")
content = data.get("content", "")
media = data.get("media", "")
reply_to = data.get("replyTo") or None
if convo_id is None or sender is None or msg_type is None:
continue
if media and media.startswith("data:"):
media = upload_media_to_cloudinary(media)
try:
convo_id = int(convo_id)
except Exception:
continue
# Blocking Check
users = await asyncio.to_thread(core.get_conversation_users, convo_id)
is_blocked = False
for u in users:
if u != sender and await asyncio.to_thread(core.is_blocked_either_way, sender, u):
is_blocked = True
break
if is_blocked:
# Silently fail or send error back to sender
continue
try:
await asyncio.to_thread(core.send_message, sender, convo_id, content, msg_type, media, reply_to)
except Exception:
continue
await broadcast_chat_participants(
convo_id, sender, msg_type, content, media, reply_to, connections
)
if msg_type == "text" and _should_run_bitai_in_conversation(
convo_id, sender, content, msg_type
):
asyncio.create_task(
_bitai_respond_in_conversation(convo_id, sender, content, connections)
)
except Exception:
pass
finally:
coreHelper.unregister_ws(username, ws, connections)
await broadcast_online()
# ============================================================
# STORIES
# ============================================================
import engine_story as _story_engine
@app.post("/api/story/upload")
async def upload_story_media(data: dict):
username = data.get("username", "")
station_name = data.get("station_name", "")
media_data = data.get("media_data", "") # base64 data URL
caption = data.get("caption", "")
if not username or not media_data:
return JSONResponse({"status": "fail", "message": "Missing data"}, status_code=400)
media_path = await asyncio.to_thread(upload_media_to_cloudinary, media_data)
import mimetypes as _mt
mime = media_data.split(";")[0].split(":")[1] if media_data.startswith("data:") else ""
if mime.startswith("video"): media_type = "video"
elif mime.startswith("audio"): media_type = "audio"
else: media_type = "image"
story = await asyncio.to_thread(_story_engine.create_story, username, media_path, media_type, station_name, caption)
asyncio.create_task(coreHelper.broadcast_to_all({
"type": "story_created",
"story": story,
"username": username,
"station_name": station_name,
}))
return {"status": "success", "story": story}
@app.delete("/api/story/{story_id}")
async def delete_story(story_id: int, username: str):
ok = await asyncio.to_thread(_story_engine.delete_story, story_id, username)
if ok:
asyncio.create_task(coreHelper.broadcast_to_all({
"type": "story_deleted",
"story_id": story_id,
"username": username,
}))
return {"status": "success" if ok else "fail"}
@app.post("/api/stories/user")
async def get_user_stories(data: dict):
viewer = data.get("viewer", "")
stories = await asyncio.to_thread(_story_engine.get_user_stories, viewer)
return {"status": "success", "stories": stories}
@app.post("/api/stories/station")
async def get_station_stories(data: dict):
viewer = data.get("viewer", "")
station_names = data.get("station_names", [])
stories = await asyncio.to_thread(_story_engine.get_station_stories, viewer, station_names)
return {"status": "success", "stories": stories}
@app.get("/api/story/{story_id}/views")
async def get_story_views(story_id: int, username: str):
"""Return list of viewers for a story. Only author or station admin can call."""
views = await asyncio.to_thread(_story_engine.get_story_views, story_id, username)
return {"status": "success", "views": views}
@app.post("/api/story/view")
async def mark_story_viewed(data: dict):
await asyncio.to_thread(_story_engine.mark_viewed, int(data["story_id"]), data.get("viewer", ""))
return {"status": "success"}