fix: command return value image_url handling for DingTalk, Slack, LINE, and Lark adapters (#1810)

* Initial plan

* Fix command return value image_url handling for DingTalk, Slack, and LINE adapters

Co-authored-by: the-lazy-me <52873503+the-lazy-me@users.noreply.github.com>

* Refactor DingTalk image handling into helper method and add clarifying comment

Co-authored-by: the-lazy-me <52873503+the-lazy-me@users.noreply.github.com>

* Fix Lark adapter to not append empty paragraph before images

Co-authored-by: the-lazy-me <52873503+the-lazy-me@users.noreply.github.com>

* Improve Lark adapter image handling with better error logging

Co-authored-by: the-lazy-me <52873503+the-lazy-me@users.noreply.github.com>

* Fix Lark adapter to send images as separate image messages instead of embedded in post

Co-authored-by: the-lazy-me <52873503+the-lazy-me@users.noreply.github.com>

* Parse Markdown image syntax in Lark adapter and render as separate image messages

Co-authored-by: the-lazy-me <52873503+the-lazy-me@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: the-lazy-me <52873503+the-lazy-me@users.noreply.github.com>
This commit is contained in:
Copilot
2025-11-28 22:24:42 +08:00
committed by GitHub
parent cf646752c5
commit 58312deb8c
4 changed files with 264 additions and 134 deletions
+34 -7
View File
@@ -12,17 +12,41 @@ from langbot.pkg.platform.logger import EventLogger
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target(message_chain: platform_message.MessageChain): def _format_image_as_markdown(msg: platform_message.Image) -> str:
"""Convert an Image message to Markdown format for DingTalk."""
if msg.url:
return f'\n![image]({msg.url})\n'
elif msg.base64:
# For base64 images, try to include them as data URIs
# DingTalk may have limited support for base64 in markdown
if msg.base64.startswith('data:'):
return f'\n![image]({msg.base64})\n'
else:
return f'\n![image](data:image/png;base64,{msg.base64})\n'
return ''
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, markdown_enabled: bool = True):
content = '' content = ''
at = False at = False
for msg in message_chain: for msg in message_chain:
if type(msg) is platform_message.At: if type(msg) is platform_message.At:
at = True at = True
if type(msg) is platform_message.Plain: elif type(msg) is platform_message.Plain:
content += msg.text content += msg.text
if type(msg) is platform_message.Forward: elif type(msg) is platform_message.Image:
# DingTalk supports markdown images when markdown_card is enabled
# When markdown is disabled, images cannot be rendered in plain text mode
if markdown_enabled:
content += DingTalkMessageConverter._format_image_as_markdown(msg)
# Note: When markdown_enabled is False, images are not included
# as DingTalk plain text messages don't support image embedding
elif type(msg) is platform_message.Forward:
for node in msg.node_list: for node in msg.node_list:
content += (await DingTalkMessageConverter.yiri2target(node.message_chain))[0] forwarded_content, _ = await DingTalkMessageConverter.yiri2target(
node.message_chain, markdown_enabled
)
content += forwarded_content
return content, at return content, at
@staticmethod @staticmethod
@@ -157,7 +181,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
incoming_message = event.incoming_message incoming_message = event.incoming_message
content, at = await DingTalkMessageConverter.yiri2target(message) markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
await self.bot.send_message(content, incoming_message, at) await self.bot.send_message(content, incoming_message, at)
async def reply_message_chunk( async def reply_message_chunk(
@@ -178,7 +203,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
msg_seq = bot_message.msg_sequence msg_seq = bot_message.msg_sequence
if (msg_seq - 1) % 8 == 0 or is_final: if (msg_seq - 1) % 8 == 0 or is_final:
content, at = await DingTalkMessageConverter.yiri2target(message) markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
card_instance, card_instance_id = self.card_instance_id_dict[message_id] card_instance, card_instance_id = self.card_instance_id_dict[message_id]
if not content and bot_message.content: if not content and bot_message.content:
@@ -191,7 +217,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content = await DingTalkMessageConverter.yiri2target(message) markdown_enabled = self.config.get('markdown_card', False)
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
if target_type == 'person': if target_type == 'person':
await self.bot.send_proactive_message_to_one(target_id, content) await self.bot.send_proactive_message_to_one(target_id, content)
if target_type == 'group': if target_type == 'group':
+149 -64
View File
@@ -55,42 +55,23 @@ class AESCipher(object):
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target( async def upload_image_to_lark(
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client msg: platform_message.Image, api_client: lark_oapi.Client
) -> typing.Tuple[list]: ) -> typing.Optional[str]:
message_elements = [] """Upload an image to Lark and return the image_key, or None if upload fails."""
pending_paragraph = []
for msg in message_chain:
if isinstance(msg, platform_message.Plain):
# Ensure text is valid UTF-8
try:
text = msg.text.encode('utf-8').decode('utf-8')
pending_paragraph.append({'tag': 'md', 'text': text})
except UnicodeError:
# If text is not valid UTF-8, try to decode with other encodings
try:
text = msg.text.encode('latin1').decode('utf-8')
pending_paragraph.append({'tag': 'md', 'text': text})
except UnicodeError:
# If still fails, replace invalid characters
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
pending_paragraph.append({'tag': 'md', 'text': text})
elif isinstance(msg, platform_message.At):
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
elif isinstance(msg, platform_message.AtAll):
pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []})
elif isinstance(msg, platform_message.Image):
image_bytes = None image_bytes = None
if msg.base64: if msg.base64:
try: try:
# Remove data URL prefix if present # Remove data URL prefix if present
if msg.base64.startswith('data:'): base64_data = msg.base64
msg.base64 = msg.base64.split(',', 1)[1] if base64_data.startswith('data:'):
image_bytes = base64.b64decode(msg.base64) base64_data = base64_data.split(',', 1)[1]
except Exception: image_bytes = base64.b64decode(base64_data)
except Exception as e:
print(f'Failed to decode base64 image: {e}')
traceback.print_exc() traceback.print_exc()
continue return None
elif msg.url: elif msg.url:
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -98,37 +79,43 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
if response.status == 200: if response.status == 200:
image_bytes = await response.read() image_bytes = await response.read()
else: else:
print(f'Failed to download image from {msg.url}: HTTP {response.status}')
return None
except Exception as e:
print(f'Failed to download image from {msg.url}: {e}')
traceback.print_exc() traceback.print_exc()
continue return None
except Exception:
traceback.print_exc()
continue
elif msg.path: elif msg.path:
try: try:
with open(msg.path, 'rb') as f: with open(msg.path, 'rb') as f:
image_bytes = f.read() image_bytes = f.read()
except Exception: except Exception as e:
print(f'Failed to read image from path {msg.path}: {e}')
traceback.print_exc() traceback.print_exc()
continue return None
if image_bytes is None: if image_bytes is None:
continue print(f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})')
return None
try: try:
# Create a temporary file to store the image bytes # Create a temporary file to store the image bytes
import tempfile import tempfile
import os
with tempfile.NamedTemporaryFile(delete=False) as temp_file: with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(image_bytes) temp_file.write(image_bytes)
temp_file.flush() temp_file.flush()
temp_file_path = temp_file.name
try:
# Create image request using the temporary file # Create image request using the temporary file
request = ( request = (
CreateImageRequest.builder() CreateImageRequest.builder()
.request_body( .request_body(
CreateImageRequestBody.builder() CreateImageRequestBody.builder()
.image_type('message') .image_type('message')
.image(open(temp_file.name, 'rb')) .image(open(temp_file_path, 'rb'))
.build() .build()
) )
.build() .build()
@@ -137,39 +124,109 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
response = await api_client.im.v1.image.acreate(request) response = await api_client.im.v1.image.acreate(request)
if not response.success(): if not response.success():
raise Exception( print(
f'client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' f'client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}'
) )
return None
image_key = response.data.image_key return response.data.image_key
message_elements.append(pending_paragraph)
message_elements.append(
[
{
'tag': 'img',
'image_key': image_key,
}
]
)
pending_paragraph = []
except Exception:
traceback.print_exc()
continue
finally: finally:
# Clean up the temporary file # Clean up the temporary file
import os os.unlink(temp_file_path)
except Exception as e:
print(f'Failed to upload image to Lark: {e}')
traceback.print_exc()
return None
if 'temp_file' in locals(): @staticmethod
os.unlink(temp_file.name) async def yiri2target(
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
) -> typing.Tuple[list, list]:
"""Convert message chain to Lark format.
Returns:
Tuple of (text_elements, image_keys):
- text_elements: List of paragraphs for post message format
- image_keys: List of image_key strings for separate image messages
"""
message_elements = []
image_keys = []
pending_paragraph = []
# Regex pattern to match Markdown image syntax: ![alt](url)
markdown_image_pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
async def process_text_with_images(text: str) -> typing.Tuple[str, list]:
"""Extract Markdown images from text and return cleaned text + image URLs."""
extracted_urls = []
# Find all Markdown images
matches = list(markdown_image_pattern.finditer(text))
if not matches:
return text, []
# Extract URLs and remove image syntax from text
cleaned_text = text
for match in reversed(matches): # Reverse to maintain correct positions
url = match.group(2)
extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse
# Replace image syntax with empty string or a placeholder
cleaned_text = cleaned_text[:match.start()] + cleaned_text[match.end():]
# Clean up multiple consecutive newlines that might result from removing images
cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text)
cleaned_text = cleaned_text.strip()
return cleaned_text, extracted_urls
for msg in message_chain:
if isinstance(msg, platform_message.Plain):
# Ensure text is valid UTF-8
try:
text = msg.text.encode('utf-8').decode('utf-8')
except UnicodeError:
try:
text = msg.text.encode('latin1').decode('utf-8')
except UnicodeError:
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
# Check for and extract Markdown images from text
cleaned_text, extracted_urls = await process_text_with_images(text)
# Add cleaned text if not empty
if cleaned_text:
pending_paragraph.append({'tag': 'md', 'text': cleaned_text})
# Process extracted image URLs
for url in extracted_urls:
# Create a temporary Image message to upload
temp_image = platform_message.Image(url=url)
image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client)
if image_key:
image_keys.append(image_key)
elif isinstance(msg, platform_message.At):
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
elif isinstance(msg, platform_message.AtAll):
pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []})
elif isinstance(msg, platform_message.Image):
# Upload image and get image_key
image_key = await LarkMessageConverter.upload_image_to_lark(msg, api_client)
if image_key:
# Store image_key for separate image message
image_keys.append(image_key)
elif isinstance(msg, platform_message.Forward): elif isinstance(msg, platform_message.Forward):
for node in msg.node_list: for node in msg.node_list:
message_elements.extend(await LarkMessageConverter.yiri2target(node.message_chain, api_client)) sub_elements, sub_image_keys = await LarkMessageConverter.yiri2target(
node.message_chain, api_client
)
message_elements.extend(sub_elements)
image_keys.extend(sub_image_keys)
if pending_paragraph: if pending_paragraph:
message_elements.append(pending_paragraph) message_elements.append(pending_paragraph)
return message_elements return message_elements, image_keys
@staticmethod @staticmethod
async def target2yiri( async def target2yiri(
@@ -667,12 +724,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
): ):
# 不再需要了,因为message_id已经被包含到message_chain中 # 不再需要了,因为message_id已经被包含到message_chain中
# lark_event = await self.event_converter.yiri2target(message_source) # lark_event = await self.event_converter.yiri2target(message_source)
lark_message = await self.message_converter.yiri2target(message, self.api_client) text_elements, image_keys = await self.message_converter.yiri2target(message, self.api_client)
# Send text message if there are text elements
if text_elements:
final_content = { final_content = {
'zh_Hans': { 'zh_Hans': {
'title': '', 'title': '',
'content': lark_message, 'content': text_elements,
}, },
} }
@@ -697,6 +756,31 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
) )
# Send image messages separately using msg_type='image'
for image_key in image_keys:
image_content = json.dumps({'image_key': image_key})
request: ReplyMessageRequest = (
ReplyMessageRequest.builder()
.message_id(message_source.message_chain.message_id)
.request_body(
ReplyMessageRequestBody.builder()
.content(image_content)
.msg_type('image')
.reply_in_thread(False)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
if not response.success():
raise Exception(
f'client.im.v1.message.reply (image) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
async def reply_message_chunk( async def reply_message_chunk(
self, self,
message_source: platform_events.MessageEvent, message_source: platform_events.MessageEvent,
@@ -712,10 +796,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_id = bot_message.resp_message_id message_id = bot_message.resp_message_id
msg_seq = bot_message.msg_sequence msg_seq = bot_message.msg_sequence
if msg_seq % 8 == 0 or is_final: if msg_seq % 8 == 0 or is_final:
lark_message = await self.message_converter.yiri2target(message, self.api_client) text_elements, image_keys = await self.message_converter.yiri2target(message, self.api_client)
text_message = '' text_message = ''
for ele in lark_message[0]: if text_elements:
for ele in text_elements[0]:
if ele['tag'] == 'text': if ele['tag'] == 'text':
text_message += ele['text'] text_message += ele['text']
elif ele['tag'] == 'md': elif ele['tag'] == 'md':
+5 -4
View File
@@ -41,10 +41,9 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
elif isinstance(component, platform_message.Plain): elif isinstance(component, platform_message.Plain):
content_list.append({'type': 'text', 'content': component.text}) content_list.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image): elif isinstance(component, platform_message.Image):
if not component.url: # Only add image if it has a valid URL
pass if component.url:
content_list.append({'type': 'image', 'image': component.url}) content_list.append({'type': 'image', 'image': component.url})
elif isinstance(component, platform_message.Voice): elif isinstance(component, platform_message.Voice):
content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})
@@ -207,10 +206,12 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
) )
elif content['type'] == 'image': elif content['type'] == 'image':
# LINE ImageMessage requires original_content_url and preview_image_url
image_url = content['image']
self.bot.reply_message_with_http_info( self.bot.reply_message_with_http_info(
ReplyMessageRequest( ReplyMessageRequest(
reply_token=message_source.source_platform_object.reply_token, reply_token=message_source.source_platform_object.reply_token,
messages=[ImageMessage(text=content['content'])], messages=[ImageMessage(original_content_url=image_url, preview_image_url=image_url)],
) )
) )
+21 -4
View File
@@ -24,9 +24,20 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
if type(msg) is platform_message.Plain: if type(msg) is platform_message.Plain:
content_list.append( content_list.append(
{ {
'type': 'text',
'content': msg.text, 'content': msg.text,
} }
) )
elif type(msg) is platform_message.Image:
# Slack supports images via unfurling URLs
# Include image URL in the message so Slack can unfurl it
if msg.url:
content_list.append(
{
'type': 'image',
'content': msg.url,
}
)
return content_list return content_list
@@ -116,18 +127,24 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content_list = await SlackMessageConverter.yiri2target(message) content_list = await SlackMessageConverter.yiri2target(message)
for content in content_list: for content in content_list:
# Both text and image (URL) are sent as text messages
# Slack will auto-unfurl image URLs
message_content = content['content']
if slack_event.type == 'channel': if slack_event.type == 'channel':
await self.bot.send_message_to_channel(content['content'], slack_event.channel_id) await self.bot.send_message_to_channel(message_content, slack_event.channel_id)
if slack_event.type == 'im': if slack_event.type == 'im':
await self.bot.send_message_to_one(content['content'], slack_event.user_id) await self.bot.send_message_to_one(message_content, slack_event.user_id)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content_list = await SlackMessageConverter.yiri2target(message) content_list = await SlackMessageConverter.yiri2target(message)
for content in content_list: for content in content_list:
# Both text and image (URL) are sent as text messages
# Slack will auto-unfurl image URLs
message_content = content['content']
if target_type == 'person': if target_type == 'person':
await self.bot.send_message_to_one(content['content'], target_id) await self.bot.send_message_to_one(message_content, target_id)
if target_type == 'group': if target_type == 'group':
await self.bot.send_message_to_channel(content['content'], target_id) await self.bot.send_message_to_channel(message_content, target_id)
def register_listener( def register_listener(
self, self,