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