feat: implement channel runtime connectors
This commit is contained in:
322
external-connector/tests/test_feishu_bot_provider.py
Normal file
322
external-connector/tests/test_feishu_bot_provider.py
Normal file
@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from external_connector.app import create_app
|
||||
from external_connector.providers.composite import CompositeProvider
|
||||
from external_connector.providers.fake import FakeProvider
|
||||
from external_connector.providers.feishu_bot import FeishuBotProvider
|
||||
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
|
||||
from external_connector.state import SidecarStateStore
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
self.status_code = 200
|
||||
self.is_success = True
|
||||
self.text = json.dumps(payload)
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
def __init__(self) -> None:
|
||||
self.posts: list[tuple[str, dict[str, object] | None, dict[str, str] | None]] = []
|
||||
self.registration_poll_response: dict[str, object] = {"error": "authorization_pending"}
|
||||
|
||||
def post(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
json: dict[str, object] | None = None,
|
||||
data: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> FakeResponse:
|
||||
self.posts.append((url, json, headers))
|
||||
if url.endswith("/oauth/v1/app/registration"):
|
||||
params = parse_qs(data or "")
|
||||
action = str((params.get("action") or [""])[0])
|
||||
if action == "init":
|
||||
return FakeResponse({"supported_auth_methods": ["client_secret"]})
|
||||
if action == "begin":
|
||||
return FakeResponse(
|
||||
{
|
||||
"verification_uri_complete": "https://accounts.feishu.cn/scan?device=1",
|
||||
"device_code": "device-1",
|
||||
"interval": 1,
|
||||
"expire_in": 600,
|
||||
}
|
||||
)
|
||||
if action == "poll":
|
||||
return FakeResponse(self.registration_poll_response)
|
||||
if url.endswith("/open-apis/auth/v3/tenant_access_token/internal"):
|
||||
return FakeResponse({"code": 0, "tenant_access_token": "tenant-token", "expire": 7200})
|
||||
if "/open-apis/im/v1/messages" in url:
|
||||
return FakeResponse({"code": 0, "data": {"message_id": "om_out"}})
|
||||
raise AssertionError(url)
|
||||
|
||||
|
||||
def _provider(
|
||||
tmp_path,
|
||||
*,
|
||||
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] | None = None,
|
||||
http_client: FakeHttpClient | None = None,
|
||||
receiver_starts: list[str] | None = None,
|
||||
) -> FeishuBotProvider:
|
||||
def bridge_post(url: str, payload: dict[str, object], headers: dict[str, str]) -> None:
|
||||
if bridge_posts is not None:
|
||||
bridge_posts.append((url, payload, headers))
|
||||
|
||||
def start_receiver(session) -> object:
|
||||
if receiver_starts is not None:
|
||||
receiver_starts.append(session.connection_id)
|
||||
return object()
|
||||
|
||||
return FeishuBotProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http_client or FakeHttpClient(),
|
||||
bridge_base_url="http://beaver:8080",
|
||||
public_base_url="http://public-sidecar:8787",
|
||||
bridge_token="bridge-token",
|
||||
bridge_post=bridge_post,
|
||||
receiver_start=start_receiver,
|
||||
)
|
||||
|
||||
|
||||
def test_feishu_bot_provider_starts_create_session_with_qr_from_registration(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"mode": "create", "domain": "feishu"},
|
||||
}
|
||||
)
|
||||
|
||||
assert session["status"] == "qr_ready"
|
||||
assert session["qrCode"] == "https://accounts.feishu.cn/scan?device=1&from=onboard"
|
||||
assert session["qrImage"].startswith("data:image/svg+xml;base64,")
|
||||
assert any("一键创建飞书机器人" in item for item in session["instructions"])
|
||||
assert any("/feishu start" in item for item in session["instructions"])
|
||||
assert session["metadata"]["eventCallbackPath"] == "/feishu/events"
|
||||
assert session["metadata"]["eventCallbackUrl"] == "http://public-sidecar:8787/feishu/events"
|
||||
assert session["metadata"]["deviceCode"] == "device-1"
|
||||
|
||||
|
||||
def test_feishu_bot_provider_poll_connects_after_qr_confirmation(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
receiver_starts: list[str] = []
|
||||
provider = _provider(tmp_path, http_client=http, receiver_starts=receiver_starts)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"mode": "create", "domain": "feishu"},
|
||||
}
|
||||
)
|
||||
http.registration_poll_response = {
|
||||
"client_id": "cli_qr",
|
||||
"client_secret": "qr-secret",
|
||||
"user_info": {"tenant_brand": "feishu", "open_id": "ou_me"},
|
||||
}
|
||||
|
||||
connected = provider.get_session(session["sessionId"])
|
||||
repeated = provider.get_session(session["sessionId"])
|
||||
|
||||
assert connected["status"] == "connected"
|
||||
assert repeated["status"] == "connected"
|
||||
assert connected["accountId"] == "feishu:cli_qr"
|
||||
assert receiver_starts == ["conn_1"]
|
||||
stored = provider.store.get_session(session["sessionId"])
|
||||
assert stored.metadata["appId"] == "cli_qr"
|
||||
assert stored.metadata["appSecret"] == "qr-secret"
|
||||
assert stored.metadata["tenantAccessToken"] == "tenant-token"
|
||||
|
||||
|
||||
def test_feishu_bot_provider_connects_with_app_credentials(tmp_path) -> None:
|
||||
receiver_starts: list[str] = []
|
||||
provider = _provider(tmp_path, receiver_starts=receiver_starts)
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
|
||||
}
|
||||
)
|
||||
|
||||
assert session["status"] == "connected"
|
||||
assert session["accountId"] == "feishu:cli_xxx"
|
||||
assert session["displayName"] == "Feishu Main"
|
||||
assert receiver_starts == ["conn_1"]
|
||||
|
||||
|
||||
def test_feishu_bot_provider_send_uses_tenant_token_and_dedupes(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret"},
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"requestId": "out_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"kind": "feishu",
|
||||
"target": {"peerId": "ou_user", "peerType": "dm", "threadId": None},
|
||||
"content": "hello",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
first = provider.send(payload)
|
||||
second = provider.send(payload)
|
||||
|
||||
send_posts = [item for item in provider.http.posts if "/open-apis/im/v1/messages" in item[0]]
|
||||
assert session["status"] == "connected"
|
||||
assert first == second
|
||||
assert first["providerMessageId"] == "om_out"
|
||||
assert len(send_posts) == 1
|
||||
assert send_posts[0][0].startswith("https://open.feishu.cn/open-apis/im/v1/messages")
|
||||
assert send_posts[0][2]["Authorization"] == "Bearer tenant-token"
|
||||
assert send_posts[0][1]["receive_id"] == "ou_user"
|
||||
assert send_posts[0][1]["msg_type"] == "text"
|
||||
|
||||
|
||||
def test_feishu_event_route_returns_challenge(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/feishu/events", json={"challenge": "abc"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"challenge": "abc"}
|
||||
|
||||
|
||||
def test_feishu_event_route_forwards_message_to_bridge(tmp_path) -> None:
|
||||
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
|
||||
provider = _provider(tmp_path, bridge_posts=bridge_posts)
|
||||
provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
|
||||
}
|
||||
)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/feishu/events",
|
||||
json={
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "evt_1", "token": "verify-token", "app_id": "cli_xxx"},
|
||||
"event": {
|
||||
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||
"message": {
|
||||
"message_id": "om_1",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello feishu\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert bridge_posts[0][0] == "http://beaver:8080/api/channel-connector-bridge/events"
|
||||
assert bridge_posts[0][2]["Authorization"] == "Bearer bridge-token"
|
||||
assert bridge_posts[0][1]["eventId"] == "evt_1"
|
||||
assert bridge_posts[0][1]["content"] == "hello feishu"
|
||||
assert bridge_posts[0][1]["peerId"] == "ou_user"
|
||||
|
||||
|
||||
def test_composite_provider_routes_feishu_and_weixin_descriptors(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
provider = CompositeProvider([FakeProvider(store), _provider(tmp_path)])
|
||||
|
||||
connectors = provider.connectors()
|
||||
|
||||
assert [item["kind"] for item in connectors] == ["weixin", "feishu", "feishu"]
|
||||
assert provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)["status"] == "qr_ready"
|
||||
|
||||
|
||||
def test_composite_provider_get_session_routes_feishu_session_to_feishu_provider(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
provider = CompositeProvider(
|
||||
[
|
||||
WeixinIlinkProvider(
|
||||
store=store,
|
||||
http_client=FakeHttpClient(),
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
),
|
||||
FeishuBotProvider(
|
||||
store=store,
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
public_base_url="http://public-sidecar:8787",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"mode": "create", "domain": "feishu"},
|
||||
}
|
||||
)
|
||||
http.registration_poll_response = {
|
||||
"client_id": "cli_qr",
|
||||
"client_secret": "qr-secret",
|
||||
"user_info": {"tenant_brand": "feishu", "open_id": "ou_me"},
|
||||
}
|
||||
|
||||
connected = provider.get_session(session["sessionId"])
|
||||
|
||||
assert connected["status"] == "connected"
|
||||
assert connected["accountId"] == "feishu:cli_qr"
|
||||
135
external-connector/tests/test_sidecar_api.py
Normal file
135
external-connector/tests/test_sidecar_api.py
Normal file
@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from external_connector.app import create_app
|
||||
from external_connector.providers.fake import FakeProvider
|
||||
from external_connector.state import SidecarStateStore
|
||||
|
||||
|
||||
def test_fake_provider_lists_weixin_and_feishu(tmp_path) -> None:
|
||||
provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))
|
||||
|
||||
connectors = provider.connectors()
|
||||
|
||||
assert [item["kind"] for item in connectors] == ["weixin", "feishu"]
|
||||
assert connectors[0]["authType"] == "qr"
|
||||
|
||||
|
||||
def test_fake_provider_session_flow(tmp_path) -> None:
|
||||
provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
loaded = provider.get_session(session["sessionId"])
|
||||
|
||||
assert session["status"] == "qr_ready"
|
||||
prefix = "data:image/svg+xml;base64,"
|
||||
assert session["qrImage"].startswith(prefix)
|
||||
svg = base64.b64decode(session["qrImage"][len(prefix) :]).decode("utf-8")
|
||||
assert svg.startswith("<svg")
|
||||
assert "FAKE QR" in svg
|
||||
assert loaded["sessionId"] == session["sessionId"]
|
||||
|
||||
|
||||
def test_fake_provider_send_returns_idempotent_result(tmp_path) -> None:
|
||||
provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))
|
||||
payload = {
|
||||
"requestId": "out_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
|
||||
"content": "hello",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
first = provider.send(payload)
|
||||
second = provider.send(payload)
|
||||
|
||||
assert first == second
|
||||
assert first["ok"] is True
|
||||
|
||||
|
||||
def test_sidecar_http_api_requires_bearer_token(tmp_path) -> None:
|
||||
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/connectors")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_sidecar_http_api_session_and_send(tmp_path) -> None:
|
||||
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")
|
||||
headers = {"Authorization": "Bearer sidecar-token"}
|
||||
|
||||
with TestClient(app) as client:
|
||||
connectors = client.get("/connectors", headers=headers)
|
||||
session = client.post(
|
||||
"/connector-sessions",
|
||||
headers=headers,
|
||||
json={
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
},
|
||||
)
|
||||
session_id = session.json()["sessionId"]
|
||||
loaded = client.get(f"/connector-sessions/{session_id}", headers=headers)
|
||||
sent = client.post(
|
||||
"/send",
|
||||
headers=headers,
|
||||
json={
|
||||
"requestId": "out_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
|
||||
"content": "hello",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert connectors.status_code == 200
|
||||
assert session.status_code == 200
|
||||
assert loaded.json()["sessionId"] == session_id
|
||||
assert sent.json()["ok"] is True
|
||||
|
||||
|
||||
def test_sidecar_http_api_returns_conflict_for_processing_send(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)
|
||||
store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
app = create_app(provider=FakeProvider(store), api_token="sidecar-token")
|
||||
headers = {"Authorization": "Bearer sidecar-token"}
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/send",
|
||||
headers=headers,
|
||||
json={
|
||||
"requestId": "out_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
|
||||
"content": "hello",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["retryAfterSeconds"] == 5
|
||||
68
external-connector/tests/test_state.py
Normal file
68
external-connector/tests/test_state.py
Normal file
@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from external_connector.state import SidecarStateStore
|
||||
|
||||
|
||||
def test_state_store_saves_and_loads_connector_sessions(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
|
||||
session = store.create_session(
|
||||
kind="weixin",
|
||||
connection_id="conn_1",
|
||||
channel_id="weixin-main",
|
||||
display_name="Weixin Main",
|
||||
options={},
|
||||
)
|
||||
store.update_session(session.session_id, status="connected", account_id="weixin:me", display_name="Me")
|
||||
loaded = store.get_session(session.session_id)
|
||||
|
||||
assert session.session_id.startswith("cs_")
|
||||
assert loaded.status == "connected"
|
||||
assert loaded.account_id == "weixin:me"
|
||||
|
||||
|
||||
def test_state_store_dedupes_send_results(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
|
||||
first = store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
store.complete_send(first.dedupe_key, provider_message_id="provider-1")
|
||||
duplicate = store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
|
||||
assert first.should_send is True
|
||||
assert duplicate.should_send is False
|
||||
assert duplicate.status == "completed"
|
||||
assert duplicate.http_status == 200
|
||||
assert duplicate.provider_message_id == "provider-1"
|
||||
|
||||
|
||||
def test_state_store_returns_conflict_for_active_send_processing(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)
|
||||
|
||||
store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
duplicate = store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
|
||||
assert duplicate.should_send is False
|
||||
assert duplicate.status == "processing"
|
||||
assert duplicate.http_status == 409
|
||||
assert duplicate.retry_after_seconds == 5
|
||||
|
||||
|
||||
def test_state_store_retries_stale_send_processing(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=0)
|
||||
|
||||
store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
retry = store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
|
||||
assert retry.should_send is True
|
||||
assert retry.status == "processing"
|
||||
|
||||
|
||||
def test_state_store_retries_failed_send_immediately(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)
|
||||
|
||||
first = store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
store.fail_send(first.dedupe_key, error="provider rejected message")
|
||||
retry = store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
|
||||
assert retry.should_send is True
|
||||
assert retry.status == "processing"
|
||||
182
external-connector/tests/test_vendor_cli_provider.py
Normal file
182
external-connector/tests/test_vendor_cli_provider.py
Normal file
@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from external_connector.providers.vendor_cli import VendorCliProvider
|
||||
from external_connector.state import SidecarStateStore
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[list[str]] = []
|
||||
self.cwd: str | None = None
|
||||
self.timeout: float | None = None
|
||||
|
||||
def __call__(self, command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
|
||||
self.commands.append(command)
|
||||
self.cwd = cwd
|
||||
self.timeout = timeout
|
||||
return 0, "connected account=weixin:me", ""
|
||||
|
||||
|
||||
def test_vendor_cli_provider_uses_env_command_templates(tmp_path) -> None:
|
||||
runner = FakeRunner()
|
||||
provider = VendorCliProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
env={
|
||||
"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}",
|
||||
"CONNECTOR_COMMAND_TIMEOUT_SECONDS": "30",
|
||||
},
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert session["status"] in {"waiting_for_user", "connected"}
|
||||
assert runner.commands[0][0] == "vendor-weixin"
|
||||
assert runner.cwd == str(tmp_path)
|
||||
assert runner.timeout == 30.0
|
||||
|
||||
|
||||
def test_vendor_cli_provider_redacts_sensitive_error(tmp_path) -> None:
|
||||
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
|
||||
return 1, "", "failed secret-token appSecret=abc"
|
||||
|
||||
provider = VendorCliProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
env={"FEISHU_CONNECT_COMMAND": "vendor-feishu install --secret abc"},
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert session["status"] == "error"
|
||||
assert "secret-token" not in (session["error"] or "")
|
||||
assert "appSecret=abc" not in (session["error"] or "")
|
||||
|
||||
|
||||
def test_vendor_cli_provider_refreshes_session_from_status_command_json(tmp_path) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
|
||||
calls.append(command)
|
||||
if command[0] == "vendor-weixin-status":
|
||||
return (
|
||||
0,
|
||||
'{"status":"qr_ready","qrImage":"data:image/png;base64,abc","qrCode":"weixin://scan","metadata":{"phase":"scan"}}',
|
||||
"",
|
||||
)
|
||||
return 0, "waiting", ""
|
||||
|
||||
provider = VendorCliProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
env={
|
||||
"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}",
|
||||
"WEIXIN_STATUS_COMMAND": "vendor-weixin-status --state {state_dir} --session {session_id}",
|
||||
},
|
||||
runner=runner,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
|
||||
refreshed = provider.get_session(session["sessionId"])
|
||||
|
||||
assert calls[1][0] == "vendor-weixin-status"
|
||||
assert refreshed["status"] == "qr_ready"
|
||||
assert refreshed["qrImage"] == "data:image/png;base64,abc"
|
||||
assert refreshed["qrCode"] == "weixin://scan"
|
||||
assert refreshed["metadata"]["phase"] == "scan"
|
||||
|
||||
|
||||
def test_vendor_cli_provider_refreshes_connected_session_from_key_value_status(tmp_path) -> None:
|
||||
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
|
||||
if command[0] == "vendor-feishu-status":
|
||||
return 0, "status=connected accountId=feishu:tenant-bot displayName=FeishuBot", ""
|
||||
return 0, "waiting", ""
|
||||
|
||||
provider = VendorCliProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
env={
|
||||
"FEISHU_CONNECT_COMMAND": "vendor-feishu install --state {state_dir}",
|
||||
"FEISHU_STATUS_COMMAND": "vendor-feishu-status --state {state_dir}",
|
||||
},
|
||||
runner=runner,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
|
||||
refreshed = provider.get_session(session["sessionId"])
|
||||
|
||||
assert refreshed["status"] == "connected"
|
||||
assert refreshed["accountId"] == "feishu:tenant-bot"
|
||||
assert refreshed["displayName"] == "FeishuBot"
|
||||
|
||||
|
||||
def test_vendor_cli_provider_send_uses_payload_file_and_dedupes_result(tmp_path) -> None:
|
||||
payloads: list[dict[str, object]] = []
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
|
||||
commands.append(command)
|
||||
payload_path = Path(command[command.index("--payload") + 1])
|
||||
payloads.append(json.loads(payload_path.read_text(encoding="utf-8")))
|
||||
return 0, '{"providerMessageId":"wx-msg-1"}', ""
|
||||
|
||||
provider = VendorCliProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
env={"WEIXIN_SEND_COMMAND": "vendor-weixin-send --payload {payload_path}"},
|
||||
runner=runner,
|
||||
)
|
||||
payload = {
|
||||
"requestId": "out_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
|
||||
"content": "hello world",
|
||||
"metadata": {"source": "test"},
|
||||
}
|
||||
|
||||
first = provider.send(payload)
|
||||
duplicate = provider.send(payload)
|
||||
|
||||
assert first == {"ok": True, "providerMessageId": "wx-msg-1"}
|
||||
assert duplicate == first
|
||||
assert len(commands) == 1
|
||||
assert payloads[0]["content"] == "hello world"
|
||||
assert payloads[0]["target"] == {"peerId": "peer-1", "peerType": "dm", "threadId": None}
|
||||
408
external-connector/tests/test_weixin_ilink_provider.py
Normal file
408
external-connector/tests/test_weixin_ilink_provider.py
Normal file
@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
|
||||
from external_connector.state import SidecarStateStore
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
self.text = json.dumps(payload)
|
||||
self.status_code = 200
|
||||
self.is_success = True
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
def __init__(self) -> None:
|
||||
self.posts: list[tuple[str, dict[str, object] | None, dict[str, str] | None]] = []
|
||||
self.gets: list[str] = []
|
||||
|
||||
def post(self, url: str, *, json: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
|
||||
self.posts.append((url, json, headers))
|
||||
if "get_bot_qrcode" in url:
|
||||
return FakeResponse({"qrcode": "qr-token", "qrcode_img_content": "https://scan.example/qr"})
|
||||
if "sendmessage" in url:
|
||||
return FakeResponse({"ret": 0})
|
||||
if "getupdates" in url:
|
||||
return FakeResponse(
|
||||
{
|
||||
"ret": 0,
|
||||
"get_updates_buf": "next-buf",
|
||||
"msgs": [
|
||||
{
|
||||
"message_id": 42,
|
||||
"from_user_id": "wx-user",
|
||||
"to_user_id": "wx-bot",
|
||||
"context_token": "ctx-1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
raise AssertionError(url)
|
||||
|
||||
def get(self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
|
||||
self.gets.append(url)
|
||||
return FakeResponse(
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "bot-token",
|
||||
"ilink_bot_id": "bot-1@im.bot",
|
||||
"baseurl": "https://api.weixin.example",
|
||||
"ilink_user_id": "wx-owner",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BusinessFailingSendHttpClient(FakeHttpClient):
|
||||
def post(self, url: str, *, json: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
|
||||
self.posts.append((url, json, headers))
|
||||
if "sendmessage" in url:
|
||||
return FakeResponse({"ret": 47001, "errmsg": "invalid receiver"})
|
||||
return super().post(url, json=json, headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
class ScannedAfterConnectedHttpClient(FakeHttpClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.get_count = 0
|
||||
|
||||
def get(self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
|
||||
self.gets.append(url)
|
||||
self.get_count += 1
|
||||
if self.get_count == 1:
|
||||
return FakeResponse(
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "bot-token",
|
||||
"ilink_bot_id": "bot-1@im.bot",
|
||||
"baseurl": "https://api.weixin.example",
|
||||
"ilink_user_id": "wx-owner",
|
||||
}
|
||||
)
|
||||
return FakeResponse({"status": "scaned"})
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_starts_real_qr_session(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
)
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert session["status"] == "qr_ready"
|
||||
assert session["qrCode"] == "https://scan.example/qr"
|
||||
assert session["qrImage"].startswith("data:image/svg+xml;base64,")
|
||||
assert http.posts[0][0].endswith("/ilink/bot/get_bot_qrcode?bot_type=3")
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_connects_on_confirmed_status(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
|
||||
connected = provider.get_session(session["sessionId"])
|
||||
|
||||
assert connected["status"] == "connected"
|
||||
assert connected["accountId"] == "weixin:bot-1@im.bot"
|
||||
assert connected["displayName"] == "Weixin Main"
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_does_not_downgrade_token_session_to_scanned(tmp_path) -> None:
|
||||
http = ScannedAfterConnectedHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
connected = provider.get_session(session["sessionId"])
|
||||
refreshed = provider.get_session(session["sessionId"])
|
||||
|
||||
assert connected["status"] == "connected"
|
||||
assert refreshed["status"] == "connected"
|
||||
assert refreshed["accountId"] == "weixin:bot-1@im.bot"
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_recovers_token_session_persisted_as_scanned(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
provider = WeixinIlinkProvider(
|
||||
store=store,
|
||||
http_client=FakeHttpClient(),
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
)
|
||||
session = store.create_session(
|
||||
kind="weixin",
|
||||
connection_id="conn_1",
|
||||
channel_id="weixin-main",
|
||||
display_name="Weixin Main",
|
||||
options={},
|
||||
)
|
||||
session = store.update_session(
|
||||
session.session_id,
|
||||
status="scanned",
|
||||
account_id="weixin:bot-1@im.bot",
|
||||
metadata={
|
||||
"token": "bot-token",
|
||||
"baseUrl": "https://api.weixin.example",
|
||||
"userId": "wx-owner",
|
||||
"getUpdatesBuf": "buf",
|
||||
},
|
||||
)
|
||||
|
||||
recovered = provider.get_session(session.session_id)
|
||||
|
||||
assert recovered["status"] == "connected"
|
||||
assert recovered["accountId"] == "weixin:bot-1@im.bot"
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_send_uses_saved_token_and_dedupes(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
provider.get_session(session["sessionId"])
|
||||
payload = {
|
||||
"requestId": "out_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
|
||||
"content": "reply",
|
||||
"metadata": {"contextToken": "ctx-1"},
|
||||
}
|
||||
|
||||
first = provider.send(payload)
|
||||
second = provider.send(payload)
|
||||
|
||||
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
|
||||
assert first == second
|
||||
assert first["ok"] is True
|
||||
assert len(send_posts) == 1
|
||||
assert send_posts[0][2]["Authorization"] == "Bearer bot-token"
|
||||
assert send_posts[0][1]["msg"]["from_user_id"] == ""
|
||||
assert send_posts[0][1]["msg"]["to_user_id"] == "wx-user"
|
||||
assert send_posts[0][1]["msg"]["client_id"] == "out_1"
|
||||
assert send_posts[0][1]["msg"]["message_type"] == 2
|
||||
assert send_posts[0][1]["msg"]["message_state"] == 2
|
||||
assert send_posts[0][1]["msg"]["context_token"] == "ctx-1"
|
||||
assert send_posts[0][1]["msg"]["item_list"][0]["text_item"]["text"] == "reply"
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_send_uses_cached_context_token(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
bridge_post=lambda url, payload, headers: None,
|
||||
start_receivers=False,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
provider.get_session(session["sessionId"])
|
||||
provider.poll_once("conn_1")
|
||||
|
||||
result = provider.send(
|
||||
{
|
||||
"requestId": "out_2",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
|
||||
"content": "reply",
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
|
||||
assert result["ok"] is True
|
||||
assert send_posts[-1][1]["msg"]["context_token"] == "ctx-1"
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_send_uses_safe_client_id_for_platform(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
provider.get_session(session["sessionId"])
|
||||
|
||||
provider.send(
|
||||
{
|
||||
"requestId": "out_weixin-main:account@im.bot:peer@im.wechat:msg-1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
|
||||
"content": "reply",
|
||||
"metadata": {"contextToken": "ctx-1"},
|
||||
}
|
||||
)
|
||||
|
||||
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
|
||||
client_id = send_posts[-1][1]["msg"]["client_id"]
|
||||
assert client_id.startswith("beaver-weixin-")
|
||||
assert ":" not in client_id
|
||||
assert "@" not in client_id
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_send_rejects_business_error_without_completing(tmp_path) -> None:
|
||||
http = BusinessFailingSendHttpClient()
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
provider = WeixinIlinkProvider(
|
||||
store=store,
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
start_receivers=False,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
provider.get_session(session["sessionId"])
|
||||
payload = {
|
||||
"requestId": "out_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
|
||||
"content": "reply",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
result = provider.send(payload)
|
||||
retry = store.begin_send(connection_id="conn_1", request_id="out_1")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "invalid receiver" in result["error"]
|
||||
assert retry.should_send is True
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_poll_once_forwards_bridge_event(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
|
||||
|
||||
def bridge_post(url: str, payload: dict[str, object], headers: dict[str, str]) -> None:
|
||||
bridge_posts.append((url, payload, headers))
|
||||
|
||||
provider = WeixinIlinkProvider(
|
||||
store=SidecarStateStore(tmp_path / "state.json"),
|
||||
http_client=http,
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
bridge_post=bridge_post,
|
||||
start_receivers=False,
|
||||
)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"displayName": "Weixin Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
provider.get_session(session["sessionId"])
|
||||
|
||||
provider.poll_once("conn_1")
|
||||
|
||||
assert bridge_posts[0][0] == "http://beaver:8080/api/channel-connector-bridge/events"
|
||||
assert bridge_posts[0][2]["Authorization"] == "Bearer bridge-token"
|
||||
assert bridge_posts[0][1]["eventId"] == "weixin-main:42"
|
||||
assert bridge_posts[0][1]["content"] == "hello"
|
||||
assert bridge_posts[0][1]["peerId"] == "wx-user"
|
||||
Reference in New Issue
Block a user