mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 13:55:00 +08:00
fix(types): make Discord callbacks and receipts explicit
This commit is contained in:
@@ -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]]:
|
||||
"""
|
||||
按回复上下文和配置优先级解析发送频道。
|
||||
|
||||
|
||||
@@ -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% |
|
||||
|
||||
|
||||
1
mypy.ini
1
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,
|
||||
|
||||
10
tests/fixtures/architecture/mypy-baseline.json
vendored
10
tests/fixtures/architecture/mypy-baseline.json
vendored
@@ -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,
|
||||
|
||||
68
tests/test_discord_typing_contracts.py
Normal file
68
tests/test_discord_typing_contracts.py
Normal 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
|
||||
Reference in New Issue
Block a user