mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-07 20:00:59 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
1040 lines
44 KiB
Python
1040 lines
44 KiB
Python
import asyncio
|
||
import base64
|
||
import json
|
||
import logging
|
||
import os
|
||
import time
|
||
import typing
|
||
import uuid
|
||
import urllib.parse
|
||
from contextlib import asynccontextmanager
|
||
from typing import Awaitable, Callable, Optional
|
||
import dingtalk_stream # type: ignore
|
||
import websockets
|
||
from .EchoHandler import EchoTextHandler
|
||
from .card_callback import DingTalkCardActionHandler
|
||
from .dingtalkevent import DingTalkEvent
|
||
import httpx
|
||
import traceback
|
||
from langbot.pkg.utils import httpclient
|
||
|
||
|
||
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
|
||
|
||
|
||
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
|
||
_MAX_MEDIA_BYTES = 10 * 1024 * 1024
|
||
_MAX_GATEWAY_MESSAGE_BYTES = 1024 * 1024
|
||
|
||
|
||
def _read_local_media_limited(file_path: str) -> bytes:
|
||
if os.path.getsize(file_path) > _MAX_MEDIA_BYTES:
|
||
raise ValueError('DingTalk media exceeds the size limit')
|
||
with open(file_path, 'rb') as file:
|
||
body = file.read(_MAX_MEDIA_BYTES + 1)
|
||
if len(body) > _MAX_MEDIA_BYTES:
|
||
raise ValueError('DingTalk media exceeds the size limit')
|
||
return body
|
||
|
||
|
||
async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
|
||
content_length = response.headers.get('Content-Length')
|
||
if content_length is not None:
|
||
try:
|
||
if int(content_length) > _MAX_MEDIA_BYTES:
|
||
raise ValueError('DingTalk media exceeds the size limit')
|
||
except (TypeError, ValueError) as exc:
|
||
if 'exceeds' in str(exc):
|
||
raise
|
||
body = bytearray()
|
||
async for chunk in response.aiter_bytes():
|
||
body.extend(chunk)
|
||
if len(body) > _MAX_MEDIA_BYTES:
|
||
raise ValueError('DingTalk media exceeds the size limit')
|
||
return bytes(body)
|
||
|
||
|
||
def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
|
||
"""DingTalk cardParamMap only accepts string values.
|
||
|
||
Keep callers free to pass structured values for template variables such
|
||
as button groups or select options, then encode them once at the API
|
||
boundary.
|
||
"""
|
||
if not card_param_map:
|
||
return {}
|
||
result = {}
|
||
for key, value in card_param_map.items():
|
||
if value is None:
|
||
result[key] = ''
|
||
elif isinstance(value, str):
|
||
result[key] = value
|
||
else:
|
||
result[key] = json.dumps(value, ensure_ascii=False)
|
||
return result
|
||
|
||
|
||
class DingTalkClient:
|
||
_MAX_INBOUND_TASKS = 100
|
||
|
||
def __init__(
|
||
self,
|
||
client_id: str,
|
||
client_secret: str,
|
||
robot_name: str,
|
||
robot_code: str,
|
||
markdown_card: bool,
|
||
logger: None,
|
||
card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None,
|
||
):
|
||
"""初始化 WebSocket 连接并自动启动"""
|
||
self.credential = dingtalk_stream.Credential(client_id, client_secret)
|
||
self.client = dingtalk_stream.DingTalkStreamClient(self.credential)
|
||
self.key = client_id
|
||
self.secret = client_secret
|
||
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
|
||
self.EchoTextHandler = EchoTextHandler(self)
|
||
self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)
|
||
# STREAM-mode card action button click handler. Forwards parsed payload
|
||
# to the adapter so it can resume paused Dify workflows.
|
||
self.card_action_callback = card_action_callback
|
||
self.card_action_handler = DingTalkCardActionHandler(self.client, self._on_card_action)
|
||
self.client.register_callback_handler(
|
||
dingtalk_stream.handlers.CallbackHandler.TOPIC_CARD_CALLBACK,
|
||
self.card_action_handler,
|
||
)
|
||
self._message_handlers = {
|
||
'example': [],
|
||
}
|
||
self.access_token = ''
|
||
self.robot_name = robot_name
|
||
self.robot_code = robot_code
|
||
self.access_token_expiry_time = ''
|
||
self.markdown_card = markdown_card
|
||
self.logger = logger
|
||
# Legacy access_token used by the OLD oapi.dingtalk.com endpoints
|
||
# (e.g. /media/upload, which is the only documented way to get an
|
||
# `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0
|
||
# token doesn't work there — different auth domain.
|
||
self.legacy_access_token = ''
|
||
self.legacy_access_token_expiry_time: typing.Optional[float] = None
|
||
self._stopped = False # Flag to control the event loop
|
||
self._inbound_tasks: set[asyncio.Task] = set()
|
||
self._http_client: httpx.AsyncClient | None = None
|
||
|
||
@asynccontextmanager
|
||
async def _http_client_context(self):
|
||
"""Reuse one connection pool while preserving existing call structure."""
|
||
|
||
if self._http_client is None or self._http_client.is_closed:
|
||
self._http_client = httpx.AsyncClient(event_hooks=httpclient.httpx_response_limit_hooks())
|
||
yield self._http_client
|
||
|
||
def _start_inbound_task(self, coro: typing.Coroutine) -> bool:
|
||
"""Start one bounded inbound callback task."""
|
||
|
||
for task in tuple(self._inbound_tasks):
|
||
if task.done():
|
||
self._inbound_tasks.discard(task)
|
||
if len(self._inbound_tasks) >= self._MAX_INBOUND_TASKS:
|
||
coro.close()
|
||
return False
|
||
|
||
task = asyncio.create_task(coro)
|
||
self._inbound_tasks.add(task)
|
||
|
||
def done(done_task: asyncio.Task) -> None:
|
||
self._inbound_tasks.discard(done_task)
|
||
if not done_task.cancelled():
|
||
done_task.exception()
|
||
|
||
task.add_done_callback(done)
|
||
return True
|
||
|
||
async def _on_card_action(self, payload: dict) -> None:
|
||
"""Dispatch a parsed card-action payload to the adapter callback."""
|
||
if self.card_action_callback is None:
|
||
return
|
||
try:
|
||
await self.card_action_callback(payload)
|
||
except Exception:
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk card action callback error: {traceback.format_exc()}')
|
||
|
||
async def get_access_token(self):
|
||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||
headers = {'Content-Type': 'application/json'}
|
||
data = {'appKey': self.key, 'appSecret': self.secret}
|
||
async with self._http_client_context() as client:
|
||
try:
|
||
response = await client.post(url, json=data, headers=headers)
|
||
if response.status_code == 200:
|
||
response_data = await httpclient.parse_json_response(response)
|
||
self.access_token = response_data.get('accessToken')
|
||
expires_in = int(response_data.get('expireIn', 7200))
|
||
self.access_token_expiry_time = time.time() + expires_in - 60
|
||
except Exception:
|
||
await self.logger.error('failed to get access token in dingtalk')
|
||
|
||
async def is_token_expired(self):
|
||
"""检查token是否过期"""
|
||
if self.access_token_expiry_time is None:
|
||
return True
|
||
return time.time() > self.access_token_expiry_time
|
||
|
||
async def check_access_token(self):
|
||
if not self.access_token or await self.is_token_expired():
|
||
return False
|
||
return bool(self.access_token and self.access_token.strip())
|
||
|
||
async def download_image(self, download_code: str):
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
|
||
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
|
||
headers = {'x-acs-dingtalk-access-token': self.access_token}
|
||
async with self._http_client_context() as client:
|
||
response = await client.post(url, headers=headers, json=params)
|
||
if response.status_code == 200:
|
||
result = await httpclient.parse_json_response(response)
|
||
download_url = result.get('downloadUrl')
|
||
else:
|
||
error_payload = await httpclient.parse_json_response(response)
|
||
await self.logger.error(f'failed to get download url: {error_payload}')
|
||
|
||
if download_url:
|
||
return await self.download_url_to_base64(download_url)
|
||
|
||
async def download_url_to_base64(self, download_url):
|
||
async with self._http_client_context() as client:
|
||
async with client.stream('GET', download_url) as response:
|
||
if response.status_code == 200:
|
||
file_bytes = await _read_httpx_media_limited(response)
|
||
mime_type = response.headers.get('Content-Type', 'application/octet-stream')
|
||
base64_str = (await asyncio.to_thread(base64.b64encode, file_bytes)).decode('utf-8')
|
||
return f'data:{mime_type};base64,{base64_str}'
|
||
error_body = await _read_httpx_media_limited(response)
|
||
await self.logger.error(f'failed to get files: {error_body[:300]!r}')
|
||
|
||
async def get_audio_url(self, download_code: str):
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
|
||
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
|
||
headers = {'x-acs-dingtalk-access-token': self.access_token}
|
||
async with self._http_client_context() as client:
|
||
response = await client.post(url, headers=headers, json=params)
|
||
if response.status_code == 200:
|
||
result = await httpclient.parse_json_response(response)
|
||
download_url = result.get('downloadUrl')
|
||
if download_url:
|
||
return await self.download_url_to_base64(download_url)
|
||
else:
|
||
error_payload = await httpclient.parse_json_response(response)
|
||
await self.logger.error(f'failed to get audio: {error_payload}')
|
||
else:
|
||
body = await httpclient.response_text(response)
|
||
raise Exception(f'Error: {response.status_code}, {body}')
|
||
|
||
async def get_file_url(self, download_code: str):
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
|
||
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
|
||
headers = {'x-acs-dingtalk-access-token': self.access_token}
|
||
async with self._http_client_context() as client:
|
||
response = await client.post(url, headers=headers, json=params)
|
||
if response.status_code == 200:
|
||
result = await httpclient.parse_json_response(response)
|
||
download_url = result.get('downloadUrl')
|
||
if download_url:
|
||
return download_url
|
||
else:
|
||
error_payload = await httpclient.parse_json_response(response)
|
||
await self.logger.error(f'failed to get file: {error_payload}')
|
||
else:
|
||
body = await httpclient.response_text(response)
|
||
raise Exception(f'Error: {response.status_code}, {body}')
|
||
|
||
async def update_incoming_message(self, message):
|
||
"""异步更新 DingTalkClient 中的 incoming_message"""
|
||
message_data = await self.get_message(message)
|
||
if message_data:
|
||
event = DingTalkEvent.from_payload(message_data)
|
||
if event:
|
||
await self._handle_message(event)
|
||
|
||
async def send_message(self, content: str, incoming_message, at: bool):
|
||
if self.markdown_card:
|
||
if at:
|
||
self.EchoTextHandler.reply_markdown(
|
||
title='@' + incoming_message.sender_nick + ' ' + content,
|
||
text='@' + incoming_message.sender_nick + ' ' + content,
|
||
incoming_message=incoming_message,
|
||
)
|
||
else:
|
||
self.EchoTextHandler.reply_markdown(
|
||
title=content,
|
||
text=content,
|
||
incoming_message=incoming_message,
|
||
)
|
||
else:
|
||
self.EchoTextHandler.reply_text(content, incoming_message)
|
||
|
||
async def get_incoming_message(self):
|
||
"""获取收到的消息"""
|
||
return await self.EchoTextHandler.get_incoming_message()
|
||
|
||
def on_message(self, msg_type: str):
|
||
def decorator(func: Callable[[DingTalkEvent], None]):
|
||
if msg_type not in self._message_handlers:
|
||
self._message_handlers[msg_type] = []
|
||
self._message_handlers[msg_type].append(func)
|
||
return func
|
||
|
||
return decorator
|
||
|
||
async def _handle_message(self, event: DingTalkEvent):
|
||
"""
|
||
处理消息事件。
|
||
"""
|
||
# Skip message handling if stopped
|
||
if self._stopped:
|
||
return
|
||
msg_type = event.conversation
|
||
if msg_type in self._message_handlers:
|
||
for handler in self._message_handlers[msg_type]:
|
||
await handler(event)
|
||
|
||
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
|
||
"""Parse the quoted/replied message and extract its content.
|
||
|
||
Args:
|
||
replied_msg: The repliedMsg object from DingTalk message
|
||
|
||
Returns:
|
||
A dict containing the quoted message info with keys:
|
||
- message_id: The original message ID
|
||
- msg_type: The message type (text, file, picture, audio, etc.)
|
||
- content: The text content (if any)
|
||
- file_url: The file download URL (if file type)
|
||
- file_name: The file name (if file type)
|
||
- picture: The picture base64 (if picture type)
|
||
- audio: The audio base64 (if audio type)
|
||
"""
|
||
quote_info = {
|
||
'message_id': replied_msg.get('msgId', ''),
|
||
'msg_type': replied_msg.get('msgType', ''),
|
||
'sender_id': replied_msg.get('senderId', ''),
|
||
}
|
||
|
||
msg_type = replied_msg.get('msgType', '')
|
||
content = replied_msg.get('content', {})
|
||
|
||
# Handle content as string (JSON) or dict
|
||
if isinstance(content, str):
|
||
try:
|
||
content = json.loads(content)
|
||
except (json.JSONDecodeError, TypeError):
|
||
content = {}
|
||
|
||
if msg_type == 'text':
|
||
# Text message
|
||
if isinstance(content, dict):
|
||
quote_info['content'] = content.get('content', '')
|
||
else:
|
||
quote_info['content'] = str(content)
|
||
|
||
elif msg_type == 'file':
|
||
# File message
|
||
download_code = content.get('downloadCode')
|
||
file_name = content.get('fileName')
|
||
if download_code and file_name:
|
||
try:
|
||
quote_info['file_url'] = await self.get_file_url(download_code)
|
||
quote_info['file_name'] = file_name
|
||
except Exception as e:
|
||
if self.logger:
|
||
await self.logger.error(f'Failed to get quoted file URL: {e}')
|
||
|
||
elif msg_type == 'picture':
|
||
# Picture message
|
||
download_code = content.get('downloadCode')
|
||
if download_code:
|
||
try:
|
||
quote_info['picture'] = await self.download_image(download_code)
|
||
except Exception as e:
|
||
if self.logger:
|
||
await self.logger.error(f'Failed to download quoted image: {e}')
|
||
|
||
elif msg_type == 'audio':
|
||
# Audio message
|
||
download_code = content.get('downloadCode')
|
||
if download_code:
|
||
try:
|
||
quote_info['audio'] = await self.get_audio_url(download_code)
|
||
except Exception as e:
|
||
if self.logger:
|
||
await self.logger.error(f'Failed to get quoted audio: {e}')
|
||
|
||
elif msg_type == 'richText':
|
||
# Rich text message - extract text content
|
||
rich_text = content.get('richText', [])
|
||
texts = []
|
||
for item in rich_text:
|
||
if 'text' in item and item['text'] != '\n':
|
||
texts.append(item['text'])
|
||
quote_info['content'] = '\n'.join(texts)
|
||
|
||
return quote_info
|
||
|
||
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||
try:
|
||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||
message_data = {
|
||
'IncomingMessage': incoming_message,
|
||
}
|
||
if str(incoming_message.conversation_type) == '1':
|
||
message_data['conversation_type'] = 'FriendMessage'
|
||
elif str(incoming_message.conversation_type) == '2':
|
||
message_data['conversation_type'] = 'GroupMessage'
|
||
|
||
# Check for quoted/replied message
|
||
raw_data = incoming_message.to_dict()
|
||
text_data = raw_data.get('text', {})
|
||
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
|
||
replied_msg = text_data.get('repliedMsg', {})
|
||
if replied_msg:
|
||
quote_info = await self._parse_quoted_message(replied_msg)
|
||
message_data['QuotedMessage'] = quote_info
|
||
|
||
if incoming_message.message_type == 'richText':
|
||
data = incoming_message.rich_text_content.to_dict()
|
||
|
||
# 使用统一的结构化数据格式,保持顺序
|
||
rich_content = {
|
||
'Type': 'richText',
|
||
'Elements': [], # 按顺序存储所有元素
|
||
'SimpleContent': '', # 兼容字段:纯文本内容
|
||
'SimplePicture': '', # 兼容字段:第一张图片
|
||
}
|
||
|
||
# 先收集所有文本和图片占位符
|
||
text_elements = []
|
||
|
||
# 解析富文本内容,保持原始顺序
|
||
for item in data['richText']:
|
||
# 处理文本内容
|
||
if 'text' in item and item['text'] != '\n':
|
||
element = {'Type': 'text', 'Content': item['text']}
|
||
rich_content['Elements'].append(element)
|
||
text_elements.append(item['text'])
|
||
|
||
# 检查是否是图片元素 - 根据钉钉API的实际结构调整
|
||
# 钉钉富文本中的图片通常有特定标识,可能需要根据实际返回调整
|
||
elif item.get('type') == 'picture':
|
||
# 创建图片占位符
|
||
element = {
|
||
'Type': 'image_placeholder',
|
||
}
|
||
rich_content['Elements'].append(element)
|
||
|
||
# 获取并下载所有图片
|
||
image_list = incoming_message.get_image_list()
|
||
if image_list:
|
||
new_elements = []
|
||
image_index = 0
|
||
|
||
for element in rich_content['Elements']:
|
||
if element['Type'] == 'image_placeholder':
|
||
if image_index < len(image_list) and image_list[image_index]:
|
||
image_url = await self.download_image(image_list[image_index])
|
||
new_elements.append({'Type': 'image', 'Picture': image_url})
|
||
image_index += 1
|
||
else:
|
||
# 如果没有对应的图片,保留占位符或跳过
|
||
continue
|
||
else:
|
||
new_elements.append(element)
|
||
|
||
rich_content['Elements'] = new_elements
|
||
|
||
# 设置兼容字段
|
||
all_texts = [elem['Content'] for elem in rich_content['Elements'] if elem.get('Type') == 'text']
|
||
rich_content['SimpleContent'] = '\n'.join(all_texts) if all_texts else ''
|
||
|
||
all_images = [elem['Picture'] for elem in rich_content['Elements'] if elem.get('Type') == 'image']
|
||
if all_images:
|
||
rich_content['SimplePicture'] = all_images[0]
|
||
rich_content['AllImages'] = all_images # 所有图片的列表
|
||
|
||
# 设置原始的 content 和 picture 字段以保持兼容
|
||
message_data['Content'] = rich_content['SimpleContent']
|
||
message_data['Rich_Content'] = rich_content
|
||
if all_images:
|
||
message_data['Picture'] = all_images[0]
|
||
|
||
elif incoming_message.message_type == 'text':
|
||
message_data['Content'] = incoming_message.get_text_list()[0]
|
||
|
||
message_data['Type'] = 'text'
|
||
elif incoming_message.message_type == 'picture':
|
||
message_data['Picture'] = await self.download_image(incoming_message.get_image_list()[0])
|
||
|
||
message_data['Type'] = 'image'
|
||
elif incoming_message.message_type == 'audio':
|
||
raw_content = incoming_message.to_dict().get('content', {})
|
||
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||
if isinstance(raw_content, str):
|
||
try:
|
||
raw_content = json.loads(raw_content)
|
||
except (json.JSONDecodeError, TypeError):
|
||
raw_content = {}
|
||
|
||
if self.logger:
|
||
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
|
||
|
||
# 提取钉钉自带的语音转写文字(Powered by Qwen)
|
||
recognition = raw_content.get('recognition', '')
|
||
if recognition:
|
||
message_data['Content'] = recognition
|
||
|
||
download_code = raw_content.get('downloadCode')
|
||
if download_code:
|
||
message_data['Audio'] = await self.get_audio_url(download_code)
|
||
|
||
message_data['Type'] = 'audio'
|
||
elif incoming_message.message_type == 'file':
|
||
# 获取原始数据字典并提取嵌套的文件信息
|
||
raw_data = incoming_message.to_dict()
|
||
file_info = raw_data.get('content', {})
|
||
|
||
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||
if isinstance(file_info, str):
|
||
try:
|
||
file_info = json.loads(file_info)
|
||
except (json.JSONDecodeError, TypeError):
|
||
file_info = {}
|
||
|
||
download_code = file_info.get('downloadCode')
|
||
file_name = file_info.get('fileName')
|
||
|
||
if download_code and file_name:
|
||
# 转换 downloadCode 为可下载的真实 URL
|
||
message_data['File'] = await self.get_file_url(download_code)
|
||
message_data['Name'] = file_name
|
||
else:
|
||
if self.logger:
|
||
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
|
||
message_data['File'] = None
|
||
message_data['Name'] = None
|
||
|
||
message_data['Type'] = 'file'
|
||
|
||
copy_message_data = message_data.copy()
|
||
del copy_message_data['IncomingMessage']
|
||
# print("message_data:", json.dumps(copy_message_data, indent=4, ensure_ascii=False))
|
||
except Exception:
|
||
if self.logger:
|
||
await self.logger.error(f'Error in get_message: {traceback.format_exc()}')
|
||
else:
|
||
traceback.print_exc()
|
||
|
||
return message_data
|
||
|
||
async def send_proactive_message_to_one(self, target_id: str, content: str):
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
|
||
url = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend'
|
||
|
||
headers = {
|
||
'x-acs-dingtalk-access-token': self.access_token,
|
||
'Content-Type': 'application/json',
|
||
}
|
||
|
||
# For enterprise-internal robots, robotCode == AppKey (client_id).
|
||
# The dedicated robot_code field is only required for scenario-group
|
||
# robots or third-party robots; fall back to client_id when empty so
|
||
# the common single-bot setup keeps working without manual config.
|
||
robot_code = self.robot_code or self.key
|
||
data = {
|
||
'robotCode': robot_code,
|
||
'userIds': [target_id],
|
||
'msgKey': 'sampleText',
|
||
'msgParam': json.dumps({'content': content}),
|
||
}
|
||
_stdout_logger.info(
|
||
'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d',
|
||
robot_code,
|
||
target_id,
|
||
len(content),
|
||
)
|
||
try:
|
||
async with self._http_client_context() as client:
|
||
response = await client.post(url, headers=headers, json=data)
|
||
response_body = await httpclient.response_text(response, max_chars=500)
|
||
_stdout_logger.info(
|
||
'DingTalk send_proactive_message_to_one response: status=%d body=%s',
|
||
response.status_code,
|
||
response_body,
|
||
)
|
||
if response.status_code == 200:
|
||
return
|
||
except Exception:
|
||
_stdout_logger.exception('DingTalk send_proactive_message_to_one error')
|
||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||
|
||
async def send_proactive_message_to_group(self, target_id: str, content: str):
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
|
||
url = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'
|
||
|
||
headers = {
|
||
'x-acs-dingtalk-access-token': self.access_token,
|
||
'Content-Type': 'application/json',
|
||
}
|
||
|
||
data = {
|
||
'robotCode': self.robot_code or self.key,
|
||
'openConversationId': target_id,
|
||
'msgKey': 'sampleText',
|
||
'msgParam': json.dumps({'content': content}),
|
||
}
|
||
try:
|
||
async with self._http_client_context() as client:
|
||
response = await client.post(url, headers=headers, json=data)
|
||
if response.status_code == 200:
|
||
return
|
||
except Exception:
|
||
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||
|
||
async def create_and_card(
|
||
self,
|
||
temp_card_id: str,
|
||
incoming_message: dingtalk_stream.ChatbotMessage,
|
||
quote_origin: bool = False,
|
||
card_auto_layout: bool = False,
|
||
):
|
||
"""Create + deliver the streaming chat card for a chatbot reply.
|
||
|
||
Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
|
||
`(None, out_track_id)` to keep call sites compatible with the
|
||
previous `(card_instance, card_instance_id)` shape — the first slot
|
||
is unused now that everything is driven by out_track_id.
|
||
"""
|
||
out_track_id = uuid.uuid4().hex
|
||
is_group = str(incoming_message.conversation_type) == '2'
|
||
if is_group:
|
||
open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}'
|
||
else:
|
||
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
|
||
|
||
card_param_map = {'content': ''}
|
||
if incoming_message.message_type == 'text':
|
||
card_param_map['query'] = incoming_message.get_text_list()[0]
|
||
else:
|
||
card_param_map['query'] = '...'
|
||
|
||
await self.create_and_deliver_card(
|
||
card_template_id=temp_card_id,
|
||
out_track_id=out_track_id,
|
||
open_space_id=open_space_id,
|
||
is_group=is_group,
|
||
card_param_map=card_param_map,
|
||
card_data_config={'autoLayout': card_auto_layout},
|
||
)
|
||
return None, out_track_id
|
||
|
||
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
|
||
"""Stream a single chunk into an existing card's `content` field."""
|
||
try:
|
||
await self.streaming_update_card(
|
||
out_track_id=card_instance_id,
|
||
content_key='content',
|
||
content_value=content,
|
||
append=False,
|
||
finished=is_final,
|
||
failed=False,
|
||
)
|
||
except Exception as e:
|
||
if self.logger:
|
||
self.logger.exception(e)
|
||
await self.streaming_update_card(
|
||
out_track_id=card_instance_id,
|
||
content_key='content',
|
||
content_value='',
|
||
append=False,
|
||
finished=is_final,
|
||
failed=True,
|
||
)
|
||
|
||
async def create_and_deliver_card(
|
||
self,
|
||
*,
|
||
card_template_id: str,
|
||
out_track_id: str,
|
||
open_space_id: str,
|
||
is_group: bool,
|
||
card_param_map: Optional[dict] = None,
|
||
callback_type: str = 'STREAM',
|
||
callback_route_key: Optional[str] = None,
|
||
support_forward: bool = True,
|
||
dynamic_data_source_configs: Optional[list] = None,
|
||
card_data_config: Optional[dict] = None,
|
||
at_user_ids: Optional[dict] = None,
|
||
recipients: Optional[list] = None,
|
||
) -> bool:
|
||
"""POST /v1.0/card/instances/createAndDeliver.
|
||
|
||
Mirrors the SDK's `async_create_and_deliver_card` shape but exposes
|
||
the dynamic-data-source config slot so we can register a pull URL
|
||
for variable-length button lists.
|
||
"""
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
|
||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
||
if card_data_config is not None:
|
||
cardData['config'] = json.dumps(card_data_config)
|
||
|
||
body: dict = {
|
||
'cardTemplateId': card_template_id,
|
||
'outTrackId': out_track_id,
|
||
'cardData': cardData,
|
||
'callbackType': callback_type,
|
||
'openSpaceId': open_space_id,
|
||
'imGroupOpenSpaceModel': {'supportForward': support_forward},
|
||
'imRobotOpenSpaceModel': {'supportForward': support_forward},
|
||
}
|
||
if callback_type == 'HTTP' and callback_route_key:
|
||
body['callbackRouteKey'] = callback_route_key
|
||
|
||
if is_group:
|
||
deliver: dict = {'robotCode': self.robot_code or self.key}
|
||
if at_user_ids:
|
||
deliver['atUserIds'] = at_user_ids
|
||
if recipients is not None:
|
||
deliver['recipients'] = recipients
|
||
body['imGroupOpenDeliverModel'] = deliver
|
||
else:
|
||
body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'}
|
||
|
||
if dynamic_data_source_configs:
|
||
body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs}
|
||
|
||
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver'
|
||
headers = {
|
||
'x-acs-dingtalk-access-token': self.access_token,
|
||
'Content-Type': 'application/json',
|
||
}
|
||
try:
|
||
_stdout_logger.info(
|
||
'DingTalk createAndDeliver request body: %s',
|
||
json.dumps(body, ensure_ascii=False)[:1500],
|
||
)
|
||
async with self._http_client_context() as client:
|
||
response = await client.post(url, headers=headers, json=body, timeout=30.0)
|
||
response_body = await httpclient.response_text(response, max_chars=500)
|
||
if response.status_code == 200:
|
||
_stdout_logger.info(
|
||
'DingTalk createAndDeliver response: %s',
|
||
response_body,
|
||
)
|
||
return True
|
||
_stdout_logger.error(
|
||
'DingTalk createAndDeliver failed: status=%s body=%s',
|
||
response.status_code,
|
||
response_body,
|
||
)
|
||
if self.logger:
|
||
await self.logger.error(
|
||
f'DingTalk createAndDeliver failed: status={response.status_code} body={response_body}'
|
||
)
|
||
return False
|
||
except Exception:
|
||
_stdout_logger.exception('DingTalk createAndDeliver error')
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}')
|
||
return False
|
||
|
||
async def streaming_update_card(
|
||
self,
|
||
*,
|
||
out_track_id: str,
|
||
content_key: str,
|
||
content_value: str,
|
||
append: bool,
|
||
finished: bool,
|
||
failed: bool = False,
|
||
) -> bool:
|
||
"""PUT /v1.0/card/streaming.
|
||
|
||
Replaces `dingtalk_stream.AICardReplier.async_streaming` — same body
|
||
shape (outTrackId / guid / key / content / isFull / isFinalize /
|
||
isError) per the SDK source.
|
||
"""
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
|
||
body = {
|
||
'outTrackId': out_track_id,
|
||
'guid': uuid.uuid4().hex,
|
||
'key': content_key,
|
||
'content': content_value,
|
||
'isFull': not append,
|
||
'isFinalize': finished,
|
||
'isError': failed,
|
||
}
|
||
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming'
|
||
headers = {
|
||
'x-acs-dingtalk-access-token': self.access_token,
|
||
'Content-Type': 'application/json',
|
||
}
|
||
try:
|
||
async with self._http_client_context() as client:
|
||
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
||
if response.status_code == 200:
|
||
return True
|
||
if self.logger:
|
||
response_body = await httpclient.response_text(response)
|
||
await self.logger.error(
|
||
f'DingTalk card streaming failed: status={response.status_code} body={response_body}'
|
||
)
|
||
return False
|
||
except Exception:
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk card streaming error: {traceback.format_exc()}')
|
||
return False
|
||
|
||
async def update_card_data(
|
||
self,
|
||
*,
|
||
out_track_id: str,
|
||
card_param_map: Optional[dict] = None,
|
||
private_data: Optional[dict] = None,
|
||
) -> bool:
|
||
"""PUT /v1.0/card/instances — non-streaming card content update."""
|
||
if not await self.check_access_token():
|
||
await self.get_access_token()
|
||
|
||
body: dict = {
|
||
'outTrackId': out_track_id,
|
||
'cardData': {'cardParamMap': _stringify_card_param_map(card_param_map)},
|
||
}
|
||
if private_data:
|
||
body['privateData'] = private_data
|
||
|
||
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances'
|
||
headers = {
|
||
'x-acs-dingtalk-access-token': self.access_token,
|
||
'Content-Type': 'application/json',
|
||
}
|
||
try:
|
||
_stdout_logger.info(
|
||
'DingTalk update_card_data request: out_track_id=%s body=%s',
|
||
out_track_id,
|
||
json.dumps(body, ensure_ascii=False)[:1500],
|
||
)
|
||
async with self._http_client_context() as client:
|
||
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
||
response_body = await httpclient.response_text(response, max_chars=300)
|
||
_stdout_logger.info(
|
||
'DingTalk update_card_data response: status=%d body=%s',
|
||
response.status_code,
|
||
response_body,
|
||
)
|
||
if response.status_code == 200:
|
||
return True
|
||
if self.logger:
|
||
await self.logger.error(
|
||
f'DingTalk update card failed: status={response.status_code} body={response_body}'
|
||
)
|
||
return False
|
||
except Exception:
|
||
_stdout_logger.exception('DingTalk update_card_data error')
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}')
|
||
return False
|
||
|
||
async def get_legacy_access_token(self) -> Optional[str]:
|
||
"""Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a
|
||
different auth domain from the v1.0 token cached in
|
||
``self.access_token`` — only the legacy token authorises the
|
||
``/media/upload`` endpoint that returns an ``@xxx`` media_id
|
||
consumable by card components like Avatar.imageUrl.
|
||
|
||
Returns the token string on success, None on failure. Caches
|
||
with a 60s safety margin before the documented 7200s expiry.
|
||
"""
|
||
now = time.time()
|
||
if (
|
||
self.legacy_access_token
|
||
and self.legacy_access_token_expiry_time
|
||
and now < self.legacy_access_token_expiry_time
|
||
):
|
||
return self.legacy_access_token
|
||
|
||
url = 'https://oapi.dingtalk.com/gettoken'
|
||
try:
|
||
async with self._http_client_context() as client:
|
||
response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0)
|
||
data = await httpclient.parse_json_response(response) if response.status_code == 200 else {}
|
||
if data.get('errcode') == 0 and data.get('access_token'):
|
||
self.legacy_access_token = data['access_token']
|
||
expires_in = int(data.get('expires_in', 7200))
|
||
self.legacy_access_token_expiry_time = now + expires_in - 60
|
||
return self.legacy_access_token
|
||
if self.logger:
|
||
response_body = await httpclient.response_text(response, max_chars=200)
|
||
await self.logger.error(
|
||
f'DingTalk legacy gettoken failed: status={response.status_code} body={response_body}'
|
||
)
|
||
except Exception:
|
||
_stdout_logger.exception('DingTalk legacy gettoken error')
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}')
|
||
return None
|
||
|
||
async def upload_image_media(self, file_path: str) -> Optional[str]:
|
||
"""Upload an image file to DingTalk media storage and return the
|
||
``@xxx`` media_id, which can be passed straight into card variables
|
||
like Avatar.imageUrl. Endpoint:
|
||
|
||
POST https://oapi.dingtalk.com/media/upload?access_token=…&type=image
|
||
|
||
Returns the media_id on success, None on any failure (caller
|
||
should handle a None gracefully — DingTalk falls back to a
|
||
default avatar when imageUrl is empty/unknown).
|
||
"""
|
||
if not os.path.exists(file_path):
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}')
|
||
return None
|
||
|
||
token = await self.get_legacy_access_token()
|
||
if not token:
|
||
return None
|
||
|
||
url = 'https://oapi.dingtalk.com/media/upload'
|
||
try:
|
||
file_bytes = await asyncio.to_thread(_read_local_media_limited, file_path)
|
||
file_name = os.path.basename(file_path)
|
||
# Best-effort content-type guess; DingTalk accepts the major image
|
||
# mime types and otherwise infers from the bytes.
|
||
ext = os.path.splitext(file_name)[1].lower().lstrip('.')
|
||
mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
|
||
ext, 'application/octet-stream'
|
||
)
|
||
async with self._http_client_context() as client:
|
||
response = await client.post(
|
||
url,
|
||
params={'access_token': token, 'type': 'image'},
|
||
files={'media': (file_name, file_bytes, mime)},
|
||
timeout=30.0,
|
||
)
|
||
data = await httpclient.parse_json_response(response) if response.status_code == 200 else {}
|
||
if data.get('errcode') == 0 and data.get('media_id'):
|
||
_stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
|
||
return data['media_id']
|
||
if self.logger:
|
||
response_body = await httpclient.response_text(response, max_chars=300)
|
||
await self.logger.error(
|
||
f'DingTalk upload_image_media failed: status={response.status_code} body={response_body}'
|
||
)
|
||
except Exception:
|
||
_stdout_logger.exception('DingTalk upload_image_media error')
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}')
|
||
return None
|
||
|
||
async def start(self):
|
||
"""启动 WebSocket 连接,监听消息"""
|
||
self._stopped = False
|
||
self.client.pre_start()
|
||
|
||
while not self._stopped:
|
||
try:
|
||
# open_connection performs blocking network I/O in the DingTalk SDK.
|
||
# Run it off the event loop so connection stalls do not block the
|
||
# LangBot HTTP server and other async tasks.
|
||
connection = await asyncio.to_thread(self.client.open_connection)
|
||
|
||
if not connection:
|
||
if self.logger:
|
||
await self.logger.error('DingTalk: open connection failed')
|
||
await asyncio.sleep(10)
|
||
continue
|
||
|
||
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
|
||
async with websockets.connect(uri, max_size=_MAX_GATEWAY_MESSAGE_BYTES) as websocket:
|
||
self.client.websocket = websocket
|
||
keepalive_task = asyncio.create_task(self._keepalive(websocket))
|
||
try:
|
||
async for raw_message in websocket:
|
||
if self._stopped:
|
||
break
|
||
json_message = await asyncio.to_thread(json.loads, raw_message)
|
||
if not self._start_inbound_task(self.client.background_task(json_message)):
|
||
if self.logger:
|
||
await self.logger.warning(
|
||
'DingTalk inbound task capacity reached; dropping message'
|
||
)
|
||
finally:
|
||
keepalive_task.cancel()
|
||
try:
|
||
await keepalive_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except asyncio.CancelledError:
|
||
# Properly exit when task is cancelled
|
||
break
|
||
except websockets.exceptions.ConnectionClosedError as e:
|
||
if self._stopped:
|
||
break
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}')
|
||
await asyncio.sleep(5)
|
||
continue
|
||
except Exception as e:
|
||
if self._stopped:
|
||
break
|
||
if self.logger:
|
||
await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}')
|
||
await asyncio.sleep(3)
|
||
continue
|
||
|
||
async def _keepalive(self, ws, ping_interval=60):
|
||
"""Keep WebSocket connection alive"""
|
||
while not self._stopped:
|
||
await asyncio.sleep(ping_interval)
|
||
try:
|
||
await ws.ping()
|
||
except websockets.exceptions.ConnectionClosed:
|
||
break
|
||
|
||
async def stop(self):
|
||
"""停止 WebSocket 连接"""
|
||
self._stopped = True
|
||
# Close WebSocket connection if exists
|
||
if self.client.websocket:
|
||
try:
|
||
await self.client.websocket.close()
|
||
except Exception:
|
||
pass
|
||
inbound_tasks = list(self._inbound_tasks)
|
||
for task in inbound_tasks:
|
||
if not task.done():
|
||
task.cancel()
|
||
if inbound_tasks:
|
||
await asyncio.gather(*inbound_tasks, return_exceptions=True)
|
||
self._inbound_tasks.clear()
|
||
# Clear message handlers to prevent stale callbacks
|
||
self._message_handlers = {'example': []}
|
||
if self._http_client is not None:
|
||
await self._http_client.aclose()
|
||
self._http_client = None
|