kin: BATON-002 [Research] UX Designer

This commit is contained in:
Gros Frumos 2026-03-20 20:44:00 +02:00
commit 057e500d5f
29 changed files with 3530 additions and 0 deletions

0
backend/__init__.py Normal file
View file

21
backend/config.py Normal file
View file

@ -0,0 +1,21 @@
from __future__ import annotations
import os
from dotenv import load_dotenv
load_dotenv()
def _require(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"Required environment variable {name!r} is not set")
return value
BOT_TOKEN: str = _require("BOT_TOKEN")
CHAT_ID: str = _require("CHAT_ID")
DB_PATH: str = os.getenv("DB_PATH", "baton.db")
WEBHOOK_SECRET: str = _require("WEBHOOK_SECRET")
WEBHOOK_URL: str = _require("WEBHOOK_URL")
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")

123
backend/db.py Normal file
View file

@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Optional
import aiosqlite
from backend import config
async def _get_conn() -> aiosqlite.Connection:
conn = await aiosqlite.connect(config.DB_PATH)
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA busy_timeout=5000")
await conn.execute("PRAGMA synchronous=NORMAL")
conn.row_factory = aiosqlite.Row
return conn
async def init_db() -> None:
async with await _get_conn() as conn:
await conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_uuid TEXT NOT NULL REFERENCES users(uuid),
timestamp INTEGER NOT NULL,
lat REAL DEFAULT NULL,
lon REAL DEFAULT NULL,
accuracy REAL DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
telegram_batch_id INTEGER DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS telegram_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_text TEXT DEFAULT NULL,
sent_at TEXT DEFAULT NULL,
signals_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
ON users(uuid);
CREATE INDEX IF NOT EXISTS idx_signals_user_uuid
ON signals(user_uuid);
CREATE INDEX IF NOT EXISTS idx_signals_created_at
ON signals(created_at);
CREATE INDEX IF NOT EXISTS idx_batches_status
ON telegram_batches(status);
""")
await conn.commit()
async def register_user(uuid: str, name: str) -> dict:
async with await _get_conn() as conn:
await conn.execute(
"INSERT OR IGNORE INTO users (uuid, name) VALUES (?, ?)",
(uuid, name),
)
await conn.commit()
async with conn.execute(
"SELECT id, uuid FROM users WHERE uuid = ?", (uuid,)
) as cur:
row = await cur.fetchone()
return {"user_id": row["id"], "uuid": row["uuid"]}
async def save_signal(
user_uuid: str,
timestamp: int,
lat: Optional[float],
lon: Optional[float],
accuracy: Optional[float],
) -> int:
async with await _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO signals (user_uuid, timestamp, lat, lon, accuracy)
VALUES (?, ?, ?, ?, ?)
""",
(user_uuid, timestamp, lat, lon, accuracy),
) as cur:
signal_id = cur.lastrowid
await conn.commit()
return signal_id
async def get_user_name(uuid: str) -> Optional[str]:
async with await _get_conn() as conn:
async with conn.execute(
"SELECT name FROM users WHERE uuid = ?", (uuid,)
) as cur:
row = await cur.fetchone()
return row["name"] if row else None
async def save_telegram_batch(
message_text: str,
signals_count: int,
signal_ids: list[int],
) -> int:
async with await _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO telegram_batches (message_text, sent_at, signals_count, status)
VALUES (?, datetime('now'), ?, 'sent')
""",
(message_text, signals_count),
) as cur:
batch_id = cur.lastrowid
if signal_ids:
placeholders = ",".join("?" * len(signal_ids))
await conn.execute(
f"UPDATE signals SET telegram_batch_id = ? WHERE id IN ({placeholders})",
[batch_id, *signal_ids],
)
await conn.commit()
return batch_id

114
backend/main.py Normal file
View file

@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from backend import config, db, telegram
from backend.middleware import verify_webhook_secret
from backend.models import (
RegisterRequest,
RegisterResponse,
SignalRequest,
SignalResponse,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
aggregator = telegram.SignalAggregator(interval=10)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await db.init_db()
logger.info("Database initialized")
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
logger.info("Webhook registered")
task = asyncio.create_task(aggregator.run())
logger.info("Aggregator started")
yield
# Shutdown
aggregator.stop()
await aggregator.flush()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("Aggregator stopped, final flush done")
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[config.FRONTEND_ORIGIN],
allow_methods=["POST"],
allow_headers=["Content-Type"],
)
@app.post("/api/register", response_model=RegisterResponse)
async def register(body: RegisterRequest) -> RegisterResponse:
result = await db.register_user(uuid=body.uuid, name=body.name)
return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"])
@app.post("/api/signal", response_model=SignalResponse)
async def signal(body: SignalRequest) -> SignalResponse:
geo = body.geo
lat = geo.lat if geo else None
lon = geo.lon if geo else None
accuracy = geo.accuracy if geo else None
signal_id = await db.save_signal(
user_uuid=body.user_id,
timestamp=body.timestamp,
lat=lat,
lon=lon,
accuracy=accuracy,
)
user_name = await db.get_user_name(body.user_id)
await aggregator.add_signal(
user_uuid=body.user_id,
user_name=user_name,
timestamp=body.timestamp,
geo={"lat": lat, "lon": lon, "accuracy": accuracy} if geo else None,
signal_id=signal_id,
)
return SignalResponse(status="ok", signal_id=signal_id)
@app.post("/api/webhook/telegram")
async def webhook_telegram(
request: Request,
_: None = Depends(verify_webhook_secret),
) -> dict[str, Any]:
update = await request.json()
message = update.get("message", {})
text = message.get("text", "")
if text.startswith("/start"):
tg_user = message.get("from", {})
tg_user_id = str(tg_user.get("id", ""))
first_name = tg_user.get("first_name", "")
last_name = tg_user.get("last_name", "")
name = (first_name + " " + last_name).strip() or tg_user_id
if tg_user_id:
await db.register_user(uuid=tg_user_id, name=name)
logger.info("Telegram /start: registered user %s", tg_user_id)
return {"ok": True}

12
backend/middleware.py Normal file
View file

@ -0,0 +1,12 @@
from __future__ import annotations
from fastapi import Header, HTTPException
from backend import config
async def verify_webhook_secret(
x_telegram_bot_api_secret_token: str = Header(default=""),
) -> None:
if x_telegram_bot_api_secret_token != config.WEBHOOK_SECRET:
raise HTTPException(status_code=403, detail="Forbidden")

31
backend/models.py Normal file
View file

@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class RegisterRequest(BaseModel):
uuid: str = Field(..., min_length=1)
name: str = Field(..., min_length=1, max_length=100)
class RegisterResponse(BaseModel):
user_id: int
uuid: str
class GeoData(BaseModel):
lat: float = Field(..., ge=-90.0, le=90.0)
lon: float = Field(..., ge=-180.0, le=180.0)
accuracy: float = Field(..., gt=0)
class SignalRequest(BaseModel):
user_id: str = Field(..., min_length=1)
timestamp: int = Field(..., gt=0)
geo: Optional[GeoData] = None
class SignalResponse(BaseModel):
status: str
signal_id: int

121
backend/telegram.py Normal file
View file

@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
from backend import config, db
logger = logging.getLogger(__name__)
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
async def send_message(text: str) -> None:
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
async with httpx.AsyncClient(timeout=10) as client:
while True:
resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text})
if resp.status_code == 429:
retry_after = resp.json().get("parameters", {}).get("retry_after", 30)
logger.warning("Telegram 429, sleeping %s sec", retry_after)
await asyncio.sleep(retry_after)
continue
if resp.status_code >= 500:
logger.error("Telegram 5xx: %s", resp.text)
await asyncio.sleep(30)
resp2 = await client.post(
url, json={"chat_id": config.CHAT_ID, "text": text}
)
if resp2.status_code != 200:
logger.error("Telegram retry failed: %s", resp2.text)
elif resp.status_code != 200:
logger.error("Telegram error %s: %s", resp.status_code, resp.text)
break
async def set_webhook(url: str, secret: str) -> None:
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
api_url, json={"url": url, "secret_token": secret}
)
if resp.status_code != 200 or not resp.json().get("result"):
raise RuntimeError(f"setWebhook failed: {resp.text}")
logger.info("Webhook registered: %s", url)
class SignalAggregator:
def __init__(self, interval: int = 10) -> None:
self._interval = interval
self._buffer: list[dict] = []
self._lock = asyncio.Lock()
self._stopped = False
async def add_signal(
self,
user_uuid: str,
user_name: Optional[str],
timestamp: int,
geo: Optional[dict],
signal_id: int,
) -> None:
async with self._lock:
self._buffer.append(
{
"user_uuid": user_uuid,
"user_name": user_name,
"timestamp": timestamp,
"geo": geo,
"signal_id": signal_id,
}
)
async def flush(self) -> None:
async with self._lock:
if not self._buffer:
return
items = self._buffer[:]
self._buffer.clear()
signal_ids = [item["signal_id"] for item in items]
timestamps = [item["timestamp"] for item in items]
ts_start = datetime.fromtimestamp(min(timestamps) / 1000, tz=timezone.utc)
ts_end = datetime.fromtimestamp(max(timestamps) / 1000, tz=timezone.utc)
t_fmt = "%H:%M:%S"
names = []
for item in items:
name = item["user_name"]
label = name if name else item["user_uuid"][:8]
names.append(label)
geo_count = sum(1 for item in items if item["geo"])
n = len(items)
text = (
f"\U0001f6a8 Получено {n} сигнал{'ов' if n != 1 else ''} "
f"[{ts_start.strftime(t_fmt)}{ts_end.strftime(t_fmt)}]\n"
f"Пользователи: {', '.join(names)}\n"
f"\U0001f4cd С геолокацией: {geo_count} из {n}"
)
try:
await send_message(text)
await db.save_telegram_batch(text, n, signal_ids)
# rate-limit: 1 msg/sec max (#1014)
await asyncio.sleep(1)
except Exception:
logger.exception("Failed to flush aggregator batch")
async def run(self) -> None:
while not self._stopped:
await asyncio.sleep(self._interval)
if self._buffer:
await self.flush()
def stop(self) -> None:
self._stopped = True