From 16723f03a40f7511fa69d775f307d5b66218cda1 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 20:12:05 +0800 Subject: [PATCH] fix(types): preserve Feishu notification identity boundaries --- app/modules/feishu/feishu.py | 119 ++++++++++-------- docs/refactor/optimization-checklist.md | 2 +- mypy.ini | 1 + .../fixtures/architecture/mypy-baseline.json | 9 -- tests/test_feishu_target_typing.py | 53 ++++++++ 5 files changed, 124 insertions(+), 60 deletions(-) create mode 100644 tests/test_feishu_target_typing.py diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index a2b0b0a18..cf764de25 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -59,8 +59,8 @@ class Feishu: FEISHU_VERIFICATION_TOKEN: Optional[str] = None, FEISHU_ENCRYPT_KEY: Optional[str] = None, name: Optional[str] = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """ 读取应用配置并启动后台长连接接收线程。 @@ -189,7 +189,7 @@ class Feishu: finally: self._ready.clear() - def _forward_to_message_chain(self, payload: dict) -> bool: + def _forward_to_message_chain(self, payload: JsonDict) -> bool: """ 将飞书入站消息转发到统一消息入口,复用现有交互主链。 """ @@ -254,7 +254,7 @@ class Feishu: return text, images, audio_refs, files @staticmethod - def _resolve_post_message_body(content: dict) -> Optional[dict]: + def _resolve_post_message_body(content: JsonDict) -> Optional[JsonDict]: """ 解析飞书富文本消息在事件和 webhook 结构中的正文节点。 """ @@ -275,7 +275,7 @@ class Feishu: return None @staticmethod - def _parse_post_element_text(element: dict) -> str: + def _parse_post_element_text(element: JsonDict) -> str: """ 将飞书富文本元素转换为消息链可消费的纯文本片段。 """ @@ -303,7 +303,7 @@ class Feishu: @staticmethod def _parse_post_message_content( - content: dict, + content: JsonDict, message_id: Optional[str] = None, ) -> Tuple[str, Optional[List[IncomingMessage.MessageImage]]]: """ @@ -455,7 +455,7 @@ class Feishu: name=action.get("name"), ) - payload = { + payload: JsonDict = { "type": "cardAction", "source": self._name, "message_id": context.get("open_message_id"), @@ -645,11 +645,11 @@ class Feishu: images = IncomingMessage.MessageImage.normalize_list(message.get("images")) audio_refs = None if isinstance(message.get("audio_refs"), list): - audio_refs = [str(item).strip() for item in message.get("audio_refs") if str(item).strip()] or None + audio_refs = [str(item).strip() for item in (message.get("audio_refs") or []) if str(item).strip()] or None files = None if isinstance(message.get("files"), list): normalized_files = [] - for item in message.get("files"): + for item in (message.get("files") or []): if isinstance(item, dict) and item.get("ref"): normalized_files.append(IncomingMessage.MessageAttachment(**item)) files = normalized_files or None @@ -690,7 +690,7 @@ class Feishu: def _resolve_target( self, - userid: Optional[str] = None, + userid: Optional[Union[str, int]] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, ) -> Optional[Tuple[str, str]]: @@ -702,7 +702,11 @@ class Feishu: :param receive_id_type: open_id、user_id 或 chat_id;未指定时依据已记录身份推断 :return: 接收标识与类型的二元组;无显式及默认目标返回 None """ - resolved_userid = (userid or "").strip() or None + user_value = userid or "" + if isinstance(user_value, int): + # 保留历史非零整数在目标解析处失败的异常类型与外层捕获边界。 + raise AttributeError("'int' object has no attribute 'strip'") + resolved_userid = user_value.strip() or None resolved_chat_id = (chat_id or "").strip() or None normalized_receive_id_type = (receive_id_type or "").strip() or None if not resolved_userid and not resolved_chat_id: @@ -740,11 +744,11 @@ class Feishu: def _send_with_fallback_broadcast( self, msg_type: str, - content: dict, - userid: Optional[str] = None, + content: JsonDict, + userid: Optional[Union[str, int]] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 按解析目标发送消息,无明确目标时发送给已互动会话。 @@ -918,7 +922,7 @@ class Feishu: text: Optional[str], text_size: str = "normal", margin: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 构造非空卡片文字区块,按需设置图片布局所需的外边距。 """ @@ -1038,16 +1042,16 @@ class Feishu: return str(callback_data).strip() if callback_data else None @staticmethod - def _card_actions(buttons: Optional[List[List[dict]]], margin: Optional[str] = None) -> List[dict]: + def _card_actions(buttons: Optional[List[List[JsonDict]]], margin: Optional[str] = None) -> List[JsonDict]: """ 将统一按钮结构转换为飞书卡片按钮配置。 """ if not buttons: return [] card_rows = [] - for row in buttons[:8]: + for button_row in buttons[:8]: columns = [] - for button in row[:3]: + for button in button_row[:3]: text = (button or {}).get("text") if not text: continue @@ -1094,14 +1098,14 @@ class Feishu: } ) if columns: - row = { + card_row: JsonDict = { "tag": "column_set", "flex_mode": "none", "columns": columns, } if margin: - row["margin"] = margin - card_rows.append(row) + card_row["margin"] = margin + card_rows.append(card_row) return card_rows def _build_card( @@ -1109,13 +1113,13 @@ class Feishu: title: Optional[str], text: Optional[str], link: Optional[str], - buttons: Optional[List[List[dict]]], + buttons: Optional[List[List[JsonDict]]], image_key: Optional[str] = None, ) -> Dict[str, Any]: """ 构建飞书交互卡片结构。 """ - elements: List[dict] = [] + elements: List[JsonDict] = [] if image_key: # 图文混合消息需要让图片贴近卡片顶部,避免先展示文字再露出海报。 elements.append( @@ -1169,7 +1173,7 @@ class Feishu: """ 构建支持 CardKit 流式更新的飞书卡片 JSON 2.0。 """ - elements: List[dict] = [] + elements: List[JsonDict] = [] title_content = self._escape_card_text(title).strip() if title else "" body_content = self._escape_card_text( self._strip_streaming_markdown_images(text) @@ -1235,11 +1239,11 @@ class Feishu: self, title: Optional[str], text: Optional[str], - userid: Optional[str] = None, + userid: Optional[Union[str, int]] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, original_message_id: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 发送或回复流式卡片,并记录后续更新需要的卡片标识与序号。 """ @@ -1276,7 +1280,7 @@ class Feishu: def _send_agent_streaming_images( self, image_urls: List[str], - userid: Optional[str] = None, + userid: Optional[Union[str, int]] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, sent_image_urls: Optional[List[str]] = None, @@ -1400,7 +1404,7 @@ class Feishu: ) return False - def _send_message(self, receive_id: str, receive_id_type: str, msg_type: str, content: dict) -> Optional[dict]: + def _send_message(self, receive_id: str, receive_id_type: str, msg_type: str, content: JsonDict) -> Optional[JsonDict]: """ 调用飞书 IM API 发送消息,并返回统一结果结构。 """ @@ -1439,9 +1443,9 @@ class Feishu: self, message_id: str, msg_type: str, - content: dict, + content: JsonDict, reply_in_thread: bool = False, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 按原消息回复,保持飞书会话中的引用关系。 """ @@ -1576,7 +1580,7 @@ class Feishu: chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, original_message_id: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 发送纯文本,按原消息回复或按目标投递。 @@ -1608,8 +1612,11 @@ class Feishu: if not result: return {"success": False} - result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get( - userid or "") or self._default_chat_id + result["chat_id"] = ( + result.get("chat_id") or chat_id + or (self._user_chat_mapping.get(userid or "") if isinstance(userid, str) or not userid else None) + or self._default_chat_id + ) return result def send_file( @@ -1622,7 +1629,7 @@ class Feishu: file_name: Optional[str] = None, receive_id_type: Optional[str] = None, original_message_id: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 发送本地图片卡片或普通文件,必要时补发说明。 @@ -1703,8 +1710,11 @@ class Feishu: if not result: return {"success": False} - result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get( - userid or "") or self._default_chat_id + result["chat_id"] = ( + result.get("chat_id") or chat_id + or (self._user_chat_mapping.get(userid or "") if isinstance(userid, str) or not userid else None) + or self._default_chat_id + ) return result def send_voice( @@ -1715,7 +1725,7 @@ class Feishu: caption: Optional[str] = None, receive_id_type: Optional[str] = None, original_message_id: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 上传并发送本地语音,可另外发送文字说明。 @@ -1764,18 +1774,21 @@ class Feishu: if not result: return {"success": False} - result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get( - userid or "") or self._default_chat_id + result["chat_id"] = ( + result.get("chat_id") or chat_id + or (self._user_chat_mapping.get(userid or "") if isinstance(userid, str) or not userid else None) + or self._default_chat_id + ) return result def send_notification( self, message: Message, - userid: Optional[str] = None, + userid: Optional[Union[str, int]] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, original_message_id: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 发送通知卡片,符合条件的 Agent 正文使用流式卡片。 @@ -1795,8 +1808,8 @@ class Feishu: ) if is_streaming_agent_text: try: - stream_image_urls = [] - if self._is_external_image_url(message.image): + stream_image_urls: List[str] = [] + if message.image and self._is_external_image_url(message.image): stream_image_urls.append(message.image) stream_image_urls.extend(self._extract_markdown_image_urls(message.text)) result = self._send_streaming_card_message( @@ -1812,8 +1825,11 @@ class Feishu: return {"success": False} if not result: return {"success": False} - result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get( - userid or "") or self._default_chat_id + result["chat_id"] = ( + result.get("chat_id") or chat_id + or (self._user_chat_mapping.get(userid or "") if isinstance(userid, str) or not userid else None) + or self._default_chat_id + ) sent_image_urls = self._send_agent_streaming_images( stream_image_urls, userid=userid, @@ -1851,8 +1867,11 @@ class Feishu: if not result: return {"success": False} - result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get( - userid or "") or self._default_chat_id + result["chat_id"] = ( + result.get("chat_id") or chat_id + or (self._user_chat_mapping.get(userid or "") if isinstance(userid, str) or not userid else None) + or self._default_chat_id + ) return result def _build_notification_cards(self, message: Message, image_key: Optional[str]) -> List[JsonDict]: @@ -1885,7 +1904,7 @@ class Feishu: ) for index, text in enumerate(texts)] def edit_message(self, message_id: str, title: Optional[str] = None, text: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, metadata: Optional[dict] = None, + buttons: Optional[List[List[JsonDict]]] = None, metadata: Optional[JsonDict] = None, chat_id: Optional[str] = None) -> bool: """ 更新普通交互卡片或继续写入流式正文。 @@ -2007,7 +2026,7 @@ class Feishu: userid: Optional[str] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 将前 10 个媒体候选项渲染为通知卡片。 @@ -2048,7 +2067,7 @@ class Feishu: userid: Optional[str] = None, chat_id: Optional[str] = None, receive_id_type: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[JsonDict]: """ 将前 10 个种子候选项渲染为通知卡片。 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index ca5425d79..468212205 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -108,7 +108,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 373,625 行 | 本次重新扫描物理行数,排除 `app/plugins/**`;74 个文件超过 1,000 行,12 个超过 2,000 行;源码尺寸仅作观察 | | 长方法 | 309 个超过 80 行 | 本次 AST 扫描排除 `app/plugins/**`;61 个超过 150 行,17 个超过 250 行;新增函数仍受 C901/嵌套深度门禁约束 | -| 全量 mypy 历史债务 | 4,220 / 247 文件 | 本分支为 Pydantic 字段补充描述并保留默认值,减少 3 项 assignment 错误;本次 NFO 返回类型与契约对齐,减少 2 项历史类型错误;本轮在消息、站点、诊断、备份、用户配置、订阅、AcoustID、TMDB、仪表板和命令生命周期边界补齐类型,历史债务再减少 53 项;TMDB v3 API 显式导出并保留集合 parts 的列表合同,逐文件逐错误码无增长;此前工作流筛选、动态插件路由和 stdio 流补齐边界类型,历史债务减少 3 项;Bitpt 与 CookieCloud 补齐边界类型,历史债务减少 16 项;知行站点解析器补齐数值属性与基类合同,历史债务减少 18 项;通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项;本批清理 Servarr、认证、存储、订阅策略和搜索/规则/通知 Schema 的 18 项类型错误,并修复系统配置中的 Python 2 异常语法;本批再清理订阅元数据、订阅写入、交互宿主、整理路径、删除统计和工作流动作的 14 项类型错误,逐文件逐错误码无增长;本批补齐 OCR 请求参数、工具 Schema 摘要和宿主模块配置快照的 5 项边界类型,逐文件逐错误码无增长;本批为 Rousi 用户消息分页解析补齐返回类型与数据结构兜底,减少 2 项类型错误;本批优先处理 TMDB v3 电影与电视剧对象模型,补齐 98 个同步/异步方法注解并减少 122 项历史类型错误;随后补齐剧集、季度、人物、搜索和趋势对象的 142 个方法注解,再减少 142 项类型错误;绿联 API 响应边界补齐具体泛型与运行时字典收窄,减少 31 项类型错误;运行时限流器补齐状态方法、装饰器边界和时间队列类型,减少 31 项类型错误;QQ Bot API 响应边界补齐具体泛型与 JSON 形状校验,减少 17 项类型错误;rTorrent 客户端清理 182 项错误,调用端同步明确字典合同,累计再减少 190 项错误;豆瓣 API、中心服务接口及订阅消费端补齐返回边界和参数收窄,结合 Context 方法签名与缓存代理类型清理,累计再减少 455 项错误;共享 HTTP 客户端清理 110 项错误,TMDB API 清理 84 项错误,索引器爬虫清理 87 项错误,并补充 U115、AliPan、OpenList 对可空流响应的防守;U115 其余存储基类契约债务暂留待测试完备后处理;缓存同步/异步继承冲突保留为设计债务,未纳入严格门禁 | +| 全量 mypy 历史债务 | 4,187 / 246 文件 | 本分支为 Pydantic 字段补充描述并保留默认值,减少 3 项 assignment 错误;本次 NFO 返回类型与契约对齐,减少 2 项历史类型错误;本轮在消息、站点、诊断、备份、用户配置、订阅、AcoustID、TMDB、仪表板和命令生命周期边界补齐类型,历史债务再减少 53 项;TMDB v3 API 显式导出并保留集合 parts 的列表合同,逐文件逐错误码无增长;此前工作流筛选、动态插件路由和 stdio 流补齐边界类型,历史债务减少 3 项;Bitpt 与 CookieCloud 补齐边界类型,历史债务减少 16 项;知行站点解析器补齐数值属性与基类合同,历史债务减少 18 项;通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项;本批清理 Servarr、认证、存储、订阅策略和搜索/规则/通知 Schema 的 18 项类型错误,并修复系统配置中的 Python 2 异常语法;本批再清理订阅元数据、订阅写入、交互宿主、整理路径、删除统计和工作流动作的 14 项类型错误,逐文件逐错误码无增长;本批补齐 OCR 请求参数、工具 Schema 摘要和宿主模块配置快照的 5 项边界类型,逐文件逐错误码无增长;本批为 Rousi 用户消息分页解析补齐返回类型与数据结构兜底,减少 2 项类型错误;本批优先处理 TMDB v3 电影与电视剧对象模型,补齐 98 个同步/异步方法注解并减少 122 项历史类型错误;随后补齐剧集、季度、人物、搜索和趋势对象的 142 个方法注解,再减少 142 项类型错误;绿联 API 响应边界补齐具体泛型与运行时字典收窄,减少 31 项类型错误;运行时限流器补齐状态方法、装饰器边界和时间队列类型,减少 31 项类型错误;QQ Bot API 响应边界补齐具体泛型与 JSON 形状校验,减少 17 项类型错误;rTorrent 客户端清理 182 项错误,调用端同步明确字典合同,累计再减少 190 项错误;豆瓣 API、中心服务接口及订阅消费端补齐返回边界和参数收窄,结合 Context 方法签名与缓存代理类型清理,累计再减少 455 项错误;共享 HTTP 客户端清理 110 项错误,TMDB API 清理 84 项错误,索引器爬虫清理 87 项错误,并补充 U115、AliPan、OpenList 对可空流响应的防守;U115 其余存储基类契约债务暂留待测试完备后处理;缓存同步/异步继承冲突保留为设计债务,未纳入严格门禁 | | Ruff 历史诊断 | 73 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率逐文件低水位 | 全 app(排除 app/plugins)当前聚合 77.68% | 存量文件比例不降;语句减少且未覆盖行数不增时允许比例变化,新文件至少 80% | diff --git a/mypy.ini b/mypy.ini index 46ae0fda2..3899bedeb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,6 +7,7 @@ follow_imports = skip show_error_codes = True pretty = True files = + app/modules/feishu/feishu.py, app/modules/emby/emby.py, app/modules/discord/discord.py, app/modules/zspace/zspace.py, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index c0403c802..3c9fc7971 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -828,15 +828,6 @@ "type-arg": 9, "var-annotated": 1 }, - "app/modules/feishu/feishu.py": { - "arg-type": 5, - "assignment": 1, - "call-overload": 1, - "no-untyped-def": 1, - "return-value": 1, - "type-arg": 22, - "union-attr": 2 - }, "app/modules/feishu/module.py": { "assignment": 6, "attr-defined": 1, diff --git a/tests/test_feishu_target_typing.py b/tests/test_feishu_target_typing.py new file mode 100644 index 000000000..6f62fa522 --- /dev/null +++ b/tests/test_feishu_target_typing.py @@ -0,0 +1,53 @@ +""" +飞书列表通知保留用户标识的历史目标解析与异常边界。 +""" + +from unittest.mock import Mock + +import pytest + +from app.modules.feishu.feishu import Feishu +from app.schemas.message import Message + + +@pytest.mark.parametrize("method", ["send_medias_message", "send_torrents_message"]) +@pytest.mark.parametrize("userid, expected_target", [("ou_user", "ou_user"), (0, "ou_default"), (12, None)]) +def test_list_notification_preserves_userid_target_boundary(method, userid, expected_target): + """ + 字符串发往用户,零值沿用默认目标,非零整数在通知内部捕获失败而不外抛。 + """ + client = Feishu.__new__(Feishu) + client._default_open_id = "ou_default" + client._default_chat_id = None + client._user_receive_id_type_mapping = {} + client._user_chat_mapping = {} + client._upload_remote_image = Mock(return_value=None) + client._build_notification_cards = Mock(return_value=[{"schema": "2.0"}]) + client._send_message = Mock(return_value={"success": True, "chat_id": "oc_chat"}) + + result = getattr(client, method)(Message(title="测试", userid=userid), []) + + if expected_target is None: + assert result == {"success": False} + client._send_message.assert_not_called() + else: + assert result["success"] is True + assert client._send_message.call_args.args[:2] == (expected_target, "open_id") + + +def test_reply_does_not_resolve_integer_userid(): + """ + 明确回复原消息时不解析用户标识,非零整数仍允许成功回复。 + """ + client = Feishu.__new__(Feishu) + client._default_chat_id = None + client._user_chat_mapping = {} + client._upload_remote_image = Mock(return_value=None) + client._build_notification_cards = Mock(return_value=[{"schema": "2.0"}]) + client._reply_message = Mock(return_value={"success": True, "chat_id": "oc_reply"}) + client._resolve_target = Mock(side_effect=AssertionError("回复不可解析目标")) + + result = client.send_notification(Message(title="测试"), userid=12, original_message_id="om_reply") + + assert result == {"success": True, "chat_id": "oc_reply"} + client._resolve_target.assert_not_called()