feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

View File

@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com"
ARG NPM_FETCH_RETRIES="5"
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
ARG APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/debian"
ARG PYPI_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
RUN apt-get update && \
RUN find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) -exec \
sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" {} + && \
apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
@ -63,7 +67,7 @@ WORKDIR /opt/app/backend
COPY backend/pyproject.toml backend/README.md ./
COPY backend/beaver/ ./beaver/
RUN uv pip install --system --no-cache .
RUN uv pip install --system --no-cache --index-url "${PYPI_INDEX_URL}" ".[channels]"
WORKDIR /opt/app/frontend
COPY --from=frontend-builder /build/frontend/next.config.js ./

View File

@ -76,7 +76,12 @@ class SessionContext:
model: str | None = None
user_id: str | None = None
channel: str | None = None
channel_kind: str | None = None
account_id: str | None = None
peer_id: str | None = None
peer_type: str | None = None
chat_id: str | None = None
thread_id: str | None = None
parent_session_id: str | None = None
@ -354,8 +359,18 @@ class ContextBuilder:
rows.append(f"User ID: {session_context.user_id}")
if session_context.channel:
rows.append(f"Channel: {session_context.channel}")
if session_context.channel_kind:
rows.append(f"Channel Kind: {session_context.channel_kind}")
if session_context.account_id:
rows.append(f"Account ID: {session_context.account_id}")
if session_context.peer_id:
rows.append(f"Peer ID: {session_context.peer_id}")
if session_context.peer_type:
rows.append(f"Peer Type: {session_context.peer_type}")
if session_context.chat_id:
rows.append(f"Chat ID: {session_context.chat_id}")
if session_context.thread_id:
rows.append(f"Thread ID: {session_context.thread_id}")
if session_context.parent_session_id:
rows.append(f"Parent Session ID: {session_context.parent_session_id}")

View File

@ -13,6 +13,7 @@ from uuid import uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
from beaver.foundation.events import ChannelIdentity
from beaver.memory.runs import RunRecord, SkillEffectRecord
from beaver.skills.learning import RunReceiptContext
from beaver.skills.catalog.utils import strip_frontmatter
@ -248,6 +249,7 @@ class AgentLoop:
pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult:
"""跑通最小 direct run 主链。
@ -297,6 +299,7 @@ class AgentLoop:
pinned_skill_contexts=pinned_skill_contexts,
allow_candidate_generation=allow_candidate_generation,
intent_agent_decision=intent_agent_decision,
channel_identity=channel_identity,
)
async def _process_direct_impl(
@ -334,6 +337,7 @@ class AgentLoop:
pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。
@ -576,6 +580,13 @@ class AgentLoop:
source=source,
model=resolved_model,
user_id=user_id,
channel=channel_identity.channel_id if channel_identity else None,
channel_kind=channel_identity.kind if channel_identity else None,
account_id=channel_identity.account_id if channel_identity else None,
peer_id=channel_identity.peer_id if channel_identity else None,
peer_type=channel_identity.peer_type if channel_identity else None,
chat_id=channel_identity.peer_id if channel_identity else None,
thread_id=channel_identity.thread_id if channel_identity else None,
parent_session_id=parent_session_id,
),
runtime_context=self._current_runtime_context(),

View File

@ -13,6 +13,7 @@ from .schema import (
AuthzConfig,
BackendIdentityConfig,
BeaverConfig,
ChannelConfig,
EmbeddingConfig,
MCPServerConfig,
ProviderConfig,
@ -73,6 +74,7 @@ def load_config(
embedding=_parse_embedding(data),
tools=_parse_tools(data.get("tools")),
authz=_parse_authz(data.get("authz")),
channels=_parse_channels(data.get("channels")),
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
config_path=path,
)
@ -196,6 +198,48 @@ def _parse_authz(raw: Any) -> AuthzConfig:
)
def _parse_channels(raw: Any) -> dict[str, ChannelConfig]:
channels: dict[str, ChannelConfig] = {}
for channel_id, payload in _as_dict(raw).items():
cleaned_id = str(channel_id).strip()
if not cleaned_id:
continue
channels[cleaned_id] = _parse_channel_config(payload)
return channels
def _parse_channel_config(payload: Any) -> ChannelConfig:
data = _as_dict(payload)
return ChannelConfig(
enabled=_bool(data.get("enabled"), default=False),
kind=_string(data.get("kind")) or "",
mode=_string(data.get("mode")) or "webhook",
account_id=_string(data.get("accountId") or data.get("account_id")) or "",
display_name=_string(data.get("displayName") or data.get("display_name")) or "",
config=_normalize_config_map(data.get("config")),
secrets=_string_dict(data.get("secrets")),
)
def _normalize_config_map(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
return {
_camel_to_snake_key(str(key)): item
for key, item in value.items()
if str(key).strip()
}
def _camel_to_snake_key(value: str) -> str:
result: list[str] = []
for char in value:
if char.isupper() and result:
result.append("_")
result.append(char.lower())
return "".join(result)
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
data = _as_dict(raw)
return BackendIdentityConfig(

View File

@ -91,6 +91,19 @@ class AuthzConfig:
outlook_mcp_url: str = ""
@dataclass(slots=True)
class ChannelConfig:
"""One configured channel adapter instance."""
enabled: bool = False
kind: str = ""
mode: str = "webhook"
account_id: str = ""
display_name: str = ""
config: dict[str, Any] = field(default_factory=dict)
secrets: dict[str, str] = field(default_factory=dict)
@dataclass(slots=True)
class BackendIdentityConfig:
"""This backend's AuthZ client identity."""
@ -111,6 +124,7 @@ class BeaverConfig:
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig)
authz: AuthzConfig = field(default_factory=AuthzConfig)
channels: dict[str, ChannelConfig] = field(default_factory=dict)
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
config_path: Path | None = None

View File

@ -1,5 +1,5 @@
"""Event contracts and dispatch helpers."""
from .message_bus import InboundMessage, MessageBus, OutboundMessage
from .message_bus import ChannelIdentity, InboundMessage, MessageBus, OutboundMessage
__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"]
__all__ = ["ChannelIdentity", "InboundMessage", "MessageBus", "OutboundMessage"]

View File

@ -9,12 +9,58 @@ from typing import Any
from uuid import uuid4
@dataclass(slots=True)
class ChannelIdentity:
"""Normalized channel routing identity.
`channel_id` is the Beaver adapter instance id, not the platform kind.
"""
channel_id: str
kind: str
account_id: str
peer_id: str
thread_id: str | None = None
peer_type: str = "unknown"
user_id: str | None = None
message_id: str | None = None
def validation_error(self) -> str | None:
if not self.channel_id.strip():
return "channel_id is required"
if not self.account_id.strip():
return "account_id is required"
if not self.peer_id.strip():
return "peer_id is required"
return None
def session_id(self) -> str:
parts = [self.channel_id, self.account_id, self.peer_id]
if self.thread_id:
parts.append(self.thread_id)
return ":".join(_clean_session_part(part) for part in parts)
def dedupe_key(self) -> str | None:
if not self.message_id:
return None
return f"{self.session_id()}:{_clean_session_part(self.message_id)}"
def _clean_session_part(value: str) -> str:
cleaned = str(value).strip()
if not cleaned:
return "unknown"
return cleaned.replace(":", "_")
@dataclass(slots=True)
class InboundMessage:
"""A minimal inbound message accepted by the gateway bridge."""
channel: str
content: str
content_type: str = "text"
channel_identity: ChannelIdentity | None = None
session_id: str | None = None
user_id: str | None = None
title: str | None = None
@ -35,6 +81,8 @@ class OutboundMessage:
content: str
session_id: str | None
finish_reason: str
content_type: str = "text"
channel_identity: ChannelIdentity | None = None
message_id: str = field(default_factory=lambda: str(uuid4()))
run_id: str | None = None
provider_name: str | None = None

View File

@ -1,7 +1,17 @@
"""Channel interfaces."""
from .base import ChannelAdapter
from .base import ChannelInboundSink
from .external_connector import ExternalConnectorChannel
from .manager import ChannelManager
from .memory import MemoryChannelAdapter
from .terminal_websocket import TerminalWebSocketAdapter
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
__all__ = [
"ChannelAdapter",
"ChannelInboundSink",
"ExternalConnectorChannel",
"ChannelManager",
"MemoryChannelAdapter",
"TerminalWebSocketAdapter",
]

View File

@ -2,16 +2,17 @@
from __future__ import annotations
from typing import Protocol
from typing import Any, Protocol
from beaver.foundation.events import MessageBus, OutboundMessage
from beaver.foundation.events import InboundMessage, OutboundMessage
class ChannelAdapter(Protocol):
"""Minimal contract every gateway channel must implement."""
"""Minimal contract every runtime channel adapter must implement."""
name: str
bus: MessageBus
channel_id: str
kind: str
mode: str
async def start(self) -> None:
"""Prepare the channel before messages are routed."""
@ -22,3 +23,9 @@ class ChannelAdapter(Protocol):
async def send(self, message: OutboundMessage) -> None:
"""Deliver an outbound message to the concrete channel."""
class ChannelInboundSink(Protocol):
"""Runtime callback used by adapters to submit normalized inbound messages."""
async def accept_inbound(self, message: InboundMessage) -> Any:
"""Accept a normalized inbound message from an adapter."""

View File

@ -0,0 +1,29 @@
"""Channel connection setup layer."""
from .connectors import ChannelConnector, ChannelConnectorRegistry
from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore
from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
from .sidecar_client import ConnectorSidecarClient
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
from .telegram import TelegramConnector
__all__ = [
"ChannelConnector",
"ChannelConnectorRegistry",
"ConnectorMessageDedupeRecord",
"DedupeBeginResult",
"MessageDedupeStore",
"ExternalConnectorBase",
"FeishuConnector",
"WeixinConnector",
"ConnectorSidecarClient",
"ChannelConnection",
"ChannelRuntimeSpec",
"PairingSession",
"ValidationResult",
"ChannelConnectionStore",
"CredentialStore",
"PairingTokenStore",
"TelegramConnector",
]

View File

@ -0,0 +1,93 @@
"""Channel connector registry."""
from __future__ import annotations
from typing import Protocol
from beaver.foundation.config.schema import ChannelConfig
from .models import ChannelRuntimeSpec, ValidationResult
from .store import ChannelConnectionStore, CredentialStore
class ChannelConnector(Protocol):
kind: str
async def validate(self, connection_id: str) -> ValidationResult:
...
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
...
async def revoke(self, connection_id: str) -> None:
...
class ChannelConnectorRegistry:
def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None:
self.connection_store = connection_store
self.credential_store = credential_store
self._connectors: dict[str, ChannelConnector] = {}
def register(self, connector: ChannelConnector) -> None:
kind = connector.kind.strip()
if not kind:
raise ValueError("Connector kind is required")
if kind in self._connectors:
raise ValueError(f"Connector already registered: {kind}")
self._connectors[kind] = connector
def connectors(self) -> list[dict[str, str]]:
return [{"kind": kind} for kind in sorted(self._connectors)]
def connector_for_kind(self, kind: str) -> ChannelConnector:
return self._connector(kind)
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
connector = self._connector(connection.kind)
result = await connector.validate(connection_id)
self.connection_store.update_status(
connection_id,
status=result.status,
last_error=result.error,
)
return result
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
connection = self.connection_store.get(connection_id)
return await self._connector(connection.kind).materialize_runtime(connection_id)
async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]:
specs: list[ChannelRuntimeSpec] = []
for connection in self.connection_store.list():
if connection.status not in {"connected", "running"}:
continue
specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id))
return specs
async def materialize_channel_configs(self) -> dict[str, ChannelConfig]:
channels: dict[str, ChannelConfig] = {}
for spec in await self.materialize_connected_runtime_specs():
secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {}
channels[spec.channel_id] = ChannelConfig(
enabled=True,
kind=spec.kind,
mode=spec.mode,
account_id=spec.account_id,
display_name=spec.display_name,
config=dict(spec.config),
secrets=secrets,
)
return channels
async def revoke(self, connection_id: str) -> None:
connection = self.connection_store.get(connection_id)
await self._connector(connection.kind).revoke(connection_id)
self.connection_store.revoke(connection_id)
def _connector(self, kind: str) -> ChannelConnector:
connector = self._connectors.get(kind)
if connector is None:
raise KeyError(f"Connector not registered: {kind}")
return connector

View File

@ -0,0 +1,144 @@
"""Bridge event dedupe store for external connector retries."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@dataclass(slots=True)
class ConnectorMessageDedupeRecord:
dedupe_key: str
connection_id: str
event_id: str
status: str
first_seen_at: str
updated_at: str
delivery_attempts: int
message_id: str | None = None
last_error: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord":
return cls(
dedupe_key=str(data.get("dedupe_key") or ""),
connection_id=str(data.get("connection_id") or ""),
event_id=str(data.get("event_id") or ""),
status=str(data.get("status") or "processing"),
first_seen_at=str(data.get("first_seen_at") or _iso_now()),
updated_at=str(data.get("updated_at") or _iso_now()),
delivery_attempts=int(data.get("delivery_attempts") or 0),
message_id=str(data["message_id"]) if data.get("message_id") is not None else None,
last_error=str(data["last_error"]) if data.get("last_error") is not None else None,
)
@dataclass(slots=True)
class DedupeBeginResult:
should_process: bool
dedupe_key: str
status: str
http_status: int
retry_after_seconds: int | None
record: ConnectorMessageDedupeRecord
class MessageDedupeStore:
def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None:
self.path = Path(path)
self.processing_ttl_seconds = int(processing_ttl_seconds)
self._lock = Lock()
def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult:
dedupe_key = f"{connection_id}:{event_id}"
now = _iso_now()
with self._lock:
data = self._load()
raw = data["records"].get(dedupe_key)
if isinstance(raw, dict):
record = ConnectorMessageDedupeRecord.from_dict(raw)
if record.status == "completed":
return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record)
if record.status == "processing" and not self._is_stale(record, now):
return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record)
record.status = "processing"
record.updated_at = now
record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt))
record.last_error = None
else:
record = ConnectorMessageDedupeRecord(
dedupe_key=dedupe_key,
connection_id=connection_id,
event_id=event_id,
status="processing",
first_seen_at=now,
updated_at=now,
delivery_attempts=max(1, int(delivery_attempt)),
)
data["records"][dedupe_key] = record.to_dict()
self._save(data)
return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record)
def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord:
return self._mark(dedupe_key, status="completed", message_id=message_id, error=None)
def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord:
return self._mark(dedupe_key, status="failed", message_id=None, error=error)
def _mark(
self,
dedupe_key: str,
*,
status: str,
message_id: str | None,
error: str | None,
) -> ConnectorMessageDedupeRecord:
with self._lock:
data = self._load()
raw = data["records"].get(dedupe_key)
if not isinstance(raw, dict):
raise KeyError(dedupe_key)
record = ConnectorMessageDedupeRecord.from_dict(raw)
record.status = status
record.updated_at = _iso_now()
record.message_id = message_id or record.message_id
record.last_error = error
data["records"][dedupe_key] = record.to_dict()
self._save(data)
return record
def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool:
age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds()
return age >= self.processing_ttl_seconds
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"records": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"records": {}}
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
return {"records": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)

View File

@ -0,0 +1,131 @@
"""Sidecar-backed channel connectors."""
from __future__ import annotations
from typing import Any
from .models import ChannelRuntimeSpec, ValidationResult
from .sidecar_client import ConnectorSidecarClient
from .store import ChannelConnectionStore, CredentialStore
class ExternalConnectorBase:
kind = ""
capabilities: list[str] = []
def __init__(
self,
*,
connection_store: ChannelConnectionStore,
credential_store: CredentialStore,
sidecar_client: ConnectorSidecarClient | Any,
sidecar_base_url: str,
) -> None:
self.connection_store = connection_store
self.credential_store = credential_store
self.sidecar_client = sidecar_client
self.sidecar_base_url = sidecar_base_url
async def start_session(
self,
*,
display_name: str,
owner_user_id: str | None,
options: dict[str, Any],
) -> dict[str, Any]:
connection = self.connection_store.create(
kind=self.kind,
mode="sidecar",
display_name=display_name or self.kind,
account_id="",
owner_user_id=owner_user_id,
auth_type="connector_session",
runtime_config={"sidecarBaseUrl": self.sidecar_base_url},
capabilities=list(self.capabilities),
)
connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
payload = {
"kind": self.kind,
"connectionId": connection.connection_id,
"channelId": connection.channel_id,
"displayName": connection.display_name,
"callbackBaseUrl": "",
"options": dict(options),
}
view = dict(await self.sidecar_client.start_session(payload))
connection.pairing_session_id = str(view.get("sessionId") or "")
self.connection_store.update(connection)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
async def poll_session(self, session_id: str) -> dict[str, Any]:
view = dict(await self.sidecar_client.get_session(session_id))
connection = self._connection_for_session(session_id)
status = str(view.get("status") or "")
if status == "connected":
connection.account_id = str(view.get("accountId") or connection.account_id)
connection.display_name = str(view.get("displayName") or connection.display_name)
metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {}
state_ref = metadata.get("stateRef")
if state_ref:
connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref})
self.connection_store.update(connection)
self.connection_store.update_status(connection.connection_id, status="connected", last_error=None)
elif status in {"expired", "error", "cancelled"}:
self.connection_store.update_status(
connection.connection_id,
status="error",
last_error=str(view.get("error") or status),
)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
if connection.status in {"connected", "running"}:
return ValidationResult(
ok=True,
status="connected",
account_id=connection.account_id,
display_name=connection.display_name,
)
return ValidationResult(ok=False, status=connection.status, error=connection.last_error)
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
connection = self.connection_store.get(connection_id)
if connection.status not in {"connected", "running"}:
raise ValueError(f"Connection is not connected: {connection.connection_id}")
return ChannelRuntimeSpec(
channel_id=connection.channel_id,
kind="external_connector",
mode="http",
account_id=connection.account_id,
display_name=connection.display_name,
config={
"platformKind": self.kind,
"connectionId": connection.connection_id,
"sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
},
secrets_ref=None,
)
async def revoke(self, connection_id: str) -> None:
await self.sidecar_client.logout(connection_id)
def _connection_for_session(self, session_id: str):
for connection in self.connection_store.list():
if connection.pairing_session_id == session_id:
return connection
raise KeyError(session_id)
class WeixinConnector(ExternalConnectorBase):
kind = "weixin"
capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"]
class FeishuConnector(ExternalConnectorBase):
kind = "feishu"
capabilities = ["receive_text", "send_text", "receive_media", "groups"]

View File

@ -0,0 +1,117 @@
"""Channel connection setup models."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"}
def iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(slots=True)
class ChannelConnection:
connection_id: str
owner_user_id: str | None
channel_id: str
kind: str
mode: str
display_name: str
account_id: str
status: str
auth_type: str
credentials_ref: str | None = None
connector_ref: str | None = None
pairing_session_id: str | None = None
runtime_config: dict[str, Any] = field(default_factory=dict)
capabilities: list[str] = field(default_factory=list)
created_at: str = field(default_factory=iso_now)
updated_at: str = field(default_factory=iso_now)
last_seen_at: str | None = None
last_error: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ChannelConnection":
return cls(
connection_id=str(data.get("connection_id") or ""),
owner_user_id=_optional_string(data.get("owner_user_id")),
channel_id=str(data.get("channel_id") or ""),
kind=str(data.get("kind") or ""),
mode=str(data.get("mode") or ""),
display_name=str(data.get("display_name") or ""),
account_id=str(data.get("account_id") or ""),
status=str(data.get("status") or "draft"),
auth_type=str(data.get("auth_type") or ""),
credentials_ref=_optional_string(data.get("credentials_ref")),
connector_ref=_optional_string(data.get("connector_ref")),
pairing_session_id=_optional_string(data.get("pairing_session_id")),
runtime_config=dict(data.get("runtime_config") or {}),
capabilities=[str(item) for item in data.get("capabilities") or []],
created_at=str(data.get("created_at") or iso_now()),
updated_at=str(data.get("updated_at") or iso_now()),
last_seen_at=_optional_string(data.get("last_seen_at")),
last_error=_optional_string(data.get("last_error")),
)
@dataclass(slots=True)
class PairingSession:
pairing_session_id: str
kind: str
scope: str
token: str
status: str
expires_at_ms: int
created_at_ms: int
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PairingSession":
return cls(
pairing_session_id=str(data.get("pairing_session_id") or ""),
kind=str(data.get("kind") or ""),
scope=str(data.get("scope") or ""),
token=str(data.get("token") or ""),
status=str(data.get("status") or "pending"),
expires_at_ms=int(data.get("expires_at_ms") or 0),
created_at_ms=int(data.get("created_at_ms") or 0),
)
@dataclass(slots=True)
class ChannelRuntimeSpec:
channel_id: str
kind: str
mode: str
account_id: str
display_name: str
config: dict[str, Any] = field(default_factory=dict)
secrets_ref: str | None = None
external_endpoint: str | None = None
@dataclass(slots=True)
class ValidationResult:
ok: bool
status: str
account_id: str | None = None
display_name: str | None = None
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def _optional_string(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,39 @@
"""HTTP client for the generic external connector sidecar."""
from __future__ import annotations
from typing import Any
import httpx
class ConnectorSidecarClient:
def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout_seconds = float(timeout_seconds)
async def get_connectors(self) -> list[dict[str, Any]]:
return await self._request("GET", "/connectors")
async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/connector-sessions", json=payload)
async def get_session(self, session_id: str) -> dict[str, Any]:
return await self._request("GET", f"/connector-sessions/{session_id}")
async def cancel_session(self, session_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={})
async def logout(self, connection_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connections/{connection_id}/logout", json={})
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/send", json=payload)
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any:
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers)
response.raise_for_status()
return response.json()

View File

@ -0,0 +1,222 @@
"""Persistent channel connection stores."""
from __future__ import annotations
import json
import time
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now
class ChannelConnectionStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = Lock()
def create(
self,
*,
kind: str,
mode: str,
display_name: str,
account_id: str,
owner_user_id: str | None,
auth_type: str,
runtime_config: dict[str, Any] | None = None,
capabilities: list[str] | None = None,
credentials_ref: str | None = None,
) -> ChannelConnection:
with self._lock:
data = self._load()
connection_id = f"conn_{uuid4().hex}"
channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}"
now = iso_now()
connection = ChannelConnection(
connection_id=connection_id,
owner_user_id=owner_user_id,
channel_id=channel_id,
kind=kind,
mode=mode,
display_name=display_name or channel_id,
account_id=account_id,
status="draft",
auth_type=auth_type,
credentials_ref=credentials_ref,
runtime_config=runtime_config or {},
capabilities=capabilities or [],
created_at=now,
updated_at=now,
)
data["connections"][connection_id] = connection.to_dict()
self._save(data)
return connection
def get(self, connection_id: str) -> ChannelConnection:
data = self._load()
raw = data["connections"].get(connection_id)
if not isinstance(raw, dict):
raise KeyError(connection_id)
return ChannelConnection.from_dict(raw)
def list(self) -> list[ChannelConnection]:
data = self._load()
return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)]
def update(self, connection: ChannelConnection) -> ChannelConnection:
with self._lock:
data = self._load()
if connection.connection_id not in data["connections"]:
raise KeyError(connection.connection_id)
connection.updated_at = iso_now()
data["connections"][connection.connection_id] = connection.to_dict()
self._save(data)
return connection
def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection:
if status not in CONNECTION_STATUSES:
raise ValueError(f"Unsupported connection status: {status}")
connection = self.get(connection_id)
connection.status = status
connection.last_error = last_error
if status in {"connected", "running"}:
connection.last_seen_at = iso_now()
return self.update(connection)
def revoke(self, connection_id: str) -> ChannelConnection:
return self.update_status(connection_id, status="revoked", last_error=None)
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"connections": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"connections": {}}
if not isinstance(data, dict) or not isinstance(data.get("connections"), dict):
return {"connections": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
class CredentialStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = Lock()
def put(self, *, kind: str, values: dict[str, Any]) -> str:
cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()}
ref = f"cred_{uuid4().hex}"
with self._lock:
data = self._load()
data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()}
self._save(data)
return ref
def get(self, ref: str) -> dict[str, str]:
data = self._load()
item = data["credentials"].get(ref)
if not isinstance(item, dict):
raise KeyError(ref)
values = item.get("values")
if not isinstance(values, dict):
return {}
return {str(key): str(value) for key, value in values.items()}
def redacted(self, ref: str | None) -> dict[str, str]:
if not ref:
return {}
try:
values = self.get(ref)
except KeyError:
return {}
return {key: "***" for key in values}
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"credentials": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"credentials": {}}
if not isinstance(data, dict) or not isinstance(data.get("credentials"), dict):
return {"credentials": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
class PairingTokenStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = Lock()
def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession:
now_ms = _now_ms()
session = PairingSession(
pairing_session_id=f"pair_{uuid4().hex}",
kind=kind,
scope=scope,
token=f"pair_{uuid4().hex}",
status="pending",
expires_at_ms=now_ms + int(ttl_seconds * 1000),
created_at_ms=now_ms,
)
with self._lock:
data = self._load()
data["sessions"][session.pairing_session_id] = session.to_dict()
self._save(data)
return session
def consume(self, token: str, *, expected_kind: str) -> PairingSession | None:
with self._lock:
data = self._load()
for key, raw in data["sessions"].items():
session = PairingSession.from_dict(raw)
if session.token != token or session.kind != expected_kind:
continue
if session.status != "pending" or session.expires_at_ms <= _now_ms():
return None
session.status = "consumed"
data["sessions"][key] = session.to_dict()
self._save(data)
return session
return None
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"sessions": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"sessions": {}}
if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict):
return {"sessions": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def _now_ms() -> int:
return int(time.time() * 1000)
def _slug(value: str) -> str:
text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower())
return "-".join(part for part in text.split("-") if part) or "channel"

View File

@ -0,0 +1,92 @@
"""Telegram channel connector."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from .models import ChannelRuntimeSpec, ValidationResult
from .store import ChannelConnectionStore, CredentialStore
class TelegramConnector:
kind = "telegram"
def __init__(
self,
*,
connection_store: ChannelConnectionStore,
credential_store: CredentialStore,
client_factory: Callable[[str], Any] | None = None,
) -> None:
self.connection_store = connection_store
self.credential_store = credential_store
self.client_factory = client_factory or _default_client_factory
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
token = self._bot_token(connection.credentials_ref)
try:
client = self.client_factory(token)
raw = await client.get_me()
bot_id = _value(raw, "id")
username = _value(raw, "username")
first_name = _value(raw, "first_name") or "Telegram Bot"
account_id = f"telegram:{bot_id}" if bot_id else connection.account_id
display_name = f"{first_name} (@{username})" if username else first_name
connection.account_id = account_id
connection.display_name = display_name
connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"]
self.connection_store.update(connection)
return ValidationResult(
ok=True,
status="connected",
account_id=account_id,
display_name=display_name,
metadata={"username": username} if username else {},
)
except Exception as exc:
return ValidationResult(ok=False, status="error", error=str(exc))
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
connection = self.connection_store.get(connection_id)
if connection.status not in {"connected", "running"}:
raise ValueError(f"Connection is not connected: {connection.connection_id}")
return ChannelRuntimeSpec(
channel_id=connection.channel_id,
kind=connection.kind,
mode=connection.mode,
account_id=connection.account_id,
display_name=connection.display_name,
config=dict(connection.runtime_config),
secrets_ref=connection.credentials_ref,
)
async def revoke(self, connection_id: str) -> None:
# Telegram bot tokens do not have a Beaver-managed platform revoke action.
# The registry owns local connection state transitions.
return None
def _bot_token(self, credentials_ref: str | None) -> str:
if not credentials_ref:
raise ValueError("Telegram credentials are missing")
token = self.credential_store.get(credentials_ref).get("botToken")
if not token:
raise ValueError("botToken is required")
return token
def _value(raw: Any, key: str) -> str:
if isinstance(raw, dict):
value = raw.get(key)
else:
value = getattr(raw, key, None)
return str(value).strip() if value is not None else ""
def _default_client_factory(token: str) -> Any:
try:
from telegram import Bot
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc
return Bot(token=token)

View File

@ -0,0 +1,97 @@
"""Generic runtime channel backed by an external connector sidecar."""
from __future__ import annotations
import hashlib
from typing import Any
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
class ExternalConnectorChannel:
def __init__(
self,
*,
channel_id: str,
platform_kind: str,
connection_id: str,
account_id: str,
display_name: str,
sidecar_client: ConnectorSidecarClient | Any,
) -> None:
self.channel_id = channel_id
self.kind = "external_connector"
self.mode = "http"
self.platform_kind = platform_kind
self.connection_id = connection_id
self.account_id = account_id
self.display_name = display_name or channel_id
self.sidecar_client = sidecar_client
self.started = False
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
async def send(self, message: OutboundMessage) -> None:
identity = message.channel_identity
if identity is None:
raise ValueError("channel_identity is required for external connector sends")
metadata = {
"inboundMessageId": identity.message_id,
"sessionId": message.session_id,
}
context_token = _context_token(message)
if context_token:
metadata["contextToken"] = context_token
payload = {
"requestId": _request_id(message),
"connectionId": self.connection_id,
"channelId": self.channel_id,
"kind": self.platform_kind,
"target": {
"peerId": identity.peer_id,
"peerType": identity.peer_type,
"threadId": identity.thread_id,
},
"content": message.content,
"metadata": metadata,
}
await self.sidecar_client.send(payload)
def _request_id(message: OutboundMessage) -> str:
identity = message.channel_identity
channel = message.channel or (identity.channel_id if identity else "unknown")
session_id = message.session_id or (identity.session_id() if identity else "unknown")
message_id = str(message.message_id or "").strip()
if not message_id:
basis = "|".join(
[
message.content,
identity.message_id if identity and identity.message_id else "",
identity.peer_id if identity else "",
message.finish_reason,
]
)
message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]
return f"out_{channel}:{session_id}:{message_id}"
def _context_token(message: OutboundMessage) -> str | None:
inbound_metadata = message.metadata.get("inbound_metadata")
if isinstance(inbound_metadata, dict):
value = _clean_optional(inbound_metadata.get("contextToken") or inbound_metadata.get("context_token"))
if value:
return value
return _clean_optional(message.metadata.get("contextToken") or message.metadata.get("context_token"))
def _clean_optional(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,116 @@
"""Generic fixed-schema text webhook channel adapter."""
from __future__ import annotations
import asyncio
from typing import Any
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
class GenericWebhookAdapter:
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str = "",
inbound_sink: ChannelInboundSink,
response_timeout_seconds: float = 1800,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name or channel_id
self.inbound_sink = inbound_sink
self.response_timeout_seconds = max(1.0, float(response_timeout_seconds))
self.started = False
self._pending: dict[str, asyncio.Future[OutboundMessage]] = {}
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
for future in list(self._pending.values()):
if not future.done():
future.cancel()
self._pending.clear()
async def handle_webhook_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
text = str(payload.get("text") or "").strip()
peer_id = str(payload.get("peer_id") or "").strip()
message_id = str(payload.get("message_id") or "").strip()
thread_id = str(payload.get("thread_id") or "").strip() or None
peer_type = str(payload.get("peer_type") or "unknown").strip() or "unknown"
user_id = str(payload.get("user_id") or "").strip() or None
if not text:
return {"ok": False, "error": "text is required"}
if not peer_id:
return {"ok": False, "error": "peer_id is required"}
if not message_id:
return {"ok": False, "error": "message_id is required"}
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
)
inbound = InboundMessage(
channel=self.channel_id,
content=text,
user_id=user_id,
channel_identity=identity,
metadata={"webhook": {"peer_type": peer_type}},
)
future = asyncio.get_running_loop().create_future()
self._pending[inbound.message_id] = future
accept = await self.inbound_sink.accept_inbound(inbound)
if not accept.accepted:
self._pending.pop(inbound.message_id, None)
record = accept.record or {}
return {
"ok": accept.error is None,
"duplicate": accept.duplicate,
"pending": accept.pending,
"session_id": accept.session_id,
"status": record.get("status"),
"run_id": record.get("run_id"),
"reply": record.get("reply"),
"error": accept.error or record.get("error"),
}
try:
outbound = await asyncio.wait_for(future, timeout=self.response_timeout_seconds)
except asyncio.TimeoutError:
self._pending.pop(inbound.message_id, None)
return {
"ok": True,
"duplicate": False,
"pending": True,
"session_id": accept.session_id,
}
return {
"ok": outbound.finish_reason != "error",
"duplicate": False,
"pending": False,
"session_id": outbound.session_id,
"run_id": outbound.run_id,
"reply": outbound.content,
"error": outbound.metadata.get("error"),
}
async def send(self, message: OutboundMessage) -> None:
future = self._pending.pop(message.message_id, None)
if future is None or future.done():
message.metadata["delivery_status"] = "unclaimed"
return
future.set_result(message)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import suppress
from beaver.foundation.events import MessageBus, OutboundMessage
@ -20,13 +21,17 @@ class ChannelManager:
self.started = False
def register(self, channel: ChannelAdapter) -> None:
if self.started:
raise RuntimeError("Cannot register channels after ChannelManager.start()")
if channel.name in self.channels:
raise ValueError(f"Channel already registered: {channel.name}")
if channel.bus is not self.bus:
raise ValueError("Channel must share the same MessageBus as ChannelManager")
self.channels[channel.name] = channel
if channel.channel_id in self.channels:
raise ValueError(f"Channel already registered: {channel.channel_id}")
self.channels[channel.channel_id] = channel
def unregister(self, channel_id: str) -> ChannelAdapter | None:
return self.channels.pop(channel_id, None)
def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None:
old = self.channels.get(channel.channel_id)
self.channels[channel.channel_id] = channel
return old
async def start(self) -> None:
started: list[ChannelAdapter] = []
@ -53,7 +58,13 @@ class ChannelManager:
if errors:
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
async def dispatch_outbound(self, stop_event: asyncio.Event) -> None:
async def dispatch_outbound(
self,
stop_event: asyncio.Event,
*,
on_delivered: Callable[[OutboundMessage], Awaitable[None]] | None = None,
on_failed: Callable[[OutboundMessage, Exception | None], Awaitable[None]] | None = None,
) -> None:
"""Route bus outbound messages until stopped and the queue is drained."""
while True:
@ -68,9 +79,16 @@ class ChannelManager:
channel = self.channels.get(message.channel)
if channel is None:
self.undeliverable.append(message)
if on_failed is not None:
await on_failed(message, None)
continue
try:
await channel.send(message)
except Exception: # pragma: no cover - defensive channel isolation
except Exception as exc: # pragma: no cover - defensive channel isolation
self.undeliverable.append(message)
if on_failed is not None:
await on_failed(message, exc)
else:
if on_delivered is not None:
await on_delivered(message)

View File

@ -4,15 +4,27 @@ from __future__ import annotations
from typing import Any
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
class MemoryChannelAdapter:
"""A local channel that stores outbound messages in memory."""
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None:
self.name = name
self.bus = bus
def __init__(
self,
inbound_sink: ChannelInboundSink,
*,
channel_id: str = "memory-dev",
kind: str = "memory",
mode: str = "webhook",
account_id: str = "memory",
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.inbound_sink = inbound_sink
self.started = False
self.sent_messages: list[OutboundMessage] = []
@ -36,12 +48,24 @@ class MemoryChannelAdapter:
model: str | None = None,
provider_name: str | None = None,
embedding_model: str | None = None,
peer_id: str = "default",
thread_id: str | None = None,
message_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> InboundMessage:
"""Publish a text message from this channel into the shared bus."""
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
user_id=user_id,
message_id=message_id,
)
message = InboundMessage(
channel=self.name,
channel=self.channel_id,
content=content,
session_id=session_id,
user_id=user_id,
@ -50,9 +74,10 @@ class MemoryChannelAdapter:
model=model,
provider_name=provider_name,
embedding_model=embedding_model,
channel_identity=identity,
metadata=metadata or {},
)
await self.bus.publish_inbound(message)
await self.inbound_sink.accept_inbound(message)
return message
async def publish_external_text(
@ -73,9 +98,6 @@ class MemoryChannelAdapter:
the shared gateway bus.
"""
session_parts = [self.name, chat_id]
if thread_id:
session_parts.append(thread_id)
metadata = {
"chat_id": chat_id,
"message_id": message_id,
@ -84,8 +106,10 @@ class MemoryChannelAdapter:
}
return await self.publish_text(
content,
session_id=":".join(str(part) for part in session_parts if str(part)),
user_id=user_id,
title=title,
peer_id=chat_id,
thread_id=thread_id,
message_id=message_id,
metadata=metadata,
)

View File

@ -0,0 +1 @@
"""Platform channel adapters."""

View File

@ -0,0 +1,138 @@
"""Shared helpers for platform channel adapters."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
@dataclass(slots=True)
class OutboundTarget:
peer_id: str | None
thread_id: str | None = None
peer_type: str = "unknown"
user_id: str | None = None
class PlatformDeliveryError(RuntimeError):
"""Raised when a platform client rejects a delivery."""
def config_bool(config: dict[str, Any], key: str, *, default: bool = False) -> bool:
value = config.get(key)
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return default
def config_list(config: dict[str, Any], key: str) -> list[str]:
value = config.get(key)
if value is None:
return []
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
return [text] if text else []
def chunk_text(text: str, *, max_chars: int) -> list[str]:
if max_chars <= 0:
raise ValueError("max_chars must be positive")
if not text:
return [""]
return [text[index : index + max_chars] for index in range(0, len(text), max_chars)]
def compact_media_summary(media_type: str, *, file_name: str | None = None) -> str:
label = str(media_type or "attachment").strip() or "attachment"
if file_name:
return f"[{label}: {file_name}]"
return f"[{label}]"
def target_from_session_id(session_id: str | None) -> OutboundTarget:
if not session_id:
return OutboundTarget(peer_id=None)
parts = str(session_id).split(":")
if len(parts) < 3:
return OutboundTarget(peer_id=None)
thread_id = parts[3] if len(parts) > 3 and parts[3] else None
return OutboundTarget(peer_id=parts[2] or None, thread_id=thread_id)
def outbound_target(message: OutboundMessage) -> OutboundTarget:
identity = message.channel_identity
if identity is None:
return target_from_session_id(message.session_id)
return OutboundTarget(
peer_id=identity.peer_id,
thread_id=identity.thread_id,
peer_type=identity.peer_type,
user_id=identity.user_id,
)
def mark_unclaimed(message: OutboundMessage) -> None:
message.metadata["delivery_status"] = "unclaimed"
def build_inbound_message(
*,
channel_id: str,
kind: str,
account_id: str,
peer_id: str,
content: str,
message_id: str | None,
peer_type: str,
user_id: str | None = None,
thread_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> InboundMessage:
identity = ChannelIdentity(
channel_id=channel_id,
kind=kind,
account_id=account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
)
return InboundMessage(
channel=channel_id,
content=content,
session_id=identity.session_id(),
user_id=user_id,
message_id=message_id or "",
channel_identity=identity,
metadata=metadata or {},
)
def allowed_by_policy(
*,
policy: str | None,
identifier: str | None,
allowlist: list[str],
default: str = "open",
) -> bool:
effective = (policy or default).strip().lower()
if effective == "disabled":
return False
if effective == "allowlist":
return bool(identifier and identifier in allowlist)
return True

View File

@ -0,0 +1,207 @@
"""Feishu/Lark channel adapter."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
build_inbound_message,
chunk_text,
compact_media_summary,
config_bool,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class FeishuAdapter:
"""Feishu/Lark bot adapter with injectable client support."""
KIND = "feishu"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self.max_message_chars = int(self.config.get("maxMessageChars") or 4096)
async def start(self) -> None:
if self._client is not None:
return
if self.mode not in {"websocket", "webhook"}:
raise ValueError(f"Unsupported feishu mode: {self.mode}")
self._client = self._build_client()
async def stop(self) -> None:
close = getattr(self._client, "close", None)
if close is not None:
result = close()
if hasattr(result, "__await__"):
await result
async def handle_event_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_text(receive_id_type="chat_id", receive_id=target.peer_id, text=chunk)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
message = event.get("message") if isinstance(event.get("message"), dict) else {}
sender = event.get("sender") if isinstance(event.get("sender"), dict) else {}
peer_id = _string_or_none(message.get("chat_id"))
if not peer_id:
return None
message_id = _string_or_none(message.get("message_id"))
message_type = str(message.get("message_type") or "unknown")
chat_type = str(message.get("chat_type") or "unknown")
peer_type = "dm" if chat_type == "p2p" else "group"
user_id = _sender_open_id(sender)
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
return None
if peer_type == "group" and not self._group_allowed(peer_id, user_id):
return None
if peer_type == "group" and config_bool(self.config, "requireMentionInGroups", default=False):
if not self._message_mentions_bot(message):
return None
content = self._message_content(message_type, message)
if not content:
return None
metadata = {
"chat_id": peer_id,
"message_id": message_id,
"chat_type": chat_type,
"message_type": message_type,
}
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _message_content(self, message_type: str, message: dict[str, Any]) -> str:
content = _parse_json_object(message.get("content"))
if message_type == "text":
return str(content.get("text") or "").strip()
file_name = _string_or_none(content.get("file_name") or content.get("name"))
return compact_media_summary(message_type, file_name=file_name)
def _message_mentions_bot(self, message: dict[str, Any]) -> bool:
bot_open_id = _string_or_none(self.config.get("botOpenId"))
if not bot_open_id:
return False
mentions = message.get("mentions")
if not isinstance(mentions, list):
return False
for mention in mentions:
if not isinstance(mention, dict):
continue
mention_id = mention.get("id") if isinstance(mention.get("id"), dict) else {}
if _string_or_none(mention_id.get("open_id")) == bot_open_id:
return True
return False
def _dm_allowed(self, identifier: str | None) -> bool:
allowlist = config_list(self.config, "allowFrom")
if not allowlist:
return True
return bool(identifier and identifier in allowlist)
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
allowlist = config_list(self.config, "groupAllowFrom")
if not allowlist:
return True
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_client()
return self._client
def _build_client(self) -> Any:
self._require_secret("appId")
self._require_secret("appSecret")
try:
import lark_oapi # noqa: F401
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[feishu] to enable FeishuAdapter") from exc
raise RuntimeError("Feishu live client is not configured for direct construction")
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _parse_json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if not isinstance(value, str):
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _sender_open_id(sender: dict[str, Any]) -> str | None:
sender_id = sender.get("sender_id") if isinstance(sender.get("sender_id"), dict) else {}
return _string_or_none(sender_id.get("open_id"))
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,206 @@
"""QQ Bot channel adapter."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
allowed_by_policy,
build_inbound_message,
chunk_text,
compact_media_summary,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class QQBotAdapter:
"""QQ Bot API adapter with injectable client support."""
KIND = "qqbot"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self.max_message_chars = int(self.config.get("maxMessageChars") or 2000)
async def start(self) -> None:
if self._client is not None:
return
if self.mode != "websocket":
raise ValueError(f"Unsupported qqbot mode: {self.mode}")
self._client = self._build_client()
async def stop(self) -> None:
close = getattr(self._client, "close", None)
if close is not None:
result = close()
if hasattr(result, "__await__"):
await result
async def handle_event_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
platform_message_id = message.channel_identity.message_id if message.channel_identity else None
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_text(
peer_type=target.peer_type,
peer_id=target.peer_id,
content=chunk,
message_id=platform_message_id,
)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
event_type = str(payload.get("t") or payload.get("type") or "")
data = payload.get("d") if isinstance(payload.get("d"), dict) else payload
author = data.get("author") if isinstance(data.get("author"), dict) else {}
route = self._route(event_type, data, author)
if route is None:
return None
peer_id, peer_type, user_id, thread_id = route
if peer_type == "dm":
if not allowed_by_policy(
policy=self.config.get("dmPolicy"),
identifier=user_id or peer_id,
allowlist=config_list(self.config, "allowFrom"),
default="open",
):
return None
elif peer_type == "group":
if not allowed_by_policy(
policy=self.config.get("groupPolicy"),
identifier=peer_id,
allowlist=config_list(self.config, "groupAllowFrom"),
default="open",
):
return None
message_id = _string_or_none(data.get("id"))
content = str(data.get("content") or "").strip()
media_entries = self._media_entries(data)
if media_entries:
content = "\n".join([part for part in [content, *media_entries] if part]).strip()
if not content:
return None
metadata = {
"event_type": event_type,
"message_id": message_id,
"peer_type": peer_type,
}
if media_entries:
metadata["media"] = media_entries
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _route(
self,
event_type: str,
data: dict[str, Any],
author: dict[str, Any],
) -> tuple[str, str, str | None, str | None] | None:
if event_type == "C2C_MESSAGE_CREATE":
peer_id = _string_or_none(author.get("user_openid"))
if not peer_id:
return None
return peer_id, "dm", peer_id, None
if event_type == "GROUP_AT_MESSAGE_CREATE":
peer_id = _string_or_none(data.get("group_openid"))
if not peer_id:
return None
return peer_id, "group", _string_or_none(author.get("member_openid")), None
if data.get("guild_id") and data.get("channel_id"):
peer_id = _string_or_none(data.get("channel_id"))
if not peer_id:
return None
return peer_id, "channel", _string_or_none(author.get("id")), _string_or_none(data.get("guild_id"))
return None
def _media_entries(self, data: dict[str, Any]) -> list[str]:
entries: list[str] = []
attachments = data.get("attachments")
if not isinstance(attachments, list):
return entries
for attachment in attachments:
if not isinstance(attachment, dict):
continue
media_type = str(attachment.get("content_type") or attachment.get("type") or "attachment")
entries.append(compact_media_summary(media_type, file_name=_string_or_none(attachment.get("filename"))))
return entries
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_client()
return self._client
def _build_client(self) -> Any:
self._require_secret("appId")
self._require_secret("clientSecret")
try:
import aiohttp # noqa: F401
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[qqbot] to enable QQBotAdapter") from exc
raise RuntimeError("QQBot live client is not configured for direct construction")
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,244 @@
"""Telegram channel adapter."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
build_inbound_message,
chunk_text,
compact_media_summary,
config_bool,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class TelegramAdapter:
"""Telegram Bot API adapter with injectable client support."""
KIND = "telegram"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
application_factory: Callable[[], Any] | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self._application_factory = application_factory
self._application: Any | None = None
self.max_message_chars = int(self.config.get("maxMessageChars") or 4096)
async def start(self) -> None:
if self._client is not None:
return
if self.mode == "polling":
self._application = self._build_application()
await self._application.initialize()
await self._application.start()
if getattr(self._application, "updater", None) is not None:
await self._application.updater.start_polling()
self._client = self._application.bot
return
if self.mode == "webhook":
self._client = self._build_bot()
return
raise ValueError(f"Unsupported telegram mode: {self.mode}")
async def stop(self) -> None:
if self._application is None:
return
updater = getattr(self._application, "updater", None)
if updater is not None:
await updater.stop()
await self._application.stop()
await self._application.shutdown()
self._application = None
async def handle_update_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
kwargs: dict[str, Any] = {"chat_id": target.peer_id}
if target.thread_id:
kwargs["message_thread_id"] = int(target.thread_id) if str(target.thread_id).isdigit() else target.thread_id
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_message(**kwargs, text=chunk)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
data = payload.get("message") or payload.get("edited_message")
if not isinstance(data, dict):
return None
chat = data.get("chat") if isinstance(data.get("chat"), dict) else {}
sender = data.get("from") if isinstance(data.get("from"), dict) else {}
peer_id = _string_or_none(chat.get("id"))
if not peer_id:
return None
chat_type = str(chat.get("type") or "unknown")
peer_type = self._peer_type(chat_type)
user_id = _string_or_none(sender.get("id"))
message_id = _string_or_none(data.get("message_id"))
thread_id = _string_or_none(data.get("message_thread_id"))
content = str(data.get("text") or data.get("caption") or "").strip()
media_entries = self._media_entries(data)
if media_entries:
content = "\n".join([part for part in [content, *media_entries] if part]).strip()
if not content:
return None
if peer_type in {"group", "channel"} and not self._group_allowed(peer_id, user_id):
return None
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
return None
if peer_type in {"group", "channel"} and config_bool(self.config, "requireMentionInGroups", default=False):
gated = self._strip_required_mention(content)
if gated is None:
return None
content = gated
metadata = {
"chat_id": peer_id,
"message_id": message_id,
"chat_type": chat_type,
}
if media_entries:
metadata["media"] = media_entries
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _media_entries(self, data: dict[str, Any]) -> list[str]:
entries: list[str] = []
if data.get("photo"):
entries.append(compact_media_summary("photo"))
for media_type in ("document", "audio", "video"):
value = data.get(media_type)
if isinstance(value, dict):
entries.append(compact_media_summary(media_type, file_name=_string_or_none(value.get("file_name"))))
return entries
def _strip_required_mention(self, content: str) -> str | None:
username = str(self.config.get("botUsername") or "").strip().lstrip("@")
if not username:
return None
mention = f"@{username}"
if mention not in content:
return None
return content.replace(mention, "", 1).strip()
def _dm_allowed(self, identifier: str | None) -> bool:
allowlist = config_list(self.config, "allowFrom")
if not allowlist:
return True
return bool(identifier and identifier in allowlist)
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
allowlist = config_list(self.config, "groupAllowFrom")
if not allowlist:
return True
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
def _peer_type(self, chat_type: str) -> str:
if chat_type == "private":
return "dm"
if chat_type in {"group", "supergroup"}:
return "group"
if chat_type == "channel":
return "channel"
return chat_type or "unknown"
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_bot()
return self._client
def _build_bot(self) -> Any:
token = self._require_secret("botToken")
try:
from telegram import Bot
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
return Bot(token=token)
def _build_application(self) -> Any:
if self._application_factory is not None:
return self._application_factory()
token = self._require_secret("botToken")
try:
from telegram.ext import Application
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
async def handle(update: Any, context: Any) -> None:
if hasattr(update, "to_dict"):
await self.handle_update_payload(update.to_dict())
application = Application.builder().token(token).build()
try:
from telegram.ext import MessageHandler, filters
application.add_handler(MessageHandler(filters.ALL, handle))
except Exception:
pass
return application
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,180 @@
"""Weixin channel adapter."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
allowed_by_policy,
build_inbound_message,
chunk_text,
compact_media_summary,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class WeixinAdapter:
"""Tencent iLink-style Weixin adapter with injectable client support."""
KIND = "weixin"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self.max_message_chars = int(self.config.get("maxMessageChars") or 2000)
async def start(self) -> None:
if self._client is not None:
return
if self.mode != "polling":
raise ValueError(f"Unsupported weixin mode: {self.mode}")
self._client = self._build_client()
async def stop(self) -> None:
close = getattr(self._client, "close", None)
if close is not None:
result = close()
if hasattr(result, "__await__"):
await result
async def handle_message_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
context_token = self._context_token(message)
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_text(peer_id=target.peer_id, text=chunk, context_token=context_token)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
sender_id = _string_or_none(payload.get("from") or payload.get("from_user"))
room_id = _string_or_none(payload.get("room_id") or payload.get("roomId"))
message_id = _string_or_none(payload.get("id") or payload.get("message_id"))
message_type = str(payload.get("type") or payload.get("message_type") or "text")
if room_id:
peer_id = room_id
peer_type = "group"
user_id = sender_id
if not allowed_by_policy(
policy=self.config.get("groupPolicy"),
identifier=peer_id,
allowlist=config_list(self.config, "groupAllowFrom"),
default="disabled",
):
return None
else:
peer_id = sender_id
peer_type = "dm"
user_id = sender_id
if not allowed_by_policy(
policy=self.config.get("dmPolicy"),
identifier=peer_id,
allowlist=config_list(self.config, "allowFrom"),
default="open",
):
return None
if not peer_id:
return None
content = self._content(message_type, payload)
if not content:
return None
metadata = {
"message_id": message_id,
"message_type": message_type,
}
context_token = _string_or_none(payload.get("context_token") or payload.get("contextToken"))
if context_token:
metadata["context_token"] = context_token
if room_id:
metadata["room_id"] = room_id
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _content(self, message_type: str, payload: dict[str, Any]) -> str:
if message_type == "text":
return str(payload.get("text") or payload.get("content") or "").strip()
file_name = _string_or_none(payload.get("file_name") or payload.get("filename"))
return compact_media_summary(message_type, file_name=file_name)
def _context_token(self, message: OutboundMessage) -> str | None:
inbound_metadata = message.metadata.get("inbound_metadata")
if isinstance(inbound_metadata, dict):
value = _string_or_none(inbound_metadata.get("context_token"))
if value:
return value
return _string_or_none(message.metadata.get("context_token"))
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_client()
return self._client
def _build_client(self) -> Any:
self._require_secret("token")
try:
import aiohttp # noqa: F401
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[weixin] to enable WeixinAdapter") from exc
raise RuntimeError("Weixin live client is not configured for direct construction")
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,526 @@
"""Channel runtime host for adapter lifecycle and bus-first routing."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.interfaces.channels.base import ChannelAdapter
from beaver.interfaces.channels.manager import ChannelManager
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
from beaver.services.agent_service import AgentService
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _channel_capabilities(kind: str, mode: str) -> list[str]:
if kind == "webhook":
return ["receive_text", "send_text", "sync_webhook_response"]
if kind == "terminal" and mode == "websocket":
return ["receive_text", "send_text", "persistent_connection"]
if kind in {"feishu", "qqbot", "telegram"}:
return ["receive_text", "send_text", "receive_media", "groups"]
if kind == "weixin":
return ["receive_text", "send_text", "receive_media", "direct_messages"]
return []
@dataclass(slots=True)
class ChannelAcceptResult:
accepted: bool
duplicate: bool = False
pending: bool = False
rejected: bool = False
session_id: str | None = None
dedupe_key: str | None = None
record: dict[str, Any] | None = None
error: str | None = None
class ChannelRuntime:
"""Own channel adapters, state, and the inbound/outbound bus bridge."""
def __init__(
self,
*,
service: AgentService,
workspace: Path,
channels: dict[str, ChannelConfig],
bus: MessageBus | None = None,
) -> None:
self.service = service
self.workspace = Path(workspace)
self.bus = bus or MessageBus()
self.manager = ChannelManager(self.bus)
self.channel_configs = dict(channels)
self.adapters: dict[str, ChannelAdapter] = {}
self.states: dict[str, dict[str, Any]] = {}
state_dir = self.workspace / "state" / "channels"
retention = self._default_dedupe_retention_hours()
self.dedupe = ChannelDedupeStore(state_dir / "dedupe.json", retention_hours=retention)
self.events = ChannelEventLog(state_dir / "events.jsonl")
self._bridge_task: asyncio.Task[None] | None = None
self._dispatch_task: asyncio.Task[None] | None = None
self._stop_event = asyncio.Event()
self._dispatch_stop_event = asyncio.Event()
self._lifecycle_lock = asyncio.Lock()
async def start(self) -> None:
self._stop_event.clear()
self._dispatch_stop_event.clear()
for channel_id, cfg in self.channel_configs.items():
if not cfg.enabled:
self.states[channel_id] = {"state": "disabled", "last_error": None}
continue
try:
adapter = self._build_adapter(channel_id, cfg)
self.adapters[channel_id] = adapter
self.manager.register(adapter)
await adapter.start()
self.states[channel_id] = {
"state": "running",
"last_error": None,
"started_at": _iso_now(),
}
self.events.record(channel_id=channel_id, kind="adapter_started")
except Exception as exc: # pragma: no cover - defensive startup isolation
self.states[channel_id] = {"state": "error", "last_error": str(exc)}
self.events.record(
channel_id=channel_id,
kind="adapter_error",
status="error",
error=str(exc),
)
self._bridge_task = asyncio.create_task(self._bridge_inbound_to_agent())
self._dispatch_task = asyncio.create_task(
self.manager.dispatch_outbound(
self._dispatch_stop_event,
on_delivered=self._record_outbound_delivered,
on_failed=self._record_outbound_failed,
)
)
async def stop(self) -> None:
self._stop_event.set()
if self._bridge_task is not None:
self._bridge_task.cancel()
try:
await self._bridge_task
except asyncio.CancelledError:
pass
self._dispatch_stop_event.set()
if self._dispatch_task is not None:
try:
await asyncio.wait_for(self._dispatch_task, timeout=1.0)
except asyncio.TimeoutError:
self._dispatch_task.cancel()
try:
await self._dispatch_task
except asyncio.CancelledError:
pass
await self.manager.stop()
for channel_id in self.adapters:
self.events.record(channel_id=channel_id, kind="adapter_stopped")
async def add_channel(self, channel_id: str, config: ChannelConfig) -> None:
async with self._lifecycle_lock:
current = self.channel_configs.get(channel_id)
if current == config and channel_id in self.adapters:
return
if not config.enabled:
await self._remove_channel_locked(channel_id)
self.channel_configs[channel_id] = config
self.states[channel_id] = {"state": "disabled", "last_error": None}
return
adapter = self._build_adapter(channel_id, config)
await adapter.start()
old_adapter = self.adapters.get(channel_id)
self.manager.replace_registered(adapter)
self.adapters[channel_id] = adapter
self.channel_configs[channel_id] = config
self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()}
self.events.record(channel_id=channel_id, kind="adapter_started")
if old_adapter is not None and old_adapter is not adapter:
await old_adapter.stop()
async def remove_channel(self, channel_id: str) -> None:
async with self._lifecycle_lock:
await self._remove_channel_locked(channel_id)
async def _remove_channel_locked(self, channel_id: str) -> None:
adapter = self.adapters.pop(channel_id, None)
self.manager.unregister(channel_id)
self.channel_configs.pop(channel_id, None)
if adapter is not None:
await adapter.stop()
self.events.record(channel_id=channel_id, kind="adapter_stopped")
self.states[channel_id] = {"state": "removed", "last_error": None}
async def accept_inbound(self, message: InboundMessage) -> ChannelAcceptResult:
identity = message.channel_identity
if identity is None:
self.events.record(
channel_id=message.channel,
kind="inbound_rejected",
status="error",
error="channel_identity is required",
)
return ChannelAcceptResult(
accepted=False,
rejected=True,
error="channel_identity is required",
)
validation_error = identity.validation_error()
if validation_error:
self.events.record(
channel_id=identity.channel_id,
kind="inbound_rejected",
status="error",
error=validation_error,
)
return ChannelAcceptResult(accepted=False, rejected=True, error=validation_error)
expected_session_id = identity.session_id()
if message.session_id != expected_session_id:
self.events.record(
channel_id=identity.channel_id,
kind="session_id_normalized",
session_id=expected_session_id,
message_id=identity.message_id,
)
message.session_id = expected_session_id
message.channel = identity.channel_id
dedupe_key = identity.dedupe_key()
if dedupe_key:
write = self.dedupe.mark_processing(
dedupe_key=dedupe_key,
session_id=expected_session_id,
message_id=identity.message_id or "",
)
if not write.created:
record = write.record or {}
self.events.record(
channel_id=identity.channel_id,
kind="inbound_duplicate",
session_id=expected_session_id,
message_id=identity.message_id,
status=str(record.get("status") or "processing"),
)
return ChannelAcceptResult(
accepted=False,
duplicate=True,
pending=record.get("status") == "processing",
session_id=expected_session_id,
dedupe_key=dedupe_key,
record=record,
)
self.events.record(
channel_id=identity.channel_id,
kind="inbound_accepted",
session_id=expected_session_id,
message_id=identity.message_id,
text=message.content,
)
await self.bus.publish_inbound(message)
return ChannelAcceptResult(
accepted=True,
session_id=expected_session_id,
dedupe_key=dedupe_key,
)
def statuses(self) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
recent = self.events.recent(limit=500)
last_by_channel = {event["channel_id"]: event for event in recent if event.get("channel_id")}
for channel_id, cfg in self.channel_configs.items():
state = self.states.get(channel_id, {"state": "configured", "last_error": None})
capabilities = _channel_capabilities(cfg.kind, cfg.mode)
webhook_url = None
websocket_url = None
connected_peers = 0
if cfg.kind == "webhook":
webhook_url = f"/api/channels/{channel_id}/webhook"
elif cfg.kind == "terminal" and cfg.mode == "websocket":
websocket_url = f"/api/channels/{channel_id}/ws"
adapter = self.adapters.get(channel_id)
if adapter is not None and hasattr(adapter, "status_extra"):
extra = adapter.status_extra() # type: ignore[attr-defined]
connected_peers = int(extra.get("connected_peers") or 0)
items.append(
{
"channel_id": channel_id,
"name": channel_id,
"kind": cfg.kind,
"mode": cfg.mode,
"display_name": cfg.display_name or channel_id,
"enabled": cfg.enabled,
"state": state.get("state", "configured"),
"account_id": cfg.account_id,
"last_error": state.get("last_error"),
"started_at": state.get("started_at"),
"last_event_at": last_by_channel.get(channel_id, {}).get("created_at"),
"capabilities": capabilities,
"webhook_url": webhook_url,
"websocket_url": websocket_url,
"connected_peers": connected_peers,
}
)
return items
def recent_events(self, channel_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
return self.events.recent(channel_id=channel_id, limit=limit)
def record_event(
self,
*,
channel_id: str,
kind: str,
session_id: str | None = None,
message_id: str | None = None,
run_id: str | None = None,
status: str = "ok",
error: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
self.events.record(
channel_id=channel_id,
kind=kind,
session_id=session_id,
message_id=message_id,
run_id=run_id,
status=status,
error=error,
metadata=metadata,
)
def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter:
if cfg.kind == "webhook" and cfg.mode == "webhook":
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
return GenericWebhookAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800),
)
if cfg.kind == "terminal" and cfg.mode == "websocket":
from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter
return TerminalWebSocketAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
event_recorder=self.record_event,
heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30),
max_message_chars=int(cfg.config.get("max_message_chars") or 20000),
)
if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}:
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
return TelegramAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "feishu" and cfg.mode in {"websocket", "webhook"}:
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
return FeishuAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "qqbot" and cfg.mode == "websocket":
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
return QQBotAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "weixin" and cfg.mode == "polling":
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
return WeixinAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "external_connector" and cfg.mode == "http":
import os
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel
base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip()
token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
platform_kind = str(cfg.config.get("platformKind") or "").strip()
connection_id = str(cfg.config.get("connectionId") or "").strip()
if not base_url:
raise ValueError("external connector sidecarBaseUrl is required")
if not platform_kind:
raise ValueError("external connector platformKind is required")
if not connection_id:
raise ValueError("external connector connectionId is required")
return ExternalConnectorChannel(
channel_id=channel_id,
platform_kind=platform_kind,
connection_id=connection_id,
account_id=cfg.account_id,
display_name=cfg.display_name,
sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token),
)
raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}")
async def _bridge_inbound_to_agent(self) -> None:
current_inbound: InboundMessage | None = None
while not self._stop_event.is_set():
try:
current_inbound = await asyncio.wait_for(self.bus.consume_inbound(), timeout=0.25)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
raise
inbound = current_inbound
identity = inbound.channel_identity
try:
self.events.record(
channel_id=inbound.channel,
kind="direct_run_started",
session_id=inbound.session_id,
message_id=identity.message_id if identity else inbound.message_id,
)
outbound = await self.service.handle_inbound_message(inbound)
except asyncio.CancelledError:
outbound = AgentService.build_outbound_error(
inbound,
detail="Channel runtime stopped before completing the inbound message",
finish_reason="cancelled",
)
self._mark_dedupe_result(inbound, outbound)
await self.bus.publish_outbound(outbound)
current_inbound = None
raise
except Exception as exc:
self.events.record(
channel_id=inbound.channel,
kind="direct_run_failed",
session_id=inbound.session_id,
message_id=identity.message_id if identity else inbound.message_id,
status="error",
error=str(exc),
)
outbound = AgentService.build_outbound_error(
inbound,
detail=str(exc),
finish_reason="error",
)
else:
self.events.record(
channel_id=outbound.channel,
kind="direct_run_finished",
session_id=outbound.session_id,
message_id=identity.message_id if identity else inbound.message_id,
run_id=outbound.run_id,
)
self._mark_dedupe_result(inbound, outbound)
await self.bus.publish_outbound(outbound)
current_inbound = None
def _mark_dedupe_result(self, inbound: InboundMessage, outbound: OutboundMessage) -> None:
identity = inbound.channel_identity
dedupe_key = identity.dedupe_key() if identity else None
if not dedupe_key:
return
cfg = self.channel_configs.get(identity.channel_id)
max_reply_chars = int((cfg.config if cfg else {}).get("max_cached_reply_chars") or 20000)
max_error_chars = int((cfg.config if cfg else {}).get("max_cached_error_chars") or 4000)
if outbound.finish_reason == "error":
self.dedupe.mark_error(
dedupe_key=dedupe_key,
error=outbound.content,
max_error_chars=max_error_chars,
)
else:
self.dedupe.mark_done(
dedupe_key=dedupe_key,
run_id=outbound.run_id,
reply=outbound.content,
max_reply_chars=max_reply_chars,
)
async def _record_outbound_delivered(self, message: OutboundMessage) -> None:
kind = "outbound_unclaimed" if message.metadata.get("delivery_status") == "unclaimed" else "outbound_delivered"
self.events.record(
channel_id=message.channel,
kind=kind,
session_id=message.session_id,
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
run_id=message.run_id,
)
async def _record_outbound_failed(self, message: OutboundMessage, exc: Exception | None) -> None:
self.events.record(
channel_id=message.channel,
kind="outbound_delivery_failed",
session_id=message.session_id,
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
run_id=message.run_id,
status="error",
error=str(exc) if exc else "channel not registered",
)
def _default_dedupe_retention_hours(self) -> int:
for cfg in self.channel_configs.values():
value = cfg.config.get("dedupe_retention_hours")
if value is not None:
return int(value)
return 48

View File

@ -0,0 +1,198 @@
"""Persistent channel runtime state."""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
def _now_ms() -> int:
return int(time.time() * 1000)
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(slots=True)
class DedupeWriteResult:
created: bool
record: dict[str, Any] | None = None
class ChannelDedupeStore:
def __init__(self, path: Path, *, retention_hours: int = 48) -> None:
self.path = path
self.retention_ms = max(1, int(retention_hours)) * 60 * 60 * 1000
self._lock = Lock()
def get(self, dedupe_key: str) -> dict[str, Any] | None:
with self._lock:
data = self._load()
self._prune_unlocked(data, _now_ms())
record = data["records"].get(dedupe_key)
self._save(data)
return record
def mark_processing(self, *, dedupe_key: str, session_id: str, message_id: str) -> DedupeWriteResult:
with self._lock:
data = self._load()
now_ms = _now_ms()
self._prune_unlocked(data, now_ms)
existing = data["records"].get(dedupe_key)
if existing is not None:
self._save(data)
return DedupeWriteResult(created=False, record=existing)
record = {
"dedupe_key": dedupe_key,
"status": "processing",
"session_id": session_id,
"message_id": message_id,
"run_id": None,
"reply": None,
"error": None,
"created_at_ms": now_ms,
"updated_at_ms": now_ms,
}
data["records"][dedupe_key] = record
self._save(data)
return DedupeWriteResult(created=True, record=record)
def mark_done(
self,
*,
dedupe_key: str,
run_id: str | None,
reply: str,
max_reply_chars: int,
) -> None:
self._mark_result(
dedupe_key=dedupe_key,
status="done",
run_id=run_id,
reply=reply[: max(0, int(max_reply_chars))],
error=None,
)
def mark_error(self, *, dedupe_key: str, error: str, max_error_chars: int) -> None:
self._mark_result(
dedupe_key=dedupe_key,
status="error",
run_id=None,
reply=None,
error=error[: max(0, int(max_error_chars))],
)
def _mark_result(
self,
*,
dedupe_key: str,
status: str,
run_id: str | None,
reply: str | None,
error: str | None,
) -> None:
with self._lock:
data = self._load()
record = data["records"].get(dedupe_key)
if record is None:
record = {"dedupe_key": dedupe_key, "created_at_ms": _now_ms()}
data["records"][dedupe_key] = record
record.update(
{
"status": status,
"run_id": run_id,
"reply": reply,
"error": error,
"updated_at_ms": _now_ms(),
}
)
self._save(data)
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"records": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"records": {}}
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
return {"records": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def _prune_unlocked(self, data: dict[str, Any], now_ms: int) -> None:
records = data.get("records", {})
expired_before = now_ms - self.retention_ms
for key, record in list(records.items()):
updated_at_ms = int(record.get("updated_at_ms") or record.get("created_at_ms") or 0)
if updated_at_ms < expired_before:
records.pop(key, None)
class ChannelEventLog:
def __init__(self, path: Path) -> None:
self.path = path
self._lock = Lock()
def record(
self,
*,
channel_id: str,
kind: str,
session_id: str | None = None,
message_id: str | None = None,
run_id: str | None = None,
status: str = "ok",
error: str | None = None,
text: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
entry = {
"event_id": uuid4().hex,
"channel_id": channel_id,
"kind": kind,
"session_id": session_id,
"message_id": message_id,
"run_id": run_id,
"status": status,
"error": error,
"text_preview": (text or "")[:120] if text else None,
"text_length": len(text) if text else 0,
"metadata": metadata or {},
"created_at": _iso_now(),
}
with self._lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
return entry
def recent(self, *, channel_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
if not self.path.exists():
return []
lines = self.path.read_text(encoding="utf-8").splitlines()
items: list[dict[str, Any]] = []
for line in reversed(lines):
try:
item = json.loads(line)
except json.JSONDecodeError:
continue
if channel_id and item.get("channel_id") != channel_id:
continue
items.append(item)
if len(items) >= max(1, int(limit)):
break
return list(reversed(items))

View File

@ -0,0 +1,301 @@
"""Text-only terminal WebSocket channel adapter."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
try:
from fastapi import WebSocket
from starlette.websockets import WebSocketDisconnect
except ModuleNotFoundError: # pragma: no cover - import-only fallback
class WebSocketDisconnect(Exception):
"""Fallback disconnect exception for skeleton import environments."""
class WebSocket: # type: ignore[override]
"""Fallback websocket annotation shim."""
def _clean(value: Any) -> str:
return str(value or "").strip()
@dataclass(slots=True)
class TerminalConnection:
websocket: WebSocket
peer_id: str
session_id: str
thread_id: str | None = None
user_id: str | None = None
device_name: str = ""
capabilities: list[str] = field(default_factory=list)
class TerminalWebSocketAdapter:
"""Accept text terminal websocket frames and deliver final assistant replies."""
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str = "",
inbound_sink: ChannelInboundSink,
event_recorder: Callable[..., None] | None = None,
heartbeat_seconds: float = 30,
max_message_chars: int = 20000,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name or channel_id
self.inbound_sink = inbound_sink
self.event_recorder = event_recorder
self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
self.max_message_chars = max(1, int(max_message_chars))
self.started = False
self._connections_by_session: dict[str, TerminalConnection] = {}
self._session_by_peer: dict[str, str] = {}
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
for connection in list(self._connections_by_session.values()):
with suppress(Exception):
await connection.websocket.close(code=1001)
self._connections_by_session.clear()
self._session_by_peer.clear()
def status_extra(self) -> dict[str, Any]:
return {"connected_peers": len(self._connections_by_session)}
async def handle_websocket(self, websocket: WebSocket) -> None:
await websocket.accept()
connection: TerminalConnection | None = None
try:
while True:
try:
payload = await websocket.receive_json()
except WebSocketDisconnect:
break
except ValueError:
await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
continue
if not isinstance(payload, dict):
await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
continue
frame_type = _clean(payload.get("type")).lower()
if frame_type == "ping":
await websocket.send_json({"type": "pong"})
continue
if frame_type == "connect":
connection = await self._handle_connect(websocket, payload, current=connection)
continue
if frame_type == "message":
if connection is None:
await websocket.send_json({"type": "error", "error": "connect is required before message"})
continue
await self._handle_message(websocket, connection, payload)
continue
await websocket.send_json(
{
"type": "error",
"error": f"Unsupported websocket frame type: {frame_type or '<empty>'}",
}
)
finally:
if connection is not None:
self._remove_connection(connection)
self._record(
kind="terminal_disconnected",
session_id=connection.session_id,
metadata={"peer_id": connection.peer_id, "device_name": connection.device_name},
)
async def _handle_connect(
self,
websocket: WebSocket,
payload: dict[str, Any],
*,
current: TerminalConnection | None,
) -> TerminalConnection | None:
peer_id = _clean(payload.get("peer_id"))
if not peer_id:
await websocket.send_json({"type": "error", "error": "peer_id is required"})
return current
thread_id = _clean(payload.get("thread_id")) or None
user_id = _clean(payload.get("user_id")) or None
device_name = _clean(payload.get("device_name"))
capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None]
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type="terminal",
user_id=user_id,
)
session_id = identity.session_id()
connection = TerminalConnection(
websocket=websocket,
peer_id=peer_id,
session_id=session_id,
thread_id=thread_id,
user_id=user_id,
device_name=device_name,
capabilities=capabilities,
)
if current is not None and current.session_id != session_id:
self._remove_connection(current)
old = self._connections_by_session.get(session_id)
if old is not None and old.websocket is not websocket:
with suppress(Exception):
await old.websocket.close(code=1000)
self._connections_by_session[session_id] = connection
self._session_by_peer[peer_id] = session_id
self._record(
kind="terminal_connected",
session_id=session_id,
metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities},
)
await websocket.send_json(
{
"type": "connected",
"channel_id": self.channel_id,
"session_id": session_id,
}
)
return connection
async def _handle_message(
self,
websocket: WebSocket,
connection: TerminalConnection,
payload: dict[str, Any],
) -> None:
message_id = _clean(payload.get("message_id"))
text = _clean(payload.get("text"))
if not message_id:
await websocket.send_json({"type": "error", "error": "message_id is required"})
return
if not text:
await websocket.send_json({"type": "error", "error": "text is required"})
return
if len(text) > self.max_message_chars:
await websocket.send_json(
{
"type": "error",
"error": f"text exceeds max_message_chars ({self.max_message_chars})",
}
)
return
thread_id = _clean(payload.get("thread_id")) or connection.thread_id
user_id = _clean(payload.get("user_id")) or connection.user_id
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=connection.peer_id,
thread_id=thread_id,
peer_type="terminal",
user_id=user_id,
message_id=message_id,
)
inbound = InboundMessage(
channel=self.channel_id,
content=text,
content_type="text",
user_id=user_id,
channel_identity=identity,
metadata={
"terminal": {
"peer_id": connection.peer_id,
"device_name": connection.device_name,
"capabilities": connection.capabilities,
}
},
)
accept = await self.inbound_sink.accept_inbound(inbound)
ack: dict[str, Any] = {
"type": "ack",
"message_id": message_id,
"session_id": accept.session_id or identity.session_id(),
"accepted": accept.accepted,
}
if accept.duplicate:
ack["duplicate"] = True
ack["pending"] = accept.pending
record = accept.record or {}
if record.get("reply"):
ack["reply"] = record["reply"]
if accept.error or record.get("error"):
ack["error"] = accept.error or record.get("error")
await websocket.send_json(ack)
async def send(self, message: OutboundMessage) -> None:
session_id = message.session_id
if not session_id and message.channel_identity is not None:
session_id = message.channel_identity.session_id()
connection = self._connections_by_session.get(session_id or "")
if connection is None:
message.metadata["delivery_status"] = "unclaimed"
return
payload = {
"type": "message",
"role": "assistant",
"message_id": message.channel_identity.message_id if message.channel_identity else message.message_id,
"run_id": message.run_id,
"text": message.content,
"finish_reason": message.finish_reason,
}
try:
await connection.websocket.send_json(payload)
except Exception:
message.metadata["delivery_status"] = "unclaimed"
self._remove_connection(connection)
def _remove_connection(self, connection: TerminalConnection) -> None:
current = self._connections_by_session.get(connection.session_id)
if current is connection:
self._connections_by_session.pop(connection.session_id, None)
if self._session_by_peer.get(connection.peer_id) == connection.session_id:
self._session_by_peer.pop(connection.peer_id, None)
def _record(
self,
*,
kind: str,
session_id: str | None = None,
message_id: str | None = None,
status: str = "ok",
error: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
if self.event_recorder is None:
return
self.event_recorder(
channel_id=self.channel_id,
kind=kind,
session_id=session_id,
message_id=message_id,
status=status,
error=error,
metadata=metadata,
)

View File

@ -19,6 +19,18 @@ from typing import Any
from beaver.engine.providers.registry import PROVIDERS, find_by_name
from beaver.foundation.config import default_config_path, load_config
from beaver.foundation.events import ChannelIdentity, InboundMessage
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
ConnectorSidecarClient,
CredentialStore,
FeishuConnector,
MessageDedupeStore,
TelegramConnector,
WeixinConnector,
)
from beaver.foundation.models import CronExecutionResult, CronRunRecord
from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
@ -53,6 +65,16 @@ from .schemas import (
WebErrorResponse,
WebAgentConfigRequest,
WebAgentConfigResponse,
WebChannelConfigRequest,
WebChannelConfigResponse,
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
WebConnectorBridgeEventRequest,
WebConnectorBridgeEventResponse,
WebConnectorSessionCreateRequest,
WebConnectorSessionResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebStatusResponse,
@ -60,7 +82,7 @@ from .schemas import (
try:
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from fastapi.responses import JSONResponse, Response
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
def File(default: Any = None) -> Any: # type: ignore[override]
return default
@ -94,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
self.media_type = media_type
self.headers = headers or {}
class JSONResponse(Response): # type: ignore[override]
def __init__(self, content: Any, status_code: int = 200) -> None:
super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json")
self.status_code = status_code
class WebSocketDisconnect(Exception):
"""Fallback websocket disconnect exception."""
@ -183,7 +210,9 @@ async def _app_lifespan(
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
app.state.agent_service = attached_service
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
app.state.channel_runtime = None
started = False
channel_runtime: ChannelRuntime | None = None
if owns_service:
try:
await attached_service.start()
@ -200,6 +229,29 @@ async def _app_lifespan(
else:
attached_service.close()
raise
try:
loaded = attached_service.create_loop().boot()
app.state.channel_connection_workspace = loaded.workspace
connector_registry = _build_channel_connector_registry(loaded.workspace)
app.state.channel_connector_registry = connector_registry
connection_channels = await connector_registry.materialize_channel_configs()
runtime_channels = dict(loaded.config.channels)
runtime_channels.update(connection_channels)
channel_runtime = ChannelRuntime(
service=attached_service,
workspace=loaded.workspace,
channels=runtime_channels,
)
app.state.channel_runtime = channel_runtime
await channel_runtime.start()
except BaseException:
if owns_service and started:
with suppress(BaseException):
await attached_service.shutdown(
timeout_seconds=shutdown_timeout_seconds,
force=shutdown_force,
)
raise
worker: SkillLearningWorker | None = None
worker_task = None
worker_config = SkillLearningWorkerConfig.from_env()
@ -216,6 +268,10 @@ async def _app_lifespan(
try:
yield
finally:
runtime = getattr(app.state, "channel_runtime", None)
if isinstance(runtime, ChannelRuntime):
with suppress(BaseException):
await runtime.stop()
cron_service = getattr(app.state, "cron_service", None)
if isinstance(cron_service, CronService):
cron_service.stop()
@ -283,6 +339,118 @@ def get_cron_service(request: Request) -> CronService:
return service
def get_channel_runtime(request: Request) -> ChannelRuntime:
runtime = getattr(request.app.state, "channel_runtime", None)
if not isinstance(runtime, ChannelRuntime):
raise HTTPException(status_code=503, detail="Channel runtime is not running")
return runtime
def _connection_state_dir(workspace: Path) -> Path:
return Path(workspace) / "state" / "channel_connections"
def _channel_connection_workspace(request: Request) -> Path:
workspace = getattr(request.app.state, "channel_connection_workspace", None)
if workspace is not None:
return Path(workspace)
return Path(get_agent_service(request).loader.workspace)
def _message_dedupe_store(workspace: Path) -> MessageDedupeStore:
return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json")
def _bridge_token() -> str:
return os.getenv("BEAVER_BRIDGE_TOKEN", "")
def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry:
state_dir = _connection_state_dir(workspace)
connection_store = ChannelConnectionStore(state_dir / "connections.json")
credential_store = CredentialStore(state_dir / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(
TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
)
)
sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787")
sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token)
registry.register(
WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=sidecar_client,
sidecar_base_url=sidecar_base_url,
)
)
registry.register(
FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=sidecar_client,
sidecar_base_url=sidecar_base_url,
)
)
return registry
def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry:
registry = getattr(request.app.state, "channel_connector_registry", None)
if isinstance(registry, ChannelConnectorRegistry):
return registry
workspace = getattr(request.app.state, "channel_connection_workspace", None)
if workspace is None:
raise RuntimeError("Channel connector registry unavailable before service boot")
registry = _build_channel_connector_registry(workspace)
request.app.state.channel_connector_registry = registry
return registry
def _connection_response_view(connection: Any) -> dict[str, Any]:
view = connection.to_dict()
view.pop("credentials_ref", None)
view.pop("connector_ref", None)
view.pop("pairing_session_id", None)
return view
def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(config, dict):
return {}
return {
_camel_to_snake_text(str(key)): value
for key, value in config.items()
if str(key).strip()
}
def _camel_to_snake_text(value: str) -> str:
result: list[str] = []
for char in value.strip():
if char.isupper() and result:
result.append("_")
result.append(char.lower())
return "".join(result)
def _self_restart_enabled() -> bool:
return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"}
def _schedule_self_restart(delay_seconds: float = 0.75) -> None:
import threading
def _exit_later() -> None:
time.sleep(delay_seconds)
os._exit(0)
threading.Thread(target=_exit_later, daemon=True).start()
def create_app(
*,
workspace: str | Path | None = None,
@ -380,10 +548,330 @@ def create_app(
"temperature": agent_service.profile.temperature,
"max_tool_iterations": agent_service.profile.max_tool_iterations,
"providers": providers_status,
"channels": [{"name": "web", "enabled": True}],
"channels": get_channel_runtime(request).statuses(),
"runtime_controls": {"self_restart": _self_restart_enabled()},
"cron": cron_service.status(),
}
@app.get("/api/channels")
async def list_channels(request: Request) -> list[dict[str, Any]]:
return get_channel_runtime(request).statuses()
@app.get("/api/channel-connectors")
async def list_channel_connectors(request: Request) -> list[dict[str, str]]:
return get_channel_connector_registry(request).connectors()
@app.get("/api/channel-connections")
async def list_channel_connections(request: Request) -> list[dict[str, Any]]:
registry = get_channel_connector_registry(request)
return [_connection_response_view(connection) for connection in registry.connection_store.list()]
@app.post("/api/channel-connections", response_model=WebChannelConnectionResponse)
async def create_channel_connection(
request: Request,
payload: WebChannelConnectionCreateRequest,
) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
kind = _clean_text(payload.kind)
mode = _clean_text(payload.mode)
if not kind:
raise HTTPException(status_code=400, detail="Connection kind is required")
if not mode:
raise HTTPException(status_code=400, detail="Connection mode is required")
secrets_payload = payload.secrets or {}
secrets = {key: value for key, value in secrets_payload.items() if value}
credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None
connection = registry.connection_store.create(
kind=kind,
mode=mode,
display_name=_clean_text(payload.display_name) or kind,
account_id=_clean_text(payload.account_id) or "",
owner_user_id=_clean_text(payload.owner_user_id) or None,
auth_type=_clean_text(payload.auth_type) or "token",
credentials_ref=credentials_ref,
runtime_config=_normalize_connection_config(payload.config),
)
return WebChannelConnectionResponse(
connection=_connection_response_view(connection),
credentials=registry.credential_store.redacted(credentials_ref),
)
@app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
async def update_channel_connection(
connection_id: str,
request: Request,
payload: WebChannelConnectionUpdateRequest,
) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
try:
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
if payload.display_name is not None:
connection.display_name = _clean_text(payload.display_name) or connection.display_name
if payload.account_id is not None:
connection.account_id = _clean_text(payload.account_id) or connection.account_id
if payload.config is not None:
connection.runtime_config = _normalize_connection_config(payload.config)
if payload.secrets:
secrets = {key: value for key, value in payload.secrets.items() if value}
if secrets:
# TODO: add credential GC when connection updates credentials.
connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets)
connection = registry.connection_store.update(connection)
return WebChannelConnectionResponse(
connection=_connection_response_view(connection),
credentials=registry.credential_store.redacted(connection.credentials_ref),
)
@app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
try:
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
return WebChannelConnectionResponse(
connection=_connection_response_view(connection),
credentials=registry.credential_store.redacted(connection.credentials_ref),
)
@app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse)
async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse:
registry = get_channel_connector_registry(request)
try:
result = await registry.validate(connection_id)
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
return WebChannelValidationResponse(
ok=result.ok,
status=result.status,
account_id=result.account_id,
display_name=result.display_name,
error=result.error,
metadata=result.metadata,
connection=_connection_response_view(connection),
)
@app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse)
async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
try:
await registry.revoke(connection_id)
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={})
@app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse)
async def start_channel_connector_session(
request: Request,
payload: WebConnectorSessionCreateRequest,
) -> WebConnectorSessionResponse:
registry = get_channel_connector_registry(request)
kind = _clean_text(payload.kind)
try:
connector = registry.connector_for_kind(kind)
except KeyError:
raise HTTPException(status_code=404, detail="Connector not found")
start_session = getattr(connector, "start_session", None)
if start_session is None:
raise HTTPException(status_code=400, detail="Connector does not support sessions")
view = await start_session(
display_name=_clean_text(payload.display_name) or kind,
owner_user_id=_clean_text(payload.owner_user_id) or None,
options=payload.options,
)
connection_id = _clean_text(view.get("connectionId"))
connection_view = None
if connection_id:
connection_view = _connection_response_view(registry.connection_store.get(connection_id))
return WebConnectorSessionResponse(session=view, connection=connection_view)
@app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
registry = get_channel_connector_registry(request)
connection = next(
(item for item in registry.connection_store.list() if item.pairing_session_id == session_id),
None,
)
if connection is None:
raise HTTPException(status_code=404, detail="Connector session not found")
connector = registry.connector_for_kind(connection.kind)
poll_session = getattr(connector, "poll_session", None)
if poll_session is None:
raise HTTPException(status_code=400, detail="Connector does not support sessions")
view = await poll_session(session_id)
connection = registry.connection_store.get(connection.connection_id)
if connection.status == "connected":
runtime = get_channel_runtime(request)
config = (await registry.materialize_channel_configs())[connection.channel_id]
await runtime.add_channel(connection.channel_id, config)
return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
@app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
async def accept_connector_bridge_event(
request: Request,
payload: WebConnectorBridgeEventRequest,
authorization: str | None = Header(default=None),
) -> Any:
expected = _bridge_token()
if not expected or authorization != f"Bearer {expected}":
raise HTTPException(status_code=401, detail="Invalid connector bridge token")
registry = get_channel_connector_registry(request)
try:
connection = registry.connection_store.get(payload.connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
if connection.status == "revoked":
raise HTTPException(status_code=404, detail="Channel connection not found")
store = _message_dedupe_store(_channel_connection_workspace(request))
begin = store.begin(
connection_id=payload.connection_id,
event_id=payload.event_id,
delivery_attempt=payload.delivery_attempt,
)
if not begin.should_process:
body = WebConnectorBridgeEventResponse(
accepted=begin.http_status == 200,
duplicate=True,
pending=begin.http_status == 409,
retryAfterSeconds=begin.retry_after_seconds,
).model_dump(by_alias=True)
return JSONResponse(status_code=begin.http_status, content=body)
runtime = get_channel_runtime(request)
identity = ChannelIdentity(
channel_id=payload.channel_id,
kind=payload.kind,
account_id=payload.account_id,
peer_id=payload.peer_id,
thread_id=payload.thread_id,
peer_type=payload.peer_type,
user_id=payload.user_id,
message_id=payload.message_id,
)
inbound = InboundMessage(
channel=payload.channel_id,
content=payload.content,
content_type=payload.message_type,
channel_identity=identity,
user_id=payload.user_id,
message_id=payload.message_id,
metadata=dict(payload.metadata),
)
result = await runtime.accept_inbound(inbound)
if result.accepted or result.duplicate:
store.complete(begin.dedupe_key, message_id=payload.message_id)
else:
store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event")
return WebConnectorBridgeEventResponse(
accepted=result.accepted,
duplicate=result.duplicate,
pending=result.pending,
)
@app.get("/api/channels/{channel_id}/config")
async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
channel = _ensure_dict(raw, "channels").get(channel_id)
if not isinstance(channel, dict):
raise HTTPException(status_code=404, detail="Channel not found")
return _channel_config_view(channel_id, channel)
@app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse)
async def update_channel_config(
channel_id: str,
request: Request,
payload: WebChannelConfigRequest,
) -> WebChannelConfigResponse:
if not _clean_text(channel_id):
raise HTTPException(status_code=400, detail="Channel id is required")
kind = _clean_text(payload.kind)
mode = _clean_text(payload.mode)
if not kind:
raise HTTPException(status_code=400, detail="Channel kind is required")
if not mode:
raise HTTPException(status_code=400, detail="Channel mode is required")
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
channels = _ensure_dict(raw, "channels")
current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {}
current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {}
next_secrets = dict(current_secrets)
for key, value in (payload.secrets or {}).items():
cleaned_key = _clean_text(key)
cleaned_value = _clean_text(value)
if not cleaned_key or not cleaned_value:
continue
next_secrets[cleaned_key] = cleaned_value
channel_payload: dict[str, Any] = {
"enabled": bool(payload.enabled),
"kind": kind,
"mode": mode,
"accountId": _clean_text(payload.account_id) or "",
"displayName": _clean_text(payload.display_name) or channel_id,
"config": payload.config or {},
"secrets": next_secrets,
}
channels[channel_id] = channel_payload
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return WebChannelConfigResponse(
ok=True,
channel_id=channel_id,
restart_required=True,
channel=_channel_config_view(channel_id, channel_payload),
)
@app.get("/api/channels/{channel_id}/events")
async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]:
return get_channel_runtime(request).recent_events(channel_id, limit=limit)
@app.post("/api/channels/{channel_id}/webhook")
async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse:
runtime = get_channel_runtime(request)
adapter = runtime.adapters.get(channel_id)
if adapter is None or not hasattr(adapter, "handle_webhook_payload"):
raise HTTPException(status_code=404, detail="Webhook channel not found")
payload = await request.json()
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object")
result = await adapter.handle_webhook_payload(payload) # type: ignore[attr-defined]
status_code = 202 if result.get("pending") else 200
return JSONResponse(result, status_code=status_code)
@app.websocket("/api/channels/{channel_id}/ws")
async def channel_websocket(websocket: WebSocket, channel_id: str) -> None:
runtime = getattr(websocket.app.state, "channel_runtime", None)
if not isinstance(runtime, ChannelRuntime):
await websocket.accept()
await websocket.send_json({"type": "error", "error": "Channel runtime is not running"})
await websocket.close(code=1011)
return
adapter = runtime.adapters.get(channel_id)
if adapter is None or not hasattr(adapter, "handle_websocket"):
await websocket.accept()
await websocket.send_json({"type": "error", "error": "WebSocket channel not found"})
await websocket.close(code=1008)
return
await adapter.handle_websocket(websocket) # type: ignore[attr-defined]
@app.post("/api/runtime/restart")
async def restart_runtime() -> JSONResponse:
if not _self_restart_enabled():
raise HTTPException(status_code=403, detail="Self restart is disabled")
_schedule_self_restart()
return JSONResponse({"ok": True, "restarting": True}, status_code=202)
@app.post("/api/auth/login")
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
username = _clean_text(payload.get("username"))
@ -3011,6 +3499,25 @@ def _mask_secret(value: str | None) -> str:
return f"{secret[:4]}••••{secret[-4:]}"
def _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]:
secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {}
config_payload = data.get("config") if isinstance(data.get("config"), dict) else {}
return {
"channel_id": channel_id,
"enabled": bool(data.get("enabled")),
"kind": _clean_text(data.get("kind")) or "",
"mode": _clean_text(data.get("mode")) or "webhook",
"account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "",
"display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id,
"config": dict(config_payload),
"secrets": {
str(key): _mask_secret(str(value) if value is not None else None)
for key, value in secrets_payload.items()
if str(key).strip()
},
}
def _read_config_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
@ -3082,7 +3589,14 @@ def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None
old_manager = getattr(loaded, "mcp_manager", None)
if old_manager is not None:
async def _close_old_manager() -> None:
await old_manager.close()
try:
await old_manager.close()
except Exception:
# MCP transports may own anyio cancel scopes created by a
# previous request task. Config reload must not leak that
# cleanup failure as an unhandled background exception or
# knock the app out of running mode.
pass
try:
running_loop = asyncio.get_running_loop()

View File

@ -10,6 +10,16 @@ from .chat import (
WebErrorResponse,
WebAgentConfigRequest,
WebAgentConfigResponse,
WebChannelConfigRequest,
WebChannelConfigResponse,
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
WebConnectorBridgeEventRequest,
WebConnectorBridgeEventResponse,
WebConnectorSessionCreateRequest,
WebConnectorSessionResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebProviderTarget,
@ -26,6 +36,16 @@ __all__ = [
"WebErrorResponse",
"WebAgentConfigRequest",
"WebAgentConfigResponse",
"WebChannelConfigRequest",
"WebChannelConfigResponse",
"WebChannelConnectionCreateRequest",
"WebChannelConnectionResponse",
"WebChannelConnectionUpdateRequest",
"WebChannelValidationResponse",
"WebConnectorBridgeEventRequest",
"WebConnectorBridgeEventResponse",
"WebConnectorSessionCreateRequest",
"WebConnectorSessionResponse",
"WebProviderConfigRequest",
"WebProviderConfigResponse",
"WebProviderTarget",

View File

@ -139,6 +139,113 @@ class WebProviderConfigResponse(BaseModel):
enabled: bool
class WebChannelConfigRequest(BaseModel):
"""Channel config update from the settings page."""
enabled: bool = False
kind: str
mode: str
account_id: str | None = None
display_name: str | None = None
config: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, str | None] = Field(default_factory=dict)
class WebChannelConfigResponse(BaseModel):
"""Channel config update result."""
ok: bool
channel_id: str
restart_required: bool
channel: dict[str, Any]
class WebChannelConnectionCreateRequest(BaseModel):
"""Create a channel connection from the setup UI."""
kind: str
mode: str
display_name: str | None = Field(default=None, alias="displayName")
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
auth_type: str = Field(default="token", alias="authType")
account_id: str | None = Field(default=None, alias="accountId")
config: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, str | None] = Field(default_factory=dict)
class WebChannelConnectionResponse(BaseModel):
"""Channel connection response with redacted credentials."""
connection: dict[str, Any]
credentials: dict[str, str] = Field(default_factory=dict)
class WebChannelConnectionUpdateRequest(BaseModel):
"""Update editable channel connection setup fields."""
display_name: str | None = Field(default=None, alias="displayName")
account_id: str | None = Field(default=None, alias="accountId")
config: dict[str, Any] | None = None
secrets: dict[str, str | None] | None = None
class WebChannelValidationResponse(BaseModel):
"""Connector validation response."""
ok: bool
status: str
account_id: str | None = None
display_name: str | None = None
error: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
connection: dict[str, Any]
class WebConnectorBridgeEventRequest(BaseModel):
"""Inbound connector bridge event from the external sidecar."""
event_id: str = Field(alias="eventId")
timestamp: str
delivery_attempt: int = Field(default=1, alias="deliveryAttempt")
connection_id: str = Field(alias="connectionId")
channel_id: str = Field(alias="channelId")
kind: str
account_id: str = Field(alias="accountId")
peer_id: str = Field(alias="peerId")
peer_type: str = Field(default="unknown", alias="peerType")
user_id: str | None = Field(default=None, alias="userId")
thread_id: str | None = Field(default=None, alias="threadId")
message_id: str = Field(alias="messageId")
message_type: str = Field(default="text", alias="messageType")
content: str
metadata: dict[str, Any] = Field(default_factory=dict)
class WebConnectorBridgeEventResponse(BaseModel):
"""Connector bridge event accept/dedupe response."""
accepted: bool
duplicate: bool = False
pending: bool = False
retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds")
class WebConnectorSessionCreateRequest(BaseModel):
"""Start a connector-managed onboarding session."""
kind: str
display_name: str | None = Field(default=None, alias="displayName")
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
options: dict[str, Any] = Field(default_factory=dict)
class WebConnectorSessionResponse(BaseModel):
"""Connector session view plus optional connection view."""
session: dict[str, Any]
connection: dict[str, Any] | None = None
class WebAgentConfigRequest(BaseModel):
"""Agent runtime defaults update from the settings page."""

View File

@ -1237,17 +1237,19 @@ class AgentService:
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
channel_identity = inbound.channel_identity
try:
result = await self.submit_direct(
inbound.content,
session_id=inbound.session_id,
source=f"gateway:{inbound.channel}",
user_id=inbound.user_id,
user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None),
title=inbound.title,
execution_context=inbound.execution_context,
model=inbound.model,
provider_name=inbound.provider_name,
embedding_model=inbound.embedding_model,
channel_identity=channel_identity,
)
except Exception as exc:
return self.build_outbound_error(
@ -1283,6 +1285,8 @@ class AgentService:
finish_reason=result.finish_reason,
provider_name=result.provider_name,
model=result.model,
content_type=inbound.content_type,
channel_identity=inbound.channel_identity,
usage=dict(result.usage),
metadata={
"inbound_metadata": dict(inbound.metadata),
@ -1308,6 +1312,8 @@ class AgentService:
session_id=inbound.session_id,
content=detail,
finish_reason=finish_reason,
content_type=inbound.content_type,
channel_identity=inbound.channel_identity,
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
)

View File

@ -22,6 +22,23 @@ dependencies = [
dev = [
"pytest>=9.0.0,<10.0.0",
]
telegram = [
"python-telegram-bot>=22.0,<23.0",
]
feishu = [
"lark-oapi>=1.4.22,<2.0.0",
]
qqbot = [
"aiohttp>=3.9.0,<4.0.0",
]
weixin = [
"aiohttp>=3.9.0,<4.0.0",
]
channels = [
"python-telegram-bot>=22.0,<23.0",
"lark-oapi>=1.4.22,<2.0.0",
"aiohttp>=3.9.0,<4.0.0",
]
[project.scripts]
beaver = "beaver.interfaces.cli.main:main"

View File

@ -0,0 +1,84 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
try:
with TestClient(app) as client:
created = client.post(
"/api/channel-connections",
json={
"kind": "telegram",
"mode": "polling",
"displayName": "Telegram Main",
"authType": "token",
"secrets": {"botToken": "token-1"},
"config": {"maxMessageChars": 4096, "requireMentionInGroups": True},
},
)
assert created.status_code == 200
body = created.json()
connection_id = body["connection"]["connection_id"]
assert body["connection"]["kind"] == "telegram"
assert body["connection"]["status"] == "draft"
assert "credentials_ref" not in body["connection"]
assert body["connection"]["runtime_config"] == {
"max_message_chars": 4096,
"require_mention_in_groups": True,
}
assert body["credentials"] == {"botToken": "***"}
patched = client.patch(
f"/api/channel-connections/{connection_id}",
json={
"displayName": "Telegram Ops",
"config": {"maxMessageChars": 2048},
"secrets": {"botToken": "token-2"},
},
)
assert patched.status_code == 200
assert patched.json()["connection"]["display_name"] == "Telegram Ops"
assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048}
assert patched.json()["credentials"] == {"botToken": "***"}
listed = client.get("/api/channel-connections")
assert listed.status_code == 200
assert listed.json()[0]["connection_id"] == connection_id
assert "credentials_ref" not in listed.json()[0]
revoked = client.post(f"/api/channel-connections/{connection_id}/revoke")
assert revoked.status_code == 200
assert revoked.json()["connection"]["status"] == "revoked"
finally:
service.close()
def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
try:
with TestClient(app) as client:
response = client.get("/api/channel-connectors")
finally:
service.close()
assert response.status_code == 200
assert response.json() == [{"kind": "feishu"}, {"kind": "telegram"}, {"kind": "weixin"}]

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
CredentialStore,
PairingTokenStore,
)
def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None:
store = ChannelConnectionStore(tmp_path / "connections.json")
created = store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:bot-main",
owner_user_id="user-1",
auth_type="token",
runtime_config={"max_message_chars": 4096},
capabilities=["receive_text", "send_text"],
)
updated = store.update_status(created.connection_id, status="connected", last_error=None)
revoked = store.revoke(created.connection_id)
assert created.connection_id
assert created.channel_id.startswith("telegram-")
assert created.status == "draft"
assert updated.status == "connected"
assert revoked.status == "revoked"
assert store.get(created.connection_id).status == "revoked"
assert [item.connection_id for item in store.list()] == [created.connection_id]
def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None:
store = CredentialStore(tmp_path / "credentials.json")
ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""})
assert ref.startswith("cred_")
assert store.get(ref) == {"botToken": "secret-token"}
assert store.redacted(ref) == {"botToken": "***"}
def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None:
store = PairingTokenStore(tmp_path / "pairing.json")
session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair")
consumed = store.consume(session.token, expected_kind="terminal")
reused = store.consume(session.token, expected_kind="terminal")
assert session.status == "pending"
assert consumed is not None
assert consumed.status == "consumed"
assert reused is None
def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None:
store = PairingTokenStore(tmp_path / "pairing.json")
session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair")
assert store.consume(session.token, expected_kind="weixin") is None

View File

@ -0,0 +1,164 @@
from __future__ import annotations
import asyncio
from beaver.foundation.config.schema import ChannelConfig
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
ChannelRuntimeSpec,
CredentialStore,
ValidationResult,
)
class FakeConnector:
kind = "fake"
def __init__(self) -> None:
self.validated: list[str] = []
self.revoked: list[str] = []
async def validate(self, connection_id: str) -> ValidationResult:
self.validated.append(connection_id)
return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake")
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
return ChannelRuntimeSpec(
channel_id="fake-channel",
kind="fake",
mode="webhook",
account_id="fake-account",
display_name="Fake",
config={"enabled": True},
)
async def revoke(self, connection_id: str) -> None:
self.revoked.append(connection_id)
return None
def test_connector_registry_dispatches_by_kind(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connector = FakeConnector()
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(connector)
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Fake",
account_id="fake-account",
owner_user_id=None,
auth_type="token",
)
result = await registry.validate(connection.connection_id)
spec = await registry.materialize_runtime(connection.connection_id)
assert result.ok is True
assert connector.validated == [connection.connection_id]
assert spec.channel_id == "fake-channel"
asyncio.run(run())
def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Connected",
account_id="connected",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
class CredentialAwareConnector(FakeConnector):
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
stored = connection_store.get(connection_id)
return ChannelRuntimeSpec(
channel_id="fake-channel",
kind="fake",
mode="webhook",
account_id="fake-account",
display_name="Fake",
config={"enabled": True},
secrets_ref=stored.credentials_ref,
)
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(CredentialAwareConnector())
configs = await registry.materialize_channel_configs()
assert isinstance(configs["fake-channel"], ChannelConfig)
assert configs["fake-channel"].enabled is True
assert configs["fake-channel"].secrets == {"botToken": "token-1"}
asyncio.run(run())
def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(FakeConnector())
draft = connection_store.create(
kind="fake",
mode="webhook",
display_name="Draft",
account_id="draft",
owner_user_id=None,
auth_type="token",
)
connected = connection_store.create(
kind="fake",
mode="webhook",
display_name="Connected",
account_id="connected",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connected.connection_id, status="connected", last_error=None)
specs = await registry.materialize_connected_runtime_specs()
assert [spec.channel_id for spec in specs] == ["fake-channel"]
assert connection_store.get(draft.connection_id).status == "draft"
asyncio.run(run())
def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connector = FakeConnector()
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(connector)
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Fake",
account_id="fake-account",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
await registry.revoke(connection.connection_id)
assert connector.revoked == [connection.connection_id]
assert connection_store.get(connection.connection_id).status == "revoked"
asyncio.run(run())

View File

@ -0,0 +1,414 @@
import asyncio
import json
from fastapi.testclient import TestClient
from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.foundation.events import MessageBus
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
from beaver.interfaces.web.app import _self_restart_enabled, create_app
from beaver.services.agent_service import AgentService
def test_channel_identity_builds_stable_session_id() -> None:
identity = ChannelIdentity(
channel_id="webhook-dev",
kind="webhook",
account_id="local",
peer_id="demo-user",
thread_id="main",
peer_type="dm",
message_id="msg-1",
)
assert identity.session_id() == "webhook-dev:local:demo-user:main"
assert identity.dedupe_key() == "webhook-dev:local:demo-user:main:msg-1"
def test_channel_identity_requires_routing_fields() -> None:
identity = ChannelIdentity(channel_id="webhook-dev", kind="webhook", account_id="", peer_id="demo")
assert identity.validation_error() == "account_id is required"
def test_messages_carry_channel_identity() -> None:
identity = ChannelIdentity(
channel_id="webhook-dev",
kind="webhook",
account_id="local",
peer_id="demo-user",
message_id="msg-1",
)
inbound = InboundMessage(channel="webhook-dev", content="hello", channel_identity=identity)
outbound = OutboundMessage(
channel="webhook-dev",
content="ok",
session_id=identity.session_id(),
finish_reason="stop",
channel_identity=identity,
)
assert inbound.channel_identity is identity
assert outbound.channel_identity is identity
def test_dedupe_store_tracks_processing_and_done(tmp_path) -> None:
store = ChannelDedupeStore(tmp_path / "dedupe.json", retention_hours=48)
created = store.mark_processing(
dedupe_key="webhook-dev:local:demo:msg-1",
session_id="webhook-dev:local:demo",
message_id="msg-1",
)
duplicate = store.mark_processing(
dedupe_key="webhook-dev:local:demo:msg-1",
session_id="webhook-dev:local:demo",
message_id="msg-1",
)
assert created.created is True
assert duplicate.created is False
assert duplicate.record is not None
assert duplicate.record["status"] == "processing"
store.mark_done(
dedupe_key="webhook-dev:local:demo:msg-1",
run_id="run-1",
reply="hello" * 10000,
max_reply_chars=20,
)
done = store.get("webhook-dev:local:demo:msg-1")
assert done is not None
assert done["status"] == "done"
assert done["reply"] == "hellohellohellohello"
def test_channel_event_log_writes_recent_events(tmp_path) -> None:
log = ChannelEventLog(tmp_path / "events.jsonl")
log.record(
channel_id="webhook-dev",
kind="inbound_accepted",
session_id="webhook-dev:local:demo",
message_id="msg-1",
status="ok",
text="hello world",
)
events = log.recent(channel_id="webhook-dev", limit=10)
assert len(events) == 1
assert events[0]["kind"] == "inbound_accepted"
assert events[0]["text_preview"] == "hello world"
assert "raw_channel_payload" not in json.dumps(events[0])
class FakeAgentService:
is_running = True
async def handle_inbound_message(self, inbound):
return OutboundMessage(
message_id=inbound.message_id,
channel=inbound.channel,
content=f"echo:{inbound.content}",
session_id=inbound.session_id,
finish_reason="stop",
run_id="run-1",
channel_identity=inbound.channel_identity,
)
class SlowFakeAgentService(FakeAgentService):
async def handle_inbound_message(self, inbound):
await asyncio.sleep(0.05)
return await super().handle_inbound_message(inbound)
def test_channel_runtime_accept_inbound_normalizes_session_and_dedupes(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(
service=FakeAgentService(),
bus=bus,
workspace=tmp_path,
channels={},
)
identity = ChannelIdentity(
channel_id="webhook-dev",
kind="webhook",
account_id="local",
peer_id="demo",
message_id="msg-1",
)
result = await runtime.accept_inbound(
InboundMessage(
channel="webhook-dev",
content="hello",
session_id="wrong",
channel_identity=identity,
)
)
duplicate = await runtime.accept_inbound(
InboundMessage(
channel="webhook-dev",
content="hello",
channel_identity=identity,
)
)
queued = await bus.consume_inbound()
assert result.accepted is True
assert queued.session_id == "webhook-dev:local:demo"
assert duplicate.accepted is False
assert duplicate.duplicate is True
asyncio.run(run())
def test_generic_webhook_adapter_waits_for_outbound_reply(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(
service=FakeAgentService(),
bus=bus,
workspace=tmp_path,
channels={},
)
adapter = GenericWebhookAdapter(
channel_id="webhook-dev",
kind="webhook",
mode="webhook",
account_id="local",
display_name="Webhook Dev",
inbound_sink=runtime,
response_timeout_seconds=1,
)
runtime.manager.register(adapter)
await runtime.start()
try:
response = await adapter.handle_webhook_payload(
{
"peer_id": "demo",
"message_id": "msg-1",
"text": "hello",
"peer_type": "dm",
}
)
finally:
await runtime.stop()
assert response["ok"] is True
assert response["reply"] == "echo:hello"
assert response["session_id"] == "webhook-dev:local:demo"
asyncio.run(run())
def test_generic_webhook_records_unclaimed_outbound_after_timeout(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(
service=SlowFakeAgentService(),
bus=bus,
workspace=tmp_path,
channels={},
)
adapter = GenericWebhookAdapter(
channel_id="webhook-dev",
kind="webhook",
mode="webhook",
account_id="local",
display_name="Webhook Dev",
inbound_sink=runtime,
response_timeout_seconds=1,
)
adapter.response_timeout_seconds = 0.01
runtime.manager.register(adapter)
await runtime.start()
try:
response = await adapter.handle_webhook_payload(
{
"peer_id": "demo",
"message_id": "msg-1",
"text": "hello",
"peer_type": "dm",
}
)
await asyncio.sleep(0.1)
events = runtime.recent_events("webhook-dev", limit=20)
finally:
await runtime.stop()
assert response["pending"] is True
assert any(event["kind"] == "outbound_unclaimed" for event in events)
asyncio.run(run())
def test_channel_runtime_starts_enabled_generic_webhook_and_reports_status(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={
"webhook-dev": ChannelConfig(
enabled=True,
kind="webhook",
mode="webhook",
account_id="local",
display_name="Webhook Dev",
config={"response_timeout_seconds": 1800},
),
"off": ChannelConfig(
enabled=False,
kind="webhook",
mode="webhook",
account_id="local",
),
},
)
await runtime.start()
try:
statuses = runtime.statuses()
finally:
await runtime.stop()
by_id = {item["channel_id"]: item for item in statuses}
assert by_id["webhook-dev"]["state"] == "running"
assert by_id["webhook-dev"]["webhook_url"] == "/api/channels/webhook-dev/webhook"
assert by_id["off"]["state"] == "disabled"
asyncio.run(run())
def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={},
)
cases = {
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
"feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"),
"qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"),
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
}
for channel_id, cfg in cases.items():
adapter = runtime._build_adapter(channel_id, cfg)
assert adapter.channel_id == channel_id
assert adapter.kind == cfg.kind
assert adapter.mode == cfg.mode
def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
},
)
by_id = {item["channel_id"]: item for item in runtime.statuses()}
assert by_id["telegram-main"]["capabilities"] == [
"receive_text",
"send_text",
"receive_media",
"groups",
]
assert by_id["weixin-main"]["capabilities"] == [
"receive_text",
"send_text",
"receive_media",
"direct_messages",
]
def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={
"telegram-main": ChannelConfig(
enabled=True,
kind="telegram",
mode="polling",
account_id="bot-main",
secrets={},
),
"off": ChannelConfig(
enabled=False,
kind="weixin",
mode="polling",
account_id="wx-main",
),
},
)
await runtime.start()
try:
by_id = {item["channel_id"]: item for item in runtime.statuses()}
finally:
await runtime.stop()
assert by_id["telegram-main"]["state"] == "error"
assert "botToken" in by_id["telegram-main"]["last_error"]
assert by_id["off"]["state"] == "disabled"
asyncio.run(run())
def test_web_app_status_exposes_configured_channel(tmp_path) -> None:
config_path = tmp_path / "config.json"
workspace = tmp_path / "workspace"
workspace.mkdir()
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
"providers": {},
"channels": {
"webhook-dev": {
"enabled": True,
"kind": "webhook",
"mode": "webhook",
"accountId": "local",
"displayName": "Webhook Dev",
}
},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
payload = client.get("/api/status").json()
service.close()
assert payload["channels"][0]["channel_id"] == "webhook-dev"
assert payload["channels"][0]["state"] == "running"
assert payload["channels"][0]["webhook_url"] == "/api/channels/webhook-dev/webhook"
assert payload["runtime_controls"]["self_restart"] is True
def test_self_restart_env_defaults_enabled(monkeypatch) -> None:
monkeypatch.delenv("BEAVER_ENABLE_SELF_RESTART", raising=False)
assert _self_restart_enabled() is True
def test_self_restart_env_can_disable(monkeypatch) -> None:
monkeypatch.setenv("BEAVER_ENABLE_SELF_RESTART", "0")
assert _self_restart_enabled() is False

View File

@ -0,0 +1,119 @@
from __future__ import annotations
import asyncio
from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import MessageBus, OutboundMessage
from beaver.interfaces.channels.runtime import ChannelRuntime
class FakeService:
async def handle_inbound_message(self, inbound):
return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop")
def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"webhook-dev",
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
)
assert "webhook-dev" in runtime.adapters
assert runtime.states["webhook-dev"]["state"] == "running"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None:
async def run() -> None:
cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel("webhook-dev", cfg)
first = runtime.adapters["webhook-dev"]
await runtime.add_channel("webhook-dev", cfg)
assert runtime.adapters["webhook-dev"] is first
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None:
async def run() -> None:
good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel("webhook-dev", good)
old = runtime.adapters["webhook-dev"]
try:
await runtime.add_channel("webhook-dev", bad)
except ValueError:
pass
else:
raise AssertionError("Expected ValueError")
assert runtime.adapters["webhook-dev"] is old
assert runtime.channel_configs["webhook-dev"] == good
assert runtime.states["webhook-dev"]["state"] == "running"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"webhook-dev",
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
)
await runtime.remove_channel("webhook-dev")
assert "webhook-dev" not in runtime.adapters
assert "webhook-dev" not in runtime.manager.channels
assert runtime.states["webhook-dev"]["state"] == "removed"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None:
async def run() -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"weixin-main",
ChannelConfig(
enabled=True,
kind="external_connector",
mode="http",
account_id="weixin:me",
display_name="Weixin Main",
config={
"platformKind": "weixin",
"connectionId": "conn_1",
"sidecarBaseUrl": "http://external-connector:8787",
},
),
)
adapter = runtime.adapters["weixin-main"]
assert adapter.kind == "external_connector"
assert adapter.mode == "http"
assert getattr(adapter, "platform_kind") == "weixin"
finally:
await runtime.stop()
asyncio.run(run())

View File

@ -1,4 +1,5 @@
import json
import asyncio
from fastapi.testclient import TestClient
@ -46,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
assert target["extra_headers"] == {"X-Test": "1"}
def test_config_loader_reads_channels(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"webhook-dev": {
"enabled": True,
"kind": "webhook",
"mode": "webhook",
"accountId": "local",
"displayName": "Webhook Dev",
"config": {
"responseTimeoutSeconds": 1800,
"dedupeRetentionHours": 48,
},
"secrets": {"ignored_for_status": "secret-value"},
}
},
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
channel = config.channels["webhook-dev"]
assert channel.enabled is True
assert channel.kind == "webhook"
assert channel.mode == "webhook"
assert channel.account_id == "local"
assert channel.display_name == "Webhook Dev"
assert channel.config["response_timeout_seconds"] == 1800
assert channel.config["dedupe_retention_hours"] == 48
assert channel.secrets == {"ignored_for_status": "secret-value"}
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
@ -163,6 +202,58 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
service.close()
def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None:
async def run_case() -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
await service.start()
class FailingMCPManager:
async def close(self) -> None:
raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
loaded = service.create_loop().boot()
loaded.mcp_manager = FailingMCPManager()
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
}
),
encoding="utf-8",
)
loop = asyncio.get_running_loop()
unhandled: list[dict[str, object]] = []
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: unhandled.append(context))
try:
_reload_agent_config(service, config_path)
await asyncio.sleep(0)
target = service.create_loop().boot().config.resolve_provider_target()
assert service.is_running is True
assert target["model"] == "new-model"
assert target["api_base"] == "https://new.example.com/v1"
assert unhandled == []
finally:
loop.set_exception_handler(previous_handler)
await service.shutdown(force=True)
asyncio.run(run_case())
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
@ -245,6 +336,67 @@ def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> N
service.close()
def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"telegram-main": {
"enabled": False,
"kind": "telegram",
"mode": "polling",
"accountId": "bot-main",
"displayName": "Telegram Main",
"secrets": {"botToken": "1234567890abcdef"},
"config": {"requireMentionInGroups": True},
}
},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
before = client.get("/api/channels/telegram-main/config")
response = client.post(
"/api/channels/telegram-main/config",
json={
"enabled": True,
"kind": "telegram",
"mode": "polling",
"account_id": "bot-main",
"display_name": "Telegram Primary",
"secrets": {"botToken": ""},
"config": {
"requireMentionInGroups": False,
"allowFrom": ["1001", "1002"],
"maxMessageChars": 3000,
},
},
)
saved = json.loads(config_path.read_text(encoding="utf-8"))
channel = saved["channels"]["telegram-main"]
assert before.status_code == 200
assert before.json()["secrets"] == {"botToken": "1234••••cdef"}
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["restart_required"] is True
assert response.json()["channel"]["display_name"] == "Telegram Primary"
assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"}
assert channel["enabled"] is True
assert channel["displayName"] == "Telegram Primary"
assert channel["secrets"]["botToken"] == "1234567890abcdef"
assert channel["config"]["allowFrom"] == ["1001", "1002"]
assert load_config(config_path=config_path).channels["telegram-main"].enabled is True
service.close()
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle(
model="qwen-plus",

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from beaver.interfaces.channels.connections import MessageDedupeStore
def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
store.complete(first.dedupe_key, message_id="msg_1")
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert first.should_process is True
assert duplicate.should_process is False
assert duplicate.status == "completed"
assert duplicate.http_status == 200
def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60)
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert duplicate.should_process is False
assert duplicate.status == "processing"
assert duplicate.http_status == 409
assert duplicate.retry_after_seconds == 5
def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0)
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert stale.should_process is True
assert stale.status == "processing"
assert stale.record.delivery_attempts == 2
def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
store.fail(first.dedupe_key, error="runtime rejected")
retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert retry.should_process is True
assert retry.record.delivery_attempts == 2
assert retry.record.last_error is None

View File

@ -0,0 +1,107 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from beaver.interfaces.channels.connections import ChannelConnectionStore
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def _app(tmp_path, monkeypatch):
monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token")
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
return app, service
def _connected_connection(tmp_path):
state_dir = tmp_path / "state" / "channel_connections"
store = ChannelConnectionStore(state_dir / "connections.json")
connection = store.create(
kind="weixin",
mode="sidecar",
display_name="Weixin Main",
account_id="weixin:me",
owner_user_id=None,
auth_type="connector_session",
)
store.update_status(connection.connection_id, status="connected", last_error=None)
return connection
def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict:
return {
"eventId": event_id,
"timestamp": "2026-06-02T09:30:00Z",
"deliveryAttempt": delivery_attempt,
"connectionId": connection.connection_id,
"channelId": connection.channel_id,
"kind": "weixin",
"accountId": "weixin:me",
"peerId": "peer-1",
"peerType": "dm",
"userId": "sender-1",
"threadId": None,
"messageId": "msg-1",
"messageType": "text",
"content": "hello",
"metadata": {},
}
def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection),
)
assert response.status_code == 200
assert response.json()["accepted"] is True
finally:
service.close()
def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer wrong"},
json=_payload(connection),
)
assert response.status_code == 401
finally:
service.close()
def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
first = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection),
)
second = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection, delivery_attempt=2),
)
assert first.status_code == 200
assert second.status_code in {200, 409}
assert second.json()["duplicate"] is True
finally:
service.close()

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id
class FakeSidecarClient:
def __init__(self) -> None:
self.sent: list[dict] = []
async def send(self, payload: dict) -> dict:
self.sent.append(payload)
return {"ok": True, "providerMessageId": "provider-1"}
def test_external_connector_channel_sends_with_target_and_request_id() -> None:
async def run() -> None:
client = FakeSidecarClient()
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=client,
)
message = OutboundMessage(
channel="weixin-main",
content="reply",
session_id="s1",
finish_reason="stop",
message_id="out-msg-1",
channel_identity=ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
thread_id=None,
user_id="sender-1",
message_id="in-msg-1",
),
metadata={"inbound_metadata": {"contextToken": "ctx-1"}},
)
await channel.send(message)
assert client.sent == [
{
"requestId": "out_weixin-main:s1:out-msg-1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1", "contextToken": "ctx-1"},
}
]
asyncio.run(run())
def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None:
identity = ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
message_id="in-msg-1",
)
first = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id=None, # type: ignore[arg-type]
channel_identity=identity,
)
second = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id="",
channel_identity=identity,
)
assert _request_id(first) == _request_id(second)
assert _request_id(first).startswith("out_weixin-main:s1:")
def test_external_connector_channel_requires_identity() -> None:
async def run() -> None:
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=FakeSidecarClient(),
)
message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop")
try:
await channel.send(message)
except ValueError as exc:
assert "channel_identity is required" in str(exc)
else:
raise AssertionError("Expected ValueError")
asyncio.run(run())

View File

@ -0,0 +1,176 @@
from __future__ import annotations
import asyncio
from fastapi.testclient import TestClient
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
CredentialStore,
FeishuConnector,
WeixinConnector,
)
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
class FakeSidecarClient:
def __init__(self) -> None:
self.sessions: dict[str, dict] = {}
self.started: list[dict] = []
self.logged_out: list[str] = []
async def start_session(self, payload: dict) -> dict:
self.started.append(payload)
session = {
"sessionId": "cs_1",
"kind": payload["kind"],
"status": "qr_ready",
"qrImage": "data:image/png;base64,abc",
"accountId": None,
"displayName": None,
"metadata": {},
}
self.sessions["cs_1"] = session
return session
async def get_session(self, session_id: str) -> dict:
return self.sessions[session_id]
async def logout(self, connection_id: str) -> dict:
self.logged_out.append(connection_id)
return {"ok": True}
def test_weixin_connector_starts_connector_session(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={})
assert view["sessionId"] == "cs_1"
assert view["connectionId"].startswith("conn_")
assert client.started[0]["kind"] == "weixin"
assert client.started[0]["connectionId"].startswith("conn_")
assert connection_store.list()[0].kind == "weixin"
assert connection_store.list()[0].status == "pairing"
asyncio.run(run())
def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={})
connection = connection_store.list()[0]
client.sessions["cs_1"] = {
"sessionId": "cs_1",
"kind": "weixin",
"status": "connected",
"accountId": "weixin:me",
"displayName": "Me",
"metadata": {"stateRef": "state-1"},
}
result = await connector.poll_session("cs_1")
updated = connection_store.get(connection.connection_id)
spec = await connector.materialize_runtime(connection.connection_id)
assert result["status"] == "connected"
assert updated.status == "connected"
assert updated.account_id == "weixin:me"
assert spec.kind == "external_connector"
assert spec.mode == "http"
assert spec.config["platformKind"] == "weixin"
assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787"
asyncio.run(run())
def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"})
assert client.started[0]["kind"] == "feishu"
assert client.started[0]["options"] == {"domain": "feishu"}
asyncio.run(run())
def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
client = FakeSidecarClient()
try:
with TestClient(app) as http:
state_dir = tmp_path / "state" / "channel_connections"
connection_store = ChannelConnectionStore(state_dir / "connections.json")
credential_store = CredentialStore(state_dir / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(
WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
)
app.state.channel_connector_registry = registry
started = http.post(
"/api/channel-connector-sessions",
json={"kind": "weixin", "displayName": "Weixin Main", "options": {}},
)
session_id = started.json()["session"]["sessionId"]
connection_id = started.json()["connection"]["connection_id"]
client.sessions[session_id] = {
"sessionId": session_id,
"kind": "weixin",
"status": "connected",
"accountId": "weixin:me",
"displayName": "Me",
"metadata": {},
}
polled = http.get(f"/api/channel-connector-sessions/{session_id}")
assert started.status_code == 200
assert polled.status_code == 200
assert polled.json()["connection"]["status"] == "connected"
assert connection_store.get(connection_id).status == "connected"
assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters
finally:
service.close()

View File

@ -0,0 +1,154 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeFeishuClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, receive_id_type: str, receive_id: str, text: str):
self.sent.append({"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text})
def test_feishu_normalizes_direct_text_event() -> None:
async def run() -> None:
sink = FakeSink()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={},
client=FakeFeishuClient(),
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "feishu-main:tenant-main:oc_chat"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "ou_user"
asyncio.run(run())
def test_feishu_group_mention_gate() -> None:
async def run() -> None:
sink = FakeSink()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={"requireMentionInGroups": True, "botOpenId": "ou_bot"},
client=FakeFeishuClient(),
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
"mentions": [],
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m2",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
"mentions": [{"id": {"open_id": "ou_bot"}}],
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
assert len(sink.messages) == 1
asyncio.run(run())
def test_feishu_sends_text_to_chat_id() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeFeishuClient()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={},
client=client,
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
await adapter.send(
OutboundMessage(
channel="feishu-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert client.sent == [{"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}]
asyncio.run(run())

View File

@ -2,9 +2,10 @@ import asyncio
from dataclasses import dataclass, field
from typing import Any
from beaver.foundation.events import InboundMessage, MessageBus
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
from beaver.interfaces.gateway.main import run_gateway
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.services.agent_service import AgentService
@ -52,22 +53,15 @@ class InvalidService:
is_running = True
def test_gateway_routes_memory_channel_roundtrip() -> None:
def test_gateway_routes_memory_channel_roundtrip(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
)
)
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(runtime)
runtime.manager.register(channel)
await runtime.start()
await channel.publish_text("hello", session_id="s1")
await channel.publish_text("hello", peer_id="s1", message_id="m1")
for _ in range(40):
if channel.sent_messages:
break
@ -76,38 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
assert channel.sent_messages
message = channel.sent_messages[0]
assert message.content == "echo:hello"
assert message.session_id == "s1"
assert message.session_id == "memory-dev:memory:s1"
assert message.finish_reason == "stop"
assert message.metadata["task_id"] == "task-1"
assert message.metadata["task_status"] == "awaiting_acceptance"
assert message.metadata["evidence_status"] == "recorded"
assert message.metadata["validation_result"] is None
stop_event.set()
await asyncio.wait_for(task, timeout=2)
await runtime.stop()
asyncio.run(run())
def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
def test_channel_manager_dispatches_by_channel_id() -> None:
class CaptureChannel:
channel_id = "webhook-dev"
kind = "webhook"
mode = "webhook"
def __init__(self) -> None:
self.sent = []
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, message: Any) -> None:
self.sent.append(message)
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=SlowService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
channel = CaptureChannel()
manager = ChannelManager(bus)
manager.register(channel)
await bus.publish_outbound(
OutboundMessage(
channel="webhook-dev",
content="ok",
session_id="webhook-dev:local:demo",
finish_reason="stop",
)
)
await channel.publish_text("slow", session_id="s1")
await asyncio.sleep(0.05)
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(task, timeout=3)
await manager.dispatch_outbound(stop_event)
assert channel.sent[0].content == "ok"
asyncio.run(run())
def test_gateway_delivers_cancelled_outbound_to_channel(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(service=SlowService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(runtime)
runtime.manager.register(channel)
await runtime.start()
await channel.publish_text("slow", peer_id="s1", message_id="m1")
for _ in range(40):
if any(event["kind"] == "direct_run_started" for event in runtime.events.recent(limit=20)):
break
await asyncio.sleep(0.05)
await runtime.stop()
assert channel.sent_messages
assert channel.sent_messages[0].finish_reason == "cancelled"
@ -118,13 +147,27 @@ def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
async def run() -> None:
bus = MessageBus()
class CaptureChannel:
channel_id = "memory-dev"
kind = "memory"
mode = "webhook"
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, message: Any) -> None:
pass
try:
await run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channel_manager=ChannelManager(bus),
channels=[MemoryChannelAdapter(bus)],
channels=[CaptureChannel()],
stop_event=asyncio.Event(),
)
except ValueError as exc:
@ -212,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
asyncio.run(run())
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
def test_memory_channel_adapts_payload_to_channel_identity_session_id(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus, name="telegram")
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(
runtime,
channel_id="telegram-main",
kind="telegram",
account_id="bot-main",
)
inbound = await channel.publish_external_text(
"hello",
chat_id="chat-1",
@ -225,8 +274,10 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
queued = await bus.consume_inbound()
assert queued is inbound
assert queued.channel == "telegram"
assert queued.session_id == "telegram:chat-1"
assert queued.channel == "telegram-main"
assert queued.session_id == "telegram-main:bot-main:chat-1"
assert queued.channel_identity is not None
assert queued.channel_identity.kind == "telegram"
assert queued.metadata["chat_id"] == "chat-1"
assert queued.metadata["message_id"] == "message-1"
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
@ -236,7 +287,9 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
class StartedChannel:
name = "started"
channel_id = "started"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus
@ -252,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non
pass
class BlockingChannel:
name = "blocking"
channel_id = "blocking"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus

View File

@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
def test_platform_channel_modules_import_without_live_clients() -> None:
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
assert FeishuAdapter.KIND == "feishu"
assert QQBotAdapter.KIND == "qqbot"
assert TelegramAdapter.KIND == "telegram"
assert WeixinAdapter.KIND == "weixin"
def test_platform_channel_optional_extras_are_declared() -> None:
import tomllib
from pathlib import Path
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
extras = data["project"]["optional-dependencies"]
assert "python-telegram-bot>=22.0,<23.0" in extras["telegram"]
assert "lark-oapi>=1.4.22,<2.0.0" in extras["feishu"]
assert "aiohttp>=3.9.0,<4.0.0" in extras["qqbot"]
assert "aiohttp>=3.9.0,<4.0.0" in extras["weixin"]
assert "python-telegram-bot>=22.0,<23.0" in extras["channels"]
assert "lark-oapi>=1.4.22,<2.0.0" in extras["channels"]
def test_agent_loop_boots(tmp_path) -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
loaded = loop.boot()
@ -32,10 +60,14 @@ def test_message_bus_imports() -> None:
def test_channel_imports() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
class Sink:
async def accept_inbound(self, message):
await bus.publish_inbound(message)
channel = MemoryChannelAdapter(Sink())
manager = ChannelManager(bus)
manager.register(channel)
assert manager.channels["memory"] is channel
assert manager.channels["memory-dev"] is channel
def test_web_schema_imports() -> None:

View File

@ -0,0 +1,66 @@
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.platforms.base import (
chunk_text,
compact_media_summary,
config_bool,
config_list,
outbound_target,
)
def test_config_helpers_normalize_common_values() -> None:
assert config_bool({"enabled": "true"}, "enabled", default=False) is True
assert config_bool({"enabled": "0"}, "enabled", default=True) is False
assert config_list({"allowFrom": "u1,u2"}, "allowFrom") == ["u1", "u2"]
assert config_list({"allowFrom": ["u1", 2]}, "allowFrom") == ["u1", "2"]
def test_chunk_text_preserves_order_and_limit() -> None:
chunks = chunk_text("abcdef", max_chars=2)
assert chunks == ["ab", "cd", "ef"]
def test_outbound_target_prefers_channel_identity() -> None:
identity = ChannelIdentity(
channel_id="telegram-main",
kind="telegram",
account_id="bot-main",
peer_id="chat-1",
thread_id="topic-1",
peer_type="group",
user_id="user-1",
)
message = OutboundMessage(
channel="telegram-main",
content="ok",
session_id="ignored",
finish_reason="stop",
channel_identity=identity,
)
target = outbound_target(message)
assert target.peer_id == "chat-1"
assert target.thread_id == "topic-1"
assert target.peer_type == "group"
assert target.user_id == "user-1"
def test_outbound_target_falls_back_to_session_id() -> None:
message = OutboundMessage(
channel="telegram-main",
content="ok",
session_id="telegram-main:bot-main:chat-1:topic-1",
finish_reason="stop",
)
target = outbound_target(message)
assert target.peer_id == "chat-1"
assert target.thread_id == "topic-1"
def test_compact_media_summary_mentions_attachment_type() -> None:
assert compact_media_summary("photo", file_name="cat.png") == "[photo: cat.png]"
assert compact_media_summary("document") == "[document]"

View File

@ -0,0 +1,143 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeQQBotClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None):
self.sent.append(
{
"peer_type": peer_type,
"peer_id": peer_id,
"content": content,
"message_id": message_id,
}
)
def test_qqbot_normalizes_private_c2c_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=FakeQQBotClient(),
)
await adapter.handle_event_payload(
{
"t": "C2C_MESSAGE_CREATE",
"d": {
"id": "m1",
"author": {"user_openid": "u1"},
"content": "hello",
},
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "qq-main:qq-bot:u1"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "u1"
asyncio.run(run())
def test_qqbot_normalizes_group_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=FakeQQBotClient(),
)
await adapter.handle_event_payload(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"id": "m2",
"group_openid": "g1",
"author": {"member_openid": "u1"},
"content": "hello group",
},
}
)
message = sink.messages[0]
assert message.session_id == "qq-main:qq-bot:g1"
assert message.channel_identity.peer_type == "group"
assert message.channel_identity.user_id == "u1"
asyncio.run(run())
def test_qqbot_sends_reply_with_original_message_id() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeQQBotClient()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=client,
)
await adapter.handle_event_payload(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"id": "m2",
"group_openid": "g1",
"author": {"member_openid": "u1"},
"content": "hello group",
},
}
)
await adapter.send(
OutboundMessage(
channel="qq-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert client.sent[0] == {
"peer_type": "group",
"peer_id": "g1",
"content": "ok",
"message_id": "m2",
}
asyncio.run(run())

View File

@ -0,0 +1,141 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeTelegramClient:
def __init__(self) -> None:
self.sent = []
async def send_message(self, **kwargs):
self.sent.append(kwargs)
def test_telegram_normalizes_private_text_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={},
client=FakeTelegramClient(),
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 100,
"text": "hello",
"chat": {"id": 200, "type": "private"},
"from": {"id": 300, "username": "ivan"},
}
}
)
message = sink.messages[0]
assert message.channel == "telegram-main"
assert message.content == "hello"
assert message.session_id == "telegram-main:bot-main:200"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "300"
assert message.channel_identity.message_id == "100"
asyncio.run(run())
def test_telegram_group_requires_mention_when_configured() -> None:
async def run() -> None:
sink = FakeSink()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={"requireMentionInGroups": True, "botUsername": "beaver_bot"},
client=FakeTelegramClient(),
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 101,
"text": "hello group",
"chat": {"id": -20, "type": "group"},
"from": {"id": 300},
}
}
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 102,
"text": "@beaver_bot hello",
"chat": {"id": -20, "type": "group"},
"from": {"id": 300},
}
}
)
assert len(sink.messages) == 1
assert sink.messages[0].content == "hello"
asyncio.run(run())
def test_telegram_sends_chunked_reply_to_identity_target() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeTelegramClient()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={"maxMessageChars": 3},
client=client,
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 100,
"text": "hello",
"chat": {"id": 200, "type": "private"},
"from": {"id": 300},
}
}
)
await adapter.send(
OutboundMessage(
channel="telegram-main",
content="abcdef",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert [item["text"] for item in client.sent] == ["abc", "def"]
assert client.sent[0]["chat_id"] == "200"
asyncio.run(run())

View File

@ -0,0 +1,143 @@
from __future__ import annotations
import asyncio
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
CredentialStore,
TelegramConnector,
)
class FakeTelegramClient:
async def get_me(self):
return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"}
class BrokenTelegramClient:
async def get_me(self):
raise RuntimeError("invalid token")
def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="",
owner_user_id="user-1",
auth_type="token",
credentials_ref=credentials_ref,
runtime_config={"max_message_chars": 4096},
)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
result = await connector.validate(connection.connection_id)
updated = connection_store.get(connection.connection_id)
assert result.ok is True
assert result.status == "connected"
assert result.account_id == "telegram:12345"
assert updated.account_id == "telegram:12345"
assert updated.display_name == "Beaver (@beaver_bot)"
assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"]
asyncio.run(run())
def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:12345",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True},
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
spec = await connector.materialize_runtime(connection.connection_id)
assert spec.channel_id == connection.channel_id
assert spec.kind == "telegram"
assert spec.mode == "polling"
assert spec.account_id == "telegram:12345"
assert spec.config["max_message_chars"] == 4096
assert spec.config["require_mention_in_groups"] is True
assert spec.secrets_ref == credentials_ref
asyncio.run(run())
def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: BrokenTelegramClient(),
)
result = await connector.validate(connection.connection_id)
assert result.ok is False
assert result.status == "error"
assert "invalid token" in (result.error or "")
asyncio.run(run())
def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:12345",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
await connector.revoke(connection.connection_id)
assert connection_store.get(connection.connection_id).status == "connected"
asyncio.run(run())

View File

@ -0,0 +1,243 @@
import asyncio
import json
import time
from pathlib import Path
from fastapi.testclient import TestClient
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
class TerminalFakeAgentService(AgentService):
def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None:
super().__init__(config_path=config_path)
self.delay_seconds = delay_seconds
self.inbound_calls: list[InboundMessage] = []
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
self.inbound_calls.append(inbound)
if self.delay_seconds:
await asyncio.sleep(self.delay_seconds)
return OutboundMessage(
message_id=inbound.message_id,
channel=inbound.channel,
content=f"echo:{inbound.content}",
session_id=inbound.session_id,
finish_reason="stop",
run_id="run-1",
channel_identity=inbound.channel_identity,
)
def write_terminal_config(tmp_path: Path) -> Path:
workspace = tmp_path / "workspace"
workspace.mkdir()
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
"providers": {},
"channels": {
"terminal-dev": {
"enabled": True,
"kind": "terminal",
"mode": "websocket",
"accountId": "local",
"displayName": "Terminal Dev",
"config": {"heartbeatSeconds": 30, "maxMessageChars": 20000},
}
},
}
),
encoding="utf-8",
)
return config_path
def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json(
{
"type": "connect",
"peer_id": "device-001",
"device_name": "desk-terminal",
"capabilities": ["text"],
}
)
assert websocket.receive_json() == {
"type": "connected",
"channel_id": "terminal-dev",
"session_id": "terminal-dev:local:device-001",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
websocket.send_json(
{
"type": "message",
"message_id": "device-001-000001",
"text": "hello",
}
)
assert websocket.receive_json() == {
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": True,
}
reply = websocket.receive_json()
service.close()
assert reply == {
"type": "message",
"role": "assistant",
"message_id": "device-001-000001",
"run_id": "run-1",
"text": "echo:hello",
"finish_reason": "stop",
}
assert len(service.inbound_calls) == 1
inbound = service.inbound_calls[0]
assert inbound.channel == "terminal-dev"
assert inbound.content == "hello"
assert inbound.content_type == "text"
assert inbound.session_id == "terminal-dev:local:device-001"
assert inbound.channel_identity is not None
assert inbound.channel_identity.peer_id == "device-001"
assert inbound.channel_identity.peer_type == "terminal"
assert inbound.channel_identity.message_id == "device-001-000001"
def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"})
assert websocket.receive_json() == {
"type": "error",
"error": "connect is required before message",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
service.close()
assert service.inbound_calls == []
def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "example"})
assert websocket.receive_json() == {
"type": "error",
"error": "Unsupported websocket frame type: example",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
service.close()
def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
websocket.send_json({"type": "message", "text": "hello"})
assert websocket.receive_json() == {"type": "error", "error": "message_id is required"}
websocket.send_json({"type": "message", "message_id": "m1", "text": " "})
assert websocket.receive_json() == {"type": "error", "error": "text is required"}
service.close()
assert service.inbound_calls == []
def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"}
websocket.send_json(frame)
assert websocket.receive_json()["accepted"] is True
assert websocket.receive_json()["text"] == "echo:hello"
websocket.send_json(frame)
duplicate = websocket.receive_json()
service.close()
assert duplicate["type"] == "ack"
assert duplicate["accepted"] is False
assert duplicate["duplicate"] is True
assert duplicate["pending"] is False
assert duplicate["reply"] == "echo:hello"
assert len(service.inbound_calls) == 1
def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"})
assert websocket.receive_json()["accepted"] is True
time.sleep(0.15)
events = client.get("/api/channels/terminal-dev/events").json()
service.close()
kinds = [event["kind"] for event in events]
assert "terminal_disconnected" in kinds
assert "outbound_unclaimed" in kinds
def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
initial = client.get("/api/status").json()["channels"][0]
assert initial["channel_id"] == "terminal-dev"
assert initial["websocket_url"] == "/api/channels/terminal-dev/ws"
assert initial["connected_peers"] == 0
assert "persistent_connection" in initial["capabilities"]
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
connected = client.get("/api/status").json()["channels"][0]
assert connected["connected_peers"] == 1
service.close()

View File

@ -0,0 +1,129 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeWeixinClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, peer_id: str, text: str, context_token: str | None):
self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token})
def test_weixin_normalizes_direct_text_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={},
client=FakeWeixinClient(),
)
await adapter.handle_message_payload(
{
"id": "m1",
"from": "wx_user",
"room_id": "",
"type": "text",
"text": "hello",
"context_token": "ctx1",
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "weixin-main:wx-main:wx_user"
assert message.channel_identity.peer_type == "dm"
assert message.metadata["context_token"] == "ctx1"
asyncio.run(run())
def test_weixin_group_message_is_best_effort() -> None:
async def run() -> None:
sink = FakeSink()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={"groupPolicy": "open"},
client=FakeWeixinClient(),
)
await adapter.handle_message_payload(
{
"id": "m2",
"from": "wx_user",
"room_id": "room1",
"type": "text",
"text": "hello room",
"context_token": "ctx2",
}
)
message = sink.messages[0]
assert message.session_id == "weixin-main:wx-main:room1"
assert message.channel_identity.peer_type == "group"
assert message.channel_identity.user_id == "wx_user"
asyncio.run(run())
def test_weixin_sends_text_with_context_token() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeWeixinClient()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={},
client=client,
)
await adapter.handle_message_payload(
{
"id": "m1",
"from": "wx_user",
"type": "text",
"text": "hello",
"context_token": "ctx1",
}
)
await adapter.send(
OutboundMessage(
channel="weixin-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
metadata={"inbound_metadata": sink.messages[0].metadata},
)
)
assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}]
asyncio.run(run())

View File

@ -252,27 +252,51 @@ dependencies = [
]
[package.optional-dependencies]
channels = [
{ name = "aiohttp" },
{ name = "lark-oapi" },
{ name = "python-telegram-bot" },
]
dev = [
{ name = "pytest" },
]
feishu = [
{ name = "lark-oapi" },
]
qqbot = [
{ name = "aiohttp" },
]
telegram = [
{ name = "python-telegram-bot" },
]
weixin = [
{ name = "aiohttp" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", marker = "extra == 'channels'", specifier = ">=3.9.0,<4.0.0" },
{ name = "aiohttp", marker = "extra == 'qqbot'", specifier = ">=3.9.0,<4.0.0" },
{ name = "aiohttp", marker = "extra == 'weixin'", specifier = ">=3.9.0,<4.0.0" },
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
{ name = "json-repair", specifier = ">=0.39.0,<1.0.0" },
{ name = "lark-oapi", marker = "extra == 'channels'", specifier = ">=1.4.22,<2.0.0" },
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.4.22,<2.0.0" },
{ name = "litellm", specifier = ">=1.79.0,<2.0.0" },
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
{ name = "python-telegram-bot", marker = "extra == 'channels'", specifier = ">=22.0,<23.0" },
{ name = "python-telegram-bot", marker = "extra == 'telegram'", specifier = ">=22.0,<23.0" },
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "telegram", "feishu", "qqbot", "weixin", "channels"]
[[package]]
name = "cachetools"
@ -1277,6 +1301,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "lark-oapi"
version = "1.6.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pycryptodome" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "websockets" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/54/a3b649b83299606aa7ebfd2391663fde650e934421dfba37af171bfbf456/lark_oapi-1.6.7-py3-none-any.whl", hash = "sha256:df1d44891d266f5c063daa1d37ae6f72c7f166bdc2fb01e607088410e952b92c", size = 7146261, upload-time = "2026-05-28T03:32:21.268Z" },
]
[[package]]
name = "litellm"
version = "1.80.0"
@ -1759,6 +1798,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]]
name = "pydantic"
version = "2.13.3"
@ -1973,6 +2042,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
name = "python-telegram-bot"
version = "22.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpcore", marker = "python_full_version >= '3.14'" },
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" },
]
[[package]]
name = "pywin32"
version = "311"
@ -2189,6 +2271,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
@ -2687,61 +2781,44 @@ wheels = [
[[package]]
name = "websockets"
version = "16.0"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]

View File

@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
NETWORK_NAME="${NETWORK_NAME:-}"
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
SEED_INITIAL_SKILLS=1
FORCE_BUILD=0
REPLACE=0
@ -78,6 +80,9 @@ Optional:
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
--network <name> Optional docker network name.
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
--initial-skills-dir <path> Directory copied into workspace/skills on first create.
Default: ../skills
--skip-initial-skills Do not seed initial workspace skills.
--build Force rebuild image before running.
--replace Remove existing container with same name before running.
--help Show this help.
@ -225,6 +230,69 @@ data = {
"name": os.environ["BACKEND_NAME"].strip(),
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
},
"channels": {
"telegram-main": {
"enabled": False,
"kind": "telegram",
"mode": "polling",
"accountId": "bot-main",
"displayName": "Telegram Main",
"secrets": {
"botToken": "",
},
"config": {
"requireMentionInGroups": True,
"maxMessageChars": 4096,
},
},
"feishu-main": {
"enabled": False,
"kind": "feishu",
"mode": "websocket",
"accountId": "tenant-main",
"displayName": "Feishu Main",
"secrets": {
"appId": "",
"appSecret": "",
},
"config": {
"domain": "feishu",
"connectionMode": "websocket",
"requireMentionInGroups": True,
},
},
"qqbot-main": {
"enabled": False,
"kind": "qqbot",
"mode": "websocket",
"accountId": "qqbot-main",
"displayName": "QQ Bot Main",
"secrets": {
"appId": "",
"clientSecret": "",
},
"config": {
"dmPolicy": "open",
"groupPolicy": "allowlist",
"markdownSupport": False,
},
},
"weixin-main": {
"enabled": False,
"kind": "weixin",
"mode": "polling",
"accountId": "wx-main",
"displayName": "Weixin Main",
"secrets": {
"token": "",
},
"config": {
"dmPolicy": "open",
"groupPolicy": "disabled",
"textBatchDelaySeconds": 0.5,
},
},
},
}
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@ -255,6 +323,66 @@ target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encodin
PY
}
seed_initial_skills() {
local workspace_path="$1"
local initial_skills_dir="$2"
local target_dir="${workspace_path}/skills"
if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then
return
fi
if [[ ! -d "$initial_skills_dir" ]]; then
log "initial skills directory not found, skipping: ${initial_skills_dir}"
return
fi
mkdir -p "$target_dir"
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY'
import json
import shutil
import os
from pathlib import Path
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
target = Path(os.environ["TARGET_DIR"]).resolve()
for child in sorted(initial.iterdir()):
if child.name.startswith("."):
continue
destination = target / child.name
if destination.exists():
continue
if child.is_dir():
shutil.copytree(child, destination)
elif child.is_file():
shutil.copy2(child, destination)
for index_name in ("published", "disabled"):
initial_index = initial / "_index" / f"{index_name}.json"
target_index = target / "_index" / f"{index_name}.json"
if not initial_index.exists():
continue
try:
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
except json.JSONDecodeError:
initial_items = []
if target_index.exists():
try:
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
except json.JSONDecodeError:
target_items = []
else:
target_items = []
merged = []
for item in [*target_items, *initial_items]:
text = str(item).strip()
if text and text not in merged:
merged.append(text)
target_index.parent.mkdir(parents=True, exist_ok=True)
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
PY
}
render_runtime_env_file() {
local target_path="$1"
@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do
HOST_BIND_IP="${2:-}"
shift 2
;;
--initial-skills-dir)
INITIAL_SKILLS_DIR="${2:-}"
shift 2
;;
--skip-initial-skills)
SEED_INITIAL_SKILLS=0
shift
;;
--build)
FORCE_BUILD=1
shift
@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
render_config_json "$CONFIG_PATH"
render_auth_users_json "$AUTH_USERS_PATH"
render_runtime_env_file "$RUNTIME_ENV_PATH"
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
log "building image ${IMAGE_NAME}"
@ -564,6 +701,7 @@ RUN_ARGS=(
-e "APP_PUBLIC_PORT=8080"
-e "APP_FRONTEND_PORT=3000"
-e "APP_BACKEND_PORT=18080"
-e "BEAVER_ENABLE_SELF_RESTART=1"
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
--label "beaver.instance.id=${INSTANCE_ID}"
--label "beaver.instance.slug=${INSTANCE_SLUG}"

View File

@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { containedJsonTextClass } from '@/lib/text-wrapping';
function eventLabel(event: ChatLogEvent): string {
return event.event_type || event.role || 'event';
@ -175,7 +176,7 @@ export default function LogsPage() {
return (
<div
key={`${event.message_id ?? index}:${event.event_type}`}
className="rounded-lg border border-border bg-background"
className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-background"
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
@ -188,7 +189,7 @@ export default function LogsPage() {
</div>
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
</div>
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
<pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 text-foreground ${containedJsonTextClass}`}>
{body || formatPayload(event)}
</pre>
</div>

View File

@ -19,7 +19,12 @@ import {
uploadFile,
wsManager,
} from '@/lib/api';
import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
import {
getSessionRefreshIntervalMs,
mergeServerWithPendingUsers,
shouldDisplayChatMessage,
shouldMergePendingUsers,
} from '@/lib/chat-messages';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { buildSessionProgressView } from '@/lib/session-progress';
@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean {
return stored == null ? false : stored !== 'false';
}
function isDocumentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export default function ChatPage() {
const { locale } = useAppI18n();
const {
@ -78,6 +87,7 @@ export default function ChatPage() {
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -247,14 +257,26 @@ export default function ChatPage() {
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
useEffect(() => {
if (!isLoading && !isThinking) {
const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
if (intervalMs == null) {
return;
}
const timer = setInterval(() => {
loadSessionMessages(useChatStore.getState().sessionId);
}, 1500);
const currentSessionId = useChatStore.getState().sessionId;
void loadSessionMessages(currentSessionId);
void loadSessions();
}, intervalMs);
return () => clearInterval(timer);
}, [isLoading, isThinking, loadSessionMessages]);
}, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const updateVisibility = () => setDocumentHidden(isDocumentHidden());
document.addEventListener('visibilitychange', updateVisibility);
return () => document.removeEventListener('visibilitychange', updateVisibility);
}, []);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current;

View File

@ -73,6 +73,7 @@ import type {
} from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
@ -1094,7 +1095,7 @@ function ReadableFact({
{icon}
{label}
</div>
<div className="break-words text-sm leading-5">{value || '-'}</div>
<div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
</div>
);
}
@ -1119,12 +1120,12 @@ function MetricTile({
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
return (
<details className="mt-3 rounded-md border border-border bg-white">
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
{title}
<ChevronDown className="h-3.5 w-3.5" />
</summary>
<pre className="max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5">
<pre className={`max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5 ${containedJsonTextClass}`}>
{JSON.stringify(payload, null, 2)}
</pre>
</details>

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,32 @@
}
}
@layer utilities {
.contained-long-text {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.contained-preserved-long-text {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.contained-json-text {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
}
/* Override Tailwind Typography table defaults for markdown rendering */
.prose table {
margin-top: 0;

View File

@ -3,9 +3,11 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { containedLongTextClass } from '@/lib/text-wrapping';
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className={`prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-code:[overflow-wrap:anywhere] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] prose-pre:whitespace-pre-wrap prose-pre:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${containedLongTextClass}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{

View File

@ -12,6 +12,7 @@ import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
@ -66,7 +67,7 @@ function MessageBubble({
</div>
)}
<div
className={`max-w-[88%] px-4 py-3 ${
className={`min-w-0 max-w-[88%] px-4 py-3 ${
isUser
? 'rounded-[28px] bg-primary text-primary-foreground'
: 'rounded-none bg-transparent text-[#1D1715]'
@ -92,14 +93,14 @@ function MessageBubble({
key={att.file_id}
href={fileUrl}
download={att.name}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
className={`flex min-w-0 items-center gap-2 px-3 py-2 rounded-md text-sm ${
isUser
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
: 'bg-muted hover:bg-muted/80'
}`}
>
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{att.name}</span>
<span className="min-w-0 truncate">{att.name}</span>
{att.size && (
<span className="text-xs opacity-70 flex-shrink-0">
{att.size > 1024 * 1024
@ -114,7 +115,7 @@ function MessageBubble({
)}
{isUser ? (
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
<p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
) : (
<MarkdownContent content={textContent} />
)}

View File

@ -11,6 +11,7 @@ import { Textarea } from '@/components/ui/textarea';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
@ -177,7 +178,7 @@ export function TaskAcceptanceControls({
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
</div>
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap text-muted-foreground">{String(recordedFeedback.comment)}</p> : null}
{recordedFeedback.comment ? <p className={`mt-2 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(recordedFeedback.comment)}</p> : null}
{recordedFeedback.created_at ? (
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
) : null}
@ -229,7 +230,7 @@ export function TaskAcceptanceControls({
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
/>
<div className="text-xs text-muted-foreground">
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
<span className="font-mono">{runId || '-'}</span>
<span className="mx-1">·</span>

View File

@ -24,6 +24,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { containedJsonTextClass, containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
@ -146,14 +147,14 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
const versions = historyVersions(card.details);
return (
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</summary>
<div className="mt-3 space-y-3">
{versions.map((version, index) => (
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3">
<div key={String(version.runId || index)} className="min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium">
{pickAppText(locale, `${index + 1} 轮结果`, `Version ${index + 1}`)}
@ -162,9 +163,9 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
{renderHistoryStatus(version, locale)}
</Badge>
</div>
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null}
{version.result ? <p className={`mt-2 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(version.result)}</p> : null}
{version.comment ? (
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground">
<div className={`mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground ${containedLongTextClass}`}>
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
</div>
) : null}
@ -181,7 +182,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
return (
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28">
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
<CardContent className="p-4">
<div className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
@ -197,7 +198,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
</Badge>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{card.actorName ? <span>{card.actorName}</span> : null}
{card.actorName ? <span className={containedLongTextClass}>{card.actorName}</span> : null}
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
</div>
@ -213,7 +214,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
) : null}
</div>
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
{card.summary ? <p className={`mt-3 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{card.summary}</p> : null}
{shouldRenderResultAcceptance ? (
<div className="mt-4 border-t border-border pt-4">
@ -222,11 +223,11 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
) : null}
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
{pickAppText(locale, '详情 JSON', 'Details JSON')}
</summary>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
{detailsJson(card.details)}
</pre>
</details>

View File

@ -8,6 +8,12 @@ import type {
ChatLogsResponse,
BackendTask,
ChatMessage,
ChannelConfigDetail,
ChannelConfigPayload,
ChannelConnectorDescriptor,
ConnectorSessionResponse,
ConnectorSessionStartPayload,
ChannelEventRecord,
CronJob,
FileAttachment,
NotificationDetail,
@ -638,6 +644,53 @@ export async function updateProviderConfig(
});
}
export async function getChannelConfig(channelId: string): Promise<ChannelConfigDetail> {
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`);
}
export async function updateChannelConfig(
channelId: string,
payload: ChannelConfigPayload
): Promise<{ ok: boolean; channel_id: string; restart_required: boolean; channel: ChannelConfigDetail }> {
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function listChannelEvents(channelId: string, limit: number = 100): Promise<ChannelEventRecord[]> {
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/events?limit=${limit}`);
}
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
return fetchJSON('/api/channel-connectors');
}
export async function startChannelConnectorSession(
payload: ConnectorSessionStartPayload
): Promise<ConnectorSessionResponse> {
return fetchJSON('/api/channel-connector-sessions', {
method: 'POST',
body: JSON.stringify({
kind: payload.kind,
displayName: payload.displayName,
ownerUserId: payload.ownerUserId,
options: payload.options || {},
}),
});
}
export async function getChannelConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`);
}
export async function restartRuntime(): Promise<{ ok: boolean; restarting: boolean }> {
return fetchJSON('/api/runtime/restart', {
method: 'POST',
timeoutMs: 5000,
});
}
// ---------------------------------------------------------------------------
// Cron (proxied)
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getChannelConnectorSession,
listChannelConnectors,
startChannelConnectorSession,
} from '@/lib/api';
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
if (typeof localStorage !== 'undefined') {
localStorage.clear();
}
vi.restoreAllMocks();
});
function mockJsonResponse(body: unknown) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(body),
} as Response);
}
function firstFetchCall(fetchMock: any): [unknown, RequestInit] {
return fetchMock.mock.calls[0] as [unknown, RequestInit];
}
describe('channel connector api', () => {
it('lists available channel connectors', async () => {
const fetchMock = vi.fn(() => mockJsonResponse([{ kind: 'weixin', displayName: 'Weixin' }]));
globalThis.fetch = fetchMock as typeof fetch;
const connectors = await listChannelConnectors();
expect(connectors).toEqual([{ kind: 'weixin', displayName: 'Weixin' }]);
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
});
it('starts a connector session with options', async () => {
const fetchMock = vi.fn(() =>
mockJsonResponse({
session: { sessionId: 'cs_1', kind: 'weixin', status: 'qr_ready' },
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'pairing' },
})
);
globalThis.fetch = fetchMock as typeof fetch;
const response = await startChannelConnectorSession({
kind: 'weixin',
displayName: 'Weixin Main',
options: { mode: 'qr' },
});
expect(response.session.sessionId).toBe('cs_1');
const [, request] = firstFetchCall(fetchMock);
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions$/);
expect(request).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ kind: 'weixin', displayName: 'Weixin Main', options: { mode: 'qr' } }),
})
);
});
it('polls a connector session by id', async () => {
const fetchMock = vi.fn(() =>
mockJsonResponse({
session: { sessionId: 'cs_1', kind: 'weixin', status: 'connected' },
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'connected' },
})
);
globalThis.fetch = fetchMock as typeof fetch;
const response = await getChannelConnectorSession('cs_1');
expect(response.connection?.status).toBe('connected');
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions\/cs_1$/);
});
});

View File

@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
import {
getSessionRefreshIntervalMs,
getTaskCardMessageIndexes,
mergeServerWithPendingUsers,
shouldDisplayChatMessage,
shouldMergePendingUsers,
} from '@/lib/chat-messages';
import type { ChatMessage } from '@/types';
describe('chat message helpers', () => {
@ -98,4 +104,11 @@ describe('chat message helpers', () => {
expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true);
expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true);
});
it('keeps polling idle visible chats so external channel messages appear', () => {
expect(getSessionRefreshIntervalMs({ isLoading: true, isThinking: false, documentHidden: false })).toBe(1500);
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: true, documentHidden: false })).toBe(1500);
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: false })).toBe(5000);
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: true })).toBeNull();
});
});

View File

@ -1,6 +1,26 @@
import type { ChatMessage } from '@/types';
const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g;
export const CHAT_WAITING_REFRESH_INTERVAL_MS = 1500;
export const CHAT_IDLE_REFRESH_INTERVAL_MS = 5000;
export function getSessionRefreshIntervalMs({
isLoading,
isThinking,
documentHidden,
}: {
isLoading: boolean;
isThinking: boolean;
documentHidden: boolean;
}): number | null {
if (documentHidden) {
return null;
}
if (isLoading || isThinking) {
return CHAT_WAITING_REFRESH_INTERVAL_MS;
}
return CHAT_IDLE_REFRESH_INTERVAL_MS;
}
export function normalizedMessageText(content: unknown): string {
if (typeof content === 'string') {

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { containedJsonTextClass, containedLongTextClass } from './text-wrapping';
const globalsCss = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8');
describe('contained long text classes', () => {
it('keeps long plain text inside its container', () => {
expect(containedLongTextClass).toBe('contained-long-text');
expect(globalsCss).toContain('.contained-long-text');
expect(globalsCss).toContain('overflow-wrap: anywhere');
expect(globalsCss).toContain('word-break: break-word');
});
it('keeps long JSON and monospace output inside its container', () => {
expect(containedJsonTextClass).toBe('contained-json-text');
expect(globalsCss).toContain('.contained-json-text');
expect(globalsCss).toContain('white-space: pre-wrap');
});
});

View File

@ -0,0 +1,5 @@
export const containedLongTextClass = 'contained-long-text';
export const containedPreservedLongTextClass = 'contained-preserved-long-text';
export const containedJsonTextClass = 'contained-json-text';

View File

@ -6,6 +6,7 @@ const config: Config = {
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./lib/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {

View File

@ -148,9 +148,116 @@ export interface AgentConfigPayload {
max_tool_iterations: number;
}
export interface ChannelStatus {
name: string;
export interface ChannelConfigDetail {
channel_id: string;
enabled: boolean;
kind: string;
mode: string;
account_id: string;
display_name: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
}
export interface ChannelConfigPayload {
enabled: boolean;
kind: string;
mode: string;
account_id?: string;
display_name?: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
}
export interface ChannelStatus {
channel_id: string;
name?: string;
kind: string;
mode: string;
display_name: string;
enabled: boolean;
state: 'configured' | 'disabled' | 'starting' | 'running' | 'degraded' | 'error' | 'stopped';
account_id: string;
last_error?: string | null;
last_event_at?: string | null;
started_at?: string | null;
capabilities: string[];
webhook_url?: string | null;
websocket_url?: string | null;
connected_peers?: number;
}
export interface ChannelEventRecord {
event_id: string;
channel_id: string;
kind: string;
session_id?: string | null;
message_id?: string | null;
run_id?: string | null;
status: string;
error?: string | null;
text_preview?: string | null;
text_length?: number;
created_at: string;
metadata?: Record<string, unknown>;
}
export interface ChannelConnectorDescriptor {
kind: string;
displayName?: string;
display_name?: string;
authType?: string;
auth_type?: string;
providerId?: string;
provider_id?: string;
capabilities?: string[];
}
export interface ChannelConnectionView {
connection_id: string;
owner_user_id?: string | null;
channel_id: string;
kind: string;
mode: string;
display_name: string;
account_id: string;
status: string;
auth_type: string;
runtime_config?: Record<string, unknown>;
capabilities?: string[];
created_at?: string;
updated_at?: string;
last_seen_at?: string | null;
last_error?: string | null;
}
export interface ConnectorSessionView {
sessionId: string;
kind: string;
status: string;
qrCode?: string | null;
qrImage?: string | null;
instructions?: string[];
accountId?: string | null;
displayName?: string | null;
error?: string | null;
metadata?: Record<string, unknown>;
}
export interface ConnectorSessionResponse {
session: ConnectorSessionView;
connection?: ChannelConnectionView | null;
}
export interface ConnectorSessionStartPayload {
kind: string;
displayName?: string;
ownerUserId?: string;
options?: Record<string, unknown>;
}
export interface RuntimeControls {
self_restart: boolean;
}
export interface SystemStatus {
@ -165,6 +272,7 @@ export interface SystemStatus {
max_tool_iterations: number;
providers: ProviderStatus[];
channels: ChannelStatus[];
runtime_controls?: RuntimeControls;
cron: {
enabled: boolean;
jobs: number;

View File

@ -36,6 +36,15 @@ http {
proxy_pass http://127.0.0.1:18080;
}
location /api/channels/ {
proxy_pass http://127.0.0.1:18080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
}
location /api/ {
proxy_pass http://127.0.0.1:18080;
}
@ -69,4 +78,3 @@ http {
}
}
}