473 lines
16 KiB
Python
473 lines
16 KiB
Python
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_starts_existing_connected_receiver_on_startup(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:bot-1@im.bot",
|
|
metadata={
|
|
"token": "bot-token",
|
|
"baseUrl": "https://api.weixin.example",
|
|
"userId": "wx-owner",
|
|
"getUpdatesBuf": "buf",
|
|
},
|
|
)
|
|
|
|
provider = WeixinIlinkProvider(
|
|
store=store,
|
|
http_client=FakeHttpClient(),
|
|
bridge_base_url="http://beaver:8080",
|
|
bridge_token="bridge-token",
|
|
)
|
|
|
|
assert "conn_1" in provider._receiver_stops
|
|
provider.logout("conn_1")
|
|
|
|
|
|
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"
|
|
|
|
|
|
def test_weixin_ilink_provider_poll_once_uses_session_callback_base_url(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://global-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://app-instance-jaychen:8080",
|
|
"options": {},
|
|
}
|
|
)
|
|
provider.get_session(session["sessionId"])
|
|
|
|
provider.poll_once("conn_1")
|
|
|
|
assert bridge_posts[0][0] == "http://app-instance-jaychen:8080/api/channel-connector-bridge/events"
|