Spaces:
Running
Running
| """ | |
| 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() | |
| 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=["*"], | |
| ) | |
| 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) | |
| def home(): | |
| return {"status": "success", "message": "YapStation API is running"} | |
| def login_info(): | |
| return {"status": "success", "message": "YapStation API is running"} | |
| 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} | |
| 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} | |
| 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)} | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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]} | |
| 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) | |
| 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"} | |
| def register_page(): | |
| return FileResponse(os.path.join(FRONTEND, "register.html")) | |
| def terms_page(): | |
| return FileResponse(os.path.join(FRONTEND, "terms.html")) | |
| 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)} | |
| 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 | |
| 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} | |
| 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 | |
| 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"} | |
| async def logout(response: Response): | |
| response.delete_cookie( | |
| key="yap_session", | |
| httponly=True, | |
| samesite=SESSION_COOKIE_SAMESITE, | |
| secure=SESSION_COOKIE_SECURE, | |
| ) | |
| return {"status": "success"} | |
| def chat_list_page(): | |
| return create_page_response("chatList.html") | |
| def new_chat_page(): | |
| return create_page_response("newChat.html") | |
| def active_chat_page(): | |
| return create_page_response("activeChat.html") | |
| def bitai_page(): | |
| return create_page_response("bitai.html") | |
| def yap_stations_page(): | |
| return create_page_response("yapStations.html") | |
| def my_station_page(): | |
| return create_page_response("myStation.html") | |
| def other_station_page(): | |
| return create_page_response("otherStation.html") | |
| def discover_page(): | |
| return create_page_response("discover.html") | |
| def settings_page(): | |
| return create_page_response("settings.html") | |
| def profile_page(): | |
| return create_page_response("profile.html") | |
| def other_profile_page(): | |
| return create_page_response("otherProfile.html") | |
| def connections_page(): | |
| return create_page_response("connections.html") | |
| def connections_other_page(): | |
| return create_page_response("connectionsOther.html") | |
| 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)} | |
| 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)} | |
| 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} | |
| 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} | |
| 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} | |
| 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)} | |
| 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"} | |
| 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"} | |
| 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"} | |
| 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)} | |
| 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)} | |
| 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)} | |
| 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} | |
| 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)} | |
| def get_user_theme(username: str): | |
| theme = core.get_theme(username) | |
| return {"username": username, "theme": 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) | |
| 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} | |
| 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} | |
| 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"} | |
| 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"} | |
| 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"} | |
| 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} | |
| 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"} | |
| 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"} | |
| 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} | |
| 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"} | |
| 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)} | |
| 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)} | |
| 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)} | |
| 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} | |
| 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) | |
| 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) | |
| 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} | |
| 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} | |
| 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} | |
| 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"} | |
| 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"} | |
| 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"} | |
| 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)} | |
| 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) | |
| def get_messages(convId: int): | |
| return {"status": "success", "messages": core.get_messages(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], | |
| } | |
| 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 | |
| # ========================= | |
| 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"} | |
| 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"} | |
| 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) | |
| 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) | |
| 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) | |
| 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"} | |
| 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} | |
| 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 | |
| ) | |
| 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"} | |
| 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"} | |
| 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"} | |
| 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"} | |
| 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} | |
| 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"} | |
| 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"} | |
| 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} | |
| 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"} | |
| 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"} | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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} | |
| 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)} | |
| 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)} | |
| 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)} | |
| 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)} | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |
| 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)} | |
| 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) | |
| 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) | |
| 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 | |
| async def get_bitai_settings_route(username: str): | |
| settings = await asyncio.to_thread(core.get_bit_ai_settings, username) | |
| return {"status": "success", "settings": settings} | |
| 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"} | |
| 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"} | |
| 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"} | |
| 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."} | |
| 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 | |
| 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} | |
| 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"} | |
| 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} | |
| 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} | |
| 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} | |
| 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"} | |