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
+30 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from typing import Any
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.observability import CommandTrace
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", "markets"}
@@ -140,27 +142,48 @@ class BasicCommandHandler:
trace = CommandTrace("/bind", message)
try:
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:
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(
message,
(
"❌ 用法:\n"
"<code>/bind &lt;supabase_user_id&gt; [email]</code>\n\n"
"示例:\n"
"<code>/bind 11111111-2222-3333-4444-555555555555 user@example.com</code>"
"🔗 点击以下链接绑定网页账户(10 分钟内有效):\n"
f"{bind_url}\n\n"
"打开链接后系统将自动验证群成员身份并绑定 Telegram。"
),
parse_mode="HTML",
disable_web_page_preview=False,
)
trace.set_status("bad_request", "missing_supabase_user_id")
trace.set_status("ok", "token_generated")
return
# Args mode: manual bind with supabase_user_id
supabase_user_id = str(parts[1] or "").strip()
if len(supabase_user_id) < 8:
self.bot.reply_to(message, "❌ supabase_user_id 格式不正确。")
trace.set_status("bad_request", "invalid_supabase_user_id")
return
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))
result = self.io_layer.db.bind_supabase_identity(
telegram_id=user.id,
+64
View File
@@ -2,6 +2,7 @@ import sqlite3
import os
import hashlib
import json
import secrets
import threading
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Set
@@ -364,6 +365,17 @@ class DBManager:
conn.execute(
"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("""
CREATE TABLE IF NOT EXISTS airport_obs_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1368,6 +1380,58 @@ class DBManager:
)
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(
self,
telegram_id: int,