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
+34 -24
View File
@@ -6,15 +6,15 @@ import pytest
from telegram import Chat, Message, Update, User
from telegram.ext import CallbackContext
from steward.bot.core import ConversationService
from steward.bot.telegram import (
_history,
_is_allowed,
_is_chat_enabled,
_thread_history,
clear_handler,
message_handler,
start_handler,
)
from steward.bot.thread_key import ThreadKey
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.memory.thread_store import ThreadMemoryStore
@@ -68,10 +68,13 @@ def _make_context(
mock_store = MagicMock(spec=ThreadMemoryStore)
mock_store.search.return_value = []
store = mock_store
if llm is None:
llm = MagicMock(spec=LLMClient)
service = ConversationService(settings, llm, store)
ctx.bot_data = {
"settings": settings,
"service": service,
"llm": llm,
"thread_store": store,
}
ctx.args = []
return ctx
@@ -140,14 +143,15 @@ async def test_clear_handler_clears_user_history():
"""clear_handler without a thread clears per-user history."""
settings = _make_settings()
user_id = 42
_history[user_id] = [{"role": "user", "content": "old msg"}]
ctx = _make_context(settings)
service: ConversationService = ctx.bot_data["service"]
key = ThreadKey(platform="telegram", scope=str(user_id))
service._histories[key] = [{"role": "user", "content": "old msg"}]
update = _make_update(user_id=user_id) # no thread_id
ctx = _make_context(settings)
await clear_handler(update, ctx)
assert _history[user_id] == []
assert not service.has_history(key)
update.message.reply_text.assert_awaited_once()
@@ -155,15 +159,15 @@ async def test_clear_handler_clears_user_history():
async def test_clear_handler_clears_thread_history():
"""clear_handler inside a thread clears that thread's history."""
settings = _make_settings()
key = (100, 7)
_thread_history[key] = [{"role": "user", "content": "thread msg"}]
ctx = _make_context(settings)
service: ConversationService = ctx.bot_data["service"]
key = ThreadKey(platform="telegram", scope="100", thread="7")
service._histories[key] = [{"role": "user", "content": "thread msg"}]
update = _make_update(user_id=1, chat_id=100, thread_id=7)
ctx = _make_context(settings)
await clear_handler(update, ctx)
assert _thread_history[key] == []
assert not service.has_history(key)
update.message.reply_text.assert_awaited_once()
@@ -175,15 +179,17 @@ async def test_message_handler_calls_llm_and_replies():
update = _make_update(user_id=77, text="What is the weather?")
ctx = _make_context(settings, llm=mock_llm)
service: ConversationService = ctx.bot_data["service"]
key = ThreadKey(platform="telegram", scope="77")
_history[77].clear()
await message_handler(update, ctx)
mock_llm.chat.assert_awaited_once()
update.message.reply_text.assert_awaited_once()
assert len(_history[77]) == 2
assert _history[77][0]["role"] == "user"
assert _history[77][1]["role"] == "assistant"
history = service._histories[key]
assert len(history) == 2
assert history[0]["role"] == "user"
assert history[1]["role"] == "assistant"
@pytest.mark.asyncio
@@ -196,15 +202,16 @@ async def test_message_handler_sends_multiple_reply_messages():
update = _make_update(user_id=78, text="Can you help with this vague thing?")
ctx = _make_context(settings, llm=mock_llm)
service: ConversationService = ctx.bot_data["service"]
key = ThreadKey(platform="telegram", scope="78")
_history[78].clear()
await message_handler(update, ctx)
assert update.message.reply_text.await_count == 2
assert update.message.reply_text.await_args_list[0].args[0] == "First thought."
assert update.message.reply_text.await_args_list[1].args[0] == "What outcome do you want?"
update.message.reply_poll.assert_not_awaited()
assert _history[78][1]["content"] == "First thought.\n\nWhat outcome do you want?"
assert service._histories[key][1]["content"] == "First thought.\n\nWhat outcome do you want?"
@pytest.mark.asyncio
@@ -225,8 +232,9 @@ async def test_message_handler_sends_native_poll_from_directive():
update = _make_update(user_id=79, text="Should we do a minimal change or redesign?")
ctx = _make_context(settings, llm=mock_llm)
service: ConversationService = ctx.bot_data["service"]
key = ThreadKey(platform="telegram", scope="79")
_history[79].clear()
await message_handler(update, ctx)
assert update.message.reply_text.await_count == 2
@@ -235,7 +243,7 @@ async def test_message_handler_sends_native_poll_from_directive():
options=["Minimal change", "Full redesign"],
is_anonymous=False,
)
assert _history[79][1]["content"] == (
assert service._histories[key][1]["content"] == (
"I can turn that into a vote.\n\n"
"Poll: Which implementation should we choose? (Minimal change; Full redesign)\n\n"
"I'll use the winning option."
@@ -245,22 +253,24 @@ async def test_message_handler_sends_native_poll_from_directive():
@pytest.mark.asyncio
async def test_message_handler_non_thread_trims_history():
"""Non-thread history is trimmed to _MAX_HISTORY turns."""
from steward.bot.telegram import _MAX_HISTORY
from steward.bot.core import _MAX_HISTORY
settings = _make_settings()
mock_llm = MagicMock(spec=LLMClient)
mock_llm.chat = AsyncMock(return_value="reply")
user_id = 200
ctx = _make_context(settings, llm=mock_llm)
service: ConversationService = ctx.bot_data["service"]
key = ThreadKey(platform="telegram", scope=str(user_id))
# Pre-fill exactly at the limit
_history[user_id] = [
service._histories[key] = [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"}
for i in range(_MAX_HISTORY * 2)
]
update = _make_update(user_id=user_id, text="new question")
ctx = _make_context(settings, llm=mock_llm)
await message_handler(update, ctx)
# After appending 2 new messages, should trim back to _MAX_HISTORY * 2
assert len(_history[user_id]) == _MAX_HISTORY * 2
assert len(service._histories[key]) == _MAX_HISTORY * 2
+98 -102
View File
@@ -7,12 +7,13 @@ import pytest
from telegram import Chat, Message, Update, User
from telegram.ext import CallbackContext
from steward.bot.core import ConversationService
from steward.bot.telegram import (
_thread_history,
flush_handler,
message_handler,
recall_handler,
)
from steward.bot.thread_key import ThreadKey
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.memory.thread_store import ThreadMemoryStore, ThreadSummary
@@ -64,10 +65,13 @@ def _make_context(
mock_store = MagicMock(spec=ThreadMemoryStore)
mock_store.search.return_value = []
store = mock_store
if llm is None:
llm = MagicMock(spec=LLMClient)
service = ConversationService(settings, llm, store)
ctx.bot_data = {
"settings": settings,
"llm": llm or MagicMock(spec=LLMClient),
"thread_store": store,
"service": service,
"llm": llm,
}
ctx.args = []
return ctx
@@ -82,50 +86,56 @@ class TestThreadMemoryStore:
def _store(self, tmp_path: Path) -> ThreadMemoryStore:
return ThreadMemoryStore(tmp_path / "mem.json")
def _key(self, scope: str = "1", thread: str | None = "2") -> ThreadKey:
return ThreadKey(platform="telegram", scope=scope, thread=thread)
def _summary(self, scope: str = "1", thread: str | None = "2", **kwargs) -> ThreadSummary:
defaults = dict(summary="A recap.", message_count=5)
defaults.update(kwargs)
return ThreadSummary(platform="telegram", scope=scope, thread=thread, **defaults)
def test_get_missing_returns_none(self, tmp_path: Path):
store = self._store(tmp_path)
assert store.get(1, 2) is None
assert store.get(self._key()) is None
def test_save_and_get_roundtrip(self, tmp_path: Path):
store = self._store(tmp_path)
summary = ThreadSummary(chat_id=1, thread_id=42, summary="A recap.", message_count=5)
summary = self._summary()
store.save(summary)
retrieved = store.get(1, 42)
retrieved = store.get(self._key())
assert retrieved is not None
assert retrieved.summary == "A recap."
assert retrieved.message_count == 5
def test_save_overwrites_previous_entry(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="old", message_count=2))
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="new", message_count=4))
assert store.get(1, 1).summary == "new" # type: ignore[union-attr]
store.save(self._summary(summary="old", message_count=2))
store.save(self._summary(summary="new", message_count=4))
assert store.get(self._key()).summary == "new" # type: ignore[union-attr]
def test_persisted_to_disk(self, tmp_path: Path):
path = tmp_path / "mem.json"
store = ThreadMemoryStore(path)
store.save(ThreadSummary(chat_id=5, thread_id=9, summary="saved", message_count=1))
store.save(self._summary(scope="5", thread="9", summary="saved", message_count=1))
# Load a fresh store from the same file
store2 = ThreadMemoryStore(path)
assert store2.get(5, 9) is not None
assert store2.get(self._key("5", "9")) is not None
def test_all_returns_newest_first(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
self._summary(
thread="1",
summary="first",
message_count=1,
flushed_at="2026-01-01T00:00:00+00:00",
)
)
store.save(
ThreadSummary(
chat_id=1,
thread_id=2,
self._summary(
thread="2",
summary="second",
message_count=1,
flushed_at="2026-06-01T00:00:00+00:00",
@@ -142,19 +152,13 @@ class TestThreadMemoryStore:
assert store.all() == []
def test_format_for_telegram_contains_thread_id(self, tmp_path: Path):
s = ThreadSummary(chat_id=1, thread_id=77, summary="recap", message_count=3)
s = self._summary(thread="77", summary="recap", message_count=3)
text = s.format_for_telegram()
assert "77" in text
assert "recap" in text
def test_format_for_telegram_shows_tags(self, tmp_path: Path):
s = ThreadSummary(
chat_id=1,
thread_id=77,
summary="recap",
message_count=3,
tags=["api", "auth"],
)
s = self._summary(thread="77", summary="recap", message_count=3, tags=["api", "auth"])
text = s.format_for_telegram()
assert "api" in text
assert "auth" in text
@@ -174,55 +178,37 @@ class TestThreadMemoryStore:
def test_search_returns_matching_summaries(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
summary="API work",
message_count=2,
tags=["api", "design", "auth"],
self._summary(
thread="1", summary="API work", message_count=2, tags=["api", "design", "auth"]
)
)
store.save(
ThreadSummary(
chat_id=1,
thread_id=2,
summary="Database work",
message_count=2,
tags=["database", "schema"],
self._summary(
thread="2", summary="Database work", message_count=2, tags=["database", "schema"]
)
)
results = store.search("api")
assert len(results) == 1
assert results[0].thread_id == 1
assert results[0].thread_id == "1"
def test_search_no_match_returns_empty(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(
ThreadSummary(
chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["database"]
)
)
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=["database"]))
assert store.search("deployment") == []
def test_search_empty_query_returns_empty(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(
ThreadSummary(
chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["database"]
)
)
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=["database"]))
assert store.search("") == []
def test_search_case_insensitive(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(
ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["API"])
)
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=["API"]))
assert len(store.search("api")) == 1
def test_search_skips_untagged_summaries(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1, tags=[]))
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=[]))
assert store.search("api") == []
def test_legacy_store_roundtrip(self, tmp_path: Path):
@@ -245,7 +231,7 @@ class TestThreadMemoryStore:
encoding="utf-8",
)
store = ThreadMemoryStore(path)
s = store.get(1, 1)
s = store.get(self._key("1", "1"))
assert s is not None
assert s.tags == []
@@ -257,49 +243,49 @@ class TestThreadMemoryStore:
@pytest.mark.asyncio
async def test_thread_message_stored_in_thread_history():
"""Messages in a thread go to _thread_history, not _history."""
from steward.bot.telegram import _history
"""Messages in a thread go to the thread's history, not the user's."""
settings = _make_settings()
mock_llm = MagicMock(spec=LLMClient)
mock_llm.chat = AsyncMock(return_value="thread reply")
key = (100, 55)
_thread_history[key].clear()
ctx = _make_context(settings, llm=mock_llm)
service: ConversationService = ctx.bot_data["service"]
thread_key = ThreadKey(platform="telegram", scope="100", thread="55")
user_key = ThreadKey(platform="telegram", scope="1")
update = _make_update(user_id=1, chat_id=100, thread_id=55, text="thread message")
ctx = _make_context(settings, llm=mock_llm)
await message_handler(update, ctx)
assert len(_thread_history[key]) == 2
assert _thread_history[key][0]["role"] == "user"
assert len(service._histories[thread_key]) == 2
assert service._histories[thread_key][0]["role"] == "user"
# Regular user history untouched
assert len(_history[1]) == 0
assert not service.has_history(user_key)
@pytest.mark.asyncio
async def test_thread_history_is_unbounded():
"""Thread history never gets trimmed regardless of how many turns there are."""
from steward.bot.telegram import _MAX_HISTORY
from steward.bot.core import _MAX_HISTORY
settings = _make_settings()
mock_llm = MagicMock(spec=LLMClient)
mock_llm.chat = AsyncMock(return_value="reply")
key = (200, 66)
ctx = _make_context(settings, llm=mock_llm)
service: ConversationService = ctx.bot_data["service"]
thread_key = ThreadKey(platform="telegram", scope="200", thread="66")
# Pre-fill well beyond the cap used for non-thread history
_thread_history[key] = [
service._histories[thread_key] = [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"}
for i in range(_MAX_HISTORY * 4) # 4× the normal cap
]
prior_len = len(_thread_history[key])
prior_len = len(service._histories[thread_key])
update = _make_update(user_id=2, chat_id=200, thread_id=66, text="more")
ctx = _make_context(settings, llm=mock_llm)
await message_handler(update, ctx)
# Should have grown by exactly 2 (user + assistant), never trimmed
assert len(_thread_history[key]) == prior_len + 2
assert len(service._histories[thread_key]) == prior_len + 2
# ---------------------------------------------------------------------------
@@ -333,9 +319,6 @@ async def test_flush_empty_thread_warns():
mock_llm = MagicMock(spec=LLMClient)
mock_llm.chat = AsyncMock(return_value="summary")
key = (300, 88)
_thread_history[key].clear()
update = _make_update(chat_id=300, thread_id=88)
ctx = _make_context(settings, llm=mock_llm, store=store)
await flush_handler(update, ctx)
@@ -353,8 +336,10 @@ async def test_flush_summarises_stores_and_compresses():
# First call → summary, second call → tags
mock_llm.chat = AsyncMock(side_effect=["Great summary of the thread.", "api, design, testing"])
key = (400, 99)
_thread_history[key] = [
ctx = _make_context(settings, llm=mock_llm, store=store)
service: ConversationService = ctx.bot_data["service"]
key = ThreadKey(platform="telegram", scope="400", thread="99")
service._histories[key] = [
{"role": "user", "content": "question one"},
{"role": "assistant", "content": "answer one"},
{"role": "user", "content": "question two"},
@@ -362,7 +347,6 @@ async def test_flush_summarises_stores_and_compresses():
]
update = _make_update(chat_id=400, thread_id=99)
ctx = _make_context(settings, llm=mock_llm, store=store)
await flush_handler(update, ctx)
# LLM called twice: once for summary, once for tags
@@ -370,16 +354,17 @@ async def test_flush_summarises_stores_and_compresses():
# Summary persisted
store.save.assert_called_once()
saved: ThreadSummary = store.save.call_args.args[0]
assert saved.chat_id == 400
assert saved.thread_id == 99
assert saved.platform == "telegram"
assert saved.scope == "400"
assert saved.thread == "99"
assert saved.summary == "Great summary of the thread."
assert saved.message_count == 2 # 2 user turns
assert saved.tags == ["api", "design", "testing"]
# In-memory history replaced with compressed context
assert len(_thread_history[key]) == 1
assert _thread_history[key][0]["role"] == "system"
assert "Great summary" in _thread_history[key][0]["content"]
assert len(service._histories[key]) == 1
assert service._histories[key][0]["role"] == "system"
assert "Great summary" in service._histories[key][0]["content"]
# ---------------------------------------------------------------------------
@@ -392,7 +377,11 @@ async def test_recall_in_thread_returns_stored_summary(tmp_path):
"""recall_handler inside a thread returns the stored summary for that thread."""
settings = _make_settings()
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(ThreadSummary(chat_id=500, thread_id=11, summary="recap text", message_count=3))
store.save(
ThreadSummary(
platform="telegram", scope="500", thread="11", summary="recap text", message_count=3
)
)
update = _make_update(chat_id=500, thread_id=11)
ctx = _make_context(settings, store=store)
@@ -423,8 +412,12 @@ async def test_recall_outside_thread_lists_all(tmp_path):
"""recall_handler outside a thread lists all stored summaries."""
settings = _make_settings()
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="alpha", message_count=1))
store.save(ThreadSummary(chat_id=1, thread_id=2, summary="beta", message_count=2))
store.save(
ThreadSummary(platform="telegram", scope="1", thread="1", summary="alpha", message_count=1)
)
store.save(
ThreadSummary(platform="telegram", scope="1", thread="2", summary="beta", message_count=2)
)
update = _make_update(thread_id=None) # no thread
ctx = _make_context(settings, store=store)
@@ -457,8 +450,9 @@ async def test_recall_with_query_returns_matching_summaries(tmp_path):
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
platform="telegram",
scope="1",
thread="1",
summary="API authentication discussion",
message_count=2,
tags=["api", "auth"],
@@ -466,8 +460,9 @@ async def test_recall_with_query_returns_matching_summaries(tmp_path):
)
store.save(
ThreadSummary(
chat_id=1,
thread_id=2,
platform="telegram",
scope="1",
thread="2",
summary="Database schema planning",
message_count=3,
tags=["database", "schema"],
@@ -492,8 +487,9 @@ async def test_recall_with_query_no_match(tmp_path):
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
platform="telegram",
scope="1",
thread="1",
summary="recap",
message_count=1,
tags=["database"],
@@ -520,8 +516,9 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
platform="telegram",
scope="1",
thread="1",
summary="Previous API discussion",
message_count=2,
tags=["api"],
@@ -529,12 +526,11 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
)
user_id = 999
from steward.bot.telegram import _history
_history[user_id].clear()
ctx = _make_context(settings, llm=mock_llm, store=store)
service: ConversationService = ctx.bot_data["service"]
user_key = ThreadKey(platform="telegram", scope=str(user_id))
update = _make_update(user_id=user_id, text="tell me about the api work")
ctx = _make_context(settings, llm=mock_llm, store=store)
await message_handler(update, ctx)
# LLM was called
@@ -547,8 +543,10 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
assert history_arg is not None
assert any("knowledge base" in m.get("content", "").lower() for m in history_arg)
# But the KB message must NOT be stored in _history
assert all("knowledge base" not in m.get("content", "").lower() for m in _history[user_id])
# But the KB message must NOT be stored in history
assert all(
"knowledge base" not in m.get("content", "").lower() for m in service._histories[user_key]
)
@pytest.mark.asyncio
@@ -562,8 +560,9 @@ async def test_message_handler_no_kb_injection_when_no_match(tmp_path):
# Store a summary with unrelated tags
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
platform="telegram",
scope="1",
thread="1",
summary="Database recap",
message_count=1,
tags=["database"],
@@ -571,12 +570,9 @@ async def test_message_handler_no_kb_injection_when_no_match(tmp_path):
)
user_id = 888
from steward.bot.telegram import _history
_history[user_id].clear()
ctx = _make_context(settings, llm=mock_llm, store=store)
update = _make_update(user_id=user_id, text="what is the weather like?")
ctx = _make_context(settings, llm=mock_llm, store=store)
await message_handler(update, ctx)
call_kwargs = mock_llm.chat.call_args