AgentEvalTool/backend/agenteval/channels/tutu.py

138 lines
5.5 KiB
Python

"""Tutu API message channel implementation."""
import asyncio
import json
import time
from typing import Any, Optional
import httpx
from agenteval.channels.base import ChannelHealth, EvalChannel, Reply, SendResult
class TutuApiChannel(EvalChannel):
"""Message channel backed by the Tutu chat API.
Uses a single ``httpx.AsyncClient`` per instance to reuse TCP connections
across the many small poll requests during an evaluation run.
"""
def __init__(self, config: dict[str, Any]):
self.base_url = config["base_url"].rstrip("/")
self.token = config["token"]
self.tenant = config["tenant"]
self.chat_channel_id = config["chat_channel_id"]
self.chat_contact_id = config["chat_contact_id"]
self.sender_type = config.get("sender_type", "WORK_WE_CUSTOMER")
self.contact_type = config.get("contact_type", "EXTERNAL")
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=15.0)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
def _build_url(self, path: str) -> str:
return f"{self.base_url}/api/{self.tenant}/{path}"
def _build_headers(self, accept: str = "*/*") -> dict[str, str]:
return {
"accept": accept,
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
async def health_check(self) -> ChannelHealth:
"""Send a lightweight request to verify connectivity."""
client = await self._get_client()
try:
url = self._build_url("v1/chat/message")
params = {
"chatChannelId": self.chat_channel_id,
"chatContactId": self.chat_contact_id,
"page": 0,
"size": 1,
}
resp = await client.get(url, headers=self._build_headers(), params=params)
if resp.status_code == 200:
return ChannelHealth(ok=True, message="通道正常")
return ChannelHealth(ok=False, message=f"HTTP {resp.status_code}: {resp.text[:200]}")
except Exception as exc:
return ChannelHealth(ok=False, message=f"请求异常: {exc}")
async def _send(self, content: str, **kwargs: Any) -> SendResult:
"""Send a text message to the configured chat contact."""
payload = {
"chatChannelId": self.chat_channel_id,
"chatContactType": self.contact_type,
"chatContactId": self.chat_contact_id,
"msgType": kwargs.get("msg_type", "text"),
"msgBody": json.dumps({"content": content}, ensure_ascii=False),
"actualSenderType": self.sender_type,
"sender": {"type": self.contact_type},
}
client = await self._get_client()
try:
url = self._build_url("v1/chat/message/sendMsg")
resp = await client.post(url, headers=self._build_headers(), json=payload)
if resp.status_code != 200:
return SendResult(ok=False, error=f"HTTP {resp.status_code}: {resp.text[:500]}")
data = resp.json()
# The reply references the sent message via metadata.questionMsgId == sent msgId.
question_msg_id = data.get("msgId")
return SendResult(ok=True, question_msg_id=question_msg_id, raw_response=data)
except Exception as exc:
return SendResult(ok=False, error=f"发送异常: {exc}")
async def _poll_reply(
self,
question_msg_id: str,
timeout: float = 30.0,
poll_interval: float = 1.0,
) -> Optional[Reply]:
"""Poll chat history until a reply matching the questionMsgId arrives."""
deadline = time.time() + timeout
seen_ids: set[str] = set()
client = await self._get_client()
while time.time() < deadline:
try:
url = self._build_url("v1/chat/message")
params = {
"chatChannelId": self.chat_channel_id,
"chatContactId": self.chat_contact_id,
"page": 0,
"size": 20,
}
resp = await client.get(url, headers=self._build_headers(), params=params)
if resp.status_code == 200:
data = resp.json()
records = data.get("data", []) if isinstance(data, dict) else []
for msg in records:
msg_id = msg.get("id") or msg.get("msgId")
if not msg_id or msg_id in seen_ids:
continue
seen_ids.add(msg_id)
meta = msg.get("metadata", {})
if meta.get("questionMsgId") == question_msg_id:
return Reply(
question_msg_id=question_msg_id,
content=msg.get("msgBody"),
sender_name=msg.get("senderName") or msg.get("actualSenderName"),
msg_time=msg.get("msgTime"),
raw_message=msg,
)
except Exception:
pass
await asyncio.sleep(poll_interval)
return None