forked from ComputerTech/aprhodite
1120 lines
41 KiB
Python
1120 lines
41 KiB
Python
"""
|
||
app.py – Flask-SocketIO backend for SexyChat (Phase 2).
|
||
|
||
Architecture
|
||
------------
|
||
Single 'lobby' room – ephemeral, nothing persisted.
|
||
PM rooms – AES-GCM encrypted; persisted for registered users.
|
||
AI ('violet') room – transit-decrypted for Ollama, re-encrypted for DB.
|
||
Inference queue – one Ollama request at a time; broadcasts violet_typing.
|
||
|
||
Socket events (client → server)
|
||
--------------------------------
|
||
join { mode, username, password?, email?, mod_password? }
|
||
message { text }
|
||
pm_open { target }
|
||
pm_accept { room }
|
||
pm_message { room, text? } | { room, ciphertext, nonce }
|
||
ai_message { ciphertext, nonce, transit_key }
|
||
mod_kick { target }
|
||
mod_ban { target }
|
||
mod_mute { target }
|
||
|
||
Socket events (server → client)
|
||
--------------------------------
|
||
joined { username, is_admin, is_registered, has_ai_access,
|
||
ai_messages_used, token? }
|
||
nicklist { users }
|
||
message { username, text, is_admin, is_registered, ts }
|
||
system { msg, ts }
|
||
error { msg }
|
||
kicked { msg }
|
||
pm_invite { from, room }
|
||
pm_ready { with, room }
|
||
pm_message { from, text?, ciphertext?, nonce?, room, ts }
|
||
violet_typing { busy: bool }
|
||
ai_response { ciphertext, nonce, ai_messages_used, has_ai_access }
|
||
or { error: 'ai_limit_reached' }
|
||
ai_unlock { msg }
|
||
"""
|
||
|
||
import os
|
||
import time
|
||
import hmac
|
||
import hashlib
|
||
import functools
|
||
from collections import defaultdict
|
||
|
||
import bcrypt
|
||
import eventlet # noqa – monkey-patched in start.py before any other import
|
||
from eventlet.queue import Queue as EvQueue
|
||
|
||
from flask import Flask, request, send_from_directory
|
||
from flask_socketio import SocketIO, emit, join_room, disconnect
|
||
|
||
from database import db, init_db
|
||
from models import User, Message, UserIgnore, Ban, Mute, VioletHistory
|
||
from config import (
|
||
SECRET_KEY, ADMIN_PASSWORD, DATABASE_URL, CORS_ORIGINS,
|
||
MAX_MSG_LEN, LOBBY, AI_FREE_LIMIT, AI_BOT_NAME,
|
||
OLLAMA_URL, VIOLET_MODEL, VIOLET_SYSTEM,
|
||
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
|
||
)
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# In-process state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# sid → { username, ip, is_admin, role, joined_at, user_id, is_registered,
|
||
# has_ai_access, ai_messages_used }
|
||
connected_users: dict = {}
|
||
|
||
# role hierarchy – higher number = more power
|
||
ROLE_POWER = {"user": 0, "mod": 1, "admin": 2, "root": 3}
|
||
username_to_sid: dict = {} # lowercase_name → sid
|
||
muted_users: set = set()
|
||
banned_usernames: set = set()
|
||
banned_ips: set = set()
|
||
message_timestamps: dict = defaultdict(list)
|
||
pending_pm_invites: dict = {} # sid → set of room names they were invited to
|
||
|
||
RATE_LIMIT = 6
|
||
RATE_WINDOW = 5
|
||
|
||
# AI inference queue (one Ollama call at a time)
|
||
ai_queue: EvQueue = EvQueue()
|
||
_app_ref = None # set in create_app() for greenlet app-context access
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ollama integration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
MAX_HISTORY_PER_USER = 20 # last N turns loaded into Violet prompt
|
||
|
||
def call_ollama(messages: list) -> str:
|
||
"""Call the local Ollama API with a full messages list. Returns plaintext AI response."""
|
||
import requests as req
|
||
try:
|
||
resp = req.post(
|
||
f"{OLLAMA_URL}/api/chat",
|
||
json={
|
||
"model": VIOLET_MODEL,
|
||
"messages": messages,
|
||
"stream": False,
|
||
"options": {"temperature": 0.88, "num_predict": 120},
|
||
},
|
||
timeout=90,
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.json()["message"]["content"].strip()
|
||
except Exception as exc:
|
||
print(f"[Violet/Ollama] error: {exc}")
|
||
return "Give me just a moment, darling... 💜"
|
||
|
||
|
||
def _load_violet_history(user_id: int) -> list:
|
||
"""Load recent conversation turns from DB. Returns list of {role, content} dicts."""
|
||
rows = (
|
||
VioletHistory.query
|
||
.filter_by(user_id=user_id)
|
||
.order_by(VioletHistory.id.desc())
|
||
.limit(MAX_HISTORY_PER_USER)
|
||
.all()
|
||
)
|
||
return [{"role": r.role, "content": r.text} for r in reversed(rows)]
|
||
|
||
|
||
def _save_violet_turn(user_id: int, role: str, text: str) -> None:
|
||
"""Persist a single conversation turn."""
|
||
db.session.add(VioletHistory(user_id=user_id, role=role, text=text))
|
||
db.session.commit()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AI inference queue worker (single greenlet, serialises Ollama calls)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _ai_worker() -> None:
|
||
"""Eventlet greenlet – drains ai_queue one task at a time."""
|
||
global _app_ref
|
||
while True:
|
||
task = ai_queue.get() # blocks cooperatively until item available
|
||
|
||
sid = task["sid"]
|
||
|
||
try:
|
||
# Derive room name (needs app context for DB lookup)
|
||
with _app_ref.app_context():
|
||
if task.get("user_id"):
|
||
db_user = db.session.get(User, task["user_id"])
|
||
room = _pm_room(db_user.username, AI_BOT_NAME) if db_user else None
|
||
else:
|
||
uname = connected_users.get(sid, {}).get("username", "unknown")
|
||
room = _pm_room(uname, AI_BOT_NAME)
|
||
|
||
# ── Announce Violet is busy ───────────────────────────────────────
|
||
if room:
|
||
socketio.emit("violet_typing", {"busy": True, "room": room}, to=room)
|
||
else:
|
||
socketio.emit("violet_typing", {"busy": True})
|
||
|
||
# ── Decrypt user message (transit; key never stored) ──────────────
|
||
plaintext = aesgcm_decrypt(
|
||
task["transit_key"], task["ciphertext"], task["nonce_val"]
|
||
)
|
||
# ── Build messages array with history ─────────────────────────────
|
||
messages = [{"role": "system", "content": VIOLET_SYSTEM}]
|
||
if task.get("user_id"):
|
||
with _app_ref.app_context():
|
||
messages.extend(_load_violet_history(task["user_id"]))
|
||
messages.append({"role": "user", "content": plaintext})
|
||
|
||
ai_text = call_ollama(messages)
|
||
|
||
# ── Save conversation turns ───────────────────────────────────────
|
||
if task.get("user_id"):
|
||
with _app_ref.app_context():
|
||
_save_violet_turn(task["user_id"], "user", plaintext)
|
||
_save_violet_turn(task["user_id"], "assistant", ai_text)
|
||
|
||
# ── Re-encrypt AI response ────────────────────────────────────────
|
||
resp_ct, resp_nonce = aesgcm_encrypt(task["transit_key"], ai_text)
|
||
|
||
ai_messages_used = task.get("ai_messages_used", 0)
|
||
has_ai_access = task.get("has_ai_access", False)
|
||
|
||
# ── DB operations (need explicit app context in greenlet) ─────────
|
||
with _app_ref.app_context():
|
||
bot = User.query.filter_by(username=AI_BOT_NAME).first()
|
||
if bot and task.get("user_id"):
|
||
_save_pm(task["user_id"], bot.id,
|
||
task["ciphertext"], task["nonce_val"]) # user → Violet
|
||
_save_pm(bot.id, task["user_id"],
|
||
resp_ct, resp_nonce) # Violet → user
|
||
|
||
if task.get("user_id") and not has_ai_access:
|
||
db_user = db.session.get(User, task["user_id"])
|
||
if db_user and not db_user.has_ai_access:
|
||
db_user.ai_messages_used = min(
|
||
db_user.ai_messages_used + 1, AI_FREE_LIMIT
|
||
)
|
||
db.session.commit()
|
||
ai_messages_used = db_user.ai_messages_used
|
||
has_ai_access = db_user.has_ai_access
|
||
|
||
# Update in-process cache
|
||
if sid in connected_users:
|
||
connected_users[sid]["ai_messages_used"] = ai_messages_used
|
||
connected_users[sid]["has_ai_access"] = has_ai_access
|
||
|
||
# ── Emit response to originating client ───────────────────────────
|
||
if task.get("plaintext_mode"):
|
||
socketio.emit("pm_message", {
|
||
"from": AI_BOT_NAME,
|
||
"text": ai_text,
|
||
"room": room,
|
||
"ts": _ts()
|
||
}, to=room)
|
||
else:
|
||
socketio.emit("pm_message", {
|
||
"from": AI_BOT_NAME,
|
||
"ciphertext": resp_ct,
|
||
"nonce": resp_nonce,
|
||
"room": room,
|
||
"ts": _ts()
|
||
}, to=room)
|
||
|
||
socketio.emit("violet_typing", {"busy": False, "room": room}, to=room)
|
||
|
||
except Exception as exc:
|
||
import traceback; traceback.print_exc()
|
||
# Try to send error feedback to user
|
||
try:
|
||
uname = connected_users.get(sid, {}).get("username", "unknown")
|
||
room = _pm_room(uname, AI_BOT_NAME)
|
||
socketio.emit("pm_message", {
|
||
"from": AI_BOT_NAME,
|
||
"text": "Mmm, something went wrong, darling 💜",
|
||
"room": room,
|
||
"ts": _ts()
|
||
}, to=room)
|
||
socketio.emit("violet_typing", {"busy": False, "room": room}, to=room)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
ai_queue.task_done()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# General helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _pm_room(a: str, b: str) -> str:
|
||
return "pm:" + ":".join(sorted([a.lower(), b.lower()]))
|
||
|
||
|
||
def _pm_room_key(room: str) -> str:
|
||
"""Derive a deterministic AES-256 key for a PM room.
|
||
|
||
Uses HMAC-SHA256 keyed with JWT_SECRET so the same room always gets
|
||
the same key (allowing history to be decrypted across sessions).
|
||
The server mediates the key – this is NOT end-to-end, but it fixes
|
||
the broken cross-user decryption while matching the existing trust model.
|
||
"""
|
||
from config import JWT_SECRET
|
||
raw = hmac.new(JWT_SECRET.encode(), room.encode(), hashlib.sha256).digest()
|
||
import base64
|
||
return base64.b64encode(raw).decode()
|
||
|
||
|
||
def _get_nicklist() -> list:
|
||
users = []
|
||
for info in connected_users.values():
|
||
if not info.get("username"):
|
||
continue
|
||
users.append({
|
||
"username": info["username"],
|
||
"is_admin": info["is_admin"],
|
||
"is_registered": info.get("is_registered", False),
|
||
"is_verified": info.get("is_verified", False),
|
||
"role": info.get("role", "user"),
|
||
})
|
||
# Static "Violet" AI user
|
||
users.append({
|
||
"username": AI_BOT_NAME,
|
||
"is_admin": False,
|
||
"is_registered": True,
|
||
"is_verified": True,
|
||
"is_ai": True,
|
||
"role": "user",
|
||
})
|
||
return sorted(users, key=lambda u: u["username"].lower())
|
||
|
||
|
||
def _require_admin(f):
|
||
@functools.wraps(f)
|
||
def wrapped(*args, **kwargs):
|
||
user = connected_users.get(request.sid)
|
||
if not user or not user.get("is_admin"):
|
||
emit("error", {"msg": "Forbidden."})
|
||
return
|
||
return f(*args, **kwargs)
|
||
return wrapped
|
||
|
||
|
||
def _require_role(min_role):
|
||
"""Decorator: require at least min_role power level."""
|
||
def decorator(f):
|
||
@functools.wraps(f)
|
||
def wrapped(*args, **kwargs):
|
||
user = connected_users.get(request.sid)
|
||
if not user:
|
||
emit("error", {"msg": "Forbidden."}); return
|
||
user_power = ROLE_POWER.get(user.get("role", "user"), 0)
|
||
needed = ROLE_POWER.get(min_role, 0)
|
||
if user_power < needed:
|
||
emit("error", {"msg": "Forbidden."}); return
|
||
return f(*args, **kwargs)
|
||
return wrapped
|
||
return decorator
|
||
|
||
|
||
def _rate_limited(sid: str) -> bool:
|
||
now = time.time()
|
||
message_timestamps[sid] = [t for t in message_timestamps[sid] if now - t < RATE_WINDOW]
|
||
if len(message_timestamps[sid]) >= RATE_LIMIT:
|
||
return True
|
||
message_timestamps[sid].append(now)
|
||
return False
|
||
|
||
|
||
def _ts() -> str:
|
||
return time.strftime("%H:%M", time.localtime())
|
||
|
||
|
||
def _do_disconnect(sid: str) -> None:
|
||
try:
|
||
socketio.server.disconnect(sid)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _save_pm(sender_id: int, recipient_id: int,
|
||
encrypted_content: str, nonce: str) -> None:
|
||
msg = Message(
|
||
sender_id=sender_id,
|
||
recipient_id=recipient_id,
|
||
encrypted_content=encrypted_content,
|
||
nonce=nonce,
|
||
)
|
||
db.session.add(msg)
|
||
db.session.commit()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SocketIO instance
|
||
# ---------------------------------------------------------------------------
|
||
|
||
socketio = SocketIO()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# App factory
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def create_app() -> Flask:
|
||
global _app_ref
|
||
|
||
app = Flask(__name__, static_folder="static", template_folder=".")
|
||
app.config.update(
|
||
SECRET_KEY=SECRET_KEY,
|
||
SQLALCHEMY_DATABASE_URI=DATABASE_URL,
|
||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||
SESSION_COOKIE_HTTPONLY=True,
|
||
SESSION_COOKIE_SAMESITE="Lax",
|
||
)
|
||
|
||
init_db(app)
|
||
_app_ref = app
|
||
|
||
# Load persisted bans and mutes from the database
|
||
with app.app_context():
|
||
for ban in Ban.query.all():
|
||
banned_usernames.add(ban.username.lower())
|
||
if ban.ip:
|
||
banned_ips.add(ban.ip)
|
||
for mute in Mute.query.all():
|
||
muted_users.add(mute.username.lower())
|
||
|
||
msg_queue = (
|
||
os.environ.get("SOCKETIO_MESSAGE_QUEUE")
|
||
or os.environ.get("REDIS_URL")
|
||
or None
|
||
)
|
||
socketio.init_app(
|
||
app,
|
||
async_mode="eventlet",
|
||
cors_allowed_origins=CORS_ORIGINS,
|
||
message_queue=msg_queue,
|
||
logger=False,
|
||
engineio_logger=False,
|
||
)
|
||
|
||
# Start the AI inference queue worker greenlet
|
||
eventlet.spawn(_ai_worker)
|
||
|
||
from routes import api
|
||
app.register_blueprint(api)
|
||
|
||
@app.route("/")
|
||
def index():
|
||
return send_from_directory(".", "index.html")
|
||
|
||
return app
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Socket – connection lifecycle
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("connect")
|
||
def on_connect(auth=None):
|
||
sid = request.sid
|
||
ip = request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr)
|
||
|
||
if ip in banned_ips:
|
||
emit("error", {"msg": "You are banned."})
|
||
disconnect()
|
||
return False
|
||
|
||
user_id = None; is_registered = False
|
||
has_ai_access = False; ai_used = 0; jwt_username = None
|
||
|
||
if auth and isinstance(auth, dict) and auth.get("token"):
|
||
payload = verify_jwt(auth["token"])
|
||
if payload:
|
||
db_user = db.session.get(User, payload.get("user_id"))
|
||
if db_user:
|
||
user_id = db_user.id
|
||
is_registered = True
|
||
has_ai_access = db_user.has_ai_access
|
||
ai_used = db_user.ai_messages_used
|
||
jwt_username = db_user.username
|
||
|
||
connected_users[sid] = {
|
||
"username": None,
|
||
"ip": ip,
|
||
"is_admin": False,
|
||
"role": "user",
|
||
"joined_at": time.time(),
|
||
"user_id": user_id,
|
||
"is_registered": is_registered,
|
||
"has_ai_access": has_ai_access,
|
||
"ai_messages_used": ai_used,
|
||
"_jwt_username": jwt_username,
|
||
}
|
||
|
||
|
||
@socketio.on("disconnect")
|
||
def on_disconnect():
|
||
sid = request.sid
|
||
user = connected_users.pop(sid, None)
|
||
message_timestamps.pop(sid, None)
|
||
pending_pm_invites.pop(sid, None)
|
||
if user and user.get("username"):
|
||
lower = user["username"].lower()
|
||
username_to_sid.pop(lower, None)
|
||
socketio.emit("system", {"msg": f"**{user['username']}** left the room.", "ts": _ts()}, to=LOBBY)
|
||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Join / auth
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("join")
|
||
def on_join(data):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user:
|
||
return
|
||
|
||
mode = str(data.get("mode", "guest")).strip()
|
||
username = str(data.get("username", "")).strip()[:20]
|
||
password = str(data.get("password", "")).strip()
|
||
email = str(data.get("email", "")).strip()[:255] or None
|
||
token = None
|
||
db_user = None
|
||
|
||
if mode == "register":
|
||
if not username or not username.replace("_","").replace("-","").isalnum():
|
||
emit("error", {"msg": "Invalid username."}); return
|
||
if len(password) < 6:
|
||
emit("error", {"msg": "Password must be at least 6 characters."}); return
|
||
if username.lower() == AI_BOT_NAME.lower():
|
||
emit("error", {"msg": "That username is reserved."}); return
|
||
if User.query.filter(db.func.lower(User.username) == username.lower()).first():
|
||
emit("error", {"msg": "Username already registered."}); return
|
||
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||
db_user = User(username=username, password_hash=hashed, email=email)
|
||
db.session.add(db_user); db.session.commit()
|
||
user.update(user_id=db_user.id, is_registered=True,
|
||
has_ai_access=False, ai_messages_used=0)
|
||
token = issue_jwt(db_user.id, db_user.username)
|
||
|
||
elif mode == "login":
|
||
db_user = User.query.filter(
|
||
db.func.lower(User.username) == username.lower()
|
||
).first()
|
||
if not db_user or not bcrypt.checkpw(password.encode(), db_user.password_hash.encode()):
|
||
emit("error", {"msg": "Invalid username or password."}); return
|
||
if not db_user.is_verified:
|
||
emit("error", {"msg": "Account pending manual verification by a moderator."}); return
|
||
username = db_user.username
|
||
user["user_id"] = db_user.id
|
||
user["is_registered"] = True
|
||
user["has_ai_access"] = db_user.has_ai_access
|
||
user["ai_messages_used"] = db_user.ai_messages_used
|
||
token = issue_jwt(db_user.id, db_user.username)
|
||
|
||
elif mode == "restore":
|
||
if not user.get("user_id"):
|
||
emit("error", {"msg": "Session expired. Please log in again."}); return
|
||
db_user = db.session.get(User, user["user_id"])
|
||
if not db_user:
|
||
emit("error", {"msg": "Account not found."}); return
|
||
if not db_user.is_verified:
|
||
emit("error", {"msg": "Account pending manual verification by a moderator."}); return
|
||
username = db_user.username
|
||
user["has_ai_access"] = db_user.has_ai_access
|
||
user["ai_messages_used"] = db_user.ai_messages_used
|
||
token = issue_jwt(db_user.id, db_user.username)
|
||
|
||
else: # guest
|
||
if not username or not username.replace("_","").replace("-","").isalnum():
|
||
emit("error", {"msg": "Invalid username. Use letters, numbers, - or _."}); return
|
||
|
||
lower = username.lower()
|
||
if lower in banned_usernames:
|
||
emit("error", {"msg": "That username is banned."}); return
|
||
if lower in username_to_sid and username_to_sid[lower] != sid:
|
||
emit("error", {"msg": "Username already in use."}); return
|
||
|
||
# Derive role from DB (root/admin/mod grant is_admin automatically)
|
||
db_role = db_user.role if db_user else "user"
|
||
is_admin = ROLE_POWER.get(db_role, 0) >= ROLE_POWER["mod"]
|
||
|
||
# Legacy mod-password fallback for guests (temporary mod access)
|
||
mod_pw = str(data.get("mod_password", "")).strip()
|
||
if mod_pw and mod_pw == ADMIN_PASSWORD and not is_admin:
|
||
is_admin = True
|
||
if db_role == "user":
|
||
db_role = "mod" # temporary elevation for the session
|
||
|
||
user["username"] = username
|
||
user["is_admin"] = is_admin
|
||
user["role"] = db_role
|
||
user["is_verified"] = db_user.is_verified if db_user else True
|
||
username_to_sid[lower] = sid
|
||
join_room(LOBBY)
|
||
|
||
# Role badge for join message
|
||
role_icon = {"root": "👑 ", "admin": "⚔️ ", "mod": "🛡️ "}.get(db_role, "")
|
||
|
||
emit("joined", {
|
||
"username": username,
|
||
"is_admin": is_admin,
|
||
"role": db_role,
|
||
"is_registered": user["is_registered"],
|
||
"has_ai_access": user["has_ai_access"],
|
||
"ai_messages_used": user["ai_messages_used"],
|
||
"email": db_user.email if db_user else None,
|
||
"token": token,
|
||
"ignored_list": [u.username for u in db_user.ignoring] if db_user else []
|
||
})
|
||
emit("system", {
|
||
"msg": f"{role_icon}**{username}** joined the room.",
|
||
"ts": _ts(),
|
||
}, to=LOBBY)
|
||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lobby (ephemeral – never persisted)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("message")
|
||
def on_message(data):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user or not user.get("username"):
|
||
return
|
||
if user["username"].lower() in muted_users:
|
||
emit("error", {"msg": "You are muted."}); return
|
||
if _rate_limited(sid):
|
||
emit("error", {"msg": "Slow down!"}); return
|
||
text = str(data.get("text", "")).strip()[:MAX_MSG_LEN]
|
||
if not text:
|
||
return
|
||
emit("message", {
|
||
"username": user["username"],
|
||
"text": text,
|
||
"is_admin": user["is_admin"],
|
||
"is_registered": user.get("is_registered", False),
|
||
"ts": _ts(),
|
||
}, to=LOBBY)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Private Messaging
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("pm_open")
|
||
def on_pm_open(data):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user or not user["username"]:
|
||
return
|
||
target = str(data.get("target", "")).strip()
|
||
target_sid = username_to_sid.get(target.lower())
|
||
|
||
# "Violet" virtual connection
|
||
if not target_sid and target.lower() == AI_BOT_NAME.lower():
|
||
# She's always online
|
||
pass
|
||
elif not target_sid:
|
||
emit("error", {"msg": f"{target} is not online."}); return
|
||
|
||
# Check if target has ignored me
|
||
target_info = connected_users.get(target_sid)
|
||
if target_info and target_info.get("user_id"):
|
||
target_db = db.session.get(User, target_info["user_id"])
|
||
me_db = User.query.filter(db.func.lower(User.username) == user["username"].lower()).first()
|
||
if target_db and me_db and me_db in target_db.ignoring:
|
||
# We don't want to tell the requester they are ignored (stealth)
|
||
# but we just don't send the invite.
|
||
# Actually, to make it clear why nothing is happening:
|
||
emit("error", {"msg": f"{target} is not accepting messages from you right now."})
|
||
return
|
||
|
||
room = _pm_room(user["username"], target)
|
||
join_room(room)
|
||
room_key = _pm_room_key(room)
|
||
if target_sid:
|
||
pending_pm_invites.setdefault(target_sid, set()).add(room)
|
||
socketio.emit("pm_invite", {"from": user["username"], "room": room, "room_key": room_key}, to=target_sid)
|
||
emit("pm_ready", {"with": target, "room": room, "room_key": room_key})
|
||
|
||
|
||
|
||
@socketio.on("pm_accept")
|
||
def on_pm_accept(data):
|
||
sid = request.sid
|
||
room = str(data.get("room", ""))
|
||
allowed = pending_pm_invites.get(sid, set())
|
||
if room not in allowed:
|
||
emit("error", {"msg": "Invalid or expired PM invitation."})
|
||
return
|
||
allowed.discard(room)
|
||
join_room(room)
|
||
|
||
|
||
@socketio.on("pm_message")
|
||
def on_pm_message(data):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user or not user["username"]:
|
||
return
|
||
room = str(data.get("room", ""))
|
||
if not room.startswith("pm:"):
|
||
return
|
||
|
||
ciphertext = data.get("ciphertext", "")
|
||
nonce_val = data.get("nonce", "")
|
||
text = str(data.get("text", "")).strip()[:MAX_MSG_LEN]
|
||
is_encrypted = bool(ciphertext and nonce_val)
|
||
|
||
if not is_encrypted and not text:
|
||
return
|
||
|
||
ts = _ts()
|
||
payload = (
|
||
{"from": user["username"], "ciphertext": ciphertext,
|
||
"nonce": nonce_val, "room": room, "ts": ts}
|
||
if is_encrypted else
|
||
{"from": user["username"], "text": text, "room": room, "ts": ts}
|
||
)
|
||
|
||
# Route to AI if recipient is Violet
|
||
if room.endswith(f":{AI_BOT_NAME.lower()}"):
|
||
if not user.get("user_id") and not user.get("is_admin"):
|
||
emit("error", {"msg": "You must be registered to chat with Violet."}); return
|
||
if not user.get("has_ai_access") and user.get("ai_messages_used", 0) >= AI_FREE_LIMIT:
|
||
emit("ai_response", {"error": "ai_limit_reached", "room": room}, to=sid)
|
||
return
|
||
|
||
# Echo the user's own message back so it appears in their chat
|
||
emit("pm_message", payload, to=sid)
|
||
|
||
transit_key = data.get("transit_key", "")
|
||
if not all([ciphertext, nonce_val, transit_key]):
|
||
# Plaintext fallback (e.g. session restore without crypto key)
|
||
if text:
|
||
import base64 as _b64
|
||
transit_key = _b64.b64encode(os.urandom(32)).decode()
|
||
ciphertext_new, nonce_new = aesgcm_encrypt(transit_key, text)
|
||
ai_queue.put({
|
||
"sid": sid,
|
||
"user_id": user.get("user_id"),
|
||
"has_ai_access": user.get("has_ai_access", False),
|
||
"ai_messages_used": user.get("ai_messages_used", 0),
|
||
"ciphertext": ciphertext_new,
|
||
"nonce_val": nonce_new,
|
||
"transit_key": transit_key,
|
||
"plaintext_mode": True,
|
||
})
|
||
return
|
||
emit("error", {"msg": "Message cannot be empty."}); return
|
||
|
||
ai_queue.put({
|
||
"sid": sid,
|
||
"user_id": user["user_id"],
|
||
"has_ai_access": user["has_ai_access"],
|
||
"ai_messages_used": user["ai_messages_used"],
|
||
"ciphertext": ciphertext,
|
||
"nonce_val": nonce_val,
|
||
"transit_key": transit_key,
|
||
})
|
||
return # ai_worker will handle the delivery
|
||
|
||
emit("pm_message", payload, to=room)
|
||
|
||
if is_encrypted and user.get("user_id"):
|
||
parts = room.split(":")[1:]
|
||
my_lower = user["username"].lower()
|
||
other_low = next((p for p in parts if p != my_lower), None)
|
||
if other_low:
|
||
other_sid = username_to_sid.get(other_low)
|
||
other_info = connected_users.get(other_sid, {}) if other_sid else {}
|
||
if other_info.get("user_id"):
|
||
_save_pm(user["user_id"], other_info["user_id"],
|
||
ciphertext, nonce_val)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AI – Violet (queued Ollama inference)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("ai_message")
|
||
def on_ai_message(data):
|
||
# Deprecated: use pm_message to Violet instead
|
||
pass
|
||
|
||
|
||
@socketio.on("violet_reset")
|
||
def on_violet_reset(_data=None):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user or not user.get("user_id"):
|
||
emit("error", {"msg": "You must be registered to reset Violet history."}); return
|
||
user_id = user["user_id"]
|
||
VioletHistory.query.filter_by(user_id=user_id).delete()
|
||
db.session.commit()
|
||
room = _pm_room(user["username"], AI_BOT_NAME)
|
||
emit("pm_message", {
|
||
"from": AI_BOT_NAME,
|
||
"text": "Memory cleared, darling. Let's start fresh! 💜",
|
||
"room": room,
|
||
"ts": _ts(),
|
||
}, to=sid)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Mod tools
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("mod_kick")
|
||
@_require_admin
|
||
def on_kick(data):
|
||
target = str(data.get("target", "")).strip()
|
||
target_sid = username_to_sid.get(target.lower())
|
||
if not target_sid:
|
||
emit("error", {"msg": f"{target} is not online."}); return
|
||
socketio.emit("kicked", {"msg": "You have been kicked by a moderator."}, to=target_sid)
|
||
socketio.emit("system", {"msg": f"🚫 **{target}** was kicked.", "ts": _ts()}, to=LOBBY)
|
||
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
|
||
|
||
|
||
@socketio.on("mod_ban")
|
||
@_require_admin
|
||
def on_ban(data):
|
||
target = str(data.get("target", "")).strip()
|
||
lower = target.lower()
|
||
banned_usernames.add(lower)
|
||
ip = None
|
||
target_sid = username_to_sid.get(lower)
|
||
if target_sid:
|
||
info = connected_users.get(target_sid, {})
|
||
if info.get("ip"):
|
||
banned_ips.add(info["ip"])
|
||
ip = info["ip"]
|
||
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
|
||
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
|
||
# Persist to DB
|
||
if not Ban.query.filter_by(username=lower).first():
|
||
db.session.add(Ban(username=lower, ip=ip))
|
||
db.session.commit()
|
||
socketio.emit("system", {"msg": f"🔨 **{target}** was banned.", "ts": _ts()}, to=LOBBY)
|
||
|
||
|
||
@socketio.on("mod_mute")
|
||
@_require_admin
|
||
def on_mute(data):
|
||
target = str(data.get("target", "")).strip()
|
||
lower = target.lower()
|
||
if lower in muted_users:
|
||
muted_users.discard(lower)
|
||
Mute.query.filter_by(username=lower).delete()
|
||
db.session.commit()
|
||
action = "unmuted"
|
||
else:
|
||
muted_users.add(lower)
|
||
if not Mute.query.filter_by(username=lower).first():
|
||
db.session.add(Mute(username=lower))
|
||
db.session.commit()
|
||
action = "muted"
|
||
emit("system", {"msg": f"🔇 **{target}** was {action}.", "ts": _ts()}, to=LOBBY)
|
||
|
||
|
||
@socketio.on("mod_kickban")
|
||
@_require_admin
|
||
def on_kickban(data):
|
||
target = str(data.get("target", "")).strip()
|
||
lower = target.lower()
|
||
# Ban
|
||
banned_usernames.add(lower)
|
||
ip = None
|
||
target_sid = username_to_sid.get(lower)
|
||
if target_sid:
|
||
info = connected_users.get(target_sid, {})
|
||
if info.get("ip"):
|
||
banned_ips.add(info["ip"])
|
||
ip = info["ip"]
|
||
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
|
||
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
|
||
# Persist to DB
|
||
if not Ban.query.filter_by(username=lower).first():
|
||
db.session.add(Ban(username=lower, ip=ip))
|
||
db.session.commit()
|
||
# Announce
|
||
socketio.emit("system", {"msg": f"💀 **{target}** was kickbanned.", "ts": _ts()}, to=LOBBY)
|
||
|
||
|
||
@socketio.on("user_ignore")
|
||
def on_ignore(data):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user or not user.get("user_id"):
|
||
return
|
||
|
||
target_name = str(data.get("target", "")).strip()
|
||
target_user = User.query.filter(db.func.lower(User.username) == target_name.lower()).first()
|
||
|
||
if target_user:
|
||
me = db.session.get(User, user["user_id"])
|
||
if target_user not in me.ignoring:
|
||
me.ignoring.append(target_user)
|
||
db.session.commit()
|
||
emit("ignore_status", {"target": target_user.username, "ignored": True})
|
||
|
||
|
||
@socketio.on("user_unignore")
|
||
def on_unignore(data):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user or not user.get("user_id"):
|
||
return
|
||
|
||
target_name = str(data.get("target", "")).strip()
|
||
me = db.session.get(User, user["user_id"])
|
||
target_user = me.ignoring.filter(db.func.lower(User.username) == target_name.lower()).first()
|
||
|
||
if target_user:
|
||
me.ignoring.remove(target_user)
|
||
db.session.commit()
|
||
emit("ignore_status", {"target": target_user.username, "ignored": False})
|
||
|
||
|
||
@socketio.on("mod_verify")
|
||
@_require_admin
|
||
def on_verify(data):
|
||
target_name = str(data.get("target", "")).strip()
|
||
target_user = User.query.filter(db.func.lower(User.username) == target_name.lower()).first()
|
||
|
||
if target_user:
|
||
target_user.is_verified = True
|
||
db.session.commit()
|
||
|
||
# Update online status if target is currently online as a guest
|
||
target_sid = username_to_sid.get(target_name.lower())
|
||
if target_sid:
|
||
target_info = connected_users.get(target_sid)
|
||
if target_info:
|
||
target_info["is_verified"] = True
|
||
|
||
socketio.emit("system", {"msg": f"✅ **{target_user.username}** has been verified by a moderator.", "ts": _ts()}, to=LOBBY)
|
||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Account management
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("change_password")
|
||
def on_change_password(data):
|
||
sid = request.sid
|
||
user = connected_users.get(sid)
|
||
if not user or not user.get("user_id"):
|
||
emit("password_changed", {"success": False, "msg": "You must be registered."})
|
||
return
|
||
|
||
old_pw = str(data.get("old_password", ""))
|
||
new_pw = str(data.get("new_password", ""))
|
||
|
||
if not old_pw or not new_pw:
|
||
emit("password_changed", {"success": False, "msg": "Both fields are required."})
|
||
return
|
||
if len(new_pw) < 6:
|
||
emit("password_changed", {"success": False, "msg": "Password must be at least 6 characters."})
|
||
return
|
||
|
||
db_user = db.session.get(User, user["user_id"])
|
||
if not db_user:
|
||
emit("password_changed", {"success": False, "msg": "User not found."})
|
||
return
|
||
|
||
if not bcrypt.checkpw(old_pw.encode("utf-8"), db_user.password_hash.encode("utf-8")):
|
||
emit("password_changed", {"success": False, "msg": "Current password is incorrect."})
|
||
return
|
||
|
||
db_user.password_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||
db.session.commit()
|
||
emit("password_changed", {"success": True})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Admin panel
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@socketio.on("admin_get_users")
|
||
@_require_role("mod")
|
||
def on_admin_get_users(_data=None):
|
||
"""Send the full user list to the admin panel."""
|
||
users = User.query.order_by(User.id).all()
|
||
result = []
|
||
for u in users:
|
||
if u.username == AI_BOT_NAME:
|
||
continue
|
||
online_sid = username_to_sid.get(u.username.lower())
|
||
result.append({
|
||
"id": u.id,
|
||
"username": u.username,
|
||
"role": u.role,
|
||
"is_verified": u.is_verified,
|
||
"has_ai_access": u.has_ai_access,
|
||
"email": u.email or "",
|
||
"created_at": u.created_at.strftime("%Y-%m-%d"),
|
||
"online": online_sid is not None,
|
||
})
|
||
emit("admin_users", {"users": result})
|
||
|
||
|
||
@socketio.on("admin_get_bans")
|
||
@_require_role("mod")
|
||
def on_admin_get_bans(_data=None):
|
||
bans = Ban.query.order_by(Ban.created_at.desc()).all()
|
||
emit("admin_bans", {"bans": [
|
||
{"id": b.id, "username": b.username, "ip": b.ip or "", "reason": b.reason or "",
|
||
"created_at": b.created_at.strftime("%Y-%m-%d")} for b in bans
|
||
]})
|
||
|
||
|
||
@socketio.on("admin_get_mutes")
|
||
@_require_role("mod")
|
||
def on_admin_get_mutes(_data=None):
|
||
mutes = Mute.query.order_by(Mute.created_at.desc()).all()
|
||
emit("admin_mutes", {"mutes": [
|
||
{"id": m.id, "username": m.username,
|
||
"created_at": m.created_at.strftime("%Y-%m-%d")} for m in mutes
|
||
]})
|
||
|
||
|
||
@socketio.on("admin_set_role")
|
||
@_require_role("admin")
|
||
def on_admin_set_role(data):
|
||
"""Change a user's role. Only root can set admin/root. Admins can set mod/user."""
|
||
sid = request.sid
|
||
me = connected_users.get(sid)
|
||
my_power = ROLE_POWER.get(me.get("role", "user"), 0)
|
||
|
||
target_id = int(data.get("user_id", 0))
|
||
new_role = str(data.get("role", "")).strip().lower()
|
||
if new_role not in ROLE_POWER:
|
||
emit("error", {"msg": "Invalid role."}); return
|
||
|
||
target_power = ROLE_POWER[new_role]
|
||
if target_power >= my_power:
|
||
emit("error", {"msg": "Cannot assign a role equal/above your own."}); return
|
||
|
||
target_user = db.session.get(User, target_id)
|
||
if not target_user:
|
||
emit("error", {"msg": "User not found."}); return
|
||
|
||
# Can't change someone with equal or higher power
|
||
if ROLE_POWER.get(target_user.role, 0) >= my_power:
|
||
emit("error", {"msg": "Cannot modify a user with equal/higher privileges."}); return
|
||
|
||
target_user.role = new_role
|
||
db.session.commit()
|
||
|
||
# Update live session if they're online
|
||
target_sid = username_to_sid.get(target_user.username.lower())
|
||
if target_sid and target_sid in connected_users:
|
||
connected_users[target_sid]["role"] = new_role
|
||
connected_users[target_sid]["is_admin"] = ROLE_POWER[new_role] >= ROLE_POWER["mod"]
|
||
# Notify the target user of their new role
|
||
socketio.emit("role_updated", {"role": new_role}, to=target_sid)
|
||
|
||
socketio.emit("system", {
|
||
"msg": f"⚙️ **{target_user.username}** is now **{new_role}**.",
|
||
"ts": _ts()
|
||
}, to=LOBBY)
|
||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||
emit("admin_action_ok", {"msg": f"{target_user.username} → {new_role}"})
|
||
|
||
|
||
@socketio.on("admin_verify_user")
|
||
@_require_role("mod")
|
||
def on_admin_verify(data):
|
||
target_id = int(data.get("user_id", 0))
|
||
target_user = db.session.get(User, target_id)
|
||
if not target_user:
|
||
emit("error", {"msg": "User not found."}); return
|
||
|
||
target_user.is_verified = not target_user.is_verified
|
||
db.session.commit()
|
||
|
||
status = "verified" if target_user.is_verified else "unverified"
|
||
|
||
target_sid = username_to_sid.get(target_user.username.lower())
|
||
if target_sid and target_sid in connected_users:
|
||
connected_users[target_sid]["is_verified"] = target_user.is_verified
|
||
|
||
socketio.emit("system", {
|
||
"msg": f"{'✅' if target_user.is_verified else '❌'} **{target_user.username}** was {status}.",
|
||
"ts": _ts()
|
||
}, to=LOBBY)
|
||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||
emit("admin_action_ok", {"msg": f"{target_user.username} → {status}"})
|
||
|
||
|
||
@socketio.on("admin_toggle_ai")
|
||
@_require_role("admin")
|
||
def on_admin_toggle_ai(data):
|
||
target_id = int(data.get("user_id", 0))
|
||
target_user = db.session.get(User, target_id)
|
||
if not target_user:
|
||
emit("error", {"msg": "User not found."}); return
|
||
|
||
target_user.has_ai_access = not target_user.has_ai_access
|
||
db.session.commit()
|
||
|
||
target_sid = username_to_sid.get(target_user.username.lower())
|
||
if target_sid and target_sid in connected_users:
|
||
connected_users[target_sid]["has_ai_access"] = target_user.has_ai_access
|
||
|
||
status = "granted" if target_user.has_ai_access else "revoked"
|
||
emit("admin_action_ok", {"msg": f"AI access {status} for {target_user.username}"})
|
||
|
||
|
||
@socketio.on("admin_unban")
|
||
@_require_role("mod")
|
||
def on_admin_unban(data):
|
||
ban_id = int(data.get("ban_id", 0))
|
||
ban = db.session.get(Ban, ban_id)
|
||
if not ban:
|
||
emit("error", {"msg": "Ban not found."}); return
|
||
|
||
banned_usernames.discard(ban.username.lower())
|
||
if ban.ip:
|
||
banned_ips.discard(ban.ip)
|
||
db.session.delete(ban)
|
||
db.session.commit()
|
||
|
||
socketio.emit("system", {
|
||
"msg": f"🔓 **{ban.username}** was unbanned.",
|
||
"ts": _ts()
|
||
}, to=LOBBY)
|
||
emit("admin_action_ok", {"msg": f"Unbanned {ban.username}"})
|
||
|
||
|
||
@socketio.on("admin_unmute")
|
||
@_require_role("mod")
|
||
def on_admin_unmute(data):
|
||
mute_id = int(data.get("mute_id", 0))
|
||
mute = db.session.get(Mute, mute_id)
|
||
if not mute:
|
||
emit("error", {"msg": "Mute not found."}); return
|
||
|
||
muted_users.discard(mute.username.lower())
|
||
db.session.delete(mute)
|
||
db.session.commit()
|
||
|
||
socketio.emit("system", {
|
||
"msg": f"🔊 **{mute.username}** was unmuted.",
|
||
"ts": _ts()
|
||
}, to=LOBBY)
|
||
emit("admin_action_ok", {"msg": f"Unmuted {mute.username}"})
|