feat: implement basic command handlers, orchestrator, database manager, and account binding UI
This commit is contained in:
@@ -233,7 +233,7 @@ const WALLETCONNECT_POLYGON_RPC_URL = String(
|
|||||||
process.env.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ||
|
process.env.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ||
|
||||||
"https://polygon-bor-rpc.publicnode.com",
|
"https://polygon-bor-rpc.publicnode.com",
|
||||||
).trim();
|
).trim();
|
||||||
const TELEGRAM_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx";
|
const TELEGRAM_GROUP_URL = "https://t.me/+Se93RpNQ58FhYmZh";
|
||||||
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();
|
||||||
@@ -298,7 +298,9 @@ const InfoRow = ({
|
|||||||
<div className="shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
<div className="shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||||
{Icon && <Icon size={18} />}
|
{Icon && <Icon size={18} />}
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-0 text-slate-400 text-sm font-medium leading-5">{label}</span>
|
<span className="min-w-0 text-slate-400 text-sm font-medium leading-5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
|
className={`min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
|
||||||
@@ -733,12 +735,10 @@ export function AccountCenter() {
|
|||||||
telegramBotLink: isEn
|
telegramBotLink: isEn
|
||||||
? "Open Bot (@WeatherQuant_bot)"
|
? "Open Bot (@WeatherQuant_bot)"
|
||||||
: "打开机器人 (@WeatherQuant_bot)",
|
: "打开机器人 (@WeatherQuant_bot)",
|
||||||
telegramGroupLink: isEn
|
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
|
||||||
? "Apply to Join Pro Telegram Group"
|
|
||||||
: "申请加入 Pro Telegram 群(需审核)",
|
|
||||||
telegramTopicsGroupLink: isEn
|
telegramTopicsGroupLink: isEn
|
||||||
? "Apply to City Topics Group (Pro)"
|
? "Real-time Weather Updates"
|
||||||
: "申请加入城市话题群(Pro,需审核)",
|
: "城市实测温度群",
|
||||||
copyCommand: isEn ? "Copy command" : "复制命令",
|
copyCommand: isEn ? "Copy command" : "复制命令",
|
||||||
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
||||||
paymentToken: isEn ? "Payment Token" : "支付币种",
|
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||||
@@ -869,7 +869,9 @@ export function AccountCenter() {
|
|||||||
const [paymentError, setPaymentError] = useState("");
|
const [paymentError, setPaymentError] = useState("");
|
||||||
const [lastIntentId, setLastIntentId] = useState("");
|
const [lastIntentId, setLastIntentId] = useState("");
|
||||||
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 [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
|
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
|
||||||
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
||||||
@@ -1346,17 +1348,13 @@ export function AccountCenter() {
|
|||||||
const parsed = JSON.parse(raw) as PaymentRecoveryState;
|
const parsed = JSON.parse(raw) as PaymentRecoveryState;
|
||||||
const userId = String(parsed?.userId || "").trim();
|
const userId = String(parsed?.userId || "").trim();
|
||||||
const intentId = String(parsed?.intentId || "").trim();
|
const intentId = String(parsed?.intentId || "").trim();
|
||||||
const txHash = String(parsed?.txHash || "").trim().toLowerCase();
|
const txHash = String(parsed?.txHash || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
const createdAt = Number(parsed?.createdAt || 0);
|
const createdAt = Number(parsed?.createdAt || 0);
|
||||||
const expired =
|
const expired =
|
||||||
!createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
|
!createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
|
||||||
if (
|
if (expired || !intentId || !txHash || !userId || userId !== authUserId) {
|
||||||
expired ||
|
|
||||||
!intentId ||
|
|
||||||
!txHash ||
|
|
||||||
!userId ||
|
|
||||||
userId !== authUserId
|
|
||||||
) {
|
|
||||||
clearStoredPaymentRecovery();
|
clearStoredPaymentRecovery();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1543,7 +1541,9 @@ export function AccountCenter() {
|
|||||||
);
|
);
|
||||||
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
|
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
|
||||||
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
|
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
|
||||||
const reminderExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw || totalExpiryRaw;
|
const reminderExpiryRaw = isSubscribed
|
||||||
|
? totalExpiryRaw
|
||||||
|
: currentExpiryRaw || totalExpiryRaw;
|
||||||
const expiryInfo = parseSubscriptionExpiry(reminderExpiryRaw);
|
const expiryInfo = parseSubscriptionExpiry(reminderExpiryRaw);
|
||||||
const expiryFormatted = formatTime(displayExpiryRaw, locale);
|
const expiryFormatted = formatTime(displayExpiryRaw, locale);
|
||||||
const currentExpiryFormatted = formatTime(currentExpiryRaw, locale);
|
const currentExpiryFormatted = formatTime(currentExpiryRaw, locale);
|
||||||
@@ -1555,16 +1555,18 @@ export function AccountCenter() {
|
|||||||
: copy.noProSubscription;
|
: copy.noProSubscription;
|
||||||
const showExpiringSoon = Boolean(
|
const showExpiringSoon = Boolean(
|
||||||
isSubscribed &&
|
isSubscribed &&
|
||||||
!hasQueuedExtension &&
|
!hasQueuedExtension &&
|
||||||
expiryInfo &&
|
expiryInfo &&
|
||||||
!expiryInfo.expired &&
|
!expiryInfo.expired &&
|
||||||
expiryInfo.daysLeft <= 3,
|
expiryInfo.daysLeft <= 3,
|
||||||
|
);
|
||||||
|
const showExpiredReminder = Boolean(
|
||||||
|
!isSubscribed && expiryInfo && expiryInfo.expired,
|
||||||
);
|
);
|
||||||
const showExpiredReminder = Boolean(!isSubscribed && expiryInfo && expiryInfo.expired);
|
|
||||||
const paymentFeatureReady = paymentReadyForRecovery;
|
const paymentFeatureReady = paymentReadyForRecovery;
|
||||||
const canOpenCheckoutOverlay = Boolean(
|
const canOpenCheckoutOverlay = Boolean(
|
||||||
paymentFeatureReady &&
|
paymentFeatureReady &&
|
||||||
(!isSubscribed || isTrialPlan || showExpiringSoon || showExpiredReminder),
|
(!isSubscribed || isTrialPlan || showExpiringSoon || showExpiredReminder),
|
||||||
);
|
);
|
||||||
const subscriptionStatusTitle = showExpiredReminder
|
const subscriptionStatusTitle = showExpiredReminder
|
||||||
? isTrialPlan
|
? isTrialPlan
|
||||||
@@ -1607,12 +1609,12 @@ export function AccountCenter() {
|
|||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
canOpenCheckoutOverlay,
|
canOpenCheckoutOverlay,
|
||||||
planCode,
|
planCode,
|
||||||
showExpiredReminder,
|
showExpiredReminder,
|
||||||
showExpiringSoon,
|
showExpiringSoon,
|
||||||
showOverlay,
|
showOverlay,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Points Logic
|
// Points Logic
|
||||||
const backendPointsRaw = Number(backend?.points);
|
const backendPointsRaw = Number(backend?.points);
|
||||||
@@ -2472,7 +2474,9 @@ export function AccountCenter() {
|
|||||||
}
|
}
|
||||||
const created = (await createRes.json()) as CreatedIntent;
|
const created = (await createRes.json()) as CreatedIntent;
|
||||||
const direct = created.direct_payment;
|
const direct = created.direct_payment;
|
||||||
const intentId = String(created.intent?.intent_id || direct?.intent_id || "");
|
const intentId = String(
|
||||||
|
created.intent?.intent_id || direct?.intent_id || "",
|
||||||
|
);
|
||||||
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
|
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
|
||||||
throw new Error("manual payment payload invalid");
|
throw new Error("manual payment payload invalid");
|
||||||
}
|
}
|
||||||
@@ -2498,8 +2502,12 @@ export function AccountCenter() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submitManualPaymentTx = async () => {
|
const submitManualPaymentTx = async () => {
|
||||||
const txHashNorm = String(manualTxHash || "").trim().toLowerCase();
|
const txHashNorm = String(manualTxHash || "")
|
||||||
const intentId = String(lastIntentId || manualPayment?.intent_id || "").trim();
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const intentId = String(
|
||||||
|
lastIntentId || manualPayment?.intent_id || "",
|
||||||
|
).trim();
|
||||||
if (!intentId || !manualPayment) {
|
if (!intentId || !manualPayment) {
|
||||||
setPaymentError("请先创建手动转账订单。");
|
setPaymentError("请先创建手动转账订单。");
|
||||||
return;
|
return;
|
||||||
@@ -2512,20 +2520,26 @@ export function AccountCenter() {
|
|||||||
setPaymentError("");
|
setPaymentError("");
|
||||||
try {
|
try {
|
||||||
const authHeaders = await buildAuthedHeaders(true, false);
|
const authHeaders = await buildAuthedHeaders(true, false);
|
||||||
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
|
const submitRes = await fetch(
|
||||||
method: "POST",
|
`/api/payments/intents/${intentId}/submit`,
|
||||||
headers: authHeaders,
|
{
|
||||||
body: JSON.stringify({ tx_hash: txHashNorm }),
|
method: "POST",
|
||||||
});
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||||
|
},
|
||||||
|
);
|
||||||
if (!submitRes.ok) {
|
if (!submitRes.ok) {
|
||||||
const raw = (await submitRes.text()).slice(0, 350);
|
const raw = (await submitRes.text()).slice(0, 350);
|
||||||
throw new Error(`submit tx failed: ${raw}`);
|
throw new Error(`submit tx failed: ${raw}`);
|
||||||
}
|
}
|
||||||
const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, {
|
const confirmRes = await fetch(
|
||||||
method: "POST",
|
`/api/payments/intents/${intentId}/confirm`,
|
||||||
headers: authHeaders,
|
{
|
||||||
body: JSON.stringify({ tx_hash: txHashNorm }),
|
method: "POST",
|
||||||
});
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||||
|
},
|
||||||
|
);
|
||||||
if (!confirmRes.ok) {
|
if (!confirmRes.ok) {
|
||||||
const raw = (await confirmRes.text()).slice(0, 350);
|
const raw = (await confirmRes.text()).slice(0, 350);
|
||||||
const lowerRaw = raw.toLowerCase();
|
const lowerRaw = raw.toLowerCase();
|
||||||
@@ -2535,7 +2549,9 @@ export function AccountCenter() {
|
|||||||
(lowerRaw.includes("confirmations not enough") ||
|
(lowerRaw.includes("confirmations not enough") ||
|
||||||
lowerRaw.includes("tx indexed partially")));
|
lowerRaw.includes("tx indexed partially")));
|
||||||
if (maybePending) {
|
if (maybePending) {
|
||||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
setPaymentInfo(
|
||||||
|
`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`,
|
||||||
|
);
|
||||||
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
|
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2627,17 +2643,17 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!showOverlay && canOpenCheckoutOverlay && (
|
{!showOverlay && canOpenCheckoutOverlay && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowOverlay(true)}
|
onClick={() => setShowOverlay(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
|
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
|
||||||
>
|
>
|
||||||
<Crown size={16} />{" "}
|
<Crown size={16} />{" "}
|
||||||
{showExpiringSoon || showExpiredReminder
|
{showExpiringSoon || showExpiredReminder
|
||||||
? copy.renewNow
|
? copy.renewNow
|
||||||
: copy.upgradePro}
|
: copy.upgradePro}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onRefresh()}
|
onClick={() => void onRefresh()}
|
||||||
@@ -2688,8 +2704,8 @@ export function AccountCenter() {
|
|||||||
) : null}
|
) : null}
|
||||||
{billing.canRedeem ? (
|
{billing.canRedeem ? (
|
||||||
<p className="mt-2 text-xs text-emerald-200/90">
|
<p className="mt-2 text-xs text-emerald-200/90">
|
||||||
当前可用 {billing.pointsUsed} 积分抵扣 ${billing.discountAmount.toFixed(2)},
|
当前可用 {billing.pointsUsed} 积分抵扣 $
|
||||||
续费时会自动生效。
|
{billing.discountAmount.toFixed(2)}, 续费时会自动生效。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -2826,7 +2842,8 @@ export function AccountCenter() {
|
|||||||
<div className="mt-6 flex items-start gap-2 p-3 bg-black/20 rounded-xl">
|
<div className="mt-6 flex items-start gap-2 p-3 bg-black/20 rounded-xl">
|
||||||
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
|
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
|
||||||
<p className="text-[10px] text-slate-500 leading-normal italic">
|
<p className="text-[10px] text-slate-500 leading-normal italic">
|
||||||
积分规则:群内有效发言(自动防刷检测)+ 每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。
|
积分规则:群内有效发言(自动防刷检测)+
|
||||||
|
每日首条发言额外奖励。每周一零点结算周榜,所有活跃用户均享参与奖。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2843,9 +2860,9 @@ export function AccountCenter() {
|
|||||||
|
|
||||||
{/* Subscription Info & Paywall */}
|
{/* Subscription Info & Paywall */}
|
||||||
<div className="lg:col-span-12 relative">
|
<div className="lg:col-span-12 relative">
|
||||||
<div
|
<div
|
||||||
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
|
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
|
||||||
>
|
>
|
||||||
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
||||||
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
|
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
|
||||||
{copy.membershipDetails}
|
{copy.membershipDetails}
|
||||||
@@ -2968,11 +2985,13 @@ export function AccountCenter() {
|
|||||||
Telegram 群成员价格
|
Telegram 群成员价格
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
|
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
|
||||||
已验证群成员身份,当前会员价 {backend.telegram_pricing.amount_usdc ?? "5"}U。
|
已验证群成员身份,当前会员价{" "}
|
||||||
|
{backend.telegram_pricing.amount_usdc ?? "5"}U。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<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 ?? "5"}U · 群成员
|
当前价格: {backend.telegram_pricing.amount_usdc ?? "5"}U
|
||||||
|
· 群成员
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3125,7 +3144,8 @@ export function AccountCenter() {
|
|||||||
手动转账(无需绑定钱包)
|
手动转账(无需绑定钱包)
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
|
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
|
||||||
先创建订单,向唯一收款地址转账,完成后提交 tx hash 自动开通。请不要和钱包支付同时使用。
|
先创建订单,向唯一收款地址转账,完成后提交 tx hash
|
||||||
|
自动开通。请不要和钱包支付同时使用。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -3173,7 +3193,9 @@ export function AccountCenter() {
|
|||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
value={manualTxHash}
|
value={manualTxHash}
|
||||||
onChange={(event) => setManualTxHash(event.target.value)}
|
onChange={(event) =>
|
||||||
|
setManualTxHash(event.target.value)
|
||||||
|
}
|
||||||
placeholder="0x..."
|
placeholder="0x..."
|
||||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50"
|
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import os
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from loguru import logger # type: ignore
|
||||||
|
|
||||||
from src.bot.command_parser import extract_command_name
|
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.supabase_entitlement import SUPABASE_ENTITLEMENT
|
||||||
from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBER_STATUSES
|
from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBER_STATUSES
|
||||||
|
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
||||||
|
|
||||||
_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"}
|
||||||
@@ -21,11 +25,13 @@ class BasicCommandHandler:
|
|||||||
io_layer: BotIOLayer,
|
io_layer: BotIOLayer,
|
||||||
runtime_status_provider: Callable[[], RuntimeStatus],
|
runtime_status_provider: Callable[[], RuntimeStatus],
|
||||||
config: dict | None = None,
|
config: dict | None = None,
|
||||||
|
entitlement_service: Any | None = None,
|
||||||
):
|
):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.io_layer = io_layer
|
self.io_layer = io_layer
|
||||||
self.runtime_status_provider = runtime_status_provider
|
self.runtime_status_provider = runtime_status_provider
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
|
self.entitlement_service = entitlement_service or SUPABASE_ENTITLEMENT
|
||||||
|
|
||||||
def register(self) -> None:
|
def register(self) -> None:
|
||||||
@self.bot.message_handler(commands=["start", "help"])
|
@self.bot.message_handler(commands=["start", "help"])
|
||||||
@@ -56,6 +62,11 @@ class BasicCommandHandler:
|
|||||||
def _markets(message):
|
def _markets(message):
|
||||||
self._dispatch(message)
|
self._dispatch(message)
|
||||||
|
|
||||||
|
if hasattr(self.bot, "chat_join_request_handler"):
|
||||||
|
@self.bot.chat_join_request_handler(func=lambda request: True)
|
||||||
|
def _chat_join_request(request):
|
||||||
|
self.handle_chat_join_request(request)
|
||||||
|
|
||||||
@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(
|
||||||
@@ -246,6 +257,93 @@ class BasicCommandHandler:
|
|||||||
finally:
|
finally:
|
||||||
trace.emit()
|
trace.emit()
|
||||||
|
|
||||||
|
def handle_chat_join_request(self, request: Any) -> str:
|
||||||
|
chat = getattr(request, "chat", None)
|
||||||
|
user = getattr(request, "from_user", None)
|
||||||
|
chat_id = getattr(chat, "id", None)
|
||||||
|
user_id = getattr(user, "id", None)
|
||||||
|
if chat_id is None or user_id is None:
|
||||||
|
logger.warning("telegram join request missing chat_id/user_id")
|
||||||
|
return "ignored:invalid_request"
|
||||||
|
|
||||||
|
configured_chat_ids = {str(value).strip() for value in get_telegram_chat_ids_from_env() if str(value).strip()}
|
||||||
|
configured_chat_ids.update(
|
||||||
|
str(value).strip()
|
||||||
|
for value in [
|
||||||
|
os.getenv("POLYWEATHER_TELEGRAM_GROUP_ID"),
|
||||||
|
os.getenv("POLYWEATHER_TELEGRAM_TOPICS_GROUP_ID"),
|
||||||
|
]
|
||||||
|
if str(value or "").strip()
|
||||||
|
)
|
||||||
|
if configured_chat_ids and str(chat_id) not in configured_chat_ids:
|
||||||
|
logger.info(
|
||||||
|
"telegram join request ignored for non-configured chat chat_id={} user_id={}",
|
||||||
|
chat_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return "ignored:chat_not_configured"
|
||||||
|
|
||||||
|
try:
|
||||||
|
supabase_user_ids = self.io_layer.db.list_supabase_user_ids_for_telegram(int(user_id))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("telegram join request binding lookup failed user_id={}: {}", user_id, exc)
|
||||||
|
return "pending:lookup_error"
|
||||||
|
|
||||||
|
if not supabase_user_ids:
|
||||||
|
return self._handle_ineligible_join_request(
|
||||||
|
chat_id=int(chat_id),
|
||||||
|
user_id=int(user_id),
|
||||||
|
reason="unbound",
|
||||||
|
)
|
||||||
|
|
||||||
|
for supabase_user_id in supabase_user_ids:
|
||||||
|
try:
|
||||||
|
if self.entitlement_service.has_active_subscription(
|
||||||
|
supabase_user_id,
|
||||||
|
respect_requirement=False,
|
||||||
|
):
|
||||||
|
self.bot.approve_chat_join_request(int(chat_id), int(user_id))
|
||||||
|
logger.info(
|
||||||
|
"telegram join request approved chat_id={} user_id={} supabase_user_id={}",
|
||||||
|
chat_id,
|
||||||
|
user_id,
|
||||||
|
supabase_user_id,
|
||||||
|
)
|
||||||
|
return "approved"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"telegram join request entitlement lookup failed user_id={} supabase_user_id={}: {}",
|
||||||
|
user_id,
|
||||||
|
supabase_user_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return "pending:entitlement_error"
|
||||||
|
|
||||||
|
return self._handle_ineligible_join_request(
|
||||||
|
chat_id=int(chat_id),
|
||||||
|
user_id=int(user_id),
|
||||||
|
reason="no_active_subscription",
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
if action in {"decline", "reject", "deny"}:
|
||||||
|
self.bot.decline_chat_join_request(chat_id, user_id)
|
||||||
|
logger.info(
|
||||||
|
"telegram join request declined chat_id={} user_id={} reason={}",
|
||||||
|
chat_id,
|
||||||
|
user_id,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
return f"declined:{reason}"
|
||||||
|
logger.info(
|
||||||
|
"telegram join request left pending chat_id={} user_id={} reason={}",
|
||||||
|
chat_id,
|
||||||
|
user_id,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
return f"pending:{reason}"
|
||||||
|
|
||||||
def handle_unbind(self, message: Any) -> None:
|
def handle_unbind(self, message: Any) -> None:
|
||||||
trace = CommandTrace("/unbind", message)
|
trace = CommandTrace("/unbind", message)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -103,4 +103,4 @@ def start_bot() -> None:
|
|||||||
started_count,
|
started_count,
|
||||||
len(runtime_status.loops),
|
len(runtime_status.loops),
|
||||||
)
|
)
|
||||||
bot.infinity_polling()
|
bot.infinity_polling(allowed_updates=["message", "chat_join_request"])
|
||||||
|
|||||||
@@ -988,6 +988,38 @@ class DBManager:
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def list_supabase_user_ids_for_telegram(self, telegram_id: int) -> List[str]:
|
||||||
|
"""Return all Supabase accounts currently bound to a Telegram user."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT supabase_user_id
|
||||||
|
FROM supabase_bindings
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
ORDER BY updated_at DESC, supabase_user_id ASC
|
||||||
|
""",
|
||||||
|
(int(telegram_id),),
|
||||||
|
).fetchall()
|
||||||
|
ids = {
|
||||||
|
str(row["supabase_user_id"] or "").strip().lower()
|
||||||
|
for row in rows
|
||||||
|
if str(row["supabase_user_id"] or "").strip()
|
||||||
|
}
|
||||||
|
legacy = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT supabase_user_id
|
||||||
|
FROM users
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(telegram_id),),
|
||||||
|
).fetchone()
|
||||||
|
legacy_id = str((legacy["supabase_user_id"] if legacy else "") or "").strip().lower()
|
||||||
|
if legacy_id:
|
||||||
|
ids.add(legacy_id)
|
||||||
|
return sorted(ids)
|
||||||
|
|
||||||
def search_users(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
def search_users(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
text = str(query or "").strip()
|
text = str(query or "").strip()
|
||||||
safe_limit = max(1, min(int(limit or 20), 100))
|
safe_limit = max(1, min(int(limit or 20), 100))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ class DummyBot:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.replies = []
|
self.replies = []
|
||||||
self.sent_messages = []
|
self.sent_messages = []
|
||||||
|
self.approved_join_requests = []
|
||||||
|
self.declined_join_requests = []
|
||||||
|
|
||||||
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):
|
||||||
self.replies.append(
|
self.replies.append(
|
||||||
@@ -41,6 +43,19 @@ class DummyBot:
|
|||||||
|
|
||||||
return _decorator
|
return _decorator
|
||||||
|
|
||||||
|
def chat_join_request_handler(self, *args, **kwargs): # pragma: no cover - decorator stub
|
||||||
|
def _decorator(func):
|
||||||
|
self.join_request_handler = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
return _decorator
|
||||||
|
|
||||||
|
def approve_chat_join_request(self, chat_id, user_id):
|
||||||
|
self.approved_join_requests.append({"chat_id": chat_id, "user_id": user_id})
|
||||||
|
|
||||||
|
def decline_chat_join_request(self, chat_id, user_id):
|
||||||
|
self.declined_join_requests.append({"chat_id": chat_id, "user_id": user_id})
|
||||||
|
|
||||||
|
|
||||||
def _message(text: str):
|
def _message(text: str):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
@@ -127,3 +142,107 @@ def test_basic_handler_markets_rejects_channel_chat():
|
|||||||
|
|
||||||
assert len(bot.replies) == 1
|
assert len(bot.replies) == 1
|
||||||
assert "仅支持私聊机器人查询" in bot.replies[0]["text"]
|
assert "仅支持私聊机器人查询" in bot.replies[0]["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def _join_request(user_id: int = 12345, chat_id: int = -100123):
|
||||||
|
return SimpleNamespace(
|
||||||
|
from_user=SimpleNamespace(id=user_id, username="ada", first_name="Ada"),
|
||||||
|
chat=SimpleNamespace(id=chat_id, title="PolyWeather Pro"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_request_auto_approves_bound_active_pro_user(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||||
|
bot = DummyBot()
|
||||||
|
db = SimpleNamespace(
|
||||||
|
list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"]
|
||||||
|
if telegram_id == 12345
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
io_layer = SimpleNamespace(
|
||||||
|
build_welcome_text=lambda: "WELCOME",
|
||||||
|
build_points_rank_text=lambda _user: "TOP",
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
entitlement = SimpleNamespace(
|
||||||
|
has_active_subscription=lambda user_id, respect_requirement=False: user_id == "user-1"
|
||||||
|
)
|
||||||
|
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}]
|
||||||
|
assert bot.declined_join_requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_request_keeps_unbound_user_pending_by_default(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||||
|
bot = DummyBot()
|
||||||
|
db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: [])
|
||||||
|
io_layer = SimpleNamespace(
|
||||||
|
build_welcome_text=lambda: "WELCOME",
|
||||||
|
build_points_rank_text=lambda _user: "TOP",
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
entitlement = SimpleNamespace(has_active_subscription=lambda *_args, **_kwargs: False)
|
||||||
|
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:unbound"
|
||||||
|
assert bot.approved_join_requests == []
|
||||||
|
assert bot.declined_join_requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||||
|
monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline")
|
||||||
|
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(has_active_subscription=lambda *_args, **_kwargs: False)
|
||||||
|
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 == "declined:no_active_subscription"
|
||||||
|
assert bot.approved_join_requests == []
|
||||||
|
assert bot.declined_join_requests == [{"chat_id": -100123, "user_id": 12345}]
|
||||||
|
|||||||
Reference in New Issue
Block a user