mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
feat(notification): add DingTalk robot channel
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"")
|
||||
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()
|
||||
@@ -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="存量模块尚未在二阶段任务中修改,按渐进策略暂不提升门禁",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -492,6 +492,7 @@ class NotificationChannel(Enum):
|
||||
Telegram = "Telegram"
|
||||
Slack = "Slack"
|
||||
Discord = "Discord"
|
||||
DingTalk = "钉钉"
|
||||
SynologyChat = "SynologyChat"
|
||||
VoceChat = "VoceChat"
|
||||
Web = "Web"
|
||||
|
||||
+18
-3
@@ -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",
|
||||
|
||||
@@ -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 "" 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
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user