fix(types): preserve Feishu notification identity boundaries

This commit is contained in:
jxxghp
2026-10-17 20:12:05 +08:00
parent a03e620c27
commit 16723f03a4
5 changed files with 124 additions and 60 deletions

View File

@@ -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 个种子候选项渲染为通知卡片。

View File

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

View File

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

View File

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

View File

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