From 3e4e182ff9ce33fe27e72aeeb2342a42a7516ff6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 01:55:27 +0800 Subject: [PATCH] refactor: type routed module contracts --- app/runtime/extensions/module/contracts.py | 11 ++ .../runtime-contract-baseline.json | 158 ++++++++++++------ tests/test_module_method_contracts.py | 37 ++++ 3 files changed, 153 insertions(+), 53 deletions(-) diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index dcd782fb7..f397314ef 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -190,6 +190,13 @@ _METHOD_CONTRACTS = { "music_fresh_releases": ModuleMethodContract(family="music", input_contract="MusicFreshReleasesRequest", result_contract="list[MusicInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("days", "sort", "past", "future", "offset", "count")), "music_lyrics": ModuleMethodContract(family="music", input_contract="MusicLyricsRequest", result_contract="MusicLyrics | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("music",)), "search_music": ModuleMethodContract(family="music", input_contract="MusicSearchRequest", result_contract="list[MusicInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("meta", "limit", "media_source")), + "channel_manage": ModuleMethodContract(family="messaging", input_contract="ChannelManageRequest", result_contract="dict[str, Any] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("channel", "action")), + "delete_message": ModuleMethodContract(family="messaging", input_contract="MessageDeleteRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("channel", "source", "message_id", "chat_id")), + "edit_message": ModuleMethodContract(family="messaging", input_contract="MessageEditRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("channel", "source", "message_id", "chat_id", "text", "title", "buttons", "metadata")), + "mark_message_processing_started": ModuleMethodContract(family="messaging", input_contract="MessageProcessingStartRequest", result_contract="dict[str, Any] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("channel", "source", "userid", "message_id", "chat_id", "text")), + "mark_message_processing_finished": ModuleMethodContract(family="messaging", input_contract="MessageProcessingFinishRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("channel", "source", "userid", "message_id", "chat_id", "status")), + "message_parser": ModuleMethodContract(family="messaging", input_contract="MessageParseRequest", result_contract="IncomingMessage | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("source", "body", "form", "args")), + "send_direct_message": ModuleMethodContract(family="messaging", input_contract="DirectMessageSendRequest", result_contract="MessageResponse | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("message",)), "send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), "finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("response",)), "register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None", required_parameters=("commands",)), @@ -210,6 +217,7 @@ _METHOD_CONTRACTS = { "download_wechat_media_bytes": ModuleMethodContract(family="messaging", input_contract="MessageMediaDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("media_ref", "source")), "downloader_info": ModuleMethodContract(family="downloader", input_contract="DownloaderInfoRequest", result_contract="list[DownloaderInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("downloader",)), "list_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentListRequest", result_contract="list[DownloaderTorrent]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("status", "hashs", "downloader", "include_all_tags")), + "refresh_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentRefreshRequest", result_contract="list[TorrentInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("site", "keyword", "cat", "page", "mtype")), "torrent_files": ModuleMethodContract(family="downloader", input_contract="TorrentFilesRequest", result_contract="DownloaderFileCollection | None", required_parameters=("tid", "downloader")), "get_torrent_trackers": ModuleMethodContract(family="downloader", input_contract="TorrentTrackersRequest", result_contract="dict[str, list[str]] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.ORDERED_MAPPING_MERGE, required_parameters=("hash_string", "downloader")), "download": ModuleMethodContract(family="downloader", input_contract="DownloadTaskRequest", result_contract="DownloadTaskResult | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("content", "download_dir", "cookie", "episodes", "category", "label", "downloader")), @@ -218,6 +226,8 @@ _METHOD_CONTRACTS = { "start_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentControlRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hashs", "downloader")), "stop_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentControlRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hashs", "downloader")), "update_torrent": ModuleMethodContract(family="downloader", input_contract="TorrentUpdateRequest", result_contract="dict[str, bool] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hash_string", "downloader", "download_limit", "upload_limit", "tracker_list", "save_path", "category", "ratio_limit", "seeding_time_limit")), + "tmdb_cache_items": ModuleMethodContract(family="tmdb", input_contract="TmdbCacheListRequest", result_contract="list[dict[str, Any]]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE), + "tmdb_cache_delete": ModuleMethodContract(family="tmdb", input_contract="TmdbCacheDeleteRequest", result_contract="dict[str, Any] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("cache_key",)), } # 同一能力的同步/异步入口共享不可变契约对象,避免参数和聚合语义各自漂移。 @@ -276,6 +286,7 @@ _METHOD_CONTRACTS.update({ "async_tmdb_tv_similar": _METHOD_CONTRACTS["tmdb_tv_similar"], "async_identify_music_by_fingerprint": _METHOD_CONTRACTS["identify_music_by_fingerprint"], "async_match_music_album": _METHOD_CONTRACTS["match_music_album"], + "async_refresh_torrents": _METHOD_CONTRACTS["refresh_torrents"], }) _PREFIX_CONTRACTS = ( diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index e971db9ef..806f62d75 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -3108,16 +3108,22 @@ "version": 1 }, "async_refresh_torrents": { - "aggregation": "legacy", + "aggregation": "ordered_list_merge", "error_policy": "isolate_provider", "execution": "sync_or_async", - "family": "integration", - "input_contract": "IntegrationKeywordArguments", + "family": "downloader", + "input_contract": "TorrentRefreshRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "IntegrationProviderResult", - "result_shape": "any", + "required_parameters": [ + "cat", + "keyword", + "mtype", + "page", + "site" + ], + "result_contract": "list[TorrentInfo]", + "result_shape": "list", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -3722,16 +3728,19 @@ "version": 1 }, "channel_manage": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "messaging", - "input_contract": "MessagingKeywordArguments", + "input_contract": "ChannelManageRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MessagingProviderResult", - "result_shape": "any", + "required_parameters": [ + "action", + "channel" + ], + "result_contract": "dict[str, Any] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -3791,16 +3800,21 @@ "version": 1 }, "delete_message": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "messaging", - "input_contract": "MessagingKeywordArguments", + "input_contract": "MessageDeleteRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MessagingProviderResult", - "result_shape": "any", + "required_parameters": [ + "channel", + "chat_id", + "message_id", + "source" + ], + "result_contract": "bool | None", + "result_shape": "boolean", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4282,16 +4296,25 @@ "version": 1 }, "edit_message": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "messaging", - "input_contract": "MessagingKeywordArguments", + "input_contract": "MessageEditRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MessagingProviderResult", - "result_shape": "any", + "required_parameters": [ + "buttons", + "channel", + "chat_id", + "message_id", + "metadata", + "source", + "text", + "title" + ], + "result_contract": "bool | None", + "result_shape": "boolean", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4500,32 +4523,46 @@ "version": 1 }, "mark_message_processing_finished": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "messaging", - "input_contract": "MessagingKeywordArguments", + "input_contract": "MessageProcessingFinishRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MessagingProviderResult", - "result_shape": "any", + "required_parameters": [ + "channel", + "chat_id", + "message_id", + "source", + "status", + "userid" + ], + "result_contract": "bool | None", + "result_shape": "boolean", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", "version": 1 }, "mark_message_processing_started": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "messaging", - "input_contract": "MessagingKeywordArguments", + "input_contract": "MessageProcessingStartRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MessagingProviderResult", - "result_shape": "any", + "required_parameters": [ + "channel", + "chat_id", + "message_id", + "source", + "text", + "userid" + ], + "result_contract": "dict[str, Any] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4873,15 +4910,20 @@ "version": 1 }, "message_parser": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "messaging", - "input_contract": "MessagingKeywordArguments", + "input_contract": "MessageParseRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MessagingProviderResult", + "required_parameters": [ + "args", + "body", + "form", + "source" + ], + "result_contract": "IncomingMessage | None", "result_shape": "any", "supports_async": true, "supports_sync": true, @@ -5307,16 +5349,22 @@ "version": 1 }, "refresh_torrents": { - "aggregation": "legacy", + "aggregation": "ordered_list_merge", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "downloader", - "input_contract": "DownloaderKeywordArguments", + "input_contract": "TorrentRefreshRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "DownloaderProviderResult", - "result_shape": "any", + "required_parameters": [ + "cat", + "keyword", + "mtype", + "page", + "site" + ], + "result_contract": "list[TorrentInfo]", + "result_shape": "list", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5568,15 +5616,17 @@ "version": 1 }, "send_direct_message": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "messaging", - "input_contract": "MessagingKeywordArguments", + "input_contract": "DirectMessageSendRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MessagingProviderResult", + "required_parameters": [ + "message" + ], + "result_contract": "MessageResponse | None", "result_shape": "any", "supports_async": true, "supports_sync": true, @@ -5733,32 +5783,34 @@ "version": 1 }, "tmdb_cache_delete": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "tmdb", - "input_contract": "TmdbKeywordArguments", + "input_contract": "TmdbCacheDeleteRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "TmdbProviderResult", - "result_shape": "any", + "required_parameters": [ + "cache_key" + ], + "result_contract": "dict[str, Any] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", "version": 1 }, "tmdb_cache_items": { - "aggregation": "legacy", + "aggregation": "ordered_list_merge", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "tmdb", - "input_contract": "TmdbKeywordArguments", + "input_contract": "TmdbCacheListRequest", "plugin_short_circuit": true, "public_to_plugins": true, "required_parameters": [], - "result_contract": "TmdbProviderResult", - "result_shape": "any", + "result_contract": "list[dict[str, Any]]", + "result_shape": "list", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py index 1cee7a911..c36de6759 100644 --- a/tests/test_module_method_contracts.py +++ b/tests/test_module_method_contracts.py @@ -150,6 +150,43 @@ def test_message_attachment_contracts_use_messaging_family() -> None: assert contract.required_parameters == parameters +def test_message_route_contracts_use_first_matching_provider() -> None: + """消息路由能力应保留渠道筛选语义,并停止依赖 legacy 结果接力。""" + expected_shapes = { + "channel_manage": ModuleResultShape.MAPPING, + "delete_message": ModuleResultShape.BOOLEAN, + "edit_message": ModuleResultShape.BOOLEAN, + "mark_message_processing_started": ModuleResultShape.MAPPING, + "mark_message_processing_finished": ModuleResultShape.BOOLEAN, + "message_parser": ModuleResultShape.ANY, + "send_direct_message": ModuleResultShape.ANY, + } + + for method, result_shape in expected_shapes.items(): + contract = get_module_method_contract(method) + assert contract.family == "messaging" + assert contract.aggregation is ModuleResultAggregation.FIRST_NON_EMPTY + assert contract.result_shape is result_shape + assert contract.required_parameters + + +def test_refresh_and_tmdb_cache_contracts_match_host_result_shapes() -> None: + """刷新种子与 TMDB 缓存入口应声明列表合并或目标值路由语义。""" + refresh = get_module_method_contract("refresh_torrents") + async_refresh = get_module_method_contract("async_refresh_torrents") + cache_items = get_module_method_contract("tmdb_cache_items") + cache_delete = get_module_method_contract("tmdb_cache_delete") + + assert refresh is async_refresh + assert refresh.family == "downloader" + assert refresh.aggregation is ModuleResultAggregation.ORDERED_LIST_MERGE + assert refresh.result_shape is ModuleResultShape.LIST + assert cache_items.aggregation is ModuleResultAggregation.ORDERED_LIST_MERGE + assert cache_items.result_shape is ModuleResultShape.LIST + assert cache_delete.aggregation is ModuleResultAggregation.FIRST_NON_EMPTY + assert cache_delete.result_shape is ModuleResultShape.MAPPING + + def test_downloader_query_contracts_merge_provider_lists() -> None: """下载器查询能力应显式合并各 provider 的有序列表结果。""" for method in ("list_torrents", "downloader_info"):