BUG FIX: ops_account返回必须使用渲染

This commit is contained in:
向乐🌌 2021-06-26 14:53:18 +08:00
parent 72fe535f3c
commit a9751d47b4
78 changed files with 10784 additions and 434 deletions

View File

@ -17,6 +17,10 @@ SECRET_KEY = 'nxnm3#&2tat_c2i6%$y74a)t$(3irh^gpwaleoja1kdv30fmcm'
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
# 不安全的内部初始密码,用于检验新密码
UN_SEC_PASSWORD = ['1qaz@WSX', '1234@Abc']
# 创建日志的路径 # 创建日志的路径
LOG_PATH = os.path.join(BASE_DIR, 'log') LOG_PATH = os.path.join(BASE_DIR, 'log')
# 如果地址不存在则会自动创建log文件夹 # 如果地址不存在则会自动创建log文件夹

View File

@ -45,16 +45,20 @@ Redis的安装和配置方法请自行百度比较简单
+ Uwsgi + Uwsgi
## 截图 ## 截图
![截图1](screenshot/Snipaste_2019-07-15_20-05-49.jpg) ![截图1](screenshot/111.png)
### 钉钉 ### 钉钉
![截图2](screenshot/Snipaste_2019-07-15_20-06-14.jpg) ![截图2](screenshot/112.png)
### 微信 ### 微信
![截图11](screenshot/微扫码11.png) ![截图11](screenshot/115.png)
![截图12](screenshot/微扫码12.png) ![截图11](screenshot/116.png)
#### 扫码成功之后:
![截图15](screenshot/113.png)
![截图15](screenshot/114.png)
## 钉钉必要条件: ## 钉钉必要条件:
#### 创建企业内部应用 #### 创建企业内部应用
* 在钉钉工作台中通过“自建应用”创建应用选择“企业内部开发”创建H5微应用或小程序在应用首页中获取应用的AgentId、AppKey、AppSecret。 * 在钉钉工作台中通过“自建应用”创建应用选择“企业内部开发”创建H5微应用或小程序在应用首页中获取应用的AgentId、AppKey、AppSecret。
@ -68,6 +72,7 @@ Redis的安装和配置方法请自行百度比较简单
![截图5](screenshot/h5微应用--权限管理.png) ![截图5](screenshot/h5微应用--权限管理.png)
#### 移动接入应用--登录权限: #### 移动接入应用--登录权限:
>登录中开启扫码登录配置回调域名“https://pwd.abc.com/callbackCheck” >登录中开启扫码登录配置回调域名“https://pwd.abc.com/callbackCheck”
其中pwd.abc.com请按自己实际域名来并记录相关的appId、appSecret。 其中pwd.abc.com请按自己实际域名来并记录相关的appId、appSecret。
@ -89,6 +94,10 @@ Redis的安装和配置方法请自行百度比较简单
![截图10](screenshot/微扫码16.png) ![截图10](screenshot/微扫码16.png)
## 飞书必要条件:
* 开放平台-->创建应-->网页开启-->配置回调url
> 飞书接口项目地址https://github.com/larksuite/feishu 感谢大佬,节省了不少时间。
## 使用脚本自动部署: ## 使用脚本自动部署:
使用脚本自动快速部署只适合Centos其它发行版本的Linux请自行修改相关命令。 使用脚本自动快速部署只适合Centos其它发行版本的Linux请自行修改相关命令。

View File

@ -6,5 +6,6 @@ dingtalk-sdk==1.3.8
cryptography==3.4.7 cryptography==3.4.7
ldap3==2.9 ldap3==2.9
django-redis==4.12.1 django-redis==4.12.1
feishu-python-sdk==0.1.4
requests requests
uwsgi uwsgi

View File

@ -150,7 +150,7 @@ def ops_account(ad_ops, request, msg_template, home_url, username, new_password)
unlock_status, result = ad_ops.ad_unlock_user_by_account(username) unlock_status, result = ad_ops.ad_unlock_user_by_account(username)
if unlock_status: if unlock_status:
context = { context = {
'msg': "密码己修改/重置成功,请妥善保管。你可以点击返回主页或直接关闭此页面!", 'msg': "密码己修改成功,请妥善保管。你可以点击返回主页或直接关闭此页面!",
'button_click': "window.location.href='%s'" % home_url, 'button_click': "window.location.href='%s'" % home_url,
'button_display': "返回主页" 'button_display': "返回主页"
} }

View File

@ -1,3 +1,4 @@
import json
import logging import logging
import os import os
from django.shortcuts import render from django.shortcuts import render
@ -6,31 +7,35 @@ from ldap3.core.exceptions import LDAPException
from utils.format_username import format2username, get_user_is_active from utils.format_username import format2username, get_user_is_active
from .form import CheckForm from .form import CheckForm
from .utils import code_2_user_info, crypto_id_2_user_info, ops_account, crypto_id_2_user_id, crypto_user_id_2_cookie from .utils import code_2_user_info, crypto_id_2_user_info, ops_account, crypto_id_2_user_id, crypto_user_id_2_cookie
from django.conf import settings
APP_ENV = os.getenv('APP_ENV') APP_ENV = os.getenv('APP_ENV')
if APP_ENV == 'dev': if APP_ENV == 'dev':
from conf.local_settings_dev import SCAN_CODE_TYPE, DING_MO_APP_ID, WEWORK_CORP_ID, WEWORK_AGENT_ID, HOME_URL from conf.local_settings_dev import SCAN_CODE_TYPE, DING_MO_APP_ID, WEWORK_CORP_ID, WEWORK_AGENT_ID, HOME_URL, DING_CORP_ID
else: else:
from conf.local_settings import SCAN_CODE_TYPE, DING_MO_APP_ID, WEWORK_CORP_ID, WEWORK_AGENT_ID, HOME_URL from conf.local_settings import SCAN_CODE_TYPE, DING_MO_APP_ID, WEWORK_CORP_ID, WEWORK_AGENT_ID, HOME_URL, DING_CORP_ID
msg_template = 'messages.html' msg_template = 'messages.v1.html'
logger = logging.getLogger('django') logger = logging.getLogger('django')
class PARAMS(object): class PARAMS(object):
if SCAN_CODE_TYPE == 'DING': if SCAN_CODE_TYPE == 'DING':
corp_id = DING_CORP_ID
app_id = DING_MO_APP_ID app_id = DING_MO_APP_ID
agent_id = None agent_id = None
SCAN_APP = '钉钉' SCAN_APP = '钉钉'
from utils.dingding_ops import DingDingOps from utils.dingding_ops import DingDingOps
ops = DingDingOps() ops = DingDingOps()
elif SCAN_CODE_TYPE == 'WEWORK': elif SCAN_CODE_TYPE == 'WEWORK':
corp_id = None
app_id = WEWORK_CORP_ID app_id = WEWORK_CORP_ID
agent_id = WEWORK_AGENT_ID agent_id = WEWORK_AGENT_ID
SCAN_APP = '微信' SCAN_APP = '微信'
from utils.wework_ops import WeWorkOps from utils.wework_ops import WeWorkOps
ops = WeWorkOps() ops = WeWorkOps()
else: else:
corp_id = None
app_id = WEWORK_CORP_ID app_id = WEWORK_CORP_ID
agent_id = WEWORK_AGENT_ID agent_id = WEWORK_AGENT_ID
SCAN_APP = '微信' SCAN_APP = '微信'
@ -56,16 +61,22 @@ def index(request):
:return: :return:
""" """
home_url = '%s://%s' % (request.scheme, HOME_URL) home_url = '%s://%s' % (request.scheme, HOME_URL)
corp_id = scan_params.corp_id
app_id = scan_params.app_id app_id = scan_params.app_id
agent_id = scan_params.agent_id agent_id = scan_params.agent_id
scan_app = scan_params.SCAN_APP
unsecpwd = settings.UN_SEC_PASSWORD
if request.method == 'GET' and SCAN_CODE_TYPE == 'DING': if request.method == 'GET' and SCAN_CODE_TYPE == 'DING':
return render(request, 'ding_index.html', locals()) return render(request, 'ding_index.html', locals())
elif request.method == 'GET' and SCAN_CODE_TYPE == 'WEWORK': elif request.method == 'GET' and SCAN_CODE_TYPE == 'WEWORK':
return render(request, 'we_index.html', locals()) return render(request, 'we_index.v1.html', locals())
elif request.method == 'GET' and SCAN_CODE_TYPE == 'FEISHU': elif request.method == 'GET' and SCAN_CODE_TYPE == 'FEISHU':
return render(request, 'feishu_index.html', locals()) return render(request, 'index.v1.html', locals())
else: else:
logger.error('[异常] 请求方法:%s,请求路径:%s' % (request.method, request.path)) logger.error('[异常] 请求方法:%s,请求路径%s' % (request.method, request.path))
#
# if request.method == 'GET':
# return render(request, 'index.v1.html', locals())
if request.method == 'POST': if request.method == 'POST':
# 对前端提交的数据进行二次验证,防止恶意提交简单密码或篡改账号。 # 对前端提交的数据进行二次验证,防止恶意提交简单密码或篡改账号。
@ -167,7 +178,7 @@ def reset_pwd_by_callback(request):
context = { context = {
'username': username, 'username': username,
} }
return render(request, 'resetPassword.html', context) return render(request, 'resetPassword.v1.html', context)
else: else:
context = { context = {
'msg': "{},您好,企业{}中未能找到您账号的邮箱配置请联系HR完善信息。".format(user_info.get('name'), scan_params.SCAN_APP), 'msg': "{},您好,企业{}中未能找到您账号的邮箱配置请联系HR完善信息。".format(user_info.get('name'), scan_params.SCAN_APP),
@ -217,7 +228,7 @@ def unlock_account(request):
context = { context = {
'username': username, 'username': username,
} }
return render(request, 'resetPassword.html', context) return render(request, 'resetPassword.v1.html', context)
elif request.method == 'POST': elif request.method == 'POST':
_status, user_info = crypto_id_2_user_info(_ops, request, msg_template, home_url, scan_params.SCAN_APP) _status, user_info = crypto_id_2_user_info(_ops, request, msg_template, home_url, scan_params.SCAN_APP)

BIN
screenshot/111.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

BIN
screenshot/112.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

BIN
screenshot/113.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
screenshot/114.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

BIN
screenshot/115.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

BIN
screenshot/116.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

320
static/css/dmaku.css Normal file
View File

@ -0,0 +1,320 @@
* {
box-sizing: border-box;
}
body {
font-family: 'Montserrat', sans-serif;
background: #f6f5f7;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: -20px 0 50px;
}
h1 {
font-weight: bold;
margin: 0;
}
p {
font-size: 4px;
line-height: 20px;
letter-spacing: .5px;
margin: 20px 0 30px;
}
span {
font-size: 12px;
}
a {
color: #333;
font-size: 14px;
text-decoration: none;
margin: 15px 0;
}
.head-container {
background: url(/static/img/logo.png) left center no-repeat;
overflow: hidden;
width: 768px;
max-width: 100%;
height: 100px;
max-height: 100px;
color: #1b83d8;
vertical-align: middle;
display: inline-block;
}
.head-container p {
float: right;
margin-top: 28px;
padding: 10px 30px;
font-size: 28px;
vertical-align: middle;
text-shadow: 0 14px 28px rgba(0, 0, 0, .25), 0 5px 5px rgba(0, 0, 0, .22);
}
.middle-container {
background: #fff;
border-radius: 10px;
box-shadow: 0 14px 28px rgba(0, 0, 0, .25), 0 10px 10px rgba(0, 0, 0, .22);
position: relative;
overflow: hidden;
width: 768px;
max-width: 100%;
min-height: 480px;
}
.form-container form {
background: #fff;
display: flex;
flex-direction: column;
padding: 0 50px;
height: 100%;
justify-content: center;
align-items: center;
text-align: center;
}
.social-container {
margin: 20px 0;
}
.social-container a {
border: 1px solid #ddd;
border-radius: 50%;
display: inline-flex;
justify-content: center;
align-items: center;
margin: 0 5px;
height: 40px;
width: 40px;
}
.social-container a:hover {
background-color: #eee;
}
.form-container input {
background: #eee;
border: none;
padding: 12px 15px;
margin: 8px 0;
width: 100%;
outline: none;
}
button {
border-radius: 20px;
border: 1px solid #1b83d8;
background: #1b83d8;
color: #fff;
font-size: 12px;
font-weight: bold;
padding: 12px 45px;
letter-spacing: 1px;
text-transform: uppercase;
transition: transform 80ms ease-in;
cursor: pointer;
}
button:active {
transform: scale(.95);
}
button:focus {
outline: none;
}
button.ghost {
background: transparent;
border-color: #fff;
}
.form-container {
position: absolute;
top: 0;
height: 100%;
transition: all .6s ease-in-out;
}
.left-content-container {
left: 0;
width: 50%;
z-index: 2;
}
.right-content-container {
left: 0;
width: 50%;
z-index: 1;
opacity: 0;
}
.overlay-container {
position: absolute;
top: 0;
left: 50%;
width: 50%;
height: 100%;
overflow: hidden;
transition: transform .6s ease-in-out;
z-index: 100;
}
.overlay {
background: #41aaff;
background: linear-gradient(to right, #1b83d8, #6ebbfa) no-repeat 0 0 / cover;
color: #fff;
position: relative;
left: -100%;
height: 100%;
width: 200%;
transform: translateY(0);
transition: transform .6s ease-in-out;
}
.overlay-panel {
position: absolute;
top: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0 40px;
height: 100%;
width: 50%;
text-align: center;
transform: translateY(0);
transition: transform .6s ease-in-out;
}
.overlay-right {
right: 0;
transform: translateY(0);
}
.overlay-left {
transform: translateY(-20%);
}
/* Move signin to right */
.middle-container.right-panel-active .left-content-container {
transform: translateY(100%);
}
/* Move overlay to left */
.middle-container.right-panel-active .overlay-container {
transform: translateX(-100%);
}
/* Bring signup over signin */
.middle-container.right-panel-active .right-content-container {
transform: translateX(100%);
opacity: 1;
z-index: 5;
}
/* Move overlay back to right */
.middle-container.right-panel-active .overlay {
transform: translateX(50%);
}
/* Bring back the text to center */
.middle-container.right-panel-active .overlay-left {
transform: translateY(0);
}
/* Same effect for right */
.middle-container.right-panel-active .overlay-right {
transform: translateY(20%);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

69
static/js/alert.js Normal file
View File

@ -0,0 +1,69 @@
(function ($) {
if (!$) {
throw new Error('jQuery is undefined!');
}
$('head').append(
"<style>.hide-scroll{height:100vh;overflow:hidden}.wrap_overlay_drak{position:fixed;top:0;bottom:0;left:0;right:0;background-color:rgba(99,99,99,.3);z-index:999999;font-size:14px}.wrap_overlay_drak .wrap_overlay{position:fixed;width:400px;margin-left:-220px;padding:10px 20px;transform:translate(0,-180px);left:50%;top:50%;opacity:.3;box-shadow:0 2px 10px rgba(99,99,99,.3);background:#fff;transition:all .15s linear;border-radius:5px}.wrap_overlay_drak .wrap_overlay.wrap_overlay_show{transform:translate(0,-150px);opacity:1}.wrap_overlay_drak .wrap_overlay #confirm_msg{z-index:9998}.wrap_overlay_drak .wrap_overlay .content_overlay{padding:20px;font-size:14px;text-align:left}.wrap_overlay_drak .wrap_overlay #alert_buttons,.wrap_overlay_drak .wrap_overlay #confirm_buttons{padding:10px;text-align:right;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}.wrap_overlay_drak .wrap_overlay .alert_btn{padding:5px 15px;margin:0 5px;background:#3187de;cursor:pointer;color:#fff;border:none;border-radius:5px;font-size:14px;outline:0;-webkit-appearance:none}.wrap_overlay_drak .wrap_overlay .alert_btn_cancel{background:0 0;color:#409eff;border:1px solid #ddd}.wrap_overlay_drak .wrap_overlay #alert_buttons .alert_btn:hover,.wrap_overlay_drak .wrap_overlay #confirm_buttons .alert_btn:hover{opacity:.7}</style>"
);
$.extend({
alert: function () {
var args = arguments;
if (
args.length &&
typeof args[0] == 'string' &&
!$('#alert_msg').length
) {
var dialog = $(
'<div class="wrap_overlay_drak"><div class="wrap_overlay" id="alert_msg"><div class="content_overlay">' +
args[0] +
'</div><div id="alert_buttons"><button class="alert_btn alert_btn_ok">确定</button></div></div></div>'
);
dialog
.on('click', '.alert_btn_ok', function () {
$('.wrap_overlay').removeClass('wrap_overlay_show');
setTimeout(function () {
dialog.remove();
}, 150);
if (typeof args[1] == 'function') args[1].call($, !0);
})
.appendTo('body');
setTimeout(function () {
$('.wrap_overlay').addClass('wrap_overlay_show');
}, 10);
}
},
confirm: function () {
var args = arguments;
if (
args.length &&
typeof args[0] == 'string' &&
!$('#confirm_msg').length
) {
var dialog = $(
'<div class="wrap_overlay_drak"><div class="wrap_overlay" id="confirm_msg"><div class="content_overlay">' +
args[0] +
'</div><div id="confirm_buttons"><button class="alert_btn alert_btn_ok">确定</button><button class="alert_btn alert_btn_cancel">取消</button></div></div></div>'
);
dialog
.on('click', '#confirm_buttons .alert_btn_ok', function () {
$('.wrap_overlay').removeClass('wrap_overlay_show');
setTimeout(function () {
dialog.remove();
if (typeof args[1] == 'function') args[1].call($, !0);
}, 200);
})
.on('click', '#confirm_buttons .alert_btn_cancel', function () {
$('.wrap_overlay').removeClass('wrap_overlay_show');
setTimeout(function () {
dialog.remove();
if (typeof args[1] == 'function') args[1].call($, !1);
}, 200);
})
.appendTo('body');
setTimeout(function () {
$('.wrap_overlay').addClass('wrap_overlay_show');
}, 10);
}
},
});
})(jQuery);

1
static/js/bubbly-bg.js Normal file
View File

@ -0,0 +1 @@
"use strict";window.bubbly=function(t){var n=t||{},o=function(){return Math.random()},r=n.canvas||document.createElement("canvas"),e=r.width,a=r.height;null===r.parentNode&&(r.setAttribute("style","position:fixed;z-index:-1;left:0;top:0;min-width:100vw;min-height:100vh;"),e=r.width=window.innerWidth,a=r.height=window.innerHeight,document.body.appendChild(r));var i=r.getContext("2d");i.shadowColor=n.shadowColor||"#fff",i.shadowBlur=n.blur||4;var l=i.createLinearGradient(0,0,e,a);l.addColorStop(0,n.colorStart||"#2AE"),l.addColorStop(1,n.colorStop||"#17B");for(var c=n.bubbles||Math.floor(.02*(e+a)),u=[],d=0;d<c;d++)u.push({f:(n.bubbleFunc||function(){return"hsla(0, 0%, 100%, "+.1*o()+")"}).call(),x:o()*e,y:o()*a,r:(n.radiusFunc||function(){return 4+o()*e/25}).call(),a:(n.angleFunc||function(){return o()*Math.PI*2}).call(),v:(n.velocityFunc||function(){return.1+.5*o()}).call()});!function t(){if(null===r.parentNode)return cancelAnimationFrame(t);!1!==n.animate&&requestAnimationFrame(t),i.globalCompositeOperation="source-over",i.fillStyle=l,i.fillRect(0,0,e,a),i.globalCompositeOperation=n.compose||"lighter",u.forEach(function(t){i.beginPath(),i.arc(t.x,t.y,t.r,0,2*Math.PI),i.fillStyle=t.f,i.fill(),t.x+=Math.cos(t.a)*t.v,t.y+=Math.sin(t.a)*t.v,t.x-t.r>e&&(t.x=-t.r),t.x+t.r<0&&(t.x=e+t.r),t.y-t.r>a&&(t.y=-t.r),t.y+t.r<0&&(t.y=a+t.r)})}()};

View File

@ -1,87 +1,39 @@
$(function () { 
$(".content .con_right .left").click(function (e) { function BtnClick(btn, type, unsecpwd) {
$(this).css({ "color": "#333333", "border-bottom": "2px solid #2e558e" }); $(btn).click(function () {
$(".content .con_right .right").css({ "color": "#999999", "border-bottom": "2px solid #dedede" });
$(".content .con_right ul .con_r_left").css("display", "block");
$(".content .con_right ul .con_r_right").css("display", "none");
});
$(".content .con_right .right").click(function (e) {
$(this).css({ "color": "#333333", "border-bottom": "2px solid #2e558e" });
$(".content .con_right .left").css({ "color": "#999999", "border-bottom": "2px solid #dedede" });
$(".content .con_right ul .con_r_right").css("display", "block");
$(".content .con_right ul .con_r_left").css("display", "none");
});
$('#btn_modify').click(function () {
// ^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$\%\^\&\*\(\)])[0-9a-zA-Z!@#$\%\^\&\*\(\)]{8,32}$ 要求密码了里面包含字母、数字、特殊字符。 // ^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$\%\^\&\*\(\)])[0-9a-zA-Z!@#$\%\^\&\*\(\)]{8,32}$ 要求密码了里面包含字母、数字、特殊字符。
// (?=.*[0-9])(?=.*[a-zA-Z])(?=.*[^a-zA-Z0-9]).{8,30} 密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位 // (?=.*[0-9])(?=.*[a-zA-Z])(?=.*[^a-zA-Z0-9]).{8,30} 密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位
// (?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d)(?=.*?[!#@*&.])[a-zA-Z\d!#@*&.]*{8,30}$ // (?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d)(?=.*?[!#@*&.])[a-zA-Z\d!#@*&.]*{8,30}$
// 判断密码满足大写字母,小写字母,数字和特殊字符,其中四种组合都需要包含 // 判断密码满足大写字母,小写字母,数字和特殊字符,其中四种组合都需要包含
// (?=.*[0-9])(?=.*[a-zA-Z]).{8,30} 大小写字母+数字 // (?=.*[0-9])(?=.*[a-zA-Z]).{8,30} 大小写字母+数字
regex_mail = new RegExp('^\\w+((-\\w+)|(\\.\\w+))*\\@[A-Za-z0-9]+((\\.|-)[A-Za-z0-9]+)*\\.[A-Za-z0-9]+$')
regex_pwd = new RegExp('(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[^a-zA-Z0-9]).{8,30}');
//if ($.trim($('#user_email').val()) === '') {
// alert('请输入邮箱账号');
// return false;
//} else if (!regex_mail.test($.trim($('#user_email').val()))) {
// alert('请输入正确的邮箱账号。\n');
// return false;
//} else
if ($.trim($('#old_password').val()) === '') {
alert('请输入旧密码');
return false;
} else if ($.trim($('#new_password').val()) === '') {
alert('请输入新密码');
return false;
} else if ($.trim($('#new_password').val()) === '1qaz@WSX') {
alert('密码1qaz@WSX为初始密码禁止使用请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。');
return false;
} else if (!regex_pwd.test($.trim($('#new_password').val()))) {
alert('密码不符合复杂度规则,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。');
return false;
} else if ($.trim($('#ensure_password').val()) === '') {
alert('请再次输入新密码');
return false;
} else if ($.trim($('#new_password').val()) === $.trim($('#old_password').val())) {
alert('新旧密码不能一样');
return false;
} else if ($.trim($('#ensure_password').val()) !== $.trim($('#new_password').val())) {
alert('两次输入的新密码不一致');
return false;
} else {
return true;
}
});
$('#btn_reset').click(function () {
let regex_mail = new RegExp('^\\w+((-\\w+)|(\\.\\w+))*\\@[A-Za-z0-9]+((\\.|-)[A-Za-z0-9]+)*\\.[A-Za-z0-9]+$') let regex_mail = new RegExp('^\\w+((-\\w+)|(\\.\\w+))*\\@[A-Za-z0-9]+((\\.|-)[A-Za-z0-9]+)*\\.[A-Za-z0-9]+$')
let regex_pwd = new RegExp('(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[^a-zA-Z0-9]).{8,30}'); let regex_pwd = new RegExp('(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[^a-zA-Z0-9]).{8,30}');
//if ($.trim($('#user_email').val()) === '') { let new_password = $('#new_password').val()
//alert('请输入邮箱账号'); let old_password = $('#old_password').val()
// return false; let ensure_password = $('#ensure_password').val()
//} else if (!regex_mail.test($.trim($('#user_email').val()))) { if ($.trim(old_password) === '' && type === 'modify') {
// alert('请输入正确的邮箱账号。\n'); $.alert('请输入旧密码');
// return false;
//} else
if ($.trim($('#new_password').val()) === '') {
alert('请输入密码');
return false; return false;
} else if ($.trim($('#ensure_password').val()) === '') { } else if ($.trim(new_password) === '') {
alert('请再次输入新密码'); $.alert('请输入新密码');
return false; return false;
} else if ($.trim($('#new_password').val()) === '1qaz@WSX') { } else if (jQuery.inArray(new_password, unsecpwd) !== -1) {
alert('密码1qaz@WSX为初始密码禁止使用,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。'); $.alert('弱密码禁止使用,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。');
return false; return false;
} else if (!regex_pwd.test($.trim($('#new_password').val()))) { } else if (!regex_pwd.test($.trim(new_password))) {
alert('密码不符合复杂度规则,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。\n例如1qaz@WSX'); $.alert('密码不符合复杂度规则,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。');
return false; return false;
} else if ($.trim($('#ensure_password').val()) !== $.trim($('#new_password').val())) { } else if ($.trim(ensure_password) === '') {
alert('两次输入的新密码不一致'); $.alert('请再次输入新密码');
return false;
} else if ($.trim(new_password) === $.trim(old_password)) {
$.alert('新旧密码不能一样');
return false;
} else if ($.trim(ensure_password) !== $.trim(new_password)) {
$.alert('两次输入的新密码不一致');
return false; return false;
} else { } else {
return true; return true;
} }
}); });
}) }

View File

@ -1,18 +1,19 @@
!function (window, document) { !function (window, document) {
function d(a) { function d(a) {
var e, c = document.createElement("iframe"), var e, c = document.createElement("iframe"),
d = "https://login.dingtalk.com/login/qrcode.htm?goto=" + a.goto ; d = "https://login.dingtalk.com/login/qrcode.htm?goto=" + a.goto;
d += a.style ? "&style=" + encodeURIComponent(a.style) : "", d += a.style ? "&style=" + encodeURIComponent(a.style) : "",
d += a.href ? "&href=" + a.href : "", d += a.href ? "&href=" + a.href : "",
c.src = d, c.src = d,
c.frameBorder = "0", c.frameBorder = "0",
c.allowTransparency = "true", c.allowTransparency = "true",
c.scrolling = "no", c.scrolling = "no",
c.width = a.width ? a.width + 'px' : "365px", c.width = a.width ? a.width + 'px' : "365px",
c.height = a.height ? a.height + 'px' : "400px", c.height = a.height ? a.height + 'px' : "400px",
e = document.getElementById(a.id), e = document.getElementById(a.id),
e.innerHTML = "", e.innerHTML = "",
e.appendChild(c) e.appendChild(c)
} }
window.DDLogin = d window.DDLogin = d
}(window, document); }(window, document);

14
static/js/dmaku.js Normal file
View File

@ -0,0 +1,14 @@
var scanCodeButton = document.getElementById('scanCode')
var modifyPwdButton = document.getElementById('modifyPwd')
var container = document.getElementById('middle-container')
if (scanCodeButton !== null) {
scanCodeButton.addEventListener('click', function () {
container.classList.add('right-panel-active')
})
}
if (modifyPwdButton !== null) {
modifyPwdButton.addEventListener('click', function () {
container.classList.remove('right-panel-active')
});
}

View File

@ -0,0 +1,24 @@
!function(a, b, c) {
function d(c) {
var d = b.createElement("iframe"),
e = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid=" + c.appid + "&agentid=" + c.agentid + "&redirect_uri=" + c.redirect_uri + "&state=" + c.state + "&login_type=jssdk";
e += c.style ? "&style=" + c.style: "",
e += c.href ? "&href=" + c.href: "",
d.src = e,
d.frameBorder = "0",
d.allowTransparency = "true",
d.scrolling = "no",
d.width = "300px",
d.height = "400px";
var f = b.getElementById(c.id);
f.innerHTML = "",
f.appendChild(d),
d.onload = function() {
d.contentWindow.postMessage && a.addEventListener && (a.addEventListener("message",
function(b) {
b.data && b.origin.indexOf("work.weixin.qq.com") > -1 && (a.location.href = b.data)
}), d.contentWindow.postMessage("ask_usePostMessage", "*"))
}
}
a.WwLogin = d
} (window, document);

40
templates/base.html Normal file
View File

@ -0,0 +1,40 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自助密码平台</title>
<link rel="stylesheet" href="{% static 'css/dmaku.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/alert.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
{% block head %}{% endblock %}
</head>
<body>
<div class="head-container" id="head-container">
<p>
密码自助服务平台
</p>
</div>
<div class="middle-container" id="middle-container">
<div class="form-container right-content-container">
{% block right-content %} {% endblock %}
</div>
<div class="form-container left-content-container">
{% block left-content %} {% endblock %}
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
{% block left-overlay %} {% endblock %}
</div>
<div class="overlay-panel overlay-right">
{% block right-overlay %} {% endblock %}
</div>
</div>
</div>
</div>
<script src="{% static 'js/dmaku.js' %}"></script>
{% block footer %} {% endblock %}
</body>
</html>

View File

@ -1,15 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>密码自助服务</title>
<link type="text/css" rel="stylesheet" href="{% static 'css/login.css' %}">
<link type="text/css" rel="stylesheet" href="{% static 'css/load.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
</head>
<body style="overflow: hidden">
<form name="callbackCheck" method="post" action="resetPassword" autocomplete="off">
{% csrf_token %}
</form>
</body></html>

View File

@ -1,118 +1,81 @@
{% extends 'base.html' %}
{% load static %} {% load static %}
<!DOCTYPE html> {% block head %}
<html xmlns="http://www.w3.org/1999/xhtml"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <script type="text/javascript" src="{% static 'js/ddLogin-0.0.5.js' %}"></script>
<title>密码自助服务</title> {% endblock %}
<link type="text/css" rel="stylesheet" href="{% static 'css/login.css' %}"> {% block right-content %}
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script> <form action="" name="ding_qrcode">
<script type="text/javascript" src="{% static 'js/check.js' %}"></script> <div style="width: 300px; height: 300px; margin: 0 auto" id="ding_code"></div>
<script src="https://g.alicdn.com/dingding/dinglogin/0.0.5/ddLogin.js"></script> <p>使用钉钉扫一扫进行登录验证</p>
</head> <script type="text/javascript">
<body> // 构造钉钉登录二唯码
<div class="pagewrap"> var home_url = "{{ home_url }}";
<div class="main"> var app_id = "{{ app_id }}";
<div class="header"> var redirect_url = encodeURIComponent(home_url + '/callbackCheck');
var goto = encodeURIComponent('https://oapi.dingtalk.com/connect/qrconnect?appid='
</div> + app_id
<div class="content"> + '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri='
<div class="con_left" > + redirect_url);
<div style="margin: 0 auto; width:100%; height: 200px; line-height: 200px;" align="center"> console.log(goto)
<p style="margin: 0 auto; color: #fdfdfe; font-size: 36px; width:100%; ">「域账号或邮箱」<small>密码自助平台</small></p> DDLogin({
</div> id: "ding_code",
<div style="margin: 0 auto; width:400px; height: 240px;"> goto: goto,
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%; style: "border:none;background-color:#FFFFFF;",
">提示新密码要求满足8至30位长度(不包含空格),至少包含大小写字母及数字组成。</p> width: "300",
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%; height: "300"
">如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。</p> });
</div> // 扫码后的操作
</div> var hanndleMessage = function (event) {
<div class="con_right"> var origin = event.origin;
<div class="con_r_top"> console.log("origin", event.origin)
<a href="javascript:" class="right" style="color: rgb(51, 51, 51); border-bottom-width: 2px; border-bottom-style: solid; border-bottom-color: rgb(46, 85, 142);">修改密码</a> if (origin === "https://login.dingtalk.com") {
<a href="javascript:" class="left" style="color: rgb(153, 153, 153); border-bottom-width: 2px; border-bottom-style: solid; border-bottom-color: rgb(222, 222, 222);">重置/解锁</a> var loginTmpCode = event.data;
</div> console.log("loginTmpCode", loginTmpCode);
<ul> if (loginTmpCode) {
<li class="con_r_right" style="display: block;"> //拿到loginTmpCode后就可以在这里构造跳转链接进行跳转了
<form name="modifypwd" method="post" action="" autocomplete="off"> location.href = 'https://oapi.dingtalk.com/connect/oauth2/sns_authorize?appid='
{% csrf_token %} + app_id
<div class="user"> + '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri='
<div><span class="user-icon"></span> + redirect_url
<input type="text" id="username" name="username" placeholder="格式abc\lisi、lisi、lisi@abc.com" value=""> + '&loginTmpCode=' + loginTmpCode;
</div> }
<div><span class="mima-icon"></span> }
<input type="password" id="old_password" name="old_password" };
placeholder=" 输入旧密码" value=""> if (typeof window.addEventListener !== 'undefined') {
</div> window.addEventListener('message', hanndleMessage, false);
<div><span class="mima-icon"></span> } else if (typeof window.attachEvent !== 'undefined') {
<input type="password" id="new_password" name="new_password" window.attachEvent('onmessage', hanndleMessage);
placeholder=" 输入新密码" value=""> }
</div> </script>
<div><span class="mima-icon"></span> </form>
<input type="password" id="ensure_password" name="ensure_password" {% endblock %}
placeholder=" 再次输入新密码" value=""> {% block left-content %}
</div> <form action="/" method="post" autocomplete="off">
</div><br> {% csrf_token %}
<button id="btn_modify" type="submit">修改密码</button> <h1>修改密码</h1>
</form> <span>新密码8至30位长度要求包含大小写字母及数字。</span>
</li> <input type="text" id="username" name="username" placeholder="账号格式abc\lisi、lisi、lisi@abc.com">
<input type="password" id="old_password" name="old_password" placeholder="旧密码">
<li class="con_r_left" style="display: none;"> <input type="password" id="new_password" name="new_password" placeholder="新密码">
<div style="margin-top: -30px" class="erweima"> <input type="password" id="ensure_password" name="ensure_password" placeholder="再次确认新密码">
<div style="width: 300px; height: 300px; margin: 0 auto" id="ding_code"></div> <p></p>
<script type="text/javascript"> <button id="btn_modify" type="submit">提交</button>
// 构造钉钉登录 </form>
// 扫描之后需要跳转的域名填写自己的修改密码的域名地址http或https {% endblock %}
var home_url = "{{ home_url }}"; {% block left-overlay %}
// 钉钉移动应用接入ID <h1>我要修改密码</h1>
var app_id = "{{ app_id }}"; <p>记得自己的旧密码,需要自行修改</p>
var redirect_url = encodeURIComponent(home_url + '/callbackCheck'); <p>⬇️点它</p>
var goto = encodeURIComponent('https://oapi.dingtalk.com/connect/qrconnect?appid=' <button class="ghost" id="modifyPwd">自助修改密码</button>
+ app_id {% endblock %}
+ '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri=' {% block right-overlay %}
+ redirect_url); <h1>忘记密码或被锁</h1>
var obj = DDLogin({ <p>如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置</p>
id: "ding_code", <p>⬇️点它</p>
goto: goto, <button class="ghost" id="scanCode">扫码验证</button>
style: "border:none;background-color:#FFFFFF;", {% endblock %}
width: "300", {% block footer %}
height: "300" <script>
}); BtnClick("#btn_modify", 'modify', {{ unsecpwd|safe }})
var hanndleMessage = function (event) { </script>
var origin = event.origin; {% endblock %}
console.log("origin", event.origin)
//判断是否来自ddLogin扫码事件。
if (origin === "https://login.dingtalk.com") {
var loginTmpCode = event.data;
console.log("loginTmpCode", loginTmpCode);
if (loginTmpCode) {
//拿到loginTmpCode后就可以在这里构造跳转链接进行跳转了
location.href = 'https://oapi.dingtalk.com/connect/oauth2/sns_authorize?appid='
+ app_id
+ '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri='
+ redirect_url
+ '&loginTmpCode=' + loginTmpCode;
}
}
};
if (typeof window.addEventListener !== 'undefined') {
window.addEventListener('message', hanndleMessage, false);
} else if (typeof window.attachEvent !== 'undefined') {
window.attachEvent('onmessage', hanndleMessage);
}
</script>
</div>
<div style="height: 70px; margin-top: -30px">
<p style="font-size: 18px; color: #2e558e" align="center">钉钉扫码验证用户信息</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<script type="text/javascript">
window.onload=function() {
if (!!window.ActiveXObject || "ActiveXObject" in window)
alert("您当前使用的浏览器为IE或IE内核因为IE各种体验问题本网站不对IE兼容。\n为能正常使用密码自助修改服务请更换谷歌、火狐等非IE核心的浏览器。\n如果是360、Maxthon等这类双核心浏览器请切换至[极速模式]亦可。")
}
</script>
</body></html>

View File

@ -0,0 +1,102 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自助密码平台</title>
<link rel="stylesheet" href="{% static 'css/dmaku.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/alert.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
<script type="text/javascript" src="{% static 'js/ddLogin-0.0.5.js' %}"></script>
</head>
<body>
<div class="head-container" id="head-container">
<p>
密码自助服务平台
</p>
</div>
<div class="middle-container" id="middle-container">
<div class="form-container right-content-container">
<form action="" name="ding_qrcode">
<div style="width: 300px; height: 300px; margin: 0 auto" id="ding_code"></div>
<p>使用钉钉扫一扫进行登录验证</p>
<script type="text/javascript">
// 构造钉钉登录二唯码
var home_url = "{{ home_url }}";
var app_id = "{{ app_id }}";
var redirect_url = encodeURIComponent(home_url + '/callbackCheck');
var goto = encodeURIComponent('https://oapi.dingtalk.com/connect/qrconnect?appid='
+ app_id
+ '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri='
+ redirect_url);
console.log(goto)
DDLogin({
id: "ding_code",
goto: goto,
style: "border:none;background-color:#FFFFFF;",
width: "300",
height: "300"
});
// 扫码后的操作
var hanndleMessage = function (event) {
var origin = event.origin;
console.log("origin", event.origin)
if (origin === "https://login.dingtalk.com") {
var loginTmpCode = event.data;
console.log("loginTmpCode", loginTmpCode);
if (loginTmpCode) {
//拿到loginTmpCode后就可以在这里构造跳转链接进行跳转了
location.href = 'https://oapi.dingtalk.com/connect/oauth2/sns_authorize?appid='
+ app_id
+ '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri='
+ redirect_url
+ '&loginTmpCode=' + loginTmpCode;
}
}
};
if (typeof window.addEventListener !== 'undefined') {
window.addEventListener('message', hanndleMessage, false);
} else if (typeof window.attachEvent !== 'undefined') {
window.attachEvent('onmessage', hanndleMessage);
}
</script>
</form>
</div>
<div class="form-container left-content-container">
<form action="/" method="post" autocomplete="off">
{% csrf_token %}
<h1>修改密码</h1>
<span>新密码8至30位长度要求包含大小写字母及数字。</span>
<input type="text" id="username" name="username" placeholder="账号格式abc\lisi、lisi、lisi@abc.com">
<input type="password" id="old_password" name="old_password" placeholder="旧密码">
<input type="password" id="new_password" name="new_password" placeholder="新密码">
<input type="password" id="ensure_password" name="ensure_password" placeholder="再次确认新密码">
<p></p>
<button id="btn_modify" type="submit">提交</button>
</form>
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>我要修改密码</h1>
<p>记得自己的旧密码,需要自行修改</p>
<p>⬇️点它</p>
<button class="ghost" id="modifyPwd">自助修改密码</button>
</div>
<div class="overlay-panel overlay-right">
<h1>忘记密码或被锁</h1>
<p>如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置</p>
<p>⬇️点它</p>
<button class="ghost" id="scanCode">扫码验证</button>
</div>
</div>
</div>
</div>
<script src="{% static 'js/dmaku.js' %}"></script>
<script>
BtnClick("#btn_modify", 'modify', {{ unsecpwd|safe }})
</script>
</body>
</html>

View File

@ -0,0 +1,78 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自助密码平台</title>
<link rel="stylesheet" href="{% static 'css/dmaku.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/alert.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
<script type="text/javascript" src="{% static 'js/wwLogin-1.0.0.js' %}"></script>
</head>
<body>
<div class="head-container" id="head-container">
<p>
密码自助服务平台
</p>
</div>
<div class="middle-container" id="middle-container">
<div class="form-container right-content-container">
<form action="">
<div style="width: 300px; height: 300px; margin: 0 auto" id="feishu_code"></div>
<script type="text/javascript">
let home_url = "{{ home_url }}";
let app_id = "{{ app_id }}";
let agent_id = "{{ agent_id }}"
let redirect_url = encodeURIComponent(home_url + '/callbackCheck');
window.WwLogin({
id: "we_code",
appid: app_id,
agentid: agent_id,
redirect_uri: redirect_url,
// 样式使用base64加密而不使用https的方式
href: 'data:text/css;base64, ' +
'LmltcG93ZXJCb3ggLnRpdGxlIHtkaXNwbGF5OiBub25lO30KLmltcG93ZXJCb3ggLnFyY29kZSB7d2lkdGg6IDIyMHB4O30KLmltcG93ZXJCb3ggLmluZm8ge3dpZHRoOiAyMjBweDt9Ci5zdGF0dXNfaWNvbiB7ZGlzcGxheTogbm9uZSAgIWltcG9ydGFudH0KLmltcG93ZXJCb3ggLnN0YXR1cy5zdGF0dXNfYnJvd3NlciB7ZGlzcGxheTogbm9uZTt9Ci5pbXBvd2VyQm94IC5zdGF0dXMge3RleHQtYWxpZ246IGNlbnRlcjt9'
});
</script>
<p>使用企业微信扫一扫</p>
</form>
</div>
<div class="form-container left-content-container">
<form action="/" method="post" autocomplete="off">
{% csrf_token %}
<h1>修改密码</h1>
<span>新密码8至30位长度要求包含大小写字母及数字。</span>
<input type="text" id="username" name="username" placeholder="账号格式abc\lisi、lisi、lisi@abc.com">
<input type="password" id="old_password" name="old_password" placeholder="旧密码">
<input type="password" id="new_password" name="new_password" placeholder="新密码">
<input type="password" id="ensure_password" name="ensure_password" placeholder="再次确认新密码">
<p></p>
<button id="btn_modify" type="submit">提交</button>
</form>
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>我要修改密码</h1>
<p>记得自己的旧密码,需要自行修改</p>
<p>⬇️点它</p>
<button class="ghost" id="modifyPwd">自助修改密码</button>
</div>
<div class="overlay-panel overlay-right">
<h1>忘记密码或被锁</h1>
<p>如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置</p>
<p>⬇️点它</p>
<button class="ghost" id="scanCode">扫码验证</button>
</div>
</div>
</div>
</div>
<script src="{% static 'js/dmaku.js' %}"></script>
<script>
let qrcode_inner = document.getElementsByClassName("form-container right-content-container")
console.log(qrcode_inner)
BtnClick("#btn_modify", 'modify',{{ unsecpwd|safe }})
</script>
</body>
</html>

103
templates/index.v1.html Normal file
View File

@ -0,0 +1,103 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自助密码平台</title>
<link rel="stylesheet" href="{% static 'css/dmaku.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/alert.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
<script type="text/javascript" src="{% static 'js/ddLogin-0.0.5.js' %}"></script>
</head>
<body>
<div class="head-container" id="head-container">
<p>
密码自助服务平台
</p>
</div>
<div class="middle-container" id="middle-container">
<div class="form-container right-content-container">
<form action="">
<div style="width: 300px; height: 300px; margin: 0 auto" id="ding_code"></div>
<script type="text/javascript">
// 构造钉钉登录二唯码
var home_url = "{{ home_url }}";
var app_id = "{{ app_id }}";
var redirect_url = encodeURIComponent(home_url + '/callbackCheck');
var goto = encodeURIComponent('https://oapi.dingtalk.com/connect/qrconnect?appid='
+ app_id
+ '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri='
+ redirect_url);
DDLogin({
id: "ding_code",
goto: goto,
style: "border:none;background-color:#FFFFFF;",
width: "300",
height: "300"
});
// 扫码后的操作
var hanndleMessage = function (event) {
var origin = event.origin;
console.log("origin", event.origin)
if (origin === "https://login.dingtalk.com") {
var loginTmpCode = event.data;
console.log("loginTmpCode", loginTmpCode);
if (loginTmpCode) {
//拿到loginTmpCode后就可以在这里构造跳转链接进行跳转了
location.href = 'https://oapi.dingtalk.com/connect/oauth2/sns_authorize?appid='
+ app_id
+ '&response_type=code&scope=snsapi_login&state=STATE&redirect_uri='
+ redirect_url
+ '&loginTmpCode=' + loginTmpCode;
}
}
};
if (typeof window.addEventListener !== 'undefined') {
window.addEventListener('message', hanndleMessage, false);
} else if (typeof window.attachEvent !== 'undefined') {
window.attachEvent('onmessage', hanndleMessage);
}
</script>
</form>
</div>
<div class="form-container left-content-container">
<form action="/" method="post" autocomplete="off">
{% csrf_token %}
<h1>修改密码</h1>
<span>新密码8至30位长度要求包含大小写字母及数字。</span>
<input type="text" id="username" name="username" placeholder="账号格式abc\lisi、lisi、lisi@abc.com">
<input type="password" id="old_password" name="old_password" placeholder="旧密码">
<input type="password" id="new_password" name="new_password" placeholder="新密码">
<input type="password" id="ensure_password" name="ensure_password" placeholder="再次确认新密码">
<p></p>
<button id="btn_modify" type="submit">提交</button>
</form>
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>我要修改密码</h1>
<p>记得自己的旧密码,需要自行修改</p>
<p>⬇️点它</p>
<button class="ghost" id="modifyPwd">自助修改密码</button>
</div>
<div class="overlay-panel overlay-right">
<h1>忘记密码或被锁</h1>
<p>如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置</p>
<p>⬇️点它</p>
<button class="ghost" id="scanCode">扫码验证</button>
</div>
</div>
</div>
</div>
<script src="{% static 'js/dmaku.js' %}"></script>
<script>
let qrcode_inner = document.getElementsByClassName("form-container right-content-container")
console.log(qrcode_inner)
BtnClick("#btn_modify", 'modify', {{ unsecpwd|safe }})
</script>
</body>
</html>

View File

@ -1,44 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>密码自助服务</title>
<link type="text/css" rel="stylesheet" href="{% static 'css/login.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
</head>
<body style="overflow: hidden">
<div class="pagewrap">
<div class="main">
<div class="header"></div>
<div class="content">
<div class="con_left" >
<div style="margin: 0 auto; width:100%; height: 200px; line-height: 200px;" align="center">
<p style="margin: 0 auto; color: #fdfdfe; font-size: 36px; width:100%; ">「域账号或邮箱」<small>密码自助平台</small></p>
</div>
<div style="margin: 0 auto; width:400px; height: 240px;">
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%;
">提示新密码要求满足8至30位长度(不包含空格),至少包含大小写字母及数字组成。</p>
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%;
">如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。</p>
</div>
</div>
<div class="con_right">
<div class="con_r_top">
<p class="right" style="color: rgb(51, 51, 51); border-bottom: 2px solid rgb(46, 85, 142);">提示</p>
</div>
<form name="msg" method="get" action="" autocomplete="off">
<ul>
<li class="con_r_right" style="display: block;">
{% csrf_token %}
<div class="user" style="height: 220px;">
<p style="font-size: 16px;min-height: 1px; padding-left: 0; padding-right: 40px; margin:0">{{ msg }}</p>
</div><br>
<button id="btn_back" style="margin-top: 0" type="button" onclick="{{ button_click }}">{{ button_display }}</button>
</li>
</ul>
</form>
</div>
</div>
</div>
</div>
</body></html>

View File

@ -0,0 +1,41 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自助密码平台</title>
<link rel="stylesheet" href="{% static 'css/dmaku.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/alert.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
<script type="text/javascript" src="{% static 'js/ddLogin-0.0.5.js' %}"></script>
</head>
<body>
<div class="head-container" id="head-container">
<p>
密码自助服务平台
</p>
</div>
<div class="middle-container" id="middle-container">
<div class="form-container right-content-container"></div>
<div class="form-container left-content-container">
<form action="messages" method="get" autocomplete="off">
{% csrf_token %}
<h1>结果</h1>
<p style="font-size: 16px">{{ msg }}</p>
</form>
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left"></div>
<div class="overlay-panel overlay-right">
<button class="ghost" id="btn_back" type="button" onclick="{{ button_click }}">{{ button_display }}</button>
</div>
</div>
</div>
</div>
<script src="{% static 'js/dmaku.js' %}"></script>
<script>
</script>
</body>
</html>

View File

@ -1,72 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>密码自助服务</title>
<link type="text/css" rel="stylesheet" href="{% static 'css/login.css' %}">
<link type="text/css" rel="stylesheet" href="{% static 'css/load.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
</head>
<body style="overflow: hidden">
<div class="pagewrap">
<div class="main">
<div class="header"></div>
<div class="content">
<div class="con_left" >
<div style="margin: 0 auto; width:100%; height: 200px; line-height: 200px;" align="center" >
<p style="margin: 0 auto; color: #fdfdfe; font-size: 36px; width:100%; ">「域账号或邮箱」
<small>密码自助平台</small></p>
</div>
<div style="margin: 0 auto; width:400px; height: 240px;">
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%;
">提示新密码要求满足8至30位长度包含大小写字母及数字。</p>
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%;
">如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。</p>
</div>
</div>
<div class="con_right">
<div class="con_r_top">
<p class="right" style="color: rgb(51, 51, 51); border-bottom: 2px solid rgb(46, 85, 142);">重置密码</p>
<a href="javascript:" class="left" style="color: rgb(153, 153, 153); border-bottom-width:2px; border-bottom-style: solid; border-bottom-color: rgb(222, 222, 222);">解锁账号</a>
</div>
<ul>
<li class="con_r_right" style="display: block;">
<form name="resetPassword" method="post" action="" autocomplete="off">
{% csrf_token %}
<div class="user">
<div><span class="user-icon"></span>
<input type="text" id="username" name="username" readonly placeholder="{{ username }}" value="{{ username }}">
</div>
<div><span class="mima-icon"></span>
<input type="password" id="new_password" name="new_password"
placeholder=" 输入新密码" value="">
</div>
<div><span class="mima-icon"></span>
<input type="password" id="ensure_password" name="ensure_password" placeholder=" 再次输入新密码" value="">
</div>
</div><br>
<p style="height: 20px">会话有效期5分钟重密码会自动解锁被锁定的账号。</p>
<button id="btn_reset" style="margin-top: 0" type="submit">重置密码</button>
</form>
</li>
<li class="con_r_left" style="display: none;">
<form name="unlockAccount" method="post" action="unlockAccount" autocomplete="off">
{% csrf_token %}
<div class="user" style="height: 168px">
<div><span class="user-icon"></span>
<input type="text" id="username" name="username" readonly placeholder="{{ username }}" value="{{ username }}">
</div>
<span class="msgs"></span>
</div><br>
<p style="height: 20px">会话有效期5分钟</p>
<button id="btn_unlock" style="margin-top: 0px" type="submit">解锁账号</button>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</body></html>

View File

@ -0,0 +1,66 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自助密码平台</title>
<link rel="stylesheet" href="{% static 'css/dmaku.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/alert.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
<script type="text/javascript" src="{% static 'js/ddLogin-0.0.5.js' %}"></script>
</head>
<body>
<div class="head-container" id="head-container">
<p>
密码自助服务平台
</p>
</div>
<div class="middle-container" id="middle-container">
<div class="form-container right-content-container">
<form name="unlockAccount" method="post" action="unlockAccount" autocomplete="off">
{% csrf_token %}
<h1>重置</h1>
<input type="text" id="username" name="username" readonly placeholder="{{ username }}" value="{{ username }}">
<p></p>
<p></p>
<button id="btn_unlock" type="submit">解锁账号</button>
<p>会话有效期5分钟</p>
</form>
</div>
<div class="form-container left-content-container">
<form name="resetPassword" method="post" action="" autocomplete="off">
{% csrf_token %}
<h1>重置</h1>
<span>新密码8至30位长度要求包含大小写字母及数字。</span>
<input type="text" id="username" name="username" readonly placeholder="{{ username }}" value="{{ username }}">
<input type="password" id="new_password" name="new_password" placeholder="新密码">
<input type="password" id="ensure_password" name="ensure_password" placeholder="再次确认新密码">
<p></p>
<button id="btn_reset" type="submit">重置密码</button>
<p>会话有效期5分钟重密码会自动解锁账号</p>
</form>
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>我要重置密码</h1>
<p></p>
<p>⬇️点它</p>
<button class="ghost" id="modifyPwd">切换重置密码</button>
</div>
<div class="overlay-panel overlay-right">
<h1>我要解锁账号</h1>
<p></p>
<p>⬇️点它</p>
<button class="ghost" id="scanCode">切换解锁账号</button>
</div>
</div>
</div>
</div>
<script src="{% static 'js/dmaku.js' %}"></script>
<script>
BtnClick("#btn_reset", 'reset', {{ unsecpwd|safe }})
</script>
</body>
</html>

View File

@ -1,98 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>密码自助服务</title>
<link type="text/css" rel="stylesheet" href="{% static 'css/login.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
<script src="https://rescdn.qqmail.com/node/ww/wwopenmng/js/sso/wwLogin-1.0.0.js"></script>
</head>
<body>
<div class="pagewrap">
<div class="main">
<div class="header">
</div>
<div class="content">
<div class="con_left">
<div style="margin: 0 auto; width:100%; height: 200px; line-height: 200px;" align="center">
<p style="margin: 0 auto; color: #fdfdfe; font-size: 36px; width:100%; ">「域账号或邮箱」<small>密码自助平台</small></p>
</div>
<div style="margin: 0 auto; width:400px; height: 240px;">
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%;
">提示新密码要求满足8至30位长度(不包含空格),至少包含大小写字母及数字组成。</p>
<p style="margin: 0 auto; color: #fdfdfe; font-size: 16px; width:100%;
">如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。</p>
</div>
</div>
<div class="con_right">
<div class="con_r_top">
<a href="javascript:" class="right"
style="color: rgb(51, 51, 51); border-bottom-width: 2px; border-bottom-style: solid; border-bottom-color: rgb(46, 85, 142);">修改密码</a>
<a href="javascript:" class="left"
style="color: rgb(153, 153, 153); border-bottom-width: 2px; border-bottom-style: solid; border-bottom-color: rgb(222, 222, 222);">重置/解锁</a>
</div>
<ul>
<li class="con_r_right" style="display: block;">
<form name="modifypwd" method="post" action="" autocomplete="off">
{% csrf_token %}
<div class="user">
<div><span class="user-icon"></span>
<input type="text" id="username" name="username" placeholder="格式abc\lisi、lisi、lisi@abc.com" value="">
</div>
<div><span class="mima-icon"></span>
<input type="password" id="old_password" name="old_password"
placeholder=" 输入旧密码" value="">
</div>
<div><span class="mima-icon"></span>
<input type="password" id="new_password" name="new_password"
placeholder=" 输入新密码" value="">
</div>
<div><span class="mima-icon"></span>
<input type="password" id="ensure_password" name="ensure_password"
placeholder=" 再次输入新密码" value="">
</div>
</div>
<br>
<button id="btn_modify" type="submit">修改密码</button>
</form>
</li>
<li class="con_r_left" style="display: none;">
<div style="margin-top: -30px" class="erweima">
<div style="width: 300px; height: 300px; margin: 0 auto" id="we_code"></div>
<script type="text/javascript">
let home_url = "{{ home_url }}";
let app_id = "{{ app_id }}";
let agent_id = "{{ agent_id }}"
let redirect_url = encodeURIComponent(home_url + '/callbackCheck');
window.WwLogin({
id: "we_code",
appid: app_id,
agentid: agent_id,
redirect_uri: redirect_url,
// 样式使用base64加密而不使用https的方式
href: 'data:text/css;base64, ' +
'LmltcG93ZXJCb3ggLnRpdGxlIHtkaXNwbGF5OiBub25lO30KLmltcG93ZXJCb3ggLnFyY29kZSB7d2lkdGg6IDIyMHB4O30KLmltcG93ZXJCb3ggLmluZm8ge3dpZHRoOiAyMjBweDt9Ci5zdGF0dXNfaWNvbiB7ZGlzcGxheTogbm9uZSAgIWltcG9ydGFudH0KLmltcG93ZXJCb3ggLnN0YXR1cy5zdGF0dXNfYnJvd3NlciB7ZGlzcGxheTogbm9uZTt9Ci5pbXBvd2VyQm94IC5zdGF0dXMge3RleHQtYWxpZ246IGNlbnRlcjt9'
});
</script>
</div>
<div style="height: 70px; margin-top: -30px">
<p style="font-size: 18px; color: #2e558e" align="center">企业微信扫码验证用户信息</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<script type="text/javascript">
window.onload = function () {
if (!!window.ActiveXObject || "ActiveXObject" in window)
alert("您当前使用的浏览器为IE或IE内核因为IE各种体验问题本网站不对IE兼容。\n为能正常使用密码自助修改服务请更换谷歌、火狐等非IE核心的浏览器。\n如果是360、Maxthon等这类双核心浏览器请切换至[极速模式]亦可。")
}
</script>
</body>
</html>

View File

@ -0,0 +1,86 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>自助密码平台</title>
<link rel="stylesheet" href="{% static 'css/dmaku.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-1.8.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/alert.js' %}"></script>
<script type="text/javascript" src="{% static 'js/check.js' %}"></script>
<script src="https://rescdn.qqmail.com/node/ww/wwopenmng/js/sso/wwLogin-1.0.0.js"></script>
</head>
<body>
<div class="head-container" id="head-container">
<p>
密码自助服务平台
</p>
</div>
<div class="middle-container" id="middle-container">
<div class="form-container right-content-container">
<form action="">
<div style="width: 300px; height: 300px; margin: 0 auto" id="we_code"></div>
<script type="text/javascript">
let home_url = "{{ home_url }}";
let app_id = "{{ app_id }}";
let agent_id = "{{ agent_id }}"
let redirect_url = encodeURIComponent(home_url + '/callbackCheck');
window.WwLogin({
id: "we_code",
appid: app_id,
agentid: agent_id,
redirect_uri: redirect_url,
// 样式使用base64加密而不使用https的方式
/*
.impowerBox .title {display: none;}
.impowerBox .qrcode {width: 220px;}
.impowerBox .info {width: 220px;}
.status_icon {display: none !important}
.impowerBox .status.status_browser {display: none;}
.impowerBox .status {text-align: center;}
* */
href: 'data:text/css;base64, ' +
'LmltcG93ZXJCb3ggLnRpdGxlIHtkaXNwbGF5OiBub25lO30KLmltcG93ZXJCb3ggLnFyY29kZSB7d2lkdGg6IDIyMHB4O30KLmltcG93ZXJCb3ggLmluZm8ge3dpZHRoOiAyMjBweDt9Ci5zdGF0dXNfaWNvbiB7ZGlzcGxheTogbm9uZSAgIWltcG9ydGFudH0KLmltcG93ZXJCb3ggLnN0YXR1cy5zdGF0dXNfYnJvd3NlciB7ZGlzcGxheTogbm9uZTt9Ci5pbXBvd2VyQm94IC5zdGF0dXMge3RleHQtYWxpZ246IGNlbnRlcjt9'
});
</script>
<p>使用企业微信扫一扫登录验证</p>
</form>
</div>
<div class="form-container left-content-container">
<form action="/" method="post" autocomplete="off">
{% csrf_token %}
<h1>修改密码</h1>
<span>新密码8至30位长度要求包含大小写字母及数字。</span>
<input type="text" id="username" name="username" placeholder="账号格式abc\lisi、lisi、lisi@abc.com">
<input type="password" id="old_password" name="old_password" placeholder="旧密码">
<input type="password" id="new_password" name="new_password" placeholder="新密码">
<input type="password" id="ensure_password" name="ensure_password" placeholder="再次确认新密码">
<p></p>
<button id="btn_modify" type="submit">提交</button>
</form>
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>我要修改密码</h1>
<p>记得自己的旧密码,需要自行修改</p>
<p>⬇️点它</p>
<button class="ghost" id="modifyPwd">自助修改密码</button>
</div>
<div class="overlay-panel overlay-right">
<h1>忘记密码或被锁</h1>
<p>如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置</p>
<p>⬇️点它</p>
<button class="ghost" id="scanCode">扫码验证</button>
</div>
</div>
</div>
</div>
<script src="{% static 'js/dmaku.js' %}"></script>
<script>
let qrcode_inner = document.getElementsByClassName("form-container right-content-container")
console.log(qrcode_inner)
BtnClick("#btn_modify", 'modify',{{ unsecpwd|safe }})
</script>
</body>
</html>

2
utils/feishu/README.MD Normal file
View File

@ -0,0 +1,2 @@
# 飞书接口项目地址,感谢大佬
### https://github.com/larksuite/feishu

368
utils/feishu/__init__.py Normal file
View File

@ -0,0 +1,368 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from utils.feishu.__version__ import __version__ # NOQA
from utils.feishu.api import OpenLark
from utils.feishu.api_app_link import APIAppLink
from utils.feishu.api_approval import ApprovalUploadFileType
from utils.feishu.api_calendar import CalendarRole
from utils.feishu.dt_application import App
from utils.feishu.dt_approval import (ApprovalComment, ApprovalDefinition, ApprovalForm, ApprovalInstance, ApprovalNode,
ApprovalTask)
from utils.feishu.dt_calendar import Calendar, CalendarAttendee, CalendarEvent
from utils.feishu.dt_callback import (EventAppOpen, EventAppOpenUser, EventApproval, EventAppTicket, EventContactDepartment,
EventContactScope, EventContactUser, EventLeaveApproval, EventMessage,
EventMessageMergeForward, EventP2PCreateChat, EventP2PCreateChatUser,
EventRemedyApproval, EventRemoveAddBot, EventRemoveAddBotI18NTitle, EventShiftApproval,
EventTripApproval, EventTripApprovalSchedule, EventUserInAndOutChat, EventWorkApproval)
from utils.feishu.dt_code import Bot, Chat, I18NTitle, MinaCodeToSessionResp, OAuthCodeToSessionResp, SimpleUser, User
from utils.feishu.dt_contact import (ContactAsyncChildTaskInfo, ContactAsyncTaskResult, DepartmentUser, DepartmentUserAvatar,
DepartmentUserCustomAttr, DepartmentUserCustomAttrValue, DepartmentUserOrder,
DepartmentUserPosition, DepartmentUserStatus, EmployeeType, Gender, Role,
SimpleDepartment, SimpleUserWithPosition)
from utils.feishu.dt_drive import (DriveComment, DriveCopyFile, DriveCreateFile, DriveDeleteFile, DriveDeleteFlag,
DriveDocFileMeta, DriveFileMeta, DriveFilePermission, DriveFilePublicLinkSharePermission,
DriveFileToken, DriveFileType, DriveFileUser, DriveFileUserPermission, DriveFolderMeta,
DriveInsertSheet, DriveSheetCellAt, DriveSheetCellURL, DriveSheetMergeType, DriveSheetMeta,
DriveSheetStyle, DriveSheetStyleBorderType, DriveSheetStyleFont,
DriveSheetStyleHorizontalAlign, DriveSheetStyleNumber, DriveSheetStyleTextDecoration,
DriveSheetStyleVerticalAlign, DriveSubSheetMeta, ReadDriveSheetRequest,
WriteDriveSheetRequest)
from utils.feishu.dt_enum import (ApprovalInstanceStatus, ApprovalTaskStatus, ApprovalTaskTypeStatus, ApprovalTimelineType,
CalendarEventVisibility, EventType, I18NType, ImageColor, MeetingReplyStatus, MessageType,
MethodType, PayBuyType, PayPricePlanType, PayStatus, UrgentType)
from utils.feishu.dt_meeting_room import Building, Room, RoomFreeBusy
from utils.feishu.dt_message import (CardAction, CardButton, CardHeader, CardURL, I18nText, MessageAt, MessageImage,
MessageLink, MessageText)
from utils.feishu.dt_pay import PayOrder
from utils.feishu.dt_req import CreateDepartmentRequest, CreateUserRequest, UpdateUserRequest
from utils.feishu.exception import (LarkAllOpenIDInvalidException, LarkAppHasNoBotException,
LarkAppIsNotVisibleToUserException, LarkAppNotExistException,
LarkApprovalApprovalCodeNotFoundException, LarkApprovalDepartmentValidFailedException,
LarkApprovalForbiddenException, LarkApprovalFormValidFailedException,
LarkApprovalInstanceCodeConflictException, LarkApprovalInstanceCodeNotFoundException,
LarkApprovalInvalidRequestParamsException, LarkApprovalNeedPayException,
LarkApprovalNotExistException, LarkApprovalSubscriptionExistException,
LarkApprovalTaskIDNotFoundException, LarkApprovalUserNotFoundException,
LarkAppUnavailableException, LarkAppUsageInfoNotExistException, LarkBanAtALLException,
LarkBotForbiddenToGetImageBelongToThemException, LarkBotInChatFullException,
LarkBotIsNotMessageOwnerException, LarkBotNotGroupAdminException,
LarkBotNotInChatException, LarkChatDisbandedException, LarkCheckOpenChatIDFailException,
LarkConflictAppIDException, LarkDriveDuplicateException, LarkDriveEmptySheetIDException,
LarkDriveEmptySheetTitleException, LarkDriveEmptyValueException,
LarkDriveExistSheetIDException, LarkDriveExistSheetTitleException,
LarkDriveFailedException, LarkDriveFailException, LarkDriveForbiddenException,
LarkDriveInternalErrorException, LarkDriveInvalidOperationException,
LarkDriveInvalidUsersException, LarkDriveLoginRequiredException,
LarkDriveMetaDeletedException, LarkDriveMetaNotExistException,
LarkDriveOutOfLimitException, LarkDriveParamErrorException,
LarkDrivePermissionFailException, LarkDriveProcessingException,
LarkDriveReviewNotPassException, LarkDriveSameSheetIDOrTitleException,
LarkDriveSheetIDNotFoundException, LarkDriveSpreadSheetNotFoundException,
LarkDriveTimeoutException, LarkDriveTooManyRequestException,
LarkDriveUserNoSharePermissionException, LarkDriveWrongRangeException,
LarkDriveWrongRequestBodyException, LarkDriveWrongRequestJsonException,
LarkDriveWrongRowOrColException, LarkDriveWrongSheetIDException,
LarkEmployeeIDNotExistException, LarkEmptyChatIDException,
LarkForbiddenBotBatchSendMessageToDepartmentException,
LarkForbiddenBotBatchSendMessageToUserException, LarkForbiddenBotDisbandChatException,
LarkForbiddenSendMessageException, LarkForbiddenUrgentException,
LarkFrequencyLimitException, LarkGetAppAccessTokenFailException,
LarkGetChatIDFailException, LarkGetCheckSecurityTokenFailException,
LarkGetEmployeeIDFailException, LarkGetMessageIDFailException,
LarkGetOpenChatIDFailException, LarkGetOpenDepartmentIDFailException,
LarkGetOpenIDFailException, LarkGetSSOAccessTokenFailException,
LarkGetTenantAccessTokenFailException, LarkGetUserInfoFailOrUserIDNotExistException,
LarkImageKeyNotExistException, LarkInternalException, LarkInvalidAppAccessTokenException,
LarkInvalidAppIDException, LarkInvalidAppTicketException, LarkInvalidArguments,
LarkInvalidMessageIDException, LarkInvalidOpenChatIDException,
LarkInvalidTenantAccessTokenException, LarkInvalidTenantCodeException,
LarkInviteBotToChatFailException, LarkInviteUserToChatInvalidParamsException,
LarkMeetingRoomInvalidBuildingIDException, LarkMeetingRoomInvalidFieldSelectionException,
LarkMeetingRoomInvalidPageTokenException, LarkMeetingRoomInvalidRoomIDException,
LarkMeetingRoomTimeFormatMustFollowRFC3339StandardException, LarkMessageTooOldException,
LarkNoPermissionToGotException, LarkNotOpenApplicationSendMessagePermissionException,
LarkOnlyChatAdminCanInviteUserException, LarkOpenDepartmentIDNotExistException,
LarkOpenIDNotExistException, LarkOwnerOfBotIsNotInChatException,
LarkRemoveUserFromChatInvalidParamsException, LarkRequestParamsInvalidException,
LarkSendAppTicketFailException, LarkSendMessageFailException,
LarkUnsupportedChatCrossTenantException, LarkUnsupportedCrossTenantException,
LarkUnsupportedUrgentTypeException, LarkUpdateChatInvalidParamsException,
LarkUpdateChatNameFailException, LarkUploadImageInvalidParamsException,
LarkUserCannotGrantToChatAdminException, LarkUserNotActiveException,
LarkWrongAppSecretException, LarkWrongMessageIDException, OpenLarkException)
__author__ = 'chenyunpeng.1024 <chenyunpeng.1024@bytedance.com>'
__all__ = {
'OpenLark',
# datatype
'SimpleUser',
'User',
'Bot',
'Chat',
'MinaCodeToSessionResp',
'OAuthCodeToSessionResp',
'I18NTitle',
'APIAppLink',
# callback datatype
'EventMessageMergeForward',
'EventMessage',
'EventApproval',
'EventLeaveApproval',
'EventWorkApproval',
'EventShiftApproval',
'EventRemedyApproval',
'EventTripApprovalSchedule',
'EventTripApproval',
'EventAppOpenUser',
'EventAppOpen',
'EventContactUser',
'EventContactDepartment',
'EventContactScope',
'EventRemoveAddBot',
'EventRemoveAddBotI18NTitle',
'EventAppTicket',
'EventP2PCreateChat',
'EventP2PCreateChatUser',
'EventUserInAndOutChat',
# message datatype,
'MessageText',
'MessageAt',
'MessageImage',
'MessageLink',
'I18nText',
'CardURL',
'CardHeader',
'CardButton',
'CardAction',
# 审批
'ApprovalNode',
'ApprovalForm',
'ApprovalDefinition',
'ApprovalComment',
'ApprovalInstanceStatus',
'ApprovalTaskStatus',
'ApprovalTaskTypeStatus',
'ApprovalTask',
'ApprovalInstance',
'ApprovalTimelineType',
# calendar datatype
'Calendar',
'CalendarAttendee',
'CalendarEvent',
# drive folder
'DriveFileType',
'DriveFolderMeta',
'DriveCreateFile',
'DriveDeleteFile',
'DriveCopyFile',
'DriveDeleteFlag',
'DriveDocFileMeta',
'DriveComment',
'DriveFileToken',
'DriveFileMeta',
'DriveSubSheetMeta',
'DriveSheetMeta',
'DriveInsertSheet',
'DriveSheetCellURL',
'DriveSheetCellAt',
'DriveSheetStyleTextDecoration',
'DriveSheetStyleNumber',
'DriveSheetStyleHorizontalAlign',
'DriveSheetStyleVerticalAlign',
'DriveSheetStyleBorderType',
'DriveSheetStyleFont',
'DriveSheetStyle',
'DriveSheetMergeType',
'ReadDriveSheetRequest',
'WriteDriveSheetRequest',
'DriveFilePermission',
'DriveFileUserPermission',
'DriveFileUser',
'DriveFilePublicLinkSharePermission',
# 会议室
'Building',
'Room',
'RoomFreeBusy',
# 订单
'PayPricePlanType',
'PayBuyType',
'PayStatus',
'PayOrder',
# 应用
'App',
# 通讯录
'SimpleDepartment',
'DepartmentUserStatus',
'DepartmentUserAvatar',
'EmployeeType',
'Gender',
'SimpleUserWithPosition',
'DepartmentUserPosition',
'DepartmentUserOrder',
'DepartmentUserCustomAttrValue',
'DepartmentUserCustomAttr',
'DepartmentUser',
'ContactAsyncChildTaskInfo',
'ContactAsyncTaskResult',
'Role',
'CreateDepartmentRequest',
'CreateUserRequest',
'UpdateUserRequest',
# enum
'MessageType',
'UrgentType',
'I18NType',
'ImageColor',
'MethodType',
'CalendarRole',
'CalendarEventVisibility',
'ApprovalUploadFileType',
'EventType',
'MeetingReplyStatus',
# exception
'OpenLarkException',
'LarkInvalidArguments',
# 数字特别大
'LarkFrequencyLimitException',
# 审批
'LarkApprovalNotExistException',
'LarkApprovalSubscriptionExistException',
'LarkApprovalInvalidRequestParamsException',
'LarkApprovalApprovalCodeNotFoundException',
'LarkApprovalInstanceCodeNotFoundException',
'LarkApprovalUserNotFoundException',
'LarkApprovalForbiddenException',
'LarkApprovalTaskIDNotFoundException',
'LarkApprovalDepartmentValidFailedException',
'LarkApprovalFormValidFailedException',
'LarkApprovalNeedPayException',
'LarkApprovalInstanceCodeConflictException',
'LarkBotIsNotMessageOwnerException',
'LarkBanAtALLException',
'LarkUserNotActiveException',
'LarkChatDisbandedException',
'LarkMessageTooOldException',
'LarkNoPermissionToGotException',
'LarkInvalidTenantAccessTokenException',
'LarkInvalidAppAccessTokenException',
'LarkInvalidTenantCodeException',
'LarkInvalidAppTicketException',
# 机器人
'LarkSendMessageFailException',
'LarkRequestParamsInvalidException',
'LarkGetUserInfoFailOrUserIDNotExistException',
'LarkConflictAppIDException',
'LarkGetOpenChatIDFailException',
'LarkForbiddenSendMessageException',
'LarkGetAppAccessTokenFailException',
'LarkInvalidOpenChatIDException',
'LarkGetTenantAccessTokenFailException',
'LarkGetTenantAccessTokenFailException',
'LarkWrongAppSecretException',
'LarkSendAppTicketFailException',
'LarkUnsupportedUrgentTypeException',
'LarkWrongMessageIDException',
'LarkForbiddenUrgentException',
'LarkCheckOpenChatIDFailException',
'LarkBotNotInChatException',
'LarkAllOpenIDInvalidException',
'LarkUnsupportedCrossTenantException',
'LarkGetMessageIDFailException',
'LarkGetSSOAccessTokenFailException',
'LarkGetCheckSecurityTokenFailException',
'LarkCheckOpenChatIDFailException',
'LarkOpenIDNotExistException',
'LarkGetOpenIDFailException',
'LarkOpenDepartmentIDNotExistException',
'LarkGetOpenDepartmentIDFailException',
'LarkEmployeeIDNotExistException',
'LarkGetEmployeeIDFailException',
'LarkUpdateChatNameFailException',
'LarkBotNotGroupAdminException',
'LarkBotNotGroupAdminException',
'LarkOnlyChatAdminCanInviteUserException',
'LarkForbiddenBotBatchSendMessageToUserException',
'LarkForbiddenBotBatchSendMessageToDepartmentException',
'LarkAppHasNoBotException',
'LarkUserCannotGrantToChatAdminException',
'LarkAppUnavailableException',
'LarkAppNotExistException',
'LarkAppUsageInfoNotExistException',
'LarkInviteUserToChatInvalidParamsException',
'LarkRemoveUserFromChatInvalidParamsException',
'LarkUpdateChatInvalidParamsException',
'LarkUploadImageInvalidParamsException',
'LarkEmptyChatIDException',
'LarkGetChatIDFailException',
'LarkInviteBotToChatFailException',
'LarkBotInChatFullException',
'LarkUnsupportedChatCrossTenantException',
'LarkForbiddenBotDisbandChatException',
'LarkBotForbiddenToGetImageBelongToThemException',
'LarkOwnerOfBotIsNotInChatException',
'LarkNotOpenApplicationSendMessagePermissionException',
'LarkInvalidMessageIDException',
'LarkAppIsNotVisibleToUserException',
'LarkInvalidAppIDException',
'LarkImageKeyNotExistException',
'LarkInternalException',
# 云空间
'LarkDriveWrongRequestJsonException',
'LarkDriveWrongRangeException',
'LarkDriveFailException',
'LarkDriveWrongRequestBodyException',
'LarkDriveInvalidUsersException',
'LarkDriveEmptySheetIDException',
'LarkDriveEmptySheetTitleException',
'LarkDriveSameSheetIDOrTitleException',
'LarkDriveExistSheetIDException',
'LarkDriveExistSheetTitleException',
'LarkDriveWrongSheetIDException',
'LarkDriveWrongRowOrColException',
'LarkDrivePermissionFailException',
'LarkDriveSpreadSheetNotFoundException',
'LarkDriveSheetIDNotFoundException',
'LarkDriveEmptyValueException',
'LarkDriveTooManyRequestException',
'LarkDriveTimeoutException',
'LarkDriveProcessingException',
'LarkDriveLoginRequiredException',
'LarkDriveFailedException',
'LarkDriveOutOfLimitException',
'LarkDriveDuplicateException',
'LarkDriveForbiddenException',
'LarkDriveInvalidOperationException',
'LarkDriveUserNoSharePermissionException',
'LarkDriveParamErrorException',
'LarkDriveMetaDeletedException',
'LarkDriveMetaNotExistException',
'LarkDriveReviewNotPassException',
'LarkDriveInternalErrorException',
# 会议室
'LarkMeetingRoomInvalidPageTokenException',
'LarkMeetingRoomInvalidFieldSelectionException',
'LarkMeetingRoomTimeFormatMustFollowRFC3339StandardException',
'LarkMeetingRoomInvalidBuildingIDException',
'LarkMeetingRoomInvalidRoomIDException',
}

View File

@ -0,0 +1,3 @@
VERSION = (0, 0, 1)
__version__ = '.'.join(map(str, VERSION))

459
utils/feishu/api.py Normal file
View File

@ -0,0 +1,459 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import json
import logging
import time
from typing import Any, Callable, Optional
import requests
from six import string_types
from utils.feishu.api_app_link import APIAppLinkMixin
from utils.feishu.api_application import APIApplicationMixin
from utils.feishu.api_approval import APIApprovalMixin
from utils.feishu.api_bot import APIBotMixin
from utils.feishu.api_calendar import APICalendarMixin
from utils.feishu.api_callback import APICallbackMixin
from utils.feishu.api_chat import APIChatMixin
from utils.feishu.api_contact import APIContactMixin
from utils.feishu.api_drive_comment import APIDriveCommentMixin
from utils.feishu.api_drive_doc import APIDriveDocMixin
from utils.feishu.api_drive_file import APIDriveFileMixin
from utils.feishu.api_drive_folder import APIDriveFolderMixin
from utils.feishu.api_drive_permission import APIDrivePermissionMixin
from utils.feishu.api_drive_sheet import APIDriveSheetMixin
from utils.feishu.api_drive_suite import APIDriveSuiteMixin
from utils.feishu.api_duty import APIDutyMixin
from utils.feishu.api_file import APIFileMixin
from utils.feishu.api_id import APIIDMixin
from utils.feishu.api_image import APIImageMixin
from utils.feishu.api_meeting_room import APIMeetingRoomMixin
from utils.feishu.api_message import APIMessageMixin
from utils.feishu.api_mina import APIMinaMixin
from utils.feishu.api_oauth import APIOAuthMixin
from utils.feishu.api_pay import APIPayMixin
from utils.feishu.api_user import APIUserMixin
from utils.feishu.exception import LarkGetAppTicketFail, LarkInvalidArguments, LarkUnknownError, gen_exception
from utils.feishu.helper import to_native
from utils.feishu.internal_cache import _Cache
logger = logging.getLogger('feishu')
def _gen_default_token_getter_setter():
cache = _Cache(maxsize=1024, ttl=3600)
def _getter(key):
return cache.get(key=key)
def _setter(key, value, ttl):
cache.set(key=key, value=value, ttl=ttl)
return _getter, _setter
class OpenLark(APIIDMixin,
APIMinaMixin,
APIImageMixin,
APIFileMixin,
APIMessageMixin,
APIUserMixin,
APIBotMixin,
APIChatMixin,
APICallbackMixin,
APIContactMixin,
APIOAuthMixin,
APIApprovalMixin,
APICalendarMixin,
APIApplicationMixin,
APIMeetingRoomMixin,
APIPayMixin,
APIDutyMixin,
APIAppLinkMixin,
APIDriveFolderMixin,
APIDriveFileMixin,
APIDriveDocMixin,
APIDriveCommentMixin,
APIDriveSuiteMixin,
APIDriveSheetMixin,
APIDrivePermissionMixin):
__app_access_token = ''
__app_access_token_expire = 0
__tenant_access_token = ''
__tenant_access_token_expire = 0
__key_app_ticket = ''
__token_setter = None # type: Callable[[str, str, int], Any]
__token_getter = None # type: Callable[[str], Optional[str]]
is_isv = False
tenant_key = '' # type: string_types
def __init__(self, app_id,
app_secret,
encrypt_key=None,
verification_token='',
oauth_redirect_uri='',
token_setter=None,
token_getter=None,
is_isv=False,
is_lark=False,
tenant_key='',
is_staging=False,
ignore_ssl=False):
"""构造 OpenLark
:param app_id: 应用唯一的 ID 标识
:type app_id: string_types
:param app_secret: 应用的秘钥创建 App 的时候由平台生成
:type app_secret: string_types
:param encrypt_key: 应用的 AppID
:type encrypt_key: string_types
:param verification_token: 用于验证回调是否是开放平台发送的
:type verification_token: string_types
:param oauth_redirect_uri: 用于 OAuth 登录的重定向地址
:type oauth_redirect_uri: string_types
:param token_setter: 用于分布式设置 token
:type token_setter: Callable[[str, str, int], Any]
:param token_getter: 用于分布式获取 token
:type token_getter: Callable[[str], Optional[Union[str, bytes]]]
:param is_isv: 指定本实例是否是 ISV 应用在获取 tenant_access_token 的时候会使用不同的参数
:type is_isv: bool
:param tenant_key: 租户的唯一 ID如果实例是 ISV 应用必须指定本参数在获取 tenant_access_token 的时候会使用不同的参数
:type tenant_key: string_types
:param is_staging: 是否是 staging 环境
:param ignore_ssl: 忽略 ssl
"""
self.app_id = app_id
self.app_secret = app_secret
self.oauth_redirect_uri = oauth_redirect_uri
self.encrypt_key = encrypt_key # 解密回调数据
self.verification_token = verification_token # 回调的时候会有这个字段,校验一致性
self.is_isv = is_isv # 是否是 ISV 应用
self.is_lark = is_lark # 是 Lark 还是飞书
self.tenant_key = tenant_key # 租户 key
self.is_staging = is_staging
self.ignore_ssl = ignore_ssl
self.__key_app_ticket = 'feishu:{}:app_ticket'.format(app_id)
if is_isv and not tenant_key:
# 在一开始的时候,是不知道 tenant_key 是多少的,又依赖本库解析参数,所以可以先不设置
logger.warning('[init_class] 设置 is_isv 的时候,没有设置 tenant_key')
# 数据是一份的,所以必须同时有或者没有
if (token_getter and not token_setter) or (not token_getter and token_setter):
raise LarkInvalidArguments(msg='token_getter / token_setter 必须同时设置或者不设置')
if not token_setter:
token_getter, token_setter = _gen_default_token_getter_setter()
self.__token_getter = token_getter
self.__token_setter = token_setter
def _gen_open_exception(self, url, res):
try:
msg = res.get('msg') or res.get('message')
if msg is None:
if 'code' in res:
del res['code']
if 'error' in res:
del res['error']
msg = json.dumps(res)
except AttributeError:
msg = json.dumps(res)
try:
code = res.get('code')
if code is None:
code = res.get('error')
if code is None:
code = LarkUnknownError.code
except AttributeError:
code = LarkUnknownError.code
exception = gen_exception(code, url, msg)
logger.error('[exception] code=%d, url=%s, msg=%s exception=%s', code, url, msg, exception)
return exception
def _base_request(self, method,
url,
body=None,
files=None,
with_app_token=False,
with_tenant_token=False,
auth_token='',
raw_content=False,
success_code=0):
headers = {}
if with_tenant_token:
token = self.tenant_access_token
headers['Authorization'] = 'Bearer {}'.format(token)
elif with_app_token:
token = self.app_access_token
headers['Authorization'] = 'Bearer {}'.format(token)
elif auth_token:
headers['Authorization'] = 'Bearer {}'.format(auth_token)
verify = not self.ignore_ssl
if files and body:
r = requests.request(method=method, data=body, url=url, files=files, headers=headers, verify=verify)
elif files:
r = requests.request(method=method, url=url, files=files, headers=headers, verify=verify)
elif body:
headers['Content-Type'] = 'application/json'
r = requests.request(method=method, url=url, json=body, headers=headers, verify=verify)
else:
r = requests.request(method=method, url=url, headers=headers, verify=verify)
if not raw_content:
logger.debug('[http] method=%s, url=%s, body=%s, files=%s, status_code=%d, res=%s',
method, url, body, files, r.status_code, r.content)
try:
res = r.json()
except Exception:
if raw_content:
logger.debug('[http] method=%s, url=%s, body=%s, files=%s, status_code=%d, res is raw',
method, url, body, files, r.status_code)
return r.content
logger.error('[http] method=%s, url=%s, body=%s, files=%s, status_code=%d, res=%s',
method, url, body, files, r.status_code, r.text)
# 为了记录 error 日志,所以原样抛出
raise
code = res.get('code')
if code is not None and isinstance(code, int) and code != success_code:
# 抛出 OpenLarkException
raise self._gen_open_exception(url, res)
error = res.get('error')
if error is not None and isinstance(error, int) and error != success_code:
raise self._gen_open_exception(url, {
'code': error,
'msg': res.get('message') or res.get('msg') or res.get('BaseResp', {}).get('StatusMessage')
})
if raw_content:
logger.debug('[http] method=%s, url=%s, body=%s, files=%s, status_code=%d, res is raw',
method, url, body, files, r.status_code)
return r.content
return res
def _get(self, url, with_tenant_token=False, with_app_token=False, auth_token='', raw_content=False,
success_code=0):
return self._base_request('get', url=url,
with_tenant_token=with_tenant_token,
with_app_token=with_app_token,
auth_token=auth_token,
raw_content=raw_content,
success_code=success_code)
def _delete(self, url, body=None, with_tenant_token=False, auth_token='', raw_content=False, success_code=0):
return self._base_request('delete', url=url,
body=body,
with_tenant_token=with_tenant_token,
auth_token=auth_token,
raw_content=raw_content,
success_code=success_code)
def _patch(self, url, body=None, with_app_token=False, with_tenant_token=False, raw_content=False, success_code=0):
return self._base_request('patch', url=url,
body=body,
with_app_token=with_app_token,
with_tenant_token=with_tenant_token,
raw_content=raw_content,
success_code=success_code)
def _post(self, url, body=None, files=None, with_app_token=False, with_tenant_token=False, auth_token='',
success_code=0):
return self._base_request('post', url=url,
body=body,
files=files,
with_app_token=with_app_token,
with_tenant_token=with_tenant_token,
auth_token=auth_token,
success_code=success_code)
def _put(self, url, body=None, files=None, with_app_token=False, with_tenant_token=False, auth_token='',
success_code=0):
return self._base_request('put', url=url,
body=body,
files=files,
with_app_token=with_app_token,
with_tenant_token=with_tenant_token,
auth_token=auth_token,
success_code=success_code)
def _gen_request_url(self, path, app='normal'):
"""
:type self: OpenLark
:param path:
:type path: string_types
:param app:
:type app: str
:return:
"""
hosts = {
# lark非中国区
1: {
# online
1: {
'normal': 'https://open.larksuite.com',
'approval': 'https://www.larksuite.com',
},
# staging
0: {
'normal': 'https://open.larksuite-staging.com',
'approval': 'https://www.larksuite-staging.com',
}
},
# 飞书(中国区)
0: {
# online
1: {
'normal': 'https://open.feishu.cn',
'approval': 'https://www.feishu.cn',
},
# staging
0: {
'normal': 'https://open.feishu-staging.cn',
'approval': 'https://www.feishu-staging.cn',
}
}
}
open_feishu_host = hosts[int(self.is_lark)][int(not self.is_staging)][app]
return open_feishu_host + path
@property
def app_access_token(self):
"""获取 app_access_token
:rtype str
https://open.feishu.cn/document/ukTMukTMukTM/uADN14CM0UjLwQTN
"""
key_app_access_token = 'feishu:{}:app_token'.format(self.app_id)
cache_token = self.__token_getter(key_app_access_token)
if cache_token:
return to_native(cache_token)
body = {
'app_id': self.app_id,
'app_secret': self.app_secret
}
if self.is_isv:
url = self._gen_request_url('/open-apis/auth/v3/app_access_token/')
body['app_ticket'] = self.app_ticket
else:
url = self._gen_request_url('/open-apis/auth/v3/app_access_token/internal/')
res = self._post(url, body)
app_access_token = res['app_access_token']
expire = res['expire']
if expire <= 360:
return app_access_token
self.__token_setter(key_app_access_token, app_access_token, expire - 100)
return app_access_token
@property
def tenant_access_token(self):
"""获取 tenant_access_token
:rtype str
注意如果是 ISV 应用那么必须在构造 OpenLark 实例的时候必须传入 is_isv=True tenant_key
https://open.feishu.cn/document/ukTMukTMukTM/uMjNz4yM2MjLzYzM
"""
key_token = 'feishu:tenant_token:{}:{}'.format(self.app_id, self.tenant_key)
cache_token = self.__token_getter(key_token)
if cache_token:
return to_native(cache_token)
if self.is_isv:
if not self.tenant_key:
raise LarkInvalidArguments(msg='[tenant_access_token] '
'must set tenant_key for isv app get tenant_access_token')
body = {
'app_access_token': self.app_access_token,
'tenant_key': self.tenant_key,
}
url = self._gen_request_url('/open-apis/auth/v3/tenant_access_token/')
else:
body = {
'app_id': self.app_id,
'app_secret': self.app_secret
}
url = self._gen_request_url('/open-apis/auth/v3/tenant_access_token/internal/')
res = self._post(url, body)
tenant_access_token = res['tenant_access_token']
expire = res['expire']
if expire <= 360:
return tenant_access_token
self.__token_setter(key_token, tenant_access_token, expire - 100)
return tenant_access_token
def resend_app_ticket(self):
"""重新推送 app_ticket
飞书每隔 1 小时会给应用推送一次最新的 app_ticket
应用也可以主动调用此接口触发飞书进行及时的重新推送app_ticket 会推送到回调地址
resend 旧的 app_ticket 会在 5-10 分钟内失效
https://open.feishu.cn/document/ukTMukTMukTM/uQjNz4CN2MjL0YzM
"""
url = self._gen_request_url('/open-apis/auth/v3/app_ticket/resend/')
body = {'app_id': self.app_id, 'app_secret': self.app_secret}
self._post(url, body=body)
@property
def app_ticket(self):
"""获取 app_ticket
"""
if not self.is_isv:
raise LarkInvalidArguments(msg='[app_ticket] 非 isv 应用无法调用 app_ticket')
res = self.__token_getter(self.__key_app_ticket)
if not res:
logger.warning('[app_ticket] no found, try resend.')
self.resend_app_ticket()
for t in range(3):
sleep_time = 2 ** t
logger.warning('[app_ticket] had resend, wait to got app_ticket, time=%d, sleep=%d', t + 1, sleep_time)
time.sleep(sleep_time)
res = self.__token_getter(self.__key_app_ticket)
if res:
logger.warning('[app_ticket] resend to got app_ticket not found, time=%d', t + 1)
break
if not res:
raise LarkGetAppTicketFail()
if not isinstance(res, string_types):
raise LarkInvalidArguments(
msg='response of token_getter must be str or bytes, but got {}'.format(type(res)))
return to_native(res)
def update_app_ticket(self, app_ticket):
"""设置新的 app_ticket
:type self: OpenLark
:param app_ticket 的来源是从回调中获取
:type app_ticket: string_types
"""
if callable(self.__token_setter):
self.__token_setter(self.__key_app_ticket, app_ticket, 3600)
return
logger.warning('call update_app_ticket, but token_setter is not callable')

View File

@ -0,0 +1,156 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING
from six.moves.urllib.parse import quote
from utils.feishu.helper import join_url
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIAppLink(object):
def __init__(self, host, qs):
self.host = host
self.qs = qs
def open_client(self):
"""唤起飞书客户端的 app_link 链接
"""
base = 'https://{}/client/op/open'.format(self.host)
return join_url(base, self.qs, sep='?')
def open_mini_program(self, app_id, mode='', path='', path_android='', path_ios='', path_pc=''):
"""打开一个小程序或者小程序中的一个页面
:param app_id: 小程序 app_id
:type app_id: str
:param mode: PC 必填 PC小程序的三种模式sidebar-semiwindowappCenter
:type mode: str
:param path: 需要跳转的页面路径路径后可以带参数也可以使用path_androidpath_iospath_pc参数对不同的客户端指定不同的path
:type path: str
:param path_android: path 参数Android 端会优先使用该参数如果该参数不存在则会使用 ptah 参数
:type path_android: str
:param path_ios: path 参数iOS 端会优先使用该参数如果该参数不存在则会使用 ptah 参数
:type path_ios: str
:param path_pc: path 参数PC 端会优先使用该参数如果该参数不存在则会使用 ptah 参数
:type path_pc: str
:return: url
:rtype: str
使用示例
1. 打开小程序
lark_cli.app_link().open_mini_program(app_id='1234567890', mode='window')
# https://applink.feishu.cn/client/mini_program/open?appId=1234567890&mode=window
2. 打开小程序的一个页面 pages/home
lark_cli.app_link().open_mini_program(app_id='1234567890', mode='window', path='pages/home')
# https://applink.feishu.cn/client/mini_program/open?appId=1234567890&mode=window&path=pages%2Fhome
3. 打开小程序的一个页面带参数 pages/home?xid=123
lark_cli.app_link().open_mini_program(app_id='1234567890', mode='window', path='pages/home?xid=123')
# https://applink.feishu.cn/client/mini_program/open?appId=1234567890&mode=window&
path=pages%2Fhome%3Fxid%3D123
4. PC 端打开页面 pages/pc_home?pid=123在其他端打开页面 pages/home?xid=123
lark_cli.app_link().open_mini_program(app_id='1234567890', mode='window', path='pages/home?xid=123',
path_pc='pages/pc_home?pid=123')
# https://applink.feishu.cn/client/mini_program/open?appId=1234567890&mode=window&
path=pages%2Fhome%3Fxid%3D123&path_pc=pages%2Fpc_home%3Fpid%3D123
5. PC 4.2.0 及以上版本支持打开小程序PC 4.2.0 以下版本提示不支持
lark_cli.app_link(min_ver_pc='4.2.0').open_mini_program(app_id='1234567890', mode='window')
# https://applink.feishu.cn/client/mini_program/open?appId=1234567890&mode=window&min_lk_ver_pc=4.2.0
"""
qs = [
('appId', app_id),
('mode', mode),
('path', quote(path, safe='') if path else ''),
('path_android', quote(path_android, safe='') if path_android else ''),
('path_ios', quote(path_ios, safe='') if path_ios else ''),
('path_pc', quote(path_pc, safe='') if path_pc else ''),
]
for i in self.qs:
qs.append((i[0], i[1]))
base = 'https://{}/client/mini_program/open'.format(self.host)
return join_url(base, qs, sep='?')
def open_chat(self, open_id='', open_chat_id=''):
"""打开一个小程序或者小程序中的一个页面
:param open_id: 用户 open_id
:type open_id: str
:param open_chat_id: 用会话ID包括单聊会话和群聊会话
:type open_chat_id: str
:return: url
:rtype: str
使用示例
使用 open_id 打开聊天页面
lark_cli.app_link().open_chat(open_id='1234567890')
# https://applink.feishu.cn/client/chat/open?openId=1234567890
"""
qs = [
('openId', open_id),
('openChatId', open_chat_id)
]
for i in self.qs:
if i[1]:
qs.append((i[0], i[1]))
base = 'https://{}/client/mini_program/open'.format(self.host)
return join_url(base, qs, sep='?')
class APIAppLinkMixin(object):
@property
def _app_link_host(self):
"""
:type self: OpenLark
"""
if self.is_lark:
return 'applink.larksuite.com'
return 'applink.feishu.cn'
def app_link(self, min_lk_ver='', min_lk_ver_android='', min_lk_ver_ios='', min_lk_ver_pc=''):
"""
:param min_lk_ver:
:param min_lk_ver_android:
:param min_lk_ver_ios:
:param min_lk_ver_pc:
:return:
:rtype: APIAppLink
指定 AppLink 协议能够兼容的最小飞书版本使用三位版本号 x.y.z
如果当前飞书版本号小于min_lk_ver打开该 AppLink 会显示为兼容页面
也可使用 min_lk_ver_androidmin_lk_ver_iosmin_lk_ver_pc 参数对不同的客户端指定不同的版本
"""
qs = [
('min_lk_ver', min_lk_ver),
('min_lk_ver_android', min_lk_ver_android),
('min_lk_ver_ios', min_lk_ver_ios),
('min_lk_ver_pc', min_lk_ver_pc),
]
return APIAppLink(self._app_link_host, qs)

View File

@ -0,0 +1,222 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, List
from utils.feishu.dt_application import App
from utils.feishu.dt_code import SimpleUser
from utils.feishu.dt_enum import I18NType
from utils.feishu.dt_help import make_datatype
from utils.feishu.exception import LarkInvalidArguments
from utils.feishu.helper import converter_enum
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIApplicationMixin(object):
def is_user_admin(self, open_id=None, employee_id=None):
"""获取应用管理权限
:type self: OpenLark
:param open_id: 用户 open_id
:type open_id: str
:param employee_id: 用户租户 ID
:type employee_id: str
:return: 是否是应用管理员
:rtype: bool
该接口用于查询用户是否为应用管理员
https://open.feishu.cn/document/ukTMukTMukTM/uITN1EjLyUTNx4iM1UTM
"""
if open_id:
url = '/open-apis/application/v3/is_user_admin?open_id={}'.format(open_id)
elif employee_id:
url = '/open-apis/application/v3/is_user_admin?employee_id={}'.format(employee_id)
else:
raise LarkInvalidArguments(msg='[is_user_admin] empty open_id and employee_id')
url = self._gen_request_url(url)
res = self._get(url, with_tenant_token=True)
data = res['data']
return data['is_app_admin'] # type: bool
def get_app_visibility(self, app_id, user_page_token='', user_page_size=20):
"""获取应用在企业内的可用范围
:type self: OpenLark
:param app_id: 目标应用的 ID
:type app_id: str
:param user_page_token: 分页拉取用户列表起始位置标示不填表示从头开始
:type user_page_token: str
:param user_page_size: 本次拉取用户列表最大个数(最大值 1000 0 自动最大个数 )
:type user_page_size: int
:return: (部门列表, 可见的用户列表是否全员可见是否还有更多用户用户翻页token
所有可见用户数量仅包含单独设置的用户可用部门中的用户不计算在内)
:rtype: (list[str], list[SimpleUser], bool, bool, str, int)
该接口用于查询应用在该企业内可以被使用的范围只能被企业自建应用调用且需要获取应用信息权限
https://open.feishu.cn/document/ukTMukTMukTM/uIjM3UjLyIzN14iMycTN
"""
url = self._gen_request_url('/open-apis/application/v1/app/visibility?app_id={}'.format(app_id))
if user_page_token:
url = '{}&user_page_token={}'.format(url, user_page_token)
if user_page_size:
url = '{}&user_page_size={}'.format(url, user_page_size)
res = self._get(url, with_tenant_token=True)
data = res['data']
department_ids = [i.get('id') for i in data.get('departments', [])] # type: List[str]
users = [make_datatype(SimpleUser, i) for i in data.get('users', [])] # type: List[SimpleUser]
is_visible_to_all = bool(data.get('is_visible_to_all', False)) # type: bool
has_more_users = bool(data.get('has_more_users', False)) # type: bool
user_page_token = data.get('user_page_token') # type: str
total_user_count = data.get('total_user_count') # type: int
return department_ids, users, is_visible_to_all, has_more_users, user_page_token, total_user_count
def get_visible_apps(self, user_id=None, open_id=None, page_size=20, page_token='', lang=I18NType.zh_cn):
"""获取应用在企业内的可用范围
:type self: OpenLark
:param user_id: 目标用户 user_id open_id 至少给其中之一user_id 优先于 open_id
:type user_id: str
:param open_id: 目标用户 open_id
:type open_id: str
:param page_size: 本次拉取用户列表最大个数(最大值 1000 0 自动最大个数 )
:type page_size: int
:param page_token: 分页拉取用户列表起始位置标示不填表示从头开始
:type page_token: str
:param lang: 优先展示的应用信息的语言版本zh_cn中文en_us英文ja_jp日文
:type lang: I18NType
:return: 是否还有更多, page_token, page_size, 总数, 语言, 应用列表
:rtype: (bool, str, int, int, I18NType, list[App])
该接口用于查询应用在该企业内可以被使用的范围只能被企业自建应用调用且需要获取应用信息权限
https://open.feishu.cn/document/ukTMukTMukTM/uIjM3UjLyIzN14iMycTN
"""
url = self._gen_request_url('/open-apis/application/v1/user/visible_apps?')
if user_id:
url = '{}&user_id={}'.format(url, user_id)
elif open_id:
url = '{}&open_id={}'.format(url, open_id)
else:
raise LarkInvalidArguments(msg='empty user_id and open_id')
if page_token:
url = '{}&page_token={}'.format(url, page_token)
if page_size:
url = '{}&page_size={}'.format(url, page_size)
if lang:
url = '{}&lang={}'.format(url, converter_enum(lang))
res = self._get(url, with_tenant_token=True)
data = res['data']
apps = [make_datatype(App, i) for i in data.get('app_list', [])] # type: List[App]
has_more = bool(data.get('has_more', False)) # type: bool
lang = I18NType(data.get('lang', 'zh_cn')) # type: I18NType
page_size = data.get('page_size') # type: int
page_token = data.get('page_token') # type: str
total_count = data.get('total_count') # type: int
return has_more, page_token, page_size, total_count, lang, apps
def get_installed_apps(self, page_size=20, page_token='', lang=I18NType.zh_cn, status=-1):
"""获取企业安装的应用
:type self: OpenLark
:param page_size: 本次拉取用户列表最大个数(最大值 1000 0 自动最大个数 )
:type page_size: int
:param page_token: 分页拉取用户列表起始位置标示不填表示从头开始
:type page_token: str
:param lang: 优先展示的应用信息的语言版本zh_cn中文en_us英文ja_jp日文
:type lang: I18NType
:param status: 要返回的应用的状态0:停用1:启用-1:全部
:type status: int
:return: 是否还有更多, page_token, page_size, 总数, 语言, 应用列表
:rtype: (bool, str, int, int, I18NType, list[App])
该接口用于查询企业安装的应用列表只能被企业自建应用调用且需要获取应用信息权限
https://open.feishu.cn/document/ukTMukTMukTM/uYDN3UjL2QzN14iN0cTN
"""
url = self._gen_request_url('/open-apis/application/v3/app/list?')
if page_token:
url = '{}&page_token={}'.format(url, page_token)
if page_size:
url = '{}&page_size={}'.format(url, page_size)
if lang:
url = '{}&lang={}'.format(url, converter_enum(lang))
if status in [0, 1, -1]:
url = '{}&status={}'.format(url, status)
res = self._get(url, with_tenant_token=True)
data = res['data']
apps = [make_datatype(App, i) for i in data.get('app_list', [])] # type: List[App]
has_more = bool(data.get('has_more', False)) # type: bool
lang = I18NType(data.get('lang', 'zh_cn')) # type: I18NType
page_size = data.get('page_size') # type: int
page_token = data.get('page_token') # type: str
total_count = data.get('total_count') # type: int
return has_more, page_token, page_size, total_count, lang, apps
def update_app_visibility(self, app_id, add_users=None, del_users=None, add_departments=None, del_departments=None,
is_visiable_to_all=None):
"""更新应用可用范围
:type self: OpenLark
:param app_id: 应用 ID
:type app_id: str
:param add_users: 增加的用户列表元素个数不超过500先增加后删除
:type add_users: list[SimpleUser]
:param del_users: 删除的用户列表元素个数不超过 500先增加后删除
:type del_users: list[SimpleUser]
:param add_departments: 添加的部门列表元素个数不超过 500先增加后删除
:type add_departments: list[str]
:param del_departments: 删除的部门列表元素个数不超过 500先增加后删除
:type del_departments: list[str]
:param is_visiable_to_all: 是否全员可见不填继续当前状态不改变
:type is_visiable_to_all: bool
该接口用于增加或者删除指定应用被哪些人可用只能被企业自建应用调用且需要管理应用权限
https://open.feishu.cn/document/ukTMukTMukTM/ucDN3UjL3QzN14yN0cTN
"""
url = self._gen_request_url('/open-apis/application/v3/app/update_visibility')
_add_users = []
for i in (add_users or []):
if i.user_id:
_add_users.append({'user_id': i.user_id})
elif i.open_id:
_add_users.append({'open_id': i.open_id})
else:
raise LarkInvalidArguments(msg='empty user_id and open_id')
_del_users = []
for i in (del_users or []):
if i.user_id:
_del_users.append({'user_id': i.user_id})
elif i.open_id:
_del_users.append({'open_id': i.open_id})
else:
raise LarkInvalidArguments(msg='empty user_id and open_id')
body = {
'app_id': app_id,
'add_users': _add_users,
'del_users': _del_users,
'add_departments': add_departments or [],
'del_departments': del_departments or []
}
if is_visiable_to_all is not None:
body['is_visiable_to_all'] = int(is_visiable_to_all)
self._post(url, body=body, with_tenant_token=True)

View File

@ -0,0 +1,349 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import json
from typing import TYPE_CHECKING, Dict, List, Tuple
from utils.feishu.dt_approval import ApprovalDefinition, ApprovalForm, ApprovalInstance, ApprovalNode
from utils.feishu.dt_enum import ApprovalUploadFileType
from utils.feishu.dt_help import make_datatype
from utils.feishu.exception import LarkInvalidArguments
from utils.feishu.helper import converter_enum, to_file_like
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
def _transfer_locale(locale='zh'):
return {'zh': 'zh-CN', 'en': 'en-US'}.get(str(locale).lower())
class APIApprovalMixin(object):
def get_approval_definition(self, definition_code, locale='zh'):
"""查看审批定义
:type self: OpenLark
:param definition_code: 审批定义Code需要有管理员权限然后在 https://www.feishu.cn/approval/admin/approvalList 创建
:type definition_code: str
:param locale: zh or en
:type locale: str
:return: 审批定义对象 ApprovalDefinition
:rtype: ApprovalDefinition
根据 definition_code 获取某个审批定义的详情用于构造创建审批实例的请求
https://open.feishu.cn/document/ukTMukTMukTM/uADNyUjLwQjM14CM0ITN
"""
url = self._gen_request_url('/approval/openapi/v2/approval/get', app='approval')
locale = {'zh': 'zh-CN', 'en': 'en-US'}.get(str(locale).lower())
body = {'approval_code': definition_code, 'locale': locale}
res = self._post(url, body, with_tenant_token=True)
data = res['data']
approval_name = data['approval_name']
form = json.loads(data['form'])
nodes = data['node_list']
definition = ApprovalDefinition(
approval_name=approval_name,
forms=[make_datatype(ApprovalForm, i) for i in form],
nodes=[make_datatype(ApprovalNode, i) for i in nodes],
)
return definition
def get_approval_instance_code_list(self, approval_code, start_time, end_time, offset=0, limit=100):
"""批量获取审批实例ID
:type self: OpenLark
:param approval_code: 审批定义code需要有管理员权限然后在 https://www.feishu.cn/approval/admin/approvalList 创建
:type approval_code: str
:param start_time: 审批实例创建时间区间13位毫秒级时间戳
:type start_time: int
:param end_time: 审批实例创建时间区间13位毫秒级时间戳
:type end_time: int
:param offset: 分页参数
:type offset: int
:param limit: 分页参数不得大于100
:type limit: int
:return: 审批实例ID数组
:rtype: list[str]
根据 approval_code 批量获取审批实例的 instance_code用于拉取租户下某个审批定义的全部审批实例 默认以审批创建时间排序
https://open.feishu.cn/document/ukTMukTMukTM/uQDOyUjL0gjM14CN4ITN
"""
url = self._gen_request_url('/approval/openapi/v2/instance/list', app='approval')
body = {
'approval_code': approval_code,
'start_time': start_time,
'end_time': end_time,
'offset': offset,
'limit': limit,
}
res = self._post(url, body=body, with_tenant_token=True)
data = res['data']
return data.get('instance_code_list', []) # type: List[str]
def get_approval_instance(self, instance_code, locale='zh'):
"""获取单个审批实例详情
:type self: OpenLark
:param instance_code: 审批实例 code需要有管理员权限然后在 https://www.feishu.cn/approval/admin/approvalList 创建
:type instance_code: str
:param locale zh 中文en 英文
:type locale: str
:return: 审批实例的对象 ApprovalInstance
:rtype: ApprovalInstance
根据 instance_code 获取某个审批实例的详情instance_code 批量获取审批实例接口获取
一般情况下当实例状态为1需定期重新拉取以确保获取到最新的实例详情
https://open.feishu.cn/document/ukTMukTMukTM/uEDNyUjLxQjM14SM0ITN
"""
url = self._gen_request_url('/approval/openapi/v2/instance/get', app='approval')
body = {'instance_code': instance_code, 'locale': _transfer_locale(locale)}
res = self._post(url, body=body, with_tenant_token=True)
data = res['data']
instance = make_datatype(ApprovalInstance, data)
return instance
def create_approval(self,
definition_code,
employee_id,
department_id,
form_list,
approver_employee_id_list,
cc_employee_id_list=None,
node_approver_employee_id_list=None):
"""创建审批实例
:type self: OpenLark
:param definition_code: 审批定义 code需要有管理员权限然后在 https://www.feishu.cn/approval/admin/approvalList 创建
:type definition_code: str
:param employee_id: 租户内用户唯一 ID
:type employee_id: str
:param department_id: 部门 ID
:type department_id: str
:param form_list: 审批的表单内容
:type form_list: list[(str, Any)]
:param approver_employee_id_list: 审批人用户 ID 列表
:type approver_employee_id_list: list[str]
:param cc_employee_id_list: 抄送人用户 ID 列表
:type cc_employee_id_list: list[str]
:param node_approver_employee_id_list: 发起人自选审批人列表
:type node_approver_employee_id_list: Dict[str, list[str]]
:return: 审批实例的 instance_code
:rtype: str
创建一个审批实例调用方需对审批定义的表单有详细了解将按照定义的表单结构将表单 Value 通过接口传入
https://open.feishu.cn/document/ukTMukTMukTM/uYDO24iN4YjL2gjN
"""
form = []
for i in form_list:
if len(i) != 2:
raise LarkInvalidArguments(msg='the length of item in a form_list be 2(key and value)')
form.append({'id': i[0], 'value': i[1]})
url = self._gen_request_url('/approval/openapi/v1/instance/create', app='approval')
if not cc_employee_id_list:
cc_employee_id_list = []
if not node_approver_employee_id_list:
node_approver_employee_id_list = {}
body = {
'definition_code': definition_code,
'employee_id': employee_id,
'department_id': department_id,
'form': json.dumps(form),
'approver_employee_id_list': approver_employee_id_list,
'cc_employee_id_list': cc_employee_id_list,
'node_approver_employee_id_list': node_approver_employee_id_list,
}
res = self._post(url, body=body, with_tenant_token=True)
return res.get('data', {}).get('instance_code', '')
def subscribe_approval(self, definition_code):
"""订阅审批事件
:type self: OpenLark
:param definition_code: 审批定义Code需要有管理员权限然后在 https://www.feishu.cn/approval/admin/approvalList 创建
:type definition_code: str
:raise: LarkApprovalSubscriptionExistException 重复订阅会抛错
订阅 definition_code 可以收到该审批定义对应实例的事件通知
https://open.feishu.cn/document/ukTMukTMukTM/uUTOwEjL1kDMx4SN5ATM
"""
url = self._gen_request_url('/approval/openapi/v1/subscription/subscribe', app='approval')
body = {'definition_code': definition_code}
self._post(url, body=body, with_tenant_token=True)
def unsubscribe_approval(self, definition_code):
"""取消订阅审批事件
:type self: OpenLark
:param definition_code: 审批定义Code
:type definition_code: str
取消订阅 definition_code 无法再收到该审批定义对应实例的事件通知
https://open.feishu.cn/document/ukTMukTMukTM/uYTOwEjL2kDMx4iN5ATM
"""
url = self._gen_request_url('/approval/openapi/v1/subscription/unsubscribe', app='approval')
body = {'definition_code': definition_code}
self._post(url, body=body, with_tenant_token=True)
def approve_approval(self,
approval_code,
instance_code,
user_id,
task_id,
comment=None):
"""审批任务同意
:type self: OpenLark
:param approval_code: 审批定义 code
:type approval_code: str
:param instance_code: 审批实例 code
:type instance_code: str
:param user_id: 操作用户的 user_id(v3 接口的 employee_id)
:type user_id: str
:param task_id: 任务id
:type task_id: str
:param comment: 意见
:type comment: str
对于单个审批任务进行同意操作同意后审批流程会流转到下一个审批人
https://open.feishu.cn/document/ukTMukTMukTM/uMDNyUjLzQjM14yM0ITN
"""
url = self._gen_request_url('/approval/openapi/v2/instance/approve', app='approval')
body = {
'approval_code': approval_code,
'instance_code': instance_code,
'user_id': user_id,
'task_id': task_id,
'comment': comment,
}
self._post(url, body, with_tenant_token=True)
def reject_approval(self,
approval_code,
instance_code,
user_id,
task_id,
comment=None):
"""审批任务拒绝
:type self: OpenLark
:param approval_code: 审批定义 code
:type approval_code: str
:param instance_code: 审批实例 code
:type instance_code: str
:param user_id: 操作用户的 user_id(v3 接口的 employee_id)
:type user_id: str
:param task_id: 任务id
:type task_id: str
:param comment: 意见
:type comment: str
对于单个审批任务进行拒绝操作拒绝后审批流程结束
https://open.feishu.cn/document/ukTMukTMukTM/uQDNyUjL0QjM14CN0ITN
"""
url = self._gen_request_url('/approval/openapi/v2/instance/reject', app='approval')
body = {
'approval_code': approval_code,
'instance_code': instance_code,
'user_id': user_id,
'task_id': task_id,
'comment': comment,
}
self._post(url, body, with_tenant_token=True)
def transfer_approval(self,
approval_code,
instance_code,
user_id,
task_id,
transfer_user_id,
comment=None):
"""审批任务转交
:type self: OpenLark
:param approval_code: 审批定义 code
:type approval_code: str
:param instance_code: 审批实例 code
:type instance_code: str
:param user_id: 操作用户的 user_id(v3 接口的 employee_id)
:type user_id: str
:param task_id: 任务id
:type task_id: str
:param comment: 意见
:type comment: str
:param transfer_user_id: 被转交用户的 user_id(v3 接口的 employee_id)
:type transfer_user_id: str
对于单个审批任务进行转交操作转交后审批流程流转给被转交人
https://open.feishu.cn/document/ukTMukTMukTM/uUDNyUjL1QjM14SN0ITN?lang=zh-CN
"""
url = self._gen_request_url('/approval/openapi/v2/instance/transfer', app='approval')
body = {
'approval_code': approval_code,
'instance_code': instance_code,
'user_id': user_id,
'task_id': task_id,
'comment': comment,
'transfer_user_id': transfer_user_id,
}
self._post(url, body, with_tenant_token=True)
def cancel_approval(self, approval_code, instance_code, user_id):
"""审批实例撤销
:type self: OpenLark
:param approval_code: 审批定义 code
:type approval_code: str
:param instance_code: 审批实例 code
:param user_id: 操作用户的 user_id(v3 接口的 employee_id)
对于单个审批实例进行撤销操作撤销后审批流程结束
https://open.feishu.cn/document/ukTMukTMukTM/uYDNyUjL2QjM14iN0ITN?lang=zh-CN
"""
url = self._gen_request_url('/approval/openapi/v2/instance/cancel', app='approval')
body = {
'approval_code': approval_code,
'instance_code': instance_code,
'user_id': user_id,
}
self._post(url, body, with_tenant_token=True)
def upload_approval_file(self, name, filetype, content):
"""审批所需要的文件上传
:type self: OpenLark
:param name: 文件名需包含文件扩展名文件.doc
:type name: str
:param filetype: 文件类型只能是 image attachment 之一
:type filetype: ApprovalUploadFileType
:param content: 文件支持路径bytesBytesIO
:return: 返回的第一个是可以用于审批的 code第二个是图片或者文件的 URL
:rtype: Tuple[str, str]
"""
content = to_file_like(content)
url = self._gen_request_url('/approval/openapi/v1/file/upload', app='approval')
body = {
'name': name,
'type': converter_enum(filetype),
}
files = {'content': content}
res = self._post(url=url, body=body, files=files, with_tenant_token=True)
data = res['data']
code = data.get('code', '') # type: str
url = data.get('url', '') # type: str
return code, url

27
utils/feishu/api_bot.py Normal file
View File

@ -0,0 +1,27 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Any, Dict
from utils.feishu.dt_code import Bot
from utils.feishu.dt_help import make_datatype
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIBotMixin(object):
def get_bot_info(self):
"""获取机器人信息
:type self: OpenLark
:return: 机器人的对象 Bot
:rtype: Bot
https://open.feishu.cn/document/ukTMukTMukTM/uAjMxEjLwITMx4CMyETM
"""
url = self._gen_request_url('/open-apis/bot/v3/info/')
body = {} # type: Dict[str, Any]
res = self._post(url, body, with_tenant_token=True)
return make_datatype(Bot, res['bot'])

View File

@ -0,0 +1,383 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Any, Dict, List, Tuple
from utils.feishu.dt_calendar import Calendar, CalendarAttendee, CalendarEvent
from utils.feishu.dt_enum import CalendarEventVisibility, CalendarRole
from utils.feishu.dt_help import make_datatype
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
from six import string_types
"""
page_token && sync_token
使用 page_token/sync_token 可以方便开发者以分页的形式获取日历列表
max_results 表示一个分页最多包含多少个日历
假如当次请求返回的结果未包含最后一个日历response 中会携带 page_token代表当前分页
下次请求时带上此 page_token 可以访问下个分页依此类推
response 中携带了 sync_token代表当次请求已经是最后分页若以后还有新增日历可以通过携带 sync_token 继续访问
"""
class APICalendarMixin(object):
def get_calendar_by_id(self, calendar_id):
"""获取日历
:type self: OpenLark
:param calendar_id: 日历 ID来源于 URL 路径创建日历后也会返回
:type calendar_id: str
:return: 日历的对象 Calendar
:rtype: Calendar
该接口用于根据日历 ID 获取日历信息
https://open.feishu.cn/document/ukTMukTMukTM/uMDN04yM0QjLzQDN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendar_list/{}'.format(calendar_id))
res = self._get(url, with_tenant_token=True, success_code=200000)
data = res.get('data', {})
return make_datatype(Calendar, data)
def get_calendar_list(self,
max_results=500,
page_token='',
sync_token=''):
"""获取日历列表
:type self: OpenLark
:param max_results: 一次请求要求返回最大数该参数不能大于 1000默认 500
:type max_results: int
:param page_token: 用于标志当次请求从哪页开始90 天有效期
:type page_token: str
:param sync_token: 表示从上次返回的截止页起返回结果90 天有效期
:type sync_token: str
:return: 两个分页参数和日历对象 Calendar 的列表
:rtype: Tuple[str, str, List[Calendar]]
该接口用于获取应用在企业内的日历列表
https://open.feishu.cn/document/ukTMukTMukTM/uMTM14yMxUjLzETN
"""
if max_results > 1000:
max_results = 1000
url = self._gen_request_url('/open-apis/calendar/v3/calendar_list?max_results={}'.format(max_results))
if page_token:
url = '{}&page_token={}'.format(url, page_token)
if sync_token:
url = '{}&sync_token={}'.format(url, sync_token)
res = self._get(url, with_tenant_token=True, success_code=200000)
data = res.get('data', [])
page_token = res.get('page_token', '')
sync_token = res.get('sync_token', '')
calendar_list = [make_datatype(Calendar, i) for i in data]
return page_token, sync_token, calendar_list
def create_calendar(self,
summary,
description='',
is_private=False,
default_access_role=CalendarRole.free_busy_reader):
"""创建日历
:type self: OpenLark
:param summary: 日历标题最大长度为 256
:type summary: str
:param description: 日历描述最大长度为 256
:type description: str
:param is_private: 是否为私有日历私有日历不可被搜索默认为false
:param default_access_role: 表示用户的默认访问权限取值如下
reader: 订阅者可查看日程详情
free_busy_reader: 游客只能看到"忙碌/空闲"
:type default_access_role: CalendarRole
:return: 日历的对象 Calendar
:rtype: Calendar
该接口用于为应用在企业内创建一个日历
https://open.feishu.cn/document/ukTMukTMukTM/uQTM14CNxUjL0ETN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendars')
body = {
'summary': summary,
'description': description,
'is_private': is_private,
'default_access_role': default_access_role.value
}
res = self._post(url, body=body, with_tenant_token=True, success_code=200000)
data = res.get('data', {})
return make_datatype(Calendar, data)
def delete_calendar_by_id(self, calendar_id):
"""删除日历
:type self: OpenLark
:param calendar_id: 日历 ID
:type calendar_id: str
该接口用于删除应用在企业内的指定日历
https://open.feishu.cn/document/ukTMukTMukTM/uUTM14SNxUjL1ETN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendars/{}'.format(calendar_id))
self._delete(url, with_tenant_token=True, success_code=200000)
def update_calendar_by_id(self,
calendar_id,
summary=None,
description=None,
is_private=None,
default_access_role=None):
"""更新日历
:type self: OpenLark
:param calendar_id: 日历 ID
:type calendar_id: str
:param summary: 日历标题最大长度为 256
:type summary: str
:param description: 日历描述最大长度为 256
:type description: str
:param is_private: 是否为私有日历私有日历不可被搜索默认为false
:type is_private: bool
:param default_access_role: 表示用户的默认访问权限取值如下
reader: 订阅者可查看日程详情
free_busy_reader: 游客只能看到"忙碌/空闲"
:type default_access_role: CalendarRole
:return: Calendar 对象
:rtype: Calendar
该接口用于修改指定日历的信息
https://open.feishu.cn/document/ukTMukTMukTM/uYTM14iNxUjL2ETN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendars/{}'.format(calendar_id))
body = {}
for k, v in {
'summary': summary,
'description': description,
'is_private': is_private,
'default_access_role': default_access_role.value if default_access_role else None,
}.items():
if v is not None:
body[k] = v
res = self._patch(url, body, with_tenant_token=True, success_code=200000)
data = res['data']
return make_datatype(Calendar, data)
def _gen_calendar_event(self, data):
"""生成 CalendarEvent
:rtype CalendarEvent
"""
return CalendarEvent(
id=data.get('id', ''),
summary=data.get('summary', ''),
description=data.get('description', ''),
start=data.get('start', {}).get('time_stamp', 0),
end=data.get('end', {}).get('time_stamp', 0),
visibility=CalendarEventVisibility(data.get('visibility', 'default')),
attendees=[make_datatype(CalendarAttendee, i) for i in (data.get('attendees') or [])]
)
def get_calendar_event(self, calendar_id, event_id):
"""获取日程
:type self: OpenLark
:param calendar_id: 日历 ID
:param event_id: 日程 ID
:return: 日历事件的对象
:rtype: CalendarEvent
该接口用于获取指定日历下的指定日程
https://open.feishu.cn/document/ukTMukTMukTM/ucTM14yNxUjL3ETN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendars/{}/events/{}'.format(calendar_id, event_id))
res = self._get(url, with_tenant_token=True, success_code=200000)
data = res['data']
return self._gen_calendar_event(data)
def create_calendar_event(self,
calendar_id,
summary,
start_timestamp,
end_timestamp,
description='',
attendees=None,
visibility=CalendarEventVisibility.default):
"""创建日程
:type self: OpenLark
:param calendar_id: 日历 ID
:type calendar_id: str
:param summary: 日程标题最大长度为 256
:type summary: str
:param start_timestamp: 日程的起始时间10 位秒级时间戳
:type start_timestamp: int
:param end_timestamp: 日程的结束时间10 位秒级时间戳
:type end_timestamp: int
:param description: 日程描述最大长度为 256默认空
:type description: str
:param attendees: 日程参与者信息默认空数组每个 Attendess 必须有 open_id 或者 employee_id
:type attendees: List[CalendarAttendee]
:param visibility: 公开范围
:type visibility: CalendarEventVisibility
:return: 日历事件的对象
:rtype: CalendarEvent
该接口用于在日历中创建一个日程
https://open.feishu.cn/document/ukTMukTMukTM/ugTM14COxUjL4ETN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendars/{}/events'.format(calendar_id))
if not attendees:
attendees = []
else:
attendees = [dict(filter(lambda x: x[1], [('open_id', i.open_id),
('employee_id', i.employee_id),
('display_name', i.display_name)])) for i in attendees]
body = {
'summary': summary,
'description': description,
'start': {
'time_stamp': start_timestamp,
},
'end': {
'time_stamp': end_timestamp,
},
'attendees': attendees, # type: ignore
'visibility': visibility.value if isinstance(visibility, CalendarEventVisibility) else visibility,
}
res = self._post(url, body, with_tenant_token=True, success_code=200000)
data = res['data']
return self._gen_calendar_event(data)
def get_calendar_event_list(self,
calendar_id,
max_results=500,
page_token='',
sync_token=''):
"""获取日程列表
:type self: OpenLark
:param calendar_id: 日历 ID
:type calendar_id: str
:param max_results: 一次请求要求返回最大数该参数不能大于 1000默认 500
:param page_token: 用于标志当次请求从哪页开始
:type page_token: str
:param sync_token: 表示从上次返回的截止页起返回结果
:type sync_token: str
:return: page_token, sync_token 日历事件的列表
:rtype: Tuple[str, str, List[CalendarEvent]]
该接口用于获取指定日历下的日程列表
https://open.feishu.cn/document/ukTMukTMukTM/ukTM14SOxUjL5ETN
"""
if max_results > 1000:
max_results = 1000
url = self._gen_request_url(
'/open-apis/calendar/v3/calendars/{}/events?max_results={}'.format(calendar_id, max_results))
if page_token:
url = '{}&page_token={}'.format(url, page_token)
if sync_token:
url = '{}&sync_token={}'.format(url, sync_token)
res = self._get(url, with_tenant_token=True, success_code=200000)
data = res.get('data', [])
page_token = res.get('page_token', '')
sync_token = res.get('sync_token', '')
calendar_event_list = [self._gen_calendar_event(i) for i in data]
return page_token, sync_token, calendar_event_list
def delete_calendar_event(self, calendar_id, event_id=''):
"""删除日程
:type self: OpenLark
:param calendar_id: 日历 ID
:param event_id: 日程 ID
该接口用于删除指定日历下的日程
https://open.feishu.cn/document/ukTMukTMukTM/uAjM14CMyUjLwITN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendars/{}/events/{}'.format(calendar_id, event_id))
self._delete(url, with_tenant_token=True, success_code=200000)
def update_calendar_event(self,
calendar_id,
event_id,
summary=None,
start_timestamp=None,
end_timestamp=None,
description=None,
attendees=None,
visibility=None):
"""更新日程
:type self: OpenLark
:param calendar_id: 日历 ID
:type calendar_id: str
:param event_id: 日程 ID
:type event_id: str
:param summary: 日程标题最大长度为 256
:type summary: str
:param start_timestamp: 日程的起始时间10 位秒级时间戳
:type start_timestamp: int
:param end_timestamp: 日程的结束时间10 位秒级时间戳
:type end_timestamp: int
:param description: 日程描述最大长度为 256默认空
:type description: str
:param attendees: 日程参与者信息默认空数组每个 Attendess 必须有 open_id 或者 employee_id
:type attendees: List[CalendarAttendee]
:param visibility: 公开范围
:type visibility: CalendarEventVisibility
:return: 日历事件
:rtype: CalendarEvent
该接口用于在日历中创建一个日程
https://open.feishu.cn/document/ukTMukTMukTM/uEjM14SMyUjLxITN
"""
url = self._gen_request_url('/open-apis/calendar/v3/calendars/{}/events/{}'.format(calendar_id, event_id))
if not attendees:
attendees = []
else:
attendees = [dict(filter(lambda x: x[1], [('open_id', i.open_id),
('employee_id', i.employee_id),
('display_name', i.display_name)])) for i in attendees]
body = {} # type: Dict[string_types, Any]
if summary is not None:
body['summary'] = summary
if description is not None:
body['description'] = description
if start_timestamp is not None:
body['start'] = {'time_stamp': start_timestamp}
if end_timestamp is not None:
body['end'] = {'time_stamp': end_timestamp}
if attendees is not None:
body['attendees'] = attendees
if visibility is not None:
body['visibility'] = visibility.value if isinstance(visibility, CalendarEventVisibility) else visibility
res = self._patch(url, body, with_tenant_token=True, success_code=200000)
data = res['data']
return self._gen_calendar_event(data)
# TODO:
# 邀请/移除日程参与者
# 获取访问控制列表
# 创建访问控制
# 删除访问控制
# 查询日历的忙闲状态

View File

@ -0,0 +1,338 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import base64
import hashlib
import json
import logging
from typing import TYPE_CHECKING, Any, Dict
from Crypto.Cipher import AES
from utils.feishu.dt_callback import (EventAppOpen, EventApproval, EventAppTicket, EventContactDepartment, EventContactScope,
EventContactUser, EventLeaveApproval, EventMessage, EventP2PCreateChat,
EventRemedyApproval, EventRemoveAddBot, EventShiftApproval, EventTripApproval,
EventUserInAndOutChat, EventWorkApproval)
from utils.feishu.dt_enum import EventType
from utils.feishu.dt_help import make_datatype
from utils.feishu.exception import LarkInvalidArguments, LarkInvalidCallback
from utils.feishu.helper import pop_or_none
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
logger = logging.getLogger('feishu')
class _AESCipher(object):
def __init__(self, key):
self.bs = AES.block_size
self.key = hashlib.sha256(_AESCipher.str_to_bytes(key)).digest()
@staticmethod
def str_to_bytes(data):
u_type = type(b"".decode('utf8'))
if isinstance(data, u_type):
return data.encode('utf8')
return data # pragma: no cover
@staticmethod
def _unpad(s):
return s[:-ord(s[len(s) - 1:])]
def decrypt(self, enc):
iv = enc[:AES.block_size]
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return self._unpad(cipher.decrypt(enc[AES.block_size:]))
def decrypt_string(self, enc):
enc = base64.b64decode(enc)
return self.decrypt(enc).decode('utf8')
def get_event_type(body):
"""
:param body:
:type body: Dict[string_types, typing.Union[Any, Dict[string_types, Any]]]
:return:
"""
t = body.get('type')
if t == 'event_callback':
t = body.get('event', {}).get('type')
return {
'app_ticket': EventType.app_ticket, # DONE
'app_open': EventType.app_open,
'message': EventType.message, # DONE
'user_add': EventType.user_add,
'user_update': EventType.user_update,
'user_leave': EventType.user_leave,
'dept_add': EventType.dept_add,
'dept_update': EventType.dept_update,
'dept_delete': EventType.dept_delete,
'contact_scope_change': EventType.contact_scope_change,
'approval': EventType.approval,
'leave_approval': EventType.leave_approval,
'work_approval': EventType.work_approval,
'shift_approval': EventType.shift_approval,
'remedy_approval': EventType.remedy_approval,
'trip_approval': EventType.trip_approval,
'remove_bot': EventType.remove_bot,
'add_bot': EventType.add_bot,
'p2p_chat_create': EventType.p2p_chat_create,
}.get(t, EventType.unknown)
return {
'url_verification': EventType.url_verification,
}.get(t, EventType.unknown)
class APICallbackMixin(object):
"""订阅事件
飞书中很多操作都会产生事件应用可以订阅这些事件来与飞书进行高度整合可以在开发者后台进行事件订阅配置来监听事件
已经有的事件类型
- 审批通过
- 收到消息必须单聊或者是艾特机器人
- 推送 app_ticket
https://open.feishu.cn/document/uYjL24iN/uUTNz4SN1MjL1UzM
"""
def handle_callback(
self,
body,
handle_message=None,
handle_app_ticket=None,
handle_approval=None,
handle_leave_approval=None,
handle_work_approval=None,
handle_shift_approval=None,
handle_remedy_approval=None,
handle_trip_approval=None,
handle_app_open=None,
handle_contact_user=None,
handle_contact_department=None,
handle_contact_scope=None,
handle_remove_add_bot=None,
handle_p2p_chat_create=None,
handle_user_in_out_chat=None,
):
"""处理机器人回调
:type self: OpenLark
:param body: 回调的消息主题
:type body: Dict[string_types, Any]
:param handle_message: 消息的回调 - 处理函数
:type handle_message: Callable[[str, str, 'EventMessage', Dict[str, Any]], Any]
:param handle_app_ticket: app_ticket 事件 - 处理函数
:type handle_app_ticket: Callable[[str, str, 'EventAppTicket', Dict[str, Any]], Any]
:param handle_approval:
:type handle_approval: Callable[[str, str, 'EventApproval', Dict[str, Any]], Any]
:param handle_leave_approval:
:type handle_leave_approval: Callable[[str, str, 'EventLeaveApproval', Dict[str, Any]], Any]
:param handle_work_approval:
:type handle_work_approval: Callable[[str, str, 'EventWorkApproval', Dict[str, Any]], Any]
:param handle_shift_approval:
:type handle_shift_approval: Callable[[str, str, 'EventShiftApproval', Dict[str, Any]], Any]
:param handle_remedy_approval:
:type handle_remedy_approval: Callable[[str, str, 'EventRemedyApproval', Dict[str, Any]], Any]
:param handle_trip_approval:
:type handle_trip_approval: Callable[[str, str, 'EventTripApproval', Dict[str, Any]], Any]
:param handle_app_open:
:type handle_app_open: Callable[[str, str, 'EventAppOpen', Dict[str, Any]], Any]
:param handle_contact_user:
:type handle_contact_user: Callable[[str, str, 'EventContactUser', Dict[str, Any]], Any]
:param handle_contact_department:
:type handle_contact_department: Callable[[str, str, 'EventContactDepartment', Dict[str, Any]], Any]
:param handle_contact_scope:
:type handle_contact_scope: Callable[[str, str, 'EventContactScope', Dict[str, Any]], Any]
:param handle_remove_add_bot:
:type handle_remove_add_bot: Callable[[str, str, 'EventRemoveAddBot', Dict[str, Any]], Any]
:param handle_p2p_chat_create:
:type handle_p2p_chat_create: Callable[[str, str, 'EventP2PCreateChat', Dict[str, Any]], Any]
:param handle_user_in_out_chat:
:type handle_user_in_out_chat: Callable[[str, str, 'EventUserInAndOutChat', Dict[str, Any]], Any]
"""
if not isinstance(body, dict):
raise LarkInvalidArguments(msg='回调参数需要是字典')
if 'encrypt' in body:
body = json.loads(self.decrypt_string(body['encrypt']))
if not self.verification_token:
raise LarkInvalidArguments(msg='回调需要 verification_token 参数')
token = body.get('token')
if token != self.verification_token:
raise LarkInvalidCallback(msg='token: {} 不合法'.format(token))
event_type = get_event_type(body)
if event_type == EventType.url_verification:
return {'challenge': body.get('challenge')}
msg_uuid = body.get('uuid', '') # type: str
msg_timestamp = body.get('ts', '') # type: str
json_event = body.get('event', {}) # type: Dict[str, Any]
logger.info('[callback] uuid=%s, ts=%s, event=%s', msg_uuid, msg_timestamp, json_event)
if event_type == EventType.approval:
# 审批通过
if handle_approval:
event_approval = make_datatype(EventApproval, json_event)
return handle_approval(msg_uuid, msg_timestamp, event_approval, json_event)
return
if event_type == EventType.leave_approval:
# 请假审批
if handle_leave_approval:
event_leave_approval = make_datatype(EventLeaveApproval, json_event)
return handle_leave_approval(msg_uuid, msg_timestamp, event_leave_approval, json_event)
return
if event_type == EventType.work_approval:
# 加班审批
if handle_work_approval:
event_work_approval = make_datatype(EventWorkApproval, json_event)
return handle_work_approval(msg_uuid, msg_timestamp, event_work_approval, json_event)
return
if event_type == EventType.shift_approval:
# 换班审批
if handle_shift_approval:
event_shift_approval = make_datatype(EventShiftApproval, json_event)
return handle_shift_approval(msg_uuid, msg_timestamp, event_shift_approval, json_event)
return
if event_type == EventType.remedy_approval:
# 补卡审批
if handle_remedy_approval:
event_remedy_approval = make_datatype(EventRemedyApproval, json_event)
return handle_remedy_approval(msg_uuid, msg_timestamp, event_remedy_approval, json_event)
return
if event_type == EventType.trip_approval:
# 出差审批
if handle_trip_approval:
event_trip_approval = make_datatype(EventTripApproval, json_event)
return handle_trip_approval(msg_uuid, msg_timestamp, event_trip_approval, json_event)
return
if event_type == EventType.app_open:
# 开通应用
if handle_app_open:
event_app_open = make_datatype(EventAppOpen, json_event)
return handle_app_open(msg_uuid, msg_timestamp, event_app_open, json_event)
return
if event_type in [EventType.user_add, EventType.user_leave, EventType.user_update]:
# 通讯录用户相关变更事件,包括 user_add, user_update 和 user_leave 事件类型
if handle_contact_user:
event_contact_user = make_datatype(EventContactUser, json_event)
return handle_contact_user(msg_uuid, msg_timestamp, event_contact_user, json_event)
return
if event_type in [EventType.dept_add, EventType.dept_delete, EventType.dept_update]:
# 通讯录部门相关变更事件,包括 dept_add, dept_update 和 dept_delete
if handle_contact_department:
event_contact_department = make_datatype(EventContactDepartment, json_event)
return handle_contact_department(msg_uuid, msg_timestamp, event_contact_department, json_event)
return
if event_type == EventType.contact_scope_change:
# 变更权限范围
if handle_contact_scope:
event_contact_scope = make_datatype(EventContactScope, json_event)
return handle_contact_scope(msg_uuid, msg_timestamp, event_contact_scope, json_event)
return
if event_type == EventType.message:
# 收到消息(必须单聊或者是艾特机器人)的回调
if handle_message:
event = make_datatype(EventMessage, json_event) # type: EventMessage
return handle_message(msg_uuid, msg_timestamp, event, json_event)
return
if event_type in [EventType.remove_bot, EventType.add_bot]:
# 机器人被移出群聊/机器人被邀请进入群聊
if handle_remove_add_bot:
event_remove_add_bot = make_datatype(EventRemoveAddBot, json_event)
return handle_remove_add_bot(msg_uuid, msg_timestamp, event_remove_add_bot, json_event)
return
if event_type == EventType.app_ticket:
# 下发 app_ticket
event_app_ticket = make_datatype(EventAppTicket, json_event)
self.update_app_ticket(event_app_ticket.app_ticket)
if handle_app_ticket:
return handle_app_ticket(msg_uuid, msg_timestamp, event_app_ticket, json_event)
return
if event_type == EventType.p2p_chat_create:
# 机器人和用户的会话第一次创建
if handle_p2p_chat_create:
event_chat_create = make_datatype(EventP2PCreateChat, json_event)
return handle_p2p_chat_create(msg_uuid, msg_timestamp, event_chat_create, json_event)
return
if event_type in [EventType.add_user_to_chat, EventType.remove_user_from_chat,
EventType.revoke_add_user_from_chat]:
# 用户进群和出群
if handle_user_in_out_chat:
event_in_and_out_chat = make_datatype(EventUserInAndOutChat, json_event)
return handle_user_in_out_chat(msg_uuid, msg_timestamp, event_in_and_out_chat, json_event)
return
logger.warning('[callback][unknown event] uuid=%s, ts=%s, event=%s', msg_uuid, msg_timestamp, event_type)
return {
'message': 'event: {} not handle'.format(event_type),
'msg_uuid': msg_uuid,
'msg_timestamp': msg_timestamp,
'json_event': json_event,
}
def handle_card_message_callback(self, body, handle=None):
"""处理卡片消息的回调
:type self: OpenLark
:type body: Dict[string_types, Any]
:type handle: Callable[[str, str, str, str, str, Dict[str, Any]], Any]
"""
if not isinstance(body, dict):
raise LarkInvalidArguments(msg='回调参数需要是字典')
if 'encrypt' in body:
body = json.loads(self.decrypt_string(body['encrypt']))
event_type = get_event_type(body)
if event_type == EventType.url_verification:
if not self.verification_token:
raise LarkInvalidArguments(msg='回调需要 verification_token 参数')
token = body.get('token')
if token != self.verification_token:
raise LarkInvalidCallback(msg='token: {} 不合法'.format(token))
return {'challenge': body.get('challenge')}
open_id = pop_or_none(body, 'open_id')
employee_id = pop_or_none(body, 'employee_id')
open_message_id = pop_or_none(body, 'open_message_id')
tenant_key = pop_or_none(body, 'tenant_key')
tag = pop_or_none(body, 'tag')
return handle(tenant_key, open_id, employee_id, open_message_id, tag, body)
def decrypt_string(self, s):
"""
:type self: OpenLark
:param s:
:return:
"""
if not self.encrypt_key:
raise LarkInvalidArguments(msg='需要 encrypt_key 参数')
return _AESCipher(self.encrypt_key).decrypt_string(s)

341
utils/feishu/api_chat.py Normal file
View File

@ -0,0 +1,341 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Any, Dict, List, Tuple
from utils.feishu.dt_code import Chat, DetailChat
from utils.feishu.dt_help import make_datatype
from utils.feishu.exception import LarkInvalidArguments
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
from six import string_types
class APIChatMixin(object):
def get_chat_info(self, chat_id):
"""获取会话信息,包括和机器人的私聊 + 群聊
:type self: OpenLark
:param chat_id: 会话的 ID
:type chat_id str
:return: 会话信息
:rtype: DetailChat
获取群名称群主 ID成员列表 ID 等群基本信息
https://open.feishu.cn/document/ukTMukTMukTM/uMTO5QjLzkTO04yM5kDN
"""
url = self._gen_request_url('/open-apis/chat/v4/info/?chat_id=' + chat_id)
res = self._get(url, with_tenant_token=True)
chat = res['data']
return make_datatype(DetailChat, chat)
def get_chat_list_of_bot(self, page_size=100, page_token=None):
"""获取机器人所在的群列表
:type self: OpenLark
:param page_size: 分页大小最大支持 200默认为 100
:type page_size: int
:param page_token: 分页标记分页查询还有更多群时会同时返回新的 page_token, 下次遍历可采用该 page_token 获取更多
:type page_token:str
:return: 有更多群 下次分页的参数群的列表
:rtype: Tuple[bool, str, List[Chat]]
获取机器人所在的群列表
https://open.feishu.cn/document/ukTMukTMukTM/uITO5QjLykTO04iM5kDN
"""
url = self._gen_request_url('/open-apis/chat/v4/list?page_size={}'.format(page_size))
if page_token:
url = '{}&page_token={}'.format(url, page_token)
res = self._get(url, with_tenant_token=True)
data = res['data']
has_more = data.get('has_more') # type: bool
page_token = data.get('page_token') # type: str
chats = [make_datatype(Chat, i) for i in data.get('groups', [])]
return has_more, page_token, chats
def get_chat_list_of_user(self, user_access_token, query_key=None, page_size=100, page_token=None):
"""获取用户所在的群列表
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param query_key: 搜索的关键词
:type query_key: str
:param page_size: 分页大小最大支持 200默认为 100
:type page_size: int
:param page_token: 分页标记分页查询还有更多群时会同时返回新的 page_token, 下次遍历可采用该 page_token 获取更多群
:type page_token:
:return: 有更多群 下次分页的参数群的列表
:rtype: (bool, str, list[Chat])
获取用户所在的群列表https://open.feishu.cn/document/ukTMukTMukTM/uQzMwUjL0MDM14CNzATN
搜索用户所在的群列表https://open.feishu.cn/document/ukTMukTMukTM/uUTOyUjL1kjM14SN5ITN
"""
if query_key:
url = self._gen_request_url('/open-apis/chat/v4/search?page_size={}'.format(page_size))
else:
url = self._gen_request_url('/open-apis/user/v4/group_list?page_size={}'.format(page_size))
if page_token:
url = '{}&page_token={}'.format(url, page_token)
if query_key:
url = '{}&query={}'.format(url, query_key)
res = self._get(url, auth_token=user_access_token)
data = res['data']
has_more = data.get('has_more')
page_token = data.get('page_token')
chats = [make_datatype(Chat, i) for i in data.get('groups', [])]
return has_more, page_token, chats
def create_chat(self,
open_ids=None,
user_ids=None,
name=None,
description=None,
en_name=None,
ja_name=None):
"""机器人创建群并拉用户进群
:type self: OpenLark
:param open_ids: 成员 open_id 列表
:type open_ids: list[str]
:param user_ids: 成员 user_id 列表
:type user_ids: list[str]
:param name: 群的名称
:type name: str
:param description: 群描述
:type description: str
:param en_name: 群的英文名称
:type en_name: str
:param ja_name: 群的日文名称
:type ja_name: str
:return: open_chat_id, invalid_open_ids, invalid_user_ids
:rtype: Tuple[str, list[str], list[str]]
https://open.feishu.cn/document/ukTMukTMukTM/ukDO5QjL5gTO04SO4kDN
"""
if not open_ids and not user_ids:
raise LarkInvalidArguments(msg='open_ids or user_ids cannot empty')
url = self._gen_request_url('/open-apis/chat/v4/create/')
body = {} # type: Dict[string_types, Any]
if open_ids:
body['open_ids'] = open_ids
if user_ids:
body['user_ids'] = user_ids
if name:
body['name'] = name
if description:
body['description'] = description
if name is not None:
body['name'] = name
if description is not None:
body['description'] = description
if en_name or ja_name:
body['i18n_names'] = {
"zh_cn": name,
"en_us": en_name,
'ja_jp': ja_name,
}
res = self._post(url, body, with_tenant_token=True)
data = res['data']
open_chat_id = data.get('chat_id', '') # type: str
invalid_open_ids = data.get('invalid_open_ids', []) # type: List[str]
invalid_user_ids = data.get('invalid_user_ids', []) # type: List[str]
return open_chat_id, invalid_open_ids, invalid_user_ids
def update_chat_info(self,
chat_id,
owner_user_id=None,
owner_open_id=None,
name=None,
en_name=None,
ja_name=None):
"""更新群信息
:type self: OpenLark
:param chat_id: ID
:type chat_id: str
:param owner_user_id: 群主的 open_id
:type owner_user_id: str
:param owner_open_id: 群主的 user_id
:type owner_open_id: str
:param name: 群的名称
:type name: str
:param en_name: 群的英文名称
:type en_name: str
:param ja_name: 群的日文名称
:type ja_name: str
:return: open_chat_id, invalid_open_ids, invalid_user_ids
:rtype: Tuple[str, list[str], list[str]]
更新群名称转让群主等
https://open.feishu.cn/document/ukTMukTMukTM/uYTO5QjL2kTO04iN5kDN
"""
url = self._gen_request_url('/open-apis/chat/v4/update/')
body = {
'chat_id': chat_id,
} # type: Dict[string_types, Any]
if owner_user_id:
body['owner_user_id'] = owner_user_id
if owner_open_id:
body['owner_open_id'] = owner_open_id
if name:
body['name'] = name
if name is not None:
body['name'] = name
if en_name or ja_name:
body['i18n_names'] = {
'zh_cn': name,
'en_us': en_name,
'ja_jp': ja_name,
}
self._post(url, body, with_tenant_token=True)
def invite_user_to_chat(self,
chat_id,
open_ids=None,
user_ids=None):
"""机器人拉用户进群
:type self: OpenLark
:param chat_id: ID
:type chat_id: str
:param open_ids: 需要加入群的用户的 open_id 列表
:type open_ids: list[str]
:param user_ids: 需要加入群的用户的 employee_id 列表
:type user_ids: list[str]
:return: invalid_open_ids, invalid_user_ids
:rtype: Tuple[list[str], list[str]]
机器人拉用户进群机器人必须在群里
https://open.feishu.cn/document/ukTMukTMukTM/uMjMxEjLzITMx4yMyETM
"""
url = self._gen_request_url('/open-apis/chat/v4/chatter/add/')
if not open_ids and not user_ids:
raise LarkInvalidArguments(msg='[invite_user_to_chat] empty open_ids and user_ids')
if not open_ids:
open_ids = None
if not user_ids:
user_ids = None
body = {'chat_id': chat_id, 'open_ids': open_ids, 'user_ids': user_ids}
res = self._post(url, body, with_tenant_token=True)
data = res['data']
invalid_open_ids = data.get('invalid_open_ids', [])
invalid_user_ids = data.get('invalid_user_ids', [])
return invalid_open_ids, invalid_user_ids
def remove_user_from_chat(self,
chat_id,
open_ids=None,
user_ids=None):
"""机器踢人出群
:type self: OpenLark
:param chat_id: ID
:type chat_id: str
:param open_ids: 需要踢出群的用户的 open_id 列表
:type open_ids: list[str]
:param user_ids: 需要踢出群的用户的 user_ids 列表
:type user_ids: list[str]
:return: invalid_open_ids, invalid_employee_ids
:rtype: Tuple[list[str], list[str]]
机器人踢用户出群机器人必须是群主
https://open.feishu.cn/document/ukTMukTMukTM/uADMwUjLwADM14CMwATN
"""
if not open_ids and not user_ids:
raise LarkInvalidArguments(msg='[remove_user_from_chat] empty open_ids and user_ids')
if not open_ids:
open_ids = None
if not user_ids:
user_ids = None
url = self._gen_request_url('/open-apis/chat/v4/chatter/delete/')
body = {'chat_id': chat_id, 'open_ids': open_ids, 'user_ids': user_ids}
res = self._post(url, body, with_tenant_token=True)
data = res['data']
invalid_open_ids = data.get('invalid_open_ids', []) # type: List[str]
invalid_user_ids = data.get('invalid_user_ids', []) # type: List[str]
return invalid_open_ids, invalid_user_ids
def invite_bot_to_chat(self, chat_id):
"""邀请机器人加群
:type self: OpenLark
:param chat_id: 群的 ID
:type chat_id: str
拉机器人进群机器人的owner需要已经在群里
https://open.feishu.cn/document/ukTMukTMukTM/uYDO04iN4QjL2gDN
"""
url = self._gen_request_url('/open-apis/bot/v4/add')
body = {'chat_id': str(chat_id)}
self._post(url, body, with_tenant_token=True)
def remove_bot_from_chat(self, chat_id):
"""把机器人从群里踢掉
:type self: OpenLark
:param chat_id: 群的 ID
:type chat_id: str
机器人的owner需要已经在群里
https://open.feishu.cn/document/ukTMukTMukTM/ucDO04yN4QjL3gDN
"""
url = self._gen_request_url('/open-apis/bot/v4/remove')
body = {'chat_id': str(chat_id)}
self._post(url, body, with_tenant_token=True)
def disband_chat(self, chat_id):
"""机器人解散群,机器人需要是群主
:type self: OpenLark
:param chat_id: 群的 ID
:type chat_id: str
机器人解散群(机器人需要是群主)
https://open.feishu.cn/document/ukTMukTMukTM/uUDN5QjL1QTO04SN0kDN
"""
url = self._gen_request_url('/open-apis/chat/v4/disband')
body = {'chat_id': str(chat_id)}
self._post(url, body, with_tenant_token=True)
def is_user_in_chat(self, user_access_token, chat_ids):
"""判断用户是否在群里
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param chat_ids: 群的chat_id数组
:type chat_ids: list[str]
判断用户是否在群里
https://open.feishu.cn/document/ukTMukTMukTM/uUzM3UjL1MzN14SNzcTN
"""
url = self._gen_request_url(
'/open-apis/chat/v4/is_user_in?' + '&'.join(['chat_ids={}'.format(i) for i in chat_ids]))
res = self._get(url, auth_token=user_access_token)
in_chat_ids = res['data'].get('in_chat', []) # type: List[str]
return in_chat_ids

773
utils/feishu/api_contact.py Normal file
View File

@ -0,0 +1,773 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Dict, List
from utils.feishu.dt_code import SimpleUser
from utils.feishu.dt_contact import (ContactAsyncTaskResult, Department, DepartmentUser, DepartmentUserCustomAttr, Role,
SimpleDepartment)
from utils.feishu.dt_help import make_datatype
from utils.feishu.dt_req import CreateDepartmentRequest, CreateUserRequest, UpdateUserRequest
from utils.feishu.exception import LarkInvalidArguments, OpenLarkException, gen_exception
from utils.feishu.helper import join_dict, join_url
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIContactMixin(object):
def get_contact_scope(self):
"""获取通讯录授权范围
:type self: OpenLark
:return: is_visible_to_all, department_ids, users
:rtype: (bool, list[str], list[SimpleUser])
该接口用于获取应用被授权可访问的通讯录范围包括可访问的部门列表及用户列表
授权范围为全员时返回的部门列表为该企业所有的一级部门
否则返回的部门为管理员在设置授权范围时勾选的部门不包含勾选部门的子部门
https://open.feishu.cn/document/ukTMukTMukTM/ugjNz4CO2MjL4YzM
https://bytedance.feishu.cn/docs/doccnOcR1fnxBACchoY9tlg7Amg#
"""
url = self._gen_request_url('/open-apis/contact/v2/scope/get')
res = self._get(url, with_tenant_token=True)
data = res['data']
department_ids = data.get('authed_departments', []) # type: List[str]
users = [make_datatype(SimpleUser, i) for i in data.get('authed_users', [])] # type: List[SimpleUser]
is_visible_to_all = '0' in department_ids
return is_visible_to_all, department_ids, users
def create_department(self, parent_id, name, custom_id=None, leader_open_id=None, leader_user_id=None,
create_group_chat=False):
"""新增部门
:type self: OpenLark
:param parent_id: 父部门 ID
:type parent_id: str
:param name: 部门名称
:type name: str
:param name: 部门名称
:type name: str
:param custom_id: 自定义部门 ID
该字段企业内必须唯一不区分大小写长度为 1 ~ 64 个字符只能由数字字母和_-@.四种字符组成且第一个字符必须是数字或字母
创建部门时指定的 ID 后续不允许修改不指定该字段由系统自动生成 ID系统生成的 ID 仅允许修改一次
:type custom_id: str
:param leader_open_id: 部门领导 ID支持通过 leader_user_id 或者 leader_open_id 设置部门领导
请求同时传递两个参数时按 leader_user_id 处理
:type leader_open_id: str
:param leader_user_id: 部门领导 ID支持通过 leader_user_id 或者 leader_open_id 设置部门领导
请求同时传递两个参数时按 leader_user_id 处理
:type leader_user_id: str
:param create_group_chat: 是否同时创建部门群默认不创建部门群
:type create_group_chat: bool
:return: 创建后的部门
:rtype: Department
该接口用于向通讯录中增加新的部门
调用该接口需要具有该部门父部门的通讯录操作权限
应用商店应用无权限调用接口
https://open.feishu.cn/document/ukTMukTMukTM/uYzNz4iN3MjL2czM
"""
url = self._gen_request_url('/open-apis/contact/v1/department/add')
body = {
'name': name,
'parent_id': parent_id,
}
body = join_dict(body, [
('id', custom_id),
('leader_open_id', leader_open_id),
('leader_employee_id', leader_user_id),
('create_group_chat', create_group_chat)
])
res = self._post(url, body=body, with_tenant_token=True)
return _make_v1_department_info(res) # type: Department
def batch_create_department(self, create_params):
"""批量新增部门
:type self: OpenLark
:param create_params: 批量创建参数
:type create_params: list[CreateDepartmentRequest]
:return: 生成的异步任务 ID使用查询批量任务执行状态查询状态
:rtype: str
该接口用于向通讯录中批量新增多个部门
调用该接口需要具有所有新增部门父部门的通讯录写入权限
应用商店应用无权限调用此接口
https://open.feishu.cn/document/ukTMukTMukTM/uMDOwUjLzgDM14yM4ATN
"""
url = self._gen_request_url('/open-apis/contact/v2/department/batch_add')
body = []
for i in create_params:
if isinstance(i, CreateDepartmentRequest):
i = i.json()
d = {}
for k, v in i.items():
if v:
d[k] = v
body.append(d)
body = {
'departments': body,
}
res = self._post(url, body=body, with_tenant_token=True)
return res['data'].get('task_id') # type: str
def delete_department(self, department_id):
"""删除部门
:type self: OpenLark
:param department_id: 待删除部门 ID
:type department_id: str
该接口用于从通讯录中删除部门
调用该接口需要具有该部门或者其所在部门的通讯录权限
应用商店应用无权限调用此接口
https://open.feishu.cn/document/ukTMukTMukTM/ugzNz4CO3MjL4czM
"""
url = self._gen_request_url('/open-apis/contact/v1/department/delete')
body = {
'id': department_id
}
self._post(url, body=body, with_tenant_token=True)
def update_department(self, department_id, parent_id=None, name=None, custom_id=None, leader_open_id=None,
leader_user_id=None, create_group_chat=None):
"""更新部门
:type self: OpenLark
:param department_id: 部门ID
:type department_id: str
:param parent_id: 父部门 ID
:type parent_id: str
:param name: 部门名称
:type name: str
:param name: 部门名称
:type name: str
:param custom_id: 仅允许创建部门时未指定自定义 ID 的部门修改该 ID一次新的自定义部门 ID
该字段企业内必须唯一不区分大小写长度为 1 ~ 64 个字符只能由数字字母和_-@.四种字符组成且第一个字符必须是数字或字母
创建部门时指定的 ID 后续不允许修改不指定该字段由系统自动生成 ID系统生成的 ID 仅允许修改一次
:type custom_id: str
:param leader_open_id: 部门领导 ID支持通过 leader_user_id 或者 leader_open_id 设置部门领导
请求同时传递两个参数时按 leader_user_id 处理
:type leader_open_id: str
:param leader_user_id: 部门领导 ID支持通过 leader_user_id 或者 leader_open_id 设置部门领导
请求同时传递两个参数时按 leader_user_id 处理
:type leader_user_id: str
:param create_group_chat: 是否同时创建部门群默认不创建部门群
:type create_group_chat: bool
:return: 创建后的部门
:rtype: Department
该接口用于更新通讯录中部门的信息
调用该接口需要具有该部门父部门的通讯录操作权限
应用商店应用无权限调用接口
https://open.feishu.cn/document/ukTMukTMukTM/uczNz4yN3MjL3czM
"""
url = self._gen_request_url('/open-apis/contact/v1/department/update')
body = join_dict({}, [
('id', department_id),
('name', name),
('parent_id', parent_id),
('leader_open_id', leader_open_id),
('leader_employee_id', leader_user_id),
('create_group_chat', create_group_chat),
('new_id', custom_id),
])
res = self._post(url, body=body, with_tenant_token=True)
return _make_v1_department_info(res) # type: Department
def get_department(self, department_id):
"""获取部门详情
:type self: OpenLark
:param department_id: 部门ID
:type department_id: str
:return: 创建后的部门
:rtype: Department
该接口用于获取部门详情信息
https://open.feishu.cn/document/ukTMukTMukTM/uAzNz4CM3MjLwczM
"""
url = self._gen_request_url('/open-apis/contact/v1/department/info/get')
url = join_url(url, [('department_id', department_id)], sep='?')
res = self._get(url, with_tenant_token=True)
return _make_v1_department_info(res) # type: Department
def get_child_department_simple_list(self, department_id, page_size=20, page_token='', fetch_child=False):
"""获取当前部门子部门列表
:type self: OpenLark
:param department_id: 部门 ID
:type department_id: str
:param page_size: 分页大小最大支持 100
:type page_size: str
:param page_token: 分页标记分页查询还有更多群时会同时返回新的 page_token, 下次遍历可采用该 page_token 获取更多
:type page_token: str
:param fetch_child: 是否递归返回子部门列表默认不递归
:type fetch_child: bool
:return: has_more, page_token, departments
:rtype: (bool, str, list[SimpleDepartment])
获取当前部门子部门列表
https://open.feishu.cn/document/ukTMukTMukTM/ugzN3QjL4czN04CO3cDN
https://bytedance.feishu.cn/docs/doccnOcR1fnxBACchoY9tlg7Amg#
"""
url = self._gen_request_url('/open-apis/contact/v2/department/simple/list')
qs = [
('id', department_id),
('page_size', page_size),
('page_token', page_token),
('fetch_child', fetch_child)
]
url = join_url(url, qs, sep='?')
res = self._get(url, with_tenant_token=True)
data = res['data']
has_more = data.get('has_more', False)
page_token = data.get('page_token', '')
department_ids = [make_datatype(SimpleDepartment, i) for i in data.get('departments', [])]
return has_more, page_token, department_ids
def get_child_department_id_list(self, department_id):
"""获取子部门 ID 列表
:type self: OpenLark
:param department_id: 部门 ID
:type department_id str
:return: 部门 ID 列表
:rtype: list[str]
该接口用于获取子部门 ID 列表只返回授权的部门列表
企业根部门 ID 0当参数指定的部门为根部门时如果授权范围为全员返回该企业的所有一级部门
否则返回管理员在设置通讯录授权范围时勾选的部门不包含子部门
指定获取特定部门部门 ID 0的部门列表时需要具有该部门的通讯录授权返回部门列表为该部门所有的子部门
https://open.feishu.cn/document/ukTMukTMukTM/ukjNz4SO2MjL5YzM
"""
url = self._gen_request_url('/open-apis/contact/v1/department/list?department_id=' + department_id)
res = self._get(url, with_tenant_token=True)
department_ids = res.get('data', {}).get('departments_list', []) # type: List[str]
return department_ids
def batch_get_department_detail(self, department_ids):
"""批量获取部门详情,只返回权限范围内的部门。
:type self: OpenLark
:param department_ids: 部门 ID 列表
:type department_ids: list[str]
:return: departments, errors
:rtype: (dict[str, Department], dict[str, OpenLarkException])
批量获取部门详情只返回权限范围内的部门
https://open.feishu.cn/document/ukTMukTMukTM/uczN3QjL3czN04yN3cDN
https://bytedance.feishu.cn/docs/doccnOcR1fnxBACchoY9tlg7Amg#
"""
if isinstance(department_ids, str):
department_ids = [department_ids]
qs = '&'.join(['ids={}'.format(i) for i in department_ids])
url = self._gen_request_url('/open-apis/contact/v2/department/detail/batch_get?' + qs)
res = self._get(url, with_tenant_token=True)
data = res['data']
departments = {} # type: Dict[str, Department]
for i in data.get('departments', []):
dept = make_datatype(Department, i) # type: Department
departments[dept.id] = dept
errors = {} # type: Dict[str, OpenLarkException]
for i in data.get('errors', []):
e = gen_exception(code=i.get('code'), url='', msg=i.get('msg'))
errors[i.get('id', '')] = e
return departments, errors
def get_department_simple_user_list(self, department_id, page_size=20, page_token='', fetch_child=False):
"""获取部门用户 ID 列表
:type self: OpenLark
:param department_id: 部门 ID
:type department_id: str
:param page_size: 分页大小最大支持 100
:type page_size: str
:param page_token: 分页标记分页查询还有更多群时会同时返回新的 page_token, 下次遍历可采用该 page_token 获取更多
:type page_token: str
:param fetch_child: 是否递归返回子部门列表默认不递归
:type fetch_child: bool
:return: has_more, page_token, users
:rtype: (bool, str, list[SimpleUser])
获取部门用户列表需要有该部门的通讯录授权
https://open.feishu.cn/document/ukTMukTMukTM/uEzNz4SM3MjLxczM
https://bytedance.feishu.cn/docs/doccnOcR1fnxBACchoY9tlg7Amg#
"""
url = self._gen_request_url('/open-apis/contact/v2/department/user/list')
qs = [
('id', department_id),
('page_size', page_size),
('page_token', page_token),
('fetch_child', fetch_child)
]
url = join_url(url, qs, sep='?')
res = self._get(url, with_tenant_token=True)
data = res['data']
has_more = data.get('has_more', False)
page_token = data.get('page_token', '')
users = [make_datatype(SimpleUser, i) for i in data.get('users', [])]
return has_more, page_token, users
def get_department_detail_user_list(self, department_id, page_size=20, page_token='', fetch_child=False):
"""获取部门用户详情列表
:type self: OpenLark
:param department_id: 部门 ID
:type department_id: str
:param page_size: 分页大小最大支持 100
:type page_size: str
:param page_token: 分页标记分页查询还有更多群时会同时返回新的 page_token, 下次遍历可采用该 page_token 获取更多
:type page_token: str
:param fetch_child: 是否递归返回子部门列表默认不递归
:type fetch_child: bool
:return: has_more, page_token, departments
:rtype: (bool, str, list[DepartmentUser])
获取部门用户详情需要有该部门的通讯录授权
https://open.feishu.cn/document/ukTMukTMukTM/uYzN3QjL2czN04iN3cDN
https://bytedance.feishu.cn/docs/doccnOcR1fnxBACchoY9tlg7Amg#
"""
url = self._gen_request_url('/open-apis/contact/v2/department/user/detail/list')
qs = [
('id', department_id),
('page_token', page_token),
('page_size', page_size),
('fetch_child', fetch_child),
]
url = join_url(url, qs, sep='?')
res = self._get(url, with_tenant_token=True)
data = res['data']
has_more = data.get('has_more', False)
page_token = data.get('page_token', '')
users = [make_datatype(DepartmentUser, i) for i in data.get('users', [])]
return has_more, page_token, users
def create_user(self, create_user_request):
"""新增用户
:type self: OpenLark
:param create_user_request: 创建参数
:type create_user_request: CreateUserRequest
:return: user
:rtype: DepartmentUser
该接口用于向通讯录中新增用户
调用该接口需要具有用户所在部门的通讯录写入权限
应用商店应用无权限调用此接口
https://open.feishu.cn/document/ukTMukTMukTM/uMzNz4yM3MjLzczM
"""
url = self._gen_request_url('/open-apis/contact/v1/user/add')
if isinstance(create_user_request, CreateUserRequest):
create_user_request = create_user_request.v1_json() # type: dict
body = join_dict({}, list(create_user_request.items()))
res = self._post(url, body=body, with_tenant_token=True)
user_info = res['data'].get('user_info', {})
return _make_v1_user(user_info)
def batch_create_user(self, create_user_request_list):
"""批量新增用户
:type self: OpenLark
:param create_user_request_list: 创建参数
:type create_user_request_list: list[CreateUserRequest]
:return: task_id
:rtype: str
该接口用于向通讯录中批量新增多个用户
调用该接口需要具有用户所在部门的通讯录写入权限
应用商店应用无权限调用此接口
https://open.feishu.cn/document/ukTMukTMukTM/uMzNz4yM3MjLzczM
"""
url = self._gen_request_url('/open-apis/contact/v2/user/batch_add')
users = []
need_send_notification = None
for create_user_request in create_user_request_list:
if isinstance(create_user_request, CreateUserRequest):
create_user_request = create_user_request.json()
if isinstance(create_user_request, dict) and 'need_send_notification' in create_user_request:
if create_user_request['need_send_notification'] is not None:
need_send_notification = need_send_notification or create_user_request['need_send_notification']
del create_user_request['need_send_notification']
users.append(create_user_request)
body = {
'users': users,
'need_send_notification': need_send_notification,
}
res = self._post(url, body=body, with_tenant_token=True)
return res['data'].get('task_id') # type: str
def delete_user(self, user_user_id=None, user_open_id=None,
department_chat_acceptor_user_id=None,
department_chat_acceptor_open_id=None,
external_chat_acceptor_user_id=None,
external_chat_acceptor_open_id=None,
docs_acceptor_user_id=None,
docs_acceptor_open_id=None,
calendar_acceptor_user_id=None,
calendar_acceptor_open_id=None,
application_acceptor_user_id=None,
application_acceptor_open_id=None):
"""删除用户
:type self: OpenLark
:param user_user_id: 被删除用户请求至少包含被删除用户的 user_id 或者 open_id 之一同时传递两个参数时按 user_id 处理
:type user_user_id: str
:param user_open_id: 被删除用户请求至少包含被删除用户的 user_id 或者 open_id 之一同时传递两个参数时按 user_id 处理
:type user_open_id: str
:param department_chat_acceptor_user_id: 部门群接收者
被删除用户为部门群群主时转让群主给指定接收者不指定接收者则默认转让给群内第一个入群的人
:type department_chat_acceptor_user_id: str
:param department_chat_acceptor_open_id: 部门群接收者
被删除用户为部门群群主时转让群主给指定接收者不指定接收者则默认转让给群内第一个入群的人
:type department_chat_acceptor_open_id: str
:param external_chat_acceptor_user_id: 外部群接收者
被删除用户为外部群群主时转让群主给指定接收者不指定接收者则默认转让给群内与被删除用户在同一组织的第一个入群的人
如果组织内只有该用户在群里则解散外部群
:type external_chat_acceptor_user_id: str
:param external_chat_acceptor_user_id: 外部群接收者
被删除用户为外部群群主时转让群主给指定接收者不指定接收者则默认转让给群内与被删除用户在同一组织的第一个入群的人
如果组织内只有该用户在群里则解散外部群
:type external_chat_acceptor_user_id: str
:param external_chat_acceptor_open_id: 文档接收者
用户被删除时其拥有的文档转让给接收者不指定接收者则默认转让给直接领导如果无直接领导则直接删除文档资源
:type external_chat_acceptor_open_id: str
:param docs_acceptor_user_id: 文档接收者
用户被删除时其拥有的文档转让给接收者不指定接收者则默认转让给直接领导如果无直接领导则直接删除文档资源
:type docs_acceptor_user_id: str
:param docs_acceptor_open_id: 文档接收者
用户被删除时其拥有的文档转让给接收者不指定接收者则默认转让给直接领导如果无直接领导则直接删除文档资源
:type docs_acceptor_open_id: str
:param calendar_acceptor_user_id: 日程接收者
用户被删除时其拥有的日程转让给接收者不指定接收者则默认转让给直接领导如果无直接领导则直接删除日程资源
:type calendar_acceptor_user_id: str
:param calendar_acceptor_open_id: 日程接收者
用户被删除时其拥有的日程转让给接收者不指定接收者则默认转让给直接领导如果无直接领导则直接删除日程资源
:type calendar_acceptor_open_id: str
:param application_acceptor_user_id: 应用接收者
用户被删除时其创建的应用转让给接收者不指定接收者则默认转让给直接领导如果无直接领导则不会转移应用会造成应用不可用
:type application_acceptor_user_id: str
:param application_acceptor_open_id: 应用接收者
用户被删除时其创建的应用转让给接收者不指定接收者则默认转让给直接领导如果无直接领导则不会转移应用会造成应用不可用
:type application_acceptor_open_id: str
该接口用于从通讯录中删除用户
调用该接口需要具有该用户或者用户所在部门的通讯录权限
应用商店应用无权限调用接口
https://open.feishu.cn/document/ukTMukTMukTM/uUzNz4SN3MjL1czM
"""
if not user_user_id and not user_open_id:
raise LarkInvalidArguments(msg='empty user user_id and open_id')
url = self._gen_request_url('/open-apis/contact/v1/user/delete')
body = {
'employee_id': user_user_id,
'open_id': user_open_id,
'department_chat_acceptor': {
'employee_id': department_chat_acceptor_user_id,
'open_id': department_chat_acceptor_open_id,
},
'external_chat_acceptor': {
'employee_id': external_chat_acceptor_user_id,
'open_id': external_chat_acceptor_open_id,
},
'docs_acceptor': {
'employee_id': docs_acceptor_user_id,
'open_id': docs_acceptor_open_id,
},
'calendar_acceptor': {
'employee_id': calendar_acceptor_user_id,
'open_id': calendar_acceptor_open_id,
},
'application_acceptor': {
'employee_id': application_acceptor_user_id,
'open_id': application_acceptor_open_id,
},
}
self._post(url, body=body, with_tenant_token=True)
def update_user(self, update_user_request):
"""更新用户
:type self: OpenLark
:param update_user_request: 创建参数
:type update_user_request: UpdateUserRequest
:return: user
:rtype: DepartmentUser
该接口用于更新通讯录中用户信息
调用该接口需要具有用户所在部门的通讯录写入权限
应用商店应用无权限调用此接口
https://open.feishu.cn/document/ukTMukTMukTM/uMzNz4yM3MjLzczM
"""
url = self._gen_request_url('/open-apis/contact/v1/user/update')
if isinstance(update_user_request, UpdateUserRequest):
update_user_request = update_user_request.v1_json()
body = join_dict({}, list(update_user_request.items()))
self._post(url, body=body, with_tenant_token=True)
def batch_get_department_detail_user(self, user_ids=None, open_ids=None):
"""批量获取用户详细信息
:type self: OpenLark
:param user_ids: 用户 UserID 列表
:type user_ids: list[str]
:param open_ids: 用户 OpenID 列表
:type open_ids: list[str]
:return: has_more, page_token, departments
:rtype: (Dict[str, DepartmentUser], Dict[str, OpenLarkException])
批量获取用户信息详情需具有用户所在部门或者用户的通讯录权限
https://open.feishu.cn/document/ukTMukTMukTM/ugjNz4CO2MjL4YzM
https://bytedance.feishu.cn/docs/doccnOcR1fnxBACchoY9tlg7Amg#
"""
if user_ids and open_ids:
raise LarkInvalidArguments(msg='only need user_ids or open_ids')
elif not user_ids and not open_ids:
raise LarkInvalidArguments(msg='need user_ids or open_ids')
qs = ''
user_key = ''
if user_ids:
qs = '&'.join(['user_ids={}'.format(i) for i in user_ids])
user_key = 'user_id'
elif open_ids:
qs = '&'.join(['open_ids={}'.format(i) for i in open_ids])
user_key = 'open_id'
url = self._gen_request_url('/open-apis/contact/v2/user/batch_get')
url = url + '?' + qs
res = self._get(url, with_tenant_token=True)
data = res['data']
users = {} # type: Dict[str, DepartmentUser]
for i in data.get('users', []):
user = make_datatype(DepartmentUser, i)
users[getattr(user, user_key)] = user
errors = {} # type: Dict[str, OpenLarkException]
for i in data.get('errors', []):
e = gen_exception(code=i.get('code'), url='', msg=i.get('msg'))
errors[i.get('id', '')] = e
return users, errors
def get_tenant_custom_attr(self):
"""获取企业自定义属性信息
:type self: OpenLark
:return: is_open, attrs
:rtype: (bool, list[DepartmentUserCustomAttr])
https://open.feishu.cn/document/ukTMukTMukTM/ucTN3QjL3UzN04yN1cDN
https://bytedance.feishu.cn/docs/doccnOcR1fnxBACchoY9tlg7Amg#
"""
url = self._gen_request_url('/open-apis/contact/v2/tenant/custom_attr/get')
res = self._get(url, with_tenant_token=True)
data = res['data']
is_open = data.get('is_open', False)
attrs = [make_datatype(DepartmentUserCustomAttr, i) for i in data.get('custom_attrs', [])]
return is_open, attrs
def get_contact_task(self, task_id):
"""查询批量任务执行状态
:type self: OpenLark
:param task_id: 任务id
:type task_id: str
:return: result
:rtype: ContactAsyncTaskResult
该接口用于查询通讯录异步任务当前的执行状态以及执行结果
调用该接口需要具有通讯录写入权限
应用商店应用无权限调用此接口
https://open.feishu.cn/document/ukTMukTMukTM/uUDOwUjL1gDM14SN4ATN
"""
url = self._gen_request_url('/open-apis/contact/v2/task/get?task_id={}'.format(task_id))
res = self._get(url, with_tenant_token=True)
data = res['data']
return make_datatype(ContactAsyncTaskResult, data) # type: ContactAsyncTaskResult
def get_admin_scope(self, user_id=None, open_id=None):
"""获取应用管理员管理范围
:type self: OpenLark
:param user_id:
:type user_id: str
:param open_id:
:type open_id: str
:return: is_all, department_ids
is_all true 不返回 department_ids
:rtype: (bool, list[str])
该接口用于获取应用管理员的管理范围即该应用管理员能够管理哪些部门
https://open.feishu.cn/document/ukTMukTMukTM/uMzN3QjLzczN04yM3cDN
"""
url = self._gen_request_url('/open-apis/contact/v1/user/admin_scope/get')
if user_id:
url = '{}?employee_id={}'.format(url, user_id)
elif open_id:
url = '{}?open_id={}'.format(url, open_id)
else:
raise LarkInvalidArguments(msg='empty user_id and open_id')
res = self._get(url, with_tenant_token=True)
data = res['data']
is_all = data.get('is_all')
department_ids = data.get('department_list', [])
return is_all, department_ids
def get_role_list(self):
"""获取角色列表
:type self: OpenLark
:return: role_list
:rtype: list[Role]
该接口用于获取企业的用户角色列表调用该接口的应用需要具有当前企业通讯录的读取或者更新权限
https://open.feishu.cn/document/ukTMukTMukTM/uYzMwUjL2MDM14iNzATN
"""
url = self._gen_request_url('/open-apis/contact/v2/role/list')
res = self._get(url, with_tenant_token=True)
data = res['data']
return [make_datatype(Role, i) for i in data.get('role_list', [])] # type: List[Role]
def get_role_user_list(self, role_id, page_size=20, page_token=''):
"""获取角色成员列表
:type self: OpenLark
:param role_id: 角色 id
:type role_id: str
:param page_size: 分页大小最大支持 200默认为 20
:type page_size: int
:param page_token: 分页标记分页查询还有更多群时会同时返回新的 page_token, 下次遍历可采用该 page_token 获取更多
:type page_token: str
:return: (has_more, page_token, user_list)
:rtype: (bool, str, list[SimpleUser])
该接口用于获取角色下的用户列表调用该接口需要有该企业的通讯录读权限或写权限返回结果为该应用通讯录权限范围内的角色成员列表
https://open.feishu.cn/document/ukTMukTMukTM/uczMwUjL3MDM14yNzATN
"""
base = self._gen_request_url('/open-apis/contact/v2/role/members')
url = join_url(base, [
('role_id', role_id),
('page_size', page_size),
('page_token', page_token),
])
res = self._get(url, with_tenant_token=True)
data = res['data']
has_more = data.get('has_more', False)
page_token = data.get('page_token', '')
user_list = [make_datatype(SimpleUser, i) for i in data.get('user_list', [])] # type: List[SimpleUser]
return has_more, page_token, user_list
def _make_v1_department_info(res):
"""
:rtype: Department
"""
department_info = res['data'].get('department_info') or {}
open_id = department_info.get('leader_open_id')
user_id = department_info.get('leader_employee_id')
department_info['leader'] = {
'open_id': open_id,
'user_id': user_id,
}
return make_datatype(Department, department_info) # type: Department
def _make_v1_user(user_info):
"""
:rtype: DepartmentUser
"""
user_info['user_id'] = user_info.get('employee_id')
user_info['en_name'] = user_info.get('name_py')
user_info['avatar'] = {
'avatar_72': user_info.get('avatar_72'),
'avatar_240': user_info.get('avatar_240'),
'avatar_640': user_info.get('avatar_640'),
'avatar_origin': user_info.get('avatar_url'),
}
user_info['leader'] = {
'user_id': user_info.get('leader_employee_id'),
'open_id': user_info.get('leader_open_id')
}
return make_datatype(DepartmentUser, user_info) # type: DepartmentUser

View File

@ -0,0 +1,52 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING
from six import string_types
from utils.feishu.dt_drive import DriveComment
from utils.feishu.dt_help import make_datatype
from utils.feishu.exception import LarkInvalidArguments
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIDriveCommentMixin(object):
def add_drive_comment(self, user_access_token, file_token, content):
"""添加全文评论
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param content: 评论内容
:type content: str
:return: 评论对象
:rtype: DriveComment
该接口用于根据 file_token 给文档添加全文评论
https://open.feishu.cn/document/ukTMukTMukTM/ucDN4UjL3QDO14yN0gTN
"""
if not content or not isinstance(content, string_types):
raise LarkInvalidArguments(msg='content empty')
content = content. \
replace('<', '&lt;'). \
replace('>', '&gt;'). \
replace('&', '&amp;'). \
replace('\'', '&#x27;'). \
replace('"', '&quot;')
url = self._gen_request_url('/open-apis/comment/add_whole')
body = {
'type': 'doc',
'token': file_token,
'content': content
}
res = self._post(url, body=body, auth_token=user_access_token)
return make_datatype(DriveComment, res['data'])

View File

@ -0,0 +1,51 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING
from utils.feishu.dt_drive import DriveDocFileMeta
from utils.feishu.dt_help import make_datatype
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIDriveDocMixin(object):
def get_drive_doc_content(self, user_access_token, doc_token):
"""获取 doc 文件内容
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param doc_token: 文件的 token
:type doc_token: str
:return: 文件原始内容
:rtype: str
该接口用于获取文档的纯文本内容不包含富文本格式信息主要用于搜索如导入 es
https://open.feishu.cn/document/ukTMukTMukTM/ukzNzUjL5czM14SO3MTN
"""
url = self._gen_request_url('/open-apis/doc/v2/{}/raw_content'.format(doc_token))
res = self._get(url, auth_token=user_access_token)
return res['data']['content']
def get_drive_doc_meta(self, user_access_token, doc_token):
"""获取 doc 文件元信息
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param doc_token: 文件的 token
:type doc_token: str
:return: 文件元内容
:rtype: DriveDocFileMeta
该接口用于根据 docToken 获取元数据
https://open.feishu.cn/document/ukTMukTMukTM/uczN3UjL3czN14yN3cTN
"""
url = self._gen_request_url('/open-apis/doc/v2/meta/{}'.format(doc_token))
res = self._get(url, auth_token=user_access_token)
return make_datatype(DriveDocFileMeta, res['data'])

View File

@ -0,0 +1,109 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING
from utils.feishu.dt_drive import DriveCopyFile, DriveCreateFile, DriveDeleteFile, DriveFileType
from utils.feishu.dt_help import make_datatype
from utils.feishu.exception import LarkInvalidArguments
from utils.feishu.helper import converter_enum
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIDriveFileMixin(object):
def create_drive_file(self, user_access_token, folder_token, title, file_type):
"""创建云空间文件
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param folder_token: 文件夹的 token
:type folder_token: str
:param title: 文档标题
:type title: str
:param file_type: 文档类型可选值为 doc sheet
:type file_type: DriveFileType
:return: 文件夹元信息
:rtype: DriveCreateFile
该接口用于根据 folder_token 创建 Docs或 Sheets
https://open.feishu.cn/document/ukTMukTMukTM/uQTNzUjL0UzM14CN1MTN
"""
url = self._gen_request_url('/open-apis/drive/explorer/v2/file/{}'.format(folder_token))
body = {
'title': title,
'type': converter_enum(file_type, ranges=[DriveFileType.doc, DriveFileType.sheet]),
}
res = self._post(url, body=body, auth_token=user_access_token)
return make_datatype(DriveCreateFile, res['data'])
def delete_drive_file(self, user_access_token, file_token, file_type):
"""删除云空间文件
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文档类型可选值为 doc sheet
:type file_type: DriveFileType
:return: 文件夹元信息
:rtype: DriveDeleteFile
本文档包含两个接口分别用于删除 Doc Sheet对应的文档类型请调用对应的接口
文档只能被文档所有者删除文档被删除后将会放到回收站里
https://open.feishu.cn/document/ukTMukTMukTM/uATM2UjLwEjN14CMxYTN
"""
if converter_enum(file_type) == 'doc':
url = self._gen_request_url('/open-apis/drive/explorer/v2/file/docs/{}'.format(file_token))
elif converter_enum(file_type) == 'sheet':
url = self._gen_request_url('/open-apis/drive/explorer/v2/file/spreadsheets/{}'.format(file_token))
else:
raise LarkInvalidArguments(msg='delete file type should be doc or sheet')
res = self._delete(url, auth_token=user_access_token)
return make_datatype(DriveDeleteFile, res['data'])
def copy_drive_file(self, user_access_token, file_token, file_type, dst_folder_token, dst_title,
permission_needed=False, comment_needed=False):
"""复制云空间文件
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文档类型可选值为 doc sheet
:type file_type: DriveFileType
:param dst_folder_token目标文件夹 token
:type dst_folder_token: str
:param dst_title: 目标文档标题
:type dst_title: str
:param permission_needed: 同时复制权限
:type permission_needed: bool
:param comment_needed: 同时复制评论
:type comment_needed: bool
:return: 复制文件的返回值
:rtype: DriveCopyFile
该接口用于根据 file_token 复制 docs sheets
https://open.feishu.cn/document/ukTMukTMukTM/uYTNzUjL2UzM14iN1MTN
"""
url = self._gen_request_url('/open-apis/drive/explorer/v2/file/copy/files/{}'.format(file_token))
body = {
'type': converter_enum(file_type),
'dstFolderToken': dst_folder_token,
'dstName': dst_title,
'permissionNeeded': permission_needed,
'CommentNeeded': comment_needed
}
res = self._post(url, body=body, auth_token=user_access_token)
return make_datatype(DriveCopyFile, res['data'])

View File

@ -0,0 +1,94 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Tuple
from utils.feishu.dt_drive import DriveCreateFile, DriveFileToken, DriveFolderMeta
from utils.feishu.dt_help import make_datatype
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIDriveFolderMixin(object):
def create_drive_folder(self, user_access_token, folder_token, title):
"""新建文件夹
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param folder_token: 父文件夹的 token
:type folder_token: str
:param title: 要创建的文件夹名称
:type title: str
:return: 文件夹元信息
:rtype: DriveFolderMeta
该接口用于根据 folder_token 在该 folder 下创建文件夹
https://open.feishu.cn/document/ukTMukTMukTM/ukTNzUjL5UzM14SO1MTN
"""
url = self._gen_request_url('/open-apis/drive/explorer/v2/folder/{}'.format(folder_token))
body = {
'title': title,
}
res = self._post(url, body=body, auth_token=user_access_token)
return make_datatype(DriveCreateFile, res['data'])
def get_drive_folder_meta(self, user_access_token, folder_token):
"""获取文件夹元信息
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param folder_token: 文件夹的 token
:type folder_token: str
:return: 文件夹元信息
:rtype: DriveFolderMeta
该接口用于根据 folder_token 获取该文件夹的元信息
https://open.feishu.cn/document/ukTMukTMukTM/uAjNzUjLwYzM14CM2MTN
"""
url = self._gen_request_url('/open-apis/drive/explorer/v2/folder/{}/meta'.format(folder_token))
res = self._get(url, auth_token=user_access_token)
return make_datatype(DriveFolderMeta, res['data'])
def get_drive_root_folder_meta(self, user_access_token):
"""获取root folder我的空间 meta
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:return: 文件夹元信息
:rtype: DriveFolderMeta
该接口用于获取 "我的文档" 的元信息
https://open.feishu.cn/document/ukTMukTMukTM/uAjNzUjLwYzM14CM2MTN
"""
url = self._gen_request_url('/open-apis/drive/explorer/v2/root_folder/meta')
res = self._get(url, auth_token=user_access_token)
return DriveFolderMeta(id=res['data']['id'],
token=res['data']['token'],
own_uid=res['data']['user_id'])
def get_drive_folder_children(self, user_access_token, folder_token):
"""获取文件夹下的文档清单
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param folder_token: 文件夹的 token
:type folder_token: str
:return: 该文件夹的文档清单 docsheetbitablefolder
:rtype: list[DriveFileToken]
该接口用于获取 "我的文档" 的元信息
https://open.feishu.cn/document/ukTMukTMukTM/uEjNzUjLxYzM14SM2MTN
"""
url = self._gen_request_url('/open-apis/drive/explorer/v2/folder/{}/children'.format(folder_token))
res = self._get(url, auth_token=user_access_token)
return [make_datatype(DriveFileToken, i) for _, i in res['data']['children'].items()]

View File

@ -0,0 +1,252 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, List
from utils.feishu.dt_drive import DriveFilePermission, unmarshal_drive_user_permission
from utils.feishu.helper import converter_enum
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
from utils.feishu.dt_drive import DriveFileUserPermission, DriveFileToken, DriveFileUser, \
DriveFilePublicLinkSharePermission, DriveFileType
class APIDrivePermissionMixin(object):
def add_drive_file_permission(self, user_access_token, file_token, file_type, members):
"""增加权限
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文件类型
:type file_type: DriveFileType
:param members: 要添加的权限的人
:type members: list[DriveFileUserPermission]
:rtype: list[DriveFileUserPermission]
该接口用于根据 file_token 给用户增加文档的权限
https://open.feishu.cn/document/ukTMukTMukTM/uMzNzUjLzczM14yM3MTN
"""
url = self._gen_request_url('/open-apis/drive/permission/member/create')
body = {
'token': file_token,
'type': converter_enum(file_type),
'members': [i.as_dict() for i in members]
}
res = self._post(url, body=body, auth_token=user_access_token)
if res['data'].get('is_all_success', False):
return []
return unmarshal_drive_user_permission(res['data']['fail_members']) # type: List[DriveFileUserPermission]
def transfer_drive_file_owner(self, user_access_token, file_token, file_type, owner, remove_old_owner=False,
notify_old_owner=True):
"""转移拥有者
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文件类型
:type file_type: DriveFileType
:param owner: 要转移的人
:type owner: DriveFileUser
:param remove_old_owner: 转移后删除旧 owner 的权限默认为 False
:type remove_old_owner: bool
:param notify_old_owner: 通知旧 owner默认为 True
:type notify_old_owner: bool
该接口用于根据文档信息和用户信息转移文档的所有者
https://open.feishu.cn/document/ukTMukTMukTM/uQzNzUjL0czM14CN3MTN
"""
url = self._gen_request_url('/open-apis/drive/permission/member/transfer')
body = {
'type': converter_enum(file_type),
'token': file_token,
'owner': owner.as_dict(),
'remove_old_owner': remove_old_owner,
'cancel_notify': not notify_old_owner
}
self._post(url, body=body, auth_token=user_access_token)
def update_drive_file_public_permission(self, user_access_token,
file_token,
file_type,
copy_print_export_status=None,
comment=None,
tenant_shareable=None,
link_share_entity=None,
external_access=None,
invite_external=None):
"""更新文档公共设置
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文件类型
:type file_type: DriveFileType
:param copy_print_export_status: 可创建副本/打印/导出/复制设置不传则保持原值
true - 所有可访问此文档的用户
false - 有编辑权限的用户
:type copy_print_export_status: bool
:param comment: 可评论设置不传则保持原值
true - 所有可访问此文档的用户
false - 有编辑权限的用户
:type comment: bool
:param tenant_shareable: 租户内用户是否有共享权限不传则保持原值
:type tenant_shareable: bool
:param link_share_entity: 链接共享不传则保持原值
"tenant_readable" - 组织内获得链接的人可阅读
"tenant_editable" - 组织内获得链接的人可编辑
"anyone_readable" - 获得链接的任何人可阅读
"anyone_editable" - 获得链接的任何人可编辑
:type link_share_entity: DriveFilePublicLinkSharePermission
:param external_access: 是否允许分享到租户外开关不传则保持原值
:type external_access: bool
:param invite_external: 非owner是否允许邀请外部人不传则保持原值
:type invite_external: bool
该接口用于根据 file_token 更新文档的公共设置
https://open.feishu.cn/document/ukTMukTMukTM/ukTM3UjL5EzN14SOxcTN
"""
url = self._gen_request_url('/open-apis/drive/permission/public/update')
body = {
'token': file_token,
'type': converter_enum(file_type),
}
if copy_print_export_status is not None:
body['copy_print_export_status'] = copy_print_export_status
if comment is not None:
body['comment'] = comment
if tenant_shareable is not None:
body['tenant_shareable'] = tenant_shareable
if link_share_entity is not None:
body['link_share_entity'] = link_share_entity.value
if external_access is not None:
body['external_access'] = external_access
if invite_external is not None:
body['invite_external'] = invite_external
self._post(url, body=body, auth_token=user_access_token)
def get_drive_file_permissions(self, user_access_token, file_token, file_type):
"""获取协作者列表
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文件类型
:type file_type: DriveFileType
:rtype list[DriveFileUserPermission]
该接口用于根据 file_token 查询协作者目前包括人("user")和群("chat")
你能获取到协作者列表的前提是你对该文档有权限
https://open.feishu.cn/document/ukTMukTMukTM/uATN3UjLwUzN14CM1cTN
"""
url = self._gen_request_url('/open-apis/drive/permission/member/list')
body = {
'type': converter_enum(file_type),
'token': file_token,
}
res = self._post(url, body=body, auth_token=user_access_token)
return unmarshal_drive_user_permission(res['data']['members'],
open_id_type='user',
open_id_key='member_open_id',
chat_id_type='chat',
chat_id_key='member_open_id') # type: List[DriveFileUserPermission]
def delete_drive_file_permission(self, user_access_token, file_token, file_type, member):
"""移除协作者权限
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文件类型
:type file_type: DriveFileType
:param member: 成员
:type member: DriveFileUser
该接口用于根据 file_token 移除文档协作者的权限
https://open.feishu.cn/document/ukTMukTMukTM/uYTN3UjL2UzN14iN1cTN
"""
url = self._gen_request_url('/open-apis/drive/permission/member/delete')
body = member.as_dict()
body['token'] = file_token
body['type'] = converter_enum(file_type)
self._post(url, body=body, auth_token=user_access_token)
def update_drive_file_permission(self, user_access_token,
file_token, file_type,
member,
perm=DriveFilePermission.view,
is_notify=None):
"""更新协作者权限
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文件类型
:type file_type: DriveFileType
:param member: 成员
:type member: DriveFileUser
:param perm: 权限
:type perm: DriveFilePermission
:param is_notify: 是否通知
:type is_notify: bool
该接口用于根据 file_token 更新文档协作者的权限
https://open.feishu.cn/document/ukTMukTMukTM/ucTN3UjL3UzN14yN1cTN
"""
url = self._gen_request_url('/open-apis/drive/permission/member/update')
body = member.as_dict()
body['token'] = file_token
body['type'] = converter_enum(file_type)
body['perm'] = converter_enum(perm)
if is_notify is not None:
body['notify_lark'] = is_notify
self._post(url, body=body, auth_token=user_access_token)
def check_drive_file_permission(self, user_access_token, file_token, file_type, perm):
"""判断协作者是否有某权限
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param file_token: 文件的 token
:type file_token: str
:param file_type: 文件类型
:type file_type: DriveFileType
:param perm: 权限
:type perm: DriveFilePermission
该接口用于根据 file_token 判断当前登录用户是否具有某权限
https://open.feishu.cn/document/ukTMukTMukTM/uYzN3UjL2czN14iN3cTN
"""
url = self._gen_request_url('/open-apis/drive/permission/member/permitted')
body = {
'token': file_token,
'type': converter_enum(file_type),
'perm': converter_enum(perm),
}
res = self._post(url, body=body, auth_token=user_access_token)
return res['data']['is_permitted']

View File

@ -0,0 +1,722 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Any, Dict, List
from six.moves.urllib.parse import quote
from utils.feishu.dt_drive import (BatchSetDriveSheetStyleRequest, DriveInsertSheet, DriveSheetMergeType, DriveSheetMeta,
DriveSheetStyle, LockDriveSheetRequest, ReadDriveSheetRequest, UpdateDriveSheetResponse,
WriteDriveSheetRequest, join_range)
from utils.feishu.dt_help import make_datatype
from utils.feishu.helper import converter_enum
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
from utils.feishu.dt_drive import BatchUpdateDriveSheetRequestAdd, BatchUpdateDriveSheetRequestCopy, \
BatchUpdateDriveSheetRequestDelete
class APIDriveSheetMixin(object):
def get_drive_sheet_meta(self, user_access_token, sheet_token):
"""获取 sheet 文件的元数据
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:return: sheet 元信息
:rtype: DriveSheetMeta
该接口用于根据 token 获取各类文件的元数据
https://open.feishu.cn/document/ukTMukTMukTM/uMjN3UjLzYzN14yM2cTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/metainfo'.format(sheet_token))
res = self._get(url, auth_token=user_access_token)
properties = res['data']['properties']
for k, v in res['data'].items():
if k != 'properties':
properties[k] = v
return make_datatype(DriveSheetMeta, properties)
def update_drive_sheet_properties(self, user_access_token, sheet_token, title):
"""更新表格属性
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param title: 标题
:type title: str
该接口用于根据 sheet_token 更新表格属性如更新表格标题
https://open.feishu.cn/document/ukTMukTMukTM/ucTMzUjL3EzM14yNxMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/properties'.format(sheet_token))
body = {
'properties': {
'title': title,
}
}
self._put(url, body=body, auth_token=user_access_token)
def batch_update_drive_sheet(self, user_access_token, sheet_token, adds=None, copys=None, deletes=None):
"""操作子表
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param adds: 参数
:type adds: List[BatchUpdateDriveSheetRequestAdd]
:param copys: 参数
:type copys: List[BatchUpdateDriveSheetRequestCopy]
:param deletes: 参数
:type deletes: List[BatchUpdateDriveSheetRequestDelete]
:rtype (list[UpdateDriveSheetResponse], list[UpdateDriveSheetResponse], list[UpdateDriveSheetResponse])
该接口用于根据 sheet_token 操作表格如增加sheet复制sheet删除sheet
https://open.feishu.cn/document/ukTMukTMukTM/uYTMzUjL2EzM14iNxMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/sheets_batch_update'.format(sheet_token))
requests = []
if adds:
for i in adds:
requests.append(i.as_dict())
if copys:
for i in copys:
requests.append(i.as_dict())
if deletes:
for i in deletes:
requests.append(i.as_dict())
res = self._post(url, body={'requests': requests}, auth_token=user_access_token)
adds_resp = [] # type: List[UpdateDriveSheetResponse]
copys_resp = [] # type: List[UpdateDriveSheetResponse]
deletes_resp = [] # type: List[UpdateDriveSheetResponse]
for i in res.get('data', {}).get('replies', []):
if 'addSheet' in i:
adds_resp.append(make_datatype(UpdateDriveSheetResponse, i['addSheet']['properties']))
elif 'copySheet' in i:
copys_resp.append(make_datatype(UpdateDriveSheetResponse, i['copySheet']['properties']))
elif 'deleteSheet' in i:
deletes_resp.append(make_datatype(UpdateDriveSheetResponse, i['deleteSheet']))
return adds_resp, copys_resp, deletes_resp
def update_drive_sub_sheet_properties(self, user_access_token, sheet_token, sheet_id, title=None, index=None,
hidden=None, is_lock=None, lock_info=None, user_uids=None):
"""操作子表属性
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param title: 更改 sheet 标题
:type title: str
:param index: 移动 sheet 的位置
:type index: int
:param hidden: 隐藏表格默认 false
:type hidden: bool
:param is_lock: 上锁/解锁
:type is_lock: bool
:param lock_info: 锁定信息
:type lock_info: str
:param user_uids: 除了本人与所有者外添加其他的可编辑人员
:type user_uids: list[str]
该接口用于根据 sheet_token 更新子表属性
https://open.feishu.cn/document/ukTMukTMukTM/ugjMzUjL4IzM14COyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/sheets_batch_update'.format(sheet_token))
properties = {
'sheetId': sheet_id,
'protect': {
'lock': 'LOCK' if is_lock else 'UNLOCK',
}
}
if title is not None:
properties['title'] = title
if index is not None:
properties['index'] = index
if hidden is not None:
properties['hidden'] = hidden
if lock_info is not None:
properties['protect']['lockInfo'] = lock_info
if user_uids is not None:
properties['protect']['userIds'] = user_uids
body = {
'requests': [
{
'updateSheet': {
'properties': properties,
}
}
]
}
self._post(url, body=body, auth_token=user_access_token)
def prepend_write_drive_sheet_cells(self, user_access_token, sheet_token, sheet_id, range, values):
"""在表前面插入数据
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param range: 范围形如'A1:D2'
:type range: str
:param values: 数据二位数组每个子数组就是一行数据
:type values: list[list[Any]]
:rtype: DriveInsertSheet
该接口用于根据 sheet_token range 向范围之前增加相应数据的行和相应的数据相当于数组的插入操作
单次写入不超过5000行100每个格子大小为0.5M
https://open.feishu.cn/document/ukTMukTMukTM/uIjMzUjLyIzM14iMyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/values_prepend'.format(sheet_token))
body = {
'valueRange': {
'range': join_range(sheet_id, range),
'values': [[i.as_sheet_dict() if hasattr(i, 'as_sheet_dict') else i for i in value] for value in values]
}
}
res = self._post(url, body=body, auth_token=user_access_token)
return _pack_insert_sheet(sheet_token, sheet_id, res)
def append_write_drive_sheet_cells(self, user_access_token, sheet_token, sheet_id, range, values,
overwrite_empty_line=False):
"""在表后面插入数据
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param range: 范围形如'A1:D2'
:type range: str
:param values: 数据二位数组每个子数组就是一行数据
:type values: list[list[Any]]
:param overwrite_empty_line: 是否覆盖空行第一个格子是空就是空行
如果覆盖 && range 的左上角第一个格子为空从这个格子开始数据被覆盖
如果不覆盖 && range 的左上角第一个格子为空 && 其他数据不为空则将将这些数据下移 N
再插入数据
:type overwrite_empty_line: bool
:rtype: DriveInsertSheet
该接口用于根据 sheet_token range 遇到空行则进行覆盖追加或新增行追加数据
空行默认该行第一个格子是空则认为是空行单次写入不超过5000行100每个格子大小为0.5M
https://open.feishu.cn/document/ukTMukTMukTM/uMjMzUjLzIzM14yMyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/values_append'.format(sheet_token))
if not overwrite_empty_line:
url = url + '?insertDataOption=INSERT_ROWS'
body = {
'valueRange': {
'range': join_range(sheet_id, range),
'values': values
}
}
res = self._post(url, body=body, auth_token=user_access_token)
return _pack_insert_sheet(sheet_token, sheet_id, res)
def insert_drive_sheet_rows_columns(self, user_access_token, sheet_token, sheet_id, start_index, end_index,
is_rows=True, inherit_style=None):
"""插入行列
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param start_index: 从这一行的下一行开始插入或下一列
:type start_index: int
:param end_index: 截止到这一行
:type end_index: int
:param is_rows: True 插入行 False 插入列
:type is_rows: bool
:param inherit_style: BEFORE AFTER不填为不继承 style
:type inherit_style: str
该接口用于根据 sheet_token 和维度信息 插入空行/
startIndex=3 endIndex=7则从第 4 行开始开始插入行列一直到第 7 共插入 4 单次操作不超过5000行或列
https://open.feishu.cn/document/ukTMukTMukTM/uQjMzUjL0IzM14CNyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/insert_dimension_range'.format(sheet_token))
body = {
'dimension': {
'sheetId': sheet_id,
'majorDimension': 'ROWS' if is_rows else 'COLUMNS',
'startIndex': start_index,
'endIndex': end_index,
},
}
if inherit_style:
body['inheritStyle'] = inherit_style
self._post(url, body=body, auth_token=user_access_token)
def add_drive_sheet_rows_columns(self, user_access_token, sheet_token, sheet_id, length, is_rows=True):
"""添加行列
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param length: 需要添加的行数列数
:type length: int
:param is_rows: True 插入行 False 插入列
:type is_rows: bool
该接口用于根据 sheet_token 和长度在末尾增加空行/单次操作不超过5000行或列
https://open.feishu.cn/document/ukTMukTMukTM/uUjMzUjL1IzM14SNyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/dimension_range'.format(sheet_token))
body = {
'dimension': {
'sheetId': sheet_id,
'majorDimension': 'ROWS' if is_rows else 'COLUMNS',
'length': length
}
}
self._post(url, body=body, auth_token=user_access_token)
def update_drive_sheet_rows_columns(self, user_access_token, sheet_token, sheet_id, start_index, end_index,
visible=None, fixed_size=None, is_rows=True):
"""更新行列
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param start_index: 从这一行的下一行开始插入或下一列
:type start_index: int
:param end_index: 截止到这一行
:type end_index: int
:param visible: true 为显示false 为隐藏行列
:type visible: bool
:param fixed_size: /列的大小
:type fixed_size: int
:param is_rows: True 插入行 False 插入列
:type is_rows: bool
该接口用于根据 sheet_token 和维度信息更新隐藏行列单元格大小单次操作不超过5000行或列
https://open.feishu.cn/document/ukTMukTMukTM/uYjMzUjL2IzM14iNyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/dimension_range'.format(sheet_token))
body = {
'dimension': {
'sheetId': sheet_id,
'majorDimension': 'ROWS' if is_rows else 'COLUMNS',
'startIndex': start_index,
'endIndex': end_index
},
'dimensionProperties': {
}
}
if visible is not None:
body['dimensionProperties']['visible'] = visible
if fixed_size is not None:
body['dimensionProperties']['fixedSize'] = fixed_size
self._put(url, body=body, auth_token=user_access_token)
def delete_drive_sheet_rows_columns(self, user_access_token, sheet_token, sheet_id, start_index, end_index,
is_rows=True):
"""删除行列
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param start_index: 从这一行的下一行开始插入或下一列
:type start_index: int
:param end_index: 截止到这一行
:type end_index: int
:param is_rows: True 插入行 False 插入列
:type is_rows: bool
该接口用于根据 sheet_token 和维度信息删除行/ 单次操作不超过5000行或列
https://open.feishu.cn/document/ukTMukTMukTM/ucjMzUjL3IzM14yNyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/dimension_range'.format(sheet_token))
body = {
'dimension': {
'sheetId': sheet_id,
'majorDimension': 'ROWS' if is_rows else 'COLUMNS',
'startIndex': start_index,
'endIndex': end_index
}
}
self._delete(url, body=body, auth_token=user_access_token)
def set_drive_sheet_style(self, user_access_token, sheet_token, sheet_id, range, style=None, raw_style=None):
"""设置单元格样式
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:type sheet_id: str
:param range: 范围形如'A1:D2'
:type range: str
:param style: 单元格样式具体请参考文档https://open.feishu.cn/document/ukTMukTMukTM/ukjMzUjL5IzM14SOyMTN
:type style: DriveSheetStyle
:param raw_style: 单元格样式 原始样式
:type raw_style: Any
该接口用于根据 sheet_token range 和样式信息更新单元格样式单次写入不超过5000行100
https://open.feishu.cn/document/ukTMukTMukTM/ukjMzUjL5IzM14SOyMTN
"""
if not raw_style and style:
raw_style = style.as_sheet_style()
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/style'.format(sheet_token))
body = {
'appendStyle': {
'range': join_range(sheet_id, range),
'style': raw_style
}
}
self._put(url, body=body, auth_token=user_access_token)
def batch_set_drive_sheet_style(self, user_access_token, sheet_token, sheet_id, styles):
"""批量设置单元格样式
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:type sheet_id: str
:param styles: BatchSetDriveSheetStyleRequest 的数组
:type styles: list[BatchSetDriveSheetStyleRequest]
该接口用于根据 sheet_token range和样式信息 批量更新单元格样式单次写入不超过5000行100
https://open.feishu.cn/document/ukTMukTMukTM/uAzMzUjLwMzM14CMzMTN
"""
d = []
for i in styles:
d.append(i.as_dict(sheet_id))
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/styles_batch_update'.format(sheet_token))
body = {
'data': d
}
self._put(url, body=body, auth_token=user_access_token)
def batch_lock_drive_sheet_rows_columns(self, user_access_token, sheet_token, requests):
"""批量增加锁定单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param requests: 参数
:type requests: list[LockDriveSheetRequest]
:rtype: dict[str, str]
该接口用于根据 sheet_token 和维度信息增加多个范围的锁定单元格单次操作不超过5000行或列
https://open.feishu.cn/document/ukTMukTMukTM/ugDNzUjL4QzM14CO0MTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/protected_dimension'.format(sheet_token))
body = {
'addProtectedDimension': [i.as_dict() for i in requests]
}
res = self._post(url, body=body, auth_token=user_access_token)
return {i['dimension']['sheetId']: i['protectId'] for i in res['data']['addProtectedDimension']}
def lock_drive_sheet_rows_columns(self, user_access_token, sheet_token, sheet_id, start_index, end_index,
is_rows=True, editor_uids=None, lock_info=None):
"""增加锁定单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param start_index: 从这一行的下一行开始插入或下一列
:type start_index: int
:param end_index: 截止到这一行
:type end_index: int
:param is_rows: True 插入行 False 插入列
:type is_rows: bool
:param editor_uids: 可以编辑者
:type editor_uids: list[int]
:param lock_info: lock 信息
:type lock_info: str
:rtype: str
该接口用于根据 sheet_token 和维度信息增加多个范围的锁定单元格单次操作不超过5000行或列
https://open.feishu.cn/document/ukTMukTMukTM/ugDNzUjL4QzM14CO0MTN
"""
res = self.batch_lock_drive_sheet_rows_columns(user_access_token=user_access_token,
sheet_token=sheet_token,
requests=[LockDriveSheetRequest(
sheet_id=sheet_id,
start_index=start_index,
end_index=end_index,
editor_uids=editor_uids,
is_rows=is_rows,
lock_info=lock_info,
)])
return res.get(sheet_id, '')
def merge_drive_sheet_cells(self, user_access_token, sheet_token, sheet_id, range, merge_type):
"""合并单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param range: 范围形如'A1:D2'
:type range: str
:param merge_type: lock 信息
:type merge_type: DriveSheetMergeType
:rtype: str
该接口用于根据 sheet_token 和维度信息合并单元格单次操作不超过5000行100
https://open.feishu.cn/document/ukTMukTMukTM/ukDNzUjL5QzM14SO0MTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/merge_cells'.format(sheet_token))
body = {
'range': join_range(sheet_id, range),
'mergeType': converter_enum(merge_type),
}
self._post(url, body=body, auth_token=user_access_token)
def unmerge_drive_sheet_cells(self, user_access_token, sheet_token, sheet_id, range):
"""拆分单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param range: 范围形如'A1:D2'
:type range: str
:rtype: str
该接口用于根据 sheet_token 和维度信息合并单元格单次操作不超过5000行100
https://open.feishu.cn/document/ukTMukTMukTM/ukDNzUjL5QzM14SO0MTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/unmerge_cells'.format(sheet_token))
body = {
'range': join_range(sheet_id, range),
}
self._post(url, body=body, auth_token=user_access_token)
def read_drive_sheet_cells(self, user_access_token, sheet_token, sheet_id, range, to_str=False):
"""读取单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param range: 范围形如'A1:D2'
:type range: str
:param to_str: 是否返回 to_str 后的值
:type to_str: bool
:rtype: (int, list[list[Any]])
:return: 版本值的二维数组
该接口用于根据 sheet_token range 读取表格单个范围的值返回数据限制为10M
https://open.feishu.cn/document/ukTMukTMukTM/ugTMzUjL4EzM14COxMTN
"""
url = self._gen_request_url(
'/open-apis/sheet/v2/spreadsheets/{}/values/{}'.format(sheet_token, join_range(sheet_id, range)))
if to_str:
url = url + '?valueRenderOption=ToString'
res = self._get(url, auth_token=user_access_token)
revision = res['data']['revision'] # type: int
values = res['data']['valueRange'].get('values', []) # type: List[List[Any]]
return revision, values
def batch_read_drive_sheet_cells(self, user_access_token, sheet_token, requests, to_str=False):
"""批量读取单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param requests: 参数
:type requests: list[ReadDriveSheetRequest]
:param to_str: 是否返回 to_str 后的值
:type to_str: bool
:rtype: (int, dict[str, list[list[Any]]])
:return: 版本值的二维数组
该接口用于根据 sheet_token range 读取表格单个范围的值返回数据限制为10M
https://open.feishu.cn/document/ukTMukTMukTM/ugTMzUjL4EzM14COxMTN
"""
ranges = ','.join([i.as_str() for i in requests])
url = self._gen_request_url(
'/open-apis/sheet/v2/spreadsheets/{}/values_batch_get?ranges={}'.format(sheet_token, quote(ranges)))
if to_str:
url = url + '&valueRenderOption=ToString'
res = self._get(url, auth_token=user_access_token)
revision = res['data']['revision'] # type: int
values = {i.get('range', ''): i.get('values', []) for i in
res['data']['valueRanges']} # type: Dict[str, List[List[Any]]]
return revision, values
def write_drive_sheet_cells(self, user_access_token, sheet_token, sheet_id, range, values):
"""写入单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param sheet_id: 作为表格唯一识别参数
:type sheet_id: str
:param range: 范围形如'A1:D2'
:type range: str
:param values: 数据二位数组每个子数组就是一行数据
:type values: list[list[Any]]
:rtype: (int, list[list[Any]])
:return: 版本值的二维数组
该接口用于根据 sheet_token range 向单个范围写入数据若范围内有数据将被更新覆盖
单次写入不超过5000行100每个格子大小为0.5M
https://open.feishu.cn/document/ukTMukTMukTM/uAjMzUjLwIzM14CMyMTN
"""
url = self._gen_request_url(
'/open-apis/sheet/v2/spreadsheets/{}/values'.format(sheet_token))
body = {
'valueRange': {
'range': join_range(sheet_id, range),
'values': [[i.as_sheet_dict() if hasattr(i, 'as_sheet_dict') else i for i in value] for value in values]
}
}
self._put(url, body=body, auth_token=user_access_token)
def batch_write_drive_sheet_cells(self, user_access_token, sheet_token, requests):
"""批量写入单元格
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param sheet_token: 文件的 token 列表
:type sheet_token: str
:param requests: 参数
:type requests: list[WriteDriveSheetRequest]
:rtype: (int, list[list[Any]])
:return: 版本值的二维数组
该接口用于根据 sheet_token range 向多个范围写入数据若范围内有数据将被更新覆盖
单次写入不超过5000行100每个格子大小为0.5M
https://open.feishu.cn/document/ukTMukTMukTM/uEjMzUjLxIzM14SMyMTN
"""
url = self._gen_request_url('/open-apis/sheet/v2/spreadsheets/{}/values_batch_update'.format(sheet_token))
body = {
'valueRanges': [i.as_dict() for i in requests]
}
self._post(url, body=body, auth_token=user_access_token)
def _pack_insert_sheet(sheet_token, sheet_id, res):
"""
:rtype: DriveInsertSheet
"""
data = res['data']
revision = data.get('revision', 0)
updates = data.get('updates', {})
updated_range = updates.get('updatedRange', '')
rows = updates.get('updatedRows', 0)
columns = updates.get('updatedColumns', 0)
cells = updates.get('updatedCells', 0)
return make_datatype(DriveInsertSheet, dict(sheet_token=sheet_token,
sheet_id=sheet_id,
revision=revision,
updated_range=updated_range,
rows=rows,
columns=columns,
cells=cells))

View File

@ -0,0 +1,86 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING
from utils.feishu.dt_drive import DriveFileMeta
from utils.feishu.dt_help import make_datatype
from utils.feishu.helper import converter_enum
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
from utils.feishu.dt_drive import DriveFileToken, DriveFileType
class APIDriveSuiteMixin(object):
def get_drive_file_meta(self, user_access_token, files):
"""获取各类文件的元数据
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param files: 文件的 token 列表
:type files: list[DriveFileToken]
:return: 文件元信息
:rtype: list[DriveFileMeta]
该接口用于根据 token 获取各类文件的元数据
https://open.feishu.cn/document/ukTMukTMukTM/uMjN3UjLzYzN14yM2cTN
"""
url = self._gen_request_url('/open-apis/suite/docs-api/meta')
body = {
'request_docs': [
{
'docs_token': i.token,
'docs_type': i.type,
} for i in files
]
}
res = self._post(url, body=body, auth_token=user_access_token)
return [make_datatype(DriveFileMeta, i) for i in res['data']['docs_metas']]
def search_drive_file(self, user_access_token, key, owner_open_ids=None, chat_open_ids=None, docs_types=None,
count=50, offset=0):
"""文档搜索
:type self: OpenLark
:param user_access_token: user_access_token
:type user_access_token: str
:param key: 搜索的关键词
:type key: str
:param owner_open_ids: 文档所有人
:type owner_open_ids: list[str]
:param chat_open_ids: 文档所在群
:type chat_open_ids: list[str]
:param docs_types: 文档类型支持"doc", "sheet", "slide", "bitable", "mindnote", "file", "wiki"
:type docs_types: list[DriveFileType]
:param count: 个数
:type count: int
:param offset: 偏移
:type offset: int
:return: 文件元信息
:rtype: (bool, int, list[DriveFileMeta])
该接口用于根据搜索条件进行文档搜索
https://open.feishu.cn/document/ukTMukTMukTM/ugDM4UjL4ADO14COwgTN
"""
url = self._gen_request_url('/open-apis/suite/docs-api/search/object')
body = {
'search_key': key,
'count': count,
'offset': offset,
}
if owner_open_ids:
body['owner_ids'] = owner_open_ids
if chat_open_ids:
body['chat_ids'] = chat_open_ids
if docs_types:
body['docs_types'] = [converter_enum(i) for i in docs_types]
res = self._post(url, body=body, auth_token=user_access_token)
has_more = res['data']['has_more']
total = res['data']['total']
entities = [make_datatype(DriveFileMeta, i) for i in res['data']['docs_entities']]
return has_more, total, entities

51
utils/feishu/api_duty.py Normal file
View File

@ -0,0 +1,51 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
from typing import TYPE_CHECKING, Tuple
import requests
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
logger = logging.getLogger('feishu')
class APIDutyMixin(object):
def join_duty_room(self, duty_room_id, duty_room_token, user_id):
"""加入服务台
:type self: OpenLark
:param duty_room_id: 服务台的 id
:param duty_room_token: 服务台的 token
:param user_id: 用户 id
:return: 用户所在的服务台对话的 chat_id和带有 Lark 协议的跳转 URI
:rtype: Tuple[str, str]
跳转入用户专属的飞书中值班号客服群中
需要服务台的管理员联系 @杨帆 获取接入服务台 duty_room_token 和服务台 duty_room_id
https://bytedance.feishu.cn/space/doc/YlsR7QzmqM0gg6wbwpY4la
"""
url = 'https://api.zjurl.cn/saipan/api/chat/to_chat'
r = requests.post(url, json={
'duty_room_id': duty_room_id,
'token': duty_room_token,
'user_id': user_id,
})
logger.debug('[join_duty_room] duty_room_id=%s, user_id=%s, status_code=%d, resp=%s', duty_room_id, user_id,
r.status_code, r.text)
try:
r.raise_for_status()
data = r.json()
chat_id = data['data']['chat_id']
lark_client_uri = 'lark://client/chat/{}'.format(chat_id)
return chat_id, lark_client_uri
except Exception as e:
logger.error('[join_duty_room] duty_room_id=%s, user_id=%s, status_code=%d, resp=%s, err=%s', duty_room_id,
user_id, r.status_code, r.text, e)
raise

30
utils/feishu/api_file.py Normal file
View File

@ -0,0 +1,30 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from io import BytesIO
from typing import TYPE_CHECKING, Tuple, Union
from utils.feishu.helper import to_file_like
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIFileMixin(object):
def get_file_by_key(self, file_key):
"""获取文件
:type self: OpenLark
:param file_key: 文件的 key
:type file_key: str
:return: 文件的二进制数据流
:rtype: list[byte]
根据文件的 file_key 拉取文件内容当前仅可用来获取用户与机器人单聊发送的文件
https://open.feishu.cn/document/ukTMukTMukTM/uMDN4UjLzQDO14yM0gTN
"""
url = self._gen_request_url('/open-apis/open-file/v1/get?file_key={}'.format(file_key))
res = self._get(url, raw_content=True, with_tenant_token=True)
return res

235
utils/feishu/api_id.py Normal file
View File

@ -0,0 +1,235 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Tuple
from utils.feishu.exception import LarkInvalidArguments, OpenLarkException
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
# https://open.feishu.cn/document/ukTMukTMukTM/uIzMxEjLyMTMx4iMzETM
class APIIDMixin(object):
def email_to_id(self, email):
"""邮箱转 open_id 和 user_id
:type self: OpenLark
:param email: 用户的邮箱
:type email: str
:return: open_id, user_id
:rtype: Tuple[str, str]
根据用户邮箱获取用户 open_id user_id
user_id 需要申请 user_id 的权限才能获取到
https://open.feishu.cn/document/ukTMukTMukTM/uEDMwUjLxADM14SMwATN
"""
url = self._gen_request_url('/open-apis/user/v3/email2id')
body = {'email': email}
res = self._post(url, body, with_tenant_token=True)
open_id = res.get('open_id', '') # type: str
user_id = res.get('employee_id', '') # type: str
return open_id, user_id
def open_id_to_user_id(self, open_id):
"""open_id 转 user_id
:type self: OpenLark
:param open_id: open_id
:type open_id: str
:return: user_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/openid2uid/')
body = {'open_id': open_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('user_id')
def user_id_to_open_id(self, user_id):
"""user_id 转 open_id
:type self: OpenLark
:param user_id: user_id
:type user_id: str
:return: open_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/uid2openid/')
body = {'user_id': user_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('open_id')
def employee_id_to_user_id(self, employee_id):
"""employee_id 转 user_id
:type self: OpenLark
:param employee_id: employee_id
:type employee_id: str
:return: user_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/eid2uid/')
body = {'employee_id': employee_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('user_id')
def user_id_to_employee_id(self, user_id):
"""user_id 转 employee_id
:type self: OpenLark
:param user_id: user_id
:type user_id: str
:return: employee_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/uid2eid/')
body = {'user_id': user_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('employee_id')
def chat_id_to_open_chat_id(self, chat_id):
"""chat_id 转 open_chat_id
:type self: OpenLark
:param chat_id: chat_id
:type chat_id: str
:return: open_chat_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/cid2ocid/')
body = {'chat_id': chat_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('open_chat_id')
def open_chat_id_to_chat_id(self, open_chat_id):
"""open_chat_id 转 chat_id
:type self: OpenLark
:param open_chat_id: open_chat_id
:type open_chat_id: str
:return: chat_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/ocid2cid/')
body = {'open_chat_id': open_chat_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('chat_id')
def message_id_to_open_message_id(self, message_id):
"""message_id 转 open_message_id
:type self: OpenLark
:param message_id: message_id
:type message_id: str
:return: open_message_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/mid2omid/')
body = {'message_id': message_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('open_message_id')
def open_message_id_to_message_id(self, open_message_id):
"""open_message_id 转 message_id
:type self: OpenLark
:param open_message_id: open_message_id
:type open_message_id: str
:return: message_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/omid2mid/')
body = {'open_message_id': open_message_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('message_id')
def department_id_to_open_department_id(self, department_id):
"""department_id 转 open_department_id
:type self: OpenLark
:param department_id: department_id
:type department_id: str
:return: open_department_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/did2odid/')
body = {'department_id': department_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('open_department_id')
def open_department_id_to_department_id(self, open_department_id):
"""open_department_id 转 department_id
:type self: OpenLark
:param open_department_id: open_department_id
:type open_department_id: str
:return: department_id
:rtype: str
"""
url = self._gen_request_url('/open-apis/exchange/v3/odid2did/')
body = {'open_department_id': open_department_id}
res = self._post(url, body, with_tenant_token=True)
return res.get('department_id')
def get_chat_id_between_user_bot(self, open_id='', user_id=''):
"""获取机器人和用户的 chat_id
:type self: OpenLark
:param open_id: open_id
:type open_id: str
:param user_id: user_id
:return: open_chat_id, chat_id
:rtype: Tuple[str, str]
https://lark-open.bytedance.net/document/ukTMukTMukTM/uYjMxEjL2ITMx4iNyETM
"""
if open_id:
url = self._gen_request_url('/open-apis/chat/v3/p2p/id?open_id={}'.format(open_id))
elif user_id:
url = self._gen_request_url('/open-apis/chat/v3/p2p/id?user_id={}'.format(user_id))
else:
raise OpenLarkException(msg='[get_chat_id_between_user_bot] empty open_id and user_id')
res = self._get(url, with_tenant_token=True)
open_chat_id = res.get('open_chat_id', '') # type: str
chat_id = res.get('chat_id', '') # type: str
return open_chat_id, chat_id
def get_chat_id_between_users(self, to_user_id,
open_id='',
user_id=''):
"""获取用户和用户的之前的 chat_id
:type self: OpenLark
:param to_user_id: 到谁的 open_id
:type to_user_id: str
:param open_id: 从谁来的 open_id
:type open_id: str
:param user_id: 从谁来的 user_id
:type user_id: str
:return: 两个人之间的 open_chat_id, chat_id
:rtype: Tuple[str, str]
仅头条内部用户可用 需要申请权限才能获取 @fanlv
open_id user_id 传一个就行
https://lark-open.bytedance.net/document/ukTMukTMukTM/uYjMxEjL2ITMx4iNyETM
"""
if open_id:
url = self._gen_request_url('/open-apis/chat/v3/p2p/id?open_id={}&chatter={}'.format(open_id, to_user_id))
elif user_id:
url = self._gen_request_url('/open-apis/chat/v3/p2p/id?user_id={}&chatter={}'.format(user_id, to_user_id))
else:
raise LarkInvalidArguments(msg='[get_chat_id_between_users] empty open_id and user_id')
res = self._get(url, with_tenant_token=True)
open_chat_id = res.get('open_chat_id', '') # type: str
chat_id = res.get('chat_id', '') # type: str
return open_chat_id, chat_id

52
utils/feishu/api_image.py Normal file
View File

@ -0,0 +1,52 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from io import BytesIO
from typing import TYPE_CHECKING, Tuple, Union
from utils.feishu.helper import to_file_like
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIImageMixin(object):
def upload_image(self, image):
"""上传图片
:type self: OpenLark
:param image: 图片文件支持路径bytesBytesIO
:type image: Union[str, bytes, BytesIO]
:return: image_key, url
:rtype: Tuple[str, str]
上传图片获取图片的 image_key
https://open.feishu.cn/document/ukTMukTMukTM/uEDO04SM4QjLxgDN
"""
content = to_file_like(image)
url = self._gen_request_url('/open-apis/image/v4/upload/')
files = {'image': content}
res = self._post(url, files=files, with_tenant_token=True)
data = res['data']
image_key = data['image_key'] # type: str
url = data['url'] # type: str
return image_key, url
def get_image(self, image_key):
"""获取图片
:type self: OpenLark
:param image_key: 图片的key
:type image_key: str
:return: 图片 bytes
:rtype: bytes
根据图片的image_key拉取图片内容仅可以拉到自己上传或者收到推送的图片
https://open.feishu.cn/document/ukTMukTMukTM/uYzN5QjL2cTO04iN3kDN
"""
url = self._gen_request_url('/open-apis/image/v4/get?image_key=' + image_key)
return self._get(url=url, raw_content=True, with_tenant_token=True)

View File

@ -0,0 +1,251 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import datetime
from typing import TYPE_CHECKING, List
from dateutil import parser
from six.moves.urllib.parse import urlencode
from utils.feishu.dt_code import SimpleUser
from utils.feishu.dt_help import make_datatype
from utils.feishu.dt_meeting_room import Building, Room, RoomFreeBusy
from utils.feishu.helper import converter_enum, datetime_format_rfc3339
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class TimeZoneChina(datetime.tzinfo):
_offset = datetime.timedelta(hours=8)
_dst = datetime.timedelta(0)
_name = "+08"
def utcoffset(self, dt):
return self.__class__._offset
def dst(self, dt):
return self.__class__._dst
def tzname(self, dt):
return self.__class__._name
class APIMeetingRoomMixin(object):
def get_building_list(self, page_size=10, page_token='', order_by='', fields=None):
"""获取建筑物列表
:type self: OpenLark
:param page_size: 可选参数请求期望返回的建筑物数量不足则返回全部该值默认为 10最大为 100
:type page_size: int
:param page_token: 可选参数用于标记当前请求的页数将返回以当前页开始往后 page_size 个元素
:type page_token: str
:param order_by: 可选参数提供用于对名称进行升序/降序排序的方式查询可选项有"name-asc,name-desc"传入其他字符串不做处理默认无序
:type order_by: str
:param fields: 可选参数可选字段有"id,name,description,floors"默认返回所有字段
:type fields: list[str]
:return: has_more, page_token, 建筑物列表
:rtype: (bool, str, list[Building])
该接口用于获取本企业下的建筑物办公大楼
https://open.feishu.cn/document/ukTMukTMukTM/ugzNyUjL4cjM14CO3ITN
"""
if page_size > 100:
page_size = 100
elif page_size <= 0:
page_size = 10
params = {
'page_size': page_size,
}
if page_token:
params['page_token'] = page_token
if order_by:
params['order_by'] = order_by
if fields:
params['fields'] = ','.join(fields)
url = self._gen_request_url('/open-apis/meeting_room/building/list?{}'.format(urlencode(params)))
res = self._get(url, with_tenant_token=True)
data = res['data']
has_more = data.get('has_more', False)
page_token = data.get('page_token', '')
building_list = [make_datatype(Building, i) for i in data.get('buildings', [])]
return has_more, page_token, building_list
def batch_get_building(self, building_ids, fields=None):
"""查询建筑物详情
:type self: OpenLark
:param building_ids: 必须参数用于查询指定建筑物的ID列表
:type building_ids: list[str]
:param fields: 可选参数用于指定返回的字段名可选字段有"id,name,description,floors"默认返回所有字段
:return: 建筑物列表
:rtype: list[Building]
https://open.feishu.cn/document/ukTMukTMukTM/ukzNyUjL5cjM14SO3ITN
"""
params = {
'building_ids': building_ids,
}
if fields:
params['fields'] = ','.join(fields)
url = self._gen_request_url(
'/open-apis/meeting_room/building/batch_get?{}'.format(urlencode(params, doseq=True)))
res = self._get(url, with_tenant_token=True)
data = res.get('data', {})
buildings = data.get('buildings', [])
building_list = [make_datatype(Building, i) for i in buildings]
return building_list
def get_room_list(self, building_id, page_size=100, page_token='', order_by='', fields=None):
"""获取会议室列表
:type self: OpenLark
:param building_id: 被查询的建筑物ID
:type building_id: str
:param page_size: 请求期望返回的会议室数量不足则返回全部该值默认为 100最大为 1000
:type page_size: int
:param page_token: 用于标记当前请求的页数将返回以当前页开始往后 page_size 个元素
:type page_token: str
:param order_by: 提供用于对名称/楼层进行升序/降序排序的方式查询可选项有"name-asc,name-desc,floor_name-asc,
floor_name-desc",传入其他字符串不做处理,默认无序
:type order_by: str
:param fields: 可选字段有"id,name,description,capacity,building_id,building_name,floor_name,is_disabled,
display_id",默认返回所有字段
:type fields: list[str]
:return: has_more, page_token, 会议室列表
:rtype: (bool, str, list[Room])
https://open.feishu.cn/document/ukTMukTMukTM/uADOyUjLwgjM14CM4ITN
"""
if page_size > 1000:
page_size = 1000
elif page_size <= 0:
page_size = 100
params = {
'building_id': building_id,
'page_size': page_size
}
if page_token:
params['page_token'] = page_token
if order_by:
params['order_by'] = order_by
if fields:
params['fields'] = ','.join(fields)
url = self._gen_request_url('/open-apis/meeting_room/room/list?{}'.format(urlencode(params)))
res = self._get(url, with_tenant_token=True)
data = res.get('data', {})
rooms = data.get('rooms', [])
has_more = data.get('has_more', False)
page_token = data.get('page_token', '')
room_list = [make_datatype(Room, i) for i in rooms]
return has_more, page_token, room_list
def batch_get_room_list(self, room_ids, fields=None):
"""查询会议室详情
:type self: OpenLark
:param room_ids: 用于查询指定会议室的ID列表
:type room_ids: List[str]
:param fields: 可选字段有"id,name,description,capacity,building_id,building_name,floor_name,is_disabled,
display_id",默认返回所有字段
:return: 会议室列表
:rtype: list[Room]
https://open.feishu.cn/document/ukTMukTMukTM/uEDOyUjLxgjM14SM4ITN
"""
params = {
'room_ids': room_ids,
}
if fields:
params['fields'] = ','.join(fields)
url = self._gen_request_url('/open-apis/meeting_room/room/batch_get?{}'.format(urlencode(params, doseq=True)))
res = self._get(url, with_tenant_token=True)
data = res.get('data', {})
rooms = data.get('rooms', [])
room_list = [make_datatype(Room, i) for i in rooms]
return room_list
def batch_get_room_freebusy(self, room_ids, time_min, time_max):
"""会议室忙闲查询
:type self: OpenLark
:param room_ids: 用于查询指定会议室的ID列表
:type room_ids: list[str]
:param time_min: 查询会议室忙闲的起始时间
:type time_min: datetime.datetime
:param time_max: 查询会议室忙闲的结束时间
:type time_max: datetime.datetime
:return: 查询会议室忙闲的起始时间(与请求参数完全相同), 查询会议室忙闲的结束时间(与请求参数完全相同), Dict['会议室ID', List[忙碌时间]]
:rtype: (datetime.datetime, datetime.datetime, dict[str, list[RoomFreeBusy]])
https://open.feishu.cn/document/ukTMukTMukTM/uIDOyUjLygjM14iM4ITN
"""
tz = TimeZoneChina()
params = {
'room_ids': room_ids,
'time_min': datetime_format_rfc3339(time_min, tz),
'time_max': datetime_format_rfc3339(time_max, tz)
}
url = self._gen_request_url(
'/open-apis/meeting_room/freebusy/batch_get?{}'.format(urlencode(params, doseq=True)))
res = self._get(url, with_tenant_token=True)
data = res.get('data', {})
time_min = data.get('time_min')
time_max = data.get('time_max')
if time_min:
time_min = parser.parse(time_min)
if time_max:
time_max = parser.parse(time_max)
return time_min, time_max, {
k: [RoomFreeBusy(
start_time=parser.parse(i.get('start_time')),
end_time=parser.parse(i.get('end_time')),
uid=i.get('uid'),
original_time=i.get('original_time'),
organizer_info=make_datatype(SimpleUser, i.get('organizer_info'))
) for i in v]
for k, v in data.get('free_busy', {}).items()}
def reply_meeting(self, room_id, uid, original_time, status):
"""回复会议室日程实例
:type self: OpenLark
:param room_id: 会议室的 ID
:type room_id: str
:param uid: 会议室的日程 ID
:type uid: str
:param original_time: 日程实例原始时间非重复日程必为0重复日程若为0则表示回复其所有实例否则表示回复单个实例
:type original_time: int
:param status: 回复状态NOT_CHECK_IN 表示未签到ENDED_BEFORE_DUE 表示提前结束
:type status: MeetingReplyStatus
:return: 查询会议室忙闲的起始时间(与请求参数完全相同), 查询会议室忙闲的结束时间(与请求参数完全相同), Dict['会议室ID', List[忙碌时间]]
:rtype: (datetime.datetime, datetime.datetime, dict[str, list[RoomFreeBusy]])
https://open.feishu.cn/document/ukTMukTMukTM/uIDOyUjLygjM14iM4ITN
"""
body = {
'room_id': room_id,
'uid': uid,
'original_time': original_time,
'status': converter_enum(status),
}
url = self._gen_request_url('/open-apis/meeting_room/instance/reply')
self._post(url, body=body, with_tenant_token=True)

441
utils/feishu/api_message.py Normal file
View File

@ -0,0 +1,441 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union
from six import string_types
from utils.feishu.dt_enum import MessageType, UrgentType
from utils.feishu.dt_message import CardAction, CardHeader, CardURL, MessageAt, MessageImage, MessageLink, MessageText
from utils.feishu.exception import LarkInvalidArguments
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
def _send_all_message(self,
open_id='',
root_id='',
open_chat_id='',
employee_id='',
email='',
msg_type=MessageType.text,
content=None,
content_key='content'):
"""发消息
:type open_id: str
:type root_id: str
:type open_chat_id: str
:type employee_id: str
:type email: str
:type msg_type: MessageType
:type content: Dict[str, Any]
:type content_key: str
:rtype str
"""
url = self._gen_request_url('/open-apis/message/v3/send/')
body = {
'msg_type': msg_type.value,
content_key: content
}
if open_id:
body['open_id'] = open_id
elif open_chat_id:
body['open_chat_id'] = open_chat_id
elif employee_id:
body['employee_id'] = employee_id
elif email:
body['email'] = email
if root_id:
body['root_id'] = root_id
res = self._post(url, body, with_tenant_token=True)
return res['open_message_id']
class _Send(object):
__to = {} # type: Dict[string_types, Any]
__open_lark = None
__root_id = ''
def __init__(self, open_lark, to, root_id=''):
self.__to = to
self.__open_lark = open_lark
self.__root_id = root_id
def __dir__(self):
"""dir(x)
:rtype Iterable[str]
"""
return ['send_card', 'send_image', 'send_post', 'send_share_chat', 'send_text']
def send_text(self, text):
"""发送文本消息
:param text: 文本
:return: 发送成功的消息 open_message_id
"""
body = deepcopy(self.__to)
if self.__root_id:
body['root_id'] = self.__root_id
body['msg_type'] = MessageType.text
body['content'] = {
'text': text
}
return _send_all_message(self.__open_lark, **body)
def send_image(self, image_key):
"""发送图片消息
:param image_key: 图片的 key可以通过 upload_image 接口上传文件获取
:return: 发送成功的消息 open_message_id
"""
body = deepcopy(self.__to)
if self.__root_id:
body['root_id'] = self.__root_id
body['msg_type'] = MessageType.image
body['content'] = {
'image_key': image_key
}
return _send_all_message(self.__open_lark, **body)
def send_share_chat(self, open_chat_id):
"""分享群聊卡片
:param open_chat_id: 群聊的 open_chat_id
:type open_chat_id: str
:return: 发送成功的消息 open_message_id
"""
body = deepcopy(self.__to)
if self.__root_id:
body['root_id'] = self.__root_id
body['msg_type'] = MessageType.share_chat
body['content'] = {
'share_open_chat_id': open_chat_id
}
return _send_all_message(self.__open_lark, **body)
def send_post(self,
zh_cn_title,
zh_cn_content,
en_us_title=None,
en_us_content=None):
"""发送富文本消息
:param zh_cn_title: 中文标题
:type zh_cn_title: str
:param zh_cn_content: 中文内容 MessageText, MessageAt, MessageImage, MessageLink 的二维数组
:type zh_cn_content: List[List[Union[MessageText, MessageAt, MessageImage, MessageLink]]]
:param en_us_title: 英文标题
:type en_us_title: str
:param en_us_content: 英文内容 MessageText, MessageAt, MessageImage, MessageLink 的二维数组
:type en_us_content: List[List[Union[MessageText, MessageAt, MessageImage, MessageLink]]]
:return: 发送成功的消息 open_message_id
"""
if not zh_cn_title and not en_us_title and not zh_cn_content and not en_us_content:
raise LarkInvalidArguments(msg='send post message with empty content')
body = deepcopy(self.__to)
if self.__root_id:
body['root_id'] = self.__root_id
body['msg_type'] = MessageType.post
body['content'] = {
'post': {
'zh_cn': {
'title': zh_cn_title,
'content': [list(map(lambda cls: cls.as_post_dict(), i)) for i in zh_cn_content],
}
}
}
if en_us_title is not None or en_us_content is not None:
body['content']['post']['en_us'] = {}
if en_us_title is not None:
body['content']['post']['en_us']['title'] = en_us_title
if en_us_content is not None:
body['content']['post']['en_us']['content'] = \
[list(map(lambda cls: cls.as_post_dict(), i)) for i in en_us_content]
return _send_all_message(self.__open_lark, **body)
def send_card(self,
card_link=None,
header=None,
content=None,
actions=None):
"""发送卡片消息
:param card_link: 卡片消息的链接 CardURL
:type card_link: CardURL
:param header: 卡片消息的头部 CardHeader
:type header: CardHeader
:param content: 卡片消息的内容 MessageText, MessageAt, MessageImage, MessageLink 中任意一个的二维数组
:type content: List[List[Union[MessageText, MessageAt, MessageImage, MessageLink]]]
:param actions: 卡片消息的按钮 CardAction 的列表
:type actions: List[CardAction]
:return: 发送成功的消息 open_message_id
"""
card = {} # type: Dict[string_types, Any]
if card_link:
card['card_link'] = card_link.as_card_dict()
if header:
card['header'] = header.as_dict()
if content:
card['content'] = [list(map(lambda cls: cls.as_card_dict(), i)) for i in content]
if actions:
card['actions'] = [i.as_dict() for i in actions]
body = deepcopy(self.__to)
if self.__root_id:
body['root_id'] = self.__root_id
body['msg_type'] = MessageType.card
body['content'] = {'card': card}
return _send_all_message(self.__open_lark, **body)
def send_forward_post(self, title, post):
"""转发富文本消息,富文本消息来自消息监听内容
:param title: 监听到的标题
:type title: str
:param post: 监听到的富文本内容
:return: 发送成功的消息 open_message_id
"""
body = deepcopy(self.__to)
if self.__root_id:
body['root_id'] = self.__root_id
body['msg_type'] = MessageType.forward
body['content'] = {
'title': title,
'text': post,
}
return _send_all_message(self.__open_lark, **body)
class _To(object):
__open_lark = None
__root_id = ''
__to = {} # type: Dict[string_types, string_types]
def __init__(self, open_lark, root_id=''):
self.__open_lark = open_lark
self.__root_id = root_id
def __dir__(self):
"""
:rtype Iterable[str]
"""
return [
'to_email',
'to_open_id',
'to_employee_id',
'to_open_chat_id'
]
def to_email(self, email):
"""发送到哪个 email 的用户处
:param email: email
:return: 链式调用对象可以调用 send 方法
"""
self.__to = {'email': email}
return _Send(open_lark=self.__open_lark, to=self.__to, root_id=self.__root_id)
def to_open_id(self, open_id):
"""发送到哪个 open_id 的用户处
:param open_id: open_id
:return: 链式调用对象可以调用 send 方法
"""
self.__to = {'open_id': open_id}
return _Send(open_lark=self.__open_lark, to=self.__to, root_id=self.__root_id)
def to_employee_id(self, employee_id):
"""发送到哪个 employee_id 的用户处
:param employee_id: employee_id
:return: 链式调用对象可以调用 send 方法
"""
self.__to = {'employee_id': employee_id}
return _Send(open_lark=self.__open_lark, to=self.__to, root_id=self.__root_id)
def to_open_chat_id(self, open_chat_id):
"""发送到哪个 open_chat_id 的用户处
:param open_chat_id: open_chat_id
:return: 链式调用对象可以调用 send 方法
"""
self.__to = {'open_chat_id': open_chat_id}
return _Send(open_lark=self.__open_lark, to=self.__to, root_id=self.__root_id)
class APIMessageMixin(object):
email = None
open_id = None
def batch_send_message(self,
department_ids=None,
open_ids=None,
employee_ids=None,
msg_type=MessageType.text,
content=None):
"""批量发送消息
:type self: OpenLark
:param department_ids: 部门 department_ids
:type department_ids: List[str]
:param open_ids: open_ids
:type open_ids: List[str]
:param employee_ids: employee_ids
:type employee_ids: List[str]
:param msg_type: 消息类型 MessageType
:type msg_type: MessageType
:param content: 消息内容请参考文档设置
:type content: Dict[str, Any]
:return: 消息 id和三个数组
:rtype: Tuple[str, List[str], List[str], List[str]]
给多个用户或者多个部门发送消息
https://open.feishu.cn/document/ukTMukTMukTM/ucDO1EjL3gTNx4yN4UTM
"""
url = self._gen_request_url('/open-apis/message/v3/batch_send/')
if department_ids is None:
department_ids = []
if open_ids is None:
open_ids = []
if employee_ids is None:
employee_ids = []
body = {
"department_ids": department_ids,
"open_ids": open_ids,
"employee_ids": employee_ids,
"msg_type": msg_type.value,
"content": content
}
res = self._post(url=url, body=body, with_tenant_token=True)
invalid_department_ids = res.get('invalid_department_ids', []) # type: List[str]
invalid_open_ids = res.get('invalid_open_ids', []) # type: List[str]
invalid_employee_ids = res.get('invalid_employee_ids', []) # type: List[str]
message_id = res.get('message_id', '') # type: str
return message_id, invalid_department_ids, invalid_open_ids, invalid_employee_ids
def send_raw_message(self,
open_id='',
root_id='',
open_chat_id='',
employee_id='',
email='',
msg_type=MessageType.text,
content=None,
content_key='content'):
"""发原始消息
:type self: OpenLark
:param open_id: open_id
:type open_id: str
:param root_id: 要回复的那条消息的 open_message_id
:type root_id: str
:param open_chat_id: 聊天的id回调中会返回
:type open_chat_id: str
:param employee_id: employee_id
:type employee_id: str
:param email: email
:type email: str
:param msg_type: 消息类型 MessageType
:type msg_type: MessageType
:param content: 消息内容请参考文档设置
:type content: Dict[str, Any]
:param content_key: 新版本的卡片消息需要设置为 card
:type content_key: str
:return: 发送的消息的 open_message_id
:rtype: str
https://open.feishu.cn/document/ukTMukTMukTM/uUjNz4SN2MjL1YzM
"""
return _send_all_message(self,
open_id=open_id,
root_id=root_id,
open_chat_id=open_chat_id,
employee_id=employee_id,
email=email,
msg_type=msg_type,
content=content,
content_key=content_key)
def reply(self, root_id):
"""
:type self: OpenLark
:param root_id:
:return:
:rtype _TO
"""
return _To(self, root_id=root_id)
def send(self):
"""创建发消息的调用链,返回链式对象
:type self: OpenLark
:rtype _To
"""
return _To(self)
def urgent_message(self,
open_message_id,
open_ids,
urgent_type=UrgentType.app):
"""消息加急
:type self: OpenLark
:param open_message_id: 消息 ID指定对某条消息进行加急 ID 在发送消息后获得
:type open_message_id: str
:param urgent_type: 加急类型目前支持应用内加急app短信加急sms电话加急phone加急权限需要申请
:type urgent_type: UrgentType
:param open_ids: 用户 open_id 列表指定该参数对指定用户进行消息加急
:type open_ids: List[str]
:return: 非法的用户 ID 列表
:rtype List[str]
对指定消息进行加急
https://lark-open.bytedance.net/document/ukTMukTMukTM/uYzM04iNzQjL2MDN
"""
url = self._gen_request_url('/open-apis/message/v3/urgent/')
body = {
'open_message_id': open_message_id,
'urgent_type': urgent_type.value,
'open_ids': open_ids
}
res = self._post(url, body=body, with_tenant_token=True)
invalid_open_ids = res.get('invalid_open_ids', []) # type: List[str]
return invalid_open_ids
def recall(self, open_message_id):
"""撤回消息
:type self: OpenLark
:param open_message_id: 需要撤回的消息id
:type open_message_id: str
撤回指定消息
https://open.feishu.cn/document/ukTMukTMukTM/ukjN1UjL5YTN14SO2UTN
"""
url = self._gen_request_url('/open-apis/message/v4/recall/')
body = {
'message_id': open_message_id,
}
self._post(url, body=body, with_tenant_token=True)
# TODO: 消息卡片安全校验

37
utils/feishu/api_mina.py Normal file
View File

@ -0,0 +1,37 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING
from utils.feishu.dt_code import MinaCodeToSessionResp
from utils.feishu.dt_help import make_datatype
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIMinaMixin(object):
def mina_code_2_session(self, code):
"""通过login接口获取到登录凭证后开发者可以通过服务器发送请求的方式获取 session_key 和 openId 等
:type self: OpenLark
:param code: 登录时获取的 code
:type code: str
:return: MinaCodeToSessionResp
:type: MinaCodeToSessionResp
:rtype MinaCodeToSessionResp
https://open.feishu.cn/document/ukTMukTMukTM/ukjM04SOyQjL5IDN
"""
hosts = {
1: 'https://mina.bytedance.com/openapi/tokenLoginValidate',
0: 'https://mina-staging.bytedance.net/openapi/tokenLoginValidate',
}
url = hosts[int(not self.is_staging)]
app_access_token = self.app_access_token
body = {'token': app_access_token, 'code': code}
res = self._post(url, body)
return make_datatype(MinaCodeToSessionResp, res)

102
utils/feishu/api_oauth.py Normal file
View File

@ -0,0 +1,102 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING
from utils.feishu.dt_code import OAuthCodeToSessionResp, User
from utils.feishu.dt_help import make_datatype
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIOAuthMixin(object):
def gen_oauth_url(self, state=''):
"""生成 OAuth 授权链接,请求身份验证
:type self: OpenLark
:param state: 用来维护请求和回调状态的附加字符串在授权完成回调时会附加此参数
应用可以根据此字符串来判断上下文关系
:return: 跳转链接
:rtype: str
应用请求用户身份验证时需按如下方式构造登录链接并跳转至此链接飞书客户端内用户免登系统浏览器内用户需完成扫码登录
特别需要注意的是 api 获取的 expire_in 是时间戳 api 获取的 expire_in 是秒数
https://open.feishu.cn/document/ukTMukTMukTM/ukzN4UjL5cDO14SO3gTN
"""
url = '/open-apis/authen/v1/index?redirect_uri={}&app_id={}&state={}'
return self._gen_request_url(url.format(self.oauth_redirect_uri, self.app_id, state))
def oauth_code_2_session(self, code):
"""获取登录用户身份
:type self: OpenLark
:param code: 扫码登录后会自动 302 redirect_uri 并带上此参数
:type code: str
:return: OAuthCodeToSessionResp
:rtype: OAuthCodeToSessionResp
Web 扫码后拿到的 code 换取用户信息通过此接口获取登录用户身份
特别需要注意的是 api 获取的 expire_in 是时间戳 api 获取的 expire_in 是秒数
https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN
"""
url = self._gen_request_url('/open-apis/authen/v1/access_token')
body = {
'app_access_token': self.app_access_token,
'grant_type': 'authorization_code',
'code': code,
}
res = self._post(url, body)
return make_datatype(OAuthCodeToSessionResp, res.get('data') or {})
def refresh_user_session(self, refresh_token):
"""刷新用户扫码登录后获取的 access_token
:type self: OpenLark
:param refresh_token: 扫码登录后会拿到这个值
:type refresh_token: str
:return: OAuthCodeToSessionResp
:rtype: OAuthCodeToSessionResp
刷新用户 token
特别需要注意的是 api 获取的 expire_in 是时间戳 api 获取的 expire_in 是秒数
https://open.feishu.cn/document/ukTMukTMukTM/uQDO4UjL0gDO14CN4gTN
"""
url = self._gen_request_url('/open-apis/authen/v1/refresh_access_token')
body = {
"app_access_token": self.app_access_token,
"grant_type": "refresh_token",
"refresh_token": refresh_token
}
res = self._post(url, body)
return make_datatype(OAuthCodeToSessionResp, res.get('data') or {})
def oauth_get_user(self, user_access_token):
"""获取用户信息
:type self: OpenLark
:param user_access_token:
此接口仅用于获取登录用户的信息 调用此接口需要在 Header 中带上 user_access_token
https://open.feishu.cn/document/ukTMukTMukTM/uIDO4UjLygDO14iM4gTN
"""
url = self._gen_request_url('/open-apis/authen/v1/user_info')
res = self._get(url, auth_token=user_access_token)
data = res['data']
data['avatar_url'] = data.get('avatar') or data.get('avatar_url')
data['user_id'] = data.get('user_id') or data.get('employee_id')
return make_datatype(User, data)

127
utils/feishu/api_pay.py Normal file
View File

@ -0,0 +1,127 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, Any, Dict, List, Tuple
from utils.feishu.dt_code import Chat, DetailChat
from utils.feishu.dt_help import make_datatype
from utils.feishu.dt_pay import PayOrder
from utils.feishu.exception import LarkInvalidArguments
from utils.feishu.helper import join_url
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
from six import string_types
class APIPayMixin(object):
def is_user_in_paid_scope(self, open_id=None, user_id=None):
"""查询用户是否在应用开通范围
:type self: OpenLark
:param open_id: 用户 open_idopen_id user_id 两个参数必须包含其一若同时传入取 open_id
:type open_id: str
:param user_id: 用户 user_iduser_id open_id 两个参数必须包含其一若同时传入取 open_id
:type user_id: str
:return status: 用户是否在开通范围中"valid" -该用户在开通范围中"not_in_scope"-该用户不在开通范围中
"no_active_license"-租户未购买任何价格方案或价格方案已过期
:return price_plan_id: 租户当前使用的价格方案ID对应开发者后台中价格方案配置中的价格方案
:return is_trial: 是否为试用版本true-是试用版本false-非试用版本
:return service_stop_time: 租户当前有生效价格方案时表示价格方案的到期时间为时间unix时间戳
:return: 状态, 付费方案, 是否是适用版本, 到期时间
:rtype: (str, str, bool, int)
该接口用于查询用户是否在企业管理员设置的使用该应用的范围中
如果设置的付费套餐是按人收费或者限制了最大人数开放平台会引导企业管理员设置付费功能开通范围
本接口用于查询用户是否在企业管理员设置的使用该应用的范围中可以通过此接口在付费功能点入口判断是否允许某个用户进入使用
https://open.feishu.cn/document/ukTMukTMukTM/uATNwUjLwUDM14CM1ATN
"""
url = self._gen_request_url('/open-apis/pay/v1/paid_scope/check_user?')
if open_id:
url = '{}open_id={}'.format(url, open_id)
if user_id:
url = '{}user_id={}'.format(url, user_id)
res = self._get(url, with_tenant_token=True)
data = res['data']
status = data.get('status')
price_plan_id = data.get('price_plan_id')
is_trial = data.get('is_trial')
service_stop_time = data.get('service_stop_time')
if service_stop_time:
try:
service_stop_time = int(service_stop_time)
except Exception:
pass
return status, price_plan_id, is_trial, service_stop_time
def get_pay_orders(self, status='all', page_size=20, page_token='', tenant_key=None):
"""查询租户购买的付费方案
:type self: OpenLark
:param status: 获取用户购买套餐信息设置的过滤条件normal为正常状态refund为已退款为空或者all表示所有未支付的订单无法查到
:type status: str
:param page_size: 每页显示的订单数量
:type page_size: str
:param page_token: 翻页标识可以从上次请求的响应中获取不填或者为空时表示从开头获取
:type page_token: str
:param tenant_key: 购买应用的租户唯一标识为空表示获取应用下所有订单有值表示获取应用下该租户购买的订单
:type tenant_key: str
查询应用租户下的付费订单
该接口用于分页查询应用租户下的已付费订单每次购买对应一个唯一的订单订单会记录购买的套餐的相关信息
业务方需要自行处理套餐的有效期和付费方案的升级
https://open.feishu.cn/document/ukTMukTMukTM/uETNwUjLxUDM14SM1ATN
"""
url = self._gen_request_url('/open-apis/pay/v1/order/list')
qs = [
('status', status),
('page_size', page_size),
('page_token', page_token),
('tenant_key', tenant_key)
]
url = join_url(url, qs, sep='?')
res = self._get(url, with_app_token=True)
data = res['data']
total = data.get('total')
has_more = data.get('has_more')
page_token = data.get('page_token')
orders = [make_datatype(PayOrder, i) for i in data.get('order_list', [])]
return has_more, page_token, total, orders
def get_pay_order_detail(self, order_id):
"""查询订单详情
:type self: OpenLark
:param order_id: 获取用户购买套餐信息设置的过滤条件normal为正常状态refund为已退款为空或者all表示所有未支付的订单无法查到
:type order_id: str
该接口用于查询某个订单的具体信息
https://open.feishu.cn/document/ukTMukTMukTM/uITNwUjLyUDM14iM1ATN
"""
url = self._gen_request_url('/open-apis/pay/v1/order/get?order_id={}'.format(order_id))
res = self._get(url, with_app_token=True)
data = res['data']
total = data.get('total')
has_more = data.get('has_more')
page_token = data.get('page_token')
orders = [make_datatype(PayOrder, i) for i in data.get('order_list', [])]
return has_more, page_token, total, orders

74
utils/feishu/api_user.py Normal file
View File

@ -0,0 +1,74 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import TYPE_CHECKING, List
from utils.feishu.dt_code import SimpleUser, User
from utils.feishu.dt_help import make_datatype
from utils.feishu.exception import LarkInvalidArguments
from utils.feishu.helper import pop_or_none
if TYPE_CHECKING:
from utils.feishu.api import OpenLark
class APIUserMixin(object):
def get_user(self, open_id='', user_id=''):
"""获取用户信息
:type self: OpenLark
:param open_id: 用户的 open_id
:type open_id: str
:param user_id: 用户的 user_id
:type user_id: str
:return: User 对象
:rtype: User
https://open.feishu.cn/document/ukTMukTMukTM/ukjMwUjL5IDM14SOyATN
"""
if open_id:
url = self._gen_request_url('/open-apis/user/v3/info?open_id={}'.format(open_id))
elif user_id:
url = self._gen_request_url('/open-apis/user/v3/info?employee_id={}'.format(user_id))
else:
raise LarkInvalidArguments(msg='[get_user] empty open_id and user_id')
res = self._get(url, with_tenant_token=True)
res['user_id'] = pop_or_none(res, 'employee_id')
return make_datatype(User, res)
def get_users_by_mobile_email(self, emails=None, mobiles=None):
"""获取用户信息
:type self: OpenLark
:param emails: 邮箱列表
:type emails: list[str]
:param mobiles: 手机列表
:type mobiles: list[str]
:return: 邮箱的用户字典邮箱不存在的列表手机的用户字典手机不存在的列表
:rtype: (dict[str, list[SimpleUser]], list[str], dict[str, list[SimpleUser]], list[str])
根据用户邮箱或手机号查询用户 open_id user_id支持批量查询
只能查询到应用可用性范围内的用户 ID
调用该接口需要具有 获取用户 ID 权限
https://open.feishu.cn/document/ukTMukTMukTM/uUzMyUjL1MjM14SNzITN
"""
qs = '&'.join(['mobiles=' + i for i in (mobiles or [])] + ['emails=' + i for i in (emails or [])])
url = self._gen_request_url('/open-apis/user/v1/batch_get_id?' + qs)
res = self._get(url, with_tenant_token=True)
data = res['data']
email_users = data.get('email_users', {})
email_users = {k: [make_datatype(SimpleUser, vv) for vv in v] for k, v in
email_users.items()} # type: dict[str, List[SimpleUser]]
emails_not_exist = data.get('emails_not_exist', []) # type: List[str]
mobile_users = data.get('mobile_users', {})
mobile_users = {k: [make_datatype(SimpleUser, vv) for vv in v] for k, v in
mobile_users.items()} # type: dict[str, List[SimpleUser]]
mobiles_not_exist = data.get('mobiles_not_exist', []) # type: List[str]
return email_users, emails_not_exist, mobile_users, mobiles_not_exist

View File

@ -0,0 +1,23 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import attr
from utils.feishu.dt_enum import I18NType
from utils.feishu.dt_help import int_convert_bool, to_json_decorator
@to_json_decorator
@attr.s
class App(object):
"""应用
"""
app_id = attr.ib(type=str, default='') # 应用ID
app_name = attr.ib(type=str, default='') # 应用名称
description = attr.ib(type=str, default='') # 应用描述
is_isv = attr.ib(type=bool, default=False, metadata={'json': 'app_scene_type'},
converter=int_convert_bool) # 是否是ISV
avatar_url = attr.ib(type=str, default='') # 应用Icon
primary_language = attr.ib(type=I18NType, default=I18NType.zh_cn) # 应用首选语言
status = attr.ib(type=int, default=0) # 是否是启用

119
utils/feishu/dt_approval.py Normal file
View File

@ -0,0 +1,119 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import Any, List
import attr
from utils.feishu.dt_enum import ApprovalInstanceStatus, ApprovalTaskStatus, ApprovalTaskTypeStatus, ApprovalTimelineType
from utils.feishu.dt_help import to_json_decorator
@to_json_decorator
@attr.s
class ApprovalNode(object):
"""审批节点对象
"""
name = attr.ib(type=str, default='') # 节点名称
need_approver = attr.ib(type=bool, default=None) # 是否发起人自选节点true - 发起审批时需要提交审批人
node_id = attr.ib(type=str, default=None) # 节点 ID
custom_id = attr.ib(type=str, default=None) # 节点自定义 ID如果没有设置则不返回
node_type = attr.ib(type=str, default=None) # 审批方式AND -会签OR - 或签
@to_json_decorator
@attr.s
class ApprovalForm(object):
"""审批表单字段对象
"""
id = attr.ib(type=str, default=None) # 控件 ID
custom_id = attr.ib(type=str, default=None) # 控件自定义 ID
type = attr.ib(type=str, default=None) # 控件类型
name = attr.ib(type=str, default=None) # 控件名称
# 获取审批定义的时候不会返回,获取实例的时候会返回
value = attr.ib(type=Any, default=None) # 表单值
@to_json_decorator
@attr.s
class ApprovalDefinition(object):
"""审批定义对象
"""
approval_name = attr.ib(type=str, default=None)
forms = attr.ib(type=List[ApprovalForm], default=attr.Factory(list)) # type: List[ApprovalForm]
nodes = attr.ib(type=List[ApprovalNode], default=attr.Factory(list)) # type: List[ApprovalNode]
@to_json_decorator
@attr.s
class ApprovalTask(object):
"""审批任务对象
"""
id = attr.ib(type=str, default=None) # 任务 ID
user_id = attr.ib(type=str, default=None) # 审批人,自动通过、自动拒绝 task user_id 为空
status = attr.ib(type=ApprovalTaskStatus, default=None) # 任务状态
node_id = attr.ib(type=str, default=None) # task 所属节点 id
custom_node_id = attr.ib(type=str, default=None) # task 所属节点自定义 id, 如果没设置自定义 id, 则不返回该字段
type = attr.ib(type=ApprovalTaskTypeStatus, default=None) # 任务类型
start_time = attr.ib(type=int, default=None) # 创建时间
end_time = attr.ib(type=int, default=0) # 结束时间,未完成为 0
@to_json_decorator
@attr.s
class ApprovalComment(object):
"""审批中的评论对象
"""
id = attr.ib(type=str, default=None) # 评论 ID
user_id = attr.ib(type=str, default=None) # 发表评论用户
comment = attr.ib(type=str, default=None) # 评论详情
create_time = attr.ib(type=int, default=None) # 创建时间
@to_json_decorator
@attr.s
class ApprovalTimeline(object):
"""审批动态
"""
type = attr.ib(type=ApprovalTimelineType, default=None) # 发生时间
create_time = attr.ib(type=int, default=0) # 发生时间
user_id = attr.ib(type=str, default=None) # 动态产生用户
# 被抄送人列表
user_id_list = attr.ib(type=List[str], default=None) # type: List[str]
task_id = attr.ib(type=str, default='') # 产生动态关联的task_id
comment = attr.ib(type=str, default='') # 评论详情
# type类型 - user_id_list 含义
# TRANSFER - 被转交人
# ADD_APPROVER_BEFORE - 被加签人
# ADD_APPROVER - 被加签人
# ADD_APPROVER_AFTER - 被加签人
# DELETE_APPROVER - 被减签人
# type类型 - user_id 含义
# CC - 抄送人
ext = attr.ib(type=dict, default=None) # 动态其他信息,目前包括 user_id_list, user_id
@to_json_decorator
@attr.s
class ApprovalInstance(object):
"""审批实例对象
"""
approval_code = attr.ib(type=str, default='') # 审批定义唯一标识
approval_name = attr.ib(type=str, default='') # 审批定义名称
start_time = attr.ib(type=int, default=0) # 创建时间(毫秒)
end_time = attr.ib(type=int, default=0) # 结束时间毫秒未结束为0
user_id = attr.ib(type=str, default='') # 用户ID
department_id = attr.ib(type=str, default='') # 部门ID
status = attr.ib(type=ApprovalInstanceStatus, default=None) # 实例状态
form = attr.ib(type=List[ApprovalForm], default=attr.Factory(list)) # type: List[ApprovalForm]
# 任务列表
task_list = attr.ib(type=List[ApprovalTask], default=attr.Factory(list)) # type: List[ApprovalTask]
# 评论列表
comment_list = attr.ib(type=List[ApprovalComment], default=attr.Factory(list)) # type: List[ApprovalComment]
# 审批动态
timeline = attr.ib(type=List[ApprovalTimeline], default=attr.Factory(list)) # type: List[ApprovalTimeline]
serial_number = attr.ib(type=str, default='') # 审批编号

View File

@ -0,0 +1,49 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import List
import attr
from utils.feishu.dt_enum import CalendarEventVisibility, CalendarRole
from utils.feishu.dt_help import to_json_decorator
@to_json_decorator
@attr.s
class Calendar(object):
"""日历对象
"""
id = attr.ib(type=str, default=None)
summary = attr.ib(type=str, default=None)
description = attr.ib(type=str, default=None)
default_access_role = attr.ib(type=CalendarRole, default=None)
is_private = attr.ib(type=bool, default=None)
# ----- 日历日程
@to_json_decorator
@attr.s
class CalendarAttendee(object):
"""日历的参与人对象
"""
open_id = attr.ib(type=str, default='')
employee_id = attr.ib(type=str, default='')
optional = attr.ib(type=bool, default=None)
display_name = attr.ib(type=str, default='')
@attr.s
class CalendarEvent(object):
"""日历的日程对象
"""
id = attr.ib(type=str, default=None)
description = attr.ib(type=str, default=None)
start = attr.ib(type=int, default=None)
end = attr.ib(type=int, default=None)
visibility = attr.ib(type=CalendarEventVisibility, default=CalendarEventVisibility.default)
summary = attr.ib(type=str, default='')
attendees = attr.ib(type=List[CalendarAttendee], default=attr.Factory(list))

344
utils/feishu/dt_callback.py Normal file
View File

@ -0,0 +1,344 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import List
import attr
from utils.feishu.dt_code import SimpleUser
from utils.feishu.dt_help import to_json_decorator
@to_json_decorator
@attr.s
class EventMessageMergeForward(object):
root_id = attr.ib(type=str, default=None)
parent_id = attr.ib(type=str, default=None)
open_chat_id = attr.ib(type=str, default=None)
msg_type = attr.ib(type=str, default=None)
open_id = attr.ib(type=str, default=None)
open_message_id = attr.ib(type=str, default=None)
is_mention = attr.ib(type=bool, default=None)
image_key = attr.ib(type=str, default=None)
image_url = attr.ib(type=str, default=None)
create_time = attr.ib(type=int, default=None)
@to_json_decorator
@attr.s
class EventMessage(object):
"""消息事件
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None) # 租户 key
type = attr.ib(type=str, default=None)
# 消息事件公共
root_id = attr.ib(type=str, default=None) # 回复的祖先消息id
parent_id = attr.ib(type=str, default=None) # 回复的消息id
open_chat_id = attr.ib(type=str, default=None) # 本次消息所发生的对话,注意:私聊也有这个
chat_type = attr.ib(type=str, default=None) # 私聊private群聊group
msg_type = attr.ib(type=str, default=None) # 消息类型
open_id = attr.ib(type=str, default=None)
open_message_id = attr.ib(type=str, default=None) # 本校消息的 id
is_mention = attr.ib(type=bool, default=None)
# 文本消息、富文本消息含有的
text = attr.ib(type=str, default='') # 文本text 和 post
text_without_at_bot = attr.ib(type=str, default='') # 消息内容会过滤掉at你的机器人的内容text 和 post
# 富文本消息
image_keys = attr.ib(type=List[str], default=attr.Factory(list)) # image_key的列表post
title = attr.ib(type=str, default='') # 标题post
# 图片消息独有的
image_height = attr.ib(type=str, default='') # 图片高度image
image_width = attr.ib(type=str, default='') # 图片宽度image
image_url = attr.ib(type=str, default='') # 图片的urlimage
image_key = attr.ib(type=str, default='') # 图片的keyimage
# 合并转发消息,(日历卡片、投票消息、会话记录等不支持合并转发)
msg_list = attr.ib(type=List[EventMessageMergeForward], default=attr.Factory(list)) # 合并转发消息的每个消息体
@to_json_decorator
@attr.s
class EventApproval(object):
""""审批通过
订阅审批定义后该定义产生的审批实例在结束时会推送事件消息
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
definition_code = attr.ib(type=str, default=None) # 审批定义Code
definition_name = attr.ib(type=str, default=None) # 审批定义名称
instance_code = attr.ib(type=str, default=None) # 审批实例Code
start_time = attr.ib(type=int, default=None) # 审批发起时间10位秒级
end_time = attr.ib(type=int, default=None) # 审批结束时间10位秒级
event = attr.ib(type=str, default=None) # 审批结果 approve:通过 reject:拒绝 cancel:取消
@to_json_decorator
@attr.s
class EventLeaveApproval(object):
""""请假审批
请假审批通过后 开放平台推送 leave_approval 事件到请求网址
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
instance_code = attr.ib(type=str, default=None) # 审批实例Code
employee_id = attr.ib(type=str, default=None) # 用户id
start_time = attr.ib(type=int, default=None) # 审批发起时间10位秒级
end_time = attr.ib(type=int, default=None) # 审批结束时间10位秒级
leave_type = attr.ib(type=str, default=None) # 请假类型
leave_unit = attr.ib(type=int, default=None) # 请假单位1半天2一天
leave_start_time = attr.ib(type=str, default=None) # 请假开始时间 "2018-12-01 12:00:00"
leave_end_time = attr.ib(type=str, default=None) # 请假结束时间
leave_interval = attr.ib(type=int, default=None) # 请假时长,单位(秒)
leave_reason = attr.ib(type=str, default=None) # 请假事由
@to_json_decorator
@attr.s
class EventWorkApproval(object):
"""加班审批
加班审批通过后推送消息开放平台推送 work_approval 事件到请求网址
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
instance_code = attr.ib(type=str, default=None) # 审批实例Code
employee_id = attr.ib(type=str, default=None) # 用户id
start_time = attr.ib(type=int, default=None) # 审批发起时间10位秒级
end_time = attr.ib(type=int, default=None) # 审批结束时间10位秒级
work_type = attr.ib(type=str, default=None) # 加班类型
work_start_time = attr.ib(type=str, default=None) # 加班开始时间 2018-12-01 12:00:00
work_end_time = attr.ib(type=str, default=None) # 加班结束时间
work_interval = attr.ib(type=int, default=None) # 加班时长,单位(秒)
work_reason = attr.ib(type=str, default=None) # 加班事由
@to_json_decorator
@attr.s
class EventShiftApproval(object):
"""换班审批
换班审批通过后推送消息开放平台推送 shift_approval 事件到请求网址
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
instance_code = attr.ib(type=str, default=None) # 审批实例Code
employee_id = attr.ib(type=str, default=None) # 用户id
start_time = attr.ib(type=int, default=None) # 审批发起时间10位秒级
end_time = attr.ib(type=int, default=None) # 审批结束时间10位秒级
shift_time = attr.ib(type=str, default=None) # 换班时间 2018-12-01 12:00:00
return_time = attr.ib(type=str, default=None) # 还班时间 2018-12-01 12:00:00
shift_reason = attr.ib(type=str, default=None) # 换班事由
@to_json_decorator
@attr.s
class EventRemedyApproval(object):
"""补卡审批
补卡审批通过后 开放平台推送 remedy_approval 事件到请求网址
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
instance_code = attr.ib(type=str, default=None) # 审批实例Code
employee_id = attr.ib(type=str, default=None) # 用户id
start_time = attr.ib(type=int, default=None) # 审批发起时间
end_time = attr.ib(type=int, default=None) # 审批结束时间
remedy_time = attr.ib(type=str, default=None) # 补卡时间 2018-12-01 12:00:00
remedy_reason = attr.ib(type=str, default=None) # 补卡原因
@to_json_decorator
@attr.s
class EventTripApprovalSchedule(object):
trip_start_time = attr.ib(type=str, default=None) # 行程开始时间,"2018-12-01 12:00:00"
trip_end_time = attr.ib(type=str, default=None) # 行程结束时间,"2018-12-01 12:00:00"
trip_interval = attr.ib(type=int, default=None) # 行程时长(秒)
departure = attr.ib(type=str, default=None) # 出发地
destination = attr.ib(type=str, default=None) # 目的地
transportation = attr.ib(type=str, default=None) # 目的地
trip_type = attr.ib(type=str, default=None) # 单程/往返
remark = attr.ib(type=str, default=None) # 备注
@to_json_decorator
@attr.s
class EventTripApproval(object):
"""出差审批
出差审批通过后推送消息开放平台推送 trip_approval 事件到请求网址
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
instance_code = attr.ib(type=str, default=None) # 审批实例Code
employee_id = attr.ib(type=str, default=None) # 用户id
start_time = attr.ib(type=int, default=None) # 审批发起时间
end_time = attr.ib(type=int, default=None) # 审批结束时间
trip_interval = attr.ib(type=int, default=None) # 行程总时长(秒)
trip_reason = attr.ib(type=str, default=None) # 出差事由
trip_peers = attr.ib(type=List[str], default=attr.Factory(list)) # 同行人
schedules = attr.ib(type=List[EventTripApprovalSchedule], default=attr.Factory(list))
@to_json_decorator
@attr.s
class EventAppOpenUser(object):
open_id = attr.ib(type=str, default=None) # 申请者的open_id
user_id = attr.ib(type=str, default=None) # 申请者的user_id
@to_json_decorator
@attr.s
class EventAppOpen(object):
"""开通应用
当企业管理员在管理员后台开通应用时开放平台推送 app_open 事件到请求网址
"""
app_id = attr.ib(type=str, default=None) # 应用ID
tenant_key = attr.ib(type=str, default=None) # 企业ID
type = attr.ib(type=str, default=None) # 事件类型
applicants = attr.ib(type=List[EventAppOpenUser], default=attr.Factory(list)) # 申请者的open_id
installer = attr.ib(type=EventAppOpenUser, default=None) # 申请者的user_id
@to_json_decorator
@attr.s
class EventContactUser(object):
"""通讯录变更
应用申请通讯录读权限平台会自动给相应应用订阅通讯录变更事件
通讯录用户相关变更事件包括 user_add, user_update user_leave 事件类型
"""
type = attr.ib(type=str, default=None) # 事件类型,包括 user_add, user_update, user_leave
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None) # 企业标识
open_id = attr.ib(type=str, default=None)
employee_id = attr.ib(type=str, default='') # 企业自建应用返回
@to_json_decorator
@attr.s
class EventContactDepartment(object):
"""通讯录变更
应用申请通讯录读权限平台会自动给相应应用订阅通讯录变更事件
通讯录部门相关变更事件包括 dept_add, dept_update dept_delete
"""
type = attr.ib(type=str, default=None) # 事件类型,包括 dept_add,dept_update,dept_delete
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None) # 企业标识
open_department_id = attr.ib(type=str, default=None) # 部门的Id
@to_json_decorator
@attr.s
class EventContactScope(object):
"""当企业管理员在企业管理后台变更权限范围时,开放平台通知 contact_scope_change 到请求网址
"""
type = attr.ib(type=str, default=None) # 事件类型
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None) # 企业标识
@to_json_decorator
@attr.s
class EventRemoveAddBotI18NTitle(object):
en_us = attr.ib(type=str, default=None)
zh_cn = attr.ib(type=str, default=None)
@to_json_decorator
@attr.s
class EventAppTicket(object):
"""app_ticket 事件
对于应用商店应用开放平台会定时发送 app_ticket 事件到请求网址应用通过该 app_ticket 获取 app_access_token
"""
app_id = attr.ib(type=str, default=None)
app_ticket = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
@to_json_decorator
@attr.s
class EventRemoveAddBot(object):
"""机器人被移出群聊/机器人被邀请进入群聊
机器人被邀请进入群聊时/被从群聊中移除时平台推送 add_bot/remove_bot 通知事件到请求网址
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
chat_name = attr.ib(type=str, default=None) # 群名称
chat_owner_employee_id = attr.ib(type=str, default=None) # 群主的employee_id如果群主是机器人则没有这个字段
chat_owner_name = attr.ib(type=str, default=None) # 群主名称
chat_owner_open_id = attr.ib(type=str, default=None) # 群主的open_id
open_chat_id = attr.ib(type=str, default=None)
operator_employee_id = attr.ib(type=str, default=None) # 操作者的emplolyee_id
operator_name = attr.ib(type=str, default=None) # 操作者姓名
operator_open_id = attr.ib(type=str, default=None) # 操作者的open_id
owner_is_bot = attr.ib(type=bool, default=False) # 群主是否是机器人
chat_i18n_names = attr.ib(type=EventRemoveAddBotI18NTitle, default=None) # 群名称国际化字段
@to_json_decorator
@attr.s
class EventP2PCreateChatUser(object):
open_id = attr.ib(type=str, default=None)
user_id = attr.ib(type=str, default=None)
name = attr.ib(type=str, default=None)
@to_json_decorator
@attr.s
class EventP2PCreateChat(object):
"""会话第一次创建的事件
机器人和用户的会话第一次创建的时候会发送通知
"""
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
chat_id = attr.ib(type=str, default=None) # 机器人和用户的会话id
# 如果是机器人发起的operator里面是机器人的open_id。如果是用户发起operator里面是用户的open_id和user_id
operator = attr.ib(type=EventP2PCreateChatUser, default=None)
user = attr.ib(type=EventP2PCreateChatUser, default=None)
@to_json_decorator
@attr.s
class EventUserInAndOutChat(object):
"""用户进出群聊
"""
# ISV 应用没有 “user_id” 字段
app_id = attr.ib(type=str, default=None)
tenant_key = attr.ib(type=str, default=None)
# 用户进群 add_user_to_chat"
# 用户出群 remove_user_from_chat
# 撤销加人 revoke_add_user_from_chat
type = attr.ib(type=str, default=None)
chat_id = attr.ib(type=str, default=None)
# 用户主动退群的话operator 就是user自己
operator = attr.ib(type=SimpleUser, default=None)
users = attr.ib(type=List[SimpleUser], default=attr.Factory(list))

118
utils/feishu/dt_code.py Normal file
View File

@ -0,0 +1,118 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import List
import attr
from utils.feishu.dt_help import to_json_decorator
@to_json_decorator
@attr.s
class I18NTitle(object):
en_us = attr.ib(type=str, default=None)
zh_cn = attr.ib(type=str, default=None)
ja_jp = attr.ib(type=str, default=None)
@to_json_decorator
@attr.s
class SimpleUser(object):
"""用户对象
"""
name = attr.ib(type=str, default=None)
open_id = attr.ib(type=str, default=None)
user_id = attr.ib(type=str, default=None)
@to_json_decorator
@attr.s
class User(object):
"""用户对象
"""
avatar_url = attr.ib(type=str, default='')
name = attr.ib(type=str, default='')
open_id = attr.ib(type=str, default='')
email = attr.ib(type=str, default='') # 需要申请获取email权限才有
mobile = attr.ib(type=str, default='') # 用户手机号,已申请"获取用户手机号"权限的企业自建应用返回该字段
user_id = attr.ib(type=str, default='') # 用户的user_id
status = attr.ib(type=int, default=0)
@to_json_decorator
@attr.s
class Bot(object):
"""机器人对象
"""
activate_status = attr.ib(type=int, default=None) # TODO 啥意思
app_name = attr.ib(type=str, default=None)
avatar_url = attr.ib(type=str, default=None)
open_id = attr.ib(type=str, default='') # 啥意思,没有在文档中
ip_white_list = attr.ib(type=List[str], default=attr.Factory(list)) # type: List[str]
@to_json_decorator
@attr.s
class Chat(object):
"""会话信息,包括和机器人的私聊 + 群聊
"""
avatar = attr.ib(type=str, default='') # 群头像
description = attr.ib(type=str, default='') # 群描述
chat_id = attr.ib(type=str, default=None)
name = attr.ib(type=str, default='') # 群聊的名字如果是p2p就是空
owner_user_id = attr.ib(type=str, default='') # 群主的user_id机器人是群主的时候没有这个字段
owner_open_id = attr.ib(type=str, default='') # 群主的open_id机器人是群主的时候没有这个字段
@to_json_decorator
@attr.s
class DetailChat(object):
"""详细的会话信息,包括和机器人的私聊 + 群聊
"""
chat_id = attr.ib(type=str, default=None)
avatar = attr.ib(type=str, default='') # 群头像
description = attr.ib(type=str, default='') # 群描述
name = attr.ib(type=str, default='') # 群聊的名字如果是p2p就是空
i18n_names = attr.ib(type=I18NTitle, default=None) # 国际化名称
members = attr.ib(type=List[SimpleUser], default=attr.Factory(list)) # type: List[SimpleUser]
type = attr.ib(type=str, default=None) # 群类型group表示群聊p2p表示单聊
owner_user_id = attr.ib(type=str, default='') # 群主的user_id机器人是群主的时候没有这个字段
owner_open_id = attr.ib(type=str, default='') # 群主的open_id机器人是群主的时候没有这个字段
@to_json_decorator
@attr.s
class MinaCodeToSessionResp(object):
"""小程序 code 换取 session 对象
"""
open_id = attr.ib(type=str, default=None) # 用户唯一标识,openid 用于在同一个应用中对用户进行标识,用户和应用可以确定一个唯一的 openid
union_id = attr.ib(type=str, default=None) # 用户在同一个开发者所属的多个应用中唯一标识,一个用户在同一个开发者所属的多个应用中unionid 唯一
session_key = attr.ib(type=str, default=None) # 会话密钥
tenant_key = attr.ib(type=str, default=None) # 用户所在租户唯一标识
employee_id = attr.ib(type=str, default='') # 用户在同一个租户下的唯一标识(可选)
token_type = attr.ib(type=str, default='') # 此处为Bearer
access_token = attr.ib(type=str, default='') # user_access_token用于获取用户资源
expires_in = attr.ib(type=int, default=0) # user_access_token过期时间
refresh_token = attr.ib(type=str, default='') # 刷新用户 access_token 时使用的 token
@to_json_decorator
@attr.s
class OAuthCodeToSessionResp(object):
"""获取登录用户身份OAuth code 换取 session 对象
"""
access_token = attr.ib(type=str, default=None) # user_access_token用于获取用户资源
avatar_url = attr.ib(type=str, default=None) # 用户头像
avatar_thumb = attr.ib(type=str, default=None) # 用户头像 72x72
avatar_middle = attr.ib(type=str, default=None) # 用户头像 240x240
avatar_big = attr.ib(type=str, default=None) # 用户头像 640x640
expires_in = attr.ib(type=int, default=None) # access_token 的有效期,单位: 秒
name = attr.ib(type=str, default=None) # 用户姓名
en_name = attr.ib(type=str, default=None) # 用户英文姓名
open_id = attr.ib(type=str, default=None) # 用户在应用内的唯一标识
tenant_key = attr.ib(type=str, default=None) # 当前企业标识
refresh_token = attr.ib(type=str, default=None) # 刷新用户 access_token 时使用的 token
refresh_expires_in = attr.ib(type=int, default=None) # refresh_token过期时间秒数
token_type = attr.ib(type=str, default=None) # 此处为 Bearer

201
utils/feishu/dt_contact.py Normal file
View File

@ -0,0 +1,201 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from enum import Enum
from typing import List
import attr
from utils.feishu.dt_code import I18NTitle, SimpleUser
from utils.feishu.dt_help import to_json_decorator, to_lower_converter
@to_json_decorator
@attr.s
class SimpleDepartment(object):
"""部门对象
"""
id = attr.ib(type=str, default='')
name = attr.ib(type=str, default='')
parent_id = attr.ib(type=str, default='')
@to_json_decorator
@attr.s
class DepartmentUnit(object):
# 部门对应单元
id = attr.ib(type=str, default='') # 单元ID
unit_type = attr.ib(type=str, default='') # 单元类型
unit_name = attr.ib(type=str, default='') # 单元名称
@to_json_decorator
@attr.s
class Department(object):
chat_id = attr.ib(type=str, default='') # 部门群ID
dept_units = attr.ib(type=list, default=attr.Factory(list)) # type: List[DepartmentUnit]
has_child = attr.ib(type=bool, default=False) # 是否有子部门
id = attr.ib(type=str, default='') # 部门ID
leader = attr.ib(type=SimpleUser, default=None) # 部门负责人信息
member_count = attr.ib(type=int, default=0) # 部门成员数量
name = attr.ib(type=str, default='') # 部门名称
parent_id = attr.ib(type=str, default='') # 父部门 ID
status = attr.ib(type=int, default=0) # 部门状态0 无效1 有效
@to_json_decorator
@attr.s
class DepartmentUserStatus(object):
is_frozen = attr.ib(type=bool, default=False) # 用户是否被冻结
is_resigned = attr.ib(type=bool, default=False) # 用户是否离职
is_activated = attr.ib(type=bool, default=False) # 用户账号是否已激活
@to_json_decorator
@attr.s
class DepartmentUserAvatar(object):
avatar_72 = attr.ib(type=str, default='')
avatar_240 = attr.ib(type=str, default='')
avatar_640 = attr.ib(type=str, default='')
avatar_origin = attr.ib(type=str, default='')
class EmployeeType(Enum):
full_time = 1 # 正式员工
internship = 2 # 实习生
outsourcing = 3 # 外包
labor = 4 # 劳务
consultant = 5 # 顾问
class Gender(Enum):
# 性别未设置不返回该字段。1:男2:女
man = 1
woman = 2
@to_json_decorator
@attr.s
class SimpleUserWithPosition(SimpleUser):
position_code = attr.ib(type=str, default='') # 岗位标识
@to_json_decorator
@attr.s
class DepartmentUserPosition(object):
position_code = attr.ib(type=str, default='') # 岗位标识
position_name = attr.ib(type=str, default='') # 岗位名称
department_id = attr.ib(type=str, default='') # 岗位对应的部门ID必须是用户所属的部门中的一个
is_major = attr.ib(type=bool, default=False) # 是否为主岗位,每个用户只有一个主岗位
leader = attr.ib(type=SimpleUserWithPosition, default=None) # 对应岗位上的直接上级
@to_json_decorator
@attr.s
class DepartmentUserOrder(object):
"""用户的所有部门排序信息,每个部门都有独立的排序信息"""
department_id = attr.ib(type=str, default='') # 排序信息对应的部门 ID
user_order = attr.ib(type=int, default=0) # 当前用户在对应部门中所有用户间的排序序号
department_order = attr.ib(type=int, default=0) # 对应部门在当前用户所有部门间的排序序号
@to_json_decorator
@attr.s
class DepartmentUserCustomAttrValue(object):
text = attr.ib(type=str, default='') # 文字属性的值属性类型为text时返回此字段
url = attr.ib(type=str, default='') # URL 属性的值,属性类型为 href 时,返回此字段
pc_url = attr.ib(type=str, default='') # URL 属性的 PC 端 URL 值,属性类型为 href 时,返回此字段
@to_json_decorator
@attr.s
class DepartmentUserCustomAttr(object):
"""用户的自定义属性信息。企业开放了自定义用户属性且为该用户设置了自定义属性的值,才会返回该字段"""
id = attr.ib(type=str, default='') # 排序信息对应的部门ID
type = attr.ib(type=str, default='', converter=to_lower_converter) # 属性类型目前有text和href
value = attr.ib(type=DepartmentUserCustomAttrValue, default=None) # 属性值
i18n_names = attr.ib(type=I18NTitle, default=None) # 国际化属性名称
@to_json_decorator
@attr.s
class DepartmentUser(object):
name = attr.ib(type=str, default='') # 用户名
en_name = attr.ib(type=str, default='') # 英文名
user_id = attr.ib(type=str, default='') # user_id应用商店应用不返回
employee_no = attr.ib(type=str, default='') # 工号
open_id = attr.ib(type=str, default='') # open_id
status = attr.ib(type=DepartmentUserStatus, default=None) # 用户状态
employee_type = attr.ib(type=EmployeeType, default=None) # 员工类型。1:正式员工2:实习生3:外包4:劳务5:顾问
avatar = attr.ib(type=DepartmentUserAvatar, default=None) # 头像
gender = attr.ib(type=Gender, default=None) # 性别未设置不返回该字段。1:男2:女
email = attr.ib(type=str, default='') # 用户邮箱地址,已申请邮箱权限才返回该字段
mobile = attr.ib(type=str, default='') # 用户手机号,已申请"获取用户手机号"权限的企业自建应用返回该字段
country = attr.ib(type=str, default='') # 用户所在国家
city = attr.ib(type=str, default='') # 用户所在城市
work_station = attr.ib(type=str, default='') # 工位
is_tenant_manager = attr.ib(type=bool, default=False) # 是否是企业超级管理员
join_time = attr.ib(type=int, default=0) # 入职时间,未设置不返回该字段
update_time = attr.ib(type=int, default=0) # 更新时间
leader = attr.ib(type=SimpleUser, default=None) # 用户直接上级
# 用户所在部门 ID用户可能同时存在于多个部门
departments = attr.ib(type=List[str], default=attr.Factory(list)) # type: List[str]
# 用户岗位
positions = attr.ib(type=List[DepartmentUserPosition],
default=attr.Factory(list)) # type: List[DepartmentUserPosition]
# 用户的所有部门排序信息,每个部门都有独立的排序信息
orders = attr.ib(type=List[DepartmentUserOrder], default=attr.Factory(list)) # type: List[DepartmentUserOrder]
# 用户的自定义属性信息。企业开放了自定义用户属性且为该用户设置了自定义属性的值,才会返回该字段
custom_attrs = attr.ib(type=List[DepartmentUserCustomAttr],
default=attr.Factory(list)) # type: List[DepartmentUserCustomAttr]
@to_json_decorator
@attr.s
class ContactAsyncChildTaskInfo(object):
# 以下字段适用于在用户操作时
code = attr.ib(type=int, default=0) # 子任务返回码,非 0 表示失败
msg = attr.ib(type=str, default='') # 子任务返回码的描述
action = attr.ib(type=int, default=0) # 子任务进行的操作1:添加2:更新,执行失败时没有此字段
name = attr.ib(type=str, default='') # 子任务请求名称,用户操作时为用户名,部门操作时为部门名
email = attr.ib(type=str, default='') # 请求时的用户邮箱地址
mobile = attr.ib(type=str, default='') # 请求时的用户手机号
user_id = attr.ib(type=str, default='') # 请求时的用户企业内唯一标识或自动生成的唯一标识
open_id = attr.ib(type=str, default='') # 生成的用户open_id执行失败时没有此字段
# 请求时的用户所在部门
departments = attr.ib(type=List[str], default=attr.Factory(list)) # type: List[str]
# 以下字段适用于在部门操作时
department_id = attr.ib(type=str, default='') # 请求时的自定义部门 ID 或生成的部门 ID
parent_id = attr.ib(type=str, default='') # 请求时的父部门 ID
chat_id = attr.ib(type=str, default='') # 部门群 ID如果存在部门群则返回该字段
@to_json_decorator
@attr.s
class ContactAsyncTaskResult(object):
task_id = attr.ib(type=str, default='') # 异步任务 ID
type = attr.ib(type=str, default='') # 任务类型目前有两种添加用户为add_user添加部门为 add_department
# 任务当前执行状态小于9:正在执行过程中9:执行完成10:执行失败11:超出当前人数限制无法执行
status = attr.ib(type=int, default=0)
progress = attr.ib(type=int, default=0) # 任务进度百分比
total_num = attr.ib(type=int, default=0) # 任务总条数
success_num = attr.ib(type=int, default=0) # 任务当前执行成功的条数
fail_num = attr.ib(type=int, default=0) # 任务当前执行失败的条数
create_time = attr.ib(type=int, default=0) # 任务创建时间以秒为单位的Unix时间戳
finish_time = attr.ib(type=int, default=0) # 任务完成时间以秒为单位的Unix时间戳当任务状态小于8时没有此字段
# 任务执行结果列表,当任务状态不为 9 时没有此字段,执行结果和添加任务时的请求体按顺序对应
task_info = attr.ib(type=List[ContactAsyncChildTaskInfo],
default=attr.Factory(list)) # type: List[ContactAsyncChildTaskInfo]
@to_json_decorator
@attr.s
class Role(object):
id = attr.ib(type=str, default='') # 角色 ID
name = attr.ib(type=str, default='') # 角色名称

520
utils/feishu/dt_drive.py Normal file
View File

@ -0,0 +1,520 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from enum import Enum
from typing import Any, List
import attr
from six import string_types
from utils.feishu.dt_help import to_json_decorator
from utils.feishu.exception import LarkInvalidArguments
def join_range(sheet_id, range):
if not range or not isinstance(range, string_types):
raise LarkInvalidArguments(msg='empty range')
for i in [sheet_id, sheet_id + '!']:
if range.startswith(i):
range = range[len(i):]
return sheet_id + '!' + range
# 文档类型,支持:"doc", "sheet", "slide", "bitable", "mindnote", "file", "wiki"
class DriveFileType(Enum):
doc = 'doc' # doc
sheet = 'sheet' # sheet
bitable = 'bitable' # bitable
folder = 'folder' # folder
slide = 'slide'
mindnote = 'mindnote'
file = 'file'
wiki = 'wiki'
class DriveDeleteFlag(Enum):
# 删除标志0表示正常访问未删除1表示在回收站2表示已经彻底删除
normal = 0
in_recycle = 1
complete_deletion = 2
@to_json_decorator
@attr.s
class DriveSheetCellURL(object):
"""有文本的url
"""
title = attr.ib(type=str, default='')
url = attr.ib(type=str, default='')
def as_sheet_dict(self):
return {'text': self.title, 'link': self.url, 'type': 'url'}
@to_json_decorator
@attr.s
class DriveSheetCellAt(object):
"""@人名
个人邮箱只支持同租户@notify 为是否发送 Lark 消息
"""
email = attr.ib(type=str, default='')
notify = attr.ib(type=bool, default=False)
def as_sheet_dict(self):
return {'type': 'mention', 'text': self.email, 'notify': self.notify}
@to_json_decorator
@attr.s
class DriveFileToken(object):
"""表示一个文件, token + type
"""
token = attr.ib(type=str, default='')
type = attr.ib(type=DriveFileType, default=None)
name = attr.ib(type=str, default='')
@to_json_decorator
@attr.s
class DriveFolderMeta(object):
"""文件夹元信息
"""
id = attr.ib(type=str, default='')
name = attr.ib(type=str, default='', metadata={'json': 'name'})
token = attr.ib(type=str, default='')
create_uid = attr.ib(type=str, default='', metadata={'json': 'createUid'})
edit_uid = attr.ib(type=str, default='', metadata={'json': 'editUid'})
parent_id = attr.ib(type=str, default='', metadata={'json': 'parentId'})
own_uid = attr.ib(type=str, default='', metadata={'json': 'ownUid'})
@to_json_decorator
@attr.s
class DriveFileMeta(object):
"""文件元信息
"""
name = attr.ib(type=str, default='', metadata={'json': 'title'})
token = attr.ib(type=str, default='', metadata={'json': 'docs_token'})
type = attr.ib(type=DriveFileType, default=None, metadata={'json': 'docs_type'})
owner_open_id = attr.ib(type=str, default='', metadata={'json': 'owner_id'})
create_time = attr.ib(type=int, default=0)
latest_modify_open_id = attr.ib(type=str, default='', metadata={'json': 'latest_modify_user'})
latest_modify_time = attr.ib(type=str, default='')
@to_json_decorator
@attr.s
class DriveCreateFile(object):
"""创建的文件对象
"""
revision = attr.ib(type=int, default=0)
token = attr.ib(type=str, default='')
url = attr.ib(type=str, default='')
@to_json_decorator
@attr.s
class DriveDeleteFile(object):
"""删除的文件对象
"""
id = attr.ib(type=str, default='')
result = attr.ib(type=bool, default=False)
@to_json_decorator
@attr.s
class DriveCopyFile(object):
"""复制的文件对象
"""
folder_token = attr.ib(type=str, default='', metadata={'json': 'folderToken'})
revision = attr.ib(type=int, default=0)
token = attr.ib(type=str, default='')
url = attr.ib(type=str, default='')
type = attr.ib(type=DriveFileType, default=None)
@to_json_decorator
@attr.s
class DriveDocFileMeta(object):
create_date = attr.ib(type=str, default='')
create_time = attr.ib(type=int, default=0)
create_uid = attr.ib(type=str, default='')
create_user_name = attr.ib(type=str, default='')
delete_flag = attr.ib(type=DriveDeleteFlag, default=DriveDeleteFlag.normal)
edit_time = attr.ib(type=int, default=0)
edit_user_name = attr.ib(type=str, default='')
is_external = attr.ib(type=bool, default=False)
is_pined = attr.ib(type=bool, default=False)
is_stared = attr.ib(type=bool, default=False)
type = attr.ib(type=DriveFileType, default=None, metadata={'json': 'obj_type'}) # doc
owner_uid = attr.ib(type=str, default='', metadata={'json': 'owner_id'}) # 这里不是 open_id接口不标准
owner_user_name = attr.ib(type=str, default='')
server_time = attr.ib(type=int, default=0)
tenant_id = attr.ib(type=str, default='')
title = attr.ib(type=str, default='')
url = attr.ib(type=str, default='')
@to_json_decorator
@attr.s
class DriveComment(object):
"""回复的对象
"""
comment_id = attr.ib(type=str, default='')
create_timestamp = attr.ib(type=int, default=0)
reply_id = attr.ib(type=str, default='')
update_timestamp = attr.ib(type=int, default=0)
@to_json_decorator
@attr.s
class DriveSubSheetMeta(object):
id = attr.ib(type=str, default='', metadata={'json': 'sheetId'})
title = attr.ib(type=str, default='')
index = attr.ib(type=int, default=0)
row_count = attr.ib(type=int, default=0, metadata={'json': 'rowCount'})
column_count = attr.ib(type=int, default=0, metadata={'json': 'columnCount'})
@to_json_decorator
@attr.s
class DriveSheetMeta(object):
title = attr.ib(type=str, default='')
owner_uid = attr.ib(type=int, default=0, metadata={'json': 'ownerUser'})
sheet_count = attr.ib(type=int, default=0, metadata={'json': 'sheetCount'})
token = attr.ib(type=str, default='', metadata={'json': 'spreadsheetToken'})
sheets = attr.ib(type=List[DriveSubSheetMeta], default=None) # type: List[DriveSubSheetMeta]
@to_json_decorator
@attr.s
class DriveInsertSheet(object):
sheet_token = attr.ib(type=str, default='')
sheet_id = attr.ib(type=str, default='')
revision = attr.ib(type=int, default=0)
updated_range = attr.ib(type=str, default='')
rows = attr.ib(type=int, default=0)
columns = attr.ib(type=int, default=0)
cells = attr.ib(type=int, default=0)
class DriveSheetStyleTextDecoration(Enum):
normal = 0
underline = 1 # 下划线
line_through = 2 # 删除线
underline_and_line_through = 3 # 下划线+删除线
class DriveSheetStyleNumber(Enum):
normal = '' # 常规
plain_text = '@' # 纯文本
number = '0' # 数字1024
number_thousandths = '#,##0' # 数字(千分位)1,024
number_thousandths_decimal = '#,##0.00' # 数字(千分位 小数点)1024.56
percent = '0%' # 百分比10%
percent_decimal = '0.00%' # 百分比(小数点)10.24%
scientific_notation = '0.00E+00' # 科学计数1.02E+03
rmb = '¥#,##0' # 人民币¥1,024
rmb_decimal = '¥#,##0.00' # 人民币(小数点)¥1,024.56
usd = '$#,##0' # 美元:$1,024
usd_decimal = '$#,##0.00' # 美元(小数点)$1,024.56
date_slash = 'yyyy/MM/dd' # 日期2017/08/10
date_hor = 'yyyy-MM-dd' # 日期2017-08-10
time = 'HH:mm:ss' # 时间23:24:25
datetime = 'yyyy/MM/dd HH:mm:ss' # 日期时间2017/08/10 23:24:25
class DriveSheetStyleHorizontalAlign(Enum):
left = 0 # 左
center = 1
right = 2 # 右
class DriveSheetStyleVerticalAlign(Enum):
up = 0 # 上
center = 1
down = 2 # 下
class DriveSheetStyleBorderType(Enum):
full_border = 'FULL_BORDER'
outer_border = 'OUTER_BORDER'
inner_border = 'INNER_BORDER'
no_border = 'NO_BORDER'
left_border = 'LEFT_BORDER'
right_border = 'RIGHT_BORDER'
top_border = 'TOP_BORDER'
bottom_border = 'BOTTOM_BORDER'
@to_json_decorator
@attr.s
class DriveSheetStyleFont(object):
bold = attr.ib(type=bool, default=False) # 是否粗体
italic = attr.ib(type=bool, default=False) # 是否斜体
font_size = attr.ib(type=str, default=None, metadata={'json': 'fontSize'}) # 10pt/1.5字号大小为9~36 行距固定为1.5
clean = attr.ib(type=bool, default=False) # 清除font格式
def as_sheet_style(self):
d = {
'bold': self.bold,
'italic': self.italic,
'clean': self.clean,
}
if self.font_size is not None:
d['fontSize'] = self.font_size
return d
@to_json_decorator
@attr.s
class DriveSheetStyle(object):
font = attr.ib(type=DriveSheetStyleFont, default=None) # 字体
text_decoration = attr.ib(type=DriveSheetStyleTextDecoration, default=DriveSheetStyleTextDecoration.normal,
metadata={'json': 'textDecoration'}) # 文本装饰0 默认1 下划线2 删除线3 下划线和删除线
formatter = attr.ib(type=DriveSheetStyleNumber, default=DriveSheetStyleNumber.normal) # 数字格式
horizontal_align = attr.ib(type=DriveSheetStyleHorizontalAlign, default=DriveSheetStyleHorizontalAlign.left,
metadata={'json': 'hAlign'}) # 水平对齐0 左对齐1 中对齐2 右对齐
vertical_align = attr.ib(type=DriveSheetStyleVerticalAlign, default=DriveSheetStyleVerticalAlign.up,
metadata={'json': 'vAlign'}) # 垂直对齐0 上对齐1 中对齐, 2 下对齐
fore_color = attr.ib(type=str, default='', metadata={'json': 'foreColor'}) # 字体颜色
back_color = attr.ib(type=str, default='', metadata={'json': 'backColor'}) # 背景颜色
border_type = attr.ib(type=DriveSheetStyleBorderType, default=None, metadata={'json': 'borderType'}) # 边框颜色
border_color = attr.ib(type=str, default='', metadata={'json': 'borderColor'}) # 边框颜色
clean = attr.ib(type=bool, default=False) # 清除格式
def as_sheet_style(self):
d = {
'textDecoration': self.text_decoration.value,
'formatter': self.formatter.value,
'hAlign': self.horizontal_align.value,
'vAlign': self.vertical_align.value,
'foreColor': self.fore_color,
'backColor': self.back_color,
'borderColor': self.border_color,
'clean': self.clean,
}
if self.border_type is not None:
d['borderType'] = self.border_type.value
if self.font is not None:
d['font'] = self.font.as_sheet_style()
return d
@to_json_decorator
@attr.s
class BatchSetDriveSheetStyleRequest(object):
ranges = attr.ib(type=List[str], default=None) # type: List[str]
style = attr.ib(type=DriveSheetStyle, default=None)
raw_style = attr.ib(type=dict, default=None)
def as_dict(self, sheet_id):
d = {
'ranges': [join_range(sheet_id, i) for i in self.ranges],
}
if self.raw_style is not None:
d['style'] = self.raw_style
return d
d['style'] = self.style.as_sheet_style()
return d
@to_json_decorator
@attr.s
class LockDriveSheetRequest(object):
sheet_id = attr.ib(type=str)
start_index = attr.ib(type=int)
end_index = attr.ib(type=int)
editor_uids = attr.ib(type=List[int], default=None) # type: List[int]
is_rows = attr.ib(type=bool, default=True)
lock_info = attr.ib(type=str, default=None)
def as_dict(self):
d = {
"dimension": {
"sheetId": self.sheet_id,
"majorDimension": 'ROWS' if self.is_rows else "COLUMNS",
"startIndex": self.start_index,
"endIndex": self.end_index
},
}
if self.editor_uids is not None:
d['editors'] = self.editor_uids
if self.lock_info is not None:
d['lockInfo'] = self.lock_info
return d
@to_json_decorator
@attr.s
class ReadDriveSheetRequest(object):
sheet_id = attr.ib(type=str)
range = attr.ib(type=str)
def as_str(self):
return join_range(self.sheet_id, self.range)
class DriveSheetMergeType(Enum):
all = 'MERGE_ALL' # 将所选区域直接合并
rows = 'MERGE_ROWS' # 将所选区域按行合并
columns = 'MERGE_COLUMNS' # 将所选区域按列合并响应
@to_json_decorator
@attr.s
class WriteDriveSheetRequest(object):
sheet_id = attr.ib(type=str)
range = attr.ib(type=str)
values = attr.ib(type=List[List[Any]]) # type: List[List[Any]]
def as_dict(self):
return {
'range': join_range(self.sheet_id, self.range),
'values': [[i.as_sheet_dict() if hasattr(i, 'as_sheet_dict') else i for i in value]
for value in self.values]
}
class DriveFilePermission(Enum):
view = 'view'
edit = 'edit'
@to_json_decorator
@attr.s
class DriveFileUser(object):
email = attr.ib(type=str, default=None) # 邮箱
open_id = attr.ib(type=str, default=None) # 人的 open_id
chat_id = attr.ib(type=str, default=None) # 群聊的 chat_id
employee_id = attr.ib(type=str, default=None) # lark_id
def as_dict(self):
d = {}
if self.email is not None:
d['member_id'] = self.email
d['member_type'] = 'email'
elif self.open_id is not None:
d['member_type'] = 'openid'
d['member_id'] = self.open_id
elif self.chat_id is not None:
d['member_type'] = 'openchat'
d['member_id'] = self.chat_id
elif self.employee_id is not None:
d['member_type'] = 'userid'
d['member_id'] = self.employee_id
else:
raise LarkInvalidArguments(msg='email / open_id / chat_id / uid 必须有一个')
return d
@to_json_decorator
@attr.s
class DriveFileUserPermission(DriveFileUser):
permission = attr.ib(type=DriveFilePermission, default=DriveFilePermission.view)
def as_dict(self):
d = super(DriveFileUserPermission, self).as_dict()
d['perm'] = self.permission.value
return d
def unmarshal_drive_user_permission(members,
email_type='email',
email_key='member_id',
open_id_type='openid',
open_id_key='member_id',
chat_id_type='openchat',
chat_id_key='member_id',
employee_id_type='userid',
employee_id_key='member_id',
is_unmarshal_perm=False):
d = []
for i in members:
member_type = i.get('member_type', '')
if is_unmarshal_perm:
v = DriveFileUserPermission(permission=i.get('perm', ''))
else:
v = DriveFileUser()
if member_type == email_type:
v.email = i.get(email_key) or ''
elif member_type == open_id_type:
v.open_id = i.get(open_id_key) or ''
elif member_type == chat_id_type:
v.chat_id = i.get(chat_id_key) or ''
elif member_type == employee_id_type:
v.employee_id = i.get(employee_id_key) or ''
d.append(v)
return d
class DriveFilePublicLinkSharePermission(Enum):
tenant_readable = 'tenant_readable' # 组织内获得链接的人可阅读
tenant_editable = 'tenant_editable' # 组织内获得链接的人可编辑
anyone_readable = 'anyone_readable' # 获得链接的任何人可阅读
anyone_editable = 'anyone_editable' # 获得链接的任何人可编辑
@to_json_decorator
@attr.s
class BatchUpdateDriveSheetRequestAdd(object):
title = attr.ib(type=str)
index = attr.ib(type=int, default=None)
def as_dict(self):
d = {'title': self.title}
if self.index is not None:
d['index'] = self.index
return {
'addSheet': {
'properties': d
}
}
@to_json_decorator
@attr.s
class BatchUpdateDriveSheetRequestCopy(object):
sheet_id = attr.ib(type=str)
dst_title = attr.ib(type=str)
def as_dict(self):
return {
'copySheet': {
'source': {
'sheetId': self.sheet_id
},
'destination': {
'title': self.dst_title
}
}
}
@to_json_decorator
@attr.s
class BatchUpdateDriveSheetRequestDelete(object):
sheet_id = attr.ib(type=str)
def as_dict(self):
return {
'deleteSheet': {
'sheetId': self.sheet_id
}
}
@to_json_decorator
@attr.s
class UpdateDriveSheetResponse(object):
sheet_id = attr.ib(type=str, default=None, metadata={'json': 'sheetId'})
title = attr.ib(type=str, default=None)
index = attr.ib(type=int, default=None)

190
utils/feishu/dt_enum.py Normal file
View File

@ -0,0 +1,190 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from enum import Enum
class MessageType(Enum):
"""消息的类型
支持文本图片富文本分享群聊卡片卡片消息
"""
text = 'text' # 文本
image = 'image' # 图片
post = 'post' # 富文本
share_chat = 'share_chat' # 分享群名片
card = 'interactive' # 卡片消息
forward = 'forward' # 转发消息
class UrgentType(Enum):
"""消息加急类型
支持飞书内部短信电话
"""
app = 'app' # 飞书内部
sms = 'sms' # 短信
phone = 'phone' # 电话
class I18NType(Enum):
"""国际化消息的类型
支持中文英文日文
"""
zh_cn = 'zh_cn'
ja_jp = 'ja_jp'
en_us = 'en_us'
class ImageColor(Enum):
"""卡片消息头部的颜色
"""
orange = 'orange'
red = 'red'
yellow = 'yellow'
gray = 'gray'
blue = 'blue'
green = 'green'
class MethodType(Enum):
"""卡片消息按钮的请求类型
"""
post = 'post' # 发送 post 请求
get = 'get' # 发送 get 请求
jump = 'jump' # 跳转到指定 url
class CalendarRole(Enum):
reader = 'reader' # 订阅者,可查看日程详情
free_busy_reader = 'free_busy_reader' # 游客,只能看到"忙碌/空闲"
class CalendarEventVisibility(Enum):
"""日历的日程的可见性
支持仅向他人显示是否忙碌公开显示日程详情仅自己可见
"""
default = 'default' # 默认,仅向他人显示是否“忙碌”
public = 'public' # 公开,显示日程详情
private = 'private' # 仅自己可见
class ApprovalUploadFileType(Enum):
image = 'image'
attachment = 'attachment'
class EventType(Enum):
"""事件类型
https://open.feishu.cn/document/uYjL24iN/uUTNz4SN1MjL1UzM
"""
url_verification = 'url_verification' # 这是一个验证请求
app_ticket = 'app_ticket' # 租户管理员开通 ISV 应用后,会定时发送 app_ticket 事件到监听地址
app_open = 'app_open' # 当企业管理员在管理员后台开通应用时推送事件
message = 'message' # 接收用户发送给应用的消息,包括与机器人直接对话或者在群聊中与机器人交流
user_add = 'user_add' # 通讯录变更
user_update = 'user_update'
user_leave = 'user_leave'
dept_add = 'dept_add'
dept_update = 'dept_update'
dept_delete = 'dept_delete'
contact_scope_change = 'contact_scope_change'
approval = 'approval' # 审批通过
leave_approval = 'leave_approval' # 请假审批
work_approval = 'work_approval' # 加班审批
shift_approval = 'shift_approval' # 换班审批
remedy_approval = 'remedy_approval' # 补卡审批
trip_approval = 'trip_approval' # 出差审批
remove_bot = 'remove_bot' # 移除机器人
add_bot = 'add_bot' # 添加机器人
p2p_chat_create = 'p2p_chat_create' # 用户第一次打开这个机器人的会话界面
add_user_to_chat = 'add_user_to_chat' # 用户进群
remove_user_from_chat = 'remove_user_from_chat' # 用户出群
revoke_add_user_from_chat = 'revoke_add_user_from_chat' # 撤销加人
unknown = 'unknown'
class ApprovalInstanceStatus(Enum):
pending = 'PENDING' # 待审核
approved = 'APPROVED' # 已通过
rejected = 'REJECTED' # 已拒绝
canceled = 'CANCELED' # 已取消
deleted = 'DELETED' # 已取消
class ApprovalTaskStatus(Enum):
pending = 'PENDING' # 审批中
approved = 'APPROVED' # 通过
rejected = 'REJECTED' # 拒绝
transfered = 'TRANSFERRED' # 已转交
canceled = 'DONE' # 完成
class ApprovalTaskTypeStatus(Enum):
or_sign = 'OR' # 或签,一名负责人通过即可通过审批节点
and_sign = 'AND' # 或签,需所有负责人通过才能通过审批节点
auto_pass = 'AUTO_PASS' # 自动通过
auto_reject = 'AUTO_REJECT' # 自动拒绝
sequential = 'SEQUENTIAL' # 按照顺序
class ApprovalTimelineType(Enum):
"""动态类型"""
start = 'START' # 审批开始
passed = 'PASS' # 通过
reject = 'REJECT' # 拒绝
auto_pass = 'AUTO_PASS' # 自动通过
auto_reject = 'AUTO_REJECT' # 自动拒绝
remove_repeat = 'REMOVE_REPEAT' # 去重
transfer = 'TRANSFER' # 转交
add_approver_before = 'ADD_APPROVER_BEFORE' # 前加签
add_approver = 'ADD_APPROVER' # 并加签
add_approver_after = 'ADD_APPROVER_AFTER' # 后加签
delete_approver = 'DELETE_APPROVER' # 减签
rollback_selected = 'ROLLBACK_SELECTED' # 指定回退
rollback = 'ROLLBACK' # 全部回退
cancel = 'CANCEL' # 撤回
delete = 'DELETE' # 删除
cc = 'CC' # 抄送
class PayPricePlanType(Enum):
"""价格方案类型
"""
trial = 'trial' # 试用
permanent = 'permanent' # 一次性付费
per_year = 'per_year' # 企业年付费
per_month = 'per_month' # 企业月付费
per_seat_per_year = 'per_seat_per_year' # 按人按年付费
per_seat_per_month = 'per_seat_per_month' # 按人按月付费
permanent_count = 'permanent_count' # 按次付费
class PayBuyType(Enum):
"""购买类型
"""
buy = 'buy' # 普通购买
# 升级购买仅price_plan_type为per_year、per_month、per_seat_per_year、per_seat_per_month时可升级购买
upgrade = 'upgrade'
renew = 'renew' # 续费购买
class PayStatus(Enum):
"""订单当前状态
"""
normal = 'normal' # 正常
refund = 'refund' # 已退款
all = 'all' # 全部,查询的时候会用到
class MeetingReplyStatus(Enum):
"""回复状态NOT_CHECK_IN 表示未签到ENDED_BEFORE_DUE 表示提前结束
"""
not_check_in = 'NOT_CHECK_IN' # 未签到
ended_before_due = 'ENDED_BEFORE_DUE' # 提前结束

84
utils/feishu/dt_help.py Normal file
View File

@ -0,0 +1,84 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import inspect
from copy import deepcopy
from enum import Enum
from typing import List, TypeVar
import attr
T = TypeVar('T')
def to_json(data):
if not hasattr(data, '__dict__'):
return data
d = {}
for k, v in data.__dict__.items():
if hasattr(v, 'json'):
d[k] = v.json()
elif isinstance(v, list):
d[k] = [to_json(i) for i in v]
elif isinstance(v, dict):
d[k] = {kk: to_json(vv) for kk, vv in v.items()}
elif isinstance(v, Enum):
d[k] = v.value
else:
d[k] = v
return d
def to_json_decorator(cls):
cls.json = to_json
return cls
def make_datatype(t, kwargs):
"""make_datatype
:type t: Type[T]
:type kwargs: Any
:rtype: T
"""
kwargs = deepcopy(kwargs)
if inspect.isclass(t) and issubclass(t, Enum):
return t(kwargs)
if isinstance(kwargs, (int, str, bool)):
return kwargs
if t.__class__ == List.__class__ and isinstance(kwargs, list):
return [make_datatype(t.__args__[0], i) for i in kwargs]
if not hasattr(t, '__attrs_attrs__'):
return kwargs
d = {}
attr_field = attr.fields(t)
for att in getattr(t, '__attrs_attrs__', []):
att_name = att.name
json_name = getattr(attr_field, att.name).metadata.get('json') or att_name
att_default = att.default
att_value = kwargs.get(json_name)
if att_value:
d[att_name] = make_datatype(att.type, att_value)
del kwargs[json_name]
elif att_default is attr.NOTHING:
d[att_name] = None
return t(**d)
def int_convert_bool(v):
return bool(v)
def to_lower_converter(s):
if isinstance(s, str):
return s.lower()
return s

View File

@ -0,0 +1,50 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import datetime
from typing import List
import attr
from utils.feishu.dt_code import SimpleUser
from utils.feishu.dt_help import to_json_decorator
@to_json_decorator
@attr.s
class Building(object):
"""建筑物对象
"""
building_id = attr.ib(type=str, default='') # 建筑物ID
description = attr.ib(type=str, default='') # 建筑物的相关描述
name = attr.ib(type=str, default='') # 建筑物名称
floors = attr.ib(type=List[str], default=attr.Factory(list)) # type: List[str] # 属于当前建筑物的所有楼层列表
@to_json_decorator
@attr.s
class Room(object):
"""会议室对象
"""
room_id = attr.ib(type=str, default='') # 会议室ID
building_id = attr.ib(type=str, default='') # 会议室所属建筑物ID
building_name = attr.ib(type=str, default='') # 会议室所属建筑物名称
capacity = attr.ib(type=int, default=None) # 会议室能容纳的人数
description = attr.ib(type=str, default='') # 会议室的相关描述
display_id = attr.ib(type=str, default='') # 会议室的展示ID
floor_name = attr.ib(type=str, default='') # 会议室所在楼层名称
is_disabled = attr.ib(type=bool, default=False) # 会议室是否不可用,若会议室不可用,则该值为 True否则为 False
name = attr.ib(type=str, default='') # 会议室名称
@to_json_decorator
@attr.s
class RoomFreeBusy(object):
"""会议室忙闲时间段
"""
start_time = attr.ib(type=datetime.datetime, default=None)
end_time = attr.ib(type=datetime.datetime, default=None)
uid = attr.ib(type=str, default=None)
original_time = attr.ib(type=int, default=0)
organizer_info = attr.ib(type=SimpleUser, default=None)

314
utils/feishu/dt_message.py Normal file
View File

@ -0,0 +1,314 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
from typing import TYPE_CHECKING, Any, Dict, List
import attr
from utils.feishu.dt_enum import ImageColor
from utils.feishu.dt_help import to_json_decorator
from utils.feishu.exception import LarkInvalidArguments
if TYPE_CHECKING:
from six import string_types
# 文字图片atlink这四个在post和card中都有做统一处理
# 富文本https://lark-open.bytedance.net/document/ukTMukTMukTM/uMDMxEjLzATMx4yMwETM
# 卡片https://lark-open.bytedance.net/document/ukTMukTMukTM/uUzMxEjL1MTMx4SNzETM
@to_json_decorator
@attr.s
class I18nText(object):
text = attr.ib(type=str, default=None)
zh_cn = attr.ib(type=str, default=None) # 如果设置了,那么国际化中文环境会显示
en_us = attr.ib(type=str, default=None) # 如果设置了,那么国际化日文环境会显示
ja_jp = attr.ib(type=str, default=None) # 如果设置了,那么国际化英文环境会显示
def as_dict(self):
return {
'text': self.text,
'i18n': {
'zh_cn': self.zh_cn or self.text,
'en_us': self.en_us or self.text,
'ja_jp': self.ja_jp or self.text,
}
}
@to_json_decorator
@attr.s
class MessageText(object):
text = attr.ib(type=str, default=None)
zh_cn = attr.ib(type=str, default=None) # card 格式有效:如果设置了,那么国际化中文环境会显示
en_us = attr.ib(type=str, default=None) # card 格式有效:如果设置了,那么国际化日文环境会显示
ja_jp = attr.ib(type=str, default=None) # card 格式有效:如果设置了,那么国际化英文环境会显示
lines = attr.ib(type=int, default=None) # 只能在富文本消息中起作用,最大显示行数
un_escape = attr.ib(type=bool, default=None) # 只能在富文本消息中起作用,表示为 unescape 解码
def as_post_dict(self):
if self.zh_cn or self.en_us or self.ja_jp:
logging.warning('zh_cn or en_us or ja_jp is for card text')
text = ''
for i in [self.text, self.zh_cn, self.en_us, self.ja_jp]:
if i is not None:
text = i
break
d = {
'tag': 'text',
'text': text,
}
for i in ['un_escape', 'lines']:
r = getattr(self, i)
if r is not None:
d[i] = r
return d
def as_card_dict(self):
if self.lines is not None:
logging.warning('lines is for post text')
if self.un_escape is not None:
logging.warning('un_escape is for post text')
return {
'tag': 'text',
'text': self.text,
'i18n': {
'zh_cn': self.zh_cn or self.text,
'en_us': self.en_us or self.text,
'ja_jp': self.ja_jp or self.text,
}
}
@to_json_decorator
@attr.s
class MessageAt(object):
"""富文本的at消息
"""
user_id = attr.ib(type=str, default=None) # open_id 或者 employee_id
text = attr.ib(type=str, default=None) # 用户名
def as_dict(self):
return {
'tag': 'at',
'user_id': self.user_id,
'text': self.text,
}
as_post_dict = as_dict
as_card_dict = as_dict
@to_json_decorator
@attr.s
class MessageImage(object):
"""富文本的图片消息
"""
# 图片的唯一标识,可以通过图片上传接口获得: https://lark-open.bytedance.net/document/ukTMukTMukTM/uEDO04SM4QjLxgDN
image_key = attr.ib(type=str, default=None)
height = attr.ib(type=int, default=None) # 图片的宽
width = attr.ib(type=int, default=None) # 图片的高
def as_dict(self):
return {
'tag': 'img',
'image_key': self.image_key,
'height': self.height,
'width': self.width
}
as_post_dict = as_dict
as_card_dict = as_dict
@to_json_decorator
@attr.s
class MessageLink(object):
text = attr.ib(type=str, default=None)
href = attr.ib(type=str, default=None) # 默认的链接地址,如果这个没有写,从下面三个取一个
un_escape = attr.ib(type=bool, default=None)
# 以下配置只在 card 有效
zh_cn = attr.ib(type=str, default=None) # card有效如果设置了那么国际化中文环境会显示
en_us = attr.ib(type=str, default=None) # card有效如果设置了那么国际化日文环境会显示
ja_jp = attr.ib(type=str, default=None) # card有效如果设置了那么国际化英文环境会显示
pc_href = attr.ib(type=str, default=None) # card有效PC 端的链接地址
ios_href = attr.ib(type=str, default=None) # card有效iOS 端的链接地址
android_href = attr.ib(type=str, default=None) # card有效Android 端的链接地址
def as_post_dict(self):
i18n_text = self.zh_cn or self.en_us or self.ja_jp
if i18n_text:
logging.warning('zh_cn or en_us or ja_jp is for card text')
multi_platform_href = self.pc_href or self.ios_href or self.android_href
if multi_platform_href:
logging.warning('pc_href or ios_href or android_href is for card text')
text = self.text or i18n_text
if text is None:
raise LarkInvalidArguments(msg='[message] empty text')
href = self.href or multi_platform_href
if href is None:
raise LarkInvalidArguments(msg='[message] empty href')
d = {
'tag': 'a',
'text': text,
'href': href,
} # type: Dict[string_types, Any]
if self.un_escape is not None:
d['un_escape'] = self.un_escape
return d
def as_card_dict(self):
return {
'tag': 'a',
'text': self.text,
'i18n': {
'zh_cn': self.zh_cn or self.text,
'en_us': self.en_us or self.text,
'ja_jp': self.ja_jp or self.text,
},
'href': {
'href': self.href,
'pc_href': self.pc_href,
'ios_href': self.ios_href,
'android_href': self.android_href,
}
}
@to_json_decorator
@attr.s
class CardURL(object):
"""多设备 URL
"""
href = attr.ib(type=str, default=None) # 默认的链接地址,如果这个没有写,从下面三个取一个
pc_href = attr.ib(type=str, default=None) # PC 端的链接地址
ios_href = attr.ib(type=str, default=None) # iOS 端的链接地址
android_href = attr.ib(type=str, default=None) # Android 端的链接地址
def as_card_dict(self):
d = {'href': self.href}
for i in ['pc_href', 'ios_href', 'android_href']:
href = getattr(self, i)
if not href:
continue
d[i] = href
if not d['href']:
d['href'] = href
return d
def as_button_dict(self):
d = {}
if self.pc_href:
d['pc_url'] = self.pc_href or self.href
if self.ios_href:
d['ios_url'] = self.ios_href or self.href
if self.android_href:
d['android_url'] = self.android_href or self.href
return d
@to_json_decorator
@attr.s
class CardHeader(object):
title = attr.ib(type=str, default=None) # 显示的默认的文本内容,如果设置了 i18n 内容,会优先显示 i18n 里面对应的语种内容
zh_cn = attr.ib(type=str, default=None) # 如果设置了,那么国际化中文环境会显示
en_us = attr.ib(type=str, default=None) # 如果设置了,那么国际化日文环境会显示
ja_jp = attr.ib(type=str, default=None) # 如果设置了,那么国际化英文环境会显示
# 标题前图标的颜色。可选范围:[orange, red, yellow, gray, blue, green] 默认为 red
image_color = attr.ib(type=ImageColor, default=ImageColor.red)
lines = attr.ib(type=int, default=None) # 指定文本最大显示行数0 表示不限行数
def as_dict(self):
d = {'title': self.title, 'image_color': self.image_color.value}
if self.lines:
d['lines'] = self.lines
if self.zh_cn or self.en_us or self.ja_jp:
d['i18n'] = {
'zh_cn': self.zh_cn or self.title,
'en_us': self.en_us or self.title,
'ja_jp': self.ja_jp or self.title,
}
return d
@to_json_decorator
@attr.s
class CardButton(object):
text = attr.ib(type=str, default=None)
# 发送 post 请求
method = attr.ib(type=str, default=None)
# 请求或者跳转的地址
url = attr.ib(type=str, default=None)
# 标题的 i18n
text_i18n = attr.ib(type=I18nText, default=None)
# 点击按钮以后显示的默认的文本内容,仅在 method 是 post 或 get 时候才有效
triggered_text = attr.ib(type=str, default=None)
# 点击按钮以后国际化内容的字段,仅在 method 是 post 或 get 时候才有效
triggered_i18n = attr.ib(type=I18nText, default=None)
# 为 true 时,请求参数会带上 open_id 或者 employee_id仅在 method 是 post 时候才有效
need_user_info = attr.ib(type=bool, default=None)
# 为 true 时,请求参数会带上 open_message_id仅在 method 是 post 时候才有效
need_message_info = attr.ib(type=bool, default=None)
# 开发者自定义的请求参数,仅在 method 是 post 时候才有效
parameter = attr.ib(type=Any, default=None)
# 可包含 pc_url, ios_url, android_url, 仅在 method 是 jump 时候才有效. 如果配置了该字段, 则在相应端上优先使用指定的链接
open_url = attr.ib(type=CardURL, default=None)
# 配置是否点击成功后,需要将其它按钮隐藏,仅在 method 是 post 或 get 时候才有效
hide_others = attr.ib(type=bool, default=None)
def as_dict(self):
d = {
'text': self.text,
'method': self.method,
'url': self.url,
} # type: Dict[string_types, Any]
if self.text_i18n:
d['i18n'] = self.text_i18n.as_dict()
if self.triggered_i18n:
d['triggered_i18n'] = self.triggered_i18n.as_dict()
if self.open_url:
d['open_url'] = self.open_url.as_button_dict()
for i in ['triggered_text', 'need_user_info', 'need_message_info', 'parameter', 'hide_others']:
r = getattr(self, i, None)
if r is not None:
d[i] = r
return d
as_post_dict = as_dict
as_card_dict = as_dict
@to_json_decorator
@attr.s
class CardAction(object):
buttons = attr.ib(type=List[CardButton], default=None) # type: List[CardButton]
changeable = attr.ib(type=bool, default=None)
def as_dict(self):
d = {'buttons': [i.as_dict() for i in self.buttons]} # type: Dict[string_types, Any]
if self.changeable is not None:
d['changeable'] = self.changeable
return d

27
utils/feishu/dt_pay.py Normal file
View File

@ -0,0 +1,27 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import attr
from utils.feishu.dt_enum import PayBuyType, PayPricePlanType, PayStatus
from utils.feishu.dt_help import to_json_decorator
@to_json_decorator
@attr.s
class PayOrder(object):
order_id = attr.ib(type=str, default='') # 订单ID唯一标识
price_plan_id = attr.ib(type=str, default='') # 价格方案ID唯一标识
price_plan_type = attr.ib(type=PayPricePlanType, default=None) # 价格方案类型
seats = attr.ib(type=int, default=0) # 实际购买人数仅对price_plan_type为per_seat_per_year和per_seat_per_month 有效
buy_count = attr.ib(type=int, default=0) # 购买数量总是为1
create_time = attr.ib(type=str, default='') # 订单创建时间戳
pay_time = attr.ib(type=str, default='') # 订单支付时间戳
status = attr.ib(type=PayStatus, default=None) # 订单当前状态
buy_type = attr.ib(type=PayBuyType, default=None) # 购买类型
src_order_id = attr.ib(type=str, default='') # 源订单ID当前订单为升级购买时即buy_type为upgrade时此字段记录源订单等ID
# 升级后的新订单ID当前订单如果做过升级购买此字段记录升级购买后生成的新订单ID当前订单仍然有效
dst_order_id = attr.ib(type=str, default='')
order_pay_price = attr.ib(type=int, default=0) # 订单实际支付金额, 单位分
tenant_key = attr.ib(type=str, default='') # 租户唯一标识

143
utils/feishu/dt_req.py Normal file
View File

@ -0,0 +1,143 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from typing import List
import attr
from utils.feishu.dt_contact import DepartmentUserCustomAttr, EmployeeType
from utils.feishu.dt_help import to_json, to_json_decorator
from utils.feishu.helper import pop_or_none
@to_json_decorator
@attr.s
class CreateDepartmentRequest(object):
parent_id = attr.ib(type=str) # 父部门 ID
name = attr.ib(type=str) # 部门名称
# 自定义部门 ID。只能在创建部门时指定不支持更新。企业内必须唯一若不填该参数将自动生成。不区分大小写长度为 1 ~ 64 个字符。
# 只能由数字、字母和 "_-@." 四种字符组成,且第一个字符必须是数字或字母
id = attr.ib(type=str, default='')
# 部门负责人 ID支持通过 leader_user_id 或 leader_open_id 设置部门负责人,请求同时传递两个参数时按 leader_user_id 处理
leader_open_id = attr.ib(type=str, default='')
leader_user_id = attr.ib(type=str, default='')
create_group_chat = attr.ib(type=bool, default=False) # 是否同时创建部门群,默认为 false不创建部门群
@to_json_decorator
@attr.s
class CreateUserRequest(object):
"""创建用户所需要的参数
"""
# 必填
name = attr.ib(type=str) # 用户名
mobile = attr.ib(type=str) # 用户手机号
# 新增用户所在部门,仅支持一个用户在一个部门下,需要有加入部门的通讯录权限
department_ids = attr.ib(type=List[str]) # type: List[str]
# 可选
# 用户邮箱地址
email = attr.ib(type=str, default=None)
# 手机号码可见性true 为可见false 为不可见,目前默认为 true。不可见时组织员工将无法查看该员工的手机号码
mobile_visible = attr.ib(type=bool, default=None)
# 用户所在城市
city = attr.ib(type=str, default=None)
# 用户所在国家
country = attr.ib(type=str, default=None)
# 性别1:男2:女
gender = attr.ib(type=int, default=None)
employee_type = attr.ib(type=EmployeeType, default=None) # 员工类型。1:正式员工2:实习生3:外包4:劳务5:顾问
join_time = attr.ib(type=int, default=None) # 入职时间
# 直接领导信息,支持通过 leader_user_id 或者 leader_open_id 设置直接领导,同时传递两个参数时按参数 leader_user_id 处理
leader_open_id = attr.ib(type=str, default=None)
leader_user_id = attr.ib(type=str, default=None) # v1 需要转成 employee
# 用户企业内唯一标识。
# 自定义唯一标识不区分大小写,长度为 1 ~ 64 个字符。只能由数字、字母和 "_-@.“ 四种字符组成,且第一个字符必须是数字或字母。
# 创建用户时可指定该唯一标识,指定的唯一标识不能修改。
user_id = attr.ib(type=str, default=None) # v1 需要转成 employee
# 工号
employee_no = attr.ib(type=str, default=None)
# 是否发送邀请通知。该字段为 true 时, 添加用户成功后会往相应的邮箱或者 mobile 发送邀请通知
need_send_notification = attr.ib(type=bool, default=None)
# 自定义用户属性。
# 该字段仅当企业管理员在企业管理后台开启了“允许开放平台API调用”时有效。
# 传入的每个自定义用户属性需要包含平台生成的属性ID和要设置的属性值。
# 当企业管理后台未开启“允许开放平台API调用”以及传入的自定义用户属性 ID 不存在或者非法时,会忽略该条属性设置信息。
custom_attrs = attr.ib(type=List[DepartmentUserCustomAttr],
default=attr.Factory(list)) # type: List[DepartmentUserCustomAttr]
# 工位
work_station = attr.ib(type=str, default=None)
def v1_json(self):
d = to_json(self)
d['leader_employee_id'] = pop_or_none(d, 'leader_user_id')
d['employee_id'] = pop_or_none(d, 'user_id')
custom_attrs = pop_or_none(d, 'custom_attrs')
custom = {}
for i in custom_attrs:
attr_id = pop_or_none(i, 'id')
custom[attr_id] = i
d['custom_attrs'] = custom
return d
@to_json_decorator
@attr.s
class UpdateUserRequest(object):
"""更新用户所需要的参数
"""
# 下面两个,必填一个
user_id = attr.ib(type=str, default=None)
open_id = attr.ib(type=str, default=None)
# 选填
name = attr.ib(type=str, default=None) # 用户名
mobile = attr.ib(type=str, default=None) # 用户手机号
# 新增用户所在部门,仅支持一个用户在一个部门下,需要有加入部门的通讯录权限
department_ids = attr.ib(type=List[str], default=attr.Factory(list)) # type: List[str]
is_frozen = attr.ib(type=bool, default=None) # 是否冻结用户
# 用户邮箱地址
email = attr.ib(type=str, default=None)
# 手机号码可见性true 为可见false 为不可见,目前默认为 true。不可见时组织员工将无法查看该员工的手机号码
mobile_visible = attr.ib(type=bool, default=None)
# 用户所在城市
city = attr.ib(type=str, default=None)
# 用户所在国家
country = attr.ib(type=str, default=None)
# 性别1:男2:女
gender = attr.ib(type=int, default=None)
employee_type = attr.ib(type=EmployeeType, default=None) # 员工类型。1:正式员工2:实习生3:外包4:劳务5:顾问
join_time = attr.ib(type=int, default=None) # 入职时间
# 直接领导信息,支持通过 leader_user_id 或者 leader_open_id 设置直接领导,同时传递两个参数时按参数 leader_user_id 处理
leader_open_id = attr.ib(type=str, default=None)
leader_user_id = attr.ib(type=str, default=None) # v1 需要转成 employee
# 用户企业内唯一标识。
# 自定义唯一标识不区分大小写,长度为 1 ~ 64 个字符。只能由数字、字母和 "_-@.“ 四种字符组成,且第一个字符必须是数字或字母。
# 创建用户时可指定该唯一标识,指定的唯一标识不能修改。
# 工号
employee_no = attr.ib(type=str, default=None)
# 是否发送邀请通知。该字段为 true 时, 添加用户成功后会往相应的邮箱或者 mobile 发送邀请通知
need_send_notification = attr.ib(type=bool, default=None)
# 自定义用户属性。
# 该字段仅当企业管理员在企业管理后台开启了“允许开放平台API调用”时有效。
# 传入的每个自定义用户属性需要包含平台生成的属性ID和要设置的属性值。
# 当企业管理后台未开启“允许开放平台API调用”以及传入的自定义用户属性 ID 不存在或者非法时,会忽略该条属性设置信息。
custom_attrs = attr.ib(type=List[DepartmentUserCustomAttr],
default=attr.Factory(list)) # type: List[DepartmentUserCustomAttr]
# 工位
work_station = attr.ib(type=str, default=None)
def v1_json(self):
d = to_json(self)
d['employee_id'] = pop_or_none(d, 'user_id')
d['leader_employee_id'] = pop_or_none(d, 'leader_user_id')
custom_attrs = pop_or_none(d, 'custom_attrs')
custom = {}
for i in custom_attrs:
attr_id = pop_or_none(i, 'id')
custom[attr_id] = i
d['custom_attrs'] = custom
return d

807
utils/feishu/exception.py Normal file
View File

@ -0,0 +1,807 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
from six import PY2
if PY2:
def implements_to_string(cls):
cls.__unicode__ = cls.__str__
cls.__str__ = lambda x: x.__unicode__().encode('utf-8')
return cls
else:
def implements_to_string(x):
return x
@implements_to_string
class OpenLarkException(Exception):
def __init__(self, *args, **kwargs):
"""基本 Exception
"""
self.code = kwargs.pop('code', None) or getattr(self, 'code', None) or 0
self.msg = kwargs.pop('msg', None) or getattr(self, 'msg', None)
self.url = kwargs.pop('url', None) or getattr(self, 'url', None)
def __str__(self):
if PY2:
if self.url:
return u'<{} code={} msg="{}" url="{}">'.format(self.__class__.__name__, self.code, self.msg, self.url)
else:
return u'<{} code={} msg="{}">'.format(self.__class__.__name__, self.code, self.msg)
else:
if self.url:
return '<{} code={} msg="{}" url="{}">'.format(self.__class__.__name__, self.code, self.msg, self.url)
else:
return '<{} code={} msg="{}">'.format(self.__class__.__name__, self.code, self.msg)
# --------- OpenLark SDK 定义的参数错误
class LarkInvalidArguments(OpenLarkException):
code = 999901
msg = 'feishu invalid arguments'
class LarkInvalidCallback(OpenLarkException):
code = 999902
msg = 'feishu callback error'
class LarkGetAppTicketFail(OpenLarkException):
code = 999903
msg = 'get app_ticket fail'
class LarkUnknownError(OpenLarkException):
code = 999904
msg = 'unknown error'
# --------- 机器人和服务端异常
class LarkSendMessageFailException(OpenLarkException):
code = 10002
msg = '发送消息失败'
class LarkRequestParamsInvalidException(OpenLarkException):
code = 10003
msg = '请求参数不合法'
class LarkGetUserInfoFailOrUserIDNotExistException(OpenLarkException):
code = 10004
msg = '获取用户信息失败或者用户 ID 不存在'
class LarkConflictAppIDException(OpenLarkException):
code = 10005
msg = '生成 token 的 app_id 和相关 chat、open_id 的 app_id 不一致'
class LarkGetOpenChatIDFailException(OpenLarkException):
code = 10009
msg = '获取 open_chat_id 失败'
class LarkForbiddenSendMessageException(OpenLarkException):
code = 10010
msg = '禁止发送消息,请检查 scope 权限,机器人可见性范围'
class LarkGetAppAccessTokenFailException(OpenLarkException):
code = 10012
msg = '获取 app access token 失败'
class LarkGetTenantAccessTokenFailException(OpenLarkException):
code = 10013 # 10014
msg = '获取 tenant access token 失败'
class LarkWrongAppSecretException(OpenLarkException):
code = 10015
msg = 'app_secret 不正确'
class LarkSendAppTicketFailException(OpenLarkException):
code = 10016
msg = '发送 app_ticket 失败'
class LarkUnsupportedUrgentTypeException(OpenLarkException):
code = 10019
msg = '加急类型不支持'
class LarkWrongMessageIDException(OpenLarkException):
code = 10020
msg = '消息 ID 不正确'
class LarkForbiddenUrgentException(OpenLarkException):
code = 10023
msg = '没有加急 scope 权限'
class LarkInvalidOpenChatIDException(OpenLarkException):
code = 10029
msg = 'open_chat_id 不合法'
class LarkBotNotInChatException(OpenLarkException):
code = 10030
msg = '机器人不在群里'
class LarkAllOpenIDInvalidException(OpenLarkException):
code = 10032
msg = '所有 open_id 都不合法'
class LarkUnsupportedCrossTenantException(OpenLarkException):
code = 10034
msg = '不支持跨企业操作'
class LarkGetMessageIDFailException(OpenLarkException):
code = 10037
msg = '获取 message_id 失败'
class LarkGetSSOAccessTokenFailException(OpenLarkException):
code = 11000
msg = '获取 sso_access_token 失败'
class LarkGetCheckSecurityTokenFailException(OpenLarkException):
code = 11001
msg = '获取 CheckSecurityToken 失败'
class LarkCheckOpenChatIDFailException(OpenLarkException):
code = 11100
msg = 'open_chat_id 不合法或者 chat 不存在'
class LarkOpenIDNotExistException(OpenLarkException):
code = 11101
msg = 'open_id 不存在'
class LarkGetOpenIDFailException(OpenLarkException):
code = 11102
msg = '查询用户 open_id 失败'
class LarkOpenDepartmentIDNotExistException(OpenLarkException):
code = 11103
msg = 'open_department_id 不存在'
class LarkGetOpenDepartmentIDFailException(OpenLarkException):
code = 11104
msg = '查询用户 open_department_id 失败'
class LarkEmployeeIDNotExistException(OpenLarkException):
code = 11105
msg = 'user_id 不存在'
class LarkGetEmployeeIDFailException(OpenLarkException):
code = 11106
msg = '查询用户 user_id 失败'
class LarkUpdateChatNameFailException(OpenLarkException):
code = 11200
msg = '更新群名称失败'
class LarkBotNotGroupAdminException(OpenLarkException):
code = 11201 # 11208
msg = '机器人不是群主'
class LarkOnlyChatAdminCanInviteUserException(OpenLarkException):
code = 11202
msg = '只有群主才能拉用户进群'
class LarkForbiddenBotBatchSendMessageToUserException(OpenLarkException):
code = 11203
msg = '机器人没有给用户批量发送权限'
class LarkForbiddenBotBatchSendMessageToDepartmentException(OpenLarkException):
code = 11204
msg = '机器人没有给部门批量发送权限'
class LarkAppHasNoBotException(OpenLarkException):
code = 11205
msg = '应用没有机器人'
class LarkUserCannotGrantToChatAdminException(OpenLarkException):
code = 11206
msg = '用户不在群中不能被设置为群主'
class LarkAppUnavailableException(OpenLarkException):
code = 11207
msg = 'app 不可用'
class LarkAppNotExistException(OpenLarkException):
code = 11209
msg = 'app 不存在'
class LarkAppUsageInfoNotExistException(OpenLarkException):
code = 11210
msg = 'AppUsageInfo 不存在'
class LarkInviteUserToChatInvalidParamsException(OpenLarkException):
code = 11211
msg = '拉人进群参数错误'
class LarkRemoveUserFromChatInvalidParamsException(OpenLarkException):
code = 11212
msg = '踢人出群参数错误'
class LarkUpdateChatInvalidParamsException(OpenLarkException):
code = 11213
msg = '更新群参数错误'
class LarkUploadImageInvalidParamsException(OpenLarkException):
code = 11214
msg = '上传图片参数错误'
class LarkEmptyChatIDException(OpenLarkException):
code = 11215
msg = 'chat_id 为空'
class LarkGetChatIDFailException(OpenLarkException):
code = 11216
msg = '获取chat_id失败'
class LarkInviteBotToChatFailException(OpenLarkException):
code = 11217
msg = '拉机器人进群失败'
class LarkBotInChatFullException(OpenLarkException):
code = 11218
msg = '群机器人已满'
class LarkUnsupportedChatCrossTenantException(OpenLarkException):
code = 11219
msg = '不支持 chat 跨租户'
class LarkForbiddenBotDisbandChatException(OpenLarkException):
code = 11220
msg = '禁止机器人解散群'
class LarkBotForbiddenToGetImageBelongToThemException(OpenLarkException):
code = 11221
msg = '机器人不能获取不属于自己的图片'
class LarkOwnerOfBotIsNotInChatException(OpenLarkException):
code = 11222
msg = '机器人的 Owner 不在群里'
class LarkNotOpenApplicationSendMessagePermissionException(OpenLarkException):
code = 11223
msg = '没有打开应用发消息权限'
class LarkInvalidMessageIDException(OpenLarkException):
code = 11224
msg = 'message_id 参数错误'
class LarkAppIsNotVisibleToUserException(OpenLarkException):
code = 11225
msg = '你的应用对用户不可见'
class LarkInvalidAppIDException(OpenLarkException):
code = 11226
msg = 'app_id 参数不对或者没传'
class LarkImageKeyNotExistException(OpenLarkException):
code = 11227
msg = ' image_key 不存在'
class LarkBotIsNotMessageOwnerException(OpenLarkException):
code = 11234
msg = 'bot非消息owner'
class LarkBanAtALLException(OpenLarkException):
code = 11235
msg = '禁止@所有人'
class LarkUserNotActiveException(OpenLarkException):
code = 11236
msg = '用户已离职'
class LarkChatDisbandedException(OpenLarkException):
code = 11237
msg = '群聊已解散'
class LarkMessageTooOldException(OpenLarkException):
code = 11238
msg = '消息过久,不能撤销'
class LarkNoPermissionToGotException(OpenLarkException):
code = 11239
msg = '无权限获取'
class LarkInvalidTenantAccessTokenException(OpenLarkException):
code = 99991663
msg = 'tenant access token 无效'
class LarkInvalidAppAccessTokenException(OpenLarkException):
code = 99991664
msg = 'app access token 无效'
class LarkInvalidTenantCodeException(OpenLarkException):
code = 99991665
msg = 'tenant code 无效'
class LarkInvalidAppTicketException(OpenLarkException):
code = 99991666
msg = 'app ticket 无效'
class LarkFrequencyLimitException(OpenLarkException):
code = 99991400
msg = '发消息频率超过频控限制目前每个AppID每个接口50/s、1000/min的限制'
class LarkInternalException(OpenLarkException):
code = 20000
msg = '内部异常'
# --------- 审批异常 / 审批错误码
# 40xx 是审批的 v1 接口
class LarkApprovalNotExistException(OpenLarkException):
code = 4002
msg = 'approval not exist'
class LarkApprovalSubscriptionExistException(OpenLarkException):
code = 4007
msg = 'subscription exist'
# 600xxx 是审批的 v3 接口
class LarkApprovalInvalidRequestParamsException(OpenLarkException):
code = 60001
msg = '请求参数错误'
class LarkApprovalApprovalCodeNotFoundException(OpenLarkException):
code = 60002
msg = '审批定义 approval_code 找不到'
class LarkApprovalInstanceCodeNotFoundException(OpenLarkException):
code = 60003
msg = '审批实例 instance_code 找不到'
class LarkApprovalUserNotFoundException(OpenLarkException):
code = 60004
msg = '用户找不到'
class LarkApprovalForbiddenException(OpenLarkException):
code = 60009
msg = '权限不足'
class LarkApprovalTaskIDNotFoundException(OpenLarkException):
code = 60010
msg = '审批任务 task_id 找不到'
class LarkApprovalDepartmentValidFailedException(OpenLarkException):
code = 60005
msg = '部门验证失败'
class LarkApprovalFormValidFailedException(OpenLarkException):
code = 60006
msg = '表单验证失败'
class LarkApprovalNeedPayException(OpenLarkException):
code = 60011
msg = '该审批为付费审批,免费版用户不能发起这个审批'
class LarkApprovalInstanceCodeConflictException(OpenLarkException):
code = 60012
msg = '审批实例 uuid 冲突'
# --------- 审批异常 / 审批错误码
# --------- 云空间
class LarkDriveWrongRequestJsonException(OpenLarkException):
code = 90201
msg = '请求体不是一个 json'
class LarkDriveWrongRangeException(OpenLarkException):
code = 90202
msg = '请求中 range 格式有误'
class LarkDriveFailException(OpenLarkException):
code = 90203
msg = '不是预期内的 fail'
class LarkDriveWrongRequestBodyException(OpenLarkException):
code = 90204
msg = '请求体有误'
class LarkDriveInvalidUsersException(OpenLarkException):
code = 90205
msg = '非法的 user'
class LarkDriveEmptySheetIDException(OpenLarkException):
code = 90206
msg = 'sheet_id 为空'
class LarkDriveEmptySheetTitleException(OpenLarkException):
code = 90207
msg = 'sheet 名称为空'
class LarkDriveSameSheetIDOrTitleException(OpenLarkException):
code = 90208
msg = '请求中有相同的 sheet_id 或 title'
class LarkDriveExistSheetIDException(OpenLarkException):
code = 90209
msg = 'sheet_id 已经存在'
class LarkDriveExistSheetTitleException(OpenLarkException):
code = 90210
msg = 'sheet title 已经存在'
class LarkDriveWrongSheetIDException(OpenLarkException):
code = 90211
msg = '错误的 sheet_id'
class LarkDriveWrongRowOrColException(OpenLarkException):
code = 90212
msg = '非法的行列'
class LarkDrivePermissionFailException(OpenLarkException):
code = 90213
msg = '没有文件的权限 forbidden'
class LarkDriveSpreadSheetNotFoundException(OpenLarkException):
code = 90214
msg = 'sheet 没有找到'
class LarkDriveSheetIDNotFoundException(OpenLarkException):
code = 90215
msg = 'sheet_id 没有找到'
class LarkDriveEmptyValueException(OpenLarkException):
code = 90216
msg = '请求中有空值'
class LarkDriveTooManyRequestException(OpenLarkException):
code = 90217
msg = '请求太频繁'
class LarkDriveTimeoutException(OpenLarkException):
code = 96402
msg = '超时'
class LarkDriveProcessingException(OpenLarkException):
code = 96403
msg = '请求正在处理中'
class LarkDriveLoginRequiredException(OpenLarkException):
code = 91404
msg = '需要登录'
class LarkDriveFailedException(OpenLarkException):
code = 90301 # 91201 / 96401
msg = '失败'
class LarkDriveOutOfLimitException(OpenLarkException):
code = 91206
msg = '超过限制'
class LarkDriveDuplicateException(OpenLarkException):
code = 91207
msg = '重复记录'
class LarkDriveForbiddenException(OpenLarkException):
code = 91002 # 90303 / 91204 / 91403
msg = '没有权限'
class LarkDriveInvalidOperationException(OpenLarkException):
code = 91003
msg = '操作异常'
class LarkDriveUserNoSharePermissionException(OpenLarkException):
code = 91004
msg = '用户没有共享权限'
class LarkDriveParamErrorException(OpenLarkException):
code = 90302 # 91001 / 91202 / 91401
msg = '参数错误'
class LarkDriveMetaDeletedException(OpenLarkException):
code = 90304 # 91205
msg = '文件已删除'
class LarkDriveMetaNotExistException(OpenLarkException):
code = 90305 # 91203 / 91402
msg = '文件不存在'
class LarkDriveReviewNotPassException(OpenLarkException):
code = 90306 # 91208
msg = '评论内容审核不通过'
class LarkDriveInternalErrorException(OpenLarkException):
code = 90399 # 95299 / 96201 / 96202 / 96001 / 95201 / 95201—95209
msg = '内部错误'
# --------- 云空间
# --------- 会议室
class LarkMeetingRoomInvalidPageTokenException(OpenLarkException):
code = 100001
msg = 'page token 格式非法'
class LarkMeetingRoomInvalidFieldSelectionException(OpenLarkException):
code = 100002
msg = 'fields 中存在非法字段名'
class LarkMeetingRoomTimeFormatMustFollowRFC3339StandardException(OpenLarkException):
code = 100003
msg = '时间格式未遵循 RFC3339 标准'
class LarkMeetingRoomInvalidBuildingIDException(OpenLarkException):
code = 100004
msg = 'building ID 非法'
class LarkMeetingRoomInvalidRoomIDException(OpenLarkException):
code = 100005
msg = 'room ID 非法'
class LarkMeetingRoomInternalErrorException(OpenLarkException):
code = 105001
msg = '内部错误'
# --------- 会议室
def gen_exception(code, url, msg=''):
"""生成异常
:type code: int
:type url: str
:type msg: str
:rtype: OpenLarkException
"""
exceptions = {
# 自定义
999901: LarkInvalidArguments,
999902: LarkInvalidCallback,
999903: LarkGetAppTicketFail,
999904: LarkUnknownError,
# 审批
4002: LarkApprovalNotExistException,
4007: LarkApprovalSubscriptionExistException,
60001: LarkApprovalInvalidRequestParamsException,
60002: LarkApprovalApprovalCodeNotFoundException,
60003: LarkApprovalInstanceCodeNotFoundException,
60004: LarkApprovalUserNotFoundException,
60009: LarkApprovalForbiddenException,
60010: LarkApprovalTaskIDNotFoundException,
60005: LarkApprovalDepartmentValidFailedException,
60006: LarkApprovalFormValidFailedException,
60011: LarkApprovalNeedPayException,
60012: LarkApprovalInstanceCodeConflictException,
# 数字超级大的异常,
99991400: LarkFrequencyLimitException,
99991663: LarkInvalidTenantAccessTokenException,
99991664: LarkInvalidAppAccessTokenException,
99991665: LarkInvalidTenantCodeException,
99991666: LarkInvalidAppTicketException,
10002: LarkSendMessageFailException,
10003: LarkRequestParamsInvalidException,
10004: LarkGetUserInfoFailOrUserIDNotExistException,
10005: LarkConflictAppIDException,
10009: LarkGetOpenChatIDFailException,
10010: LarkForbiddenSendMessageException,
10012: LarkGetAppAccessTokenFailException,
10013: LarkGetTenantAccessTokenFailException,
10014: LarkGetTenantAccessTokenFailException,
10015: LarkWrongAppSecretException,
10016: LarkSendAppTicketFailException,
10019: LarkUnsupportedUrgentTypeException,
10020: LarkWrongMessageIDException,
10023: LarkForbiddenUrgentException,
10029: LarkInvalidOpenChatIDException,
10030: LarkBotNotInChatException,
10032: LarkAllOpenIDInvalidException,
10034: LarkUnsupportedCrossTenantException,
10037: LarkGetMessageIDFailException,
11000: LarkGetSSOAccessTokenFailException,
11001: LarkGetCheckSecurityTokenFailException,
11100: LarkCheckOpenChatIDFailException,
11101: LarkOpenIDNotExistException,
11102: LarkGetOpenIDFailException,
11103: LarkOpenDepartmentIDNotExistException,
11104: LarkGetOpenDepartmentIDFailException,
11105: LarkEmployeeIDNotExistException,
11106: LarkGetEmployeeIDFailException,
11200: LarkUpdateChatNameFailException,
11201: LarkBotNotGroupAdminException,
11208: LarkBotNotGroupAdminException,
11202: LarkOnlyChatAdminCanInviteUserException,
11203: LarkForbiddenBotBatchSendMessageToUserException,
11204: LarkForbiddenBotBatchSendMessageToDepartmentException,
11205: LarkAppHasNoBotException,
11206: LarkUserCannotGrantToChatAdminException,
11207: LarkAppUnavailableException,
11209: LarkAppNotExistException,
11210: LarkAppUsageInfoNotExistException,
11211: LarkInviteUserToChatInvalidParamsException,
11212: LarkRemoveUserFromChatInvalidParamsException,
11213: LarkUpdateChatInvalidParamsException,
11214: LarkUploadImageInvalidParamsException,
11215: LarkEmptyChatIDException,
11216: LarkGetChatIDFailException,
11217: LarkInviteBotToChatFailException,
11218: LarkBotInChatFullException,
11219: LarkUnsupportedChatCrossTenantException,
11220: LarkForbiddenBotDisbandChatException,
11221: LarkBotForbiddenToGetImageBelongToThemException,
11222: LarkOwnerOfBotIsNotInChatException,
11223: LarkNotOpenApplicationSendMessagePermissionException,
11224: LarkInvalidMessageIDException,
11225: LarkAppIsNotVisibleToUserException,
11226: LarkInvalidAppIDException,
11227: LarkImageKeyNotExistException,
11234: LarkBotIsNotMessageOwnerException,
11235: LarkBanAtALLException,
11236: LarkUserNotActiveException,
11237: LarkChatDisbandedException,
11238: LarkMessageTooOldException,
11239: LarkNoPermissionToGotException,
# 云空间
90201: LarkDriveWrongRequestJsonException,
90202: LarkDriveWrongRangeException,
90203: LarkDriveFailException,
90204: LarkDriveWrongRequestBodyException,
90205: LarkDriveInvalidUsersException,
90206: LarkDriveEmptySheetIDException,
90207: LarkDriveEmptySheetTitleException,
90208: LarkDriveSameSheetIDOrTitleException,
90209: LarkDriveExistSheetIDException,
90210: LarkDriveExistSheetTitleException,
90211: LarkDriveWrongSheetIDException,
90212: LarkDriveWrongRowOrColException,
90213: LarkDrivePermissionFailException,
90214: LarkDriveSpreadSheetNotFoundException,
90215: LarkDriveSheetIDNotFoundException,
90216: LarkDriveEmptyValueException,
90217: LarkDriveTooManyRequestException,
96402: LarkDriveTimeoutException,
96403: LarkDriveProcessingException,
91404: LarkDriveLoginRequiredException,
91206: LarkDriveOutOfLimitException,
91207: LarkDriveDuplicateException,
91003: LarkDriveInvalidOperationException,
91004: LarkDriveUserNoSharePermissionException,
# 会议室
100001: LarkMeetingRoomInvalidPageTokenException,
100002: LarkMeetingRoomInvalidFieldSelectionException,
100003: LarkMeetingRoomTimeFormatMustFollowRFC3339StandardException,
100004: LarkMeetingRoomInvalidBuildingIDException,
100005: LarkMeetingRoomInvalidRoomIDException,
}
if code in exceptions:
return exceptions[code](code=code, msg=msg, url=url)
if 18000 <= code <= 20000:
return LarkInternalException(code=code, msg=msg, url=url)
if code in [4002, 60002]:
return LarkApprovalNotExistException(code=code, msg=msg, url=url)
if code in [90303, 91002, 91204, 91403]:
return LarkDriveForbiddenException(code=code, msg=msg, url=url)
if code in [90301, 91201, 96401]:
return LarkDriveFailedException(code=code, msg=msg, url=url)
if code in [90302, 91001, 91202, 91401]:
return LarkDriveParamErrorException(code=code, msg=msg, url=url)
if code in [90304, 91205]:
return LarkDriveMetaDeletedException(code=code, msg=msg, url=url)
if code in [90305, 91203, 91402]:
return LarkDriveMetaNotExistException(code=code, msg=msg, url=url)
if code in [90306, 91208]:
return LarkDriveReviewNotPassException(code=code, msg=msg, url=url)
if code in [90399, 95201, 95299, 96201, 96202, 96001] or (code >= 95201 and code <= 95209):
return LarkDriveInternalErrorException(code=code, msg=msg, url=url)
return OpenLarkException(code=code, msg=msg, url=url)

125
utils/feishu/helper.py Normal file
View File

@ -0,0 +1,125 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import time
from datetime import datetime
from enum import Enum
from io import BytesIO
from typing import TYPE_CHECKING
from six import PY2, string_types
from six.moves.urllib.request import urlretrieve
from utils.feishu.exception import LarkInvalidArguments
if TYPE_CHECKING:
from datetime import tzinfo
def to_timestamp(t):
"""
:param t:
:type t: datetime
:return:
:rtype: int
"""
try:
return int(t.timestamp())
except AttributeError:
return int((time.mktime(t.timetuple()) + t.microsecond / 1000000.0))
def to_native(s):
"""转成 str
:type s: Union[str, bytes]
:rtype str
"""
if isinstance(s, bytes):
return s.decode('utf-8')
return s
def _read_from_url(url):
filename, _ = urlretrieve(url)
return open(filename, 'rb')
def _read_from_file(file):
return open(file, 'rb')
def to_file_like(image):
"""
:param image:
:type image: Union[string_types, bytes, BytesIO]
:return:
"""
if isinstance(image, bytes):
return BytesIO(image)
if isinstance(image, string_types):
if image.startswith(str('http://')) or image.startswith(str('https://')):
return _read_from_url(image)
return _read_from_file(image)
return image
def converter_enum(value, ranges=None):
v = value.value if isinstance(value, Enum) else value
if ranges is not None:
ranges_v = [i.value if isinstance(i, Enum) else i for i in ranges]
if v not in ranges_v:
raise LarkInvalidArguments(msg='enum: %s should be in ranges: %s' % (v, ' / '.join(map(str, ranges_v))))
return v
def datetime_format_rfc3339(d, default_tz=None):
"""datetime 转 RFC3339 格式的时间字符串
:param d: datetime
:type d: datetime
:param default_tz:
:type default_tz: tzinfo
:return: RFC3339 格式的时间字符串
:rtype: str
"""
# 如果没有时区,给一个 default_tz
if not d.tzinfo and default_tz:
d = d.replace(tzinfo=default_tz)
return d.astimezone(d.tzinfo).isoformat()
def join_url(base_url, qs, sep='?'):
url = base_url
qs = '&'.join(map(lambda x: '{}={}'.format(x[0], x[1]), filter(lambda x: x[1], qs)))
if qs:
url = url + sep + qs
return url
def join_dict(base, d):
for i in d:
key, val = i[0], i[1]
if isinstance(val, bool):
if val is not None:
base[key] = val
else:
if val:
base[key] = val
return base
def pop_or_none(d, key):
try:
return d.pop(key)
except KeyError:
return

View File

@ -0,0 +1,325 @@
# coding: utf-8
from __future__ import absolute_import, division, print_function, unicode_literals
import time
from collections import OrderedDict
from threading import RLock
_NOTSET = object()
class _Cache(object):
"""
An in-memory, FIFO cache object that supports:
- Maximum number of cache entries
- Global TTL default
- Per cache entry TTL
- TTL first/non-TTL FIFO cache eviction policy
Cache entries are stored in an ``OrderedDict`` so that key ordering based
on the cache type can be maintained without the need for additional
list(s). Essentially, the key order of the ``OrderedDict`` is treated as an
"eviction queue" with the convention that entries at the beginning of the
queue are "newer" while the entries at the end are "older" (the exact
meaning of "newer" and "older" will vary between different cache types).
When cache entries need to be evicted, expired entries are removed first
followed by the "older" entries (i.e. the ones at the end of the queue).
Attributes:
maxsize (int, optional): Maximum size of cache dictionary. Defaults to ``256``.
ttl (int, optional): Default TTL for all cache entries. Defaults to ``0`` which
means that entries do not expire.
timer (callable, optional): Timer function to use to calculate TTL expiration.
Defaults to ``time.time``.
default (mixed, optional): Default value or function to use in :meth:`get` when
key is not found. If callable, it will be passed a single argument, ``key``,
and its return value will be set for that cache key.
"""
def __init__(self, maxsize=None, ttl=None, timer=None, default=None):
if maxsize is None:
maxsize = 256
if ttl is None:
ttl = 0
if timer is None:
timer = time.time
self.setup()
self.configure(maxsize=maxsize, ttl=ttl, timer=timer, default=default)
def setup(self):
self._cache = OrderedDict()
self._expire_times = {}
self._lock = RLock()
def configure(self, maxsize=None, ttl=None, timer=None, default=None):
"""
Configure cache settings. This method is meant to support runtime level
configurations for global level cache objects.
"""
if maxsize is not None:
if not isinstance(maxsize, int):
raise TypeError("maxsize must be an integer")
if not maxsize >= 0:
raise ValueError("maxsize must be greater than or equal to 0")
self.maxsize = maxsize
if ttl is not None:
if not isinstance(ttl, (int, float)):
raise TypeError("ttl must be a number")
if not ttl >= 0:
raise ValueError("ttl must be greater than or equal to 0")
self.ttl = ttl
if timer is not None:
if not callable(timer):
raise TypeError("timer must be a callable")
self.timer = timer
self.default = default
def __repr__(self):
return "{}({})".format(self.__class__.__name__, list(self.copy().items()))
def __len__(self):
with self._lock:
return len(self._cache)
def __contains__(self, key):
with self._lock:
return key in self._cache
def __iter__(self):
for i in self.keys():
yield i
def __next__(self):
return next(iter(self._cache))
def next(self):
return next(iter(self._cache))
def copy(self):
"""
Return a copy of the cache.
Returns:
OrderedDict
"""
with self._lock:
return self._cache.copy()
def keys(self):
"""
Return ``dict_keys`` view of all cache keys.
Note:
Cache is copied from the underlying cache storage before returning.
Returns:
dict_keys
"""
return self.copy().keys()
def _has(self, key):
# Use get method since it will take care of evicting expired keys.
return self._get(key, default=_NOTSET) is not _NOTSET
def size(self):
"""Return number of cache entries."""
return len(self)
def full(self):
"""
Return whether the cache is full or not.
Returns:
bool
"""
if self.maxsize == 0:
return False
return len(self) >= self.maxsize
def get(self, key, default=None):
"""
Return the cache value for `key` or `default` or ``missing(key)`` if it doesn't
exist or has expired.
Args:
key (mixed): Cache key.
default (mixed, optional): Value to return if `key` doesn't exist. If any
value other than ``None``, then it will take precendence over
:attr:`missing` and be used as the return value. If `default` is
callable, it will function like :attr:`missing` and its return value
will be set for the cache `key`. Defaults to ``None``.
Returns:
mixed: The cached value.
"""
with self._lock:
return self._get(key, default=default)
def _get(self, key, default=None):
try:
value = self._cache[key]
if self.expired(key):
self._delete(key)
raise KeyError
except KeyError:
if default is None:
default = self.default
if callable(default):
value = default(key)
self._set(key, value)
else:
value = default
return value
def add(self, key, value, ttl=None):
"""
Add cache key/value if it doesn't already exist. Essentially, this method
ignores keys that exist which leaves the original TTL in tact.
Note:
Cache key must be hashable.
Args:
key (mixed): Cache key to add.
value (mixed): Cache value.
ttl (int, optional): TTL value. Defaults to ``None`` which uses :attr:`ttl`.
"""
with self._lock:
self._add(key, value, ttl=ttl)
def _add(self, key, value, ttl=None):
if self._has(key):
return
self._set(key, value, ttl=ttl)
def set(self, key, value, ttl=None):
"""
Set cache key/value and replace any previously set cache key. If the cache key
previous existed, setting it will move it to the end of the cache stack which
means it would be evicted last.
Note:
Cache key must be hashable.
Args:
key (mixed): Cache key to set.
value (mixed): Cache value.
ttl (int, optional): TTL value. Defaults to ``None`` which uses :attr:`ttl`.
"""
with self._lock:
self._set(key, value, ttl=ttl)
def _set(self, key, value, ttl=None):
if ttl is None:
ttl = self.ttl
if key not in self:
self.evict()
self._delete(key)
self._cache[key] = value
if ttl and ttl > 0:
self._expire_times[key] = self.timer() + ttl
def _delete(self, key):
count = 0
try:
del self._cache[key]
count = 1
except KeyError:
pass
try:
del self._expire_times[key]
except KeyError:
pass
return count
def delete_expired(self):
"""
Delete expired cache keys and return number of entries deleted.
Returns:
int: Number of entries deleted.
"""
with self._lock:
return self._delete_expired()
def _delete_expired(self):
count = 0
if not self._expire_times:
return count
# Use a static expiration time for each key for better consistency as opposed to
# a newly computed timestamp on each iteration.
expires_on = self.timer()
expire_times = self._expire_times.copy()
for key, expiration in expire_times.items():
if expiration <= expires_on:
count += self._delete(key)
return count
def expired(self, key, expires_on=None):
"""
Return whether cache key is expired or not.
Args:
key (mixed): Cache key.
expires_on (float, optional): Timestamp of when the key is considered
expired. Defaults to ``None`` which uses the current value returned from
:meth:`timer`.
Returns:
bool
"""
if not expires_on:
expires_on = self.timer()
try:
return self._expire_times[key] <= expires_on
except KeyError:
return key not in self
def evict(self):
"""
Perform cache eviction per the cache replacement policy:
- First, remove **all** expired entries.
- Then, remove non-TTL entries using the cache replacement policy.
When removing non-TTL entries, this method will only remove the minimum number
of entries to reduce the number of entries below :attr:`maxsize`. If
:attr:`maxsize` is ``0``, then only expired entries will be removed.
Returns:
int: Number of cache entries evicted.
"""
count = self.delete_expired()
if not self.full():
return count
with self._lock:
while self.full():
try:
self._popitem()
except KeyError: # pragma: no cover
break
count += 1
return count
def _popitem(self):
try:
key = next(self)
except StopIteration:
raise KeyError("popitem(): cache is empty")
value = self._cache[key]
self._delete(key)
return (key, value)

68
utils/feishu_ops.py Normal file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# @FileName feishu_ops.py
# @Software:
# @Author: Leven Xiang
# @Mail: xiangle0109@outlook.com
# @Date 2021/5/20 15:28
from __future__ import absolute_import, unicode_literals
import os
from utils.feishu import OpenLark as FeiShu
from utils.storage.memorystorage import MemoryStorage
from pwdselfservice import cache_storage
from utils.feishu.helper import to_native
APP_ENV = os.getenv('APP_ENV')
if APP_ENV == 'dev':
from conf.local_settings_dev import *
else:
from conf.local_settings import *
class FeiShuOps(FeiShu):
def __init__(self, corp_id=None, app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, token_store=cache_storage):
super().__init__(app_id, app_secret)
self.corp_id = corp_id
self.app_id = app_id
self.app_secret = app_secret
self.token_store = token_store or MemoryStorage()
self.token_store.prefix = "feishu:%s" % ("app_id:%s" % self.app_id)
@property
def app_access_token(self):
"""
重写app_access_token使用自己的token_storage
"""
key_app_access_token = 'app_token'.format(self.app_id)
cache_token = self.token_store.get(key_app_access_token)
if cache_token:
return to_native(cache_token)
body = {
'app_id': self.app_id,
'app_secret': self.app_secret
}
if self.is_isv:
url = self._gen_request_url('/open-apis/auth/v3/app_access_token/')
body['app_ticket'] = self.app_ticket
else:
url = self._gen_request_url('/open-apis/auth/v3/app_access_token/internal/')
res = self._post(url, body)
app_access_token = res['app_access_token']
expire = res['expire']
if expire <= 360:
return app_access_token
self.token_store.set(key_app_access_token, app_access_token, expire - 100)
return app_access_token
if __name__ == '__main__':
fs = FeiShuOps()
print(fs.get_user(user_id='4g924c3b'))