Add Telegram topic binding command

This commit is contained in:
2569718930@qq.com
2026-06-14 04:48:58 +08:00
parent e6c99c946a
commit e74dfb4953
6 changed files with 285 additions and 11 deletions
+5 -4
View File
@@ -41,22 +41,23 @@ BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
CHAT_ID = -1003927451869 CHAT_ID = -1003927451869
CITIES: list[tuple[str, str]] = [ CITIES: list[tuple[str, str]] = [
("🇰🇷 Seoul", "seoul"),
("🇰🇷 Busan", "busan"),
("🇯🇵 Tokyo", "tokyo"),
("🇨🇳 Beijing", "beijing"), ("🇨🇳 Beijing", "beijing"),
("🇨🇳 Shanghai", "shanghai"), ("🇨🇳 Shanghai", "shanghai"),
("🇨🇳 Guangzhou", "guangzhou"), ("🇨🇳 Guangzhou", "guangzhou"),
("🇨🇳 Shenzhen / Lau Fau Shan", "shenzhen"),
("🇨🇳 Qingdao", "qingdao"), ("🇨🇳 Qingdao", "qingdao"),
("🇨🇳 Chengdu", "chengdu"), ("🇨🇳 Chengdu", "chengdu"),
("🇨🇳 Chongqing", "chongqing"), ("🇨🇳 Chongqing", "chongqing"),
("🇨🇳 Wuhan", "wuhan"), ("🇨🇳 Wuhan", "wuhan"),
("🇭🇰 Hong Kong", "hong kong"), ("🇭🇰 Hong Kong", "hong kong"),
("🇭🇰 Lau Fau Shan", "lau fau shan"),
("🇹🇼 Taipei", "taipei"), ("🇹🇼 Taipei", "taipei"),
("🇰🇷 Seoul", "seoul"),
("🇰🇷 Busan", "busan"),
("🇯🇵 Tokyo", "tokyo"),
("🇸🇬 Singapore", "singapore"), ("🇸🇬 Singapore", "singapore"),
("🇹🇷 Istanbul", "istanbul"), ("🇹🇷 Istanbul", "istanbul"),
("🇹🇷 Ankara", "ankara"), ("🇹🇷 Ankara", "ankara"),
("🇮🇱 Tel Aviv", "tel aviv"),
("🇫🇮 Helsinki", "helsinki"), ("🇫🇮 Helsinki", "helsinki"),
("🇳🇱 Amsterdam", "amsterdam"), ("🇳🇱 Amsterdam", "amsterdam"),
("🇫🇷 Paris", "paris"), ("🇫🇷 Paris", "paris"),
+106 -2
View File
@@ -9,14 +9,34 @@ from loguru import logger # type: ignore
from src.bot.command_parser import extract_command_name from src.bot.command_parser import extract_command_name
from src.bot.command_parser import looks_like_slash_command from src.bot.command_parser import looks_like_slash_command
from src.bot.command_parser import split_command_and_args
from src.bot.io_layer import BotIOLayer from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace from src.bot.observability import CommandTrace
from src.bot.runtime_coordinator import RuntimeStatus, render_runtime_status_html from src.bot.runtime_coordinator import RuntimeStatus, render_runtime_status_html
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT
from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBER_STATUSES from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBER_STATUSES
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env, parse_telegram_chat_ids
_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind"} _BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind", "bindtopic"}
def normalize_airport_push_city(raw: str) -> str:
from src.utils.telegram_push import normalize_airport_push_city as _normalize
return _normalize(raw)
def record_city_thread_id(city: str, thread_id: int) -> dict:
from src.utils.telegram_push import record_city_thread_id as _record
return _record(city, thread_id)
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
class BasicCommandHandler: class BasicCommandHandler:
@@ -59,6 +79,10 @@ class BasicCommandHandler:
def _unbind(message): def _unbind(message):
self._dispatch(message) self._dispatch(message)
@self.bot.message_handler(commands=["bindtopic"])
def _bindtopic(message):
self._dispatch(message)
if hasattr(self.bot, "chat_join_request_handler"): if hasattr(self.bot, "chat_join_request_handler"):
@self.bot.chat_join_request_handler(func=lambda request: True) @self.bot.chat_join_request_handler(func=lambda request: True)
def _chat_join_request(request): def _chat_join_request(request):
@@ -118,6 +142,9 @@ class BasicCommandHandler:
if command == "unbind": if command == "unbind":
self.handle_unbind(message) self.handle_unbind(message)
return return
if command == "bindtopic":
self.handle_bindtopic(message)
return
def handle_start_help(self, message: Any) -> None: def handle_start_help(self, message: Any) -> None:
trace = CommandTrace("/start", message) trace = CommandTrace("/start", message)
@@ -158,6 +185,83 @@ class BasicCommandHandler:
) )
return "replied" return "replied"
def _configured_forum_chat_ids(self) -> set[str]:
return set(
parse_telegram_chat_ids(
os.getenv("TELEGRAM_FORUM_CHAT_ID"),
os.getenv("POLYWEATHER_TELEGRAM_TOPICS_GROUP_ID"),
os.getenv("POLYWEATHER_TELEGRAM_GROUP_ID"),
os.getenv("TELEGRAM_CHAT_IDS"),
os.getenv("TELEGRAM_CHAT_ID"),
)
)
def _can_bind_topic(self, message: Any) -> bool:
if not _env_bool("POLYWEATHER_TOPIC_BIND_ADMIN_ONLY", True):
return True
chat = getattr(message, "chat", None)
user = getattr(message, "from_user", None)
chat_id = getattr(chat, "id", None)
user_id = getattr(user, "id", None)
if chat_id is None or user_id is None or not hasattr(self.bot, "get_chat_member"):
return False
try:
member = self.bot.get_chat_member(chat_id, user_id)
except Exception as exc:
logger.warning("bindtopic admin check failed chat_id={} user_id={}: {}", chat_id, user_id, exc)
return False
status = str(getattr(member, "status", "") or "").strip().lower()
return status in {"creator", "administrator"}
def handle_bindtopic(self, message: Any) -> str:
trace = CommandTrace("/bindtopic", message)
try:
chat = getattr(message, "chat", None)
chat_id = getattr(chat, "id", None)
chat_type = str(getattr(chat, "type", "") or "").strip().lower()
if chat_type not in {"group", "supergroup"} or chat_id is None:
self.bot.reply_to(message, "❌ 请在目标 Telegram 群子话题里执行 /bindtopic <city>。")
trace.set_status("blocked", "not_group_topic")
return "not_group_topic"
configured_chat_ids = self._configured_forum_chat_ids()
if configured_chat_ids and str(chat_id) not in configured_chat_ids:
self.bot.reply_to(message, "❌ 当前群不在推送目标配置里,已拒绝绑定。")
trace.set_status("blocked", "chat_not_configured")
return "chat_not_configured"
thread_id = int(getattr(message, "message_thread_id", 0) or 0)
if thread_id <= 0:
self.bot.reply_to(message, "❌ 请进入具体子话题后再执行 /bindtopic <city>。")
trace.set_status("blocked", "missing_thread_id")
return "missing_thread_id"
if not self._can_bind_topic(message):
self.bot.reply_to(message, "❌ 只有群管理员可以绑定推送子话题。")
trace.set_status("blocked", "unauthorized")
return "unauthorized"
_command, raw_city = split_command_and_args(getattr(message, "text", "") or "")
city = normalize_airport_push_city(raw_city)
if not city:
self.bot.reply_to(
message,
"❌ 未识别城市。用法:<code>/bindtopic seoul</code> 或 <code>/bindtopic LauFauShan</code>",
parse_mode="HTML",
)
trace.set_status("blocked", "unsupported_city")
return "unsupported_city"
result = record_city_thread_id(city, thread_id)
self.bot.reply_to(
message,
(
"✅ 已绑定推送子话题\n"
f"city=<code>{html.escape(city)}</code>\n"
f"thread_id=<code>{int(result.get('thread_id') or thread_id)}</code>"
),
parse_mode="HTML",
)
trace.set_status("ok", city)
return "bound"
finally:
trace.emit()
def _prompt_bind_from_web_token(self, message: Any, token: str) -> str: def _prompt_bind_from_web_token(self, message: Any, token: str) -> str:
if not token: if not token:
self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。") self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。")
+2 -1
View File
@@ -197,7 +197,8 @@ class BotIOLayer:
"/id - 获取当前聊天的 Chat ID\n\n" "/id - 获取当前聊天的 Chat ID\n\n"
"/diag - 查看 Bot 启动诊断\n\n" "/diag - 查看 Bot 启动诊断\n\n"
"/bind - 绑定 Supabase 账号(可选)\n" "/bind - 绑定 Supabase 账号(可选)\n"
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n" "/unbind - 解除当前 Telegram 与网页账号绑定\n"
"/bindtopic <city> - 管理员在子话题内绑定城市推送\n\n"
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n" "🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>" "👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>"
) )
+74 -1
View File
@@ -17,6 +17,7 @@ from src.database.runtime_state import (
TelegramAlertStateRepository, TelegramAlertStateRepository,
get_state_storage_mode, get_state_storage_mode,
) )
from src.data_collection.city_registry import ALIASES
from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_registry import CITY_REGISTRY
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env, parse_telegram_chat_ids from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env, parse_telegram_chat_ids
from src.utils.telegram_i18n import ( from src.utils.telegram_i18n import (
@@ -35,6 +36,7 @@ _CITY_THREAD_IDS_PATH = os.path.join(
) )
_DEFAULT_FORUM_CHAT_ID = "-1003927451869" _DEFAULT_FORUM_CHAT_ID = "-1003927451869"
_city_thread_ids: dict = {} _city_thread_ids: dict = {}
_CITY_THREAD_IDS_LOCK = threading.Lock()
# Shared HTTP session for AROME and auxiliary queries (connection reuse) # Shared HTTP session for AROME and auxiliary queries (connection reuse)
_HTTP_SESSION: Optional[requests_lib.Session] = None _HTTP_SESSION: Optional[requests_lib.Session] = None
@@ -106,6 +108,75 @@ def _load_city_thread_ids() -> dict:
return {} return {}
def normalize_airport_push_city(raw: str) -> str:
city = str(raw or "").strip().lower().replace("-", " ")
city = re.sub(r"\s+", " ", city)
compact = city.replace(" ", "")
city = ALIASES.get(city, ALIASES.get(compact, city))
if city not in HIGH_FREQ_AIRPORT_CITIES:
city = {
candidate.replace(" ", ""): candidate
for candidate in HIGH_FREQ_AIRPORT_CITIES
}.get(compact, city)
if city in HIGH_FREQ_AIRPORT_CITIES:
return city
return ""
def _city_thread_ids_write_path() -> str:
env_path = str(os.getenv("POLYWEATHER_CITY_THREAD_IDS_PATH") or "").strip()
if env_path:
return env_path
for path in (
"/var/lib/polyweather/city_thread_ids.json",
"/app/data/city_thread_ids.json",
_CITY_THREAD_IDS_PATH,
):
if os.path.isfile(path):
return path
return "/var/lib/polyweather/city_thread_ids.json"
def record_city_thread_id(city: str, thread_id: int) -> dict:
"""Persist a forum topic mapping and update the in-process cache."""
global _city_thread_ids
normalized_city = normalize_airport_push_city(city)
if not normalized_city:
raise ValueError(f"unsupported airport push city: {city}")
try:
normalized_thread_id = int(thread_id)
except Exception as exc:
raise ValueError(f"invalid message_thread_id: {thread_id}") from exc
if normalized_thread_id <= 0:
raise ValueError(f"invalid message_thread_id: {thread_id}")
with _CITY_THREAD_IDS_LOCK:
path = _city_thread_ids_write_path()
mapping = dict(_load_city_thread_ids())
mapping[normalized_city] = normalized_thread_id
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(mapping, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp_path, path)
_city_thread_ids = mapping
logger.info(
"recorded forum topic mapping city={} thread_id={} path={} total={}",
normalized_city,
normalized_thread_id,
path,
len(mapping),
)
return {
"city": normalized_city,
"thread_id": normalized_thread_id,
"path": path,
"total": len(mapping),
}
def _forum_chat_ids() -> Set[str]: def _forum_chat_ids() -> Set[str]:
return set( return set(
parse_telegram_chat_ids( parse_telegram_chat_ids(
@@ -626,6 +697,7 @@ HIGH_FREQ_AIRPORT_CITIES = {
"seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam",
"istanbul", "paris", "hong kong", "taipei", "istanbul", "paris", "hong kong", "taipei",
"beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan", "beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan",
"shenzhen",
"new york", "los angeles", "chicago", "denver", "atlanta", "new york", "los angeles", "chicago", "denver", "atlanta",
"miami", "san francisco", "houston", "dallas", "austin", "seattle", "miami", "san francisco", "houston", "dallas", "austin", "seattle",
"tel aviv", "tel aviv",
@@ -638,7 +710,7 @@ HIGH_FREQ_AIRPORT_ICAO = {
"ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058",
"paris": "LFPB", "hong kong": "HKO", "taipei": "466920", "paris": "LFPB", "hong kong": "HKO", "taipei": "466920",
"beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "qingdao": "ZSQD", "beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "qingdao": "ZSQD",
"chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH", "chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH", "shenzhen": "LFS",
"new york": "KLGA", "los angeles": "KLAX", "chicago": "KORD", "new york": "KLGA", "los angeles": "KLAX", "chicago": "KORD",
"denver": "KBKF", "atlanta": "KATL", "miami": "KMIA", "denver": "KBKF", "atlanta": "KATL", "miami": "KMIA",
"san francisco": "KSFO", "houston": "KHOU", "dallas": "KDAL", "san francisco": "KSFO", "houston": "KHOU", "dallas": "KDAL",
@@ -1145,6 +1217,7 @@ def _build_airport_status_message(
"taipei": "Songshan", "beijing": "Capital", "shanghai": "Pudong", "taipei": "Songshan", "beijing": "Capital", "shanghai": "Pudong",
"guangzhou": "Baiyun", "qingdao": "Jiaodong", "guangzhou": "Baiyun", "qingdao": "Jiaodong",
"chengdu": "Shuangliu", "chongqing": "Jiangbei", "wuhan": "Tianhe", "chengdu": "Shuangliu", "chongqing": "Jiangbei", "wuhan": "Tianhe",
"shenzhen": "Lau Fau Shan",
"new york": "LaGuardia", "los angeles": "LAX", "chicago": "O'Hare", "new york": "LaGuardia", "los angeles": "LAX", "chicago": "O'Hare",
"denver": "Buckley", "atlanta": "Hartsfield", "miami": "Intl", "denver": "Buckley", "atlanta": "Hartsfield", "miami": "Intl",
"san francisco": "SFO", "houston": "Hobby", "dallas": "Love Field", "san francisco": "SFO", "houston": "Hobby", "dallas": "Love Field",
+85
View File
@@ -11,6 +11,7 @@ class DummyBot:
self.approved_join_requests = [] self.approved_join_requests = []
self.declined_join_requests = [] self.declined_join_requests = []
self.callback_handlers = [] self.callback_handlers = []
self.chat_member_status = "administrator"
def reply_to(self, message, text, parse_mode=None, disable_web_page_preview=None, **kwargs): def reply_to(self, message, text, parse_mode=None, disable_web_page_preview=None, **kwargs):
self.replies.append( self.replies.append(
@@ -65,6 +66,9 @@ class DummyBot:
def decline_chat_join_request(self, chat_id, user_id): def decline_chat_join_request(self, chat_id, user_id):
self.declined_join_requests.append({"chat_id": chat_id, "user_id": user_id}) self.declined_join_requests.append({"chat_id": chat_id, "user_id": user_id})
def get_chat_member(self, chat_id, user_id):
return SimpleNamespace(status=self.chat_member_status)
def _message(text: str): def _message(text: str):
return SimpleNamespace( return SimpleNamespace(
@@ -74,6 +78,15 @@ def _message(text: str):
) )
def _topic_message(text: str, *, thread_id: int = 13102):
return SimpleNamespace(
text=text,
from_user=SimpleNamespace(id=1, username="admin", first_name="Admin"),
chat=SimpleNamespace(id=-1003927451869, type="supergroup"),
message_thread_id=thread_id,
)
def test_basic_handler_diag_returns_html(): def test_basic_handler_diag_returns_html():
runtime = RuntimeStatus( runtime = RuntimeStatus(
started_at="2026-03-12 00:00:00 UTC", started_at="2026-03-12 00:00:00 UTC",
@@ -100,6 +113,78 @@ def test_basic_handler_diag_returns_html():
assert "Bot 启动诊断" in bot.replies[0]["text"] assert "Bot 启动诊断" in bot.replies[0]["text"]
def test_bindtopic_records_current_forum_thread(monkeypatch):
import src.bot.handlers.basic as basic
saved = []
monkeypatch.setenv("TELEGRAM_CHAT_IDS", "-1003927451869")
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-1003927451869")
monkeypatch.setattr(
basic,
"record_city_thread_id",
lambda city, thread_id: saved.append((city, thread_id))
or {"city": city, "thread_id": thread_id},
)
bot = DummyBot()
handler = BasicCommandHandler(
bot=bot,
io_layer=SimpleNamespace(
build_welcome_text=lambda: "WELCOME",
build_points_rank_text=lambda _user: "TOP",
),
runtime_status_provider=lambda: RuntimeStatus(
started_at="2026-03-12 00:00:00 UTC",
loops=[],
command_access_mode="public",
protected_commands=[],
required_group_chat_id="-1003927451869",
),
)
result = handler.handle_bindtopic(_topic_message("/bindtopic seoul"))
assert result == "bound"
assert saved == [("seoul", 13102)]
assert "seoul" in bot.replies[0]["text"].lower()
assert "13102" in bot.replies[0]["text"]
def test_bindtopic_rejects_non_admin(monkeypatch):
import src.bot.handlers.basic as basic
saved = []
monkeypatch.setenv("TELEGRAM_CHAT_IDS", "-1003927451869")
monkeypatch.setattr(
basic,
"record_city_thread_id",
lambda city, thread_id: saved.append((city, thread_id)),
)
bot = DummyBot()
bot.chat_member_status = "member"
handler = BasicCommandHandler(
bot=bot,
io_layer=SimpleNamespace(
build_welcome_text=lambda: "WELCOME",
build_points_rank_text=lambda _user: "TOP",
),
runtime_status_provider=lambda: RuntimeStatus(
started_at="2026-03-12 00:00:00 UTC",
loops=[],
command_access_mode="public",
protected_commands=[],
required_group_chat_id="-1003927451869",
),
)
result = handler.handle_bindtopic(_topic_message("/bindtopic seoul"))
assert result == "unauthorized"
assert saved == []
assert "管理员" in bot.replies[0]["text"]
def test_start_bind_token_binds_telegram_to_web_account(): def test_start_bind_token_binds_telegram_to_web_account():
bot = DummyBot() bot = DummyBot()
consumed = [] consumed = []
+13 -3
View File
@@ -5,6 +5,7 @@ from src.utils.telegram_push import (
_build_airport_status_message, _build_airport_status_message,
_compute_slope_15m, _compute_slope_15m,
_due_airport_cities, _due_airport_cities,
normalize_airport_push_city,
_parse_observation_time_epoch, _parse_observation_time_epoch,
_run_high_freq_airport_cycle, _run_high_freq_airport_cycle,
_telegram_push_language, _telegram_push_language,
@@ -165,9 +166,14 @@ def test_singapore_is_in_telegram_push_city_lists():
assert HIGH_FREQ_AIRPORT_ICAO["singapore"] == "WSSS" assert HIGH_FREQ_AIRPORT_ICAO["singapore"] == "WSSS"
def test_shenzhen_is_not_in_airport_push_city_lists(): def test_shenzhen_lau_fau_shan_topic_is_in_airport_push_city_lists():
assert "shenzhen" not in HIGH_FREQ_AIRPORT_CITIES assert normalize_airport_push_city("LauFauShan") == "shenzhen"
assert "shenzhen" not in HIGH_FREQ_AIRPORT_ICAO assert normalize_airport_push_city("HongKong") == "hong kong"
assert normalize_airport_push_city("NewYork") == "new york"
assert normalize_airport_push_city("LosAngeles") == "los angeles"
assert normalize_airport_push_city("SanFrancisco") == "san francisco"
assert "shenzhen" in HIGH_FREQ_AIRPORT_CITIES
assert HIGH_FREQ_AIRPORT_ICAO["shenzhen"] == "LFS"
def test_china_airport_push_defaults_to_one_minute_city_interval(): def test_china_airport_push_defaults_to_one_minute_city_interval():
@@ -182,6 +188,10 @@ def test_china_airport_push_defaults_to_one_minute_city_interval():
assert _AIRPORT_PUSH_INTERVAL["wuhan"] == 60 assert _AIRPORT_PUSH_INTERVAL["wuhan"] == 60
def test_shenzhen_lau_fau_shan_push_uses_hko_ten_minute_interval():
assert _AIRPORT_PUSH_INTERVAL["shenzhen"] == 600
def test_airport_push_prioritizes_china_markets(): def test_airport_push_prioritizes_china_markets():
due = _due_airport_cities( due = _due_airport_cities(
{"paris", "shanghai", "wuhan", "ankara", "beijing"}, {"paris", "shanghai", "wuhan", "ankara", "beijing"},