fix(feishu): apply image referer and proxy policy

Refs #6879
This commit is contained in:
jxxghp
2026-10-04 12:23:08 +08:00
parent e21d68496f
commit 3f206171e7
5 changed files with 110 additions and 8 deletions

View File

@@ -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)

View File

@@ -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` 再执行快照测试及一次

View File

@@ -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 等基线均为零 |

View File

@@ -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",

View File

@@ -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"