feat: implement channel runtime connectors
This commit is contained in:
@ -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 ./
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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",
|
||||
]
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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"]
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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"
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -0,0 +1 @@
|
||||
"""Platform channel adapters."""
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal file
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal 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
|
||||
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal file
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal 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))
|
||||
@ -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,
|
||||
)
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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)},
|
||||
)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"}]
|
||||
@ -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
|
||||
@ -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())
|
||||
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal 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
|
||||
@ -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())
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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())
|
||||
@ -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()
|
||||
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal file
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal 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())
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]"
|
||||
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal file
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal 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())
|
||||
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal file
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal 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())
|
||||
@ -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())
|
||||
@ -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()
|
||||
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal file
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal 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())
|
||||
185
app-instance/backend/uv.lock
generated
185
app-instance/backend/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal file
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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') {
|
||||
|
||||
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal file
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const containedLongTextClass = 'contained-long-text';
|
||||
|
||||
export const containedPreservedLongTextClass = 'contained-preserved-long-text';
|
||||
|
||||
export const containedJsonTextClass = 'contained-json-text';
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user