├── .bumpversion.cfg ├── .gitignore ├── .readthedocs.yml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── dev-requirements.txt ├── dingtalk ├── __init__.py ├── client │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── attendance.py │ │ ├── base.py │ │ ├── blackboard.py │ │ ├── bpms.py │ │ ├── calendar.py │ │ ├── callback.py │ │ ├── chat.py │ │ ├── checkin.py │ │ ├── cspace.py │ │ ├── department.py │ │ ├── employeerm.py │ │ ├── ext.py │ │ ├── extcontact.py │ │ ├── health.py │ │ ├── message.py │ │ ├── microapp.py │ │ ├── report.py │ │ ├── role.py │ │ ├── taobao.py │ │ ├── user.py │ │ └── workrecord.py │ ├── base.py │ ├── channel.py │ └── isv.py ├── core │ ├── __init__.py │ ├── constants.py │ ├── exceptions.py │ └── utils.py ├── crypto │ ├── __init__.py │ ├── base.py │ ├── cryptography.py │ ├── pkcs7.py │ └── pycrypto.py ├── model │ ├── __init__.py │ ├── field.py │ └── message.py └── storage │ ├── __init__.py │ ├── cache.py │ ├── kvstorage.py │ └── memorystorage.py ├── docs ├── Makefile ├── changelog.rst ├── client │ ├── api │ │ ├── attendance.rst │ │ ├── blackboard.rst │ │ ├── bpms.rst │ │ ├── calendar.rst │ │ ├── callback.rst │ │ ├── chat.rst │ │ ├── checkin.rst │ │ ├── cspace.rst │ │ ├── department.rst │ │ ├── employeerm.rst │ │ ├── ext.rst │ │ ├── extcontact.rst │ │ ├── health.rst │ │ ├── message.rst │ │ ├── microapp.rst │ │ ├── report.rst │ │ ├── role.rst │ │ ├── taobao.rst │ │ ├── user.rst │ │ └── workrecord.rst │ ├── index.rst │ └── isv.rst ├── conf.py ├── index.rst ├── install.rst └── model │ ├── field.rst │ └── message.rst ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── test_crypto.py ├── test_message.py ├── test_storage.py └── test_utils.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | files = setup.py dingtalk/__init__.py 3 | commit = True 4 | tag = True 5 | current_version = 1.3.8 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | .pytest_cache 91 | 92 | .idea/ 93 | 94 | .DS_Store 95 | 96 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | formats: 2 | - none 3 | python: 4 | version: 3 5 | pip_install: true 6 | extra_requirements: 7 | - cryptography 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | # Use container-based infrastructure 4 | sudo: false 5 | 6 | matrix: 7 | include: 8 | - env: TOX_ENV=py27-cryptography 9 | python: 2.7 10 | - env: TOX_ENV=py27-pycrypto 11 | python: 2.7 12 | - env: TOX_ENV=py34-cryptography 13 | python: 3.4 14 | - env: TOX_ENV=py34-pycrypto 15 | python: 3.4 16 | - env: TOX_ENV=py35-cryptography 17 | python: 3.5 18 | - env: TOX_ENV=py35-pycrypto 19 | python: 3.5 20 | - env: TOX_ENV=py36-cryptography 21 | python: 3.6 22 | - env: TOX_ENV=py36-pycrypto 23 | python: 3.6 24 | - env: TOX_ENV=pypy-cryptography 25 | python: "pypy" 26 | - env: TOX_ENV=pypy3-cryptography 27 | python: "pypy3" 28 | 29 | services: 30 | - redis-server 31 | - memcached 32 | 33 | cache: 34 | directories: 35 | - $HOME/.cache/pip 36 | 37 | install: 38 | - pip install tox 39 | - pip install "flake8>=3.7" 40 | 41 | before_script: 42 | - "flake8 ." 43 | 44 | script: 45 | tox -e $TOX_ENV 46 | 47 | after_success: 48 | - | 49 | if [[ "${TRAVIS_TAG:-}" != "" && "$TOX_ENV" == "py36-cryptography" ]]; then 50 | python3.6 setup.py sdist bdist_wheel; 51 | python3.6 -m pip install twine; 52 | python3.6 -m twine upload --skip-existing dist/*; 53 | fi 54 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ####################### 2 | DingTalk Sdk for Python 3 | ####################### 4 | .. image:: https://travis-ci.org/007gzs/dingtalk-sdk.svg?branch=master 5 | :target: https://travis-ci.org/007gzs/dingtalk-sdk 6 | .. image:: https://img.shields.io/pypi/v/dingtalk-sdk.svg 7 | :target: https://pypi.org/project/dingtalk-sdk 8 | 9 | 钉钉开放平台第三方 Python SDK。 10 | `【阅读文档】 `_。 11 | 12 | ******** 13 | 功能特性 14 | ******** 15 | + 企业内部开发接入api 16 | + 应用服务商(ISV)接入api 17 | 18 | ******** 19 | 安装 20 | ******** 21 | 22 | 目前 dingtalk-sdk 支持的 Python 环境有 2.7, 3.4, 3.5, 3.6 和 pypy。 23 | 24 | dingtalk-sdk 消息加解密同时兼容 cryptography 和 PyCrypto, 优先使用 cryptography 库。 25 | 可先自行安装 cryptography 或者 PyCrypto 库:: 26 | 27 | # 安装 cryptography 28 | pip install cryptography>=0.8.2 29 | # 或者安装 PyCrypto 30 | pip install pycrypto>=2.6.1 31 | 32 | 为了简化安装过程,推荐使用 pip 进行安装 33 | 34 | .. code-block:: bash 35 | 36 | pip install dingtalk-sdk 37 | # with cryptography 38 | pip install dingtalk-sdk[cryptography] 39 | # with pycrypto 40 | pip install dingtalk-sdk[pycrypto] 41 | 42 | 升级 dingtalk-sdk 到新版本:: 43 | 44 | pip install -U dingtalk-sdk 45 | 46 | **************** 47 | 使用示例 48 | **************** 49 | 50 | django 示例 https://github.com/007gzs/dingtalk-django-example 51 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest 3 | redis 4 | pymemcache 5 | 6 | -------------------------------------------------------------------------------- /dingtalk/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import logging 4 | 5 | from dingtalk.client import SecretClient, AppKeyClient # NOQA 6 | from dingtalk.client.isv import ISVClient # NOQA 7 | from dingtalk.core.exceptions import DingTalkClientException, DingTalkException # NOQA 8 | 9 | __version__ = '1.3.8' 10 | __author__ = '007gzs' 11 | 12 | # Set default logging handler to avoid "No handler found" warnings. 13 | try: # Python 2.7+ 14 | from logging import NullHandler 15 | except ImportError: 16 | class NullHandler(logging.Handler): 17 | def emit(self, record): 18 | pass 19 | 20 | logging.getLogger(__name__).addHandler(NullHandler()) 21 | -------------------------------------------------------------------------------- /dingtalk/client/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import logging 5 | 6 | import time 7 | 8 | from dingtalk.client import api 9 | from dingtalk.client.api.taobao import TaobaoMixin 10 | from dingtalk.client.base import BaseClient 11 | from dingtalk.core.utils import DingTalkSigner, random_string 12 | from dingtalk.crypto import DingTalkCrypto 13 | from dingtalk.storage.cache import DingTalkCache 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class DingTalkClient(BaseClient, TaobaoMixin): 19 | 20 | attendance = api.Attendance() 21 | blackboard = api.BlackBoard() 22 | bpms = api.Bpms() 23 | calendar = api.Calendar() 24 | callback = api.Callback() 25 | chat = api.Chat() 26 | checkin = api.Checkin() 27 | cspace = api.Cspace() 28 | department = api.Department() 29 | ext = api.Ext() 30 | extcontact = api.ExtContact() 31 | employeerm = api.Employeerm() 32 | health = api.Health() 33 | message = api.Message() 34 | microapp = api.MicroApp() 35 | report = api.Report() 36 | role = api.Role() 37 | user = api.User() 38 | workrecord = api.WorkRecord() 39 | 40 | def __init__(self, corp_id, prefix='client', storage=None, timeout=None, auto_retry=True): 41 | super(DingTalkClient, self).__init__(storage, timeout, auto_retry) 42 | self.corp_id = corp_id 43 | self.cache = DingTalkCache(self.storage, "%s:%s" % (prefix, self.get_access_token_key())) 44 | 45 | def get_access_token_key(self): 46 | return "corp_id:%s" % self.corp_id 47 | 48 | @property 49 | def access_token(self): 50 | token = self.cache.access_token.get() 51 | if token is None: 52 | ret = self.get_access_token() 53 | token = ret['access_token'] 54 | expires_in = ret.get('expires_in', 7200) 55 | self.cache.access_token.set(value=token, ttl=expires_in) 56 | return token 57 | 58 | @property 59 | def jsapi_ticket(self): 60 | ticket = self.cache.jsapi_ticket.get() 61 | if ticket is None: 62 | ret = self.get_jsapi_ticket() 63 | ticket = ret['ticket'] 64 | expires_in = ret.get('expires_in', 7200) 65 | self.cache.jsapi_ticket.set(value=ticket, ttl=expires_in) 66 | return ticket 67 | 68 | def get_jsapi_params(self, url, noncestr=None, timestamp=None): 69 | if not noncestr: 70 | noncestr = random_string() 71 | if timestamp is None: 72 | timestamp = int(time.time() * 1000) 73 | data = [ 74 | 'noncestr={noncestr}'.format(noncestr=noncestr), 75 | 'jsapi_ticket={ticket}'.format(ticket=self.jsapi_ticket), 76 | 'timestamp={timestamp}'.format(timestamp=timestamp), 77 | 'url={url}'.format(url=url), 78 | ] 79 | signer = DingTalkSigner(delimiter=b'&') 80 | signer.add_data(*data) 81 | 82 | ret = { 83 | 'corpId': self.corp_id, 84 | 'timeStamp': timestamp, 85 | 'nonceStr': noncestr, 86 | 'signature': signer.signature 87 | } 88 | return ret 89 | 90 | def _handle_pre_request(self, method, uri, kwargs): 91 | if 'access_token=' in uri or 'access_token' in kwargs.get('params', {}): 92 | raise ValueError("uri参数中不允许有access_token: " + uri) 93 | uri = '%s%saccess_token=%s' % (uri, '&' if '?' in uri else '?', self.access_token) 94 | return method, uri, kwargs 95 | 96 | def _handle_pre_top_request(self, params, uri): 97 | if 'session=' in uri or 'session' in params: 98 | raise ValueError("uri参数中不允许有session: " + uri) 99 | params['session'] = self.access_token 100 | 101 | return super(DingTalkClient, self)._handle_pre_top_request(params, uri) 102 | 103 | def _handle_request_except(self, e, func, *args, **kwargs): 104 | if e.errcode in (33001, 40001, 42001, 40014): 105 | self.cache.access_token.delete() 106 | if self.auto_retry: 107 | return func(*args, **kwargs) 108 | raise e 109 | 110 | def get_jsapi_ticket(self): 111 | return self.get('/get_jsapi_ticket') 112 | 113 | def get_access_token(self): 114 | raise NotImplementedError 115 | 116 | 117 | class SecretClient(DingTalkClient): 118 | 119 | def __init__(self, corp_id, corp_secret, token=None, aes_key=None, storage=None, timeout=None, auto_retry=True): 120 | super(SecretClient, self).__init__(corp_id, 'secret:'+corp_id, storage, timeout, auto_retry) 121 | self.corp_secret = corp_secret 122 | self.crypto = DingTalkCrypto(token, aes_key, corp_id) 123 | 124 | def get_access_token(self): 125 | return self._request( 126 | 'GET', 127 | '/gettoken', 128 | params={'corpid': self.corp_id, 'corpsecret': self.corp_secret} 129 | ) 130 | 131 | 132 | class AppKeyClient(DingTalkClient): 133 | 134 | def __init__(self, corp_id, app_key, app_secret, token=None, aes_key=None, storage=None, timeout=None, 135 | auto_retry=True): 136 | self.app_key = app_key 137 | self.app_secret = app_secret 138 | super(AppKeyClient, self).__init__(corp_id, 'secret:' + corp_id, storage, timeout, auto_retry) 139 | self.crypto = DingTalkCrypto(token, aes_key, corp_id) 140 | 141 | def get_access_token_key(self): 142 | return "app_key:%s" % self.app_key 143 | 144 | def get_access_token(self): 145 | return self._request( 146 | 'GET', 147 | '/gettoken', 148 | params={'appkey': self.app_key, 'appsecret': self.app_secret} 149 | ) 150 | -------------------------------------------------------------------------------- /dingtalk/client/api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.attendance import Attendance # NOQA 5 | from dingtalk.client.api.blackboard import BlackBoard # NOQA 6 | from dingtalk.client.api.bpms import Bpms # NOQA 7 | from dingtalk.client.api.calendar import Calendar # NOQA 8 | from dingtalk.client.api.callback import Callback # NOQA 9 | from dingtalk.client.api.chat import Chat # NOQA 10 | from dingtalk.client.api.checkin import Checkin # NOQA 11 | from dingtalk.client.api.cspace import Cspace # NOQA 12 | from dingtalk.client.api.department import Department # NOQA 13 | from dingtalk.client.api.ext import Ext # NOQA 14 | from dingtalk.client.api.extcontact import ExtContact # NOQA 15 | from dingtalk.client.api.employeerm import Employeerm # NOQA 16 | from dingtalk.client.api.health import Health # NOQA 17 | from dingtalk.client.api.message import Message # NOQA 18 | from dingtalk.client.api.microapp import MicroApp # NOQA 19 | from dingtalk.client.api.report import Report # NOQA 20 | from dingtalk.client.api.role import Role # NOQA 21 | from dingtalk.client.api.user import User # NOQA 22 | from dingtalk.client.api.workrecord import WorkRecord # NOQA 23 | -------------------------------------------------------------------------------- /dingtalk/client/api/attendance.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import time 5 | import datetime 6 | 7 | from dingtalk.client.api.base import DingTalkBaseAPI 8 | 9 | 10 | class Attendance(DingTalkBaseAPI): 11 | 12 | DATE_TIME_FORMAT = '%Y-%m-%d %H:%M:%S' 13 | 14 | def list_record(self, user_ids, check_date_from, check_date_to): 15 | """ 16 | 考勤打卡记录开放 17 | 18 | :param user_ids: 企业内的员工id列表,最多不能超过50个 19 | :param check_date_from: 查询考勤打卡记录的起始工作日 20 | :param check_date_to: 查询考勤打卡记录的结束工作日。注意,起始与结束工作日最多相隔7天 21 | :return: 22 | """ 23 | if isinstance(check_date_from, (datetime.date, datetime.datetime)): 24 | check_date_from = check_date_from.strftime(self.DATE_TIME_FORMAT) 25 | if isinstance(check_date_to, (datetime.date, datetime.datetime)): 26 | check_date_to = check_date_to.strftime(self.DATE_TIME_FORMAT) 27 | 28 | return self._post( 29 | '/attendance/listRecord', 30 | { 31 | "userIds": user_ids, 32 | "checkDateFrom": check_date_from, 33 | "checkDateTo": check_date_to 34 | }, 35 | result_processor=lambda x: x['recordresult'] 36 | ) 37 | 38 | def list(self, work_date_from, work_date_to, user_ids=(), offset=0, limit=50): 39 | """ 40 | 考勤打卡数据开放 41 | 42 | :param work_date_from: 查询考勤打卡记录的起始工作日 43 | :param work_date_to: 查询考勤打卡记录的结束工作日 44 | :param user_ids: 员工在企业内的UserID列表,企业用来唯一标识用户的字段 45 | :param offset: 表示获取考勤数据的起始点,第一次传0,如果还有多余数据,下次获取传的offset值为之前的offset+limit 46 | :param limit: 表示获取考勤数据的条数,最大不能超过50条 47 | :return: 48 | """ 49 | if isinstance(work_date_from, (datetime.date, datetime.datetime)): 50 | work_date_from = work_date_from.strftime(self.DATE_TIME_FORMAT) 51 | if isinstance(work_date_to, (datetime.date, datetime.datetime)): 52 | work_date_to = work_date_to.strftime(self.DATE_TIME_FORMAT) 53 | 54 | return self._post( 55 | '/attendance/list', 56 | { 57 | "workDateFrom": work_date_from, 58 | "workDateTo": work_date_to, 59 | "userIdList": user_ids, 60 | "offset": offset, 61 | "limit": limit 62 | } 63 | ) 64 | 65 | def listschedule(self, work_date, offset=0, size=200): 66 | """ 67 | 考勤排班信息按天全量查询接 68 | 69 | :param work_date: 排班时间 70 | :param offset: 偏移位置 71 | :param size: 分页大小,最大200 72 | :return: 73 | """ 74 | if isinstance(work_date, (datetime.date, datetime.datetime)): 75 | work_date = work_date.strftime(self.DATE_TIME_FORMAT) 76 | return self._top_request( 77 | 'dingtalk.smartwork.attends.listschedule', 78 | { 79 | "work_date": work_date, 80 | "offset": offset, 81 | "size": size 82 | } 83 | ) 84 | 85 | def getsimplegroups(self, offset=0, size=10): 86 | """ 87 | 获取考勤组列表详情 88 | 89 | :param offset: 偏移位置 90 | :param size: 分页大小,最大10 91 | :return: 92 | """ 93 | return self._top_request( 94 | 'dingtalk.smartwork.attends.getsimplegroups', 95 | { 96 | "offset": offset, 97 | "size": size 98 | } 99 | ) 100 | 101 | def getleaveapproveduration(self, userid, from_date, to_date): 102 | """ 103 | 计算请假时长 104 | 105 | :param userid: 员工在企业内的UserID,企业用来唯一标识用户的字段。 106 | :param from_date: 请假开始时间 107 | :param to_date: 请假结束时间 108 | :return: 请假时长(单位分钟) 109 | """ 110 | if isinstance(from_date, (datetime.date, datetime.datetime)): 111 | from_date = from_date.strftime(self.DATE_TIME_FORMAT) 112 | if isinstance(to_date, (datetime.date, datetime.datetime)): 113 | to_date = to_date.strftime(self.DATE_TIME_FORMAT) 114 | 115 | return self._top_request( 116 | 'dingtalk.smartwork.attends.getleaveapproveduration', 117 | { 118 | "userid": userid, 119 | "from_date": from_date, 120 | "to_date": to_date 121 | }, 122 | result_processor=lambda x: x['duration_in_minutes'] 123 | ) 124 | 125 | def getleavestatus(self, userid_list, start_time, end_time, offset=0, size=20): 126 | """ 127 | 请假状态查询接口 128 | 该接口用于查询指定企业下的指定用户在指定时间段内的请假状态 129 | 130 | :param userid_list: 待查询用户id列表,支持最多100个用户的批量查询 131 | :param start_time: 开始时间 ,时间戳,支持最多180天的查询 132 | :param end_time: 结束时间,时间戳,支持最多180天的查询 133 | :param offset: 分页偏移,非负整数 134 | :param size: 分页大小,正整数,最大20 135 | """ 136 | if isinstance(start_time, (datetime.date, datetime.datetime)): 137 | start_time = int(time.mktime(start_time.timetuple()) * 1000) 138 | if isinstance(end_time, (datetime.date, datetime.datetime)): 139 | end_time = int(time.mktime(end_time.timetuple()) * 1000) 140 | return self._top_request( 141 | "dingtalk.oapi.attendance.getleavestatus", 142 | { 143 | "userid_list": userid_list, 144 | "start_time": start_time, 145 | "end_time": end_time, 146 | "offset": offset, 147 | "size": size 148 | } 149 | ) 150 | 151 | def getusergroup(self, userid): 152 | """ 153 | 获取用户考勤组 154 | 155 | :param userid: 员工在企业内的UserID,企业用来唯一标识用户的字段。 156 | :return: 157 | """ 158 | return self._top_request( 159 | 'dingtalk.smartwork.attends.getusergroup', 160 | {"userid": userid} 161 | ) 162 | -------------------------------------------------------------------------------- /dingtalk/client/api/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | 5 | class DingTalkBaseAPI(object): 6 | 7 | API_BASE_URL = None 8 | 9 | def __init__(self, client=None): 10 | self._client = client 11 | 12 | def _get(self, url, params=None, **kwargs): 13 | if self.API_BASE_URL: 14 | kwargs['api_base_url'] = self.API_BASE_URL 15 | return self._client.get(url, params, **kwargs) 16 | 17 | def _post(self, url, data=None, params=None, **kwargs): 18 | if self.API_BASE_URL: 19 | kwargs['api_base_url'] = self.API_BASE_URL 20 | return self._client.post(url, data, params, **kwargs) 21 | 22 | def _top_request(self, method, params=None, format_='json', v='2.0', 23 | simplify='false', partner_id=None, url=None, **kwargs): 24 | if self.API_BASE_URL: 25 | kwargs['api_base_url'] = self.API_BASE_URL 26 | return self._client.top_request(method, params, format_, v, simplify, partner_id, url, **kwargs) 27 | 28 | @property 29 | def corp_id(self): 30 | return self._client.corp_id 31 | -------------------------------------------------------------------------------- /dingtalk/client/api/blackboard.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.base import DingTalkBaseAPI 5 | 6 | 7 | class BlackBoard(DingTalkBaseAPI): 8 | 9 | def listtopten(self, userid): 10 | """ 11 | 列出用户的公告列表 12 | 13 | :param userid: 用户id 14 | """ 15 | return self._top_request( 16 | "dingtalk.oapi.blackboard.listtopten", 17 | {"userid": userid} 18 | ) 19 | -------------------------------------------------------------------------------- /dingtalk/client/api/bpms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import datetime 5 | import time 6 | 7 | import six 8 | from dingtalk.client.api.base import DingTalkBaseAPI 9 | from dingtalk.core.utils import to_text 10 | from optionaldict import optionaldict 11 | 12 | 13 | class Bpms(DingTalkBaseAPI): 14 | 15 | def process_copy(self, agent_id, process_code, biz_category_id=None, process_name=None, description=None): 16 | """ 17 | 复制审批流 18 | 19 | :param agent_id: 企业微应用标识 20 | :param process_code: 审批流的唯一码 21 | :param biz_category_id: 业务分类标识 22 | :param process_name: 审批流名称 23 | :param description: 审批流描述 24 | :return: 25 | """ 26 | 27 | return self._top_request( 28 | 'dingtalk.smartwork.bpms.process.copy', 29 | optionaldict({ 30 | 'agent_id': agent_id, 31 | 'process_code': process_code, 32 | 'biz_category_id': biz_category_id, 33 | 'process_name': process_name, 34 | 'description': description 35 | }) 36 | ) 37 | 38 | def process_sync(self, agent_id, src_process_code, target_process_code, biz_category_id=None, process_name=None): 39 | """ 40 | 更新审批流 41 | 42 | :param agent_id: 企业微应用标识 43 | :param src_process_code: 源审批流的唯一码 44 | :param target_process_code: 目标审批流的唯一码 45 | :param biz_category_id: 业务分类标识 46 | :param process_name: 审批流名称 47 | :return: 48 | """ 49 | 50 | return self._top_request( 51 | 'dingtalk.smartwork.bpms.process.sync', 52 | optionaldict({ 53 | 'agent_id': agent_id, 54 | 'src_process_code': src_process_code, 55 | 'target_process_code': target_process_code, 56 | 'biz_category_id': biz_category_id, 57 | 'process_name': process_name 58 | }) 59 | ) 60 | 61 | def processinstance_create( 62 | self, process_code, originator_user_id, dept_id, approvers=None, form_component_values=None, 63 | agent_id=None, cc_list=(), cc_start=False, cc_finish=False, approvers_v2=None 64 | ): 65 | """ 66 | 发起审批实例 67 | 68 | :param process_code: 审批流的唯一码 69 | :param originator_user_id: 审批实例发起人的userid 70 | :param dept_id: 发起人所在的部门 71 | :param approvers: 审批人userid列表 72 | :param form_component_values: 审批流表单参数 name: 表单每一栏的名称 value: 表单每一栏的值, ext_value: 扩展值 73 | 例:OrderedDict({name1: value1, name2: (value2, ext_value2), name3: (value3, )}) 74 | :param agent_id: 75 | :param cc_list: 抄送人userid列表 76 | :param cc_start: 开始时抄送 77 | :param cc_finish: 结束时抄送 78 | :param approvers_v2: 审批人列表,支持会签/或签,优先级高于approvers变量 79 | :return: 80 | """ 81 | cc_position = 'START' if cc_start else '' 82 | if cc_finish: 83 | if cc_position: 84 | cc_position += '_' 85 | cc_position += 'FINISH' 86 | if isinstance(approvers, (list, tuple, set)): 87 | approvers = ','.join(map(to_text, approvers)) 88 | form_component_value_list = [] 89 | if form_component_values: 90 | for name, value in form_component_values.items(): 91 | data = {'name': name} 92 | if isinstance(value, (list, tuple)): 93 | if len(value) > 1: 94 | data['ext_value'] = value[1] 95 | value = value[0] 96 | data['value'] = value 97 | form_component_value_list.append(data) 98 | 99 | return self._top_request( 100 | "dingtalk.oapi.processinstance.create", 101 | optionaldict({ 102 | "process_code": process_code, 103 | "originator_user_id": originator_user_id, 104 | "dept_id": dept_id, 105 | "form_component_values": form_component_value_list, 106 | "agent_id": agent_id, 107 | "approvers": approvers, 108 | "cc_list": cc_list, 109 | "cc_position": cc_position, 110 | "approvers_v2": approvers_v2 111 | }), 112 | result_processor=lambda x: x['process_instance_id'] 113 | ) 114 | 115 | def processinstance_listids(self, process_code, start_time, end_time, size='10', cursor='0', userid_list=()): 116 | """ 117 | 分页获取审批实例id列表 118 | 企业可以根据审批流的唯一标识,分页获取该审批流对应的审批实例id。只能取到权限范围内的相关部门的审批实例 119 | 120 | :param process_code: 流程模板唯一标识,可在oa后台编辑审批表单部分查询 121 | :param start_time: 审批实例开始时间,毫秒级 122 | :param end_time: 审批实例结束时间,毫秒级,默认取当前值 123 | :param size: 分页参数,每页大小,最多传10 124 | :param cursor: 分页查询的游标,最开始传0,后续传返回参数中的next_cursor值 125 | :param userid_list: 发起人用户id列表 126 | """ 127 | if isinstance(start_time, (datetime.date, datetime.datetime)): 128 | start_time = int(time.mktime(start_time.timetuple()) * 1000) 129 | if isinstance(end_time, (datetime.date, datetime.datetime)): 130 | end_time = int(time.mktime(end_time.timetuple()) * 1000) 131 | if isinstance(userid_list, (list, tuple)): 132 | userid_list = ','.join(map(to_text, userid_list)) 133 | return self._top_request( 134 | "dingtalk.oapi.processinstance.listids", 135 | optionaldict({ 136 | "process_code": process_code, 137 | "start_time": start_time, 138 | "end_time": end_time, 139 | "size": size, 140 | "cursor": cursor, 141 | "userid_list": userid_list 142 | }) 143 | ) 144 | 145 | def processinstance_list(self, process_code, start_time, end_time=None, cursor=0, size=10, userid_list=()): 146 | """ 147 | 获取审批实例列表 148 | 149 | :param process_code: 流程模板唯一标识,可在oa后台编辑审批表单部分查询 150 | :param start_time: 审批实例开始时间 151 | :param end_time: 审批实例结束时间,默认取当前值 152 | :param cursor: 每页大小,最多传10 153 | :param size: 分页查询的游标,最开始传0,后续传返回参数中的next_cursor值 154 | :param userid_list: 发起人用户id列表 155 | :return: 156 | """ 157 | userid_list = ','.join(map(to_text, userid_list)) 158 | 159 | if isinstance(start_time, (datetime.date, datetime.datetime)): 160 | start_time = int(time.mktime(start_time.timetuple()) * 1000) 161 | 162 | if isinstance(end_time, (datetime.date, datetime.datetime)): 163 | end_time = int(time.mktime(end_time.timetuple()) * 1000) 164 | 165 | assert isinstance(start_time, six.integer_types) 166 | assert end_time is None or isinstance(end_time, six.integer_types) 167 | 168 | return self._top_request( 169 | 'dingtalk.smartwork.bpms.processinstance.list', 170 | optionaldict({ 171 | 'process_code': process_code, 172 | 'start_time': start_time, 173 | 'end_time': end_time, 174 | 'cursor': cursor, 175 | 'size': size, 176 | 'userid_list': userid_list 177 | }) 178 | ) 179 | 180 | def processinstance_get(self, process_instance_id): 181 | """ 182 | 获取单个审批实例详情 183 | 184 | :param process_instance_id: 审批实例id 185 | :return: 186 | """ 187 | return self._top_request( 188 | 'dingtalk.smartwork.bpms.processinstance.get', 189 | {'process_instance_id': process_instance_id}, 190 | result_processor=lambda x: x['process_instance'] 191 | ) 192 | 193 | def dingtalk_oapi_process_gettodonum(self, userid): 194 | """ 195 | 获取待我审批数量 196 | 获取用户待审批数量 197 | 198 | :param userid: 用户id 199 | """ 200 | return self._top_request( 201 | "dingtalk.oapi.process.gettodonum", 202 | {"userid": userid} 203 | ) 204 | 205 | def process_listbyuserid(self, userid, offset=0, size=100): 206 | """ 207 | 根据用户id获取可见审批模板列表 208 | 209 | :param userid: 用户id 210 | :param offset: 分页游标,从0开始。根据返回结果中next_cursor是否为空判断是否有下一页,且再次调用offset设置成next_cursor的值 211 | :param size: 分页大小,最大可设置成100 212 | :return: 213 | """ 214 | return self._top_request( 215 | 'dingtalk.oapi.process.listbyuserid', 216 | { 217 | 'userid': userid, 218 | 'offset': offset, 219 | 'size': size 220 | } 221 | ) 222 | 223 | def process_instance_terminate(self, process_instance_id, remark="", is_system=True, operating_userid=""): 224 | """ 225 | 调用本接口通过实例id终止当前企业下发起的审批实例。 226 | 终止审批实例后,审批状态为“已撤销”。 227 | :param process_instance_id: 审批实例ID 228 | :param remark: 终止说明 229 | :param is_system: 是否通过系统操作:true:由系统直接终止false:由指定的操作者终止 230 | :param operating_userid: 操作人的userid, 当is_system为false时,该参数必传 231 | :return: 232 | """ 233 | return self._top_request( 234 | "dingtalk.oapi.process.instance.terminate", 235 | { 236 | "request": { 237 | "process_instance_id": process_instance_id, 238 | "remark": remark, 239 | "is_system": is_system, 240 | "operating_userid": operating_userid 241 | } 242 | } 243 | 244 | ) 245 | -------------------------------------------------------------------------------- /dingtalk/client/api/calendar.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | 5 | from dingtalk.client.api.base import DingTalkBaseAPI 6 | 7 | 8 | class Calendar(DingTalkBaseAPI): 9 | 10 | def create(self, create_vo): 11 | """ 12 | 创建日程 13 | 14 | :param create_vo: 创建日程实体 15 | """ 16 | return self._top_request( 17 | "dingtalk.oapi.calendar.create", 18 | {"create_vo": create_vo} 19 | ) 20 | 21 | def list( 22 | self, 23 | user_id, 24 | calendar_folder_id='', 25 | time_min=None, 26 | i_cal_uid='', 27 | single_events='', 28 | page_token='', 29 | max_results=250, 30 | time_max=None 31 | ): 32 | """ 33 | 日程查询 34 | 35 | :param user_id: 员工ID 36 | :param calendar_folder_id: 钉钉日历文件夹的对外id,默认是自己的默认文件夹 37 | :param time_min: 查询时间下限 38 | :param i_cal_uid: 日程跨域唯一id,用于唯一标识一组关联日程事件 39 | :param single_events: 是否需要展开循环日程 40 | :param page_token: 查询对应页,值有上一次请求返回的结果里对应nextPageToken 41 | :param max_results: 结果返回的最多数量,默认250,最多返回2500 42 | :param time_max: 查询时间上限 43 | """ 44 | return self._top_request( 45 | "dingtalk.oapi.calendar.list", 46 | { 47 | "user_id": user_id, 48 | "calendar_folder_id": calendar_folder_id, 49 | "time_min": time_min, 50 | "i_cal_uid": i_cal_uid, 51 | "single_events": single_events, 52 | "page_token": page_token, 53 | "max_results": max_results, 54 | "time_max": time_max 55 | } 56 | ) 57 | 58 | def delete(self, userid='', calendar_id=''): 59 | """ 60 | 日程删除 61 | 62 | :param userid: 员工id 63 | :param calendar_id: 日程id 64 | """ 65 | return self._top_request( 66 | "dingtalk.oapi.calendar.delete", 67 | { 68 | "userid": userid, 69 | "calendar_id": calendar_id 70 | } 71 | ) 72 | -------------------------------------------------------------------------------- /dingtalk/client/api/callback.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.base import DingTalkBaseAPI 5 | 6 | 7 | class Callback(DingTalkBaseAPI): 8 | ALL_CALL_BACK_TAGS = ( 9 | 'user_add_org', 'user_modify_org', 'user_leave_org', 'user_active_org', 10 | 'org_admin_add', 'org_admin_remove', 'org_dept_create', 'org_dept_modify', 11 | 'org_dept_remove', 'org_change', 'org_remove', 12 | 'label_user_change', 'label_conf_add', 'label_conf_modify', 'label_conf_del', 13 | 'edu_user_insert', 'edu_user_update', 'edu_user_delete', 14 | 'edu_user_relation_insert', 'edu_user_relation_update', 'edu_user_relation_delete', 15 | 'edu_dept_insert', 'edu_dept_update', 'edu_dept_delete', 16 | 'chat_add_member', 'chat_remove_member', 'chat_quit', 'chat_update_owner', 'chat_update_title', 'chat_disband', 17 | 'check_in', 'bpms_task_change', 'bpms_instance_change', 18 | 'attendance_check_record', 'attendance_schedule_change', 'attendance_overtime_duration', 19 | 'meetingroom_book', 'meetingroom_room_info' 20 | ) 21 | 22 | def register_call_back(self, call_back_tags, token, aes_key, url): 23 | """ 24 | 注册事件回调接口 25 | 26 | :param call_back_tags: 需要监听的事件类型 27 | :param token: 加解密需要用到的token 28 | :param aes_key: 数据加密密钥 29 | :param url: 接收事件回调的url 30 | :return: 31 | """ 32 | call_back_tag = [] 33 | for k in call_back_tags: 34 | if k in self.ALL_CALL_BACK_TAGS: 35 | call_back_tag.append(k) 36 | return self._post( 37 | '/call_back/register_call_back', 38 | { 39 | "call_back_tag": call_back_tag, 40 | "token": token, 41 | "aes_key": aes_key, 42 | "url": url 43 | } 44 | ) 45 | 46 | def get_call_back(self): 47 | """ 48 | 查询事件回调接口 49 | 50 | :return: 51 | """ 52 | return self._get('/call_back/get_call_back') 53 | 54 | def update_call_back(self, call_back_tags, token, aes_key, url): 55 | """ 56 | 更新事件回调接口 57 | 58 | :param call_back_tags: 需要监听的事件类型 59 | :param token: 加解密需要用到的token 60 | :param aes_key: 数据加密密钥 61 | :param url: 接收事件回调的url 62 | :return: 63 | """ 64 | call_back_tag = [] 65 | for k in call_back_tags: 66 | if k in self.ALL_CALL_BACK_TAGS: 67 | call_back_tag.append(k) 68 | return self._post( 69 | '/call_back/update_call_back', 70 | { 71 | "call_back_tag": call_back_tag, 72 | "token": token, 73 | "aes_key": aes_key, 74 | "url": url 75 | } 76 | ) 77 | 78 | def delete_call_back(self): 79 | """ 80 | 删除事件回调接口 81 | 82 | :return: 83 | """ 84 | return self._get('/call_back/delete_call_back') 85 | 86 | def get_call_back_failed_result(self): 87 | """ 88 | 获取回调失败的结果 89 | 90 | :return: 91 | """ 92 | return self._get('/call_back/get_call_back_failed_result') 93 | -------------------------------------------------------------------------------- /dingtalk/client/api/chat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.base import DingTalkBaseAPI 5 | from dingtalk.model.message import BodyBase 6 | 7 | 8 | class Chat(DingTalkBaseAPI): 9 | 10 | def create(self, name, owner, useridlist, show_history_type=False, searchable=0, 11 | validation_type=0, mention_all_authority=0, chat_banned_type=0, management_type=0): 12 | """ 13 | 创建会话 14 | 15 | :param name: 群名称。长度限制为1~20个字符 16 | :param owner: 群主userId,员工唯一标识ID;必须为该会话useridlist的成员之一 17 | :param useridlist: 群成员列表,每次最多支持40人,群人数上限为1000 18 | :param show_history_type: 新成员是否可查看聊天历史消息(新成员入群是否可查看最近100条聊天记录) 19 | :param searchable: 群可搜索,0-默认,不可搜索,1-可搜索 20 | :param validation_type: 入群验证,0:不入群验证(默认) 1:入群验证 21 | :param mention_all_authority: @all 权限,0-默认,所有人,1-仅群主可@all 22 | :param chat_banned_type: 群禁言,0-默认,不禁言,1-全员禁言 23 | :param management_type: 管理类型,0-默认,所有人可管理,1-仅群主可管理 24 | :return: 群会话的id 25 | """ 26 | return self._post( 27 | '/chat/create', 28 | { 29 | 'name': name, 30 | 'owner': owner, 31 | 'useridlist': useridlist, 32 | 'showHistoryType': 1 if show_history_type else 0, 33 | 'chatBannedType': chat_banned_type, 34 | 'searchable': searchable, 35 | 'validationType': validation_type, 36 | 'mentionAllAuthority': mention_all_authority, 37 | 'managementType': management_type 38 | }, 39 | result_processor=lambda x: x['chatid'] 40 | ) 41 | 42 | def update(self, chatid, name=None, owner=None, add_useridlist=(), del_useridlist=(), icon='', chat_banned_type=0, 43 | searchable=0, validation_type=0, mention_all_authority=0, show_history_type=False, management_type=0): 44 | """ 45 | 修改会话 46 | 47 | :param chatid: 群会话的id 48 | :param name: 群名称。长度限制为1~20个字符,不传则不修改 49 | :param owner: 群主userId,员工唯一标识ID;必须为该会话成员之一;不传则不修改 50 | :param add_useridlist: 添加成员列表,每次最多支持40人,群人数上限为1000 51 | :param del_useridlist: 删除成员列表,每次最多支持40人,群人数上限为1000 52 | :param icon: 群头像mediaid 53 | :param chat_banned_type: 群禁言,0-默认,不禁言,1-全员禁言 54 | :param searchable: 群可搜索,0-默认,不可搜索,1-可搜索 55 | :param validation_type: 入群验证,0:不入群验证(默认) 1:入群验证 56 | :param mention_all_authority: @all 权限,0-默认,所有人,1-仅群主可@all 57 | :param show_history_type: 新成员是否可查看聊天历史消息(新成员入群是否可查看最近100条聊天记录) 58 | :param management_type: 管理类型,0-默认,所有人可管理,1-仅群主可管理 59 | :return: 60 | """ 61 | return self._post( 62 | '/chat/update', 63 | { 64 | 'chatid': chatid, 65 | 'name': name, 66 | 'owner': owner, 67 | 'add_useridlist': add_useridlist, 68 | 'del_useridlist': del_useridlist, 69 | 'icon': icon, 70 | 'chatBannedType': chat_banned_type, 71 | 'searchable': searchable, 72 | 'validationType': validation_type, 73 | 'mentionAllAuthority': mention_all_authority, 74 | 'showHistoryType': 1 if show_history_type else 0, 75 | 'managementType': management_type 76 | } 77 | ) 78 | 79 | def get(self, chatid): 80 | """ 81 | 获取会话 82 | 83 | :param chatid: 群会话的id 84 | :return: 群会话信息 85 | """ 86 | return self._get( 87 | '/chat/get', 88 | {'chatid': chatid}, 89 | result_processor=lambda x: x['chat_info'] 90 | ) 91 | 92 | def send(self, chatid, msg_body): 93 | """ 94 | 发送群消息 95 | 96 | :param chatid: 群会话的id 97 | :param msg_body: BodyBase 消息体 98 | :return: 加密的消息id 99 | """ 100 | if isinstance(msg_body, BodyBase): 101 | msg_body = msg_body.get_dict() 102 | msg_body['chatid'] = chatid 103 | return self._post( 104 | '/chat/send', 105 | msg_body, 106 | result_processor=lambda x: x['messageId'] 107 | ) 108 | 109 | def get_read_list(self, message_id, cursor=0, size=100): 110 | """ 111 | 查询群消息已读人员列表 112 | 113 | :param message_id: 发送群消息接口返回的加密消息id 114 | :param cursor: 分页查询的游标,第一次传0,后续传返回结果中的next_cursor。返回结果中没有next_cursor时,表示没有后续的数据了 115 | :param size: 分页查询的大小,最大可以传100 116 | :return: 117 | """ 118 | return self._get( 119 | '/chat/getReadList', 120 | {"messageId": message_id, "cursor": cursor, "size": size} 121 | ) 122 | -------------------------------------------------------------------------------- /dingtalk/client/api/checkin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import time 5 | import datetime 6 | 7 | import six 8 | from dingtalk.core.utils import to_text 9 | 10 | from dingtalk.client.api.base import DingTalkBaseAPI 11 | 12 | 13 | class Checkin(DingTalkBaseAPI): 14 | 15 | def record(self, department_id, start_time, end_time, offset=0, size=100, order_asc=True): 16 | """ 17 | 获得签到数据 18 | 19 | :param department_id: 部门id(1 表示根部门) 20 | :param start_time: 开始时间 21 | :param end_time: 结束时间 22 | :param offset: 偏移量 23 | :param size: 分页大小 24 | :param order_asc: 是否正序排列 25 | :return: 26 | """ 27 | 28 | if isinstance(start_time, (datetime.date, datetime.datetime)): 29 | start_time = int(time.mktime(start_time.timetuple()) * 1000) 30 | 31 | if isinstance(end_time, (datetime.date, datetime.datetime)): 32 | end_time = int(time.mktime(end_time.timetuple()) * 1000) 33 | 34 | assert isinstance(start_time, six.integer_types) and isinstance(end_time, six.integer_types) 35 | return self._get( 36 | '/checkin/record', 37 | { 38 | 'department_id': department_id, 39 | 'start_time': start_time, 40 | 'end_time': end_time, 41 | 'offset': offset, 42 | 'size': size, 43 | 'order_asc': 'asc' if order_asc else 'desc' 44 | }, 45 | result_processor=lambda x: x['data'] 46 | ) 47 | 48 | def record_get(self, userid_list, start_time, end_time, offset=0, size=100): 49 | """ 50 | 获取多个用户的签到记录 (如果是取1个人的数据,时间范围最大到10天,如果是取多个人的数据,时间范围最大1天。) 51 | 52 | :param userid_list: 需要查询的用户列表 53 | :param start_time: 起始时间 54 | :param end_time: 截止时间 55 | :param offset: 偏移量 56 | :param size: 分页大小 57 | :return: 58 | """ 59 | if isinstance(start_time, (datetime.date, datetime.datetime)): 60 | start_time = int(time.mktime(start_time.timetuple()) * 1000) 61 | 62 | if isinstance(end_time, (datetime.date, datetime.datetime)): 63 | end_time = int(time.mktime(end_time.timetuple()) * 1000) 64 | 65 | assert isinstance(start_time, six.integer_types) and isinstance(end_time, six.integer_types) 66 | if isinstance(userid_list, (list, tuple, set)): 67 | userid_list = ','.join(map(to_text, userid_list)) 68 | 69 | return self._top_request( 70 | 'dingtalk.smartwork.checkin.record.get', 71 | { 72 | 'userid_list': userid_list, 73 | 'start_time': start_time, 74 | 'end_time': end_time, 75 | 'offset': offset, 76 | 'size': size 77 | } 78 | ) 79 | -------------------------------------------------------------------------------- /dingtalk/client/api/cspace.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import six 5 | from dingtalk.core.utils import to_text, json_loads 6 | 7 | from dingtalk.client.api.base import DingTalkBaseAPI 8 | 9 | 10 | class Cspace(DingTalkBaseAPI): 11 | 12 | def add_to_single_chat(self, agent_id, userid, media_id, file_name): 13 | """ 14 | 发送文件给指定用户 15 | 16 | :param agent_id: 文件发送者微应用的agentId 17 | :param userid: 文件接收人的userid 18 | :param media_id: 调用钉盘上传文件接口得到的mediaid 19 | :param file_name: 文件名(需包含含扩展名) 20 | :return: 21 | """ 22 | return self._post( 23 | '/cspace/add_to_single_chat', 24 | params={ 25 | 'agent_id': agent_id, 26 | 'userid': userid, 27 | 'media_id': media_id, 28 | 'file_name': file_name 29 | } 30 | ) 31 | 32 | def add(self, code, media_id, space_id, folder_id, name, agent_id=None, overwrite=False): 33 | """ 34 | 新增文件到用户钉盘 35 | 36 | :param code: 如果是微应用,code值为微应用免登授权码,如果是服务窗应用,code值为服务窗免登授权码 37 | code为临时授权码,只能消费一次,下次请求需要重新获取新的code。 38 | :param media_id: 调用钉盘上传文件接口得到的mediaid 39 | :param space_id: 调用云盘选择控件后获取的用户钉盘空间ID 40 | :param folder_id: 调用云盘选择控件后获取的用户钉盘文件夹ID 41 | :param name: 上传文件的名称,不能包含非法字符 42 | :param agent_id: 微应用的agentId 43 | :param overwrite: 到同名文件是否覆盖,若不覆盖,则会自动重命名本次新增的文件 44 | :return: 45 | """ 46 | return self._get( 47 | '/cspace/add', 48 | { 49 | 'agent_id': agent_id, 50 | 'code': code, 51 | 'media_id': media_id, 52 | 'space_id': space_id, 53 | 'folder_id': folder_id, 54 | 'name': name, 55 | 'overwrite': overwrite 56 | }, 57 | result_processor=lambda x: 58 | json_loads(x['dentry']) if isinstance(x['dentry'], six.string_types) else x['dentry'] 59 | ) 60 | 61 | def get_custom_space(self, domain=None, agent_id=None): 62 | """ 63 | 获取企业下的自定义空间 64 | 65 | :param domain: 企业调用时传入,需要为10个字节以内的字符串,仅可包含字母和数字,大小写不敏感 66 | :param agent_id: ISV调用时传入,微应用agentId 67 | :return: 申请到的空间id 68 | """ 69 | return self._get( 70 | '/cspace/get_custom_space', 71 | { 72 | 'agent_id': agent_id, 73 | 'domain': domain 74 | }, 75 | result_processor=lambda x: x['spaceid'] 76 | ) 77 | 78 | def grant_custom_space(self, isdownload, userid, agent_id=None, domain=None, duration=30, path=None, fileids=()): 79 | """ 80 | 授权用户访问企业下的自定义空间 81 | 82 | :param isdownload: 权限类型,true为下载,false为上传 83 | :param userid: 企业用户userid 84 | :param agent_id: ISV调用时传入,授权访问指定微应用的自定义空间 85 | :param domain: 企业调用时传入,授权访问该domain的自定义空间 86 | :param duration: 权限有效时间,有效范围为0~3600秒,超出此范围或不传默认为30秒 87 | :param path: 授权访问的路径 88 | :param fileids: 授权访问的文件id列表 89 | :return: 90 | """ 91 | _type = 'download' if isdownload else 'add' 92 | fileids = ','.join(map(to_text, fileids)) 93 | return self._get( 94 | '/cspace/grant_custom_space', 95 | { 96 | 'type': _type, 97 | 'agent_id': agent_id, 98 | 'domain': domain, 99 | 'userid': userid, 100 | 'duration': duration, 101 | 'path': path, 102 | 'fileids': fileids 103 | }, 104 | result_processor=lambda x: x['spaceid'] 105 | ) 106 | 107 | def file_upload_transaction(self, agent_id, file_size, chunk_numbers, upload_id=None): 108 | """ 109 | 开启/提交 文件上传事务 110 | 111 | :param agent_id: 微应用的agentId 112 | :param file_size: 文件大小 113 | :param chunk_numbers: 文件总块数 114 | :param upload_id: 上传事务id 不传该值为开启事务,传该值为提交事务 115 | :return: 开启事务:上传事务id; 提交事务:文件存储id 116 | """ 117 | return self._get( 118 | '/file/upload/transaction', 119 | { 120 | 'agent_id': agent_id, 121 | 'file_size': file_size, 122 | 'chunk_numbers': chunk_numbers, 123 | 'upload_id': upload_id 124 | }, 125 | result_processor=lambda x: x['upload_id'] if upload_id is None else x['media_id'] 126 | ) 127 | 128 | def file_upload_chunk(self, agent_id, upload_id, chunk_sequence, file_chunk): 129 | """ 130 | 上传文件块 131 | 132 | :param agent_id: 微应用的agentId 133 | :param upload_id: 上传事务id 134 | :param chunk_sequence: 文件块号,从1开始计数 135 | :param file_chunk: 要上传的文件块,一个 File-object 136 | :return: 137 | """ 138 | return self._post( 139 | '/file/upload/chunk', 140 | params={ 141 | 'agent_id': agent_id, 142 | 'upload_id': upload_id, 143 | 'chunk_sequence': chunk_sequence 144 | }, 145 | files={ 146 | 'file': file_chunk 147 | } 148 | ) 149 | 150 | def file_upload_single(self, agent_id, file_size, media_file): 151 | """ 152 | 单步文件上传 153 | 154 | :param agent_id: 微应用的agentId 155 | :param file_size: 文件大小 156 | :param media_file: 要上传的文件,一个 File-object 157 | :return: 158 | """ 159 | return self._post( 160 | '/file/upload/single', 161 | params={ 162 | 'agent_id': agent_id, 163 | 'file_size': file_size 164 | }, 165 | files={ 166 | 'file': media_file 167 | }, 168 | result_processor=lambda x: x['media_id'] 169 | ) 170 | -------------------------------------------------------------------------------- /dingtalk/client/api/department.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.base import DingTalkBaseAPI 5 | 6 | 7 | class Department(DingTalkBaseAPI): 8 | 9 | def list_ids(self, _id=1): 10 | """ 11 | 获取子部门ID列表 12 | 13 | :param _id: 父部门id(如果不传,默认部门为根部门,根部门ID为1) 14 | :return: 子部门ID列表数据 15 | """ 16 | return self._get( 17 | '/department/list_ids', 18 | {'id': _id}, 19 | result_processor=lambda x: x['sub_dept_id_list'] 20 | ) 21 | 22 | def list(self, _id=1, lang='zh_CN', fetch_child=False): 23 | """ 24 | 获取部门列表 25 | 26 | :param _id: 父部门id(如果不传,默认部门为根部门,根部门ID为1) 27 | :param lang: 通讯录语言(默认zh_CN,未来会支持en_US) 28 | :param fetch_child: 是否递归部门的全部子部门,ISV微应用固定传递false。 29 | :return: 部门列表数据。以部门的order字段从小到大排列 30 | """ 31 | return self._get( 32 | '/department/list', 33 | {'id': _id, 'lang': lang, 'fetch_child': fetch_child}, 34 | result_processor=lambda x: x['department'] 35 | ) 36 | 37 | def get(self, _id, lang='zh_CN'): 38 | """ 39 | 获取部门详情 40 | 41 | :param _id: 部门id 42 | :param lang: 通讯录语言(默认zh_CN,未来会支持en_US) 43 | :return: 部门列表数据。以部门的order字段从小到大排列 44 | """ 45 | return self._get( 46 | '/department/get', 47 | {'id': _id, 'lang': lang} 48 | ) 49 | 50 | def create(self, department_data): 51 | """ 52 | 创建部门 53 | 54 | :param department_data: 部门信息 55 | :return: 创建的部门id 56 | """ 57 | if 'id' in department_data: 58 | raise AttributeError('不能包含Id') 59 | return self._post( 60 | '/department/create', 61 | department_data, 62 | result_processor=lambda x: x['id'] 63 | ) 64 | 65 | def update(self, department_data): 66 | """ 67 | 更新部门 68 | 69 | :param department_data: 部门信息 70 | :return: 已经更新的部门id 71 | """ 72 | if 'id' not in department_data: 73 | raise AttributeError('必须包含Id') 74 | return self._post( 75 | '/department/update', 76 | department_data, 77 | result_processor=lambda x: x['id'] 78 | ) 79 | 80 | def delete(self, _id): 81 | """ 82 | 删除部门 83 | 84 | :param _id: 部门id。(注:不能删除根部门;不能删除含有子部门、成员的部门) 85 | :return: 86 | """ 87 | return self._get( 88 | '/department/delete', 89 | {'id': _id} 90 | ) 91 | 92 | def list_parent_depts_by_dept(self, _id): 93 | """ 94 | 查询部门的所有上级父部门路径 95 | 96 | :param _id: 希望查询的部门的id,包含查询的部门本身 97 | :return: 该部门的所有父部门id列表 98 | """ 99 | return self._get( 100 | '/department/list_parent_depts_by_dept', 101 | {'id': _id}, 102 | result_processor=lambda x: x['parentIds'] 103 | ) 104 | 105 | def list_parent_depts(self, user_id): 106 | """ 107 | 查询指定用户的所有上级父部门路径 108 | 109 | :param user_id: 希望查询的用户的id 110 | :return: 按顺序依次为其所有父部门的ID,直到根部门 111 | """ 112 | return self._get( 113 | '/department/list_parent_depts', 114 | {'userId': user_id}, 115 | result_processor=lambda x: x['department'] 116 | ) 117 | -------------------------------------------------------------------------------- /dingtalk/client/api/employeerm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import datetime 5 | import json 6 | 7 | from optionaldict import optionaldict 8 | 9 | from dingtalk.core.utils import to_text 10 | from dingtalk.client.api.base import DingTalkBaseAPI 11 | 12 | 13 | class Employeerm(DingTalkBaseAPI): 14 | 15 | DATE_TIME_FORMAT = '%Y-%m-%d %H:%M:%S' 16 | 17 | def get(self, userid): 18 | """ 19 | 获取智能人事员工花名册详细数据 20 | 21 | :param userid: 查询用户userid 22 | :return: 23 | """ 24 | return self._top_request( 25 | 'dingtalk.corp.hrm.employee.get', 26 | {'userid': userid}, 27 | result_processor=lambda x: x['group_list'] 28 | ) 29 | 30 | def list(self, userid_list, field_filter_list=()): 31 | """ 32 | 批量获取员工花名册字段信息 33 | 智能人事业务,企业/ISV根据员工id批量访问员工花名册信息 34 | 35 | :param userid_list: 员工id列表 36 | :param field_filter_list: 需要获取的花名册字段信息 37 | """ 38 | if isinstance(userid_list, (list, tuple, set)): 39 | userid_list = ','.join(map(to_text, userid_list)) 40 | if isinstance(field_filter_list, (list, tuple, set)): 41 | field_filter_list = ','.join(map(to_text, field_filter_list)) 42 | return self._top_request( 43 | "dingtalk.oapi.smartwork.hrm.employee.list", 44 | { 45 | "userid_list": userid_list, 46 | "field_filter_list": field_filter_list 47 | } 48 | ) 49 | 50 | def querypreentry(self, offset=0, size=50): 51 | """ 52 | 智能人事查询公司待入职员工列表 53 | 智能人事业务,企业/ISV分页查询公司待入职员工id列表 54 | 55 | :param offset: 分页起始值,默认0开始 56 | :param size: 分页大小,最大50 57 | """ 58 | return self._top_request( 59 | "dingtalk.oapi.smartwork.hrm.employee.querypreentry", 60 | { 61 | "offset": offset, 62 | "size": size 63 | } 64 | ) 65 | 66 | def queryonjob(self, status_list=(), offset=0, size=50): 67 | """ 68 | 智能人事查询公司在职员工列表 69 | 智能人事业务,提供企业/ISV按在职状态分页查询公司在职员工id列表 70 | 71 | :param status_list: 在职员工子状态筛选。2,试用期;3,正式;5,待离职;-1,无状态 72 | :param offset: 分页起始值,默认0开始 73 | :param size: 分页大小,最大50 74 | """ 75 | if isinstance(status_list, (list, tuple, set)): 76 | status_list = ','.join(map(to_text, status_list)) 77 | return self._top_request( 78 | "dingtalk.oapi.smartwork.hrm.employee.queryonjob", 79 | { 80 | "status_list": status_list, 81 | "offset": offset, 82 | "size": size 83 | } 84 | ) 85 | 86 | def querydimission(self, offset=0, size=50): 87 | """ 88 | 智能人事查询公司离职员工列表 89 | 智能人事业务,提供企业/ISV分页查询公司离职员工id列表 90 | 91 | :param offset: 分页游标,从0开始。根据返回结果里的next_cursor是否为空来判断是否还有下一页,且再次调用时offset设置成next_cursor的值 92 | :param size: 分页大小,最大50 93 | """ 94 | return self._top_request( 95 | "dingtalk.oapi.smartwork.hrm.employee.querydimission", 96 | { 97 | "offset": offset, 98 | "size": size 99 | } 100 | ) 101 | 102 | def listdimission(self, userid_list=()): 103 | """ 104 | 批量获取员工离职信息 105 | 根据传入的staffId列表,批量查询员工的离职信息 106 | 107 | :param userid_list: 员工id 108 | """ 109 | if isinstance(userid_list, (list, tuple, set)): 110 | userid_list = ','.join(map(to_text, userid_list)) 111 | return self._top_request( 112 | "dingtalk.oapi.smartwork.hrm.employee.listdimission", 113 | { 114 | "userid_list": userid_list 115 | } 116 | ) 117 | 118 | def addpreentry(self, name, mobile, pre_entry_time=None, op_userid=None, extend_info=None): 119 | """ 120 | 智能人事添加企业待入职员工 121 | 122 | :param name: 员工姓名 123 | :param mobile: 手机号 124 | :param pre_entry_time: 预期入职时间 125 | :param op_userid: 操作人userid 126 | :param extend_info: 扩展信息 127 | :return: 128 | """ 129 | if isinstance(pre_entry_time, (datetime.date, datetime.datetime)): 130 | pre_entry_time = pre_entry_time.strftime(self.DATE_TIME_FORMAT) 131 | if isinstance(extend_info, dict): 132 | extend_info = json.dumps(extend_info) 133 | 134 | return self._top_request( 135 | "dingtalk.oapi.smartwork.hrm.employee.addpreentry", 136 | { 137 | "param": optionaldict({ 138 | "name": name, 139 | "mobile": mobile, 140 | "pre_entry_time": pre_entry_time, 141 | "op_userid": op_userid, 142 | "extend_info": extend_info 143 | }) 144 | } 145 | ) 146 | 147 | def getdismissionlist(self, op_userid, current=1, page_size=100): 148 | """ 149 | 获取离职人员信息 150 | 151 | :param op_userid: 操作人userid 152 | :param current: 第几页,从1开始 153 | :param page_size: 一页多少数据,在1-100之间 154 | :return: 155 | """ 156 | return self._top_request( 157 | 'dingtalk.corp.hrm.employee.getdismissionlist', 158 | {'op_userid': op_userid, 'current': current, 'page_size': page_size}, 159 | result_processor=lambda x: x['page'] 160 | ) 161 | 162 | def setuserworkdata(self, op_userid, userid, data_value, data_desc=None): 163 | """ 164 | 更新用户绩效数据 165 | 166 | :param op_userid: 操作人userid,必须是拥有被操作人操作权限的管理员userid 167 | :param userid: 被操作人userid 168 | :param data_value: 数据值,可以为数值或者字符串 169 | :param data_desc: 数据项描述信息 170 | :return: 171 | """ 172 | hrm_api_user_data_model = {'userid': userid, 'data_value': data_value, 'data_desc': data_desc} 173 | return self._top_request( 174 | 'dingtalk.corp.hrm.employee.getdismissionlist', 175 | {'op_userid': op_userid, 'hrm_api_user_data_model': hrm_api_user_data_model} 176 | ) 177 | 178 | def modjobinfo(self, op_userid, userid, employee_type=None, employee_status=None, confirm_join_time=None, 179 | probation_period_type=None, regular_time=None, join_working_time=None, birth_time=None): 180 | """ 181 | 更新员工工作信息 182 | 183 | :param op_userid: 操作人userid,必须是拥有被操作人操作权限的管理员userid 184 | :param userid: 被操作人userid 185 | :param employee_type: 员工类型(1:全职,2:兼职,3:实习,4:劳务派遣,5:退休返聘,6:劳务外包) 186 | :param employee_status: 员工状态(2:试用,3:正式) 187 | :param confirm_join_time: 入职日期 188 | :param probation_period_type: 试用期(1:无试用期,2:1个月,3:2个月,4:3个月,5:4个月,6:5个月,7:6个月,8:其他) 189 | :param regular_time: 转正时间 190 | :param join_working_time: 首次参加工作时间 191 | :param birth_time: 生日日期 192 | :return: 193 | """ 194 | if confirm_join_time is not None and isinstance(confirm_join_time, (datetime.date, datetime.datetime)): 195 | confirm_join_time = confirm_join_time.strftime('%Y-%m-%d %H:%M:%S') 196 | if regular_time is not None and isinstance(regular_time, (datetime.date, datetime.datetime)): 197 | regular_time = regular_time.strftime('%Y-%m-%d %H:%M:%S') 198 | if join_working_time is not None and isinstance(join_working_time, (datetime.date, datetime.datetime)): 199 | join_working_time = join_working_time.strftime('%Y-%m-%d %H:%M:%S') 200 | if birth_time is not None and isinstance(birth_time, (datetime.date, datetime.datetime)): 201 | birth_time = birth_time.strftime('%Y-%m-%d %H:%M:%S') 202 | hrm_api_job_model = { 203 | 'userid': userid, 204 | 'employee_type': employee_type, 205 | 'employee_status': employee_status, 206 | 'confirm_join_time': confirm_join_time, 207 | 'probation_period_type': probation_period_type, 208 | 'regular_time': regular_time, 209 | 'join_working_time': join_working_time, 210 | 'birth_time': birth_time 211 | } 212 | return self._top_request( 213 | 'dingtalk.corp.hrm.employee.modjobinfo', 214 | {'op_userid': op_userid, 'hrm_api_job_model': hrm_api_job_model} 215 | ) 216 | -------------------------------------------------------------------------------- /dingtalk/client/api/ext.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import six 5 | 6 | from dingtalk.client.api.base import DingTalkBaseAPI 7 | from dingtalk.core.utils import json_loads 8 | 9 | 10 | class Ext(DingTalkBaseAPI): 11 | 12 | def listlabelgroups(self, offset=0, size=100): 13 | """ 14 | 标签列表 15 | 16 | :param offset: 偏移位置 17 | :param size: 分页大小,最大100 18 | :return: 19 | """ 20 | return self._top_request( 21 | 'dingtalk.corp.ext.listlabelgroups', 22 | {'offset': offset, 'size': size}, 23 | result_processor=lambda x: json_loads(x) if isinstance(x, six.string_types) else x 24 | ) 25 | 26 | def list(self, offset=0, size=100): 27 | """ 28 | 外部联系人列表 29 | 30 | :param offset: 偏移位置 31 | :param size: 分页大小,最大100 32 | :return: 33 | """ 34 | return self._top_request( 35 | 'dingtalk.corp.ext.list', 36 | {'offset': offset, 'size': size}, 37 | result_processor=lambda x: json_loads(x) if isinstance(x, six.string_types) else x 38 | ) 39 | 40 | def add(self, name, follower_userid, label_ids, mobile, state_code='86', 41 | title=None, share_deptids=(), remark=None, address=None, company_name=None, share_userids=()): 42 | """ 43 | 添加企业外部联系人 44 | 45 | :param name: 名称 46 | :param follower_userid: 负责人userId 47 | :param state_code: 手机号国家码 48 | :param mobile: 手机号 49 | :param label_ids: 标签列表 50 | :param title: 职位 51 | :param share_deptids: 共享给的部门ID 52 | :param remark: 备注 53 | :param address: 地址 54 | :param company_name: 企业名 55 | :param share_userids: 共享给的员工userId列表 56 | :return: 57 | """ 58 | 59 | return self._top_request( 60 | 'dingtalk.corp.ext.add', 61 | { 62 | 'contact': { 63 | 'name': name, 64 | 'follower_user': follower_userid, 65 | 'state_code': state_code, 66 | 'mobile': mobile, 67 | 'label_ids': label_ids, 68 | 'title': title, 69 | 'share_deptids': share_deptids, 70 | 'remark': remark, 71 | 'address': address, 72 | 'company_name': company_name, 73 | 'share_userid': share_userids 74 | } 75 | }, 76 | result_processor=lambda x: x['userid'] 77 | ) 78 | -------------------------------------------------------------------------------- /dingtalk/client/api/extcontact.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.base import DingTalkBaseAPI 5 | 6 | 7 | class ExtContact(DingTalkBaseAPI): 8 | 9 | def listlabelgroups(self, offset=0, size=100): 10 | """ 11 | 获取外部联系人标签列表 12 | 13 | :param size: 分页大小,最大100 14 | :param offset: 偏移位置 15 | """ 16 | return self._top_request( 17 | "dingtalk.oapi.extcontact.listlabelgroups", 18 | {"size": size, "offset": offset} 19 | ) 20 | 21 | def list(self, offset=0, size=100): 22 | """ 23 | 获取外部联系人列表 24 | 25 | :param size: 分页大小, 最大100 26 | :param offset: 偏移位置 27 | """ 28 | return self._top_request( 29 | "dingtalk.oapi.extcontact.list", 30 | {"size": size, "offset": offset} 31 | ) 32 | 33 | def get(self, user_id): 34 | """ 35 | 获取企业外部联系人详情 36 | 37 | :param user_id: userId 38 | """ 39 | return self._top_request( 40 | "dingtalk.oapi.extcontact.get", 41 | {"user_id": user_id} 42 | ) 43 | 44 | def create(self, name, follower_user_id, label_ids, mobile, state_code='86', 45 | title=None, share_dept_ids=(), remark=None, address=None, company_name=None, share_user_ids=()): 46 | """ 47 | 添加外部联系人 48 | 49 | :param name: 名称 50 | :param follower_user_id: 负责人userId 51 | :param state_code: 手机号国家码 52 | :param mobile: 手机号 53 | :param label_ids: 标签列表 54 | :param title: 职位 55 | :param share_dept_ids: 共享给的部门ID 56 | :param remark: 备注 57 | :param address: 地址 58 | :param company_name: 企业名 59 | :param share_user_ids: 共享给的员工userId列表 60 | :return: 61 | """ 62 | if not isinstance(label_ids, (list, tuple, set)): 63 | label_ids = (label_ids, ) 64 | if not isinstance(share_dept_ids, (list, tuple, set)): 65 | share_dept_ids = (share_dept_ids, ) 66 | if not isinstance(share_user_ids, (list, tuple, set)): 67 | share_user_ids = (share_user_ids, ) 68 | return self._top_request( 69 | "dingtalk.oapi.extcontact.create", 70 | { 71 | "contact": { 72 | 'name': name, 73 | 'follower_user_id': follower_user_id, 74 | 'state_code': state_code, 75 | 'mobile': mobile, 76 | 'label_ids': label_ids, 77 | 'title': title, 78 | 'share_dept_ids': share_dept_ids, 79 | 'remark': remark, 80 | 'address': address, 81 | 'company_name': company_name, 82 | 'share_user_ids': share_user_ids 83 | } 84 | }, 85 | result_processor=lambda x: x['userid'] 86 | ) 87 | 88 | def update(self, user_id, name, follower_user_id, label_ids, mobile, state_code='86', 89 | title=None, share_dept_ids=(), remark=None, address=None, company_name=None, share_user_ids=()): 90 | """ 91 | 更新外部联系人 92 | 93 | :param user_id: 该外部联系人的userId 94 | :param name: 名称 95 | :param follower_user_id: 负责人userId 96 | :param state_code: 手机号国家码 97 | :param mobile: 手机号 98 | :param label_ids: 标签列表 99 | :param title: 职位 100 | :param share_dept_ids: 共享给的部门ID 101 | :param remark: 备注 102 | :param address: 地址 103 | :param company_name: 企业名 104 | :param share_user_ids: 共享给的员工userId列表 105 | :return: 106 | """ 107 | if not isinstance(label_ids, (list, tuple, set)): 108 | label_ids = (label_ids, ) 109 | if not isinstance(share_dept_ids, (list, tuple, set)): 110 | share_dept_ids = (share_dept_ids, ) 111 | if not isinstance(share_user_ids, (list, tuple, set)): 112 | share_user_ids = (share_user_ids, ) 113 | return self._top_request( 114 | "dingtalk.oapi.extcontact.update", 115 | { 116 | "contact": { 117 | "user_id": user_id, 118 | 'name': name, 119 | 'follower_user_id': follower_user_id, 120 | 'state_code': state_code, 121 | 'mobile': mobile, 122 | 'label_ids': label_ids, 123 | 'title': title, 124 | 'share_dept_ids': share_dept_ids, 125 | 'remark': remark, 126 | 'address': address, 127 | 'company_name': company_name, 128 | 'share_user_ids': share_user_ids 129 | } 130 | } 131 | ) 132 | 133 | def delete(self, user_id): 134 | """ 135 | 删除外部联系人 136 | 137 | :param user_id: 用户id 138 | """ 139 | return self._top_request( 140 | "dingtalk.oapi.extcontact.delete", 141 | {"user_id": user_id} 142 | ) 143 | -------------------------------------------------------------------------------- /dingtalk/client/api/health.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import datetime 5 | 6 | from dingtalk.client.api.base import DingTalkBaseAPI 7 | from dingtalk.core.utils import to_text 8 | 9 | 10 | class Health(DingTalkBaseAPI): 11 | 12 | def stepinfo_getuserstatus(self, userid): 13 | """ 14 | 查询用户是否开启了钉钉运动 15 | 16 | :param userid: 用户id 17 | """ 18 | return self._top_request( 19 | "dingtalk.oapi.health.stepinfo.getuserstatus", 20 | {"userid": userid}, 21 | result_processor=lambda x: x['status'] 22 | ) 23 | 24 | def stepinfo_list(self, _type, object_id, stat_dates): 25 | """ 26 | 获取个人或部门钉钉运动步数 27 | 28 | :param _type: 0表示取用户步数,1表示取部门步数 29 | :param object_id: 可以传入用户userid或者部门id 30 | :param stat_dates: 时间列表 31 | """ 32 | if not isinstance(stat_dates, (list, tuple, set)): 33 | stat_dates = [stat_dates] 34 | 35 | stat_dates = ",".join(map(lambda x: x.strftime("%Y%m%d") if isinstance(x, datetime.date) else x, stat_dates)) 36 | return self._top_request( 37 | "dingtalk.oapi.health.stepinfo.list", 38 | { 39 | "type": _type, 40 | "object_id": object_id, 41 | "stat_dates": stat_dates 42 | } 43 | ) 44 | 45 | def stepinfo_listbyuserid(self, userids, stat_date): 46 | """ 47 | 批量查询多个用户的钉钉运动步数 48 | 49 | :param userids: 员工userid列表,最多传50个 50 | :param stat_date: 时间 51 | """ 52 | if isinstance(stat_date, datetime.date): 53 | stat_date = stat_date.strftime("%Y%m%d") 54 | if isinstance(userids, (list, tuple, set)): 55 | userids = ",".join(map(to_text, userids)) 56 | return self._top_request( 57 | "dingtalk.oapi.health.stepinfo.listbyuserid", 58 | { 59 | "userids": userids, 60 | "stat_date": stat_date 61 | } 62 | ) 63 | -------------------------------------------------------------------------------- /dingtalk/client/api/message.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import json 5 | 6 | from optionaldict import optionaldict 7 | 8 | from dingtalk.core.utils import to_text 9 | from six.moves.urllib.parse import urlencode 10 | 11 | from dingtalk.client.api.base import DingTalkBaseAPI 12 | from dingtalk.model.message import BodyBase 13 | 14 | 15 | class Message(DingTalkBaseAPI): 16 | 17 | @staticmethod 18 | def get_pc_url(url, pc_slide=True): 19 | """ 20 | 消息链接能在PC端打开 21 | 22 | :param url: 要打开的链接 23 | :param pc_slide: 如果为true代表在PC客户端打开,为false或者不写代表用浏览器打开 24 | :return: 25 | """ 26 | params = {'url': url} 27 | if pc_slide: 28 | params['pc_slide'] = 'true' 29 | return "dingtalk://dingtalkclient/page/link?%s" % urlencode(params) 30 | 31 | def media_upload(self, media_type, media_file): 32 | """ 33 | 上传媒体文件 34 | 35 | :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、普通文件(file) 36 | :param media_file: 要上传的文件,一个 File-object 37 | :return: 38 | """ 39 | return self._post( 40 | '/media/upload', 41 | params={'type': media_type}, 42 | files={'media': media_file} 43 | ) 44 | 45 | def media_download_file(self, media_id): 46 | """ 47 | 获取媒体文件 48 | 49 | :param media_id: 媒体文件的唯一标示 50 | :return: requests 的 Response 实例 51 | """ 52 | return self._get( 53 | '/media/downloadFile', 54 | {'media_id': media_id} 55 | ) 56 | 57 | def send_to_conversation(self, sender, cid, msg_body): 58 | """ 59 | 发送普通消息 60 | 61 | :param sender: 消息发送者员工ID 62 | :param cid: 群消息或者个人聊天会话Id 63 | :param msg_body: BodyBase 消息体 64 | :return: 65 | """ 66 | if isinstance(msg_body, BodyBase): 67 | msg_body = msg_body.get_dict() 68 | msg_body['sender'] = sender 69 | msg_body['cid'] = cid 70 | return self._post('/message/send_to_conversation', msg_body) 71 | 72 | def send(self, agentid, msg_body, touser_list=(), toparty_list=()): 73 | """ 74 | 发送企业通知消息 75 | 76 | :param agentid: 企业应用id,这个值代表以哪个应用的名义发送消息 77 | :param msg_body: BodyBase 消息体 78 | :param touser_list: 员工id列表 79 | :param toparty_list: 部门id列表 80 | :return: 81 | """ 82 | 83 | touser = "|".join(map(to_text, touser_list)) 84 | toparty = "|".join(map(to_text, toparty_list)) 85 | if isinstance(msg_body, BodyBase): 86 | msg_body = msg_body.get_dict() 87 | msg_body['touser'] = touser 88 | msg_body['toparty'] = toparty 89 | msg_body['agentid'] = agentid 90 | return self._post('/message/send', msg_body) 91 | 92 | def list_message_status(self, message_id): 93 | """ 94 | 获取企业通知消息已读未读状态 95 | 96 | :param message_id: 消息id 97 | :return: 98 | """ 99 | return self._post('/message/list_message_status', {"messageId": message_id}) 100 | 101 | def send_by_code(self, code, msg_body): 102 | """ 103 | 企业通知消息接口(用户反馈式) 104 | 105 | :param code: 用户操作产生的授权码 106 | :param msg_body: BodyBase 消息体 107 | :return: 108 | """ 109 | if isinstance(msg_body, BodyBase): 110 | msg_body = msg_body.get_dict() 111 | msg_body['code'] = code 112 | return self._post('/message/sendByCode', msg_body) 113 | 114 | def asyncsend(self, msg_body, agent_id, userid_list=(), dept_id_list=(), to_all_user=False): 115 | """ 116 | 企业会话消息异步发送 117 | 118 | :param msg_body: BodyBase 消息体 119 | :param agent_id: 微应用的id 120 | :param userid_list: 接收者的用户userid列表 121 | :param dept_id_list: 接收者的部门id列表 122 | :param to_all_user: 是否发送给企业全部用户 123 | :return: 任务id 124 | """ 125 | userid_list = ",".join(map(to_text, userid_list)) 126 | dept_id_list = ",".join(map(to_text, dept_id_list)) 127 | 128 | if isinstance(msg_body, BodyBase): 129 | msg_body = msg_body.get_dict() 130 | msgtype = msg_body['msgtype'] 131 | msgcontent = json.dumps(msg_body[msgtype]) 132 | return self._top_request( 133 | 'dingtalk.corp.message.corpconversation.asyncsend', 134 | { 135 | 'msgtype': msgtype, 136 | 'agent_id': agent_id, 137 | 'msgcontent': msgcontent, 138 | 'userid_list': userid_list, 139 | 'dept_id_list': dept_id_list, 140 | 'to_all_user': to_all_user 141 | }, 142 | result_processor=lambda x: x['task_id'] 143 | ) 144 | 145 | def asyncsend_v2(self, msg_body, agent_id, userid_list=(), dept_id_list=(), to_all_user=False): 146 | """ 147 | 企业会话消息异步发送 148 | 149 | :param msg_body: BodyBase 消息体 150 | :param agent_id: 微应用的id 151 | :param userid_list: 接收者的用户userid列表 152 | :param dept_id_list: 接收者的部门id列表 153 | :param to_all_user: 是否发送给企业全部用户 154 | :return: 任务id 155 | """ 156 | if isinstance(userid_list, (list, tuple, set)): 157 | userid_list = ",".join(map(to_text, userid_list)) 158 | if isinstance(dept_id_list, (list, tuple, set)): 159 | dept_id_list = ",".join(map(to_text, dept_id_list)) 160 | if not userid_list: 161 | userid_list = None 162 | if not dept_id_list: 163 | dept_id_list = None 164 | if isinstance(msg_body, BodyBase): 165 | msg_body = msg_body.get_dict() 166 | return self._top_request( 167 | 'dingtalk.oapi.message.corpconversation.asyncsend_v2', 168 | optionaldict({ 169 | "msg": msg_body, 170 | 'agent_id': agent_id, 171 | 'userid_list': userid_list, 172 | 'dept_id_list': dept_id_list, 173 | 'to_all_user': 'true' if to_all_user else 'false' 174 | }), 175 | result_processor=lambda x: x['task_id'] 176 | ) 177 | 178 | def recall(self, agent_id, msg_task_id): 179 | """ 180 | 撤回工作通知消息 181 | 182 | :param agent_id: 发送工作通知的微应用agentId 183 | :param msg_task_id: 发送工作通知返回的taskId 184 | """ 185 | return self._top_request( 186 | "dingtalk.oapi.message.corpconversation.recall", 187 | {"agent_id": agent_id, "msg_task_id": msg_task_id} 188 | ) 189 | 190 | def getsendprogress(self, agent_id, task_id): 191 | """ 192 | 获取异步发送企业会话消息的发送进度 193 | 194 | :param agent_id: 发送消息时使用的微应用的id 195 | :param task_id: 发送消息时钉钉返回的任务id 196 | :return: 197 | """ 198 | return self._top_request( 199 | 'dingtalk.corp.message.corpconversation.getsendprogress', 200 | {'agent_id': agent_id, 'task_id': task_id}, 201 | result_processor=lambda x: x['progress'] 202 | ) 203 | 204 | def getsendresult(self, agent_id=None, task_id=None): 205 | """ 206 | 获取异步向企业会话发送消息的结果 207 | 208 | :param agent_id: 微应用的agentid 209 | :param task_id: 异步任务的id 210 | :return: 211 | """ 212 | return self._top_request( 213 | 'dingtalk.corp.message.corpconversation.getsendresult', 214 | {'agent_id': agent_id, 'task_id': task_id}, 215 | result_processor=lambda x: x['send_result'] 216 | ) 217 | 218 | def asyncsendbycode(self, code, msg_body, agent_id, userid_list=(), dept_id_list=(), to_all_user=False): 219 | """ 220 | 通过用户授权码异步向企业会话发送消息 221 | 222 | :param code: 用户操作产生的授权码 223 | :param msg_body: BodyBase 消息体 224 | :param agent_id: 微应用的id 225 | :param userid_list: 接收者的用户userid列表 226 | :param dept_id_list: 接收者的部门id列表 227 | :param to_all_user: 是否发送给企业全部用户 228 | :return: 任务id 229 | """ 230 | userid_list = ",".join(map(to_text, userid_list)) 231 | dept_id_list = ",".join(map(to_text, dept_id_list)) 232 | 233 | if isinstance(msg_body, BodyBase): 234 | msg_body = msg_body.get_dict() 235 | msgtype = msg_body['msgtype'] 236 | msgcontent = json.dumps(msg_body[msgtype]) 237 | 238 | return self._top_request( 239 | 'dingtalk.corp.message.corpconversation.asyncsendbycode', 240 | { 241 | 'msgtype': msgtype, 242 | 'code': code, 243 | 'agent_id': agent_id, 244 | 'msgcontent': msgcontent, 245 | 'userid_list': userid_list, 246 | 'dept_id_list': dept_id_list, 247 | 'to_all_user': to_all_user 248 | }, 249 | result_processor=lambda x: x['task_id'] 250 | ) 251 | -------------------------------------------------------------------------------- /dingtalk/client/api/microapp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.base import DingTalkBaseAPI 5 | 6 | 7 | class MicroApp(DingTalkBaseAPI): 8 | 9 | def create(self, app_icon, app_name, app_desc, homepage_url, pc_homepage_url=None, omp_link=None): 10 | """ 11 | 创建微应用 12 | 13 | :param app_icon: 微应用的图标。需要调用上传接口将图标上传到钉钉服务器后获取到的mediaId 14 | :param app_name: 微应用的名称。长度限制为1~10个字符 15 | :param app_desc: 微应用的描述。长度限制为1~20个字符 16 | :param homepage_url: 微应用的移动端主页,必须以http开头或https开头 17 | :param pc_homepage_url: 微应用的PC端主页,必须以http开头或https开头,如果不为空则必须与homepageUrl的域名一致 18 | :param omp_link: 微应用的OA后台管理主页,必须以http开头或https开头。 19 | :return: 微应用实例化id 20 | """ 21 | return self._post( 22 | '/microapp/create', 23 | { 24 | "appIcon": app_icon, 25 | "appName": app_name, 26 | "appDesc": app_desc, 27 | "homepageUrl": homepage_url, 28 | "pcHomepageUrl": pc_homepage_url, 29 | "ompLink": omp_link 30 | }, 31 | result_processor=lambda x: x['agentId'] 32 | ) 33 | 34 | def update(self, agent_id, app_icon=None, app_name=None, app_desc=None, 35 | homepage_url=None, pc_homepage_url=None, omp_link=None): 36 | """ 37 | 更新微应用 38 | 39 | :param agent_id: 微应用实例化id 40 | :param app_icon: 微应用的图标。需要调用上传接口将图标上传到钉钉服务器后获取到的mediaId 41 | :param app_name: 微应用的名称。长度限制为1~10个字符 42 | :param app_desc: 微应用的描述。长度限制为1~20个字符 43 | :param homepage_url: 微应用的移动端主页,必须以http开头或https开头 44 | :param pc_homepage_url: 微应用的PC端主页,必须以http开头或https开头,如果不为空则必须与homepageUrl的域名一致 45 | :param omp_link: 微应用的OA后台管理主页,必须以http开头或https开头。 46 | :return: 微应用实例化id 47 | """ 48 | return self._post( 49 | '/microapp/update', 50 | { 51 | "agentId": agent_id, 52 | "appIcon": app_icon, 53 | "appName": app_name, 54 | "appDesc": app_desc, 55 | "homepageUrl": homepage_url, 56 | "pcHomepageUrl": pc_homepage_url, 57 | "ompLink": omp_link 58 | }, 59 | result_processor=lambda x: x['agentId'] 60 | ) 61 | 62 | def delete(self, agent_id): 63 | """ 64 | 删除微应用 65 | 66 | :param agent_id: 微应用实例化id,企业只能删除自建微应用 67 | :return: 68 | """ 69 | return self._post( 70 | '/microapp/delete', 71 | {'agentId': agent_id} 72 | ) 73 | 74 | def list(self): 75 | """ 76 | 列出微应用 77 | 78 | :return: 微应用列表 79 | """ 80 | return self._post( 81 | '/microapp/list', 82 | result_processor=lambda x: x['appList'] 83 | ) 84 | 85 | def list_by_userid(self, userid): 86 | """ 87 | 列出员工可见的微应用 88 | 89 | :return: 微应用列表 90 | """ 91 | return self._get( 92 | '/microapp/list_by_userid', 93 | {'userid': userid}, 94 | result_processor=lambda x: x['appList'] 95 | ) 96 | 97 | def visible_scopes(self, agent_id): 98 | """ 99 | 删除微应用 100 | 101 | :param agent_id: 需要查询的微应用实例化agentId 102 | :return: 103 | """ 104 | return self._post( 105 | '/microapp/visible_scopes', 106 | {'agentId': agent_id} 107 | ) 108 | 109 | def set_visible_scopes(self, agent_id, is_hidden=False, dept_visible_scopes=(), user_visible_scopes=()): 110 | """ 111 | 设置微应用的可见范围 112 | 113 | :param agent_id: 微应用实例化id 114 | :param is_hidden: 是否仅限管理员可见,true代表仅限管理员可见 115 | :param dept_visible_scopes: 设置可见的部门id列表 116 | :param user_visible_scopes: 设置可见的员工id列表 117 | :return: 118 | """ 119 | return self._post( 120 | '/microapp/set_visible_scopes', 121 | { 122 | "agentId": agent_id, 123 | "isHidden": is_hidden, 124 | "deptVisibleScopes": dept_visible_scopes, 125 | "userVisibleScopes": user_visible_scopes 126 | } 127 | ) 128 | -------------------------------------------------------------------------------- /dingtalk/client/api/report.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import datetime 5 | import time 6 | 7 | from dingtalk.client.api.base import DingTalkBaseAPI 8 | 9 | 10 | class Report(DingTalkBaseAPI): 11 | 12 | def list(self, start_time, end_time, cursor=0, size=20, template_name='', userid=''): 13 | """ 14 | 查询企业员工发出的日志列表 15 | 16 | :param start_time: 查询起始时间 17 | :param end_time: 查询截止时间 18 | :param cursor: 查询游标,初始传入0,后续从上一次的返回值中获取 19 | :param size: 每页数据量 20 | :param template_name: 要查询的模板名称 21 | :param userid: 员工的userid 22 | """ 23 | if isinstance(start_time, (datetime.date, datetime.datetime)): 24 | start_time = int(time.mktime(start_time.timetuple()) * 1000) 25 | if isinstance(end_time, (datetime.date, datetime.datetime)): 26 | end_time = int(time.mktime(end_time.timetuple()) * 1000) 27 | return self._top_request( 28 | "dingtalk.oapi.report.list", 29 | { 30 | "start_time": start_time, 31 | "end_time": end_time, 32 | "cursor": cursor, 33 | "size": size, 34 | "template_name": template_name, 35 | "userid": userid 36 | } 37 | ) 38 | 39 | def statistics(self, report_id): 40 | """ 41 | 获取日志统计数据 42 | 43 | :param report_id: 日志id 44 | """ 45 | return self._top_request( 46 | "dingtalk.oapi.report.statistics", 47 | { 48 | "report_id": report_id 49 | } 50 | ) 51 | 52 | def statistics_listbytype(self, report_id, _type, offset=0, size=100): 53 | """ 54 | 根据类型获取日志相关人员列表 55 | 56 | :param report_id: 日志id 57 | :param _type: 查询类型 0:已读人员列表 1:评论人员列表 2:点赞人员列表 58 | :param offset: 分页查询的游标,最开始传0,后续传返回参数中的next_cursor值,默认值为0 59 | :param size: 分页参数,每页大小,最多传100,默认值为100 60 | """ 61 | return self._top_request( 62 | "dingtalk.oapi.report.statistics.listbytype", 63 | { 64 | "report_id": report_id, 65 | "type": _type, 66 | "offset": offset, 67 | "size": size 68 | } 69 | ) 70 | 71 | def receiver_list(self, report_id, offset=0, size=100): 72 | """ 73 | 获取日志分享人员列表 74 | 75 | :param report_id: 日志id 76 | :param offset: 分页查询的游标,最开始传0,后续传返回参数中的next_cursor值,默认值为0 77 | :param size: 分页参数,每页大小,最多传100,默认值为100 78 | """ 79 | return self._top_request( 80 | "dingtalk.oapi.report.receiver.list", 81 | { 82 | "report_id": report_id, 83 | "offset": offset, 84 | "size": size 85 | } 86 | ) 87 | 88 | def comment_list(self, report_id, offset=0, size=20): 89 | """ 90 | 获取日志评论详情 91 | 92 | :param report_id: 日志id 93 | :param offset: 分页查询的游标,最开始传0,后续传返回参数中的next_cursor值,默认值为0 94 | :param size: 分页参数,每页大小,最多传20,默认值为20 95 | """ 96 | return self._top_request( 97 | "dingtalk.oapi.report.comment.list", 98 | { 99 | "report_id": report_id, 100 | "offset": offset, 101 | "size": size 102 | } 103 | ) 104 | 105 | def getunreadcount(self, userid=''): 106 | """ 107 | 查询企业员工的日志未读数 108 | 109 | :param userid: 员工id 110 | """ 111 | return self._top_request( 112 | "dingtalk.oapi.report.getunreadcount", 113 | {"userid": userid}, 114 | result_processor=lambda x: x['count'] 115 | ) 116 | 117 | def template_listbyuserid(self, userid='', offset=0, size=100): 118 | """ 119 | 根据用户id获取可见的日志模板列表 120 | 121 | :param userid: 员工userId, 不传递表示获取所有日志模板 122 | :param offset: 分页游标,从0开始。根据返回结果里的next_cursor是否为空来判断是否还有下一页,且再次调用时offset设置成next_cursor的值 123 | :param size: 分页大小,最大可设置成100 124 | """ 125 | return self._top_request( 126 | "dingtalk.oapi.report.template.listbyuserid", 127 | { 128 | "userid": userid, 129 | "offset": offset, 130 | "size": size 131 | } 132 | ) 133 | -------------------------------------------------------------------------------- /dingtalk/client/api/role.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.core.utils import to_text 5 | 6 | from dingtalk.client.api.base import DingTalkBaseAPI 7 | 8 | 9 | class Role(DingTalkBaseAPI): 10 | 11 | def simplelist(self, role_id, offset=0, size=20): 12 | """ 13 | 获取角色的员工列表 14 | 15 | :param role_id: 角色ID 16 | :param offset: 分页大小 17 | :param size: 分页偏移 18 | :return: 19 | """ 20 | return self._top_request( 21 | 'dingtalk.corp.role.simplelist', 22 | {'role_id': role_id, 'offset': offset, 'size': size} 23 | ) 24 | 25 | def list(self, offset=0, size=20): 26 | """ 27 | 获取企业角色列表 28 | 29 | :param offset: 分页大小 30 | :param size: 分页偏移 31 | :return: 32 | """ 33 | return self._top_request( 34 | 'dingtalk.corp.role.list', 35 | {'offset': offset, 'size': size} 36 | ) 37 | 38 | def addrolesforemps(self, rolelid_list, userid_list): 39 | """ 40 | 批量为员工增加角色信息 41 | 42 | :param rolelid_list: 角色id list 43 | :param userid_list: 员工id list 44 | :return: 45 | """ 46 | if isinstance(rolelid_list, (list, tuple, set)): 47 | rolelid_list = ','.join(map(to_text, rolelid_list)) 48 | if isinstance(userid_list, (list, tuple, set)): 49 | userid_list = ','.join(map(to_text, userid_list)) 50 | return self._top_request( 51 | 'dingtalk.corp.role.addrolesforemps', 52 | {'rolelid_list': rolelid_list, 'userid_list': userid_list} 53 | ) 54 | 55 | def removerolesforemps(self, rolelid_list, userid_list): 56 | """ 57 | 批量删除员工角的色信息 58 | 59 | :param rolelid_list: 角色id list 60 | :param userid_list: 员工id list 61 | :return: 62 | """ 63 | if isinstance(rolelid_list, (list, tuple, set)): 64 | rolelid_list = ','.join(map(to_text, rolelid_list)) 65 | if isinstance(userid_list, (list, tuple, set)): 66 | userid_list = ','.join(map(to_text, userid_list)) 67 | return self._top_request( 68 | 'dingtalk.corp.role.removerolesforemps', 69 | {'rolelid_list': rolelid_list, 'userid_list': userid_list} 70 | ) 71 | 72 | def deleterole(self, role_id): 73 | """ 74 | 删除角色信息 75 | 76 | :param role_id: 角色id 77 | :return: 78 | """ 79 | return self._top_request( 80 | 'dingtalk.corp.role.deleterole', 81 | {'role_id': role_id} 82 | ) 83 | 84 | def getrolegroup(self, group_id): 85 | """ 86 | 获取角色组信息 87 | 88 | :param group_id: 角色组的Id 89 | :return: 90 | """ 91 | return self._top_request( 92 | 'dingtalk.corp.role.getrolegroup', 93 | {'group_id': group_id}, 94 | result_processor=lambda x: x['role_group'] 95 | ) 96 | 97 | def getrole(self, role_id): 98 | """ 99 | 获取角色详情 100 | 101 | :param role_id: 角色id 102 | """ 103 | return self._top_request( 104 | "dingtalk.oapi.role.getrole", 105 | { 106 | "roleId": role_id 107 | } 108 | ) 109 | 110 | def add_role(self, role_name, group_id): 111 | """ 112 | 创建角色 113 | 114 | :param role_name: 角色名称 115 | :param group_id: 角色组id 116 | """ 117 | return self.post( 118 | "/role/add_role", 119 | { 120 | "roleName": role_name, 121 | "groupId": group_id 122 | } 123 | ) 124 | 125 | def update_role(self, role_name, group_id): 126 | """ 127 | 更新角色 128 | 129 | :param role_name: 角色名称 130 | :param group_id: 角色组id 131 | """ 132 | return self.post( 133 | "/role/update_role", 134 | { 135 | "roleName": role_name, 136 | "groupId": group_id 137 | } 138 | ) 139 | 140 | def add_role_group(self, name): 141 | """ 142 | 创建角色组 143 | 144 | :param name: 角色组名称 145 | """ 146 | return self.post( 147 | "/role/add_role_group", 148 | {"name": name}, 149 | result_processor=lambda x: x['groupId'] 150 | ) 151 | -------------------------------------------------------------------------------- /dingtalk/client/api/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.client.api.base import DingTalkBaseAPI 5 | 6 | 7 | class User(DingTalkBaseAPI): 8 | 9 | def auth_scopes(self): 10 | """ 11 | 获取CorpSecret授权范围 12 | 13 | :return: 14 | """ 15 | return self._get('/auth/scopes') 16 | 17 | def get_org_user_count(self, only_active): 18 | """ 19 | 获取企业员工人数 20 | 21 | :param only_active: 是否包含未激活钉钉的人员数量 22 | :return: 企业员工数量 23 | """ 24 | return self._get( 25 | '/user/get_org_user_count', 26 | {'onlyActive': 0 if only_active else 1}, 27 | result_processor=lambda x: x['count'] 28 | ) 29 | 30 | def getuserinfo(self, code): 31 | """ 32 | 通过CODE换取用户身份 33 | 34 | :param code: requestAuthCode接口中获取的CODE 35 | :return: 36 | """ 37 | return self._get( 38 | '/user/getuserinfo', 39 | {'code': code} 40 | ) 41 | 42 | def get(self, userid, lang='zh_CN'): 43 | """ 44 | 获取成员详情 45 | 46 | :param userid: 员工在企业内的UserID,企业用来唯一标识用户的字段 47 | :param lang: 通讯录语言(默认zh_CN,未来会支持en_US) 48 | :return: 49 | """ 50 | return self._get( 51 | '/user/get', 52 | {'userid': userid, 'lang': lang} 53 | ) 54 | 55 | def create(self, user_data): 56 | """ 57 | 创建成员 58 | 59 | :param user_data: 用户信息 60 | :return: userid 61 | """ 62 | return self._post( 63 | '/user/create', 64 | user_data, 65 | result_processor=lambda x: x['userid'] 66 | 67 | ) 68 | 69 | def update(self, user_data): 70 | """ 71 | 更新成员 72 | 73 | :param user_data: 用户信息 74 | :return: 75 | """ 76 | return self._post( 77 | '/user/update', 78 | user_data 79 | ) 80 | 81 | def delete(self, userid): 82 | """ 83 | 删除成员 84 | 85 | :param userid: 员工在企业内的UserID,企业用来唯一标识用户的字段 86 | :return: 87 | """ 88 | return self._get( 89 | '/user/delete', 90 | {'userid': userid} 91 | ) 92 | 93 | def batchdelete(self, user_ids): 94 | """ 95 | 批量删除成员 96 | 97 | :param user_ids: 员工UserID列表。列表长度在1到20之间 98 | :return: 99 | """ 100 | return self._post( 101 | '/user/delete', 102 | {'useridlist': list(user_ids)} 103 | ) 104 | 105 | def simple_list(self, department_id, offset=0, size=100, order='custom', lang='zh_CN'): 106 | """ 107 | 获取部门成员 108 | 109 | :param department_id: 获取的部门id 110 | :param offset: 偏移量 111 | :param size: 表分页大小,最大100 112 | :param order: 排序规则 113 | entry_asc 代表按照进入部门的时间升序 114 | entry_desc 代表按照进入部门的时间降序 115 | modify_asc 代表按照部门信息修改时间升序 116 | modify_desc 代表按照部门信息修改时间降序 117 | custom 代表用户定义排序 118 | :param lang: 通讯录语言(默认zh_CN另外支持en_US) 119 | :return: 120 | """ 121 | return self._get( 122 | '/user/simplelist', 123 | { 124 | 'department_id': department_id, 125 | 'offset': offset, 126 | 'size': size, 127 | 'order': order, 128 | 'lang': lang 129 | } 130 | ) 131 | 132 | def list(self, department_id, offset=0, size=100, order='custom', lang='zh_CN'): 133 | """ 134 | 获取部门成员(详情) 135 | 136 | :param department_id: 获取的部门id 137 | :param offset: 偏移量 138 | :param size: 表分页大小,最大100 139 | :param order: 排序规则 140 | entry_asc 代表按照进入部门的时间升序 141 | entry_desc 代表按照进入部门的时间降序 142 | modify_asc 代表按照部门信息修改时间升序 143 | modify_desc 代表按照部门信息修改时间降序 144 | custom 代表用户定义排序 145 | :param lang: 通讯录语言(默认zh_CN另外支持en_US) 146 | :return: 147 | """ 148 | return self._get( 149 | '/user/list', 150 | { 151 | 'department_id': department_id, 152 | 'offset': offset, 153 | 'size': size, 154 | 'order': order, 155 | 'lang': lang 156 | } 157 | ) 158 | 159 | def get_admin(self): 160 | """ 161 | 获取管理员列表 162 | 163 | :return: sys_level 管理员角色 1:主管理员,2:子管理员 164 | """ 165 | return self._get( 166 | '/user/get_admin', 167 | result_processor=lambda x: x['admin_list'] 168 | ) 169 | 170 | def can_access_microapp(self, app_id, user_id): 171 | """ 172 | 获取管理员的微应用管理权限 173 | 174 | :param app_id: 微应用id 175 | :param user_id: 员工唯一标识ID 176 | :return: 是否能管理该微应用 177 | """ 178 | return self._get( 179 | '/user/can_access_microapp', 180 | {'appId': app_id, 'userId': user_id}, 181 | result_processor=lambda x: x['canAccess'] 182 | ) 183 | 184 | def get_userid_by_unionid(self, unionid): 185 | """ 186 | 根据unionid获取成员的userid 187 | 188 | :param unionid: 用户在当前钉钉开放平台账号范围内的唯一标识 189 | :return: 190 | """ 191 | return self._get( 192 | '/user/getUseridByUnionid', 193 | {'unionid': unionid} 194 | ) 195 | 196 | def get_dept_member(self, dept_id): 197 | """ 198 | 获取部门用户userid列表 199 | 200 | :param dept_id: 用户在当前钉钉开放平台账号范围内的唯一标识 201 | :return 部门userid列表: 202 | """ 203 | return self._get( 204 | '/user/getDeptMember', 205 | {'deptId': dept_id}, 206 | result_processor=lambda x: x['userIds'] 207 | ) 208 | 209 | def listbypage(self, department_id, offset=0, size=100, order='custom', lang='zh_CN'): 210 | """ 211 | 获取部门用户 212 | 213 | :param department_id: 获取的部门id 214 | :param offset: 偏移量 215 | :param size: 表分页大小,最大100 216 | :param order: 排序规则 217 | entry_asc 代表按照进入部门的时间升序 218 | entry_desc 代表按照进入部门的时间降序 219 | modify_asc 代表按照部门信息修改时间升序 220 | modify_desc 代表按照部门信息修改时间降序 221 | custom 代表用户定义排序 222 | :param lang: 通讯录语言(默认zh_CN另外支持en_US) 223 | :return: 224 | """ 225 | return self._get( 226 | '/user/list', 227 | { 228 | 'department_id': department_id, 229 | 'offset': offset, 230 | 'size': size, 231 | 'order': order, 232 | 'lang': lang 233 | } 234 | ) 235 | 236 | def get_admin_scope(self, userid): 237 | """ 238 | 查询管理员通讯录权限范围 239 | 240 | :param userid: 用户id 241 | """ 242 | return self._top_request( 243 | "dingtalk.oapi.user.get_admin_scope", 244 | {"userid": userid}, 245 | result_processor=lambda x: x['dept_ids'] 246 | ) 247 | -------------------------------------------------------------------------------- /dingtalk/client/api/workrecord.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import datetime 5 | import time 6 | 7 | from optionaldict import optionaldict 8 | 9 | from dingtalk.client.api.base import DingTalkBaseAPI 10 | from dingtalk.model.field import FieldBase 11 | 12 | 13 | class WorkRecord(DingTalkBaseAPI): 14 | 15 | def add(self, userid, create_time, title, url, form_item_dict, originator_user_id='', source_name=''): 16 | """ 17 | 新增待办事项 18 | 19 | :param userid: 用户id 20 | :param create_time: 待办时间。Unix时间戳 21 | :param title: 标题 22 | :param url: 待办跳转url 23 | :param form_item_dict: 表单列表 OrderedDict((('标题1', '内容1'),('标题2', '内容2'))) 24 | :param originator_user_id: manager7078 25 | :param source_name: 待办来源名称 26 | """ 27 | 28 | if isinstance(create_time, (datetime.date, datetime.datetime)): 29 | create_time = int(time.mktime(create_time.timetuple()) * 1000) 30 | form_item_list = [{'title': k, 'content': v}for k, v in form_item_dict.items()] 31 | return self._top_request( 32 | "dingtalk.oapi.workrecord.add", 33 | { 34 | "userid": userid, 35 | "create_time": create_time, 36 | "title": title, 37 | "url": url, 38 | "formItemList": form_item_list, 39 | "originator_user_id": originator_user_id, 40 | "source_name": source_name 41 | }, 42 | result_processor=lambda x: x['record_id'] 43 | ) 44 | 45 | def update(self, userid, record_id): 46 | """ 47 | 更新待办事项状态 48 | 49 | :param userid: 用户id 50 | :param record_id: 待办事项唯一id 51 | """ 52 | return self._top_request( 53 | "dingtalk.oapi.workrecord.update", 54 | {"userid": userid, "record_id": record_id} 55 | ) 56 | 57 | def getbyuserid(self, userid, status, offset=0, limit=50): 58 | """ 59 | 获取用户的待办事项 60 | 61 | :param userid: 用户唯一ID 62 | :param offset: 分页游标,从0开始,如返回结果中has_more为true,则表示还有数据,offset再传上一次的offset+limit 63 | :param limit: 分页大小,最多50 64 | :param status: 待办事项状态,0表示未完成,1表示完成 65 | """ 66 | return self._top_request( 67 | "dingtalk.oapi.workrecord.getbyuserid", 68 | { 69 | "userid": userid, 70 | "offset": offset, 71 | "limit": limit, 72 | "status": status 73 | }, 74 | result_processor=lambda x: x['records'] 75 | ) 76 | 77 | def process_save(self, name, description, form_component_list=(), process_code=None, agentid=None): 78 | """ 79 | 保存审批模板 80 | 81 | :param name: 模板名称 82 | :param description: 模板描述 83 | :param form_component_list: 表单列表 84 | :param process_code: 模板的唯一码 85 | :param agentid: 企业微应用标识 86 | """ 87 | form_component_list = [form.get_dict() if isinstance(form, FieldBase) else form for form in form_component_list] 88 | 89 | return self._top_request( 90 | "dingtalk.oapi.process.save", 91 | { 92 | "saveProcessRequest": optionaldict({ 93 | "agentid": agentid, 94 | "process_code": process_code, 95 | "name": name, 96 | "description": description, 97 | "fake_mode": True, 98 | "form_component_list": form_component_list 99 | }) 100 | }, 101 | result_processor=lambda x: x['process_code'] 102 | ) 103 | 104 | def process_delete(self, process_code, agentid=''): 105 | """ 106 | 删除创建的审批模板 107 | 108 | :param process_code: 模板的唯一码 109 | :param agentid: 微应用agentId,ISV必填 110 | """ 111 | return self._top_request( 112 | "dingtalk.oapi.process.delete", 113 | { 114 | "request": { 115 | "process_code": process_code, 116 | "agentid": agentid 117 | } 118 | } 119 | ) 120 | 121 | def process_workrecord_create( 122 | self, 123 | process_code, 124 | originator_user_id, 125 | form_component_values, 126 | url, 127 | agentid='', 128 | title='' 129 | ): 130 | """ 131 | 发起不带流程的审批实例 132 | 133 | :param process_code: 审批模板唯一码 134 | :param originator_user_id: 审批发起人 135 | :param form_component_values: 表单参数列表 136 | :param url: 实例跳转链接 137 | :param agentid: 应用id 138 | :param title: 实例标题 139 | """ 140 | if isinstance(form_component_values, dict): 141 | form_component_values = [{"name": name, "value": value} for name, value in form_component_values.items()] 142 | return self._top_request( 143 | "dingtalk.oapi.process.workrecord.create", 144 | { 145 | "request": { 146 | "process_code": process_code, 147 | "originator_user_id": originator_user_id, 148 | "form_component_values": form_component_values, 149 | "url": url, 150 | "agentid": agentid, 151 | "title": title 152 | } 153 | }, 154 | result_processor=lambda x: x['process_instance_id'] 155 | ) 156 | 157 | def process_workrecord_update( 158 | self, 159 | process_instance_id, 160 | status, 161 | result, 162 | agentid='' 163 | ): 164 | """ 165 | 同步待办实例状态 166 | 167 | :param process_instance_id: 实例id 168 | :param status: 实例状态,分为COMPLETED, TERMINATED 169 | :param result: 实例结果, 如果实例状态是COMPLETED,需要设置result,分为agree和refuse 170 | :param agentid: 应用id 171 | """ 172 | return self._top_request( 173 | "dingtalk.oapi.process.workrecord.update", 174 | { 175 | "request": { 176 | "process_instance_id": process_instance_id, 177 | "status": status, 178 | "result": result, 179 | "agentid": agentid 180 | } 181 | } 182 | ) 183 | 184 | def process_workrecord_task_create( 185 | self, 186 | process_instance_id, 187 | tasks, 188 | agentid='', 189 | activity_id='' 190 | ): 191 | """ 192 | 创建待办任务 193 | 194 | :param process_instance_id: 实例id 195 | :param tasks: 任务列表 196 | :param agentid: 应用id 197 | :param activity_id: 节点id 198 | """ 199 | return self._top_request( 200 | "dingtalk.oapi.process.workrecord.task.create", 201 | { 202 | "request": { 203 | "process_instance_id": process_instance_id, 204 | "tasks": tasks, 205 | "agentid": agentid, 206 | "activity_id": activity_id 207 | } 208 | }, 209 | result_processor=lambda x: x['tasks'] 210 | ) 211 | 212 | def dingtalk_oapi_process_workrecord_task_update( 213 | self, 214 | process_instance_id, 215 | tasks, 216 | agentid='' 217 | ): 218 | """ 219 | 更新待办任务状态 220 | 221 | :param process_instance_id: 实例id 222 | :param tasks: 任务列表 223 | :param agentid: 应用id 224 | """ 225 | return self._top_request( 226 | "dingtalk.oapi.process.workrecord.task.update", 227 | { 228 | "request": { 229 | "process_instance_id": process_instance_id, 230 | "tasks": tasks, 231 | "agentid": agentid 232 | } 233 | }, 234 | result_processor=lambda x: x['tasks'] 235 | ) 236 | 237 | def process_workrecord_taskgroup_cancel( 238 | self, 239 | process_instance_id, 240 | activity_id, 241 | agentid='' 242 | ): 243 | """ 244 | 批量取消任务 245 | 246 | :param process_instance_id: 实例id 247 | :param activity_id: 任务组id 248 | :param agentid: 应用id 249 | """ 250 | return self._top_request( 251 | "dingtalk.oapi.process.workrecord.taskgroup.cancel", 252 | { 253 | "request": { 254 | "process_instance_id": process_instance_id, 255 | "activity_id": activity_id, 256 | "agentid": agentid 257 | } 258 | } 259 | ) 260 | -------------------------------------------------------------------------------- /dingtalk/client/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import inspect 5 | import json 6 | import logging 7 | import requests 8 | import six 9 | from six.moves.urllib.parse import urljoin 10 | 11 | from dingtalk.client.api.base import DingTalkBaseAPI 12 | from dingtalk.core.exceptions import DingTalkClientException 13 | from dingtalk.core.utils import json_loads 14 | from dingtalk.storage.memorystorage import MemoryStorage 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def _is_api_endpoint(obj): 21 | return isinstance(obj, DingTalkBaseAPI) 22 | 23 | 24 | class BaseClient(object): 25 | 26 | _http = requests.Session() 27 | 28 | API_BASE_URL = 'https://oapi.dingtalk.com/' 29 | 30 | def __new__(cls, *args, **kwargs): 31 | self = super(BaseClient, cls).__new__(cls) 32 | api_endpoints = inspect.getmembers(self, _is_api_endpoint) 33 | for name, api in api_endpoints: 34 | api_cls = type(api) 35 | api = api_cls(self) 36 | setattr(self, name, api) 37 | return self 38 | 39 | def __init__(self, storage=None, timeout=None, auto_retry=True): 40 | self.storage = storage or MemoryStorage() 41 | self.timeout = timeout 42 | self.auto_retry = auto_retry 43 | 44 | def _request(self, method, url_or_endpoint, **kwargs): 45 | if not url_or_endpoint.startswith(('http://', 'https://')): 46 | api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) 47 | url = urljoin(api_base_url, url_or_endpoint) 48 | else: 49 | url = url_or_endpoint 50 | 51 | if 'params' not in kwargs: 52 | kwargs['params'] = {} 53 | if isinstance(kwargs.get('data', ''), dict): 54 | body = json.dumps(kwargs['data'], ensure_ascii=False) 55 | body = body.encode('utf-8') 56 | kwargs['data'] = body 57 | if 'headers' not in kwargs: 58 | kwargs['headers'] = {} 59 | kwargs['headers']['Content-Type'] = 'application/json' 60 | 61 | kwargs['timeout'] = kwargs.get('timeout', self.timeout) 62 | result_processor = kwargs.pop('result_processor', None) 63 | top_response_key = kwargs.pop('top_response_key', None) 64 | res = self._http.request( 65 | method=method, 66 | url=url, 67 | **kwargs 68 | ) 69 | try: 70 | res.raise_for_status() 71 | except requests.RequestException as reqe: 72 | logger.error("\n【请求地址】: %s\n【请求参数】:%s \n%s\n【异常信息】:%s", 73 | url, kwargs.get('params', ''), kwargs.get('data', ''), reqe) 74 | raise DingTalkClientException( 75 | errcode=None, 76 | errmsg=None, 77 | client=self, 78 | request=reqe.request, 79 | response=reqe.response 80 | ) 81 | 82 | result = self._handle_result( 83 | res, method, url, result_processor, top_response_key, **kwargs 84 | ) 85 | 86 | logger.debug("\n【请求地址】: %s\n【请求参数】:%s \n%s\n【响应数据】:%s", 87 | url, kwargs.get('params', ''), kwargs.get('data', ''), result) 88 | return result 89 | 90 | def _decode_result(self, res): 91 | try: 92 | result = json_loads(res.content.decode('utf-8', 'ignore'), strict=False) 93 | except (TypeError, ValueError): 94 | # Return origin response object if we can not decode it as JSON 95 | logger.debug('Can not decode response as JSON', exc_info=True) 96 | return res 97 | return result 98 | 99 | def _handle_result(self, res, method=None, url=None, result_processor=None, top_response_key=None, **kwargs): 100 | if not isinstance(res, dict): 101 | # Dirty hack around asyncio based AsyncWeChatClient 102 | result = self._decode_result(res) 103 | else: 104 | result = res 105 | 106 | if not isinstance(result, dict): 107 | return result 108 | if top_response_key: 109 | if 'error_response' in result: 110 | error_response = result['error_response'] 111 | logger.error("\n【请求地址】: %s\n【请求参数】:%s \n%s\n【错误信息】:%s", 112 | url, kwargs.get('params', ''), kwargs.get('data', ''), result) 113 | raise DingTalkClientException( 114 | error_response.get('code', -1), 115 | error_response.get('sub_msg', error_response.get('msg', '')), 116 | client=self, 117 | request=res.request, 118 | response=res 119 | ) 120 | top_result = result 121 | if top_response_key in top_result: 122 | top_result = result[top_response_key] 123 | if 'result' in top_result: 124 | top_result = top_result['result'] 125 | if isinstance(top_result, six.string_types): 126 | try: 127 | top_result = json_loads(top_result) 128 | except Exception: 129 | pass 130 | if isinstance(top_result, dict): 131 | if ('success' in top_result and not top_result['success']) or ( 132 | 'is_success' in top_result and not top_result['is_success']): 133 | logger.error("\n【请求地址】: %s\n【请求参数】:%s \n%s\n【错误信息】:%s", 134 | url, kwargs.get('params', ''), kwargs.get('data', ''), result) 135 | raise DingTalkClientException( 136 | top_result.get('ding_open_errcode', -1), 137 | top_result.get('error_msg', ''), 138 | client=self, 139 | request=res.request, 140 | response=res 141 | ) 142 | result = top_result 143 | if not isinstance(result, dict): 144 | return result 145 | if 'errcode' in result: 146 | result['errcode'] = int(result['errcode']) 147 | 148 | if 'errcode' in result and result['errcode'] != 0: 149 | errcode = result['errcode'] 150 | errmsg = result.get('errmsg', errcode) 151 | 152 | logger.error("\n【请求地址】: %s\n【请求参数】:%s \n%s\n【错误信息】:%s", 153 | url, kwargs.get('params', ''), kwargs.get('data', ''), result) 154 | raise DingTalkClientException( 155 | errcode, 156 | errmsg, 157 | client=self, 158 | request=res.request, 159 | response=res 160 | ) 161 | 162 | return result if not result_processor else result_processor(result) 163 | 164 | def _handle_pre_request(self, method, uri, kwargs): 165 | return method, uri, kwargs 166 | 167 | def _handle_pre_top_request(self, params, uri): 168 | if not uri.startswith(('http://', 'https://')): 169 | uri = urljoin('https://eco.taobao.com', uri) 170 | return params, uri 171 | 172 | def _handle_request_except(self, e, func, *args, **kwargs): 173 | raise e 174 | 175 | def request(self, method, uri, **kwargs): 176 | method, uri_with_access_token, kwargs = self._handle_pre_request(method, uri, kwargs) 177 | try: 178 | return self._request(method, uri_with_access_token, **kwargs) 179 | except DingTalkClientException as e: 180 | return self._handle_request_except(e, self.request, method, uri, **kwargs) 181 | 182 | def top_request(self, method, params=None, format_='json', v='2.0', 183 | simplify='false', partner_id=None, url=None, **kwargs): 184 | """ 185 | top 接口请求 186 | 187 | :param method: API接口名称。 188 | :param params: 请求参数 (dict 格式) 189 | :param format_: 响应格式(默认json,如果使用xml,需要自己对返回结果解析) 190 | :param v: API协议版本,可选值:2.0。 191 | :param simplify: 是否采用精简JSON返回格式 192 | :param partner_id: 合作伙伴身份标识。 193 | :param url: 请求url,默认为 https://eco.taobao.com/router/rest 194 | """ 195 | from datetime import datetime 196 | 197 | reqparams = {} 198 | if params is not None: 199 | for key, value in params.items(): 200 | reqparams[key] = value if not isinstance(value, (dict, list, tuple)) else json.dumps(value) 201 | reqparams['method'] = method 202 | reqparams['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 203 | reqparams['format'] = format_ 204 | reqparams['v'] = v 205 | 206 | if format_ == 'json': 207 | reqparams['simplify'] = simplify 208 | if partner_id: 209 | reqparams['partner_id'] = partner_id 210 | base_url = url or '/router/rest' 211 | 212 | reqparams, base_url = self._handle_pre_top_request(reqparams, base_url) 213 | 214 | if not base_url.startswith(('http://', 'https://')): 215 | base_url = urljoin(self.API_BASE_URL, base_url) 216 | response_key = method.replace('.', '_') + "_response" 217 | try: 218 | return self._request('POST', base_url, params=reqparams, top_response_key=response_key, **kwargs) 219 | except DingTalkClientException as e: 220 | return self._handle_request_except(e, self.request, 221 | method, format_, v, simplify, partner_id, url, params, **kwargs) 222 | 223 | def get(self, uri, params=None, **kwargs): 224 | """ 225 | get 接口请求 226 | 227 | :param uri: 请求url 228 | :param params: get 参数(dict 格式) 229 | """ 230 | if params is not None: 231 | kwargs['params'] = params 232 | return self.request('GET', uri, **kwargs) 233 | 234 | def post(self, uri, data=None, params=None, **kwargs): 235 | """ 236 | post 接口请求 237 | 238 | :param uri: 请求url 239 | :param data: post 数据(dict 格式会自动转换为json) 240 | :param params: post接口中url问号后参数(dict 格式) 241 | """ 242 | if data is not None: 243 | kwargs['data'] = data 244 | if params is not None: 245 | kwargs['params'] = params 246 | return self.request('POST', uri, **kwargs) 247 | -------------------------------------------------------------------------------- /dingtalk/client/channel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import logging 5 | 6 | import time 7 | 8 | from dingtalk.client.base import BaseClient 9 | from dingtalk.core.utils import random_string, DingTalkSigner 10 | from dingtalk.storage.cache import ChannelCache 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class ChannelClient(BaseClient): 16 | 17 | def __init__(self, corp_id, prefix='channel', storage=None, timeout=None, auto_retry=True): 18 | super(ChannelClient, self).__init__(storage, timeout, auto_retry) 19 | self.corp_id = corp_id 20 | self.cache = ChannelCache(self.storage, prefix) 21 | 22 | def _handle_pre_request(self, method, uri, kwargs): 23 | if 'access_token=' in uri or 'access_token' in kwargs.get('params', {}): 24 | raise ValueError("access_token: " + uri) 25 | uri = '%s%access_token=%s' % (uri, '&' if '?' in uri else '?', self.channel_token) 26 | return method, uri, kwargs 27 | 28 | def _handle_request_except(self, e, func, *args, **kwargs): 29 | if e.errcode in (33001, 40001, 42001, 40014): 30 | self.cache.channel_token.delete() 31 | if self.auto_retry: 32 | return func(*args, **kwargs) 33 | raise e 34 | 35 | @property 36 | def channel_token(self): 37 | self.cache.channel_token.get() 38 | token = self.cache.channel_token.get() 39 | if token is None: 40 | ret = self.get_channel_token() 41 | token = ret['access_token'] 42 | expires_in = ret.get('expires_in', 7200) 43 | self.cache.channel_token.set(value=token, ttl=expires_in) 44 | return token 45 | 46 | @property 47 | def channel_jsapi_ticket(self): 48 | ticket = self.cache.jsapi_ticket.get() 49 | if ticket is None: 50 | ret = self.get_channel_jsapi_ticket() 51 | ticket = ret['ticket'] 52 | expires_in = ret.get('expires_in', 7200) 53 | self.cache.jsapi_ticket.set(value=ticket, ttl=expires_in) 54 | return ticket 55 | 56 | def get_jsapi_params(self, url, noncestr=None, timestamp=None): 57 | if not noncestr: 58 | noncestr = random_string() 59 | if timestamp is None: 60 | timestamp = int(time.time() * 1000) 61 | data = [ 62 | 'noncestr={noncestr}'.format(noncestr=noncestr), 63 | 'jsapi_ticket={ticket}'.format(ticket=self.channel_jsapi_ticket), 64 | 'timestamp={timestamp}'.format(timestamp=timestamp), 65 | 'url={url}'.format(url=url), 66 | ] 67 | signer = DingTalkSigner(delimiter=b'&') 68 | signer.add_data(*data) 69 | 70 | ret = { 71 | 'corpId': self.corp_id, 72 | 'timeStamp': timestamp, 73 | 'nonceStr': noncestr, 74 | 'signature': signer.signature 75 | } 76 | return ret 77 | 78 | def get_channel_token(self): 79 | raise NotImplementedError 80 | 81 | def get_channel_jsapi_ticket(self): 82 | """ 83 | 获取企业服务窗JSAPI鉴权ticket 84 | 85 | :return: 86 | """ 87 | return self.get('/channel/get_channel_jsapi_ticket') 88 | 89 | def get_user_list(self, offset=0, size=100): 90 | """ 91 | 获取服务窗关注者列表 92 | 93 | :param offset: 偏移量,必须大于等于0 94 | :param size: 获取数量,大于等于0,小于等于100 95 | :return: 96 | """ 97 | return self.get( 98 | '/channel/user/list', 99 | {"offset": offset, "size": size} 100 | ) 101 | 102 | def get_by_openid(self, openid): 103 | """ 104 | 获取关注者详情 105 | 106 | :param openid: 在本服务窗运营服务商 范围内,唯一标识关注者身份的id 107 | :return: 108 | """ 109 | return self.get( 110 | '/channel/user/get_by_openid', 111 | {"openid": openid} 112 | ) 113 | 114 | def get_by_code(self, code): 115 | """ 116 | 关注者免登接口 117 | 118 | :param code: 服务窗关注者在服务窗应用中免登时生成的临时授权码 119 | :return: 120 | """ 121 | return self.get( 122 | '/channel/user/get_by_code', 123 | {"code": code} 124 | ) 125 | 126 | 127 | class SecretChannelClient(ChannelClient): 128 | def __init__(self, corp_id, channel_secret, storage=None, timeout=None, auto_retry=True): 129 | super(SecretChannelClient, self).__init__(corp_id, storage, timeout, auto_retry) 130 | self.channel_secret = channel_secret 131 | self.cache = ChannelCache(self.storage, 'channelsecret:' + self.corp_id) 132 | 133 | def get_channel_token(self): 134 | """ 135 | 获取服务窗ChannelToken 136 | 137 | :return: 138 | """ 139 | return self.get( 140 | '/channel/get_channel_token', 141 | {"corpid": self.corp_id, "channel_secret": self.channel_secret} 142 | ) 143 | -------------------------------------------------------------------------------- /dingtalk/client/isv.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import logging 5 | 6 | from dingtalk.core.utils import to_text, json_loads 7 | from dingtalk.client import DingTalkClient 8 | from dingtalk.client.base import BaseClient 9 | from dingtalk.client.channel import ChannelClient 10 | from dingtalk.core.constants import SuitePushType 11 | from dingtalk.crypto import DingTalkCrypto 12 | from dingtalk.storage.cache import ISVCache 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class ISVDingTalkClient(DingTalkClient): 18 | def __init__(self, corp_id, isv_client): 19 | super(ISVDingTalkClient, self).__init__(corp_id, 'isv_auth:' + isv_client.suite_key, 20 | isv_client.storage, isv_client.timeout, isv_client.auto_retry) 21 | self.isv_client = isv_client 22 | 23 | def get_access_token(self): 24 | return self.isv_client.get_access_token_by_corpid(self.corp_id) 25 | 26 | 27 | class ISVChannelClient(ChannelClient): 28 | def __init__(self, corp_id, isv_client): 29 | super(ISVChannelClient, self).__init__(corp_id, 'isv_channel:' + isv_client.suite_key, 30 | isv_client.storage, isv_client.timeout, isv_client.auto_retry) 31 | self.isv_client = isv_client 32 | 33 | def get_channel_token(self): 34 | return self.isv_client.get_channel_token_by_corpid(self.corp_id) 35 | 36 | 37 | class ISVClient(BaseClient): 38 | 39 | def __init__(self, suite_key, suite_secret, token=None, aes_key=None, storage=None, timeout=None, auto_retry=True): 40 | super(ISVClient, self).__init__(storage, timeout, auto_retry) 41 | self.suite_key = suite_key 42 | self.suite_secret = suite_secret 43 | self.cache = ISVCache(self.storage, 'isv:' + self.suite_key) 44 | self.crypto = DingTalkCrypto(token, aes_key, suite_key) 45 | 46 | def _handle_pre_request(self, method, uri, kwargs): 47 | if 'suite_access_token=' in uri or 'suite_access_token' in kwargs.get('params', {}): 48 | raise ValueError("suite_access_token: " + uri) 49 | uri = '%s%ssuite_access_token=%s' % (uri, '&' if '?' in uri else '?', self.suite_access_token) 50 | return method, uri, kwargs 51 | 52 | def _handle_request_except(self, e, func, *args, **kwargs): 53 | if e.errcode in (33001, 40001, 42001, 40014): 54 | self.cache.suite_access_token.delete() 55 | if self.auto_retry: 56 | return func(*args, **kwargs) 57 | raise e 58 | 59 | def set_suite_ticket(self, suite_ticket): 60 | self.cache.suite_ticket.set(value=suite_ticket) 61 | 62 | @property 63 | def suite_access_token(self): 64 | self.cache.suite_access_token.get() 65 | token = self.cache.suite_access_token.get() 66 | if token is None: 67 | ret = self.get_suite_access_token() 68 | token = ret['suite_access_token'] 69 | expires_in = ret.get('expires_in', 7200) 70 | self.cache.suite_access_token.set(value=token, ttl=expires_in) 71 | return token 72 | 73 | def _handle_permanent_code(self, permanent_code_data): 74 | permanent_code = permanent_code_data.get('permanent_code', None) 75 | ch_permanent_code = permanent_code_data.get('ch_permanent_code', None) 76 | corp_id = permanent_code_data.get('auth_corp_info', {}).get('corpid', None) 77 | if corp_id is None: 78 | return 79 | if permanent_code is not None: 80 | self.cache.permanent_code.set(corp_id, permanent_code) 81 | if ch_permanent_code is not None: 82 | self.cache.ch_permanent_code.set(corp_id, ch_permanent_code) 83 | 84 | def get_dingtalk_client(self, corp_id): 85 | return ISVDingTalkClient(corp_id, self) 86 | 87 | def get_channel_client(self, corp_id): 88 | return ISVChannelClient(corp_id, self) 89 | 90 | def proc_message(self, message): 91 | if not isinstance(message, dict): 92 | return 93 | event_type = message.get('EventType', None) 94 | 95 | if event_type == SuitePushType.SUITE_TICKET.value: 96 | suite_ticket = message.get('SuiteTicket', None) 97 | if suite_ticket: 98 | self.set_suite_ticket(suite_ticket) 99 | return 100 | elif event_type == SuitePushType.TMP_AUTH_CODE.value: 101 | auth_code = message.get('AuthCode') 102 | permanent_code_data = self.get_permanent_code(auth_code) 103 | message['__permanent_code_data'] = permanent_code_data 104 | return 105 | elif event_type == SuitePushType.SUITE_RELIEVE.value: 106 | corp_id = message.get('AuthCorpId') 107 | self.cache.permanent_code.delete(corp_id) 108 | self.cache.ch_permanent_code.delete(corp_id) 109 | return 110 | else: 111 | return 112 | 113 | def parse_message(self, msg, signature, timestamp, nonce): 114 | message = self.crypto.decrypt_message(msg, signature, timestamp, nonce) 115 | try: 116 | message = json_loads(to_text(message)) 117 | self.proc_message(message) 118 | except Exception as e: 119 | logger.error("proc_message error %s %s", message, e) 120 | return message 121 | 122 | def get_ch_permanent_code_from_cache(self, corp_id): 123 | return self.cache.ch_permanent_code.get(corp_id) 124 | 125 | def get_permanent_code_from_cache(self, corp_id): 126 | return self.cache.permanent_code.get(corp_id) 127 | 128 | def get_suite_access_token(self): 129 | """ 130 | 获取应用套件令牌Token 131 | 132 | :return: 133 | """ 134 | return self._request( 135 | 'post', 136 | '/service/get_suite_token', 137 | data={ 138 | "suite_key": self.suite_key, 139 | "suite_secret": self.suite_secret, 140 | "suite_ticket": self.cache.suite_ticket.get() 141 | } 142 | ) 143 | 144 | def get_permanent_code(self, tmp_auth_code): 145 | """ 146 | 获取企业授权的永久授权码 147 | 148 | :param tmp_auth_code: 回调接口(tmp_auth_code)获取的临时授权码 149 | :return: 150 | """ 151 | permanent_code_data = self.post( 152 | '/service/get_permanent_code', 153 | {'tmp_auth_code': tmp_auth_code} 154 | ) 155 | self._handle_permanent_code(permanent_code_data) 156 | return permanent_code_data 157 | 158 | def activate_suite(self, corp_id): 159 | """ 160 | 激活套件 161 | 162 | :param corp_id: 授权方corpid 163 | :return: 164 | """ 165 | return self.post( 166 | '/service/activate_suite', 167 | { 168 | 'suite_key': self.suite_key, 169 | 'auth_corpid': corp_id, 170 | 'permanent_code': self.cache.permanent_code.get(corp_id)} 171 | ) 172 | 173 | def get_access_token_by_corpid(self, corp_id): 174 | """ 175 | 获取企业授权的凭证 176 | 177 | :param corp_id: 授权方corpid 178 | :return: 179 | """ 180 | return self.post( 181 | '/service/get_corp_token', 182 | {'auth_corpid': corp_id, 'permanent_code': self.cache.permanent_code.get(corp_id)} 183 | ) 184 | 185 | def get_auth_info(self, corp_id): 186 | """ 187 | 获取企业基本信息 188 | 189 | :param corp_id: 授权方corpid 190 | :return: 191 | """ 192 | return self.post( 193 | '/service/get_auth_info', 194 | {'auth_corpid': corp_id, 'suite_key': self.suite_key} 195 | ) 196 | 197 | def get_agent(self, corp_id, agent_id): 198 | """ 199 | 获取企业的应用信息 200 | 201 | :param corp_id: 授权方corpid 202 | :param agent_id: 授权方应用id 203 | :return: 204 | """ 205 | return self.post( 206 | '/service/get_agent', 207 | { 208 | 'suite_key': self.suite_key, 209 | 'auth_corpid': corp_id, 210 | 'agentid': agent_id, 211 | 'permanent_code': self.get_permanent_code_from_cache(corp_id) 212 | } 213 | ) 214 | 215 | def get_unactive_corp(self, app_id): 216 | """ 217 | 获取应用未激活的企业列表 218 | 219 | :param app_id: 套件下的微应用ID 220 | :return: 221 | """ 222 | return self.post( 223 | '/service/get_unactive_corp', 224 | {'app_id': app_id} 225 | ) 226 | 227 | def reauth_corp(self, app_id, corpid_list): 228 | """ 229 | 重新授权未激活应用的企业 230 | 231 | :param app_id: 套件下的微应用ID 232 | :param corpid_list: 未激活的corpid列表 233 | :return: 234 | """ 235 | return self.post( 236 | '/service/reauth_corp', 237 | {'app_id': app_id, 'corpid_list': corpid_list} 238 | ) 239 | 240 | def set_corp_ipwhitelist(self, corp_id, ip_whitelist): 241 | """ 242 | ISV为授权方的企业单独设置IP白名单 243 | 244 | :param corp_id: 授权方corpid 245 | :param ip_whitelist: 要为其设置的IP白名单,格式支持IP段,用星号表示,注意:仅支持后两段设置为星号 246 | :return: 247 | """ 248 | return self.post( 249 | '/service/set_corp_ipwhitelist', 250 | {'auth_corpid': corp_id, 'ip_whitelist': ip_whitelist} 251 | ) 252 | 253 | def get_channel_token_by_corpid(self, corp_id): 254 | """ 255 | ISV获取企业服务窗接口调用TOKEN 256 | 257 | :param corp_id: 授权方corpid 258 | :return: 259 | """ 260 | return self.post( 261 | '/service/get_channel_corp_token', 262 | {'auth_corpid': corp_id, 'ch_permanent_code': self.get_ch_permanent_code_from_cache(corp_id)} 263 | ) 264 | -------------------------------------------------------------------------------- /dingtalk/core/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | -------------------------------------------------------------------------------- /dingtalk/core/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from enum import Enum 5 | 6 | 7 | class SuitePushType(Enum): 8 | """套件相关回调枚举""" 9 | CHECK_URL = "check_url" # 校验url 10 | CHANGE_AUTH = "change_auth" # 授权变更 11 | SUITE_TICKET = "suite_ticket" # 套件ticket 12 | TMP_AUTH_CODE = "tmp_auth_code" # 临时授权码 13 | SUITE_RELIEVE = "suite_relieve" # 解除授权 14 | CHECK_CREATE_SUITE_URL = "check_create_suite_url" # 校验创建套件时候的url 15 | CHECK_UPDATE_SUITE_URL = "check_update_suite_url" # 校验更改套件时候的url 16 | CHECK_SUITE_LICENSE_CODE = "check_suite_license_code" # 校验序列号 17 | MARKET_BUY = "market_buy" # 用户购买下单 18 | ORG_MICRO_APP_STOP = "org_micro_app_stop" # 企业逻辑停用微应用 19 | ORG_MICRO_APP_REMOVE = "org_micro_app_remove" # 企业物理删除微应用 20 | ORG_MICRO_APP_RESTORE = "org_micro_app_restore" # 企业逻辑启用微应用 21 | -------------------------------------------------------------------------------- /dingtalk/core/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import six 5 | 6 | from dingtalk.core.utils import to_binary, to_text 7 | 8 | 9 | class DingTalkException(Exception): 10 | 11 | def __init__(self, errcode, errmsg): 12 | """ 13 | :param errcode: Error code 14 | :param errmsg: Error message 15 | """ 16 | self.errcode = errcode 17 | self.errmsg = errmsg 18 | 19 | def __str__(self): 20 | _repr = 'Error code: {code}, message: {msg}'.format( 21 | code=self.errcode, 22 | msg=self.errmsg 23 | ) 24 | 25 | if six.PY2: 26 | return to_binary(_repr) 27 | else: 28 | return to_text(_repr) 29 | 30 | def __repr__(self): 31 | _repr = '{klass}({code}, {msg})'.format( 32 | klass=self.__class__.__name__, 33 | code=self.errcode, 34 | msg=self.errmsg 35 | ) 36 | if six.PY2: 37 | return to_binary(_repr) 38 | else: 39 | return to_text(_repr) 40 | 41 | 42 | class DingTalkClientException(DingTalkException): 43 | """WeChat API client exception class""" 44 | def __init__(self, errcode, errmsg, client=None, 45 | request=None, response=None): 46 | super(DingTalkClientException, self).__init__(errcode, errmsg) 47 | self.client = client 48 | self.request = request 49 | self.response = response 50 | 51 | 52 | class InvalidSignatureException(DingTalkException): 53 | """Invalid signature exception class""" 54 | 55 | def __init__(self, errcode=-40001, errmsg='Invalid signature'): 56 | super(InvalidSignatureException, self).__init__(errcode, errmsg) 57 | 58 | 59 | class InvalidCorpIdOrSuiteKeyException(DingTalkException): 60 | """Invalid app_id exception class""" 61 | 62 | def __init__(self, errcode=-40005, errmsg='Invalid CorpIdOrSuiteKey'): 63 | super(InvalidCorpIdOrSuiteKeyException, self).__init__(errcode, errmsg) 64 | -------------------------------------------------------------------------------- /dingtalk/core/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import hashlib 5 | import json 6 | import random 7 | import string 8 | 9 | import six 10 | 11 | 12 | class ObjectDict(dict): 13 | """Makes a dictionary behave like an object, with attribute-style access. 14 | """ 15 | 16 | def __getattr__(self, key): 17 | if key in self: 18 | return self[key] 19 | return None 20 | 21 | def __setattr__(self, key, value): 22 | self[key] = value 23 | 24 | 25 | class DingTalkSigner(object): 26 | """DingTalk data signer""" 27 | 28 | def __init__(self, delimiter=b''): 29 | self._data = [] 30 | self._delimiter = to_binary(delimiter) 31 | 32 | def add_data(self, *args): 33 | """Add data to signer""" 34 | for data in args: 35 | self._data.append(to_binary(data)) 36 | 37 | @property 38 | def signature(self): 39 | """Get data signature""" 40 | self._data.sort() 41 | str_to_sign = self._delimiter.join(self._data) 42 | return hashlib.sha1(str_to_sign).hexdigest() 43 | 44 | 45 | def to_text(value, encoding='utf-8'): 46 | """Convert value to unicode, default encoding is utf-8 47 | 48 | :param value: Value to be converted 49 | :param encoding: Desired encoding 50 | """ 51 | if not value: 52 | return '' 53 | if isinstance(value, six.text_type): 54 | return value 55 | if isinstance(value, six.binary_type): 56 | return value.decode(encoding) 57 | return six.text_type(value) 58 | 59 | 60 | def to_binary(value, encoding='utf-8'): 61 | """Convert value to binary string, default encoding is utf-8 62 | 63 | :param value: Value to be converted 64 | :param encoding: Desired encoding 65 | """ 66 | if not value: 67 | return b'' 68 | if isinstance(value, six.binary_type): 69 | return value 70 | if isinstance(value, six.text_type): 71 | return value.encode(encoding) 72 | return to_text(value).encode(encoding) 73 | 74 | 75 | def random_string(length=16): 76 | rule = string.ascii_letters + string.digits 77 | rand_list = random.sample(rule, length) 78 | return ''.join(rand_list) 79 | 80 | 81 | def byte2int(c): 82 | if six.PY2: 83 | return ord(c) 84 | return c 85 | 86 | 87 | def json_loads(s, object_hook=ObjectDict, **kwargs): 88 | return json.loads(s, object_hook=object_hook, **kwargs) 89 | -------------------------------------------------------------------------------- /dingtalk/crypto/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | import time 4 | import base64 5 | import json 6 | 7 | from dingtalk.core.exceptions import InvalidSignatureException, InvalidCorpIdOrSuiteKeyException 8 | from dingtalk.core.utils import to_text, to_binary, DingTalkSigner, random_string 9 | 10 | from dingtalk.crypto.base import BasePrpCrypto 11 | 12 | 13 | def _get_signature(token, timestamp, nonce, encrypt): 14 | signer = DingTalkSigner() 15 | signer.add_data(token, timestamp, nonce, encrypt) 16 | return signer.signature 17 | 18 | 19 | class PrpCrypto(BasePrpCrypto): 20 | 21 | def encrypt(self, text, _id): 22 | return self._encrypt(text, _id) 23 | 24 | def decrypt(self, text, _id): 25 | return self._decrypt(text, _id, InvalidCorpIdOrSuiteKeyException) 26 | 27 | 28 | class BaseDingTalkCrypto(object): 29 | 30 | def __init__(self, token, encoding_aes_key, _id): 31 | if encoding_aes_key: 32 | encoding_aes_key = to_binary(encoding_aes_key + '=') 33 | self.key = base64.b64decode(encoding_aes_key) 34 | assert len(self.key) == 32 35 | else: 36 | self.key = None 37 | self.token = token 38 | self._id = _id 39 | 40 | def _decrypt_encrypt_str(self, 41 | signature, 42 | timestamp, 43 | nonce, 44 | encrypt_str, 45 | crypto_class=None): 46 | 47 | _signature = _get_signature(self.token, timestamp, nonce, encrypt_str) 48 | if _signature != signature: 49 | raise InvalidSignatureException() 50 | assert self.key is not None 51 | pc = crypto_class(self.key) 52 | return pc.decrypt(encrypt_str, self._id) 53 | 54 | def _encrypt_message(self, 55 | msg, 56 | nonce=None, 57 | timestamp=None, 58 | crypto_class=None): 59 | 60 | timestamp = timestamp or int(time.time() * 1000) 61 | timestamp = to_text(timestamp) 62 | if nonce is None: 63 | nonce = random_string() 64 | assert self.key is not None 65 | pc = crypto_class(self.key) 66 | encrypt = to_text(pc.encrypt(msg, self._id)) 67 | signature = _get_signature(self.token, timestamp, nonce, encrypt) 68 | result = dict() 69 | result['msg_signature'] = signature 70 | result['encrypt'] = encrypt 71 | result['timeStamp'] = timestamp 72 | result['nonce'] = nonce 73 | return result 74 | 75 | def _decrypt_message(self, 76 | msg, 77 | signature, 78 | timestamp, 79 | nonce, 80 | crypto_class=None): 81 | if not isinstance(msg, dict): 82 | msg = json.loads(to_text(msg)) 83 | encrypt = msg['encrypt'] 84 | return self._decrypt_encrypt_str(signature, timestamp, nonce, encrypt, crypto_class) 85 | 86 | 87 | class DingTalkCrypto(BaseDingTalkCrypto): 88 | 89 | def __init__(self, token, encoding_aes_key, corpid_or_suitekey): 90 | super(DingTalkCrypto, self).__init__(token, encoding_aes_key, corpid_or_suitekey) 91 | 92 | def decrypt_encrypt_str(self, signature, timestamp, nonce, encrypt_str): 93 | return self._decrypt_encrypt_str(signature, timestamp, nonce, encrypt_str, PrpCrypto) 94 | 95 | def encrypt_message(self, msg, nonce=None, timestamp=None): 96 | return self._encrypt_message(msg, nonce, timestamp, PrpCrypto) 97 | 98 | def decrypt_message(self, msg, signature, timestamp, nonce): 99 | return self._decrypt_message( 100 | msg, 101 | signature, 102 | timestamp, 103 | nonce, 104 | PrpCrypto 105 | ) 106 | -------------------------------------------------------------------------------- /dingtalk/crypto/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | import struct 4 | import socket 5 | import base64 6 | 7 | from dingtalk.core.utils import to_text, to_binary, random_string, byte2int 8 | from dingtalk.crypto.pkcs7 import PKCS7Encoder 9 | try: 10 | from dingtalk.crypto.cryptography import DingTalkCipher 11 | except ImportError: 12 | try: 13 | from dingtalk.crypto.pycrypto import DingTalkCipher 14 | except ImportError: 15 | raise Exception('You must install either cryptography or PyCrypto!') 16 | 17 | 18 | class BasePrpCrypto(object): 19 | 20 | def __init__(self, key): 21 | self.cipher = DingTalkCipher(key) 22 | 23 | def get_random_string(self): 24 | return random_string(16) 25 | 26 | def _encrypt(self, text, _id): 27 | text = to_binary(text) 28 | tmp_list = [] 29 | tmp_list.append(to_binary(self.get_random_string())) 30 | length = struct.pack(b'I', socket.htonl(len(text))) 31 | tmp_list.append(length) 32 | tmp_list.append(text) 33 | tmp_list.append(to_binary(_id)) 34 | 35 | text = b''.join(tmp_list) 36 | text = PKCS7Encoder.encode(text) 37 | 38 | ciphertext = to_binary(self.cipher.encrypt(text)) 39 | return base64.b64encode(ciphertext) 40 | 41 | def _decrypt(self, text, _id, exception=None): 42 | text = to_binary(text) 43 | plain_text = self.cipher.decrypt(base64.b64decode(text)) 44 | padding = byte2int(plain_text[-1]) 45 | content = plain_text[16:-padding] 46 | xml_length = socket.ntohl(struct.unpack(b'I', content[:4])[0]) 47 | xml_content = to_text(content[4:xml_length + 4]) 48 | from_id = to_text(content[xml_length + 4:]) 49 | if from_id != _id: 50 | exception = exception or Exception 51 | raise exception() 52 | return xml_content 53 | -------------------------------------------------------------------------------- /dingtalk/crypto/cryptography.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 4 | from cryptography.hazmat.backends import default_backend 5 | 6 | 7 | class DingTalkCipher(object): 8 | 9 | def __init__(self, key): 10 | backend = default_backend() 11 | self.cipher = Cipher( 12 | algorithms.AES(key), 13 | modes.CBC(key[:16]), 14 | backend=backend 15 | ) 16 | 17 | def encrypt(self, plaintext): 18 | encryptor = self.cipher.encryptor() 19 | return encryptor.update(plaintext) + encryptor.finalize() 20 | 21 | def decrypt(self, ciphertext): 22 | decryptor = self.cipher.decryptor() 23 | return decryptor.update(ciphertext) + decryptor.finalize() 24 | -------------------------------------------------------------------------------- /dingtalk/crypto/pkcs7.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | from dingtalk.core.utils import to_binary, byte2int 4 | 5 | 6 | class PKCS7Encoder(object): 7 | block_size = 32 8 | 9 | @classmethod 10 | def encode(cls, text): 11 | length = len(text) 12 | padding_count = cls.block_size - length % cls.block_size 13 | if padding_count == 0: 14 | padding_count = cls.block_size 15 | padding = to_binary(chr(padding_count)) 16 | return text + padding * padding_count 17 | 18 | @classmethod 19 | def decode(cls, decrypted): 20 | padding = byte2int(decrypted[-1]) 21 | if padding < 1 or padding > 32: 22 | padding = 0 23 | return decrypted[:-padding] 24 | -------------------------------------------------------------------------------- /dingtalk/crypto/pycrypto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | from Crypto.Cipher import AES 4 | 5 | 6 | class DingTalkCipher(object): 7 | 8 | def __init__(self, key): 9 | self.cipher = AES.new(key, AES.MODE_CBC, key[:16]) 10 | 11 | def encrypt(self, plaintext): 12 | return self.cipher.encrypt(plaintext) 13 | 14 | def decrypt(self, ciphertext): 15 | return self.cipher.decrypt(ciphertext) 16 | -------------------------------------------------------------------------------- /dingtalk/model/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | -------------------------------------------------------------------------------- /dingtalk/model/field.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import random 5 | import string 6 | 7 | 8 | class FieldBase(object): 9 | component_name = None 10 | 11 | def __init__(self, **kwargs): 12 | for k, v in kwargs.items(): 13 | if callable(v): 14 | v = v() 15 | setattr(self, k, v) 16 | if getattr(kwargs, 'id', None) is None: 17 | self.id = self.gen_id() 18 | 19 | def gen_id(self): 20 | return "%s-%s" % (self.component_name, random.sample(string.ascii_uppercase + string.digits, 8)) 21 | 22 | def get_dict(self): 23 | assert self.component_name 24 | return {'component_name': self.component_name, "props": self.get_data()} 25 | 26 | def get_data(self): 27 | ret = {} 28 | for k in dir(self): 29 | if k.startswith('_') or k == 'component_name': 30 | continue 31 | v = getattr(self, k, None) 32 | if v is None or hasattr(v, '__call__'): 33 | continue 34 | if v is not None: 35 | if isinstance(v, FieldBase): 36 | v = v.get_data() 37 | ret[k] = v 38 | return ret 39 | 40 | 41 | class TextField(FieldBase): 42 | component_name = "TextField" 43 | 44 | def __init__(self, label, required=True, placeholder='', **kwargs): 45 | """ 46 | 47 | :param label: 表单组件名称 48 | :param required: 是否必填 49 | :param placeholder: 输入提示 50 | :param kwargs: 51 | """ 52 | super(TextField, self).__init__(required=required, placeholder=placeholder, label=label, **kwargs) 53 | 54 | 55 | class TextareaField(FieldBase): 56 | component_name = "TextareaField" 57 | 58 | def __init__(self, label, required=True, placeholder='', **kwargs): 59 | """ 60 | 61 | :param label: 表单组件名称 62 | :param required: 是否必填 63 | :param placeholder: 输入提示 64 | :param kwargs: 65 | """ 66 | super(TextareaField, self).__init__(required=required, placeholder=placeholder, label=label, **kwargs) 67 | 68 | 69 | class MoneyField(FieldBase): 70 | component_name = "MoneyField" 71 | 72 | def __init__(self, label, required=True, placeholder='', not_upper='', **kwargs): 73 | """ 74 | 75 | :param label: 表单组件名称 76 | :param required: 是否必填 77 | :param placeholder: 输入提示 78 | :param kwargs: 79 | """ 80 | super(MoneyField, self).__init__( 81 | required=required, placeholder=placeholder, label=label, not_upper=not_upper, **kwargs 82 | ) 83 | 84 | 85 | class NumberField(FieldBase): 86 | component_name = "NumberField" 87 | 88 | def __init__(self, label, required=True, placeholder='', unit='', **kwargs): 89 | """ 90 | 91 | :param label: 表单组件名称 92 | :param required: 是否必填 93 | :param placeholder: 输入提示 94 | :param kwargs: 95 | """ 96 | super(NumberField, self).__init__(required=required, placeholder=placeholder, label=label, unit=unit, **kwargs) 97 | 98 | 99 | class DDDateField(FieldBase): 100 | component_name = "DDDateField" 101 | 102 | def __init__(self, label, required=True, placeholder='', unit='', **kwargs): 103 | """ 104 | 105 | :param label: 表单组件名称 106 | :param required: 是否必填 107 | :param placeholder: 输入提示 108 | :param kwargs: 109 | """ 110 | super(DDDateField, self).__init__(required=required, placeholder=placeholder, label=label, unit=unit, **kwargs) 111 | 112 | 113 | class DDDateRangeField(FieldBase): 114 | component_name = "DDDateRangeField" 115 | 116 | def __init__(self, label, required=True, placeholder='', unit='', **kwargs): 117 | """ 118 | 119 | :param label: 表单组件名称 120 | :param required: 是否必填 121 | :param placeholder: 输入提示 122 | :param kwargs: 123 | """ 124 | super(DDDateRangeField, self).__init__( 125 | required=required, placeholder=placeholder, label=label, unit=unit, **kwargs 126 | ) 127 | -------------------------------------------------------------------------------- /dingtalk/model/message.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from dingtalk.core.utils import to_text 5 | 6 | 7 | class BodyBase(object): 8 | 9 | _msgtype = None 10 | 11 | def __init__(self, **kwargs): 12 | for k, v in kwargs.items(): 13 | if callable(v): 14 | v = v() 15 | setattr(self, k, v) 16 | 17 | def get_dict(self): 18 | assert self._msgtype 19 | return {'msgtype': self._msgtype, self._msgtype: self.get_data()} 20 | 21 | def get_data(self): 22 | ret = {} 23 | for k in dir(self): 24 | if k.startswith('_'): 25 | continue 26 | v = getattr(self, k, None) 27 | if v is None or hasattr(v, '__call__'): 28 | continue 29 | if v is not None: 30 | if isinstance(v, BodyBase): 31 | v = v.get_data() 32 | ret[k] = v 33 | return ret 34 | 35 | 36 | class TextBody(BodyBase): 37 | _msgtype = 'text' 38 | content = None 39 | 40 | def __init__(self, content, **kwargs): 41 | """ 42 | 文本消息 43 | 44 | :param content: 消息内容 45 | :param kwargs: 46 | """ 47 | super(TextBody, self).__init__(content=content, **kwargs) 48 | 49 | 50 | class FileBody(BodyBase): 51 | _msgtype = 'file' 52 | media_id = None 53 | 54 | def __init__(self, media_id, **kwargs): 55 | """ 56 | 文件消息 57 | 58 | :param media_id: 媒体文件id,可以调用上传媒体文件接口获取。10MB 59 | :param kwargs: 60 | """ 61 | super(FileBody, self).__init__(media_id=media_id, **kwargs) 62 | 63 | 64 | class ImageBody(FileBody): 65 | _msgtype = 'image' 66 | 67 | def __init__(self, media_id, **kwargs): 68 | """ 69 | 图片消息 70 | 71 | :param media_id: 图片媒体文件id,可以调用上传媒体文件接口获取。建议宽600像素 x 400像素,宽高比3:2 72 | :param kwargs: 73 | """ 74 | super(FileBody, self).__init__(media_id=media_id, **kwargs) 75 | 76 | 77 | class VoiceBody(FileBody): 78 | _msgtype = 'voice' 79 | duration = None 80 | 81 | def __init__(self, media_id, duration=None, **kwargs): 82 | """ 83 | 语音消息 84 | 85 | :param media_id: 语音媒体文件id,可以调用上传媒体文件接口获取。2MB,播放长度不超过60s,AMR格式 86 | :param duration: 正整数,小于60,表示音频时长 87 | :param kwargs: 88 | """ 89 | super(VoiceBody, self).__init__(media_id=media_id, duration=duration, **kwargs) 90 | 91 | 92 | class LinkBody(BodyBase): 93 | _msgtype = 'link' 94 | messageUrl = None 95 | title = None 96 | text = None 97 | picUrl = None 98 | 99 | def __init__(self, message_url, pic_url, title, text, **kwargs): 100 | """ 101 | 超链接消息 102 | 103 | :param message_url: 消息点击链接地址 104 | :param pic_url: 图片媒体文件id,可以调用上传媒体文件接口获取 105 | :param title: 消息标题 106 | :param text: 消息描述 107 | """ 108 | super(LinkBody, self).__init__(messageUrl=message_url, picUrl=pic_url, title=title, text=text, **kwargs) 109 | 110 | 111 | class MarkdownBody(BodyBase): 112 | _msgtype = "markdown" 113 | title = None 114 | text = None 115 | 116 | def __init__(self, title, text, **kwargs): 117 | """ 118 | markdown消息 119 | 120 | :param title: 首屏会话透出的展示内容 121 | :param text: markdown格式的消息 122 | :param kwargs: 123 | """ 124 | super(MarkdownBody, self).__init__(title=title, text=text, **kwargs) 125 | 126 | 127 | class OaBodyContent(BodyBase): 128 | title = None 129 | _forms = None 130 | rich = None 131 | content = None 132 | image = None 133 | file_count = None 134 | author = None 135 | 136 | def __init__(self, title=None, content=None, author=None, image=None, file_count=None, forms=dict, 137 | rich_num=None, rish_unit=None, **kwargs): 138 | """ 139 | OA消息 消息体 140 | 141 | :param title: 消息体的标题 142 | :param content: 消息体的内容,最多显示3行 143 | :param author: 自定义的作者名字 144 | :param image: 消息体中的图片media_id 145 | :param file_count: 自定义的附件数目。此数字仅供显示,钉钉不作验证 146 | :param forms: 消息体的表单 147 | :param rich_num: 单行富文本信息的数目 148 | :param rish_unit: 单行富文本信息的单位 149 | :param kwargs: 150 | """ 151 | rich = None 152 | if rich_num is not None or rish_unit is not None: 153 | rich = {'num': rich_num, 'unit': rish_unit} 154 | super(OaBodyContent, self).__init__(title=title, content=content, author=author, image=image, 155 | file_count=file_count, _forms=forms, rich=rich, **kwargs) 156 | 157 | @property 158 | def form(self): 159 | if not self._forms: 160 | return None 161 | ret = [] 162 | for k, v in self._forms.items(): 163 | ret.append({"key": k, "value": v}) 164 | return ret 165 | 166 | 167 | class OaBody(BodyBase): 168 | _msgtype = 'oa' 169 | message_url = None 170 | pc_message_url = None 171 | head = None 172 | body = None 173 | 174 | def __init__(self, message_url, head_bgcolor, head_text, body, pc_message_url=None, **kwargs): 175 | """ 176 | OA消息 177 | 178 | :param message_url: 客户端点击消息时跳转到的H5地址 179 | :param head_bgcolor: 消息头部的背景颜色。长度限制为8个英文字符,其中前2为表示透明度,后6位表示颜色值。不要添加0x 180 | :param head_text: 消息的头部标题(向普通会话发送时有效,向企业会话发送时会被替换为微应用的名字),长度限制为最多10个字符 181 | :param body: OaBodyContent OA消息 消息体 182 | :param pc_message_url: PC端点击消息时跳转到的H5地址 183 | :param kwargs: 184 | """ 185 | super(OaBody, self).__init__(message_url=message_url, head={"bgcolor": head_bgcolor, "text": head_text}, 186 | body=body, pc_message_url=pc_message_url, **kwargs) 187 | 188 | 189 | class ActionCardBody(BodyBase): 190 | _msgtype = 'action_card' 191 | title = None 192 | markdown = None 193 | 194 | def __init__(self, title, markdown, **kwargs): 195 | super(ActionCardBody, self).__init__(title=title, markdown=markdown, **kwargs) 196 | 197 | 198 | class SingleActionCardBody(ActionCardBody): 199 | single_title = None 200 | single_url = None 201 | 202 | def __init__(self, title, markdown, single_title, single_url, **kwargs): 203 | """ 204 | 整体跳转ActionCard消息 205 | 206 | :param title: 透出到会话列表和通知的文案 207 | :param markdown: 消息内容,支持markdown 208 | :param single_title: 标题 209 | :param single_url: 链接url 210 | :param kwargs: 211 | """ 212 | super(SingleActionCardBody, self).__init__(title=title, markdown=markdown, 213 | single_title=single_title, single_url=single_url, **kwargs) 214 | 215 | 216 | class BtnActionCardBody(ActionCardBody): 217 | btn_orientation = None 218 | btn_json_list = None 219 | 220 | def __init__(self, title, markdown, btn_orientation, btn_list=(), **kwargs): 221 | """ 222 | 独立跳转ActionCard消息 223 | 224 | :param title: 透出到会话列表和通知的文案 225 | :param markdown: 消息内容,支持markdown 226 | :param btn_orientation: 按钮排列方式,竖直排列(0),横向排列(1) 227 | :param btn_json_list: 按钮列表 228 | :param kwargs: 229 | """ 230 | btn_orientation = to_text(btn_orientation) 231 | assert btn_orientation in ('0', '1') 232 | super(BtnActionCardBody, self).__init__(title=title, markdown=markdown, 233 | btn_orientation=btn_orientation, btn_json_list=list(btn_list), **kwargs) 234 | 235 | def add_btn(self, title, action_url): 236 | """ 237 | 添加按钮 238 | 239 | :param title: 标题 240 | :param action_url: 链接url 241 | :return: 242 | """ 243 | assert isinstance(self.btn_json_list, list) 244 | self.btn_json_list.append({'title': title, 'action_url': action_url}) 245 | -------------------------------------------------------------------------------- /dingtalk/storage/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | 5 | class BaseStorage(object): 6 | 7 | def get(self, key, default=None): 8 | raise NotImplementedError() 9 | 10 | def set(self, key, value, ttl=None): 11 | raise NotImplementedError() 12 | 13 | def delete(self, key): 14 | raise NotImplementedError() 15 | 16 | def __getitem__(self, key): 17 | self.get(key) 18 | 19 | def __setitem__(self, key, value): 20 | self.set(key, value) 21 | 22 | def __delitem__(self, key): 23 | self.delete(key) 24 | -------------------------------------------------------------------------------- /dingtalk/storage/cache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import inspect 5 | 6 | from dingtalk.storage import BaseStorage 7 | 8 | 9 | def _is_cache_item(obj): 10 | return isinstance(obj, DingTalkCacheItem) 11 | 12 | 13 | class DingTalkCacheItem(object): 14 | 15 | def __init__(self, cache=None, name=None): 16 | self.cache = cache 17 | self.name = name 18 | 19 | def key_name(self, key): 20 | if isinstance(key, (tuple, list)): 21 | key = ':'.join(key) 22 | 23 | k = '{0}:{1}'.format(self.cache.prefix, self.name) 24 | if key is not None: 25 | k = '{0}:{1}'.format(k, key) 26 | return k 27 | 28 | def get(self, key=None, default=None): 29 | return self.cache.storage.get(self.key_name(key), default) 30 | 31 | def set(self, key=None, value=None, ttl=None): 32 | return self.cache.storage.set(self.key_name(key), value, ttl) 33 | 34 | def delete(self, key=None): 35 | return self.cache.storage.delete(self.key_name(key)) 36 | 37 | 38 | class BaseCache(object): 39 | 40 | def __new__(cls, *args, **kwargs): 41 | self = super(BaseCache, cls).__new__(cls) 42 | api_endpoints = inspect.getmembers(self, _is_cache_item) 43 | for name, api in api_endpoints: 44 | api_cls = type(api) 45 | api = api_cls(self, name) 46 | setattr(self, name, api) 47 | return self 48 | 49 | def __init__(self, storage, prefix='client'): 50 | assert isinstance(storage, BaseStorage) 51 | self.storage = storage 52 | self.prefix = prefix 53 | 54 | 55 | class DingTalkCache(BaseCache): 56 | access_token = DingTalkCacheItem() 57 | jsapi_ticket = DingTalkCacheItem() 58 | 59 | 60 | class ChannelCache(BaseCache): 61 | channel_token = DingTalkCacheItem() 62 | jsapi_ticket = DingTalkCacheItem() 63 | 64 | 65 | class ISVCache(BaseCache): 66 | suite_ticket = DingTalkCacheItem() 67 | suite_access_token = DingTalkCacheItem() 68 | permanent_code = DingTalkCacheItem() 69 | ch_permanent_code = DingTalkCacheItem() 70 | -------------------------------------------------------------------------------- /dingtalk/storage/kvstorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import json 5 | 6 | from dingtalk.core.utils import to_text 7 | from dingtalk.storage import BaseStorage 8 | 9 | 10 | class KvStorage(BaseStorage): 11 | 12 | def __init__(self, kvdb, prefix='dingtalk'): 13 | for method_name in ('get', 'set', 'delete'): 14 | assert hasattr(kvdb, method_name) 15 | self.kvdb = kvdb 16 | self.prefix = prefix 17 | 18 | def key_name(self, key): 19 | return '{0}:{1}'.format(self.prefix, key) 20 | 21 | def get(self, key, default=None): 22 | key = self.key_name(key) 23 | value = self.kvdb.get(key) 24 | if value is None: 25 | return default 26 | return json.loads(to_text(value)) 27 | 28 | def set(self, key, value, ttl=None): 29 | if value is None: 30 | return 31 | key = self.key_name(key) 32 | value = json.dumps(value) 33 | self.kvdb.set(key, value, ttl) 34 | 35 | def delete(self, key): 36 | key = self.key_name(key) 37 | self.kvdb.delete(key) 38 | -------------------------------------------------------------------------------- /dingtalk/storage/memorystorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import time 5 | 6 | from dingtalk.storage import BaseStorage 7 | 8 | 9 | class MemoryStorage(BaseStorage): 10 | 11 | def __init__(self): 12 | self._data = {} 13 | 14 | def get(self, key, default=None): 15 | ret = self._data.get(key, None) 16 | if ret is None or len(ret) != 2: 17 | return default 18 | else: 19 | value = ret[0] 20 | expires_at = ret[1] 21 | if expires_at is None or expires_at > time.time(): 22 | return value 23 | else: 24 | return default 25 | 26 | def set(self, key, value, ttl=None): 27 | if value is None: 28 | return 29 | self._data[key] = (value, int(time.time()) + ttl) 30 | 31 | def delete(self, key): 32 | self._data.pop(key, None) 33 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = dingtalk-sdk 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ================ 3 | 4 | Version 1.3.3 5 | ------------------ 6 | + fix 创建审批bug 7 | 8 | Version 1.3.2 9 | ------------------ 10 | + 增加审批待办接口 11 | 12 | Version 1.3.1 13 | ------------------ 14 | 15 | + 增加DING日程相关接口 16 | + 增加钉钉运动相关接口 17 | + 增加公告相关接口 18 | + 增加待办事项相关接口 19 | + 增加撤回工作通知消息接口 20 | + fix 发送工作通知参数错误 21 | 22 | Version 1.3.0 23 | ------------------ 24 | 25 | + 增加企业外部联系人相关接口 26 | + 增加日志相关接口 27 | + 更新淘宝接口 28 | 29 | Version 1.2.8 30 | ------------------ 31 | 32 | + 发起审批实例支持会签/或签 33 | 34 | Version 1.2.7 35 | ------------------ 36 | 37 | + fix 会话 获取群会话请求方法错误 38 | 39 | Version 1.2.6 40 | ------------------ 41 | 42 | + 增加asyncsend_v2接口 43 | 44 | Version 1.2.4 45 | ------------------ 46 | 47 | + 增加新接口 48 | 49 | Version 1.2.3 50 | ------------------ 51 | 52 | + top接口成功状态判断增加 is_success, error_response 增加msg错误信息判断 53 | 54 | Version 1.2.2 55 | ------------------ 56 | 57 | + 增加淘宝接口 58 | 59 | Version 1.1.9 60 | ------------------ 61 | 62 | + fix callback接口的返回失败接口 使用错误 63 | 64 | 65 | Version 1.1.8 66 | ------------------ 67 | 68 | + ssl._create_unverified_context 增加异常判断 69 | 70 | 71 | Version 1.1.7 72 | ------------------ 73 | 74 | + 新增app_key app_secret 获取 access_token 的 AppKeyClient 75 | 76 | Version 1.1.5 77 | ------------------ 78 | 79 | + [fix]the list_message_status api 's method is post 80 | 81 | Version 1.1.4 82 | ------------------ 83 | 84 | + 解决接口返回 ObjectDict 时 json_loads 未return bug 85 | 86 | Version 1.1.3 87 | ------------------ 88 | 89 | + 接口返回结果使用 ObjectDict 90 | 91 | Version 1.1.2 92 | ------------------ 93 | 94 | + 增加 智能人事接口 95 | 96 | Version 1.1.1 97 | ------------------ 98 | 99 | + 修复 LinkBody 参数层级错误 100 | + 修复 OaBodyContent form 参数处理错误 101 | + 增加 testcase 102 | + 增加 文档 103 | 104 | 105 | Version 1.1.0 106 | ------------------ 107 | 108 | + 企业钉钉接口 109 | + ISV服务商接口 110 | 111 | -------------------------------------------------------------------------------- /docs/client/api/attendance.rst: -------------------------------------------------------------------------------- 1 | 考勤接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Attendance 7 | :members: 8 | :inherited-members: 9 | 10 | -------------------------------------------------------------------------------- /docs/client/api/blackboard.rst: -------------------------------------------------------------------------------- 1 | 公告接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: BlackBoard 7 | :members: 8 | :inherited-members: 9 | 10 | -------------------------------------------------------------------------------- /docs/client/api/bpms.rst: -------------------------------------------------------------------------------- 1 | 审批流接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Bpms 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/calendar.rst: -------------------------------------------------------------------------------- 1 | 日程接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Calendar 7 | :members: 8 | :inherited-members: 9 | 10 | -------------------------------------------------------------------------------- /docs/client/api/callback.rst: -------------------------------------------------------------------------------- 1 | 回调接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Callback 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/chat.rst: -------------------------------------------------------------------------------- 1 | 会话接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Chat 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/checkin.rst: -------------------------------------------------------------------------------- 1 | 签到接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Checkin 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/cspace.rst: -------------------------------------------------------------------------------- 1 | 钉盘接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Cspace 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/department.rst: -------------------------------------------------------------------------------- 1 | 部门接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Department 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/employeerm.rst: -------------------------------------------------------------------------------- 1 | 智能人事 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Employeerm 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/ext.rst: -------------------------------------------------------------------------------- 1 | 外部联系人接口(废弃) 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Ext 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/extcontact.rst: -------------------------------------------------------------------------------- 1 | 外部联系人接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: ExtContact 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/health.rst: -------------------------------------------------------------------------------- 1 | 健康接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Health 7 | :members: 8 | :inherited-members: 9 | 10 | -------------------------------------------------------------------------------- /docs/client/api/message.rst: -------------------------------------------------------------------------------- 1 | 消息接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Message 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/microapp.rst: -------------------------------------------------------------------------------- 1 | 微应用接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: MicroApp 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/report.rst: -------------------------------------------------------------------------------- 1 | 日志接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Report 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/role.rst: -------------------------------------------------------------------------------- 1 | 角色接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: Role 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/taobao.rst: -------------------------------------------------------------------------------- 1 | 淘宝接口 2 | =================== 3 | 4 | .. automodule:: dingtalk.client.api.taobao 5 | :members: 6 | :inherited-members: 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/client/api/user.rst: -------------------------------------------------------------------------------- 1 | 用户接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: User 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/api/workrecord.rst: -------------------------------------------------------------------------------- 1 | 待办接口 2 | =================== 3 | 4 | .. module:: dingtalk.client.api 5 | 6 | .. autoclass:: WorkRecord 7 | :members: 8 | :inherited-members: 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/client/index.rst: -------------------------------------------------------------------------------- 1 | 钉钉企业内部开发接口 2 | =========================================== 3 | 4 | .. module:: dingtalk.client 5 | 6 | .. autoclass:: DingTalkClient 7 | :members: 8 | :inherited-members: 9 | 10 | `DingTalkClient` 基本使用方法:: 11 | 12 | from dingtalk import SecretClient, AppKeyClient 13 | 14 | client = SecretClient('corp_id', 'secret') # 旧 access_token 获取方式 15 | client = AppKeyClient('corp_id', 'app_key', 'app_secret') # 新 access_token 获取方式 16 | 17 | user = client.user.get('userid') 18 | departments = client.department.list() 19 | # 以此类推,参见下面的 API 说明 20 | # client.chat.xxx() 21 | # client.role.xxx() 22 | 23 | 如果不提供 ``storage`` 参数,默认使用 ``dingtalk.storage.memorystorage.MemoryStorage`` 类型, 24 | 注意该类型不是线程安全的,而且非持久化保存,不推荐生产环境使用。 25 | 26 | .. toctree:: 27 | :maxdepth: 2 28 | :glob: 29 | 30 | api/* 31 | 32 | -------------------------------------------------------------------------------- /docs/client/isv.rst: -------------------------------------------------------------------------------- 1 | 钉钉应用服务商(ISV)接口 2 | =========================================== 3 | 4 | .. module:: dingtalk.client.isv 5 | 6 | .. autoclass:: ISVClient 7 | :members: 8 | :inherited-members: 9 | 10 | `ISVClient` 基本使用方法:: 11 | 12 | from dingtalk import ISVClient 13 | 14 | client = ISVClient('corp_id', 'secret', 'token', 'aes_key') 15 | corp_info = client.get_auth_info('corpid') 16 | 17 | corp_client = client.get_dingtalk_client('corpid') 18 | user = corp_client.user.get('userid') 19 | departments = corp_client.department.list() 20 | # 以此类推,corp_client可针对企业执行api 21 | 22 | .. toctree:: 23 | :maxdepth: 1 24 | :glob: 25 | 26 | index 27 | 28 | 29 | 如果不提供 ``storage`` 参数,默认使用 ``dingtalk.storage.memorystorage.MemoryStorage`` 类型, 30 | 注意该类型不是线程安全的,而且非持久化保存,不推荐生产环境使用。 31 | 32 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | import sphinx_rtd_theme 10 | 11 | # -- Path setup -------------------------------------------------------------- 12 | 13 | # If extensions (or modules to document with autodoc) are in another directory, 14 | # add these directories to sys.path here. If the directory is relative to the 15 | # documentation root, use os.path.abspath to make it absolute, like shown here. 16 | # 17 | import os 18 | import sys 19 | import dingtalk 20 | 21 | 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | # -- Project information ----------------------------------------------------- 25 | 26 | project = 'dingtalk-sdk' 27 | copyright = '2018, 007gzs' 28 | author = '007gzs' 29 | 30 | # The short X.Y version 31 | version = dingtalk.__version__ 32 | # The full version, including alpha/beta/rc tags 33 | release = dingtalk.__version__ 34 | 35 | 36 | # -- General configuration --------------------------------------------------- 37 | 38 | # If your documentation needs a minimal Sphinx version, state it here. 39 | # 40 | # needs_sphinx = '1.0' 41 | 42 | # Add any Sphinx extension module names here, as strings. They can be 43 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 44 | # ones. 45 | extensions = [ 46 | 'sphinx.ext.autodoc', 47 | 'sphinx.ext.doctest', 48 | 'sphinx.ext.intersphinx', 49 | 'sphinx.ext.todo', 50 | 'sphinx.ext.coverage', 51 | 'sphinx.ext.mathjax', 52 | 'sphinx.ext.ifconfig', 53 | 'sphinx.ext.viewcode', 54 | 'sphinx.ext.githubpages', 55 | ] 56 | 57 | # Add any paths that contain templates here, relative to this directory. 58 | templates_path = ['_templates'] 59 | 60 | # The suffix(es) of source filenames. 61 | # You can specify multiple suffix as a list of string: 62 | # 63 | # source_suffix = ['.rst', '.md'] 64 | source_suffix = '.rst' 65 | 66 | # The master toctree document. 67 | master_doc = 'index' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = 'zh_cn' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This pattern also affects html_static_path and html_extra_path . 79 | exclude_patterns = [] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'sphinx' 83 | 84 | 85 | # -- Options for HTML output ------------------------------------------------- 86 | 87 | # The theme to use for HTML and HTML Help pages. See the documentation for 88 | # a list of builtin themes. 89 | # 90 | # html_theme = 'alabaster' 91 | 92 | html_theme = "sphinx_rtd_theme" 93 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ['_static'] 105 | 106 | # Custom sidebar templates, must be a dictionary that maps document names 107 | # to template names. 108 | # 109 | # The default sidebars (for documents that don't match any pattern) are 110 | # defined by theme itself. Builtin themes are using these templates by 111 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 112 | # 'searchbox.html']``. 113 | # 114 | # html_sidebars = {} 115 | 116 | 117 | # -- Options for HTMLHelp output --------------------------------------------- 118 | 119 | # Output file base name for HTML help builder. 120 | htmlhelp_basename = 'dingtalk-sdkdoc' 121 | 122 | 123 | # -- Options for LaTeX output ------------------------------------------------ 124 | 125 | latex_elements = { 126 | # The paper size ('letterpaper' or 'a4paper'). 127 | # 128 | # 'papersize': 'letterpaper', 129 | 130 | # The font size ('10pt', '11pt' or '12pt'). 131 | # 132 | # 'pointsize': '10pt', 133 | 134 | # Additional stuff for the LaTeX preamble. 135 | # 136 | # 'preamble': '', 137 | 138 | # Latex figure (float) alignment 139 | # 140 | # 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, 145 | # author, documentclass [howto, manual, or own class]). 146 | latex_documents = [ 147 | (master_doc, 'dingtalk-sdk.tex', 'dingtalk-sdk Documentation', 148 | '007gzs', 'manual'), 149 | ] 150 | 151 | 152 | # -- Options for manual page output ------------------------------------------ 153 | 154 | # One entry per manual page. List of tuples 155 | # (source start file, name, description, authors, manual section). 156 | man_pages = [ 157 | (master_doc, 'dingtalk-sdk', 'dingtalk-sdk Documentation', 158 | [author], 1) 159 | ] 160 | 161 | 162 | # -- Options for Texinfo output ---------------------------------------------- 163 | 164 | # Grouping the document tree into Texinfo files. List of tuples 165 | # (source start file, target name, title, author, 166 | # dir menu entry, description, category) 167 | texinfo_documents = [ 168 | (master_doc, 'dingtalk-sdk', 'dingtalk-sdk Documentation', 169 | author, 'dingtalk-sdk', 'One line description of project.', 170 | 'Miscellaneous'), 171 | ] 172 | 173 | 174 | # -- Extension configuration ------------------------------------------------- 175 | 176 | # -- Options for intersphinx extension --------------------------------------- 177 | 178 | # Example configuration for intersphinx: refer to the Python standard library. 179 | intersphinx_mapping = {'https://docs.python.org/': None} 180 | 181 | # -- Options for todo extension ---------------------------------------------- 182 | 183 | # If true, `todo` and `todoList` produce output, else they produce nothing. 184 | todo_include_todos = True 185 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. dingtalk-sdk documentation master file, created by 2 | sphinx-quickstart on Fri May 4 11:18:22 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | dingtalk-sdk 使用文档 7 | ======================================== 8 | 9 | dingtalk-sdk 是一个钉钉开放平台的第三方 Python SDK, 实现了 企业内部开发 和 应用服务商(ISV)的 API。 10 | 11 | 快速入门 12 | ------------- 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | 17 | install 18 | 19 | 20 | 企业内部开发 21 | -------------------- 22 | 建议在使用前先阅读 `钉钉开放平台文档 `_ 23 | 24 | .. toctree:: 25 | :glob: 26 | :maxdepth: 2 27 | 28 | model/message 29 | model/field 30 | client/index 31 | 32 | 应用服务商(ISV) 33 | ---------------------------- 34 | .. toctree:: 35 | :glob: 36 | :maxdepth: 2 37 | 38 | client/isv 39 | 40 | 未实现接口 41 | -------------------- 42 | 由于钉钉接口过多,文档较分散,有未实现的接口可以提交 Issues, sdk未更新时候可根据下面代码临时使用 43 | post/get接口中的access_token,top接口中的session会在请求时自动设置,无需手动添加 44 | 45 | .. module:: dingtalk.client.base 46 | 47 | .. autoclass:: BaseClient 48 | 49 | .. automethod:: get 50 | .. automethod:: post 51 | .. automethod:: top_request 52 | 53 | 调用示例:: 54 | 55 | client = SecretClient('CORP_ID', 'CORP_SECRET') 56 | 57 | # top 接口: 获取考勤组列表详情 58 | ret = client._top_request( 59 | 'dingtalk.smartwork.attends.getsimplegroups', 60 | { 61 | "offset": 0, 62 | "size": 10 63 | } 64 | ) 65 | has_more = ret.result.has_more 66 | groups = ret.result.groups 67 | 68 | 69 | # get 接口:获取子部门ID列表 70 | ret = client.get( 71 | '/department/list_ids', 72 | {'id': 0} 73 | ) 74 | sub_dept_id_list = ret.sub_dept_id_list 75 | 76 | 77 | # post 接口:创建会话 78 | return self._post( 79 | '/chat/create', 80 | { 81 | 'name': "群名称", 82 | 'owner': "zhangsan", 83 | 'useridlist': ["zhangsan", "lisi"] 84 | } 85 | ) 86 | chatid = ret.chatid 87 | 88 | 89 | 示例项目 90 | --------------------- 91 | 92 | `django demo `_ 93 | 94 | 95 | Changelogs 96 | --------------- 97 | 98 | .. toctree:: 99 | :maxdepth: 1 100 | 101 | changelog 102 | 103 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | 安装与升级 2 | ========== 3 | 4 | 目前 dingtalk-sdk 支持的 Python 环境有 2.7, 3.4, 3.5, 3.6 和 pypy。 5 | 6 | dingtalk-sdk 消息加解密同时兼容 cryptography 和 PyCrypto, 优先使用 cryptography 库。 7 | 可先自行安装 cryptography 或者 PyCrypto 库:: 8 | 9 | # 安装 cryptography 10 | pip install cryptography>=0.8.2 11 | # 或者安装 PyCrypto 12 | pip install pycrypto>=2.6.1 13 | 14 | 为了简化安装过程,推荐使用 pip 进行安装 15 | 16 | .. code-block:: bash 17 | 18 | pip install dingtalk-sdk 19 | # with cryptography 20 | pip install dingtalk-sdk[cryptography] 21 | # with pycrypto 22 | pip install dingtalk-sdk[pycrypto] 23 | 24 | 升级 dingtalk-sdk 到新版本:: 25 | 26 | pip install -U dingtalk-sdk 27 | 28 | 如果需要安装 GitHub 上的最新代码:: 29 | 30 | pip install https://github.com/007gzs/dingtalk-sdk/archive/master.zip 31 | 32 | -------------------------------------------------------------------------------- /docs/model/field.rst: -------------------------------------------------------------------------------- 1 | 表单组件 2 | =================== 3 | 4 | .. module:: dingtalk.model.field 5 | 6 | .. autoclass:: TextField 7 | :members: 8 | :inherited-members: 9 | 10 | .. automethod:: __init__ 11 | 12 | .. autoclass:: TextareaField 13 | :members: 14 | :inherited-members: 15 | 16 | .. automethod:: __init__ 17 | 18 | .. autoclass:: MoneyField 19 | :members: 20 | :inherited-members: 21 | 22 | .. automethod:: __init__ 23 | 24 | .. autoclass:: NumberField 25 | :members: 26 | :inherited-members: 27 | 28 | .. automethod:: __init__ 29 | 30 | .. autoclass:: DDDateField 31 | :members: 32 | :inherited-members: 33 | 34 | .. automethod:: __init__ 35 | 36 | .. autoclass:: DDDateRangeField 37 | :members: 38 | :inherited-members: 39 | 40 | .. automethod:: __init__ 41 | 42 | -------------------------------------------------------------------------------- /docs/model/message.rst: -------------------------------------------------------------------------------- 1 | 消息实体 2 | =================== 3 | 4 | .. module:: dingtalk.model.message 5 | 6 | .. autoclass:: TextBody 7 | :members: 8 | :inherited-members: 9 | 10 | .. automethod:: __init__ 11 | 12 | .. autoclass:: FileBody 13 | :members: 14 | :inherited-members: 15 | 16 | .. automethod:: __init__ 17 | 18 | .. autoclass:: ImageBody 19 | :members: 20 | :inherited-members: 21 | 22 | .. automethod:: __init__ 23 | 24 | .. autoclass:: VoiceBody 25 | :members: 26 | :inherited-members: 27 | 28 | .. automethod:: __init__ 29 | 30 | .. autoclass:: LinkBody 31 | :members: 32 | :inherited-members: 33 | 34 | .. automethod:: __init__ 35 | 36 | .. autoclass:: MarkdownBody 37 | :members: 38 | :inherited-members: 39 | 40 | .. automethod:: __init__ 41 | 42 | .. autoclass:: OaBodyContent 43 | :members: 44 | :inherited-members: 45 | 46 | .. automethod:: __init__ 47 | 48 | .. autoclass:: OaBody 49 | :members: 50 | :inherited-members: 51 | 52 | .. automethod:: __init__ 53 | 54 | .. autoclass:: SingleActionCardBody 55 | :members: 56 | :inherited-members: 57 | 58 | .. automethod:: __init__ 59 | 60 | .. autoclass:: BtnActionCardBody 61 | :members: 62 | :inherited-members: 63 | 64 | .. automethod:: __init__ 65 | 66 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | enum34>=1.1.4; python_version < '3.4' 2 | six>=1.8.0 3 | requests>=2.4.3 4 | optionaldict>=0.1.0 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .svn,CVS,.bzr,.hg,.git,__pycache,.ropeproject 3 | max-line-length = 120 4 | per-file-ignores = 5 | dingtalk/client/api/taobao.py:E501,W605 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # encoding: utf-8 3 | 4 | 5 | """A setuptools based setup module. 6 | 7 | See: 8 | https://packaging.python.org/en/latest/distributing.html 9 | https://github.com/pypa/sampleproject 10 | """ 11 | 12 | # Always prefer setuptools over distutils 13 | from setuptools import setup 14 | from setuptools.command.test import test as TestCommand 15 | # To use a consistent encoding 16 | from codecs import open 17 | from os import path 18 | import sys 19 | 20 | import ssl 21 | try: 22 | ssl._create_default_https_context = ssl._create_unverified_context 23 | except Exception: 24 | pass 25 | 26 | here = path.abspath(path.dirname(__file__)) 27 | 28 | 29 | class PyTest(TestCommand): 30 | user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] 31 | 32 | def initialize_options(self): 33 | TestCommand.initialize_options(self) 34 | self.pytest_args = [] 35 | 36 | def finalize_options(self): 37 | TestCommand.finalize_options(self) 38 | self.test_args = [] 39 | self.test_suite = True 40 | 41 | def run_tests(self): 42 | import pytest 43 | errno = pytest.main(self.pytest_args) 44 | sys.exit(errno) 45 | 46 | 47 | cmdclass = {} 48 | cmdclass['test'] = PyTest 49 | 50 | # Get the long description from the README file 51 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 52 | long_description = f.read() 53 | 54 | with open('requirements.txt') as f: 55 | requirements = [line for line in f.read().splitlines() if line] 56 | 57 | setup( 58 | name='dingtalk-sdk', 59 | version='1.3.8', 60 | keywords='dingding, ding, dtalk, dingtalk, SDK', 61 | description='DingTalk SDK for Python', 62 | long_description=long_description, 63 | url='https://github.com/007gzs/dingtalk-sdk', 64 | author='007gzs', 65 | author_email='007gzs@sina.com', 66 | license='LGPL v3', 67 | classifiers=[ 68 | 'Development Status :: 3 - Alpha', 69 | 'Intended Audience :: Developers', 70 | 'Topic :: Software Development :: Build Tools', 71 | 'License :: OSI Approved :: ' 72 | 'GNU Lesser General Public License v3 (LGPLv3)', 73 | 'Programming Language :: Python :: 2', 74 | 'Programming Language :: Python :: 2.7', 75 | 'Programming Language :: Python :: 3', 76 | 'Programming Language :: Python :: 3.3', 77 | 'Programming Language :: Python :: 3.4', 78 | 'Programming Language :: Python :: 3.5', 79 | 'Programming Language :: Python :: 3.6', 80 | ], 81 | packages=[ 82 | 'dingtalk', 83 | 'dingtalk.core', 84 | 'dingtalk.crypto', 85 | 'dingtalk.storage', 86 | 'dingtalk.model', 87 | 'dingtalk.client', 88 | 'dingtalk.client.api' 89 | ], 90 | install_requires=requirements, 91 | zip_safe=False, 92 | include_package_data=True, 93 | tests_require=[ 94 | 'pytest', 95 | 'redis', 96 | 'pymemcache', 97 | ], 98 | cmdclass=cmdclass, 99 | extras_require={ 100 | 'cryptography': ['cryptography'], 101 | 'pycrypto': ['pycrypto'], 102 | }, 103 | ) 104 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | import unittest 4 | 5 | import json 6 | 7 | from dingtalk import crypto as _crypto 8 | from dingtalk.crypto import DingTalkCrypto 9 | 10 | 11 | class PrpCryptoMock(_crypto.PrpCrypto): 12 | 13 | def get_random_string(self): 14 | return '1234567890123456' 15 | 16 | 17 | class CryptoTestCase(unittest.TestCase): 18 | 19 | token = '123456' 20 | encoding_aes_key = '4g5j64qlyl3zvetqxz5jiocdr586fn2zvjpa8zls3ij' 21 | suite_key = 'suite4xxxxxxxxxxxxxxx' 22 | 23 | def test_encrypt_message(self): 24 | origin_crypto = _crypto.PrpCrypto 25 | _crypto.PrpCrypto = PrpCryptoMock 26 | 27 | nonce = 'nEXhMP4r' 28 | timestamp = '1445827045067' 29 | msg = """{"EventType":"check_create_suite_url","Random":"LPIdSnlF","TestSuiteKey":"suite4xxxxxxxxxxxxxxx"}""" 30 | 31 | expected = { 32 | 'msg_signature': 'bcf6dcefa4ce2dbaf7b0666c7264d46fd9aad4bd', 33 | 'encrypt': '5DJFWzjRNOQk+5GSZxW+VrFMDWCIidPjEjg3//gm5x556BedVi62rDj1F9uXU97a4jw1R4FACUv9RCpoDobNqxhxRB2Yt' 34 | 'W901k4KHbP1/wpFJ3xdLG0n0A8U1VhENg80zKJd+YROR0YMGum4WYuoXJ6J98vt0ihYeIFoapNddLML5MyNAGM9saSpko' 35 | 'uDMSvD+iU14i7V8ix1ia1Tb9ogog==', 36 | 'timeStamp': '1445827045067', 37 | 'nonce': 'nEXhMP4r' 38 | } 39 | crypto = DingTalkCrypto(self.token, self.encoding_aes_key, self.suite_key) 40 | encrypted = crypto.encrypt_message(msg, nonce, timestamp) 41 | 42 | _crypto.PrpCrypto = origin_crypto 43 | 44 | self.assertEqual(expected.keys(), encrypted.keys()) 45 | for key in expected.keys(): 46 | self.assertEqual(expected[key], encrypted[key]) 47 | 48 | def test_decrypt_message(self): 49 | from dingtalk.core.utils import to_text 50 | jsonstr = '{"encrypt":"1a3NBxmCFwkCJvfoQ7WhJHB+iX3qHPsc9JbaDznE1i03peOk1LaOQoRz3+nlyGNhwmwJ3vDMG+OzrHMeiZI7g' \ 51 | 'TRWVdUBmfxjZ8Ej23JVYa9VrYeJ5as7XM/ZpulX8NEQis44w53h1qAgnC3PRzM7Zc/D6Ibr0rgUathB6zRHP8PYrfgnNOS9Ph' \ 52 | 'SBdHlegK+AGGanfwjXuQ9+0pZcy0w9lQ=="}' 53 | 54 | signature = '5a65ceeef9aab2d149439f82dc191dd6c5cbe2c0' 55 | timestamp = '1445827045067' 56 | nonce = 'nEXhMP4r' 57 | 58 | crypto = DingTalkCrypto(self.token, self.encoding_aes_key, self.suite_key) 59 | msg = crypto.decrypt_message(jsonstr, signature, timestamp, nonce) 60 | msg_dict = json.loads(to_text(msg)) 61 | self.assertEqual('check_create_suite_url', msg_dict['EventType']) 62 | self.assertEqual('LPIdSnlF', msg_dict['Random']) 63 | self.assertEqual(self.suite_key, msg_dict['TestSuiteKey']) 64 | 65 | def test_decrypt_binary_message(self): 66 | from dingtalk.core.utils import to_text 67 | jsonbinary = b'{"encrypt":"1a3NBxmCFwkCJvfoQ7WhJHB+iX3qHPsc9JbaDznE1i03peOk1LaOQoRz3+nlyGNhwmwJ3vDMG+OzrHMei' \ 68 | b'ZI7gTRWVdUBmfxjZ8Ej23JVYa9VrYeJ5as7XM/ZpulX8NEQis44w53h1qAgnC3PRzM7Zc/D6Ibr0rgUathB6zRHP8PYrf' \ 69 | b'gnNOS9PhSBdHlegK+AGGanfwjXuQ9+0pZcy0w9lQ=="}' 70 | 71 | signature = '5a65ceeef9aab2d149439f82dc191dd6c5cbe2c0' 72 | timestamp = '1445827045067' 73 | nonce = 'nEXhMP4r' 74 | 75 | crypto = DingTalkCrypto(self.token, self.encoding_aes_key, self.suite_key) 76 | msg = crypto.decrypt_message(jsonbinary, signature, timestamp, nonce) 77 | msg_dict = json.loads(to_text(msg)) 78 | self.assertEqual('check_create_suite_url', msg_dict['EventType']) 79 | self.assertEqual('LPIdSnlF', msg_dict['Random']) 80 | self.assertEqual(self.suite_key, msg_dict['TestSuiteKey']) 81 | 82 | def test_decrypt_encrypt_str(self): 83 | from dingtalk.core.utils import to_text 84 | signature = '5a65ceeef9aab2d149439f82dc191dd6c5cbe2c0' 85 | timestamp = '1445827045067' 86 | nonce = 'nEXhMP4r' 87 | encrypt_str = '1a3NBxmCFwkCJvfoQ7WhJHB+iX3qHPsc9JbaDznE1i03peOk1LaOQoRz3+nlyGNhwmwJ3vDMG+OzrHMeiZI7gTRWVdUBm' \ 88 | 'fxjZ8Ej23JVYa9VrYeJ5as7XM/ZpulX8NEQis44w53h1qAgnC3PRzM7Zc/D6Ibr0rgUathB6zRHP8PYrfgnNOS9PhSBdH' \ 89 | 'legK+AGGanfwjXuQ9+0pZcy0w9lQ==' 90 | 91 | crypto = DingTalkCrypto(self.token, self.encoding_aes_key, self.suite_key) 92 | msg = crypto.decrypt_encrypt_str( 93 | signature, 94 | timestamp, 95 | nonce, 96 | encrypt_str 97 | ) 98 | msg_dict = json.loads(to_text(msg)) 99 | self.assertEqual('check_create_suite_url', msg_dict['EventType']) 100 | self.assertEqual('LPIdSnlF', msg_dict['Random']) 101 | self.assertEqual(self.suite_key, msg_dict['TestSuiteKey']) 102 | 103 | def test_decrypt_encrypt_binary(self): 104 | from dingtalk.core.utils import to_text 105 | signature = '5a65ceeef9aab2d149439f82dc191dd6c5cbe2c0' 106 | timestamp = '1445827045067' 107 | nonce = 'nEXhMP4r' 108 | encrypt_str = b'1a3NBxmCFwkCJvfoQ7WhJHB+iX3qHPsc9JbaDznE1i03peOk1LaOQoRz3+nlyGNhwmwJ3vDMG+OzrHMeiZI7gTRWVdUB' \ 109 | b'mfxjZ8Ej23JVYa9VrYeJ5as7XM/ZpulX8NEQis44w53h1qAgnC3PRzM7Zc/D6Ibr0rgUathB6zRHP8PYrfgnNOS9PhSB' \ 110 | b'dHlegK+AGGanfwjXuQ9+0pZcy0w9lQ==' 111 | 112 | crypto = DingTalkCrypto(self.token, self.encoding_aes_key, self.suite_key) 113 | msg = crypto.decrypt_encrypt_str( 114 | signature, 115 | timestamp, 116 | nonce, 117 | encrypt_str 118 | ) 119 | msg_dict = json.loads(to_text(msg)) 120 | self.assertEqual('check_create_suite_url', msg_dict['EventType']) 121 | self.assertEqual('LPIdSnlF', msg_dict['Random']) 122 | self.assertEqual(self.suite_key, msg_dict['TestSuiteKey']) 123 | 124 | def test_decrypt_encrypt_str_should_fail(self): 125 | from dingtalk.core.exceptions import InvalidSignatureException 126 | signature = '5a65ceeef9aab2d149439f82dc191dd6c5cbe2c0' 127 | timestamp = '1445827045067' 128 | nonce = 'xxxxx' 129 | encrypt_str = b'1a3NBxmCFwkCJvfoQ7WhJHB+iX3qHPsc9JbaDznE1i03peOk1LaOQoRz3+nlyGNhwmwJ3vDMG+OzrHMeiZI7gTRWVdUB' \ 130 | b'mfxjZ8Ej23JVYa9VrYeJ5as7XM/ZpulX8NEQis44w53h1qAgnC3PRzM7Zc/D6Ibr0rgUathB6zRHP8PYrfgnNOS9PhSB' \ 131 | b'dHlegK+AGGanfwjXuQ9+0pZcy0w9lQ==' 132 | 133 | crypto = DingTalkCrypto(self.token, self.encoding_aes_key, self.suite_key) 134 | 135 | self.assertRaises( 136 | InvalidSignatureException, 137 | crypto.decrypt_encrypt_str, 138 | signature, timestamp, nonce, encrypt_str 139 | ) 140 | -------------------------------------------------------------------------------- /tests/test_message.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | import unittest 4 | 5 | 6 | class MessagesTestCase(unittest.TestCase): 7 | 8 | def test_text_message(self): 9 | from dingtalk.model.message import TextBody 10 | 11 | msgbody = TextBody('test') 12 | msg = msgbody.get_dict() 13 | 14 | self.assertEqual('text', msg['msgtype']) 15 | self.assertEqual('test', msg['text']['content']) 16 | 17 | def test_file_message(self): 18 | from dingtalk.model.message import FileBody 19 | 20 | msgbody = FileBody('123456') 21 | msg = msgbody.get_dict() 22 | 23 | self.assertEqual('file', msg['msgtype']) 24 | self.assertEqual('123456', msg['file']['media_id']) 25 | 26 | def test_image_message(self): 27 | from dingtalk.model.message import ImageBody 28 | 29 | msgbody = ImageBody('123456') 30 | msg = msgbody.get_dict() 31 | 32 | self.assertEqual('image', msg['msgtype']) 33 | self.assertEqual('123456', msg['image']['media_id']) 34 | 35 | def test_voice_message(self): 36 | from dingtalk.model.message import VoiceBody 37 | 38 | msgbody = VoiceBody('123456') 39 | msg = msgbody.get_dict() 40 | 41 | self.assertEqual('123456', msgbody.media_id) 42 | self.assertEqual('voice', msg['msgtype']) 43 | self.assertEqual('123456', msg['voice']['media_id']) 44 | 45 | def test_link_message(self): 46 | from dingtalk.model.message import LinkBody 47 | 48 | msgbody = LinkBody('http://dingtalk.com', 'http://dingtalk.com/img.png', 'testtitle', 'testtext') 49 | msg = msgbody.get_dict() 50 | 51 | self.assertEqual('link', msg['msgtype']) 52 | self.assertEqual('http://dingtalk.com', msg['link']['messageUrl']) 53 | self.assertEqual('http://dingtalk.com/img.png', msg['link']['picUrl']) 54 | self.assertEqual('testtitle', msg['link']['title']) 55 | self.assertEqual('testtext', msg['link']['text']) 56 | 57 | def test_markdown_message(self): 58 | from dingtalk.model.message import MarkdownBody 59 | 60 | msgbody = MarkdownBody('title', 'markdowntext') 61 | msg = msgbody.get_dict() 62 | 63 | self.assertEqual('markdown', msg['msgtype']) 64 | self.assertEqual('title', msg['markdown']['title']) 65 | self.assertEqual('markdowntext', msg['markdown']['text']) 66 | 67 | def test_oa_message(self): 68 | from collections import OrderedDict 69 | from dingtalk.model.message import OaBodyContent, OaBody 70 | forms = OrderedDict() 71 | forms['key1'] = 'value1' 72 | forms['key2'] = 'value2' 73 | body_content = OaBodyContent('title', 'content', 'author', '123', '1', forms, '1.12', 'unit') 74 | msgbody = OaBody('http://dingtalk.com', 'ffffff', 'head_text', body_content, 'http://dingtalk.com/index.html') 75 | msg = msgbody.get_dict() 76 | 77 | self.assertEqual('oa', msg['msgtype']) 78 | self.assertEqual('http://dingtalk.com', msg['oa']['message_url']) 79 | self.assertEqual('http://dingtalk.com/index.html', msg['oa']['pc_message_url']) 80 | self.assertEqual('ffffff', msg['oa']['head']['bgcolor']) 81 | self.assertEqual('head_text', msg['oa']['head']['text']) 82 | self.assertEqual('title', msg['oa']['body']['title']) 83 | self.assertEqual('content', msg['oa']['body']['content']) 84 | self.assertEqual('author', msg['oa']['body']['author']) 85 | self.assertEqual('123', msg['oa']['body']['image']) 86 | self.assertEqual('1', msg['oa']['body']['file_count']) 87 | self.assertEqual('1.12', msg['oa']['body']['rich']['num']) 88 | self.assertEqual('unit', msg['oa']['body']['rich']['unit']) 89 | self.assertEqual('key1', msg['oa']['body']['form'][0]['key']) 90 | self.assertEqual('value1', msg['oa']['body']['form'][0]['value']) 91 | self.assertEqual('key2', msg['oa']['body']['form'][1]['key']) 92 | self.assertEqual('value2', msg['oa']['body']['form'][1]['value']) 93 | 94 | def test_single_action_card_message(self): 95 | from dingtalk.model.message import SingleActionCardBody 96 | 97 | msgbody = SingleActionCardBody('title', 'markdown', 'single_title', 'http://dingtalk.com/index.html') 98 | msg = msgbody.get_dict() 99 | 100 | self.assertEqual('action_card', msg['msgtype']) 101 | self.assertEqual('title', msg['action_card']['title']) 102 | self.assertEqual('markdown', msg['action_card']['markdown']) 103 | self.assertEqual('single_title', msg['action_card']['single_title']) 104 | self.assertEqual('title', msg['action_card']['title']) 105 | self.assertEqual('http://dingtalk.com/index.html', msg['action_card']['single_url']) 106 | 107 | def test_button_action_card_message(self): 108 | from dingtalk.model.message import BtnActionCardBody 109 | 110 | msgbody = BtnActionCardBody('title', 'markdown', '0', [{'title': 'title1', 'action_url': 'action_url1'}, 111 | {'title': 'title2', 'action_url': 'action_url2'}]) 112 | msgbody.add_btn('title3', 'action_url3') 113 | msg = msgbody.get_dict() 114 | 115 | self.assertEqual('action_card', msg['msgtype']) 116 | self.assertEqual('title', msg['action_card']['title']) 117 | self.assertEqual('markdown', msg['action_card']['markdown']) 118 | self.assertEqual('0', msg['action_card']['btn_orientation']) 119 | self.assertEqual('title1', msg['action_card']['btn_json_list'][0]['title']) 120 | self.assertEqual('action_url1', msg['action_card']['btn_json_list'][0]['action_url']) 121 | self.assertEqual('title2', msg['action_card']['btn_json_list'][1]['title']) 122 | self.assertEqual('action_url2', msg['action_card']['btn_json_list'][1]['action_url']) 123 | self.assertEqual('title3', msg['action_card']['btn_json_list'][2]['title']) 124 | self.assertEqual('action_url3', msg['action_card']['btn_json_list'][2]['action_url']) 125 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | import unittest 4 | 5 | 6 | class WeChatSessionTestCase(unittest.TestCase): 7 | 8 | def test_dingtalk_cache(self, storage=None): 9 | from dingtalk.storage.cache import DingTalkCache 10 | if storage is None: 11 | return 12 | cache = DingTalkCache(storage) 13 | cache.access_token.set('crop', 'test1', 7200) 14 | cache.jsapi_ticket.set('crop', 'test2', 7200) 15 | 16 | self.assertEqual('test1', cache.access_token.get('crop')) 17 | self.assertEqual('test2', cache.jsapi_ticket.get('crop')) 18 | 19 | def test_channel_cache(self, storage=None): 20 | from dingtalk.storage.cache import ChannelCache 21 | if storage is None: 22 | return 23 | cache = ChannelCache(storage) 24 | cache.channel_token.set('crop', 'test3', 7200) 25 | cache.jsapi_ticket.set('crop', 'test4', 7200) 26 | 27 | self.assertEqual('test3', cache.channel_token.get('crop')) 28 | self.assertEqual('test4', cache.jsapi_ticket.get('crop')) 29 | 30 | def test_isv_cache(self, storage=None): 31 | from dingtalk.storage.cache import ISVCache 32 | if storage is None: 33 | return 34 | cache = ISVCache(storage) 35 | cache.suite_ticket.set('crop', 'test5', 7200) 36 | cache.suite_access_token.set('crop', 'test6', 7200) 37 | cache.permanent_code.set('crop', 'test7', 7200) 38 | cache.ch_permanent_code.set('crop', 'test8', 7200) 39 | 40 | self.assertEqual('test5', cache.suite_ticket.get('crop')) 41 | self.assertEqual('test6', cache.suite_access_token.get('crop')) 42 | self.assertEqual('test7', cache.permanent_code.get('crop')) 43 | self.assertEqual('test8', cache.ch_permanent_code.get('crop')) 44 | 45 | def test_caches(self, storage=None): 46 | if storage is None: 47 | return 48 | self.test_dingtalk_cache(storage) 49 | self.test_channel_cache(storage) 50 | self.test_isv_cache(storage) 51 | 52 | def test_memory_storage(self): 53 | from dingtalk.storage.memorystorage import MemoryStorage 54 | 55 | storage = MemoryStorage() 56 | self.test_caches(storage) 57 | 58 | def test_redis_storage(self): 59 | from redis import Redis 60 | from dingtalk.storage.kvstorage import KvStorage 61 | redis = Redis() 62 | storage = KvStorage(redis) 63 | self.test_caches(storage) 64 | 65 | def test_memcache_storage(self): 66 | from pymemcache.client import Client 67 | from dingtalk.storage.kvstorage import KvStorage 68 | servers = ("127.0.0.1", 11211) 69 | memcached = Client(servers) 70 | storage = KvStorage(memcached) 71 | self.test_caches(storage) 72 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | import unittest 4 | 5 | from dingtalk.core.utils import ObjectDict, DingTalkSigner 6 | 7 | 8 | class UtilityTestCase(unittest.TestCase): 9 | 10 | def test_object_dict(self): 11 | obj = ObjectDict() 12 | self.assertTrue(obj.xxx is None) 13 | obj.xxx = 1 14 | self.assertEqual(1, obj.xxx) 15 | 16 | def test_wechat_card_signer(self): 17 | 18 | signer = DingTalkSigner() 19 | signer.add_data('789') 20 | signer.add_data('456') 21 | signer.add_data('123') 22 | signature = signer.signature 23 | 24 | self.assertEqual('f7c3bc1d808e04732adf679965ccc34ca7ae3441', signature) 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py27,py34,py35,py36,pypy,pypy3}-{cryptography,pycrypto} 3 | 4 | [testenv] 5 | usedevelop = True 6 | basepython = 7 | py27: python2.7 8 | py34: python3.4 9 | py35: python3.5 10 | py36: python3.6 11 | pypy: pypy 12 | pypy3: pypy3 13 | deps = 14 | -rdev-requirements.txt 15 | commands = 16 | pytest -v 17 | 18 | [testenv:py27-cryptography] 19 | extras=cryptography 20 | 21 | [testenv:py27-pycrypto] 22 | extras=pycrypto 23 | 24 | [testenv:py34-cryptography] 25 | extras=cryptography 26 | 27 | [testenv:py34-pycrypto] 28 | extras=pycrypto 29 | 30 | [testenv:py35-cryptography] 31 | extras=cryptography 32 | 33 | [testenv:py35-pycrypto] 34 | extras=pycrypto 35 | 36 | [testenv:py36-cryptography] 37 | extras=cryptography 38 | 39 | [testenv:py36-pycrypto] 40 | extras=pycrypto 41 | 42 | [testenv:pypy-cryptography] 43 | extras=cryptography 44 | 45 | [testenv:pypy-pycrypto] 46 | extras=pycrypto 47 | 48 | [testenv:pypy3-cryptography] 49 | extras=cryptography 50 | 51 | [testenv:pypy3-pycrypto] 52 | extras=pycrypto 53 | --------------------------------------------------------------------------------