notifier.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """告警出口:钉钉机器人 + 邮件群发,统一封装为"双重告警"。"""
  2. import base64
  3. import hashlib
  4. import hmac
  5. import logging
  6. import time
  7. import requests
  8. from mailer import forward_notice, send_mail
  9. log = logging.getLogger("notifier")
  10. # 钉钉群机器人固定接口地址;access_token 作为 query 参数传入
  11. DINGTALK_API = "https://oapi.dingtalk.com/robot/send"
  12. def _dingtalk_sign(secret):
  13. """钉钉加签:毫秒时间戳 + "\\n" + secret,HMAC-SHA256 后 Base64。
  14. 返回 (timestamp, sign)。sign 交给 requests 的 params 去做 URL 编码,
  15. 不要自己 quote,否则容易出现二次编码导致验签失败。
  16. """
  17. ts = str(round(time.time() * 1000))
  18. digest = hmac.new(
  19. secret.encode("utf-8"),
  20. f"{ts}\n{secret}".encode("utf-8"),
  21. digestmod=hashlib.sha256,
  22. ).digest()
  23. return ts, base64.b64encode(digest).decode("utf-8")
  24. def send_dingtalk(dt_cfg, title, text):
  25. """发送 markdown 消息到钉钉群机器人。未启用时直接跳过。
  26. 凭证支持两种写法,二选一:
  27. access_token: "xxx" # 推荐,只填 token
  28. webhook: "https://...?access_token=xxx" # 兼容整条 webhook
  29. 配了 secret 就自动加签(对应机器人安全设置里的"加签"方式)。
  30. """
  31. if not dt_cfg.get("enabled"):
  32. log.info("[钉钉] 未启用,跳过")
  33. return
  34. url = (dt_cfg.get("webhook") or DINGTALK_API).strip()
  35. token = (dt_cfg.get("access_token") or "").strip()
  36. params = {}
  37. if "access_token=" not in url:
  38. if not token:
  39. log.warning("[钉钉] 已启用但未配置 access_token/webhook,跳过")
  40. return
  41. params["access_token"] = token
  42. secret = (dt_cfg.get("secret") or "").strip()
  43. if secret:
  44. params["timestamp"], params["sign"] = _dingtalk_sign(secret)
  45. at_mobiles = [str(m).strip() for m in (dt_cfg.get("at_mobiles") or []) if str(m).strip()]
  46. at_all = bool(dt_cfg.get("at_all"))
  47. # markdown 消息里必须出现 @手机号 字面量,@ 才会真的生效
  48. if at_mobiles:
  49. text = text + "\n\n" + " ".join(f"@{m}" for m in at_mobiles)
  50. payload = {"msgtype": "markdown", "markdown": {"title": title, "text": text}}
  51. if at_mobiles or at_all:
  52. payload["at"] = {"atMobiles": at_mobiles, "isAtAll": at_all}
  53. resp = requests.post(
  54. url,
  55. params=params,
  56. json=payload,
  57. headers={"Content-Type": "application/json"},
  58. timeout=dt_cfg.get("timeout_seconds", 15),
  59. )
  60. resp.raise_for_status()
  61. data = resp.json()
  62. if data.get("errcode") != 0:
  63. # errmsg 里通常直接写明原因(token 无效 / 验签失败 / 关键词不匹配 / 限流)
  64. raise RuntimeError(
  65. "钉钉返回错误 errcode=%s errmsg=%s" % (data.get("errcode"), data.get("errmsg"))
  66. )
  67. log.info("[钉钉] 告警已发送: %s", title)
  68. def dual_forward(cfg, notice, dry_run=False):
  69. """下线通知双通道:邮件转发原文给通知列表 + 钉钉推送摘要。
  70. 任一通道失败不影响另一个。返回 (转发是否成功, 钉钉是否成功)。
  71. """
  72. mid = notice["message_id"]
  73. if dry_run:
  74. log.info(
  75. "[DRY-RUN] 将转发下线通知\nMessage-ID: %s\n原发件人: %s\n原标题: %s",
  76. mid, notice["from"], notice["subject"],
  77. )
  78. return True, True
  79. fwd_ok = False
  80. try:
  81. forward_notice(cfg["mail"], notice)
  82. fwd_ok = True
  83. except Exception as e: # noqa: BLE001
  84. log.error("[告警] 下线通知转发失败: %s", e)
  85. text = "\n".join([
  86. "检测到模型下线通知,已转发给通知列表。",
  87. "",
  88. f"原发件人: {notice['from']}",
  89. f"原发送时间: {notice['date']}",
  90. f"原邮件标题: {notice['subject']}",
  91. f"Message-ID: {mid}",
  92. "",
  93. "正文摘要:",
  94. (notice.get("body") or "")[:500],
  95. ])
  96. ding_ok = False
  97. try:
  98. send_dingtalk(cfg["dingtalk"], "【模型下线通知】" + notice["subject"], _to_markdown(
  99. "【模型下线通知】" + notice["subject"], text))
  100. ding_ok = True
  101. except Exception as e: # noqa: BLE001
  102. log.error("[告警] 钉钉发送失败: %s", e)
  103. return fwd_ok, ding_ok
  104. def dual_alert(cfg, subject, body, dry_run=False):
  105. """双重告警:邮件群发 + 钉钉推送。任一通道失败不影响另一个。
  106. 返回 (邮件是否成功, 钉钉是否成功)。
  107. """
  108. if dry_run:
  109. log.info("[DRY-RUN] 将发送告警\n标题: %s\n正文:\n%s", subject, body)
  110. return True, True
  111. mail_ok = False
  112. try:
  113. send_mail(cfg["mail"], subject, body)
  114. mail_ok = True
  115. except Exception as e: # noqa: BLE001
  116. log.error("[告警] 邮件发送失败: %s", e)
  117. ding_ok = False
  118. try:
  119. send_dingtalk(cfg["dingtalk"], subject, _to_markdown(subject, body))
  120. ding_ok = True
  121. except Exception as e: # noqa: BLE001
  122. log.error("[告警] 钉钉发送失败: %s", e)
  123. return mail_ok, ding_ok
  124. def _to_markdown(subject, body):
  125. """纯文本正文转钉钉 markdown:保留换行,避免被 md 合并成一行。"""
  126. lines = ["### " + subject, ""]
  127. for line in body.splitlines():
  128. lines.append(line.rstrip() + " " if line.strip() else "")
  129. return "\n".join(lines)