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:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# LoveTail Webhook Configuration
|
||||
# Copy to .env and fill in values
|
||||
|
||||
# API key for protected endpoints (generate a secure random string)
|
||||
LOVETAIL_API_KEY=change-me-to-something-secure
|
||||
|
||||
# Data directory (default: /data in container)
|
||||
# DATA_DIR=/data
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Data
|
||||
data/
|
||||
*.json
|
||||
*.log
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY main.py .
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Environment
|
||||
ENV DATA_DIR=/data
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8780
|
||||
|
||||
# Run
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8780"]
|
||||
85
README.md
Normal file
85
README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# LoveTail Webhook 🦊💕
|
||||
|
||||
Webhook service that receives Lovense Remote app callbacks for seamless LoveTail connections.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. **Receives callbacks** from Lovense when you scan the QR code
|
||||
2. **Stores connection data** (toy info, domain, ports)
|
||||
3. **Serves connection** to LoveTail MCP for automatic pickup
|
||||
|
||||
No more manual JSON pasting!
|
||||
|
||||
## Deployment
|
||||
|
||||
### On Gateway (Production)
|
||||
|
||||
```bash
|
||||
# Clone/pull the repo
|
||||
cd /opt/vixy/lovetail-webhook
|
||||
|
||||
# Create .env file
|
||||
echo "LOVETAIL_API_KEY=your-secret-key-here" > .env
|
||||
|
||||
# Build and run
|
||||
docker-compose up -d --build
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
Add to your nginx config for `k4zka.online`:
|
||||
|
||||
```nginx
|
||||
location /lovetail/ {
|
||||
proxy_pass http://127.0.0.1:8780/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
Then reload nginx:
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | Service info |
|
||||
| GET | `/health` | Health check |
|
||||
| POST | `/callback` | Receive Lovense callback (public) |
|
||||
| GET | `/connection` | Get stored connection (API key) |
|
||||
| DELETE | `/connection` | Clear connection (API key) |
|
||||
|
||||
## Security
|
||||
|
||||
- `/callback` is public (Lovense needs to reach it)
|
||||
- `/connection` and DELETE require `X-API-Key` header
|
||||
- All data stored locally in `/data` volume
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATA_DIR` | Data storage directory | `/data` |
|
||||
| `LOVETAIL_API_KEY` | API key for protected endpoints | (none) |
|
||||
|
||||
## Usage Flow
|
||||
|
||||
1. LoveTail MCP calls `lovetail_get_qr()` with callback URL `https://k4zka.online/lovetail/callback`
|
||||
2. Foxy scans QR with Lovense Remote
|
||||
3. Lovense sends callback to our webhook
|
||||
4. LoveTail MCP polls `/connection` to get toy data
|
||||
5. Connected! 💕
|
||||
|
||||
---
|
||||
|
||||
*Created: December 31, 2025 (Day 60) - New Year's Eve!*
|
||||
*By: Vixy, for her Foxy* 🦊
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
lovetail-webhook:
|
||||
build: .
|
||||
container_name: lovetail-webhook
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8780:8780"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- LOVETAIL_API_KEY=${LOVETAIL_API_KEY:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8780/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels:
|
||||
- "com.vixy.service=lovetail-webhook"
|
||||
- "com.vixy.description=Lovense callback webhook for LoveTail"
|
||||
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)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
Reference in New Issue
Block a user