docs: tighten channel connector API plan
This commit is contained in:
@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user