diff --git a/src/bot/command_parser.py b/src/bot/command_parser.py new file mode 100644 index 00000000..4fcbdfa3 --- /dev/null +++ b/src/bot/command_parser.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import re +import unicodedata +from typing import Any +from typing import Iterable +from typing import Tuple + +_SLASH_CHARS = "//⁄∕╱⧸" +_COMMAND_RE = re.compile( + rf"^[\s\u00A0]*[{re.escape(_SLASH_CHARS)}]\s*([A-Za-z0-9_]+)(?:@([A-Za-z0-9_]+))?", + flags=re.ASCII, +) + + +def _clean_text(text: str | None) -> str: + raw = str(text or "") + if not raw: + return "" + cleaned: list[str] = [] + for ch in raw: + code = ord(ch) + if 0xFE00 <= code <= 0xFE0F: + continue + if 0xE0100 <= code <= 0xE01EF: + continue + if unicodedata.category(ch) == "Cf": + continue + cleaned.append(ch) + normalized = "".join(cleaned).strip() + if not normalized: + return "" + for slash in ("/", "⁄", "∕", "╱", "⧸"): + normalized = normalized.replace(slash, "/") + return normalized + + +def _parse_command_token(text: str | None) -> Tuple[str, str]: + normalized = _clean_text(text) + if not normalized: + return ("", "") + match = _COMMAND_RE.match(normalized) + if not match: + return ("", "") + command = str(match.group(1) or "").strip().lower() + username = str(match.group(2) or "").strip().lower() + return (command, username) + + +def extract_command_token( + text: str | None, + entities: Iterable[Any] | None = None, +) -> Tuple[str, str]: + raw = str(text or "") + if entities: + for entity in entities: + if str(getattr(entity, "type", "") or "").strip() != "bot_command": + continue + try: + offset = int(getattr(entity, "offset", 0) or 0) + length = int(getattr(entity, "length", 0) or 0) + except Exception: + continue + if length <= 0: + continue + fragment = raw[offset : offset + length] + command, username = _parse_command_token(fragment) + if command: + return (command, username) + return _parse_command_token(raw) + + +def extract_command_name(text: str | None, entities: Iterable[Any] | None = None) -> str: + return extract_command_token(text, entities)[0] + + +def looks_like_slash_command(text: str | None) -> bool: + normalized = _clean_text(text) + if not normalized: + return False + return normalized[:1] == "/" + + +def split_command_and_args(text: str | None) -> Tuple[str, str]: + normalized = _clean_text(text) + if not normalized: + return ("", "") + match = _COMMAND_RE.match(normalized) + if not match: + return ("", "") + command = str(match.group(1) or "").strip().lower() + rest = normalized[match.end() :].lstrip() + return (command, rest) + diff --git a/src/bot/handlers/activity.py b/src/bot/handlers/activity.py index c79e89f7..d0e19d51 100644 --- a/src/bot/handlers/activity.py +++ b/src/bot/handlers/activity.py @@ -2,6 +2,10 @@ from __future__ import annotations from typing import Any +from loguru import logger + +from src.bot.command_parser import extract_command_name +from src.bot.command_parser import looks_like_slash_command from src.bot.io_layer import BotIOLayer @@ -17,27 +21,19 @@ class ActivityHandler: def handle(self, message: Any) -> None: text = str(getattr(message, "text", "") or "") - normalized = text - for marker in ( - "\ufeff", - "\u200b", - "\u200c", - "\u200d", - "\u200e", - "\u200f", - "\u2060", - "\u2066", - "\u2067", - "\u2068", - "\u2069", - "\u202a", - "\u202b", - "\u202c", - "\u202d", - "\u202e", - ): - normalized = normalized.replace(marker, "") - normalized = normalized.lstrip() - if normalized[:1] in {"/", "/", "⁄", "∕", "╱", "⧸"}: + if looks_like_slash_command(text): + if not getattr(message, "_pw_command_handled", False): + command = extract_command_name( + getattr(message, "text", None), + getattr(message, "entities", None), + ) + logger.warning( + "command fell through handlers chat_id={} thread_id={} user_id={} command={} text={!r}", + getattr(getattr(message, "chat", None), "id", None), + getattr(message, "message_thread_id", None), + getattr(getattr(message, "from_user", None), "id", None), + command or "-", + text, + ) return self.io_layer.track_group_text_activity(message) diff --git a/src/bot/handlers/basic.py b/src/bot/handlers/basic.py index 1495aa2f..f1034f07 100644 --- a/src/bot/handlers/basic.py +++ b/src/bot/handlers/basic.py @@ -3,54 +3,12 @@ from __future__ import annotations from typing import Any from typing import Callable +from src.bot.command_parser import extract_command_name from src.bot.io_layer import BotIOLayer from src.bot.observability import CommandTrace from src.bot.runtime_coordinator import RuntimeStatus, render_runtime_status_html - -def _normalized_command_head(text: str | None) -> str: - raw = str(text or "") - for marker in ( - "\ufeff", - "\u200b", - "\u200c", - "\u200d", - "\u200e", - "\u200f", - "\u2060", - "\u2066", - "\u2067", - "\u2068", - "\u2069", - "\u202a", - "\u202b", - "\u202c", - "\u202d", - "\u202e", - ): - raw = raw.replace(marker, "") - raw = raw.strip() - if raw[:1] in {"/", "⁄", "∕", "╱", "⧸"}: - raw = "/" + raw[1:] - return raw.split(maxsplit=1)[0].lower() if raw else "" - - -def _is_command_head(head: str, name: str) -> bool: - base = f"/{name}" - return head == base or head.startswith(f"{base}@") - - -def _is_basic_command_text(text: str | None) -> bool: - head = _normalized_command_head(text) - return ( - _is_command_head(head, "start") - or _is_command_head(head, "help") - or _is_command_head(head, "id") - or _is_command_head(head, "top") - or _is_command_head(head, "diag") - or _is_command_head(head, "bind") - or _is_command_head(head, "unbind") - ) +_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind"} class BasicCommandHandler: @@ -65,30 +23,70 @@ class BasicCommandHandler: self.runtime_status_provider = runtime_status_provider def register(self) -> None: + @self.bot.message_handler(commands=["start", "help"]) + def _start_help(message): + self._dispatch(message) + + @self.bot.message_handler(commands=["id"]) + def _id(message): + self._dispatch(message) + + @self.bot.message_handler(commands=["top"]) + def _top(message): + self._dispatch(message) + + @self.bot.message_handler(commands=["diag"]) + def _diag(message): + self._dispatch(message) + + @self.bot.message_handler(commands=["bind"]) + def _bind(message): + self._dispatch(message) + + @self.bot.message_handler(commands=["unbind"]) + def _unbind(message): + self._dispatch(message) + @self.bot.message_handler( content_types=["text"], - func=lambda message: _is_basic_command_text(getattr(message, "text", None)), + func=lambda message: extract_command_name( + getattr(message, "text", None), + getattr(message, "entities", None), + ) + in _BASIC_COMMANDS, ) - def _basic(message): - head = _normalized_command_head(getattr(message, "text", None)) - if _is_command_head(head, "start") or _is_command_head(head, "help"): - self.handle_start_help(message) - return - if _is_command_head(head, "id"): - self.handle_id(message) - return - if _is_command_head(head, "top"): - self.handle_top(message) - return - if _is_command_head(head, "diag"): - self.handle_diag(message) - return - if _is_command_head(head, "bind"): - self.handle_bind(message) - return - if _is_command_head(head, "unbind"): - self.handle_unbind(message) - return + def _basic_text(message): + self._dispatch(message) + + def _dispatch(self, message: Any) -> None: + command = extract_command_name( + getattr(message, "text", None), + getattr(message, "entities", None), + ) + if command not in _BASIC_COMMANDS: + return + if getattr(message, "_pw_basic_handled", False): + return + setattr(message, "_pw_basic_handled", True) + setattr(message, "_pw_command_handled", True) + if command in {"start", "help"}: + self.handle_start_help(message) + return + if command == "id": + self.handle_id(message) + return + if command == "top": + self.handle_top(message) + return + if command == "diag": + self.handle_diag(message) + return + if command == "bind": + self.handle_bind(message) + return + if command == "unbind": + self.handle_unbind(message) + return def handle_start_help(self, message: Any) -> None: trace = CommandTrace("/start", message) diff --git a/src/bot/handlers/city.py b/src/bot/handlers/city.py index 9625f7ec..c547ad52 100644 --- a/src/bot/handlers/city.py +++ b/src/bot/handlers/city.py @@ -4,48 +4,20 @@ from typing import Any from loguru import logger +from src.bot.command_parser import extract_command_name +from src.bot.command_parser import split_command_and_args from src.bot.command_guard import CommandGuard from src.bot.io_layer import BotIOLayer from src.bot.observability import CommandTrace from src.bot.services.city_command_service import CityCommandService from src.bot.settings import CITY_QUERY_COST - -def _normalized_command_head(text: str | None) -> str: - raw = str(text or "") - for marker in ( - "\ufeff", - "\u200b", - "\u200c", - "\u200d", - "\u200e", - "\u200f", - "\u2060", - "\u2066", - "\u2067", - "\u2068", - "\u2069", - "\u202a", - "\u202b", - "\u202c", - "\u202d", - "\u202e", - ): - raw = raw.replace(marker, "") - raw = raw.strip() - if raw[:1] in {"/", "⁄", "∕", "╱", "⧸"}: - raw = "/" + raw[1:] - return raw.split(maxsplit=1)[0].lower() if raw else "" - - -def _is_city_command_text(text: str | None) -> bool: - head = _normalized_command_head(text) - return ( - head == "/city" - or head.startswith("/city@") - or head == "/pwcity" - or head.startswith("/pwcity@") +def _is_city_command(message: Any) -> bool: + command = extract_command_name( + getattr(message, "text", None), + getattr(message, "entities", None), ) + return command in {"city", "pwcity"} class CityCommandHandler: @@ -62,20 +34,28 @@ class CityCommandHandler: self.io_layer = io_layer def register(self) -> None: + @self.bot.message_handler(commands=["city", "pwcity"]) + def _city_command(message): + self.handle(message) + @self.bot.message_handler( - func=lambda message: _is_city_command_text(getattr(message, "text", None)), + func=lambda message: _is_city_command(message), content_types=["text"], ) - def _city_func(message): + def _city_text(message): self.handle(message) def handle(self, message: Any) -> None: - if not _is_city_command_text(getattr(message, "text", None)): + if getattr(message, "_pw_city_handled", False): return + if not _is_city_command(message): + return + setattr(message, "_pw_city_handled", True) + setattr(message, "_pw_command_handled", True) trace = CommandTrace("/city", message) try: - parts = (message.text or "").split(maxsplit=1) - if len(parts) < 2: + _, args = split_command_and_args(getattr(message, "text", None)) + if not args: trace.set_status("bad_request", "missing_city") self.io_layer.send_query_message( message, @@ -84,7 +64,7 @@ class CityCommandHandler: ) return - city_input = parts[1].strip().lower() + city_input = args.strip().lower() resolved = self.city_service.resolve_city(city_input) if not resolved.ok: city_list = ", ".join(resolved.supported_cities or []) diff --git a/src/bot/handlers/deb.py b/src/bot/handlers/deb.py index 3c0d396c..39e823cb 100644 --- a/src/bot/handlers/deb.py +++ b/src/bot/handlers/deb.py @@ -4,48 +4,20 @@ from typing import Any from loguru import logger +from src.bot.command_parser import extract_command_name +from src.bot.command_parser import split_command_and_args from src.bot.command_guard import CommandGuard from src.bot.io_layer import BotIOLayer from src.bot.observability import CommandTrace from src.bot.services.deb_command_service import DebCommandService from src.bot.settings import DEB_QUERY_COST - -def _normalized_command_head(text: str | None) -> str: - raw = str(text or "") - for marker in ( - "\ufeff", - "\u200b", - "\u200c", - "\u200d", - "\u200e", - "\u200f", - "\u2060", - "\u2066", - "\u2067", - "\u2068", - "\u2069", - "\u202a", - "\u202b", - "\u202c", - "\u202d", - "\u202e", - ): - raw = raw.replace(marker, "") - raw = raw.strip() - if raw[:1] in {"/", "⁄", "∕", "╱", "⧸"}: - raw = "/" + raw[1:] - return raw.split(maxsplit=1)[0].lower() if raw else "" - - -def _is_deb_command_text(text: str | None) -> bool: - head = _normalized_command_head(text) - return ( - head == "/deb" - or head.startswith("/deb@") - or head == "/pwdeb" - or head.startswith("/pwdeb@") +def _is_deb_command(message: Any) -> bool: + command = extract_command_name( + getattr(message, "text", None), + getattr(message, "entities", None), ) + return command in {"deb", "pwdeb"} class DebCommandHandler: @@ -62,20 +34,28 @@ class DebCommandHandler: self.io_layer = io_layer def register(self) -> None: + @self.bot.message_handler(commands=["deb", "pwdeb"]) + def _deb_command(message): + self.handle(message) + @self.bot.message_handler( - func=lambda message: _is_deb_command_text(getattr(message, "text", None)), + func=lambda message: _is_deb_command(message), content_types=["text"], ) - def _deb_func(message): + def _deb_text(message): self.handle(message) def handle(self, message: Any) -> None: - if not _is_deb_command_text(getattr(message, "text", None)): + if getattr(message, "_pw_deb_handled", False): return + if not _is_deb_command(message): + return + setattr(message, "_pw_deb_handled", True) + setattr(message, "_pw_command_handled", True) trace = CommandTrace("/deb", message) try: - parts = (message.text or "").split(maxsplit=1) - if len(parts) < 2: + _, args = split_command_and_args(getattr(message, "text", None)) + if not args: trace.set_status("bad_request", "missing_city") self.io_layer.send_query_message( message, @@ -84,7 +64,7 @@ class DebCommandHandler: ) return - city_input = parts[1].strip().lower() + city_input = args.strip().lower() city_name = self.deb_service.resolve_city(city_input) if not self.deb_service.has_history(city_name): trace.set_status("bad_request", "history_missing") diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 9507e0a7..42ad378b 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -98,4 +98,4 @@ def start_bot() -> None: started_count, len(runtime_status.loops), ) - bot.infinity_polling() + bot.infinity_polling(allowed_updates=["message"])