本次提交新增了完整的多渠道消息网关系统,包括: 1. 支持飞书、钉钉、Web、Hook 四种渠道的适配器与配置 2. 领域模型层:消息、会话、绑定、出箱等核心实体 3. 应用服务层:管道、中间件、DTO 与业务逻辑 4. 基础设施层:持久化、过滤器、队列等端口实现 5. 接口层:REST API、SSE、WebSocket 通信端点 6. 前端页面与路由配置,添加渠道管理菜单 7. 新增相关依赖包与 docker-compose 部署配置
123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
|
|
from yuxi.channel.domain.model.shared.content_chunker import chunk_content
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DINGTALK_CAPABILITIES = ChannelCapabilities(
|
|
media=False,
|
|
group=True,
|
|
dm=True,
|
|
streaming=False,
|
|
typing=False,
|
|
reaction=False,
|
|
thread=False,
|
|
max_text_length=4096,
|
|
)
|
|
|
|
|
|
class DingTalkOutbound:
|
|
def __init__(self, *, client_id: str, client_secret: str) -> None:
|
|
self._client_id = client_id
|
|
self._client_secret = client_secret
|
|
self._access_token: str = ""
|
|
self._token_expires_at: float = 0.0
|
|
self._lock = asyncio.Lock()
|
|
|
|
@property
|
|
def client_id(self) -> str:
|
|
return self._client_id
|
|
|
|
async def start(self) -> None:
|
|
pass
|
|
|
|
async def send_text(self, conversation_id: str, text: str) -> bool:
|
|
token = await self._get_access_token()
|
|
if not token:
|
|
logger.warning("dingtalk access token not available, skipping send")
|
|
return False
|
|
|
|
chunks = chunk_content(text, DINGTALK_CAPABILITIES.max_text_length)
|
|
for chunk in chunks:
|
|
if not await self._send_via_api(conversation_id, chunk):
|
|
return False
|
|
return True
|
|
|
|
async def _get_access_token(self, *, invalidate_first: bool = False) -> str:
|
|
async with self._lock:
|
|
if invalidate_first:
|
|
self._access_token = ""
|
|
self._token_expires_at = 0.0
|
|
|
|
if self._access_token and time.monotonic() < self._token_expires_at:
|
|
return self._access_token
|
|
|
|
def _sync_get() -> tuple[str, int]:
|
|
resp = httpx.post(
|
|
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
|
|
json={"appKey": self._client_id, "appSecret": self._client_secret},
|
|
timeout=10.0,
|
|
)
|
|
data = resp.json()
|
|
token = data.get("accessToken", "")
|
|
expire_in = data.get("expireIn", 7200)
|
|
return token, expire_in
|
|
|
|
try:
|
|
token, expire_in = await asyncio.to_thread(_sync_get)
|
|
if token:
|
|
self._access_token = token
|
|
self._token_expires_at = time.monotonic() + expire_in - 300
|
|
except Exception:
|
|
logger.exception("dingtalk get access token failed")
|
|
return self._access_token
|
|
|
|
def _invalidate_token(self) -> None:
|
|
self._access_token = ""
|
|
self._token_expires_at = 0.0
|
|
|
|
async def _send_via_api(self, conversation_id: str, text: str) -> bool:
|
|
token = await self._get_access_token(invalidate_first=True)
|
|
if not token:
|
|
return False
|
|
|
|
def _sync_send() -> bool:
|
|
resp = httpx.post(
|
|
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
|
|
headers={"x-acs-dingtalk-access-token": token},
|
|
json={
|
|
"robotCode": self._client_id,
|
|
"conversationIds": [conversation_id],
|
|
"msgKey": "sampleText",
|
|
"msgParam": json.dumps({"content": text}),
|
|
},
|
|
timeout=10.0,
|
|
)
|
|
if resp.status_code in (401, 403):
|
|
return False
|
|
if resp.status_code != 200:
|
|
logger.error(
|
|
"dingtalk send failed: status=%s, body=%s",
|
|
resp.status_code,
|
|
resp.text,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
try:
|
|
ok = await asyncio.to_thread(_sync_send)
|
|
if not ok:
|
|
self._invalidate_token()
|
|
return ok
|
|
except Exception:
|
|
logger.exception("dingtalk send error")
|
|
return False
|