389 lines
14 KiB
Python
389 lines
14 KiB
Python
#!/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()
|