diff --git a/.env.example b/.env.example index cae44f2b..b12b1466 100644 --- a/.env.example +++ b/.env.example @@ -40,10 +40,14 @@ TELEGRAM_QUERY_TOPIC_ID= TELEGRAM_QUERY_TOPIC_MAP= POLYWEATHER_BOT_GROUP_INVITE_URL= POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app +# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it. +TELEGRAM_PUSH_LANGUAGE=both # High-frequency airport push loop. Keep workers at 1 on shared VPS. TELEGRAM_AIRPORT_PUSH_ENABLED=true TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60 TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1 +# Optional airport-only override. +TELEGRAM_AIRPORT_PUSH_LANGUAGE=both # Docker-only safety limits for the bot service. POLYWEATHER_BOT_CPUS=0.75 POLYWEATHER_BOT_MEM_LIMIT=768m diff --git a/docs/AIRPORT_REALTIME_SOURCES.md b/docs/AIRPORT_REALTIME_SOURCES.md index 0b0d231a..bb9a8910 100644 --- a/docs/AIRPORT_REALTIME_SOURCES.md +++ b/docs/AIRPORT_REALTIME_SOURCES.md @@ -78,8 +78,10 @@ Seoul / Incheon 16:03 | 变量 | 说明 | 默认值 | |------|------|--------| +| `TELEGRAM_PUSH_LANGUAGE` | Telegram 自动推送的全局语言,可选 `both`/`en`/`zh` | `both` | | `TELEGRAM_AIRPORT_PUSH_ENABLED` | 启用机场推送 | `true` | | `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` | +| `TELEGRAM_AIRPORT_PUSH_LANGUAGE` | 机场推送语言覆盖,可选 `both`/`en`/`zh` | `both` | | `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — | ## 未接入城市 diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 781d39ef..f73a7386 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -438,6 +438,47 @@ animation: markerGlow 2s ease-in-out infinite; } + .peak-glow-card { + position: relative; + isolation: isolate; + } + + .peak-glow-card::after { + content: ""; + pointer-events: none; + position: absolute; + inset: 0; + z-index: 20; + border-radius: inherit; + } + + .peak-glow-watch::after { + box-shadow: + inset 0 0 0 1px rgba(245, 158, 11, 0.42), + inset 0 0 18px rgba(245, 158, 11, 0.12); + } + + .peak-glow-near::after { + animation: peakGlowNear 3.2s ease-in-out infinite; + } + + .peak-glow-breakout::after { + animation: peakGlowBreakout 2.4s ease-in-out infinite; + } + + .peak-glow-cooling::after { + box-shadow: + inset 0 0 0 1px rgba(100, 116, 139, 0.38), + inset 0 0 18px rgba(148, 163, 184, 0.16); + } + + @media (prefers-reduced-motion: reduce) { + .peak-glow-near::after, + .peak-glow-breakout::after { + animation: none; + } + } + /* ── Scrollbar ── */ .custom-scrollbar::-webkit-scrollbar { width: 6px; @@ -460,3 +501,35 @@ box-shadow: 0 10px 26px rgba(0, 224, 164, 0.3); } } + +@keyframes peakGlowNear { + 0%, + 100% { + box-shadow: + inset 0 0 0 1px rgba(249, 115, 22, 0.48), + inset 0 0 18px rgba(249, 115, 22, 0.12), + 0 0 0 rgba(249, 115, 22, 0); + } + 50% { + box-shadow: + inset 0 0 0 1px rgba(249, 115, 22, 0.7), + inset 0 0 28px rgba(249, 115, 22, 0.22), + 0 0 22px rgba(249, 115, 22, 0.18); + } +} + +@keyframes peakGlowBreakout { + 0%, + 100% { + box-shadow: + inset 0 0 0 1px rgba(244, 63, 94, 0.54), + inset 0 0 22px rgba(244, 63, 94, 0.16), + 0 0 0 rgba(244, 63, 94, 0); + } + 50% { + box-shadow: + inset 0 0 0 1px rgba(244, 63, 94, 0.78), + inset 0 0 32px rgba(244, 63, 94, 0.26), + 0 0 24px rgba(244, 63, 94, 0.2); + } +} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index d6ef2095..a21a5c6d 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -822,8 +822,13 @@ function PolyWeatherTerminal({ > {visibleSlots.map((cityInSlot, slotIndex) => { const isSlotActive = activeSlotIndex === slotIndex; + const rowForSlot = cityInSlot + ? filteredRegionRows.find( + (r) => String(r.city || "").toLowerCase() === cityInSlot + ) || null + : null; - if (!cityInSlot) { + if (!cityInSlot || !rowForSlot) { return (
String(r.city || "").toLowerCase() === cityInSlot - ) || null; - return (
buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]); + const peakGlow = useMemo(() => getPeakGlowState(row, data, series), [row, data, series]); const autoWindowRange = useMemo( () => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null), @@ -442,6 +484,17 @@ export function LiveTemperatureThresholdChart({ · {subtitle} + {peakGlow.state !== "none" && ( + + {peakGlowLabel(peakGlow.state, isEn)} + + )}
) : isEn ? ( "Temperature Chart" @@ -559,7 +612,11 @@ export function LiveTemperatureThresholdChart({ }; return ( - +
value !== null).length >= 2, "MADIS series should keep sub-hourly observations"); + const torontoWithLatestAirportReport = __buildTemperatureChartDataForTest( + { + city: "toronto", + local_date: "2026-05-27", + local_time: "19:16", + tz_offset_seconds: -4 * 60 * 60, + airport: "CYYZ", + temp_symbol: "°C", + } as any, + { + localTime: "19:16", + times: ["10:00", "13:00", "16:00", "19:00"], + temps: [23, 26, 27, 26], + airportPrimary: { + source_code: "metar", + source_label: "METAR", + temp: 26, + obs_time: "2026-05-27T23:16:00Z", + }, + airportPrimaryTodayObs: [ + ["2026-05-27T21:00:00Z", 27], + ["2026-05-27T22:00:00Z", 28], + ["2026-05-27T23:00:00Z", 27], + ], + } as any, + "1D", + ); + const latestAirportPoint = torontoWithLatestAirportReport.data.find( + (point) => point.label === "19:16:00" && point.madis === 26, + ); + assert( + latestAirportPoint, + "latest airport/METAR report should be appended to the live chart series even when history stops earlier", + ); + const newYorkMinuteStream = __buildTemperatureChartDataForTest( { city: "new york", diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts index 6ebf9cb8..a054b101 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -41,4 +41,9 @@ export function runTests() { selectorSource.includes("grid grid-cols-3"), "grid selector must expose at most a 3 by 3 chart layout", ); + assert( + dashboardSource.includes("if (!cityInSlot || !rowForSlot)") && + dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"), + "stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 8c057f00..a0636a3f 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -187,6 +187,20 @@ type EvidenceSeries = { values: Array; }; +type PeakGlowState = "none" | "watch" | "near_peak" | "breakout" | "cooling"; + +type PeakGlowMeta = { + state: PeakGlowState; + currentTemp: number | null; + debPeak: number | null; + distanceToDeb: number | null; + trend30m: number | null; + trend60m: number | null; + observedHigh: number | null; + peakStartTs: number | null; + peakEndTs: number | null; +}; + type RunwayHistorySeries = { key: string; label: string; @@ -435,6 +449,36 @@ function normObs( .slice(-limit); } +function appendLatestAirportObservation( + points: RawObsPoint[] | null | undefined, + ...currentSources: Array +): RawObsPoint[] { + const merged = [...(points || [])]; + const seen = new Set( + merged + .map(normalizeRawObsPoint) + .filter((point): point is ObsPoint => point !== null) + .map((point) => `${String(point.time || "")}:${validNumber(point.temp) ?? ""}`), + ); + + currentSources.forEach((source) => { + const temp = validNumber(source?.temp); + const time = + (source as any)?.obs_time ?? + (source as any)?.observation_time ?? + (source as any)?.timestamp ?? + (source as any)?.time ?? + null; + if (temp === null || !time) return; + const key = `${String(time)}:${temp}`; + if (seen.has(key)) return; + seen.add(key); + merged.push({ time: String(time), temp }); + }); + + return merged; +} + function seriesStats(values: Array) { const nums = values.filter((v): v is number => validNumber(v) !== null); const latest = nums.length ? nums[nums.length - 1] : null; @@ -479,7 +523,12 @@ function getObservationDisplayMetrics( const localDateStr = resolveChartLocalDate(row, hourly); const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr); const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr); - const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr); + const madisObs = normObs( + appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent), + tzOffset, + MAX_OBS_POINTS, + localDateStr, + ); const latestSettlement = latestObservationValue(settlementObs); const latestMetar = latestObservationValue(metarObs); const latestMadis = latestObservationValue(madisObs); @@ -1236,7 +1285,15 @@ function buildFullDayChartData( normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr), localDayBounds, ); - const madisObs = filterTimelinePointsToLocalDay(normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr), localDayBounds); + const madisObs = filterTimelinePointsToLocalDay( + normObs( + appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent), + tzOffset, + MAX_OBS_POINTS, + localDateStr, + ), + localDayBounds, + ); const runwayHistorySeries = filterRunwayHistoryToLocalDay( buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr), localDayBounds, @@ -1535,6 +1592,163 @@ function latestLiveObservationTimestamp( return latest; } +function chartDeltaForCelsius(row: ScanOpportunityRow | null, deltaC: number) { + const symbol = String(row?.temp_symbol || "").toUpperCase(); + return symbol.includes("F") ? deltaC * 1.8 : deltaC; +} + +function getLiveObservationPoints( + data: Array>, + series: EvidenceSeries[], +) { + const liveSeries = series.filter(isLiveObservationSeries); + const points: Array<{ ts: number; temp: number }> = []; + data.forEach((row, index) => { + const ts = typeof row?.ts === "number" ? row.ts : null; + if (ts === null) return; + const values = liveSeries + .map((item) => validNumber(item.values[index])) + .filter((value): value is number => value !== null); + if (!values.length) return; + points.push({ ts, temp: Math.max(...values) }); + }); + return points.sort((left, right) => left.ts - right.ts); +} + +function getDebPeakProfile( + row: ScanOpportunityRow | null, + data: Array>, + series: EvidenceSeries[], +) { + const debSeries = series.find((item) => item.key === "hourly_forecast"); + if (!debSeries) return null; + const debPoints = debSeries.values + .map((value, index) => { + const ts = typeof data[index]?.ts === "number" ? data[index].ts : null; + const temp = validNumber(value); + return ts === null || temp === null ? null : { index, ts, temp }; + }) + .filter((point): point is { index: number; ts: number; temp: number } => point !== null); + if (!debPoints.length) return null; + const peak = debPoints.reduce((best, point) => (point.temp > best.temp ? point : best), debPoints[0]); + let peakIndex = debPoints.findIndex((point) => point.index === peak.index); + if (peakIndex < 0) peakIndex = 0; + + const hotThreshold = peak.temp - chartDeltaForCelsius(row, 2); + let hotStartIndex = peakIndex; + let hotEndIndex = peakIndex; + while (hotStartIndex > 0 && debPoints[hotStartIndex - 1].temp >= hotThreshold) { + hotStartIndex -= 1; + } + while (hotEndIndex < debPoints.length - 1 && debPoints[hotEndIndex + 1].temp >= hotThreshold) { + hotEndIndex += 1; + } + + return { + peak, + hotStartTs: debPoints[hotStartIndex].ts, + hotEndTs: debPoints[hotEndIndex].ts, + }; +} + +function pointAtOrBefore( + points: Array<{ ts: number; temp: number }>, + targetTs: number, +): { ts: number; temp: number } | null { + let match: { ts: number; temp: number } | null = null; + for (const point of points) { + if (point.ts <= targetTs) match = point; + } + return match; +} + +function getPeakGlowState( + row: ScanOpportunityRow | null, + data: Array>, + series: EvidenceSeries[], +): PeakGlowMeta { + const empty: PeakGlowMeta = { + state: "none", + currentTemp: null, + debPeak: null, + distanceToDeb: null, + trend30m: null, + trend60m: null, + observedHigh: null, + peakStartTs: null, + peakEndTs: null, + }; + const livePoints = getLiveObservationPoints(data, series); + const latest = livePoints[livePoints.length - 1] || null; + const debProfile = getDebPeakProfile(row, data, series); + if (!latest || !debProfile) return empty; + + const peakStartTs = debProfile.hotStartTs; + const peakEndTs = debProfile.hotEndTs; + const previousLivePoints = livePoints.filter((point) => point.ts < latest.ts); + const previousHigh = previousLivePoints.length + ? Math.max(...previousLivePoints.map((point) => point.temp)) + : null; + const observedHigh = Math.max(...livePoints.map((point) => point.temp)); + const trend30Base = pointAtOrBefore(livePoints, latest.ts - 30 * 60 * 1000); + const trend60Base = pointAtOrBefore(livePoints, latest.ts - 60 * 60 * 1000); + const trend30m = trend30Base ? latest.temp - trend30Base.temp : null; + const trend60m = trend60Base ? latest.temp - trend60Base.temp : null; + const distanceToDeb = debProfile.peak.temp - latest.temp; + + const metaBase = { + currentTemp: latest.temp, + debPeak: debProfile.peak.temp, + distanceToDeb, + trend30m, + trend60m, + observedHigh, + peakStartTs, + peakEndTs, + }; + + const nearThreshold = chartDeltaForCelsius(row, 2); + const watchThreshold = chartDeltaForCelsius(row, 3); + const breakoutNearThreshold = chartDeltaForCelsius(row, 0.5); + const flatTrendFloor = -chartDeltaForCelsius(row, 0.2); + const coolingDrop = -chartDeltaForCelsius(row, 0.5); + const breakoutStep = chartDeltaForCelsius(row, 0.1); + const phase = String((row as any)?.window_phase || "").toLowerCase(); + const afterPeak = + phase === "post_peak" || + (peakEndTs !== null && latest.ts > peakEndTs); + const isCooling = + afterPeak && + ((trend60m !== null && trend60m <= coolingDrop) || + (previousHigh !== null && latest.temp <= previousHigh + coolingDrop)); + if (isCooling) return { state: "cooling", ...metaBase }; + + const isBreakout = + latest.temp >= debProfile.peak.temp || + (distanceToDeb <= breakoutNearThreshold && + previousHigh !== null && + latest.temp > previousHigh + breakoutStep); + if (isBreakout) return { state: "breakout", ...metaBase }; + + if ( + distanceToDeb <= nearThreshold && + (trend30m === null || trend30m >= flatTrendFloor) + ) { + return { state: "near_peak", ...metaBase }; + } + + const isNearPeakTime = + peakStartTs !== null && + peakEndTs !== null && + latest.ts <= peakEndTs && + peakStartTs - latest.ts <= 3 * 60 * 60 * 1000; + if (distanceToDeb <= watchThreshold || isNearPeakTime) { + return { state: "watch", ...metaBase }; + } + + return { state: "none", ...metaBase }; +} + function getDebPeakWindowRange( data: Array>, series: EvidenceSeries[], @@ -1645,6 +1859,7 @@ export { buildChartDomain, buildFullDayChartData, getDebPeakWindowRange, + getPeakGlowState, buildIntDegreeTicks, buildModelSummaryCards, buildRunwayPlates, @@ -1664,4 +1879,4 @@ export { validNumber, }; -export type { EvidenceSeries, HourlyForecast }; +export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState }; diff --git a/src/bot/weekly_reward_loop.py b/src/bot/weekly_reward_loop.py index b0f69c35..5a28e558 100644 --- a/src/bot/weekly_reward_loop.py +++ b/src/bot/weekly_reward_loop.py @@ -21,6 +21,7 @@ from src.bot.settings import ( ) from src.database.db_manager import DBManager from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env +from src.utils.telegram_i18n import copy_text, normalize_push_language, telegram_push_language def _env_bool(name: str, default: bool) -> bool: @@ -41,6 +42,14 @@ def _env_int(name: str, default: int, min_value: int = 0) -> int: return max(min_value, value) +def _weekly_reward_language() -> str: + return telegram_push_language( + "POLYWEATHER_WEEKLY_REWARD_LANGUAGE", + "TELEGRAM_PUSH_LANGUAGE", + "POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", + ) + + def _safe_week_key(dt: datetime) -> str: iso_year, iso_week, _ = dt.isocalendar() return f"{iso_year}-W{iso_week:02d}" @@ -177,10 +186,19 @@ def _render_settle_report( skipped: int, participation_count: int = 0, active_count: int = 0, + language: Optional[str] = None, ) -> str: - lines = [f"🏆 PolyWeather 周榜奖励已结算 ({week_key})", "────────────────────"] + language = normalize_push_language(language or _weekly_reward_language()) + lines = [ + copy_text( + language, + f"🏆 PolyWeather weekly rewards settled ({week_key})", + f"🏆 PolyWeather 周榜奖励已结算 ({week_key})", + ), + "────────────────────", + ] if not winners: - lines.append("本周无上榜用户。") + lines.append(copy_text(language, "No ranked users this week.", "本周无上榜用户。")) else: medals = {1: "🥇", 2: "🥈", 3: "🥉"} for row in winners: @@ -188,16 +206,39 @@ def _render_settle_report( name = str(row.get("username") or "unknown")[:16] points_bonus = int(row.get("points_bonus") or 0) pro_days = int(row.get("pro_days") or 0) - pro_text = f" + {pro_days}天Pro" if pro_days > 0 else "" + pro_text = ( + copy_text(language, f" + {pro_days}d Pro", f" + {pro_days}天Pro") + if pro_days > 0 + else "" + ) medal = medals.get(rank, f"{rank}.") - lines.append(f"{medal} {name}: +{points_bonus} 积分{pro_text}") + points_label = copy_text(language, "points", "积分") + lines.append(f"{medal} {name}: +{points_bonus} {points_label}{pro_text}") if skipped > 0: - lines.append(f"(重复发放保护已生效,跳过 {skipped} 条)") + lines.append( + copy_text( + language, + f"Duplicate payout guard skipped {skipped} rows.", + f"(重复发放保护已生效,跳过 {skipped} 条)", + ) + ) if participation_count > 0 or active_count > 0: lines.append("────────────────────") - lines.append(f"🎁 参与奖 +{WEEKLY_PARTICIPATION_BONUS} 积分: {participation_count} 人") + lines.append( + copy_text( + language, + f"🎁 Participation bonus +{WEEKLY_PARTICIPATION_BONUS} points: {participation_count} users", + f"🎁 参与奖 +{WEEKLY_PARTICIPATION_BONUS} 积分: {participation_count} 人", + ) + ) if active_count > 0: - lines.append(f"🔥 活跃奖 +{WEEKLY_ACTIVE_BONUS} 积分 (周积分≥{WEEKLY_ACTIVE_THRESHOLD}): {active_count} 人") + lines.append( + copy_text( + language, + f"🔥 Active bonus +{WEEKLY_ACTIVE_BONUS} points (weekly points >= {WEEKLY_ACTIVE_THRESHOLD}): {active_count} users", + f"🔥 活跃奖 +{WEEKLY_ACTIVE_BONUS} 积分 (周积分≥{WEEKLY_ACTIVE_THRESHOLD}): {active_count} 人", + ) + ) return "\n".join(lines) diff --git a/src/utils/daily_weather_report.py b/src/utils/daily_weather_report.py index 23f63838..b6f940a9 100644 --- a/src/utils/daily_weather_report.py +++ b/src/utils/daily_weather_report.py @@ -22,6 +22,7 @@ except Exception: from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.weather_sources import WeatherDataCollector +from src.utils.telegram_i18n import copy_text, normalize_push_language, telegram_push_language TARGET_CITIES: List[str] = [ "beijing", @@ -73,6 +74,14 @@ def _env_int(name: str, default: int, min_val: int = 0) -> int: return default +def _daily_report_language() -> str: + return telegram_push_language( + "DAILY_WEATHER_REPORT_LANGUAGE", + "TELEGRAM_PUSH_LANGUAGE", + "POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", + ) + + def _fetch_cma_forecast(city_key: str) -> Optional[Dict[str, Any]]: """Scrape today's forecast from weather.com.cn (CMA).""" code = CMA_CITY_CODES.get(city_key) @@ -158,13 +167,14 @@ def _fetch_city_data( collector: WeatherDataCollector, city_key: str ) -> Optional[Dict[str, Any]]: name = CITY_NAME_ZH.get(city_key, city_key) + info = CITY_REGISTRY.get(city_key) + name_en = str((info or {}).get("name") or city_key.title()) # 1. Try CMA first for weather description cma = _fetch_cma_forecast(city_key) # 2. Get Open-Meteo data for temperature fallback om_high = None - info = CITY_REGISTRY.get(city_key) if info: try: results = collector.fetch_all_sources( @@ -209,6 +219,7 @@ def _fetch_city_data( return { "city": city_key, "name": name, + "name_en": name_en, "weather": weather or "?", "forecast_high": forecast_high, } @@ -235,12 +246,40 @@ def _wmo_to_weather(code: Any) -> str: return "阴" -def _build_ai_prompt(cities_data: List[Dict[str, Any]], report_date: str) -> str: - lines = [f"今天是 {report_date}。请用自然亲切的中文写一段天气日报。\n"] - lines.append("城市天气数据:") +def _build_ai_prompt( + cities_data: List[Dict[str, Any]], + report_date: str, + language: Optional[str] = None, +) -> str: + language = normalize_push_language(language or _daily_report_language()) + if language == "zh": + lines = [f"今天是 {report_date}。请用自然亲切的中文写一段天气日报。\n"] + lines.append("城市天气数据:") + for c in cities_data: + lines.append(f"{c['name']}:{c['weather']},最高{c['forecast_high']}度") + lines.append("\n要求:每城一行播报,城市名加粗,开头问候,禁止结尾废话。") + return "\n".join(lines) + + if language == "en": + lines = [f"Today is {report_date}. Write a concise Telegram weather briefing in English.\n"] + lines.append("City weather data:") + for c in cities_data: + lines.append( + f"{c.get('name_en') or c['city'].title()}: weather={c['weather']}, high={c['forecast_high']}°C" + ) + lines.append("\nRequirements: one line per city, bold city names with , start with a short greeting, no generic closing.") + return "\n".join(lines) + + lines = [f"Today is {report_date}. Write a bilingual Telegram weather briefing.\n"] + lines.append("City weather data:") for c in cities_data: - lines.append(f"{c['name']}:{c['weather']},最高{c['forecast_high']}度") - lines.append("\n要求:每城一行播报,城市名加粗,开头问候,禁止结尾废话。") + lines.append( + f"{c.get('name_en') or c['city'].title()} / {c['name']}: weather={c['weather']}, high={c['forecast_high']}°C" + ) + lines.append( + "\nRequirements: one line per city, English first then Chinese in the same line, " + "bold city names with , start with a short bilingual greeting, no generic closing." + ) return "\n".join(lines) @@ -352,8 +391,9 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None: time.sleep(60) continue + language = _daily_report_language() report_date = now.strftime("%m月%d日") - prompt = _build_ai_prompt(cities_data, report_date) + prompt = _build_ai_prompt(cities_data, report_date, language=language) report_text = _call_ai(prompt) if not report_text: @@ -363,7 +403,11 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None: continue try: - report_text += "\n\n⚠️ 以上为粗略预测,仅供参考。" + report_text += "\n\n" + copy_text( + language, + "⚠️ Rough forecast only. For reference.", + "⚠️ 以上为粗略预测,仅供参考。", + ) bot.send_message( FORUM_CHAT_ID, report_text, diff --git a/src/utils/telegram_i18n.py b/src/utils/telegram_i18n.py new file mode 100644 index 00000000..2c780e11 --- /dev/null +++ b/src/utils/telegram_i18n.py @@ -0,0 +1,41 @@ +import os +from typing import Optional + + +def normalize_push_language(raw: Optional[str]) -> str: + value = str(raw or "").strip().lower().replace("_", "-") + if value in {"zh", "zh-cn", "cn", "chinese"}: + return "zh" + if value in {"en", "en-us", "english"}: + return "en" + if value in {"both", "bilingual", "dual", "en-zh", "zh-en"}: + return "both" + return "both" + + +def telegram_push_language(*env_names: str, default: str = "both") -> str: + names = env_names or ( + "TELEGRAM_PUSH_LANGUAGE", + "POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", + ) + for name in names: + raw = os.getenv(name) + if raw: + return normalize_push_language(raw) + return normalize_push_language(default) + + +def is_zh(language: Optional[str]) -> bool: + return normalize_push_language(language) == "zh" + + +def is_bilingual(language: Optional[str]) -> bool: + return normalize_push_language(language) == "both" + + +def copy_text(language: Optional[str], en: str, zh: str) -> str: + if is_zh(language): + return zh + if is_bilingual(language): + return f"{en} / {zh}" + return en diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 1caa76fa..df37acec 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -19,6 +19,13 @@ from src.database.runtime_state import ( ) from src.data_collection.city_registry import CITY_REGISTRY from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env +from src.utils.telegram_i18n import ( + copy_text as _copy, + is_bilingual as _is_bilingual, + is_zh as _is_zh, + normalize_push_language as _normalize_push_language, + telegram_push_language as _resolve_telegram_push_language, +) # 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. @@ -144,6 +151,14 @@ def _env_float(name: str, default: float) -> float: return default +def _telegram_push_language() -> str: + return _resolve_telegram_push_language( + "TELEGRAM_AIRPORT_PUSH_LANGUAGE", + "TELEGRAM_PUSH_LANGUAGE", + "POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", + ) + + def _norm_prob(v: Any) -> Optional[float]: if v is None: return None @@ -631,11 +646,16 @@ WIND_REGIME: Dict[str, Dict[str, Tuple[int, int]]] = { # Legacy alias for backward compat with existing _select_focus_runway_obs / _focus_runway_pairs_for_city FOCUS_RUNWAY_PAIRS = SETTLEMENT_RUNWAY_PAIRS -_FUNCTION_HASHTAGS = { +_FUNCTION_HASHTAGS_ZH = { "runway": "#跑道观测", "airport": "#机场观测", "trade": "#交易机会", } +_FUNCTION_HASHTAGS_EN = { + "runway": "#RunwayObs", + "airport": "#AirportObs", + "trade": "#TradeAlert", +} def _city_hashtag(city: Optional[str]) -> Optional[str]: @@ -659,15 +679,27 @@ def _build_telegram_hashtag_line( city: Optional[str] = None, station: Optional[str] = None, extra: Optional[List[str]] = None, + language: Optional[str] = None, ) -> str: tags: List[str] = [] - primary = _FUNCTION_HASHTAGS.get(kind) - if primary: - tags.append(primary) + tag_maps = ( + (_FUNCTION_HASHTAGS_EN, _FUNCTION_HASHTAGS_ZH) + if _is_bilingual(language) + else ((_FUNCTION_HASHTAGS_ZH,) if _is_zh(language) else (_FUNCTION_HASHTAGS_EN,)) + ) + for tag_map in tag_maps: + primary = tag_map.get(kind) + if primary and primary not in tags: + tags.append(primary) for item in extra or []: - tag = _FUNCTION_HASHTAGS.get(item, item) - if tag and tag not in tags: - tags.append(tag) + added = False + for tag_map in tag_maps: + tag = tag_map.get(item) + if tag and tag not in tags: + tags.append(tag) + added = True + if not added and item and item not in tags: + tags.append(item) city_tag = _city_hashtag(city) if city_tag and city_tag not in tags: tags.append(city_tag) @@ -752,7 +784,7 @@ def _is_settlement_runway(city: str, r1: str, r2: str) -> bool: return _runway_pair_key(r1, r2) in pair_set -def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]: +def _wind_regime_label(city: str, wind_dir: Optional[int], language: Optional[str] = None) -> Optional[str]: """Classify wind direction into a thermal regime label.""" if wind_dir is None: return None @@ -760,9 +792,9 @@ def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]: sea = regimes.get("sea_breeze") warm = regimes.get("warm_advection") if sea and sea[0] != sea[1] and sea[0] <= wind_dir <= sea[1]: - return "海风降温" + return _copy(language, "sea-breeze cooling", "海风降温") if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]: - return "暖平流增强" + return _copy(language, "warm advection", "暖平流增强") return None @@ -789,18 +821,21 @@ def _runway_heat_signal( slope_15m: Optional[float], wind_dir: Optional[int], city: str, + language: Optional[str] = None, ) -> str: """Compute a simple runway heat signal label.""" if slope_15m is None: return "" - regime = _wind_regime_label(city, wind_dir) + regime = _wind_regime_label(city, wind_dir, language) + warm_regime = _copy(language, "warm advection", "暖平流增强") + sea_regime = _copy(language, "sea-breeze cooling", "海风降温") if slope_15m >= 1.0: - return "🚀 冲顶增强" if regime == "暖平流增强" else "🔥 升温中" + return _copy(language, "🚀 Peak push strengthening", "🚀 冲顶增强") if regime == warm_regime else _copy(language, "🔥 Warming", "🔥 升温中") if slope_15m >= 0.5: - return "🔥 升温中" + return _copy(language, "🔥 Warming", "🔥 升温中") if slope_15m >= -0.2: - return "⚠️ 高位观察" if regime == "海风降温" else "⏸️ 高位横盘" - return "🧊 过峰风险" + return _copy(language, "⚠️ Watch sea-breeze cooling", "⚠️ 高位观察") if regime == sea_regime else _copy(language, "⏸️ Holding near high", "⏸️ 高位横盘") + return _copy(language, "🧊 Peak-risk cooling", "🧊 过峰风险") def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[float]: @@ -953,7 +988,9 @@ def _build_airport_status_message( source_label: str = "", arome_temp: Optional[float] = None, aeroweb_available: bool = False, + language: Optional[str] = None, ) -> str: + language = _normalize_push_language(language or _telegram_push_language()) _AIRPORT_EN = {"seoul": "Incheon", "singapore": "Changi", "busan": "Gimhae", "tokyo": "Haneda", "ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol", "istanbul": "Airport", "paris": "Le Bourget", @@ -1018,8 +1055,8 @@ def _build_airport_status_message( # ── Heat model ── wind_dir = amos.get("wind_dir") if is_amsc else None slope_15m = _compute_slope_15m(amos_icao, display_temp) if is_amsc and display_temp is not None else None - heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city) if is_amsc else "" - wind_label = _wind_regime_label(city, wind_dir) if is_amsc and wind_dir is not None else None + heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city, language) if is_amsc else "" + wind_label = _wind_regime_label(city, wind_dir, language) if is_amsc and wind_dir is not None else None max_so_far, max_temp_time = _get_airport_daily_high(city_weather) # ── Build message ── @@ -1029,6 +1066,7 @@ def _build_airport_status_message( hashtag_line = _build_telegram_hashtag_line( "runway" if has_runway else "airport", city=city, + language=language, ) icao_display = f"{amos_icao} · " if amos_icao else "" settlement_str = f" · ★{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else "" @@ -1055,7 +1093,7 @@ def _build_airport_status_message( mid = pts.get("mid_temp") end = pts.get("end_temp") is_settlement = _is_settlement_runway(city, r1, r2) - marker = " ★结算" if is_settlement else "" + marker = _copy(language, " ★Settlement", " ★结算") if is_settlement else "" tmax = pts.get("target_runway_max") if tdz is not None or mid is not None or end is not None: line = f"{r1}/{r2}{marker} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}" @@ -1063,7 +1101,8 @@ def _build_airport_status_message( line += f" max:{tmax:.1f}" lines.append(line) else: - lines.append(f"{r1}/{r2}{marker} {t:.1f}°C") + temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip() + lines.append(f"{r1}/{r2}{marker} {t:.1f}{temp_symbol}") # Summary stats lines.append("") @@ -1072,36 +1111,43 @@ def _build_airport_status_message( if city == "paris": if aeroweb_available and display_temp is not None: - lines.append(f"当前实况:{cur_str}") + lines.append(f"{_copy(language, 'Current observation', '当前实况')}: {cur_str}") else: - lines.append("当前实况:暂无") + lines.append(_copy(language, "Current observation: unavailable", "当前实况:暂无")) else: if has_runway: - lines.append(f"结算跑道当前:{cur_str}") + current_label = ( + _copy(language, "Settlement runway now", "结算跑道当前") + if settlement_pair + else _copy(language, "Runway now", "跑道当前") + ) + lines.append(f"{current_label}: {cur_str}") else: - lines.append(f"当前:{cur_str}") + lines.append(f"{_copy(language, 'Current', '当前')}: {cur_str}") if city == "paris": if aeroweb_available and max_so_far is not None: - time_str = f"({max_temp_time})" if max_temp_time else "" - lines.append(f"日高实况:{max_so_far:.1f}{temp_symbol}{time_str}") + time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"({max_temp_time})" if max_temp_time else "") + lines.append(f"{_copy(language, 'Observed high', '日高实况')}: {max_so_far:.1f}{temp_symbol}{time_str}") elif not aeroweb_available: last_temp = _LAST_AEROWEB.get("temp") if last_temp is not None: last_time = _LAST_AEROWEB.get("max_time", "") - time_str = f"({last_time})" if last_time else "" - lines.append(f"最近实况:{last_temp:.1f}{temp_symbol}{time_str}") + time_str = f" ({last_time})" if last_time and not _is_zh(language) else (f"({last_time})" if last_time else "") + lines.append(f"{_copy(language, 'Latest observation', '最近实况')}: {last_temp:.1f}{temp_symbol}{time_str}") elif max_so_far is not None: - time_str = f"({max_temp_time})" if max_temp_time else "" + time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"({max_temp_time})" if max_temp_time else "") if has_runway: - lines.append(f"今日跑道高点:{max_so_far:.1f}{temp_symbol}{time_str}") + high_label = _copy(language, "Today's runway high", "今日跑道高点") + lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}") else: - lines.append(f"日高:{max_so_far:.1f}{temp_symbol}{time_str}") + high_label = _copy(language, "Today's high", "日高") + lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}") if slope_15m is not None: sign = "+" if slope_15m >= 0 else "" - lines.append(f"15分钟趋势:{sign}{slope_15m:.1f}°C") + lines.append(f"{_copy(language, '15m trend', '15分钟趋势')}: {sign}{slope_15m:.1f}°") if wind_dir is not None: - wind_str = f"风向:{wind_dir}°" + wind_str = f"{_copy(language, 'Wind dir', '风向')}: {wind_dir}°" if wind_label: wind_str += f" {wind_label}" lines.append(wind_str) @@ -1127,7 +1173,7 @@ def _build_airport_status_message( bj_h = hh + 8 if bj_h >= 24: bj_h -= 24 - metar_time = f"北京时 {bj_h:02d}:{mm}" + metar_time = f"{_copy(language, 'Beijing time', '北京时')} {bj_h:02d}:{mm}" break if metar_temp or metar_time: bits = [] @@ -1135,46 +1181,54 @@ def _build_airport_status_message( bits.append(f"{metar_temp}{temp_symbol}") if metar_time: bits.append(metar_time) - lines.append(f"报文: {' '.join(bits)}") + lines.append(f"{_copy(language, 'METAR', '报文')}: {' '.join(bits)}") if deb_pred is not None: if city == "paris": if aeroweb_available and display_temp is not None: diff = display_temp - deb_pred sign = "+" if diff >= 0 else "" - lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(距实况 {sign}{diff:.1f}°)") + suffix = _copy(language, f" (vs obs {sign}{diff:.1f}°)", f"(距实况 {sign}{diff:.1f}°)") + lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}") elif not aeroweb_available: last_temp = _LAST_AEROWEB.get("temp") if last_temp is not None: diff = last_temp - deb_pred sign = "+" if diff >= 0 else "" - lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(距最近实况 {sign}{diff:.1f}°)") + suffix = _copy(language, f" (vs latest obs {sign}{diff:.1f}°)", f"(距最近实况 {sign}{diff:.1f}°)") + lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}") else: - lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}") + lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}") else: - lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}") + lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}") elif display_temp is not None and display_temp > deb_pred: - lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(已突破 +{display_temp - deb_pred:.1f}°)") + suffix = _copy(language, f" (already above +{display_temp - deb_pred:.1f}°)", f"(已突破 +{display_temp - deb_pred:.1f}°)") + lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}") else: - lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}") + lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}") if city == "paris": lines.append("") if aeroweb_available and display_temp is not None: - lines.append(f"📡 AEROWEB 机场实况:{display_temp:.1f}{temp_symbol} · Météo-France") + label = _copy(language, "AEROWEB airport obs", "AEROWEB 机场实况") + lines.append(f"📡 {label}: {display_temp:.1f}{temp_symbol} · Météo-France") else: - lines.append("📡 AEROWEB 机场实况:暂不可用") + lines.append(_copy(language, "📡 AEROWEB airport obs: unavailable", "📡 AEROWEB 机场实况:暂不可用")) if arome_temp is not None: if aeroweb_available and display_temp is not None: d = arome_temp - display_temp sign = "+" if d >= 0 else "" - lines.append(f"🕐 AROME HD 15分钟临近预报:{arome_temp:.1f}{temp_symbol}(较实况 {sign}{d:.1f}°)") + label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报") + suffix = _copy(language, f" (vs obs {sign}{d:.1f}°)", f"(较实况 {sign}{d:.1f}°)") + lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}{suffix}") else: - lines.append(f"🕐 AROME HD 15分钟临近预报:{arome_temp:.1f}{temp_symbol}") + label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报") + lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}") else: - lines.append("🕐 AROME HD 15分钟临近预报:--") + label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报") + lines.append(f"🕐 {label}: --") if not aeroweb_available: lines.append("") - lines.append("提示:当前仅显示模型参考,不更新实况日高。") + lines.append(_copy(language, "Note: showing model reference only; observed high is not updating.", "提示:当前仅显示模型参考,不更新实况日高。")) elif source_label: lines.append("") lines.append(f"📡 {source_label}") @@ -1186,8 +1240,14 @@ def _build_airport_status_message( if len(vals) >= 2: lo, hi = vals[0][0], vals[-1][0] spread = hi - lo - spread_label = "低分歧" if spread <= 2.0 else ("中等分歧" if spread <= 4.0 else "高分歧") - lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}") + if spread <= 2.0: + spread_label = _copy(language, "low dispersion", "低分歧") + elif spread <= 4.0: + spread_label = _copy(language, "moderate dispersion", "中等分歧") + else: + spread_label = _copy(language, "high dispersion", "高分歧") + range_label = _copy(language, "Model range", "模型区间") + lines.append(f"{range_label}: {lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}") return "\n".join(lines) diff --git a/tests/test_telegram_hashtags.py b/tests/test_telegram_hashtags.py index 0933b824..b0dc63d4 100644 --- a/tests/test_telegram_hashtags.py +++ b/tests/test_telegram_hashtags.py @@ -3,11 +3,16 @@ from src.utils.telegram_push import ( HIGH_FREQ_AIRPORT_ICAO, _build_airport_status_message, _run_high_freq_airport_cycle, + _telegram_push_language, ) from pathlib import Path -def test_airport_status_message_starts_with_runway_city_and_station_hashtags(): +def test_airport_status_message_defaults_to_bilingual_runway_copy(monkeypatch): + monkeypatch.delenv("TELEGRAM_AIRPORT_PUSH_LANGUAGE", raising=False) + monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False) + monkeypatch.delenv("POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", raising=False) + text = _build_airport_status_message( "qingdao", { @@ -32,10 +37,13 @@ def test_airport_status_message_starts_with_runway_city_and_station_hashtags(): ) first_line = text.splitlines()[0] - assert first_line == "#跑道观测 #Qingdao" + assert _telegram_push_language() == "both" + assert first_line == "#RunwayObs #跑道观测 #Qingdao" assert "Qingdao / Jiaodong" in text assert "TDZ:23.0" in text - assert "DEB" in text and "24.0" in text + assert "Runway now / 跑道当前:" in text + assert "Today's runway high / 今日跑道高点:" in text + assert "DEB: 24.0°C" in text def test_airport_status_hides_non_focus_runways_for_key_airports(): @@ -62,7 +70,7 @@ def test_airport_status_hides_non_focus_runways_for_key_airports(): assert "02L/20R" in text assert "02R/20L" in text - assert "结算跑道当前:31.2°C" in text + assert "Settlement runway now / 结算跑道当前: 31.2°C" in text def test_singapore_is_in_telegram_push_city_lists(): diff --git a/tests/test_telegram_i18n.py b/tests/test_telegram_i18n.py new file mode 100644 index 00000000..4b4f9731 --- /dev/null +++ b/tests/test_telegram_i18n.py @@ -0,0 +1,59 @@ +from src.bot.weekly_reward_loop import _render_settle_report +from src.utils.daily_weather_report import _build_ai_prompt +from src.utils.telegram_i18n import copy_text, telegram_push_language + + +def test_telegram_push_language_defaults_to_bilingual(monkeypatch): + monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False) + monkeypatch.delenv("POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", raising=False) + + assert telegram_push_language() == "both" + assert copy_text("both", "Current", "当前") == "Current / 当前" + + +def test_daily_weather_report_prompt_defaults_to_bilingual(monkeypatch): + monkeypatch.delenv("DAILY_WEATHER_REPORT_LANGUAGE", raising=False) + monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False) + + prompt = _build_ai_prompt( + [ + { + "city": "beijing", + "name": "北京", + "name_en": "Beijing", + "weather": "晴", + "forecast_high": 28.0, + } + ], + "05月27日", + ) + + assert "bilingual Telegram weather briefing" in prompt + assert "Beijing / 北京" in prompt + assert "English first then Chinese" in prompt + + +def test_weekly_reward_report_defaults_to_bilingual(monkeypatch): + monkeypatch.delenv("POLYWEATHER_WEEKLY_REWARD_LANGUAGE", raising=False) + monkeypatch.delenv("TELEGRAM_PUSH_LANGUAGE", raising=False) + + text = _render_settle_report( + week_key="2026-W21", + winners=[ + { + "rank": 1, + "username": "ada", + "points_bonus": 200, + "pro_days": 7, + } + ], + skipped=1, + participation_count=3, + active_count=1, + ) + + assert "weekly rewards settled" in text + assert "周榜奖励已结算" in text + assert "points / 积分" in text + assert "Participation bonus" in text + assert "参与奖" in text