Python实现钉钉通报和邮件通知

概述

监控预警是一个自动化系统中常见的模块,我们希望系统出问题时能够主动把问题推送给维护人员,而不是要维护人员定时主动到服务端去查看各项服务的运行状态。

本文实现两种类型的消息通知功能:钉钉机器人通报和邮件通报。

基于Webhook的钉钉机器人推送消息

准备步骤

  1. 首先创建一个钉钉群聊,在群聊添加自定义机器人[

  2. 机器人安全设置

    目前支持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}&timestamp={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 > ![screenshot](https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png)\n > ###### 10点20分发布 [天气](https://www.dingtalk.com) \n"
         },
          "at": {
              "atMobiles": [
                  "150XXXXXXXX"
              ],
              "atUserIds": [
                  "user123"
              ],
              "isAtAll": false
          }
     }
    

    【注:只支持markdown语法的子集,详情参考文档

  • 整体跳转ActionCard类型

    {
        "actionCard": {
            "title": "乔布斯 20 年前想打造一间苹果咖啡厅,而它正是 Apple Store 的前身", 
            "text": "![screenshot](https://gw.alicdn.com/tfs/TB1ut3xxbsrBKNjSZFpXXcXhFXa-846-786.png) 
     ### 乔布斯 20 年前想打造的苹果咖啡厅 
     Apple Store 的设计正从原来满满的科技感走向生活化,而其生活化的走向其实可以追溯到 20 年前苹果一个建立咖啡馆的计划", 
            "btnOrientation": "0", 
            "singleTitle" : "阅读全文",
            "singleURL" : "https://www.dingtalk.com/"
        }, 
        "msgtype": "actionCard"
    }
    
  • 独立跳转ActionCard

    {
        "msgtype": "actionCard",
        "actionCard": {
            "title": "我 20 年前想打造一间苹果咖啡厅,而它正是 Apple Store 的前身", 
            "text": "![screenshot](https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png) \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}&timestamp={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)
CoolCats
CoolCats
理学学士

我的研究兴趣是时空数据分析、知识图谱、自然语言处理与服务端开发