diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index cf764de25..04b8b4ac4 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -19,6 +19,7 @@ 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 from app.domain.context import Context, MediaInfo +from app.foundation import size as size_tools from app.modules.feishu.openapi import FEISHU_DOMAIN, FeishuOpenApi, FeishuResponse, JsonDict from app.runtime.log import logger from app.runtime.settings import get_runtime_setting @@ -2071,6 +2072,8 @@ class Feishu: """ 将前 10 个种子候选项渲染为通知卡片。 + 每项显示站点、标题、大小和做种数;缺失或为零的详情不展示,保留输入顺序与选择序号。 + :param message: 原通知标题、按钮、链接和目标 :param torrents: 含种子信息的候选上下文 :param userid: 显式用户标识;未提供用户和会话时回退到默认配置或已互动会话 @@ -2082,7 +2085,16 @@ class Feishu: for index, torrent in enumerate(torrents[:10], start=1): torrent_info = getattr(torrent, "torrent_info", None) title = getattr(torrent_info, "title", None) or getattr(torrent_info, "site_name", None) or "未知种子" - lines.append(f"{index}. {title}") + site_name = getattr(torrent_info, "site_name", None) + size = getattr(torrent_info, "size", None) + seeders = getattr(torrent_info, "seeders", None) + site_prefix = f"【{site_name}】" if site_name else " " + details = [f"{index}.{site_prefix}{title}"] + if size: + details.append(size_tools.format_compact_size(size)) + if seeders: + details.append(f"{seeders}↑") + lines.append(" ".join(details)) proxy_message = Message( title=message.title, text="\n".join(lines), diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 232cc0886..7b7b00761 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -817,7 +817,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1127 | -| 内部导入边 | 9,640 | +| 内部导入边 | 9,642 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 2f834dbac..cde42e3e7 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -975,8 +975,8 @@ "runtime_only": true } }, - "edge_count": 9640, - "edge_sha256": "e282d6307ea8c6f0cd2843f5c2faae04995bc2630c945e408d1dd490e337fcfe", + "edge_count": 9642, + "edge_sha256": "bace4b8791f142f8d9fdc9e12f532c562d7eec5bed42adb2d07bdb9c8ec8251a", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -7323,6 +7323,8 @@ "app.modules.feishu.feishu -> app.application.security.user", "app.modules.feishu.feishu -> app.domain", "app.modules.feishu.feishu -> app.domain.context", + "app.modules.feishu.feishu -> app.foundation", + "app.modules.feishu.feishu -> app.foundation.size", "app.modules.feishu.feishu -> app.modules", "app.modules.feishu.feishu -> app.modules.feishu", "app.modules.feishu.feishu -> app.modules.feishu.openapi", diff --git a/tests/test_feishu_torrents_message.py b/tests/test_feishu_torrents_message.py new file mode 100644 index 000000000..6a19c115a --- /dev/null +++ b/tests/test_feishu_torrents_message.py @@ -0,0 +1,95 @@ +""" +验证飞书资源选择卡片的种子详情、缺失字段和选择按钮。 +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from app.domain.context import Context +from app.modules.feishu.feishu import Feishu +from app.modules.feishu.openapi import FeishuOpenApi, FeishuResponse +from app.schemas.context import TorrentInfo +from app.schemas.message import Message + + +def _build_client() -> tuple[Feishu, MagicMock]: + """ + 构造隔离长连接与开放平台请求的客户端,保留真实卡片渲染路径。 + """ + api = MagicMock(spec=FeishuOpenApi) + api.create_message.return_value = FeishuResponse( + code=0, msg="success", data={"message_id": "om_test", "chat_id": "oc_test"}, + ) + with ( + patch.object(Feishu, "_build_api_client", return_value=api), + patch.object(Feishu, "_start_ws_client"), + ): + client = Feishu(FEISHU_APP_ID="test_app", FEISHU_APP_SECRET="test_secret") + return client, api + + +@pytest.mark.parametrize( + ("fields", "expected"), + [ + ({"site_name": "站点", "size": 2.5 * 1024 ** 3, "seeders": 35}, "1.【站点】影片 2.5G 35↑"), + ({"site_name": "站点", "size": 2.5 * 1024 ** 3}, "1.【站点】影片 2.5G"), + ({"site_name": "站点", "seeders": 35}, "1.【站点】影片 35↑"), + ({"size": 2.5 * 1024 ** 3, "seeders": 35}, "1. 影片 2.5G 35↑"), + ({"site_name": "站点"}, "1.【站点】影片"), + ({"size": 2.5 * 1024 ** 3}, "1. 影片 2.5G"), + ({"seeders": 35}, "1. 影片 35↑"), + ({}, "1. 影片"), + ({"site_name": None, "size": None, "seeders": None}, "1. 影片"), + ({"site_name": "", "size": 0, "seeders": 0}, "1. 影片"), + ({"title": None, "site_name": "站点"}, "1.【站点】站点"), + ({"title": None}, "1. 未知种子"), + ], +) +def test_torrent_card_displays_available_details(fields: dict, expected: str) -> None: + """ + 实际发送的卡片显示可用站点、大小和做种数,省略空值且不附加促销文案。 + """ + client, api = _build_client() + torrent = TorrentInfo(**{"title": "影片", "volume_factor": "免费", **fields}) + + result = client.send_torrents_message( + Message(title="请选择下载"), [Context(torrent_info=torrent)], userid="ou_user", + ) + + assert result["success"] + card = json.loads(api.create_message.call_args.kwargs["content"]) + assert card["body"]["elements"][1]["content"] == expected + + +def test_torrent_card_keeps_selection_order_and_target() -> None: + """ + 列表仅展示前十项,缺失种子信息仍保留序号,接收会话、链接和下载按钮保持可用。 + """ + client, api = _build_client() + torrents = [Context()] + [ + Context(torrent_info=TorrentInfo(title=f"影片{index}", seeders=index)) + for index in range(2, 12) + ] + result = client.send_torrents_message( + Message( + title="请选择下载", link="https://example.com/search", + buttons=[[{"text": "下载第2项", "callback_data": "2"}]], + ), + torrents, chat_id="oc_target", receive_id_type="chat_id", + ) + + assert result["success"] + request = api.create_message.call_args.kwargs + assert request["receive_id"] == "oc_target" + assert request["receive_id_type"] == "chat_id" + card = json.loads(request["content"]) + content = card["body"]["elements"][1]["content"] + assert content.splitlines()[:10] == ["1. 未知种子"] + [ + f"{index}. 影片{index} {index}↑" for index in range(2, 11) + ] + assert "影片11" not in content + assert "https://example.com/search" in content + button = card["body"]["elements"][-1]["columns"][0]["elements"][0] + assert button["behaviors"] == [{"type": "callback", "value": {"callback_data": "2"}}]