ForcePilot/backend/package/yuxi/channel/channels/dingtalk/outbound.py
Kris 9a8a27bf36 feat(channel): 新增渠道网关模块完整实现
本次提交新增了完整的多渠道消息网关系统,包括:
1. 支持飞书、钉钉、Web、Hook 四种渠道的适配器与配置
2. 领域模型层:消息、会话、绑定、出箱等核心实体
3. 应用服务层:管道、中间件、DTO 与业务逻辑
4. 基础设施层:持久化、过滤器、队列等端口实现
5. 接口层:REST API、SSE、WebSocket 通信端点
6. 前端页面与路由配置,添加渠道管理菜单
7. 新增相关依赖包与 docker-compose 部署配置
2026-05-30 21:53:09 +08:00

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