feat: add Box IM client for Netease Yunxin integration
Add BoxIMClient with get_credentials and send_p2p_message methods, using Huihui platform MD5 signing mechanism (sorted keys, 24h timestamp, 12-char alphanumeric nonce). Includes full test coverage.
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
"""盒子 IM 客户端 — 封装网易云信 IM 接口调用"""
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class BoxIMClient:
|
||||||
|
"""盒子 IM 客户端,通过会会平台网关调用网易云信 IM"""
|
||||||
|
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
self.base_url = config.get("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040")
|
||||||
|
self.app_id = config.get("HUIHUI_APP_ID", "")
|
||||||
|
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
|
||||||
|
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
|
||||||
|
|
||||||
|
def _build_sign_params(self, extra: dict) -> dict:
|
||||||
|
"""构建带签名的请求参数(复用 news_service 签名模式)"""
|
||||||
|
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S") # 24小时制
|
||||||
|
params = {
|
||||||
|
"appId": self.app_id,
|
||||||
|
"accessId": self.access_id,
|
||||||
|
"nonce": nonce,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
# 计算签名 — 排序 key, 过滤空值, 拼接后加 accessSecret, MD5 大写
|
||||||
|
keys = sorted(params.keys())
|
||||||
|
sign_parts = []
|
||||||
|
for k in keys:
|
||||||
|
v = params.get(k)
|
||||||
|
if v and v != "" and v != []:
|
||||||
|
sign_parts.append(f"{k}={v}")
|
||||||
|
sign_str = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
|
||||||
|
signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||||
|
params["signature"] = signature
|
||||||
|
params["signType"] = "MD5"
|
||||||
|
params["signVersion"] = "1.0"
|
||||||
|
return params
|
||||||
|
|
||||||
|
async def get_credentials(self, user_id: str) -> Optional[dict]:
|
||||||
|
"""获取用户的网易云信 IM 凭证 (accid, token)"""
|
||||||
|
params = self._build_sign_params({"userId": user_id})
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base_url}/box/netease",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
data = r.json()
|
||||||
|
if data.get("code") in (0, 200):
|
||||||
|
return data.get("data", {})
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def send_p2p_message(
|
||||||
|
self, from_accid: str, to_accid: str, content: str
|
||||||
|
) -> bool:
|
||||||
|
"""发送单聊消息(文本)"""
|
||||||
|
params = self._build_sign_params({
|
||||||
|
"from": from_accid,
|
||||||
|
"to": to_accid,
|
||||||
|
"msgType": "text",
|
||||||
|
"content": content,
|
||||||
|
})
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base_url}/box/message/send/p2p",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
data = r.json()
|
||||||
|
return data.get("code") in (0, 200)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests for the Box IM client (Netease Yunxin gateway wrapper)."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config():
|
||||||
|
return {
|
||||||
|
"HUIHUI_IM_BASE_URL": "http://192.168.1.200:60040",
|
||||||
|
"HUIHUI_APP_ID": "test_app",
|
||||||
|
"HUIHUI_ACCESS_ID": "test_access",
|
||||||
|
"HUIHUI_ACCESS_SECRET": "test_secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_response(json_data: dict):
|
||||||
|
"""Create a properly configured mock for httpx.Response."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = json_data
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_httpx_client(json_data: dict):
|
||||||
|
"""Patch httpx.AsyncClient so that `async with httpx.AsyncClient() as c: await c.post(...)` returns json_data."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = _make_mock_response(json_data)
|
||||||
|
|
||||||
|
mock_cm = AsyncMock()
|
||||||
|
mock_cm.__aenter__.return_value = mock_client
|
||||||
|
mock_cm.__aexit__.return_value = None
|
||||||
|
|
||||||
|
return patch("httpx.AsyncClient", return_value=mock_cm)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_credentials(mock_config):
|
||||||
|
"""get_credentials should return accid and token from the gateway response."""
|
||||||
|
with _patch_httpx_client({"code": 200, "data": {"accid": "user123", "token": "tok_xyz"}}):
|
||||||
|
from services.boxim_client import BoxIMClient
|
||||||
|
|
||||||
|
client = BoxIMClient(mock_config)
|
||||||
|
result = await client.get_credentials("user123")
|
||||||
|
|
||||||
|
assert result["accid"] == "user123"
|
||||||
|
assert result["token"] == "tok_xyz"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_p2p_message_success(mock_config):
|
||||||
|
"""send_p2p_message should return True when the gateway responds with code 200."""
|
||||||
|
with _patch_httpx_client({"code": 200}):
|
||||||
|
from services.boxim_client import BoxIMClient
|
||||||
|
|
||||||
|
client = BoxIMClient(mock_config)
|
||||||
|
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_p2p_message_failure(mock_config):
|
||||||
|
"""send_p2p_message should return False when the gateway responds with a non-200 code."""
|
||||||
|
with _patch_httpx_client({"code": 500, "message": "error"}):
|
||||||
|
from services.boxim_client import BoxIMClient
|
||||||
|
|
||||||
|
client = BoxIMClient(mock_config)
|
||||||
|
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_credentials_returns_none_on_error(mock_config):
|
||||||
|
"""get_credentials should return None when the gateway responds with an error code."""
|
||||||
|
with _patch_httpx_client({"code": 500, "message": "user not found"}):
|
||||||
|
from services.boxim_client import BoxIMClient
|
||||||
|
|
||||||
|
client = BoxIMClient(mock_config)
|
||||||
|
result = await client.get_credentials("nonexistent")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sign_params_contains_required_fields(mock_config):
|
||||||
|
"""_build_sign_params should produce appId, accessId, nonce, timestamp, signature, signType, signVersion."""
|
||||||
|
from services.boxim_client import BoxIMClient
|
||||||
|
|
||||||
|
client = BoxIMClient(mock_config)
|
||||||
|
params = client._build_sign_params({"userId": "u1"})
|
||||||
|
|
||||||
|
assert "appId" in params
|
||||||
|
assert "accessId" in params
|
||||||
|
assert "nonce" in params
|
||||||
|
assert "timestamp" in params
|
||||||
|
assert "signature" in params
|
||||||
|
assert params["signType"] == "MD5"
|
||||||
|
assert params["signVersion"] == "1.0"
|
||||||
|
assert len(params["nonce"]) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sign_params_signature_is_deterministic(mock_config):
|
||||||
|
"""Same inputs should produce valid MD5 signatures."""
|
||||||
|
from services.boxim_client import BoxIMClient
|
||||||
|
|
||||||
|
client = BoxIMClient(mock_config)
|
||||||
|
|
||||||
|
params1 = client._build_sign_params({"userId": "u1"})
|
||||||
|
params2 = client._build_sign_params({"userId": "u1"})
|
||||||
|
|
||||||
|
assert params1["signature"] is not None
|
||||||
|
assert params2["signature"] is not None
|
||||||
|
assert len(params1["signature"]) == 32 # MD5 hex length
|
||||||
Reference in New Issue
Block a user