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,
+ }