Merge pull request #2967 from AaronZ345/agent/feishu-markdown-images

feat: render remote Markdown images in Feishu cards
This commit is contained in:
zhayujie
2026-07-19 11:24:32 +08:00
committed by GitHub
6 changed files with 357 additions and 8 deletions

View File

@@ -28,7 +28,11 @@ from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.feishu.feishu_message import FeishuMessage
from channel.feishu.feishu_static_card import build_text_delivery
from channel.feishu.feishu_static_card import (
build_text_delivery,
resolve_markdown_images,
upload_public_image_to_feishu,
)
from channel.feishu.feishu_progress_card import FeishuProgressState
from channel.feishu.feishu_scheduler_card import (
build_scheduler_card,
@@ -752,9 +756,19 @@ class FeiShuChanel(ChatChannel):
content_key = "text"
prepared_content_json = None
if reply.type == ReplyType.TEXT:
delivery_text = reply.content
markdown_cards_enabled = conf().get("feishu_markdown_card", True)
if markdown_cards_enabled:
delivery_text = resolve_markdown_images(
delivery_text,
lambda url: upload_public_image_to_feishu(
url,
access_token,
),
)
msg_type, prepared_content_json = build_text_delivery(
reply.content,
enabled=conf().get("feishu_markdown_card", True),
delivery_text,
enabled=markdown_cards_enabled,
)
elif reply.type == ReplyType.IMAGE_URL:
# 图片上传
@@ -1199,6 +1213,12 @@ class FeiShuChanel(ChatChannel):
final_text = progress_state.current_text
has_card = card_id[0] is not None
init_busy = init_in_flight[0]
final_text = resolve_markdown_images(
final_text,
lambda url: upload_public_image_to_feishu(url, access_token),
)
with lock:
progress_state.current_text = final_text
context["feishu_streamed"] = True
if not has_card and not init_busy:
@@ -1465,9 +1485,15 @@ class FeiShuChanel(ChatChannel):
if not cid:
return
preview_text = final_text
final_text = resolve_markdown_images(
final_text,
lambda url: upload_public_image_to_feishu(url, access_token),
)
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
# settings 接口的 config 不接受 summary 字段,会报 code=2200
preview_src = (final_text or "").strip().replace("\n", " ")
preview_src = (preview_text or "").strip().replace("\n", " ")
preview = preview_src[:30] if preview_src else ""
full_card = {
"schema": "2.0",

View File

@@ -1,8 +1,13 @@
"""Helpers for choosing the native Feishu delivery format for text replies."""
import ipaddress
import json
import re
from typing import Tuple
import socket
from typing import Callable, Optional, Tuple
from urllib.parse import urljoin, urlparse
import requests
_BLOCK_MARKDOWN = re.compile(
@@ -10,6 +15,10 @@ _BLOCK_MARKDOWN = re.compile(
)
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
_MARKDOWN_IMAGE = re.compile(r"!\[([^\]\n]*)\]\(([^)\s]+)\)")
_REDIRECT_CODES = {301, 302, 303, 307, 308}
_MAX_REDIRECTS = 3
_MAX_REMOTE_IMAGE_BYTES = 10 * 1024 * 1024
def contains_markdown(text: str) -> bool:
@@ -44,3 +53,166 @@ def build_text_delivery(text: str, enabled: bool = True) -> Tuple[str, str]:
if enabled and contains_markdown(text):
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
return "text", json.dumps({"text": text}, ensure_ascii=False)
def resolve_markdown_images(
text: str,
uploader: Callable[[str], Optional[str]],
max_images: int = 5,
) -> str:
"""Replace remote Markdown image URLs with Feishu image keys."""
cache = {}
uploaded = 0
def replace(match):
nonlocal uploaded
alt = match.group(1).strip() or "image"
target = match.group(2).strip()
if target.startswith("img_"):
return match.group(0)
if urlparse(target).scheme not in ("http", "https"):
return match.group(0)
if target not in cache:
if uploaded >= max_images:
cache[target] = None
else:
uploaded += 1
try:
cache[target] = uploader(target)
except Exception:
cache[target] = None
image_key = cache[target]
if image_key:
return "![{}]({})".format(alt, image_key)
return "[Image unavailable: {}]".format(alt)
return _MARKDOWN_IMAGE.sub(replace, text or "")
def validate_public_image_url(url: str) -> None:
"""Reject non-HTTP and non-public image targets before downloading."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("unsupported image URL scheme")
if not parsed.hostname:
raise ValueError("image URL has no hostname")
try:
literal_address = ipaddress.ip_address(parsed.hostname)
resolved_addresses = [literal_address]
except ValueError:
try:
addresses = socket.getaddrinfo(
parsed.hostname,
parsed.port,
socket.AF_UNSPEC,
socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise ValueError("cannot resolve image hostname") from exc
resolved_addresses = [ipaddress.ip_address(item[4][0]) for item in addresses]
for address in resolved_addresses:
if (
address.is_private
or address.is_loopback
or address.is_link_local
or address.is_reserved
or address.is_multicast
or address.is_unspecified
):
raise ValueError("image URL resolves to a non-public address")
def download_public_image(
url: str,
get=requests.get,
max_bytes: int = _MAX_REMOTE_IMAGE_BYTES,
) -> Tuple[bytes, str]:
"""Download a public image with redirect, type, and size checks."""
current = url
for _ in range(_MAX_REDIRECTS + 1):
validate_public_image_url(current)
response = get(
current,
headers={"User-Agent": "CowAgent/Feishu"},
timeout=(5, 15),
allow_redirects=False,
stream=True,
)
if response.status_code in _REDIRECT_CODES:
location = response.headers.get("Location")
response.close()
if not location:
raise ValueError("image redirect has no location")
current = urljoin(current, location)
continue
if response.status_code != 200:
response.close()
raise ValueError("image download returned HTTP {}".format(response.status_code))
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
if not content_type.startswith("image/"):
response.close()
raise ValueError("remote resource is not an image")
try:
content_length = int(response.headers.get("Content-Length") or 0)
except (TypeError, ValueError):
content_length = 0
if content_length > max_bytes:
response.close()
raise ValueError("remote image is too large")
chunks = []
downloaded = 0
try:
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
downloaded += len(chunk)
if downloaded > max_bytes:
raise ValueError("remote image is too large")
chunks.append(chunk)
finally:
response.close()
return b"".join(chunks), content_type
raise ValueError("too many image redirects")
def upload_public_image_to_feishu(
url: str,
access_token: str,
post=requests.post,
) -> Optional[str]:
"""Download a public image and upload its bytes to Feishu."""
payload, content_type = download_public_image(url)
extension = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
}.get(content_type, "img")
response = post(
"https://open.feishu.cn/open-apis/im/v1/images",
headers={"Authorization": "Bearer " + access_token},
data={"image_type": "message"},
files={
"image": (
"markdown-image.{}".format(extension),
payload,
content_type,
)
},
timeout=(5, 15),
)
body = response.json()
if body.get("code") != 0:
return None
return (body.get("data") or {}).get("image_key")

View File

@@ -104,7 +104,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| Voice messages | ✅ send/receive |
| Quoted replies | ✅ quoted text and rich-post context |
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
| Markdown card | ✅ for non-streaming and scheduled replies |
| Markdown card | ✅ remote images are uploaded to Feishu for static and final streaming cards |
| Rich progress card | ✅ status header, reasoning/tool panels and elapsed time (enable with `feishu_progress_card: true`, off by default) |
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |

View File

@@ -101,7 +101,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| 音声メッセージ | ✅ 送受信 |
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
| ストリーミング応答 | ✅Feishu cardkit ストリーミングカードベース) |
| Markdown カード | ✅ ストリーミング応答と定期配信 |
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |

View File

@@ -105,7 +105,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| 语音消息 | ✅ 收发 |
| 引用回复 | ✅ 引用文本与富文本上下文 |
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |

View File

@@ -0,0 +1,151 @@
import socket
import pytest
from channel.feishu import feishu_static_card
class FakeResponse:
def __init__(self, status_code=200, headers=None, chunks=None):
self.status_code = status_code
self.headers = headers or {}
self._chunks = chunks or []
self.closed = False
def iter_content(self, chunk_size=8192):
yield from self._chunks
def close(self):
self.closed = True
def json(self):
return getattr(self, "json_body", {})
def test_remote_markdown_images_are_uploaded_once_and_replaced_with_image_keys():
calls = []
def upload(url):
calls.append(url)
return "img_v2_chart"
markdown = (
"Before\n![chart](https://cdn.example.com/chart.png)\n"
"Again ![same](https://cdn.example.com/chart.png)"
)
result = feishu_static_card.resolve_markdown_images(markdown, upload)
assert result == (
"Before\n![chart](img_v2_chart)\nAgain ![same](img_v2_chart)"
)
assert calls == ["https://cdn.example.com/chart.png"]
def test_existing_feishu_image_keys_and_regular_links_are_untouched():
def unexpected(_url):
raise AssertionError("uploader should not be called")
markdown = "![ready](img_v2_existing) [docs](https://example.com/docs)"
assert feishu_static_card.resolve_markdown_images(markdown, unexpected) == markdown
def test_failed_remote_image_upload_degrades_to_readable_alt_text():
markdown = "Result: ![latency chart](https://cdn.example.com/chart.png)"
result = feishu_static_card.resolve_markdown_images(markdown, lambda _url: None)
assert result == "Result: [Image unavailable: latency chart]"
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1/image.png",
"http://169.254.169.254/latest/meta-data",
"http://[::1]/image.png",
],
)
def test_public_image_guard_rejects_non_public_literal_addresses(url):
with pytest.raises(ValueError, match="non-public"):
feishu_static_card.validate_public_image_url(url)
def test_image_download_revalidates_redirect_targets(monkeypatch):
monkeypatch.setattr(
socket,
"getaddrinfo",
lambda *args, **kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))
],
)
redirect = FakeResponse(
status_code=302,
headers={"Location": "http://127.0.0.1/private.png"},
)
with pytest.raises(ValueError, match="non-public"):
feishu_static_card.download_public_image(
"https://example.com/image.png", get=lambda *args, **kwargs: redirect
)
assert redirect.closed is True
def test_image_download_enforces_content_type_and_size(monkeypatch):
monkeypatch.setattr(
socket,
"getaddrinfo",
lambda *args, **kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))
],
)
not_image = FakeResponse(
headers={"Content-Type": "text/html"},
chunks=[b"not an image"],
)
too_large = FakeResponse(
headers={"Content-Type": "image/png", "Content-Length": "11"},
chunks=[b"01234567890"],
)
with pytest.raises(ValueError, match="not an image"):
feishu_static_card.download_public_image(
"https://example.com/image.png", get=lambda *args, **kwargs: not_image
)
with pytest.raises(ValueError, match="too large"):
feishu_static_card.download_public_image(
"https://example.com/image.png",
get=lambda *args, **kwargs: too_large,
max_bytes=10,
)
def test_public_image_is_uploaded_to_feishu_without_a_temp_file(monkeypatch):
monkeypatch.setattr(
feishu_static_card,
"download_public_image",
lambda _url: (b"png-bytes", "image/png"),
)
response = FakeResponse()
response.json_body = {
"code": 0,
"data": {"image_key": "img_v2_uploaded"},
}
calls = []
def post(url, **kwargs):
calls.append((url, kwargs))
return response
image_key = feishu_static_card.upload_public_image_to_feishu(
"https://example.com/image.png", "tenant-token", post=post
)
assert image_key == "img_v2_uploaded"
assert calls[0][1]["headers"] == {"Authorization": "Bearer tenant-token"}
filename, payload, content_type = calls[0][1]["files"]["image"]
assert filename == "markdown-image.png"
assert payload == b"png-bytes"
assert content_type == "image/png"