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:
2026-08-18 21:38:01 +10:00
co-authored by Sisyphus
parent 260720dd10
commit 76777c98eb
11 changed files with 749 additions and 348 deletions
+77 -14
View File
@@ -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)