feat: add Matrix appservice bot and platform-agnostic conversation core
Introduce a shared ConversationService (steward/bot/core.py) that owns the LLM call, history, knowledge-base search, and thread-memory keying behind a normalized ThreadKey, so both Telegram and Matrix drive the same pipeline. - Add steward/bot/matrix.py: a mautrix-python appservice bot that receives Synapse transactions and replies via the client-server API. - Refactor telegram.py handlers into thin wrappers over ConversationService. - Generalize ThreadMemoryStore/ThreadSummary to platform-scoped keys with legacy chat_id:thread_id migration. - Add a matrix config section (homeserver, tokens, room/user allowlists). - Rewrite main.py as async, starting Telegram and/or Matrix on one event loop. - Add mautrix>=0.21.0 dependency. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -8,20 +8,26 @@ from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from steward.bot.thread_key import ThreadKey
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThreadSummary:
|
||||
"""A persisted summary of a flushed Telegram message thread.
|
||||
"""A persisted summary of a flushed conversation thread.
|
||||
|
||||
``tags`` is a list of short lowercase keywords extracted by the LLM at flush
|
||||
time. They are used to index the knowledge base so summaries can be recalled
|
||||
contextually without being kept permanently in the conversation context.
|
||||
|
||||
``platform``/``scope``/``thread`` normalise the conversation identity across
|
||||
chat platforms (e.g. Telegram chat+thread, or a Matrix room).
|
||||
"""
|
||||
|
||||
chat_id: int
|
||||
thread_id: int
|
||||
platform: str
|
||||
scope: str
|
||||
thread: str | None
|
||||
summary: str
|
||||
message_count: int
|
||||
flushed_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
@@ -29,18 +35,49 @@ class ThreadSummary:
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.chat_id}:{self.thread_id}"
|
||||
parts = [self.platform, self.scope]
|
||||
if self.thread:
|
||||
parts.append(self.thread)
|
||||
return ":".join(parts)
|
||||
|
||||
@property
|
||||
def thread_id(self) -> str:
|
||||
"""Human-readable thread identifier for display (falls back to scope)."""
|
||||
return self.thread or self.scope
|
||||
|
||||
@staticmethod
|
||||
def _extract_tags(data: dict[str, object]) -> list[str]:
|
||||
raw = data.get("tags")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [str(t) for t in raw]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object]) -> ThreadSummary:
|
||||
"""Deserialise from a raw dict, tolerating missing optional fields."""
|
||||
"""Deserialise from a raw dict, tolerating missing optional fields.
|
||||
|
||||
Legacy records stored ``chat_id``/``thread_id`` (Telegram-only). Those
|
||||
are mapped to ``platform="telegram"``, ``scope=str(chat_id)`` and
|
||||
``thread=str(thread_id)`` for backward compatibility.
|
||||
"""
|
||||
if "platform" in data:
|
||||
return cls(
|
||||
platform=str(data["platform"]),
|
||||
scope=str(data["scope"]),
|
||||
thread=str(data["thread"]) if data.get("thread") else None,
|
||||
summary=str(data["summary"]),
|
||||
message_count=int(str(data["message_count"])),
|
||||
flushed_at=str(data.get("flushed_at", datetime.now(UTC).isoformat())),
|
||||
tags=cls._extract_tags(data),
|
||||
)
|
||||
return cls(
|
||||
chat_id=int(data["chat_id"]), # type: ignore[arg-type]
|
||||
thread_id=int(data["thread_id"]), # type: ignore[arg-type]
|
||||
platform="telegram",
|
||||
scope=str(data["chat_id"]),
|
||||
thread=str(data["thread_id"]),
|
||||
summary=str(data["summary"]),
|
||||
message_count=int(data["message_count"]), # type: ignore[arg-type]
|
||||
message_count=int(str(data["message_count"])),
|
||||
flushed_at=str(data.get("flushed_at", datetime.now(UTC).isoformat())),
|
||||
tags=list(data.get("tags", [])), # type: ignore[arg-type]
|
||||
tags=cls._extract_tags(data),
|
||||
)
|
||||
|
||||
def format_for_telegram(self) -> str:
|
||||
@@ -72,13 +109,39 @@ class ThreadMemoryStore:
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
return raw # type: ignore[return-value]
|
||||
return self._migrate_legacy_keys(raw)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning(
|
||||
"Could not read thread memory store at %s; starting fresh", self._path
|
||||
)
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _migrate_legacy_keys(
|
||||
raw: dict[str, object],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
"""Convert legacy ``"chat_id:thread_id"`` keys to the platform-scoped format.
|
||||
|
||||
Legacy records predate multi-platform support and stored keys as
|
||||
``"<chat_id>:<thread_id>"`` with ``chat_id``/``thread_id`` fields. These
|
||||
are migrated to ``"telegram:<chat_id>:<thread_id>"`` so they remain
|
||||
addressable via :class:`~steward.bot.thread_key.ThreadKey`.
|
||||
"""
|
||||
migrated: dict[str, dict[str, object]] = {}
|
||||
for key, value in raw.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
if "platform" in value:
|
||||
migrated[key] = value
|
||||
continue
|
||||
parts = str(key).split(":")
|
||||
if len(parts) == 2 and parts[0].lstrip("-").isdigit() and parts[1].isdigit():
|
||||
new_key = f"telegram:{parts[0]}:{parts[1]}"
|
||||
migrated[new_key] = value
|
||||
else:
|
||||
migrated[key] = value
|
||||
return migrated
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self._path.write_text(
|
||||
@@ -92,9 +155,9 @@ class ThreadMemoryStore:
|
||||
self._data[summary.key] = asdict(summary)
|
||||
self._save()
|
||||
|
||||
def get(self, chat_id: int, thread_id: int) -> ThreadSummary | None:
|
||||
"""Return the stored summary for a thread, or None if not found."""
|
||||
raw = self._data.get(f"{chat_id}:{thread_id}")
|
||||
def get(self, key: ThreadKey) -> ThreadSummary | None:
|
||||
"""Return the stored summary for a conversation scope, or None if not found."""
|
||||
raw = self._data.get(str(key))
|
||||
if raw is None:
|
||||
return None
|
||||
return ThreadSummary.from_dict(raw)
|
||||
@@ -116,6 +179,6 @@ class ThreadMemoryStore:
|
||||
results = [
|
||||
ThreadSummary.from_dict(v)
|
||||
for v in self._data.values()
|
||||
if query_words & {t.lower() for t in v.get("tags", [])} # type: ignore[union-attr]
|
||||
if query_words & {t.lower() for t in ThreadSummary._extract_tags(v)}
|
||||
]
|
||||
return sorted(results, key=lambda s: s.flushed_at, reverse=True)
|
||||
|
||||
Reference in New Issue
Block a user