diff --git a/frontend/app/api/auth/telegram/bot-bind-link/route.ts b/frontend/app/api/auth/telegram/bot-bind-link/route.ts new file mode 100644 index 00000000..747f226d --- /dev/null +++ b/frontend/app/api/auth/telegram/bot-bind-link/route.ts @@ -0,0 +1,40 @@ +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 auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/api/auth/telegram/bot-bind-link`, { + method: "POST", + headers: auth.headers, + 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 create Telegram bot bind link", + }); + } +} diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 6f88482c..67b811c9 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -727,19 +727,23 @@ export function AccountCenter() { restricted: isEn ? "Restricted" : "受限", telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定", telegramHint: isEn - ? "Send the command below to the polyweather bot to sync notifications and access. Telegram group access is reviewed for Pro users." - : "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。Telegram 群组需提交申请并完成 Pro 审核。", + ? "Use one-click Bot binding first to sync notifications and access. Telegram group access is reviewed automatically for Pro users." + : "优先使用「一键绑定机器人」同步通知与权限。Telegram 群组会根据 Pro 状态自动审核入群申请。", + telegramFallbackHint: isEn + ? "Fallback: if one-click binding does not open Telegram correctly, copy the command below and send it to @WeatherQuant_bot." + : "备用复制方式:如果一键绑定无法正常打开 Telegram,请复制下方命令并发送给 @WeatherQuant_bot。", paymentManualSupport: isEn ? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases." : "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!", telegramBotLink: isEn ? "Open Bot (@WeatherQuant_bot)" : "打开机器人 (@WeatherQuant_bot)", + telegramBotBindLink: isEn ? "One-click Bot Binding" : "一键绑定机器人", telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组", telegramTopicsGroupLink: isEn ? "Real-time Weather Updates" : "城市实测温度群", - copyCommand: isEn ? "Copy command" : "复制命令", + copyCommand: isEn ? "Copy fallback command" : "复制备用命令", paymentMgmt: isEn ? "Payment Management" : "支付管理", paymentToken: isEn ? "Payment Token" : "支付币种", paymentAccount: isEn ? "Subscription Account" : "订阅归属账号", @@ -869,6 +873,7 @@ export function AccountCenter() { const [paymentError, setPaymentError] = useState(""); const [lastIntentId, setLastIntentId] = useState(""); const [lastTxHash, setLastTxHash] = useState(""); + const [telegramBindOpening, setTelegramBindOpening] = useState(false); const [manualPayment, setManualPayment] = useState< CreatedIntent["direct_payment"] | null >(null); @@ -1791,6 +1796,31 @@ export function AccountCenter() { }); }; + const openTelegramBotBindLink = async () => { + setTelegramBindOpening(true); + setPaymentError(""); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const res = await fetch("/api/auth/telegram/bot-bind-link", { + method: "POST", + headers: authHeaders, + }); + if (!res.ok) { + const raw = (await res.text()).slice(0, 300); + throw new Error(raw || "failed to create telegram bind link"); + } + const data = (await res.json()) as { bot_url?: string }; + const botUrl = String(data.bot_url || "").trim(); + if (!botUrl) throw new Error("telegram bind link missing"); + window.open(botUrl, "_blank", "noopener,noreferrer"); + setPaymentInfo("已打开 Telegram Bot,请在 Bot 内点击 Start 完成绑定。"); + } catch (error) { + setPaymentError(normalizePaymentError(error).message); + } finally { + setTelegramBindOpening(false); + } + }; + // --- Payment Logic (preserved) --- const waitForReceipt = async ( @@ -3009,7 +3039,8 @@ export function AccountCenter() { ) : null} - {TELEGRAM_TOPICS_GROUP_URL ? ( + {TELEGRAM_TOPICS_GROUP_URL && + TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL ? ( {bindCommand} + +

+ {copy.telegramFallbackHint} +

{copy.paymentManualSupport}
diff --git a/frontend/playwright-live-home.png b/frontend/playwright-live-home.png deleted file mode 100644 index c2cb0570..00000000 Binary files a/frontend/playwright-live-home.png and /dev/null differ diff --git a/src/bot/handlers/basic.py b/src/bot/handlers/basic.py index 06d8ab45..7a6c6a49 100644 --- a/src/bot/handlers/basic.py +++ b/src/bot/handlers/basic.py @@ -114,11 +114,57 @@ class BasicCommandHandler: def handle_start_help(self, message: Any) -> None: trace = CommandTrace("/start", message) try: + parts = (getattr(message, "text", None) or "").split(maxsplit=1) + payload = str(parts[1] if len(parts) > 1 else "").strip() + if payload.startswith("bind_"): + token = payload[len("bind_") :].strip() + result = self._bind_from_web_token(message, token) + trace.set_status("ok" if result == "bound" else "error", result) + return self.bot.reply_to(message, self.io_layer.build_welcome_text(), parse_mode="HTML") trace.set_status("ok") finally: trace.emit() + def _bind_from_web_token(self, message: Any, token: str) -> str: + if not token: + self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。") + return "invalid_token" + user = message.from_user + try: + payload = self.io_layer.db.consume_web_bind_token(token) + except Exception as exc: + logger.warning("web bind token consume failed user_id={}: {}", getattr(user, "id", ""), exc) + payload = None + if not isinstance(payload, dict): + self.bot.reply_to(message, "❌ 绑定链接已过期或无效,请回到网页重新点击一键绑定。") + return "invalid_or_expired_token" + + supabase_user_id = str(payload.get("supabase_user_id") or "").strip() + supabase_email = str(payload.get("supabase_email") or "").strip() + if not supabase_user_id: + self.bot.reply_to(message, "❌ 绑定链接缺少网页账号信息,请重新登录后再试。") + return "missing_supabase_user_id" + + self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user)) + bind_result = self.io_layer.db.bind_supabase_identity( + telegram_id=user.id, + supabase_user_id=supabase_user_id, + supabase_email=supabase_email, + ) + if not bool(bind_result.get("ok")): + reason = str(bind_result.get("reason") or "bind_failed") + self.bot.reply_to(message, f"❌ 绑定失败:{reason}") + return reason + self.bot.reply_to( + message, + ( + "✅ 账号绑定完成。\n" + "现在可以回到网页点击“加入 Telegram 群组”,入群申请会自动审核。" + ), + ) + return "bound" + def handle_id(self, message: Any) -> None: trace = CommandTrace("/id", message) try: diff --git a/src/database/db_manager.py b/src/database/db_manager.py index b354cd8e..41c2d7db 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -366,6 +366,18 @@ class DBManager: 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 web_telegram_bind_tokens ( + token TEXT PRIMARY KEY, + supabase_user_id TEXT NOT NULL, + supabase_email TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_web_telegram_bind_tokens_expires ON web_telegram_bind_tokens(expires_at)" + ) conn.execute(""" CREATE TABLE IF NOT EXISTS airport_obs_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1452,6 +1464,69 @@ class DBManager: conn.commit() return int(row["telegram_id"]) + def create_web_bind_token( + self, + supabase_user_id: str, + supabase_email: str = "", + ttl_minutes: int = 10, + ) -> str: + normalized_uid = str(supabase_user_id or "").strip().lower() + if not normalized_uid: + raise ValueError("supabase_user_id is required") + 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 web_telegram_bind_tokens + WHERE supabase_user_id = ? OR expires_at < ? + """, + (normalized_uid, now.isoformat()), + ) + conn.execute( + """ + INSERT INTO web_telegram_bind_tokens ( + token, supabase_user_id, supabase_email, expires_at + ) + VALUES (?, ?, ?, ?) + """, + (token, normalized_uid, str(supabase_email or "").strip(), expires_at.isoformat()), + ) + conn.commit() + return token + + def consume_web_bind_token(self, token: str) -> Optional[Dict[str, str]]: + 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 supabase_user_id, supabase_email, expires_at + FROM web_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 + conn.execute("DELETE FROM web_telegram_bind_tokens WHERE token = ?", (token,)) + conn.commit() + if now > expires_at: + return None + return { + "supabase_user_id": str(row["supabase_user_id"] or "").strip().lower(), + "supabase_email": str(row["supabase_email"] or "").strip(), + } + def add_message_activity( self, telegram_id: int, diff --git a/tests/test_bot_basic_handler.py b/tests/test_bot_basic_handler.py index b09e61d3..824b821a 100644 --- a/tests/test_bot_basic_handler.py +++ b/tests/test_bot_basic_handler.py @@ -91,6 +91,42 @@ def test_basic_handler_diag_returns_html(): assert "Bot 启动诊断" in bot.replies[0]["text"] +def test_start_bind_token_binds_telegram_to_web_account(): + bot = DummyBot() + db = SimpleNamespace( + consume_web_bind_token=lambda token: { + "supabase_user_id": "user-1", + "supabase_email": "u@example.com", + } + if token == "abc123" + else None, + upsert_user=lambda *_args, **_kwargs: None, + bind_supabase_identity=lambda **_kwargs: {"ok": True, "reason": "bound"}, + ) + io_layer = SimpleNamespace( + build_welcome_text=lambda: "WELCOME", + build_points_rank_text=lambda _user: "TOP", + display_name=lambda user: user.username, + db=db, + ) + handler = BasicCommandHandler( + bot=bot, + io_layer=io_layer, + runtime_status_provider=lambda: RuntimeStatus( + started_at="2026-03-12 00:00:00 UTC", + loops=[], + command_access_mode="group_member", + protected_commands=["/city", "/deb"], + required_group_chat_id="-1001234567890", + ), + ) + + handler.handle_start_help(_message("/start bind_abc123")) + + assert len(bot.replies) == 1 + assert "账号绑定完成" in bot.replies[0]["text"] + + def test_basic_handler_markets_returns_summary(): bot = DummyBot() io_layer = SimpleNamespace( diff --git a/web/routers/auth.py b/web/routers/auth.py index 7df41dc5..7ec49952 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Request from web.core import TelegramBindTokenRequest, TelegramLoginRequest from web.services.auth_api import ( bind_telegram_by_token, + create_telegram_bot_bind_link, get_auth_me_payload, login_with_telegram, ) @@ -25,3 +26,8 @@ async def auth_telegram_login(request: Request, body: TelegramLoginRequest): @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) + + +@router.post("/api/auth/telegram/bot-bind-link") +async def auth_telegram_bot_bind_link(request: Request): + return create_telegram_bot_bind_link(request) diff --git a/web/services/auth_api.py b/web/services/auth_api.py index 00e1864f..3196fc51 100644 --- a/web/services/auth_api.py +++ b/web/services/auth_api.py @@ -197,3 +197,30 @@ def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]: "binding": bind_result, "telegram_pricing": price, } + + +def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]: + """Create a one-time web-to-bot bind deep link for the authenticated account.""" + legacy_routes._assert_entitlement(request) + identity = legacy_routes._require_supabase_identity(request) + + db = DBManager() + token = db.create_web_bind_token( + supabase_user_id=identity["user_id"], + supabase_email=identity.get("email") or "", + ttl_minutes=10, + ) + start_param = f"bind_{token}" + bot_username = str( + legacy_routes.os.getenv("TELEGRAM_BOT_USERNAME") + or legacy_routes.os.getenv("NEXT_PUBLIC_TELEGRAM_BOT_USERNAME") + or "WeatherQuant_bot" + ).strip().lstrip("@") + bot_url = f"https://t.me/{bot_username}?start={start_param}" + return { + "ok": True, + "token": token, + "start_param": start_param, + "bot_url": bot_url, + "expires_in_seconds": 600, + }