|
1
|
|
|
# -*- coding: utf-8 -*- |
|
2
|
10 |
|
""" |
|
3
|
|
|
wechatpy.component |
|
4
|
|
|
~~~~~~~~~~~~~~~ |
|
5
|
|
|
|
|
6
|
|
|
This module provides client library for WeChat Open Platform |
|
7
|
|
|
|
|
8
|
|
|
:copyright: (c) 2015 by hunter007. |
|
9
|
|
|
:license: MIT, see LICENSE for more details. |
|
10
|
|
|
""" |
|
11
|
10 |
|
from __future__ import absolute_import, unicode_literals |
|
12
|
|
|
|
|
13
|
10 |
|
import logging |
|
14
|
10 |
|
import time |
|
15
|
10 |
|
import warnings |
|
16
|
|
|
|
|
17
|
10 |
|
import requests |
|
18
|
10 |
|
import six |
|
19
|
10 |
|
import xmltodict |
|
20
|
10 |
|
from six.moves.urllib.parse import quote |
|
21
|
|
|
|
|
22
|
10 |
|
from wechatpy.client import WeChatComponentClient |
|
23
|
10 |
|
from wechatpy.constants import WeChatErrorCode |
|
24
|
10 |
|
from wechatpy.crypto import WeChatCrypto |
|
25
|
10 |
|
from wechatpy.exceptions import APILimitedException, WeChatClientException, WeChatOAuthException, \ |
|
26
|
|
|
WeChatComponentOAuthException |
|
27
|
10 |
|
from wechatpy.fields import DateTimeField, StringField |
|
28
|
10 |
|
from wechatpy.messages import MessageMetaClass |
|
29
|
10 |
|
from wechatpy.session.memorystorage import MemoryStorage |
|
30
|
10 |
|
from wechatpy.utils import get_querystring, json, to_binary, to_text, ObjectDict |
|
31
|
|
|
|
|
32
|
10 |
|
logger = logging.getLogger(__name__) |
|
33
|
|
|
|
|
34
|
10 |
|
COMPONENT_MESSAGE_TYPES = {} |
|
35
|
|
|
|
|
36
|
|
|
|
|
37
|
10 |
|
def register_component_message(msg_type): |
|
38
|
10 |
|
def register(cls): |
|
39
|
10 |
|
COMPONENT_MESSAGE_TYPES[msg_type] = cls |
|
40
|
10 |
|
return cls |
|
41
|
10 |
|
return register |
|
42
|
|
|
|
|
43
|
|
|
|
|
44
|
10 |
|
class BaseComponentMessage(six.with_metaclass(MessageMetaClass)): |
|
45
|
|
|
"""Base class for all component messages and events""" |
|
46
|
10 |
|
type = 'unknown' |
|
47
|
10 |
|
appid = StringField('AppId') |
|
48
|
10 |
|
create_time = DateTimeField('CreateTime') |
|
49
|
|
|
|
|
50
|
10 |
|
def __init__(self, message): |
|
51
|
|
|
self._data = message |
|
52
|
|
|
|
|
53
|
|
|
def __repr__(self): |
|
54
|
|
|
_repr = "{klass}({msg})".format( |
|
55
|
|
|
klass=self.__class__.__name__, |
|
56
|
|
|
msg=repr(self._data) |
|
57
|
|
|
) |
|
58
|
|
|
if six.PY2: |
|
59
|
|
|
return to_binary(_repr) |
|
60
|
|
|
else: |
|
61
|
|
|
return to_text(_repr) |
|
62
|
|
|
|
|
63
|
|
|
|
|
64
|
10 |
|
@register_component_message('component_verify_ticket') |
|
65
|
10 |
|
class ComponentVerifyTicketMessage(BaseComponentMessage): |
|
66
|
|
|
""" |
|
67
|
|
|
component_verify_ticket协议 |
|
68
|
|
|
""" |
|
69
|
10 |
|
type = 'component_verify_ticket' |
|
70
|
10 |
|
verify_ticket = StringField('ComponentVerifyTicket') |
|
71
|
|
|
|
|
72
|
|
|
|
|
73
|
10 |
|
@register_component_message('unauthorized') |
|
74
|
10 |
|
class ComponentUnauthorizedMessage(BaseComponentMessage): |
|
75
|
|
|
""" |
|
76
|
|
|
取消授权通知 |
|
77
|
|
|
""" |
|
78
|
10 |
|
type = 'unauthorized' |
|
79
|
10 |
|
authorizer_appid = StringField('AuthorizerAppid') |
|
80
|
|
|
|
|
81
|
|
|
|
|
82
|
10 |
|
@register_component_message('authorized') |
|
83
|
10 |
|
class ComponentAuthorizedMessage(BaseComponentMessage): |
|
84
|
|
|
""" |
|
85
|
|
|
新增授权通知 |
|
86
|
|
|
""" |
|
87
|
10 |
|
type = 'authorized' |
|
88
|
10 |
|
authorizer_appid = StringField('AuthorizerAppid') |
|
89
|
10 |
|
authorization_code = StringField('AuthorizationCode') |
|
90
|
10 |
|
authorization_code_expired_time = StringField('AuthorizationCodeExpiredTime') |
|
91
|
10 |
|
pre_auth_code = StringField('PreAuthCode') |
|
92
|
|
|
|
|
93
|
|
|
|
|
94
|
10 |
|
@register_component_message('updateauthorized') |
|
95
|
10 |
|
class ComponentUpdateauthorizedMessage(BaseComponentMessage): |
|
96
|
|
|
""" |
|
97
|
|
|
更新授权通知 |
|
98
|
|
|
""" |
|
99
|
10 |
|
type = 'updateauthorized' |
|
100
|
10 |
|
authorizer_appid = StringField('AuthorizerAppid') |
|
101
|
10 |
|
authorization_code = StringField('AuthorizationCode') |
|
102
|
10 |
|
authorization_code_expired_time = StringField('AuthorizationCodeExpiredTime') |
|
103
|
10 |
|
pre_auth_code = StringField('PreAuthCode') |
|
104
|
|
|
|
|
105
|
|
|
|
|
106
|
10 |
|
class ComponentUnknownMessage(BaseComponentMessage): |
|
107
|
|
|
""" |
|
108
|
|
|
未知通知 |
|
109
|
|
|
""" |
|
110
|
10 |
|
type = 'unknown' |
|
111
|
|
|
|
|
112
|
|
|
|
|
113
|
10 |
|
class BaseWeChatComponent(object): |
|
114
|
10 |
|
API_BASE_URL = 'https://api.weixin.qq.com/cgi-bin' |
|
115
|
|
|
|
|
116
|
10 |
|
def __init__(self, |
|
117
|
|
|
component_appid, |
|
118
|
|
|
component_appsecret, |
|
119
|
|
|
component_token, |
|
120
|
|
|
encoding_aes_key, |
|
121
|
|
|
session=None, |
|
122
|
|
|
auto_retry=True): |
|
123
|
|
|
""" |
|
124
|
|
|
:param component_appid: 第三方平台appid |
|
125
|
|
|
:param component_appsecret: 第三方平台appsecret |
|
126
|
|
|
:param component_token: 公众号消息校验Token |
|
127
|
|
|
:param encoding_aes_key: 公众号消息加解密Key |
|
128
|
|
|
""" |
|
129
|
10 |
|
self._http = requests.Session() |
|
130
|
10 |
|
self.component_appid = component_appid |
|
131
|
10 |
|
self.component_appsecret = component_appsecret |
|
132
|
10 |
|
self.expires_at = None |
|
133
|
10 |
|
self.crypto = WeChatCrypto( |
|
134
|
|
|
component_token, encoding_aes_key, component_appid) |
|
135
|
10 |
|
self.session = session or MemoryStorage() |
|
136
|
10 |
|
self.auto_retry = auto_retry |
|
137
|
|
|
|
|
138
|
10 |
View Code Duplication |
if isinstance(session, six.string_types): |
|
|
|
|
|
|
139
|
|
|
from shove import Shove |
|
140
|
|
|
from wechatpy.session.shovestorage import ShoveStorage |
|
141
|
|
|
|
|
142
|
|
|
querystring = get_querystring(session) |
|
143
|
|
|
prefix = querystring.get('prefix', ['wechatpy'])[0] |
|
144
|
|
|
|
|
145
|
|
|
shove = Shove(session) |
|
146
|
|
|
storage = ShoveStorage(shove, prefix) |
|
147
|
|
|
self.session = storage |
|
148
|
|
|
|
|
149
|
10 |
|
@property |
|
150
|
|
|
def component_verify_ticket(self): |
|
151
|
10 |
|
return self.session.get('component_verify_ticket') |
|
152
|
|
|
|
|
153
|
10 |
|
def _request(self, method, url_or_endpoint, **kwargs): |
|
154
|
10 |
|
if not url_or_endpoint.startswith(('http://', 'https://')): |
|
155
|
10 |
|
api_base_url = kwargs.pop('api_base_url', self.API_BASE_URL) |
|
156
|
10 |
|
url = '{base}{endpoint}'.format( |
|
157
|
|
|
base=api_base_url, |
|
158
|
|
|
endpoint=url_or_endpoint |
|
159
|
|
|
) |
|
160
|
|
|
else: |
|
161
|
|
|
url = url_or_endpoint |
|
162
|
|
|
|
|
163
|
10 |
|
if 'params' not in kwargs: |
|
164
|
10 |
|
kwargs['params'] = {} |
|
165
|
10 |
|
if isinstance(kwargs['params'], dict) and \ |
|
166
|
|
|
'component_access_token' not in kwargs['params']: |
|
167
|
10 |
|
kwargs['params'][ |
|
168
|
|
|
'component_access_token'] = self.access_token |
|
169
|
10 |
|
if isinstance(kwargs['data'], dict): |
|
170
|
10 |
|
kwargs['data'] = json.dumps(kwargs['data']) |
|
171
|
|
|
|
|
172
|
10 |
|
res = self._http.request( |
|
173
|
|
|
method=method, |
|
174
|
|
|
url=url, |
|
175
|
|
|
**kwargs |
|
176
|
|
|
) |
|
177
|
10 |
|
try: |
|
178
|
10 |
|
res.raise_for_status() |
|
179
|
|
|
except requests.RequestException as reqe: |
|
180
|
|
|
raise WeChatClientException( |
|
181
|
|
|
errcode=None, |
|
182
|
|
|
errmsg=None, |
|
183
|
|
|
client=self, |
|
184
|
|
|
request=reqe.request, |
|
185
|
|
|
response=reqe.response |
|
186
|
|
|
) |
|
187
|
|
|
|
|
188
|
10 |
|
return self._handle_result(res, method, url, **kwargs) |
|
189
|
|
|
|
|
190
|
10 |
View Code Duplication |
def _handle_result(self, res, method=None, url=None, **kwargs): |
|
|
|
|
|
|
191
|
10 |
|
result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) |
|
192
|
10 |
|
if 'errcode' in result: |
|
193
|
10 |
|
result['errcode'] = int(result['errcode']) |
|
194
|
|
|
|
|
195
|
10 |
|
if 'errcode' in result and result['errcode'] != 0: |
|
196
|
|
|
errcode = result['errcode'] |
|
197
|
|
|
errmsg = result.get('errmsg', errcode) |
|
198
|
|
|
if self.auto_retry and errcode in ( |
|
199
|
|
|
WeChatErrorCode.INVALID_CREDENTIAL.value, |
|
200
|
|
|
WeChatErrorCode.INVALID_ACCESS_TOKEN.value, |
|
201
|
|
|
WeChatErrorCode.EXPIRED_ACCESS_TOKEN.value): |
|
202
|
|
|
logger.info('Component access token expired, fetch a new one and retry request') |
|
203
|
|
|
self.fetch_access_token() |
|
204
|
|
|
kwargs['params']['component_access_token'] = self.session.get( |
|
205
|
|
|
'component_access_token' |
|
206
|
|
|
) |
|
207
|
|
|
return self._request( |
|
208
|
|
|
method=method, |
|
209
|
|
|
url_or_endpoint=url, |
|
210
|
|
|
**kwargs |
|
211
|
|
|
) |
|
212
|
|
|
elif errcode == WeChatErrorCode.OUT_OF_API_FREQ_LIMIT.value: |
|
213
|
|
|
# api freq out of limit |
|
214
|
|
|
raise APILimitedException( |
|
215
|
|
|
errcode, |
|
216
|
|
|
errmsg, |
|
217
|
|
|
client=self, |
|
218
|
|
|
request=res.request, |
|
219
|
|
|
response=res |
|
220
|
|
|
) |
|
221
|
|
|
else: |
|
222
|
|
|
raise WeChatClientException( |
|
223
|
|
|
errcode, |
|
224
|
|
|
errmsg, |
|
225
|
|
|
client=self, |
|
226
|
|
|
request=res.request, |
|
227
|
|
|
response=res |
|
228
|
|
|
) |
|
229
|
10 |
|
return result |
|
230
|
|
|
|
|
231
|
10 |
|
def fetch_access_token(self): |
|
232
|
|
|
""" |
|
233
|
|
|
获取 component_access_token |
|
234
|
|
|
详情请参考 https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list\ |
|
235
|
|
|
&t=resource/res_list&verify=1&id=open1419318587&token=&lang=zh_CN |
|
236
|
|
|
|
|
237
|
|
|
:return: 返回的 JSON 数据包 |
|
238
|
|
|
""" |
|
239
|
10 |
|
url = '{0}{1}'.format( |
|
240
|
|
|
self.API_BASE_URL, |
|
241
|
|
|
'/component/api_component_token' |
|
242
|
|
|
) |
|
243
|
10 |
|
return self._fetch_access_token( |
|
244
|
|
|
url=url, |
|
245
|
|
|
data=json.dumps({ |
|
246
|
|
|
'component_appid': self.component_appid, |
|
247
|
|
|
'component_appsecret': self.component_appsecret, |
|
248
|
|
|
'component_verify_ticket': self.component_verify_ticket |
|
249
|
|
|
}) |
|
250
|
|
|
) |
|
251
|
|
|
|
|
252
|
10 |
View Code Duplication |
def _fetch_access_token(self, url, data): |
|
|
|
|
|
|
253
|
|
|
""" The real fetch access token """ |
|
254
|
10 |
|
logger.info('Fetching component access token') |
|
255
|
10 |
|
res = self._http.post( |
|
256
|
|
|
url=url, |
|
257
|
|
|
data=data |
|
258
|
|
|
) |
|
259
|
10 |
|
try: |
|
260
|
10 |
|
res.raise_for_status() |
|
261
|
|
|
except requests.RequestException as reqe: |
|
262
|
|
|
raise WeChatClientException( |
|
263
|
|
|
errcode=None, |
|
264
|
|
|
errmsg=None, |
|
265
|
|
|
client=self, |
|
266
|
|
|
request=reqe.request, |
|
267
|
|
|
response=reqe.response |
|
268
|
|
|
) |
|
269
|
10 |
|
result = res.json() |
|
270
|
10 |
|
if 'errcode' in result and result['errcode'] != 0: |
|
271
|
|
|
raise WeChatClientException( |
|
272
|
|
|
result['errcode'], |
|
273
|
|
|
result['errmsg'], |
|
274
|
|
|
client=self, |
|
275
|
|
|
request=res.request, |
|
276
|
|
|
response=res |
|
277
|
|
|
) |
|
278
|
|
|
|
|
279
|
10 |
|
expires_in = 7200 |
|
280
|
10 |
|
if 'expires_in' in result: |
|
281
|
10 |
|
expires_in = result['expires_in'] |
|
282
|
10 |
|
self.session.set( |
|
283
|
|
|
'component_access_token', |
|
284
|
|
|
result['component_access_token'], |
|
285
|
|
|
expires_in |
|
286
|
|
|
) |
|
287
|
10 |
|
self.expires_at = int(time.time()) + expires_in |
|
288
|
10 |
|
return result |
|
289
|
|
|
|
|
290
|
10 |
|
@property |
|
291
|
|
|
def access_token(self): |
|
292
|
|
|
""" WeChat component access token """ |
|
293
|
10 |
|
access_token = self.session.get('component_access_token') |
|
294
|
10 |
|
if access_token: |
|
295
|
10 |
|
if not self.expires_at: |
|
296
|
|
|
# user provided access_token, just return it |
|
297
|
|
|
return access_token |
|
298
|
|
|
|
|
299
|
10 |
|
timestamp = time.time() |
|
300
|
10 |
|
if self.expires_at - timestamp > 60: |
|
301
|
10 |
|
return access_token |
|
302
|
|
|
|
|
303
|
10 |
|
self.fetch_access_token() |
|
304
|
10 |
|
return self.session.get('component_access_token') |
|
305
|
|
|
|
|
306
|
10 |
|
def get(self, url, **kwargs): |
|
307
|
|
|
return self._request( |
|
308
|
|
|
method='get', |
|
309
|
|
|
url_or_endpoint=url, |
|
310
|
|
|
**kwargs |
|
311
|
|
|
) |
|
312
|
|
|
|
|
313
|
10 |
|
def post(self, url, **kwargs): |
|
314
|
10 |
|
return self._request( |
|
315
|
|
|
method='post', |
|
316
|
|
|
url_or_endpoint=url, |
|
317
|
|
|
**kwargs |
|
318
|
|
|
) |
|
319
|
|
|
|
|
320
|
|
|
|
|
321
|
10 |
|
class WeChatComponent(BaseWeChatComponent): |
|
322
|
|
|
|
|
323
|
10 |
|
PRE_AUTH_URL = 'https://mp.weixin.qq.com/cgi-bin/componentloginpage' |
|
324
|
|
|
|
|
325
|
10 |
|
def get_pre_auth_url(self, redirect_uri): |
|
326
|
|
|
redirect_uri = quote(redirect_uri, safe=b'') |
|
327
|
|
|
return "{0}?component_appid={1}&pre_auth_code={2}&redirect_uri={3}".format( |
|
328
|
|
|
self.PRE_AUTH_URL, self.component_appid, self.create_preauthcode()['pre_auth_code'], redirect_uri |
|
329
|
|
|
) |
|
330
|
|
|
|
|
331
|
10 |
|
def get_pre_auth_url_m(self, redirect_uri): |
|
332
|
|
|
""" |
|
333
|
|
|
快速获取pre auth url,可以直接微信中发送该链接,直接授权 |
|
334
|
|
|
""" |
|
335
|
|
|
url = "https://mp.weixin.qq.com/safe/bindcomponent?action=bindcomponent&auth_type=3&no_scan=1&" |
|
336
|
|
|
redirect_uri = quote(redirect_uri, safe='') |
|
337
|
|
|
return "{0}component_appid={1}&pre_auth_code={2}&redirect_uri={3}".format( |
|
338
|
|
|
url, self.component_appid, self.create_preauthcode()['pre_auth_code'], redirect_uri |
|
339
|
|
|
) |
|
340
|
|
|
|
|
341
|
10 |
|
def create_preauthcode(self): |
|
342
|
|
|
""" |
|
343
|
|
|
获取预授权码 |
|
344
|
|
|
""" |
|
345
|
10 |
|
return self.post( |
|
346
|
|
|
'/component/api_create_preauthcode', |
|
347
|
|
|
data={ |
|
348
|
|
|
'component_appid': self.component_appid |
|
349
|
|
|
} |
|
350
|
|
|
) |
|
351
|
|
|
|
|
352
|
10 |
|
def _query_auth(self, authorization_code): |
|
353
|
|
|
""" |
|
354
|
|
|
使用授权码换取公众号的授权信息 |
|
355
|
|
|
|
|
356
|
|
|
:params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 |
|
357
|
|
|
""" |
|
358
|
10 |
|
return self.post( |
|
359
|
|
|
'/component/api_query_auth', |
|
360
|
|
|
data={ |
|
361
|
|
|
'component_appid': self.component_appid, |
|
362
|
|
|
'authorization_code': authorization_code |
|
363
|
|
|
} |
|
364
|
|
|
) |
|
365
|
|
|
|
|
366
|
10 |
|
def query_auth(self, authorization_code): |
|
367
|
|
|
""" |
|
368
|
|
|
使用授权码换取公众号的授权信息,同时储存token信息 |
|
369
|
|
|
|
|
370
|
|
|
:params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 |
|
371
|
|
|
""" |
|
372
|
10 |
|
result = self._query_auth(authorization_code) |
|
373
|
|
|
|
|
374
|
10 |
|
assert result is not None \ |
|
375
|
|
|
and 'authorization_info' in result \ |
|
376
|
|
|
and 'authorizer_appid' in result['authorization_info'] |
|
377
|
|
|
|
|
378
|
10 |
|
authorizer_appid = result['authorization_info']['authorizer_appid'] |
|
379
|
10 |
|
if 'authorizer_access_token' in result['authorization_info'] \ |
|
380
|
|
|
and result['authorization_info']['authorizer_access_token']: |
|
381
|
10 |
|
access_token = result['authorization_info']['authorizer_access_token'] |
|
382
|
10 |
|
access_token_key = '{0}_access_token'.format(authorizer_appid) |
|
383
|
10 |
|
expires_in = 7200 |
|
384
|
10 |
|
if 'expires_in' in result['authorization_info']: |
|
385
|
10 |
|
expires_in = result['authorization_info']['expires_in'] |
|
386
|
10 |
|
self.session.set(access_token_key, access_token, expires_in) |
|
387
|
10 |
|
if 'authorizer_refresh_token' in result['authorization_info'] \ |
|
388
|
|
|
and result['authorization_info']['authorizer_refresh_token']: |
|
389
|
10 |
|
refresh_token = result['authorization_info']['authorizer_refresh_token'] |
|
390
|
10 |
|
refresh_token_key = '{0}_refresh_token'.format(authorizer_appid) |
|
391
|
10 |
|
self.session.set(refresh_token_key, refresh_token) # refresh_token 需要永久储存,不建议使用内存储存,否则每次重启服务需要重新扫码授权 |
|
392
|
10 |
|
return result |
|
393
|
|
|
|
|
394
|
10 |
|
def refresh_authorizer_token( |
|
395
|
|
|
self, authorizer_appid, authorizer_refresh_token): |
|
396
|
|
|
""" |
|
397
|
|
|
获取(刷新)授权公众号的令牌 |
|
398
|
|
|
|
|
399
|
|
|
:params authorizer_appid: 授权方appid |
|
400
|
|
|
:params authorizer_refresh_token: 授权方的刷新令牌 |
|
401
|
|
|
""" |
|
402
|
10 |
|
return self.post( |
|
403
|
|
|
'/component/api_authorizer_token', |
|
404
|
|
|
data={ |
|
405
|
|
|
'component_appid': self.component_appid, |
|
406
|
|
|
'authorizer_appid': authorizer_appid, |
|
407
|
|
|
'authorizer_refresh_token': authorizer_refresh_token |
|
408
|
|
|
} |
|
409
|
|
|
) |
|
410
|
|
|
|
|
411
|
10 |
|
def get_authorizer_info(self, authorizer_appid): |
|
412
|
|
|
""" |
|
413
|
|
|
获取授权方的账户信息 |
|
414
|
|
|
|
|
415
|
|
|
:params authorizer_appid: 授权方appid |
|
416
|
|
|
""" |
|
417
|
10 |
|
return self.post( |
|
418
|
|
|
'/component/api_get_authorizer_info', |
|
419
|
|
|
data={ |
|
420
|
|
|
'component_appid': self.component_appid, |
|
421
|
|
|
'authorizer_appid': authorizer_appid, |
|
422
|
|
|
} |
|
423
|
|
|
) |
|
424
|
|
|
|
|
425
|
10 |
|
def get_authorizer_list(self, offset=0, count=500): |
|
426
|
|
|
""" |
|
427
|
|
|
拉取所有已授权的帐号信息 |
|
428
|
|
|
|
|
429
|
|
|
:params offset: 偏移位置/起始位置 |
|
430
|
|
|
:params count: 拉取数量 |
|
431
|
|
|
""" |
|
432
|
|
|
return self.post( |
|
433
|
|
|
'/component/api_get_authorizer_list', |
|
434
|
|
|
data={ |
|
435
|
|
|
'component_appid': self.component_appid, |
|
436
|
|
|
'offset': offset, |
|
437
|
|
|
'count': count, |
|
438
|
|
|
} |
|
439
|
|
|
) |
|
440
|
|
|
|
|
441
|
10 |
|
def get_authorizer_option(self, authorizer_appid, option_name): |
|
442
|
|
|
""" |
|
443
|
|
|
获取授权方的选项设置信息 |
|
444
|
|
|
|
|
445
|
|
|
:params authorizer_appid: 授权公众号appid |
|
446
|
|
|
:params option_name: 选项名称 |
|
447
|
|
|
""" |
|
448
|
10 |
|
return self.post( |
|
449
|
|
|
'/component/api_get_authorizer_option', |
|
450
|
|
|
data={ |
|
451
|
|
|
'component_appid': self.component_appid, |
|
452
|
|
|
'authorizer_appid': authorizer_appid, |
|
453
|
|
|
'option_name': option_name |
|
454
|
|
|
} |
|
455
|
|
|
) |
|
456
|
|
|
|
|
457
|
10 |
|
def set_authorizer_option( |
|
458
|
|
|
self, authorizer_appid, option_name, option_value): |
|
459
|
|
|
""" |
|
460
|
|
|
设置授权方的选项信息 |
|
461
|
|
|
|
|
462
|
|
|
:params authorizer_appid: 授权公众号appid |
|
463
|
|
|
:params option_name: 选项名称 |
|
464
|
|
|
:params option_value: 设置的选项值 |
|
465
|
|
|
""" |
|
466
|
10 |
|
return self.post( |
|
467
|
|
|
'/component/api_set_authorizer_option', |
|
468
|
|
|
data={ |
|
469
|
|
|
'component_appid': self.component_appid, |
|
470
|
|
|
'authorizer_appid': authorizer_appid, |
|
471
|
|
|
'option_name': option_name, |
|
472
|
|
|
'option_value': option_value |
|
473
|
|
|
} |
|
474
|
|
|
) |
|
475
|
|
|
|
|
476
|
10 |
|
def get_client_by_authorization_code(self, authorization_code): |
|
477
|
|
|
""" |
|
478
|
|
|
通过授权码直接获取 Client 对象 |
|
479
|
|
|
|
|
480
|
|
|
:params authorization_code: 授权code,会在授权成功时返回给第三方平台,详见第三方平台授权流程说明 |
|
481
|
|
|
""" |
|
482
|
|
|
warnings.warn('`get_client_by_authorization_code` method of `WeChatComponent` is deprecated,' |
|
483
|
|
|
'Use `parse_message` parse message and ' |
|
484
|
|
|
'Use `get_client_by_appid` instead', |
|
485
|
|
|
DeprecationWarning, stacklevel=2) |
|
486
|
|
|
result = self.query_auth(authorization_code) |
|
487
|
|
|
access_token = result['authorization_info']['authorizer_access_token'] |
|
488
|
|
|
refresh_token = result['authorization_info']['authorizer_refresh_token'] # NOQA |
|
489
|
|
|
authorizer_appid = result['authorization_info']['authorizer_appid'] # noqa |
|
490
|
|
|
return WeChatComponentClient( |
|
491
|
|
|
authorizer_appid, self, access_token, refresh_token, |
|
492
|
|
|
session=self.session |
|
493
|
|
|
) |
|
494
|
|
|
|
|
495
|
10 |
|
def get_client_by_appid(self, authorizer_appid): |
|
496
|
|
|
""" |
|
497
|
|
|
通过 authorizer_appid 获取 Client 对象 |
|
498
|
|
|
|
|
499
|
|
|
:params authorizer_appid: 授权公众号appid |
|
500
|
|
|
""" |
|
501
|
|
|
access_token_key = '{0}_access_token'.format(authorizer_appid) |
|
502
|
|
|
refresh_token_key = '{0}_refresh_token'.format(authorizer_appid) |
|
503
|
|
|
access_token = self.session.get(access_token_key) |
|
504
|
|
|
refresh_token = self.session.get(refresh_token_key) |
|
505
|
|
|
assert refresh_token |
|
506
|
|
|
|
|
507
|
|
|
if not access_token: |
|
508
|
|
|
ret = self.refresh_authorizer_token( |
|
509
|
|
|
authorizer_appid, |
|
510
|
|
|
refresh_token |
|
511
|
|
|
) |
|
512
|
|
|
access_token = ret['authorizer_access_token'] |
|
513
|
|
|
refresh_token = ret['authorizer_refresh_token'] |
|
514
|
|
|
access_token_key = '{0}_access_token'.format(authorizer_appid) |
|
515
|
|
|
expires_in = 7200 |
|
516
|
|
|
if 'expires_in' in ret: |
|
517
|
|
|
expires_in = ret['expires_in'] |
|
518
|
|
|
self.session.set(access_token_key, access_token, expires_in) |
|
519
|
|
|
|
|
520
|
|
|
return WeChatComponentClient( |
|
521
|
|
|
authorizer_appid, |
|
522
|
|
|
self, |
|
523
|
|
|
session=self.session |
|
524
|
|
|
) |
|
525
|
|
|
|
|
526
|
10 |
|
def parse_message(self, msg, msg_signature, timestamp, nonce): |
|
527
|
|
|
""" |
|
528
|
|
|
处理 wechat server 推送消息 |
|
529
|
|
|
|
|
530
|
|
|
:params msg: 加密内容 |
|
531
|
|
|
:params msg_signature: 消息签名 |
|
532
|
|
|
:params timestamp: 时间戳 |
|
533
|
|
|
:params nonce: 随机数 |
|
534
|
|
|
""" |
|
535
|
|
|
content = self.crypto.decrypt_message(msg, msg_signature, timestamp, nonce) |
|
536
|
|
|
message = xmltodict.parse(to_text(content))['xml'] |
|
537
|
|
|
message_type = message['InfoType'].lower() |
|
538
|
|
|
message_class = COMPONENT_MESSAGE_TYPES.get(message_type, ComponentUnknownMessage) |
|
539
|
|
|
msg = message_class(message) |
|
540
|
|
|
if msg.type == 'component_verify_ticket': |
|
541
|
|
|
self.session.set(msg.type, msg.verify_ticket) |
|
542
|
|
|
elif msg.type in ('authorized', 'updateauthorized'): |
|
543
|
|
|
msg.query_auth_result = self.query_auth(msg.authorization_code) |
|
544
|
|
|
return msg |
|
545
|
|
|
|
|
546
|
10 |
|
def cache_component_verify_ticket(self, msg, signature, timestamp, nonce): |
|
547
|
|
|
""" |
|
548
|
|
|
处理 wechat server 推送的 component_verify_ticket消息 |
|
549
|
|
|
|
|
550
|
|
|
:params msg: 加密内容 |
|
551
|
|
|
:params signature: 消息签名 |
|
552
|
|
|
:params timestamp: 时间戳 |
|
553
|
|
|
:params nonce: 随机数 |
|
554
|
|
|
""" |
|
555
|
|
|
warnings.warn('`cache_component_verify_ticket` method of `WeChatComponent` is deprecated,' |
|
556
|
|
|
'Use `parse_message` instead', |
|
557
|
|
|
DeprecationWarning, stacklevel=2) |
|
558
|
|
|
content = self.crypto.decrypt_message(msg, signature, timestamp, nonce) |
|
559
|
|
|
message = xmltodict.parse(to_text(content))['xml'] |
|
560
|
|
|
o = ComponentVerifyTicketMessage(message) |
|
561
|
|
|
self.session.set(o.type, o.verify_ticket) |
|
562
|
|
|
|
|
563
|
10 |
|
def get_unauthorized(self, msg, signature, timestamp, nonce): |
|
564
|
|
|
""" |
|
565
|
|
|
处理取消授权通知 |
|
566
|
|
|
|
|
567
|
|
|
:params msg: 加密内容 |
|
568
|
|
|
:params signature: 消息签名 |
|
569
|
|
|
:params timestamp: 时间戳 |
|
570
|
|
|
:params nonce: 随机数 |
|
571
|
|
|
""" |
|
572
|
|
|
warnings.warn('`get_unauthorized` method of `WeChatComponent` is deprecated,' |
|
573
|
|
|
'Use `parse_message` instead', |
|
574
|
|
|
DeprecationWarning, stacklevel=2) |
|
575
|
|
|
content = self.crypto.decrypt_message(msg, signature, timestamp, nonce) |
|
576
|
|
|
message = xmltodict.parse(to_text(content))['xml'] |
|
577
|
|
|
return ComponentUnauthorizedMessage(message) |
|
578
|
|
|
|
|
579
|
10 |
|
def get_component_oauth(self, authorizer_appid): |
|
580
|
|
|
""" |
|
581
|
|
|
代公众号 OAuth 网页授权 |
|
582
|
|
|
|
|
583
|
|
|
:params authorizer_appid: 授权公众号appid |
|
584
|
|
|
""" |
|
585
|
|
|
return ComponentOAuth(authorizer_appid, component=self) |
|
586
|
|
|
|
|
587
|
|
|
|
|
588
|
10 |
|
class ComponentOAuth(object): |
|
589
|
|
|
""" 微信开放平台 代公众号 OAuth 网页授权 |
|
590
|
|
|
|
|
591
|
|
|
详情请参考 |
|
592
|
|
|
https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list&t=resource/res_list&verify=1&id=open1419318590 |
|
593
|
|
|
""" |
|
594
|
10 |
|
API_BASE_URL = 'https://api.weixin.qq.com/' |
|
595
|
10 |
|
OAUTH_BASE_URL = 'https://open.weixin.qq.com/connect/' |
|
596
|
|
|
|
|
597
|
10 |
|
def __init__(self, app_id, component_appid=None, component_access_token=None, |
|
598
|
|
|
redirect_uri=None, scope='snsapi_base', state='', component=None): |
|
599
|
|
|
""" |
|
600
|
|
|
|
|
601
|
|
|
:param app_id: 微信公众号 app_id |
|
602
|
|
|
:param component: WeChatComponent |
|
603
|
|
|
""" |
|
604
|
10 |
|
self._http = requests.Session() |
|
605
|
10 |
|
self.app_id = app_id |
|
606
|
10 |
|
self.component = component |
|
607
|
10 |
|
if self.component is None: |
|
608
|
10 |
|
warnings.warn('cannot found `component` param of `ComponentOAuth` `__init__` method,' |
|
609
|
|
|
'Use `WeChatComponent.get_component_oauth` instead', |
|
610
|
|
|
DeprecationWarning, stacklevel=2) |
|
611
|
|
|
|
|
612
|
10 |
|
self.component = ObjectDict({'component_appid': component_appid, 'access_token': component_access_token}) |
|
613
|
10 |
|
if redirect_uri is not None: |
|
614
|
10 |
|
warnings.warn('found `redirect_uri` param of `ComponentOAuth` `__init__` method,' |
|
615
|
|
|
'Use `ComponentOAuth.get_authorize_url` instead', |
|
616
|
|
|
DeprecationWarning, stacklevel=2) |
|
617
|
10 |
|
self.authorize_url = self.get_authorize_url(redirect_uri, scope, state) |
|
618
|
|
|
|
|
619
|
10 |
|
def get_authorize_url(self, redirect_uri, scope='snsapi_base', state=''): |
|
620
|
|
|
""" |
|
621
|
|
|
|
|
622
|
|
|
:param redirect_uri: 重定向地址,需要urlencode,这里填写的应是服务开发方的回调地址 |
|
623
|
|
|
:param scope: 可选,微信公众号 OAuth2 scope,默认为 ``snsapi_base`` |
|
624
|
|
|
:param state: 可选,重定向后会带上state参数,开发者可以填写任意参数值,最多128字节 |
|
625
|
|
|
""" |
|
626
|
10 |
|
redirect_uri = quote(redirect_uri, safe=b'') |
|
627
|
10 |
|
url_list = [ |
|
628
|
|
|
self.OAUTH_BASE_URL, |
|
629
|
|
|
'oauth2/authorize?appid=', |
|
630
|
|
|
self.app_id, |
|
631
|
|
|
'&redirect_uri=', |
|
632
|
|
|
redirect_uri, |
|
633
|
|
|
'&response_type=code&scope=', |
|
634
|
|
|
scope, |
|
635
|
|
|
] |
|
636
|
10 |
|
if state: |
|
637
|
|
|
url_list.extend(['&state=', state]) |
|
638
|
10 |
|
url_list.extend([ |
|
639
|
|
|
'&component_appid=', |
|
640
|
|
|
self.component.component_appid, |
|
641
|
|
|
]) |
|
642
|
10 |
|
url_list.append('#wechat_redirect') |
|
643
|
10 |
|
return ''.join(url_list) |
|
644
|
|
|
|
|
645
|
10 |
View Code Duplication |
def fetch_access_token(self, code): |
|
|
|
|
|
|
646
|
|
|
"""获取 access_token |
|
647
|
|
|
|
|
648
|
|
|
:param code: 授权完成跳转回来后 URL 中的 code 参数 |
|
649
|
|
|
:return: JSON 数据包 |
|
650
|
|
|
""" |
|
651
|
10 |
|
res = self._get( |
|
652
|
|
|
'sns/oauth2/component/access_token', |
|
653
|
|
|
params={ |
|
654
|
|
|
'appid': self.app_id, |
|
655
|
|
|
'component_appid': self.component.component_appid, |
|
656
|
|
|
'component_access_token': self.component.access_token, |
|
657
|
|
|
'code': code, |
|
658
|
|
|
'grant_type': 'authorization_code', |
|
659
|
|
|
} |
|
660
|
|
|
) |
|
661
|
10 |
|
self.access_token = res['access_token'] |
|
662
|
10 |
|
self.open_id = res['openid'] |
|
663
|
10 |
|
self.refresh_token = res['refresh_token'] |
|
664
|
10 |
|
self.expires_in = res['expires_in'] |
|
665
|
10 |
|
self.scope = res['scope'] |
|
666
|
10 |
|
return res |
|
667
|
|
|
|
|
668
|
10 |
View Code Duplication |
def refresh_access_token(self, refresh_token): |
|
|
|
|
|
|
669
|
|
|
"""刷新 access token |
|
670
|
|
|
|
|
671
|
|
|
:param refresh_token: OAuth2 refresh token |
|
672
|
|
|
:return: JSON 数据包 |
|
673
|
|
|
""" |
|
674
|
10 |
|
res = self._get( |
|
675
|
|
|
'sns/oauth2/component/refresh_token', |
|
676
|
|
|
params={ |
|
677
|
|
|
'appid': self.app_id, |
|
678
|
|
|
'grant_type': 'refresh_token', |
|
679
|
|
|
'refresh_token': refresh_token, |
|
680
|
|
|
'component_appid': self.component.component_appid, |
|
681
|
|
|
'component_access_token': self.component.access_token, |
|
682
|
|
|
} |
|
683
|
|
|
) |
|
684
|
10 |
|
self.access_token = res['access_token'] |
|
685
|
10 |
|
self.open_id = res['openid'] |
|
686
|
10 |
|
self.refresh_token = res['refresh_token'] |
|
687
|
10 |
|
self.expires_in = res['expires_in'] |
|
688
|
10 |
|
self.scope = res['scope'] |
|
689
|
10 |
|
return res |
|
690
|
|
|
|
|
691
|
10 |
|
def get_user_info(self, openid=None, access_token=None, lang='zh_CN'): |
|
692
|
|
|
""" 获取用户基本信息(需授权作用域为snsapi_userinfo) |
|
693
|
|
|
|
|
694
|
|
|
如果网页授权作用域为snsapi_userinfo,则此时开发者可以通过access_token和openid拉取用户信息了。 |
|
695
|
|
|
|
|
696
|
|
|
:param openid: 可选,微信 openid,默认获取当前授权用户信息 |
|
697
|
|
|
:param access_token: 可选,access_token,默认使用当前授权用户的 access_token |
|
698
|
|
|
:param lang: 可选,语言偏好, 默认为 ``zh_CN`` |
|
699
|
|
|
:return: JSON 数据包 |
|
700
|
|
|
""" |
|
701
|
10 |
|
openid = openid or self.open_id |
|
702
|
10 |
|
access_token = access_token or self.access_token |
|
703
|
10 |
|
return self._get( |
|
704
|
|
|
'sns/userinfo', |
|
705
|
|
|
params={ |
|
706
|
|
|
'access_token': access_token, |
|
707
|
|
|
'openid': openid, |
|
708
|
|
|
'lang': lang |
|
709
|
|
|
} |
|
710
|
|
|
) |
|
711
|
|
|
|
|
712
|
10 |
|
def _request(self, method, url_or_endpoint, **kwargs): |
|
713
|
10 |
|
if not url_or_endpoint.startswith(('http://', 'https://')): |
|
714
|
10 |
|
url = '{base}{endpoint}'.format( |
|
715
|
|
|
base=self.API_BASE_URL, |
|
716
|
|
|
endpoint=url_or_endpoint |
|
717
|
|
|
) |
|
718
|
|
|
else: |
|
719
|
|
|
url = url_or_endpoint |
|
720
|
|
|
|
|
721
|
10 |
|
if isinstance(kwargs.get('data', ''), dict): |
|
722
|
|
|
body = json.dumps(kwargs['data'], ensure_ascii=False) |
|
723
|
|
|
body = body.encode('utf-8') |
|
724
|
|
|
kwargs['data'] = body |
|
725
|
|
|
|
|
726
|
10 |
|
res = self._http.request( |
|
727
|
|
|
method=method, |
|
728
|
|
|
url=url, |
|
729
|
|
|
**kwargs |
|
730
|
|
|
) |
|
731
|
10 |
|
try: |
|
732
|
10 |
|
res.raise_for_status() |
|
733
|
10 |
|
except requests.RequestException as reqe: |
|
734
|
10 |
|
raise WeChatOAuthException( |
|
735
|
|
|
errcode=None, |
|
736
|
|
|
errmsg=None, |
|
737
|
|
|
client=self, |
|
738
|
|
|
request=reqe.request, |
|
739
|
|
|
response=reqe.response |
|
740
|
|
|
) |
|
741
|
|
|
|
|
742
|
10 |
|
return self._handle_result(res, method=method, url=url, **kwargs) |
|
743
|
|
|
|
|
744
|
10 |
View Code Duplication |
def _handle_result(self, res, method=None, url=None, **kwargs): |
|
|
|
|
|
|
745
|
10 |
|
result = json.loads(res.content.decode('utf-8', 'ignore'), strict=False) |
|
746
|
10 |
|
if 'errcode' in result: |
|
747
|
|
|
result['errcode'] = int(result['errcode']) |
|
748
|
|
|
|
|
749
|
10 |
|
if 'errcode' in result and result['errcode'] != 0: |
|
750
|
|
|
errcode = result['errcode'] |
|
751
|
|
|
errmsg = result.get('errmsg', errcode) |
|
752
|
|
|
if self.component.auto_retry and errcode in ( |
|
753
|
|
|
WeChatErrorCode.INVALID_CREDENTIAL.value, |
|
754
|
|
|
WeChatErrorCode.INVALID_ACCESS_TOKEN.value, |
|
755
|
|
|
WeChatErrorCode.EXPIRED_ACCESS_TOKEN.value): |
|
756
|
|
|
logger.info('Component access token expired, fetch a new one and retry request') |
|
757
|
|
|
self.component.fetch_access_token() |
|
758
|
|
|
kwargs['params']['component_access_token'] = self.component.access_token |
|
759
|
|
|
return self._request( |
|
760
|
|
|
method=method, |
|
761
|
|
|
url_or_endpoint=url, |
|
762
|
|
|
**kwargs |
|
763
|
|
|
) |
|
764
|
|
|
elif errcode == WeChatErrorCode.OUT_OF_API_FREQ_LIMIT.value: |
|
765
|
|
|
# api freq out of limit |
|
766
|
|
|
raise APILimitedException( |
|
767
|
|
|
errcode, |
|
768
|
|
|
errmsg, |
|
769
|
|
|
client=self, |
|
770
|
|
|
request=res.request, |
|
771
|
|
|
response=res |
|
772
|
|
|
) |
|
773
|
|
|
else: |
|
774
|
|
|
raise WeChatComponentOAuthException( |
|
775
|
|
|
errcode, |
|
776
|
|
|
errmsg, |
|
777
|
|
|
client=self, |
|
778
|
|
|
request=res.request, |
|
779
|
|
|
response=res |
|
780
|
|
|
) |
|
781
|
10 |
|
return result |
|
782
|
|
|
|
|
783
|
10 |
|
def _get(self, url, **kwargs): |
|
784
|
10 |
|
return self._request( |
|
785
|
|
|
method='get', |
|
786
|
|
|
url_or_endpoint=url, |
|
787
|
|
|
**kwargs |
|
788
|
|
|
) |
|
789
|
|
|
|