├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── example.py ├── flask_weixin.py ├── setup.cfg ├── setup.py └── test_weixin.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.egg 4 | *.egg-info 5 | __pycache__ 6 | bin 7 | build 8 | develop-eggs 9 | dist 10 | eggs 11 | parts 12 | .DS_Store 13 | .installed.cfg 14 | docs/_build 15 | cover/ 16 | .tox 17 | *.bak 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install flask 4 | 5 | python: 6 | - "2.6" 7 | - "2.7" 8 | - "3.3" 9 | - "pypy" 10 | 11 | script: 12 | - python setup.py -q nosetests 13 | 14 | after_success: 15 | - pip install coveralls 16 | - coverage run --source=flask_weixin setup.py -q nosetests 17 | - coveralls 18 | 19 | notifications: 20 | email: false 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 - 2015, Hsiaoming Yang 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | * Neither the name of the creator nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs 2 | 3 | lint: 4 | @flake8 5 | 6 | test: 7 | @nosetests -s 8 | 9 | coverage: 10 | @rm -f .coverage 11 | @nosetests --with-coverage --cover-package=flask_weixin --cover-html 12 | 13 | clean: clean-build clean-pyc clean-docs 14 | 15 | 16 | clean-build: 17 | @rm -fr build/ 18 | @rm -fr dist/ 19 | @rm -fr cover/ 20 | @rm -fr *.egg-info 21 | 22 | 23 | clean-pyc: 24 | @find . -name '*.pyc' -exec rm -f {} + 25 | @find . -name '*.pyo' -exec rm -f {} + 26 | @find . -name '*~' -exec rm -f {} + 27 | @find . -name '__pycache__' -exec rm -fr {} + 28 | 29 | clean-docs: 30 | @rm -fr docs/_build 31 | 32 | docs: 33 | @$(MAKE) -C docs html 34 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Flask-Weixin 2 | ============ 3 | 4 | Flask-Weixin is the implementation for http://mp.weixin.qq.com/ with the 5 | flavor of Flask. It can be used without Flask too. 6 | 7 | .. image:: https://img.shields.io/pypi/wheel/flask-weixin.svg?style=flat 8 | :target: https://pypi.python.org/pypi/Flask-Weixin/ 9 | :alt: Wheel Status 10 | .. image:: https://img.shields.io/pypi/v/flask-weixin.svg?style=flat 11 | :target: https://pypi.python.org/pypi/Flask-Weixin/ 12 | :alt: Latest Version 13 | .. image:: https://travis-ci.org/lepture/flask-weixin.svg?branch=master 14 | :target: https://travis-ci.org/lepture/flask-weixin 15 | :alt: Travis CI 16 | .. image:: https://coveralls.io/repos/lepture/flask-weixin/badge.svg?branch=master 17 | :target: https://coveralls.io/r/lepture/flask-weixin 18 | :alt: Coverage Status 19 | 20 | 21 | Installation 22 | ------------ 23 | 24 | You can install Flask-Weixin with pip:: 25 | 26 | $ pip install Flask-Weixin 27 | 28 | Or, with setuptools easy_install in case you didn't have pip:: 29 | 30 | $ easy_install Flask-Weixin 31 | 32 | 33 | Getting Started 34 | --------------- 35 | 36 | Eager to get started? It is always the Flask way to create a new instance:: 37 | 38 | from flask_weixin import Weixin 39 | 40 | weixin = Weixin(app) 41 | 42 | Or pass the ``app`` later:: 43 | 44 | weixin = Weixin() 45 | weixin.init_app(app) 46 | 47 | However, you need to configure before using it, here is the configuration 48 | list: 49 | 50 | * WEIXIN_TOKEN: this is required 51 | * WEIXIN_SENDER: a default sender, optional 52 | * WEIXIN_EXPIRES_IN: not expires by default 53 | 54 | For Flask user, it is suggested that you use the default view function:: 55 | 56 | app.add_url_rule('/', view_func=weixin.view_func) 57 | 58 | @weixin.register('*') 59 | def reply(**kwargs): 60 | username = kwargs.get('sender') 61 | sender = kwargs.get('receiver') 62 | content = kwargs.get('content') 63 | return weixin.reply( 64 | username, sender=sender, content=content 65 | ) 66 | 67 | The example above will reply anything the user sent. 68 | 69 | Or you can register a function to handle a specific keyword:: 70 | 71 | @weixin.register('help') 72 | def reply_help(**kwargs): 73 | ... 74 | 75 | this function will be used to handle text message ``help``. 76 | 77 | There are more ways to match messages to handlers:: 78 | 79 | @weixin.register(type='event', event='subscribe') 80 | def send_welcome(**kwargs): 81 | username = kwargs.get('sender') 82 | sender = kwargs.get('receiver') 83 | return weixin.reply(username, sender=sender, content='Thanks for follow!') 84 | 85 | this function will send a message to new followers. 86 | 87 | 88 | Message Types 89 | ------------- 90 | 91 | Every message from weixin has these information: 92 | 93 | * id: message ID 94 | * receiver: which is ``ToUserName`` in the official documentation 95 | * sender: which is ``FromUserName`` in the official documentation 96 | * type: message type 97 | * timestamp: message timestamp 98 | 99 | Text Type 100 | ~~~~~~~~~ 101 | 102 | Text type has an extra data: ``content``. 103 | 104 | 105 | Image Type 106 | ~~~~~~~~~~ 107 | 108 | Image type has an extra data: ``picurl``. 109 | 110 | 111 | Link Type 112 | ~~~~~~~~~ 113 | 114 | Link type has extra data: 115 | 116 | * title: article title 117 | * description: article description 118 | * url: original url of the article 119 | 120 | 121 | Location Type 122 | ~~~~~~~~~~~~~ 123 | 124 | Location type has extra data: 125 | 126 | * location_x 127 | * location_y 128 | * scale 129 | * label 130 | 131 | 132 | Event Type 133 | ~~~~~~~~~~ 134 | 135 | Event type has extra data: 136 | 137 | * event 138 | * event_key 139 | * latitude 140 | * longitude 141 | * precision 142 | 143 | Voice Type 144 | ~~~~~~~~~~ 145 | 146 | Event type has extra data: 147 | 148 | * media_id 149 | * format 150 | * recognition 151 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from flask import Flask 4 | from flask_weixin import Weixin 5 | 6 | app = Flask(__name__) 7 | app.secret_key = 'secret' 8 | app.config['WEIXIN_TOKEN'] = 'B0e8alq5ZmMjcnG5gwwLRPW2' 9 | 10 | weixin = Weixin(app) 11 | app.add_url_rule('/', view_func=weixin.view_func) 12 | 13 | 14 | jing_music = ( 15 | 'http://cc.cdn.jing.fm/201310171130/19e715ce8223efd159559c15de175ab6/' 16 | '2012/0428/11/AT/2012042811ATk.m4a' 17 | ) 18 | 19 | 20 | @weixin('*') 21 | def reply_all(**kwargs): 22 | username = kwargs.get('sender') 23 | sender = kwargs.get('receiver') 24 | message_type = kwargs.get('type') 25 | content = kwargs.get('content', message_type) 26 | 27 | if content == 'music': 28 | return weixin.reply( 29 | username, type='music', sender=sender, 30 | title='Weixin Music', 31 | description='weixin description', 32 | music_url=jing_music, 33 | hq_music_url=jing_music, 34 | ) 35 | elif content == 'news': 36 | return weixin.reply( 37 | username, type='news', sender=sender, 38 | articles=[ 39 | { 40 | 'title': 'Weixin News', 41 | 'description': 'weixin description', 42 | 'picurl': '', 43 | 'url': 'http://lepture.com/', 44 | } 45 | ] 46 | ) 47 | else: 48 | return weixin.reply( 49 | username, sender=sender, content=content 50 | ) 51 | 52 | 53 | if __name__ == '__main__': 54 | # you need a proxy to serve it on 80 55 | app.run() 56 | -------------------------------------------------------------------------------- /flask_weixin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | flask_weixin 4 | ~~~~~~~~~~~~ 5 | 6 | Weixin implementation in Flask. 7 | 8 | :copyright: (c) 2013 - 2015 by Hsiaoming Yang and its contributors. 9 | :license: BSD, see LICENSE for more detail. 10 | """ 11 | 12 | import time 13 | import hashlib 14 | from datetime import datetime 15 | from collections import namedtuple 16 | 17 | try: 18 | from lxml import etree 19 | except ImportError: 20 | from xml.etree import cElementTree as etree 21 | except ImportError: 22 | from xml.etree import ElementTree as etree 23 | 24 | try: 25 | from flask import current_app, request, Response 26 | except ImportError: 27 | current_app = None 28 | request = None 29 | Response = None 30 | 31 | 32 | __all__ = ('Weixin',) 33 | __version__ = '0.5.0' 34 | __author__ = 'Hsiaoming Yang ' 35 | 36 | 37 | StandaloneApplication = namedtuple('StandaloneApplication', ['config']) 38 | 39 | 40 | class Weixin(object): 41 | """Interface for mp.weixin.qq.com 42 | 43 | http://mp.weixin.qq.com/wiki/index.php 44 | """ 45 | 46 | def __init__(self, app=None): 47 | self._registry = {} 48 | self._registry_without_key = [] 49 | 50 | if isinstance(app, dict): 51 | # flask-weixin can be used without flask 52 | app = StandaloneApplication(config=app) 53 | 54 | if app is None: 55 | self.app = current_app 56 | else: 57 | self.init_app(app) 58 | self.app = app 59 | 60 | def init_app(self, app): 61 | app.config.setdefault('WEIXIN_TOKEN', None) 62 | app.config.setdefault('WEIXIN_SENDER', None) 63 | app.config.setdefault('WEIXIN_EXPIRES_IN', 0) 64 | 65 | @property 66 | def token(self): 67 | return self.app.config['WEIXIN_TOKEN'] 68 | 69 | @property 70 | def sender(self): 71 | return self.app.config['WEIXIN_SENDER'] 72 | 73 | @property 74 | def expires_in(self): 75 | return self.app.config['WEIXIN_EXPIRES_IN'] 76 | 77 | def validate(self, signature, timestamp, nonce): 78 | """Validate request signature. 79 | 80 | :param signature: A string signature parameter sent by weixin. 81 | :param timestamp: A int timestamp parameter sent by weixin. 82 | :param nonce: A int nonce parameter sent by weixin. 83 | """ 84 | if not self.token: 85 | raise RuntimeError('WEIXIN_TOKEN is missing') 86 | 87 | if self.expires_in: 88 | try: 89 | timestamp = int(timestamp) 90 | except (ValueError, TypeError): 91 | # fake timestamp 92 | return False 93 | 94 | delta = time.time() - timestamp 95 | if delta < 0: 96 | # this is a fake timestamp 97 | return False 98 | 99 | if delta > self.expires_in: 100 | # expired timestamp 101 | return False 102 | 103 | values = [self.token, str(timestamp), str(nonce)] 104 | s = ''.join(sorted(values)) 105 | hsh = hashlib.sha1(s.encode('utf-8')).hexdigest() 106 | return signature == hsh 107 | 108 | def parse(self, content): 109 | """Parse xml body sent by weixin. 110 | 111 | :param content: A text of xml body. 112 | """ 113 | raw = {} 114 | 115 | try: 116 | root = etree.fromstring(content) 117 | except SyntaxError as e: 118 | raise ValueError(*e.args) 119 | 120 | for child in root: 121 | raw[child.tag] = child.text 122 | 123 | formatted = self.format(raw) 124 | 125 | msg_type = formatted['type'] 126 | msg_parser = getattr(self, 'parse_%s' % msg_type, None) 127 | if callable(msg_parser): 128 | parsed = msg_parser(raw) 129 | else: 130 | parsed = self.parse_invalid_type(raw) 131 | 132 | formatted.update(parsed) 133 | return formatted 134 | 135 | def format(self, kwargs): 136 | timestamp = int(kwargs.get('CreateTime', 0)) 137 | return { 138 | 'id': kwargs.get('MsgId'), 139 | 'timestamp': timestamp, 140 | 'receiver': kwargs.get('ToUserName'), 141 | 'sender': kwargs.get('FromUserName'), 142 | 'type': kwargs.get('MsgType'), 143 | 'time': datetime.fromtimestamp(timestamp), 144 | } 145 | 146 | def parse_text(self, raw): 147 | return {'content': raw.get('Content')} 148 | 149 | def parse_image(self, raw): 150 | return {'picurl': raw.get('PicUrl')} 151 | 152 | def parse_location(self, raw): 153 | return { 154 | 'location_x': raw.get('Location_X'), 155 | 'location_y': raw.get('Location_Y'), 156 | 'scale': int(raw.get('Scale', 0)), 157 | 'label': raw.get('Label'), 158 | } 159 | 160 | def parse_link(self, raw): 161 | return { 162 | 'title': raw.get('Title'), 163 | 'description': raw.get('Description'), 164 | 'url': raw.get('url'), 165 | } 166 | 167 | def parse_event(self, raw): 168 | return { 169 | 'event': raw.get('Event'), 170 | 'event_key': raw.get('EventKey'), 171 | 'ticket': raw.get('Ticket'), 172 | 'latitude': raw.get('Latitude'), 173 | 'longitude': raw.get('Longitude'), 174 | 'precision': raw.get('Precision'), 175 | } 176 | 177 | def parse_voice(self, raw): 178 | return { 179 | 'media_id': raw.get('MediaID'), 180 | 'format': raw.get('Format'), 181 | 'recognition': raw.get('Recognition'), 182 | } 183 | 184 | def parse_invalid_type(self, raw): 185 | return {} 186 | 187 | def reply(self, username, type='text', sender=None, **kwargs): 188 | """Create the reply text for weixin. 189 | 190 | The reply varies per reply type. The acceptable types are `text`, 191 | `music`, `news`, `image`, `voice`, `video`. Each type accepts 192 | different parameters, but they share some common parameters: 193 | 194 | * username: the receiver's username 195 | * type: the reply type, aka text, music and news 196 | * sender: sender is optional if you have a default value 197 | 198 | Text reply requires an additional parameter of `content`. 199 | 200 | Music reply requires 4 more parameters: 201 | 202 | * title: A string for music title 203 | * description: A string for music description 204 | * music_url: A link of the music 205 | * hq_music_url: A link of the high quality music 206 | 207 | News reply requires an additional parameter of `articles`, which 208 | is a list/tuple of articles, each one is a dict: 209 | 210 | * title: A string for article title 211 | * description: A string for article description 212 | * picurl: A link for article cover image 213 | * url: A link for article url 214 | 215 | Image and Voice reply requires an additional parameter of `media_id`. 216 | 217 | Video reply requires 3 more parameters: 218 | 219 | * media_id: A string for video `media_id` 220 | * title: A string for video title 221 | * description: A string for video description 222 | """ 223 | sender = sender or self.sender 224 | if not sender: 225 | raise RuntimeError('WEIXIN_SENDER or sender argument is missing') 226 | 227 | if type == 'text': 228 | content = kwargs.get('content', '') 229 | return text_reply(username, sender, content) 230 | 231 | if type == 'music': 232 | values = {} 233 | for k in ('title', 'description', 'music_url', 'hq_music_url'): 234 | values[k] = kwargs.get(k) 235 | return music_reply(username, sender, **values) 236 | 237 | if type == 'news': 238 | items = kwargs.get('articles', []) 239 | return news_reply(username, sender, *items) 240 | 241 | if type == 'customer_service': 242 | service_account = kwargs.get('service_account', None) 243 | return transfer_customer_service_reply(username, sender, 244 | service_account) 245 | 246 | if type == 'image': 247 | media_id = kwargs.get('media_id', '') 248 | return image_reply(username, sender, media_id) 249 | 250 | if type == 'voice': 251 | media_id = kwargs.get('media_id', '') 252 | return voice_reply(username, sender, media_id) 253 | 254 | if type == 'video': 255 | values = {} 256 | for k in ('media_id', 'title', 'description'): 257 | values[k] = kwargs.get(k) 258 | return video_reply(username, sender, **values) 259 | 260 | def register(self, key=None, func=None, **kwargs): 261 | """Register a command helper function. 262 | 263 | You can register the function:: 264 | 265 | def print_help(**kwargs): 266 | username = kwargs.get('sender') 267 | sender = kwargs.get('receiver') 268 | return weixin.reply( 269 | username, sender=sender, content='text reply' 270 | ) 271 | 272 | weixin.register('help', print_help) 273 | 274 | It is also accessible as a decorator:: 275 | 276 | @weixin.register('help') 277 | def print_help(*args, **kwargs): 278 | username = kwargs.get('sender') 279 | sender = kwargs.get('receiver') 280 | return weixin.reply( 281 | username, sender=sender, content='text reply' 282 | ) 283 | """ 284 | if func: 285 | if key is None: 286 | limitation = frozenset(kwargs.items()) 287 | self._registry_without_key.append((func, limitation)) 288 | else: 289 | self._registry[key] = func 290 | return func 291 | 292 | return self.__call__(key, **kwargs) 293 | 294 | def __call__(self, key, **kwargs): 295 | """Register a reply function. 296 | 297 | Only available as a decorator:: 298 | 299 | @weixin('help') 300 | def print_help(*args, **kwargs): 301 | username = kwargs.get('sender') 302 | sender = kwargs.get('receiver') 303 | return weixin.reply( 304 | username, sender=sender, content='text reply' 305 | ) 306 | """ 307 | def wrapper(func): 308 | self.register(key, func=func, **kwargs) 309 | return func 310 | 311 | return wrapper 312 | 313 | def view_func(self): 314 | """Default view function for Flask app. 315 | 316 | This is a simple implementation for view func, you can add it to 317 | your Flask app:: 318 | 319 | weixin = Weixin(app) 320 | app.add_url_rule('/', view_func=weixin.view_func) 321 | """ 322 | if request is None: 323 | raise RuntimeError('view_func need Flask be installed') 324 | 325 | signature = request.args.get('signature') 326 | timestamp = request.args.get('timestamp') 327 | nonce = request.args.get('nonce') 328 | if not self.validate(signature, timestamp, nonce): 329 | return 'signature failed', 400 330 | 331 | if request.method == 'GET': 332 | echostr = request.args.get('echostr', '') 333 | return echostr 334 | 335 | try: 336 | ret = self.parse(request.data) 337 | except ValueError: 338 | return 'invalid', 400 339 | 340 | if 'type' not in ret: 341 | # not a valid message 342 | return 'invalid', 400 343 | 344 | if ret['type'] == 'text' and ret['content'] in self._registry: 345 | func = self._registry[ret['content']] 346 | else: 347 | ret_set = frozenset(ret.items()) 348 | matched_rules = ( 349 | _func for _func, _limitation in self._registry_without_key 350 | if _limitation.issubset(ret_set)) 351 | func = next(matched_rules, None) # first matched rule 352 | 353 | if func is None: 354 | if '*' in self._registry: 355 | func = self._registry['*'] 356 | else: 357 | func = 'failed' 358 | 359 | if callable(func): 360 | text = func(**ret) 361 | else: 362 | # plain text 363 | text = self.reply( 364 | username=ret['sender'], 365 | sender=ret['receiver'], 366 | content=func, 367 | ) 368 | 369 | return Response(text, content_type='text/xml; charset=utf-8') 370 | 371 | view_func.methods = ['GET', 'POST'] 372 | 373 | 374 | def text_reply(username, sender, content): 375 | shared = _shared_reply(username, sender, 'text') 376 | template = '%s' 377 | return template % (shared, content) 378 | 379 | 380 | def music_reply(username, sender, **kwargs): 381 | kwargs['shared'] = _shared_reply(username, sender, 'music') 382 | 383 | template = ( 384 | '' 385 | '%(shared)s' 386 | '' 387 | '<![CDATA[%(title)s]]>' 388 | '' 389 | '' 390 | '' 391 | '' 392 | '' 393 | ) 394 | return template % kwargs 395 | 396 | 397 | def news_reply(username, sender, *items): 398 | item_template = ( 399 | '' 400 | '<![CDATA[%(title)s]]>' 401 | '' 402 | '' 403 | '' 404 | '' 405 | ) 406 | articles = [item_template % o for o in items] 407 | 408 | template = ( 409 | '' 410 | '%(shared)s' 411 | '%(count)d' 412 | '%(articles)s' 413 | '' 414 | ) 415 | dct = { 416 | 'shared': _shared_reply(username, sender, 'news'), 417 | 'count': len(items), 418 | 'articles': ''.join(articles) 419 | } 420 | return template % dct 421 | 422 | 423 | def transfer_customer_service_reply(username, sender, service_account): 424 | template = ( 425 | '%(shared)s' 426 | '%(transfer_info)s') 427 | transfer_info = '' 428 | if service_account: 429 | transfer_info = ( 430 | '' 431 | '![CDATA[%s]]' 432 | '') % service_account 433 | 434 | dct = { 435 | 'shared': _shared_reply(username, sender, 436 | type='transfer_customer_service'), 437 | 'transfer_info': transfer_info 438 | } 439 | return template % dct 440 | 441 | 442 | def image_reply(username, sender, media_id): 443 | shared = _shared_reply(username, sender, 'image') 444 | template = '%s' 445 | return template % (shared, media_id) 446 | 447 | 448 | def voice_reply(username, sender, media_id): 449 | shared = _shared_reply(username, sender, 'voice') 450 | template = '%s' 451 | return template % (shared, media_id) 452 | 453 | 454 | def video_reply(username, sender, **kwargs): 455 | kwargs['shared'] = _shared_reply(username, sender, 'video') 456 | 457 | template = ( 458 | '' 459 | '%(shared)s' 460 | '' 465 | '' 466 | ) 467 | return template % kwargs 468 | 469 | 470 | def _shared_reply(username, sender, type): 471 | dct = { 472 | 'username': username, 473 | 'sender': sender, 474 | 'type': type, 475 | 'timestamp': int(time.time()), 476 | } 477 | template = ( 478 | '' 479 | '' 480 | '%(timestamp)d' 481 | '' 482 | ) 483 | return template % dct 484 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | 5 | try: 6 | # python setup.py test 7 | import multiprocessing 8 | except ImportError: 9 | pass 10 | 11 | import os 12 | import re 13 | 14 | from setuptools import setup 15 | 16 | 17 | def fread(fname): 18 | filepath = os.path.join(os.path.dirname(__file__), fname) 19 | with open(filepath) as f: 20 | return f.read() 21 | 22 | 23 | content = fread('flask_weixin.py') 24 | m = re.findall(r'__version__\s*=\s*\'(.*)\'', content) 25 | version = m[0] 26 | 27 | 28 | setup( 29 | name='Flask-Weixin', 30 | version=version, 31 | url='https://github.com/lepture/flask-weixin', 32 | author='Hsiaoming Yang', 33 | author_email='me@lepture.com', 34 | description='Weixin for Flask.', 35 | long_description=fread('README.rst'), 36 | license='BSD', 37 | py_modules=['flask_weixin'], 38 | zip_safe=False, 39 | platforms='any', 40 | tests_require=['nose', 'Flask'], 41 | test_suite='nose.collector', 42 | classifiers=[ 43 | 'Development Status :: 4 - Beta', 44 | 'Environment :: Web Environment', 45 | 'Intended Audience :: Developers', 46 | 'License :: OSI Approved :: BSD License', 47 | 'Operating System :: OS Independent', 48 | 'Programming Language :: Python', 49 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 50 | 'Topic :: Software Development :: Libraries :: Python Modules' 51 | ] 52 | ) 53 | -------------------------------------------------------------------------------- /test_weixin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from flask import Flask 4 | from flask_weixin import Weixin 5 | from nose.tools import raises 6 | 7 | 8 | class Base(object): 9 | def setUp(self): 10 | app = self.create_app() 11 | self.client = app.test_client() 12 | 13 | weixin = Weixin(app) 14 | app.add_url_rule('/', view_func=weixin.view_func) 15 | 16 | self.weixin = weixin 17 | self.app = app 18 | 19 | self.setup_weixin() 20 | 21 | def create_app(self): 22 | app = Flask(__name__) 23 | app.debug = True 24 | app.secret_key = 'secret' 25 | app.config['WEIXIN_TOKEN'] = 'B0e8alq5ZmMjcnG5gwwLRPW2' 26 | return app 27 | 28 | def setup_weixin(self): 29 | pass 30 | 31 | 32 | signature_url = ( 33 | '/?signature=16f39f0c528790d3a448a8a7a65cc81ceddd82bb&' 34 | 'echostr=5935258128547730623&' 35 | 'timestamp=1381389497&' 36 | 'nonce=1381909961' 37 | ) 38 | 39 | 40 | class TestNoToken(Base): 41 | def create_app(self): 42 | app = Flask(__name__) 43 | app.debug = True 44 | app.secret_key = 'secret' 45 | return app 46 | 47 | @raises(RuntimeError) 48 | def test_validate(self): 49 | self.client.get(signature_url) 50 | 51 | 52 | class TestSimpleWeixin(Base): 53 | def test_invalid_get(self): 54 | rv = self.client.get('/') 55 | assert rv.status_code == 400 56 | 57 | def test_valid_get(self): 58 | rv = self.client.get(signature_url) 59 | assert rv.status_code == 200 60 | assert rv.data == b'5935258128547730623' 61 | 62 | def test_invalid_post(self): 63 | rv = self.client.post(signature_url) 64 | assert rv.status_code == 400 65 | 66 | def test_post_text(self): 67 | ''' 68 | 69 | 70 | 71 | 1348831860 72 | 73 | 74 | 1234567890123456 75 | 76 | ''' 77 | text = self.test_post_text.__doc__ 78 | rv = self.client.post(signature_url, data=text) 79 | assert rv.status_code == 200 80 | 81 | def test_post_image(self): 82 | ''' 83 | 84 | 85 | 86 | 1348831860 87 | 88 | 89 | 1234567890123456 90 | 91 | ''' 92 | text = self.test_post_image.__doc__ 93 | rv = self.client.post(signature_url, data=text) 94 | assert rv.status_code == 200 95 | 96 | def test_post_location(self): 97 | ''' 98 | 99 | 100 | 101 | 1351776360 102 | 103 | 23.134521 104 | 113.358803 105 | 20 106 | 107 | 1234567890123456 108 | 109 | ''' 110 | text = self.test_post_location.__doc__ 111 | rv = self.client.post(signature_url, data=text) 112 | assert rv.status_code == 200 113 | 114 | def test_post_link(self): 115 | ''' 116 | 117 | 118 | 119 | 1351776360 120 | 121 | <![CDATA[title]]> 122 | 123 | 124 | 1234567890123456 125 | 126 | ''' 127 | text = self.test_post_link.__doc__ 128 | rv = self.client.post(signature_url, data=text) 129 | assert rv.status_code == 200 130 | 131 | def test_post_event(self): 132 | ''' 133 | 134 | 135 | 136 | 1351776360 137 | 138 | 139 | 1234567890123456 140 | 141 | ''' 142 | text = self.test_post_event.__doc__ 143 | rv = self.client.post(signature_url, data=text) 144 | assert rv.status_code == 200 145 | 146 | def test_post_voice(self): 147 | ''' 148 | 149 | 150 | 151 | 1357290913 152 | 153 | 154 | 155 | 156 | 1234567890123456 157 | 158 | ''' 159 | text = self.test_post_event.__doc__ 160 | rv = self.client.post(signature_url, data=text) 161 | assert rv.status_code == 200 162 | 163 | def test_post_no_type(self): 164 | ''' 165 | 166 | 167 | 168 | 1351776360 169 | <![CDATA[title]]> 170 | 171 | 172 | 1234567890123456 173 | 174 | ''' 175 | text = self.test_post_no_type.__doc__ 176 | rv = self.client.post(signature_url, data=text) 177 | assert rv.status_code == 200 178 | 179 | 180 | class TestExipires(Base): 181 | def create_app(self): 182 | app = Base.create_app(self) 183 | app.config['WEIXIN_EXPIRES_IN'] = 4 184 | return app 185 | 186 | def test_expires(self): 187 | rv = self.client.get(signature_url) 188 | assert rv.status_code == 400 189 | 190 | def test_invalid_timestamp(self): 191 | signature_url = ( 192 | '/?signature=16f39f0c528790d3a448a8a7a65cc81ceddd82bb&' 193 | 'echostr=5935258128547730623&' 194 | 'timestamp=1381389497s&' 195 | 'nonce=1381909961' 196 | ) 197 | rv = self.client.get(signature_url) 198 | assert rv.status_code == 400 199 | 200 | 201 | class TestReplyWeixin(Base): 202 | ''' 203 | 204 | 205 | 206 | 1348831860 207 | 208 | 209 | 1234567890123456 210 | 211 | ''' 212 | 213 | def setup_weixin(self): 214 | weixin = self.weixin 215 | 216 | def print_all(**kwargs): 217 | username = kwargs.get('sender') 218 | sender = kwargs.get('receiver') 219 | content = kwargs.get('content') 220 | if not content: 221 | content = 'text' 222 | if content == 'music': 223 | return weixin.reply( 224 | username, type='music', sender=sender, 225 | title='weixin music', 226 | description='weixin description', 227 | music_url='link', 228 | hq_music_url='hq link', 229 | ) 230 | elif content == 'news': 231 | return weixin.reply( 232 | username, type='news', sender=sender, 233 | articles=[ 234 | { 235 | 'title': 'Hello News', 236 | 'description': 'Hello Description', 237 | 'picurl': '', 238 | 'url': 'link', 239 | } 240 | ] 241 | ) 242 | elif content == 'customer_service': 243 | return weixin.reply( 244 | username, type='customer_service', sender=sender) 245 | 246 | elif content == 'customer_service_to_foo': 247 | return weixin.reply( 248 | username, type='customer_service', sender=sender, 249 | service_account='foo@bar') 250 | elif content == 'image': 251 | return weixin.reply( 252 | username, type='image', sender=sender, 253 | media_id='weixin_image' 254 | ) 255 | elif content == 'voice': 256 | return weixin.reply( 257 | username, type='voice', sender=sender, 258 | media_id='weixin_voice' 259 | ) 260 | elif content == 'video': 261 | return weixin.reply( 262 | username, type='video', sender=sender, 263 | media_id='weixin_video', title='Hello Video', 264 | description='Hello Video Description' 265 | ) 266 | else: 267 | return weixin.reply( 268 | username, sender=sender, content='text reply' 269 | ) 270 | 271 | weixin.register('*', print_all) 272 | weixin.register('help', 'help me') 273 | 274 | @weixin.register('show') 275 | def print_show(*args, **kwargs): 276 | username = kwargs.get('sender') 277 | sender = kwargs.get('receiver') 278 | return weixin.reply( 279 | username, sender=sender, content='show reply' 280 | ) 281 | 282 | def test_help(self): 283 | text = self.__doc__ % 'help' 284 | rv = self.client.post(signature_url, data=text) 285 | assert b'help me' in rv.data 286 | 287 | def test_news(self): 288 | text = self.__doc__ % 'news' 289 | rv = self.client.post(signature_url, data=text) 290 | assert b'Hello News' in rv.data 291 | 292 | def test_music(self): 293 | text = self.__doc__ % 'music' 294 | rv = self.client.post(signature_url, data=text) 295 | assert b'weixin music' in rv.data 296 | 297 | def test_show(self): 298 | text = self.__doc__ % 'show' 299 | rv = self.client.post(signature_url, data=text) 300 | assert b'show reply' in rv.data 301 | 302 | def test_customer_service(self): 303 | text = self.__doc__ % 'customer_service' 304 | rv = self.client.post(signature_url, data=text) 305 | assert b'transfer_customer_service' in rv.data 306 | 307 | def test_customer_service_to_foo(self): 308 | text = self.__doc__ % 'customer_service_to_foo' 309 | rv = self.client.post(signature_url, data=text) 310 | assert b'transfer_customer_service' in rv.data 311 | assert b'foo@bar' in rv.data 312 | 313 | def test_image(self): 314 | text = self.__doc__ % 'image' 315 | rv = self.client.post(signature_url, data=text) 316 | assert b'weixin_image' in rv.data 317 | 318 | def test_voice(self): 319 | text = self.__doc__ % 'voice' 320 | rv = self.client.post(signature_url, data=text) 321 | assert b'weixin_voice' in rv.data 322 | 323 | def test_video(self): 324 | text = self.__doc__ % 'video' 325 | rv = self.client.post(signature_url, data=text) 326 | assert b'weixin_video' in rv.data 327 | assert b'Hello Video' in rv.data 328 | assert b'Hello Video Description' in rv.data 329 | 330 | @raises(RuntimeError) 331 | def test_no_sender(self): 332 | @self.weixin.register('send') 333 | def print_send(*args, **kwargs): 334 | username = kwargs.get('sender') 335 | return self.weixin.reply( 336 | username, sender=None, content='send reply' 337 | ) 338 | 339 | text = self.__doc__ % 'send' 340 | self.client.post(signature_url, data=text) 341 | 342 | 343 | class TestKeyMatching(Base): 344 | 345 | def setup_weixin(self): 346 | @self.weixin.register(type='event', event='subscribe') 347 | def subscribe_event(sender, receiver, event_key, **kwargs): 348 | return self.weixin.reply( 349 | sender, sender=receiver, content='@sub:%s' % event_key) 350 | 351 | @self.weixin.register(type='event') 352 | def event(sender, receiver, **kwargs): 353 | return self.weixin.reply(sender, sender=receiver, content='@event') 354 | 355 | @self.weixin.register(type='link') 356 | def link(sender, receiver, **kwargs): 357 | return self.weixin.reply(sender, sender=receiver, content='@link') 358 | 359 | @self.weixin.register(type='voice') 360 | def voice(sender, receiver, **kwargs): 361 | return self.weixin.reply(sender, sender=receiver, content='@voice') 362 | 363 | @self.weixin.register('*') 364 | def fallback(sender, receiver, **kwargs): 365 | return self.weixin.reply(sender, sender=receiver, content='@*') 366 | 367 | def test_subscribe_event(self): 368 | data = ''' 369 | 370 | 371 | 1348831860 372 | 373 | 374 | 375 | 376 | 377 | ''' 378 | rv = self.client.post(signature_url, data=data) 379 | assert rv.status_code == 200, rv.status_code 380 | assert b'@sub:qrscene_123123' in rv.data 381 | 382 | def test_other_event(self): 383 | data = ''' 384 | 385 | 386 | 1348831860 387 | 388 | 389 | 390 | 391 | 392 | ''' 393 | rv = self.client.post(signature_url, data=data) 394 | assert rv.status_code == 200, rv.status_code 395 | assert b'@event' in rv.data 396 | assert b'qrscene_123123' not in rv.data 397 | 398 | def test_link(self): 399 | data = ''' 400 | 401 | 402 | 403 | 1351776360 404 | 405 | <![CDATA[title]]> 406 | 407 | 408 | 1234567890123456 409 | 410 | ''' 411 | rv = self.client.post(signature_url, data=data) 412 | assert rv.status_code == 200, rv.status_code 413 | assert b'@link' in rv.data 414 | 415 | def test_voice(self): 416 | data = ''' 417 | 418 | 419 | 420 | 1357290913 421 | 422 | 423 | 424 | 425 | 1234567890123456 426 | 427 | ''' 428 | rv = self.client.post(signature_url, data=data) 429 | assert rv.status_code == 200, rv.status_code 430 | assert b'@voice' in rv.data 431 | 432 | def test_fallback(self): 433 | data = ''' 434 | 435 | 436 | 437 | 1351776360 438 | 439 | 23.134521 440 | 113.358803 441 | 20 442 | 443 | 1234567890123456 444 | 445 | ''' 446 | rv = self.client.post(signature_url, data=data) 447 | assert rv.status_code == 200, rv.status_code 448 | assert b'@*' in rv.data 449 | --------------------------------------------------------------------------------