From acfe477c661f9a4bfe394d2041461e2f4fde4ee9 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 20:07:14 +0800 Subject: [PATCH] fix(types): make Discord callbacks and receipts explicit --- app/modules/discord/discord.py | 78 +++++++++++-------- docs/refactor/optimization-checklist.md | 2 +- mypy.ini | 1 + .../fixtures/architecture/mypy-baseline.json | 10 --- tests/test_discord_typing_contracts.py | 68 ++++++++++++++++ 5 files changed, 115 insertions(+), 44 deletions(-) create mode 100644 tests/test_discord_typing_contracts.py diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 661198ffb..a5a0244d1 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -56,8 +56,8 @@ class Discord: DISCORD_BOT_TOKEN: Optional[str] = None, DISCORD_GUILD_ID: Optional[Union[str, int]] = None, DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """ 创建客户端和专用事件循环,注册回调并启动后台线程。 @@ -89,7 +89,7 @@ class Discord: intents.messages = True intents.guilds = True - self._client: Optional[discord.Client] = discord.Client( + self._client: discord.Client = discord.Client( intents=intents, proxy=get_runtime_setting('PROXY_HOST') ) self._tree: Optional[app_commands.CommandTree] = app_commands.CommandTree(self._client) @@ -104,7 +104,7 @@ class Discord: ] = {} self._broadcast_channel = None self._bot_user_id: Optional[int] = None - self._typing_tasks: Dict[str, asyncio.Task] = {} + self._typing_tasks: Dict[str, asyncio.Task[None]] = {} self._typing_stop_events: Dict[str, asyncio.Event] = {} self._typing_lifecycle_lock = asyncio.Lock() self._typing_accepting = True @@ -112,24 +112,26 @@ class Discord: self._typing_initial_delay_seconds = 1 self._typing_max_duration_seconds = 10 * 60 self._typing_stop_timeout_seconds = 1 - self._registered_commands: Optional[Dict[str, dict]] = None + self._registered_commands: Optional[Dict[str, dict[str, Any]]] = None self._register_events() self._start() @staticmethod def _to_int(val: Optional[Union[str, int]]) -> Optional[int]: + """ + 解析频道或服务器标识,空值和非法数字文本返回 None。 + """ try: return int(val) if val is not None and str(val).strip() else None except ValueError: return None - def _register_events(self): + def _register_events(self) -> None: """ 注册登录、普通消息和按钮交互回调,并维护原频道回复路由。 """ - @self._client.event - async def on_ready(): + async def on_ready() -> None: """ 标记登录就绪,并同步此前缓存的斜杠命令。 """ @@ -142,8 +144,9 @@ class Discord: except Exception as err: logger.error(f"同步 Discord 斜杠命令失败:{err}") - @self._client.event - async def on_message(message: discord.Message): + self._client.event(on_ready) + + async def on_message(message: discord.Message) -> None: """ 忽略机器人消息,将允许处理的消息及附件转交统一入口。 """ @@ -191,8 +194,9 @@ class Discord: ] await self._post_to_ds(payload) - @self._client.event - async def on_interaction(interaction: discord.Interaction): + self._client.event(on_message) + + async def on_interaction(interaction: discord.Interaction) -> None: """ 确认按钮交互,记录原频道并转交回调载荷。 """ @@ -236,21 +240,23 @@ class Discord: } await self._post_to_ds(payload) - def _start(self): + self._client.event(on_interaction) + + def _start(self) -> None: """ 启动并持有 Discord 客户端专用事件循环线程。 """ if self._thread: return - def runner(): + def runner() -> None: """ 在线程内运行客户端,并在退出前回收本实例拥有的异步任务。 """ loop = self._loop client = self._client asyncio.set_event_loop(loop) - start_task: Optional[asyncio.Task] = None + start_task: Optional[asyncio.Task[None]] = None try: if not self._stop_requested.is_set(): start_task = loop.create_task(client.start(self._token)) @@ -329,7 +335,7 @@ class Discord: """ return self._ready_event.is_set() and self._client is not None - def register_commands(self, commands: Dict[str, dict]) -> bool: + def register_commands(self, commands: Dict[str, dict[str, Any]]) -> bool: """ 注册 Discord 斜杠命令。 @@ -511,11 +517,11 @@ class Discord: image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, str]]]] = None, original_message_id: Optional[Union[int, str]] = None, original_chat_id: Optional[str] = None, mtype: Optional["MessageType"] = None, - ) -> Optional[bool]: + ) -> Union[bool, Tuple[bool, Optional[Dict[str, str]]]]: """ 在专用事件循环中发送或编辑通知,最多等待 30 秒。 @@ -578,7 +584,7 @@ class Discord: userid: Optional[str] = None, file_name: Optional[str] = None, original_chat_id: Optional[str] = None, - ) -> Optional[bool]: + ) -> Union[bool, Tuple[bool, Optional[Dict[str, str]]]]: """ 在专用事件循环中上传本地文件,最多等待 30 秒。 @@ -617,7 +623,7 @@ class Discord: medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, str]]]] = None, original_message_id: Optional[Union[int, str]] = None, original_chat_id: Optional[str] = None, ) -> Optional[bool]: @@ -659,7 +665,7 @@ class Discord: torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + buttons: Optional[List[List[dict[str, str]]]] = None, original_message_id: Optional[Union[int, str]] = None, original_chat_id: Optional[str] = None, ) -> Optional[bool]: @@ -943,9 +949,9 @@ class Discord: if error: logger.debug(f"Discord typing 任务异常结束:{error}") - for typing_key, task in owners.items(): - if task is None or task.done(): - if self._typing_tasks.get(typing_key) is task: + for typing_key, owned_task in owners.items(): + if owned_task is None or owned_task.done(): + if self._typing_tasks.get(typing_key) is owned_task: self._typing_tasks.pop(typing_key, None) self._typing_stop_events.pop(typing_key, None) @@ -977,7 +983,7 @@ class Discord: image: Optional[str], userid: Optional[str], link: Optional[str], - buttons: Optional[List[List[dict]]], + buttons: Optional[List[List[dict[str, str]]]], original_message_id: Optional[Union[int, str]], original_chat_id: Optional[str], mtype: Optional["MessageType"] = None, @@ -1094,8 +1100,8 @@ class Discord: self, embeds: List[discord.Embed], userid: Optional[str], - buttons: Optional[List[List[dict]]], - fallback_buttons: Optional[List[List[dict]]], + buttons: Optional[List[List[dict[str, str]]]], + fallback_buttons: Optional[List[List[dict[str, str]]]], original_message_id: Optional[Union[int, str]], original_chat_id: Optional[str], ) -> bool: @@ -1198,11 +1204,14 @@ class Discord: 仅下载、整理、订阅和手动通知解析“字段:值”;书名括号、URL 和时间中的冒号 保留为普通正文,避免错误切分。未提供详情链接时使用项目主页。 """ - fields: List[Dict[str, str]] = [] + fields: List[Dict[str, Union[str, bool]]] = [] desc_lines: List[str] = [] should_parse_fields = mtype in PARSE_FIELD_TYPES if mtype else False def _collect_spans(s: str, left: str, right: str) -> List[Tuple[int, int]]: + """ + 收集成对括号区间,供正文字段解析排除标题中的冒号。 + """ spans: List[Tuple[int, int]] = [] start = 0 while True: @@ -1216,7 +1225,10 @@ class Discord: start = r_idx + 1 return spans - def _find_colon_index(s: str, m: re.Match) -> Optional[int]: + def _find_colon_index(s: str, m: re.Match[str]) -> Optional[int]: + """ + 返回匹配字段中首个中英文冒号的位置。 + """ segment = s[m.start(): m.end()] for i, ch in enumerate(segment): if ch in (":", ":"): @@ -1366,11 +1378,11 @@ class Discord: return embeds @staticmethod - def _build_default_buttons(count: int) -> List[List[dict]]: + def _build_default_buttons(count: int) -> List[List[dict[str, str]]]: """ 为列表生成数字选择按钮,最多 5 行、每行 5 个。 """ - buttons: List[List[dict]] = [] + buttons: List[List[dict[str, str]]] = [] max_rows = 5 max_per_row = 5 capped = min(count, max_rows * max_per_row) @@ -1385,7 +1397,7 @@ class Discord: @staticmethod def _build_view( - buttons: Optional[List[List[dict]]], link: Optional[str] = None + buttons: Optional[List[List[dict[str, str]]]], link: Optional[str] = None ) -> Optional[discord.ui.View]: """ 构建无本地超时的按钮视图,最多保留前 5 行、每行 5 个按钮。 @@ -1428,7 +1440,7 @@ class Discord: async def _resolve_channel( self, userid: Optional[str] = None, chat_id: Optional[str] = None - ): + ) -> Optional[Union[discord.abc.GuildChannel, discord.abc.PrivateChannel, discord.Thread]]: """ 按回复上下文和配置优先级解析发送频道。 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 327fdddd8..4bd16f03d 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,305 / 249 文件 | 本分支为 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,257 / 248 文件 | 本分支为 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 c23e6b29f..091a56a22 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,6 +7,7 @@ follow_imports = skip show_error_codes = True pretty = True files = + app/modules/discord/discord.py, app/modules/zspace/zspace.py, app/modules/jellyfin/jellyfin.py, app/modules/ugreen/ugreen.py, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 761eafa2d..94b85c0ed 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -806,16 +806,6 @@ "truthy-function": 1, "type-arg": 8 }, - "app/modules/discord/discord.py": { - "assignment": 1, - "dict-item": 1, - "misc": 2, - "no-untyped-call": 2, - "no-untyped-def": 8, - "return-value": 2, - "type-arg": 14, - "union-attr": 18 - }, "app/modules/douban/__init__.py": { "assignment": 23, "empty-body": 1, diff --git a/tests/test_discord_typing_contracts.py b/tests/test_discord_typing_contracts.py new file mode 100644 index 000000000..f17b4cf85 --- /dev/null +++ b/tests/test_discord_typing_contracts.py @@ -0,0 +1,68 @@ +""" +验证 Discord SDK 回调注册和同步发送回执的类型边界。 +""" + +import asyncio +import threading +from concurrent.futures import Future +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import discord +import pytest + +from app.modules.discord.discord import Discord + + +def test_event_registration_preserves_sdk_dispatch_callbacks() -> None: + """ + 回调仍注册到真实 SDK 客户端,登录事件标记就绪且机器人消息不会进入宿主。 + """ + instance = Discord.__new__(Discord) + instance._client = discord.Client(intents=discord.Intents.none()) + instance._ready_event = threading.Event() + instance._registered_commands = None + instance._post_to_ds = AsyncMock() + instance._register_events() + + async def dispatch() -> None: + """ + 离线执行已注册的 SDK 事件并释放客户端资源。 + """ + try: + await instance._client.on_ready() + await instance._client.on_message(SimpleNamespace(author=SimpleNamespace(bot=True))) + assert callable(instance._client.on_interaction) + finally: + await instance._client.close() + + asyncio.run(dispatch()) + assert instance._ready_event.is_set() + assert instance._bot_user_id is None + instance._post_to_ds.assert_not_awaited() + + +@pytest.mark.parametrize("method", ["send_msg", "send_file"]) +def test_sync_send_preserves_message_receipt(monkeypatch, method: str) -> None: + """ + 同步入口应原样返回发送协程的消息回执,不把二元组转换成布尔值。 + """ + instance = Discord.__new__(Discord) + instance._client = object() + instance._loop = object() + instance._ready_event = threading.Event() + instance._ready_event.set() + instance.get_state = Mock(return_value=True) + receipt = (True, {"message_id": "123", "chat_id": "456"}) + + def submit(coroutine, _loop): + """ + 关闭未调度协程并提供已完成回执,避免测试启动真实线程或网络请求。 + """ + coroutine.close() + future = Future() + future.set_result(receipt) + return future + + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", submit) + assert getattr(instance, method)("测试") is receipt