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