feat: introduce core bot command handlers, orchestrator, and command parser for initial bot functionality.

This commit is contained in:
2569718930@qq.com
2026-03-15 09:03:59 +08:00
parent 81604bfe8a
commit 1ebd07ca31
6 changed files with 218 additions and 170 deletions
+94
View File
@@ -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)
+18 -22
View File
@@ -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)
+63 -65
View File
@@ -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)
+21 -41
View File
@@ -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 [])
+21 -41
View File
@@ -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")
+1 -1
View File
@@ -98,4 +98,4 @@ def start_bot() -> None:
started_count,
len(runtime_status.loops),
)
bot.infinity_polling()
bot.infinity_polling(allowed_updates=["message"])