diff --git a/pwdselfservice/settings.py b/pwdselfservice/settings.py index c5ab298..419dd16 100644 --- a/pwdselfservice/settings.py +++ b/pwdselfservice/settings.py @@ -17,6 +17,10 @@ SECRET_KEY = 'nxnm3#&2tat_c2i6%$y74a)t$(3irh^gpwaleoja1kdv30fmcm' ALLOWED_HOSTS = ['*'] +# 不安全的内部初始密码,用于检验新密码 +UN_SEC_PASSWORD = ['1qaz@WSX', '1234@Abc'] + + # 创建日志的路径 LOG_PATH = os.path.join(BASE_DIR, 'log') # 如果地址不存在,则会自动创建log文件夹 diff --git a/readme.md b/readme.md index d23f29c..618d82d 100644 --- a/readme.md +++ b/readme.md @@ -45,16 +45,20 @@ Redis的安装和配置方法请自行百度,比较简单 + 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) -![截图12](screenshot/微扫码12.png) +![截图11](screenshot/115.png) +![截图11](screenshot/116.png) +#### 扫码成功之后: +![截图15](screenshot/113.png) +![截图15](screenshot/114.png) + ## 钉钉必要条件: #### 创建企业内部应用 * 在钉钉工作台中通过“自建应用”创建应用,选择“企业内部开发”,创建H5微应用或小程序,在应用首页中获取应用的:AgentId、AppKey、AppSecret。 @@ -68,6 +72,7 @@ Redis的安装和配置方法请自行百度,比较简单 ![截图5](screenshot/h5微应用--权限管理.png) + #### 移动接入应用--登录权限: >登录中开启扫码登录,配置回调域名:“https://pwd.abc.com/callbackCheck” 其中pwd.abc.com请按自己实际域名来,并记录相关的:appId、appSecret。 @@ -89,6 +94,10 @@ Redis的安装和配置方法请自行百度,比较简单 ![截图10](screenshot/微扫码16.png) +## 飞书必要条件: + * 开放平台-->创建应-->网页开启-->配置回调url +> 飞书接口项目地址:https://github.com/larksuite/feishu 感谢大佬,节省了不少时间。 + ## 使用脚本自动部署: 使用脚本自动快速部署,只适合Centos,其它发行版本的Linux请自行修改相关命令。 diff --git a/requestment b/requestment index 4d96928..c6419a0 100644 --- a/requestment +++ b/requestment @@ -6,5 +6,6 @@ dingtalk-sdk==1.3.8 cryptography==3.4.7 ldap3==2.9 django-redis==4.12.1 +feishu-python-sdk==0.1.4 requests uwsgi \ No newline at end of file diff --git a/resetpwd/utils.py b/resetpwd/utils.py index aeee513..40e79c1 100644 --- a/resetpwd/utils.py +++ b/resetpwd/utils.py @@ -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) if unlock_status: context = { - 'msg': "密码己修改/重置成功,请妥善保管。你可以点击返回主页或直接关闭此页面!", + 'msg': "密码己修改成功,请妥善保管。你可以点击返回主页或直接关闭此页面!", 'button_click': "window.location.href='%s'" % home_url, 'button_display': "返回主页" } diff --git a/resetpwd/views.py b/resetpwd/views.py index 240c2f7..ab30690 100644 --- a/resetpwd/views.py +++ b/resetpwd/views.py @@ -1,3 +1,4 @@ +import json import logging import os 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 .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 django.conf import settings APP_ENV = os.getenv('APP_ENV') 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: - 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') class PARAMS(object): if SCAN_CODE_TYPE == 'DING': + corp_id = DING_CORP_ID app_id = DING_MO_APP_ID agent_id = None SCAN_APP = '钉钉' from utils.dingding_ops import DingDingOps ops = DingDingOps() elif SCAN_CODE_TYPE == 'WEWORK': + corp_id = None app_id = WEWORK_CORP_ID agent_id = WEWORK_AGENT_ID SCAN_APP = '微信' from utils.wework_ops import WeWorkOps ops = WeWorkOps() else: + corp_id = None app_id = WEWORK_CORP_ID agent_id = WEWORK_AGENT_ID SCAN_APP = '微信' @@ -56,16 +61,22 @@ def index(request): :return: """ home_url = '%s://%s' % (request.scheme, HOME_URL) + corp_id = scan_params.corp_id app_id = scan_params.app_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': return render(request, 'ding_index.html', locals()) 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': - return render(request, 'feishu_index.html', locals()) + return render(request, 'index.v1.html', locals()) 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': # 对前端提交的数据进行二次验证,防止恶意提交简单密码或篡改账号。 @@ -167,7 +178,7 @@ def reset_pwd_by_callback(request): context = { 'username': username, } - return render(request, 'resetPassword.html', context) + return render(request, 'resetPassword.v1.html', context) else: context = { 'msg': "{},您好,企业{}中未能找到您账号的邮箱配置,请联系HR完善信息。".format(user_info.get('name'), scan_params.SCAN_APP), @@ -217,7 +228,7 @@ def unlock_account(request): context = { 'username': username, } - return render(request, 'resetPassword.html', context) + return render(request, 'resetPassword.v1.html', context) elif request.method == 'POST': _status, user_info = crypto_id_2_user_info(_ops, request, msg_template, home_url, scan_params.SCAN_APP) diff --git a/screenshot/111.png b/screenshot/111.png new file mode 100644 index 0000000..b87a4f9 Binary files /dev/null and b/screenshot/111.png differ diff --git a/screenshot/112.png b/screenshot/112.png new file mode 100644 index 0000000..d23487d Binary files /dev/null and b/screenshot/112.png differ diff --git a/screenshot/113.png b/screenshot/113.png new file mode 100644 index 0000000..c03697d Binary files /dev/null and b/screenshot/113.png differ diff --git a/screenshot/114.png b/screenshot/114.png new file mode 100644 index 0000000..0b6aaed Binary files /dev/null and b/screenshot/114.png differ diff --git a/screenshot/115.png b/screenshot/115.png new file mode 100644 index 0000000..4b7217d Binary files /dev/null and b/screenshot/115.png differ diff --git a/screenshot/116.png b/screenshot/116.png new file mode 100644 index 0000000..5347bff Binary files /dev/null and b/screenshot/116.png differ diff --git a/static/css/dmaku.css b/static/css/dmaku.css new file mode 100644 index 0000000..cfde5eb --- /dev/null +++ b/static/css/dmaku.css @@ -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%); +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/logo.jpg b/static/img/logo.jpg deleted file mode 100644 index 46e17ea..0000000 Binary files a/static/img/logo.jpg and /dev/null differ diff --git a/static/img/logo.png b/static/img/logo.png index c14b9a4..94453ac 100644 Binary files a/static/img/logo.png and b/static/img/logo.png differ diff --git a/static/js/alert.js b/static/js/alert.js new file mode 100644 index 0000000..49afe3b --- /dev/null +++ b/static/js/alert.js @@ -0,0 +1,69 @@ +(function ($) { + if (!$) { + throw new Error('jQuery is undefined!'); + } + $('head').append( + "" + ); + $.extend({ + alert: function () { + var args = arguments; + if ( + args.length && + typeof args[0] == 'string' && + !$('#alert_msg').length + ) { + var dialog = $( + '
' + + args[0] + + '
' + ); + 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 = $( + '
' + + args[0] + + '
' + ); + 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); \ No newline at end of file diff --git a/static/js/bubbly-bg.js b/static/js/bubbly-bg.js new file mode 100644 index 0000000..b7a7527 --- /dev/null +++ b/static/js/bubbly-bg.js @@ -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;de&&(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)})}()}; \ No newline at end of file diff --git a/static/js/check.js b/static/js/check.js index 1eff463..6456314 100644 --- a/static/js/check.js +++ b/static/js/check.js @@ -1,87 +1,39 @@ -$(function () { - $(".content .con_right .left").click(function (e) { - $(this).css({ "color": "#333333", "border-bottom": "2px solid #2e558e" }); - $(".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 () { + +function BtnClick(btn, type, unsecpwd) { + $(btn).click(function () { // ^(?=.*[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}$ // 判断密码满足大写字母,小写字母,数字和特殊字符,其中四种组合都需要包含 // (?=.*[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_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($('#new_password').val()) === '') { - alert('请输入密码'); + let new_password = $('#new_password').val() + let old_password = $('#old_password').val() + let ensure_password = $('#ensure_password').val() + if ($.trim(old_password) === '' && type === 'modify') { + $.alert('请输入旧密码'); return false; - } else if ($.trim($('#ensure_password').val()) === '') { - alert('请再次输入新密码'); + } else if ($.trim(new_password) === '') { + $.alert('请输入新密码'); return false; - } else if ($.trim($('#new_password').val()) === '1qaz@WSX') { - alert('密码1qaz@WSX为初始密码,禁止使用,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。'); + } else if (jQuery.inArray(new_password, unsecpwd) !== -1) { + $.alert('弱密码禁止使用,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。'); return false; - } else if (!regex_pwd.test($.trim($('#new_password').val()))) { - alert('密码不符合复杂度规则,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。\n例如:1qaz@WSX'); + } else if (!regex_pwd.test($.trim(new_password))) { + $.alert('密码不符合复杂度规则,请重新输入新密码。\n密码必须同时包含大写、小写、数字和特殊字符其中三项且至少8位。'); return false; - } else if ($.trim($('#ensure_password').val()) !== $.trim($('#new_password').val())) { - alert('两次输入的新密码不一致'); + } else if ($.trim(ensure_password) === '') { + $.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; } else { return true; } }); -}) +} \ No newline at end of file diff --git a/static/js/ddLogin.js b/static/js/ddLogin-0.0.5.js similarity index 86% rename from static/js/ddLogin.js rename to static/js/ddLogin-0.0.5.js index 41abd9a..a964481 100644 --- a/static/js/ddLogin.js +++ b/static/js/ddLogin-0.0.5.js @@ -1,18 +1,19 @@ !function (window, document) { function d(a) { 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.href ? "&href=" + a.href : "", c.src = d, c.frameBorder = "0", c.allowTransparency = "true", 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", e = document.getElementById(a.id), e.innerHTML = "", e.appendChild(c) } + window.DDLogin = d -}(window, document); +}(window, document); \ No newline at end of file diff --git a/static/js/dmaku.js b/static/js/dmaku.js new file mode 100644 index 0000000..5380518 --- /dev/null +++ b/static/js/dmaku.js @@ -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') + }); +} diff --git a/static/js/wwLogin-1.0.0.js b/static/js/wwLogin-1.0.0.js new file mode 100644 index 0000000..ce7f400 --- /dev/null +++ b/static/js/wwLogin-1.0.0.js @@ -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); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..8d20445 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,40 @@ +{% load static %} + + + + + 自助密码平台 + + + + + {% block head %}{% endblock %} + + +
+

+ 密码自助服务平台 +

+
+
+
+ {% block right-content %} {% endblock %} +
+
+ {% block left-content %} {% endblock %} +
+
+
+
+ {% block left-overlay %} {% endblock %} +
+
+ {% block right-overlay %} {% endblock %} +
+
+
+
+ +{% block footer %} {% endblock %} + + \ No newline at end of file diff --git a/templates/callbackCheck.html b/templates/callbackCheck.html deleted file mode 100644 index c86d390..0000000 --- a/templates/callbackCheck.html +++ /dev/null @@ -1,15 +0,0 @@ -{% load static %} - - -密码自助服务 - - - - - - - -
- {% csrf_token %} -
- \ No newline at end of file diff --git a/templates/ding_index.html b/templates/ding_index.html index db4c7c6..bc24641 100644 --- a/templates/ding_index.html +++ b/templates/ding_index.html @@ -1,118 +1,81 @@ +{% extends 'base.html' %} {% load static %} - - -密码自助服务 - - - - - - -
-
-
- -
-
-
-
-

「域账号或邮箱」密码自助平台

-
-
-

提示:新密码要求满足8至30位长度(不包含空格),至少包含大小写字母及数字组成。

-

如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。

-
-
-
- -
    -
  • -
    - {% csrf_token %} -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -

    - -
    -
  • - - -
-
-
-
-
- - \ No newline at end of file +{% block head %} + +{% endblock %} +{% block right-content %} +
+
+

使用钉钉扫一扫进行登录验证

+ +
+{% endblock %} +{% block left-content %} +
+ {% csrf_token %} +

修改密码

+ 新密码8至30位长度,要求包含大小写字母及数字。 + + + + +

+ +
+{% endblock %} +{% block left-overlay %} +

我要修改密码

+

记得自己的旧密码,需要自行修改

+

⬇️点它

+ +{% endblock %} +{% block right-overlay %} +

忘记密码或被锁

+

如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置

+

⬇️点它

+ +{% endblock %} +{% block footer %} + +{% endblock %} diff --git a/templates/ding_index.v1.html b/templates/ding_index.v1.html new file mode 100644 index 0000000..e115568 --- /dev/null +++ b/templates/ding_index.v1.html @@ -0,0 +1,102 @@ +{% load static %} + + + + + 自助密码平台 + + + + + + + +
+

+ 密码自助服务平台 +

+
+
+
+
+
+

使用钉钉扫一扫进行登录验证

+ +
+
+
+
+ {% csrf_token %} +

修改密码

+ 新密码8至30位长度,要求包含大小写字母及数字。 + + + + +

+ +
+
+
+
+
+

我要修改密码

+

记得自己的旧密码,需要自行修改

+

⬇️点它

+ +
+
+

忘记密码或被锁

+

如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置

+

⬇️点它

+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/feishu_index.v1.html b/templates/feishu_index.v1.html new file mode 100644 index 0000000..0ab209b --- /dev/null +++ b/templates/feishu_index.v1.html @@ -0,0 +1,78 @@ +{% load static %} + + + + + 自助密码平台 + + + + + + + +
+

+ 密码自助服务平台 +

+
+
+
+
+
+ +

使用企业微信扫一扫

+
+
+
+
+ {% csrf_token %} +

修改密码

+ 新密码8至30位长度,要求包含大小写字母及数字。 + + + + +

+ +
+
+
+
+
+

我要修改密码

+

记得自己的旧密码,需要自行修改

+

⬇️点它

+ +
+
+

忘记密码或被锁

+

如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置

+

⬇️点它

+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/index.v1.html b/templates/index.v1.html new file mode 100644 index 0000000..51aa4c2 --- /dev/null +++ b/templates/index.v1.html @@ -0,0 +1,103 @@ +{% load static %} + + + + + 自助密码平台 + + + + + + + + + +
+

+ 密码自助服务平台 +

+
+
+
+
+
+ +
+
+
+
+ {% csrf_token %} +

修改密码

+ 新密码8至30位长度,要求包含大小写字母及数字。 + + + + +

+ +
+
+
+
+
+

我要修改密码

+

记得自己的旧密码,需要自行修改

+

⬇️点它

+ +
+
+

忘记密码或被锁

+

如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置

+

⬇️点它

+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/messages.html b/templates/messages.html deleted file mode 100644 index 63ec27d..0000000 --- a/templates/messages.html +++ /dev/null @@ -1,44 +0,0 @@ -{% load static %} - - -密码自助服务 - - - - - -
-
-
-
-
-
-

「域账号或邮箱」密码自助平台

-
-
-

提示:新密码要求满足8至30位长度(不包含空格),至少包含大小写字母及数字组成。

-

如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。

-
-
-
-
-

提示

-
-
-
    -
  • - {% csrf_token %} -
    -

    {{ msg }}

    -

    - -
  • -
-
-
-
-
-
- \ No newline at end of file diff --git a/templates/messages.v1.html b/templates/messages.v1.html new file mode 100644 index 0000000..a95c219 --- /dev/null +++ b/templates/messages.v1.html @@ -0,0 +1,41 @@ +{% load static %} + + + + + 自助密码平台 + + + + + + + +
+

+ 密码自助服务平台 +

+
+
+
+
+
+ {% csrf_token %} +

结果

+

{{ msg }}

+
+
+
+
+
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/resetPassword.html b/templates/resetPassword.html deleted file mode 100644 index e14178c..0000000 --- a/templates/resetPassword.html +++ /dev/null @@ -1,72 +0,0 @@ -{% load static %} - - -密码自助服务 - - - - - - -
-
-
-
-
-
-

「域账号或邮箱」 - 密码自助平台

-
-
-

提示:新密码要求满足8至30位长度,包含大小写字母及数字。

-

如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。

-
-
-
-
-

重置密码

- 解锁账号 -
-
    -
  • -
    - {% csrf_token %} -
    -
    - -
    -
    - -
    -
    - -
    -

    -

    会话有效期5分钟,重密码会自动解锁被锁定的账号。

    - -
    -
  • - - -
-
-
-
-
- \ No newline at end of file diff --git a/templates/resetPassword.v1.html b/templates/resetPassword.v1.html new file mode 100644 index 0000000..91d95c7 --- /dev/null +++ b/templates/resetPassword.v1.html @@ -0,0 +1,66 @@ +{% load static %} + + + + + 自助密码平台 + + + + + + + +
+

+ 密码自助服务平台 +

+
+
+
+
+ {% csrf_token %} +

重置

+ +

+

+ +

会话有效期5分钟

+
+
+
+
+ {% csrf_token %} +

重置

+ 新密码8至30位长度,要求包含大小写字母及数字。 + + + +

+ +

会话有效期5分钟,重密码会自动解锁账号

+
+
+
+
+
+

我要重置密码

+

+

⬇️点它

+ +
+
+

我要解锁账号

+

+

⬇️点它

+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/we_index.html b/templates/we_index.html deleted file mode 100644 index da1df75..0000000 --- a/templates/we_index.html +++ /dev/null @@ -1,98 +0,0 @@ -{% load static %} - - - - - 密码自助服务 - - - - - - -
-
-
-
-
-
-
-

「域账号或邮箱」密码自助平台

-
-
-

提示:新密码要求满足8至30位长度(不包含空格),至少包含大小写字母及数字组成。

-

如果密码己遗忘,可点击[重置/解锁],使用钉钉扫描验证后直接重置密码。

-
-
-
- -
    -
  • -
    - {% csrf_token %} -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    - -
    -
  • - - -
-
-
-
-
- - - \ No newline at end of file diff --git a/templates/we_index.v1.html b/templates/we_index.v1.html new file mode 100644 index 0000000..196ba62 --- /dev/null +++ b/templates/we_index.v1.html @@ -0,0 +1,86 @@ +{% load static %} + + + + + 自助密码平台 + + + + + + + +
+

+ 密码自助服务平台 +

+
+
+
+
+
+ +

使用企业微信扫一扫登录验证

+
+
+
+
+ {% csrf_token %} +

修改密码

+ 新密码8至30位长度,要求包含大小写字母及数字。 + + + + +

+ +
+
+
+
+
+

我要修改密码

+

记得自己的旧密码,需要自行修改

+

⬇️点它

+ +
+
+

忘记密码或被锁

+

如果密码己遗忘,可点击[扫码验证],使用{{ scan_app }}扫码验证身份信息后进行重置

+

⬇️点它

+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/utils/feishu/README.MD b/utils/feishu/README.MD new file mode 100644 index 0000000..834f4e4 --- /dev/null +++ b/utils/feishu/README.MD @@ -0,0 +1,2 @@ +# 飞书接口项目地址,感谢大佬 +### https://github.com/larksuite/feishu \ No newline at end of file diff --git a/utils/feishu/__init__.py b/utils/feishu/__init__.py new file mode 100644 index 0000000..6727e04 --- /dev/null +++ b/utils/feishu/__init__.py @@ -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 ' + +__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', +} diff --git a/utils/feishu/__version__.py b/utils/feishu/__version__.py new file mode 100644 index 0000000..8b29c93 --- /dev/null +++ b/utils/feishu/__version__.py @@ -0,0 +1,3 @@ +VERSION = (0, 0, 1) + +__version__ = '.'.join(map(str, VERSION)) diff --git a/utils/feishu/api.py b/utils/feishu/api.py new file mode 100644 index 0000000..fbb96bb --- /dev/null +++ b/utils/feishu/api.py @@ -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') diff --git a/utils/feishu/api_app_link.py b/utils/feishu/api_app_link.py new file mode 100644 index 0000000..62709c9 --- /dev/null +++ b/utils/feishu/api_app_link.py @@ -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-semi、window、appCenter + :type mode: str + :param path: 需要跳转的页面路径,路径后可以带参数。也可以使用path_android、path_ios、path_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_android、min_lk_ver_ios、min_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) diff --git a/utils/feishu/api_application.py b/utils/feishu/api_application.py new file mode 100644 index 0000000..7cd7122 --- /dev/null +++ b/utils/feishu/api_application.py @@ -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) diff --git a/utils/feishu/api_approval.py b/utils/feishu/api_approval.py new file mode 100644 index 0000000..a3791ec --- /dev/null +++ b/utils/feishu/api_approval.py @@ -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: 文件,支持路径、bytes、BytesIO + :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 diff --git a/utils/feishu/api_bot.py b/utils/feishu/api_bot.py new file mode 100644 index 0000000..ea32377 --- /dev/null +++ b/utils/feishu/api_bot.py @@ -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']) diff --git a/utils/feishu/api_calendar.py b/utils/feishu/api_calendar.py new file mode 100644 index 0000000..281db40 --- /dev/null +++ b/utils/feishu/api_calendar.py @@ -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: + +# 邀请/移除日程参与者 + +# 获取访问控制列表 + +# 创建访问控制 + +# 删除访问控制 + +# 查询日历的忙闲状态 diff --git a/utils/feishu/api_callback.py b/utils/feishu/api_callback.py new file mode 100644 index 0000000..2e36dbf --- /dev/null +++ b/utils/feishu/api_callback.py @@ -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) diff --git a/utils/feishu/api_chat.py b/utils/feishu/api_chat.py new file mode 100644 index 0000000..11b7b73 --- /dev/null +++ b/utils/feishu/api_chat.py @@ -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 diff --git a/utils/feishu/api_contact.py b/utils/feishu/api_contact.py new file mode 100644 index 0000000..4aa4841 --- /dev/null +++ b/utils/feishu/api_contact.py @@ -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 diff --git a/utils/feishu/api_drive_comment.py b/utils/feishu/api_drive_comment.py new file mode 100644 index 0000000..6353efb --- /dev/null +++ b/utils/feishu/api_drive_comment.py @@ -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('<', '<'). \ + replace('>', '>'). \ + replace('&', '&'). \ + replace('\'', '''). \ + replace('"', '"') + + 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']) diff --git a/utils/feishu/api_drive_doc.py b/utils/feishu/api_drive_doc.py new file mode 100644 index 0000000..4def350 --- /dev/null +++ b/utils/feishu/api_drive_doc.py @@ -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']) diff --git a/utils/feishu/api_drive_file.py b/utils/feishu/api_drive_file.py new file mode 100644 index 0000000..294b7eb --- /dev/null +++ b/utils/feishu/api_drive_file.py @@ -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']) diff --git a/utils/feishu/api_drive_folder.py b/utils/feishu/api_drive_folder.py new file mode 100644 index 0000000..1064d66 --- /dev/null +++ b/utils/feishu/api_drive_folder.py @@ -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: 该文件夹的文档清单,如 doc、sheet、bitable、folder + :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()] diff --git a/utils/feishu/api_drive_permission.py b/utils/feishu/api_drive_permission.py new file mode 100644 index 0000000..e475e73 --- /dev/null +++ b/utils/feishu/api_drive_permission.py @@ -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'] diff --git a/utils/feishu/api_drive_sheet.py b/utils/feishu/api_drive_sheet.py new file mode 100644 index 0000000..a900292 --- /dev/null +++ b/utils/feishu/api_drive_sheet.py @@ -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)) diff --git a/utils/feishu/api_drive_suite.py b/utils/feishu/api_drive_suite.py new file mode 100644 index 0000000..999f2c1 --- /dev/null +++ b/utils/feishu/api_drive_suite.py @@ -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 diff --git a/utils/feishu/api_duty.py b/utils/feishu/api_duty.py new file mode 100644 index 0000000..6430d1e --- /dev/null +++ b/utils/feishu/api_duty.py @@ -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 diff --git a/utils/feishu/api_file.py b/utils/feishu/api_file.py new file mode 100644 index 0000000..c01cbcd --- /dev/null +++ b/utils/feishu/api_file.py @@ -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 diff --git a/utils/feishu/api_id.py b/utils/feishu/api_id.py new file mode 100644 index 0000000..30a5ec3 --- /dev/null +++ b/utils/feishu/api_id.py @@ -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 diff --git a/utils/feishu/api_image.py b/utils/feishu/api_image.py new file mode 100644 index 0000000..2066a48 --- /dev/null +++ b/utils/feishu/api_image.py @@ -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: 图片文件,支持路径、bytes、BytesIO + :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) diff --git a/utils/feishu/api_meeting_room.py b/utils/feishu/api_meeting_room.py new file mode 100644 index 0000000..204317c --- /dev/null +++ b/utils/feishu/api_meeting_room.py @@ -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) diff --git a/utils/feishu/api_message.py b/utils/feishu/api_message.py new file mode 100644 index 0000000..ec72b41 --- /dev/null +++ b/utils/feishu/api_message.py @@ -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: 消息卡片安全校验 diff --git a/utils/feishu/api_mina.py b/utils/feishu/api_mina.py new file mode 100644 index 0000000..85acbb0 --- /dev/null +++ b/utils/feishu/api_mina.py @@ -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) diff --git a/utils/feishu/api_oauth.py b/utils/feishu/api_oauth.py new file mode 100644 index 0000000..578dc1f --- /dev/null +++ b/utils/feishu/api_oauth.py @@ -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) diff --git a/utils/feishu/api_pay.py b/utils/feishu/api_pay.py new file mode 100644 index 0000000..947196a --- /dev/null +++ b/utils/feishu/api_pay.py @@ -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_id,open_id 和 user_id 两个参数必须包含其一,若同时传入取 open_id + :type open_id: str + :param user_id: 用户 user_id,user_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 diff --git a/utils/feishu/api_user.py b/utils/feishu/api_user.py new file mode 100644 index 0000000..b1a9222 --- /dev/null +++ b/utils/feishu/api_user.py @@ -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 diff --git a/utils/feishu/dt_application.py b/utils/feishu/dt_application.py new file mode 100644 index 0000000..c653dc4 --- /dev/null +++ b/utils/feishu/dt_application.py @@ -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) # 是否是启用 diff --git a/utils/feishu/dt_approval.py b/utils/feishu/dt_approval.py new file mode 100644 index 0000000..7169b56 --- /dev/null +++ b/utils/feishu/dt_approval.py @@ -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='') # 审批编号 diff --git a/utils/feishu/dt_calendar.py b/utils/feishu/dt_calendar.py new file mode 100644 index 0000000..23dbd61 --- /dev/null +++ b/utils/feishu/dt_calendar.py @@ -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)) diff --git a/utils/feishu/dt_callback.py b/utils/feishu/dt_callback.py new file mode 100644 index 0000000..3580414 --- /dev/null +++ b/utils/feishu/dt_callback.py @@ -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='') # 图片的url(image) + image_key = attr.ib(type=str, default='') # 图片的key(image) + + # 合并转发消息,(日历卡片、投票消息、会话记录等不支持合并转发) + 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)) diff --git a/utils/feishu/dt_code.py b/utils/feishu/dt_code.py new file mode 100644 index 0000000..c766b90 --- /dev/null +++ b/utils/feishu/dt_code.py @@ -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 diff --git a/utils/feishu/dt_contact.py b/utils/feishu/dt_contact.py new file mode 100644 index 0000000..fd98fb3 --- /dev/null +++ b/utils/feishu/dt_contact.py @@ -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='') # 角色名称 diff --git a/utils/feishu/dt_drive.py b/utils/feishu/dt_drive.py new file mode 100644 index 0000000..4334895 --- /dev/null +++ b/utils/feishu/dt_drive.py @@ -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) diff --git a/utils/feishu/dt_enum.py b/utils/feishu/dt_enum.py new file mode 100644 index 0000000..1592965 --- /dev/null +++ b/utils/feishu/dt_enum.py @@ -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' # 提前结束 diff --git a/utils/feishu/dt_help.py b/utils/feishu/dt_help.py new file mode 100644 index 0000000..249847f --- /dev/null +++ b/utils/feishu/dt_help.py @@ -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 diff --git a/utils/feishu/dt_meeting_room.py b/utils/feishu/dt_meeting_room.py new file mode 100644 index 0000000..7d94a2c --- /dev/null +++ b/utils/feishu/dt_meeting_room.py @@ -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) diff --git a/utils/feishu/dt_message.py b/utils/feishu/dt_message.py new file mode 100644 index 0000000..175b4e6 --- /dev/null +++ b/utils/feishu/dt_message.py @@ -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 + + +# 文字,图片,at,link,这四个在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 diff --git a/utils/feishu/dt_pay.py b/utils/feishu/dt_pay.py new file mode 100644 index 0000000..53d5ae8 --- /dev/null +++ b/utils/feishu/dt_pay.py @@ -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='') # 租户唯一标识 diff --git a/utils/feishu/dt_req.py b/utils/feishu/dt_req.py new file mode 100644 index 0000000..2922b70 --- /dev/null +++ b/utils/feishu/dt_req.py @@ -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 diff --git a/utils/feishu/exception.py b/utils/feishu/exception.py new file mode 100644 index 0000000..196dac4 --- /dev/null +++ b/utils/feishu/exception.py @@ -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) diff --git a/utils/feishu/helper.py b/utils/feishu/helper.py new file mode 100644 index 0000000..a974280 --- /dev/null +++ b/utils/feishu/helper.py @@ -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 diff --git a/utils/feishu/internal_cache.py b/utils/feishu/internal_cache.py new file mode 100644 index 0000000..344db1c --- /dev/null +++ b/utils/feishu/internal_cache.py @@ -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) diff --git a/utils/feishu_ops.py b/utils/feishu_ops.py new file mode 100644 index 0000000..b41ba8d --- /dev/null +++ b/utils/feishu_ops.py @@ -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')) +