fix(types): make Discord callbacks and receipts explicit

This commit is contained in:
jxxghp
2026-10-17 20:07:14 +08:00
parent 74489ca32f
commit acfe477c66
5 changed files with 115 additions and 44 deletions

View File

@@ -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]]:
"""
按回复上下文和配置优先级解析发送频道。

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

View File

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

View File

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

View File

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