mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 14:48:40 +08:00
@@ -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)
|
||||
|
||||
@@ -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` 再执行快照测试及一次
|
||||
|
||||
@@ -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 等基线均为零 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user