Files
MoviePilot/app/modules/dingtalk/dingtalk.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

168 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
钉钉自定义机器人 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: object,
) -> 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:
"""
按钉钉加签协议追加时间戳与签名。
:param timestamp: Unix 时间戳,单位毫秒;省略时使用当前时间
:return: 已加签请求地址;未配置密钥时返回原 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]:
"""
将通知字段转换为钉钉 Markdown 标题和正文。
:param title: 原始标题;首行用作消息标题,空标题使用 MoviePilot 通知
:param text: 可选正文
:param image: 可选图片地址
:param link: 可选详情链接
:return: 消息标题与 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 消息并校验业务返回码。
请求结束后关闭响应;该入口不执行接收人定向发送。
:param title: 消息标题,可与正文择一提供
:param text: 消息正文
:param image: 可选图片地址
:param userid: 兼容统一通知接口的接收人字段,群机器人当前忽略
:param link: 可选详情链接
:return: HTTP 和钉钉业务状态均成功时返回 True;配置、输入或响应失败时返回 False
"""
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()