feat: introduce core bot command handlers, orchestrator, and command parser for initial bot functionality.
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user