Files
EvoScientist-Multi/EvoScientist/channels/plugin.py
T
2026-03-19 17:04:02 +01:00

244 lines
7.4 KiB
Python

"""Plugin-based channel interface.
A ChannelPlugin is a declarative object with optional adapter slots.
The framework inspects which slots are filled and auto-assembles
the message processing pipeline.
The ``Channel`` base class extends ``ChannelPlugin``, so all channel
implementations are automatically ChannelPlugin instances.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
from .capabilities import ChannelCapabilities
# ── Channel metadata ─────────────────────────────────────────────────
@dataclass
class ChannelMeta:
"""Channel metadata for registry and UI."""
id: str
label: str
description: str = ""
docs_path: str = ""
system_image: str = "" # icon name
# ── Adapter Protocols (slots) ────────────────────────────────────────
@runtime_checkable
class ConfigAdapter(Protocol):
"""Account configuration management."""
def list_account_ids(self, config: Any) -> list[str]: ...
def resolve_account(self, config: Any, account_id: str | None = None) -> Any: ...
def is_enabled(self, account: Any, config: Any) -> bool: ...
def is_configured(self, account: Any, config: Any) -> bool: ...
@runtime_checkable
class SecurityAdapter(Protocol):
"""DM policy and security warnings."""
def resolve_dm_policy(
self, ctx: Any
) -> str: ... # "open" | "allowlist" | "pairing"
def collect_warnings(self, ctx: Any) -> list[str]: ...
@runtime_checkable
class GroupAdapter(Protocol):
"""Per-group policy resolution."""
def resolve_require_mention(self, ctx: Any) -> bool | None: ...
def resolve_tool_policy(self, ctx: Any) -> dict[str, Any] | None: ...
def resolve_intro_hint(self, ctx: Any) -> str | None: ...
@runtime_checkable
class MentionAdapter(Protocol):
"""Bot mention detection and stripping."""
def strip_mentions(self, text: str, ctx: Any) -> str: ...
@runtime_checkable
class OutboundAdapter(Protocol):
"""Outbound message delivery."""
delivery_mode: str # "direct" | "gateway" | "hybrid"
async def send_text(self, ctx: Any) -> bool: ...
async def send_media(self, ctx: Any) -> bool: ...
@runtime_checkable
class ThreadingAdapter(Protocol):
"""Reply threading behavior."""
def resolve_reply_to_mode(self, ctx: Any) -> str: ... # "off" | "first" | "all"
@runtime_checkable
class StreamingAdapter(Protocol):
"""Edit-in-place streaming output."""
async def edit_message(self, chat_id: str, message_id: str, text: str) -> bool: ...
@runtime_checkable
class DirectoryAdapter(Protocol):
"""Contact/group directory queries."""
async def list_peers(self, ctx: Any) -> list[dict]: ...
async def list_groups(self, ctx: Any) -> list[dict]: ...
async def list_group_members(self, ctx: Any) -> list[dict]: ...
@runtime_checkable
class StatusAdapter(Protocol):
"""Health probing and status reporting."""
async def probe_account(self, ctx: Any) -> Any: ...
async def audit_account(self, ctx: Any) -> Any: ...
def collect_status_issues(self, accounts: list) -> list[dict]: ...
@runtime_checkable
class HeartbeatAdapter(Protocol):
"""Channel heartbeat / readiness checks."""
async def check_ready(self, ctx: Any) -> tuple[bool, str]: ...
@runtime_checkable
class ActionsAdapter(Protocol):
"""Message actions (react, edit, delete, poll, etc.)."""
def list_actions(self) -> list[str]: ...
async def handle_action(self, action: str, ctx: Any) -> Any: ...
@runtime_checkable
class PairingAdapter(Protocol):
"""DM pairing flow."""
id_label: str
def normalize_entry(self, entry: str) -> str: ...
async def notify_approval(self, ctx: Any) -> None: ...
@runtime_checkable
class OnboardingAdapter(Protocol):
"""Interactive setup wizard hooks."""
async def wizard_steps(self, ctx: Any) -> list[dict]: ...
async def validate_step(self, step: str, value: Any) -> str | None: ...
# ── Reload policy ────────────────────────────────────────────────────
@dataclass
class ReloadPolicy:
"""Declares which config prefixes trigger a channel reload."""
config_prefixes: list[str] = field(default_factory=list)
noop_prefixes: list[str] = field(default_factory=list)
# ── ChannelPlugin ────────────────────────────────────────────────────
class ChannelPlugin:
"""Declarative channel plugin with optional adapter slots.
Replaces the monolithic Channel base class. Each slot is optional —
the framework adapts behavior based on which are present.
Usage::
class MyPlugin(ChannelPlugin):
id = "my_channel"
meta = ChannelMeta(id="my_channel", label="My Channel")
capabilities = ChannelCapabilities(...)
def __init__(self):
self.outbound = MyOutboundAdapter()
self.config_adapter = MyConfigAdapter()
async def start(self, config, account_id=None):
...
async def stop(self, account_id=None):
...
"""
id: str = ""
meta: ChannelMeta | None = None
capabilities: ChannelCapabilities = ChannelCapabilities()
# Optional adapter slots — fill what you need
# Default: SingleAccountConfigAdapter so every plugin has multi-account
# support out of the box (returns a single "default" account).
config_adapter: ConfigAdapter | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
def __init__(self) -> None:
# Provide default SingleAccountConfigAdapter if not overridden
if self.config_adapter is None:
from .config import SingleAccountConfigAdapter
self.config_adapter = SingleAccountConfigAdapter()
security: SecurityAdapter | None = None
groups: GroupAdapter | None = None
mentions: MentionAdapter | None = None
outbound: OutboundAdapter | None = None
threading: ThreadingAdapter | None = None
streaming: StreamingAdapter | None = None
directory: DirectoryAdapter | None = None
status: StatusAdapter | None = None
heartbeat: HeartbeatAdapter | None = None
actions: ActionsAdapter | None = None
pairing: PairingAdapter | None = None
onboarding: OnboardingAdapter | None = None
# Lifecycle
reload: ReloadPolicy | None = None
# Connection management
async def start(self, config: Any, account_id: str | None = None) -> None:
"""Start the channel (or a specific account)."""
async def stop(self, account_id: str | None = None) -> None:
"""Stop the channel (or a specific account)."""
def filled_slots(self) -> list[str]:
"""Return names of adapter slots that are not None."""
slot_names = [
"config_adapter",
"security",
"groups",
"mentions",
"outbound",
"threading",
"streaming",
"directory",
"status",
"heartbeat",
"actions",
"pairing",
"onboarding",
]
return [s for s in slot_names if getattr(self, s, None) is not None]