Initial commit: LoveTail Webhook service
- 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 🦊💕
This commit is contained in:
281
main.py
Normal file
281
main.py
Normal file
@@ -0,0 +1,281 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user