fix chart refresh and bot command surface

This commit is contained in:
2569718930@qq.com
2026-06-13 01:34:09 +08:00
parent cf35e55da4
commit f115be1fd3
26 changed files with 328 additions and 532 deletions
+1
View File
@@ -75,6 +75,7 @@ OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1 POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600 POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45 POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS=3000
POLYWEATHER_HTTP_TIMEOUT_SEC=8 POLYWEATHER_HTTP_TIMEOUT_SEC=8
POLYWEATHER_HTTP_RETRY_COUNT=0 POLYWEATHER_HTTP_RETRY_COUNT=0
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2 POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
+1 -3
View File
@@ -25,7 +25,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
- Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`. - Subscription live: `Pro Monthly 29.9 USDC / 30 days` and `Pro Quarterly 79.9 USDC / 90 days`.
- Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month. - Referral pricing live: invited users can get the first monthly Pro at `20 USDC`; inviters receive `3500` points after a valid first Pro payment, capped at 10 paid invites per month.
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops. - Points are redeemable for payment discounts (`500 pts = 1 USDC`, monthly max `3 USDC`, quarterly max `8 USDC`). Useful user feedback can also receive manual point rewards through ops.
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation. - Onchain checkout live: Polygon contract checkout (USDC / USDC.e) plus Ethereum mainnet USDC direct-transfer confirmation.
- Auto-reconciliation live: event listener + periodic confirm loop. - Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage. - Ops dashboard live: `/ops` for memberships, leaderboard, user feedback triage, manual point grants, and payment incident triage.
@@ -195,8 +195,6 @@ Production payment routes are configured by the backend. Polygon remains the def
| Command | Purpose | | Command | Purpose |
| :-- | :-- | | :-- | :-- |
| `/city <name>` | City real-time analysis |
| `/deb <name>` | DEB historical reconciliation |
| `/top` | User leaderboard | | `/top` | User leaderboard |
| `/id` | Show current chat ID | | `/id` | Show current chat ID |
| `/diag` | Startup diagnostics | | `/diag` | Startup diagnostics |
+1 -3
View File
@@ -18,7 +18,7 @@
- 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天` - 已上线订阅制:`Pro 月付 29.9 USDC / 30 天``Pro 季度 79.9 USDC / 90 天`
- 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。 - 积分获取已切换为邀请制度:被邀请人完成首次 Pro 付款后,邀请人获得 `3500` 积分;Telegram 群发言不再获得积分。
- `/city``/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。 - 积分可用于支付抵扣(`500 分 = 1 USDC`,月付最多抵 `3 USDC`,季度最多抵 `8 USDC`)。真实、有上下文、有价值的用户反馈也可通过运营后台人工奖励积分。
- 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。 - 邀请首月价:被邀请人首次月付 `20 USDC`;每个邀请人每月最多 10 个有效付费邀请奖励。
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认。 - 已上线链上支付:Polygon 合约支付(USDC / USDC.e+ Ethereum 主网 USDC 直转确认。
- 已上线自动补单:事件监听 + 周期确认双链路。 - 已上线自动补单:事件监听 + 周期确认双链路。
@@ -205,8 +205,6 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
| 指令 | 用途 | | 指令 | 用途 |
| :-- | :-- | | :-- | :-- |
| `/city <name>` | 城市实时分析 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户积分排行 | | `/top` | 用户积分排行 |
| `/id` | 查看聊天 Chat ID | | `/id` | 查看聊天 Chat ID |
| `/diag` | Bot 启动诊断 | | `/diag` | Bot 启动诊断 |
+1
View File
@@ -103,6 +103,7 @@ services:
POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: '' POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''
POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3} POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}
POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-2} POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-2}
POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}
POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000} POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}
POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60} POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMOS_SEC:-60}
POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60} POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_AMSC_SEC:-60}
@@ -923,7 +923,7 @@ export function LiveTemperatureThresholdChart({
lastPatchAtRef.current = now; lastPatchAtRef.current = now;
markDetailRequest("network"); markDetailRequest("network");
fetchHourlyForecastForCity(city, { resolution: targetResolution }) fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })
.then((data) => { .then((data) => {
if (cancelled) return; if (cancelled) return;
if (!data) { if (!data) {
@@ -20,6 +20,7 @@ import {
__resetHourlyDetailRequestQueueForTest, __resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest, __runQueuedHourlyDetailRequestForTest,
clearCityDetailCache, clearCityDetailCache,
fetchHourlyForecastForCity,
readCityDetailBatchDiagnostics, readCityDetailBatchDiagnostics,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -64,6 +65,13 @@ export async function runTests() {
!querySource.includes("window.setInterval"), !querySource.includes("window.setInterval"),
"scan list should subscribe to SSE patch state instead of running a 5-minute interval", "scan list should subscribe to SSE patch state instead of running a 5-minute interval",
); );
assert(
querySource.includes("handleForegroundScanRefresh") &&
querySource.includes('document.visibilityState !== "visible"') &&
querySource.includes("SCAN_CACHE_TTL_MS") &&
querySource.includes("fetchScanTerminal({ forceRefresh: false, showLoading: false })"),
"scan list should silently revalidate stale rows when a long-hidden browser tab returns to the foreground",
);
assert( assert(
chartLogicSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && chartLogicSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chartSource.includes("window.setInterval"), !chartSource.includes("window.setInterval"),
@@ -79,8 +87,10 @@ export async function runTests() {
assert( assert(
chartSource.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation") && chartSource.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation") &&
chartSource.includes("refreshCachedDetail") && chartSource.includes("refreshCachedDetail") &&
chartSource.includes("fetchHourlyForecastForCity(city, { resolution: targetResolution })"), chartSource.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") &&
"visible charts should do a lightweight cached detail refresh every observation cadence when no SSE patch arrives", chartLogicSource.includes("options.bypassLocalCache") &&
chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"),
"visible charts should bypass the five-minute browser detail cache every observation cadence without force-refreshing backend sources",
); );
assert( assert(
chartSource.includes("preloadTemperatureChartCanvas"), chartSource.includes("preloadTemperatureChartCanvas"),
@@ -442,6 +452,7 @@ export async function runTests() {
const originalWindow = (globalThis as any).window; const originalWindow = (globalThis as any).window;
const originalSessionStorage = (globalThis as any).sessionStorage; const originalSessionStorage = (globalThis as any).sessionStorage;
const originalFetch = (globalThis as any).fetch;
const store = new Map<string, string>(); const store = new Map<string, string>();
(globalThis as any).window = {}; (globalThis as any).window = {};
(globalThis as any).sessionStorage = { (globalThis as any).sessionStorage = {
@@ -462,6 +473,50 @@ export async function runTests() {
}, },
}; };
try { try {
clearCityDetailCache();
const revalidationUrls: string[] = [];
let revalidationFetches = 0;
(globalThis as any).fetch = async (url: string) => {
revalidationFetches += 1;
revalidationUrls.push(String(url));
return {
ok: true,
json: async () => ({
cities: ["fallback-revalidate"],
details: {
"fallback-revalidate": {
city: "fallback-revalidate",
hourly: {
times: ["00:00"],
temps: [revalidationFetches],
},
},
},
errors: {},
missing: [],
partial: false,
}),
};
};
const firstDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" });
const cachedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" });
const revalidatedDetail = await fetchHourlyForecastForCity("fallback-revalidate", {
bypassLocalCache: true,
resolution: "10m",
});
assert(
firstDetail?.temps[0] === 1 &&
cachedDetail?.temps[0] === 1 &&
revalidatedDetail?.temps[0] === 2 &&
revalidationFetches === 2,
"bypassLocalCache should revalidate cached chart detail through the network",
);
assert(
revalidationUrls.every((url) => url.includes("force_refresh=false")),
"bypassLocalCache must not force-refresh backend observation sources",
);
clearCityDetailCache(); clearCityDetailCache();
const cacheKey = "paris:10m"; const cacheKey = "paris:10m";
store.set( store.set(
@@ -490,6 +545,7 @@ export async function runTests() {
clearCityDetailCache(); clearCityDetailCache();
(globalThis as any).window = originalWindow; (globalThis as any).window = originalWindow;
(globalThis as any).sessionStorage = originalSessionStorage; (globalThis as any).sessionStorage = originalSessionStorage;
(globalThis as any).fetch = originalFetch;
} }
} }
@@ -231,10 +231,10 @@ export function runTests() {
); );
const fallbackRefreshBlock = chart.match(/const refreshCachedDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; const fallbackRefreshBlock = chart.match(/const refreshCachedDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
assert( assert(
fallbackRefreshBlock.includes("fetchHourlyForecastForCity(city, { resolution: targetResolution })") && fallbackRefreshBlock.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") &&
!fallbackRefreshBlock.includes("ignoreCache: true") && !fallbackRefreshBlock.includes("ignoreCache: true") &&
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"), !fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
"no-patch fallback refresh should update the chart through cached batch detail without force-refreshing or showing the loading overlay", "no-patch fallback refresh should revalidate through cached backend detail without force-refreshing sources or showing the loading overlay",
); );
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution, applySuccessfulHourlyDetail\]\);/)?.[0] || ""; const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution, applySuccessfulHourlyDetail\]\);/)?.[0] || "";
assert( assert(
@@ -1321,6 +1321,7 @@ function mergeRowObservationIntoHourly(
} }
type HourlyForecastFetchOptions = { type HourlyForecastFetchOptions = {
bypassLocalCache?: boolean;
ignoreCache?: boolean; ignoreCache?: boolean;
resolution?: string; resolution?: string;
}; };
@@ -1561,13 +1562,14 @@ async function fetchHourlyForecastForCity(
const resParam = options.resolution || "10m"; const resParam = options.resolution || "10m";
const cacheKey = `${city}:${resParam}`; const cacheKey = `${city}:${resParam}`;
const forceRefresh = Boolean(options.ignoreCache); const forceRefresh = Boolean(options.ignoreCache);
const bypassLocalCache = forceRefresh || Boolean(options.bypassLocalCache);
if (!forceRefresh) { if (!bypassLocalCache) {
const cached = readHourlyCacheEntry(cacheKey); const cached = readHourlyCacheEntry(cacheKey);
if (cached) { if (cached) {
return cached.data; return cached.data;
} }
} else { } else if (forceRefresh) {
const recentlyRefreshed = readHourlyCacheEntry(cacheKey, { const recentlyRefreshed = readHourlyCacheEntry(cacheKey, {
maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS, maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS,
}); });
@@ -1576,7 +1578,11 @@ async function fetchHourlyForecastForCity(
} }
} }
const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`; const requestKey = forceRefresh
? `${city}:${resParam}:live`
: bypassLocalCache
? `${city}:${resParam}:revalidate`
: `${city}:${resParam}`;
const pending = _hourlyRequestCache.get(requestKey); const pending = _hourlyRequestCache.get(requestKey);
if (pending) return pending; if (pending) return pending;
@@ -103,6 +103,8 @@ export function useScanTerminalQuery({
} = useRemoteDataQuery<ScanTerminalResponse>(); } = useRemoteDataQuery<ScanTerminalResponse>();
const lastForcedScanRefreshAtRef = useRef(0); const lastForcedScanRefreshAtRef = useRef(0);
const lastForegroundScanRefreshAtRef = useRef(0);
const lastScanSuccessAtRef = useRef(0);
const patchVersion = useSsePatchVersion(); const patchVersion = useSsePatchVersion();
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => { const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -140,7 +142,11 @@ export function useScanTerminalQuery({
tradingRegion, tradingRegion,
}), }),
showLoading, showLoading,
onSuccess: (data) => { writeScanCache(data, tradingRegion || ""); setCachedRows(data); }, onSuccess: (data) => {
lastScanSuccessAtRef.current = Date.now();
writeScanCache(data, tradingRegion || "");
setCachedRows(data);
},
}); });
}, },
[isPro, proAccessLoading, run, timezoneOffsetSeconds, tradingRegion], [isPro, proAccessLoading, run, timezoneOffsetSeconds, tradingRegion],
@@ -172,6 +178,34 @@ export function useScanTerminalQuery({
void fetchScanTerminal({ forceRefresh: true, showLoading: true }); void fetchScanTerminal({ forceRefresh: true, showLoading: true });
}, [fetchScanTerminal, terminalData]); }, [fetchScanTerminal, terminalData]);
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") return;
if (proAccessLoading || !isPro) return;
const handleForegroundScanRefresh = () => {
if (document.visibilityState !== "visible") return;
if (scanRemote.status === "loading") return;
const now = Date.now();
if (now - lastForegroundScanRefreshAtRef.current < 30_000) return;
if (
lastScanSuccessAtRef.current > 0 &&
now - lastScanSuccessAtRef.current < SCAN_CACHE_TTL_MS
) {
return;
}
lastForegroundScanRefreshAtRef.current = now;
void fetchScanTerminal({ forceRefresh: false, showLoading: false });
};
document.addEventListener("visibilitychange", handleForegroundScanRefresh);
window.addEventListener("focus", handleForegroundScanRefresh);
return () => {
document.removeEventListener("visibilitychange", handleForegroundScanRefresh);
window.removeEventListener("focus", handleForegroundScanRefresh);
};
}, [fetchScanTerminal, isPro, proAccessLoading, scanRemote.status]);
// Preload adjacent regions in idle time for instant tab switches // Preload adjacent regions in idle time for instant tab switches
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || !tradingRegion || !isPro) return; if (typeof window === "undefined" || !tradingRegion || !isPro) return;
-31
View File
@@ -17,7 +17,6 @@ from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBE
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env 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"}
class BasicCommandHandler: class BasicCommandHandler:
@@ -60,10 +59,6 @@ class BasicCommandHandler:
def _unbind(message): def _unbind(message):
self._dispatch(message) self._dispatch(message)
@self.bot.message_handler(commands=["markets"])
def _markets(message):
self._dispatch(message)
if hasattr(self.bot, "chat_join_request_handler"): if hasattr(self.bot, "chat_join_request_handler"):
@self.bot.chat_join_request_handler(func=lambda request: True) @self.bot.chat_join_request_handler(func=lambda request: True)
def _chat_join_request(request): def _chat_join_request(request):
@@ -123,9 +118,6 @@ class BasicCommandHandler:
if command == "unbind": if command == "unbind":
self.handle_unbind(message) self.handle_unbind(message)
return return
if command == "markets":
self.handle_markets(message)
return
def handle_start_help(self, message: Any) -> None: def handle_start_help(self, message: Any) -> None:
trace = CommandTrace("/start", message) trace = CommandTrace("/start", message)
@@ -141,7 +133,6 @@ class BasicCommandHandler:
trace.set_status("ok") trace.set_status("ok")
finally: finally:
trace.emit() trace.emit()
@staticmethod @staticmethod
def _is_private_text_fallback(message: Any) -> bool: def _is_private_text_fallback(message: Any) -> bool:
text = str(getattr(message, "text", "") or "").strip() text = str(getattr(message, "text", "") or "").strip()
@@ -553,25 +544,3 @@ class BasicCommandHandler:
trace.set_status("ok", "unbound") trace.set_status("ok", "unbound")
finally: finally:
trace.emit() trace.emit()
def handle_markets(self, message: Any) -> None:
trace = CommandTrace("/markets", message)
try:
chat_type = str(getattr(getattr(message, "chat", None), "type", "") or "").strip().lower()
if chat_type and chat_type != "private":
self.bot.reply_to(
message,
"️ `/markets` 仅支持私聊机器人查询。",
parse_mode="Markdown",
)
trace.set_status("blocked", f"unsupported_chat_type:{chat_type}")
return
self.bot.reply_to(
message,
"️ 市场概览 (Focus Digest) 功能已移除。\n频道继续接收关键市场警报推送;如需查看当前市场状态,请访问 https://polyweather.top/",
disable_web_page_preview=True,
)
trace.set_status("ok", "removed")
finally:
trace.emit()
-111
View File
@@ -1,111 +0,0 @@
from __future__ import annotations
from typing import Any
from loguru import logger
from src.bot.command_parser import extract_command_name
from src.bot.command_parser import split_command_and_args
from src.bot.command_guard import CommandGuard
from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace
from src.bot.services.city_command_service import CityCommandService
from src.bot.settings import CITY_DAILY_FREE_LIMIT, CITY_QUERY_COST
def _is_city_command(message: Any) -> bool:
command = extract_command_name(
getattr(message, "text", None),
getattr(message, "entities", None),
)
return command in {"city", "pwcity"}
class CityCommandHandler:
def __init__(
self,
bot: Any,
guard: CommandGuard,
city_service: CityCommandService,
io_layer: BotIOLayer,
):
self.bot = bot
self.guard = guard
self.city_service = city_service
self.io_layer = io_layer
def register(self) -> None:
@self.bot.message_handler(commands=["city", "pwcity"])
def _city_command(message):
self.handle(message)
@self.bot.message_handler(
func=lambda message: _is_city_command(message),
content_types=["text"],
)
def _city_text(message):
self.handle(message)
def handle(self, message: Any) -> None:
if getattr(message, "_pw_city_handled", False):
return
if not _is_city_command(message):
return
setattr(message, "_pw_city_handled", True)
setattr(message, "_pw_command_handled", True)
trace = CommandTrace("/city", message)
try:
_, args = split_command_and_args(getattr(message, "text", None))
if not args:
trace.set_status("bad_request", "missing_city")
self.io_layer.send_query_message(
message,
"❌ 请输入城市名称\n\n用法: <code>/city chicago</code>",
parse_mode="HTML",
)
return
city_input = args.strip().lower()
resolved = self.city_service.resolve_city(city_input)
if not resolved.ok:
city_list = ", ".join(resolved.supported_cities or [])
trace.set_status("bad_request", "city_not_supported")
self.io_layer.send_query_message(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n支持的城市: {city_list}",
parse_mode="HTML",
)
return
city_name = str(resolved.city_name)
if not self.guard.check_daily_query_limit(message, "city", CITY_DAILY_FREE_LIMIT):
trace.set_status("blocked", "daily_query_limit")
return
if not self.guard.ensure_access_and_points(message, CITY_QUERY_COST, "/city"):
trace.set_status("blocked", "guard_rejected")
return
self.io_layer.send_query_message(
message,
f"🔍 正在查询 {city_name.title()} 的天气数据...",
)
report_result = self.city_service.build_report(city_name, CITY_QUERY_COST)
if not report_result.ok:
trace.set_status("failed", report_result.error or "city_report_failed")
self.io_layer.send_query_message(
message,
f"❌ 查询失败: {report_result.error}",
)
return
self.io_layer.send_query_message(
message,
str(report_result.report),
parse_mode="HTML",
)
trace.set_status("ok", city_name)
except Exception as exc:
trace.set_status("failed", "unexpected_error")
logger.exception("查询 /city 失败")
self.io_layer.send_query_message(message, f"❌ 查询失败: {exc}")
finally:
trace.emit()
-105
View File
@@ -1,105 +0,0 @@
from __future__ import annotations
from typing import Any
from loguru import logger
from src.bot.command_parser import extract_command_name
from src.bot.command_parser import split_command_and_args
from src.bot.command_guard import CommandGuard
from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace
from src.bot.services.deb_command_service import DebCommandService
from src.bot.settings import DEB_DAILY_FREE_LIMIT, DEB_QUERY_COST
def _is_deb_command(message: Any) -> bool:
command = extract_command_name(
getattr(message, "text", None),
getattr(message, "entities", None),
)
return command in {"deb", "pwdeb"}
class DebCommandHandler:
def __init__(
self,
bot: Any,
guard: CommandGuard,
deb_service: DebCommandService,
io_layer: BotIOLayer,
):
self.bot = bot
self.guard = guard
self.deb_service = deb_service
self.io_layer = io_layer
def register(self) -> None:
@self.bot.message_handler(commands=["deb", "pwdeb"])
def _deb_command(message):
self.handle(message)
@self.bot.message_handler(
func=lambda message: _is_deb_command(message),
content_types=["text"],
)
def _deb_text(message):
self.handle(message)
def handle(self, message: Any) -> None:
if getattr(message, "_pw_deb_handled", False):
return
if not _is_deb_command(message):
return
setattr(message, "_pw_deb_handled", True)
setattr(message, "_pw_command_handled", True)
trace = CommandTrace("/deb", message)
try:
_, args = split_command_and_args(getattr(message, "text", None))
if not args:
trace.set_status("bad_request", "missing_city")
self.io_layer.send_query_message(
message,
"❌ 用法: <code>/deb ankara</code>",
parse_mode="HTML",
)
return
city_input = args.strip().lower()
city_name = self.deb_service.resolve_city(city_input)
if not self.deb_service.has_history(city_name):
trace.set_status("bad_request", "history_missing")
self.io_layer.send_query_message(
message,
f"❌ 暂无 {city_name} 的历史数据。",
parse_mode="HTML",
)
return
if not self.guard.check_daily_query_limit(message, "deb", DEB_DAILY_FREE_LIMIT):
trace.set_status("blocked", "daily_query_limit")
return
if not self.guard.ensure_access_and_points(message, DEB_QUERY_COST, "/deb"):
trace.set_status("blocked", "guard_rejected")
return
report_result = self.deb_service.build_report(city_name, DEB_QUERY_COST)
if not report_result.ok:
trace.set_status("failed", report_result.error or "deb_report_failed")
self.io_layer.send_query_message(
message,
f"❌ 查询失败: {report_result.error}",
)
return
self.io_layer.send_query_message(
message,
str(report_result.report),
parse_mode="HTML",
)
trace.set_status("ok", city_name)
except Exception as exc:
trace.set_status("failed", "unexpected_error")
logger.exception("查询 /deb 失败")
self.io_layer.send_query_message(message, f"❌ 查询失败: {exc}")
finally:
trace.emit()
+3 -19
View File
@@ -1,14 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from loguru import logger from loguru import logger
from src.bot.settings import ( from src.bot.settings import (
CITY_DAILY_FREE_LIMIT,
DEB_DAILY_FREE_LIMIT,
GROUP_MESSAGE_POINTS_ENABLED, GROUP_MESSAGE_POINTS_ENABLED,
MESSAGE_COOLDOWN_SEC, MESSAGE_COOLDOWN_SEC,
MESSAGE_DAILY_CAP, MESSAGE_DAILY_CAP,
@@ -194,28 +191,20 @@ class BotIOLayer:
def build_welcome_text(self) -> str: def build_welcome_text(self) -> str:
return ( return (
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n" "🚀 <b>PolyWeather 机器人</b>\n\n"
"可用指令:\n" "可用指令:\n"
f"/city [城市名] 或 /pwcity [城市名] - 查询城市天气预测与实测 (免费, 每日 {CITY_DAILY_FREE_LIMIT} 次)\n"
f"/deb [城市名] 或 /pwdeb [城市名] - 查看 DEB 融合预测准确率 (免费, 每日 {DEB_DAILY_FREE_LIMIT} 次)\n"
"/markets - 私聊机器人查看当前市场监控摘要\n"
"/top - 查看积分排行榜\n" "/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n" "/id - 获取当前聊天的 Chat ID\n\n"
"/diag - 查看 Bot 启动诊断\n\n" "/diag - 查看 Bot 启动诊断\n\n"
"/bind - 绑定 Supabase 账号(可选)\n" "/bind - 绑定 Supabase 账号(可选)\n"
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n" "/unbind - 解除当前 Telegram 与网页账号绑定\n\n"
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n" "🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n" "👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>"
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
"💡 <i>提示: 积分现在通过邀请制度获得;有效邀请完成首次 Pro 付款后,邀请人获得积分奖励。</i>"
) )
def build_points_rank_text(self, user: Any) -> str: def build_points_rank_text(self, user: Any) -> str:
self.db.upsert_user(user.id, self.display_name(user)) self.db.upsert_user(user.id, self.display_name(user))
user_info = self.db.get_user(user.id) user_info = self.db.get_user(user.id)
now = datetime.now()
today_str = now.strftime("%Y-%m-%d")
leaderboard = self.db.get_leaderboard(limit=5) leaderboard = self.db.get_leaderboard(limit=5)
rank_text = "🏆 <b>PolyWeather 用户积分排行</b>\n" rank_text = "🏆 <b>PolyWeather 用户积分排行</b>\n"
@@ -227,17 +216,12 @@ class BotIOLayer:
rank_text += f"{medal} {username}: <b>{points}</b> 分\n" rank_text += f"{medal} {username}: <b>{points}</b> 分\n"
if user_info: if user_info:
daily_queries_date = str(user_info.get("daily_queries_date") or "")
city_used = int(user_info.get("daily_city_queries") or 0) if daily_queries_date == today_str else 0
deb_used = int(user_info.get("daily_deb_queries") or 0) if daily_queries_date == today_str else 0
rank_text += "────────────────────\n" rank_text += "────────────────────\n"
rank_text += ( rank_text += (
"👤 <b>我的状态:</b>\n" "👤 <b>我的状态:</b>\n"
f"┣ 累计积分: <code>{user_info['points']}</code>\n" f"┣ 累计积分: <code>{user_info['points']}</code>\n"
"┣ 积分获取: <code>邀请付费用户</code>\n" "┣ 积分获取: <code>邀请付费用户</code>\n"
" 抵扣规则: <code>500分 = 1 USDC,单笔最多抵3U</code>\n" " 抵扣规则: <code>500分 = 1 USDC,单笔最多抵3U</code>"
f"┗ /city 免费 ({city_used}/{CITY_DAILY_FREE_LIMIT}) | /deb 免费 ({deb_used}/{DEB_DAILY_FREE_LIMIT})"
) )
return rank_text return rank_text
+6 -36
View File
@@ -5,32 +5,21 @@ from typing import Any
from loguru import logger # type: ignore from loguru import logger # type: ignore
from src.bot.analysis.city_analysis_service import CityAnalysisService
from src.bot.analysis.deb_analysis_service import DebAnalysisService
from src.bot.command_guard import CommandGuard
from src.bot.handlers.activity import ActivityHandler from src.bot.handlers.activity import ActivityHandler
from src.bot.handlers.basic import BasicCommandHandler from src.bot.handlers.basic import BasicCommandHandler
from src.bot.handlers.city import CityCommandHandler
from src.bot.handlers.deb import DebCommandHandler
from src.bot.io_layer import BotIOLayer from src.bot.io_layer import BotIOLayer
from src.bot.runtime_coordinator import StartupCoordinator from src.bot.runtime_coordinator import StartupCoordinator
from src.bot.services.city_command_service import CityCommandService
from src.bot.services.deb_command_service import DebCommandService
from src.utils.config_validation import validate_or_raise from src.utils.config_validation import validate_or_raise
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _register_handlers( def _register_handlers(
bot: Any, bot: Any,
config: dict[str, Any], config: dict[str, Any],
io_layer: BotIOLayer, io_layer: BotIOLayer,
guard: CommandGuard, guard: Any | None,
city_service: CityCommandService, city_service: Any | None,
deb_service: DebCommandService, deb_service: Any | None,
startup_coordinator: StartupCoordinator, startup_coordinator: StartupCoordinator,
) -> None: ) -> None:
BasicCommandHandler( BasicCommandHandler(
@@ -39,25 +28,12 @@ def _register_handlers(
runtime_status_provider=startup_coordinator.get_runtime_status, runtime_status_provider=startup_coordinator.get_runtime_status,
config=config, config=config,
).register() ).register()
CityCommandHandler(
bot=bot,
guard=guard,
city_service=city_service,
io_layer=io_layer,
).register()
DebCommandHandler(
bot=bot,
guard=guard,
deb_service=deb_service,
io_layer=io_layer,
).register()
ActivityHandler(bot=bot, io_layer=io_layer).register() ActivityHandler(bot=bot, io_layer=io_layer).register()
def start_bot() -> None: def start_bot() -> None:
import telebot # type: ignore import telebot # type: ignore
from src.data_collection.weather_sources import WeatherDataCollector
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
from src.utils.config_loader import load_config from src.utils.config_loader import load_config
@@ -70,14 +46,8 @@ def start_bot() -> None:
bot = telebot.TeleBot(token) bot = telebot.TeleBot(token)
db = DBManager() db = DBManager()
weather = WeatherDataCollector(config)
io_layer = BotIOLayer(bot=bot, db=db) io_layer = BotIOLayer(bot=bot, db=db)
city_analysis = CityAnalysisService(weather=weather)
deb_analysis = DebAnalysisService(project_root=_project_root())
guard = CommandGuard(io_layer=io_layer)
city_service = CityCommandService(analysis=city_analysis)
deb_service = DebCommandService(analysis=deb_analysis)
startup_coordinator = StartupCoordinator( startup_coordinator = StartupCoordinator(
bot=bot, bot=bot,
config=config, config=config,
@@ -90,9 +60,9 @@ def start_bot() -> None:
bot=bot, bot=bot,
config=config, config=config,
io_layer=io_layer, io_layer=io_layer,
guard=guard, guard=None,
city_service=city_service, city_service=None,
deb_service=deb_service, deb_service=None,
startup_coordinator=startup_coordinator, startup_coordinator=startup_coordinator,
) )
runtime_status = startup_coordinator.start_all() runtime_status = startup_coordinator.start_all()
-38
View File
@@ -1,38 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from src.bot.analysis.city_analysis_service import CityAnalysisService
@dataclass
class CityResolveResult:
ok: bool
city_name: Optional[str] = None
supported_cities: List[str] | None = None
@dataclass
class CityReportResult:
ok: bool
report: Optional[str] = None
error: Optional[str] = None
class CityCommandService:
def __init__(self, analysis: CityAnalysisService):
self.analysis = analysis
def resolve_city(self, city_input: str) -> CityResolveResult:
city_name, supported = self.analysis.resolve_city(city_input)
if not city_name:
return CityResolveResult(ok=False, supported_cities=supported)
return CityResolveResult(ok=True, city_name=city_name, supported_cities=supported)
def build_report(self, city_name: str, city_query_cost: int) -> CityReportResult:
try:
report = self.analysis.build_city_report(city_name, city_query_cost)
return CityReportResult(ok=True, report=report)
except Exception as exc:
return CityReportResult(ok=False, error=str(exc))
-31
View File
@@ -1,31 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from src.bot.analysis.deb_analysis_service import DebAnalysisService
@dataclass
class DebReportResult:
ok: bool
report: Optional[str] = None
error: Optional[str] = None
class DebCommandService:
def __init__(self, analysis: DebAnalysisService):
self.analysis = analysis
def resolve_city(self, city_input: str) -> str:
return self.analysis.resolve_city(city_input)
def has_history(self, city_name: str) -> bool:
return self.analysis.has_history(city_name)
def build_report(self, city_name: str, deb_query_cost: int) -> DebReportResult:
try:
report = self.analysis.build_deb_accuracy_report(city_name, deb_query_cost)
return DebReportResult(ok=True, report=report)
except Exception as exc:
return DebReportResult(ok=False, error=str(exc))
+1 -1
View File
@@ -41,7 +41,7 @@ class BotEntitlementService:
"POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT", "POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT",
SUPABASE_ENTITLEMENT.enabled, SUPABASE_ENTITLEMENT.enabled,
) )
commands = protected_commands or ("/city", "/deb") commands = protected_commands or ()
self.protected_commands: Set[str] = {str(c).strip().lower() for c in commands if str(c).strip()} self.protected_commands: Set[str] = {str(c).strip().lower() for c in commands if str(c).strip()}
def check(self, user_id: int, command_label: str) -> EntitlementDecision: def check(self, user_id: int, command_label: str) -> EntitlementDecision:
-4
View File
@@ -31,10 +31,6 @@ MESSAGE_MIN_LENGTH = _env_int("POLYWEATHER_BOT_MESSAGE_MIN_LENGTH", 3, min_value
MESSAGE_COOLDOWN_SEC = _env_int("POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC", 30, min_value=0) MESSAGE_COOLDOWN_SEC = _env_int("POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC", 30, min_value=0)
# Optional per-chat override map, parsed in BotIOLayer: # Optional per-chat override map, parsed in BotIOLayer:
# POLYWEATHER_BOT_MESSAGE_COOLDOWN_BY_CHAT="-1003586303099:10,-1003539418691:20" # POLYWEATHER_BOT_MESSAGE_COOLDOWN_BY_CHAT="-1003586303099:10,-1003539418691:20"
CITY_QUERY_COST = _env_int("POLYWEATHER_BOT_CITY_QUERY_COST", 0, min_value=0)
DEB_QUERY_COST = _env_int("POLYWEATHER_BOT_DEB_QUERY_COST", 0, min_value=0)
CITY_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_CITY_DAILY_FREE_LIMIT", 10, min_value=1)
DEB_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_DEB_DAILY_FREE_LIMIT", 10, min_value=1)
FIRST_MESSAGE_BONUS = _env_int("POLYWEATHER_BOT_FIRST_MESSAGE_BONUS", 2, min_value=0) FIRST_MESSAGE_BONUS = _env_int("POLYWEATHER_BOT_FIRST_MESSAGE_BONUS", 2, min_value=0)
WELCOME_BONUS = _env_int("POLYWEATHER_BOT_WELCOME_BONUS", 20, min_value=0) WELCOME_BONUS = _env_int("POLYWEATHER_BOT_WELCOME_BONUS", 20, min_value=0)
+16 -44
View File
@@ -79,7 +79,7 @@ def test_basic_handler_diag_returns_html():
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
) )
bot = DummyBot() bot = DummyBot()
@@ -131,7 +131,7 @@ def test_start_bind_token_binds_telegram_to_web_account():
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
), ),
) )
@@ -173,7 +173,7 @@ def test_confirm_bind_callback_consumes_token_and_binds_account():
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
), ),
) )
@@ -204,7 +204,7 @@ def test_private_text_fallback_replies_with_binding_help():
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
), ),
) )
@@ -230,7 +230,7 @@ def test_private_text_fallback_ignores_slash_commands():
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
), ),
) )
@@ -241,7 +241,7 @@ def test_private_text_fallback_ignores_slash_commands():
assert bot.replies == [] assert bot.replies == []
def test_basic_handler_markets_returns_summary(): def test_basic_handler_ignores_removed_markets_command():
bot = DummyBot() bot = DummyBot()
io_layer = SimpleNamespace( io_layer = SimpleNamespace(
build_welcome_text=lambda: "WELCOME", build_welcome_text=lambda: "WELCOME",
@@ -254,44 +254,16 @@ def test_basic_handler_markets_returns_summary():
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
), ),
config={}, config={},
) )
handler.handle_markets(_message("/markets")) handler._dispatch(_message("/markets"))
assert len(bot.replies) == 1 assert bot.replies == []
assert "市场概览" in bot.replies[0]["text"] assert bot.sent_messages == []
assert "已移除" in bot.replies[0]["text"]
def test_basic_handler_markets_rejects_channel_chat():
bot = DummyBot()
io_layer = SimpleNamespace(
build_welcome_text=lambda: "WELCOME",
build_points_rank_text=lambda _user: "TOP",
)
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",
),
config={},
)
msg = _message("/markets")
msg.chat = SimpleNamespace(id=-1001, type="channel")
handler.handle_markets(msg)
assert len(bot.replies) == 1
assert "仅支持私聊机器人查询" in bot.replies[0]["text"]
def _join_request(user_id: int = 12345, chat_id: int = -100123): def _join_request(user_id: int = 12345, chat_id: int = -100123):
@@ -324,7 +296,7 @@ def test_join_request_auto_approves_bound_active_pro_user(monkeypatch):
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-100123", required_group_chat_id="-100123",
), ),
entitlement_service=entitlement, entitlement_service=entitlement,
@@ -354,7 +326,7 @@ def test_join_request_keeps_unbound_user_pending_by_default(monkeypatch):
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-100123", required_group_chat_id="-100123",
), ),
entitlement_service=entitlement, entitlement_service=entitlement,
@@ -389,7 +361,7 @@ def test_join_request_keeps_trial_user_pending(monkeypatch):
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-100123", required_group_chat_id="-100123",
), ),
entitlement_service=entitlement, entitlement_service=entitlement,
@@ -429,7 +401,7 @@ def test_join_request_approves_trial_user_with_queued_paid_subscription(monkeypa
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-100123", required_group_chat_id="-100123",
), ),
entitlement_service=entitlement, entitlement_service=entitlement,
@@ -469,7 +441,7 @@ def test_join_request_uses_subscription_window_without_latest_fallback(monkeypat
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-100123", required_group_chat_id="-100123",
), ),
entitlement_service=entitlement, entitlement_service=entitlement,
@@ -499,7 +471,7 @@ def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch):
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
loops=[], loops=[],
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-100123", required_group_chat_id="-100123",
), ),
entitlement_service=entitlement, entitlement_service=entitlement,
+14 -1
View File
@@ -2,6 +2,7 @@ from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
from src.bot.command_guard import CommandGuard from src.bot.command_guard import CommandGuard
from src.bot.services.entitlement_service import BotEntitlementService
def _message(): def _message():
@@ -19,8 +20,20 @@ def test_guard_charges_points():
io_layer = SimpleNamespace(bot=fake_bot, ensure_query_points=Mock(return_value=True)) io_layer = SimpleNamespace(bot=fake_bot, ensure_query_points=Mock(return_value=True))
guard = CommandGuard(io_layer=io_layer, group_chat_id="-100123") guard = CommandGuard(io_layer=io_layer, group_chat_id="-100123")
ok = guard.ensure_access_and_points(_message(), 1, "/city") ok = guard.ensure_access_and_points(_message(), 1, "/top")
assert ok is True assert ok is True
assert io_layer.ensure_query_points.call_count == 1 assert io_layer.ensure_query_points.call_count == 1
assert fake_bot.reply_to.call_count == 0 assert fake_bot.reply_to.call_count == 0
def test_entitlement_service_has_no_removed_protected_commands_by_default(monkeypatch):
monkeypatch.setenv("POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT", "false")
db = SimpleNamespace(get_user=lambda _user_id: {})
service = BotEntitlementService(db=db, enabled=True)
decision = service.check(123, "/city")
assert service.protected_commands == set()
assert decision.allowed is True
assert decision.reason == "command_not_protected"
+55 -86
View File
@@ -1,98 +1,67 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
from src.bot.handlers.city import CityCommandHandler from src.bot.orchestrator import _register_handlers
from src.bot.handlers.deb import DebCommandHandler from src.bot.runtime_coordinator import RuntimeStatus
from src.bot.services.city_command_service import CityReportResult, CityResolveResult
from src.bot.services.deb_command_service import DebReportResult
def _message(text: str): class RecordingBot:
return SimpleNamespace( def __init__(self):
text=text, self.command_handlers = []
from_user=SimpleNamespace(id=123, username="tester", first_name="Tester"),
chat=SimpleNamespace(id=999), def message_handler(self, *args, **kwargs):
entities=[], commands = kwargs.get("commands")
if commands:
self.command_handlers.extend(commands)
def _decorator(func):
return func
return _decorator
def chat_join_request_handler(self, *args, **kwargs):
def _decorator(func):
return func
return _decorator
def callback_query_handler(self, *args, **kwargs):
def _decorator(func):
return func
return _decorator
def test_register_handlers_does_not_expose_removed_query_commands():
bot = RecordingBot()
io_layer = SimpleNamespace(
build_welcome_text=Mock(return_value="WELCOME"),
build_points_rank_text=Mock(return_value="TOP"),
track_group_text_activity=Mock(),
) )
startup_coordinator = SimpleNamespace(
get_runtime_status=Mock(
def test_city_handler_missing_city_shows_usage(): return_value=RuntimeStatus(
bot = SimpleNamespace() started_at="2026-06-13 00:00:00 UTC",
guard = SimpleNamespace( loops=[],
check_daily_query_limit=Mock(return_value=True), command_access_mode="public",
ensure_access_and_points=Mock(return_value=True), protected_commands=[],
) required_group_chat_id="",
city_service = SimpleNamespace(resolve_city=Mock(), build_report=Mock())
io_layer = SimpleNamespace(send_query_message=Mock())
handler = CityCommandHandler(
bot=bot,
guard=guard,
city_service=city_service,
io_layer=io_layer,
)
handler.handle(_message("/city"))
assert io_layer.send_query_message.call_count == 1
assert "请输入城市名称" in io_layer.send_query_message.call_args[0][1]
assert guard.ensure_access_and_points.call_count == 0
def test_city_handler_happy_path_pushes_progress_and_report():
bot = SimpleNamespace()
guard = SimpleNamespace(
check_daily_query_limit=Mock(return_value=True),
ensure_access_and_points=Mock(return_value=True),
)
city_service = SimpleNamespace(
resolve_city=Mock(
return_value=CityResolveResult(
ok=True,
city_name="london",
supported_cities=["london"],
) )
), )
build_report=Mock(return_value=CityReportResult(ok=True, report="CITY_REPORT")),
) )
io_layer = SimpleNamespace(send_query_message=Mock())
handler = CityCommandHandler( _register_handlers(
bot=bot, bot=bot,
guard=guard, config={},
city_service=city_service,
io_layer=io_layer, io_layer=io_layer,
guard=SimpleNamespace(),
city_service=SimpleNamespace(),
deb_service=SimpleNamespace(),
startup_coordinator=startup_coordinator,
) )
handler.handle(_message("/city london")) assert "city" not in bot.command_handlers
assert "pwcity" not in bot.command_handlers
assert io_layer.send_query_message.call_count == 2 assert "deb" not in bot.command_handlers
first_call = io_layer.send_query_message.call_args_list[0] assert "pwdeb" not in bot.command_handlers
second_call = io_layer.send_query_message.call_args_list[1] assert "markets" not in bot.command_handlers
assert "正在查询 London 的天气数据" in first_call.args[1]
assert second_call.args[1] == "CITY_REPORT"
assert second_call.kwargs["parse_mode"] == "HTML"
def test_deb_handler_history_missing_returns_hint():
bot = SimpleNamespace()
guard = SimpleNamespace(
check_daily_query_limit=Mock(return_value=True),
ensure_access_and_points=Mock(return_value=True),
)
deb_service = SimpleNamespace(
resolve_city=Mock(return_value="ankara"),
has_history=Mock(return_value=False),
build_report=Mock(return_value=DebReportResult(ok=True, report="DEB_REPORT")),
)
io_layer = SimpleNamespace(send_query_message=Mock())
handler = DebCommandHandler(
bot=bot,
guard=guard,
deb_service=deb_service,
io_layer=io_layer,
)
handler.handle(_message("/deb ankara"))
assert io_layer.send_query_message.call_count == 1
assert "暂无 ankara 的历史数据" in io_layer.send_query_message.call_args[0][1]
assert guard.ensure_access_and_points.call_count == 0
+42
View File
@@ -9,14 +9,30 @@ class DummyDB:
def __init__(self): def __init__(self):
self.upserts = [] self.upserts = []
self.activities = [] self.activities = []
self.users = {}
def upsert_user(self, telegram_id, username): def upsert_user(self, telegram_id, username):
self.upserts.append((telegram_id, username)) self.upserts.append((telegram_id, username))
self.users.setdefault(
telegram_id,
{
"points": 0,
"daily_queries_date": "",
"daily_city_queries": 0,
"daily_deb_queries": 0,
},
)
def add_message_activity(self, telegram_id, **kwargs): def add_message_activity(self, telegram_id, **kwargs):
self.activities.append({"telegram_id": telegram_id, **kwargs}) self.activities.append({"telegram_id": telegram_id, **kwargs})
return {"awarded": True, "points_added": kwargs.get("points_to_add", 0)} return {"awarded": True, "points_added": kwargs.get("points_to_add", 0)}
def get_user(self, telegram_id):
return self.users.get(telegram_id)
def get_leaderboard(self, limit=5):
return []
def _message(chat_id: int | str, text: str = "有效发言"): def _message(chat_id: int | str, text: str = "有效发言"):
return SimpleNamespace( return SimpleNamespace(
@@ -51,3 +67,29 @@ def test_group_message_points_skip_unconfigured_chat_when_allowlist_exists(monke
assert db.upserts == [] assert db.upserts == []
assert db.activities == [] assert db.activities == []
def test_welcome_text_hides_removed_query_commands():
io_layer = BotIOLayer(bot=SimpleNamespace(), db=DummyDB())
text = io_layer.build_welcome_text()
assert "/city" not in text
assert "/pwcity" not in text
assert "/deb" not in text
assert "/pwdeb" not in text
assert "/markets" not in text
assert "私有频道" not in text
assert "示例" not in text
assert "积分现在通过邀请制度" not in text
def test_points_rank_text_hides_removed_query_usage():
db = DummyDB()
io_layer = BotIOLayer(bot=SimpleNamespace(), db=db)
user = SimpleNamespace(id=123, username="alice", first_name="Alice")
text = io_layer.build_points_rank_text(user)
assert "/city" not in text
assert "/deb" not in text
+3 -3
View File
@@ -12,7 +12,7 @@ def test_startup_coordinator_respects_disable_flags(monkeypatch):
bot=DummyBot(), bot=DummyBot(),
config={}, config={},
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
) )
runtime = coordinator.start_all() runtime = coordinator.start_all()
@@ -27,7 +27,7 @@ def test_render_runtime_status_html_contains_key_fields():
runtime = RuntimeStatus( runtime = RuntimeStatus(
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
command_access_mode="group_member", command_access_mode="group_member",
protected_commands=["/city", "/deb"], protected_commands=[],
required_group_chat_id="-1001234567890", required_group_chat_id="-1001234567890",
loops=[], loops=[],
) )
@@ -35,4 +35,4 @@ def test_render_runtime_status_html_contains_key_fields():
assert "Bot 启动诊断" in html assert "Bot 启动诊断" in html
assert "命令准入" in html assert "命令准入" in html
assert "/city, /deb" in html assert "受保护命令: <code>--</code>" in html
+1
View File
@@ -100,6 +100,7 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}" in web_block assert "POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-2}" in web_block assert "POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-2}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}" in web_block assert "POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}" in web_block
assert "UVICORN_WORKERS: ${UVICORN_WORKERS:-2}" in web_block assert "UVICORN_WORKERS: ${UVICORN_WORKERS:-2}" in web_block
assert "POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''" in bot_block assert "POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''" in bot_block
+55 -6
View File
@@ -925,9 +925,11 @@ def test_chart_scope_overlays_collector_runway_history_from_db(monkeypatch):
assert history[-1] == {"time": "2026-06-06T05:28:00+00:00", "temp": 24.8} assert history[-1] == {"time": "2026-06-06T05:28:00+00:00", "temp": 24.8}
def test_chart_data_cache_hit_does_not_start_full_stale_refresh(monkeypatch): def test_chart_data_cache_hit_starts_full_stale_refresh(monkeypatch):
import asyncio import asyncio
refresh_calls = []
class FakeCache: class FakeCache:
def get_city_cache(self, kind, city): def get_city_cache(self, kind, city):
assert kind == "full" assert kind == "full"
@@ -951,7 +953,7 @@ def test_chart_data_cache_hit_does_not_start_full_stale_refresh(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
city_api, city_api,
"_start_city_full_stale_refresh", "_start_city_full_stale_refresh",
lambda city: (_ for _ in ()).throw(AssertionError("chart scope must not start full stale refresh")), refresh_calls.append,
) )
monkeypatch.setattr( monkeypatch.setattr(
city_api.legacy_routes, city_api.legacy_routes,
@@ -962,6 +964,7 @@ def test_chart_data_cache_hit_does_not_start_full_stale_refresh(monkeypatch):
payload = asyncio.run(city_api._get_city_chart_data("paris", force_refresh=False)) payload = asyncio.run(city_api._get_city_chart_data("paris", force_refresh=False))
assert payload["hourly"]["temps"] == [25.0] assert payload["hourly"]["temps"] == [25.0]
assert refresh_calls == ["paris"]
def test_chart_data_returns_cached_payload_when_optional_overlay_times_out(monkeypatch): def test_chart_data_returns_cached_payload_when_optional_overlay_times_out(monkeypatch):
@@ -1058,12 +1061,57 @@ def test_city_detail_batch_partial_timeout_default_stays_below_proxy_budget(monk
monkeypatch.delenv("POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS", raising=False) monkeypatch.delenv("POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS", raising=False)
monkeypatch.delenv("POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY", raising=False) monkeypatch.delenv("POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY", raising=False)
monkeypatch.delenv("POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY", raising=False) monkeypatch.delenv("POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY", raising=False)
monkeypatch.delenv("POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS", raising=False)
assert city_api._city_detail_batch_concurrency() == 3 assert city_api._city_detail_batch_concurrency() == 3
assert city_api._city_detail_batch_global_concurrency() == 2 assert city_api._city_detail_batch_global_concurrency() == 2
assert city_api._city_detail_batch_queue_wait_seconds() == 3.0
assert city_api._city_detail_batch_partial_timeout_seconds() == 8.0 assert city_api._city_detail_batch_partial_timeout_seconds() == 8.0
def test_city_detail_batch_waits_briefly_for_global_builder_slot(monkeypatch):
import asyncio
import threading
build_calls = 0
async def build_batch_item(city, **kwargs):
nonlocal build_calls
build_calls += 1
return city, {"city": city}
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY", "1")
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS", "200")
monkeypatch.setattr(city_api, "_CITY_DETAIL_BATCH_BUILD_SEMAPHORE", None)
monkeypatch.setattr(city_api, "_CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE", 0)
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
monkeypatch.setattr(city_api, "_build_city_detail_batch_item_async", build_batch_item)
semaphore = city_api._city_detail_batch_build_semaphore()
assert semaphore.acquire(blocking=False) is True
release_timer = threading.Timer(0.02, semaphore.release)
release_timer.start()
try:
payload = asyncio.run(
city_api.get_city_detail_batch_payload(
object(),
cities="Wait-Paris,Wait-Shanghai",
resolution="10m",
limit=2,
)
)
finally:
release_timer.join(timeout=1)
assert payload["partial"] is False
assert payload.get("busy") is not True
assert sorted(payload["details"]) == ["wait-paris", "wait-shanghai"]
assert payload["missing"] == []
assert payload["diagnostics"]["response_source"] == "fresh_build"
assert build_calls == 2
def test_city_detail_batch_returns_busy_when_global_builder_slot_is_full(monkeypatch): def test_city_detail_batch_returns_busy_when_global_builder_slot_is_full(monkeypatch):
import asyncio import asyncio
@@ -1075,6 +1123,7 @@ def test_city_detail_batch_returns_busy_when_global_builder_slot_is_full(monkeyp
return city, {"city": city} return city, {"city": city}
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY", "1") monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY", "1")
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS", "10")
monkeypatch.setattr(city_api, "_CITY_DETAIL_BATCH_BUILD_SEMAPHORE", None) monkeypatch.setattr(city_api, "_CITY_DETAIL_BATCH_BUILD_SEMAPHORE", None)
monkeypatch.setattr(city_api, "_CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE", 0) monkeypatch.setattr(city_api, "_CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE", 0)
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None)
@@ -1087,7 +1136,7 @@ def test_city_detail_batch_returns_busy_when_global_builder_slot_is_full(monkeyp
payload = asyncio.run( payload = asyncio.run(
city_api.get_city_detail_batch_payload( city_api.get_city_detail_batch_payload(
object(), object(),
cities="Paris,Shanghai", cities="Busy-Paris,Busy-Shanghai",
resolution="10m", resolution="10m",
limit=2, limit=2,
) )
@@ -1098,14 +1147,14 @@ def test_city_detail_batch_returns_busy_when_global_builder_slot_is_full(monkeyp
assert payload["partial"] is True assert payload["partial"] is True
assert payload["busy"] is True assert payload["busy"] is True
assert payload["details"] == {} assert payload["details"] == {}
assert payload["missing"] == ["paris", "shanghai"] assert payload["missing"] == ["busy-paris", "busy-shanghai"]
assert payload["diagnostics"]["partial_reason"] == "busy" assert payload["diagnostics"]["partial_reason"] == "busy"
assert payload["diagnostics"]["response_source"] == "busy" assert payload["diagnostics"]["response_source"] == "busy"
assert payload["diagnostics"]["requested_count"] == 2 assert payload["diagnostics"]["requested_count"] == 2
assert payload["diagnostics"]["completed_count"] == 0 assert payload["diagnostics"]["completed_count"] == 0
assert payload["diagnostics"]["missing_count"] == 2 assert payload["diagnostics"]["missing_count"] == 2
assert payload["diagnostics"]["city_status"]["paris"]["status"] == "busy" assert payload["diagnostics"]["city_status"]["busy-paris"]["status"] == "busy"
assert payload["diagnostics"]["city_status"]["shanghai"]["status"] == "busy" assert payload["diagnostics"]["city_status"]["busy-shanghai"]["status"] == "busy"
assert build_calls == 0 assert build_calls == 0
+23 -1
View File
@@ -397,6 +397,8 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
if cached_entry: if cached_entry:
payload = cached_entry.get("payload") or {} payload = cached_entry.get("payload") or {}
if payload: if payload:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
_start_city_full_stale_refresh(city)
payload = await _run_optional_city_chart_overlay( payload = await _run_optional_city_chart_overlay(
city=city, city=city,
overlay_name="runway_history", overlay_name="runway_history",
@@ -999,6 +1001,17 @@ def _city_detail_batch_global_concurrency() -> int:
return max(1, min(4, value)) return max(1, min(4, value))
def _city_detail_batch_queue_wait_seconds() -> float:
try:
wait_ms = int(
os.getenv("POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS", "3000")
or "3000"
)
except ValueError:
wait_ms = 3000
return max(0.0, min(5.0, wait_ms / 1000.0))
def _city_detail_batch_build_semaphore() -> threading.BoundedSemaphore: def _city_detail_batch_build_semaphore() -> threading.BoundedSemaphore:
global _CITY_DETAIL_BATCH_BUILD_SEMAPHORE, _CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE global _CITY_DETAIL_BATCH_BUILD_SEMAPHORE, _CITY_DETAIL_BATCH_BUILD_SEMAPHORE_SIZE
size = _city_detail_batch_global_concurrency() size = _city_detail_batch_global_concurrency()
@@ -1146,7 +1159,16 @@ async def get_city_detail_batch_payload(
async def _build_uncached_payload() -> Dict[str, Any]: async def _build_uncached_payload() -> Dict[str, Any]:
build_semaphore = _city_detail_batch_build_semaphore() build_semaphore = _city_detail_batch_build_semaphore()
if not build_semaphore.acquire(blocking=False): queue_wait_seconds = _city_detail_batch_queue_wait_seconds()
acquired = await timer.measure_async(
"wait_builder_slot",
lambda: run_in_threadpool(
build_semaphore.acquire,
True,
queue_wait_seconds,
),
)
if not acquired:
missing = list(city_names) missing = list(city_names)
errors: Dict[str, str] = {} errors: Dict[str, str] = {}
details: Dict[str, Any] = {} details: Dict[str, Any] = {}