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>
315 lines
10 KiB
Python
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()
|