# 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')