alerta_alerta-contrib/plugins/dingtalk/dingtalkchatbot/chatbot.py

390 lines
14 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.

#!/usr/bin/env python
# create time: 07/01/2018 11:35
__author__ = 'Devin -- http://zhangchuzhao.site'
import json
import logging
import time
import requests
try:
JSONDecodeError = json.decoder.JSONDecodeError
except AttributeError:
JSONDecodeError = ValueError
def is_not_null_and_blank_str(content):
"""
非空字符串
:param content: 字符串
:return: 非空 - True空 - False
>>> is_not_null_and_blank_str('')
False
>>> is_not_null_and_blank_str(' ')
False
>>> is_not_null_and_blank_str(' ')
False
>>> is_not_null_and_blank_str('123')
True
"""
if content and content.strip():
return True
else:
return False
class DingtalkChatbot:
"""
钉钉群自定义机器人每个机器人每分钟最多发送20条支持文本text、连接link、markdown三种消息类型
"""
def __init__(self, webhook):
"""
机器人初始化
:param webhook: 钉钉群自定义机器人webhook地址
"""
super().__init__()
self.headers = {'Content-Type': 'application/json; charset=utf-8'}
self.webhook = webhook
self.times = 0
self.start_time = time.time()
def send_text(self, msg, is_at_all=False, at_mobiles=[], at_dingtalk_ids=[]):
"""
text类型
:param msg: 消息内容
:param is_at_all: @所有人时true否则为false可选
:param at_mobiles: 被@人的手机号(可选)
:param at_dingtalk_ids: 被@人的dingtalkId可选
:return: 返回消息发送结果
"""
data = {'msgtype': 'text', 'at': {}}
if is_not_null_and_blank_str(msg):
data['text'] = {'content': msg}
else:
logging.error('text类型消息内容不能为空')
raise ValueError('text类型消息内容不能为空')
if is_at_all:
data['at']['isAtAll'] = is_at_all
if at_mobiles:
at_mobiles = list(map(str, at_mobiles))
data['at']['atMobiles'] = at_mobiles
if at_dingtalk_ids:
at_dingtalk_ids = list(map(str, at_dingtalk_ids))
data['at']['atDingtalkIds'] = at_dingtalk_ids
logging.debug('text类型%s' % data)
return self.post(data)
def send_image(self, pic_url):
"""
image类型表情
:param pic_url: 图片表情链接
:return: 返回消息发送结果
"""
if is_not_null_and_blank_str(pic_url):
data = {
'msgtype': 'image',
'image': {
'picURL': pic_url
}
}
logging.debug('image类型%s' % data)
return self.post(data)
else:
logging.error('image类型中图片链接不能为空')
raise ValueError('image类型中图片链接不能为空')
def send_link(self, title, text, message_url, pic_url=''):
"""
link类型
:param title: 消息标题
:param text: 消息内容(如果太长自动省略显示)
:param message_url: 点击消息触发的URL
:param pic_url: 图片URL可选
:return: 返回消息发送结果
"""
if is_not_null_and_blank_str(title) and is_not_null_and_blank_str(text) and is_not_null_and_blank_str(message_url):
data = {
'msgtype': 'link',
'link': {
'text': text,
'title': title,
'picUrl': pic_url,
'messageUrl': message_url
}
}
logging.debug('link类型%s' % data)
return self.post(data)
else:
logging.error('link类型中消息标题或内容或链接不能为空')
raise ValueError('link类型中消息标题或内容或链接不能为空')
def send_markdown(self, title, text, is_at_all=False, at_mobiles=[], at_dingtalk_ids=[]):
"""
markdown类型
:param title: 首屏会话透出的展示内容
:param text: markdown格式的消息内容
:param is_at_all: 被@人的手机号在text内容里要有@手机号,可选)
:param at_mobiles: @所有人时true否则为false可选
:param at_dingtalk_ids: 被@人的dingtalkId可选
:return: 返回消息发送结果
"""
if is_not_null_and_blank_str(title) and is_not_null_and_blank_str(text):
data = {
'msgtype': 'markdown',
'markdown': {
'title': title,
'text': text
},
'at': {}
}
if is_at_all:
data['at']['isAtAll'] = is_at_all
if at_mobiles:
at_mobiles = list(map(str, at_mobiles))
data['at']['atMobiles'] = at_mobiles
if at_dingtalk_ids:
at_dingtalk_ids = list(map(str, at_dingtalk_ids))
data['at']['atDingtalkIds'] = at_dingtalk_ids
logging.debug('markdown类型%s' % data)
return self.post(data)
else:
logging.error('markdown类型中消息标题或内容不能为空')
raise ValueError('markdown类型中消息标题或内容不能为空')
def send_action_card(self, action_card):
"""
ActionCard类型
:param action_card: 整体跳转ActionCard类型实例或独立跳转ActionCard类型实例
:return: 返回消息发送结果
"""
if isinstance(action_card, ActionCard):
data = action_card.get_data()
logging.debug('ActionCard类型%s' % data)
return self.post(data)
else:
logging.error('ActionCard类型传入的实例类型不正确')
raise TypeError('ActionCard类型传入的实例类型不正确')
def send_feed_card(self, links):
"""
FeedCard类型
:param links: 信息集FeedLink数组
:return: 返回消息发送结果
"""
link_data_list = []
for link in links:
if isinstance(link, FeedLink) or isinstance(link, CardItem):
link_data_list.append(link.get_data())
if link_data_list:
# 兼容1、传入FeedLink或CardItem实例列表2、传入数据字典列表
links = link_data_list
data = {'msgtype': 'feedCard', 'feedCard': {'links': links}}
logging.debug('FeedCard类型%s' % data)
return self.post(data)
def post(self, data):
"""
发送消息内容UTF-8编码
:param data: 消息数据(字典)
:return: 返回发送结果
"""
self.times += 1
if self.times % 20 == 0:
if time.time() - self.start_time < 60:
logging.debug('钉钉官方限制每个机器人每分钟最多发送20条当前消息发送频率已达到限制条件休眠一分钟')
time.sleep(60)
self.start_time = time.time()
post_data = json.dumps(data)
try:
response = requests.post(
self.webhook, headers=self.headers, data=post_data)
except requests.exceptions.HTTPError as exc:
logging.error('消息发送失败, HTTP error: %d, reason: %s' %
(exc.response.status_code, exc.response.reason))
raise
except requests.exceptions.ConnectionError:
logging.error('消息发送失败HTTP connection error!')
raise
except requests.exceptions.Timeout:
logging.error('消息发送失败Timeout error!')
raise
except requests.exceptions.RequestException:
logging.error('消息发送失败, Request Exception!')
raise
else:
try:
result = response.json()
except JSONDecodeError:
logging.error('服务器响应异常,状态码:%s,响应内容:%s' %
(response.status_code, response.text))
return {'errcode': 500, 'errmsg': '服务器响应异常'}
else:
logging.debug('发送结果:%s' % result)
if result['errcode']:
error_data = {'msgtype': 'text', 'text': {
'content': '钉钉机器人消息发送失败,原因:%s' % result['errmsg']}, 'at': {'isAtAll': True}}
logging.error('消息发送失败,自动通知:%s' % error_data)
requests.post(self.webhook, headers=self.headers,
data=json.dumps(error_data))
return result
class ActionCard:
"""
ActionCard类型消息格式整体跳转、独立跳转
"""
def __init__(self, title, text, btns, btn_orientation=0, hide_avatar=0):
"""
ActionCard初始化
:param title: 首屏会话透出的展示内容
:param text: markdown格式的消息
:param btns: 按钮列表1按钮数量为1时整体跳转ActionCard类型2按钮数量大于1时独立跳转ActionCard类型
:param btn_orientation: 0按钮竖直排列1按钮横向排列可选
:param hide_avatar: 0正常发消息者头像1隐藏发消息者头像可选
"""
super().__init__()
self.title = title
self.text = text
self.btn_orientation = btn_orientation
self.hide_avatar = hide_avatar
btn_list = []
for btn in btns:
if isinstance(btn, CardItem):
btn_list.append(btn.get_data())
if btn_list:
btns = btn_list # 兼容1、传入CardItem示例列表2、传入数据字典列表
self.btns = btns
def get_data(self):
"""
获取ActionCard类型消息数据字典
:return: 返回ActionCard数据
"""
if is_not_null_and_blank_str(self.title) and is_not_null_and_blank_str(self.text) and len(self.btns):
if len(self.btns) == 1:
# 整体跳转ActionCard类型
data = {
'msgtype': 'actionCard',
'actionCard': {
'title': self.title,
'text': self.text,
'hideAvatar': self.hide_avatar,
'btnOrientation': self.btn_orientation,
'singleTitle': self.btns[0]['title'],
'singleURL': self.btns[0]['actionURL']
}
}
return data
else:
# 独立跳转ActionCard类型
data = {
'msgtype': 'actionCard',
'actionCard': {
'title': self.title,
'text': self.text,
'hideAvatar': self.hide_avatar,
'btnOrientation': self.btn_orientation,
'btns': self.btns
}
}
return data
else:
logging.error('ActionCard类型消息标题或内容或按钮数量不能为空')
raise ValueError('ActionCard类型消息标题或内容或按钮数量不能为空')
class FeedLink:
"""
FeedCard类型单条消息格式
"""
def __init__(self, title, message_url, pic_url):
"""
初始化单条消息文本
:param title: 单条消息文本
:param message_url: 点击单条信息后触发的URL
:param pic_url: 点击单条消息后面图片触发的URL
"""
super().__init__()
self.title = title
self.message_url = message_url
self.pic_url = pic_url
def get_data(self):
"""
获取FeedLink消息数据字典
:return: 本FeedLink消息的数据
"""
if is_not_null_and_blank_str(self.title) and is_not_null_and_blank_str(self.message_url) and is_not_null_and_blank_str(self.pic_url):
data = {
'title': self.title,
'messageURL': self.message_url,
'picURL': self.pic_url
}
return data
else:
logging.error('FeedCard类型单条消息文本、消息链接、图片链接不能为空')
raise ValueError('FeedCard类型单条消息文本、消息链接、图片链接不能为空')
class CardItem:
"""
ActionCard和FeedCard消息类型中的子控件
"""
def __init__(self, title, url, pic_url=None):
"""
CardItem初始化
@param title: 子控件名称
@param url: 点击子控件时触发的URL
@param pic_url: FeedCard的图片地址ActionCard时不需要故默认为None
"""
super().__init__()
self.title = title
self.url = url
self.pic_url = pic_url
def get_data(self):
"""
获取CardItem子控件数据字典
@return: 子控件的数据
"""
if is_not_null_and_blank_str(self.pic_url) and is_not_null_and_blank_str(self.title) and is_not_null_and_blank_str(self.url):
# FeedCard类型
data = {
'title': self.title,
'messageURL': self.url,
'picURL': self.pic_url
}
return data
elif is_not_null_and_blank_str(self.title) and is_not_null_and_blank_str(self.url):
# ActionCard类型
data = {
'title': self.title,
'actionURL': self.url
}
return data
else:
logging.error(
'CardItem是ActionCard的子控件时title、url不能为空是FeedCard的子控件时title、url、pic_url不能为空')
raise ValueError(
'CardItem是ActionCard的子控件时title、url不能为空是FeedCard的子控件时title、url、pic_url不能为空')
if __name__ == '__main__':
import doctest
doctest.testmod()