bot/models/navigator.py
2026-09-09 17:42:31 +00:00

294 lines
11 KiB
Python

"""Validated WebApp protocol and local pathfinder implementation."""
from __future__ import annotations
import base64
import binascii
import json
import math
import re
from collections import deque
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
MAX_ROUTE_DATA = 4096
MAX_REQUEST_ID = 80
MAX_ENDPOINT_TEXT = 80
_REQUEST_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,80}$")
class NavigatorRequestError(ValueError):
"""The WebApp sent an unsupported or unsafe route request."""
@dataclass(frozen=True)
class RouteEndpoint:
room: str
floor: str | None = None
@dataclass(frozen=True)
class RouteRequest:
version: int
request_id: str
start: RouteEndpoint
end: RouteEndpoint
@dataclass(frozen=True)
class CompiledFloor:
name: str
floor_id: str
neighbors: tuple[tuple[str, ...], ...]
graph: tuple[tuple[int, ...], ...]
positions: tuple[tuple[float, float], ...]
_MANIFEST_PATH = Path(__file__).resolve().parents[1] / "web" / "map" / "data" / "navigator.json"
def _text(value: Any, field: str, limit: int = MAX_ENDPOINT_TEXT) -> str:
if not isinstance(value, str):
raise NavigatorRequestError(f"{field} должен быть строкой")
value = value.strip()
if not value or len(value) > limit or any(char in value for char in "<>\r\n"):
raise NavigatorRequestError(f"Некорректное поле {field}")
return value
def _endpoint(value: Any, field: str) -> RouteEndpoint:
if not isinstance(value, dict):
raise NavigatorRequestError(f"{field} должен быть объектом")
room = _text(value.get("room"), f"{field}.room")
floor_value = value.get("floor")
floor = None if floor_value is None else _text(floor_value, f"{field}.floor", 16)
return RouteEndpoint(room=room, floor=floor)
def parse_route_request(raw: str | bytes) -> RouteRequest:
if isinstance(raw, bytes):
if len(raw) > MAX_ROUTE_DATA:
raise NavigatorRequestError("Слишком большой запрос маршрута")
raw = raw.decode("utf-8", errors="strict")
elif not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ROUTE_DATA:
raise NavigatorRequestError("Слишком большой запрос маршрута")
try:
payload = json.loads(raw)
except (UnicodeError, json.JSONDecodeError) as error:
raise NavigatorRequestError("Запрос маршрута не является JSON") from error
if not isinstance(payload, dict) or payload.get("v") != 1 or payload.get("type") != "route.request":
raise NavigatorRequestError("Неподдерживаемый формат запроса маршрута")
request_id = _text(payload.get("request_id"), "request_id", MAX_REQUEST_ID)
if not _REQUEST_ID.fullmatch(request_id):
raise NavigatorRequestError("Некорректный request_id")
start = _endpoint(payload.get("start"), "start")
end = _endpoint(payload.get("end"), "end")
if start.room.casefold() == end.room.casefold() and start.floor == end.floor:
raise NavigatorRequestError("Начальная и конечная аудитории совпадают")
return RouteRequest(version=1, request_id=request_id, start=start, end=end)
def _decode_pathfinder(value: Any) -> dict[str, Any] | None:
for _ in range(2):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
@lru_cache(maxsize=4)
def _load_floors(mtime_ns: int) -> dict[str, CompiledFloor]:
del mtime_ns # cache key; contents are read again whenever the file changes
manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8"))
raw_floors = manifest.get("floors") if isinstance(manifest, dict) else None
if not isinstance(raw_floors, dict):
return {}
floors: dict[str, CompiledFloor] = {}
for key, raw_floor in raw_floors.items():
if not isinstance(raw_floor, dict):
continue
name = str(raw_floor.get("floor_name", key)).strip()
pathfinder = _decode_pathfinder(raw_floor.get("pathfinder_data", raw_floor))
if not pathfinder:
continue
raw_neighbors = pathfinder.get("neighbors")
raw_graph = pathfinder.get("graph")
raw_positions = raw_floor.get("positions", pathfinder.get("positions"))
if not all(isinstance(value, list) for value in (raw_neighbors, raw_graph, raw_positions)):
continue
if not (len(raw_neighbors) == len(raw_graph) == len(raw_positions)):
continue
try:
neighbors = tuple(tuple(str(item) for item in items) for items in raw_neighbors)
graph = tuple(tuple(int(item) for item in items) for items in raw_graph)
positions = tuple((float(item[0]), float(item[1])) for item in raw_positions)
except (TypeError, ValueError, IndexError):
continue
node_count = len(graph)
if any(node < 0 or node >= node_count for edges in graph for node in edges):
continue
floors[name] = CompiledFloor(
name=name,
floor_id=str(raw_floor.get("id", name)),
neighbors=neighbors,
graph=graph,
positions=positions,
)
return floors
def _floors() -> dict[str, CompiledFloor]:
try:
return _load_floors(_MANIFEST_PATH.stat().st_mtime_ns)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return {}
def _contains_room(floor: CompiledFloor, room: str) -> bool:
return any(room in names for names in floor.neighbors)
def _resolve_floor(floors: dict[str, CompiledFloor], endpoint: RouteEndpoint) -> CompiledFloor | None:
if endpoint.floor and endpoint.floor in floors:
floor = floors[endpoint.floor]
return floor if _contains_room(floor, endpoint.room) else None
matches = [floor for floor in floors.values() if _contains_room(floor, endpoint.room)]
return matches[0] if len(matches) == 1 else None
def _bfs(floor: CompiledFloor, start: str | int, end: str | int) -> list[int] | None:
if isinstance(start, int):
start_index = start if 0 <= start < len(floor.graph) else None
else:
start_index = next((index for index, names in enumerate(floor.neighbors) if start in names), None)
if start_index is None:
return None
def reached(index: int) -> bool:
return index == end if isinstance(end, int) else end in floor.neighbors[index]
if reached(start_index):
return [start_index]
queue = deque([start_index])
visited = {start_index}
parents: dict[int, int] = {}
while queue:
node = queue.popleft()
for neighbor in floor.graph[node]:
if neighbor in visited:
continue
parents[neighbor] = node
if reached(neighbor):
path = [neighbor]
while path[-1] != start_index:
path.append(parents[path[-1]])
return path[::-1]
visited.add(neighbor)
queue.append(neighbor)
return None
def _xy_search(floor: CompiledFloor, x: float, y: float, *, safe: bool = True) -> int | None:
closest_index: int | None = None
closest_distance = math.inf
for index, (point_x, point_y) in enumerate(floor.positions):
if safe and "лест" not in ",".join(floor.neighbors[index]).casefold():
continue
distance = math.hypot(point_x - x, point_y - y)
if distance < closest_distance:
closest_distance = distance
closest_index = index
return closest_index if closest_index is not None and closest_distance < 2000 else None
def _nearest_node(floor: CompiledFloor, position: tuple[float, float]) -> int | None:
safe_index = _xy_search(floor, *position, safe=True)
return safe_index if safe_index is not None else _xy_search(floor, *position, safe=False)
def _segment(floor: CompiledFloor, path: list[int]) -> dict[str, Any]:
# The WebView only needs the display floor number. Keeping the UUID out of
# the payload also makes route URLs shorter.
return {"array": path, "floor_id": floor.name, "floor_name": floor.name}
def calculate_route(request: RouteRequest) -> dict[str, Any] | None:
"""Calculate a route from the pathfinder manifest shared with the WebView."""
floors = _floors()
start_floor = _resolve_floor(floors, request.start)
end_floor = _resolve_floor(floors, request.end)
if not start_floor or not end_floor:
return None
if start_floor.name == end_floor.name:
path = _bfs(start_floor, request.start.room, request.end.room)
return {"v": 1, "segments": [_segment(start_floor, path)]} if path else None
end_path = _bfs(end_floor, request.end.room, "Лестница")
if not end_path:
return None
if start_floor.name == "2":
destination = _nearest_node(start_floor, end_floor.positions[end_path[-1]])
start_path = _bfs(start_floor, request.start.room, destination) if destination is not None else None
if not start_path:
return None
segments = [_segment(start_floor, start_path), _segment(end_floor, end_path)]
return {"v": 1, "segments": segments}
start_path = _bfs(start_floor, request.start.room, "Лестница")
second_floor = floors.get("2")
if not start_path or not second_floor:
return None
second_start = _nearest_node(second_floor, start_floor.positions[start_path[-1]])
if second_start is None:
return None
if end_floor.name == "2":
second_path = _bfs(second_floor, second_start, request.end.room)
if not second_path:
return None
return {"v": 1, "segments": [_segment(start_floor, start_path), _segment(second_floor, second_path)]}
second_end = _nearest_node(second_floor, end_floor.positions[end_path[-1]])
if second_end is None:
return None
second_path = _bfs(second_floor, second_start, second_end)
if not second_path:
return None
segments = [
_segment(start_floor, start_path),
_segment(second_floor, second_path),
_segment(end_floor, end_path),
]
return {"v": 1, "segments": segments}
def encode_route(route: dict[str, Any]) -> str:
"""Encode a route for a URL without putting credentials in the URL."""
data = json.dumps(route, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def decode_route(value: str) -> dict[str, Any] | None:
"""Decode a route in tests/tools; the WebApp performs the same operation."""
try:
padded = value + "=" * ((4 - len(value) % 4) % 4)
result = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
except (ValueError, TypeError, UnicodeError, binascii.Error, json.JSONDecodeError):
return None
return result if isinstance(result, dict) else None