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

380 lines
14 KiB
Python
Raw Permalink Normal View History

2020-01-21 19:39:14 +05:30
#!/usr/bin/env python
# _*_ coding:utf-8 _*_
# create time: 07/01/2018 11:35
__author__ = 'Devin -- http://zhangchuzhao.site'
import json
import time
import logging
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(object):
"""
钉钉群自定义机器人每个机器人每分钟最多发送20条支持文本text连接linkmarkdown三种消息类型
"""
def __init__(self, webhook):
"""
机器人初始化
:param webhook: 钉钉群自定义机器人webhook地址
"""
super(DingtalkChatbot, self).__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(object):
"""
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(ActionCard, self).__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(object):
"""
FeedCard类型单条消息格式
"""
def __init__(self, title, message_url, pic_url):
"""
初始化单条消息文本
:param title: 单条消息文本
:param message_url: 点击单条信息后触发的URL
:param pic_url: 点击单条消息后面图片触发的URL
"""
super(FeedLink, self).__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(object):
"""
ActionCard和FeedCard消息类型中的子控件
"""
def __init__(self, title, url, pic_url=None):
"""
CardItem初始化
@param title: 子控件名称
@param url: 点击子控件时触发的URL
@param pic_url: FeedCard的图片地址ActionCard时不需要故默认为None
"""
super(CardItem, self).__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()