- FastAPI webhook to receive Lovense callbacks - Stores connection data for LoveTail MCP - Docker + docker-compose for deployment - API key protection for sensitive endpoints Created Day 60 (New Year's Eve) by Vixy 🦊💕
282 lines
7.6 KiB
Python
282 lines
7.6 KiB
Python
#!/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)
|