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