Python实现钉钉通报和邮件通知
概述
监控预警是一个自动化系统中常见的模块,我们希望系统出问题时能够主动把问题推送给维护人员,而不是要维护人员定时主动到服务端去查看各项服务的运行状态。
本文实现两种类型的消息通知功能:钉钉机器人通报和邮件通报。
基于Webhook的钉钉机器人推送消息
准备步骤
- 
首先创建一个钉钉群聊,在群聊添加自定义机器人[

 - 
机器人安全设置
目前支持3种安全设置:
- 
自定义关键词:消息中至少包含其中1个关键词才可以发送成功,最多可以设置10个关键词
 - 
加签:根据钉钉机器人服务提供的密钥,结合时间戳生成签名字符串,使用 HmacSHA256算法 计算签名,然后依次进行Base64 Encode、urlEncode,得到最终的签名(需要使用UTF-8字符集)
 - 
IP地址(段)。
 
 - 
 
以加签为例,机器人安全设置中勾选加签,系统会生成一个密钥,同意条款点击完成则会得到一个webhook接口,使用该接口并通过post方法附带要发送的消息内容及相关参数即可实现钉钉同步。


代码示例
利用时间戳和密钥生成签名字符串(timestamp+"\n"+secret)

#python 3.8
import time
import hmac
import hashlib
import base64
import urllib.parse
timestamp = str(round(time.time() * 1000))
secret = 'this is secret'
secret_enc = secret.encode('utf-8')
string_to_sign = '{}\n{}'.format(timestamp, secret)
string_to_sign_enc = string_to_sign.encode('utf-8')
hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
url = f"{webhook}×tamp={timestamp}&sign={sign}"
# 钉钉文本消息格式
data = {
      "at": {
                "isAtAll": at_all
            },
            "text": {
                "content": "测试通知"
            },
            "msgtype": "text"
  }
response = requests.post(url=url, data=json.dumps(data), headers=headers).textp)
print(response)

更多消息类型
钉钉自定义机器人支持文本(text)、链接(link)、markdown、ActionCard、FeedCard消息类型。根据钉钉开放平台的说明/建议:
每个机器人每分钟最多发送20条。消息发送太频繁会严重影响群成员的使用体验,
大量发消息的场景 (譬如系统监控报警) 可以将这些信息进行整合,通过markdown消息
以摘要的形式发送到群里。
- 
text: 文本类型
{ "at": { "atMobiles":[ "180xxxxxx" ], "atUserIds":[ "user123" ], "isAtAll": false }, "text": { "content":"我就是我, @XXX 是不一样的烟火" }, "msgtype":"text" } - 
link:链接类型
{ "msgtype": "link", "link": { "text": "这个即将发布的新版本,创始人xx称它为红树林。而在此之前,每当面临重大升级,产品经理们都会取一个应景的代号,这一次,为什么是红树林", "title": "时代的火车向前开", "picUrl": "", "messageUrl": "https://www.dingtalk.com/s?__biz=MzA4NjMwMTA2Ng==&mid=2650316842&idx=1&sn=60da3ea2b29f1dcc43a7c8e4a7c97a16&scene=2&srcid=09189AnRJEdIiWVaKltFzNTw&from=timeline&isappinstalled=0&key=&ascene=2&uin=&devicetype=android-23&version=26031933&nettype=WIFI" } } - 
markdown类型
{ "msgtype": "markdown", "markdown": { "title":"杭州天气", "text": "#### 杭州天气 @150XXXXXXXX \n > 9度,西北风1级,空气良89,相对温度73%\n > \n > ###### 10点20分发布 [天气](https://www.dingtalk.com) \n" }, "at": { "atMobiles": [ "150XXXXXXXX" ], "atUserIds": [ "user123" ], "isAtAll": false } }【注:只支持markdown语法的子集,详情参考文档】
 - 
整体跳转ActionCard类型
{ "actionCard": { "title": "乔布斯 20 年前想打造一间苹果咖啡厅,而它正是 Apple Store 的前身", "text": " ### 乔布斯 20 年前想打造的苹果咖啡厅 Apple Store 的设计正从原来满满的科技感走向生活化,而其生活化的走向其实可以追溯到 20 年前苹果一个建立咖啡馆的计划", "btnOrientation": "0", "singleTitle" : "阅读全文", "singleURL" : "https://www.dingtalk.com/" }, "msgtype": "actionCard" } - 
独立跳转ActionCard
{ "msgtype": "actionCard", "actionCard": { "title": "我 20 年前想打造一间苹果咖啡厅,而它正是 Apple Store 的前身", "text": " \n\n #### 乔布斯 20 年前想打造的苹果咖啡厅 \n\n Apple Store 的设计正从原来满满的科技感走向生活化,而其生活化的走向其实可以追溯到 20 年前苹果一个建立咖啡馆的计划", "btnOrientation": "0", "btns": [ { "title": "内容不错", "actionURL": "https://www.dingtalk.com/" }, { "title": "不感兴趣", "actionURL": "https://www.dingtalk.com/" } ] } } - 
FeedCard消息类型
{ "msgtype":"feedCard", "feedCard": { "links": [ { "title": "时代的火车向前开1", "messageURL": "https://www.dingtalk.com/", "picURL": "https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png" }, { "title": "时代的火车向前开2", "messageURL": "https://www.dingtalk.com/", "picURL": "https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png" } ] } } 
常见错误
// 消息内容中不包含任何关键词
{
  "errcode":310000,
  "errmsg":"keywords not in content"
}
// timestamp 无效
{
  "errcode":310000,
  "errmsg":"invalid timestamp"
}
// 签名不匹配
{
  "errcode":310000,
  "errmsg":"sign not match"
}
// IP地址不在白名单
{
  "errcode":310000,
  "errmsg":"ip X.X.X.X not in whitelist"
}
如时间戳超时,会收到如下报错信息:

参考文档
邮件通知
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import smtplib
from email.mime.text import MIMEText
from email.utils import formataddr
my_sender = 'test@foxmail.com'  # 发件人邮箱账号
my_pass = 'XXXXXXXXXXX'  # 发件人邮箱密码,若使用腾讯邮箱,则填对应的SMTP服务授权码
my_user = 'test@foxmail.com'  # 收件人邮箱账号,我这边发送给自己
def mail():
    ret = True
    try:
        msg = MIMEText('数据采集正常', 'plain', 'utf-8')
        msg['From'] = formataddr(["通知助手", my_sender])  # 括号里的对应发件人邮箱昵称、发件人邮箱账号
        msg['To'] = formataddr(["FK", my_user])  # 括号里的对应收件人邮箱昵称、收件人邮箱账号
        msg['Subject'] = "[任务执行成功] -------"  # 邮件的主题,也可以说是标题
        server = smtplib.SMTP_SSL("smtp.qq.com", 465)  # 发件人邮箱中的SMTP服务器,端口是25
        server.login(my_sender, my_pass)  # 括号中对应的是发件人邮箱账号、邮箱密码
        server.sendmail(my_sender, [my_user, ], msg.as_string())  # 括号中对应的是发件人邮箱账号、收件人邮箱账号、发送邮件
        server.quit()  # 关闭连接
    except Exception as e:  # 如果 try 中的语句没有执行,则会执行下面的 ret=False
        ret = False
        print(e)
    return ret
ret = mail()
if ret:
    print("邮件发送成功")
else:
    print("邮件发送失败")
参考文档
封装重构
考虑到消息通知可能有多个渠道,针对同一个消息有时我们想同时通过钉钉、短信、邮件等途径进行通报,因此可考虑将每一个具体通知渠道抽象成一个消息处理单元(Handler),通过通知器(Noticer)进行来执行通报逻辑,当Handler作为Noticer的成员属性,当Noticer启用了什么类型的消息处理单元(Handler),就会进行什么类型的消息通报。一个简单的示例代码如下:
import json
import time
import hmac
import hashlib
import base64
import urllib.parse
import requests
def create_noticer(enable_mail=False, enable_dingtalk=True):
    noticer = Noticer()
    if enable_mail:
        noticer.add_handler(MailHandler())
    if enable_dingtalk:
        noticer.add_handler(DingTalkHandler())
    return noticer
class Handler:
    """通知处理程序"""
    def send(self, msg):
        """作为接口"""
        pass
class DingTalkHandler(Handler):
    """钉钉通知处理程序"""
    def __init__(self):
        self.init_variables()
    def init_variables(self):
        # 密钥和webhook可抽出到配置文件中
        self.secret = "XXX"
        self.webhook = 'https://oapi.dingtalk.com/robot/send?access_token=XXX'
    def send(self, msg, at_all=False):
        """
        发送消息,当前只实现文本类型消息发送
        :param msg:
        :param at_all:
        :return:
        """
        headers = {'Content-Type': 'application/json', "Charset": "UTF-8"}
        #  webhook 地址
        timestamp = str(round(time.time() * 1000))
        # 加签密钥
        secret_enc = self.secret.encode("utf-8")
        string_to_sign = '{}\n{}'.format(timestamp, self.secret)
        string_to_sign_enc = string_to_sign.encode("utf-8")
        hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest()
        sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
        url = f"{self.webhook}×tamp={timestamp}&sign={sign}"
        # 钉钉消息格式,其中 msg 就是我们要发送的具体内容
        data = {
            "at": {
                "isAtAll": at_all
            },
            "text": {
                "content": msg
            },
            "msgtype": "text"
        }
        return requests.post(url=url, data=json.dumps(data), headers=headers).text
class MailHandler(Handler):
    """邮件通知处理程序"""
    pass
class Noticer:
    """
    消息通知器
    """
    def __init__(self):
        self.handlers = []
    def notice(self, msg):
        # 对msg进行一定的封装、定制、格式化,此处省略
        # ...
        self.handle(msg)
    def handle(self, msg):
        for handler in self.handlers:
            handler.send(msg)
    def add_handler(self, hdlr):
        """
        添加处理程序
        WARNING: 如果Handler使用了文件或数据库,则该方法线程不安全,需针对数据同步问题进行优化
        :param hdlr:
        :return:
        """
        if not (hdlr in self.handlers):
            self.handlers.append(hdlr)
    def remove_handler(self, hdlr):
        """
        移除处理程序
        WARNING: 如果Handler使用了文件或数据库,则该方法线程不安全
        :param hdlr:
        :return:
        """
        if hdlr in self.handlers:
            self.handlers.remove(hdlr)