fix(dingtalk): improve card replies and attachment delivery

This commit is contained in:
Hyu
2026-10-10 20:31:56 +08:00
parent 339dd56722
commit 0a18440faf
12 changed files with 419 additions and 247 deletions
+123 -69
View File
@@ -14,6 +14,7 @@ import websockets
from .EchoHandler import EchoTextHandler
from .card_callback import DingTalkCardActionHandler
from .dingtalkevent import DingTalkEvent
from .thinking import card_text_fields, visible_think_markers
import httpx
import traceback
from langbot.pkg.utils import httpclient
@@ -74,6 +75,12 @@ def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
return result
def is_stream_card_configured(config: dict) -> bool:
template_id = config.get('card_template_id')
# Older manifests stored instructional placeholder text as the default.
return isinstance(template_id, str) and bool(template_id.strip()) and template_id.strip() != '填写你的卡片template_id'
class DingTalkClient:
_MAX_INBOUND_TASKS = 100
@@ -108,7 +115,7 @@ class DingTalkClient:
}
self.access_token = ''
self.robot_name = robot_name
self.robot_code = robot_code
self.robot_code = (robot_code or '').strip() or client_id
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
self.logger = logger
@@ -258,28 +265,101 @@ class DingTalkClient:
async def update_incoming_message(self, message):
"""异步更新 DingTalkClient 中的 incoming_message"""
# Resolve identity before downloading any attachments from this event.
robot_code = getattr(message, 'robot_code', None)
if isinstance(robot_code, str) and robot_code.strip():
self.robot_code = robot_code.strip()
message_data = await self.get_message(message)
if message_data:
event = DingTalkEvent.from_payload(message_data)
if event:
await self._handle_message(event)
@staticmethod
def _check_send_result(response):
# HTTP 200 can still contain a rejected DingTalk message.
data = response.json()
if response.status_code != 200 or data.get('errcode', 0) != 0 or data.get('code'):
raise RuntimeError(
f"DingTalk delivery failed (HTTP {response.status_code}, "
f"code={data.get('errcode', data.get('code', 'unknown'))}): "
f"{data.get('errmsg', data.get('message', 'Unknown error'))}"
)
return data
async def send_message(self, content: str, incoming_message, at: bool):
if not content.strip():
return {}
content = visible_think_markers(content, self.markdown_card)
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,
)
text = ('@' + incoming_message.sender_nick + ' ' if at else '') + content
payload = {'msgtype': 'markdown', 'markdown': {'title': text[:80], 'text': text}}
else:
self.EchoTextHandler.reply_text(content, incoming_message)
payload = {'msgtype': 'text', 'text': {'content': content}}
payload['at'] = {'atUserIds': [incoming_message.sender_staff_id]}
async with self._http_client_context() as client:
response = await client.post(incoming_message.session_webhook, json=payload, timeout=30.0)
return self._check_send_result(response)
async def send_attachment(self, component, incoming_message):
from langbot_plugin.api.entities.builtin.platform.message import Image
is_image = isinstance(component, Image)
name = getattr(component, 'name', '') or ('image.png' if is_image else 'file')
name = os.path.basename(name)
encoded = getattr(component, 'base64', None)
if encoded:
encoded = encoded.split(',', 1)[-1]
if len(encoded) > 4 * ((_MAX_MEDIA_BYTES + 2) // 3):
raise ValueError('DingTalk media exceeds the size limit')
body = base64.b64decode(encoded, validate=True)
elif getattr(component, 'path', None):
body = await asyncio.to_thread(_read_local_media_limited, component.path)
elif getattr(component, 'url', None):
async with self._http_client_context() as client:
async with client.stream('GET', component.url, timeout=30.0) as response:
response.raise_for_status()
body = await _read_httpx_media_limited(response)
else:
raise ValueError('DingTalk attachment has no readable content')
if len(body) > _MAX_MEDIA_BYTES:
raise ValueError('DingTalk media exceeds the size limit')
token = await self.get_legacy_access_token()
if not token:
raise RuntimeError('DingTalk media authentication failed')
async with self._http_client_context() as client:
response = await client.post(
'https://oapi.dingtalk.com/media/upload',
params={'access_token': token, 'type': 'image' if is_image else 'file'},
files={'media': (name, body)}, timeout=30.0,
)
uploaded = self._check_send_result(response)
media_id = uploaded.get('media_id')
if not media_id:
raise RuntimeError('DingTalk media upload returned no media ID')
if not await self.check_access_token():
await self.get_access_token()
params = {'photoURL': media_id} if is_image else {
'mediaId': media_id, 'fileName': name, 'fileType': os.path.splitext(name)[1].lstrip('.')
}
payload = {
'robotCode': self.robot_code or self.key,
'msgKey': 'sampleImageMsg' if is_image else 'sampleFile',
'msgParam': json.dumps(params, ensure_ascii=False),
}
if str(incoming_message.conversation_type) == '2':
endpoint = 'groupMessages/send'
payload['openConversationId'] = incoming_message.conversation_id
else:
endpoint = 'oToMessages/batchSend'
payload['userIds'] = [incoming_message.sender_staff_id]
async with self._http_client_context() as client:
response = await client.post(
f'{DINGTALK_OPENAPI_BASE}/v1.0/robot/{endpoint}',
headers={'x-acs-dingtalk-access-token': self.access_token},
json=payload, timeout=30.0,
)
return self._check_send_result(response)
async def get_incoming_message(self):
"""获取收到的消息"""
@@ -562,7 +642,7 @@ class DingTalkClient:
'robotCode': robot_code,
'userIds': [target_id],
'msgKey': 'sampleText',
'msgParam': json.dumps({'content': content}),
'msgParam': json.dumps({'content': visible_think_markers(content, False)}),
}
_stdout_logger.info(
'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d',
@@ -606,7 +686,7 @@ class DingTalkClient:
'robotCode': self.robot_code or self.key,
'openConversationId': target_id,
'msgKey': 'sampleText',
'msgParam': json.dumps({'content': content}),
'msgParam': json.dumps({'content': visible_think_markers(content, False)}),
}
try:
async with self._http_client_context() as client:
@@ -627,14 +707,13 @@ class DingTalkClient:
temp_card_id: str,
incoming_message: dingtalk_stream.ChatbotMessage,
quote_origin: bool = False,
card_auto_layout: bool = False,
card_auto_layout: bool = True,
):
"""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.
`(layout_config, out_track_id)` keeps the adapter's tuple contract
while preserving the layout setting on subsequent data updates.
"""
out_track_id = uuid.uuid4().hex
is_group = str(incoming_message.conversation_type) == '2'
@@ -643,7 +722,7 @@ class DingTalkClient:
else:
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
card_param_map = {'content': ''}
card_param_map = {'content': '', 'answer': '', 'reasoning': '', 'flowStatus': '1', 'brand': '**🤖 LangBot**', 'hasReasoning': ''}
if incoming_message.message_type == 'text':
card_param_map['query'] = incoming_message.get_text_list()[0]
else:
@@ -657,30 +736,21 @@ class DingTalkClient:
card_param_map=card_param_map,
card_data_config={'autoLayout': card_auto_layout},
)
return None, out_track_id
return {'autoLayout': card_auto_layout}, 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,
)
"""Update separate reasoning/answer fields and the legacy content field."""
fields = card_text_fields(content)
# Old templates continue to render content with explicit think markers;
# the supplied split template binds only reasoning and answer.
fields['content'] = visible_think_markers(content)
fields['flowStatus'] = '3' if is_final else '2'
fields['brand'] = '**🤖 LangBot**'
if isinstance(card_instance, dict):
fields['config'] = card_instance
fields['autoLayout'] = str(card_instance.get('autoLayout', True)).lower()
if not await self.update_card_data(out_track_id=card_instance_id, card_param_map=fields):
raise RuntimeError('DingTalk card content update failed; see adapter logs for the API response')
async def create_and_deliver_card(
self,
@@ -710,6 +780,7 @@ class DingTalkClient:
template_params = dict(card_param_map or {})
if card_data_config is not None:
template_params['config'] = card_data_config
template_params['autoLayout'] = str(card_data_config.get('autoLayout', True)).lower()
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
body: dict = {
@@ -743,34 +814,17 @@ class DingTalkClient:
'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
# Propagate business failures as well as transport failures. Callers
# must not register or update a card that was never delivered.
self._check_send_result(response)
except Exception as exc:
error = RuntimeError(f'DingTalk card creation failed: {exc}')
_stdout_logger.error('%s', error)
raise error from exc
_stdout_logger.info('DingTalk card created: outTrackId=%s', out_track_id)
return True
async def streaming_update_card(
self,
+37
View File
@@ -0,0 +1,37 @@
"""Render reasoning markers without changing the pipeline output policy."""
import re
_MARKER = re.compile(r"</?think>", re.IGNORECASE)
def visible_think_markers(content: str, markdown: bool = True) -> str:
def replace(match):
tag = match.group(0).lower()
return "\n\n" + (f"`{tag}`" if markdown else tag) + "\n\n"
return _MARKER.sub(replace, content).strip()
def card_text_fields(content: str) -> dict[str, str]:
thinking, answer = [], []
inside = False
offset = 0
for match in _MARKER.finditer(content):
(thinking if inside else answer).append(content[offset:match.start()])
inside = match.group(0).lower() == "<think>"
offset = match.end()
tail = content[offset:]
# A stream can stop in the middle of an opening or closing marker.
for size in range(min(len(tail), 7), 0, -1):
if any(marker.startswith(tail[-size:].lower()) for marker in ("<think>", "</think>")):
tail = tail[:-size]
break
(thinking if inside else answer).append(tail)
reasoning = "\n".join(part.strip() for part in thinking if part.strip())
response = "\n".join(part.strip() for part in answer if part.strip())
return {
"reasoning": "> " + reasoning.replace("\n", "\n> ") if reasoning else "",
"answer": response,
"hasReasoning": "true" if reasoning else "",
}
@@ -187,6 +187,15 @@ class AdaptersRouterGroup(group.RouterGroup):
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
)
@self.route('/dingtalk/stream-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
filename = 'dingtalk_stream_card.json'
response = quart.Response(
importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json'
)
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
return response
@self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
filename = 'dingtalk_human_input_card.json'
@@ -1,12 +1,13 @@
from __future__ import annotations
import time
import traceback
import typing
import dingtalk_stream
import pydantic
from langbot.libs.dingtalk_api.api import DingTalkClient
from langbot.libs.dingtalk_api.api import DingTalkClient, is_stream_card_configured
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
@@ -75,7 +76,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = ['client_id', 'client_secret', 'robot_name', 'robot_code']
required_keys = ['client_id', 'client_secret']
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员')
@@ -83,8 +84,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
bot = DingTalkClient(
client_id=config['client_id'],
client_secret=config['client_secret'],
robot_name=config['robot_name'],
robot_code=config['robot_code'],
robot_name=config.get('robot_name', ''),
robot_code=config.get('robot_code', ''),
markdown_card=config.get('markdown_card', True),
logger=logger,
)
@@ -92,7 +93,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
config=config,
logger=logger,
card_instance_id_dict={},
bot_account_id=config['robot_name'],
bot_account_id=config['client_id'],
bot=bot,
listeners={},
_message_cache={},
@@ -158,13 +159,28 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
assert isinstance(message_source.source_platform_object, DingTalkEvent)
incoming_message = message_source.source_platform_object.incoming_message
markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
text_message = platform_message.MessageChain([
part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))
])
content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled)
raw = await self.bot.send_message(content, incoming_message, at)
await self._send_attachments(message, incoming_message)
return platform_events.MessageResult(
message_id=getattr(incoming_message, 'message_id', None),
raw=raw if isinstance(raw, dict) else {'result': raw},
)
async def _send_attachments(self, message, incoming_message):
failures = []
for part in message:
if isinstance(part, (platform_message.Image, platform_message.File)):
try:
await self.bot.send_attachment(part, incoming_message)
except Exception as exc:
failures.append(str(exc))
if failures:
raise RuntimeError('; '.join(failures))
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
@@ -174,12 +190,19 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
is_final: bool = False,
):
message_id = bot_message.resp_message_id
msg_seq = bot_message.msg_sequence
if (msg_seq - 1) % 8 != 0 and not is_final:
now = time.monotonic()
last_updates = getattr(self, '_card_last_updates', None)
if last_updates is None:
self._card_last_updates = last_updates = {}
if not is_final and now - last_updates.get(message_id, 0) < 0.35:
return
last_updates[message_id] = now
markdown_enabled = self.config.get('markdown_card', False)
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
text_message = platform_message.MessageChain([
part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))
])
content, _ = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled)
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
if not content and bot_message.content:
content = bot_message.content
@@ -187,13 +210,17 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
if is_final and bot_message.tool_calls is None:
self.card_instance_id_dict.pop(message_id)
last_updates.pop(message_id, None)
await self._send_attachments(message, message_source.source_platform_object.incoming_message)
async def create_message_card(self, message_id, event):
while len(self.card_instance_id_dict) >= 1000:
self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None)
card_template_id = self.config['card_template_id']
oldest_id = next(iter(self.card_instance_id_dict))
self.card_instance_id_dict.pop(oldest_id, None)
getattr(self, '_card_last_updates', {}).pop(oldest_id, None)
card_template_id = self.config['card_template_id'].strip()
incoming_message = event.source_platform_object.incoming_message
card_auto_layout = self.config.get('card_auto_layout', False)
card_auto_layout = self.config.get('card_auto_layout', True)
card_instance, card_instance_id = await self.bot.create_and_card(
card_template_id,
incoming_message,
@@ -203,7 +230,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
return True
async def is_stream_output_supported(self) -> bool:
return bool(self.config.get('enable-stream-reply', False))
return is_stream_card_configured(self.config)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
if action == 'interaction.request' and action in self.get_supported_apis():
@@ -262,6 +289,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
async def _handle_native_event(self, event: DingTalkEvent):
try:
self.bot_account_id = getattr(event.incoming_message, 'chatbot_user_id', None) or self.bot_account_id
interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts)
if interaction_event is not None:
await self._dispatch_eba_event(interaction_event)
@@ -271,13 +299,13 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
)
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event, self.config['robot_name'])
legacy_event = await self.event_converter.target2legacy(event, self.bot_account_id)
if legacy_event:
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
eba_event = await self.event_converter.target2yiri(event, self.config['robot_name'])
eba_event = await self.event_converter.target2yiri(event, self.bot_account_id)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
@@ -16,9 +16,9 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
async def target2yiri(event: DingTalkEvent, bot_id: str) -> platform_events.Event | None:
if event.conversation in {'FriendMessage', 'GroupMessage'}:
return await DingTalkEventConverter.message_to_eba(event, bot_name)
return await DingTalkEventConverter.message_to_eba(event, bot_id)
if event.conversation == 'CardCallback':
feedback = DingTalkEventConverter.card_callback_to_feedback(event)
if feedback is not None:
@@ -29,17 +29,17 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def target2legacy(
event: DingTalkEvent,
bot_name: str,
bot_id: str,
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await DingTalkEventConverter.message_to_eba(event, bot_name)
eba_event = await DingTalkEventConverter.message_to_eba(event, bot_id)
if eba_event:
return eba_event.to_legacy_event()
return None
@staticmethod
async def message_to_eba(event: DingTalkEvent, bot_name: str) -> platform_events.MessageReceivedEvent:
async def message_to_eba(event: DingTalkEvent, bot_id: str) -> platform_events.MessageReceivedEvent:
incoming_message = event.incoming_message
message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name)
message_chain = await DingTalkMessageConverter.target2yiri(event, bot_id)
sender = DingTalkEventConverter.user_from_event(event)
chat_type = platform_entities.ChatType.PRIVATE
chat_id = getattr(incoming_message, 'sender_staff_id', '')
@@ -28,36 +28,12 @@ spec:
zh_Hans: 一键创建应用
zh_Hant: 一鍵建立應用
description:
en_US: 'Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be
obtained automatically, you need to copy it from the DingTalk Developer Backend manually.'
zh_Hans: 使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。
zh_Hant: 使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。
en_US: Scan with DingTalk to create an app and automatically fill in credentials. The bot identity is resolved automatically.
zh_Hans: 使用钉钉扫码创建应用并自动填写凭据,机器人身份会自动识别。
zh_Hant: 使用釘釘掃碼建立應用並自動填寫憑證,機器人身分會自動識別。
type: qr-code-login
login_platform: dingtalk
required: false
- name: human_input_card_template_download
label:
en_US: Download Human Input Card Template
zh_Hans: 下载人工输入卡片模板
zh_Hant: 下載人工輸入卡片範本
description:
en_US: Download the template, import it in DingTalk Card Platform, then enter its template ID below to enable native interactions.
zh_Hans: 下载模板并导入钉钉卡片平台,再将模板 ID 填入下方以启用原生交互。
zh_Hant: 下載範本並匯入釘釘卡片平台,再將範本 ID 填入下方以啟用原生互動。
type: download-link
required: false
default: ''
url: /api/v1/platform/adapters/dingtalk/human-input-card-template
download_filename: dingtalk_human_input_card.json
help_links:
zh: https://open-dev.dingtalk.com/fe/card
en: https://open-dev.dingtalk.com/fe/card
ja: https://open-dev.dingtalk.com/fe/card
help_label:
en_US: Import Guide
zh_Hans: 导入指引
zh_Hant: 匯入指引
ja_JP: インポート手順
- name: client_id
label:
en_US: Client ID
@@ -74,22 +50,32 @@ spec:
type: string
required: true
default: ""
- name: robot_code
- name: card_template_id
label:
en_US: Robot Code
zh_Hans: 机器人代码
zh_Hant: 機器人代碼
en_US: Card Template ID
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
description:
en_US: Template for streaming text replies. Save and publish it in DingTalk Card Platform, then paste its ID here to automatically enable streaming replies. Leave empty for ordinary replies. Separate from the human input template.
zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。
zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。
type: string
required: true
required: false
default: ""
- name: robot_name
- name: stream_card_template_download
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
en_US: Download Streaming Reply Card Template
zh_Hans: 下载流式回复卡片模板
zh_Hant: 下載串流回覆卡片範本
description:
en_US: Displays reasoning and the answer separately. Import through More > Import Card Template in the DingTalk AI card editor, save and publish, then paste its ID into Card Template ID above. Respects the pipeline thinking removal setting.
zh_Hans: 分开展示思考与正文。在钉钉 AI 卡片编辑器中选择「更多 → 导入卡片模板」,保存并发布后,将 ID 填入上方「卡片模板 ID」。是否保留思考仍由流水线设置控制。
zh_Hant: 分開展示思考與正文。在釘釘 AI 卡片編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布後,將 ID 填入上方「卡片範本 ID」。是否保留思考仍由流水線設定控制。
type: download-link
required: false
default: ''
url: /api/v1/platform/adapters/dingtalk/stream-card-template
download_filename: dingtalk_stream_card.json
- name: markdown_card
label:
en_US: Markdown Card
@@ -98,47 +84,53 @@ spec:
type: boolean
required: false
default: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式
description:
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use DingTalk card streaming replies.
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean
required: true
default: false
- name: card_auto_layout
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
zh_Hant: 卡片寬螢幕自動佈局
description:
en_US: Enabled by default. Allows supported cards to adapt to wider windows. The LangBot streaming reply template includes the required variables; you can disable this for a narrower layout.
zh_Hans: 默认开启,让支持的卡片适应较宽的聊天窗口。LangBot 流式回复模板已包含所需变量;如需较窄布局,可关闭此项。
zh_Hant: 預設開啟,讓支援的卡片適應較寬的聊天視窗。LangBot 串流回覆範本已包含所需變數;如需較窄版面,可關閉此項。
type: boolean
required: false
default: false
- name: card_template_id
label:
en_US: Card Template ID
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
type: string
required: true
default: "填写你的卡片template_id"
default: true
- name: human_input_card_template_id
label:
en_US: Human Input Card Template ID
zh_Hans: 人工输入卡片模板 ID
zh_Hant: 人工輸入卡片範本 ID
description:
en_US: Import the bundled `src/langbot/templates/dingtalk_human_input_card.json`, then enter its DingTalk template ID here. It contains the `content`, `btns`, `input_*`, `select_*`, and `index_o` variables required for native interactions.
zh_Hans: 可选。模板需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 变量以支持原生交互。
zh_Hant: 可選。範本需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 變數以支援原生互動。
en_US: Optional. Used when a workflow or plugin requests user input, a choice, or confirmation before continuing (for example, a Dify Human Input node). Ordinary chat does not require it. Import the template below and enter its published ID here; streaming text replies use the separate Card Template ID.
zh_Hans: 可选。当工作流或插件需要用户填写信息、选择选项或确认后再继续执行时使用,例如 Dify 的人工输入节点。普通聊天无需配置。请导入下方模板,将发布后的 ID 填在这里;流式文字回复使用单独的「卡片模板 ID」。
zh_Hant: 可選。當工作流或外掛需要使用者填寫資訊、選擇選項或確認後再繼續執行時使用,例如 Dify 的人工輸入節點。一般聊天無需設定。請匯入下方範本,將發布後的 ID 填在這裡;串流文字回覆使用獨立的「卡片範本 ID」。
type: string
required: false
default: ""
- name: human_input_card_template_download
label:
en_US: Download Human Input Card Template
zh_Hans: 下载人工输入卡片模板
zh_Hant: 下載人工輸入卡片範本
description:
en_US: Download the human input template. Create an AI card in DingTalk Card Platform, choose More > Import Card Template in the editor, then save and publish. Paste its ID into the field above.
zh_Hans: 下载人工输入模板后,在钉钉卡片平台新建 AI 卡片,在编辑器中选择「更多 → 导入卡片模板」,保存并发布,将 ID 填入上方字段。
zh_Hant: 下載人工輸入範本後,在釘釘卡片平台新建 AI 卡片,在編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布,將 ID 填入上方欄位。
type: download-link
required: false
default: ''
url: /api/v1/platform/adapters/dingtalk/human-input-card-template
download_filename: dingtalk_human_input_card.json
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk#导入人工输入卡片模板
en: https://open-dev.dingtalk.com/fe/card
ja: https://open-dev.dingtalk.com/fe/card
help_label:
en_US: Import Guide
zh_Hans: 导入指引
zh_Hant: 匯入指引
ja_JP: インポート手順
supported_events:
- message.received
@@ -86,7 +86,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
return content, at
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_message.MessageChain:
async def target2yiri(event: DingTalkEvent, bot_id: str) -> platform_message.MessageChain:
incoming_message = event.incoming_message
components: list[platform_message.MessageComponent] = [
platform_message.Source(
@@ -95,15 +95,19 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
)
]
for at_user in getattr(incoming_message, 'at_users', []) or []:
if getattr(at_user, 'dingtalk_id', None) == getattr(incoming_message, 'chatbot_user_id', None):
components.append(platform_message.At(target=bot_name, display=bot_name))
chatbot_id = getattr(incoming_message, 'chatbot_user_id', None)
mentioned = getattr(incoming_message, 'is_in_at_list', False) or any(
chatbot_id and getattr(at_user, 'dingtalk_id', None) == chatbot_id
for at_user in getattr(incoming_message, 'at_users', []) or []
)
if mentioned:
components.append(platform_message.At(target=bot_id))
rich_content = event.rich_content
if rich_content:
for element in rich_content.get('Elements') or []:
if element.get('Type') == 'text':
text = DingTalkMessageConverter._strip_bot_mention(element.get('Content', ''), bot_name)
text = element.get('Content', '')
if text.strip():
components.append(platform_message.Plain(text=text))
elif element.get('Type') == 'image' and element.get('Picture'):
@@ -112,7 +116,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if event.content and event.type != 'audio':
components.append(
platform_message.Plain(
text=DingTalkMessageConverter._strip_bot_mention(event.content, bot_name),
text=event.content,
)
)
if event.picture:
@@ -160,10 +164,6 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
origin=platform_message.MessageChain(origin_components),
)
@staticmethod
def _strip_bot_mention(text: str, bot_name: str) -> str:
return text.replace('@' + bot_name, '')
@staticmethod
def _message_time(incoming_message: typing.Any) -> datetime.datetime:
value = getattr(incoming_message, 'create_at', None)
+37 -21
View File
@@ -1,3 +1,4 @@
from langbot.libs.dingtalk_api.thinking import visible_think_markers
import asyncio
import json
import pathlib
@@ -13,7 +14,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.libs.dingtalk_api.api import DingTalkClient
from langbot.libs.dingtalk_api.api import DingTalkClient, is_stream_card_configured
import datetime
from langbot.pkg.platform.logger import EventLogger
from langbot.pkg.platform.human_input import format_human_input_text
@@ -65,15 +66,19 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now())
)
for atUser in event.incoming_message.at_users:
if atUser.dingtalk_id == event.incoming_message.chatbot_user_id:
yiri_msg_list.append(platform_message.At(target=bot_name))
chatbot_id = getattr(event.incoming_message, 'chatbot_user_id', None)
mentioned = getattr(event.incoming_message, 'is_in_at_list', False) or any(
chatbot_id and getattr(at_user, 'dingtalk_id', None) == chatbot_id
for at_user in getattr(event.incoming_message, 'at_users', []) or []
)
if mentioned:
yiri_msg_list.append(platform_message.At(target=bot_name))
if event.rich_content:
elements = event.rich_content.get('Elements')
for element in elements:
if element.get('Type') == 'text':
text = element.get('Content', '').replace('@' + bot_name, '')
text = element.get('Content', '')
if text.strip():
yiri_msg_list.append(platform_message.Plain(text=text))
elif element.get('Type') == 'image' and element.get('Picture'):
@@ -82,7 +87,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
# 回退到原有简单逻辑
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
if event.content and event.type != 'audio':
text_content = event.content.replace('@' + bot_name, '')
text_content = event.content
yiri_msg_list.append(platform_message.Plain(text=text_content))
if event.picture:
yiri_msg_list.append(platform_message.Image(base64=event.picture))
@@ -519,8 +524,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
required_keys = [
'client_id',
'client_secret',
'robot_name',
'robot_code',
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
@@ -528,12 +531,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot = DingTalkClient(
client_id=config['client_id'],
client_secret=config['client_secret'],
robot_name=config['robot_name'],
robot_code=config['robot_code'],
robot_name=config.get('robot_name', ''),
robot_code=config.get('robot_code', ''),
markdown_card=config['markdown_card'],
logger=logger,
)
bot_account_id = config['robot_name']
bot_account_id = config['client_id']
super().__init__(
config=config,
logger=logger,
@@ -610,8 +613,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
incoming_message = event.incoming_message
markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
text_message = platform_message.MessageChain([
part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))
])
content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled)
await self.bot.send_message(content, incoming_message, at)
for part in message:
if isinstance(part, (platform_message.Image, platform_message.File)):
await self.bot.send_attachment(part, incoming_message)
async def reply_message_chunk(
self,
@@ -638,7 +647,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if (msg_seq - 1) % 8 == 0 or is_final:
markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
text_message = platform_message.MessageChain([
part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))
])
content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled)
if not content and bot_message.content:
content = bot_message.content # 兼容直接传入content的情况
@@ -707,6 +719,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
pass
if bot_message.tool_calls is None:
self.card_instance_id_dict.pop(message_id, None)
event = await DingTalkEventConverter.yiri2target(message_source)
for part in message:
if isinstance(part, (platform_message.Image, platform_message.File)):
await self.bot.send_attachment(part, event.incoming_message)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
markdown_enabled = self.config.get('markdown_card', False)
@@ -717,15 +733,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_proactive_message_to_group(target_id, content)
async def is_stream_output_supported(self) -> bool:
is_stream = False
if self.config.get('enable-stream-reply', None):
is_stream = True
return is_stream
return is_stream_card_configured(self.config)
async def create_message_card(self, message_id, event):
self._prune_card_state()
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
legacy_template_id = self.config.get('card_template_id', '')
legacy_template_id = (self.config.get('card_template_id') or '').strip()
# Synthetic events (button clicks): look up the card already in
# active_turn_card so reply_message_chunk can stream to it.
@@ -776,7 +789,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Legacy chat-card path (no form template).
incoming_message = event.source_platform_object.incoming_message
card_auto_layout = self.config.get('card_auto_layout', False)
card_auto_layout = self.config.get('card_auto_layout', True)
card_instance, card_instance_id = await self.bot.create_and_card(
legacy_template_id, incoming_message, card_auto_layout=card_auto_layout
)
@@ -812,8 +825,9 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
async def on_message(event: DingTalkEvent):
try:
self.bot_account_id = getattr(event.incoming_message, 'chatbot_user_id', None) or self.bot_account_id
return await callback(
await self.event_converter.target2yiri(event, self.config['robot_name']),
await self.event_converter.target2yiri(event, self.bot_account_id),
self,
)
except Exception:
@@ -853,6 +867,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if self.bot_avatar_media_id:
params['bot_avatar'] = self.bot_avatar_media_id
params.update(extra)
if isinstance(params.get('content'), str):
params['content'] = visible_think_markers(params['content'])
return params
async def kill(self) -> bool:
@@ -1196,7 +1212,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Legacy chat-card template doesn't carry a `bot_avatar`
# variable, so don't decorate the param map here.
card_param_map = {'content': '', 'query': '...'}
card_data_config = {'autoLayout': self.config.get('card_auto_layout', False)}
card_data_config = {'autoLayout': self.config.get('card_auto_layout', True)}
try:
success = await self.bot.create_and_deliver_card(
card_template_id=template_id,
+43 -53
View File
@@ -27,9 +27,9 @@ spec:
zh_Hans: 一键创建应用
zh_Hant: 一鍵建立應用
description:
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
en_US: Scan with DingTalk to create an app and automatically fill in credentials. The bot identity is resolved automatically.
zh_Hans: 使用钉钉扫码创建应用并自动填写凭据,机器人身份会自动识别。
zh_Hant: 使用釘釘掃碼建立應用並自動填寫憑證,機器人身分會自動識別。
type: qr-code-login
login_platform: dingtalk
required: false
@@ -49,26 +49,32 @@ spec:
type: string
required: true
default: ""
- name: robot_code
- name: card_template_id
label:
en_US: Robot Code
zh_Hans: 机器人代码
zh_Hant: 機器人代碼
en_US: card template id
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
description:
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
en_US: Template for streaming text replies. Save and publish it in DingTalk Card Platform, then paste its ID here to automatically enable streaming replies. Leave empty for ordinary replies. Separate from the human input template.
zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。
zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。
type: string
required: true
required: false
default: ""
- name: robot_name
- name: stream_card_template_download
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
en_US: Download Streaming Reply Card Template
zh_Hans: 下载流式回复卡片模板
zh_Hant: 下載串流回覆卡片範本
description:
en_US: Displays reasoning and the answer separately. Import through More > Import Card Template in the DingTalk AI card editor, save and publish, then paste its ID into Card Template ID above. Respects the pipeline thinking removal setting.
zh_Hans: 分开展示思考与正文。在钉钉 AI 卡片编辑器中选择「更多 → 导入卡片模板」,保存并发布后,将 ID 填入上方「卡片模板 ID」。是否保留思考仍由流水线设置控制。
zh_Hant: 分開展示思考與正文。在釘釘 AI 卡片編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布後,將 ID 填入上方「卡片範本 ID」。是否保留思考仍由流水線設定控制。
type: download-link
required: false
default: ''
url: /api/v1/platform/adapters/dingtalk/stream-card-template
download_filename: dingtalk_stream_card.json
- name: markdown_card
label:
en_US: Markdown Card
@@ -77,50 +83,46 @@ spec:
type: boolean
required: false
default: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式
description:
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of lark reply mode
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean
required: true
default: false
- name: card_auto_layout
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
zh_Hant: 卡片寬螢幕自動佈局
description:
en_US: Enabled by default. Allows supported cards to adapt to wider windows. The LangBot streaming reply template includes the required variables; you can disable this for a narrower layout.
zh_Hans: 默认开启,让支持的卡片适应较宽的聊天窗口。LangBot 流式回复模板已包含所需变量;如需较窄布局,可关闭此项。
zh_Hant: 預設開啟,讓支援的卡片適應較寬的聊天視窗。LangBot 串流回覆範本已包含所需變數;如需較窄版面,可關閉此項。
type: boolean
required: false
default: false
- name: card_template_id
default: true
- name: human_input_card_template_id
label:
en_US: card template id
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
en_US: Human Input Card Template ID
zh_Hans: 人工输入卡片模板ID
zh_Hant: 人工輸入卡片範本ID
description:
en_US: Optional. Used when a workflow or plugin requests user input, a choice, or confirmation before continuing (for example, a Dify Human Input node). Ordinary chat does not require it. Import the template below and enter its published ID here; streaming text replies use the separate Card Template ID.
zh_Hans: 可选。当工作流或插件需要用户填写信息、选择选项或确认后再继续执行时使用,例如 Dify 的人工输入节点。普通聊天无需配置。请导入下方模板,将发布后的 ID 填在这里;流式文字回复使用单独的「卡片模板 ID」。
zh_Hant: 可選。當工作流或外掛需要使用者填寫資訊、選擇選項或確認後再繼續執行時使用,例如 Dify 的人工輸入節點。一般聊天無需設定。請匯入下方範本,將發布後的 ID 填在這裡;串流文字回覆使用獨立的「卡片範本 ID」。
type: string
required: true
default: "填写你的卡片template_id"
required: false
default: ""
- name: human_input_card_template_download
label:
en_US: Download Human Input Card Template
zh_Hans: 下载人工输入卡片模板
zh_Hant: 下載人工輸入卡片範本
description:
en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior."
zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。"
zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。"
en_US: Download the human input template. Create an AI card in DingTalk Card Platform, choose More > Import Card Template in the editor, then save and publish. Paste its ID into the field above.
zh_Hans: 下载人工输入模板后,在钉钉卡片平台新建 AI 卡片,在编辑器中选择「更多 → 导入卡片模板」,保存并发布,将 ID 填入上方字段。
zh_Hant: 下載人工輸入範本後,在釘釘卡片平台新建 AI 卡片,在編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布,將 ID 填入上方欄位。
type: download-link
required: false
default: ""
url: /api/v1/platform/adapters/dingtalk/human-input-card-template
download_filename: dingtalk_human_input_card.json
help_links:
zh: https://open-dev.dingtalk.com/fe/card
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk#导入人工输入卡片模板
en: https://open-dev.dingtalk.com/fe/card
ja: https://open-dev.dingtalk.com/fe/card
help_label:
@@ -128,18 +130,6 @@ spec:
zh_Hans: 导入指引
zh_Hant: 匯入指引
ja_JP: インポート手順
- name: human_input_card_template_id
label:
en_US: Human Input Card Template ID
zh_Hans: 人工输入卡片模板ID
zh_Hant: 人工輸入卡片範本ID
description:
en_US: "Paste the template ID generated after importing the human input card template."
zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。"
zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。"
type: string
required: false
default: ""
execution:
python:
path: ./dingtalk.py
File diff suppressed because one or more lines are too long
@@ -47,7 +47,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { backendClient, systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { parseDynamicFormItemType } from './DynamicFormItemConfig';
import {
@@ -304,18 +304,54 @@ function DownloadLinkField({
helpUrl?: string | null;
helpLabel: string;
}) {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
const { t } = useTranslation();
const [downloading, setDownloading] = useState(false);
const [failed, setFailed] = useState(false);
const download = async () => {
if (downloading) return;
setDownloading(true);
setFailed(false);
try {
let blob: Blob;
if (/^https?:\/\//i.test(url)) {
// External templates must not receive backend credentials.
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
blob = await response.blob();
} else {
const response = await backendClient.downloadFile(url);
blob = response.data;
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename || url.split('/').pop()?.split('?')[0] || 'download';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch {
setFailed(true);
} finally {
setDownloading(false);
}
};
return (
<FormItem className="min-w-0">
<FormLabel className="break-words">{label}</FormLabel>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={downloadUrl} download={filename}>
<Download className="h-4 w-4" />
{label}
</a>
<Button
type="button"
variant="outline"
size="sm"
onClick={download}
disabled={downloading}
aria-busy={downloading}
>
<Download className="h-4 w-4" />
{label}
</Button>
{helpUrl && (
<Button asChild variant="ghost" size="sm">
@@ -326,6 +362,11 @@ function DownloadLinkField({
</Button>
)}
</div>
{failed && (
<p role="alert" className="text-sm text-destructive">
{t('common.operationFailed')}
</p>
)}
{description && (
<p className="max-w-2xl text-sm break-words text-muted-foreground">
{description}
@@ -83,7 +83,6 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
client_id: data.client_id,
client_secret: data.client_secret,
}),
successNoteKey: 'dingtalk.robotCodeNote',
},
wecombot: {
titleKey: 'wecombot.createBot',