feat: implement Telegram-to-Web account binding flow and add supporting database and UI components
This commit is contained in:
@@ -727,23 +727,23 @@ export function AccountCenter() {
|
|||||||
restricted: isEn ? "Restricted" : "受限",
|
restricted: isEn ? "Restricted" : "受限",
|
||||||
telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定",
|
telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定",
|
||||||
telegramHint: isEn
|
telegramHint: isEn
|
||||||
? "Use one-click Bot binding first to sync notifications and access. Telegram group access is reviewed automatically for Pro users."
|
? "Use one-click Telegram binding first to sync notifications and access. After binding, refresh this page and submit your Telegram group join request."
|
||||||
: "优先使用「一键绑定机器人」同步通知与权限。Telegram 群组会根据 Pro 状态自动审核入群申请。",
|
: "优先使用「一键绑定 Telegram Bot」同步通知与权限。绑定完成后刷新本页,再提交 Telegram 群组入群申请。",
|
||||||
telegramFallbackHint: isEn
|
telegramFallbackHint: isEn
|
||||||
? "Fallback: if one-click binding does not open Telegram correctly, copy the command below and send it to @WeatherQuant_bot."
|
? "Fallback copy method: only use this if one-click binding does not open Telegram correctly. Copy the command below and send it to @WeatherQuant_bot. After binding, refresh this page to show the group entry."
|
||||||
: "备用复制方式:如果一键绑定无法正常打开 Telegram,请复制下方命令并发送给 @WeatherQuant_bot。",
|
: "兜底复制方式:仅在一键绑定无法正常打开 Telegram 时使用。请复制下方命令并发送给 @WeatherQuant_bot。绑定完成后刷新本页,即可显示入群入口。",
|
||||||
paymentManualSupport: isEn
|
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."
|
? "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。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
||||||
telegramBotLink: isEn
|
telegramBotLink: isEn
|
||||||
? "Open Bot (@WeatherQuant_bot)"
|
? "Open Bot (@WeatherQuant_bot)"
|
||||||
: "打开机器人 (@WeatherQuant_bot)",
|
: "打开机器人 (@WeatherQuant_bot)",
|
||||||
telegramBotBindLink: isEn ? "One-click Bot Binding" : "一键绑定机器人",
|
telegramBotBindLink: isEn ? "One-click Telegram Binding" : "一键绑定 Telegram Bot",
|
||||||
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
|
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
|
||||||
telegramTopicsGroupLink: isEn
|
telegramTopicsGroupLink: isEn
|
||||||
? "Real-time Weather Updates"
|
? "Real-time Weather Updates"
|
||||||
: "城市实测温度群",
|
: "城市实测温度群",
|
||||||
copyCommand: isEn ? "Copy fallback command" : "复制备用命令",
|
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
|
||||||
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
||||||
paymentToken: isEn ? "Payment Token" : "支付币种",
|
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||||
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
||||||
@@ -1545,6 +1545,10 @@ export function AccountCenter() {
|
|||||||
Number(backend?.subscription_queued_days || 0),
|
Number(backend?.subscription_queued_days || 0),
|
||||||
);
|
);
|
||||||
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
|
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
|
||||||
|
const canAccessPaidTelegramGroup = Boolean(
|
||||||
|
isSubscribed && (!isTrialPlan || hasQueuedExtension),
|
||||||
|
);
|
||||||
|
const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
|
||||||
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
|
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
|
||||||
const reminderExpiryRaw = isSubscribed
|
const reminderExpiryRaw = isSubscribed
|
||||||
? totalExpiryRaw
|
? totalExpiryRaw
|
||||||
@@ -1813,7 +1817,9 @@ export function AccountCenter() {
|
|||||||
const botUrl = String(data.bot_url || "").trim();
|
const botUrl = String(data.bot_url || "").trim();
|
||||||
if (!botUrl) throw new Error("telegram bind link missing");
|
if (!botUrl) throw new Error("telegram bind link missing");
|
||||||
window.open(botUrl, "_blank", "noopener,noreferrer");
|
window.open(botUrl, "_blank", "noopener,noreferrer");
|
||||||
setPaymentInfo("已打开 Telegram Bot,请在 Bot 内点击 Start 完成绑定。");
|
setPaymentInfo(
|
||||||
|
"已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPaymentError(normalizePaymentError(error).message);
|
setPaymentError(normalizePaymentError(error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2988,14 +2994,14 @@ export function AccountCenter() {
|
|||||||
chainId={paymentConfig?.chain_id || 137}
|
chainId={paymentConfig?.chain_id || 137}
|
||||||
paymentTokenLabel={selectedTokenLabel}
|
paymentTokenLabel={selectedTokenLabel}
|
||||||
faqHref={SUBSCRIPTION_HELP_HREF}
|
faqHref={SUBSCRIPTION_HELP_HREF}
|
||||||
telegramGroupUrl={TELEGRAM_GROUP_URL}
|
telegramGroupUrl=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telegram Bot Section — paid users only */}
|
{/* Telegram Bot Section — paid users only */}
|
||||||
{showSecondarySections && isSubscribed ? (
|
{showSecondarySections && canAccessPaidTelegramGroup ? (
|
||||||
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
|
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
|
||||||
<section className="flex-1 bg-white/5 border border-white/10 rounded-[2rem] p-8 relative overflow-hidden group">
|
<section className="flex-1 bg-white/5 border border-white/10 rounded-[2rem] p-8 relative overflow-hidden group">
|
||||||
<Bot
|
<Bot
|
||||||
@@ -3028,19 +3034,9 @@ export function AccountCenter() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
{TELEGRAM_BOT_URL ? (
|
|
||||||
<Link
|
|
||||||
href={TELEGRAM_BOT_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-cyan-400/30 bg-cyan-500/10 px-3 py-2 text-xs font-semibold text-cyan-200 hover:bg-cyan-500/20"
|
|
||||||
>
|
|
||||||
{copy.telegramBotLink}
|
|
||||||
<ExternalLink size={12} />
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
{TELEGRAM_TOPICS_GROUP_URL &&
|
{TELEGRAM_TOPICS_GROUP_URL &&
|
||||||
TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL ? (
|
TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL &&
|
||||||
|
telegramBound ? (
|
||||||
<Link
|
<Link
|
||||||
href={TELEGRAM_TOPICS_GROUP_URL}
|
href={TELEGRAM_TOPICS_GROUP_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -3051,7 +3047,7 @@ export function AccountCenter() {
|
|||||||
<ExternalLink size={12} />
|
<ExternalLink size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{TELEGRAM_GROUP_URL ? (
|
{TELEGRAM_GROUP_URL && telegramBound ? (
|
||||||
<Link
|
<Link
|
||||||
href={TELEGRAM_GROUP_URL}
|
href={TELEGRAM_GROUP_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
+118
-9
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -67,6 +68,13 @@ class BasicCommandHandler:
|
|||||||
def _chat_join_request(request):
|
def _chat_join_request(request):
|
||||||
self.handle_chat_join_request(request)
|
self.handle_chat_join_request(request)
|
||||||
|
|
||||||
|
if hasattr(self.bot, "callback_query_handler"):
|
||||||
|
@self.bot.callback_query_handler(
|
||||||
|
func=lambda call: str(getattr(call, "data", "") or "").startswith("confirm_bind:")
|
||||||
|
)
|
||||||
|
def _confirm_bind(call):
|
||||||
|
self.handle_bind_confirm_callback(call)
|
||||||
|
|
||||||
@self.bot.message_handler(
|
@self.bot.message_handler(
|
||||||
content_types=["text"],
|
content_types=["text"],
|
||||||
func=lambda message: extract_command_name(
|
func=lambda message: extract_command_name(
|
||||||
@@ -118,19 +126,63 @@ class BasicCommandHandler:
|
|||||||
payload = str(parts[1] if len(parts) > 1 else "").strip()
|
payload = str(parts[1] if len(parts) > 1 else "").strip()
|
||||||
if payload.startswith("bind_"):
|
if payload.startswith("bind_"):
|
||||||
token = payload[len("bind_") :].strip()
|
token = payload[len("bind_") :].strip()
|
||||||
result = self._bind_from_web_token(message, token)
|
result = self._prompt_bind_from_web_token(message, token)
|
||||||
trace.set_status("ok" if result == "bound" else "error", result)
|
trace.set_status("ok" if result == "confirm_prompted" else "error", result)
|
||||||
return
|
return
|
||||||
self.bot.reply_to(message, self.io_layer.build_welcome_text(), parse_mode="HTML")
|
self.bot.reply_to(message, self.io_layer.build_welcome_text(), parse_mode="HTML")
|
||||||
trace.set_status("ok")
|
trace.set_status("ok")
|
||||||
finally:
|
finally:
|
||||||
trace.emit()
|
trace.emit()
|
||||||
|
|
||||||
def _bind_from_web_token(self, message: Any, token: str) -> str:
|
def _prompt_bind_from_web_token(self, message: Any, token: str) -> str:
|
||||||
|
if not token:
|
||||||
|
self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。")
|
||||||
|
return "invalid_token"
|
||||||
|
try:
|
||||||
|
payload = self.io_layer.db.peek_web_bind_token(token)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("web bind token peek failed token_prefix={}: {}", token[:6], 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"
|
||||||
|
|
||||||
|
masked_email = self._mask_email(supabase_email)
|
||||||
|
text = (
|
||||||
|
"请确认绑定:\n"
|
||||||
|
f"Telegram: <code>{html.escape(self.io_layer.display_name(message.from_user))}</code>\n"
|
||||||
|
f"网站账号: <code>{html.escape(masked_email or supabase_user_id)}</code>\n\n"
|
||||||
|
"确认后,入群申请将按此网站账号的 Pro 状态自动审核。"
|
||||||
|
)
|
||||||
|
reply_markup = self._build_confirm_bind_markup(token)
|
||||||
|
self.bot.reply_to(message, text, parse_mode="HTML", reply_markup=reply_markup)
|
||||||
|
return "confirm_prompted"
|
||||||
|
|
||||||
|
def handle_bind_confirm_callback(self, call: Any) -> str:
|
||||||
|
data = str(getattr(call, "data", "") or "")
|
||||||
|
token = data[len("confirm_bind:") :].strip() if data.startswith("confirm_bind:") else ""
|
||||||
|
message = getattr(call, "message", None)
|
||||||
|
user = getattr(call, "from_user", None)
|
||||||
|
if hasattr(self.bot, "answer_callback_query"):
|
||||||
|
try:
|
||||||
|
self.bot.answer_callback_query(getattr(call, "id", None))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if message is None or user is None:
|
||||||
|
logger.warning("telegram bind confirm callback missing message/user")
|
||||||
|
return "invalid_callback"
|
||||||
|
return self._bind_from_web_token(message, user, token)
|
||||||
|
|
||||||
|
def _bind_from_web_token(self, message: Any, user: Any, token: str) -> str:
|
||||||
if not token:
|
if not token:
|
||||||
self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。")
|
self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。")
|
||||||
return "invalid_token"
|
return "invalid_token"
|
||||||
user = message.from_user
|
|
||||||
try:
|
try:
|
||||||
payload = self.io_layer.db.consume_web_bind_token(token)
|
payload = self.io_layer.db.consume_web_bind_token(token)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -160,11 +212,37 @@ class BasicCommandHandler:
|
|||||||
message,
|
message,
|
||||||
(
|
(
|
||||||
"✅ 账号绑定完成。\n"
|
"✅ 账号绑定完成。\n"
|
||||||
"现在可以回到网页点击“加入 Telegram 群组”,入群申请会自动审核。"
|
"现在可以回到网页刷新本页,再点击“加入 Telegram 群组”。入群申请会自动审核。"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return "bound"
|
return "bound"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mask_email(email: str) -> str:
|
||||||
|
email = str(email or "").strip()
|
||||||
|
if "@" not in email:
|
||||||
|
return email
|
||||||
|
name, domain = email.split("@", 1)
|
||||||
|
if not name:
|
||||||
|
return f"***@{domain}"
|
||||||
|
return f"{name[0]}***@{domain}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_confirm_bind_markup(token: str) -> Any:
|
||||||
|
try:
|
||||||
|
from telebot import types # type: ignore
|
||||||
|
|
||||||
|
markup = types.InlineKeyboardMarkup()
|
||||||
|
markup.add(
|
||||||
|
types.InlineKeyboardButton(
|
||||||
|
"确认绑定",
|
||||||
|
callback_data=f"confirm_bind:{token}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return markup
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def handle_id(self, message: Any) -> None:
|
def handle_id(self, message: Any) -> None:
|
||||||
trace = CommandTrace("/id", message)
|
trace = CommandTrace("/id", message)
|
||||||
try:
|
try:
|
||||||
@@ -344,10 +422,7 @@ class BasicCommandHandler:
|
|||||||
|
|
||||||
for supabase_user_id in supabase_user_ids:
|
for supabase_user_id in supabase_user_ids:
|
||||||
try:
|
try:
|
||||||
if self.entitlement_service.has_active_subscription(
|
if self._has_paid_subscription(supabase_user_id):
|
||||||
supabase_user_id,
|
|
||||||
respect_requirement=False,
|
|
||||||
):
|
|
||||||
self.bot.approve_chat_join_request(int(chat_id), int(user_id))
|
self.bot.approve_chat_join_request(int(chat_id), int(user_id))
|
||||||
logger.info(
|
logger.info(
|
||||||
"telegram join request approved chat_id={} user_id={} supabase_user_id={}",
|
"telegram join request approved chat_id={} user_id={} supabase_user_id={}",
|
||||||
@@ -371,6 +446,40 @@ class BasicCommandHandler:
|
|||||||
reason="no_active_subscription",
|
reason="no_active_subscription",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _has_paid_subscription(self, supabase_user_id: str) -> bool:
|
||||||
|
if hasattr(self.entitlement_service, "get_subscription_window"):
|
||||||
|
window = self.entitlement_service.get_subscription_window(
|
||||||
|
supabase_user_id,
|
||||||
|
respect_requirement=False,
|
||||||
|
)
|
||||||
|
rows = window.get("rows") if isinstance(window, dict) else None
|
||||||
|
if isinstance(rows, list):
|
||||||
|
for row in rows:
|
||||||
|
if self._subscription_row_is_paid(row):
|
||||||
|
return True
|
||||||
|
if hasattr(self.entitlement_service, "get_latest_active_subscription"):
|
||||||
|
row = self.entitlement_service.get_latest_active_subscription(
|
||||||
|
supabase_user_id,
|
||||||
|
respect_requirement=False,
|
||||||
|
)
|
||||||
|
return self._subscription_row_is_paid(row)
|
||||||
|
return bool(
|
||||||
|
self.entitlement_service.has_active_subscription(
|
||||||
|
supabase_user_id,
|
||||||
|
respect_requirement=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subscription_row_is_paid(row: Any) -> bool:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
return False
|
||||||
|
plan_code = str(row.get("plan_code") or "").strip().lower()
|
||||||
|
source = str(row.get("source") or "").strip().lower()
|
||||||
|
if not plan_code and not source:
|
||||||
|
return False
|
||||||
|
return "trial" not in plan_code and "trial" not in source
|
||||||
|
|
||||||
def _handle_ineligible_join_request(self, chat_id: int, user_id: int, reason: str) -> str:
|
def _handle_ineligible_join_request(self, chat_id: int, user_id: int, reason: str) -> str:
|
||||||
action = str(os.getenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION") or "pending").strip().lower()
|
action = str(os.getenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION") or "pending").strip().lower()
|
||||||
if action in {"decline", "reject", "deny"}:
|
if action in {"decline", "reject", "deny"}:
|
||||||
|
|||||||
@@ -1464,6 +1464,37 @@ class DBManager:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return int(row["telegram_id"])
|
return int(row["telegram_id"])
|
||||||
|
|
||||||
|
def peek_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
|
||||||
|
if now > expires_at:
|
||||||
|
conn.execute("DELETE FROM web_telegram_bind_tokens WHERE token = ?", (token,))
|
||||||
|
conn.commit()
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"supabase_user_id": str(row["supabase_user_id"] or "").strip().lower(),
|
||||||
|
"supabase_email": str(row["supabase_email"] or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
def create_web_bind_token(
|
def create_web_bind_token(
|
||||||
self,
|
self,
|
||||||
supabase_user_id: str,
|
supabase_user_id: str,
|
||||||
|
|||||||
@@ -1927,19 +1927,17 @@ class PaymentContractCheckoutService:
|
|||||||
if isinstance(current_subscription, dict):
|
if isinstance(current_subscription, dict):
|
||||||
current_plan_code = str(current_subscription.get("plan_code") or "").strip().lower()
|
current_plan_code = str(current_subscription.get("plan_code") or "").strip().lower()
|
||||||
current_source = str(current_subscription.get("source") or "").strip().lower()
|
current_source = str(current_subscription.get("source") or "").strip().lower()
|
||||||
current_is_trial = "trial" in current_plan_code or "trial" in current_source
|
try:
|
||||||
if not current_is_trial:
|
latest_exp = datetime.fromisoformat(
|
||||||
try:
|
str(current_subscription.get("expires_at") or "").replace("Z", "+00:00")
|
||||||
latest_exp = datetime.fromisoformat(
|
)
|
||||||
str(current_subscription.get("expires_at") or "").replace("Z", "+00:00")
|
if latest_exp.tzinfo is None:
|
||||||
)
|
latest_exp = latest_exp.replace(tzinfo=timezone.utc)
|
||||||
if latest_exp.tzinfo is None:
|
latest_exp = latest_exp.astimezone(timezone.utc)
|
||||||
latest_exp = latest_exp.replace(tzinfo=timezone.utc)
|
if latest_exp > starts:
|
||||||
latest_exp = latest_exp.astimezone(timezone.utc)
|
starts = latest_exp
|
||||||
if latest_exp > starts:
|
except Exception:
|
||||||
starts = latest_exp
|
pass
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
expires = starts + timedelta(days=max(1, duration_days))
|
expires = starts + timedelta(days=max(1, duration_days))
|
||||||
sub_rows = self._rest(
|
sub_rows = self._rest(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1985,7 +1983,9 @@ class PaymentContractCheckoutService:
|
|||||||
user_id,
|
user_id,
|
||||||
respect_requirement=False,
|
respect_requirement=False,
|
||||||
)
|
)
|
||||||
if isinstance(latest_subscription, dict):
|
if isinstance(latest_subscription, dict) and not self._subscription_row_is_trial(
|
||||||
|
latest_subscription
|
||||||
|
):
|
||||||
return latest_subscription
|
return latest_subscription
|
||||||
|
|
||||||
plan = self._select_plan(intent.plan_code)
|
plan = self._select_plan(intent.plan_code)
|
||||||
@@ -2001,6 +2001,12 @@ class PaymentContractCheckoutService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subscription_row_is_trial(row: Dict[str, Any]) -> bool:
|
||||||
|
plan_code = str(row.get("plan_code") or "").strip().lower()
|
||||||
|
source = str(row.get("source") or "").strip().lower()
|
||||||
|
return "trial" in plan_code or "trial" in source
|
||||||
|
|
||||||
def _ensure_confirm_side_effects(
|
def _ensure_confirm_side_effects(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ class DummyBot:
|
|||||||
self.sent_messages = []
|
self.sent_messages = []
|
||||||
self.approved_join_requests = []
|
self.approved_join_requests = []
|
||||||
self.declined_join_requests = []
|
self.declined_join_requests = []
|
||||||
|
self.callback_handlers = []
|
||||||
|
|
||||||
def reply_to(self, message, text, parse_mode=None, disable_web_page_preview=None):
|
def reply_to(self, message, text, parse_mode=None, disable_web_page_preview=None, **kwargs):
|
||||||
self.replies.append(
|
self.replies.append(
|
||||||
{
|
{
|
||||||
"text": text,
|
"text": text,
|
||||||
"parse_mode": parse_mode,
|
"parse_mode": parse_mode,
|
||||||
"chat_id": message.chat.id,
|
"chat_id": message.chat.id,
|
||||||
"disable_web_page_preview": disable_web_page_preview,
|
"disable_web_page_preview": disable_web_page_preview,
|
||||||
|
"reply_markup": kwargs.get("reply_markup"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +52,13 @@ class DummyBot:
|
|||||||
|
|
||||||
return _decorator
|
return _decorator
|
||||||
|
|
||||||
|
def callback_query_handler(self, *args, **kwargs): # pragma: no cover - decorator stub
|
||||||
|
def _decorator(func):
|
||||||
|
self.callback_handlers.append((kwargs.get("func"), func))
|
||||||
|
return func
|
||||||
|
|
||||||
|
return _decorator
|
||||||
|
|
||||||
def approve_chat_join_request(self, chat_id, user_id):
|
def approve_chat_join_request(self, chat_id, user_id):
|
||||||
self.approved_join_requests.append({"chat_id": chat_id, "user_id": user_id})
|
self.approved_join_requests.append({"chat_id": chat_id, "user_id": user_id})
|
||||||
|
|
||||||
@@ -94,14 +103,12 @@ def test_basic_handler_diag_returns_html():
|
|||||||
def test_start_bind_token_binds_telegram_to_web_account():
|
def test_start_bind_token_binds_telegram_to_web_account():
|
||||||
bot = DummyBot()
|
bot = DummyBot()
|
||||||
db = SimpleNamespace(
|
db = SimpleNamespace(
|
||||||
consume_web_bind_token=lambda token: {
|
peek_web_bind_token=lambda token: {
|
||||||
"supabase_user_id": "user-1",
|
"supabase_user_id": "user-1",
|
||||||
"supabase_email": "u@example.com",
|
"supabase_email": "u@example.com",
|
||||||
}
|
}
|
||||||
if token == "abc123"
|
if token == "abc123"
|
||||||
else None,
|
else None,
|
||||||
upsert_user=lambda *_args, **_kwargs: None,
|
|
||||||
bind_supabase_identity=lambda **_kwargs: {"ok": True, "reason": "bound"},
|
|
||||||
)
|
)
|
||||||
io_layer = SimpleNamespace(
|
io_layer = SimpleNamespace(
|
||||||
build_welcome_text=lambda: "WELCOME",
|
build_welcome_text=lambda: "WELCOME",
|
||||||
@@ -124,7 +131,54 @@ def test_start_bind_token_binds_telegram_to_web_account():
|
|||||||
handler.handle_start_help(_message("/start bind_abc123"))
|
handler.handle_start_help(_message("/start bind_abc123"))
|
||||||
|
|
||||||
assert len(bot.replies) == 1
|
assert len(bot.replies) == 1
|
||||||
assert "账号绑定完成" in bot.replies[0]["text"]
|
assert "确认绑定" in bot.replies[0]["text"]
|
||||||
|
assert "u***@example.com" in bot.replies[0]["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_bind_callback_consumes_token_and_binds_account():
|
||||||
|
bot = DummyBot()
|
||||||
|
consumed = []
|
||||||
|
bound = []
|
||||||
|
|
||||||
|
def _consume(token):
|
||||||
|
consumed.append(token)
|
||||||
|
return {"supabase_user_id": "user-1", "supabase_email": "u@example.com"}
|
||||||
|
|
||||||
|
db = SimpleNamespace(
|
||||||
|
consume_web_bind_token=_consume,
|
||||||
|
upsert_user=lambda *_args, **_kwargs: None,
|
||||||
|
bind_supabase_identity=lambda **kwargs: bound.append(kwargs)
|
||||||
|
or {"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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
call = SimpleNamespace(
|
||||||
|
data="confirm_bind:abc123",
|
||||||
|
from_user=SimpleNamespace(id=12345, username="ada", first_name="Ada"),
|
||||||
|
message=_message("callback"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = handler.handle_bind_confirm_callback(call)
|
||||||
|
|
||||||
|
assert result == "bound"
|
||||||
|
assert consumed == ["abc123"]
|
||||||
|
assert bound[0]["telegram_id"] == 12345
|
||||||
|
assert bound[0]["supabase_user_id"] == "user-1"
|
||||||
|
|
||||||
|
|
||||||
def test_basic_handler_markets_returns_summary():
|
def test_basic_handler_markets_returns_summary():
|
||||||
@@ -253,6 +307,80 @@ def test_join_request_keeps_unbound_user_pending_by_default(monkeypatch):
|
|||||||
assert bot.declined_join_requests == []
|
assert bot.declined_join_requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_request_keeps_trial_user_pending(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||||
|
bot = DummyBot()
|
||||||
|
db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"])
|
||||||
|
io_layer = SimpleNamespace(
|
||||||
|
build_welcome_text=lambda: "WELCOME",
|
||||||
|
build_points_rank_text=lambda _user: "TOP",
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
entitlement = SimpleNamespace(
|
||||||
|
get_latest_active_subscription=lambda user_id, respect_requirement=False: {
|
||||||
|
"plan_code": "signup_trial_3d",
|
||||||
|
"source": "signup_trial",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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="-100123",
|
||||||
|
),
|
||||||
|
entitlement_service=entitlement,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = handler.handle_chat_join_request(_join_request())
|
||||||
|
|
||||||
|
assert result == "pending:no_active_subscription"
|
||||||
|
assert bot.approved_join_requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_request_approves_trial_user_with_queued_paid_subscription(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||||
|
bot = DummyBot()
|
||||||
|
db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"])
|
||||||
|
io_layer = SimpleNamespace(
|
||||||
|
build_welcome_text=lambda: "WELCOME",
|
||||||
|
build_points_rank_text=lambda _user: "TOP",
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
entitlement = SimpleNamespace(
|
||||||
|
get_subscription_window=lambda user_id, respect_requirement=False: {
|
||||||
|
"rows": [
|
||||||
|
{"plan_code": "signup_trial_3d", "source": "signup_trial"},
|
||||||
|
{"plan_code": "pro_monthly", "source": "payment_contract"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
get_latest_active_subscription=lambda user_id, respect_requirement=False: {
|
||||||
|
"plan_code": "signup_trial_3d",
|
||||||
|
"source": "signup_trial",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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="-100123",
|
||||||
|
),
|
||||||
|
entitlement_service=entitlement,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = handler.handle_chat_join_request(_join_request())
|
||||||
|
|
||||||
|
assert result == "approved"
|
||||||
|
assert bot.approved_join_requests == [{"chat_id": -100123, "user_id": 12345}]
|
||||||
|
|
||||||
|
|
||||||
def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch):
|
def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch):
|
||||||
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||||
monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline")
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline")
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ from src.payments.contract_checkout import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payment_env(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||||
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||||
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||||
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
||||||
|
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
||||||
|
|
||||||
|
|
||||||
def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
|
def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
|
||||||
db_path = tmp_path / "payments.db"
|
db_path = tmp_path / "payments.db"
|
||||||
db = DBManager(str(db_path))
|
db = DBManager(str(db_path))
|
||||||
@@ -24,6 +36,95 @@ def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
|
|||||||
assert events[0]["payload"]["events"] == 2
|
assert events[0]["payload"]["events"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_paid_subscription_starts_after_active_trial(monkeypatch, tmp_path):
|
||||||
|
_payment_env(monkeypatch, tmp_path)
|
||||||
|
service = PaymentContractCheckoutService()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
trial_expires = now + timedelta(days=2)
|
||||||
|
inserted = []
|
||||||
|
|
||||||
|
def fake_rest(method, table, **kwargs):
|
||||||
|
if method == "GET" and table == "subscriptions":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "trial-1",
|
||||||
|
"expires_at": trial_expires.isoformat(),
|
||||||
|
"status": "active",
|
||||||
|
"plan_code": "signup_trial_3d",
|
||||||
|
"source": "signup_trial",
|
||||||
|
"starts_at": (now - timedelta(days=1)).isoformat(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if method == "POST" and table == "subscriptions":
|
||||||
|
inserted.append(kwargs["payload"])
|
||||||
|
return [kwargs["payload"]]
|
||||||
|
if method == "POST" and table == "entitlement_events":
|
||||||
|
return [kwargs["payload"]]
|
||||||
|
raise AssertionError((method, table, kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||||
|
|
||||||
|
row = service._grant_subscription(
|
||||||
|
user_id="user-1",
|
||||||
|
plan_code="pro_monthly",
|
||||||
|
duration_days=30,
|
||||||
|
tx_hash="0x" + "7" * 64,
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
|
||||||
|
starts_at = datetime.fromisoformat(str(row["starts_at"]))
|
||||||
|
expires_at = datetime.fromisoformat(str(row["expires_at"]))
|
||||||
|
assert starts_at == trial_expires
|
||||||
|
assert expires_at == trial_expires + timedelta(days=30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_side_effect_repair_does_not_treat_trial_as_paid(monkeypatch, tmp_path):
|
||||||
|
_payment_env(monkeypatch, tmp_path)
|
||||||
|
service = PaymentContractCheckoutService()
|
||||||
|
intent = PaymentIntentRecord(
|
||||||
|
intent_id="intent-trial-repair",
|
||||||
|
order_id_hex="0x" + "1" * 64,
|
||||||
|
plan_code="pro_monthly",
|
||||||
|
plan_id=101,
|
||||||
|
chain_id=137,
|
||||||
|
amount_units=5000000,
|
||||||
|
amount_usdc="5",
|
||||||
|
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
||||||
|
token_decimals=6,
|
||||||
|
token_symbol="USDC.e",
|
||||||
|
receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
|
||||||
|
status="confirmed",
|
||||||
|
payment_mode="strict",
|
||||||
|
allowed_wallet="0x1111111111111111111111111111111111111111",
|
||||||
|
expires_at="2099-01-01T00:00:00+00:00",
|
||||||
|
tx_hash="0x" + "8" * 64,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
trial_row = {"plan_code": "signup_trial_3d", "source": "signup_trial"}
|
||||||
|
granted = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.payments.contract_checkout.SUPABASE_ENTITLEMENT.get_latest_active_subscription",
|
||||||
|
lambda user_id, respect_requirement=False: trial_row,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.payments.contract_checkout.SUPABASE_ENTITLEMENT.invalidate_subscription_cache",
|
||||||
|
lambda user_id: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_grant_subscription",
|
||||||
|
lambda **kwargs: granted.append(kwargs)
|
||||||
|
or {"plan_code": kwargs["plan_code"], "status": "active"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service._ensure_confirmed_subscription("user-1", intent, intent.tx_hash or "")
|
||||||
|
|
||||||
|
assert result["plan_code"] == "pro_monthly"
|
||||||
|
assert granted
|
||||||
|
|
||||||
|
|
||||||
def test_payment_checkout_parses_multiple_rpc_urls(monkeypatch, tmp_path):
|
def test_payment_checkout_parses_multiple_rpc_urls(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||||
@@ -284,7 +385,7 @@ def test_reconcile_recent_intents_dedupes_users(monkeypatch, tmp_path):
|
|||||||
assert seen == ["user-1", "user-2"]
|
assert seen == ["user-1", "user-2"]
|
||||||
|
|
||||||
|
|
||||||
def test_grant_subscription_starts_immediately_when_only_trial_is_active(monkeypatch, tmp_path):
|
def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||||
@@ -320,7 +421,6 @@ def test_grant_subscription_starts_immediately_when_only_trial_is_active(monkeyp
|
|||||||
|
|
||||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||||
|
|
||||||
before_call = datetime.now(timezone.utc)
|
|
||||||
result = service._grant_subscription(
|
result = service._grant_subscription(
|
||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
plan_code="pro_monthly",
|
plan_code="pro_monthly",
|
||||||
@@ -328,9 +428,7 @@ def test_grant_subscription_starts_immediately_when_only_trial_is_active(monkeyp
|
|||||||
tx_hash="0x" + "1" * 64,
|
tx_hash="0x" + "1" * 64,
|
||||||
payload={"kind": "test"},
|
payload={"kind": "test"},
|
||||||
)
|
)
|
||||||
after_call = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
starts_at = datetime.fromisoformat(str(result["starts_at"]).replace("Z", "+00:00"))
|
starts_at = datetime.fromisoformat(str(result["starts_at"]).replace("Z", "+00:00"))
|
||||||
|
|
||||||
assert starts_at >= before_call - timedelta(seconds=1)
|
assert starts_at == trial_end
|
||||||
assert starts_at <= after_call
|
|
||||||
|
|||||||
Reference in New Issue
Block a user