From c765604b360dc0881cfd58192accc74e741b92e3 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 22 Aug 2026 12:02:24 +0800 Subject: [PATCH] feat(notification): add DingTalk robot channel --- app/modules/dingtalk/__init__.py | 99 +++++++++++++ app/modules/dingtalk/capability.toml | 22 +++ app/modules/dingtalk/dingtalk.py | 135 ++++++++++++++++++ app/runtime/extensions/module/quality.py | 19 ++- app/schemas/notification.py | 12 ++ app/schemas/types.py | 1 + .../architecture/dependency-baseline.json | 21 ++- tests/test_dingtalk.py | 123 ++++++++++++++++ .../test_module_manager_capability_adapter.py | 12 +- 9 files changed, 434 insertions(+), 10 deletions(-) create mode 100644 app/modules/dingtalk/__init__.py create mode 100644 app/modules/dingtalk/capability.toml create mode 100644 app/modules/dingtalk/dingtalk.py create mode 100644 tests/test_dingtalk.py diff --git a/app/modules/dingtalk/__init__.py b/app/modules/dingtalk/__init__.py new file mode 100644 index 000000000..0b74d0d5a --- /dev/null +++ b/app/modules/dingtalk/__init__.py @@ -0,0 +1,99 @@ +"""钉钉自定义机器人通知模块。""" + +from typing import List, Optional, Tuple, Union + +from app.domain.context import Context, MediaInfo +from app.modules._base import _MessageChannelModuleBase +from app.modules.dingtalk.dingtalk import DingTalk +from app.schemas.message import Message +from app.schemas.types import ModuleType, NotificationChannel + + +class DingTalkModule(_MessageChannelModuleBase[DingTalk]): + """把 MoviePilot 通知转换为钉钉自定义机器人群消息。""" + + def init_module(self) -> None: + """从已启用的 dingtalk 通知配置创建客户端实例。""" + super().init_service(service_name=DingTalk.__name__.lower(), service_type=DingTalk) + self._channel = NotificationChannel.DingTalk + + @staticmethod + def get_name() -> str: + """返回模块展示名称。""" + return "DingTalk" + + @staticmethod + def get_type() -> ModuleType: + """声明该模块属于通知渠道。""" + return ModuleType.Notification + + @staticmethod + def get_subtype() -> NotificationChannel: + """返回钉钉通知渠道枚举。""" + return NotificationChannel.DingTalk + + @staticmethod + def get_priority() -> int: + """返回通知模块调度优先级。""" + return 11 + + def stop(self) -> None: + """同步 Webhook 客户端没有需要释放的长连接资源。""" + + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: + """钉钉启用状态由通知渠道配置统一管理。""" + return None + + def post_message(self, message: Message, **kwargs) -> None: + """向所有匹配消息范围的钉钉配置发送普通通知。""" + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + client = self.get_instance(conf.name) + if client: + client.send_msg( + title=message.title, + text=message.text, + image=message.image, + userid=str(message.userid) if message.userid else None, + link=message.link, + ) + + def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None: + """把媒体候选列表降级为可点击的 Markdown 列表后发送。""" + if not medias: + return + lines = [] + for index, media in enumerate(medias, start=1): + label = media.title_year + if media.detail_link: + label = f"[{label}]({media.detail_link})" + lines.append(f"{index}. {label}") + self._post_list_message(message, "\n\n".join(lines)) + + def post_torrents_message(self, message: Message, torrents: List[Context]) -> None: + """把资源候选列表降级为可点击的 Markdown 列表后发送。""" + if not torrents: + return + lines = [] + for index, context in enumerate(torrents, start=1): + torrent = context.torrent_info + label = torrent.title + if torrent.page_url: + label = f"[{label}]({torrent.page_url})" + lines.append(f"{index}. {label}") + self._post_list_message(message, "\n\n".join(lines)) + + def _post_list_message(self, message: Message, text: str) -> None: + """复用渠道筛选规则发送列表型 Markdown 消息。""" + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + client = self.get_instance(conf.name) + if client: + client.send_msg( + title=message.title, + text=text, + userid=str(message.userid) if message.userid else None, + link=message.link, + ) diff --git a/app/modules/dingtalk/capability.toml b/app/modules/dingtalk/capability.toml new file mode 100644 index 000000000..ed3ff2487 --- /dev/null +++ b/app/modules/dingtalk/capability.toml @@ -0,0 +1,22 @@ +schema_version = 1 +id = "DingTalkModule" +kind = "host_module" +entrypoint = "app.modules.dingtalk:DingTalkModule" +depends_on = [] + +[metadata] +name = "DingTalk" +type = "notification" +subtype = "DingTalk" +priority = 11 + +[activation] +policy = "when_configured" +watch = ["Notifications"] + +[activation.selector] +kind = "system_config_item" +key = "Notifications" +match_field = "type" +match_value = "dingtalk" +enabled_field = "enabled" diff --git a/app/modules/dingtalk/dingtalk.py b/app/modules/dingtalk/dingtalk.py new file mode 100644 index 000000000..1eabdea52 --- /dev/null +++ b/app/modules/dingtalk/dingtalk.py @@ -0,0 +1,135 @@ +"""钉钉自定义机器人 Webhook 客户端。""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import time +from typing import Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from app.adapters.network.http import RequestUtils +from app.runtime.log import logger + + +class DingTalk: + """通过钉钉自定义机器人 Webhook 发送 Markdown 通知。""" + + def __init__( + self, + DINGTALK_WEBHOOK: Optional[str] = None, + DINGTALK_SECRET: Optional[str] = None, + **kwargs, + ) -> None: + """保存 Webhook 与可选加签密钥,网络请求延迟到实际发送时执行。""" + self._webhook = (DINGTALK_WEBHOOK or "").strip() + self._secret = (DINGTALK_SECRET or "").strip() + self._req = RequestUtils(content_type="application/json", timeout=30) + + def get_state(self) -> bool: + """检查 Webhook 是否为可发送请求的 HTTP(S) 地址。""" + if not self._webhook: + return False + parsed = urlsplit(self._webhook) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + def build_request_url(self, timestamp: Optional[int] = None) -> str: + """按钉钉加签协议向 Webhook 查询参数追加时间戳与签名。""" + if not self._secret: + return self._webhook + timestamp = timestamp if timestamp is not None else int(time.time() * 1000) + string_to_sign = f"{timestamp}\n{self._secret}".encode("utf-8") + digest = hmac.new( + self._secret.encode("utf-8"), + string_to_sign, + digestmod=hashlib.sha256, + ).digest() + signature = base64.b64encode(digest).decode("utf-8") + parsed = urlsplit(self._webhook) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query.update({"timestamp": str(timestamp), "sign": signature}) + return urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment) + ) + + @staticmethod + def build_markdown( + title: Optional[str], + text: Optional[str] = None, + image: Optional[str] = None, + link: Optional[str] = None, + ) -> tuple[str, str]: + """将 MoviePilot 通知字段转换为钉钉 Markdown 标题与正文。""" + raw_title = str(title or "").strip() + title_lines = raw_title.splitlines() + markdown_title = title_lines[0].strip() if title_lines else "" + markdown_title = markdown_title or "MoviePilot 通知" + + content_parts = [] + if raw_title: + content_parts.append(f"### {raw_title}") + if text: + content_parts.append(str(text).strip()) + if image: + content_parts.append(f"![图片]({image})") + if link: + content_parts.append(f"[查看详情]({link})") + if not content_parts: + content_parts.append(markdown_title) + return markdown_title, "\n\n".join(part for part in content_parts if part) + + def send_msg( + self, + title: Optional[str], + text: Optional[str] = None, + image: Optional[str] = None, + userid: Optional[str] = None, + link: Optional[str] = None, + ) -> bool: + """向机器人所在群发送一条 Markdown 消息并校验钉钉业务返回码。""" + if not title and not text: + logger.warning("钉钉通知标题和内容不能同时为空") + return False + if not self.get_state(): + logger.error("钉钉自定义机器人 Webhook 配置不完整") + return False + + markdown_title, markdown_text = self.build_markdown( + title=title, + text=text, + image=image, + link=link, + ) + payload = { + "msgtype": "markdown", + "markdown": { + "title": markdown_title, + "text": markdown_text, + }, + } + response = self._req.post_res(url=self.build_request_url(), json=payload) + if response is None: + logger.error("钉钉自定义机器人请求失败") + return False + try: + if response.status_code != 200: + logger.error(f"钉钉自定义机器人返回 HTTP {response.status_code}") + return False + try: + result = response.json() + except ValueError: + logger.error("钉钉自定义机器人返回了无法解析的响应") + return False + if not isinstance(result, dict): + logger.error("钉钉自定义机器人返回了非对象响应") + return False + if result.get("errcode") == 0: + return True + logger.error( + "钉钉自定义机器人返回错误:" + f"{result.get('errcode')}-{result.get('errmsg') or '未知错误'}" + ) + return False + finally: + response.close() diff --git a/app/runtime/extensions/module/quality.py b/app/runtime/extensions/module/quality.py index 44b28f7b5..c52012857 100644 --- a/app/runtime/extensions/module/quality.py +++ b/app/runtime/extensions/module/quality.py @@ -60,6 +60,24 @@ MODULE_QUALITY_PROFILES = { "本轮仅对配置快照改动面启用 assessed 门禁" ), ), + "dingtalk": ModuleQualityProfile( + module="dingtalk", + level=ModuleQualityLevel.ASSESSED, + owner="MoviePilot core", + verified_rules=frozenset( + { + "fake-client-or-fixture", + "zero-real-network-tests", + "reload-stop-idempotent", + "module-contract-v2", + "sensitive-log-redaction", + "owner-declared", + } + ), + exemption_reason=( + "钉钉自定义机器人仅提供同步出站 Webhook,不包含长连接、轮询或入站回调" + ), + ), } @@ -74,4 +92,3 @@ def get_module_quality_profile(module: str) -> ModuleQualityProfile: exemption_reason="存量模块尚未在二阶段任务中修改,按渐进策略暂不提升门禁", ), ) - diff --git a/app/schemas/notification.py b/app/schemas/notification.py index e333ece26..fe7c98968 100644 --- a/app/schemas/notification.py +++ b/app/schemas/notification.py @@ -192,6 +192,18 @@ class ChannelCapabilityManager: max_message_length=1800, fallback_enabled=True, ), + NotificationChannel.DingTalk: ChannelCapabilities( + channel=NotificationChannel.DingTalk, + capabilities={ + ChannelCapability.MARKDOWN, + ChannelCapability.RICH_TEXT, + ChannelCapability.IMAGES, + ChannelCapability.LINKS, + }, + # 自定义机器人 Markdown 文本上限为 20000 字节,预留标题和图片链接空间。 + max_message_length=18000, + fallback_enabled=True, + ), NotificationChannel.SynologyChat: ChannelCapabilities( channel=NotificationChannel.SynologyChat, capabilities={ diff --git a/app/schemas/types.py b/app/schemas/types.py index f988ca3d9..585366b7b 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -492,6 +492,7 @@ class NotificationChannel(Enum): Telegram = "Telegram" Slack = "Slack" Discord = "Discord" + DingTalk = "钉钉" SynologyChat = "SynologyChat" VoceChat = "VoceChat" Web = "Web" diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index a57b1ae9e..ca023cee5 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6341, - "edge_sha256": "073824a6b2c8b3e9baf4755ce4dccf94693fb943d1baba0f621d3a84b17030c0", + "edge_count": 6354, + "edge_sha256": "5c7bd53e742c806fdd9fa129e35ab979008d54e568013df61a4f60bffa347ddf", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -3893,6 +3893,19 @@ "app.modules.bangumi.bangumi -> app.runtime", "app.modules.bangumi.bangumi -> app.runtime.cache", "app.modules.bangumi.bangumi -> app.runtime.config", + "app.modules.dingtalk -> app.domain", + "app.modules.dingtalk -> app.domain.context", + "app.modules.dingtalk -> app.modules", + "app.modules.dingtalk -> app.modules._base", + "app.modules.dingtalk -> app.modules.dingtalk.dingtalk", + "app.modules.dingtalk -> app.schemas", + "app.modules.dingtalk -> app.schemas.message", + "app.modules.dingtalk -> app.schemas.types", + "app.modules.dingtalk.dingtalk -> app.adapters", + "app.modules.dingtalk.dingtalk -> app.adapters.network", + "app.modules.dingtalk.dingtalk -> app.adapters.network.http", + "app.modules.dingtalk.dingtalk -> app.runtime", + "app.modules.dingtalk.dingtalk -> app.runtime.log", "app.modules.discord -> app.adapters", "app.modules.discord -> app.adapters.network", "app.modules.discord -> app.adapters.network.http", @@ -6358,7 +6371,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 787, + "module_count": 789, "modules": [ "app", "app.adapters", @@ -6830,6 +6843,8 @@ "app.modules.anilist.anilist", "app.modules.bangumi", "app.modules.bangumi.bangumi", + "app.modules.dingtalk", + "app.modules.dingtalk.dingtalk", "app.modules.discord", "app.modules.discord.discord", "app.modules.douban", diff --git a/tests/test_dingtalk.py b/tests/test_dingtalk.py new file mode 100644 index 000000000..491cc6c8c --- /dev/null +++ b/tests/test_dingtalk.py @@ -0,0 +1,123 @@ +"""钉钉自定义机器人通知渠道测试。""" + +import base64 +import hashlib +import hmac +from types import SimpleNamespace +from unittest.mock import Mock +from urllib.parse import parse_qs, urlsplit + +from app.modules.dingtalk import DingTalkModule +from app.modules.dingtalk.dingtalk import DingTalk +from app.schemas.message import Message +from app.schemas.notification import ChannelCapability, ChannelCapabilityManager +from app.schemas.types import MessageType, NotificationChannel + + +def _response(payload: dict, status_code: int = 200) -> Mock: + """构造带关闭能力的 requests.Response 测试替身。""" + response = Mock(status_code=status_code) + response.json.return_value = payload + return response + + +def test_signed_webhook_preserves_access_token_and_uses_official_signature() -> None: + """签名应使用毫秒时间戳、换行分隔串和 HMAC-SHA256。""" + client = DingTalk( + DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=token", + DINGTALK_SECRET="SEC-test", + ) + + signed_url = client.build_request_url(timestamp=1720000000123) + query = parse_qs(urlsplit(signed_url).query) + expected = base64.b64encode( + hmac.new( + b"SEC-test", + b"1720000000123\nSEC-test", + digestmod=hashlib.sha256, + ).digest() + ).decode("utf-8") + + assert query == { + "access_token": ["token"], + "timestamp": ["1720000000123"], + "sign": [expected], + } + + +def test_send_msg_posts_markdown_and_accepts_dingtalk_success() -> None: + """普通通知应保留标题、正文、图片和详情链接并检查 errcode。""" + client = DingTalk( + DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=token" + ) + response = _response({"errcode": 0, "errmsg": "ok"}) + client._req = Mock() + client._req.post_res.return_value = response + + assert client.send_msg( + title="整理完成", + text="电影已入库", + image="https://example.com/poster.jpg", + link="https://example.com/history", + ) is True + + request = client._req.post_res.call_args.kwargs + assert request["url"].endswith("access_token=token") + assert request["json"]["msgtype"] == "markdown" + assert request["json"]["markdown"]["title"] == "整理完成" + markdown = request["json"]["markdown"]["text"] + assert "### 整理完成" in markdown + assert "电影已入库" in markdown + assert "![图片](https://example.com/poster.jpg)" in markdown + assert "[查看详情](https://example.com/history)" in markdown + response.close.assert_called_once_with() + + +def test_send_msg_rejects_http_and_business_failures() -> None: + """HTTP 失败或钉钉业务错误都不能被误判为发送成功。""" + client = DingTalk( + DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=token" + ) + client._req = Mock() + client._req.post_res.side_effect = [ + _response({}, status_code=500), + _response({"errcode": 310000, "errmsg": "keywords not in content"}), + ] + + assert client.send_msg(title="测试") is False + assert client.send_msg(title="测试") is False + + +def test_module_routes_matching_notifications_to_dingtalk_client(monkeypatch) -> None: + """模块应沿通知来源和消息类型契约向对应钉钉实例发送。""" + module = DingTalkModule() + module._channel = NotificationChannel.DingTalk + client = Mock() + config = SimpleNamespace(name="家庭群", switchs=[MessageType.Organize.value]) + monkeypatch.setattr(module, "get_configs", lambda: {config.name: config}) + monkeypatch.setattr(module, "get_config", lambda name=None: config) + monkeypatch.setattr(module, "get_instance", lambda name=None: client) + + module.post_message( + Message( + channel=NotificationChannel.DingTalk, + source="家庭群", + mtype=MessageType.Organize, + title="整理完成", + text="测试内容", + ) + ) + + client.send_msg.assert_called_once() + + +def test_dingtalk_channel_declares_markdown_image_and_link_capabilities() -> None: + """能力表应允许通用消息层保留钉钉支持的富文本字段。""" + for capability in ( + ChannelCapability.MARKDOWN, + ChannelCapability.IMAGES, + ChannelCapability.LINKS, + ): + assert ChannelCapabilityManager.supports_capability( + NotificationChannel.DingTalk, capability + ) diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index ee324bfb7..3b772aea5 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -489,7 +489,7 @@ from app.runtime.extensions.host_module_adapter import ( registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 38 +assert len(specs) == 39 adapter = HostModuleAdapter() lifecycle_events = [] @@ -536,7 +536,7 @@ from app.schemas.types import EventType registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 38 +assert len(specs) == 39 spec_by_id = {spec.id: spec for spec in specs} events = {spec.id: [] for spec in specs} @@ -703,7 +703,7 @@ from app.runtime.extensions.host_module_adapter import ( registry = build_host_module_registry() specs = registry.list_specs() -assert len(specs) == 38 +assert len(specs) == 39 configured_specs = tuple( spec for spec in specs if spec.activation is ActivationPolicy.WHEN_CONFIGURED @@ -799,12 +799,12 @@ from app.application.module import configure_module_runtime configure_module_runtime(lambda: ModuleManager()) manager = ModuleManager() -assert len(manager.list_specs()) == 38 +assert len(manager.list_specs()) == 39 assert manager.get_specs() == manager.list_specs() from app.api.endpoints.system import modulelist response = modulelist(None) -assert len(response.data["modules"]) == 38 +assert len(response.data["modules"]) == 39 heavy_prefixes = ( "lark_oapi", @@ -912,7 +912,7 @@ from app.runtime.extensions.module_manager import ModuleManager manager = ModuleManager() modules = manager.get_modules() -assert len(modules) == len(manager.list_specs()) == 38 +assert len(modules) == len(manager.list_specs()) == 39 for spec in manager.list_specs(): implementation = modules[spec.id] assert implementation.get_name() == spec.metadata["name"]