Andrew Ridgway 76777c98eb
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>
2026-08-18 21:38:01 +10:00

315 lines
10 KiB
Python

"""Configuration management for Steward.
Supports configuration via:
1. YAML config file (passed via CONFIG_FILE env var)
2. Environment variables (STEWARD__SECTION__KEY=value format)
3. Default schema values
Environment variables take precedence over config file values.
"""
import json
import logging
import os
from pathlib import Path
from typing import Any
from omegaconf import OmegaConf
from pydantic import BaseModel, ConfigDict, Field, field_validator
logger = logging.getLogger(__name__)
def _parse_int_list(value: Any) -> Any:
if isinstance(value, int):
return [value]
if not isinstance(value, str):
return value
text = value.strip()
if not text:
return []
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
text = text[1:-1].strip()
if not text:
return []
if text.startswith("["):
try:
parsed = json.loads(text)
except json.JSONDecodeError:
pass
else:
if isinstance(parsed, list):
return parsed
try:
return [int(item.strip().strip("'\"")) for item in text.split(",") if item.strip()]
except ValueError as exc:
raise ValueError(
"expected a comma-separated list of integers or a JSON integer list"
) from exc
class TelegramConfig(BaseModel):
"""Telegram bot configuration."""
bot_token: str = Field(default="", description="Telegram bot token")
allowed_user_ids: list[int] = Field(default_factory=list, description="Allowed user IDs")
group_ids: list[int] = Field(default_factory=list, description="Allowed group/channel IDs")
@field_validator("allowed_user_ids", "group_ids", mode="before")
@classmethod
def parse_int_list(cls, value: Any) -> Any:
return _parse_int_list(value)
class OpenAIConfig(BaseModel):
"""OpenAI/LLM configuration."""
api_key: str = Field(default="", description="OpenAI API key")
base_url: str = Field(default="https://api.openai.com/v1", description="API base URL")
model: str = Field(default="gpt-4o", description="Model to use")
system_prompt: str = Field(
default=(
"You are Steward, a persistent, trustworthy AI-assisted personal operations platform. "
"You reduce cognitive load by observing, remembering, planning, and proposing actions. "
"You are conservative, transparent, and policy-aware. "
"Always explain your reasoning."
),
description="System prompt for the LLM",
)
class AnalysisConfig(BaseModel):
"""Analysis/proposal worker configuration."""
target_url: str = Field(default="", description="Target URL for analysis")
target_api_key: str = Field(default="", description="API key for target")
cron_hour: int = Field(default=8, description="Hour for cron schedule")
cron_minute: int = Field(default=0, description="Minute for cron schedule")
class MemoryConfig(BaseModel):
"""Memory/persistence configuration."""
thread_memory_path: str = Field(default="thread_memory.json", description="Thread memory path")
class ToolsConfig(BaseModel):
"""Tools/MCP configuration."""
mcp_server_url: str = Field(default="", description="MCP server URL")
mcp_server_api_key: str = Field(default="", description="MCP server API key")
class MatrixConfig(BaseModel):
"""Matrix appservice configuration."""
homeserver_url: str = Field(default="", description="Homeserver client-server base URL")
homeserver_domain: str = Field(default="", description="Homeserver domain (server_name)")
as_token: str = Field(
default="", description="Appservice token for authenticating to the homeserver"
)
hs_token: str = Field(
default="", description="Homeserver token for authenticating incoming transactions"
)
bot_localpart: str = Field(default="steward", description="Localpart of the bot user")
appservice_id: str = Field(default="steward", description="Unique appservice ID")
listen_host: str = Field(
default="0.0.0.0", description="Host the appservice HTTP server listens on"
)
listen_port: int = Field(default=8000, description="Port the appservice HTTP server listens on")
allowed_room_ids: list[str] = Field(default_factory=list, description="Allowed room IDs")
allowed_user_ids: list[str] = Field(default_factory=list, description="Allowed user MXIDs")
@field_validator("allowed_room_ids", "allowed_user_ids", mode="before")
@classmethod
def parse_str_list(cls, value: Any) -> Any:
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return value
class Settings(BaseModel):
"""Application settings with OmegaConf and pydantic integration."""
model_config = ConfigDict(arbitrary_types_allowed=True)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
openai: OpenAIConfig = Field(default_factory=OpenAIConfig)
analysis: AnalysisConfig = Field(default_factory=AnalysisConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig)
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
# Compatibility properties for existing code
@property
def telegram_bot_token(self) -> str:
"""Legacy property for backward compatibility."""
return self.telegram.bot_token
@property
def telegram_allowed_user_ids(self) -> list[int]:
"""Legacy property for backward compatibility."""
return self.telegram.allowed_user_ids
@property
def telegram_group_ids(self) -> list[int]:
"""Legacy property for backward compatibility."""
return self.telegram.group_ids
@property
def openai_api_key(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.api_key
@property
def openai_base_url(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.base_url
@property
def openai_model(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.model
@property
def openai_system_prompt(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.system_prompt
@property
def analysis_target_url(self) -> str:
"""Legacy property for backward compatibility."""
return self.analysis.target_url
@property
def analysis_target_api_key(self) -> str:
"""Legacy property for backward compatibility."""
return self.analysis.target_api_key
@property
def analysis_cron_hour(self) -> int:
"""Legacy property for backward compatibility."""
return self.analysis.cron_hour
@property
def analysis_cron_minute(self) -> int:
"""Legacy property for backward compatibility."""
return self.analysis.cron_minute
@property
def thread_memory_path(self) -> str:
"""Legacy property for backward compatibility."""
return self.memory.thread_memory_path
@property
def mcp_server_url(self) -> str:
"""Legacy property for backward compatibility."""
return self.tools.mcp_server_url
@property
def mcp_server_api_key(self) -> str:
"""Legacy property for backward compatibility."""
return self.tools.mcp_server_api_key
@property
def matrix_enabled(self) -> bool:
"""Return True if Matrix appservice integration is configured."""
return bool(self.matrix.homeserver_url and self.matrix.as_token and self.matrix.hs_token)
_settings_instance: Settings | None = None
def _load_config_from_file(config_path: str | Path) -> dict:
"""Load configuration from YAML file."""
config_path = Path(config_path)
if not config_path.exists():
logger.warning("Config file not found: %s", config_path)
return {}
logger.info("Loading config from %s", config_path)
cfg = OmegaConf.load(config_path)
return OmegaConf.to_container(cfg, resolve=True) or {}
def _load_config_from_env() -> dict:
"""Load configuration from environment variables (STEWARD__SECTION__KEY format)."""
cfg = {}
prefix = "STEWARD__"
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
# Parse STEWARD__SECTION__KEY=value
parts = key[len(prefix) :].lower().split("__")
if len(parts) < 2:
continue
section = parts[0]
setting_key = "__".join(parts[1:])
if section not in cfg:
cfg[section] = {}
# Try to parse value as JSON first (for lists, etc.)
if isinstance(cfg[section], dict):
try:
cfg[section][setting_key] = json.loads(value)
except (json.JSONDecodeError, ValueError):
cfg[section][setting_key] = value
return cfg
def get_settings() -> Settings:
"""Get or create the settings singleton.
Configuration is loaded in order of precedence (highest to lowest):
1. Environment variables (STEWARD__SECTION__KEY=value)
2. YAML config file (path via CONFIG_FILE env var)
3. Default values from schema
Returns:
Settings: The application settings.
"""
global _settings_instance
if _settings_instance is not None:
return _settings_instance
# Start with schema defaults
schema_path = Path(__file__).parent / "config_schema.yaml"
base_cfg = OmegaConf.load(schema_path)
# Merge in config file if specified
config_file = os.environ.get("CONFIG_FILE")
if config_file:
file_cfg = OmegaConf.create(_load_config_from_file(config_file))
base_cfg = OmegaConf.merge(base_cfg, file_cfg)
# Merge in environment variables (takes precedence)
env_cfg = OmegaConf.create(_load_config_from_env())
if env_cfg:
base_cfg = OmegaConf.merge(base_cfg, env_cfg)
# Convert to dict and create Settings instance
config_dict = OmegaConf.to_container(base_cfg, resolve=True) or {}
_settings_instance = Settings(**config_dict)
logger.info("Configuration loaded successfully")
logger.debug("Active configuration: %s", OmegaConf.to_yaml(base_cfg))
return _settings_instance
def reload_settings() -> Settings:
"""Reload configuration from source (mainly for testing)."""
global _settings_instance
_settings_instance = None
return get_settings()