138 lines
5.5 KiB
Python
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
|