|
|
@@ -39,6 +39,7 @@ from dataclasses import dataclass, field
|
|
|
from datetime import UTC, datetime
|
|
|
from difflib import SequenceMatcher
|
|
|
from enum import StrEnum
|
|
|
+from pathlib import Path
|
|
|
from typing import Any, Final, NamedTuple, TypeVar
|
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
|
|
@@ -650,6 +651,12 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
await self._announce_online()
|
|
|
|
|
|
self._closing = False
|
|
|
+ # A cursor persisted by a clean shutdown lets this process pick up where
|
|
|
+ # the previous one stopped, so a restart inside the window answers what
|
|
|
+ # arrived while it was down instead of starting at the live boundary.
|
|
|
+ self._realtime_cursor = _restore_realtime_cursor(self.chatto_config)
|
|
|
+ if self._realtime_cursor:
|
|
|
+ logger.info("Chatto: resuming the realtime stream from the stored cursor")
|
|
|
# Start background realtime WS event stream loop.
|
|
|
self._ws_task = asyncio.create_task(
|
|
|
self._chattolib_event_loop(),
|
|
|
@@ -714,6 +721,11 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# below is what actually takes the bot offline.
|
|
|
self._closing = True
|
|
|
|
|
|
+ # Store the stream position before anything is torn down: this is the
|
|
|
+ # one moment where a restart can resume instead of starting at the live
|
|
|
+ # boundary, and it costs one small write.
|
|
|
+ _persist_realtime_cursor(self.chatto_config, self._realtime_cursor)
|
|
|
+
|
|
|
# Cancel all typing tasks
|
|
|
for chat_id in list(self._typing_tasks.keys()):
|
|
|
await self.stop_typing(chat_id)
|
|
|
@@ -4125,11 +4137,87 @@ def _profile_scope_key() -> str:
|
|
|
return ""
|
|
|
|
|
|
|
|
|
+def _realtime_cursor_file() -> Path | None:
|
|
|
+ """Where a restart-resumable realtime cursor lives, or None without Hermes.
|
|
|
+
|
|
|
+ Under the profile's own home so two profiles never read each other's
|
|
|
+ position; ``None`` (a bare process with no ``hermes_constants``) simply
|
|
|
+ means nothing is persisted, which is the same behaviour as no config.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ from hermes_constants import get_hermes_home
|
|
|
+ except ImportError:
|
|
|
+ return None
|
|
|
+ return Path(get_hermes_home()) / "state" / "chatto_realtime_cursor.json"
|
|
|
+
|
|
|
+
|
|
|
+def _restore_realtime_cursor(cfg: ChattoConfiguration) -> str | None:
|
|
|
+ """The stored realtime cursor, if it is this profile's and still fresh.
|
|
|
+
|
|
|
+ Three ways a stored value is refused instead of trusted: a different
|
|
|
+ profile wrote it, it carries a shape this build does not understand, or it
|
|
|
+ is older than ``resume_window``. In every case the stream starts at the
|
|
|
+ live boundary, which is what the plugin did before the cursor was stored at
|
|
|
+ all — so a bad file costs the gap it was meant to close, never a crash.
|
|
|
+ """
|
|
|
+ window = cfg.resume_window.value
|
|
|
+ if window <= 0:
|
|
|
+ return None
|
|
|
+ path = _realtime_cursor_file()
|
|
|
+ if path is None:
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ payload = json.loads(path.read_text(encoding="utf-8"))
|
|
|
+ except (OSError, ValueError):
|
|
|
+ return None
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ return None
|
|
|
+ if payload.get("scope") != _profile_scope_key():
|
|
|
+ return None
|
|
|
+ cursor = payload.get("cursor")
|
|
|
+ saved_at = payload.get("saved_at")
|
|
|
+ if not isinstance(cursor, str) or not cursor or not isinstance(saved_at, str):
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ saved = datetime.fromisoformat(saved_at)
|
|
|
+ except ValueError:
|
|
|
+ return None
|
|
|
+ if saved.tzinfo is None:
|
|
|
+ saved = saved.replace(tzinfo=UTC)
|
|
|
+ if (datetime.now(UTC) - saved).total_seconds() > window:
|
|
|
+ return None
|
|
|
+ return cursor
|
|
|
+
|
|
|
+
|
|
|
+def _persist_realtime_cursor(cfg: ChattoConfiguration, cursor: str | None) -> None:
|
|
|
+ """Store the cursor of the last applied event, best-effort.
|
|
|
+
|
|
|
+ Written only on a clean stop, not per event: a deploy or restart should not
|
|
|
+ lose the gap, while a crash is allowed to — the alternative is a file write
|
|
|
+ on every presence change. A failed write is logged and otherwise ignored,
|
|
|
+ because losing the resume point is not a reason to fail a shutdown.
|
|
|
+ """
|
|
|
+ if not cursor or cfg.resume_window.value <= 0:
|
|
|
+ return
|
|
|
+ path = _realtime_cursor_file()
|
|
|
+ if path is None:
|
|
|
+ return
|
|
|
+ payload = {
|
|
|
+ "scope": _profile_scope_key(),
|
|
|
+ "cursor": cursor,
|
|
|
+ "saved_at": datetime.now(UTC).isoformat(),
|
|
|
+ }
|
|
|
+ try:
|
|
|
+ path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
+ except OSError as exc:
|
|
|
+ logger.debug("Chatto: realtime cursor not persisted: %s", exc)
|
|
|
+
|
|
|
+
|
|
|
# How similar an ``extra`` key must be to a declared config_key before the
|
|
|
# unknown-key check reads it as a likely typo or renamed field.
|
|
|
_NEAR_MISS_RATIO = 0.75
|
|
|
|
|
|
-
|
|
|
def _warn_on_unknown_extra_keys(config: PlatformConfig) -> None:
|
|
|
"""Warn about ``extra`` keys that look like misspelled config fields.
|
|
|
|
|
|
@@ -4185,6 +4273,14 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
|
|
|
)
|
|
|
return False
|
|
|
|
|
|
+ if resolved.resume_window.value < 0:
|
|
|
+ logger.error(
|
|
|
+ "Chatto: 'resume_window' must be 0 (disabled) or a positive number "
|
|
|
+ "of seconds, not %s.",
|
|
|
+ resolved.resume_window.value,
|
|
|
+ )
|
|
|
+ return False
|
|
|
+
|
|
|
_by_room, malformed_toolsets = _parse_room_toolsets(
|
|
|
resolved.room_toolsets.value
|
|
|
)
|