Authored by shitcoinsherpa. Adds encoding='utf-8' to all text-mode open() calls in gateway/run.py, gateway/config.py, hermes_cli/config.py, hermes_cli/main.py, and hermes_cli/status.py. Prevents encoding errors on Windows where the default locale is not UTF-8. Also fixed 4 additional open() calls in gateway/run.py that were added after the PR branch was created.
446 lines
16 KiB
Python
446 lines
16 KiB
Python
"""
|
|
Gateway configuration management.
|
|
|
|
Handles loading and validating configuration for:
|
|
- Connected platforms (Telegram, Discord, WhatsApp)
|
|
- Home channels for each platform
|
|
- Session reset policies
|
|
- Delivery preferences
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import json
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional, Any
|
|
from enum import Enum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Platform(Enum):
|
|
"""Supported messaging platforms."""
|
|
LOCAL = "local"
|
|
TELEGRAM = "telegram"
|
|
DISCORD = "discord"
|
|
WHATSAPP = "whatsapp"
|
|
SLACK = "slack"
|
|
SIGNAL = "signal"
|
|
HOMEASSISTANT = "homeassistant"
|
|
|
|
|
|
@dataclass
|
|
class HomeChannel:
|
|
"""
|
|
Default destination for a platform.
|
|
|
|
When a cron job specifies deliver="telegram" without a specific chat ID,
|
|
messages are sent to this home channel.
|
|
"""
|
|
platform: Platform
|
|
chat_id: str
|
|
name: str # Human-readable name for display
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"platform": self.platform.value,
|
|
"chat_id": self.chat_id,
|
|
"name": self.name,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
|
|
return cls(
|
|
platform=Platform(data["platform"]),
|
|
chat_id=str(data["chat_id"]),
|
|
name=data.get("name", "Home"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SessionResetPolicy:
|
|
"""
|
|
Controls when sessions reset (lose context).
|
|
|
|
Modes:
|
|
- "daily": Reset at a specific hour each day
|
|
- "idle": Reset after N minutes of inactivity
|
|
- "both": Whichever triggers first (daily boundary OR idle timeout)
|
|
- "none": Never auto-reset (context managed only by compression)
|
|
"""
|
|
mode: str = "both" # "daily", "idle", "both", or "none"
|
|
at_hour: int = 4 # Hour for daily reset (0-23, local time)
|
|
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"mode": self.mode,
|
|
"at_hour": self.at_hour,
|
|
"idle_minutes": self.idle_minutes,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
|
|
return cls(
|
|
mode=data.get("mode", "both"),
|
|
at_hour=data.get("at_hour", 4),
|
|
idle_minutes=data.get("idle_minutes", 1440),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PlatformConfig:
|
|
"""Configuration for a single messaging platform."""
|
|
enabled: bool = False
|
|
token: Optional[str] = None # Bot token (Telegram, Discord)
|
|
api_key: Optional[str] = None # API key if different from token
|
|
home_channel: Optional[HomeChannel] = None
|
|
|
|
# Platform-specific settings
|
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
result = {
|
|
"enabled": self.enabled,
|
|
"extra": self.extra,
|
|
}
|
|
if self.token:
|
|
result["token"] = self.token
|
|
if self.api_key:
|
|
result["api_key"] = self.api_key
|
|
if self.home_channel:
|
|
result["home_channel"] = self.home_channel.to_dict()
|
|
return result
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
|
|
home_channel = None
|
|
if "home_channel" in data:
|
|
home_channel = HomeChannel.from_dict(data["home_channel"])
|
|
|
|
return cls(
|
|
enabled=data.get("enabled", False),
|
|
token=data.get("token"),
|
|
api_key=data.get("api_key"),
|
|
home_channel=home_channel,
|
|
extra=data.get("extra", {}),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class GatewayConfig:
|
|
"""
|
|
Main gateway configuration.
|
|
|
|
Manages all platform connections, session policies, and delivery settings.
|
|
"""
|
|
# Platform configurations
|
|
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
|
|
|
|
# Session reset policies by type
|
|
default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
|
|
reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict)
|
|
reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict)
|
|
|
|
# Reset trigger commands
|
|
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
|
|
|
|
# Storage paths
|
|
sessions_dir: Path = field(default_factory=lambda: Path.home() / ".hermes" / "sessions")
|
|
|
|
# Delivery settings
|
|
always_log_local: bool = True # Always save cron outputs to local files
|
|
|
|
def get_connected_platforms(self) -> List[Platform]:
|
|
"""Return list of platforms that are enabled and configured."""
|
|
connected = []
|
|
for platform, config in self.platforms.items():
|
|
if not config.enabled:
|
|
continue
|
|
# Platforms that use token/api_key auth
|
|
if config.token or config.api_key:
|
|
connected.append(platform)
|
|
# WhatsApp uses enabled flag only (bridge handles auth)
|
|
elif platform == Platform.WHATSAPP:
|
|
connected.append(platform)
|
|
# Signal uses extra dict for config (http_url + account)
|
|
elif platform == Platform.SIGNAL and config.extra.get("http_url"):
|
|
connected.append(platform)
|
|
return connected
|
|
|
|
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
|
"""Get the home channel for a platform."""
|
|
config = self.platforms.get(platform)
|
|
if config:
|
|
return config.home_channel
|
|
return None
|
|
|
|
def get_reset_policy(
|
|
self,
|
|
platform: Optional[Platform] = None,
|
|
session_type: Optional[str] = None
|
|
) -> SessionResetPolicy:
|
|
"""
|
|
Get the appropriate reset policy for a session.
|
|
|
|
Priority: platform override > type override > default
|
|
"""
|
|
# Platform-specific override takes precedence
|
|
if platform and platform in self.reset_by_platform:
|
|
return self.reset_by_platform[platform]
|
|
|
|
# Type-specific override (dm, group, thread)
|
|
if session_type and session_type in self.reset_by_type:
|
|
return self.reset_by_type[session_type]
|
|
|
|
return self.default_reset_policy
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"platforms": {
|
|
p.value: c.to_dict() for p, c in self.platforms.items()
|
|
},
|
|
"default_reset_policy": self.default_reset_policy.to_dict(),
|
|
"reset_by_type": {
|
|
k: v.to_dict() for k, v in self.reset_by_type.items()
|
|
},
|
|
"reset_by_platform": {
|
|
p.value: v.to_dict() for p, v in self.reset_by_platform.items()
|
|
},
|
|
"reset_triggers": self.reset_triggers,
|
|
"sessions_dir": str(self.sessions_dir),
|
|
"always_log_local": self.always_log_local,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
|
|
platforms = {}
|
|
for platform_name, platform_data in data.get("platforms", {}).items():
|
|
try:
|
|
platform = Platform(platform_name)
|
|
platforms[platform] = PlatformConfig.from_dict(platform_data)
|
|
except ValueError:
|
|
pass # Skip unknown platforms
|
|
|
|
reset_by_type = {}
|
|
for type_name, policy_data in data.get("reset_by_type", {}).items():
|
|
reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data)
|
|
|
|
reset_by_platform = {}
|
|
for platform_name, policy_data in data.get("reset_by_platform", {}).items():
|
|
try:
|
|
platform = Platform(platform_name)
|
|
reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data)
|
|
except ValueError:
|
|
pass
|
|
|
|
default_policy = SessionResetPolicy()
|
|
if "default_reset_policy" in data:
|
|
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
|
|
|
|
sessions_dir = Path.home() / ".hermes" / "sessions"
|
|
if "sessions_dir" in data:
|
|
sessions_dir = Path(data["sessions_dir"])
|
|
|
|
return cls(
|
|
platforms=platforms,
|
|
default_reset_policy=default_policy,
|
|
reset_by_type=reset_by_type,
|
|
reset_by_platform=reset_by_platform,
|
|
reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
|
|
sessions_dir=sessions_dir,
|
|
always_log_local=data.get("always_log_local", True),
|
|
)
|
|
|
|
|
|
def load_gateway_config() -> GatewayConfig:
|
|
"""
|
|
Load gateway configuration from multiple sources.
|
|
|
|
Priority (highest to lowest):
|
|
1. Environment variables
|
|
2. ~/.hermes/gateway.json
|
|
3. cli-config.yaml gateway section
|
|
4. Defaults
|
|
"""
|
|
config = GatewayConfig()
|
|
|
|
# Try loading from ~/.hermes/gateway.json
|
|
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
|
if gateway_config_path.exists():
|
|
try:
|
|
with open(gateway_config_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
config = GatewayConfig.from_dict(data)
|
|
except Exception as e:
|
|
print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
|
|
|
|
# Bridge session_reset from config.yaml (the user-facing config file)
|
|
# into the gateway config. config.yaml takes precedence over gateway.json
|
|
# for session reset policy since that's where hermes setup writes it.
|
|
try:
|
|
import yaml
|
|
config_yaml_path = Path.home() / ".hermes" / "config.yaml"
|
|
if config_yaml_path.exists():
|
|
with open(config_yaml_path, encoding="utf-8") as f:
|
|
yaml_cfg = yaml.safe_load(f) or {}
|
|
sr = yaml_cfg.get("session_reset")
|
|
if sr and isinstance(sr, dict):
|
|
config.default_reset_policy = SessionResetPolicy.from_dict(sr)
|
|
except Exception:
|
|
pass
|
|
|
|
# Override with environment variables
|
|
_apply_env_overrides(config)
|
|
|
|
# --- Validate loaded values ---
|
|
policy = config.default_reset_policy
|
|
|
|
if not (0 <= policy.at_hour <= 23):
|
|
logger.warning(
|
|
"Invalid at_hour=%s (must be 0-23). Using default 4.", policy.at_hour
|
|
)
|
|
policy.at_hour = 4
|
|
|
|
if policy.idle_minutes is None or policy.idle_minutes <= 0:
|
|
logger.warning(
|
|
"Invalid idle_minutes=%s (must be positive). Using default 1440.",
|
|
policy.idle_minutes,
|
|
)
|
|
policy.idle_minutes = 1440
|
|
|
|
# Warn about empty bot tokens — platforms that loaded an empty string
|
|
# won't connect and the cause can be confusing without a log line.
|
|
_token_env_names = {
|
|
Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN",
|
|
Platform.DISCORD: "DISCORD_BOT_TOKEN",
|
|
Platform.SLACK: "SLACK_BOT_TOKEN",
|
|
}
|
|
for platform, pconfig in config.platforms.items():
|
|
if not pconfig.enabled:
|
|
continue
|
|
env_name = _token_env_names.get(platform)
|
|
if env_name and pconfig.token is not None and not pconfig.token.strip():
|
|
logger.warning(
|
|
"%s is enabled but %s is empty. "
|
|
"The adapter will likely fail to connect.",
|
|
platform.value, env_name,
|
|
)
|
|
|
|
return config
|
|
|
|
|
|
def _apply_env_overrides(config: GatewayConfig) -> None:
|
|
"""Apply environment variable overrides to config."""
|
|
|
|
# Telegram
|
|
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
|
if telegram_token:
|
|
if Platform.TELEGRAM not in config.platforms:
|
|
config.platforms[Platform.TELEGRAM] = PlatformConfig()
|
|
config.platforms[Platform.TELEGRAM].enabled = True
|
|
config.platforms[Platform.TELEGRAM].token = telegram_token
|
|
|
|
telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL")
|
|
if telegram_home and Platform.TELEGRAM in config.platforms:
|
|
config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id=telegram_home,
|
|
name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"),
|
|
)
|
|
|
|
# Discord
|
|
discord_token = os.getenv("DISCORD_BOT_TOKEN")
|
|
if discord_token:
|
|
if Platform.DISCORD not in config.platforms:
|
|
config.platforms[Platform.DISCORD] = PlatformConfig()
|
|
config.platforms[Platform.DISCORD].enabled = True
|
|
config.platforms[Platform.DISCORD].token = discord_token
|
|
|
|
discord_home = os.getenv("DISCORD_HOME_CHANNEL")
|
|
if discord_home and Platform.DISCORD in config.platforms:
|
|
config.platforms[Platform.DISCORD].home_channel = HomeChannel(
|
|
platform=Platform.DISCORD,
|
|
chat_id=discord_home,
|
|
name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"),
|
|
)
|
|
|
|
# WhatsApp (typically uses different auth mechanism)
|
|
whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes")
|
|
if whatsapp_enabled:
|
|
if Platform.WHATSAPP not in config.platforms:
|
|
config.platforms[Platform.WHATSAPP] = PlatformConfig()
|
|
config.platforms[Platform.WHATSAPP].enabled = True
|
|
|
|
# Slack
|
|
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
|
if slack_token:
|
|
if Platform.SLACK not in config.platforms:
|
|
config.platforms[Platform.SLACK] = PlatformConfig()
|
|
config.platforms[Platform.SLACK].enabled = True
|
|
config.platforms[Platform.SLACK].token = slack_token
|
|
# Home channel
|
|
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
|
if slack_home:
|
|
config.platforms[Platform.SLACK].home_channel = HomeChannel(
|
|
platform=Platform.SLACK,
|
|
chat_id=slack_home,
|
|
name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""),
|
|
)
|
|
|
|
# Signal
|
|
signal_url = os.getenv("SIGNAL_HTTP_URL")
|
|
signal_account = os.getenv("SIGNAL_ACCOUNT")
|
|
if signal_url and signal_account:
|
|
if Platform.SIGNAL not in config.platforms:
|
|
config.platforms[Platform.SIGNAL] = PlatformConfig()
|
|
config.platforms[Platform.SIGNAL].enabled = True
|
|
config.platforms[Platform.SIGNAL].extra.update({
|
|
"http_url": signal_url,
|
|
"account": signal_account,
|
|
"ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"),
|
|
})
|
|
signal_home = os.getenv("SIGNAL_HOME_CHANNEL")
|
|
if signal_home:
|
|
config.platforms[Platform.SIGNAL].home_channel = HomeChannel(
|
|
platform=Platform.SIGNAL,
|
|
chat_id=signal_home,
|
|
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
|
|
)
|
|
|
|
# Home Assistant
|
|
hass_token = os.getenv("HASS_TOKEN")
|
|
if hass_token:
|
|
if Platform.HOMEASSISTANT not in config.platforms:
|
|
config.platforms[Platform.HOMEASSISTANT] = PlatformConfig()
|
|
config.platforms[Platform.HOMEASSISTANT].enabled = True
|
|
config.platforms[Platform.HOMEASSISTANT].token = hass_token
|
|
hass_url = os.getenv("HASS_URL")
|
|
if hass_url:
|
|
config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url
|
|
|
|
# Session settings
|
|
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
|
if idle_minutes:
|
|
try:
|
|
config.default_reset_policy.idle_minutes = int(idle_minutes)
|
|
except ValueError:
|
|
pass
|
|
|
|
reset_hour = os.getenv("SESSION_RESET_HOUR")
|
|
if reset_hour:
|
|
try:
|
|
config.default_reset_policy.at_hour = int(reset_hour)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
def save_gateway_config(config: GatewayConfig) -> None:
|
|
"""Save gateway configuration to ~/.hermes/gateway.json."""
|
|
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
|
gateway_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(gateway_config_path, "w", encoding="utf-8") as f:
|
|
json.dump(config.to_dict(), f, indent=2)
|