kin: BATON-002 [Research] UX Designer
This commit is contained in:
commit
057e500d5f
29 changed files with 3530 additions and 0 deletions
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
21
backend/config.py
Normal file
21
backend/config.py
Normal 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
123
backend/db.py
Normal 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
114
backend/main.py
Normal 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
12
backend/middleware.py
Normal 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
31
backend/models.py
Normal 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
121
backend/telegram.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue