diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py index 97b6a9a2..286a68e9 100644 --- a/channel/feishu/feishu_channel.py +++ b/channel/feishu/feishu_channel.py @@ -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", diff --git a/channel/feishu/feishu_static_card.py b/channel/feishu/feishu_static_card.py index 19de65b4..59e4686a 100644 --- a/channel/feishu/feishu_static_card.py +++ b/channel/feishu/feishu_static_card.py @@ -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") diff --git a/docs/channels/feishu.mdx b/docs/channels/feishu.mdx index cbd83618..8951ae0b 100644 --- a/docs/channels/feishu.mdx +++ b/docs/channels/feishu.mdx @@ -103,7 +103,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | Image messages | ✅ send/receive | | Voice messages | ✅ send/receive | | 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 | diff --git a/docs/ja/channels/feishu.mdx b/docs/ja/channels/feishu.mdx index 67cadedd..4b338657 100644 --- a/docs/ja/channels/feishu.mdx +++ b/docs/ja/channels/feishu.mdx @@ -100,7 +100,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` で一覧、有効化、無効化、削除 | diff --git a/docs/zh/channels/feishu.mdx b/docs/zh/channels/feishu.mdx index 386a1f32..b1ff2b4a 100644 --- a/docs/zh/channels/feishu.mdx +++ b/docs/zh/channels/feishu.mdx @@ -104,7 +104,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` 查看、启用、停用与删除 | diff --git a/tests/test_feishu_markdown_images.py b/tests/test_feishu_markdown_images.py new file mode 100644 index 00000000..a95644f5 --- /dev/null +++ b/tests/test_feishu_markdown_images.py @@ -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"