mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 13:47:15 +08:00
feat: render remote images in Feishu cards
This commit is contained in:
@@ -28,7 +28,11 @@ from bridge.context import ContextType
|
|||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
from channel.feishu.feishu_message import FeishuMessage
|
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_progress_card import FeishuProgressState
|
||||||
from channel.feishu.feishu_scheduler_card import (
|
from channel.feishu.feishu_scheduler_card import (
|
||||||
build_scheduler_card,
|
build_scheduler_card,
|
||||||
@@ -752,9 +756,19 @@ class FeiShuChanel(ChatChannel):
|
|||||||
content_key = "text"
|
content_key = "text"
|
||||||
prepared_content_json = None
|
prepared_content_json = None
|
||||||
if reply.type == ReplyType.TEXT:
|
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(
|
msg_type, prepared_content_json = build_text_delivery(
|
||||||
reply.content,
|
delivery_text,
|
||||||
enabled=conf().get("feishu_markdown_card", True),
|
enabled=markdown_cards_enabled,
|
||||||
)
|
)
|
||||||
elif reply.type == ReplyType.IMAGE_URL:
|
elif reply.type == ReplyType.IMAGE_URL:
|
||||||
# 图片上传
|
# 图片上传
|
||||||
@@ -1199,6 +1213,12 @@ class FeiShuChanel(ChatChannel):
|
|||||||
final_text = progress_state.current_text
|
final_text = progress_state.current_text
|
||||||
has_card = card_id[0] is not None
|
has_card = card_id[0] is not None
|
||||||
init_busy = init_in_flight[0]
|
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
|
context["feishu_streamed"] = True
|
||||||
|
|
||||||
if not has_card and not init_busy:
|
if not has_card and not init_busy:
|
||||||
@@ -1465,9 +1485,15 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if not cid:
|
if not cid:
|
||||||
return
|
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
|
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
||||||
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
# (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 ""
|
preview = preview_src[:30] if preview_src else ""
|
||||||
full_card = {
|
full_card = {
|
||||||
"schema": "2.0",
|
"schema": "2.0",
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"""Helpers for choosing the native Feishu delivery format for text replies."""
|
"""Helpers for choosing the native Feishu delivery format for text replies."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import re
|
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(
|
_BLOCK_MARKDOWN = re.compile(
|
||||||
@@ -10,6 +15,10 @@ _BLOCK_MARKDOWN = re.compile(
|
|||||||
)
|
)
|
||||||
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
|
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
|
||||||
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
_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:
|
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):
|
if enabled and contains_markdown(text):
|
||||||
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
||||||
return "text", json.dumps({"text": 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")
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| Image messages | ✅ send/receive |
|
| Image messages | ✅ send/receive |
|
||||||
| Voice messages | ✅ send/receive |
|
| Voice messages | ✅ send/receive |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| 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) |
|
| 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 |
|
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 画像メッセージ | ✅ 送受信 |
|
| 画像メッセージ | ✅ 送受信 |
|
||||||
| 音声メッセージ | ✅ 送受信 |
|
| 音声メッセージ | ✅ 送受信 |
|
||||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
|
||||||
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
|
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
|
||||||
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 图片消息 | ✅ 收发 |
|
| 图片消息 | ✅ 收发 |
|
||||||
| 语音消息 | ✅ 收发 |
|
| 语音消息 | ✅ 收发 |
|
||||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
|
||||||
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
|
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
|
||||||
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
||||||
|
|
||||||
|
|||||||
151
tests/test_feishu_markdown_images.py
Normal file
151
tests/test_feishu_markdown_images.py
Normal 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\n"
|
||||||
|
"Again "
|
||||||
|
)
|
||||||
|
|
||||||
|
result = feishu_static_card.resolve_markdown_images(markdown, upload)
|
||||||
|
|
||||||
|
assert result == (
|
||||||
|
"Before\n\nAgain "
|
||||||
|
)
|
||||||
|
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 = " [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: "
|
||||||
|
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user