docs: tighten channel connector API plan

This commit is contained in:
2026-06-02 16:07:01 +08:00
parent b25713a141
commit c3a0aef104

View File

@ -18,7 +18,7 @@ Included:
- `ChannelConnection` data model and persistent JSON store. - `ChannelConnection` data model and persistent JSON store.
- Restricted local credential store with secret redaction. - Restricted local credential store with secret redaction.
- One-time pairing token store, used now by tests and future terminal/QR connectors. - One-time pairing token store, used now by tests and future terminal/QR connectors. It is implemented in this phase but not exposed through APIs; future terminal and QR connectors will consume it.
- Connector protocol and registry. - Connector protocol and registry.
- Telegram connector with fake-client test hooks. - Telegram connector with fake-client test hooks.
- Connection control APIs. - Connection control APIs.
@ -33,6 +33,7 @@ Excluded from this plan:
- Frontend connection wizard. - Frontend connection wizard.
- Hot starting/stopping adapters without backend restart. - Hot starting/stopping adapters without backend restart.
- Multi-process-safe storage. The JSON stores use `threading.Lock` plus atomic file replace for the single backend process used in phase 1. Production multi-worker deployment needs a file lock or database-backed store. - Multi-process-safe storage. The JSON stores use `threading.Lock` plus atomic file replace for the single backend process used in phase 1. Production multi-worker deployment needs a file lock or database-backed store.
- Credential garbage collection. Updating secrets writes a new credential reference and leaves the old reference in the local credential file until a later cleanup pass.
## File Structure ## File Structure
@ -1361,7 +1362,7 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N
"displayName": "Telegram Main", "displayName": "Telegram Main",
"authType": "token", "authType": "token",
"secrets": {"botToken": "token-1"}, "secrets": {"botToken": "token-1"},
"config": {"maxMessageChars": 4096}, "config": {"maxMessageChars": 4096, "requireMentionInGroups": True},
}, },
) )
assert created.status_code == 200 assert created.status_code == 200
@ -1370,6 +1371,10 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N
assert body["connection"]["kind"] == "telegram" assert body["connection"]["kind"] == "telegram"
assert body["connection"]["status"] == "draft" assert body["connection"]["status"] == "draft"
assert "credentials_ref" not in body["connection"] 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": "***"} assert body["credentials"] == {"botToken": "***"}
patched = client.patch( patched = client.patch(
@ -1382,7 +1387,7 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N
) )
assert patched.status_code == 200 assert patched.status_code == 200
assert patched.json()["connection"]["display_name"] == "Telegram Ops" assert patched.json()["connection"]["display_name"] == "Telegram Ops"
assert patched.json()["connection"]["runtime_config"] == {"maxMessageChars": 2048} assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048}
assert patched.json()["credentials"] == {"botToken": "***"} assert patched.json()["credentials"] == {"botToken": "***"}
listed = client.get("/api/channel-connections") listed = client.get("/api/channel-connections")
@ -1502,13 +1507,31 @@ def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry
if not isinstance(registry, ChannelConnectorRegistry): if not isinstance(registry, ChannelConnectorRegistry):
workspace = getattr(request.app.state, "channel_connection_workspace", None) workspace = getattr(request.app.state, "channel_connection_workspace", None)
if workspace is None: if workspace is None:
agent_service = get_agent_service(request) raise RuntimeError("Channel connector registry unavailable before service boot")
workspace = agent_service.loader.workspace
registry = _build_channel_connector_registry(workspace) registry = _build_channel_connector_registry(workspace)
request.app.state.channel_connector_registry = registry request.app.state.channel_connector_registry = registry
return registry return registry
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:
if char.isupper() and result:
result.append("_")
result.append(char.lower())
return "".join(result)
def _connection_response_view(connection: Any) -> dict[str, Any]: def _connection_response_view(connection: Any) -> dict[str, Any]:
view = connection.to_dict() view = connection.to_dict()
view.pop("credentials_ref", None) view.pop("credentials_ref", None)
@ -1553,7 +1576,7 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/
owner_user_id=_clean_text(payload.owner_user_id) or None, owner_user_id=_clean_text(payload.owner_user_id) or None,
auth_type=_clean_text(payload.auth_type) or "token", auth_type=_clean_text(payload.auth_type) or "token",
credentials_ref=credentials_ref, credentials_ref=credentials_ref,
runtime_config=payload.config or {}, runtime_config=_normalize_connection_config(payload.config),
) )
return WebChannelConnectionResponse( return WebChannelConnectionResponse(
connection=_connection_response_view(connection), connection=_connection_response_view(connection),
@ -1576,10 +1599,11 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/
if payload.account_id is not None: if payload.account_id is not None:
connection.account_id = _clean_text(payload.account_id) or connection.account_id connection.account_id = _clean_text(payload.account_id) or connection.account_id
if payload.config is not None: if payload.config is not None:
connection.runtime_config = payload.config connection.runtime_config = _normalize_connection_config(payload.config)
if payload.secrets: if payload.secrets:
secrets = {key: value for key, value in payload.secrets.items() if value} secrets = {key: value for key, value in payload.secrets.items() if value}
if secrets: if secrets:
# TODO: add credential GC when connection updates credentials.
connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets) connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets)
connection = registry.connection_store.update(connection) connection = registry.connection_store.update(connection)
return WebChannelConnectionResponse( return WebChannelConnectionResponse(