Files
Auto-Trader/agent/src/channels/registry.py
T

326 lines
10 KiB
Python

"""Auto-discovery for built-in channel modules and external plugins."""
from __future__ import annotations
import importlib
import importlib.util
import logging
import pkgutil
from collections.abc import Mapping
from dataclasses import dataclass
from types import ModuleType
from typing import Any
from typing import TYPE_CHECKING
from src.config.schema import ChannelsConfig
if TYPE_CHECKING:
from src.channels.base import BaseChannel
logger = logging.getLogger(__name__)
_INTERNAL = frozenset(
{
"base",
"bus",
"config",
"config_meta",
"dingtalk_media",
"dingtalk_probe",
"email_probe",
"feishu_probe",
"manager",
"pairing",
"qq_probe",
"registry",
"rich_text",
"runtime",
"targets",
"token_probe",
"utils",
"websocket_probe",
}
)
_LEGACY_GLOBAL_CONFIG_KEYS = frozenset(
{"restrictToWorkspace", "restrict_to_workspace", "showReasoning", "show_reasoning"}
)
_GLOBAL_CONFIG_KEYS = (
frozenset(
key
for name, field in ChannelsConfig.model_fields.items()
for key in (name, field.alias)
if key
)
| _LEGACY_GLOBAL_CONFIG_KEYS
)
_INSTALL_HINTS: dict[str, str] = {
"dingtalk": "pip install 'vibe-trading-ai[dingtalk]'",
"discord": "pip install 'vibe-trading-ai[discord]'",
"email": "No extra Python package required; configure channels.email in the agent config.",
"feishu": "pip install 'vibe-trading-ai[feishu]'",
"matrix": "pip install 'vibe-trading-ai[matrix]'",
"mochat": "pip install 'vibe-trading-ai[mochat]'",
"msteams": "pip install 'vibe-trading-ai[msteams]'",
"napcat": "pip install 'vibe-trading-ai[napcat]'",
"qq": "pip install 'vibe-trading-ai[qq]'",
"signal": "No extra Python package required; install and run signal-cli-rest-api separately.",
"slack": "pip install 'vibe-trading-ai[slack]'",
"telegram": "pip install 'vibe-trading-ai[telegram]'",
"wecom": "pip install 'vibe-trading-ai[wecom]'",
"weixin": "No extra Python package required; configure channels.weixin in the agent config.",
"whatsapp": "pip install 'vibe-trading-ai[whatsapp]'",
"websocket": "pip install 'vibe-trading-ai[channels]'",
}
_AVAILABILITY_FLAGS: dict[str, tuple[str, ...]] = {
"dingtalk": ("DINGTALK_AVAILABLE",),
"discord": ("DISCORD_AVAILABLE",),
"feishu": ("FEISHU_AVAILABLE",),
"msteams": ("MSTEAMS_AVAILABLE",),
"qq": ("QQ_AVAILABLE",),
"wecom": ("WECOM_AVAILABLE",),
}
_LAZY_IMPORT_PACKAGES: dict[str, tuple[str, ...]] = {
"whatsapp": ("neonize",),
}
@dataclass(frozen=True)
class ChannelAvailability:
"""Import-time availability for a channel adapter."""
name: str
available: bool
display_name: str
error: str = ""
install_hint: str = ""
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable availability payload."""
return {
"name": self.name,
"available": self.available,
"display_name": self.display_name,
"error": self.error,
"install_hint": self.install_hint,
}
def discover_channel_names() -> list[str]:
"""Return all built-in channel module names by scanning the package (zero imports)."""
import src.channels as pkg
return [
name
for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
if name not in _INTERNAL and not ispkg
]
def _channel_class_from_module(
module: ModuleType, module_name: str
) -> type[BaseChannel]:
"""Return the first BaseChannel subclass defined in *module*."""
from src.channels.base import BaseChannel as _Base
for attr in dir(module):
obj = getattr(module, attr)
if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base:
return obj
raise ImportError(f"No BaseChannel subclass in src.channels.{module_name}")
def _missing_optional_dependency(name: str, module: ModuleType) -> str:
"""Return a dependency status error for lazily imported adapter SDKs."""
for flag in _AVAILABILITY_FLAGS.get(name, ()):
if getattr(module, flag, True) is False:
return f"missing optional dependency for {name}"
missing_packages = [
package
for package in _LAZY_IMPORT_PACKAGES.get(name, ())
if importlib.util.find_spec(package) is None
]
if missing_packages:
return "missing optional dependency: " + ", ".join(missing_packages)
return ""
def load_channel_class(module_name: str) -> type[BaseChannel]:
"""Import *module_name* and return the first BaseChannel subclass found."""
mod = importlib.import_module(f"src.channels.{module_name}")
return _channel_class_from_module(mod, module_name)
def inspect_channel(name: str) -> ChannelAvailability:
"""Inspect one channel module without raising dependency errors.
Args:
name: Built-in or plugin channel name.
Returns:
Availability metadata that surfaces install guidance for optional SDKs.
"""
try:
mod = importlib.import_module(f"src.channels.{name}")
cls = _channel_class_from_module(mod, name)
display = getattr(cls, "display_name", name)
missing = _missing_optional_dependency(name, mod)
if missing:
return ChannelAvailability(
name=name,
available=False,
display_name=str(display),
error=missing,
install_hint=_INSTALL_HINTS.get(
name, f"pip install 'vibe-trading-ai[{name}]'"
),
)
return ChannelAvailability(name=name, available=True, display_name=str(display))
except Exception as exc: # noqa: BLE001 - status API must report every adapter
return ChannelAvailability(
name=name,
available=False,
display_name=name.replace("_", " ").title(),
error=f"{type(exc).__name__}: {exc}",
install_hint=_INSTALL_HINTS.get(
name, f"pip install 'vibe-trading-ai[{name}]'"
),
)
def _section_enabled(section: Any) -> bool:
if section is None:
return False
if isinstance(section, Mapping):
return bool(section.get("enabled", False))
return bool(getattr(section, "enabled", False))
def _config_section(config: Any, name: str) -> Any:
if isinstance(config, Mapping):
return config.get(name)
return getattr(config, name, None)
def _configured_channel_names(config: Any) -> set[str]:
if isinstance(config, Mapping):
return {
str(key) for key in config.keys() if str(key) not in _GLOBAL_CONFIG_KEYS
}
model_dump = getattr(config, "model_dump", None)
if callable(model_dump):
return _configured_channel_names(model_dump(mode="json", by_alias=False))
return {
key
for key in dir(config)
if not key.startswith("_")
and key not in _GLOBAL_CONFIG_KEYS
and key not in {"model_config", "model_fields"}
and not callable(getattr(config, key, None))
}
def inspect_channels(config: Any | None = None) -> dict[str, dict[str, Any]]:
"""Inspect all built-in channels and annotate configured/enabled state.
Args:
config: Optional channels config map or model. Extra keys are included
so external plugin configs are visible in status output.
Returns:
Mapping from channel name to JSON-serializable status metadata.
"""
names = set(discover_channel_names())
if config is not None:
names.update(_configured_channel_names(config))
statuses: dict[str, dict[str, Any]] = {}
for name in sorted(names):
section = _config_section(config, name) if config is not None else None
availability = inspect_channel(name).to_dict()
availability.update(
{
"configured": section is not None,
"enabled": _section_enabled(section),
"loaded": False,
"running": False,
}
)
statuses[name] = availability
return statuses
def discover_plugins(
enabled_names: set[str] | None = None,
) -> dict[str, type[BaseChannel]]:
"""Discover external channel plugins registered via entry_points."""
from importlib.metadata import entry_points
plugins: dict[str, type[BaseChannel]] = {}
for ep in entry_points(group="vibe_trading.channels"):
if enabled_names is not None and ep.name not in enabled_names:
continue
try:
cls = ep.load()
plugins[ep.name] = cls
except Exception as exc:
logger.warning(
"Failed to load channel plugin '%s': %s", ep.name, exc, exc_info=True
)
return plugins
def discover_enabled(
enabled_names: set[str],
*,
_names: list[str] | None = None,
_include_all_external: bool = False,
) -> dict[str, type[BaseChannel]]:
"""Return channels whose module names are in *enabled_names*.
Uses cheap ``pkgutil.iter_modules`` to list names, then imports only
those that match — skipping the heavy third-party SDK imports of
unneeded channels.
"""
names = _names if _names is not None else discover_channel_names()
result: dict[str, type[BaseChannel]] = {}
for modname in names:
if modname not in enabled_names:
continue
try:
result[modname] = load_channel_class(modname)
except ImportError as exc:
logger.debug(
"Skipping built-in channel '%s': %s", modname, exc, exc_info=True
)
external = discover_plugins(None if _include_all_external else enabled_names)
shadowed = set(external) & set(result)
if shadowed:
logger.warning(
"Plugin(s) shadowed by built-in channels (ignored): %s", shadowed
)
if _include_all_external:
result.update({k: v for k, v in external.items() if k not in shadowed})
else:
result.update(
{
k: v
for k, v in external.items()
if k not in shadowed and k in enabled_names
}
)
return result
def discover_all() -> dict[str, type[BaseChannel]]:
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
Built-in channels take priority — an external plugin cannot shadow a built-in name.
"""
names = discover_channel_names()
return discover_enabled(set(names), _names=names, _include_all_external=True)