feat: implement channel runtime connectors
This commit is contained in:
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user