diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 913eb8e6..6a44d544 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -219,6 +219,7 @@ const TELEGRAM_BOT_URL = String( process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot", ).trim(); const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx"; +const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx"; const SUBSCRIPTION_HELP_HREF = "/subscription-help"; const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery"; const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000; @@ -708,6 +709,16 @@ export function AccountCenter() { telegramHint: isEn ? "Send the command below to the polyweather bot to sync notifications and access." : "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。", + telegramUpgradeTitle: isEn + ? "Channel Upgrade: New Topics Group Live" + : "频道升级通知:新话题群组已上线", + telegramUpgradeBody: isEn + ? "Real-time airport temperature pushes are migrating to the new PolyWeather Topics group, organized by city for better tracking. Current channel will stop updating and be deleted in 3 days — please join the new group now." + : "为了更好地按城市分类推送,后续实时机场温度推送将迁移至新的 PolyWeather Topics 群组。当前频道将在 3 天后停止更新并删除,请及时迁移。", + telegramUpgradeFeatures: isEn + ? "Independent city topics:" + : "支持独立城市话题订阅:", + telegramUpgradeJoin: isEn ? "Join New Group" : "加入新群", paymentManualSupport: isEn ? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases." : "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!", @@ -2755,6 +2766,34 @@ export function AccountCenter() {

{copy.telegramHint}

+ + {/* Channel Upgrade / Migration Notice — Pro users only */} + {isSubscribed ? ( +
+

+ {copy.telegramUpgradeTitle} +

+

+ {copy.telegramUpgradeBody} +

+

+ {copy.telegramUpgradeFeatures} +

+

+ 🇯🇵 Tokyo · 🇰🇷 Seoul · 🇰🇷 Busan · 🇨🇳 Beijing · 🇨🇳 Shanghai · 🇨🇳 Guangzhou · 🇨🇳 Chengdu · 🇨🇳 Wuhan · 🇭🇰 Hong Kong · 🇹🇼 Taipei · 🇸🇬 Singapore · 🇹🇷 Istanbul · 🇫🇮 Helsinki · 🇳🇱 Amsterdam · 🇫🇷 Paris · 🇺🇸 New York · 🇺🇸 Los Angeles · 🇺🇸 Chicago +

+ + {copy.telegramUpgradeJoin} + + +
+ ) : null} +
{TELEGRAM_BOT_URL ? ( (current === "map" ? "city-list" : current)); + } else { + setActiveView((current) => (current === "city-list" ? "map" : current)); } }; syncMobileViewport(); @@ -343,72 +345,11 @@ function ScanTerminalScreen() { const renderMainView = () => { if (resolvedView === "city-list") { return ( -
-
-
- {isEn ? "Lightweight mobile entry" : "移动端轻量入口"} - {isEn ? "Pick one city to inspect" : "选择一个城市进入决策卡"} -
-

- {isEn - ? "Cached scan rows are used first. The map and heavier evidence load only when opened." - : "优先使用缓存扫描结果;地图和重证据只在打开后加载。"} -

-
-
- {timeSortedRows.slice(0, 24).map((row, index) => { - const cityName = row.city || row.city_display_name || row.display_name || ""; - const display = row.city_display_name || row.display_name || cityName; - const currentTemp = row.current_temp ?? row.current_max_so_far ?? null; - const deb = row.deb_prediction ?? null; - return ( - - ); - })} -
- {!timeSortedRows.length ? ( -
-
- {scanLoading - ? isEn - ? "Loading cached scan" - : "正在读取缓存扫描" - : isEn - ? "No city rows yet" - : "暂无城市列表"} -
-
- {isEn - ? "Use refresh only when you need a new server-side scan." - : "只有需要新的服务端扫描时再手动刷新。"} -
-
- ) : null} -
+ ); } if (resolvedView === "map") { @@ -508,29 +449,32 @@ function ScanTerminalScreen() {
- - + {isMobileViewport ? ( + + ) : ( + + )}
) : !detail ? ( diff --git a/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx b/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx index 89efc1dc..a0944bdb 100644 --- a/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx +++ b/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx @@ -7,24 +7,16 @@ import { type CityStatusTag, type StatusTone, } from "@/components/dashboard/scan-terminal/CityStatusTags"; -import { - DataFreshnessBar, - type DataFreshnessRow, -} from "@/components/dashboard/scan-terminal/DataFreshnessBar"; export function CityCardHeader({ aiStatusLabel, aiStatusTone, collapseId, collapsed, - currentTempText, - observedLabel, - dataFreshnessRows, debText, detailLocalTime, displayName, expectedHighText, - freshnessSeparator, isEn, isRefreshing, modelRange, @@ -40,14 +32,10 @@ export function CityCardHeader({ aiStatusTone: StatusTone; collapseId: string; collapsed: boolean; - currentTempText: string; - observedLabel?: string; - dataFreshnessRows: DataFreshnessRow[]; debText: string; detailLocalTime?: string | null; displayName: string; expectedHighText: string; - freshnessSeparator: string; isEn: boolean; isRefreshing: boolean; modelRange: string; @@ -67,20 +55,9 @@ export function CityCardHeader({

{displayName}

-
- - {observedLabel || (isEn ? "Observed" : "当前温度")} - {currentTempText} - {isEn ? "Expected high" : "预计最高温"} {expectedHighText} diff --git a/frontend/components/dashboard/scan-terminal/MobileCityPicker.tsx b/frontend/components/dashboard/scan-terminal/MobileCityPicker.tsx new file mode 100644 index 00000000..c4192126 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/MobileCityPicker.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { Search, ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ScanOpportunityRow } from "@/lib/dashboard-types"; + +type Locale = "zh-CN" | "en-US"; + +type CityRegionGroup = { + key: string; + label: { en: string; zh: string }; + cities: string[]; +}; + +const CITY_REGION_GROUPS: CityRegionGroup[] = [ + { + key: "eastAsia", + label: { en: "East Asia", zh: "东亚" }, + cities: [ + "tokyo", "seoul", "busan", "beijing", "shanghai", "chengdu", + "chongqing", "wuhan", "shenzhen", "guangzhou", "qingdao", + "hong kong", "lau fau shan", "taipei", + ], + }, + { + key: "southeastAsia", + label: { en: "Southeast Asia", zh: "东南亚" }, + cities: ["singapore", "kuala lumpur", "jakarta", "manila"], + }, + { + key: "southAsia", + label: { en: "South Asia", zh: "南亚" }, + cities: ["karachi", "lucknow"], + }, + { + key: "middleEast", + label: { en: "Middle East", zh: "中东" }, + cities: ["tel aviv", "jeddah", "istanbul", "ankara"], + }, + { + key: "europe", + label: { en: "Europe", zh: "欧洲" }, + cities: [ + "london", "paris", "moscow", "munich", "milan", "warsaw", + "helsinki", "amsterdam", "madrid", + ], + }, + { + key: "northAmerica", + label: { en: "North America", zh: "北美" }, + cities: [ + "new york", "los angeles", "san francisco", "denver", "austin", + "houston", "chicago", "dallas", "miami", "atlanta", "seattle", + "toronto", "mexico city", "panama city", + ], + }, + { + key: "southAmerica", + label: { en: "South America", zh: "南美" }, + cities: ["buenos aires", "são paulo"], + }, + { + key: "africa", + label: { en: "Africa", zh: "非洲" }, + cities: ["lagos", "cape town"], + }, + { + key: "oceania", + label: { en: "Oceania", zh: "大洋洲" }, + cities: ["wellington"], + }, +]; + +const CITY_SEARCH_INDEX: Record = { + "tokyo": ["东京", "東京", "RJTT", "tok", "tyo", "haneda", "羽田"], + "seoul": ["首尔", "RKSI", "sel", "seo", "incheon", "仁川"], + "busan": ["釜山", "RKPK", "pus", "bus", "gimhae", "金海"], + "beijing": ["北京", "ZBAA", "pek", "bjs", "bj", "capital", "首都"], + "shanghai": ["上海", "ZSPD", "sha", "sh", "pudong", "浦东"], + "chengdu": ["成都", "ZUUU", "ctu", "cd", "shuangliu", "双流"], + "chongqing": ["重庆", "ZUCK", "ckg", "cq", "jiangbei", "江北"], + "wuhan": ["武汉", "ZHHH", "wuh", "wh", "tianhe", "天河"], + "shenzhen": ["深圳", "ZGSZ", "szx", "sz", "baoan", "宝安"], + "guangzhou": ["广州", "ZGGG", "can", "gz", "baiyun", "白云"], + "qingdao": ["青岛", "青島", "ZSQD", "tao", "qdo", "jiaodong", "胶东"], + "hong kong": ["香港", "VHHH", "hkg", "hk", "observatory", "天文台"], + "lau fau shan": ["流浮山", "LFS", "lfs"], + "taipei": ["台北", "臺北", "RCSS", "tpe", "tp", "台湾", "臺灣"], + "singapore": ["新加坡", "WSSS", "sin", "sg", "changi", "樟宜"], + "kuala lumpur": ["吉隆坡", "WMKK", "kul", "sepang", "雪邦"], + "jakarta": ["雅加达", "雅加達", "WIHH", "jkt", "halim"], + "manila": ["马尼拉", "馬尼拉", "RPLL", "mnl", "ninoy"], + "karachi": ["卡拉奇", "OPKC", "khi", "jinnah", "真纳"], + "lucknow": ["勒克瑙", "VILK", "luc"], + "tel aviv": ["特拉维夫", "LLBG", "tlv", "ben gurion"], + "jeddah": ["吉达", "吉達", "OEJN", "jed", "king abdulaziz", "阿卜杜勒阿齐兹"], + "istanbul": ["伊斯坦布尔", "LTFM", "ist", "ltfm"], + "ankara": ["安卡拉", "LTAC", "ank", "esenboğa"], + "london": ["伦敦", "EGLC", "lon", "city airport"], + "paris": ["巴黎", "LFPB", "par", "le bourget"], + "moscow": ["莫斯科", "UUWW", "mos", "mow", "vnukovo"], + "munich": ["慕尼黑", "EDDM", "mun"], + "milan": ["米兰", "米蘭", "LIMC", "mil", "mxp", "malpensa", "马尔彭萨"], + "warsaw": ["华沙", "華沙", "EPWA", "waw", "war", "chopin", "肖邦"], + "helsinki": ["赫尔辛基", "赫爾辛基", "EFHK", "hel", "vantaa"], + "amsterdam": ["阿姆斯特丹", "EHAM", "ams", "schiphol", "史基浦"], + "madrid": ["马德里", "馬德里", "LEMD", "mad", "barajas"], + "new york": ["纽约", "KLGA", "nyc", "ny", "laguardia"], + "los angeles": ["洛杉矶", "KLAX", "la", "lax"], + "san francisco": ["旧金山", "KSFO", "sf", "sfo"], + "denver": ["丹佛", "奥罗拉", "KBKF", "aur", "buckley"], + "austin": ["奥斯汀", "KAUS", "aus"], + "houston": ["休斯顿", "KHOU", "hou", "hobby"], + "chicago": ["芝加哥", "KORD", "chi", "ohare"], + "dallas": ["达拉斯", "KDAL", "dal", "love field"], + "miami": ["迈阿密", "KMIA", "mia"], + "atlanta": ["亚特兰大", "KATL", "atl", "hartsfield"], + "seattle": ["西雅图", "KSEA", "sea", "seatac"], + "toronto": ["多伦多", "CYYZ", "tor", "pearson"], + "mexico city": ["墨西哥城", "MMMX", "cdmx"], + "panama city": ["巴拿马城", "MPMG", "pty"], + "buenos aires": ["布宜诺斯艾利斯", "SAEZ", "ba", "ezeiza"], + "são paulo": ["圣保罗", "SBGR", "sp", "guarulhos"], + "lagos": ["拉各斯", "DNMM", "los", "murtala"], + "cape town": ["开普敦", "開普敦", "FACT", "cpt"], + "wellington": ["惠灵顿", "NZWN", "wel"], +}; + +function normalizeCityKey(name: string): string { + return String(name || "").trim().toLowerCase(); +} + +function matchesCitySearch( + cityKey: string, + displayName: string, + query: string, +): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + if (cityKey.includes(q)) return true; + if (displayName.toLowerCase().includes(q)) return true; + const aliases = CITY_SEARCH_INDEX[cityKey]; + if (!aliases) return false; + return aliases.some((alias) => alias.toLowerCase().includes(q)); +} + +function pickCityRow( + rows: ScanOpportunityRow[], + cityKey: string, +): ScanOpportunityRow | null { + return ( + rows.find( + (row) => normalizeCityKey(row.city) === cityKey, + ) || null + ); +} + +export function MobileCityPicker({ + isEn, + rows, + onSelectCity, +}: { + isEn: boolean; + rows: ScanOpportunityRow[]; + onSelectCity: (row: ScanOpportunityRow) => void; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const [expandedRegions, setExpandedRegions] = useState>( + () => new Set(CITY_REGION_GROUPS.map((g) => g.key)), + ); + + const filteredGroups = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + return CITY_REGION_GROUPS + .map((group) => { + const matched = group.cities.filter((cityKey) => { + const row = pickCityRow(rows, cityKey); + if (!row) return false; + const display = + row.city_display_name || row.display_name || cityKey; + return matchesCitySearch(cityKey, display, q); + }); + return { ...group, matched }; + }) + .filter((group) => group.matched.length > 0); + }, [rows, searchQuery]); + + const totalMatched = useMemo( + () => filteredGroups.reduce((sum, g) => sum + g.matched.length, 0), + [filteredGroups], + ); + + const toggleRegion = (key: string) => { + setExpandedRegions((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + return ( +
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery ? ( + + ) : null} +
+ + {searchQuery ? ( +
+ {isEn + ? `${totalMatched} city${totalMatched !== 1 ? "ies" : "y"} found` + : `找到 ${totalMatched} 个城市`} +
+ ) : null} + + {filteredGroups.length === 0 ? ( +
+ {isEn ? "No cities match your search" : "没有匹配的城市"} +
+ ) : ( +
+ {filteredGroups.map((group) => { + const isExpanded = expandedRegions.has(group.key) || !!searchQuery; + return ( +
+ + {isExpanded ? ( +
+ {group.matched.map((cityKey) => { + const row = pickCityRow(rows, cityKey); + if (!row) return null; + const display = + row.city_display_name || + row.display_name || + cityKey; + const currentTemp = + row.current_temp ?? row.current_max_so_far ?? null; + const deb = row.deb_prediction ?? null; + const tempUnit = row.temp_symbol || "°C"; + return ( + + ); + })} +
+ ) : null} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx index 44afa7bc..6d480e1e 100644 --- a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx +++ b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx @@ -3,18 +3,12 @@ import { RefreshCw, X } from "lucide-react"; import type { MouseEvent } from "react"; import { useState } from "react"; -import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart"; import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel"; -import { AirportEvidencePanel } from "@/components/dashboard/scan-terminal/AirportEvidencePanel"; import { CityStatusTags, type CityStatusTag, type StatusTone, } from "@/components/dashboard/scan-terminal/CityStatusTags"; -import { - DataFreshnessBar, - type DataFreshnessRow, -} from "@/components/dashboard/scan-terminal/DataFreshnessBar"; import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; import { MarketDecisionLine } from "@/components/dashboard/scan-terminal/MarketDecisionLine"; import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel"; @@ -42,15 +36,12 @@ export function MobileDecisionCard({ aiReadInProgressText, aiRuleEvidenceMode, aiRuleEvidenceText, - currentTempText, - dataFreshnessRows, debPrediction, decisionState, detail, displayName, expectedHighText, fallbackAiReason, - freshnessSeparator, isEn, isHkoObservation, isRefreshing, @@ -61,7 +52,6 @@ export function MobileDecisionCard({ onRefresh, onRemove, peakWindow, - rawObservationText, removing, tempSymbol, }: { @@ -76,15 +66,12 @@ export function MobileDecisionCard({ aiReadInProgressText: string; aiRuleEvidenceMode: boolean; aiRuleEvidenceText: string; - currentTempText: string; - dataFreshnessRows: DataFreshnessRow[]; debPrediction: number | null; decisionState: CityDecisionState; detail: CityDetail | null; displayName: string; expectedHighText: string; fallbackAiReason: string; - freshnessSeparator: string; isEn: boolean; isHkoObservation: boolean; isRefreshing: boolean; @@ -95,15 +82,12 @@ export function MobileDecisionCard({ onRefresh: (event: MouseEvent) => void; onRemove: (event: MouseEvent) => void; peakWindow: string; - rawObservationText: string; removing?: boolean; tempSymbol: string; }) { const copy = getMobileDecisionCopy(isEn); const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation }); const [modelOpen, setModelOpen] = useState(false); - const [chartOpen, setChartOpen] = useState(false); - const [airportOpen, setAirportOpen] = useState(false); const statusTags: CityStatusTag[] = decisionState.badges.length ? decisionState.badges : [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }]; @@ -142,11 +126,7 @@ export function MobileDecisionCard({
- - {copy.currentTemp} - {currentTempText} - - + {copy.expectedHigh} {expectedHighText} @@ -158,13 +138,6 @@ export function MobileDecisionCard({

{decisionState.primaryReason}

- -
setAirportOpen(event.currentTarget.open)} - > - - {isEn ? "Airport live evidence" : "机场实时证据"} - - {airportOpen ? : null} -
-
{copy.modelEvidence} {modelOpen ? : null}
- -
setChartOpen(event.currentTarget.open)} - > - {copy.chart} - {chartOpen ? : null} -
)} diff --git a/scripts/create_forum_topics.py b/scripts/create_forum_topics.py new file mode 100644 index 00000000..b9ce0329 --- /dev/null +++ b/scripts/create_forum_topics.py @@ -0,0 +1,162 @@ +""" +一次性为新 Telegram 群组创建所有城市的 Forum Topics。 + +用法: + python scripts/create_forum_topics.py + +会生成 data/city_thread_ids.json 供后续推送路由使用。 +""" + +from __future__ import annotations + +import json +import os +import sys +import time + +import requests + + +def _load_env() -> None: + """从项目根目录 .env 加载环境变量(仅设置尚未存在的变量)。""" + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env_path = os.path.join(root, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key in os.environ: + continue + value = value.strip().strip('"').strip("'") + os.environ[key] = value + + +_load_env() +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip() +CHAT_ID = -1003965137823 + +CITIES: list[tuple[str, str]] = [ + ("🇨🇳 Beijing", "beijing"), + ("🇨🇳 Shanghai", "shanghai"), + ("🇨🇳 Guangzhou", "guangzhou"), + ("🇨🇳 Qingdao", "qingdao"), + ("🇨🇳 Chengdu", "chengdu"), + ("🇨🇳 Chongqing", "chongqing"), + ("🇨🇳 Wuhan", "wuhan"), + ("🇭🇰 Hong Kong", "hong kong"), + ("🇭🇰 Lau Fau Shan", "lau fau shan"), + ("🇹🇼 Taipei", "taipei"), + ("🇰🇷 Seoul", "seoul"), + ("🇰🇷 Busan", "busan"), + ("🇯🇵 Tokyo", "tokyo"), + ("🇸🇬 Singapore", "singapore"), + ("🇹🇷 Istanbul", "istanbul"), + ("🇹🇷 Ankara", "ankara"), + ("🇫🇮 Helsinki", "helsinki"), + ("🇳🇱 Amsterdam", "amsterdam"), + ("🇫🇷 Paris", "paris"), + ("🇺🇸 New York", "new york"), + ("🇺🇸 Los Angeles", "los angeles"), + ("🇺🇸 Chicago", "chicago"), + ("🇺🇸 Denver", "denver"), + ("🇺🇸 Atlanta", "atlanta"), + ("🇺🇸 Miami", "miami"), + ("🇺🇸 San Francisco", "san francisco"), + ("🇺🇸 Houston", "houston"), + ("🇺🇸 Dallas", "dallas"), + ("🇺🇸 Austin", "austin"), + ("🇺🇸 Seattle", "seattle"), +] + +URL = f"https://api.telegram.org/bot{BOT_TOKEN}/createForumTopic" + + +def _load_existing(out_path: str) -> dict: + if os.path.isfile(out_path): + try: + with open(out_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + +def _save_mapping(out_path: str, mapping: dict) -> None: + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(mapping, f, ensure_ascii=False, indent=2) + + +def main() -> int: + if not BOT_TOKEN: + print("未设置 TELEGRAM_BOT_TOKEN 环境变量", file=sys.stderr) + return 1 + + out_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "city_thread_ids.json", + ) + + mapping = _load_existing(out_path) + if mapping: + print(f"已加载 {len(mapping)} 个已有话题,将跳过已创建的城市") + + for label, city_key in CITIES: + if city_key in mapping: + print(f"SKIP {label} -> 已存在 thread_id={mapping[city_key]}") + continue + + try: + r = requests.post(URL, json={"chat_id": CHAT_ID, "name": label}, timeout=15) + data = r.json() + except Exception as exc: + print(f"ERR {label} -> network: {exc}", file=sys.stderr) + continue + + if not data.get("ok"): + err_code = data.get("error_code", "") + err_desc = data.get("description", "") + retry_after = (data.get("parameters") or {}).get("retry_after", 0) + print(f"ERR {label} -> {err_code} {err_desc} retry_after={retry_after}s", file=sys.stderr) + if retry_after: + wait = int(retry_after) + 2 + print(f" wait {wait}s ...") + time.sleep(wait) + try: + r2 = requests.post(URL, json={"chat_id": CHAT_ID, "name": label}, timeout=15) + data2 = r2.json() + if data2.get("ok"): + tid = (data2.get("result") or {}).get("message_thread_id") + if tid: + mapping[city_key] = int(tid) + print(f"OK {label} -> thread_id={tid} (retry)") + _save_mapping(out_path, mapping) + continue + else: + print(f"ERR {label} -> retry failed: {data2.get('description')}", file=sys.stderr) + except Exception as exc2: + print(f"ERR {label} -> retry network: {exc2}", file=sys.stderr) + continue + + tid = (data.get("result") or {}).get("message_thread_id") + if not tid: + print(f"WARN {label} -> no thread_id: {data}", file=sys.stderr) + continue + + mapping[city_key] = int(tid) + print(f"OK {label} -> thread_id={tid}") + _save_mapping(out_path, mapping) + time.sleep(1.2) + + _save_mapping(out_path, mapping) + print(f"\nDone: {len(mapping)}/{len(CITIES)} cities saved to {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/delete_forum_topic.py b/scripts/delete_forum_topic.py new file mode 100644 index 00000000..7d12ed30 --- /dev/null +++ b/scripts/delete_forum_topic.py @@ -0,0 +1,47 @@ +"""删除指定 thread_id 的 Forum Topic。用法: python scripts/delete_forum_topic.py """ +from __future__ import annotations + +import os +import sys +import requests + + +def _load_env() -> None: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env_path = os.path.join(root, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key in os.environ: + continue + os.environ[key] = value.strip().strip('"').strip("'") + + +_load_env() +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip() +CHAT_ID = -1003965137823 + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法: python scripts/delete_forum_topic.py ") + print("获取 thread_id: 在 Telegram 长按话题 -> 或转发该话题一条消息到 @RawDataBot") + sys.exit(1) + + tid = int(sys.argv[1]) + r = requests.post( + f"https://api.telegram.org/bot{TOKEN}/deleteForumTopic", + json={"chat_id": CHAT_ID, "message_thread_id": tid}, + timeout=10, + ) + data = r.json() + if data.get("ok"): + print(f"已删除 thread_id={tid}") + else: + print(f"失败: {data.get('description')} (code={data.get('error_code')})") + sys.exit(1) diff --git a/scripts/scan_forum_topics.py b/scripts/scan_forum_topics.py new file mode 100644 index 00000000..8854d48f --- /dev/null +++ b/scripts/scan_forum_topics.py @@ -0,0 +1,73 @@ +"""扫描群组内所有 Forum Topic,打印名称和 thread_id。""" +from __future__ import annotations + +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests + + +def _load_env() -> None: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env_path = os.path.join(root, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key in os.environ: + continue + os.environ[key] = value.strip().strip('"').strip("'") + + +_load_env() +TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip() +CHAT_ID = -1003965137823 + + +def _try_get_topic(tid: int) -> dict | None: + try: + r = requests.get( + f"https://api.telegram.org/bot{TOKEN}/getForumTopic", + params={"chat_id": CHAT_ID, "message_thread_id": tid}, + timeout=10, + ) + data = r.json() + if data.get("ok"): + name = (data.get("result") or {}).get("name", "") + return {"thread_id": tid, "name": name} + except Exception: + pass + return None + + +def main() -> int: + if not TOKEN: + print("未设置 TELEGRAM_BOT_TOKEN", file=sys.stderr) + return 1 + + print(f"扫描群组话题...") + topics = [] + with ThreadPoolExecutor(max_workers=10) as pool: + futures = {pool.submit(_try_get_topic, tid): tid for tid in range(1, 101)} + for future in as_completed(futures): + result = future.result() + if result: + topics.append(result) + print(f" thread_id={result['thread_id']} {result['name']}") + + topics.sort(key=lambda t: t["thread_id"]) + print(f"\n共 {len(topics)} 个话题:") + for t in topics: + print(f" {t['thread_id']:>4} {t['name']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 6ebe2107..a5c7ba41 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -21,6 +21,45 @@ from src.utils.telegram_chat_ids import ( get_telegram_chat_ids_from_env, ) +# Forum topic routing: maps city_key -> message_thread_id for the push forum group. +# Created by scripts/create_forum_topics.py, stored in the runtime data dir. +_CITY_THREAD_IDS_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "data", "city_thread_ids.json", +) +_FORUM_CHAT_ID = "-1003965137823" +_city_thread_ids: dict = {} + + +def _load_city_thread_ids() -> dict: + global _city_thread_ids + if _city_thread_ids: + return _city_thread_ids + paths = [ + _CITY_THREAD_IDS_PATH, + "/var/lib/polyweather/city_thread_ids.json", + "/app/data/city_thread_ids.json", + ] + for path in paths: + if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as f: + _city_thread_ids = json.load(f) + logger.info("loaded city_thread_ids from {}: {} cities", path, len(_city_thread_ids)) + return _city_thread_ids + except Exception as exc: + logger.warning("failed to load city_thread_ids from {}: {}", path, exc) + return {} + + +def _resolve_thread_id(chat_id: str, city: str) -> int: + """Return message_thread_id for a given chat and city, or 0 if not a forum topic.""" + if str(chat_id) != _FORUM_CHAT_ID: + return 0 + mapping = _load_city_thread_ids() + city_key = str(city or "").strip().lower() + return int(mapping.get(city_key) or 0) + SEVERITY_RANK = { "none": 0, @@ -1186,7 +1225,11 @@ def _run_high_freq_airport_cycle( sent = False for chat_id in chat_ids: try: - bot.send_message(chat_id, message) + kwargs = {} + thread_id = _resolve_thread_id(chat_id, city) + if thread_id: + kwargs["message_thread_id"] = thread_id + bot.send_message(chat_id, message, **kwargs) sent = True except Exception as exc: logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, exc) @@ -1288,12 +1331,17 @@ def _run_market_monitor_cycle(bot: Any, chat_ids: List[str]) -> bool: continue if message is None: continue + city_name = futures[future] for chat_id in chat_ids: try: - bot.send_message(chat_id, message) + kwargs = {} + thread_id = _resolve_thread_id(chat_id, city_name) + if thread_id: + kwargs["message_thread_id"] = thread_id + bot.send_message(chat_id, message, **kwargs) sent_any = True except Exception as exc: - logger.warning("market monitor push failed city={} chat_id={}: {}", futures[future], chat_id, exc) + logger.warning("market monitor push failed city={} chat_id={}: {}", city_name, chat_id, exc) return sent_any