432 lines
14 KiB
Python
432 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
wechatpy.component
|
|
~~~~~~~~~~~~~~~
|
|
|
|
This module provides client library for WeChat Open Platform
|
|
|
|
:copyright: (c) 2015 by hunter007.
|
|
:license: MIT, see LICENSE for more details.
|
|
"""
|
|
from __future__ import absolute_import, unicode_literals
|
|
import time
|
|
import logging
|
|
|
|
import six
|
|
import requests
|
|
import xmltodict
|
|
|
|
from wechatpy.utils import to_text, to_binary, get_querystring, json
|
|
from wechatpy.fields import StringField, DateTimeField
|
|
from wechatpy.messages import MessageMetaClass
|
|
from wechatpy.session.memorystorage import MemoryStorage
|
|
from wechatpy.exceptions import WeChatClientException, APILimitedException
|
|
from wechatpy.crypto import WeChatCrypto
|
|
from wechatpy.client import WeChatComponentClient
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BaseComponentMessage(six.with_metaclass(MessageMetaClass)):
|
|
"""Base class for all component messages and events"""
|
|
type = 'unknown'
|
|
appid = StringField('AppId')
|
|
create_time = DateTimeField('CreateTime')
|
|
|
|
def __init__(self, message):
|
|
self._data = message
|
|
|
|
def __repr__(self):
|
|
_repr = "{klass}({msg})".format(
|
|
klass=self.__class__.__name__,
|
|
msg=repr(self._data)
|
|
)
|
|
if six.PY2:
|
|
return to_binary(_repr)
|
|
else:
|
|
return to_text(_repr)
|
|
|
|
|
|
class ComponentVerifyTicketMessage(BaseComponentMessage):
|
|
"""
|
|
component_verify_ticket协议
|
|
"""
|
|
type = 'component_verify_ticket'
|
|
verify_ticket = StringField('ComponentVerifyTicket')
|
|
|
|
|
|
class ComponentUnauthorizedMessage(BaseComponentMessage):
|
|
"""
|
|
取消授权通知
|
|
"""
|
|
type = 'unauthorized'
|
|
authorizer_appid = StringField('AuthorizerAppid')
|
|
|
|
|
|
class BaseWeChatComponent(object):
|
|
|
|
API_BASE_URL = 'https://api.weixin.qq.com/cgi-bin'
|
|
|
|
def __init__(self,
|
|
component_appid,
|
|
component_appsecret,
|
|
component_token,
|
|
encoding_aes_key,
|
|
session=None):
|
|
"""
|
|
:param component_appid: 第三方平台appid
|
|
:param component_appsecret: 第三方平台appsecret
|
|
:param component_token: 公众号消息校验Token
|
|
:param encoding_aes_key: 公众号消息加解密Key
|
|
"""
|
|
self.component_appid = component_appid
|
|
self.component_appsecret = component_appsecret
|
|
self.expires_at = None
|
|
self.crypto = WeChatCrypto(
|
|
component_token, encoding_aes_key, component_appid)
|
|
self.session = session or MemoryStorage()
|
|
|
|
if isinstance(session, six.string_types):
|
|
from shove import Shove
|
|
from wechatpy.session.shovestorage import ShoveStorage
|
|
|
|
querystring = get_querystring(session)
|
|
prefix = querystring.get('prefix', ['wechatpy'])[0]
|
|
|
|
shove = Shove(session)
|
|
storage = ShoveStorage(shove, prefix)
|
|
self.session = storage
|
|
|
|
@property
|
|
def component_verify_ticket(self):
|
|
return self.session.get('component_verify_ticket')
|
|
|
|
def _request(self, method, url_or_endpoint, **kwargs):
|
|
if not url_or_endpoint.startswith(('http://', 'https://')):
|
|
api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL)
|
|
url = '{base}{endpoint}'.format(
|
|
base=api_base_url,
|
|
endpoint=url_or_endpoint
|
|
)
|
|
else:
|
|
url = url_or_endpoint
|
|
|
|
if 'params' not in kwargs:
|
|
kwargs['params'] = {}
|
|
if isinstance(kwargs['params'], dict) and \
|
|
'component_access_token' not in kwargs['params']:
|
|
kwargs['params'][
|
|
'component_access_token'] = self.access_token
|
|
if isinstance(kwargs['data'], dict):
|
|
kwargs['data'] = json.dumps(kwargs['data'])
|
|
|
|
res = requests.request(
|
|
method=method,
|
|
url=url,
|
|
**kwargs
|
|
)
|
|
try:
|
|
res.raise_for_status()
|
|
except requests.RequestException as reqe:
|
|
raise WeChatClientException(
|
|
errcode=None,
|
|
errmsg=None,
|
|
client=self,
|
|
request=reqe.request,
|
|
response=reqe.response
|
|
)
|
|
|
|
return self._handle_result(res, method, url, **kwargs)
|
|
|
|
def _handle_result(self, res, method=None, url=None, **kwargs):
|
|
result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False)
|
|
if 'errcode' in result:
|
|
result['errcode'] = int(result['errcode'])
|
|
|
|
if 'errcode' in result and result['errcode'] != 0:
|
|
errcode = result['errcode']
|
|
errmsg = result['errmsg']
|
|
if errcode == 42001:
|
|
logger.info('Component access token expired, fetch a new one and retry request')
|
|
self.fetch_component_access_token()
|
|
kwargs['params']['component_access_token'] = self.session.get(
|
|
'component_access_token'
|
|
)
|
|
return self._request(
|
|
method=method,
|
|
url_or_endpoint=url,
|
|
**kwargs
|
|
)
|
|
elif errcode == 45009:
|
|
# api freq out of limit
|
|
raise APILimitedException(
|
|
errcode,
|
|
errmsg,
|
|
client=self,
|
|
request=res.request,
|
|
response=res
|
|
)
|
|
else:
|
|
raise WeChatClientException(
|
|
errcode,
|
|
errmsg,
|
|
client=self,
|
|
request=res.request,
|
|
response=res
|
|
)
|
|
|
|
return result
|
|
|
|
def fetch_access_token(self):
|
|
"""
|
|
获取 component_access_token
|
|
详情请参考 https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list\
|
|
&t=resource/res_list&verify=1&id=open1419318587&token=&lang=zh_CN
|
|
|
|
:return: 返回的 JSON 数据包
|
|
"""
|
|
url = '{0}{1}'.format(
|
|
self.API_BASE_URL,
|
|
'/component/api_component_token'
|
|
)
|
|
return self._fetch_access_token(
|
|
url=url,
|
|
data=json.dumps({
|
|
'component_appid': self.component_appid,
|
|
'component_appsecret': self.component_appsecret,
|
|
'component_verify_ticket': self.component_verify_ticket
|
|
})
|
|
)
|
|
|
|
def _fetch_access_token(self, url, data):
|
|
""" The real fetch access token """
|
|
logger.info('Fetching component access token')
|
|
res = requests.post(
|
|
url=url,
|
|
data=data
|
|
)
|
|
try:
|
|
res.raise_for_status()
|
|
except requests.RequestException as reqe:
|
|
raise WeChatClientException(
|
|
errcode=None,
|
|
errmsg=None,
|
|
client=self,
|
|
request=reqe.request,
|
|
response=reqe.response
|
|
)
|
|
result = res.json()
|
|
if 'errcode' in result and result['errcode'] != 0:
|
|
raise WeChatClientException(
|
|
result['errcode'],
|
|
result['errmsg'],
|
|
client=self,
|
|
request=res.request,
|
|
response=res
|
|
)
|
|
|
|
expires_in = 7200
|
|
if 'expires_in' in result:
|
|
expires_in = result['expires_in']
|
|
self.session.set(
|
|
'component_access_token',
|
|
result['component_access_token'],
|
|
expires_in
|
|
)
|
|
self.expires_at = int(time.time()) + expires_in
|
|
return result
|
|
|
|
@property
|
|
def access_token(self):
|
|
""" WeChat component access token """
|
|
access_token = self.session.get('component_access_token')
|
|
if access_token:
|
|
if not self.expires_at:
|
|
# user provided access_token, just return it
|
|
return access_token
|
|
|
|
timestamp = time.time()
|
|
if self.expires_at - timestamp > 60:
|
|
return access_token
|
|
|
|
self.fetch_access_token()
|
|
return self.session.get('component_access_token')
|
|
|
|
def get(self, url, **kwargs):
|
|
return self._request(
|
|
method='get',
|
|
url_or_endpoint=url,
|
|
**kwargs
|
|
)
|
|
|
|
def post(self, url, **kwargs):
|
|
return self._request(
|
|
method='post',
|
|
url_or_endpoint=url,
|
|
**kwargs
|
|
)
|
|
|
|
|
|
class WeChatComponent(BaseWeChatComponent):
|
|
|
|
def create_preauthcode(self):
|
|
"""
|
|
获取预授权码
|
|
"""
|
|
return self.post(
|
|
'/component/api_create_preauthcode',
|
|
data={
|
|
'component_appid': self.component_appid
|
|
}
|
|
)
|
|
|
|
def query_auth(self, authorization_code):
|
|
"""
|
|
使用授权码换取公众号的授权信息
|
|
|
|
:params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明
|
|
"""
|
|
return self.post(
|
|
'/component/api_query_auth',
|
|
data={
|
|
'component_appid': self.component_appid,
|
|
'authorization_code': authorization_code
|
|
}
|
|
)
|
|
|
|
def refresh_authorizer_token(
|
|
self, authorizer_appid, authorizer_refresh_token):
|
|
"""
|
|
获取(刷新)授权公众号的令牌
|
|
|
|
:params authorizer_appid: 授权方appid
|
|
:params authorizer_refresh_token: 授权方的刷新令牌
|
|
"""
|
|
return self.post(
|
|
'/component/api_authorizer_token',
|
|
data={
|
|
'component_appid': self.component_appid,
|
|
'authorizer_appid': authorizer_appid,
|
|
'authorizer_refresh_token': authorizer_refresh_token
|
|
}
|
|
)
|
|
|
|
def get_authorizer_info(self, authorizer_appid):
|
|
"""
|
|
获取授权方的账户信息
|
|
|
|
:params authorizer_appid: 授权方appid
|
|
"""
|
|
return self.post(
|
|
'/component/api_get_authorizer_info',
|
|
data={
|
|
'component_appid': self.component_appid,
|
|
'authorizer_appid': authorizer_appid,
|
|
}
|
|
)
|
|
|
|
def get_authorizer_option(self, authorizer_appid, option_name):
|
|
"""
|
|
获取授权方的选项设置信息
|
|
|
|
:params authorizer_appid: 授权公众号appid
|
|
:params option_name: 选项名称
|
|
"""
|
|
return self.post(
|
|
'/component/api_get_authorizer_option',
|
|
data={
|
|
'component_appid': self.component_appid,
|
|
'authorizer_appid': authorizer_appid,
|
|
'option_name': option_name
|
|
}
|
|
)
|
|
|
|
def set_authorizer_option(
|
|
self, authorizer_appid, option_name, option_value):
|
|
"""
|
|
设置授权方的选项信息
|
|
|
|
:params authorizer_appid: 授权公众号appid
|
|
:params option_name: 选项名称
|
|
:params option_value: 设置的选项值
|
|
"""
|
|
return self.post(
|
|
'/component/api_set_authorizer_option',
|
|
data={
|
|
'component_appid': self.component_appid,
|
|
'authorizer_appid': authorizer_appid,
|
|
'option_name': option_name,
|
|
'option_value': option_value
|
|
}
|
|
)
|
|
|
|
def get_client_by_authorization_code(self, authorization_code):
|
|
"""
|
|
通过授权码直接获取 Client 对象
|
|
|
|
:params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明
|
|
"""
|
|
result = self.query_auth(authorization_code)
|
|
access_token = result['authorization_info']['authorizer_access_token']
|
|
refresh_token = result['authorization_info']['authorizer_refresh_token'] # NOQA
|
|
authorizer_appid = result['authorization_info']['authorizer_appid'] # noqa
|
|
return WeChatComponentClient(
|
|
authorizer_appid, self, access_token, refresh_token,
|
|
session=self.session
|
|
)
|
|
|
|
def get_client_by_appid(self, authorizer_appid):
|
|
"""
|
|
通过 authorizer_appid 获取 Client 对象
|
|
|
|
:params authorizer_appid: 授权公众号appid
|
|
"""
|
|
access_token_key = '{0}_access_token'.format(authorizer_appid)
|
|
refresh_token_key = '{0}_refresh_token'.format(authorizer_appid)
|
|
access_token = self.session.get(access_token_key)
|
|
refresh_token = self.session.get(refresh_token_key)
|
|
|
|
if not access_token:
|
|
ret = self.refresh_authorizer_token(
|
|
authorizer_appid,
|
|
refresh_token
|
|
)
|
|
access_token = ret['authorizer_access_token']
|
|
refresh_token = ret['authorizer_refresh_token']
|
|
|
|
return WeChatComponentClient(
|
|
authorizer_appid,
|
|
self,
|
|
access_token,
|
|
refresh_token,
|
|
session=self.session
|
|
)
|
|
|
|
def cache_component_verify_ticket(self, msg, signature, timestamp, nonce):
|
|
"""
|
|
处理 wechat server 推送的 component_verify_ticket消息
|
|
|
|
:params msg: 加密内容
|
|
:params signature: 消息签名
|
|
:params timestamp: 时间戳
|
|
:params nonce: 随机数
|
|
"""
|
|
content = self.crypto.decrypt_message(msg, signature, timestamp, nonce)
|
|
message = xmltodict.parse(to_text(content))['xml']
|
|
o = ComponentVerifyTicketMessage(message)
|
|
self.session.set(o.type, o.verify_ticket, 600)
|
|
|
|
def get_unauthorized(self, msg, signature, timestamp, nonce):
|
|
"""
|
|
处理取消授权通知
|
|
|
|
:params msg: 加密内容
|
|
:params signature: 消息签名
|
|
:params timestamp: 时间戳
|
|
:params nonce: 随机数
|
|
"""
|
|
content = self.crypto.decrypt_message(msg, signature, timestamp, nonce)
|
|
message = xmltodict.parse(to_text(content))['xml']
|
|
return ComponentUnauthorizedMessage(message)
|