Files
2026-07-01 17:56:31 +08:00

204 lines
7.2 KiB
Python

"""Base channel interface for external messaging platforms."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any
from loguru import logger
from opc.channels.session import ChannelSessionMapping
from opc.core.models import SystemMessage, UserMessage
from opc.layer0_interaction.message_bus import MessageBus
class BaseChannel(ABC):
name: str = "base"
dependency_name: str | None = None
def __init__(self, config: Any, bus: MessageBus):
self.config = config
self.bus = bus
self._running = False
self._started_at: datetime | None = None
self._last_error: str = ""
self._status_reason: str = ""
self.last_outbound: dict[str, Any] | None = None
@property
def is_running(self) -> bool:
return self._running
@property
def last_error(self) -> str:
return self._last_error
def set_status_reason(self, reason: str = "") -> None:
self._status_reason = reason.strip()
def set_last_error(self, error: Exception | str | None) -> None:
self._last_error = str(error or "").strip()
if self._last_error:
logger.warning("{} runtime error: {}", self.name, self._last_error)
else:
logger.debug("{} runtime error cleared", self.name)
def mark_started(self) -> None:
self._running = True
self._started_at = datetime.now()
self.set_last_error("")
def mark_stopped(self) -> None:
self._running = False
def get_required_config_fields(self) -> list[str]:
return []
def get_missing_config_fields(self) -> list[str]:
missing: list[str] = []
for field_name in self.get_required_config_fields():
value = getattr(self.config, field_name, None)
if value is None:
missing.append(field_name)
elif isinstance(value, str) and not value.strip():
missing.append(field_name)
elif isinstance(value, list) and not value:
missing.append(field_name)
return missing
def is_configured(self) -> bool:
return not self.get_missing_config_fields()
def is_allowed(self, sender_id: str) -> bool:
allow_list = getattr(self.config, "allow_from", [])
if not allow_list:
logger.warning("{}: allow_from is empty — all access denied", self.name)
return False
if "*" in allow_list:
return True
sender = str(sender_id)
return sender in allow_list or any(part in allow_list for part in sender.split("|") if part)
def describe_capability(self) -> dict[str, Any]:
return {
"name": self.name,
"dependency": self.dependency_name,
"running": self.is_running,
"configured": self.is_configured(),
"missing_config": self.get_missing_config_fields(),
"last_error": self.last_error,
"status_reason": self._status_reason,
"started_at": self._started_at.isoformat() if self._started_at else "",
}
def should_accept_inbound(self, normalized: dict[str, Any]) -> bool:
sender_id = str(normalized.get("sender_id", "") or "")
return bool(sender_id) and self.is_allowed(sender_id)
def build_session_key_override(self, normalized: dict[str, Any]) -> str | None:
_ = normalized
return None
def build_inbound_metadata(self, normalized: dict[str, Any], attachments: list[Any]) -> dict[str, Any]:
metadata = dict(normalized.get("metadata", {}) or {})
metadata.setdefault("chat_id", str(normalized.get("chat_id", "") or ""))
metadata.setdefault("sender_id", str(normalized.get("sender_id", "") or ""))
metadata.setdefault("reply_to", str(normalized.get("reply_to", "") or ""))
metadata.setdefault("thread_id", str(normalized.get("thread_id", "") or ""))
metadata["attachments"] = list(attachments or [])
return metadata
def map_session(
self,
*,
chat_id: str,
thread_id: str | None = None,
reply_to: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, str]:
route = ChannelSessionMapping.derive(
channel=self.name,
chat_id=chat_id,
thread_id=thread_id,
reply_to=reply_to,
metadata=metadata,
)
return {
"session_id": route.session_id,
"chat_id": route.chat_id,
"thread_id": route.thread_id,
"reply_to": route.reply_to,
}
async def publish_inbound(
self,
*,
sender_id: str,
chat_id: str,
content: str,
attachments: list[Any] | None = None,
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
) -> None:
if not self.is_allowed(sender_id):
logger.warning("Access denied for sender {} on channel {}", sender_id, self.name)
return
meta = dict(metadata or {})
route = self.map_session(
chat_id=chat_id,
thread_id=str(meta.get("thread_id", "") or ""),
reply_to=str(meta.get("reply_to", "") or ""),
metadata=meta,
)
await self.bus.publish_inbound(
UserMessage(
channel=self.name,
user_id=str(sender_id),
content=content,
attachments=attachments or [],
session_id=session_key or route["session_id"],
metadata={
"chat_id": route["chat_id"],
"sender_id": str(sender_id),
"reply_to": route["reply_to"],
"thread_id": route["thread_id"],
"attachments": list(attachments or []),
**meta,
},
)
)
async def publish_normalized(self, normalized: dict[str, Any]) -> bool:
sender_id = str(normalized.get("sender_id", "") or "")
chat_id = str(normalized.get("chat_id", "") or "")
content = str(normalized.get("content", "") or "")
attachments = list(normalized.get("attachments", []) or [])
if not sender_id or not chat_id:
logger.debug("{} inbound payload missing sender/chat identifiers: {}", self.name, normalized)
return False
if not self.should_accept_inbound(normalized):
logger.debug("{} inbound payload rejected by policy: {}", self.name, normalized)
return False
await self.publish_inbound(
sender_id=sender_id,
chat_id=chat_id,
content=content,
attachments=attachments,
metadata=self.build_inbound_metadata(normalized, attachments),
session_key=self.build_session_key_override(normalized),
)
return True
@abstractmethod
async def start(self) -> None:
raise NotImplementedError
@abstractmethod
async def stop(self) -> None:
raise NotImplementedError
@abstractmethod
async def send(self, message: SystemMessage) -> None:
raise NotImplementedError