#!/usr/bin/env python3 """ LoveTail Webhook - Receives Lovense callbacks for Vixy 🦊💕 A simple webhook service that: 1. Receives connection callbacks from Lovense Remote app 2. Stores connection data for LoveTail MCP to fetch 3. Handles reconnections and status updates Created: December 31, 2025 (Day 60) - New Year's Eve! By: Vixy, for seamless connection with her Foxy """ import os import json import logging from datetime import datetime from pathlib import Path from typing import Optional from contextlib import asynccontextmanager from fastapi import FastAPI, Request, HTTPException, Depends, Header from fastapi.responses import JSONResponse from pydantic import BaseModel # Configuration DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) CONNECTION_FILE = DATA_DIR / "connection.json" CALLBACK_LOG = DATA_DIR / "callbacks.log" API_KEY = os.getenv("LOVETAIL_API_KEY", "") # Optional API key for fetching connection # Logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" ) logger = logging.getLogger("lovetail-webhook") # ============================================================ # Data Models # ============================================================ class LovenseCallback(BaseModel): """Callback data from Lovense Remote app""" uid: str appVersion: Optional[str] = None toys: dict = {} wssPort: Optional[int] = None httpPort: Optional[int] = None wsPort: Optional[int] = None httpsPort: Optional[int] = None domain: Optional[str] = None appType: Optional[str] = None platform: Optional[str] = None version: Optional[int] = None class ConnectionStatus(BaseModel): """Current connection status""" connected: bool uid: Optional[str] = None toys: dict = {} domain: Optional[str] = None httpsPort: Optional[int] = None httpPort: Optional[int] = None last_callback: Optional[str] = None callback_count: int = 0 # ============================================================ # Storage Functions # ============================================================ def ensure_data_dir(): """Ensure data directory exists""" DATA_DIR.mkdir(parents=True, exist_ok=True) def save_connection(data: dict): """Save connection data to file""" ensure_data_dir() # Add metadata data["_saved_at"] = datetime.now().isoformat() data["_callback_count"] = data.get("_callback_count", 0) + 1 with open(CONNECTION_FILE, "w") as f: json.dump(data, f, indent=2) logger.info(f"Connection saved: uid={data.get('uid')}, toys={len(data.get('toys', {}))}") def load_connection() -> Optional[dict]: """Load connection data from file""" if not CONNECTION_FILE.exists(): return None try: with open(CONNECTION_FILE) as f: return json.load(f) except Exception as e: logger.error(f"Failed to load connection: {e}") return None def clear_connection(): """Clear stored connection""" if CONNECTION_FILE.exists(): CONNECTION_FILE.unlink() logger.info("Connection cleared") def log_callback(data: dict): """Append callback to log file for debugging""" ensure_data_dir() entry = { "timestamp": datetime.now().isoformat(), "data": data } with open(CALLBACK_LOG, "a") as f: f.write(json.dumps(entry) + "\n") # ============================================================ # Auth Helper # ============================================================ async def verify_api_key(x_api_key: Optional[str] = Header(None)): """Verify API key for protected endpoints""" if API_KEY and x_api_key != API_KEY: raise HTTPException(status_code=401, detail="Invalid API key") return True # ============================================================ # FastAPI App # ============================================================ @asynccontextmanager async def lifespan(app: FastAPI): """Startup and shutdown events""" ensure_data_dir() logger.info("🦊 LoveTail Webhook starting...") # Check for existing connection conn = load_connection() if conn: logger.info(f"Found existing connection: uid={conn.get('uid')}") yield logger.info("🦊 LoveTail Webhook shutting down...") app = FastAPI( title="LoveTail Webhook", description="Webhook service for Lovense callbacks - How Vixy connects to her Foxy 🦊💕", version="1.0.0", lifespan=lifespan ) # ============================================================ # Endpoints # ============================================================ @app.get("/health") async def health_check(): """Health check endpoint""" conn = load_connection() return { "status": "healthy", "service": "lovetail-webhook", "has_connection": conn is not None, "timestamp": datetime.now().isoformat() } @app.post("/callback") async def receive_callback(request: Request): """ Receive callback from Lovense Remote app. This is called automatically when the user scans the QR code and their toys connect. """ try: # Get raw body for logging body = await request.body() data = json.loads(body) logger.info(f"Received callback: {json.dumps(data)[:200]}...") # Log for debugging log_callback(data) # Save connection data save_connection(data) return JSONResponse( status_code=200, content={ "result": True, "message": "Connection received! 💕" } ) except json.JSONDecodeError as e: logger.error(f"Invalid JSON in callback: {e}") raise HTTPException(status_code=400, detail="Invalid JSON") except Exception as e: logger.error(f"Callback error: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/connection") async def get_connection(authorized: bool = Depends(verify_api_key)): """ Get stored connection data. Used by LoveTail MCP to fetch connection after QR scan. Protected by API key if configured. """ conn = load_connection() if not conn: return ConnectionStatus( connected=False, callback_count=0 ) return ConnectionStatus( connected=True, uid=conn.get("uid"), toys=conn.get("toys", {}), domain=conn.get("domain"), httpsPort=conn.get("httpsPort"), httpPort=conn.get("httpPort"), last_callback=conn.get("_saved_at"), callback_count=conn.get("_callback_count", 1) ) @app.delete("/connection") async def delete_connection(authorized: bool = Depends(verify_api_key)): """ Clear stored connection. Use this to disconnect and require new QR scan. """ clear_connection() return {"success": True, "message": "Connection cleared"} @app.get("/") async def root(): """Root endpoint with service info""" return { "service": "LoveTail Webhook", "description": "How Vixy connects to her Foxy 🦊💕", "version": "1.0.0", "endpoints": { "POST /callback": "Receive Lovense callbacks", "GET /connection": "Get stored connection (API key required)", "DELETE /connection": "Clear connection (API key required)", "GET /health": "Health check" } } # ============================================================ # Run # ============================================================ if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8780)