feat: implement Telegram account binding and update project structure in documentation

This commit is contained in:
2569718930@qq.com
2026-05-18 17:10:44 +08:00
parent 1b2731ed12
commit 78b23ef361
8 changed files with 247 additions and 99 deletions
+22 -14
View File
@@ -38,8 +38,8 @@ Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
Payment Layer (Intent + Event + Confirm Loop) Payment Layer (Intent + Event + Confirm Loop)
``` ```
- **Backend**: FastAPI on port 8000 (`web/app.py``web/core.py` + `web/routes.py` + `web/analysis_service.py`) - **Backend**: FastAPI on port 8000 (`web/app.py``web/app_factory.py` `web/routers/` (8 route modules) + `web/services/` (14 service modules) + `web/core.py`)
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 on port 3000 (dev) - **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 + shadcn/ui (new-york style) on port 3000 (dev)
- **Bot**: Telegram bot via `bot_listener.py``src/bot/` - **Bot**: Telegram bot via `bot_listener.py``src/bot/`
- **Shared analysis core** in `src/` is used by both web API and bot - **Shared analysis core** in `src/` is used by both web API and bot
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`) - **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
@@ -54,9 +54,12 @@ Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
```bash ```bash
cd frontend cd frontend
npm ci npm ci
npm run dev # Next.js dev server npm run dev # Next.js dev server (runs sync-next-server-chunks.mjs first)
npm run build # Production build npm run build # Production build (runs sync-next-server-chunks.mjs after)
npm run lint # ESLint via next lint npm run start # Production server
npm run lint # ESLint via next lint
npm run typecheck # tsc --noEmit
npm run test:business # Business state tests (also runs in CI)
``` ```
### Backend (dev on port 8000) ### Backend (dev on port 8000)
@@ -73,10 +76,9 @@ python run.py
### Docker (production-like stack) ### Docker (production-like stack)
```bash ```bash
docker compose up -d --build # bot + web API docker compose up -d --build # bot + web API (polyweather + polyweather_web)
docker compose --profile workers up -d # + prewarm worker
docker compose --profile monitoring up -d # + Prometheus/Grafana/Alertmanager
``` ```
The compose file defines two services: `polyweather` (bot) and `polyweather_web` (FastAPI on :8000). Prewarm worker and monitoring profiles were removed in v1.6.0.
### Python tests ### Python tests
```bash ```bash
@@ -101,21 +103,26 @@ curl http://127.0.0.1:8000/metrics
| Directory | Purpose | | Directory | Purpose |
|-----------|---------| |-----------|---------|
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
| `src/analysis/` | DEB algorithm, trend engine, probability calibration (EMOS/LGBM), market alert engine, settlement rounding | | `src/analysis/` | DEB algorithm, trend engine, probability calibration (EMOS/LGBM), market alert engine, settlement rounding |
| `src/models/` | LightGBM daily-high model training and feature engineering | | `src/auth/` | Supabase entitlement checks, Telegram group pricing |
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
| `src/bot/` | Telegram bot handlers and orchestrator | | `src/bot/` | Telegram bot handlers and orchestrator |
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories | | `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
| `web/` | FastAPI app, routes (~65K), analysis service (~130K), scan terminal service (~56K), AI scan modules | | `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
| `src/data_mining/` | Historical data fetch utilities |
| `src/models/` | LightGBM daily-high model training and feature engineering |
| `src/onchain/` | Polygon wallet watcher, Polymarket wallet activity watcher |
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
| `src/strategy/` | Trading strategy modules |
| `src/trading/` | Trading execution modules |
| `src/utils/` | Shared utilities: config loader, logging, metrics, Telegram push, chat ID helpers |
| `web/` | FastAPI app (`app.py``app_factory.py`), `routers/` (8 route modules), `services/` (14 service modules), `core.py`, scan terminal modules (AI fallback, AI prompts, METAR gate, city rows, ranker, cache) |
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) | | `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. | | `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. |
| `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils | | `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils |
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper | | `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
| `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation, prewarm worker | | `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation. `supabase/` subdirectory: DB schema and migration SQL. |
| `config/` | YAML config (city list, weather settings, logging) | | `config/` | YAML config (city list, weather settings, logging) |
| `docs/` | Bilingual product & technical docs | | `docs/` | Bilingual product & technical docs |
| `monitoring/` | Prometheus/Grafana/Alertmanager configs |
## Key Technical Details ## Key Technical Details
@@ -124,6 +131,7 @@ curl http://127.0.0.1:8000/metrics
- **Frontend package manager**: npm - **Frontend package manager**: npm
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only. - **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts - **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
- **Configuration**: `.env.example` is the comprehensive reference (8 config sections: runtime, Telegram, weather cache, auth, ops, frontend, optional modules, Polygon monitor). Copy to `.env` and fill in secrets.
- **Auth gating** (frontend middleware): Token-based (`POLYWEATHER_DASHBOARD_ACCESS_TOKEN`) or Supabase session-based (`POLYWEATHER_AUTH_ENABLED`). Local dev hosts bypass auth. - **Auth gating** (frontend middleware): Token-based (`POLYWEATHER_DASHBOARD_ACCESS_TOKEN`) or Supabase session-based (`POLYWEATHER_AUTH_ENABLED`). Local dev hosts bypass auth.
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app) - **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
- **EMOS/CRPS calibration**: Trainable but production should use `legacy` or `emos_shadow` engine; `emos_primary` only after local evaluation + manual rollout - **EMOS/CRPS calibration**: Trainable but production should use `legacy` or `emos_shadow` engine; `emos_primary` only after local evaluation + manual rollout
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.text();
const auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/auth/telegram/bind-by-token`, {
method: "POST",
headers,
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to bind Telegram account",
});
}
}
+32 -76
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { User } from "@supabase/supabase-js"; import type { User } from "@supabase/supabase-js";
@@ -80,16 +80,6 @@ type TelegramPricing = {
pricing_source?: string; pricing_source?: string;
}; };
type TelegramAuthPayload = {
id: number;
first_name?: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
};
type PaymentPlan = { type PaymentPlan = {
plan_code: string; plan_code: string;
plan_id: number; plan_id: number;
@@ -250,11 +240,6 @@ const TELEGRAM_GROUP_URL = String(
const TELEGRAM_BOT_URL = String( const TELEGRAM_BOT_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot", process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
).trim(); ).trim();
const TELEGRAM_LOGIN_BOT_USERNAME = String(
process.env.NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME || "WeatherQuant_bot",
)
.replace(/^@/, "")
.trim();
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx"; const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx"; const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx";
const SUBSCRIPTION_HELP_HREF = "/subscription-help"; const SUBSCRIPTION_HELP_HREF = "/subscription-help";
@@ -898,8 +883,6 @@ export function AccountCenter() {
const [lastTxHash, setLastTxHash] = useState(""); const [lastTxHash, setLastTxHash] = useState("");
const [manualPayment, setManualPayment] = useState<CreatedIntent["direct_payment"] | null>(null); const [manualPayment, setManualPayment] = useState<CreatedIntent["direct_payment"] | null>(null);
const [manualTxHash, setManualTxHash] = useState(""); const [manualTxHash, setManualTxHash] = useState("");
const [telegramLoginBusy, setTelegramLoginBusy] = useState(false);
const telegramLoginWidgetRef = useRef<HTMLDivElement | null>(null);
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0); const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
const [showSecondarySections, setShowSecondarySections] = useState(false); const [showSecondarySections, setShowSecondarySections] = useState(false);
const [reconcileBusy, setReconcileBusy] = useState(false); const [reconcileBusy, setReconcileBusy] = useState(false);
@@ -1510,64 +1493,44 @@ export function AccountCenter() {
const userId = backend?.user_id || user?.id || ""; const userId = backend?.user_id || user?.id || "";
const isAuthenticated = Boolean(userId); const isAuthenticated = Boolean(userId);
const email = backend?.email || user?.email || ""; const email = backend?.email || user?.email || "";
// Handle ?bind_token=xxx from Telegram bot /bind deep link
useEffect(() => { useEffect(() => {
const container = telegramLoginWidgetRef.current; if (!isAuthenticated) return;
if (!container || !TELEGRAM_LOGIN_BOT_USERNAME || !isAuthenticated) return; const params = new URLSearchParams(window.location.search);
container.innerHTML = ""; const token = params.get("bind_token");
const callbackName = "onPolyWeatherTelegramAuth"; if (!token) return;
(window as unknown as Record<string, unknown>)[callbackName] = async ( // Remove token from URL so refresh doesn't retry
payload: TelegramAuthPayload, const url = new URL(window.location.href);
) => { url.searchParams.delete("bind_token");
setTelegramLoginBusy(true); window.history.replaceState(null, "", url.toString());
(async () => {
setPaymentError(""); setPaymentError("");
setPaymentInfo(""); setPaymentInfo("");
try { try {
const authHeaders = await buildAuthedHeaders(true, false); const authHeaders = await buildAuthedHeaders(true, false);
const res = await fetch("/api/auth/telegram/login", { const res = await fetch("/api/auth/telegram/bind-by-token", {
method: "POST", method: "POST",
headers: authHeaders, headers: authHeaders,
body: JSON.stringify(payload), body: JSON.stringify({ token }),
}); });
if (!res.ok) { if (!res.ok) {
const raw = (await res.text()).slice(0, 350); const raw = (await res.text()).slice(0, 350);
throw new Error(`telegram login failed: ${raw}`); throw new Error(`bind failed: ${raw}`);
} }
const data = (await res.json()) as { const data = (await res.json()) as {
telegram_pricing?: TelegramPricing | null; telegram_pricing?: TelegramPricing | null;
}; };
const amount = data.telegram_pricing?.amount_usdc || "10"; if (data.telegram_pricing?.is_group_member) {
setPaymentInfo( const amount = data.telegram_pricing.amount_usdc || "5";
data.telegram_pricing?.is_group_member setPaymentInfo(`Telegram 群成员验证成功,当前会员价 ${amount}U。`);
? `Telegram 群成员验证成功,当前会员价 ${amount}U。` }
: `Telegram 已登录,但未检测到群成员身份,当前价格 ${amount}U。`,
);
await loadSnapshot(); await loadSnapshot();
await loadPaymentSnapshot(); await loadPaymentSnapshot();
} catch (error) { } catch (error) {
setPaymentError(normalizePaymentError(error).message); setPaymentError(normalizePaymentError(error).message);
} finally {
setTelegramLoginBusy(false);
} }
}; })();
const script = document.createElement("script"); }, [isAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]);
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.async = true;
script.setAttribute("data-telegram-login", TELEGRAM_LOGIN_BOT_USERNAME);
script.setAttribute("data-size", "large");
script.setAttribute("data-radius", "12");
script.setAttribute("data-userpic", "false");
script.setAttribute("data-request-access", "write");
script.setAttribute("data-onauth", `${callbackName}(user)`);
container.appendChild(script);
return () => {
container.innerHTML = "";
};
}, [
buildAuthedHeaders,
isAuthenticated,
loadPaymentSnapshot,
loadSnapshot,
]);
const displayName = const displayName =
String(user?.user_metadata?.full_name || "").trim() || String(user?.user_metadata?.full_name || "").trim() ||
(email ? String(email).split("@")[0] : "") || (email ? String(email).split("@")[0] : "") ||
@@ -3011,28 +2974,21 @@ export function AccountCenter() {
<p className="text-slate-400 text-sm mb-6"> <p className="text-slate-400 text-sm mb-6">
{copy.telegramHint} {copy.telegramHint}
</p> </p>
<div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 px-4 py-3"> {backend?.telegram_pricing?.is_group_member ? (
<p className="text-xs font-bold text-emerald-200"> <div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 px-4 py-3">
Telegram <p className="text-xs font-bold text-emerald-200">
</p> Telegram
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75"> </p>
Telegram 5U 10U <p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
</p> {backend.telegram_pricing.amount_usdc ?? "5"}U
<div className="mt-3 flex flex-wrap items-center gap-3"> </p>
<div ref={telegramLoginWidgetRef} /> <div className="mt-3">
{telegramLoginBusy ? (
<span className="text-[11px] text-cyan-200">
Telegram...
</span>
) : null}
{backend?.telegram_pricing?.amount_usdc ? (
<span className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] font-bold text-white"> <span className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] font-bold text-white">
: {backend.telegram_pricing.amount_usdc}U : {backend.telegram_pricing.amount_usdc ?? "5"}U ·
{backend.telegram_pricing.is_group_member ? " · 群成员" : " · 普通价"}
</span> </span>
) : null} </div>
</div> </div>
</div> ) : null}
{/* Channel Upgrade / Migration Notice — Pro users only */} {/* Channel Upgrade / Migration Notice — Pro users only */}
{isSubscribed ? ( {isSubscribed ? (
+30 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
from typing import Callable from typing import Callable
@@ -7,6 +8,7 @@ from src.bot.command_parser import extract_command_name
from src.bot.io_layer import BotIOLayer from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace from src.bot.observability import CommandTrace
from src.bot.runtime_coordinator import RuntimeStatus, render_runtime_status_html from src.bot.runtime_coordinator import RuntimeStatus, render_runtime_status_html
from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBER_STATUSES
_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind"} _BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind"}
_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind", "markets"} _BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind", "markets"}
@@ -140,27 +142,48 @@ class BasicCommandHandler:
trace = CommandTrace("/bind", message) trace = CommandTrace("/bind", message)
try: try:
parts = (message.text or "").split(maxsplit=2) parts = (message.text or "").split(maxsplit=2)
user = message.from_user
# No-args mode: generate a one-time bind token for group members
if len(parts) < 2: if len(parts) < 2:
pricing = TelegramGroupPricing()
if not pricing.configured:
self.bot.reply_to(
message,
"⚠️ 机器人未配置群组定价,请联系管理员。",
)
trace.set_status("error", "pricing_not_configured")
return
member_status = pricing.get_member_status(user.id)
if not member_status or member_status not in TELEGRAM_MEMBER_STATUSES:
self.bot.reply_to(
message,
"🔒 此功能仅限内部群成员使用。",
)
trace.set_status("blocked", f"not_group_member:{member_status or 'none'}")
return
token = self.io_layer.db.create_bind_token(user.id, ttl_minutes=10)
app_url = str(os.getenv("POLYWEATHER_APP_URL") or "https://polyweather-pro.vercel.app").rstrip("/")
bind_url = f"{app_url}/account?bind_token={token}"
self.bot.reply_to( self.bot.reply_to(
message, message,
( (
"❌ 用法:\n" "🔗 点击以下链接绑定网页账户(10 分钟内有效):\n"
"<code>/bind &lt;supabase_user_id&gt; [email]</code>\n\n" f"{bind_url}\n\n"
"示例:\n" "打开链接后系统将自动验证群成员身份并绑定 Telegram。"
"<code>/bind 11111111-2222-3333-4444-555555555555 user@example.com</code>"
), ),
parse_mode="HTML", disable_web_page_preview=False,
) )
trace.set_status("bad_request", "missing_supabase_user_id") trace.set_status("ok", "token_generated")
return return
# Args mode: manual bind with supabase_user_id
supabase_user_id = str(parts[1] or "").strip() supabase_user_id = str(parts[1] or "").strip()
if len(supabase_user_id) < 8: if len(supabase_user_id) < 8:
self.bot.reply_to(message, "❌ supabase_user_id 格式不正确。") self.bot.reply_to(message, "❌ supabase_user_id 格式不正确。")
trace.set_status("bad_request", "invalid_supabase_user_id") trace.set_status("bad_request", "invalid_supabase_user_id")
return return
supabase_email = str(parts[2] or "").strip() if len(parts) >= 3 else "" supabase_email = str(parts[2] or "").strip() if len(parts) >= 3 else ""
user = message.from_user
self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user)) self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user))
result = self.io_layer.db.bind_supabase_identity( result = self.io_layer.db.bind_supabase_identity(
telegram_id=user.id, telegram_id=user.id,
+64
View File
@@ -2,6 +2,7 @@ import sqlite3
import os import os
import hashlib import hashlib
import json import json
import secrets
import threading import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Set from typing import Optional, Dict, Any, List, Set
@@ -364,6 +365,17 @@ class DBManager:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_supabase_bindings_telegram_id ON supabase_bindings(telegram_id)" "CREATE INDEX IF NOT EXISTS idx_supabase_bindings_telegram_id ON supabase_bindings(telegram_id)"
) )
conn.execute("""
CREATE TABLE IF NOT EXISTS telegram_bind_tokens (
token TEXT PRIMARY KEY,
telegram_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_telegram_bind_tokens_expires ON telegram_bind_tokens(expires_at)"
)
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS airport_obs_log ( CREATE TABLE IF NOT EXISTS airport_obs_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1368,6 +1380,58 @@ class DBManager:
) )
return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid} return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid}
def create_bind_token(self, telegram_id: int, ttl_minutes: int = 10) -> str:
token = secrets.token_urlsafe(16)
now = datetime.now()
expires_at = now + timedelta(minutes=max(1, int(ttl_minutes)))
with self._get_connection() as conn:
conn.execute(
"""
DELETE FROM telegram_bind_tokens
WHERE telegram_id = ? OR expires_at < ?
""",
(int(telegram_id), now.isoformat()),
)
conn.execute(
"""
INSERT INTO telegram_bind_tokens (token, telegram_id, expires_at)
VALUES (?, ?, ?)
""",
(token, int(telegram_id), expires_at.isoformat()),
)
conn.commit()
return token
def consume_bind_token(self, token: str) -> Optional[int]:
token = str(token or "").strip()
if not token:
return None
now = datetime.now()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT telegram_id, expires_at
FROM telegram_bind_tokens
WHERE token = ?
LIMIT 1
""",
(token,),
).fetchone()
if not row:
return None
try:
expires_at = datetime.fromisoformat(row["expires_at"])
except Exception:
expires_at = now
if now > expires_at:
conn.execute("DELETE FROM telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
return None
conn.execute("DELETE FROM telegram_bind_tokens WHERE token = ?", (token,))
conn.commit()
return int(row["telegram_id"])
def add_message_activity( def add_message_activity(
self, self,
telegram_id: int, telegram_id: int,
+4
View File
@@ -411,6 +411,10 @@ class TelegramLoginRequest(BaseModel):
hash: str = Field(..., min_length=10) hash: str = Field(..., min_length=10)
class TelegramBindTokenRequest(BaseModel):
token: str = Field(..., min_length=8)
class AnalyticsEventRequest(BaseModel): class AnalyticsEventRequest(BaseModel):
event_type: str = Field(..., min_length=3, max_length=64) event_type: str = Field(..., min_length=3, max_length=64)
client_id: Optional[str] = Field(default=None, max_length=128) client_id: Optional[str] = Field(default=None, max_length=128)
+11 -2
View File
@@ -2,8 +2,12 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from web.core import TelegramLoginRequest from web.core import TelegramBindTokenRequest, TelegramLoginRequest
from web.services.auth_api import get_auth_me_payload, login_with_telegram from web.services.auth_api import (
bind_telegram_by_token,
get_auth_me_payload,
login_with_telegram,
)
router = APIRouter(tags=["auth"]) router = APIRouter(tags=["auth"])
@@ -16,3 +20,8 @@ async def auth_me(request: Request):
@router.post("/api/auth/telegram/login") @router.post("/api/auth/telegram/login")
async def auth_telegram_login(request: Request, body: TelegramLoginRequest): async def auth_telegram_login(request: Request, body: TelegramLoginRequest):
return login_with_telegram(request, body) return login_with_telegram(request, body)
@router.post("/api/auth/telegram/bind-by-token")
async def auth_telegram_bind_by_token(request: Request, body: TelegramBindTokenRequest):
return bind_telegram_by_token(request, body)
+40
View File
@@ -157,3 +157,43 @@ def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[st
"binding": bind_result, "binding": bind_result,
"telegram_pricing": price, "telegram_pricing": price,
} }
def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]:
"""Bind Telegram identity using a one-time token from the bot /bind command."""
legacy_routes._assert_entitlement(request)
identity = legacy_routes._require_supabase_identity(request)
token = str(getattr(body, "token", "") or "").strip()
if not token:
raise HTTPException(status_code=400, detail="bind_token is required")
db = DBManager()
telegram_id = db.consume_bind_token(token)
if telegram_id is None:
raise HTTPException(status_code=400, detail="invalid or expired bind token")
pricing = TelegramGroupPricing()
member_status = pricing.get_member_status(telegram_id) if pricing.configured else None
if not member_status or member_status not in ("creator", "administrator", "member"):
raise HTTPException(status_code=403, detail="not a group member")
db.upsert_user(telegram_id, "")
bind_result = db.bind_supabase_identity(
telegram_id=telegram_id,
supabase_user_id=identity["user_id"],
supabase_email=identity.get("email") or "",
)
if not bind_result.get("ok"):
raise HTTPException(
status_code=409,
detail=str(bind_result.get("reason") or "telegram bind failed"),
)
price = pricing.resolve_price_for_telegram_id(telegram_id)
return {
"ok": True,
"telegram_id": telegram_id,
"binding": bind_result,
"telegram_pricing": price,
}