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