From 3f206171e7962be4ae901682c266385e1fcc1c1a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 4 Oct 2026 12:23:08 +0800 Subject: [PATCH] fix(feishu): apply image referer and proxy policy Refs #6879 --- app/modules/feishu/feishu.py | 30 +++++++- docs/architecture-overview.md | 4 +- docs/refactor/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 5 +- tests/test_feishu_media_message.py | 77 +++++++++++++++++++ 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index 67602629c..9a8722ff3 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -4,11 +4,12 @@ import tempfile import threading import uuid from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast from urllib.parse import urlparse from app.adapters.network.feishu import FeishuLongConnection from app.adapters.network.http import RequestUtils +from app.adapters.network.ip import IpUtils from app.application.messaging.channel.admin import matches_channel_admin from app.application.messaging.ingress import submit_message_to_host from app.application.security.user import get_configured_user_channel_lookup @@ -794,6 +795,7 @@ class Feishu: text_size: str = "normal", margin: Optional[str] = None, ) -> Optional[dict]: + """构造非空卡片文字区块,按需设置图片布局所需的外边距。""" content = cls._escape_card_text(text).strip() if not content: return None @@ -841,7 +843,7 @@ class Feishu: return ".jpg" def _upload_remote_image(self, image_url: Optional[str]) -> Optional[str]: - """下载远程图片并上传到飞书,返回可用于卡片的 image_key。""" + """按图片来源配置防盗链与代理,上传后返回飞书卡片所需的 image_key。""" image_url = (image_url or "").strip() if not image_url: return None @@ -852,7 +854,19 @@ class Feishu: response = None temp_path = None try: - response = RequestUtils(timeout=30, ua=get_runtime_setting('USER_AGENT')).get_res(image_url) + # 与统一取图策略一致:豆瓣需要 Referer,豆瓣和内网图片均保持直连。 + referer = "https://movie.douban.com/" if "doubanio.com" in image_url else None + proxies = get_runtime_setting('PROXY') + is_internal = cast(Callable[[str], bool], IpUtils.is_internal) + if referer or (proxies and is_internal(image_url)): + proxies = None + request_options: Dict[str, Any] = { + "timeout": 30, + "ua": get_runtime_setting('USER_AGENT'), + "referer": referer, + "proxies": proxies, + } + response = RequestUtils(**request_options).get_res(image_url) if not response or not getattr(response, "content", None): logger.warning(f"飞书图片下载失败:{image_url}") return None @@ -1059,6 +1073,7 @@ class Feishu: } def _create_streaming_card(self, title: Optional[str], text: Optional[str]) -> Optional[str]: + """创建支持后续增量编辑的飞书卡片并返回卡片标识。""" if not self._api_client: return None response = self._api_client.create_card( @@ -1083,6 +1098,7 @@ class Feishu: receive_id_type: Optional[str] = None, original_message_id: Optional[str] = None, ) -> Optional[dict]: + """发送或回复流式卡片,并记录后续更新需要的卡片标识与序号。""" card_id = self._create_streaming_card(title=title, text=text) if not card_id: return None @@ -1159,6 +1175,7 @@ class Feishu: content: str, sequence: int, ) -> bool: + """按调用方维护的递增序号更新卡片文字,空内容使用飞书允许的占位。""" if not self._api_client: return False response = self._api_client.update_card_element_content( @@ -1183,6 +1200,7 @@ class Feishu: return False def close_streaming_card(self, card_id: str, sequence: int) -> bool: + """关闭卡片的流式展示状态,保留已发送的最终内容。""" if not self._api_client or not card_id: return False response = self._api_client.update_card_settings( @@ -1280,6 +1298,7 @@ class Feishu: @staticmethod def _guess_file_type(file_path: Path) -> str: + """映射飞书支持的文件类型,其余扩展名按通用文件上传。""" suffix = file_path.suffix.lower().lstrip(".") if suffix == "opus": return "opus" @@ -1290,6 +1309,7 @@ class Feishu: return "stream" def _upload_image(self, file_path: Path) -> Optional[str]: + """上传本地图片,返回消息与卡片可引用的图片标识。""" if not self._api_client: return None response = self._api_client.upload_image(file_path) @@ -1305,6 +1325,7 @@ class Feishu: def _upload_file(self, file_path: Path, file_name: Optional[str] = None, duration: Optional[int] = None) -> \ Optional[str]: + """上传文件及其展示元数据,返回可用于发送消息的文件标识。""" if not self._api_client: return None response = self._api_client.upload_file( @@ -1324,17 +1345,20 @@ class Feishu: return response.data.get("file_key") def download_image_bytes(self, image_key: str) -> Optional[Tuple[bytes, Optional[str], Optional[str]]]: + """按飞书图片标识下载内容及响应中的文件名、媒体类型。""" if not self._api_client or not image_key: return None return self._api_client.download_image(image_key) def download_file_bytes(self, file_key: str) -> Optional[Tuple[bytes, Optional[str], Optional[str]]]: + """按飞书文件标识下载内容及响应中的文件名、媒体类型。""" if not self._api_client or not file_key: return None return self._api_client.download_file(file_key) def download_message_resource_bytes(self, message_id: str, file_key: str, resource_type: str) -> Optional[ Tuple[bytes, Optional[str], Optional[str]]]: + """下载指定消息关联的图片或文件,返回内容、文件名和媒体类型。""" if not self._api_client or not message_id or not file_key: return None return self._api_client.download_message_resource(message_id, file_key, resource_type) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index b341aa544..19eece540 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -763,7 +763,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1104 | -| 内部导入边 | 9,303 | +| 内部导入边 | 9,304 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 49(债务已清零,49 条精确 containment) | @@ -773,7 +773,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,303,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 578、`app.factory` 为 591、`app.main` 为 593。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,304,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 578、`app.factory` 为 591、`app.main` 为 593。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 429952390..8d1f0eb6a 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -98,7 +98,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1104 / 9,303 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | +| 宿主 Python 模块 / 内部依赖边 | 1104 / 9,304 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index b1f579a05..923d755f1 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -941,8 +941,8 @@ "runtime_only": true } }, - "edge_count": 9303, - "edge_sha256": "73ee857b8715ddf49ce9316f23c3f0252342c07d80de6e5baeb04eb7fa56c95b", + "edge_count": 9304, + "edge_sha256": "c5124c9dfe6371833ac0b442f91fb11cc55e5d88c4b8f08a7aae1deef50c0546", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -7028,6 +7028,7 @@ "app.modules.feishu.feishu -> app.adapters.network", "app.modules.feishu.feishu -> app.adapters.network.feishu", "app.modules.feishu.feishu -> app.adapters.network.http", + "app.modules.feishu.feishu -> app.adapters.network.ip", "app.modules.feishu.feishu -> app.application", "app.modules.feishu.feishu -> app.application.messaging", "app.modules.feishu.feishu -> app.application.messaging.channel", diff --git a/tests/test_feishu_media_message.py b/tests/test_feishu_media_message.py index 9226633ff..4e25407d0 100644 --- a/tests/test_feishu_media_message.py +++ b/tests/test_feishu_media_message.py @@ -1,5 +1,10 @@ +import json +from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch +import pytest + from app.testing.bootstrap import ensure_optional_stub ensure_optional_stub("psutil") @@ -8,8 +13,11 @@ ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False) from app.domain.context import MediaInfo # noqa: E402 from app.modules.feishu.feishu import Feishu # noqa: E402 +from app.modules.feishu.openapi import FeishuResponse # noqa: E402 from app.schemas.message import Message # noqa: E402 +_IMAGE_PROXY = {"http": "http://proxy.example:8080", "https": "http://proxy.example:8080"} + def _build_feishu_client() -> Feishu: """构造不会启动真实飞书长连接的测试客户端。""" @@ -48,3 +56,72 @@ def test_send_medias_message_passes_first_available_image() -> None: assert proxy_message.image == "https://example.com/poster.jpg" assert proxy_message.text == "1. 无海报媒体\n2. 有海报媒体" assert send_notification.call_args.kwargs["userid"] == "ou_test" + + +@pytest.mark.parametrize( + ("image_url", "proxy", "internal", "expected_referer", "expected_proxy"), + [ + ( + "https://img9.doubanio.com/view/photo/l/public/poster.webp", + _IMAGE_PROXY, False, "https://movie.douban.com/", None, + ), + ( + "https://img3.doubanio.com/view/photo/l/public/poster.webp", + None, False, "https://movie.douban.com/", None, + ), + ("https://image.tmdb.org/t/p/w500/poster.jpg", _IMAGE_PROXY, False, None, _IMAGE_PROXY), + ("https://image.tmdb.org/t/p/w500/poster.jpg", None, False, None, None), + ("http://192.168.1.2:8096/poster", _IMAGE_PROXY, True, None, None), + ("http://media.local:8096/poster", _IMAGE_PROXY, True, None, None), + ], + ids=["douban-proxy", "douban-direct", "tmdb-proxy", "tmdb-direct", "lan-ip", "lan-domain"], +) +def test_send_notification_downloads_image_with_source_policy( + image_url: str, + proxy: Optional[dict[str, str]], + internal: bool, + expected_referer: Optional[str], + expected_proxy: Optional[dict[str, str]], +) -> None: + """海报下载遵守豆瓣防盗链、外网代理和内网直连策略,并正常上传和清理。""" + client = _build_feishu_client() + uploaded = {} + response = MagicMock(content=b"poster-bytes", headers={"Content-Type": "image/webp"}) + + def upload_image(file_path: Path) -> FeishuResponse: + """在临时文件删除前读取载荷,模拟飞书返回图片标识。""" + uploaded["path"] = file_path + uploaded["content"] = file_path.read_bytes() + return FeishuResponse(code=0, msg="success", data={"image_key": "img_poster"}) + + client._api_client.upload_image.side_effect = upload_image + client._api_client.create_message.return_value = FeishuResponse( + code=0, msg="success", data={"message_id": "om_test", "chat_id": "oc_test"}, + ) + with ( + patch("app.modules.feishu.feishu.RequestUtils") as request_utils, + patch("app.modules.feishu.feishu.get_runtime_setting", side_effect={ + "USER_AGENT": "MoviePilot/Test", "PROXY": proxy, + }.get), + patch("app.adapters.network.ip.IpUtils.is_internal", return_value=internal), + ): + request_utils.return_value.get_res.return_value = response + result = client.send_notification( + Message(title="订阅完成", text="已入库", image=image_url), userid="ou_test", + ) + + assert result["success"] + request_utils.assert_called_once() + options = request_utils.call_args.kwargs + assert options.get("referer") == expected_referer + assert options.get("proxies") == expected_proxy + assert options["ua"] == "MoviePilot/Test" + assert options["timeout"] == 30 + request_utils.return_value.get_res.assert_called_once_with(image_url) + response.close.assert_called_once() + client._api_client.upload_image.assert_called_once() + assert uploaded["content"] == b"poster-bytes" + assert uploaded["path"].suffix == ".webp" + assert not uploaded["path"].exists() + content = json.loads(client._api_client.create_message.call_args.kwargs["content"]) + assert content["body"]["elements"][0]["img_key"] == "img_poster"