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
+81 -23
View File
@@ -1,11 +1,16 @@
"""Steward application entry point."""
import asyncio
import logging
import signal
import sys
from datetime import time
from typing import Any
from telegram.ext import ContextTypes
from telegram.ext import Application, ContextTypes
from steward.bot.core import ConversationService
from steward.bot.matrix import StewardMatrixBot
from steward.bot.telegram import build_application, send_proposal
from steward.config import get_settings
from steward.llm.client import LLMClient
@@ -34,12 +39,37 @@ async def _run_scheduled_analysis(context: ContextTypes.DEFAULT_TYPE) -> None:
logger.info("No proposal generated (generator returned None)")
def main() -> None:
"""Start Steward."""
async def _run_telegram(app: Application, shutdown_event: asyncio.Event) -> None: # type: ignore[type-arg]
"""Start the Telegram bot and wait for shutdown."""
await app.initialize()
await app.start()
if app.updater is not None:
await app.updater.start_polling(allowed_updates=["message"])
logger.info("Telegram bot started")
try:
await shutdown_event.wait()
finally:
if app.updater is not None:
await app.updater.stop()
await app.stop()
await app.shutdown()
async def _run_matrix(bot: StewardMatrixBot, shutdown_event: asyncio.Event) -> None:
"""Start the Matrix appservice and wait for shutdown."""
await bot.run()
try:
await shutdown_event.wait()
finally:
await bot.stop()
async def main() -> None:
"""Start Steward (Telegram and/or Matrix)."""
settings = get_settings()
if not settings.telegram_bot_token:
logger.error("TELEGRAM_BOT_TOKEN is not set – cannot start")
if not settings.telegram_bot_token and not settings.matrix_enabled:
logger.error("No platform configured – set TELEGRAM_BOT_TOKEN or Matrix appservice config")
sys.exit(1)
if not settings.openai_api_key:
@@ -59,27 +89,55 @@ def main() -> None:
else:
logger.info("No MCP_SERVER_URL configured – tool calling disabled")
app = build_application(settings, llm, thread_store, tool_client)
generator = ProposalGenerator(settings, llm)
app.bot_data["generator"] = generator
app.bot_data["user_ids"] = settings.telegram_allowed_user_ids
service = ConversationService(settings, llm, thread_store, tool_client)
if app.job_queue is not None:
app.job_queue.run_daily(
_run_scheduled_analysis,
time=time(hour=settings.analysis_cron_hour, minute=settings.analysis_cron_minute),
shutdown_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, shutdown_event.set)
tasks: list[asyncio.Task[Any]] = []
if settings.telegram_bot_token:
app = build_application(settings, service)
generator = ProposalGenerator(settings, llm)
app.bot_data["generator"] = generator
app.bot_data["user_ids"] = settings.telegram_allowed_user_ids
if app.job_queue is not None:
app.job_queue.run_daily(
_run_scheduled_analysis,
time=time(hour=settings.analysis_cron_hour, minute=settings.analysis_cron_minute),
)
else:
logger.warning("JobQueue not available – scheduled analysis disabled")
logger.info(
"Steward starting: model=%s analysis_url=%s tools=%s",
settings.openai_model,
settings.analysis_target_url or "(none)",
settings.mcp_server_url or "(none)",
)
else:
logger.warning("JobQueue not available – scheduled analysis disabled")
tasks.append(asyncio.create_task(_run_telegram(app, shutdown_event)))
logger.info(
"Steward starting: model=%s analysis_url=%s tools=%s",
settings.openai_model,
settings.analysis_target_url or "(none)",
settings.mcp_server_url or "(none)",
)
app.run_polling(allowed_updates=["message"])
if settings.matrix_enabled:
matrix_bot = StewardMatrixBot(settings, service)
tasks.append(asyncio.create_task(_run_matrix(matrix_bot, shutdown_event)))
if not tasks:
logger.error("No platform started")
sys.exit(1)
try:
await asyncio.gather(*tasks)
except asyncio.CancelledError:
pass
if __name__ == "__main__":
main()
asyncio.run(main())
def run() -> None:
"""Synchronous entry point for the ``steward`` console script."""
asyncio.run(main())