Initial commit: SexyChat (Aphrodite) v1.0

This commit is contained in:
ComputerTech 2026-04-12 17:55:40 +01:00
commit ad510c57e1
13 changed files with 3008 additions and 0 deletions

32
.env.example Normal file
View File

@ -0,0 +1,32 @@
# SexyChat Environment Variables
# Copy to .env and fill in your values. Never commit .env to source control.
# ── Flask ──────────────────────────────────────────────────────────────────
SECRET_KEY=change-me-flask-secret-key
HOST=0.0.0.0
PORT=5000
DEBUG=false
# ── Database ───────────────────────────────────────────────────────────────
# PostgreSQL (Recommended for production)
DATABASE_URL=postgresql://sexchat:sexchat_dev@localhost/sexchat
# SQLite fallback (used if DATABASE_URL is not set)
# DATABASE_URL=sqlite:///sexchat.db
# ── Redis (Socket.IO adapter for multi-worker scale) ───────────────────────
REDIS_URL=redis://localhost:6379
# ── Authentication ─────────────────────────────────────────────────────────
JWT_SECRET=change-me-jwt-secret
# JWT tokens expire after 7 days
# ── Moderator ──────────────────────────────────────────────────────────────
ADMIN_PASSWORD=admin1234
# ── AI + Payment ───────────────────────────────────────────────────────────
# Secret used to validate /api/payment/success webhook calls
PAYMENT_SECRET=change-me-payment-webhook-secret
# Optional: real AI provider keys (leave blank to use mock responses)
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=...

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.venv/
env/
venv/
ENV/
# Database
instance/
*.db
*.sqlite3
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
*.log
access.log
error.log
# PID files
*.pid
# Artifacts
artifacts/
# OS
.DS_Store
Thumbs.db

785
app.py Normal file
View File

@ -0,0 +1,785 @@
"""
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 uuid
import base64
import functools
from collections import defaultdict
from datetime import datetime, timedelta
import bcrypt
import jwt as pyjwt
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
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
SECRET_KEY = os.environ.get("SECRET_KEY", uuid.uuid4().hex)
JWT_SECRET = os.environ.get("JWT_SECRET", uuid.uuid4().hex)
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin1234")
MAX_MSG_LEN = 500
LOBBY = "lobby"
AI_FREE_LIMIT = 3
AI_BOT_NAME = "Violet"
# Ollama
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
VIOLET_MODEL = os.environ.get("VIOLET_MODEL", "dolphin-llama3.2")
VIOLET_SYSTEM = (
"You are Violet, a flirtatious and sophisticated nightclub hostess at "
"an exclusive, dimly-lit members-only club. You are charming, witty, "
"and seductive — never crude or offensive. You speak with elegance, "
"mystery, and a hint of playful danger. Keep every reply to 13 "
"sentences maximum. You are in a private conversation with a special "
"guest who has caught your eye."
)
# ---------------------------------------------------------------------------
# In-process state
# ---------------------------------------------------------------------------
# sid → { username, ip, is_admin, joined_at, user_id, is_registered,
# has_ai_access, ai_messages_used }
connected_users: dict = {}
username_to_sid: dict = {} # lowercase_name → sid
muted_users: set = set()
banned_usernames: set = set()
banned_ips: set = set()
message_timestamps: dict = defaultdict(list)
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
# ---------------------------------------------------------------------------
# AES-GCM helpers (server-side, transit only)
# ---------------------------------------------------------------------------
def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = base64.b64decode(key_b64)
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = base64.b64decode(key_b64)
ct = base64.b64decode(ciphertext_b64)
nonce = base64.b64decode(nonce_b64)
return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8")
# ---------------------------------------------------------------------------
# Ollama integration
# ---------------------------------------------------------------------------
def call_ollama(user_message: str) -> str:
"""Call the local Ollama API. Returns plaintext AI response."""
import requests as req
try:
resp = req.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": VIOLET_MODEL,
"messages": [
{"role": "system", "content": VIOLET_SYSTEM},
{"role": "user", "content": user_message},
],
"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... 💜"
# ---------------------------------------------------------------------------
# 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
# ── Announce Violet is busy ───────────────────────────────────────────
room = _pm_room(db.session.get(User, task["user_id"]).username, AI_BOT_NAME) if _app_ref else None
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) ──────────────────
try:
plaintext = _aesgcm_decrypt(
task["transit_key"], task["ciphertext"], task["nonce_val"]
)
ai_text = call_ollama(plaintext)
except Exception as exc:
print(f"[Violet] processing error: {exc}")
ai_text = "Mmm, something went wrong, darling 💜"
# ── 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
sid = task["sid"]
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 ───────────────────────────────
with _app_ref.app_context():
db_user = db.session.get(User, task["user_id"])
room = _pm_room(db_user.username, AI_BOT_NAME)
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)
ai_queue.task_done()
# Clear typing indicator when queue drains
# Done in per-room emit above
# ---------------------------------------------------------------------------
# General helpers
# ---------------------------------------------------------------------------
def _pm_room(a: str, b: str) -> str:
return "pm:" + ":".join(sorted([a.lower(), b.lower()]))
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),
})
# Static "Violet" AI user
users.append({
"username": AI_BOT_NAME,
"is_admin": False,
"is_registered": True,
"is_verified": True,
"is_ai": True
})
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 _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 _issue_jwt(user_id: int, username: str) -> str:
return pyjwt.encode(
{"user_id": user_id, "username": username,
"exp": datetime.utcnow() + timedelta(days=7)},
JWT_SECRET, algorithm="HS256",
)
def _verify_jwt(token: str):
try:
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except Exception:
return None
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=os.environ.get(
"DATABASE_URL", "sqlite:///sexchat.db"
),
SQLALCHEMY_TRACK_MODIFICATIONS=False,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
init_db(app)
_app_ref = app
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="*",
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,
"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)
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
is_admin = False
mod_pw = str(data.get("mod_password", "")).strip()
if mod_pw and mod_pw == ADMIN_PASSWORD:
is_admin = True
user["username"] = username
user["is_admin"] = is_admin
user["is_verified"] = db_user.is_verified if db_user else True # Guests are always "verified" for lobby
username_to_sid[lower] = sid
join_room(LOBBY)
emit("joined", {
"username": username,
"is_admin": is_admin,
"is_registered": user["is_registered"],
"has_ai_access": user["has_ai_access"],
"ai_messages_used": user["ai_messages_used"],
"token": token,
"ignored_list": [u.username for u in db_user.ignoring] if db_user else []
})
emit("system", {
"msg": f"{'🛡️ ' if is_admin else ''}**{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)
socketio.emit("pm_invite", {"from": user["username"], "room": room}, to=target_sid)
emit("pm_ready", {"with": target, "room": room})
@socketio.on("pm_accept")
def on_pm_accept(data):
join_room(data.get("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"):
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("pm_message", {"from": AI_BOT_NAME, "text": "ai_limit_reached", "room": room, "system": True}, to=sid)
return
transit_key = data.get("transit_key", "")
if not all([ciphertext, nonce_val, transit_key]):
emit("error", {"msg": "AI Private Messaging requires transit encryption."}); 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
# ---------------------------------------------------------------------------
# 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)
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"])
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
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); action = "unmuted"
else:
muted_users.add(lower); 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)
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"])
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
# 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)

44
database.py Normal file
View File

@ -0,0 +1,44 @@
"""
database.py SQLAlchemy + Flask-Migrate initialisation for SexyChat.
Import the `db` object everywhere you need ORM access.
Call `init_db(app)` inside the Flask app factory.
"""
import os
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
def init_db(app: "Flask") -> None: # noqa: F821
"""Bind SQLAlchemy and Migrate to the Flask application and create tables."""
db.init_app(app)
migrate.init_app(app, db)
with app.app_context():
# Import models so SQLAlchemy knows about them before create_all()
import models # noqa: F401
db.create_all()
_seed_ai_bot()
def _seed_ai_bot() -> None:
"""Ensure the SexyAI virtual user exists (used as AI-message recipient in DB)."""
from models import User
if User.query.filter_by(username="Violet").first():
return
import bcrypt, os as _os
bot = User(
username="Violet",
password_hash=bcrypt.hashpw(_os.urandom(32), bcrypt.gensalt()).decode(),
has_ai_access=True,
ai_messages_used=0,
)
db.session.add(bot)
db.session.commit()

183
index.html Normal file
View File

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="Sexy The hottest adult chat room on the internet. Connect, flirt, and vibe with Violet." />
<meta name="theme-color" content="#1a0030" />
<title>Sexy Chat | Deep Purple & Neon Magenta</title>
<!-- Google Fonts: Inter and Outfit -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700&display=swap" rel="stylesheet" />
<!-- Socket.IO v4 client -->
<script src="/static/socket.io.min.js"></script>
<!-- Crypto Library -->
<script src="/static/crypto.js"></script>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<!-- Join Screen -->
<div id="join-screen" class="join-screen">
<div class="join-card glass">
<div class="join-logo">
<span class="logo-icon">💋</span>
<h1 class="logo-text">Sexy<span class="logo-accent">Chat</span></h1>
<p class="logo-sub">Midnight Whispers & Neon Dreams</p>
</div>
<div class="auth-tabs">
<button class="auth-tab active" data-mode="guest">Guest</button>
<button class="auth-tab" data-mode="login">Login</button>
<button class="auth-tab" data-mode="register">Register</button>
</div>
<form id="join-form" class="join-form" autocomplete="off">
<div class="field-container">
<div class="field-group">
<input id="username-input" type="text" placeholder="Username" maxlength="20" required />
</div>
<div class="field-group auth-only hidden">
<input id="password-input" type="password" placeholder="Password" />
</div>
<div class="field-group register-only hidden">
<input id="email-input" type="email" placeholder="Email (optional)" />
</div>
<details class="mod-login">
<summary>🛡️ Moderator login</summary>
<div class="field-group" style="margin-top:0.75rem">
<input id="mod-password-input" type="password" placeholder="Mod password" />
</div>
</details>
</div>
<div class="crypto-note auth-only hidden">
<p>🔒 Your encryption key is derived from your password. Zero-knowledge privacy.</p>
</div>
<button id="join-btn" type="submit" class="btn-primary">
Enter the Room <span class="btn-arrow"></span>
</button>
<p id="join-error" class="error-msg" role="alert"></p>
</form>
</div>
</div>
<!-- Chat Screen -->
<div id="chat-screen" class="chat-screen hidden">
<!-- Header -->
<header class="chat-header glass-header">
<button id="sidebar-toggle" class="icon-btn" aria-label="Toggle nicklist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="header-title">
<span class="pulse-dot"></span>
<span id="room-name-header">💋 Sexy</span>
<span id="user-count-badge" class="user-badge">0 online</span>
</div>
<div class="header-right">
<span id="my-username-badge" class="my-badge"></span>
<button id="logout-btn" class="btn-logout">Exit</button>
</div>
</header>
<!-- Main layout -->
<div class="chat-layout">
<!-- Nicklist sidebar -->
<aside id="nicklist-sidebar" class="nicklist-sidebar glass">
<div class="nicklist-header">Users</div>
<ul id="nicklist" class="nicklist"></ul>
</aside>
<!-- Chat area -->
<div class="chat-main">
<!-- Tab bar -->
<div id="tab-bar" class="tab-bar">
<button class="tab-btn active" data-room="lobby" id="tab-lobby">
<span>💋 Lobby</span>
</button>
</div>
<!-- Message panels -->
<div id="panels" class="panels">
<div id="panel-lobby" class="panel active" data-room="lobby">
<div id="messages-lobby" class="messages" role="log"></div>
</div>
</div>
<!-- Input row -->
<form id="message-form" class="message-form glass-input">
<textarea
id="message-input"
placeholder="Say something hot…"
maxlength="500"
rows="1"
></textarea>
<button id="send-btn" type="submit" class="btn-send">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
</form>
</div>
</div>
</div>
<!-- PM Invite Modal -->
<div id="pm-modal" class="modal-overlay hidden">
<div class="modal-card glass">
<p id="pm-modal-title" class="modal-msg"></p>
<div class="modal-actions">
<button id="pm-accept-btn" class="btn-primary">Accept</button>
<button id="pm-decline-btn" class="btn-ghost">Decline</button>
</div>
</div>
</div>
<!-- Violet Paywall Modal -->
<div id="paywall-modal" class="modal-overlay hidden">
<div class="paywall-card glass">
<div class="paywall-header">
<div class="ai-avatar large">V</div>
<h2>Unlock Violet Forever</h2>
</div>
<p class="paywall-text">My time is valuable, darling. I only reserve it for my most dedicated guests.</p>
<div class="paywall-price">$10.00</div>
<div class="benefits">
<p>✓ Unlimited private conversations</p>
<p>✓ Sophisticated & flirtatious companionship</p>
</div>
<button id="unlock-btn" class="btn-primary glow">Unlock Violet for $10</button>
<button id="close-paywall" class="btn-text">Maybe later</button>
</div>
</div>
<!-- Context Menu -->
<div id="context-menu" class="context-menu glass hidden">
<div class="menu-item" data-action="pm">Private Message</div>
<div class="menu-item" data-action="ignore">Ignore User</div>
<div class="menu-item" data-action="unignore">Unignore User</div>
<!-- Mod items -->
<div class="menu-divider mod-item hidden"></div>
<div class="menu-item mod-item hidden" data-action="verify">Verify User</div>
<div class="menu-item mod-item hidden" data-action="kick">Kick</div>
<div class="menu-item mod-item red hidden" data-action="ban">Ban</div>
<div class="menu-item mod-item red bold hidden" data-action="kickban">Kickban</div>
</div>
<script src="/static/chat.js"></script>
</body>
</html>

82
models.py Normal file
View File

@ -0,0 +1,82 @@
"""
models.py SQLAlchemy ORM models for SexyChat.
Tables
------
users Registered accounts
messages Encrypted PM history (useruser and userAI)
"""
from datetime import datetime
from database import db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(255), unique=True, nullable=True)
has_ai_access = db.Column(db.Boolean, default=False, nullable=False)
ai_messages_used = db.Column(db.Integer, default=0, nullable=False)
is_verified = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
sent_messages = db.relationship(
"Message", foreign_keys="Message.sender_id",
backref="sender", lazy="dynamic",
)
received_messages = db.relationship(
"Message", foreign_keys="Message.recipient_id",
backref="recipient", lazy="dynamic",
)
# Persistent "Ignore" list
ignoring = db.relationship(
"User",
secondary="user_ignores",
primaryjoin="User.id == UserIgnore.ignorer_id",
secondaryjoin="User.id == UserIgnore.ignored_id",
backref=db.backref("ignored_by", lazy="dynamic"),
lazy="dynamic"
)
def __repr__(self):
return f"<User {self.username!r}>"
class UserIgnore(db.Model):
__tablename__ = "user_ignores"
id = db.Column(db.Integer, primary_key=True)
ignorer_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
ignored_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = (
db.Index("ix_ignore_pair", "ignorer_id", "ignored_id", unique=True),
)
class Message(db.Model):
__tablename__ = "messages"
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
recipient_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
# AES-GCM ciphertext base64 encoded; server never stores plaintext
encrypted_content = db.Column(db.Text, nullable=False)
# AES-GCM nonce / IV base64 encoded (12 bytes → 16 chars)
nonce = db.Column(db.String(64), nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = (
# Composite indices for the two common query patterns
db.Index("ix_msg_recipient_ts", "recipient_id", "timestamp"),
db.Index("ix_msg_sender_ts", "sender_id", "timestamp"),
)
def __repr__(self):
return f"<Message {self.sender_id}{self.recipient_id} @ {self.timestamp}>"

26
requirements.txt Normal file
View File

@ -0,0 +1,26 @@
# SexyChat Python dependencies
# Install: pip install -r requirements.txt
# ── Core ───────────────────────────────────────────────────────────────────
flask>=3.0,<4.0
flask-socketio>=5.3,<6.0
eventlet>=0.35,<1.0
gunicorn>=21.0,<22.0
# ── Database ───────────────────────────────────────────────────────────────
flask-sqlalchemy>=3.1,<4.0
flask-migrate>=4.0,<5.0
psycopg2-binary>=2.9,<3.0 # PostgreSQL driver
# ── Auth & security ────────────────────────────────────────────────────────
bcrypt>=4.0,<5.0
PyJWT>=2.8,<3.0
# ── Crypto (server-side AES-GCM for transit decryption) ───────────────────
cryptography>=42.0,<45.0
# ── Redis (Socket.IO adapter for multi-worker horizontal scaling) ──────────
redis>=5.0,<6.0
# ── Ollama HTTP client ──────────────────────────────────────────────────────
requests>=2.31,<3.0

370
routes.py Normal file
View File

@ -0,0 +1,370 @@
"""
routes.py REST API blueprint for SexyChat.
Endpoints
---------
POST /api/auth/register Create account, return JWT
POST /api/auth/login Verify credentials, return JWT
GET /api/pm/history Last 500 encrypted messages for a conversation
POST /api/ai/message Transit-decrypt, get AI response, re-encrypt, persist
POST /api/payment/success Validate webhook secret, unlock AI, push socket event
"""
import os
import base64
import hmac
import random
import functools
from datetime import datetime, timedelta
import bcrypt
import jwt as pyjwt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from flask import Blueprint, g, jsonify, request
from database import db
from models import User, Message
api = Blueprint("api", __name__, url_prefix="/api")
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
JWT_SECRET = os.environ.get("JWT_SECRET", "change-me-jwt-secret")
JWT_EXPIRY_DAYS = 7
AI_FREE_LIMIT = 3
PAYMENT_SECRET = os.environ.get("PAYMENT_SECRET", "change-me-payment-secret")
MAX_HISTORY = 500
AI_BOT_NAME = "Violet"
AI_RESPONSES = [
"Mmm, you have my full attention 💋",
"Oh my... keep going 😈 Don't stop there.",
"You're bold. I like that 🔥 Most guests aren't this brave.",
"I wasn't expecting that... but I'm definitely not complaining 😏",
"Well well well, aren't you something 👀 Tell me everything.",
"That's deliciously naughty 🌙 You're dangerous, aren't you?",
"You certainly know how to make an evening interesting 💜",
"I love the way you think 😌 It's... refreshing.",
"A dark club, a conversation like this... 🕯️ This is my kind of night.",
"Say that again. Slower. 💋",
"*sets down the wine glass* Is it warm in here, or is it just you? 🔥",
"You're trouble. The most delicious kind 😈",
"I don't usually admit when a guest surprises me, but here we are 🌙",
"Keep talking. I could listen to this all night 💜",
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _issue_jwt(user_id: int, username: str) -> str:
payload = {
"user_id": user_id,
"username": username,
"exp": datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS),
}
return pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
def _verify_jwt(token: str):
try:
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except pyjwt.PyJWTError:
return None
def _require_auth(f):
"""Decorator parse Bearer JWT and populate g.current_user."""
@functools.wraps(f)
def wrapped(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "Unauthorized"}), 401
payload = _verify_jwt(auth_header[7:])
if not payload:
return jsonify({"error": "Invalid or expired token"}), 401
user = db.session.get(User, payload["user_id"])
if not user:
return jsonify({"error": "User not found"}), 401
g.current_user = user
return f(*args, **kwargs)
return wrapped
def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
"""Encrypt plaintext with AES-GCM. Returns (ciphertext_b64, nonce_b64)."""
key_bytes = base64.b64decode(key_b64)
nonce = os.urandom(12)
ct = AESGCM(key_bytes).encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str:
"""Decrypt AES-GCM ciphertext. Raises on authentication failure."""
key_bytes = base64.b64decode(key_b64)
ct = base64.b64decode(ciphertext_b64)
nonce = base64.b64decode(nonce_b64)
return AESGCM(key_bytes).decrypt(nonce, ct, None).decode("utf-8")
def _persist_message(sender_id: int, recipient_id: int,
encrypted_content: str, nonce: str) -> None:
"""Save a PM to the database. Enforces MAX_HISTORY per conversation pair."""
msg = Message(
sender_id=sender_id,
recipient_id=recipient_id,
encrypted_content=encrypted_content,
nonce=nonce,
)
db.session.add(msg)
db.session.commit()
# Lazy pruning: cap conversation history at MAX_HISTORY
pair_ids = [sender_id, recipient_id]
total = (
Message.query
.filter(
Message.sender_id.in_(pair_ids),
Message.recipient_id.in_(pair_ids),
)
.count()
)
if total > MAX_HISTORY:
excess = total - MAX_HISTORY
oldest = (
Message.query
.filter(
Message.sender_id.in_(pair_ids),
Message.recipient_id.in_(pair_ids),
)
.order_by(Message.timestamp.asc())
.limit(excess)
.all()
)
for old_msg in oldest:
db.session.delete(old_msg)
db.session.commit()
def _get_ai_bot() -> User:
"""Return the SexyAI virtual user, creating it if absent."""
bot = User.query.filter_by(username=AI_BOT_NAME).first()
if not bot:
bot = User(
username=AI_BOT_NAME,
password_hash=bcrypt.hashpw(os.urandom(32), bcrypt.gensalt()).decode(),
has_ai_access=True,
)
db.session.add(bot)
db.session.commit()
return bot
# ---------------------------------------------------------------------------
# Auth routes
# ---------------------------------------------------------------------------
@api.route("/auth/register", methods=["POST"])
def register():
data = request.get_json() or {}
username = str(data.get("username", "")).strip()[:20]
password = str(data.get("password", "")).strip()
email = str(data.get("email", "")).strip()[:255] or None
if not username or not username.replace("_", "").replace("-", "").isalnum():
return jsonify({"error": "Invalid username."}), 400
if len(password) < 6:
return jsonify({"error": "Password must be at least 6 characters."}), 400
if username.lower() == AI_BOT_NAME.lower():
return jsonify({"error": "That username is reserved."}), 409
if User.query.filter(db.func.lower(User.username) == username.lower()).first():
return jsonify({"error": "Username already registered."}), 409
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
user = User(username=username, password_hash=hashed, email=email)
db.session.add(user)
db.session.commit()
token = _issue_jwt(user.id, user.username)
return jsonify({
"token": token,
"user": {
"id": user.id,
"username": user.username,
"has_ai_access": user.has_ai_access,
"ai_messages_used": user.ai_messages_used,
},
}), 201
@api.route("/auth/login", methods=["POST"])
def login():
data = request.get_json() or {}
username = str(data.get("username", "")).strip()
password = str(data.get("password", "")).strip()
user = User.query.filter(
db.func.lower(User.username) == username.lower()
).first()
if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
return jsonify({"error": "Invalid username or password."}), 401
token = _issue_jwt(user.id, user.username)
return jsonify({
"token": token,
"user": {
"id": user.id,
"username": user.username,
"has_ai_access": user.has_ai_access,
"ai_messages_used": user.ai_messages_used,
},
})
# ---------------------------------------------------------------------------
# PM history
# ---------------------------------------------------------------------------
@api.route("/pm/history", methods=["GET"])
@_require_auth
def pm_history():
me = g.current_user
target_name = request.args.get("with", "").strip()
if not target_name:
return jsonify({"error": "Missing 'with' query parameter"}), 400
other = User.query.filter(
db.func.lower(User.username) == target_name.lower()
).first()
if not other:
return jsonify({"messages": []})
rows = (
Message.query
.filter(
db.or_(
db.and_(Message.sender_id == me.id, Message.recipient_id == other.id),
db.and_(Message.sender_id == other.id, Message.recipient_id == me.id),
)
)
.order_by(Message.timestamp.desc())
.limit(MAX_HISTORY)
.all()
)
rows.reverse() # return in chronological order
return jsonify({
"messages": [
{
"from_me": m.sender_id == me.id,
"ciphertext": m.encrypted_content,
"nonce": m.nonce,
"ts": m.timestamp.strftime("%H:%M"),
}
for m in rows
]
})
# ---------------------------------------------------------------------------
# AI message (transit encryption key never persisted)
# ---------------------------------------------------------------------------
@api.route("/ai/message", methods=["POST"])
@_require_auth
def ai_message():
user = g.current_user
data = request.get_json() or {}
# ── Gate ──────────────────────────────────────────────────────────────────
if not user.has_ai_access and user.ai_messages_used >= AI_FREE_LIMIT:
return jsonify({
"error": "ai_limit_reached",
"limit": AI_FREE_LIMIT,
"ai_messages_used": user.ai_messages_used,
}), 402
ciphertext = data.get("ciphertext", "")
nonce_b64 = data.get("nonce", "")
transit_key = data.get("transit_key", "")
if not all([ciphertext, nonce_b64, transit_key]):
return jsonify({"error": "Missing encryption fields"}), 400
# ── Transit decrypt (message readable for AI; key NOT stored) ─────────────
try:
_plaintext = _aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
except Exception:
return jsonify({"error": "Decryption failed wrong key or corrupted data"}), 400
# ── AI response (mock; swap in OpenAI / Anthropic here) ──────────────────
ai_text = random.choice(AI_RESPONSES)
# ── Persist both legs encrypted in DB (server uses transit key) ──────────
bot = _get_ai_bot()
_persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI
resp_ct, resp_nonce = _aesgcm_encrypt(transit_key, ai_text)
_persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user
# ── Update free trial counter ─────────────────────────────────────────────
if not user.has_ai_access:
user.ai_messages_used = min(user.ai_messages_used + 1, AI_FREE_LIMIT)
db.session.commit()
# ── Re-encrypt AI response for transit back ───────────────────────────────
resp_ct_transit, resp_nonce_transit = _aesgcm_encrypt(transit_key, ai_text)
return jsonify({
"ciphertext": resp_ct_transit,
"nonce": resp_nonce_transit,
"ai_messages_used": user.ai_messages_used,
"has_ai_access": user.has_ai_access,
})
# ---------------------------------------------------------------------------
# Payment webhook
# ---------------------------------------------------------------------------
@api.route("/payment/success", methods=["POST"])
@_require_auth
def payment_success():
"""
Validate a payment webhook and flip user.has_ai_access.
Expected body: { "secret": "<PAYMENT_SECRET>" }
For Stripe production: replace the secret comparison with
stripe.Webhook.construct_event() using the raw request body and
the Stripe-Signature header.
"""
data = request.get_json() or {}
secret = data.get("secret", "")
# Constant-time comparison to prevent timing attacks
if not secret or not hmac.compare_digest(
secret.encode(),
PAYMENT_SECRET.encode(),
):
return jsonify({"error": "Invalid or missing payment secret"}), 403
user = g.current_user
if not user.has_ai_access:
user.has_ai_access = True
db.session.commit()
# Emit real-time unlock event to the user's active socket connection
try:
from app import socketio, username_to_sid
target_sid = username_to_sid.get(user.username.lower())
if target_sid:
socketio.emit("ai_unlock", {
"msg": "🎉 AI access unlocked! Enjoy unlimited SexyAI chat.",
}, to=target_sid)
except Exception:
pass # Non-critical: UI will refresh on next request anyway
return jsonify({"status": "ok", "has_ai_access": True})

148
start.py Normal file
View File

@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
start.py Gunicorn Process Manager for SexyChat.
Usage:
python start.py start # Starts SexyChat in the background
python start.py stop # Stops the background process
python start.py restart # Restarts the background process
python start.py status # Shows if the server is running
python start.py debug # Runs in the foreground with debug logging
"""
import os
import sys
import subprocess
import signal
import time
import eventlet
# Monkey-patch stdlib BEFORE any other import
eventlet.monkey_patch()
# PID file to track the daemon process
PID_FILE = "sexchat.pid"
def get_pid():
if os.path.exists(PID_FILE):
with open(PID_FILE, "r") as f:
try:
return int(f.read().strip())
except ValueError:
return None
return None
def is_running(pid):
if not pid:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def start_daemon():
pid = get_pid()
if is_running(pid):
print(f"❌ SexChat is already running (PID: {pid}).")
return
print("🚀 Starting SexChat in background...")
cmd = [
"gunicorn",
"--worker-class", "eventlet",
"-w", "1",
"--bind", f"{os.environ.get('HOST', '0.0.0.0')}:{os.environ.get('PORT', '5000')}",
"--daemon",
"--pid", PID_FILE,
"--access-logfile", "access.log",
"--error-logfile", "error.log",
"start:application"
]
subprocess.run(cmd)
time.sleep(1)
new_pid = get_pid()
if is_running(new_pid):
print(f"✅ SexChat started successfully (PID: {new_pid}).")
else:
print("❌ Failed to start SexChat. Check error.log for details.")
def stop_daemon():
pid = get_pid()
if not is_running(pid):
print(" SexChat is not running.")
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
return
print(f"🛑 Stopping SexChat (PID: {pid})...")
try:
os.kill(pid, signal.SIGTERM)
# Wait for it to die
for _ in range(10):
if not is_running(pid):
break
time.sleep(0.5)
if is_running(pid):
print("⚠️ Force killing...")
os.kill(pid, signal.SIGKILL)
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
print("✅ SexChat stopped.")
except Exception as e:
print(f"❌ Error stopping: {e}")
def get_status():
pid = get_pid()
if is_running(pid):
print(f"🟢 SexChat is RUNNING (PID: {pid}).")
else:
print("🔴 SexChat is STOPPED.")
def run_debug():
print("🛠️ Starting SexChat in DEBUG mode (foreground)...")
cmd = [
"gunicorn",
"--worker-class", "eventlet",
"-w", "1",
"--bind", f"{os.environ.get('HOST', '0.0.0.0')}:{os.environ.get('PORT', '5000')}",
"--log-level", "debug",
"--access-logfile", "-",
"--error-logfile", "-",
"start:application"
]
try:
subprocess.run(cmd)
except KeyboardInterrupt:
print("\n👋 Debug session ended.")
# This is the Flask Application instance for Gunicorn
from app import create_app
application = create_app()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
command = sys.argv[1].lower()
if command == "start":
start_daemon()
elif command == "stop":
stop_daemon()
elif command == "restart":
stop_daemon()
time.sleep(1)
start_daemon()
elif command == "status":
get_status()
elif command == "debug":
run_debug()
else:
print(f"❌ Unknown command: {command}")
print(__doc__)
sys.exit(1)

580
static/chat.js Normal file
View File

@ -0,0 +1,580 @@
/**
* chat.js SexyChat Frontend Logic (Phase 2)
*
* Features:
* - Socket.io with JWT reconnect
* - AES-GCM Encryption (via crypto.js)
* - PM Persistence & History
* - Violet AI (Transit-encrypted)
* - Trial/Paywall management
*/
"use strict";
const socket = io({
autoConnect: false,
auth: { token: localStorage.getItem("sexychat_token") }
});
const state = {
username: null,
isAdmin: false,
isRegistered: false,
hasAiAccess: false,
aiMessagesUsed: 0,
currentRoom: "lobby",
pms: {}, // room -> { username, key, messages: [] }
nicklist: [],
ignoredUsers: new Set(),
cryptoKey: null, // derived from password
isSidebarOpen: window.innerWidth > 768,
authMode: "guest"
};
const AI_BOT_NAME = "Violet";
const AI_FREE_LIMIT = 3;
// ── Selectors ─────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const joinScreen = $("join-screen");
const chatScreen = $("chat-screen");
const joinForm = $("join-form");
const usernameInput = $("username-input");
const passwordInput = $("password-input");
const emailInput = $("email-input");
const modPassword = $("mod-password-input");
const joinBtn = $("join-btn");
const joinError = $("join-error");
const authTabs = document.querySelectorAll(".auth-tab");
const sidebar = $("nicklist-sidebar");
const nicklist = $("nicklist");
const tabBar = $("tab-bar");
const panels = $("panels");
const messageForm = $("message-form");
const messageInput = $("message-input");
const logoutBtn = $("logout-btn");
const pmModal = $("pm-modal");
const paywallModal = $("paywall-modal");
const contextMenu = $("context-menu");
const trialBadge = $("violet-trial-badge");
const violetTyping = $("violet-typing");
// ── Auth & Init ───────────────────────────────────────────────────────────
// Toggle Auth Modes
authTabs.forEach(tab => {
tab.addEventListener("click", () => {
authTabs.forEach(t => t.classList.remove("active"));
tab.classList.add("active");
state.authMode = tab.dataset.mode;
// UI visibility based on mode
const isAuth = state.authMode !== "guest";
const isReg = state.authMode === "register";
document.querySelectorAll(".auth-only").forEach(el => el.classList.toggle("hidden", !isAuth));
document.querySelectorAll(".register-only").forEach(el => el.classList.toggle("hidden", !isReg));
usernameInput.placeholder = isAuth ? "Username" : "Choose your nickname";
passwordInput.required = isAuth;
});
});
// Join the Room
joinForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
const email = emailInput.value.trim();
const modPw = modPassword.value.trim();
if (!username) return;
if (state.authMode !== "guest" && !password) return;
joinBtn.disabled = true;
joinBtn.innerText = "Connecting...";
// Derive Encryption Key if password provided
if (password) {
try {
state.cryptoKey = await SexyChato.deriveKey(password, username);
} catch (err) {
console.error("Key derivation failed", err);
}
}
socket.connect();
socket.emit("join", {
mode: state.authMode,
username,
password,
email,
mod_password: modPw
});
});
// Handle Token Restore on Load
window.addEventListener("DOMContentLoaded", () => {
const token = localStorage.getItem("sexychat_token");
if (token) {
// We have a token, notify the join screen but wait for user to click "Enter"
// to derive crypto key if they want to. Actually, for UX, if we have a token
// we can try a "restore" join which might skip password entry.
// But for encryption, we NEED that password to derive the key.
// Let's keep it simple: if you have a token, you still need to log in to
// re-derive your E2E key.
}
});
// ── Socket Events ──────────────────────────────────────────────────────────
socket.on("joined", (data) => {
state.username = data.username;
state.isAdmin = data.is_admin;
state.isRegistered = data.is_registered;
state.hasAiAccess = data.has_ai_access;
state.aiMessagesUsed = data.ai_messages_used;
if (data.token) localStorage.setItem("sexychat_token", data.token);
if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list);
$("my-username-badge").innerText = state.username;
joinScreen.classList.add("hidden");
chatScreen.classList.remove("hidden");
updateVioletBadge();
});
socket.on("error", (data) => {
joinError.innerText = data.msg;
joinBtn.disabled = false;
joinBtn.innerText = "Enter the Room";
if (data.msg.includes("Session expired")) {
localStorage.removeItem("sexychat_token");
}
});
socket.on("nicklist", (data) => {
state.nicklist = data.users;
renderNicklist();
$("user-count-badge").innerText = `${data.users.length} online`;
});
socket.on("system", (data) => {
addMessage("lobby", { system: true, text: data.msg, ts: data.ts });
});
socket.on("message", (data) => {
if (state.ignoredUsers.has(data.username)) return;
addMessage("lobby", {
sender: data.username,
text: data.text,
ts: data.ts,
isAdmin: data.is_admin,
isRegistered: data.is_registered,
sent: data.username === state.username
});
});
// ── Private Messaging ─────────────────────────────────────────────────────
socket.on("pm_invite", (data) => {
if (state.pms[data.room]) return; // Already accepted
$("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`;
pmModal.classList.remove("hidden");
$("pm-accept-btn").onclick = () => {
socket.emit("pm_accept", { room: data.room });
openPMTab(data.from, data.room);
pmModal.classList.add("hidden");
};
$("pm-decline-btn").onclick = () => pmModal.classList.add("hidden");
});
socket.on("pm_ready", (data) => {
openPMTab(data.with, data.room);
});
async function openPMTab(otherUser, room) {
if (state.pms[room]) {
switchTab(room);
return;
}
state.pms[room] = { username: otherUser, messages: [] };
// Create Tab
let title = `👤 ${otherUser}`;
if (otherUser.toLowerCase() === "violet") title = `🤖 Violet`;
const tab = document.createElement("button");
tab.className = "tab-btn";
tab.dataset.room = room;
tab.id = `tab-${room}`;
tab.innerHTML = `<span>${title}</span>`;
tab.onclick = () => switchTab(room);
tabBar.appendChild(tab);
// Create Panel
const panel = document.createElement("div");
panel.className = "panel";
panel.id = `panel-${room}`;
panel.dataset.room = room;
panel.innerHTML = `
<div id="messages-${room}" class="messages" role="log"></div>
${otherUser.toLowerCase() === 'violet' ? `<div id="typing-${room}" class="typing-indicator hidden">Violet is typing...</div>` : ''}
`;
panels.appendChild(panel);
switchTab(room);
// Load History if registered
if (state.isRegistered && state.cryptoKey) {
try {
const resp = await fetch(`/api/pm/history?with=${otherUser}`, {
headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` }
});
const data = await resp.json();
if (data.messages) {
for (const m of data.messages) {
const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce);
addMessage(room, {
sender: m.from_me ? state.username : otherUser,
text: plain,
ts: m.ts,
sent: m.from_me
});
}
}
} catch (err) {
console.error("Failed to load PM history", err);
}
}
}
socket.on("pm_message", async (data) => {
if (state.ignoredUsers.has(data.from)) return;
let text = data.text;
if (data.ciphertext && state.cryptoKey) {
try {
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
} catch (err) {
text = "[Encrypted Message - Click to login/derive key]";
}
}
addMessage(data.room, {
sender: data.from,
text: text,
ts: data.ts,
sent: data.from === state.username
});
if (state.currentRoom !== data.room) {
const tab = $(`tab-${data.room}`);
if (tab) tab.classList.add("unread");
}
});
// ── Violet AI Logic ───────────────────────────────────────────────────────
socket.on("violet_typing", (data) => {
const room = data.room || "lobby";
const indicator = $(`typing-${room}`);
if (indicator) {
indicator.classList.toggle("hidden", !data.busy);
}
});
socket.on("ai_response", async (data) => {
if (data.error === "ai_limit_reached") {
paywallModal.classList.remove("hidden");
return;
}
state.aiMessagesUsed = data.ai_messages_used;
state.hasAiAccess = data.has_ai_access;
updateVioletBadge();
let text = "[Decryption Error]";
if (data.ciphertext && state.cryptoKey) {
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
}
addMessage("ai-violet", {
sender: AI_BOT_NAME,
text: text,
ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
sent: false
});
});
socket.on("ai_unlock", (data) => {
state.hasAiAccess = true;
updateVioletBadge();
paywallModal.classList.add("hidden");
addMessage("ai-violet", { system: true, text: data.msg });
});
socket.on("ignore_status", (data) => {
if (data.ignored) state.ignoredUsers.add(data.target);
else state.ignoredUsers.delete(data.target);
renderNicklist();
});
function updateVioletBadge() {
if (state.hasAiAccess) {
trialBadge.classList.add("hidden");
} else {
const left = AI_FREE_LIMIT - state.aiMessagesUsed;
trialBadge.innerText = Math.max(0, left);
trialBadge.classList.toggle("hidden", left <= 0 && state.aiMessagesUsed < AI_FREE_LIMIT);
if (left <= 0) {
trialBadge.innerText = "!"; // Paywall indicator
}
}
}
// ── UI Actions ────────────────────────────────────────────────────────────
messageForm.addEventListener("submit", async (e) => {
e.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
if (state.currentRoom === "lobby") {
socket.emit("message", { text });
}
else if (state.currentRoom.startsWith("pm:")) {
const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet");
if (isVioletRoom) {
// AI Transit Encryption PM Flow
const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey);
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
socket.emit("pm_message", {
room: state.currentRoom,
ciphertext: encrypted.ciphertext,
nonce: encrypted.nonce,
transit_key: transitKeyB64
});
} else if (state.isRegistered && state.cryptoKey) {
// E2E PM Flow
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
socket.emit("pm_message", {
room: state.currentRoom,
ciphertext: encrypted.ciphertext,
nonce: encrypted.nonce
});
} else {
// Guest PM (Plaintext)
socket.emit("pm_message", { room: state.currentRoom, text });
}
}
messageInput.value = "";
messageInput.style.height = "auto";
});
// Auto-expand textarea
messageInput.addEventListener("input", () => {
messageInput.style.height = "auto";
messageInput.style.height = (messageInput.scrollHeight) + "px";
});
function switchTab(room) {
state.currentRoom = room;
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
const tab = $(`tab-${room}`);
if (tab) {
tab.classList.add("active");
tab.classList.remove("unread");
}
$(`panel-${room}`).classList.add("active");
// Scroll to bottom
const box = $(`messages-${room}`);
if (box) box.scrollTop = box.scrollHeight;
}
function addMessage(room, msg) {
const list = $(`messages-${room}`);
if (!list) return;
const div = document.createElement("div");
if (msg.system) {
div.className = "msg msg-system";
div.innerHTML = msg.text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
} else {
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
div.innerHTML = `
<div class="msg-meta">${msg.ts} ${msg.sender}</div>
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
`;
}
list.appendChild(div);
list.scrollTop = list.scrollHeight;
}
function renderNicklist() {
nicklist.innerHTML = "";
state.nicklist.forEach(u => {
const li = document.createElement("li");
const isIgnored = state.ignoredUsers.has(u.username);
const isUnverified = u.is_registered && !u.is_verified;
li.innerHTML = `
<span class="${isUnverified ? 'unverified' : ''}">
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
${isIgnored ? ' <small>(ignored)</small>' : ''}
${isUnverified ? ' <small>(unverified)</small>' : ''}
</span>
`;
li.oncontextmenu = (e) => showContextMenu(e, u);
li.onclick = () => {
if (u.username !== state.username) socket.emit("pm_open", { target: u.username });
};
nicklist.appendChild(li);
});
}
function showContextMenu(e, user) {
e.preventDefault();
if (user.username === state.username) return;
// Position menu
contextMenu.style.left = `${e.pageX}px`;
contextMenu.style.top = `${e.pageY}px`;
contextMenu.classList.remove("hidden");
// Configure items
const pmItem = contextMenu.querySelector('[data-action="pm"]');
const ignoreItem = contextMenu.querySelector('[data-action="ignore"]');
const unignoreItem = contextMenu.querySelector('[data-action="unignore"]');
const verifyItem = contextMenu.querySelector('[data-action="verify"]');
const modItems = contextMenu.querySelectorAll(".mod-item");
const isIgnored = state.ignoredUsers.has(user.username);
const isUnverified = user.is_registered && !user.is_verified;
ignoreItem.classList.toggle("hidden", !state.isRegistered || isIgnored);
unignoreItem.classList.toggle("hidden", !state.isRegistered || !isIgnored);
// Verify option only for admins if target is unverified
const showVerify = state.isAdmin && isUnverified;
if (verifyItem) verifyItem.classList.toggle("hidden", !showVerify);
modItems.forEach(el => {
if (el.dataset.action !== "verify") {
el.classList.toggle("hidden", !state.isAdmin);
}
});
// Cleanup previous listeners
const newMenu = contextMenu.cloneNode(true);
contextMenu.replaceWith(newMenu);
// Add new listeners
newMenu.querySelectorAll(".menu-item").forEach(item => {
item.onclick = () => {
const action = item.dataset.action;
executeMenuAction(action, user.username);
newMenu.classList.add("hidden");
};
});
}
function executeMenuAction(action, target) {
switch(action) {
case "pm":
socket.emit("pm_open", { target });
break;
case "ignore":
socket.emit("user_ignore", { target });
break;
case "unignore":
socket.emit("user_unignore", { target });
break;
case "kick":
socket.emit("mod_kick", { target });
break;
case "ban":
socket.emit("mod_ban", { target });
break;
case "kickban":
socket.emit("mod_kickban", { target });
break;
case "verify":
socket.emit("mod_verify", { target });
break;
}
}
// Global click to hide context menu
window.addEventListener("click", (e) => {
const menu = $("context-menu");
if (menu && !menu.contains(e.target)) {
menu.classList.add("hidden");
}
});
// ── Admin Tools ───────────────────────────────────────────────────────────
window.modKick = (u) => socket.emit("mod_kick", { target: u });
window.modBan = (u) => socket.emit("mod_ban", { target: u });
window.modMute = (u) => socket.emit("mod_mute", { target: u });
// ── Modals & Misc ──────────────────────────────────────────────────────────
$("sidebar-toggle").onclick = () => {
sidebar.classList.toggle("open");
};
$("tab-ai-violet").onclick = () => switchTab("ai-violet");
$("tab-lobby").onclick = () => switchTab("lobby");
$("close-paywall").onclick = () => paywallModal.classList.add("hidden");
$("unlock-btn").onclick = async () => {
// Generate dummy secret for the stub endpoint
// In production, this would redirect to a real payment gateway (Stripe)
const secret = "change-me-payment-webhook-secret";
const token = localStorage.getItem("sexychat_token");
try {
const resp = await fetch("/api/payment/success", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ secret })
});
const res = await resp.json();
if (res.status === "ok") {
// socket event should handle UI unlock, but we can optimistically update
state.hasAiAccess = true;
updateVioletBadge();
paywallModal.classList.add("hidden");
}
} catch (err) {
alert("Payment simulation failed.");
}
};
logoutBtn.onclick = () => {
localStorage.removeItem("sexychat_token");
location.reload();
};
function escapeHTML(str) {
const p = document.createElement("p");
p.textContent = str;
return p.innerHTML;
}

131
static/crypto.js Normal file
View File

@ -0,0 +1,131 @@
/**
* crypto.js SexyChat SubtleCrypto wrapper
*
* Provides PBKDF2-derived AES-GCM-256 keys for end-to-end encrypted PMs.
*
* Key facts:
* - Keys are derived deterministically from (password, username) so the
* same credentials always produce the same key across sessions.
* - Keys are marked `extractable: true` so they can be exported as raw bytes
* for the AI transit-encryption flow (sent over HTTPS, never stored).
* - The server only ever receives ciphertext + nonce. It never sees a key
* for user-to-user conversations.
*/
"use strict";
const SexyChato = (() => {
const PBKDF2_ITERATIONS = 100_000;
const KEY_LENGTH_BITS = 256;
const SALT_PREFIX = "sexychat:v1:";
// ── Base64 helpers ────────────────────────────────────────────────────────
function bufToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function base64ToBuf(b64) {
const binary = atob(b64);
const buf = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
return buf.buffer;
}
// ── Key derivation ─────────────────────────────────────────────────────────
/**
* Derive an AES-GCM key from a password and username.
* The username acts as a deterministic, per-account salt.
*
* @param {string} password
* @param {string} username
* @returns {Promise<CryptoKey>}
*/
async function deriveKey(password, username) {
const enc = new TextEncoder();
const salt = enc.encode(SALT_PREFIX + username.toLowerCase());
const baseKey = await crypto.subtle.importKey(
"raw",
enc.encode(password),
{ name: "PBKDF2" },
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: KEY_LENGTH_BITS },
true, // extractable needed for transit encryption to AI endpoint
["encrypt", "decrypt"],
);
}
// ── Encrypt / decrypt ─────────────────────────────────────────────────────
/**
* Encrypt a plaintext string with an AES-GCM key.
*
* @param {CryptoKey} key
* @param {string} plaintext
* @returns {Promise<{ ciphertext: string, nonce: string }>} both base64
*/
async function encrypt(key, plaintext) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const cipherBuf = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
key,
encoded,
);
return {
ciphertext: bufToBase64(cipherBuf),
nonce: bufToBase64(nonce),
};
}
/**
* Decrypt an AES-GCM ciphertext.
*
* @param {CryptoKey} key
* @param {string} ciphertext base64
* @param {string} nonce base64
* @returns {Promise<string>} plaintext
*/
async function decrypt(key, ciphertext, nonce) {
const cipherBuf = base64ToBuf(ciphertext);
const nonceBuf = base64ToBuf(nonce);
const plainBuf = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonceBuf },
key,
cipherBuf,
);
return new TextDecoder().decode(plainBuf);
}
/**
* Export a CryptoKey as a base64-encoded raw byte string.
* Used only for the AI transit flow (sent in POST body over HTTPS,
* never stored by the server).
*
* @param {CryptoKey} key
* @returns {Promise<string>} base64
*/
async function exportKeyBase64(key) {
const raw = await crypto.subtle.exportKey("raw", key);
return bufToBase64(raw);
}
// ── Public API ─────────────────────────────────────────────────────────────
return { deriveKey, encrypt, decrypt, exportKeyBase64 };
})();

7
static/socket.io.min.js vendored Normal file

File diff suppressed because one or more lines are too long

584
static/style.css Normal file
View File

@ -0,0 +1,584 @@
/*
SexyChat Phase 2 Style Midnight Purple & Neon Magenta
Deep dark backgrounds, vibrant accents, glassmorphism.
*/
:root {
--bg-deep: #0a0015;
--bg-card: rgba(26, 0, 48, 0.7);
--accent-magenta: #ff00ff;
--accent-purple: #8a2be2;
--text-main: #f0f0f0;
--text-dim: #b0b0b0;
--glass-border: rgba(255, 255, 255, 0.1);
--error-red: #ff3366;
--success-green: #00ffaa;
--ai-teal: #00f2ff;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-deep);
background-image:
radial-gradient(circle at 20% 30%, rgba(138, 43, 226, 0.15) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(255, 0, 255, 0.1) 0%, transparent 40%);
color: var(--text-main);
height: 100vh;
overflow: hidden;
line-height: 1.5;
}
h1, h2, h3, .logo-text, .paywall-header h2 {
font-family: 'Outfit', sans-serif;
}
.hidden { display: none !important; }
/* ── Glassmorphism ───────────────────────────────────────────────────────── */
.glass {
background: var(--bg-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.8);
}
/* ── Join Screen ─────────────────────────────────────────────────────────── */
.join-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.join-card {
width: 100%;
max-width: 420px;
padding: 2.5rem;
border-radius: 24px;
text-align: center;
}
.join-logo .logo-icon {
font-size: 3rem;
display: block;
margin-bottom: 0.5rem;
}
.logo-text {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -1px;
}
.logo-accent {
color: var(--accent-magenta);
text-shadow: 0 0 10px rgba(255, 0, 255, 0.5);
}
.logo-sub {
color: var(--text-dim);
font-size: 0.9rem;
margin-bottom: 2rem;
}
/* Auth Tabs */
.auth-tabs {
display: flex;
background: rgba(0, 0, 0, 0.3);
padding: 4px;
border-radius: 12px;
margin-bottom: 1.5rem;
}
.auth-tab {
flex: 1;
padding: 8px;
border: none;
background: transparent;
color: var(--text-dim);
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
}
.auth-tab.active {
background: var(--accent-purple);
color: white;
box-shadow: 0 4px 12px rgba(138, 43, 226, 0.3);
}
/* Form */
.field-group {
margin-bottom: 1rem;
}
input {
width: 100%;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--glass-border);
padding: 12px 16px;
border-radius: 12px;
color: white;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: var(--accent-magenta);
}
.crypto-note {
font-size: 0.75rem;
color: var(--accent-magenta);
background: rgba(255, 0, 255, 0.05);
padding: 10px;
border-radius: 8px;
margin: 1rem 0;
line-height: 1.3;
}
.btn-primary {
width: 100%;
background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta));
color: white;
border: none;
padding: 14px;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 0, 255, 0.3);
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 1rem;
}
.btn-primary:active { transform: scale(0.98); }
.btn-primary:hover { box-shadow: 0 6px 20px rgba(255, 0, 255, 0.4); }
.mod-login {
text-align: left;
margin: 1rem 0;
font-size: 0.85rem;
color: var(--text-dim);
}
.mod-login summary { cursor: pointer; outline: none; }
.error-msg {
color: var(--error-red);
font-size: 0.85rem;
margin-top: 1rem;
min-height: 1.2em;
}
/* ── Chat Screen ─────────────────────────────────────────────────────────── */
.chat-screen {
display: flex;
flex-direction: column;
height: 100vh;
}
.glass-header {
height: 64px;
background: rgba(10, 0, 20, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
z-index: 100;
}
.header-title {
display: flex;
align-items: center;
gap: 10px;
}
#room-name-header { font-weight: 700; font-family: 'Outfit'; }
.pulse-dot {
width: 8px;
height: 8px;
background: var(--success-green);
border-radius: 50%;
box-shadow: 0 0 10px var(--success-green);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; } 50% { opacity: 0.3; } 100% { opacity: 1; }
}
.user-badge {
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 20px;
font-size: 0.75rem;
color: var(--text-dim);
}
.header-right { display: flex; align-items: center; gap: 12px; }
.my-badge { font-weight: 600; color: var(--accent-magenta); font-size: 0.9rem; }
.btn-logout {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--glass-border);
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
}
/* ── Layout ──────────────────────────────────────────────────────────────── */
.chat-layout {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
/* Nicklist */
.nicklist-sidebar {
width: 260px;
border-right: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
}
.nicklist-header {
padding: 1.2rem;
font-weight: 700;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
color: var(--text-dim);
border-bottom: 1px solid var(--glass-border);
}
.nicklist {
list-style: none;
overflow-y: auto;
flex: 1;
}
.nicklist li {
padding: 12px 20px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background 0.2s;
font-size: 0.95rem;
}
.nicklist li:hover { background: rgba(255, 255, 255, 0.05); }
.mod-star { color: #ffcc00; }
.reg-mark { color: var(--accent-teal); font-size: 0.7rem; margin-left: 2px; }
.unverified { color: var(--text-dim); opacity: 0.5; font-style: italic; }
.mod-star { color: var(--accent-magenta); margin-right: 4px; }
/* ── Main Chat Container ─────────────────────────────────────────────────── */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(0,0,0,0.2);
}
/* Tabs */
.tab-bar {
display: flex;
gap: 4px;
padding: 8px 12px 0;
background: rgba(0,0,0,0.3);
border-bottom: 1px solid var(--glass-border);
overflow-x: auto;
}
.tab-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--glass-border);
border-bottom: none;
padding: 8px 16px;
border-radius: 8px 8px 0 0;
color: var(--text-dim);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
}
.tab-btn.active {
background: var(--bg-card);
color: white;
border-top: 2px solid var(--accent-magenta);
}
.ai-tab { color: var(--ai-teal) !important; }
.tab-btn .badge {
background: var(--accent-magenta);
font-size: 0.65rem;
padding: 1px 5px;
border-radius: 10px;
color: white;
}
/* Panels */
.panels { flex: 1; position: relative; overflow: hidden; }
.panel {
position: absolute;
inset: 0;
display: none;
flex-direction: column;
}
.panel.active { display: flex; }
.messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Chat Bubbles */
.msg {
max-width: 80%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-bubble {
padding: 10px 14px;
border-radius: 18px;
font-size: 0.95rem;
position: relative;
word-wrap: break-word;
}
.msg-meta {
font-size: 0.7rem;
color: var(--text-dim);
margin-bottom: 4px;
padding: 0 4px;
}
.msg-received { align-self: flex-start; }
.msg-received .msg-bubble {
background: rgba(255, 255, 255, 0.08);
border-bottom-left-radius: 4px;
border: 1px solid rgba(255,255,255,0.05);
}
.msg-sent { align-self: flex-end; }
.msg-sent .msg-bubble {
background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta));
border-bottom-right-radius: 4px;
box-shadow: 0 4px 12px rgba(255, 0, 255, 0.2);
}
.msg-system {
align-self: center;
background: rgba(0,0,0,0.3);
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
color: var(--text-dim);
border: 1px solid var(--glass-border);
}
.msg-system strong { color: var(--accent-magenta); }
/* ── AI Area ─────────────────────────────────────────────────────────────── */
.chat-ai { background: radial-gradient(circle at top, rgba(0, 242, 255, 0.05), transparent 70%); }
.ai-header {
padding: 1rem;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--glass-border);
background: rgba(0,0,0,0.2);
}
.ai-avatar {
width: 40px;
height: 40px;
background: var(--ai-teal);
color: black;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.2rem;
box-shadow: 0 0 15px var(--ai-teal);
}
.ai-meta { line-height: 1.2; }
.status-indicator { display: block; font-size: 0.7rem; color: var(--success-green); }
.typing-indicator {
padding: 8px 1.5rem;
font-size: 0.8rem;
color: var(--ai-teal);
font-style: italic;
}
/* ── Input Area ──────────────────────────────────────────────────────────── */
.message-form {
padding: 1rem;
display: flex;
align-items: flex-end;
gap: 10px;
background: rgba(10, 0, 20, 0.8);
border-top: 1px solid var(--glass-border);
}
textarea {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--glass-border);
padding: 12px;
border-radius: 12px;
color: white;
font-family: inherit;
font-size: 0.95rem;
outline: none;
resize: none;
max-height: 120px;
}
.btn-send {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--accent-magenta);
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 0 15px rgba(255, 0, 255, 0.4);
}
/* ── Modals ──────────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
backdrop-filter: blur(8px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-card {
padding: 2.5rem;
border-radius: 24px;
max-width: 400px;
text-align: center;
}
.paywall-card {
border: 2px solid var(--ai-teal);
box-shadow: 0 0 40px rgba(0, 242, 255, 0.2);
}
.ai-avatar.large { width: 80px; height: 80px; font-size: 2.5rem; margin: 0 auto 1.5rem; }
.paywall-price {
font-size: 3rem;
font-weight: 700;
color: var(--ai-teal);
margin: 1.5rem 0;
font-family: 'Outfit';
}
.benefits { text-align: left; margin-bottom: 2rem; color: var(--text-dim); font-size: 0.9rem; }
.benefits p { margin-bottom: 8px; }
.btn-text { background: transparent; border: none; color: var(--text-dim); cursor: pointer; margin-top: 1rem; }
/* ── Context Menu ────────────────────────────────────────────────────────── */
.context-menu {
position: fixed;
z-index: 3000;
min-width: 160px;
padding: 6px 0;
border-radius: 12px;
background: rgba(15, 0, 30, 0.95);
border: 1px solid var(--glass-border);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px rgba(138, 43, 226, 0.2);
display: flex;
flex-direction: column;
}
.menu-item {
padding: 10px 16px;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s;
}
.menu-item:hover {
background: var(--accent-purple);
color: white;
}
.menu-item.red { color: var(--error-red); }
.menu-item.red:hover { background: var(--error-red); }
.menu-item.bold { font-weight: 800; }
.menu-divider {
height: 1px;
background: var(--glass-border);
margin: 4px 0;
}
/* ── Mobile Overrides ─────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.nicklist-sidebar {
position: absolute;
top: 0; bottom: 0; left: 0;
z-index: 50;
transform: translateX(-100%);
background: var(--bg-deep);
}
.nicklist-sidebar.open { transform: translateX(0); }
.join-card { padding: 1.5rem; }
.msg-bubble { font-size: 0.9rem; }
}