├── .codeclimate.yml ├── .coveragerc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── Bug_report.md │ └── Feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── issue-close-app.yml └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── .pipignore ├── .scrutinizer.yml ├── .style.yapf ├── .vscode └── settings.json ├── LICENSE ├── MANIFEST.in ├── README.rst ├── artwork └── logo.svg ├── bors.toml ├── dev-requirements.txt ├── docs-requirements.txt ├── docs ├── .gitignore ├── Makefile ├── _static │ ├── logo.png │ └── qq.png ├── _themes │ ├── LICENSE │ ├── README.rst │ ├── flask │ │ ├── layout.html │ │ ├── relations.html │ │ ├── sidebarintro.html │ │ ├── static │ │ │ └── flasky.css_t │ │ └── theme.conf │ └── flask_theme_support.py ├── api.rst ├── changelog.rst ├── client.rst ├── conf.py ├── config.rst ├── contrib.rst ├── contribution-guide.rst ├── deploy.rst ├── encryption.rst ├── error-page.rst ├── events.rst ├── handlers.rst ├── index.rst ├── make.bat ├── messages.rst ├── replies.rst ├── session.rst ├── start.rst └── utils.rst ├── example └── hello_world.py ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── client_config.py ├── django_test_env │ ├── django_test │ │ ├── __init__.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ └── manage.py ├── messages │ └── test_entries.py ├── test_client.py ├── test_config.py ├── test_contrib.py ├── test_crypto.py ├── test_handler.py ├── test_logger.py ├── test_parser.py ├── test_replies.py ├── test_robot.py ├── test_session.py └── test_utils.py ├── tox-requirements.txt ├── tox.ini └── werobot ├── __init__.py ├── client.py ├── config.py ├── contrib ├── __init__.py ├── bottle.py ├── django.py ├── error.html ├── flask.py └── tornado.py ├── crypto ├── __init__.py ├── exceptions.py └── pkcs7.py ├── exceptions.py ├── logger.py ├── messages ├── __init__.py ├── base.py ├── entries.py ├── events.py └── messages.py ├── parser.py ├── pay.py ├── replies.py ├── robot.py ├── session ├── __init__.py ├── filestorage.py ├── mongodbstorage.py ├── mysqlstorage.py ├── postgresqlstorage.py ├── redisstorage.py ├── saekvstorage.py └── sqlitestorage.py ├── testing.py └── utils.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | duplication: 3 | enabled: true 4 | config: 5 | languages: 6 | - python 7 | fixme: 8 | enabled: true 9 | markdownlint: 10 | enabled: true 11 | pep8: 12 | enabled: true 13 | ratings: 14 | paths: 15 | - "**.py" 16 | exclude_paths: 17 | - docs/ 18 | - travis/ 19 | - tests/ -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = werobot 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | 8 | def __repr__ 9 | 10 | raise AssertionError 11 | raise NotImplementedError 12 | 13 | if 0: 14 | if __name__ == .__main__.: 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 4 5 | indent_style = space 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.{yml,ini}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report / Bug 反馈 3 | about: 反馈 WeRoBot 的 bug 4 | 5 | --- 6 | 7 | <!-- 8 | 请注意:不规范的问题会被部署的 issue bot 自动关闭。 9 | Issue tracker 只用于反馈 bug 和 feature request。 10 | 与 WeRoBot 用法相关的问题请加入主页上的 QQ 群讨论或在 SegmentFault 社区提问。 11 | 在提问前请仔细阅读我们的文档,并推荐阅读《提问的智慧》: 12 | https://github.com/ruby-china/How-To-Ask-Questions-The-Smart-Way/blob/master/README-zh_CN.md 13 | --> 14 | * **对 Bug 的描述** 15 | * 当前行为: 16 | * 正确的行为: 17 | 18 | * **环境** 19 | * 平台: 20 | * WeRoBot 版本号: 21 | * Python 版本: 22 | 23 | * **复现代码或 repo 链接** 24 | 25 | ```python 26 | from werobot import WeRoBot 27 | 28 | # 请在这里给出 bug 的复现代码。如有必要,可以创建一个复现 repo 并将链接粘贴到这里。 29 | ``` 30 | 31 | * **复现步骤** 32 | 33 | * **其他信息** 34 | <!-- 如对 bug 修复的建议、相关 issue 或 PR 的引用等信息 --> 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: 为 WeRoBot 的功能提出建议 4 | 5 | --- 6 | 7 | <!-- 8 | Issue tracker 只用于反馈 bug 和 feature request 。 9 | 与 WeRoBot 用法相关的问题请加入主页上的 QQ 群讨论或在 SegmentFault 社区提问。 10 | 在提问前请仔细阅读我们的文档,并推荐阅读《提问的智慧》: 11 | https://github.com/ruby-china/How-To-Ask-Questions-The-Smart-Way/blob/master/README-zh_CN.md 12 | --> 13 | 14 | * **遇到的问题** 15 | <!-- feature 总是要解决问题,来讲讲遇到的问题吧 --> 16 | 17 | * **解决方案** 18 | <!-- 描述为了解决这个问题需要 WeRoBot 添加的 feature --> 19 | 20 | * **其他内容** 21 | <!-- 如相关 issue 或 PR 的引用等信息 --> 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | <!-- 2 | 在发布 Pull Request 之前,请花一点时间读一下我们的贡献指南: 3 | https://werobot.readthedocs.io/zh_CN/master/contribution-guide.html 4 | --> 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "07:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: multipart 11 | versions: 12 | - "> 0.1" 13 | - dependency-name: redis 14 | versions: 15 | - "< 2.10.7, >= 2.10.6.a" 16 | - dependency-name: yapf 17 | versions: 18 | - ">= 0.26.a, < 0.27" 19 | - dependency-name: responses 20 | versions: 21 | - 0.13.1 22 | - dependency-name: sphinx 23 | versions: 24 | - 3.5.0 25 | - 3.5.1 26 | - 3.5.2 27 | - 3.5.3 28 | - dependency-name: tox 29 | versions: 30 | - 3.21.3 31 | - 3.21.4 32 | - 3.22.0 33 | - dependency-name: cryptography 34 | versions: 35 | - 3.4.1 36 | - dependency-name: coverage 37 | versions: 38 | - "5.4" 39 | -------------------------------------------------------------------------------- /.github/issue-close-app.yml: -------------------------------------------------------------------------------- 1 | comment: "> Issue tracker 只用于反馈 bug 和 feature request。在提问前请仔细阅读我们的文档,并推荐阅读《提问的智慧》:https://github.com/ruby-china/How-To-Ask-Questions-The-Smart-Way/blob/master/README-zh_CN.md" 2 | issueConfigs: 3 | - content: 4 | - 对 Bug 的描述 5 | - 环境 6 | - 复现代码或 repo 链接 7 | - 复现步骤 8 | - 其他信息 9 | - content: 10 | - 遇到的问题 11 | - 解决方案 12 | - 其他内容 -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | python-version: [3.6, 3.7, 3.8, 3.9] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - uses: actions/cache@v1 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ matrix.os }}-pip-${{ matrix.python-version }} 22 | restore-keys: | 23 | ${{ matrix.os }}-pip- 24 | - uses: actions/cache@v1 25 | with: 26 | path: .tox 27 | key: ${{ matrix.os }}-tox-${{ matrix.python-version }}-${{ hashFiles('**/tox.ini') }} 28 | restore-keys: | 29 | ${{ matrix.os }}-tox-${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip wheel 33 | pip install -r dev-requirements.txt 34 | - name: Test with tox 35 | run: | 36 | tox -s 37 | env: 38 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 39 | lint: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v2 43 | - name: Set up Python 44 | uses: actions/setup-python@v1 45 | - uses: actions/cache@v1 46 | with: 47 | path: ~/.cache/pip 48 | key: ${{ runner.os }}-pip-lint-${{ hashFiles('**/*requirements.txt') }} 49 | restore-keys: | 50 | ${{ runner.os }}-pip- 51 | - name: Install dependencies 52 | run: | 53 | python -m pip install --upgrade pip wheel 54 | cat dev-requirements.txt | grep yapf== | xargs pip install 55 | - name: Run yapf 56 | run: | 57 | yapf -p -r -d docs/ werobot/ tests/ *.py 58 | docs: 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v2 62 | - name: Set up Python 63 | uses: actions/setup-python@v1 64 | - uses: actions/cache@v1 65 | with: 66 | path: ~/.cache/pip 67 | key: ${{ runner.os }}-pip-docs-${{ hashFiles('**/*requirements.txt') }} 68 | restore-keys: | 69 | ${{ runner.os }}-pip- 70 | - name: Install dependencies 71 | run: | 72 | python -m pip install --upgrade pip wheel 73 | pip install -r docs-requirements.txt 74 | - name: Build docs 75 | run: | 76 | sphinx-build -W docs docs/_build/html 77 | - uses: actions/upload-artifact@v1 78 | with: 79 | name: docs 80 | path: docs/_build/html 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .python-version 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | .eggs/ 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | .cache/ 31 | cover/ 32 | htmlcov/ 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Emacs 38 | .#* 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | .DS_Store 46 | .idea 47 | 48 | #Sphinx 49 | docs/_ 50 | 51 | #VirtualEnv 52 | .Python 53 | include/ 54 | Scripts/ 55 | pyvenv.cfg 56 | 57 | werobot_session.db 58 | 59 | .coveralls.yml 60 | 61 | werobot_session* 62 | pip-selfcheck.json 63 | venv 64 | 65 | *.sqlite3 66 | .pytest_cache 67 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/offu/WeRoBot/7b3d62d7ae126fc8bc9e033e6358dec2852d1d5a/.gitmodules -------------------------------------------------------------------------------- /.pipignore: -------------------------------------------------------------------------------- 1 | WeRoBot 2 | distribute -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | checks: 2 | python: 3 | code_rating: true 4 | duplicate_code: true 5 | filter: 6 | excluded_paths: 7 | - tests/* 8 | - travis/* 9 | - example/* -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = pep8 3 | align_closing_bracket_with_visual_indent = true 4 | allow_multiline_dictionary_keys = false 5 | allow_split_before_dict_value = false 6 | dedent_closing_brackets = true 7 | each_dict_entry_on_separate_line = true 8 | coalesce_brackets = false 9 | spaces_before_comment = 2 10 | split_before_logical_operator = true 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.flake8Enabled": false, 3 | "python.formatting.provider": "yapf", 4 | "python.linting.pylintEnabled": false, 5 | "python.testing.pytestEnabled": true, 6 | "python.pythonPath": "venv/bin/python3", 7 | "restructuredtext.confPath": "${workspaceFolder}/docs" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 whtsky 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include README.rst 3 | include LICENSE 4 | include werobot/contrib/error.html 5 | recursive-include tests * 6 | recursive-exclude tests *.pyc 7 | recursive-exclude tests *.pyo 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | WeRoBot 3 | ==================================== 4 | 5 | .. image:: https://github.com/offu/werobot/workflows/tests/badge.svg 6 | :target: https://github.com/offu/werobot/actions 7 | .. image:: https://codecov.io/gh/offu/WeRoBot/branch/master/graph/badge.svg 8 | :target: https://codecov.io/gh/offu/WeRoBot 9 | .. image:: https://img.shields.io/badge/QQ%20Group-283206829-brightgreen.svg?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTc5MiIgaGVpZ2h0PSIxNzkyIiB2aWV3Qm94PSIwIDAgMTc5MiAxNzkyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0yNzAgODA2cS04LTE5LTgtNTIgMC0yMCAxMS00OXQyNC00NXEtMS0yMiA3LjUtNTN0MjIuNS00M3EwLTEzOSA5Mi41LTI4OC41dDIxNy41LTIwOS41cTEzOS02NiAzMjQtNjYgMTMzIDAgMjY2IDU1IDQ5IDIxIDkwIDQ4dDcxIDU2IDU1IDY4IDQyIDc0IDMyLjUgODQuNSAyNS41IDg5LjUgMjIgOThsMSA1cTU1IDgzIDU1IDE1MCAwIDE0LTkgNDB0LTkgMzhxMCAxIDEuNSAzLjV0My41IDUgMiAzLjVxNzcgMTE0IDEyMC41IDIxNC41dDQzLjUgMjA4LjVxMCA0My0xOS41IDEwMHQtNTUuNSA1N3EtOSAwLTE5LjUtNy41dC0xOS0xNy41LTE5LTI2LTE2LTI2LjUtMTMuNS0yNi05LTE3LjVxLTEtMS0zLTFsLTUgNHEtNTkgMTU0LTEzMiAyMjMgMjAgMjAgNjEuNSAzOC41dDY5IDQxLjUgMzUuNSA2NXEtMiA0LTQgMTZ0LTcgMThxLTY0IDk3LTMwMiA5Ny01MyAwLTExMC41LTl0LTk4LTIwLTEwNC41LTMwcS0xNS01LTIzLTctMTQtNC00Ni00LjV0LTQwLTEuNXEtNDEgNDUtMTI3LjUgNjV0LTE2OC41IDIwcS0zNSAwLTY5LTEuNXQtOTMtOS0xMDEtMjAuNS03NC41LTQwLTMyLjUtNjRxMC00MCAxMC01OS41dDQxLTQ4LjVxMTEtMiA0MC41LTEzdDQ5LjUtMTJxNCAwIDE0LTIgMi0yIDItNGwtMi0zcS00OC0xMS0xMDgtMTA1LjV0LTczLTE1Ni41bC01LTNxLTQgMC0xMiAyMC0xOCA0MS01NC41IDc0LjV0LTc3LjUgMzcuNWgtMXEtNCAwLTYtNC41dC01LTUuNXEtMjMtNTQtMjMtMTAwIDAtMjc1IDI1Mi00NjZ6IiBmaWxsPSIjZmZmIi8%2BPC9zdmc%2B 10 | :target: https://jq.qq.com/?_wv=1027&k=449sXsV 11 | 12 | WeRoBot 是一个微信公众号开发框架,采用MIT协议发布。 13 | 14 | 文档在这里: https://werobot.readthedocs.org/zh_CN/latest/ 15 | 16 | 安装 17 | ======== 18 | 19 | 推荐使用 pip 进行安装 :: 20 | 21 | pip install werobot 22 | 23 | Hello World 24 | ============= 25 | 26 | 一个非常简单的 Hello World 微信公众号,会对收到的所有文本消息回复 Hello World :: 27 | 28 | import werobot 29 | 30 | robot = werobot.WeRoBot(token='tokenhere') 31 | 32 | @robot.text 33 | def hello_world(): 34 | return 'Hello World!' 35 | 36 | robot.run() 37 | 38 | Credits 39 | ======= 40 | Contributors 41 | ----------------- 42 | Thank you to all the people who have already contributed. 43 | |occontributorimage| 44 | 45 | .. |occontributorimage| image:: https://opencollective.com/werobot/contributors.svg?width=890&button=false 46 | :target: https://opencollective.com/werobot 47 | :alt: Repo Contributors 48 | -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | status = [ 2 | "continuous-integration/travis-ci/push", 3 | "continuous-integration/appveyor/branch", 4 | "ci/circleci", 5 | ] 6 | 7 | # 4 hours 8 | timeout-sec = 14400 9 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -r tox-requirements.txt 2 | -r docs-requirements.txt 3 | yapf==0.30.0 4 | tox==3.24.5 5 | Django 6 | -------------------------------------------------------------------------------- /docs-requirements.txt: -------------------------------------------------------------------------------- 1 | -r tox-requirements.txt 2 | Sphinx==4.4.0 3 | Django 4 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make <target>' where <target> is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -W -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/WeRoBot.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/WeRoBot.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $HOME/.local/share/devhelp/WeRoBot" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $HOME/.local/share/devhelp/WeRoBot" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/offu/WeRoBot/7b3d62d7ae126fc8bc9e033e6358dec2852d1d5a/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/_static/qq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/offu/WeRoBot/7b3d62d7ae126fc8bc9e033e6358dec2852d1d5a/docs/_static/qq.png -------------------------------------------------------------------------------- /docs/_themes/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms of the theme, with or 6 | without modification, are permitted provided that the following conditions 7 | are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | We kindly ask you to only use these themes in an unmodified manner just 22 | for Flask and Flask-related products, not for unrelated projects. If you 23 | like the visual style and want to use it for your own projects, please 24 | consider making some larger changes to the themes (such as changing 25 | font faces, sizes, colors or margins). 26 | 27 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 28 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 29 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 30 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 31 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 32 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 33 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 34 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 35 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 36 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 37 | POSSIBILITY OF SUCH DAMAGE. 38 | -------------------------------------------------------------------------------- /docs/_themes/README.rst: -------------------------------------------------------------------------------- 1 | Flask Sphinx Themes 2 | =================== 3 | 4 | This repository contains Sphinx themes for Flask and Flask related 5 | projects. To use this theme in your Sphinx documentation: 6 | 7 | 1. Put this folder as ``_themes`` in the docs folder. Alternatively 8 | you can use git submodules to check out the contents there. 9 | 10 | 2. Add this to ``conf.py``: 11 | 12 | .. code-block:: python 13 | 14 | sys.path.append(os.path.join(os.path.dirname(__file__), '_themes')) 15 | html_theme_path = ['_themes'] 16 | html_theme = 'flask' 17 | 18 | Themes 19 | ------ 20 | 21 | The following themes exist for ``html_theme``. 22 | 23 | ======================= =============================================== 24 | flask The standard Flask documentation theme for 25 | large projects 26 | 27 | flask_small Small single page theme. Intended to be used 28 | by very small addon libraries for Flask. 29 | ======================= =============================================== 30 | 31 | Options 32 | ------- 33 | 34 | The following options can be set with ``html_theme_options``. 35 | 36 | ======================= =============================================== 37 | index_logo Filename of a picture in ``_static`` to be used 38 | as replacement for the ``h1`` in the 39 | ``index.rst`` file. 40 | *Default unset.* 41 | 42 | index_logo_height Height of the index logo. 43 | *Default 120px*. 44 | 45 | touch_icon Filename of a picture in ``_static`` to be use 46 | as the app icon on Apple devices. 47 | *Default unset.* 48 | 49 | github_fork Repository name on GitHub for the "Fork Me" 50 | badge. 51 | *Default unset.* 52 | 53 | github_ribbon_color Color for the "Fork Me" badge. 54 | *Default darkblue_121621.* 55 | ======================= =============================================== 56 | 57 | Sidebar Templates 58 | ----------------- 59 | 60 | The following sidebar templates can be included in ``html_sidebars``. 61 | 62 | ======================= =============================================== 63 | relations.html Show parent, previous, and next links. 64 | ======================= =============================================== 65 | 66 | Pygments Style 67 | -------------- 68 | 69 | The theme automatically sets ``pygments_style`` to the provided style. 70 | Make sure you remove any override from ``conf.py`` or set it to 71 | ``flask_theme_support.FlaskyStyle``. 72 | -------------------------------------------------------------------------------- /docs/_themes/flask/layout.html: -------------------------------------------------------------------------------- 1 | {% extends 'basic/layout.html' %} 2 | 3 | {% block extrahead %} 4 | {{ super() }} 5 | {% if theme_touch_icon %} 6 | <link rel="apple-touch-icon" href="{{ pathto('_static/' ~ theme_touch_icon, 1) }}"> 7 | {% endif %} 8 | <meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9"> 9 | {% endblock %} 10 | 11 | {% block relbar2 %} 12 | {% if theme_github_fork %} 13 | <a href="http://github.com/{{ theme_github_fork }}"> 14 | <img style="position: fixed; top: 0; right: 0; border: 0;" 15 | src="http://s3.amazonaws.com/github/ribbons/forkme_right_{{ theme_github_ribbon_color }}.png" 16 | alt="Fork me on GitHub"> 17 | </a> 18 | {% endif %} 19 | {% endblock %} 20 | 21 | {% block header %} 22 | {{ super() }} 23 | {% if pagename == 'index' %}<div class=indexwrapper>{% endif %} 24 | {% endblock %} 25 | 26 | {% block footer %} 27 | {{ super() }} 28 | {% if pagename == 'index' %}</div>{% endif %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /docs/_themes/flask/relations.html: -------------------------------------------------------------------------------- 1 | <h3>导航</h3> 2 | <ul> 3 | {%- for parent in parents %} 4 | <li><a href="{{ parent.link|e }}">{{ parent.title }}</a><ul> 5 | {%- endfor %} 6 | {%- if prev %} 7 | <li>{{ _('Previous topic') }}: <a href="{{ prev.link|e }}" title="{{ _('previous chapter') 8 | }}">{{ prev.title }}</a></li> 9 | {%- endif %} 10 | {%- if next %} 11 | <li>{{ _('Next topic') }}: <a href="{{ next.link|e }}" title="{{ _('next chapter') 12 | }}">{{ next.title }}</a></li> 13 | {%- endif %} 14 | {%- for parent in parents %} 15 | </ul></li> 16 | {%- endfor %} 17 | </ul></li> 18 | </ul> 19 | -------------------------------------------------------------------------------- /docs/_themes/flask/sidebarintro.html: -------------------------------------------------------------------------------- 1 | {% if theme_github_url %} 2 | <h3><a href="index.html">{{ project }}</a></h3> 3 | <a href="http://github.com/{{ theme_github_url }}" class="sidebar-github-link"> 4 | <img src="https://img.shields.io/github/stars/{{ theme_github_url }}.svg?style=social&label=Star&maxAge=2592000" /> 5 | <img src="https://img.shields.io/github/forks/{{ theme_github_url }}.svg?style=social&label=Fork&maxAge=2592000" /> 6 | </a> 7 | 8 | <h3>遇到问题了?</h3> 9 | <p> 10 | 如果你遇到了问题,请去 <a href="https://github.com/whtsky/WeRoBot/issues/new">发 Issue</a>。 11 | </p> 12 | QQ群: 283206829 <br> 13 | <img src="{{ pathto('_static/qq.png', 1) }}" /> 14 | {% endif %} -------------------------------------------------------------------------------- /docs/_themes/flask/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | index_logo = 8 | index_logo_height = 120px 9 | github_url = 10 | touch_icon = 11 | github_fork = 12 | github_ribbon_color = darkblue_121621 13 | -------------------------------------------------------------------------------- /docs/_themes/flask_theme_support.py: -------------------------------------------------------------------------------- 1 | # flasky extensions. flasky pygments style based on tango style 2 | from pygments.style import Style 3 | from pygments.token import Keyword, Name, Comment, String, Error, \ 4 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal 5 | 6 | 7 | class FlaskyStyle(Style): 8 | background_color = "#f8f8f8" 9 | default_style = "" 10 | 11 | styles = { 12 | # No corresponding class for the following: 13 | #Text: "", # class: '' 14 | Whitespace: "underline #f8f8f8", # class: 'w' 15 | Error: "#a40000 border:#ef2929", # class: 'err' 16 | Other: "#000000", # class 'x' 17 | Comment: "italic #8f5902", # class: 'c' 18 | Comment.Preproc: "noitalic", # class: 'cp' 19 | Keyword: "bold #004461", # class: 'k' 20 | Keyword.Constant: "bold #004461", # class: 'kc' 21 | Keyword.Declaration: "bold #004461", # class: 'kd' 22 | Keyword.Namespace: "bold #004461", # class: 'kn' 23 | Keyword.Pseudo: "bold #004461", # class: 'kp' 24 | Keyword.Reserved: "bold #004461", # class: 'kr' 25 | Keyword.Type: "bold #004461", # class: 'kt' 26 | Operator: "#582800", # class: 'o' 27 | Operator.Word: "bold #004461", # class: 'ow' - like keywords 28 | Punctuation: "bold #000000", # class: 'p' 29 | 30 | # because special names such as Name.Class, Name.Function, etc. 31 | # are not recognized as such later in the parsing, we choose them 32 | # to look the same as ordinary variables. 33 | Name: "#000000", # class: 'n' 34 | Name.Attribute: "#c4a000", # class: 'na' - to be revised 35 | Name.Builtin: "#004461", # class: 'nb' 36 | Name.Builtin.Pseudo: "#3465a4", # class: 'bp' 37 | Name.Class: "#000000", # class: 'nc' - to be revised 38 | Name.Constant: "#000000", # class: 'no' - to be revised 39 | Name.Decorator: "#888", # class: 'nd' - to be revised 40 | Name.Entity: "#ce5c00", # class: 'ni' 41 | Name.Exception: "bold #cc0000", # class: 'ne' 42 | Name.Function: "#000000", # class: 'nf' 43 | Name.Property: "#000000", # class: 'py' 44 | Name.Label: "#f57900", # class: 'nl' 45 | Name.Namespace: "#000000", # class: 'nn' - to be revised 46 | Name.Other: "#000000", # class: 'nx' 47 | Name.Tag: "bold #004461", # class: 'nt' - like a keyword 48 | Name.Variable: "#000000", # class: 'nv' - to be revised 49 | Name.Variable.Class: "#000000", # class: 'vc' - to be revised 50 | Name.Variable.Global: "#000000", # class: 'vg' - to be revised 51 | Name.Variable.Instance: "#000000", # class: 'vi' - to be revised 52 | Number: "#990000", # class: 'm' 53 | Literal: "#000000", # class: 'l' 54 | Literal.Date: "#000000", # class: 'ld' 55 | String: "#4e9a06", # class: 's' 56 | String.Backtick: "#4e9a06", # class: 'sb' 57 | String.Char: "#4e9a06", # class: 'sc' 58 | String.Doc: "italic #8f5902", # class: 'sd' - like a comment 59 | String.Double: "#4e9a06", # class: 's2' 60 | String.Escape: "#4e9a06", # class: 'se' 61 | String.Heredoc: "#4e9a06", # class: 'sh' 62 | String.Interpol: "#4e9a06", # class: 'si' 63 | String.Other: "#4e9a06", # class: 'sx' 64 | String.Regex: "#4e9a06", # class: 'sr' 65 | String.Single: "#4e9a06", # class: 's1' 66 | String.Symbol: "#4e9a06", # class: 'ss' 67 | Generic: "#000000", # class: 'g' 68 | Generic.Deleted: "#a40000", # class: 'gd' 69 | Generic.Emph: "italic #000000", # class: 'ge' 70 | Generic.Error: "#ef2929", # class: 'gr' 71 | Generic.Heading: "bold #000080", # class: 'gh' 72 | Generic.Inserted: "#00A000", # class: 'gi' 73 | Generic.Output: "#888", # class: 'go' 74 | Generic.Prompt: "#745334", # class: 'gp' 75 | Generic.Strong: "bold #000000", # class: 'gs' 76 | Generic.Subheading: "bold #800080", # class: 'gu' 77 | Generic.Traceback: "bold #a40000", # class: 'gt' 78 | } 79 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | ========== 3 | 4 | .. module:: werobot 5 | 6 | 应用对象 7 | ------------ 8 | 9 | .. module:: werobot.robot 10 | .. autoclass:: BaseRoBot 11 | :members: 12 | .. autoclass:: WeRoBot 13 | :members: 14 | 15 | 配置对象 16 | ------------ 17 | 18 | .. module:: werobot.config 19 | .. autoclass:: Config 20 | :members: 21 | 22 | Session 对象 23 | ------------ 24 | .. module:: werobot.session.sqlitestorage 25 | .. autoclass:: SQLiteStorage 26 | 27 | .. module:: werobot.session.filestorage 28 | .. autoclass:: FileStorage 29 | 30 | .. module:: werobot.session.mongodbstorage 31 | .. autoclass:: MongoDBStorage 32 | 33 | .. module:: werobot.session.redisstorage 34 | .. autoclass:: RedisStorage 35 | 36 | .. module:: werobot.session.saekvstorage 37 | .. autoclass:: SaeKVDBStorage 38 | 39 | .. module:: werobot.session.mysqlstorage 40 | .. autoclass:: MySQLStorage 41 | 42 | .. module:: werobot.session.postgresqlstorage 43 | .. autoclass:: PostgreSQLStorage 44 | 45 | log 46 | ------------ 47 | .. module:: werobot.logger 48 | .. autofunction:: enable_pretty_logging 49 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ============= 3 | 4 | Version 1.13.1 5 | ---------------- 6 | + 更新了部分依赖。 7 | 8 | Version 1.13.0 9 | ---------------- 10 | + 停止了对 Django 1.11、Django 2.0 的测试 11 | + 增加了对 Django 2.2、 Django 3.0、 Django 3.1 的测试 12 | + 停止了对 Python 3.4、 Python 3.5 的测试 13 | + 增加了对 Python 3.9 的测试 14 | + :func:`werobot.client.Client.send_template_message` 添加跳转小程序 ( `#604 <https://github.com/offu/WeRoBot/pull/604>`_ ) 15 | 16 | Version 1.12.0 17 | ---------------- 18 | + 增加了对微信模板回调消息的处理 :func:`robot.templatesendjobfinish_event <werobot.robot.BaseRoBot.templatesendjobfinish_event>` ( `#544 <https://github.com/offu/WeRoBot/pull/544>`_ ) 19 | 20 | Version 1.11.0 21 | ---------------- 22 | + 停止了对 Python2 与 PyPy 的支持。 ( `#539 <https://github.com/offu/WeRoBot/pull/539>`_ ) 23 | + 停止了对 SAE 相关代码的测试。 ( `#539 <https://github.com/offu/WeRoBot/pull/539>`_ ) 24 | 25 | Version 1.10.1 26 | ---------------- 27 | + 修复 :ref:`群发接口` 的 docstring 样式。 28 | 29 | Version 1.10.0 30 | ---------------- 31 | + 在 Client 中增加对 :ref:`上传图文消息素材` 的支持 ( `#493 <https://github.com/offu/WeRoBot/pull/493>`_ ) 32 | + 在 Client 中增加对 :ref:`群发接口` 的支持 ( `#493 <https://github.com/offu/WeRoBot/pull/493>`_ ) 33 | 34 | Version 1.9.0 35 | ---------------- 36 | + 在 Client 中增加对 :ref:`用户标签管理` 的支持 ( `#426 <https://github.com/offu/WeRoBot/pull/426>`_ ) 37 | 38 | Version 1.8.0 39 | ---------------- 40 | + 增加 :class:`werobot.session.postgresqlstorage.PostgreSQLStorage` (`#383 <https://github.com/offu/WeRoBot/issues/383>`_) (`#412 <https://github.com/offu/WeRoBot/pull/412>`_) 41 | + 修复 imp 在 Python3.4 后被废弃的问题 (`#411 <https://github.com/offu/WeRoBot/pull/411>`_) 42 | 43 | Version 1.7.0 44 | ---------------- 45 | + 为 :func:`werobot.client.Client.send_text_message`,:func:`werobot.client.Client.send_image_message`,:func:`werobot.client.Client.send_voice_message`,:func:`werobot.client.Client.send_video_message`,:func:`werobot.client.Client.send_music_message`,:func:`werobot.client.Client.send_article_message`,:func:`werobot.client.Client.send_news_message` 加入 ``kf_account`` 参数 ( `#384 <https://github.com/offu/WeRoBot/issues/384>`_ ) 46 | + 将 :class:`werobot.replies.TransferCustomerServiceReply` 的 ``account`` 改为非必需 ( `#363 <https://github.com/offu/WeRoBot/issues/363>`_ ) 47 | 48 | Version 1.6.0 49 | ---------------- 50 | + 增加对发送小程序卡片的支持: :func:`werobot.client.Client.send_miniprogrampage_message` ( `#309 <https://github.com/offu/WeRoBot/pull/309>`_ by `@helloqiu <https://github.com/helloqiu>`_) 51 | 52 | Version 1.5.0 53 | ---------------- 54 | + 为正则匹配的 handler 加入匹配后的 ``Match Object`` 作为参数(`#305 <https://github.com/offu/WeRoBot/pull/305>`_)(`Author: cxgreat2014 <https://github.com/cxgreat2014>`_) 55 | 56 | Version 1.4.1 57 | ---------------- 58 | + 修复 :func:`werobot.client.Client.post` 中文文件名的 bug (`#292 <https://github.com/offu/WeRoBot/issues/292>`_) 59 | 60 | Version 1.4.0 61 | ---------------- 62 | + 增加 :class:`werobot.messages.events.CardPassCheckEvent` 63 | + 增加 :class:`werobot.messages.events.CardNotPassCheckEvent` 64 | + 增加 :class:`werobot.messages.events.UserGetCardEvent` 65 | + 增加 :class:`werobot.messages.events.UserGiftingCardEvent` 66 | + 增加 :class:`werobot.messages.events.UserDelCardEvent` 67 | + 增加 :class:`werobot.messages.events.UserConsumeCardEvent` 68 | + 增加 :class:`werobot.messages.events.UserPayFromPayCellEvent` 69 | + 增加 :class:`werobot.messages.events.UserViewCardEvent` 70 | + 增加 :class:`werobot.messages.events.UserEnterSessionFromCardEvent` 71 | + 增加 :class:`werobot.messages.events.UpdateMemberCardEvent` 72 | + 增加 :class:`werobot.messages.events.CardSkuRemindEvent` 73 | + 增加 :class:`werobot.messages.events.CardPayOrderEvent` 74 | + 增加 :class:`werobot.messages.events.SubmitMembercardUserInfoEvent` 75 | 76 | Version 1.3.0 77 | ---------------- 78 | + 增加 :class:`werobot.messages.events.UserScanProductEvent` 79 | + 增加 :class:`werobot.messages.events.UserScanProductEnterSessionEvent` 80 | + 增加 :class:`werobot.messages.events.UserScanProductAsyncEvent` 81 | + 增加 :class:`werobot.messages.events.UserScanProductVerifyActionEvent` 82 | + 增加 :class:`werobot.messages.events.PicSysphotoEvent` 83 | + 增加 :class:`werobot.messages.events.PicPhotoOrAlbumEvent` 84 | + 增加 :class:`werobot.messages.events.PicWeixinEvent` 85 | + 增加 :class:`werobot.messages.events.LocationSelectEvent` 86 | 87 | Version 1.2.0 88 | ---------------- 89 | + 增加 :class:`werobot.session.mysqlstorage.MySQLStorage` 90 | + 增加 :class:`werobot.messages.events.ScanCodePushEvent` 91 | + 增加 :class:`werobot.messages.events.ScanCodeWaitMsgEvent` 92 | + 增加 :func:`werobot.robot.BaseRoBot.add_filter` 93 | + :func:`werobot.utils.generate_token` 在 Python 3.6+ 下优先使用 ``secrets.choice`` 来随机生成 token 94 | + 修复 :func:`werobot.client.Client.get_media_list` 的调用参数错误 (`#208 <https://github.com/whtsky/WeRoBot/issues/208>`_) 95 | + 修复了某些情况下 Client 中文编码不正确的问题 (`#250 <https://github.com/whtsky/WeRoBot/issues/250>`_) 96 | + Handler 中的 Exception 现在会以 Error level 记录到 logger 中 97 | + 在文档中增加了独立的 :doc:`api` 部分 98 | + 添加了 ``video`` 和 ``shortvideo`` 的修饰器 99 | + 增加了 :class:`werobot.session.saekvstorage.SaeKVDBStorage` 的测试 100 | + 增加了对 Django 2.0 的测试 101 | + 抛弃对 Django < 1.8 、 Django 1.9 、 Django 1.10 的支持 102 | 103 | Version 1.1.1 104 | ---------------- 105 | 106 | + 修复 :func:`werobot.client.Client.create_menu` 文档中的错误 107 | + 在 :func:`werobot.client.Client.send_music_message` 的文档中提示了可能的缩略图不显示的问题 108 | 109 | Version 1.1.0 110 | ---------------- 111 | 112 | + 为 :class:`werobot.robot.BaseRoBot` 增加 ``client`` property 113 | + 允许在初始化 :class:`werobot.robot.BaseRoBot` 时传入 :doc:`config` 。注意如果传入了 config , BaseRoBot 会忽略除 ``config`` 与 ``logger`` 外的其他所有的参数。 114 | + deprecate :class:`werobot.robot.BaseRoBot` 的 ``enable_session`` 参数 115 | + Session Storage 现在是惰性加载的了; 如果希望关闭 Session , 请将 :doc:`config` 中的 ``SESSION_STORAGE`` 设为 ``False`` (`#189 <https://github.com/whtsky/WeRoBot/issues/189>`_) 116 | + 修复了打包时 `error.html` 被忽略导致的默认错误页面错误的问题 (`#194 <https://github.com/whtsky/WeRoBot/issues/194>`_) 117 | + 允许使用 ``reply.time`` 的方式快速读取 Reply 属性 118 | + 完善 :doc:`client` 中自定义菜单、消息管理、素材管理、用户管理、账户管理、素材管理部分的 `API` 119 | + 修复了直接 GET 访问 Robot 主页返回 500 的问题 120 | 121 | Version 1.0.0 122 | ---------------- 123 | 124 | + 增加对消息加解密的支持 125 | + 重写 werobot.messages, 完善对 Event 的支持 126 | + 将微信消息的 `id` 属性重命名为 `message_id` 127 | + 增加 :class:`werobot.reply.SuccessReply` 128 | + 增加 :class:`werobot.reply.ImageReply` 129 | + 增加 :class:`werobot.reply.VoiceReply` 130 | + 增加 :class:`werobot.reply.VideoReply` 131 | + 删除 :func:`werobot.reply.create_reply` 132 | + 为 :class:`werobot.reply.WeChatReply` 增加 ``process_args`` 方法 133 | + 为 :class:`werobot.robot.BaseRoBot` 增加 ``parse_message`` 方法 134 | + 为 :class:`werobot.robot.BaseRoBot` 增加 ``get_encrypted_reply`` 方法 135 | + 删去了 Reply 中过时的 flag 136 | + 修复 :class:`werobot.session.filestorage.FileStorage` 在 PyPy 下的兼容性问题 137 | + 增加 :class:`werobot.session.sqlitestorage.SQLiteStorage` 138 | + 将默认的 SessionBackend 切换为 :class:`werobot.session.sqlitestorage.SQLiteStorage` 139 | + 将图文消息单个消息的渲染函数放到 :class:`werobot.replies.Article` 内 140 | + 取消对 Python2.6, Python3.3 的支持 141 | + 增加与 Django 1.6+, Flask, Bottle, Tornado 集成的支持 142 | + 替换 `inspect.getargspec()` 143 | 144 | Version 0.6.1 145 | ---------------- 146 | 147 | + Fix wrong URL in ``upload_media`` 148 | + Add VideoMessage 149 | 150 | Version 0.6.0 151 | ---------------- 152 | 153 | + Add ``@werobot.filter`` 154 | + Add :class:`werobot.session.saekvstorage` 155 | + Add support for Weixin Pay ( :class:`werobot.pay.WeixinPayClient` ) 156 | + Add ``werobot.reply.TransferCustomerServiceReply`` 157 | + Fix FileStorage's bug 158 | 159 | Version 0.5.3 160 | ---------------- 161 | 162 | + Fix: can't handle request for root path 163 | 164 | Version 0.5.2 165 | ---------------- 166 | 167 | + Fix Python 3 support 168 | 169 | Version 0.5.1 170 | ---------------- 171 | 172 | + Fix typo 173 | 174 | Version 0.5.0 175 | ---------------- 176 | 177 | + Add ``werobot.client`` 178 | + Add ``werobot.config`` 179 | + Add ``werobot.logger`` 180 | + Add ``@werobot.key_click`` (Thanks @tg123) 181 | + Support Location Event 182 | + Use smart args 183 | + Friendly 403 page 184 | + Improved server support 185 | + Enable session by default. 186 | + Drop ``werobot.testing.make_text_message`` 187 | + Drop ``werobot.testing.make_image_message`` 188 | + Drop ``werobot.testing.make_location_message`` 189 | + Drop ``werobot.testing.make_voice_message`` 190 | + Drop ``werobot.testing.WeTest.send`` 191 | + Rewrite ``werobot.message`` 192 | + Rewrite testing case 193 | 194 | Version 0.4.1 195 | ---------------- 196 | + Add VoiceMessage 197 | + Add ``message.raw``: Raw XML of message 198 | + Rename ``UnknownMessage.content`` to ``UnknownMessage.raw`` 199 | + Fix a bug when signature is invalid. 200 | + Ignore session when receive UnknownMessage 201 | 202 | Version 0.4.0 203 | ---------------- 204 | + Add session support 205 | + Add logging support 206 | + Rename ``werobot.test`` to ``werobot.testing`` 207 | + Handlers added by ``@robot.handler`` will have the lowest priority. 208 | 209 | Version 0.3.5 210 | ---------------- 211 | + Bug fix: Make ``BaseRoBot`` importable 212 | 213 | Version 0.3.4 214 | ---------------- 215 | + Rename ``WeRoBot.app`` to ``WeRoBot.wsgi`` 216 | + Add ``BaseRoBot`` class. It's useful for creating extensions. 217 | + Reorganized documents. 218 | 219 | Version 0.3.3 220 | ---------------- 221 | + Add ``host`` param in werobot.run 222 | + Update EventMessage 223 | + Add LinkMessage 224 | 225 | Version 0.3.2 226 | ---------------- 227 | + Convert all arguments to unicode in Python 2 ( See issue `#1 <https://github.com/whtsky/WeRoBot/pull/1>`_ ) 228 | 229 | Version 0.3.1 230 | ---------------- 231 | + Add ``server`` param in werobot.run 232 | 233 | Version 0.3.0 234 | ---------------- 235 | + Add new messages and replies support for WeChat 4.5 236 | -------------------------------------------------------------------------------- /docs/client.rst: -------------------------------------------------------------------------------- 1 | ``WeRoBot.Client`` —— 微信 API 操作类 2 | ===================================== 3 | 有部分接口暂未实现,可自行调用微信接口。 4 | 5 | .. module:: werobot.client 6 | 7 | 开始开发 8 | ------------ 9 | 10 | 获取 access token 11 | `````````````````````````````` 12 | 详细请参考 http://mp.weixin.qq.com/wiki/14/9f9c82c1af308e3b14ba9b973f99a8ba.html 13 | 14 | .. automethod:: Client.grant_token 15 | .. automethod:: Client.get_access_token 16 | 17 | .. note:: Client 的操作都会自动进行 `access token` 的获取和过期刷新操作,如果有特殊需求(如多进程部署)可重写 ``get_access_token``。 18 | 19 | 获取微信服务器IP地址 20 | `````````````````````````````` 21 | 详细请参考 http://mp.weixin.qq.com/wiki/4/41ef0843d6e108cf6b5649480207561c.html 22 | 23 | .. automethod:: Client.get_ip_list 24 | 25 | 自定义菜单 26 | ------------ 27 | 28 | 自定义菜单创建接口 29 | `````````````````````````````` 30 | 详细请参考 http://mp.weixin.qq.com/wiki/10/0234e39a2025342c17a7d23595c6b40a.html 31 | 32 | .. automethod:: Client.create_menu 33 | 34 | 自定义菜单查询接口 35 | `````````````````````````````` 36 | 详细请参考 http://mp.weixin.qq.com/wiki/5/f287d1a5b78a35a8884326312ac3e4ed.html 37 | 38 | .. automethod:: Client.get_menu 39 | 40 | 自定义菜单删除接口 41 | `````````````````````````````` 42 | 详细请参考 http://mp.weixin.qq.com/wiki/3/de21624f2d0d3dafde085dafaa226743.html 43 | 44 | .. automethod:: Client.delete_menu 45 | 46 | 个性化菜单接口 47 | `````````````````````````````` 48 | 详细请参考 http://mp.weixin.qq.com/wiki/0/c48ccd12b69ae023159b4bfaa7c39c20.html 49 | 50 | .. automethod:: Client.create_custom_menu 51 | .. automethod:: Client.delete_custom_menu 52 | .. automethod:: Client.match_custom_menu 53 | 54 | 获取自定义菜单配置接口 55 | `````````````````````````````` 56 | 详细请参考 http://mp.weixin.qq.com/wiki/14/293d0cb8de95e916d1216a33fcb81fd6.html 57 | 58 | .. automethod:: Client.get_custom_menu_config 59 | 60 | 消息管理 61 | ------------ 62 | 63 | 客服接口 64 | `````````````````````````````` 65 | 详细请参考 http://mp.weixin.qq.com/wiki/11/c88c270ae8935291626538f9c64bd123.html 66 | 发送卡券接口暂时未支持。可自行实现。 67 | 68 | .. automethod:: Client.add_custom_service_account 69 | .. automethod:: Client.update_custom_service_account 70 | .. automethod:: Client.delete_custom_service_account 71 | .. automethod:: Client.upload_custom_service_account_avatar 72 | .. automethod:: Client.get_custom_service_account_list 73 | .. automethod:: Client.get_online_custom_service_account_list 74 | .. automethod:: Client.send_text_message 75 | .. automethod:: Client.send_image_message 76 | .. automethod:: Client.send_voice_message 77 | .. automethod:: Client.send_video_message 78 | .. automethod:: Client.send_music_message 79 | .. automethod:: Client.send_article_message 80 | .. automethod:: Client.send_news_message 81 | .. automethod:: Client.send_miniprogrampage_message 82 | 83 | 群发接口 84 | `````````````````````````````` 85 | 86 | .. automethod:: Client.send_mass_msg 87 | .. automethod:: Client.delete_mass_msg 88 | .. automethod:: Client.send_mass_preview_to_user 89 | .. automethod:: Client.get_mass_msg_status 90 | .. automethod:: Client.get_mass_msg_speed 91 | 92 | 用户管理 93 | ------------ 94 | 95 | 用户分组管理 96 | `````````````````````````````` 97 | 详细请参考 http://mp.weixin.qq.com/wiki/8/d6d33cf60bce2a2e4fb10a21be9591b8.html 98 | 99 | .. automethod:: Client.create_group 100 | .. automethod:: Client.get_groups 101 | .. automethod:: Client.get_group_by_id 102 | .. automethod:: Client.update_group 103 | .. automethod:: Client.move_user 104 | .. automethod:: Client.move_users 105 | .. automethod:: Client.delete_group 106 | 107 | 设置备注名 108 | `````````````````````````````` 109 | 详细请参考 http://mp.weixin.qq.com/wiki/16/528098c4a6a87b05120a7665c8db0460.html 110 | 111 | .. automethod:: Client.remark_user 112 | 113 | 获取用户基本信息 114 | `````````````````````````````` 115 | 详细请参考 http://mp.weixin.qq.com/wiki/1/8a5ce6257f1d3b2afb20f83e72b72ce9.html 116 | 117 | .. automethod:: Client.get_user_info 118 | .. automethod:: Client.get_users_info 119 | 120 | 账户管理 121 | ------------ 122 | 长链接转短链接接口和微信认证事件推送暂未添加,可自行实现。 123 | 124 | 生成带参数的二维码 125 | `````````````````````````````` 126 | 详细请参考 http://mp.weixin.qq.com/wiki/18/167e7d94df85d8389df6c94a7a8f78ba.html 127 | 128 | .. automethod:: Client.create_qrcode 129 | .. automethod:: Client.show_qrcode 130 | 131 | 获取用户列表 132 | `````````````````````````````` 133 | 详细请参考 http://mp.weixin.qq.com/wiki/12/54773ff6da7b8bdc95b7d2667d84b1d4.html 134 | 135 | .. automethod:: Client.get_followers 136 | 137 | 素材管理 138 | ------------ 139 | 140 | 新增临时素材 141 | `````````````````````````````` 142 | 详细请参考 http://mp.weixin.qq.com/wiki/15/2d353966323806a202cd2deaafe8e557.html 143 | 144 | .. automethod:: Client.upload_media 145 | 146 | 获取临时素材 147 | `````````````````````````````` 148 | 详细请参考 http://mp.weixin.qq.com/wiki/9/677a85e3f3849af35de54bb5516c2521.html 149 | 150 | .. automethod:: Client.download_media 151 | 152 | 新增永久素材 153 | `````````````````````````````` 154 | 详细请参考 http://mp.weixin.qq.com/wiki/10/10ea5a44870f53d79449290dfd43d006.html 155 | 156 | .. automethod:: Client.add_news 157 | .. automethod:: Client.upload_news_picture 158 | .. automethod:: Client.upload_permanent_media 159 | .. automethod:: Client.upload_permanent_video 160 | 161 | 获取永久素材 162 | `````````````````````````````` 163 | 详细请参考 http://mp.weixin.qq.com/wiki/12/3c12fac7c14cb4d0e0d4fe2fbc87b638.html 164 | 165 | .. automethod:: Client.download_permanent_media 166 | 167 | 删除永久素材 168 | `````````````````````````````` 169 | 详细请参考 http://mp.weixin.qq.com/wiki/7/2212203f4e17253b9aef77dc788f5337.html 170 | 171 | .. automethod:: Client.delete_permanent_media 172 | 173 | 上传图文消息素材 174 | `````````````````````````````` 175 | 176 | .. automethod:: Client.upload_news 177 | 178 | 修改永久图文素材 179 | `````````````````````````````` 180 | 详细请参考 http://mp.weixin.qq.com/wiki/10/c7bad9a463db20ff8ccefeedeef51f9e.html 181 | 182 | .. automethod:: Client.update_news 183 | 184 | 获取素材总数 185 | `````````````````````````````` 186 | 详细请参考 http://mp.weixin.qq.com/wiki/5/a641fd7b5db7a6a946ebebe2ac166885.html 187 | 188 | .. automethod:: Client.get_media_count 189 | 190 | 获取素材列表 191 | `````````````````````````````` 192 | 详细请参考 http://mp.weixin.qq.com/wiki/15/8386c11b7bc4cdd1499c572bfe2e95b3.html 193 | 194 | .. automethod:: Client.get_media_list 195 | 196 | 用户标签管理 197 | ------------ 198 | 详细请参考 https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140837 199 | 200 | 创建标签 201 | `````````````````````````````` 202 | .. automethod:: Client.create_tag 203 | 204 | 获取公众号已创建的标签 205 | `````````````````````````````` 206 | .. automethod:: Client.get_tags 207 | 208 | 编辑标签 209 | `````````````````````````````` 210 | .. automethod:: Client.update_tag 211 | 212 | 删除标签 213 | `````````````````````````````` 214 | .. automethod:: Client.delete_tag 215 | 216 | 获取标签下粉丝列表 217 | `````````````````````````````` 218 | .. automethod:: Client.get_users_by_tag 219 | 220 | 批量为用户打标签 221 | `````````````````````````````` 222 | .. automethod:: Client.tag_users 223 | 224 | 批量为用户取消标签 225 | `````````````````````````````` 226 | .. automethod:: Client.untag_users 227 | 228 | 获取用户身上的标签列表 229 | `````````````````````````````` 230 | .. automethod:: Client.get_tags_by_user 231 | 232 | 模板消息 233 | ------------ 234 | .. automethod:: Client.send_template_message 235 | 236 | 237 | 返回码都是什么意思? 238 | -------------------------- 239 | 240 | 参考 https://mp.weixin.qq.com/wiki/10/6380dc743053a91c544ffd2b7c959166.html 241 | 242 | 48001 -- API Unauthorized 243 | --------------------------- 244 | 245 | 如果你遇到了这个错误,请检查你的微信公众号是否有调用该接口的权限。 246 | 参考: https://mp.weixin.qq.com/wiki/13/8d4957b72037e3308a0ca1b21f25ae8d.html 247 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # WeRoBot documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Feb 4 16:51:10 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath("..")) 21 | sys.path.append(os.path.abspath("_themes")) 22 | 23 | import werobot 24 | 25 | # -- General configuration ----------------------------------------------------- 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be extensions 31 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 32 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.autosectionlabel"] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ["_templates"] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = ".rst" 39 | 40 | # The encoding of source files. 41 | # source_encoding = 'utf-8-sig' 42 | 43 | # The master toctree document. 44 | master_doc = "index" 45 | 46 | # General information about the project. 47 | project = u"WeRoBot" 48 | copyright = u"2019, offu" 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | version = release = werobot.__version__ 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | language = "zh_CN" 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | # today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | # today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ["_build", "_themes"] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | # default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | # add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | # add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | # show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = "flask_theme_support.FlaskyStyle" 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | # modindex_common_prefix = [] 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | sys.path.append(os.path.join(os.path.dirname(__file__), "_themes")) 95 | html_theme_path = ["_themes"] 96 | html_theme = "flask" 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | html_theme_options = {"touch_icon": "logo.png", "github_url": "offu/WeRoBot"} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | # html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # "<project> v<release> documentation". 108 | # html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | # html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | # html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | # html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ["_static"] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | # html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | # html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | html_sidebars = { 137 | "index": ["sidebarintro.html", "searchbox.html"], 138 | "**": [ 139 | "sidebarintro.html", "localtoc.html", "relations.html", 140 | "searchbox.html" 141 | ], 142 | } 143 | 144 | # Additional templates that should be rendered to pages, maps page names to 145 | # template names. 146 | # html_additional_pages = {} 147 | 148 | # If false, no module index is generated. 149 | # html_domain_indices = True 150 | 151 | # If false, no index is generated. 152 | # html_use_index = True 153 | 154 | # If true, the index is split into individual pages for each letter. 155 | # html_split_index = False 156 | 157 | # If true, links to the reST sources are added to the pages. 158 | # html_show_sourcelink = True 159 | 160 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 161 | # html_show_sphinx = True 162 | 163 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 164 | # html_show_copyright = True 165 | 166 | # If true, an OpenSearch description file will be output, and all pages will 167 | # contain a <link> tag referring to it. The value of this option must be the 168 | # base URL from which the finished HTML is served. 169 | # html_use_opensearch = '' 170 | 171 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 172 | # html_file_suffix = None 173 | 174 | # Output file base name for HTML help builder. 175 | htmlhelp_basename = "WeRoBotdoc" 176 | 177 | # -- Options for LaTeX output -------------------------------------------------- 178 | 179 | latex_elements = { 180 | # The paper size ('letterpaper' or 'a4paper'). 181 | #'papersize': 'letterpaper', 182 | # The font size ('10pt', '11pt' or '12pt'). 183 | #'pointsize': '10pt', 184 | # Additional stuff for the LaTeX preamble. 185 | #'preamble': '', 186 | } 187 | 188 | # Grouping the document tree into LaTeX files. List of tuples 189 | # (source start file, target name, title, author, documentclass [howto/manual]). 190 | latex_documents = [ 191 | ("index", "WeRoBot.tex", u"WeRoBot Documentation", u"offu", "manual") 192 | ] 193 | 194 | # The name of an image file (relative to this directory) to place at the top of 195 | # the title page. 196 | # latex_logo = None 197 | 198 | # For "manual" documents, if this is true, then toplevel headings are parts, 199 | # not chapters. 200 | # latex_use_parts = False 201 | 202 | # If true, show page references after internal links. 203 | # latex_show_pagerefs = False 204 | 205 | # If true, show URL addresses after external links. 206 | # latex_show_urls = False 207 | 208 | # Documents to append as an appendix to all manuals. 209 | # latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | # latex_domain_indices = True 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [("index", "werobot", u"WeRoBot Documentation", [u"offu"], 1)] 219 | 220 | # If true, show URL addresses after external links. 221 | # man_show_urls = False 222 | 223 | # -- Options for Texinfo output ------------------------------------------------ 224 | 225 | # Grouping the document tree into Texinfo files. List of tuples 226 | # (source start file, target name, title, author, 227 | # dir menu entry, description, category) 228 | texinfo_documents = [ 229 | ( 230 | "index", 231 | "WeRoBot", 232 | u"WeRoBot Documentation", 233 | u"offu", 234 | "WeRoBot", 235 | "One line description of project.", 236 | "Miscellaneous", 237 | ) 238 | ] 239 | 240 | # Documents to append as an appendix to all manuals. 241 | # texinfo_appendices = [] 242 | 243 | # If false, no module index is generated. 244 | # texinfo_domain_indices = True 245 | 246 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 247 | # texinfo_show_urls = 'footnote' 248 | -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | Config 2 | ===================== 3 | 4 | WeRoBot 使用 ``WeRoBot.Config`` 类来存储配置信息。 ``WeRoBot`` 类实例的 ``config`` 属性是一个 :class:`werobot.config.Config` 实例。 5 | 6 | :class:`~werobot.config.Config` 继承自 `dict` 。因此, 你可以像使用普通 dict 一样使用它 :: 7 | 8 | from werobot import WeRoBot 9 | robot = WeRoBot(token='2333') 10 | 11 | robot.config.update( 12 | HOST='0.0.0.0', 13 | PORT=80 14 | ) 15 | 16 | 当然, 你也可以先创建一个 Config ,然后在初始化 ``WeRobot`` 的时候传入自己的 Config :: 17 | 18 | from werobot.config import Config 19 | config = Config( 20 | TOKEN="token from config!" 21 | ) 22 | robot = WeRoBot(config=config, token="token from init") 23 | assert robot.token == "token from config!" 24 | 25 | .. note:: 如果你在初始化 ``WeRoBot`` 时传入了 ``config`` 参数, ``WeRoBot`` 会忽略除 ``logger`` 外其他所有的初始化参数。 如果你需要对 ``WeRoBot`` 进行一些配置操作, 请修改 Config 。 26 | 27 | 与普通 `dict` 不同的是, 你可以先把配置文件保存在一个对象或是文件中, 然后在 :class:`~werobot.config.Config` 中导入配置 :: 28 | 29 | from werobot import WeRoBot 30 | robot = WeRoBot(token='2333') 31 | 32 | class MyConfig(object): 33 | HOST = '0.0.0.0' 34 | PORT = 80 35 | 36 | robot.config.from_object(MyConfig) 37 | robot.config.from_pyfile("config.py") 38 | 39 | 40 | 41 | 默认配置 42 | ---------- 43 | 44 | .. code:: python 45 | 46 | dict( 47 | TOKEN=None, 48 | SERVER="auto", 49 | HOST="127.0.0.1", 50 | PORT="8888", 51 | SESSION_STORAGE=None, 52 | APP_ID=None, 53 | APP_SECRET=None, 54 | ENCODING_AES_KEY=None 55 | ) 56 | -------------------------------------------------------------------------------- /docs/contrib.rst: -------------------------------------------------------------------------------- 1 | 与其他 Web 框架集成 2 | =================== 3 | 4 | WeRoBot 可以作为独立服务运行,也可以集成在其他 Web 框架中一同运行。 5 | 6 | Django 7 | -------- 8 | WeRoBot 支持 Django 2.2+。 9 | 10 | 首先,在一个文件中写好你的微信机器人 :: 11 | 12 | # Filename: robot.py 13 | 14 | from werobot import WeRoBot 15 | 16 | myrobot = WeRoBot(token='token') 17 | 18 | 19 | @myrobot.handler 20 | def hello(message): 21 | return 'Hello World!' 22 | 23 | 然后,在你 Django 项目中的 ``urls.py`` 中调用 :func:`werobot.contrib.django.make_view` ,将 WeRoBot 集成进 Django :: 24 | 25 | from django.conf.urls import patterns, include, url 26 | from werobot.contrib.django import make_view 27 | from robot import myrobot 28 | 29 | urlpatterns = patterns('', 30 | url(r'^robot/', make_view(myrobot)), 31 | ) 32 | 33 | .. module:: werobot.contrib.django 34 | .. autofunction:: make_view 35 | 36 | Flask 37 | ---------- 38 | 首先, 同样在文件中写好你的微信机器人 :: 39 | 40 | # Filename: robot.py 41 | 42 | from werobot import WeRoBot 43 | 44 | myrobot = WeRoBot(token='token') 45 | 46 | 47 | @myrobot.handler 48 | def hello(message): 49 | return 'Hello World!' 50 | 51 | 然后, 在 Flask 项目中为 Flask 实例集成 WeRoBot :: 52 | 53 | from flask import Flask 54 | from robot import myrobot 55 | from werobot.contrib.flask import make_view 56 | 57 | app = Flask(__name__) 58 | app.add_url_rule(rule='/robot/', # WeRoBot 挂载地址 59 | endpoint='werobot', # Flask 的 endpoint 60 | view_func=make_view(myrobot), 61 | methods=['GET', 'POST']) 62 | 63 | .. module:: werobot.contrib.flask 64 | .. autofunction:: make_view 65 | 66 | 67 | Bottle 68 | -------- 69 | 在你的 Bottle App 中集成 WeRoBot :: 70 | 71 | from werobot import WeRoBot 72 | 73 | myrobot = WeRoBot(token='token') 74 | 75 | @myrobot.handler 76 | def hello(message): 77 | return 'Hello World!' 78 | 79 | from bottle import Bottle 80 | from werobot.contrib.bottle import make_view 81 | 82 | app = Bottle() 83 | app.route('/robot', # WeRoBot 挂载地址 84 | ['GET', 'POST'], 85 | make_view(myrobot)) 86 | 87 | .. module:: werobot.contrib.bottle 88 | .. autofunction:: make_view 89 | 90 | Tornado 91 | ---------- 92 | 最简单的 Hello World :: 93 | 94 | import tornado.ioloop 95 | import tornado.web 96 | from werobot import WeRoBot 97 | from werobot.contrib.tornado import make_handler 98 | 99 | myrobot = WeRoBot(token='token') 100 | 101 | 102 | @myrobot.handler 103 | def hello(message): 104 | return 'Hello World!' 105 | 106 | application = tornado.web.Application([ 107 | (r"/robot/", make_handler(myrobot)), 108 | ]) 109 | 110 | if __name__ == "__main__": 111 | application.listen(8888) 112 | tornado.ioloop.IOLoop.instance().start() 113 | 114 | .. module:: werobot.contrib.tornado 115 | .. autofunction:: make_handler 116 | -------------------------------------------------------------------------------- /docs/contribution-guide.rst: -------------------------------------------------------------------------------- 1 | 贡献指南 2 | =========================== 3 | 4 | 有许多种为 WeRoBot 做贡献的方式, 包括但并不仅限于 5 | 6 | + `上报 bug <https://github.com/whtsky/WeRoBot/issues/new?labels=bug>`_ 7 | + `提交 Feature Request <https://github.com/whtsky/WeRoBot/issues/new?labels=Feature Request>`_ 8 | + :ref:`贡献代码` 9 | + 加入 WeRoBot QQ 群(283206829) 帮助解答问题 10 | + 把 WeRoBot 安利给你周围的人 :) 11 | 12 | 贡献代码 13 | ---------- 14 | 15 | 如果你希望为 WeRoBot 贡献代码, 请现在 GitHub 上 `Fork <https://github.com/whtsky/WeRoBot>`_ WeRoBot 仓库, 然后在 ``master`` 分支上开一个新的分支。 16 | 17 | 如果你的贡献的代码是修复 Bug , 请确认这个 Bug 已经有了对应的 Issue (如果没有, 请先创建一个); 然后在 Pull Request 的描述里面引用这个 Bug 的 Issue ID , 就像这样(假设 Issue ID 为 153) :: 18 | 19 | Fix #153 20 | 21 | 环境搭建 22 | ~~~~~~~~~~~ 23 | 建议使用 ``virtualenv`` 创建虚拟环境进行开发, 然后安装开发环境需要的 packages。 24 | 关于 Python 版本, 推荐使用 Python 3.6 进行开发。 25 | 26 | 如果使用的是 3.x 版本 :: 27 | 28 | # Python 3.5 29 | python -m venv venv 30 | 31 | 如果是其他版本 :: 32 | 33 | # virtualenv is highly recommended. 34 | virtualenv venv 35 | # Activate virtualenv. 36 | source venv/bin/activate 37 | # Install dev packages. 38 | pip install -r dev-requirements.txt 39 | 40 | 代码风格 41 | ~~~~~~~~~~~ 42 | 我们使用 `yapf <https://github.com/google/yapf>`_ 进行代码格式化。 43 | 在提交代码之前,请格式化一下你的代码 :: 44 | 45 | # Install yapf 46 | pip install yapf 47 | # format code 48 | yapf -i -p -r werobot/ tests/ *.py 49 | 50 | 你也可以 `安装 yapf Pre-Commit Hook <https://github.com/google/yapf/tree/master/plugins#git-pre-commit-hook>`_ 来自动进行代码格式化工作。 51 | 52 | 测试 53 | ~~~~~~~~~~~ 54 | 在代码提交之前, 请先运行本地的测试。每次提交之后会有在线的 CI 运行更多版本兼容性的测试, 请密切关注测试结果。 :: 55 | 56 | # Run tests locally. 57 | python setup.py test 58 | 59 | 当然也可以使用 tox 在本地运行多版本的兼容性测试。 :: 60 | 61 | # Run multi-version tests locally. 62 | tox 63 | 64 | 如果你的 Pull Request 添加了新的模块或者功能,请为这些代码添加必要的测试。 所有的测试文件都在 tests 文件夹下。 65 | 66 | 当一切开发完成之后, 可以发 Pull Request 到 ``master`` 分支, 我们会为你的代码做 Review。同时 CI 也会自动运行测试。 67 | 68 | .. note:: 我们只会 Merge 通过了测试的代码。 69 | -------------------------------------------------------------------------------- /docs/deploy.rst: -------------------------------------------------------------------------------- 1 | 部署 2 | ===================== 3 | 4 | .. note:: 本节所讨论的是将 WeRoBot 作为独立服务运行情况下的部署操作。 如果你希望将 WeRoBot 集成到其他 Web 框架内,请阅读 :doc:`contrib` 5 | 6 | 在独立服务器上部署 7 | ---------------------- 8 | 9 | 使用 ``werobot.run`` 来启动 WSGI 服务器 10 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 11 | 12 | 你可以在 ``werobot.config`` 中配置好 WeRoBot 需要监听的地址和端口号, 然后使用 ``werobot.run`` 来启动服务器 :: 13 | 14 | import werobot 15 | 16 | robot = werobot.WeRoBot(token='tokenhere') 17 | 18 | @robot.handler 19 | def echo(message): 20 | return 'Hello World!' 21 | 22 | robot.config['HOST'] = '0.0.0.0' 23 | robot.config['PORT'] = 80 24 | 25 | robot.run() 26 | 27 | .. note:: 你需要 root 或管理员权限才能监听 1024 以下的端口。 28 | 29 | 你可以通过传递 `server` 参数来手动指定使用的服务器 :: 30 | 31 | import werobot 32 | 33 | robot = werobot.WeRoBot(token='tokenhere') 34 | 35 | @robot.handler 36 | def echo(message): 37 | return 'Hello World!' 38 | 39 | robot.config['HOST'] = '0.0.0.0' 40 | robot.config['PORT'] = 80 41 | 42 | robot.run(server='gevent') 43 | 44 | server 支持以下几种: 45 | 46 | + cgi 47 | + flup 48 | + wsgiref 49 | + waitress 50 | + cherrypy 51 | + paste 52 | + fapws3 53 | + tornado 54 | + gae 55 | + twisted 56 | + diesel 57 | + meinheld 58 | + gunicorn 59 | + eventlet 60 | + gevent 61 | + rocket 62 | + bjoern 63 | + auto 64 | 65 | 当 server 为 auto 时, WeRoBot 会自动依次尝试以下几种服务器: 66 | 67 | + Waitress 68 | + Paste 69 | + Twisted 70 | + CherryPy 71 | + WSGIRef 72 | 73 | 所以,只要你安装了相应的服务器软件,就可以使用 ``werobot.run`` 直接跑在生产环境下。 74 | 75 | .. note:: server 的默认值为 ``auto`` 。 76 | .. attention:: `WSGIRef <http://docs.python.org/library/wsgiref.html#module-wsgiref.simple_server>`_ 的性能非常差, 仅能用于开发环境。 如果你要在生产环境下部署 WeRoBot , 请确保你在使用其他 server 。 77 | 78 | 通过 WSGI HTTP Server 运行 WeRoBot 79 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 80 | 81 | ``werobot.wsgi`` 暴露了一个 WSGI Application ,你可以使用任何你喜欢的 WSGI HTTP Server 来部署 WeRoBot。 82 | 比如, 如果你想用 Gunicorn 来部署 :: 83 | 84 | # FileName: robot.py 85 | from werobot import WeRoBot 86 | robot = WeRoBot() 87 | 88 | 那么你只需要在 Shell 下运行 :: 89 | 90 | gunicorn robot:robot.wsgi 91 | 92 | 就可以了。 93 | 94 | 使用 Supervisor 管理守护进程 95 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 96 | 97 | 请注意, ``werobot.run`` 是跑在 **非守护进程模式下** 的——也就是说,一旦你关闭终端,进程就会自动退出。 98 | 99 | 我们建议您使用 `Supervisor <http://supervisord.org/>`_ 来管理 WeRoBot 的进程。 100 | 101 | 配置文件样例: :: 102 | 103 | [program:wechat_robot] 104 | command = python /home/<username>/robot.py 105 | user = <username> 106 | redirect_stderr = true 107 | stdout_logfile = /home/<username>/logs/robot.log 108 | stdout_errfile = /home/<username>/logs/robot_error.log 109 | 110 | 使用 Nginx 进行反向代理 111 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 112 | 113 | 微信服务器只支持80端口的机器人——显然,你的服务器上不会只跑着一个微信机器人。对于这种情况,我们建议您使用 Nginx 来进行反向代理。 114 | 115 | 配置文件样例: :: 116 | 117 | server { 118 | server_name example.com; 119 | listen 80; 120 | 121 | location / { 122 | proxy_pass_header Server; 123 | proxy_redirect off; 124 | proxy_pass http://127.0.0.1:12233; 125 | } 126 | } 127 | 128 | .. note:: 在这个例子中, WeRoBot 的端口号为 12233。你应该在微信管理后台中将服务器地址设为 ``http://example.com`` 。 129 | 130 | 在SAE上部署 131 | ----------------- 132 | 133 | .. attention:: 从 :ref:`Version 1.11.0` 开始,WeRoBot 停止测试 SAE 相关部分的代码。 134 | 135 | 新浪云上的 Python 应用的入口为 index.wsgi:application ,也就是 index.wsgi 这个文件中名为 application 的 callable object。 136 | 137 | 138 | 所以,假设你在 `robot.py` 中使用了 WeRoBot :: 139 | 140 | # filename: robot.py 141 | import werobot 142 | 143 | robot = werobot.WeRoBot(token='tokenhere') 144 | 145 | 146 | @robot.handler 147 | def echo(message): 148 | return 'Hello World!' 149 | 150 | 你需要再创建一个 `index.wsgi` 文件, 里面写 :: 151 | 152 | import sae 153 | from robot import robot 154 | 155 | 156 | application = sae.create_wsgi_app(robot.wsgi) 157 | 158 | 然后按照 SAE 的要求编写好 `config.yaml` 文件就可以了。 159 | 可以参考 `示例仓库 <https://github.com/whtsky/WeRoBot-SAE-demo>`_ 160 | 161 | 如果你希望使用 SAE 提供的 KVDB 存储 Session 数据, 可以选择 :class:`werobot.session.saekvstorage` 作为你的 Session Storage. 162 | 163 | -------------------------------------------------------------------------------- /docs/encryption.rst: -------------------------------------------------------------------------------- 1 | 消息加解密 2 | ========== 3 | 4 | WeRoBot 支持对消息的加解密,即微信公众号的安全模式。 5 | 在开启消息加解密功能之前,请先阅读微信官方的 `消息加解密说明 <https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1434696670>`_ 6 | 为 WeRoBot 开启消息加密功能,首先需要安装 ``cryptography`` :: 7 | 8 | pip install cryptography 9 | 10 | 之后, 你只需要将开发者 ID(`AppID`) 和微信公众平台后台设置的 `EncodingAESKey` 加到 WeRoBot 的 :ref:`Config` 里面就可以了 :: 11 | 12 | from werobot import WeRoBot 13 | robot = WeRoBot() 14 | robot.config["APP_ID"] = "Your AppID" 15 | robot.config['ENCODING_AES_KEY'] = 'Your Encoding AES Key' 16 | 17 | WeRoBot 之后会自动进行消息的加解密工作。 18 | -------------------------------------------------------------------------------- /docs/error-page.rst: -------------------------------------------------------------------------------- 1 | 错误页面 2 | ========== 3 | ``WeRoBot`` 自带了一个错误页面,它将会在 Signature 验证不通过的时候返回错误页面。 4 | 5 | 定制错误页面 6 | ------------ 7 | 如果你想为 ``WeRoBot`` 指定 Signature 验证不通过时显示的错误页面,可以这么做: :: 8 | 9 | @robot.error_page 10 | def make_error_page(url): 11 | return "<h1>喵喵喵 %s 不是给麻瓜访问的快走开</h1>" % url 12 | -------------------------------------------------------------------------------- /docs/handlers.rst: -------------------------------------------------------------------------------- 1 | Handler 2 | ========= 3 | 4 | 5 | WeRoBot会将合法的请求发送给 handlers 依次执行。 6 | 7 | 如果某一个 Handler 返回了非空值, WeRoBot 就会根据这个值创建回复,后面的 handlers 将不会被执行。 8 | 9 | 你可以通过修饰符或 :meth:`~werobot.robot.BaseRoBot.add_handler` 添加 handler :: 10 | 11 | import werobot 12 | 13 | robot = werobot.WeRoBot(token='tokenhere') 14 | 15 | # 通过修饰符添加handler 16 | @robot.handler 17 | def echo(message): 18 | return 'Hello World!' 19 | 20 | # 通过`add_handler`添加handler 21 | def echo(message): 22 | return 'Hello World!' 23 | robot.add_handler(echo) 24 | 25 | 类型过滤 26 | ------------ 27 | 28 | 在大多数情况下, 一个 Handler 并不能处理所有类型的消息。幸运的是, WeRoBot 可以帮你过滤收到的消息。 29 | 30 | 只想处理被新用户关注的消息?:: 31 | 32 | import werobot 33 | 34 | robot = werobot.WeRoBot(token='tokenhere') 35 | 36 | @robot.subscribe 37 | def subscribe(message): 38 | return 'Hello My Friend!' 39 | 40 | robot.run() 41 | 42 | 或者,你的 handler 只能处理文本? :: 43 | 44 | import werobot 45 | 46 | robot = werobot.WeRoBot(token='tokenhere') 47 | 48 | @robot.text 49 | def echo(message): 50 | return message.content 51 | 52 | robot.run() 53 | 54 | 在 WeRobot 中我们把请求分成了 Message 和 Event 两种类型,针对两种类型的请求分别有不同的 Handler。 55 | 56 | ======================================================================================================== ========================================= 57 | 修饰符 类型 58 | ======================================================================================================== ========================================= 59 | :func:`robot.text <werobot.robot.BaseRoBot.text>` 文本 (Message) 60 | :func:`robot.image <werobot.robot.BaseRoBot.image>` 图像 (Message) 61 | :func:`robot.location <werobot.robot.BaseRoBot.location>` 位置 (Message) 62 | :func:`robot.link <werobot.robot.BaseRoBot.link>` 链接 (Message) 63 | :func:`robot.voice <werobot.robot.BaseRoBot.voice>` 语音 (Message) 64 | :func:`robot.unknown <werobot.robot.BaseRoBot.unknown>` 未知类型 (Message) 65 | :func:`robot.subscribe <werobot.robot.BaseRoBot.subscribe>` 被关注 (Event) 66 | :func:`robot.unsubscribe <werobot.robot.BaseRoBot.unsubscribe>` 被取消关注 (Event) 67 | :func:`robot.click <werobot.robot.BaseRoBot.click>` 自定义菜单事件 (Event) 68 | :func:`robot.view <werobot.robot.BaseRoBot.view>` 链接 (Event) 69 | :func:`robot.scancode_push <werobot.robot.BaseRoBot.scancode_push>` 扫描推送 (Event) 70 | :func:`robot.scancode_waitmsg <werobot.robot.BaseRoBot.scancode_waitmsg>` 扫描弹消息 (Event) 71 | :func:`robot.pic_sysphoto <werobot.robot.BaseRoBot.pic_sysphoto>` 弹出系统拍照发图(Event) 72 | :func:`robot.pic_photo_or_album <werobot.robot.BaseRoBot.pic_photo_or_album>` 弹出拍照或者相册发图(Event) 73 | :func:`robot.pic_weixin <werobot.robot.BaseRoBot.pic_weixin>` 弹出微信相册发图器(Event) 74 | :func:`robot.location_select <werobot.robot.BaseRoBot.location_select>` 弹出地理位置选择器(Event) 75 | :func:`robot.scan <werobot.robot.BaseRoBot.scan>` 已关注扫描二维码(Event) 76 | :func:`robot.user_scan_product <werobot.robot.BaseRoBot.user_scan_product>` 打开商品主页事件推送(Event) 77 | :func:`robot.user_scan_product_enter_session <werobot.robot.BaseRoBot.user_scan_product_enter_session>` 进入公众号事件推送(Event) 78 | :func:`robot.user_scan_product_async <werobot.robot.BaseRoBot.user_scan_product_async>` 地理位置信息异步推送(Event) 79 | :func:`robot.user_scan_product_verify_action <werobot.robot.BaseRoBot.user_scan_product_verify_action>` 商品审核结果推送(Event) 80 | :func:`robot.card_pass_check <werobot.robot.BaseRoBot.card_pass_check>` 卡券通过审核 (Event) 81 | :func:`robot.card_not_pass_check <werobot.robot.BaseRoBot.card_not_pass_check>` 卡券未通过审核 (Event) 82 | :func:`robot.user_get_card <werobot.robot.BaseRoBot.user_get_card>` 用户领取卡券 (Event) 83 | :func:`robot.user_gifting_card <werobot.robot.BaseRoBot.user_gifting_card>` 用户转赠卡券 (Event) 84 | :func:`robot.user_del_card <werobot.robot.BaseRoBot.user_del_card>` 用户删除卡券 (Event) 85 | :func:`robot.user_consume_card <werobot.robot.BaseRoBot.user_consume_card>` 卡券被核销 (Event) 86 | :func:`robot.user_pay_from_pay_cell <werobot.robot.BaseRoBot.user_pay_from_pay_cell>` 微信买单完成 (Event) 87 | :func:`robot.user_view_card <werobot.robot.BaseRoBot.user_view_card>` 用户进入会员卡 (Event) 88 | :func:`robot.user_enter_session_from_card <werobot.robot.BaseRoBot.user_enter_session_from_card>` 用户卡券里点击查看公众号进入会话 (Event) 89 | :func:`robot.update_member_card <werobot.robot.BaseRoBot.update_member_card>` 会员卡积分余额发生变动 (Event) 90 | :func:`robot.card_sku_remind <werobot.robot.BaseRoBot.card_sku_remind>` 库存警告 (Event) 91 | :func:`robot.card_pay_order <werobot.robot.BaseRoBot.card_pay_order>` 券点发生变动 (Event) 92 | :func:`robot.templatesendjobfinish_event <werobot.robot.BaseRoBot.templatesendjobfinish_event>` 模板信息推送事件 (Event) 93 | :func:`robot.submit_membercard_user_info <werobot.robot.BaseRoBot.submit_membercard_user_info>` 激活卡券 (Event) 94 | :func:`robot.location_event <werobot.robot.BaseRoBot.location_event>` 上报位置 (Event) 95 | :func:`robot.unknown_event <werobot.robot.BaseRoBot.unknown_event>` 未知类型 (Event) 96 | ======================================================================================================== ========================================= 97 | 98 | 额,这个 handler 想处理文本信息和地理位置信息? :: 99 | 100 | import werobot 101 | 102 | robot = werobot.WeRoBot(token='tokenhere') 103 | 104 | @robot.text 105 | @robot.location 106 | def handler(message): 107 | # Do what you love to do 108 | pass 109 | 110 | robot.run() 111 | 112 | 当然,你也可以用 :meth:`~werobot.robot.BaseRoBot.add_handler` 函数添加handler,就像这样:: 113 | 114 | import werobot 115 | 116 | robot = werobot.WeRoBot(token='tokenhere') 117 | 118 | def handler(message): 119 | # Do what you love to do 120 | pass 121 | 122 | robot.add_handler(handler, type='text') 123 | robot.add_handler(handler, type='location') 124 | 125 | robot.run() 126 | 127 | .. note:: 通过 ``robot.handler`` 添加的 handler 将收到所有信息;只有在其他 handler 没有给出返回值的情况下, 通过 ``robot.handler`` 添加的 handler 才会被调用。 128 | 129 | robot.key_click —— 回应自定义菜单 130 | --------------------------------- 131 | 132 | :meth:`~werobot.robot.BaseRoBot.key_click` 是对 :meth:`~werobot.robot.BaseRoBot.click` 修饰符的改进。 133 | 134 | 如果你在自定义菜单中定义了一个 Key 为 ``abort`` 的菜单,响应这个菜单的 handler 可以写成这样 :: 135 | 136 | @robot.key_click("abort") 137 | def abort(): 138 | return "I'm a robot" 139 | 140 | 当然,如果你不喜欢用 :meth:`~werobot.robot.BaseRoBot.key_click` ,也可以写成这样 :: 141 | 142 | @robot.click 143 | def abort(message): 144 | if message.key == "abort": 145 | return "I'm a robot" 146 | 147 | 两者是等价的。 148 | 149 | robot.filter —— 回应有指定文本的消息 150 | ------------------------------------- 151 | 152 | :meth:`~werobot.robot.BaseRoBot.filter` 是对 :meth:`~werobot.robot.BaseRoBot.text` 修饰符的改进。 153 | 154 | 现在你可以写这样的代码 :: 155 | 156 | @robot.filter("a") 157 | def a(): 158 | return "正文为 a " 159 | 160 | import re 161 | 162 | 163 | @robot.filter(re.compile(".*?bb.*?")) 164 | def b(): 165 | return "正文中含有 bb " 166 | 167 | @robot.filter(re.compile(".*?c.*?"), "d") 168 | def c(): 169 | return "正文中含有 c 或正文为 d" 170 | 171 | @robot.filter(re.compile("(.*)?e(.*)?"), "f") 172 | def d(message, session, match): 173 | if match: 174 | return "正文为 " + match.group(1) + "e" + match.group(2) 175 | return "正文为 f" 176 | 177 | 这段代码等价于 :: 178 | 179 | @robot.text 180 | def a(message): 181 | if message.content == "a": 182 | return "正文为 a " 183 | import re 184 | 185 | 186 | @robot.text 187 | def b(message): 188 | if re.compile(".*?bb.*?").match(message.content): 189 | return "正文中含有 b " 190 | 191 | @robot.text 192 | def c(message): 193 | if re.compile(".*?c.*?").match(message.content) or message.content == "d": 194 | return "正文中含有 c 或正文为 d" 195 | 196 | @robot.text 197 | def d(message): 198 | match = re.compile("(.*)?e(.*)?").match(message.content) 199 | if match: 200 | return "正文为 " + match.group(1) + "e" + match.group(2) 201 | if message.content == "f": 202 | return "正文为 f" 203 | 204 | 如果你想通过修饰符以外的方法添加 filter,可以使用 :func:`~werobot.robot.BaseRoBot.add_filter` 方法 :: 205 | 206 | def say_hello(): 207 | return "hello!" 208 | 209 | robot.add_filter(func=say_hello, rules=["hello", "hi", re.compile(".*?hello.*?")]) 210 | 211 | 更多内容详见 :class:`werobot.robot.BaseRoBot` 212 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | WeRoBot 2 | ======= 3 | 4 | 5 | WeRoBot 是一个微信公众号开发框架。 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | start 11 | encryption 12 | deploy 13 | handlers 14 | session 15 | client 16 | messages 17 | events 18 | replies 19 | config 20 | contrib 21 | error-page 22 | utils 23 | contribution-guide 24 | api 25 | changelog 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^<target^>` where ^<target^> is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\WeRoBot.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\WeRoBot.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/messages.rst: -------------------------------------------------------------------------------- 1 | Message 2 | ========== 3 | 4 | Message 公共属性 5 | ---------------- 6 | 7 | 除了 UnknownMessage, 每一种 Message 都包括以下属性: 8 | 9 | =========== =================================== 10 | name value 11 | =========== =================================== 12 | message_id 消息id,64位整型 13 | target 开发者账号( OpenID ) 14 | source 发送方账号( OpenID ) 15 | time 信息发送的时间,一个UNIX时间戳。 16 | raw 信息的原始 XML 格式 17 | =========== =================================== 18 | 19 | TextMessage 20 | ------------ 21 | 22 | TextMessage 的属性: 23 | 24 | 25 | ======== =================================== 26 | name value 27 | ======== =================================== 28 | type 'text' 29 | content 信息的内容 30 | ======== =================================== 31 | 32 | ImageMessage 33 | ------------- 34 | 35 | ImageMessage 的属性: 36 | 37 | ======= ================================== 38 | name value 39 | ======= ================================== 40 | type 'image' 41 | img 图片网址。你可以从这个网址下到图片 42 | ======= ================================== 43 | 44 | LinkMessage 45 | ------------ 46 | ============ ================================== 47 | name value 48 | ============ ================================== 49 | type 'link' 50 | title 消息标题 51 | description 消息描述 52 | url 消息链接 53 | ============ ================================== 54 | 55 | 56 | LocationMessage 57 | ---------------- 58 | 59 | LocationMessage 的属性: 60 | 61 | ========= =================================== 62 | name value 63 | ========= =================================== 64 | type 'location' 65 | location 一个元组。(纬度, 经度) 66 | scale 地图缩放大小 67 | label 地理位置信息 68 | ========= =================================== 69 | 70 | 71 | VoiceMessage 72 | -------------------- 73 | 74 | VoiceMessage 的属性: 75 | 76 | ============ ===================================== 77 | name value 78 | ============ ===================================== 79 | type 'voice' 80 | media_id 消息媒体 ID 81 | format 声音格式 82 | recognition 语音识别结果 83 | ============ ===================================== 84 | 85 | VideoMessage 86 | -------------------- 87 | 88 | VideoMessage 的属性: 89 | 90 | ================ ===================================== 91 | name value 92 | ================ ===================================== 93 | type 'video' 94 | media_id 消息媒体 ID 95 | thumb_media_id 视频缩略图媒体 ID 96 | ================ ===================================== 97 | 98 | UnknownMessage 99 | --------------- 100 | 101 | UnknownMessage 的属性: 102 | 103 | ========= ===================================== 104 | name value 105 | ========= ===================================== 106 | type 'unknown' 107 | raw 请求的正文部分。标准的XML格式。 108 | ========= ===================================== 109 | 110 | .. note:: 如果你不为 WeRoBot 贡献代码,你完全可以无视掉 UnknownMessage 。在正常的使用中,WeRoBot应该不会收到 `UnknownMessage` ——除非 WeRoBot 停止开发。 111 | -------------------------------------------------------------------------------- /docs/replies.rst: -------------------------------------------------------------------------------- 1 | 回复 2 | ============== 3 | 4 | 5 | 你可以在构建Reply时传入一个合法的 `Message` 对象来自动生成 `source` 和 `target` :: 6 | 7 | reply = TextReply(message=message, content='Hello!') 8 | 9 | 10 | TextReply 11 | ----------- 12 | 13 | `TextReply` 是简单的文本消息,构造函数的参数如下: 14 | 15 | ========= =================================== 16 | name value 17 | ========= =================================== 18 | content 信息正文。 19 | target 信息的目标用户。通常是机器人用户。 20 | source 信息的来源用户。通常是发送信息的用户。 21 | time 信息发送的时间,一个UNIX时间戳。默认情况下会使用当前时间。 22 | ========= =================================== 23 | 24 | .. note:: 如果你的handler返回了一个字符串, WeRoBot会自动将其转化为一个文本消息。 25 | 26 | ImageReply 27 | ----------- 28 | 29 | `ImageReply` 为回复图片消息,构造函数的参数如下: 30 | 31 | ========= =================================== 32 | name value 33 | ========= =================================== 34 | media_id 通过素材管理接口上传多媒体文件,得到的id。 35 | target 信息的目标用户。通常是机器人用户。 36 | source 信息的来源用户。通常是发送信息的用户。 37 | time 信息发送的时间,一个UNIX时间戳。默认情况下会使用当前时间。 38 | ========= =================================== 39 | 40 | VoiceReply 41 | ---------- 42 | 43 | `VoiceReply` 为回复语音消息,构造函数的参数如下: 44 | 45 | ========= =================================== 46 | name value 47 | ========= =================================== 48 | media_id 通过素材管理接口上传多媒体文件,得到的id。 49 | target 信息的目标用户。通常是机器人用户。 50 | source 信息的来源用户。通常是发送信息的用户。 51 | time 信息发送的时间,一个UNIX时间戳。默认情况下会使用当前时间。 52 | ========= =================================== 53 | 54 | VideoReply 55 | ---------- 56 | 57 | `VideoReply` 为回复视频消息,构造函数的参数如下: 58 | 59 | ============ =================================== 60 | name value 61 | ============ =================================== 62 | media_id 通过素材管理接口上传多媒体文件,得到的id。 63 | title 视频消息的标题。可为空。 64 | description 视频消息的描述。可为空。 65 | target 信息的目标用户。通常是机器人用户。 66 | source 信息的来源用户。通常是发送信息的用户。 67 | time 信息发送的时间,一个UNIX时间戳。默认情况下会使用当前时间。 68 | ============ =================================== 69 | 70 | 71 | ArticlesReply 72 | --------------- 73 | 74 | `ArticlesReply` 是图文消息,构造函数的参数如下: 75 | 76 | ========= =================================== 77 | name value 78 | ========= =================================== 79 | content 信息正文。**可为空**。 80 | target 信息的目标用户。通常是机器人用户。 81 | source 信息的来源用户。通常是发送信息的用户。 82 | time 信息发送的时间,一个UNIX时间戳。默认情况下会使用当前时间。 83 | ========= =================================== 84 | 85 | 你需要给 `ArticlesReply` 添加 `Article` 来增加图文。 86 | `Article` 类位于 `werobot.reply.Article` 。 87 | 88 | `Article` 的构造函数的参数如下: 89 | 90 | ============ =================================== 91 | name value 92 | ============ =================================== 93 | title 标题 94 | description 描述 95 | img 图片链接 96 | url 点击图片后跳转链接 97 | ============ =================================== 98 | 99 | 注意,微信公众平台对图片链接有特殊的要求,详情可以在 100 | `消息接口使用指南 <http://mp.weixin.qq.com/cgi-bin/readtemplate?t=wxm-callbackapi-doc&lang=zh_CN>`_ 里看到。 101 | 102 | 在构造完一个 `Article` 后, 你需要通过 `ArticlesReply` 的 `add_article` 参数把它添加进去。就像这样: :: 103 | 104 | from werobot.replies import ArticlesReply, Article 105 | reply = ArticlesReply(message=message) 106 | article = Article( 107 | title="WeRoBot", 108 | description="WeRoBot是一个微信机器人框架", 109 | img="https://github.com/apple-touch-icon-144.png", 110 | url="https://github.com/whtsky/WeRoBot" 111 | ) 112 | reply.add_article(article) 113 | 114 | .. note:: 根据微信公众平台的 `最新公告 <https://mp.weixin.qq.com/cgi-bin/announce?action=getannouncement&announce_id=115383153198yAvN&lang=zh_CN>`_,每个ArticlesReply中 **最多添加1个Article** 。 115 | 116 | 你也可以让你的 handler 返回一个列表, 里面每一个元素都是一个长度为四的列表, 117 | WeRoBot 会将其自动转为 ArticlesReply 。就像这样: :: 118 | 119 | import werobot 120 | 121 | robot = werobot.WeRoBot(token='tokenhere') 122 | 123 | @robot.text 124 | def articles(message): 125 | return [ 126 | [ 127 | "title", 128 | "description", 129 | "img", 130 | "url" 131 | ], 132 | [ 133 | "whtsky", 134 | "I wrote WeRoBot", 135 | "https://secure.gravatar.com/avatar/0024710771815ef9b74881ab21ba4173?s=420", 136 | "http://whouz.com/" 137 | ] 138 | ] 139 | 140 | robot.run() 141 | 142 | 143 | MusicReply 144 | ----------- 145 | 146 | `MusicReply` 是音乐消息,构造函数的参数如下: 147 | 148 | ============= ====================================================================== 149 | name value 150 | ============= ====================================================================== 151 | target 信息的目标用户。通常是机器人用户。 152 | source 信息的来源用户。通常是发送信息的用户。 153 | time 信息发送的时间,一个UNIX时间戳。默认情况下会使用当前时间。 154 | title 标题 155 | description 描述 156 | url 音乐链接 157 | hq_url 高质量音乐链接,WIFI环境优先使用该链接播放音乐。可为空 [3]_ 158 | ============= ====================================================================== 159 | 160 | 你也可以让你的 handler 返回一个长度为三或四的列表, [3]_ 161 | WeRoBot 会将其自动转为 MusicReply 。就像这样: :: 162 | 163 | import werobot 164 | 165 | robot = werobot.WeRoBot(token='tokenhere') 166 | 167 | @robot.text 168 | def music(message): 169 | return [ 170 | "title", 171 | "description", 172 | "music_url", 173 | "hq_music_url" 174 | ] 175 | 176 | @robot.text 177 | def music2(message): 178 | return [ 179 | "微信你不懂爱", 180 | "龚琳娜最新力作", 181 | "http://weixin.com/budongai.mp3", 182 | ] 183 | 184 | robot.run() 185 | 186 | 187 | .. [3] 如果你省略了高质量音乐链接的地址, WeRoBot 会自动将音乐链接的地址用于高质量音乐链接。 188 | 189 | TransferCustomerServiceReply 190 | ----------------------------- 191 | 192 | 将消息转发到多客服,构造函数的参数如下: 193 | 194 | ============= ====================================================================== 195 | name value 196 | ============= ====================================================================== 197 | target 信息的目标用户。通常是机器人用户。 198 | source 信息的来源用户。通常是发送信息的用户。 199 | time 信息发送的时间,一个UNIX时间戳。默认情况下会使用当前时间。 200 | account 指定会话接入的客服账号,可以没有此参数,没有时会自动分配给可用客服。 201 | ============= ====================================================================== 202 | 203 | SuccessReply 204 | --------------- 205 | 给微信服务器回复 "success"。 206 | 假如服务器无法保证在五秒内处理并回复,需要回复 `SuccessReply` ,这样微信服务器才不会对此作任何处理,并且不会发起重试。 207 | -------------------------------------------------------------------------------- /docs/session.rst: -------------------------------------------------------------------------------- 1 | Session 2 | ========== 3 | 4 | 你可以通过 Session 实现用户状态的记录。 5 | 6 | 一个简单的使用 Session 的 Demo :: 7 | 8 | from werobot import WeRoBot 9 | robot = WeRoBot(token=werobot.utils.generate_token()) 10 | 11 | @robot.text 12 | def first(message, session): 13 | if 'last' in session: 14 | return 15 | session['last'] = message.content 16 | return message.content 17 | 18 | robot.run() 19 | 20 | 开启/关闭 Session 21 | ----------------- 22 | 23 | Session 在 WeRoBot 中默认开启, 并使用 :class:`werobot.session.sqlitestorage.SQLiteStorage` 作为存储后端。 如果想要更换存储后端, 可以修改 :doc:`config` 中的 ``SESSION_STORAGE`` :: 24 | 25 | from werobot import WeRoBot 26 | from werobot.session.filestorage import FileStorage 27 | robot = WeRoBot(token="token") 28 | robot.config['SESSION_STORAGE'] = FileStorage() 29 | 30 | 31 | 如果想要关闭 Session 功能, 只需把 ``SESSION_STORAGE`` 设为 False 即可 :: 32 | 33 | from werobot import WeRoBot 34 | robot = WeRoBot(token="token") 35 | robot.config['SESSION_STORAGE'] = False 36 | 37 | 修改 Handler 以使用 Session 38 | -------------------------------- 39 | 40 | 没有打开 Session 的时候,一个标准的 WeRoBot Handler 应该是这样的 :: 41 | 42 | @robot.text 43 | def hello(message): 44 | return "Hello!" 45 | 46 | 而在打开 Session 之后, 如果你的 handler 不需要使用 Session ,可以保持不变; 如果需要使用 Session ,则这个 Handler 需要修改为接受第二个参数: ``session`` :: 47 | 48 | @robot.subscribe_event 49 | def intro(message): 50 | return "Hello!" 51 | 52 | @robot.text 53 | def hello(message, session): 54 | count = session.get("count", 0) + 1 55 | session["count"] = count 56 | return "Hello! You have sent %s messages to me" % count 57 | 58 | 传入的 ``session`` 参数是一个标准的 Python 字典。 59 | 60 | 更多可用的 Session Storage 详见 :ref:`Session 对象`。 61 | -------------------------------------------------------------------------------- /docs/start.rst: -------------------------------------------------------------------------------- 1 | 入门 2 | ============= 3 | 4 | 5 | Hello World 6 | ------------- 7 | 最简单的Hello World, 会给收到的每一条信息回复 `Hello World` :: 8 | 9 | import werobot 10 | 11 | robot = werobot.WeRoBot(token='tokenhere') 12 | 13 | @robot.handler 14 | def hello(message): 15 | return 'Hello World!' 16 | 17 | # 让服务器监听在 0.0.0.0:80 18 | robot.config['HOST'] = '0.0.0.0' 19 | robot.config['PORT'] = 80 20 | robot.run() 21 | 22 | 消息处理 23 | -------------- 24 | WeRoBot 会解析微信服务器发来的消息, 并将消息转换成成 :ref:`Message` 或者是 :ref:`Event` 。 25 | :ref:`Message` 表示用户发来的消息,如文本消息、图片消息; :ref:`Event` 则表示用户触发的事件, 如关注事件、扫描二维码事件。 26 | 在消息解析、转换完成后, WeRoBot 会将消息转交给 :ref:`Handler` 进行处理,并将 :ref:`Handler` 的返回值返回给微信服务器。 27 | 28 | 在刚才的 Hello World 中, 我们编写的 :: 29 | 30 | @robot.handler 31 | def hello(message): 32 | return 'Hello World!' 33 | 34 | 就是一个简单的 :ref:`Handler` , `@robot.handler` 意味着 `robot` 会将所有接收到的消息( 包括 :ref:`Message` 和 :ref:`Event` ) 都转交给这个 :ref:`Handler` 来处理。 35 | 当然, 你也可以编写一些只能处理特定消息的 :ref:`Handler` :: 36 | 37 | # @robot.text 修饰的 Handler 只处理文本消息 38 | @robot.text 39 | def echo(message): 40 | return message.content 41 | 42 | # @robot.image 修饰的 Handler 只处理图片消息 43 | @robot.image 44 | def img(message): 45 | return message.img 46 | 47 | 使用 Session 记录用户状态 48 | ------------------------- 49 | 50 | WeRoBot 提供了 :ref:`Session` 功能, 可以让你方便的记录用户状态。 51 | 比如, 这个 Handler 可以判断发消息的用户之前有没有发送过消息 :: 52 | 53 | @robot.text 54 | def first(message, session): 55 | if 'first' in session: 56 | return '你之前给我发过消息' 57 | session['first'] = True 58 | return '你之前没给我发过消息' 59 | 60 | Session 功能默认开启, 并使用 SQLite 存储 Session 数据。 详情请参考 :doc:`session` 文档 61 | 62 | 创建自定义菜单 63 | -------------- 64 | 65 | 自定义菜单能够帮助公众号丰富界面,让用户更好更快地理解公众号的功能。 :class:`werobot.client.Client` 封装了微信的部分 API 接口,我们可以使用 :func:`werobot.client.Client.create_menu` 来创建自定义菜单。 66 | 在使用 Client 之前, 我们需要先提供微信公众平台内的 AppID 和 AppSecret :: 67 | 68 | from werobot import WeRoBot 69 | robot = WeRoBot() 70 | robot.config["APP_ID"] = "你的 AppID" 71 | robot.config["APP_SECRET"] = "你的 AppSecret" 72 | 73 | client = robot.client 74 | 75 | 然后, 我们就可以创建自定义菜单了 :: 76 | 77 | client.create_menu({ 78 | "button":[{ 79 | "type": "click", 80 | "name": "今日歌曲", 81 | "key": "music" 82 | }] 83 | }) 84 | 85 | 注意以上代码只需要运行一次就可以了。在创建完自定义菜单之后, 我们还需要写一个 :ref:`handler` 来响应菜单的点击操作 :: 86 | 87 | @robot.key_click("music") 88 | def music(message): 89 | return '你点击了“今日歌曲”按钮' 90 | 91 | -------------------------------------------------------------------------------- /docs/utils.rst: -------------------------------------------------------------------------------- 1 | 小工具 2 | =============== 3 | 4 | 5 | Token 生成器 6 | --------------- 7 | 8 | WeRoBot帮你准备了一个Token生成器: :: 9 | 10 | import werobot.utils 11 | 12 | print(werobot.utils.generate_token()) 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/hello_world.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import werobot 4 | 5 | robot = werobot.WeRoBot(token='tokenhere') 6 | 7 | 8 | @robot.filter("帮助") 9 | def show_help(message): 10 | return """ 11 | 帮助 12 | XXXXX 13 | """ 14 | 15 | 16 | @robot.text 17 | def hello_world(message): 18 | return 'Hello World!' 19 | 20 | 21 | robot.run() 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = -rw 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bottle 2 | requests 3 | xmltodict 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = sdist --formats=zip,gztar bdist_wheel 3 | test=pytest 4 | 5 | [bdist_wheel] 6 | universal = 1 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf-8 3 | 4 | import io 5 | import werobot 6 | 7 | from setuptools import setup, find_packages 8 | from setuptools.command.test import test as TestCommand 9 | 10 | 11 | class PyTest(TestCommand): 12 | user_options = [("pytest-args=", "a", "Arguments to pass to pytest")] 13 | 14 | def initialize_options(self): 15 | TestCommand.initialize_options(self) 16 | self.pytest_args = "" 17 | 18 | def run_tests(self): 19 | import shlex 20 | 21 | # import here, cause outside the eggs aren't loaded 22 | import pytest 23 | 24 | errno = pytest.main(shlex.split(self.pytest_args)) 25 | sys.exit(errno) 26 | 27 | 28 | with io.open("README.rst", encoding="utf8") as f: 29 | readme = f.read() 30 | readme = readme.replace("latest", "v" + werobot.__version__) 31 | 32 | install_requires = open("requirements.txt").readlines() 33 | setup( 34 | name='WeRoBot', 35 | version=werobot.__version__, 36 | author=werobot.__author__, 37 | author_email='whtsky@me.com', 38 | url='https://github.com/offu/WeRoBot', 39 | packages=find_packages(), 40 | keywords="wechat weixin werobot", 41 | description='WeRoBot: writing WeChat Offical Account Robots with fun', 42 | long_description=readme, 43 | install_requires=install_requires, 44 | include_package_data=True, 45 | license='MIT License', 46 | classifiers=[ 47 | 'Development Status :: 4 - Beta', 48 | 'License :: OSI Approved :: MIT License', 49 | 'Operating System :: MacOS', 50 | 'Operating System :: POSIX', 51 | 'Operating System :: POSIX :: Linux', 52 | 'Programming Language :: Python', 53 | 'Programming Language :: Python :: 3.6', 54 | 'Programming Language :: Python :: 3.7', 55 | 'Programming Language :: Python :: 3.8', 56 | 'Programming Language :: Python :: 3.9', 57 | 'Programming Language :: Python :: Implementation :: CPython', 58 | 'Intended Audience :: Developers', 59 | 'Topic :: Software Development :: Libraries', 60 | 'Topic :: Software Development :: Libraries :: Python Modules', 61 | 'Topic :: Utilities', 62 | ], 63 | tests_require=['pytest'], 64 | cmdclass={"pytest": PyTest}, 65 | extras_require={'crypto': ["cryptography"]}, 66 | package_data={'werobot': ['contrib/*.html']} 67 | ) 68 | -------------------------------------------------------------------------------- /tests/client_config.py: -------------------------------------------------------------------------------- 1 | APP_ID = "123" 2 | APP_SECRET = "321" 3 | -------------------------------------------------------------------------------- /tests/django_test_env/django_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/offu/WeRoBot/7b3d62d7ae126fc8bc9e033e6358dec2852d1d5a/tests/django_test_env/django_test/__init__.py -------------------------------------------------------------------------------- /tests/django_test_env/django_test/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_test project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.9.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = 'eg@1m&jgpt)t1$l&=vw3r*v@$j14zdiih8!d17_tvd!3ksb43t' 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = [ 32 | 'django.contrib.admin', 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | 'django.contrib.sessions', 36 | 'django.contrib.messages', 37 | 'django.contrib.staticfiles', 38 | ] 39 | 40 | MIDDLEWARE_CLASSES = [ 41 | 'django.contrib.sessions.middleware.SessionMiddleware', 42 | 'django.middleware.common.CommonMiddleware', 43 | 'django.middleware.csrf.CsrfViewMiddleware', 44 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 45 | 'django.contrib.messages.middleware.MessageMiddleware', 46 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 47 | ] 48 | 49 | ROOT_URLCONF = 'django_test.urls' 50 | 51 | TEMPLATES = [ 52 | { 53 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 54 | 'DIRS': [], 55 | 'APP_DIRS': True, 56 | 'OPTIONS': { 57 | 'context_processors': [ 58 | 'django.template.context_processors.debug', 59 | 'django.template.context_processors.request', 60 | 'django.contrib.auth.context_processors.auth', 61 | 'django.contrib.messages.context_processors.messages', 62 | ], 63 | }, 64 | }, 65 | ] 66 | 67 | WSGI_APPLICATION = 'django_test.wsgi.application' 68 | 69 | # Database 70 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 71 | 72 | DATABASES = { 73 | 'default': { 74 | 'ENGINE': 'django.db.backends.sqlite3', 75 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 76 | } 77 | } 78 | 79 | # Password validation 80 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 81 | 82 | AUTH_PASSWORD_VALIDATORS = [ 83 | { 84 | 'NAME': 'django.contrib.auth.' 85 | 'password_validation.UserAttributeSimilarityValidator', 86 | }, 87 | { 88 | 'NAME': 'django.contrib.auth.' 89 | 'password_validation.MinimumLengthValidator', 90 | }, 91 | { 92 | 'NAME': 'django.contrib.auth.' 93 | 'password_validation.CommonPasswordValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.' 97 | 'password_validation.NumericPasswordValidator', 98 | }, 99 | ] 100 | 101 | # Internationalization 102 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 103 | 104 | LANGUAGE_CODE = 'en-us' 105 | 106 | TIME_ZONE = 'UTC' 107 | 108 | USE_I18N = True 109 | 110 | USE_L10N = True 111 | 112 | USE_TZ = True 113 | 114 | # Static files (CSS, JavaScript, Images) 115 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 116 | 117 | STATIC_URL = '/static/' 118 | -------------------------------------------------------------------------------- /tests/django_test_env/django_test/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """django_test URL Configuration 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/1.9/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: url(r'^#39;, views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: url(r'^#39;, Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.conf.urls import url, include 15 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 16 | """ 17 | from django.conf.urls import url 18 | from django.contrib import admin 19 | from werobot import WeRoBot 20 | from werobot.contrib.django import make_view 21 | from werobot.utils import generate_token 22 | 23 | robot = WeRoBot( 24 | SESSION_STORAGE=False, 25 | token="TestDjango", 26 | app_id="9998877", 27 | encoding_aes_key=generate_token(32) 28 | ) 29 | 30 | 31 | @robot.text 32 | def text_handler(): 33 | return 'hello' 34 | 35 | 36 | @robot.error_page 37 | def make_error_page(url): 38 | return '喵' 39 | 40 | 41 | urlpatterns = [ 42 | url(r'^admin/', admin.site.urls), 43 | url(r'^robot/', make_view(robot)) 44 | ] 45 | -------------------------------------------------------------------------------- /tests/django_test_env/django_test/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_test project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_test.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/django_test_env/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_test.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/messages/test_entries.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.messages.entries import get_value, StringEntry, FloatEntry, IntEntry 4 | from werobot.utils import to_text 5 | 6 | 7 | class NoNameMessage(object): 8 | test_int = IntEntry("TestInt") 9 | test_string_to_int = IntEntry("TestStringToInt") 10 | test_float_to_int = IntEntry("TestFloatToInt") 11 | test_int_none = IntEntry("MIAOMIAOMIAO") 12 | 13 | test_float = FloatEntry("TestFloat") 14 | test_string_to_float = FloatEntry("TestStringToFloat") 15 | test_float_none = FloatEntry("WANGWANG") 16 | 17 | test_string = StringEntry("TestString") 18 | test_int_to_string = StringEntry("TestIntToString") 19 | test_float_to_string = StringEntry("TestFloatToString") 20 | test_chinese = StringEntry("TestChinese") 21 | test_string_none = StringEntry("HAHAHA") 22 | 23 | def __init__(self): 24 | message = { 25 | "TestInt": 123, 26 | "TestFloat": 0.00001, 27 | "TestString": "hello", 28 | "TestStringToInt": "123", 29 | "TestFloatToInt": 123.000, 30 | "TestStringToFloat": "0.00001", 31 | "TestIntToString": 123, 32 | "TestFloatToString": 0.00001, 33 | "TestChinese": "喵", 34 | } 35 | self.__dict__.update(message) 36 | 37 | 38 | t = NoNameMessage() 39 | 40 | 41 | def test_int_entry(): 42 | assert isinstance(t.test_int, int) 43 | assert t.test_int == 123 44 | assert isinstance(t.test_string_to_int, int) 45 | assert t.test_string_to_int == 123 46 | assert isinstance(t.test_float_to_int, int) 47 | assert t.test_float_to_int == 123 48 | assert t.test_int_none is None 49 | 50 | 51 | def test_float_entry(): 52 | assert isinstance(t.test_float, float) 53 | assert t.test_float == 0.00001 54 | assert isinstance(t.test_string_to_float, float) 55 | assert t.test_string_to_float == 0.00001 56 | assert t.test_float_none is None 57 | 58 | 59 | def test_string_entry(): 60 | assert isinstance(t.test_string, str) 61 | assert t.test_string == "hello" 62 | assert isinstance(t.test_int_to_string, str) 63 | assert t.test_int_to_string == "123" 64 | assert isinstance(t.test_float_to_string, str) 65 | assert t.test_float_to_string == "1e-05" 66 | assert isinstance(t.test_chinese, str) 67 | assert t.test_chinese == to_text("喵") 68 | assert t.test_string_none is None 69 | 70 | 71 | class FakeIntance: 72 | pass 73 | 74 | 75 | def test_get_value(): 76 | instance = FakeIntance() 77 | instance.b = 6 78 | instance.a = {'c': 'd'} 79 | assert get_value(instance, 'd', 'default') == 'default' 80 | assert get_value(instance, 'b', 'default') == 6 81 | assert get_value(instance, 'a.c') == 'd' 82 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from werobot import WeRoBot 4 | from werobot.config import Config 5 | from werobot.utils import generate_token 6 | 7 | basedir = os.path.dirname(os.path.abspath(__file__)) 8 | 9 | TOKEN = "123" 10 | 11 | 12 | def test_from_pyfile(): 13 | config = Config() 14 | assert "TOKEN" not in config 15 | config.from_pyfile(os.path.join(basedir, "test_config.py")) 16 | assert config["TOKEN"] == "123" 17 | 18 | 19 | def test_from_object(): 20 | config = Config() 21 | config.from_pyfile(os.path.join(basedir, "test_config.py")) 22 | 23 | class ConfigObject: 24 | TOKEN = "456" 25 | 26 | config.from_object(ConfigObject()) 27 | assert config["TOKEN"] == "456" 28 | 29 | 30 | def test_config_attribute(): 31 | robot = WeRoBot(SESSION_STORAGE=False) 32 | assert not robot.token 33 | token = generate_token() 34 | robot.config["TOKEN"] = token 35 | assert robot.token == token 36 | 37 | token = generate_token() 38 | robot.token = token 39 | assert robot.config["TOKEN"] == token 40 | -------------------------------------------------------------------------------- /tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import random 5 | import sys 6 | import time 7 | 8 | import pytest 9 | import tornado 10 | import webtest 11 | from tornado.testing import AsyncHTTPSTestCase 12 | from webtest.app import AppError 13 | 14 | from werobot.parser import parse_xml, process_message 15 | from werobot.utils import generate_token, get_signature 16 | 17 | 18 | @pytest.fixture 19 | def wsgi_tester(): 20 | def tester(app, token, endpoint): 21 | test_app = webtest.TestApp(app) 22 | 23 | response = test_app.get(endpoint, expect_errors=True) 24 | assert response.status_code == 403 25 | 26 | timestamp = str(time.time()) 27 | nonce = str(random.randint(0, 10000)) 28 | signature = get_signature(token, timestamp, nonce) 29 | echostr = generate_token() 30 | 31 | params = "?timestamp=%s&nonce=%s&signature=%s&echostr=%s" % ( 32 | timestamp, nonce, signature, echostr 33 | ) 34 | response = test_app.get(endpoint + params) 35 | 36 | assert response.status_code == 200 37 | assert response.body.decode('utf-8') == echostr 38 | 39 | response = test_app.get(endpoint, expect_errors=True) 40 | 41 | assert response.status_code == 403 42 | assert response.body.decode('utf-8') == u'喵' 43 | 44 | xml = """ 45 | <xml> 46 | <ToUserName><![CDATA[toUser]]></ToUserName> 47 | <FromUserName><![CDATA[fromUser]]></FromUserName> 48 | <CreateTime>1348831860</CreateTime> 49 | <MsgType><![CDATA[text]]></MsgType> 50 | <Content><![CDATA[this is a test]]></Content> 51 | <MsgId>1234567890123456</MsgId> 52 | </xml> 53 | """ 54 | with pytest.raises(AppError): 55 | # WebTest will raise an AppError 56 | # if the status_code is not >= 200 and < 400. 57 | test_app.post(endpoint, xml, content_type="text/xml") 58 | 59 | response = test_app.post( 60 | endpoint + params, xml, content_type="text/xml" 61 | ) 62 | 63 | assert response.status_code == 200 64 | response = process_message(parse_xml(response.body)) 65 | assert response.content == 'hello' 66 | 67 | return tester 68 | 69 | 70 | @pytest.fixture(scope="module") 71 | def hello_robot(): 72 | from werobot import WeRoBot 73 | robot = WeRoBot(token='', SESSION_STORAGE=False) 74 | 75 | @robot.text 76 | def hello(): 77 | return 'hello' 78 | 79 | @robot.error_page 80 | def make_error_page(url): 81 | return '喵' 82 | 83 | return robot 84 | 85 | 86 | def test_django(): 87 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_test.settings") 88 | sys.path.append( 89 | os.path.join( 90 | os.path.abspath(os.path.dirname(__file__)), 'django_test_env' 91 | ) 92 | ) 93 | 94 | from django.test.utils import setup_test_environment 95 | setup_test_environment() 96 | from django.test.client import Client 97 | from werobot.parser import parse_xml, process_message 98 | import django 99 | 100 | django.setup() 101 | client = Client() 102 | 103 | token = 'TestDjango' 104 | timestamp = str(time.time()) 105 | nonce = str(random.randint(0, 10000)) 106 | signature = get_signature(token, timestamp, nonce) 107 | echostr = generate_token() 108 | 109 | response = client.get( 110 | '/robot/', { 111 | 'signature': signature, 112 | 'timestamp': timestamp, 113 | 'nonce': nonce, 114 | 'echostr': echostr 115 | } 116 | ) 117 | assert response.status_code == 200 118 | assert response.content.decode('utf-8') == echostr 119 | 120 | xml = """ 121 | <xml> 122 | <ToUserName><![CDATA[toUser]]></ToUserName> 123 | <FromUserName><![CDATA[fromUser]]></FromUserName> 124 | <CreateTime>1348831860</CreateTime> 125 | <MsgType><![CDATA[text]]></MsgType> 126 | <Content><![CDATA[this is a test]]></Content> 127 | <MsgId>1234567890123456</MsgId> 128 | </xml>""" 129 | params = "?timestamp=%s&nonce=%s&signature=%s" % \ 130 | (timestamp, nonce, signature) 131 | url = '/robot/' 132 | response = client.post(url, data=xml, content_type="text/xml") 133 | 134 | assert response.status_code == 403 135 | assert response.content.decode('utf-8') == u'喵' 136 | 137 | url += params 138 | response = client.post(url, data=xml, content_type="text/xml") 139 | 140 | assert response.status_code == 200 141 | response = process_message(parse_xml(response.content)) 142 | assert response.content == 'hello' 143 | 144 | response = client.options(url) 145 | assert response.status_code == 405 146 | 147 | 148 | def test_flask(wsgi_tester, hello_robot): 149 | from flask import Flask 150 | from werobot.contrib.flask import make_view 151 | 152 | token = generate_token() 153 | endpoint = '/werobot_flask' 154 | 155 | hello_robot.token = token 156 | flask_app = Flask(__name__) 157 | flask_app.debug = True 158 | 159 | flask_app.add_url_rule( 160 | rule=endpoint, 161 | endpoint='werobot', 162 | view_func=make_view(hello_robot), 163 | methods=['GET', 'POST'] 164 | ) 165 | 166 | wsgi_tester(flask_app, token=token, endpoint=endpoint) 167 | 168 | 169 | def test_bottle(wsgi_tester, hello_robot): 170 | from werobot.contrib.bottle import make_view 171 | from bottle import Bottle 172 | 173 | token = generate_token() 174 | endpoint = '/werobot_bottle' 175 | 176 | hello_robot.token = token 177 | 178 | bottle_app = Bottle() 179 | bottle_app.route(endpoint, ['GET', 'POST'], make_view(hello_robot)) 180 | 181 | wsgi_tester(bottle_app, token=token, endpoint=endpoint) 182 | 183 | 184 | def test_werobot_wsgi(wsgi_tester, hello_robot): 185 | token = generate_token() 186 | endpoint = r'/rand' 187 | hello_robot.token = token 188 | 189 | wsgi_tester(hello_robot.wsgi, token=token, endpoint=endpoint) 190 | 191 | 192 | # workaround to make Tornado work in Python 3.8 193 | # https://github.com/tornadoweb/tornado/issues/2608 194 | if sys.platform == 'win32' and sys.version_info >= (3, 8): 195 | import asyncio 196 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 197 | 198 | if tornado.version_info[0] < 6: 199 | 200 | def test_tornado(wsgi_tester, hello_robot): 201 | from tornado.wsgi import WSGIAdapter 202 | import tornado.web 203 | from werobot.contrib.tornado import make_handler 204 | 205 | token = generate_token() 206 | endpoint = r'/werobot_tornado' 207 | hello_robot.token = token 208 | 209 | tornado_app = tornado.web.Application( 210 | [ 211 | (endpoint, make_handler(hello_robot)), 212 | ], debug=True 213 | ) 214 | wsgi_tester(WSGIAdapter(tornado_app), token=token, endpoint=endpoint) 215 | else: 216 | 217 | class TestTornado(AsyncHTTPSTestCase): 218 | token = 'TestTornado' 219 | endpoint = '/werobot_tornado' 220 | 221 | @property 222 | def robot(self): 223 | from werobot import WeRoBot 224 | robot = WeRoBot(token=self.token, SESSION_STORAGE=False) 225 | 226 | @robot.text 227 | def hello(): 228 | return 'hello' 229 | 230 | @robot.error_page 231 | def make_error_page(url): 232 | return '喵' 233 | 234 | return robot 235 | 236 | def get_app(self): 237 | import tornado.web 238 | from werobot.contrib.tornado import make_handler 239 | 240 | tornado_app = tornado.web.Application( 241 | [ 242 | (self.endpoint, make_handler(self.robot)), 243 | ], debug=True 244 | ) 245 | return tornado_app 246 | 247 | def test_tornado(self): 248 | token = self.token 249 | timestamp = str(time.time()) 250 | nonce = str(random.randint(0, 10000)) 251 | signature = get_signature(token, timestamp, nonce) 252 | echostr = generate_token() 253 | 254 | params = "?timestamp=%s&nonce=%s&signature=%s&echostr=%s" % ( 255 | timestamp, nonce, signature, echostr 256 | ) 257 | 258 | response = self.fetch(path=self.endpoint + params) 259 | assert response.code == 200 260 | assert response.body.decode('utf-8') == echostr 261 | 262 | response = self.fetch(path=self.endpoint, ) 263 | assert response.code == 403 264 | assert response.body.decode('utf-8') == u'喵' 265 | 266 | xml = """ 267 | <xml> 268 | <ToUserName><![CDATA[toUser]]></ToUserName> 269 | <FromUserName><![CDATA[fromUser]]></FromUserName> 270 | <CreateTime>1348831860</CreateTime> 271 | <MsgType><![CDATA[text]]></MsgType> 272 | <Content><![CDATA[this is a test]]></Content> 273 | <MsgId>1234567890123456</MsgId> 274 | </xml>""" 275 | 276 | response = self.fetch( 277 | path=self.endpoint + params, 278 | method='POST', 279 | body=xml, 280 | headers={'Content-Type': 'text/xml'} 281 | ) 282 | self.assertEqual(response.code, 200) 283 | self.assertEqual( 284 | process_message(parse_xml(response.body)).content, 'hello' 285 | ) 286 | 287 | response = self.fetch( 288 | path=self.endpoint, 289 | method='POST', 290 | body=xml, 291 | headers={'Content-Type': 'text/xml'} 292 | ) 293 | self.assertEqual(response.code, 403) 294 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.crypto import PrpCrypto, MessageCrypt 4 | from werobot.utils import generate_token, to_binary, to_text 5 | from werobot.parser import parse_xml 6 | import time 7 | 8 | 9 | def test_prpcrypto(): 10 | key = "ReUrr0NKeHkppBQq" 11 | 12 | assert len(key) == 16 13 | 14 | crypto = PrpCrypto(key) 15 | text = generate_token(32) 16 | app_id = generate_token(32) 17 | assert crypto.decrypt(crypto.encrypt(text, app_id), 18 | app_id) == to_binary(text) 19 | 20 | 21 | def test_message_crypt(): 22 | encoding_aes_key = generate_token(32) + generate_token(11) 23 | token = generate_token() 24 | timestamp = to_text(int(time.time())) 25 | nonce = generate_token(5) 26 | app_id = generate_token(18) 27 | crypt = MessageCrypt( 28 | token=token, encoding_aes_key=encoding_aes_key, app_id=app_id 29 | ) 30 | 31 | message = crypt.encrypt_message('hello', timestamp, nonce) 32 | assert message is not None 33 | message = parse_xml(message) 34 | assert message is not None 35 | message = crypt.decrypt_message( 36 | message['TimeStamp'], message['Nonce'], message['MsgSignature'], 37 | message['Encrypt'] 38 | ) 39 | assert message == to_binary('hello') 40 | -------------------------------------------------------------------------------- /tests/test_logger.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | 4 | from werobot.logger import enable_pretty_logging, _LogFormatter 5 | 6 | 7 | def get_new_logger(): 8 | return logging.getLogger(str(time.time())) 9 | 10 | 11 | def test_logger_level(): 12 | for level in ('debug', 'info', 'warning', 'error'): 13 | logger = get_new_logger() 14 | enable_pretty_logging(logger, level=level) 15 | assert logger.level == getattr(logging, level.upper()) 16 | 17 | 18 | def test_handlers(): 19 | logger = get_new_logger() 20 | enable_pretty_logging(logger) 21 | assert isinstance(logger.handlers[0].formatter, _LogFormatter) 22 | -------------------------------------------------------------------------------- /tests/test_replies.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import time 4 | import pytest 5 | 6 | from werobot.parser import parse_user_msg 7 | from werobot.replies import WeChatReply, TextReply, ImageReply, MusicReply 8 | from werobot.replies import VoiceReply, VideoReply 9 | from werobot.replies import Article, ArticlesReply 10 | from werobot.replies import TransferCustomerServiceReply, SuccessReply 11 | from werobot.replies import process_function_reply 12 | from werobot.utils import to_binary, to_text 13 | 14 | 15 | def test_wechat_reply(): 16 | message = parse_user_msg( 17 | """ 18 | <xml> 19 | <ToUserName><![CDATA[toUser]]></ToUserName> 20 | <FromUserName><![CDATA[fromUser]]></FromUserName> 21 | <CreateTime>1348831860</CreateTime> 22 | <MsgType><![CDATA[image]]></MsgType> 23 | <PicUrl><![CDATA[this is a url]]></PicUrl> 24 | <MediaId><![CDATA[media_id]]></MediaId> 25 | <MsgId>1234567890123456</MsgId> 26 | </xml> 27 | """ 28 | ) 29 | s = to_binary("喵fdsjaklfsk") 30 | reply = WeChatReply(message=message, s=s) 31 | assert reply._args['source'] == 'toUser' 32 | assert reply._args['target'] == 'fromUser' 33 | assert reply._args['s'] == to_text(s) 34 | assert isinstance(reply._args['time'], int) 35 | 36 | 37 | def test_text_reply(): 38 | t = int(time.time()) 39 | reply = TextReply(target='fromUser', source='toUser', content="aa", time=t) 40 | assert reply.render().strip() == """ 41 | <xml> 42 | <ToUserName><![CDATA[fromUser]]></ToUserName> 43 | <FromUserName><![CDATA[toUser]]></FromUserName> 44 | <CreateTime>{time}</CreateTime> 45 | <MsgType><![CDATA[text]]></MsgType> 46 | <Content><![CDATA[aa]]></Content> 47 | </xml>""".format(time=t).strip() 48 | 49 | 50 | def test_image_reply(): 51 | t = int(time.time()) 52 | reply = ImageReply( 53 | target='fromUser', source='toUser', media_id="fdasfdasfasd", time=t 54 | ) 55 | assert reply.render().strip() == """ 56 | <xml> 57 | <ToUserName><![CDATA[fromUser]]></ToUserName> 58 | <FromUserName><![CDATA[toUser]]></FromUserName> 59 | <CreateTime>{time}</CreateTime> 60 | <MsgType><![CDATA[image]]></MsgType> 61 | <Image> 62 | <MediaId><![CDATA[fdasfdasfasd]]></MediaId> 63 | </Image> 64 | </xml>""".format(time=t).strip() 65 | 66 | 67 | def test_voice_reply(): 68 | t = int(time.time()) 69 | reply = VoiceReply( 70 | target='tgu', source='su', media_id="fdasfdasfasd", time=t 71 | ) 72 | assert reply.render().strip() == """ 73 | <xml> 74 | <ToUserName><![CDATA[tgu]]></ToUserName> 75 | <FromUserName><![CDATA[su]]></FromUserName> 76 | <CreateTime>{time}</CreateTime> 77 | <MsgType><![CDATA[voice]]></MsgType> 78 | <Voice> 79 | <MediaId><![CDATA[fdasfdasfasd]]></MediaId> 80 | </Voice> 81 | </xml>""".format(time=t).strip() 82 | 83 | 84 | def test_video_reply(): 85 | t = int(time.time()) 86 | reply = VideoReply( 87 | target='tgu', 88 | source='su', 89 | media_id="fdasfdasfasd", 90 | time=t, 91 | ) 92 | assert reply.render().strip() == """ 93 | <xml> 94 | <ToUserName><![CDATA[tgu]]></ToUserName> 95 | <FromUserName><![CDATA[su]]></FromUserName> 96 | <CreateTime>{time}</CreateTime> 97 | <MsgType><![CDATA[video]]></MsgType> 98 | <Video> 99 | <MediaId><![CDATA[fdasfdasfasd]]></MediaId> 100 | <Title><![CDATA[]]></Title> 101 | <Description><![CDATA[]]></Description> 102 | </Video> 103 | </xml>""".format(time=t).strip() 104 | 105 | reply_2 = VideoReply( 106 | target='tgu', 107 | source='su', 108 | media_id="fdasfdasfasd", 109 | time=t, 110 | title='meow' 111 | ) 112 | 113 | assert reply_2.render().strip() == """ 114 | <xml> 115 | <ToUserName><![CDATA[tgu]]></ToUserName> 116 | <FromUserName><![CDATA[su]]></FromUserName> 117 | <CreateTime>{time}</CreateTime> 118 | <MsgType><![CDATA[video]]></MsgType> 119 | <Video> 120 | <MediaId><![CDATA[fdasfdasfasd]]></MediaId> 121 | <Title><![CDATA[meow]]></Title> 122 | <Description><![CDATA[]]></Description> 123 | </Video> 124 | </xml>""".format(time=t).strip() 125 | 126 | reply_3 = VideoReply( 127 | target='tgu', 128 | source='su', 129 | media_id="fdasfdasfasd", 130 | time=t, 131 | title='meow', 132 | description='www' 133 | ) 134 | assert reply_3.render().strip() == """ 135 | <xml> 136 | <ToUserName><![CDATA[tgu]]></ToUserName> 137 | <FromUserName><![CDATA[su]]></FromUserName> 138 | <CreateTime>{time}</CreateTime> 139 | <MsgType><![CDATA[video]]></MsgType> 140 | <Video> 141 | <MediaId><![CDATA[fdasfdasfasd]]></MediaId> 142 | <Title><![CDATA[meow]]></Title> 143 | <Description><![CDATA[www]]></Description> 144 | </Video> 145 | </xml>""".format(time=t).strip() 146 | 147 | 148 | def test_video_reply_process_args(): 149 | reply = VideoReply( 150 | target='tgu', 151 | source='su', 152 | media_id="fdasfdasfasd", 153 | ) 154 | assert reply._args['title'] == '' 155 | assert reply._args['description'] == '' 156 | 157 | 158 | def test_music_reply(): 159 | t = int(time.time()) 160 | reply = MusicReply( 161 | target='tg', 162 | source='ss', 163 | time=t, 164 | title='tt', 165 | description='ds', 166 | url='u1', 167 | hq_url='u2', 168 | ) 169 | assert reply.render().strip() == """ 170 | <xml> 171 | <ToUserName><![CDATA[tg]]></ToUserName> 172 | <FromUserName><![CDATA[ss]]></FromUserName> 173 | <CreateTime>{time}</CreateTime> 174 | <MsgType><![CDATA[music]]></MsgType> 175 | <Music> 176 | <Title><![CDATA[tt]]></Title> 177 | <Description><![CDATA[ds]]></Description> 178 | <MusicUrl><![CDATA[u1]]></MusicUrl> 179 | <HQMusicUrl><![CDATA[u2]]></HQMusicUrl> 180 | </Music> 181 | </xml>""".format(time=t).strip() 182 | 183 | 184 | def test_music_reply_process_args(): 185 | reply = MusicReply( 186 | target='tg', 187 | source='ss', 188 | title='tt', 189 | description='ds', 190 | url='u1', 191 | ) 192 | assert reply._args['hq_url'] == 'u1' 193 | 194 | reply_2 = MusicReply( 195 | target='tg', 196 | source='ss', 197 | title='tt', 198 | description='ds', 199 | url='u1', 200 | hq_url='u2' 201 | ) 202 | assert reply_2._args['hq_url'] == 'u2' 203 | 204 | 205 | def test_article(): 206 | article = Article( 207 | title="tt", description=to_binary("附近的萨卡里发生"), img="http", url="uuu" 208 | ) 209 | assert article.render().strip() == to_text( 210 | """ 211 | <item> 212 | <Title><![CDATA[tt]]></Title> 213 | <Description><![CDATA[附近的萨卡里发生]]></Description> 214 | <PicUrl><![CDATA[http]]></PicUrl> 215 | <Url><![CDATA[uuu]]></Url> 216 | </item> 217 | """ 218 | ).strip() 219 | 220 | 221 | def test_articles_reply(): 222 | article = Article( 223 | title="tt", description="附近的萨卡里发生", img="http", url="uuu" 224 | ) 225 | t = int(time.time()) 226 | reply = ArticlesReply(target='tg', source='ss', time=t) 227 | assert reply.render().strip() == """ 228 | <xml> 229 | <ToUserName><![CDATA[tg]]></ToUserName> 230 | <FromUserName><![CDATA[ss]]></FromUserName> 231 | <CreateTime>{time}</CreateTime> 232 | <MsgType><![CDATA[news]]></MsgType> 233 | <Content><![CDATA[]]></Content> 234 | <ArticleCount>0</ArticleCount> 235 | <Articles></Articles> 236 | </xml>""".format(time=t).strip() 237 | 238 | reply._args['content'] = 'wwww' 239 | assert '<Content><![CDATA[wwww]]></Content>' in reply.render() 240 | reply.add_article(article) 241 | assert '<ArticleCount>1</ArticleCount>' in reply.render() 242 | assert article.render() in reply.render() 243 | for _ in range(9): 244 | reply.add_article(article) 245 | assert '<ArticleCount>10</ArticleCount>' in reply.render() 246 | with pytest.raises(AttributeError): 247 | reply.add_article(article) 248 | 249 | 250 | def test_transfer_customer_service_reply(): 251 | t = int(time.time()) 252 | reply = TransferCustomerServiceReply( 253 | source='aaa', target='bbb', time=t, account="test1@test" 254 | ) 255 | assert reply.render().strip() == """ 256 | <xml> 257 | <ToUserName><![CDATA[bbb]]></ToUserName> 258 | <FromUserName><![CDATA[aaa]]></FromUserName> 259 | <CreateTime>{time}</CreateTime> 260 | <MsgType><![CDATA[transfer_customer_service]]></MsgType> 261 | <TransInfo> 262 | <KfAccount><![CDATA[test1@test]]></KfAccount> 263 | </TransInfo> 264 | </xml> 265 | """.format(time=t).strip() 266 | 267 | 268 | def test_transfer_customer_service_reply_without_account(): 269 | t = int(time.time()) 270 | reply = TransferCustomerServiceReply(source='aaa', target='bbb', time=t) 271 | assert reply.render().strip() == """ 272 | <xml> 273 | <ToUserName><![CDATA[bbb]]></ToUserName> 274 | <FromUserName><![CDATA[aaa]]></FromUserName> 275 | <CreateTime>{time}</CreateTime> 276 | <MsgType><![CDATA[transfer_customer_service]]></MsgType> 277 | </xml> 278 | """.format(time=t).strip() 279 | 280 | 281 | def test_success_reply(): 282 | assert SuccessReply().render() == "success" 283 | 284 | 285 | def test_process_unknown_function_reply(): 286 | reply = SuccessReply() 287 | assert process_function_reply(reply) == reply 288 | 289 | 290 | def test_process_text_function_reply(): 291 | reply = process_function_reply("test") 292 | assert isinstance(reply, TextReply) 293 | assert reply.content == "test" 294 | 295 | 296 | def test_process_music_function_reply(): 297 | reply = process_function_reply(["title", "desc", "url"]) 298 | assert isinstance(reply, MusicReply) 299 | assert reply.title == "title" 300 | assert reply.description == "desc" 301 | assert reply.url == reply.hq_url == "url" 302 | 303 | reply = process_function_reply(["title", "desc", "url", "hq"]) 304 | assert isinstance(reply, MusicReply) 305 | assert reply.title == "title" 306 | assert reply.description == "desc" 307 | assert reply.url == "url" 308 | assert reply.hq_url == "hq" 309 | 310 | 311 | def test_process_articles_function_reply(): 312 | reply = process_function_reply( 313 | [["tt1", 'ds1', 'img', 'url'], ["tt2", 'ds2', 'im2g', 'u2rl']] 314 | ) 315 | assert isinstance(reply, ArticlesReply) 316 | assert len(reply._articles) == 2 317 | article_1, article_2 = reply._articles 318 | assert isinstance(article_1, Article), isinstance(article_2, Article) 319 | assert article_1.title == "tt1", article_2.title == "tt2" 320 | assert article_1.description == "ds1", article_2.description == "ds2" 321 | assert article_1.img == "img", article_2.img == "im2g" 322 | assert article_1.url == "url", article_2.url == "u2rl" 323 | 324 | process_function_reply([[1, 2, 3, 4]] * 10) 325 | 326 | with pytest.raises(AttributeError): 327 | process_function_reply([[1, 2, 3, 4]] * 11) 328 | -------------------------------------------------------------------------------- /tests/test_robot.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import hashlib 4 | import time 5 | import os 6 | import pytest 7 | 8 | from werobot import WeRoBot 9 | from werobot.utils import generate_token, to_text 10 | 11 | 12 | def _make_xml(content): 13 | return """ 14 | <xml> 15 | <ToUserName><![CDATA[toUser]]></ToUserName> 16 | <FromUserName><![CDATA[fromUser]]></FromUserName> 17 | <CreateTime>1348831860</CreateTime> 18 | <MsgType><![CDATA[text]]></MsgType> 19 | <Content><![CDATA[%s]]></Content> 20 | <MsgId>1234567890123456</MsgId> 21 | </xml> 22 | """ % content 23 | 24 | 25 | def test_signature_checker(): 26 | token = generate_token() 27 | 28 | robot = WeRoBot(token, SESSION_STORAGE=False) 29 | 30 | timestamp = str(int(time.time())) 31 | nonce = '12345678' 32 | 33 | sign = [token, timestamp, nonce] 34 | sign.sort() 35 | sign = ''.join(sign) 36 | sign = sign.encode() 37 | sign = hashlib.sha1(sign).hexdigest() 38 | 39 | assert robot.check_signature(timestamp, nonce, sign) 40 | 41 | 42 | def test_register_handlers(): # noqa: C901 43 | robot = WeRoBot(SESSION_STORAGE=False) 44 | 45 | for type in robot.message_types: 46 | assert hasattr(robot, 47 | type) or hasattr(robot, type.replace('_event', '')) 48 | 49 | @robot.text 50 | def text_handler(): 51 | return "Hi" 52 | 53 | assert robot._handlers["text"] == [(text_handler, 0)] 54 | 55 | @robot.image 56 | def image_handler(message): 57 | return 'nice pic' 58 | 59 | assert robot._handlers["image"] == [(image_handler, 1)] 60 | 61 | assert robot.get_handlers("text") == [(text_handler, 0)] 62 | 63 | @robot.handler 64 | def handler(message, session): 65 | pass 66 | 67 | assert robot.get_handlers("text") == [(text_handler, 0), (handler, 2)] 68 | 69 | @robot.video 70 | def video_handler(): 71 | pass 72 | 73 | assert robot._handlers["video"] == [(video_handler, 0)] 74 | assert robot.get_handlers("video") == [(video_handler, 0), (handler, 2)] 75 | 76 | @robot.shortvideo 77 | def shortvideo_handler(): 78 | pass 79 | 80 | assert robot._handlers["shortvideo"] == [(shortvideo_handler, 0)] 81 | assert robot.get_handlers("shortvideo") == [ 82 | (shortvideo_handler, 0), (handler, 2) 83 | ] 84 | 85 | @robot.location 86 | def location_handler(): 87 | pass 88 | 89 | assert robot._handlers["location"] == [(location_handler, 0)] 90 | 91 | @robot.link 92 | def link_handler(): 93 | pass 94 | 95 | assert robot._handlers["link"] == [(link_handler, 0)] 96 | 97 | @robot.subscribe 98 | def subscribe_handler(): 99 | pass 100 | 101 | assert robot._handlers["subscribe_event"] == [(subscribe_handler, 0)] 102 | 103 | @robot.unsubscribe 104 | def unsubscribe_handler(): 105 | pass 106 | 107 | assert robot._handlers["unsubscribe_event"] == [(unsubscribe_handler, 0)] 108 | 109 | @robot.voice 110 | def voice_handler(): 111 | pass 112 | 113 | assert robot._handlers["voice"] == [(voice_handler, 0)] 114 | 115 | @robot.click 116 | def click_handler(): 117 | pass 118 | 119 | assert robot._handlers["click_event"] == [(click_handler, 0)] 120 | 121 | @robot.key_click("MENU") 122 | def menu_handler(): 123 | pass 124 | 125 | assert len(robot._handlers["click_event"]) == 2 126 | 127 | @robot.scan 128 | def scan_handler(): 129 | pass 130 | 131 | assert robot._handlers["scan_event"] == [(scan_handler, 0)] 132 | 133 | @robot.scancode_push 134 | def scancode_push_handler(): 135 | pass 136 | 137 | assert robot._handlers["scancode_push_event"] == [ 138 | (scancode_push_handler, 0) 139 | ] 140 | 141 | @robot.scancode_waitmsg 142 | def scancode_waitmsg_handler(): 143 | pass 144 | 145 | assert robot._handlers["scancode_waitmsg_event"] == [ 146 | (scancode_waitmsg_handler, 0) 147 | ] 148 | 149 | 150 | def test_filter(): 151 | import re 152 | import werobot.testing 153 | robot = WeRoBot(SESSION_STORAGE=False) 154 | 155 | @robot.filter("喵") 156 | def _1(): 157 | return "喵" 158 | 159 | assert len(robot._handlers["text"]) == 1 160 | 161 | @robot.filter(re.compile(to_text(".*?呵呵.*?"))) 162 | def _2(): 163 | return "哼" 164 | 165 | assert len(robot._handlers["text"]) == 2 166 | 167 | @robot.text 168 | def _3(): 169 | return "汪" 170 | 171 | assert len(robot._handlers["text"]) == 3 172 | 173 | tester = werobot.testing.WeTest(robot) 174 | 175 | assert tester.send_xml(_make_xml("啊"))._args['content'] == u"汪" 176 | assert tester.send_xml(_make_xml("啊呵呵"))._args['content'] == u"哼" 177 | assert tester.send_xml(_make_xml("喵"))._args['content'] == u"喵" 178 | 179 | try: 180 | os.remove(os.path.abspath("werobot_session")) 181 | except OSError: 182 | pass 183 | robot = WeRoBot(SESSION_STORAGE=False) 184 | 185 | @robot.filter("帮助", "跪求帮助", re.compile("(.*?)help.*?")) 186 | def _(message, session, match): 187 | if match and match.group(1) == u"小姐姐": 188 | return "本小姐就帮你一下" 189 | return "就不帮" 190 | 191 | assert len(robot._handlers["text"]) == 3 192 | 193 | @robot.text 194 | def _4(): 195 | return "哦" 196 | 197 | assert len(robot._handlers["text"]) == 4 198 | 199 | tester = werobot.testing.WeTest(robot) 200 | 201 | assert tester.send_xml(_make_xml("啊"))._args['content'] == u"哦" 202 | assert tester.send_xml(_make_xml("帮助"))._args['content'] == u"就不帮" 203 | assert tester.send_xml(_make_xml("跪求帮助"))._args['content'] == u"就不帮" 204 | assert tester.send_xml(_make_xml("ooohelp"))._args['content'] == u"就不帮" 205 | assert tester.send_xml(_make_xml("小姐姐help") 206 | )._args['content'] == u"本小姐就帮你一下" 207 | 208 | 209 | def test_register_not_callable_object(): 210 | robot = WeRoBot(SESSION_STORAGE=False) 211 | with pytest.raises(ValueError): 212 | robot.add_handler("s") 213 | 214 | 215 | def test_error_page(): 216 | robot = WeRoBot() 217 | 218 | @robot.error_page 219 | def make_error_page(url): 220 | return url 221 | 222 | assert robot.make_error_page('喵') == '喵' 223 | 224 | 225 | def test_config_ignore(): 226 | from werobot.config import Config 227 | config = Config(TOKEN="token from config") 228 | robot = WeRoBot(config=config, token="token2333") 229 | assert robot.token == "token from config" 230 | 231 | 232 | def test_add_filter(): 233 | import werobot.testing 234 | import re 235 | 236 | robot = WeRoBot() 237 | 238 | def test_register(): 239 | return "test" 240 | 241 | robot.add_filter(test_register, ["test", re.compile(u".*?啦.*?")]) 242 | 243 | tester = werobot.testing.WeTest(robot) 244 | 245 | assert tester.send_xml(_make_xml("test"))._args["content"] == "test" 246 | assert tester.send_xml(_make_xml(u"我要测试啦"))._args["content"] == "test" 247 | assert tester.send_xml(_make_xml(u"我要测试")) is None 248 | 249 | with pytest.raises(ValueError) as e: 250 | robot.add_filter("test", ["test"]) 251 | assert e.value.args[0] == "test is not callable" 252 | 253 | with pytest.raises(ValueError) as e: 254 | robot.add_filter(test_register, "test") 255 | assert e.value.args[0] == "test is not list" 256 | 257 | with pytest.raises(TypeError) as e: 258 | robot.add_filter(test_register, [["bazinga"]]) 259 | assert e.value.args[0] == "[\'bazinga\'] is not a valid rule" 260 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import mongomock 5 | import mockredis 6 | import pytest 7 | import sqlite3 8 | 9 | import werobot 10 | import werobot.testing 11 | import werobot.utils 12 | from werobot.session import SessionStorage 13 | from werobot.session import filestorage, mongodbstorage, redisstorage, saekvstorage 14 | from werobot.session import sqlitestorage 15 | from werobot.session import mysqlstorage 16 | from werobot.session import postgresqlstorage 17 | from werobot.utils import to_binary 18 | 19 | 20 | def teardown_module(): 21 | try: 22 | os.remove("werobot_session") 23 | os.remove("werobot_session.sqlite3") 24 | except: 25 | pass 26 | 27 | 28 | def remove_session(session): 29 | try: 30 | del session[to_binary("fromUser")] 31 | except: 32 | pass 33 | 34 | 35 | def test_session(): 36 | robot = werobot.WeRoBot( 37 | token=werobot.utils.generate_token(), enable_session=True 38 | ) 39 | 40 | @robot.text 41 | def first(message, session): 42 | if 'last' in session: 43 | return 44 | session['last'] = message.content 45 | return message.content 46 | 47 | @robot.text 48 | def second(_, session): 49 | return session['last'] 50 | 51 | tester = werobot.testing.WeTest(robot) 52 | xml_1 = """ 53 | <xml> 54 | <ToUserName><![CDATA[toUser]]></ToUserName> 55 | <FromUserName><![CDATA[fromUser]]></FromUserName> 56 | <CreateTime>1348831860</CreateTime> 57 | <MsgType><![CDATA[text]]></MsgType> 58 | <Content><![CDATA[ss]]></Content> 59 | <MsgId>1234567890123456</MsgId> 60 | </xml> 61 | """ 62 | xml_2 = """ 63 | <xml> 64 | <ToUserName><![CDATA[toUser]]></ToUserName> 65 | <FromUserName><![CDATA[fromUser]]></FromUserName> 66 | <CreateTime>1348831860</CreateTime> 67 | <MsgType><![CDATA[text]]></MsgType> 68 | <Content><![CDATA[dd]]></Content> 69 | <MsgId>1234567890123456</MsgId> 70 | </xml> 71 | """ 72 | 73 | reply_1 = tester.send_xml(xml_1)._args['content'] 74 | assert reply_1 == 'ss' 75 | reply_2 = tester.send_xml(xml_2)._args['content'] 76 | assert reply_2 == 'ss' 77 | 78 | 79 | def test_session_storage_get(): 80 | session = SessionStorage() 81 | with pytest.raises(NotImplementedError): 82 | session.get('s') 83 | with pytest.raises(NotImplementedError): 84 | session['s'] 85 | 86 | 87 | def test_session_storage_set(): 88 | session = SessionStorage() 89 | with pytest.raises(NotImplementedError): 90 | session.set('s', {}) 91 | with pytest.raises(NotImplementedError): 92 | session['s'] = {} 93 | 94 | 95 | def test_session_storage_delete(): 96 | session = SessionStorage() 97 | with pytest.raises(NotImplementedError): 98 | session.delete('s') 99 | 100 | with pytest.raises(NotImplementedError): 101 | del session['s'] 102 | 103 | 104 | class MockPyMySQL: 105 | def __init__(self): 106 | self.db = sqlite3.connect("werobot_session.sqlite3") 107 | self.db.text_factory = str 108 | from werobot.session.sqlitestorage import __CREATE_TABLE_SQL__ 109 | self.db.execute(__CREATE_TABLE_SQL__) 110 | self.cache_result = None 111 | 112 | def cursor(self): 113 | return self 114 | 115 | def execute(self, *args, **kwargs): 116 | if "CREATE TABLE" not in args[0]: 117 | args = list(args) 118 | args[0] = args[0].replace('%s', '?') 119 | if "SELECT" in args[0]: 120 | self.cache_result = self.db.execute(*args, **kwargs).fetchone() 121 | elif "INSERT" in args[0]: 122 | args = [ 123 | "INSERT OR REPLACE INTO WeRoBot (id, value) VALUES (?,?);", 124 | (args[1][0], args[1][1]) 125 | ] 126 | self.db.execute(*args, **kwargs) 127 | else: 128 | self.db.execute(*args, **kwargs) 129 | 130 | def fetchone(self): 131 | return self.cache_result 132 | 133 | def commit(self): 134 | return self.db.commit() 135 | 136 | 137 | class MockPostgreSQL(MockPyMySQL): 138 | pass 139 | 140 | 141 | @pytest.mark.parametrize( 142 | "storage", [ 143 | filestorage.FileStorage(), 144 | mongodbstorage.MongoDBStorage(mongomock.MongoClient().t.t), 145 | redisstorage.RedisStorage(mockredis.mock_redis_client()), 146 | sqlitestorage.SQLiteStorage(), 147 | mysqlstorage.MySQLStorage(MockPyMySQL()), 148 | postgresqlstorage.PostgreSQLStorage(MockPostgreSQL()) 149 | ] 150 | ) 151 | def test_storage(storage): 152 | assert storage.get("喵") == {} 153 | storage.set("喵", "喵喵") 154 | assert storage.get("喵") == u"喵喵" 155 | storage.delete("喵") 156 | assert storage.get("喵") == {} 157 | 158 | assert storage["榴莲"] == {} 159 | storage["榴莲"] = "榴莲" 160 | assert storage["榴莲"] == u"榴莲" 161 | del storage["榴莲"] 162 | assert storage["榴莲"] == {} 163 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | 5 | from werobot.utils import generate_token, check_token, to_text, to_binary 6 | from werobot.utils import pay_sign_dict, make_error_page, is_regex 7 | 8 | 9 | def test_token_generator(): 10 | assert not check_token('AA C') 11 | assert check_token(generate_token()) 12 | assert 3 <= len(generate_token()) <= 32 13 | 14 | 15 | def test_to_text(): 16 | assert to_text(6) == str(6) 17 | assert to_text(b"aa") == "aa" 18 | assert to_text("cc") == "cc" 19 | assert to_text(u"喵") == u"喵" 20 | assert to_text("喵") == u"喵" 21 | 22 | 23 | def test_to_binary(): 24 | assert to_binary(6) == bytes(6) 25 | assert to_binary(b"aa") == b"aa" 26 | assert to_binary("cc") == b"cc" 27 | assert to_binary(u"喵") == b"\xe5\x96\xb5" 28 | assert to_binary("喵") == b"\xe5\x96\xb5" 29 | 30 | 31 | def test_pay_sign_dict(): 32 | appid = {"id": "nothing"} 33 | key = "test_key" 34 | 35 | pay_sign = pay_sign_dict(appid, key) 36 | 37 | assert "timestamp" in pay_sign[0] 38 | assert "noncestr" in pay_sign[0] 39 | assert "appid" in pay_sign[0] 40 | assert pay_sign[0]["appid"] == appid 41 | assert pay_sign[2] == u"SHA1" 42 | 43 | pay_sign = pay_sign_dict( 44 | appid, key, add_noncestr=False, add_timestamp=False, gadd_appid=False 45 | ) 46 | 47 | assert "timestamp" not in pay_sign[0] 48 | assert "noncestr" not in pay_sign[0] 49 | assert "appid" in pay_sign[0] 50 | 51 | 52 | def test_make_error_page(): 53 | rand_string = generate_token() 54 | content = make_error_page(rand_string) 55 | assert rand_string in content 56 | 57 | 58 | def test_is_regex(): 59 | regex = re.compile(r"test") 60 | assert not is_regex("test") 61 | assert is_regex(regex) 62 | -------------------------------------------------------------------------------- /tox-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | coverage==5.3.1 3 | cryptography==36.0.2 4 | pytest==7.0.1 5 | WebTest==2.0.35 6 | Flask 7 | tornado 8 | responses==0.12.1 9 | multipart==0.1 10 | pytest-mock==3.5.1 11 | mongomock==4.0.0 12 | mockredispy==2.9.3 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py36,py37,py38,py39}--{dj22,dj30,dj31} 3 | 4 | [testenv] 5 | commands = coverage run --source werobot -m py.test 6 | commands_post = codecov 7 | passenv = DATABASE_MYSQL_USERNAME DATABASE_MYSQL_PASSWORD CODECOV_* 8 | deps = 9 | dj22: Django < 2.3 10 | dj30: Django < 3.1 11 | dj31: Django < 3.2 12 | -rtox-requirements.txt 13 | codecov>=1.4.0 14 | -------------------------------------------------------------------------------- /werobot/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.13.1' 2 | __author__ = 'whtsky' 3 | __license__ = 'MIT' 4 | 5 | __all__ = ["WeRoBot"] 6 | 7 | try: 8 | from werobot.robot import WeRoBot 9 | except ImportError: # pragma: no cover 10 | pass # pragma: no cover 11 | -------------------------------------------------------------------------------- /werobot/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import types 4 | 5 | 6 | class ConfigAttribute(object): 7 | """ 8 | 让一个属性指向一个配置 9 | """ 10 | def __init__(self, name): 11 | self.__name__ = name 12 | 13 | def __get__(self, obj, type=None): 14 | if obj is None: 15 | return self 16 | rv = obj.config[self.__name__] 17 | return rv 18 | 19 | def __set__(self, obj, value): 20 | obj.config[self.__name__] = value 21 | 22 | 23 | class Config(dict): 24 | def from_pyfile(self, filename): 25 | """ 26 | 在一个 Python 文件中读取配置。 27 | 28 | :param filename: 配置文件的文件名 29 | :return: 如果读取成功,返回 ``True``,如果失败,会抛出错误异常 30 | """ 31 | d = types.ModuleType('config') 32 | d.__file__ = filename 33 | with open(filename) as config_file: 34 | exec(compile(config_file.read(), filename, 'exec'), d.__dict__) 35 | self.from_object(d) 36 | return True 37 | 38 | def from_object(self, obj): 39 | """ 40 | 在给定的 Python 对象中读取配置。 41 | 42 | :param obj: 一个 Python 对象 43 | """ 44 | for key in dir(obj): 45 | if key.isupper(): 46 | self[key] = getattr(obj, key) 47 | -------------------------------------------------------------------------------- /werobot/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/offu/WeRoBot/7b3d62d7ae126fc8bc9e033e6358dec2852d1d5a/werobot/contrib/__init__.py -------------------------------------------------------------------------------- /werobot/contrib/bottle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from bottle import request, HTTPResponse 3 | 4 | import html 5 | 6 | 7 | def make_view(robot): 8 | """ 9 | 为一个 BaseRoBot 生成 Bottle view。 10 | 11 | Usage :: 12 | 13 | from werobot import WeRoBot 14 | 15 | robot = WeRoBot(token='token') 16 | 17 | 18 | @robot.handler 19 | def hello(message): 20 | return 'Hello World!' 21 | 22 | from bottle import Bottle 23 | from werobot.contrib.bottle import make_view 24 | 25 | app = Bottle() 26 | app.route( 27 | '/robot', # WeRoBot 挂载地址 28 | ['GET', 'POST'], 29 | make_view(robot) 30 | ) 31 | 32 | 33 | :param robot: 一个 BaseRoBot 实例 34 | :return: 一个标准的 Bottle view 35 | """ 36 | def werobot_view(*args, **kwargs): 37 | if not robot.check_signature( 38 | request.query.timestamp, request.query.nonce, 39 | request.query.signature 40 | ): 41 | return HTTPResponse( 42 | status=403, 43 | body=robot.make_error_page(html.escape(request.url)) 44 | ) 45 | if request.method == 'GET': 46 | return request.query.echostr 47 | else: 48 | body = request.body.read() 49 | message = robot.parse_message( 50 | body, 51 | timestamp=request.query.timestamp, 52 | nonce=request.query.nonce, 53 | msg_signature=request.query.msg_signature 54 | ) 55 | return robot.get_encrypted_reply(message) 56 | 57 | return werobot_view 58 | -------------------------------------------------------------------------------- /werobot/contrib/django.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.http import HttpResponse, HttpResponseNotAllowed, HttpResponseForbidden 4 | from django.views.decorators.csrf import csrf_exempt 5 | 6 | import html 7 | 8 | 9 | def make_view(robot): 10 | """ 11 | 为一个 BaseRoBot 生成 Django view。 12 | 13 | :param robot: 一个 BaseRoBot 实例。 14 | :return: 一个标准的 Django view 15 | """ 16 | @csrf_exempt 17 | def werobot_view(request): 18 | timestamp = request.GET.get("timestamp", "") 19 | nonce = request.GET.get("nonce", "") 20 | signature = request.GET.get("signature", "") 21 | 22 | if not robot.check_signature( 23 | timestamp=timestamp, nonce=nonce, signature=signature 24 | ): 25 | return HttpResponseForbidden( 26 | robot.make_error_page( 27 | html.escape(request.build_absolute_uri()) 28 | ) 29 | ) 30 | if request.method == "GET": 31 | return HttpResponse(request.GET.get("echostr", "")) 32 | elif request.method == "POST": 33 | message = robot.parse_message( 34 | request.body, 35 | timestamp=timestamp, 36 | nonce=nonce, 37 | msg_signature=request.GET.get("msg_signature", "") 38 | ) 39 | return HttpResponse( 40 | robot.get_encrypted_reply(message), 41 | content_type="application/xml;charset=utf-8" 42 | ) 43 | return HttpResponseNotAllowed(['GET', 'POST']) 44 | 45 | return werobot_view 46 | -------------------------------------------------------------------------------- /werobot/contrib/error.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="zh"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <title>WeRoBot</title> 6 | <style type="text/css"> 7 | body, 8 | html { 9 | height: 100%; 10 | width: 100%; 11 | color: #424242; 12 | margin: 0; 13 | } 14 | 15 | body { 16 | display: -webkit-flex; 17 | display: -moz-flex; 18 | display: -ms-flex; 19 | display: -o-flex; 20 | display: flex; 21 | -webkit-flex-direction: column; 22 | -moz-flex-direction: column; 23 | -ms-flex-direction: column; 24 | -o-flex-direction: column; 25 | flex-direction: column; 26 | -ms-align-items: center; 27 | align-items: center; 28 | justify-content: center; 29 | background-color: #f5f5f5; 30 | } 31 | 32 | a { 33 | color: #424242; 34 | text-decoration: none; 35 | border-bottom: 0; 36 | transition: all ease-in-out 0.5s; 37 | -webkit-transition: all ease-in-out 0.5s; 38 | border-color: #f5f5f5; 39 | } 40 | 41 | a:hover { 42 | border-bottom: 1px solid transparent; 43 | border-color: #424242; 44 | } 45 | 46 | img { 47 | margin-bottom: 1em; 48 | height: 300px; 49 | width: 300px; 50 | } 51 | </style> 52 | </head> 53 | <body> 54 | <img 55 | src="https://cdn.rawgit.com/whtsky/WeRoBot/master/artwork/logo.svg" 56 | alt="" 57 | /> 58 | <h1> 59 | 这是一个 60 | <a href="https://github.com/whtsky/WeRoBot/">WeRoBot</a> 应用 61 | </h1> 62 | <p>想要使用本机器人,请在微信后台中将 URL 设置为</p> 63 | <pre>{url}</pre> 64 | <p>并将 Token 值设置正确。</p> 65 | <p>更多信息请与网站所有者联系</p> 66 | </body> 67 | </html> 68 | -------------------------------------------------------------------------------- /werobot/contrib/flask.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from flask import request, make_response 4 | 5 | import html 6 | 7 | 8 | def make_view(robot): 9 | """ 10 | 为一个 BaseRoBot 生成 Flask view。 11 | 12 | Usage :: 13 | 14 | from werobot import WeRoBot 15 | 16 | robot = WeRoBot(token='token') 17 | 18 | 19 | @robot.handler 20 | def hello(message): 21 | return 'Hello World!' 22 | 23 | from flask import Flask 24 | from werobot.contrib.flask import make_view 25 | 26 | app = Flask(__name__) 27 | app.add_url_rule(rule='/robot/', # WeRoBot 的绑定地址 28 | endpoint='werobot', # Flask 的 endpoint 29 | view_func=make_view(robot), 30 | methods=['GET', 'POST']) 31 | 32 | :param robot: 一个 BaseRoBot 实例 33 | :return: 一个标准的 Flask view 34 | """ 35 | def werobot_view(): 36 | timestamp = request.args.get('timestamp', '') 37 | nonce = request.args.get('nonce', '') 38 | signature = request.args.get('signature', '') 39 | if not robot.check_signature( 40 | timestamp, 41 | nonce, 42 | signature, 43 | ): 44 | return robot.make_error_page(html.escape(request.url)), 403 45 | if request.method == 'GET': 46 | return request.args['echostr'] 47 | 48 | message = robot.parse_message( 49 | request.data, 50 | timestamp=timestamp, 51 | nonce=nonce, 52 | msg_signature=request.args.get('msg_signature', '') 53 | ) 54 | response = make_response(robot.get_encrypted_reply(message)) 55 | response.headers['content_type'] = 'application/xml' 56 | return response 57 | 58 | return werobot_view 59 | -------------------------------------------------------------------------------- /werobot/contrib/tornado.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from tornado.web import RequestHandler 4 | 5 | import html 6 | 7 | 8 | def make_handler(robot): 9 | """ 10 | 为一个 BaseRoBot 生成 Tornado Handler。 11 | 12 | Usage :: 13 | 14 | import tornado.ioloop 15 | import tornado.web 16 | from werobot import WeRoBot 17 | from tornado_werobot import make_handler 18 | 19 | robot = WeRoBot(token='token') 20 | 21 | 22 | @robot.handler 23 | def hello(message): 24 | return 'Hello World!' 25 | 26 | application = tornado.web.Application([ 27 | (r"/", make_handler(robot)), 28 | ]) 29 | 30 | :param robot: 一个 BaseRoBot 实例。 31 | :return: 一个标准的 Tornado Handler 32 | """ 33 | class WeRoBotHandler(RequestHandler): 34 | def prepare(self): 35 | timestamp = self.get_argument('timestamp', '') 36 | nonce = self.get_argument('nonce', '') 37 | signature = self.get_argument('signature', '') 38 | 39 | if not robot.check_signature( 40 | timestamp=timestamp, nonce=nonce, signature=signature 41 | ): 42 | self.set_status(403) 43 | self.write( 44 | robot.make_error_page( 45 | html.escape( 46 | self.request.protocol + "://" + self.request.host + 47 | self.request.uri 48 | ) 49 | ) 50 | ) 51 | return 52 | 53 | def get(self): 54 | echostr = self.get_argument('echostr', '') 55 | self.write(echostr) 56 | 57 | def post(self): 58 | timestamp = self.get_argument('timestamp', '') 59 | nonce = self.get_argument('nonce', '') 60 | msg_signature = self.get_argument('msg_signature', '') 61 | message = robot.parse_message( 62 | self.request.body, 63 | timestamp=timestamp, 64 | nonce=nonce, 65 | msg_signature=msg_signature 66 | ) 67 | self.set_header("Content-Type", "application/xml;charset=utf-8") 68 | self.write(robot.get_encrypted_reply(message)) 69 | 70 | return WeRoBotHandler 71 | -------------------------------------------------------------------------------- /werobot/crypto/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import base64 4 | import socket 5 | import struct 6 | import time 7 | 8 | try: 9 | from cryptography.hazmat.primitives.ciphers import ( 10 | Cipher, algorithms, modes 11 | ) 12 | from cryptography.hazmat.backends import default_backend 13 | except ImportError: # pragma: no cover 14 | raise RuntimeError("You need to install Cryptography.") # pragma: no cover 15 | 16 | from . import pkcs7 17 | from .exceptions import ( 18 | UnvalidEncodingAESKey, AppIdValidationError, InvalidSignature 19 | ) 20 | from werobot.utils import ( 21 | to_text, to_binary, generate_token, byte2int, get_signature 22 | ) 23 | 24 | 25 | class PrpCrypto(object): 26 | """ 27 | 提供接收和推送给公众平台消息的加解密接口 28 | """ 29 | def __init__(self, key): 30 | key = to_binary(key) 31 | self.cipher = Cipher( 32 | algorithms.AES(key), 33 | modes.CBC(key[:16]), 34 | backend=default_backend() 35 | ) 36 | 37 | def get_random_string(self): 38 | """ 39 | :return: 长度为16的随即字符串 40 | """ 41 | return generate_token(16) 42 | 43 | def encrypt(self, text, app_id): 44 | """ 45 | 对明文进行加密 46 | :param text: 需要加密的明文 47 | :param app_id: 微信公众平台的 AppID 48 | :return: 加密后的字符串 49 | """ 50 | text = b"".join( 51 | [ 52 | to_binary(self.get_random_string()), 53 | struct.pack(b"I", socket.htonl(len(to_binary(text)))), 54 | to_binary(text), 55 | to_binary(app_id) 56 | ] 57 | ) 58 | text = pkcs7.encode(text) 59 | encryptor = self.cipher.encryptor() 60 | ciphertext = to_binary(encryptor.update(text) + encryptor.finalize()) 61 | return base64.b64encode(ciphertext) 62 | 63 | def decrypt(self, text, app_id): 64 | """ 65 | 对密文进行解密 66 | :param text: 需要解密的密文 67 | :param app_id: 微信公众平台的 AppID 68 | :return: 解密后的字符串 69 | """ 70 | text = to_binary(text) 71 | decryptor = self.cipher.decryptor() 72 | plain_text = decryptor.update(base64.b64decode(text) 73 | ) + decryptor.finalize() 74 | 75 | padding = byte2int(plain_text, -1) 76 | content = plain_text[16:-padding] 77 | 78 | xml_len = socket.ntohl(struct.unpack("I", content[:4])[0]) 79 | xml_content = content[4:xml_len + 4] 80 | from_appid = content[xml_len + 4:] 81 | 82 | if to_text(from_appid) != app_id: 83 | raise AppIdValidationError(text, app_id) 84 | 85 | return xml_content 86 | 87 | 88 | class MessageCrypt(object): 89 | ENCRYPTED_MESSAGE_XML = """ 90 | <xml> 91 | <Encrypt><![CDATA[{encrypt}]]></Encrypt> 92 | <MsgSignature><![CDATA[{signature}]]></MsgSignature> 93 | <TimeStamp>{timestamp}</TimeStamp> 94 | <Nonce><![CDATA[{nonce}]]></Nonce> 95 | </xml> 96 | """.strip() 97 | 98 | def __init__(self, token, encoding_aes_key, app_id): 99 | key = base64.b64decode(to_binary(encoding_aes_key + '=')) 100 | if len(key) != 32: 101 | raise UnvalidEncodingAESKey(encoding_aes_key) 102 | self.prp_crypto = PrpCrypto(key) 103 | 104 | self.token = token 105 | self.app_id = app_id 106 | 107 | def decrypt_message(self, timestamp, nonce, msg_signature, encrypt_msg): 108 | """ 109 | 解密收到的微信消息 110 | :param timestamp: 请求 URL 中收到的 timestamp 111 | :param nonce: 请求 URL 中收到的 nonce 112 | :param msg_signature: 请求 URL 中收到的 msg_signature 113 | :param encrypt_msg: 收到的加密文本. ( XML 中的 <Encrypt> 部分 ) 114 | :return: 解密后的 XML 文本 115 | """ 116 | signature = get_signature(self.token, timestamp, nonce, encrypt_msg) 117 | if signature != msg_signature: 118 | raise InvalidSignature(msg_signature) 119 | return self.prp_crypto.decrypt(encrypt_msg, self.app_id) 120 | 121 | def encrypt_message(self, reply, timestamp=None, nonce=None): 122 | """ 123 | 加密微信回复 124 | :param reply: 加密前的回复 125 | :type reply: WeChatReply 或 XML 文本 126 | :return: 加密后的回复文本 127 | """ 128 | if hasattr(reply, "render"): 129 | reply = reply.render() 130 | 131 | timestamp = timestamp or to_text(int(time.time())) 132 | nonce = nonce or generate_token(5) 133 | encrypt = to_text(self.prp_crypto.encrypt(reply, self.app_id)) 134 | signature = get_signature(self.token, timestamp, nonce, encrypt) 135 | return to_text( 136 | self.ENCRYPTED_MESSAGE_XML.format( 137 | encrypt=encrypt, 138 | signature=signature, 139 | timestamp=timestamp, 140 | nonce=nonce 141 | ) 142 | ) 143 | -------------------------------------------------------------------------------- /werobot/crypto/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class UnvalidEncodingAESKey(Exception): 5 | pass 6 | 7 | 8 | class AppIdValidationError(Exception): 9 | pass 10 | 11 | 12 | class InvalidSignature(Exception): 13 | pass 14 | -------------------------------------------------------------------------------- /werobot/crypto/pkcs7.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.utils import to_binary 4 | 5 | _BLOCK_SIZE = 32 6 | 7 | 8 | def encode(text): 9 | # 计算需要填充的位数 10 | amount_to_pad = _BLOCK_SIZE - (len(text) % _BLOCK_SIZE) 11 | if not amount_to_pad: 12 | amount_to_pad = _BLOCK_SIZE 13 | # 获得补位所用的字符 14 | pad = chr(amount_to_pad) 15 | return text + to_binary(pad * amount_to_pad) 16 | -------------------------------------------------------------------------------- /werobot/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class ConfigError(Exception): 5 | pass 6 | -------------------------------------------------------------------------------- /werobot/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import sys 4 | import time 5 | import logging 6 | 7 | try: 8 | import curses 9 | 10 | assert curses 11 | except ImportError: 12 | curses = None 13 | 14 | logger = logging.getLogger("WeRoBot") 15 | 16 | 17 | def enable_pretty_logging(logger, level='info'): 18 | """ 19 | 按照配置开启 log 的格式化优化。 20 | 21 | :param logger: 配置的 logger 对象 22 | :param level: 要为 logger 设置的等级 23 | """ 24 | logger.setLevel(getattr(logging, level.upper())) 25 | 26 | if not logger.handlers: 27 | # Set up color if we are in a tty and curses is installed 28 | color = False 29 | if curses and sys.stderr.isatty(): 30 | try: 31 | curses.setupterm() 32 | if curses.tigetnum("colors") > 0: 33 | color = True 34 | finally: 35 | pass 36 | channel = logging.StreamHandler() 37 | channel.setFormatter(_LogFormatter(color=color)) 38 | logger.addHandler(channel) 39 | 40 | 41 | class _LogFormatter(logging.Formatter): 42 | def __init__(self, color, *args, **kwargs): 43 | logging.Formatter.__init__(self, *args, **kwargs) 44 | self._color = color 45 | if color: 46 | fg_color = ( 47 | curses.tigetstr("setaf") or curses.tigetstr("setf") or b"" 48 | ) 49 | self._colors = { 50 | logging.DEBUG: str(curses.tparm(fg_color, 4), "ascii"), # Blue 51 | logging.INFO: str(curses.tparm(fg_color, 2), "ascii"), # Green 52 | logging.WARNING: str(curses.tparm(fg_color, 3), 53 | "ascii"), # Yellow 54 | logging.ERROR: str(curses.tparm(fg_color, 1), "ascii"), # Red 55 | } 56 | self._normal = str(curses.tigetstr("sgr0"), "ascii") 57 | 58 | def format(self, record): 59 | try: 60 | record.message = record.getMessage() 61 | except Exception as e: 62 | record.message = "Bad message (%r): %r" % (e, record.__dict__) 63 | record.asctime = time.strftime( 64 | "%y%m%d %H:%M:%S", self.converter(record.created) 65 | ) 66 | prefix = '[%(levelname)1.1s %(asctime)s %(module)s:%(lineno)d]' % record.__dict__ 67 | if self._color: 68 | prefix = ( 69 | self._colors.get(record.levelno, self._normal) + prefix + 70 | self._normal 71 | ) 72 | formatted = prefix + " " + record.message 73 | if record.exc_info: 74 | if not record.exc_text: 75 | record.exc_text = self.formatException(record.exc_info) 76 | if record.exc_text: 77 | formatted = formatted.rstrip() + "\n" + record.exc_text 78 | return formatted.replace("\n", "\n ") 79 | -------------------------------------------------------------------------------- /werobot/messages/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /werobot/messages/base.py: -------------------------------------------------------------------------------- 1 | class WeRoBotMetaClass(type): 2 | TYPES = {} 3 | 4 | def __new__(mcs, name, bases, attrs): 5 | return type.__new__(mcs, name, bases, attrs) 6 | 7 | def __init__(cls, name, bases, attrs): 8 | if '__type__' in attrs: 9 | if isinstance(attrs['__type__'], list): 10 | for _type in attrs['__type__']: 11 | cls.TYPES[_type] = cls 12 | else: 13 | cls.TYPES[attrs['__type__']] = cls 14 | type.__init__(cls, name, bases, attrs) 15 | -------------------------------------------------------------------------------- /werobot/messages/entries.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from werobot.utils import to_text 3 | 4 | 5 | def get_value(instance, path, default=None): 6 | dic = instance.__dict__ 7 | for entry in path.split('.'): 8 | dic = dic.get(entry) 9 | if dic is None: 10 | return default 11 | return dic or default 12 | 13 | 14 | class BaseEntry(object): 15 | def __init__(self, entry, default=None): 16 | self.entry = entry 17 | self.default = default 18 | 19 | 20 | class IntEntry(BaseEntry): 21 | def __get__(self, instance, owner): 22 | try: 23 | return int(get_value(instance, self.entry, self.default)) 24 | except TypeError: 25 | return 26 | 27 | 28 | class FloatEntry(BaseEntry): 29 | def __get__(self, instance, owner): 30 | try: 31 | return float(get_value(instance, self.entry, self.default)) 32 | except TypeError: 33 | return 34 | 35 | 36 | class StringEntry(BaseEntry): 37 | def __get__(self, instance, owner): 38 | v = get_value(instance, self.entry, self.default) 39 | if v is not None: 40 | return to_text(v) 41 | return v 42 | -------------------------------------------------------------------------------- /werobot/messages/events.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.messages.entries import StringEntry, IntEntry, FloatEntry 4 | from werobot.messages.base import WeRoBotMetaClass 5 | 6 | 7 | class EventMetaClass(WeRoBotMetaClass): 8 | pass 9 | 10 | 11 | class WeChatEvent(object, metaclass=EventMetaClass): 12 | target = StringEntry('ToUserName') 13 | source = StringEntry('FromUserName') 14 | time = IntEntry('CreateTime') 15 | message_id = IntEntry('MsgID', 0) 16 | 17 | def __init__(self, message): 18 | self.__dict__.update(message) 19 | 20 | 21 | class SimpleEvent(WeChatEvent): 22 | key = StringEntry('EventKey') 23 | 24 | 25 | class TicketEvent(WeChatEvent): 26 | key = StringEntry('EventKey') 27 | ticket = StringEntry('Ticket') 28 | 29 | 30 | class SubscribeEvent(TicketEvent): 31 | __type__ = 'subscribe_event' 32 | 33 | 34 | class UnSubscribeEvent(WeChatEvent): 35 | __type__ = 'unsubscribe_event' 36 | 37 | 38 | class ScanEvent(TicketEvent): 39 | __type__ = 'scan_event' 40 | 41 | 42 | class ScanCodePushEvent(SimpleEvent): 43 | __type__ = 'scancode_push_event' 44 | scan_type = StringEntry('ScanCodeInfo.ScanType') 45 | scan_result = StringEntry('ScanCodeInfo.ScanResult') 46 | 47 | 48 | class ScanCodeWaitMsgEvent(ScanCodePushEvent): 49 | __type__ = 'scancode_waitmsg_event' 50 | scan_type = StringEntry('ScanCodeInfo.ScanType') 51 | scan_result = StringEntry('ScanCodeInfo.ScanResult') 52 | 53 | 54 | class BasePicEvent(SimpleEvent): 55 | count = IntEntry('SendPicsInfo.Count') 56 | 57 | def __init__(self, message): 58 | super(BasePicEvent, self).__init__(message) 59 | self.pic_list = list() 60 | if self.count > 1: 61 | for item in message['SendPicsInfo']['PicList'].pop('item'): 62 | self.pic_list.append({'pic_md5_sum': item['PicMd5Sum']}) 63 | else: 64 | self.pic_list.append( 65 | { 66 | 'pic_md5_sum': message['SendPicsInfo'] 67 | ['PicList'].pop('item')['PicMd5Sum'] 68 | } 69 | ) 70 | 71 | 72 | class PicSysphotoEvent(BasePicEvent): 73 | __type__ = 'pic_sysphoto_event' 74 | 75 | 76 | class PicPhotoOrAlbumEvent(BasePicEvent): 77 | __type__ = 'pic_photo_or_album_event' 78 | 79 | 80 | class PicWeixinEvent(BasePicEvent): 81 | __type__ = 'pic_weixin_event' 82 | 83 | 84 | class LocationSelectEvent(SimpleEvent): 85 | __type__ = 'location_select_event' 86 | location_x = StringEntry('SendLocationInfo.Location_X') 87 | location_y = StringEntry('SendLocationInfo.Location_Y') 88 | scale = StringEntry('SendLocationInfo.Scale') 89 | label = StringEntry('SendLocationInfo.Label') 90 | poi_name = StringEntry('SendLocationInfo.Poiname') 91 | 92 | 93 | class ClickEvent(SimpleEvent): 94 | __type__ = 'click_event' 95 | 96 | 97 | class ViewEvent(SimpleEvent): 98 | __type__ = 'view_event' 99 | 100 | 101 | class LocationEvent(WeChatEvent): 102 | __type__ = 'location_event' 103 | latitude = FloatEntry('Latitude') 104 | longitude = FloatEntry('Longitude') 105 | precision = FloatEntry('Precision') 106 | 107 | 108 | class TemplateSendJobFinishEvent(WeChatEvent): 109 | __type__ = 'templatesendjobfinish_event' 110 | status = StringEntry('Status') 111 | 112 | 113 | class BaseProductEvent(WeChatEvent): 114 | key_standard = StringEntry('KeyStandard') 115 | key_str = StringEntry('KeyStr') 116 | ext_info = StringEntry('ExtInfo') 117 | 118 | 119 | class UserScanProductEvent(BaseProductEvent): 120 | __type__ = 'user_scan_product_event' 121 | country = StringEntry('Country') 122 | province = StringEntry('Province') 123 | city = StringEntry('City') 124 | sex = IntEntry('Sex') 125 | scene = IntEntry('Scene') 126 | 127 | 128 | class UserScanProductEnterSessionEvent(BaseProductEvent): 129 | __type__ = 'user_scan_product_enter_session_event' 130 | 131 | 132 | class UserScanProductAsyncEvent(BaseProductEvent): 133 | __type__ = 'user_scan_product_async_event' 134 | region_code = StringEntry('RegionCode') 135 | 136 | 137 | class UserScanProductVerifyActionEvent(WeChatEvent): 138 | __type__ = 'user_scan_product_verify_action_event' 139 | key_standard = StringEntry('KeyStandard') 140 | key_str = StringEntry('KeyStr') 141 | result = StringEntry('Result') 142 | reason_msg = StringEntry('ReasonMsg') 143 | 144 | 145 | class BaseCardCheckEvent(WeChatEvent): 146 | card_id = StringEntry('CardId') 147 | refuse_reason = StringEntry('RefuseReason') 148 | 149 | 150 | class CardPassCheckEvent(BaseCardCheckEvent): 151 | __type__ = 'card_pass_check_event' 152 | 153 | 154 | class CardNotPassCheckEvent(BaseCardCheckEvent): 155 | __type__ = 'card_not_pass_check_event' 156 | 157 | 158 | class BaseCardEvent(WeChatEvent): 159 | card_id = StringEntry('CardId') 160 | user_card_code = StringEntry('UserCardCode') 161 | 162 | 163 | class UserGetCardEvent(BaseCardEvent): 164 | __type__ = 'user_get_card_event' 165 | is_give_by_friend = IntEntry('IsGiveByFriend') 166 | friend_user_name = StringEntry('FriendUserName') 167 | outer_id = IntEntry('OuterId') 168 | old_user_card_code = StringEntry('OldUserCardCode') 169 | outer_str = StringEntry('OuterStr') 170 | is_restore_member_card = IntEntry('IsRestoreMemberCard') 171 | is_recommend_by_friend = IntEntry('IsRecommendByFriend') 172 | 173 | 174 | class UserGiftingCardEvent(BaseCardEvent): 175 | __type__ = 'user_gifting_card_event' 176 | is_return_back = IntEntry('IsReturnBack') 177 | friend_user_name = StringEntry('FriendUserName') 178 | is_chat_room = IntEntry('IsChatRoom') 179 | 180 | 181 | class UserDelCardEvent(BaseCardEvent): 182 | __type__ = 'user_del_card_event' 183 | 184 | 185 | class UserConsumeCardEvent(BaseCardEvent): 186 | __type__ = 'user_consume_card_event' 187 | consume_source = StringEntry('ConsumeSource') 188 | location_name = StringEntry('LocationName') 189 | staff_open_id = StringEntry('StaffOpenId') 190 | verify_code = StringEntry('VerifyCode') 191 | remark_amount = StringEntry('RemarkAmount') 192 | outer_str = StringEntry('OuterStr') 193 | 194 | 195 | class UserPayFromPayCellEvent(BaseCardEvent): 196 | __type__ = 'user_pay_from_pay_cell_event' 197 | trans_id = StringEntry('TransId') 198 | location_id = IntEntry('LocationId') 199 | fee = StringEntry('Fee') 200 | original_fee = StringEntry('OriginalFee') 201 | 202 | 203 | class UserViewCardEvent(BaseCardEvent): 204 | __type__ = 'user_view_card_event' 205 | outer_str = StringEntry('OuterStr') 206 | 207 | 208 | class UserEnterSessionFromCardEvent(BaseCardEvent): 209 | __type__ = 'user_enter_session_from_card_event' 210 | 211 | 212 | class UpdateMemberCardEvent(BaseCardEvent): 213 | __type__ = 'update_member_card_event' 214 | modify_bonus = IntEntry('ModifyBonus') 215 | modify_balance = IntEntry('ModifyBalance') 216 | 217 | 218 | class CardSkuRemindEvent(WeChatEvent): 219 | __type__ = 'card_sku_remind_event' 220 | card_id = StringEntry('CardId') 221 | detail = StringEntry('Detail') 222 | 223 | 224 | class CardPayOrderEvent(WeChatEvent): 225 | __type__ = 'card_pay_order_event' 226 | order_id = StringEntry('OrderId') 227 | status = StringEntry('Status') 228 | create_order_time = IntEntry('CreateOrderTime') 229 | pay_finish_time = IntEntry('PayFinishTime') 230 | desc = StringEntry('Desc') 231 | free_coin_count = StringEntry('FreeCoinCount') 232 | pay_coin_count = StringEntry('PayCoinCount') 233 | refund_free_coin_count = StringEntry('RefundFreeCoinCount') 234 | refund_pay_coin_count = StringEntry('RefundPayCoinCount') 235 | order_type = StringEntry('OrderType') 236 | memo = StringEntry('Memo') 237 | receipt_info = StringEntry('ReceiptInfo') 238 | 239 | 240 | class SubmitMembercardUserInfoEvent(BaseCardEvent): 241 | __type__ = 'submit_membercard_user_info_event' 242 | 243 | 244 | class UnknownEvent(WeChatEvent): 245 | __type__ = 'unknown_event' 246 | -------------------------------------------------------------------------------- /werobot/messages/messages.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.messages.entries import StringEntry, IntEntry, FloatEntry 4 | from werobot.messages.base import WeRoBotMetaClass 5 | 6 | 7 | class MessageMetaClass(WeRoBotMetaClass): 8 | pass 9 | 10 | 11 | class WeChatMessage(object, metaclass=MessageMetaClass): 12 | message_id = IntEntry('MsgId', 0) 13 | target = StringEntry('ToUserName') 14 | source = StringEntry('FromUserName') 15 | time = IntEntry('CreateTime', 0) 16 | 17 | def __init__(self, message): 18 | self.__dict__.update(message) 19 | 20 | 21 | class TextMessage(WeChatMessage): 22 | __type__ = 'text' 23 | content = StringEntry('Content') 24 | 25 | 26 | class ImageMessage(WeChatMessage): 27 | __type__ = 'image' 28 | img = StringEntry('PicUrl') 29 | 30 | 31 | class LocationMessage(WeChatMessage): 32 | __type__ = 'location' 33 | location_x = FloatEntry('Location_X') 34 | location_y = FloatEntry('Location_Y') 35 | label = StringEntry('Label') 36 | scale = IntEntry('Scale') 37 | 38 | @property 39 | def location(self): 40 | return self.location_x, self.location_y 41 | 42 | 43 | class LinkMessage(WeChatMessage): 44 | __type__ = 'link' 45 | title = StringEntry('Title') 46 | description = StringEntry('Description') 47 | url = StringEntry('Url') 48 | 49 | 50 | class VoiceMessage(WeChatMessage): 51 | __type__ = 'voice' 52 | media_id = StringEntry('MediaId') 53 | format = StringEntry('Format') 54 | recognition = StringEntry('Recognition') 55 | 56 | 57 | class VideoMessage(WeChatMessage): 58 | __type__ = ['video', 'shortvideo'] 59 | media_id = StringEntry('MediaId') 60 | thumb_media_id = StringEntry('ThumbMediaId') 61 | 62 | 63 | class UnknownMessage(WeChatMessage): 64 | __type__ = 'unknown' 65 | -------------------------------------------------------------------------------- /werobot/parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import xmltodict 4 | from werobot.messages.messages import MessageMetaClass, UnknownMessage 5 | from werobot.messages.events import EventMetaClass, UnknownEvent 6 | 7 | 8 | def parse_user_msg(xml): 9 | message = process_message(parse_xml(xml)) if xml else None 10 | return message 11 | 12 | 13 | def parse_xml(text): 14 | xml_dict = xmltodict.parse(text)["xml"] 15 | xml_dict["raw"] = text 16 | return xml_dict 17 | 18 | 19 | def process_message(message): 20 | """ 21 | Process a message dict and return a Message Object 22 | :param message: Message dict returned by `parse_xml` function 23 | :return: Message Object 24 | """ 25 | message["type"] = message.pop("MsgType").lower() 26 | if message["type"] == 'event': 27 | message["type"] = str(message.pop("Event")).lower() + '_event' 28 | message_type = EventMetaClass.TYPES.get(message["type"], UnknownEvent) 29 | else: 30 | message_type = MessageMetaClass.TYPES.get( 31 | message["type"], UnknownMessage 32 | ) 33 | return message_type(message) 34 | -------------------------------------------------------------------------------- /werobot/pay.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | from hashlib import sha1, md5 4 | from urllib import urlencode 5 | import time 6 | from werobot.client import Client 7 | from werobot.utils import pay_sign_dict, generate_token 8 | from functools import partial 9 | 10 | NATIVE_BASE_URL = 'weixin://wxpay/bizpayurl?' 11 | 12 | 13 | class WeixinPayClient(Client): 14 | """ 15 | 简化微信支付API操作 16 | """ 17 | def __init__(self, appid, pay_sign_key, pay_partner_id, pay_partner_key): 18 | self.pay_sign_key = pay_sign_key 19 | self.pay_partner_id = pay_partner_id 20 | self.pay_partner_key = pay_partner_key 21 | self._pay_sign_dict = partial(pay_sign_dict, appid, pay_sign_key) 22 | 23 | self._token = None 24 | self.token_expires_at = None 25 | 26 | def create_js_pay_package(self, **package): 27 | """ 28 | 签名 pay package 需要的参数 29 | 详情请参考 支付开发文档 30 | 31 | :param package: 需要签名的的参数 32 | :return: 可以使用的packagestr 33 | """ 34 | assert self.pay_partner_id, "PAY_PARTNER_ID IS EMPTY" 35 | assert self.pay_partner_key, "PAY_PARTNER_KEY IS EMPTY" 36 | 37 | package.update({ 38 | 'partner': self.pay_partner_id, 39 | }) 40 | 41 | package.setdefault('bank_type', 'WX') 42 | package.setdefault('fee_type', '1') 43 | package.setdefault('input_charset', 'UTF-8') 44 | 45 | params = package.items() 46 | params.sort() 47 | 48 | sign = md5( 49 | '&'.join( 50 | [ 51 | "%s=%s" % (str(p[0]), str(p[1])) 52 | for p in params + [('key', self.pay_partner_key)] 53 | ] 54 | ) 55 | ).hexdigest().upper() 56 | 57 | return urlencode(params + [('sign', sign)]) 58 | 59 | def create_js_pay_params(self, **package): 60 | """ 61 | 签名 js 需要的参数 62 | 详情请参考 支付开发文档 63 | 64 | :: 65 | 66 | wxclient.create_js_pay_params( 67 | body=标题, out_trade_no=本地订单号, total_fee=价格单位分, 68 | notify_url=通知url, 69 | spbill_create_ip=建议为支付人ip, 70 | ) 71 | 72 | :param package: 需要签名的的参数 73 | :return: 支付需要的对象 74 | """ 75 | pay_param, sign, sign_type = self._pay_sign_dict( 76 | package=self.create_js_pay_package(**package) 77 | ) 78 | pay_param['paySign'] = sign 79 | pay_param['signType'] = sign_type 80 | 81 | # 腾讯这个还得转成大写 JS 才认 82 | for key in ['appId', 'timeStamp', 'nonceStr']: 83 | pay_param[key] = str(pay_param.pop(key.lower())) 84 | 85 | return pay_param 86 | 87 | def create_js_edit_address_param(self, accesstoken, **params): 88 | """ 89 | alpha 90 | 暂时不建议使用 91 | 这个接口使用起来十分不友好 92 | 而且会引起巨大的误解 93 | 94 | url 需要带上 code 和 state (url?code=xxx&state=1) 95 | code 和state 是 oauth 时候回来的 96 | 97 | token 要传用户的 token 98 | 99 | 这尼玛 你能相信这些支付接口都是腾讯出的? 100 | """ 101 | params.update( 102 | { 103 | 'appId': self.appid, 104 | 'nonceStr': generate_token(8), 105 | 'timeStamp': int(time.time()) 106 | } 107 | ) 108 | 109 | _params = [(k.lower(), str(v)) for k, v in params.items()] 110 | _params += [('accesstoken', accesstoken)] 111 | _params.sort() 112 | 113 | string1 = '&'.join(["%s=%s" % (p[0], p[1]) for p in _params]) 114 | sign = sha1(string1).hexdigest() 115 | 116 | params = dict([(k, str(v)) for k, v in params.items()]) 117 | 118 | params['addrSign'] = sign 119 | params['signType'] = 'sha1' 120 | params['scope'] = params.get('scope', 'jsapi_address') 121 | 122 | return params 123 | 124 | def create_native_pay_url(self, productid): 125 | """ 126 | 创建 native pay url 127 | 详情请参考 支付开发文档 128 | 129 | :param productid: 本地商品ID 130 | :return: 返回URL 131 | """ 132 | 133 | params, sign, = self._pay_sign_dict(productid=productid) 134 | 135 | params['sign'] = sign 136 | 137 | return NATIVE_BASE_URL + urlencode(params) 138 | 139 | def pay_deliver_notify(self, **deliver_info): 140 | """ 141 | 通知 腾讯发货 142 | 143 | 一般形式 :: 144 | wxclient.pay_delivernotify( 145 | openid=openid, 146 | transid=transaction_id, 147 | out_trade_no=本地订单号, 148 | deliver_timestamp=int(time.time()), 149 | deliver_status="1", 150 | deliver_msg="ok" 151 | ) 152 | 153 | :param 需要签名的的参数 154 | :return: 支付需要的对象 155 | """ 156 | params, sign, _ = self._pay_sign_dict( 157 | add_noncestr=False, add_timestamp=False, **deliver_info 158 | ) 159 | 160 | params['app_signature'] = sign 161 | params['sign_method'] = 'sha1' 162 | 163 | return self.post( 164 | url="https://api.weixin.qq.com/pay/delivernotify", data=params 165 | ) 166 | 167 | def pay_order_query(self, out_trade_no): 168 | """ 169 | 查询订单状态 170 | 一般用于无法确定 订单状态时候补偿 171 | 172 | :param out_trade_no: 本地订单号 173 | :return: 订单信息dict 174 | """ 175 | 176 | package = { 177 | 'partner': self.pay_partner_id, 178 | 'out_trade_no': out_trade_no, 179 | } 180 | 181 | _package = package.items() 182 | _package.sort() 183 | 184 | s = '&'.join( 185 | [ 186 | "%s=%s" % (p[0], str(p[1])) 187 | for p in (_package + [('key', self.pay_partner_key)]) 188 | ] 189 | ) 190 | package['sign'] = md5(s).hexdigest().upper() 191 | 192 | package = '&'.join(["%s=%s" % (p[0], p[1]) for p in package.items()]) 193 | 194 | params, sign, _ = self._pay_sign_dict( 195 | add_noncestr=False, package=package 196 | ) 197 | 198 | params['app_signature'] = sign 199 | params['sign_method'] = 'sha1' 200 | 201 | return self.post( 202 | url="https://api.weixin.qq.com/pay/orderquery", data=params 203 | ) 204 | -------------------------------------------------------------------------------- /werobot/replies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | 4 | from collections import defaultdict, namedtuple 5 | from werobot.utils import is_string, to_text 6 | 7 | 8 | def renderable_named_tuple(typename, field_names, tempalte): 9 | class TMP(namedtuple(typename=typename, field_names=field_names)): 10 | __TEMPLATE__ = tempalte 11 | 12 | @property 13 | def args(self): 14 | # https://bugs.python.org/issue24931 15 | return dict(zip(self._fields, self)) 16 | 17 | def process_args(self, kwargs): 18 | args = defaultdict(str) 19 | for k, v in kwargs.items(): 20 | if is_string(v): 21 | v = to_text(v) 22 | args[k] = v 23 | return args 24 | 25 | def render(self): 26 | return to_text( 27 | self.__TEMPLATE__.format(**self.process_args(self.args)) 28 | ) 29 | 30 | TMP.__name__ = typename 31 | return TMP 32 | 33 | 34 | class WeChatReply(object): 35 | def process_args(self, args): 36 | pass 37 | 38 | def __init__(self, message=None, **kwargs): 39 | if message and "source" not in kwargs: 40 | kwargs["source"] = message.target 41 | 42 | if message and "target" not in kwargs: 43 | kwargs["target"] = message.source 44 | 45 | if 'time' not in kwargs: 46 | kwargs["time"] = int(time.time()) 47 | 48 | args = defaultdict(str) 49 | for k, v in kwargs.items(): 50 | if is_string(v): 51 | v = to_text(v) 52 | args[k] = v 53 | self.process_args(args) 54 | self._args = args 55 | 56 | def render(self): 57 | return to_text(self.TEMPLATE.format(**self._args)) 58 | 59 | def __getattr__(self, item): 60 | if item in self._args: 61 | return self._args[item] 62 | 63 | 64 | class TextReply(WeChatReply): 65 | TEMPLATE = to_text( 66 | """ 67 | <xml> 68 | <ToUserName><![CDATA[{target}]]></ToUserName> 69 | <FromUserName><![CDATA[{source}]]></FromUserName> 70 | <CreateTime>{time}</CreateTime> 71 | <MsgType><![CDATA[text]]></MsgType> 72 | <Content><![CDATA[{content}]]></Content> 73 | </xml> 74 | """ 75 | ) 76 | 77 | 78 | class ImageReply(WeChatReply): 79 | TEMPLATE = to_text( 80 | """ 81 | <xml> 82 | <ToUserName><![CDATA[{target}]]></ToUserName> 83 | <FromUserName><![CDATA[{source}]]></FromUserName> 84 | <CreateTime>{time}</CreateTime> 85 | <MsgType><![CDATA[image]]></MsgType> 86 | <Image> 87 | <MediaId><![CDATA[{media_id}]]></MediaId> 88 | </Image> 89 | </xml> 90 | """ 91 | ) 92 | 93 | 94 | class VoiceReply(WeChatReply): 95 | TEMPLATE = to_text( 96 | """ 97 | <xml> 98 | <ToUserName><![CDATA[{target}]]></ToUserName> 99 | <FromUserName><![CDATA[{source}]]></FromUserName> 100 | <CreateTime>{time}</CreateTime> 101 | <MsgType><![CDATA[voice]]></MsgType> 102 | <Voice> 103 | <MediaId><![CDATA[{media_id}]]></MediaId> 104 | </Voice> 105 | </xml> 106 | """ 107 | ) 108 | 109 | 110 | class VideoReply(WeChatReply): 111 | TEMPLATE = to_text( 112 | """ 113 | <xml> 114 | <ToUserName><![CDATA[{target}]]></ToUserName> 115 | <FromUserName><![CDATA[{source}]]></FromUserName> 116 | <CreateTime>{time}</CreateTime> 117 | <MsgType><![CDATA[video]]></MsgType> 118 | <Video> 119 | <MediaId><![CDATA[{media_id}]]></MediaId> 120 | <Title><![CDATA[{title}]]></Title> 121 | <Description><![CDATA[{description}]]></Description> 122 | </Video> 123 | </xml> 124 | """ 125 | ) 126 | 127 | def process_args(self, args): 128 | args.setdefault('title', '') 129 | args.setdefault('description', '') 130 | 131 | 132 | Article = renderable_named_tuple( 133 | typename="Article", 134 | field_names=("title", "description", "img", "url"), 135 | tempalte=to_text( 136 | """ 137 | <item> 138 | <Title><![CDATA[{title}]]></Title> 139 | <Description><![CDATA[{description}]]></Description> 140 | <PicUrl><![CDATA[{img}]]></PicUrl> 141 | <Url><![CDATA[{url}]]></Url> 142 | </item> 143 | """ 144 | ) 145 | ) 146 | 147 | 148 | class ArticlesReply(WeChatReply): 149 | TEMPLATE = to_text( 150 | """ 151 | <xml> 152 | <ToUserName><![CDATA[{target}]]></ToUserName> 153 | <FromUserName><![CDATA[{source}]]></FromUserName> 154 | <CreateTime>{time}</CreateTime> 155 | <MsgType><![CDATA[news]]></MsgType> 156 | <Content><![CDATA[{content}]]></Content> 157 | <ArticleCount>{count}</ArticleCount> 158 | <Articles>{items}</Articles> 159 | </xml> 160 | """ 161 | ) 162 | 163 | def __init__(self, message=None, **kwargs): 164 | super(ArticlesReply, self).__init__(message, **kwargs) 165 | self._articles = [] 166 | 167 | def add_article(self, article): 168 | if len(self._articles) >= 10: 169 | raise AttributeError( 170 | "Can't add more than 10 articles" 171 | " in an ArticlesReply" 172 | ) 173 | else: 174 | self._articles.append(article) 175 | 176 | def render(self): 177 | items = [] 178 | for article in self._articles: 179 | items.append(article.render()) 180 | self._args["items"] = ''.join(items) 181 | self._args["count"] = len(items) 182 | if "content" not in self._args: 183 | self._args["content"] = '' 184 | return ArticlesReply.TEMPLATE.format(**self._args) 185 | 186 | 187 | class MusicReply(WeChatReply): 188 | TEMPLATE = to_text( 189 | """ 190 | <xml> 191 | <ToUserName><![CDATA[{target}]]></ToUserName> 192 | <FromUserName><![CDATA[{source}]]></FromUserName> 193 | <CreateTime>{time}</CreateTime> 194 | <MsgType><![CDATA[music]]></MsgType> 195 | <Music> 196 | <Title><![CDATA[{title}]]></Title> 197 | <Description><![CDATA[{description}]]></Description> 198 | <MusicUrl><![CDATA[{url}]]></MusicUrl> 199 | <HQMusicUrl><![CDATA[{hq_url}]]></HQMusicUrl> 200 | </Music> 201 | </xml> 202 | """ 203 | ) 204 | 205 | def process_args(self, args): 206 | if 'hq_url' not in args: 207 | args['hq_url'] = args['url'] 208 | 209 | 210 | class TransferCustomerServiceReply(WeChatReply): 211 | @property 212 | def TEMPLATE(self): 213 | if 'account' in self._args: 214 | return to_text( 215 | """ 216 | <xml> 217 | <ToUserName><![CDATA[{target}]]></ToUserName> 218 | <FromUserName><![CDATA[{source}]]></FromUserName> 219 | <CreateTime>{time}</CreateTime> 220 | <MsgType><![CDATA[transfer_customer_service]]></MsgType> 221 | <TransInfo> 222 | <KfAccount><![CDATA[{account}]]></KfAccount> 223 | </TransInfo> 224 | </xml> 225 | """ 226 | ) 227 | else: 228 | return to_text( 229 | """ 230 | <xml> 231 | <ToUserName><![CDATA[{target}]]></ToUserName> 232 | <FromUserName><![CDATA[{source}]]></FromUserName> 233 | <CreateTime>{time}</CreateTime> 234 | <MsgType><![CDATA[transfer_customer_service]]></MsgType> 235 | </xml> 236 | """ 237 | ) 238 | 239 | 240 | class SuccessReply(WeChatReply): 241 | def render(self): 242 | return "success" 243 | 244 | 245 | def process_function_reply(reply, message=None): 246 | if is_string(reply): 247 | return TextReply(message=message, content=reply) 248 | elif isinstance(reply, list) and all([len(x) == 4 for x in reply]): 249 | if len(reply) > 10: 250 | raise AttributeError( 251 | "Can't add more than 10 articles" 252 | " in an ArticlesReply" 253 | ) 254 | r = ArticlesReply(message=message) 255 | for article in reply: 256 | article = Article(*article) 257 | r.add_article(article) 258 | return r 259 | elif isinstance(reply, list) and 3 <= len(reply) <= 4: 260 | if len(reply) == 3: 261 | # 如果数组长度为3, 那么高质量音乐链接的网址和普通质量的网址相同。 262 | reply.append(reply[-1]) 263 | title, description, url, hq_url = reply 264 | return MusicReply( 265 | message=message, 266 | title=title, 267 | description=description, 268 | url=url, 269 | hq_url=hq_url 270 | ) 271 | return reply 272 | -------------------------------------------------------------------------------- /werobot/session/__init__.py: -------------------------------------------------------------------------------- 1 | class SessionStorage(object): 2 | def get(self, id): 3 | raise NotImplementedError() 4 | 5 | def set(self, id, value): 6 | raise NotImplementedError() 7 | 8 | def delete(self, id): 9 | raise NotImplementedError() 10 | 11 | def __getitem__(self, id): 12 | return self.get(id) 13 | 14 | def __setitem__(self, id, session): 15 | self.set(id, session) 16 | 17 | def __delitem__(self, id): 18 | self.delete(id) 19 | -------------------------------------------------------------------------------- /werobot/session/filestorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | import anydbm as dbm 5 | 6 | assert dbm 7 | except ImportError: 8 | import dbm 9 | 10 | from werobot.session import SessionStorage 11 | from werobot.utils import json_loads, json_dumps, to_binary 12 | 13 | 14 | class FileStorage(SessionStorage): 15 | """ 16 | FileStorage 会把你的 Session 数据以 dbm 形式储存在文件中。 17 | 18 | :param filename: 文件名, 默认为 ``werobot_session`` 19 | """ 20 | def __init__(self, filename: str = 'werobot_session'): 21 | try: 22 | self.db = dbm.open(filename, "c") 23 | except TypeError: # pragma: no cover 24 | # dbm in PyPy requires filename to be binary 25 | self.db = dbm.open(to_binary(filename), "c") 26 | 27 | def get(self, id): 28 | """ 29 | 根据 id 获取数据。 30 | 31 | :param id: 要获取的数据的 id 32 | :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象 33 | """ 34 | try: 35 | session_json = self.db[id] 36 | except KeyError: 37 | session_json = "{}" 38 | return json_loads(session_json) 39 | 40 | def set(self, id, value): 41 | """ 42 | 根据 id 写入数据。 43 | 44 | :param id: 要写入的 id 45 | :param value: 要写入的数据,可以是一个 ``dict`` 对象 46 | """ 47 | self.db[id] = json_dumps(value) 48 | 49 | def delete(self, id): 50 | """ 51 | 根据 id 删除数据。 52 | 53 | :param id: 要删除的数据的 id 54 | """ 55 | del self.db[id] 56 | -------------------------------------------------------------------------------- /werobot/session/mongodbstorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.session import SessionStorage 4 | from werobot.utils import json_loads, json_dumps 5 | 6 | 7 | class MongoDBStorage(SessionStorage): 8 | """ 9 | MongoDBStorage 会把你的 Session 数据储存在一个 MongoDB Collection 中 :: 10 | 11 | import pymongo 12 | import werobot 13 | from werobot.session.mongodbstorage import MongoDBStorage 14 | 15 | collection = pymongo.MongoClient()["wechat"]["session"] 16 | session_storage = MongoDBStorage(collection) 17 | robot = werobot.WeRoBot(token="token", enable_session=True, 18 | session_storage=session_storage) 19 | 20 | 21 | 你需要安装 ``pymongo`` 才能使用 MongoDBStorage 。 22 | 23 | :param collection: 一个 MongoDB Collection。 24 | """ 25 | def __init__(self, collection): 26 | self.collection = collection 27 | collection.create_index("wechat_id") 28 | 29 | def _get_document(self, id): 30 | return self.collection.find_one({"wechat_id": id}) 31 | 32 | def get(self, id): 33 | """ 34 | 根据 id 获取数据。 35 | 36 | :param id: 要获取的数据的 id 37 | :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象 38 | """ 39 | document = self._get_document(id) 40 | if document: 41 | session_json = document["session"] 42 | return json_loads(session_json) 43 | return {} 44 | 45 | def set(self, id, value): 46 | """ 47 | 根据 id 写入数据。 48 | 49 | :param id: 要写入的 id 50 | :param value: 要写入的数据,可以是一个 ``dict`` 对象 51 | """ 52 | session = json_dumps(value) 53 | self.collection.replace_one( 54 | {"wechat_id": id}, { 55 | "wechat_id": id, 56 | "session": session 57 | }, 58 | upsert=True 59 | ) 60 | 61 | def delete(self, id): 62 | """ 63 | 根据 id 删除数据。 64 | 65 | :param id: 要删除的数据的 id 66 | """ 67 | self.collection.delete_one({"wechat_id": id}) 68 | -------------------------------------------------------------------------------- /werobot/session/mysqlstorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.session import SessionStorage 4 | from werobot.utils import json_loads, json_dumps 5 | 6 | __CREATE_TABLE_SQL__ = """ 7 | CREATE TABLE IF NOT EXISTS WeRoBot( 8 | id VARCHAR(100) NOT NULL , 9 | value BLOB NOT NULL, 10 | PRIMARY KEY (id) 11 | )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 12 | """ 13 | 14 | 15 | class MySQLStorage(SessionStorage): 16 | """ 17 | MySQLStorage 会把你的 Session 数据储存在 MySQL 中 :: 18 | 19 | import MySQLdb # 使用 mysqlclient 20 | import werobot 21 | from werobot.session.mysqlstorage import MySQLStorage 22 | 23 | conn = MySQLdb.connect(user='', db='', passwd='', host='') 24 | session_storage = MySQLStorage(conn) 25 | robot = werobot.WeRoBot(token="token", enable_session=True, 26 | session_storage=session_storage) 27 | 28 | 或者 :: 29 | 30 | import pymysql # 使用 pymysql 31 | import werobot 32 | from werobot.session.mysqlstorage import MySQLStorage 33 | 34 | session_storage = MySQLStorage( 35 | conn=pymysql.connect( 36 | user='喵', 37 | password='喵喵', 38 | db='werobot', 39 | host='127.0.0.1', 40 | charset='utf8' 41 | )) 42 | robot = werobot.WeRoBot(token="token", enable_session=True, 43 | session_storage=session_storage) 44 | 45 | 你需要安装一个 MySQL Client 才能使用 MySQLStorage,比如 ``pymysql``,``mysqlclient`` 。 46 | 47 | 理论上符合 `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_ 的库都可以使用,\ 48 | 测试时使用的是 ``pymysql``。 49 | 50 | :param conn: `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_\ 51 | 定义的 Connection 对象 52 | """ 53 | def __init__(self, conn): 54 | self.conn = conn 55 | self.conn.cursor().execute(__CREATE_TABLE_SQL__) 56 | 57 | def get(self, id): 58 | """ 59 | 根据 id 获取数据。 60 | 61 | :param id: 要获取的数据的 id 62 | :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象 63 | """ 64 | cur = self.conn.cursor() 65 | cur.execute("SELECT value FROM WeRoBot WHERE id=%s LIMIT 1;", (id, )) 66 | session_json = cur.fetchone() 67 | if session_json is None: 68 | return {} 69 | return json_loads(session_json[0]) 70 | 71 | def set(self, id, value): 72 | """ 73 | 根据 id 写入数据。 74 | 75 | :param id: 要写入的 id 76 | :param value: 要写入的数据,可以是一个 ``dict`` 对象 77 | """ 78 | value = json_dumps(value) 79 | self.conn.cursor().execute( 80 | "INSERT INTO WeRoBot (id, value) VALUES (%s,%s) \ 81 | ON DUPLICATE KEY UPDATE value=%s", ( 82 | id, 83 | value, 84 | value, 85 | ) 86 | ) 87 | self.conn.commit() 88 | 89 | def delete(self, id): 90 | """ 91 | 根据 id 删除数据。 92 | 93 | :param id: 要删除的数据的 id 94 | """ 95 | self.conn.cursor().execute("DELETE FROM WeRoBot WHERE id=%s", (id, )) 96 | self.conn.commit() 97 | -------------------------------------------------------------------------------- /werobot/session/postgresqlstorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.session import SessionStorage 4 | from werobot.utils import json_loads, json_dumps 5 | 6 | __CREATE_TABLE_SQL__ = """ 7 | CREATE TABLE IF NOT EXISTS WeRoBot 8 | ( 9 | id VARCHAR(100) PRIMARY KEY, 10 | value TEXT NOT NULL 11 | ); 12 | """ 13 | 14 | 15 | class PostgreSQLStorage(SessionStorage): 16 | """ 17 | PostgreSQLStorage 会把你的 Session 数据储存在 PostgreSQL 中 :: 18 | 19 | import psycopg2 # pip install psycopg2-binary 20 | import werobot 21 | from werobot.session.postgresqlstorage import PostgreSQLStorage 22 | 23 | conn = psycopg2.connect(host='127.0.0.1', port='5432', dbname='werobot', user='nya', password='nyanya') 24 | session_storage = PostgreSQLStorage(conn) 25 | robot = werobot.WeRoBot(token="token", enable_session=True, 26 | session_storage=session_storage) 27 | 28 | 你需要安装一个 ``PostgreSQL Client`` 才能使用 PostgreSQLStorage,比如 ``psycopg2``。 29 | 30 | 理论上符合 `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_ 的库都可以使用,\ 31 | 测试时使用的是 ``psycopg2``。 32 | 33 | :param conn: `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_\ 34 | 定义的 Connection 对象 35 | """ 36 | def __init__(self, conn): 37 | self.conn = conn 38 | self.conn.cursor().execute(__CREATE_TABLE_SQL__) 39 | 40 | def get(self, id): 41 | """ 42 | 根据 id 获取数据。 43 | 44 | :param id: 要获取的数据的 id 45 | :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象 46 | """ 47 | cur = self.conn.cursor() 48 | cur.execute("SELECT value FROM WeRoBot WHERE id=%s LIMIT 1;", (id, )) 49 | session_json = cur.fetchone() 50 | if session_json is None: 51 | return {} 52 | return json_loads(session_json[0]) 53 | 54 | def set(self, id, value): 55 | """ 56 | 根据 id 写入数据。 57 | 58 | :param id: 要写入的 id 59 | :param value: 要写入的数据,可以是一个 ``dict`` 对象 60 | """ 61 | value = json_dumps(value) 62 | self.conn.cursor().execute( 63 | "INSERT INTO WeRoBot (id, value) values (%s, %s) ON CONFLICT (id) DO UPDATE SET value = %s;", 64 | ( 65 | id, 66 | value, 67 | value, 68 | ) 69 | ) 70 | self.conn.commit() 71 | 72 | def delete(self, id): 73 | """ 74 | 根据 id 删除数据。 75 | 76 | :param id: 要删除的数据的 id 77 | """ 78 | self.conn.cursor().execute("DELETE FROM WeRoBot WHERE id=%s", (id, )) 79 | self.conn.commit() 80 | -------------------------------------------------------------------------------- /werobot/session/redisstorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.session import SessionStorage 4 | from werobot.utils import json_loads, json_dumps 5 | 6 | 7 | class RedisStorage(SessionStorage): 8 | """ 9 | RedisStorage 会把你的 Session 数据储存在 Redis 中 :: 10 | 11 | import redis 12 | import werobot 13 | from werobot.session.redisstorage import RedisStorage 14 | 15 | db = redis.Redis() 16 | session_storage = RedisStorage(db, prefix="my_prefix_") 17 | robot = werobot.WeRoBot(token="token", enable_session=True, 18 | session_storage=session_storage) 19 | 20 | 21 | 你需要安装 ``redis`` 才能使用 RedisStorage 。 22 | 23 | :param redis: 一个 Redis Client。 24 | :param prefix: Reids 中 Session 数据 key 的 prefix 。默认为 ``ws_`` 25 | """ 26 | def __init__(self, redis, prefix='ws_'): 27 | for method_name in ['get', 'set', 'delete']: 28 | assert hasattr(redis, method_name) 29 | self.redis = redis 30 | self.prefix = prefix 31 | 32 | def key_name(self, s): 33 | return '{prefix}{s}'.format(prefix=self.prefix, s=s) 34 | 35 | def get(self, id): 36 | """ 37 | 根据 id 获取数据。 38 | 39 | :param id: 要获取的数据的 id 40 | :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象 41 | """ 42 | id = self.key_name(id) 43 | session_json = self.redis.get(id) or '{}' 44 | return json_loads(session_json) 45 | 46 | def set(self, id, value): 47 | """ 48 | 根据 id 写入数据。 49 | 50 | :param id: 要写入的 id 51 | :param value: 要写入的数据,可以是一个 ``dict`` 对象 52 | """ 53 | id = self.key_name(id) 54 | self.redis.set(id, json_dumps(value)) 55 | 56 | def delete(self, id): 57 | """ 58 | 根据 id 删除数据。 59 | 60 | :param id: 要删除的数据的 id 61 | """ 62 | id = self.key_name(id) 63 | self.redis.delete(id) 64 | -------------------------------------------------------------------------------- /werobot/session/saekvstorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import SessionStorage 4 | 5 | 6 | class SaeKVDBStorage(SessionStorage): 7 | """ 8 | SaeKVDBStorage 使用SAE 的 KVDB 来保存你的session :: 9 | 10 | import werobot 11 | from werobot.session.saekvstorage import SaeKVDBStorage 12 | 13 | session_storage = SaeKVDBStorage() 14 | robot = werobot.WeRoBot(token="token", enable_session=True, 15 | session_storage=session_storage) 16 | 17 | 需要先在后台开启 KVDB 支持 18 | 19 | :param prefix: KVDB 中 Session 数据 key 的 prefix 。默认为 ``ws_`` 20 | """ 21 | def __init__(self, prefix='ws_'): 22 | try: 23 | import sae.kvdb 24 | except ImportError: 25 | raise RuntimeError("SaeKVDBStorage requires SAE environment") 26 | self.kv = sae.kvdb.KVClient() # pragma: no cover 27 | self.prefix = prefix # pragma: no cover 28 | 29 | def key_name(self, s): 30 | return '{prefix}{s}'.format(prefix=self.prefix, s=s) 31 | 32 | def get(self, id): 33 | """ 34 | 根据 id 获取数据。 35 | 36 | :param id: 要获取的数据的 id 37 | :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象 38 | """ 39 | return self.kv.get(self.key_name(id)) or {} 40 | 41 | def set(self, id, value): 42 | """ 43 | 根据 id 写入数据。 44 | 45 | :param id: 要写入的 id 46 | :param value: 要写入的数据,可以是一个 ``dict`` 对象 47 | """ 48 | return self.kv.set(self.key_name(id), value) 49 | 50 | def delete(self, id): 51 | """ 52 | 根据 id 删除数据。 53 | 54 | :param id: 要删除的数据的 id 55 | """ 56 | return self.kv.delete(self.key_name(id)) 57 | -------------------------------------------------------------------------------- /werobot/session/sqlitestorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from werobot.session import SessionStorage 4 | from werobot.utils import json_loads, json_dumps 5 | import sqlite3 6 | 7 | __CREATE_TABLE_SQL__ = """ 8 | CREATE TABLE IF NOT EXISTS WeRoBot 9 | (id TEXT PRIMARY KEY NOT NULL , 10 | value TEXT NOT NULL ); 11 | """ 12 | 13 | 14 | class SQLiteStorage(SessionStorage): 15 | """ 16 | SQLiteStorge 会把 Session 数据储存在一个 SQLite 数据库文件中 :: 17 | 18 | import werobot 19 | from werobot.session.sqlitestorage import SQLiteStorage 20 | 21 | session_storage = SQLiteStorage 22 | robot = werobot.WeRoBot(token="token", enable_session=True, 23 | session_storage=session_storage) 24 | 25 | :param filename: SQLite数据库的文件名, 默认是 ``werobot_session.sqlite3`` 26 | """ 27 | def __init__(self, filename='werobot_session.sqlite3'): 28 | self.db = sqlite3.connect(filename, check_same_thread=False) 29 | self.db.text_factory = str 30 | self.db.execute(__CREATE_TABLE_SQL__) 31 | 32 | def get(self, id): 33 | """ 34 | 根据 id 获取数据。 35 | 36 | :param id: 要获取的数据的 id 37 | :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象 38 | """ 39 | session_json = self.db.execute( 40 | "SELECT value FROM WeRoBot WHERE id=? LIMIT 1;", (id, ) 41 | ).fetchone() 42 | if session_json is None: 43 | return {} 44 | return json_loads(session_json[0]) 45 | 46 | def set(self, id, value): 47 | """ 48 | 根据 id 写入数据。 49 | 50 | :param id: 要写入的 id 51 | :param value: 要写入的数据,可以是一个 ``dict`` 对象 52 | """ 53 | self.db.execute( 54 | "INSERT OR REPLACE INTO WeRoBot (id, value) VALUES (?,?);", 55 | (id, json_dumps(value)) 56 | ) 57 | self.db.commit() 58 | 59 | def delete(self, id): 60 | """ 61 | 根据 id 删除数据。 62 | 63 | :param id: 要删除的数据的 id 64 | """ 65 | self.db.execute("DELETE FROM WeRoBot WHERE id=?;", (id, )) 66 | self.db.commit() 67 | -------------------------------------------------------------------------------- /werobot/testing.py: -------------------------------------------------------------------------------- 1 | from .parser import parse_user_msg 2 | 3 | __all__ = ['WeTest'] 4 | 5 | 6 | class WeTest(object): 7 | def __init__(self, app): 8 | self._app = app 9 | 10 | def send_xml(self, xml): 11 | message = parse_user_msg(xml) 12 | return self._app.get_reply(message) 13 | -------------------------------------------------------------------------------- /werobot/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import io 4 | import json 5 | import os 6 | import random 7 | import re 8 | import string 9 | import time 10 | from functools import wraps 11 | from hashlib import sha1 12 | 13 | try: 14 | from secrets import choice 15 | except ImportError: 16 | from random import choice 17 | 18 | string_types = (str, bytes) 19 | 20 | re_type = type(re.compile("regex_test")) 21 | 22 | 23 | def get_signature(token, timestamp, nonce, *args): 24 | sign = [token, timestamp, nonce] + list(args) 25 | sign.sort() 26 | sign = to_binary(''.join(sign)) 27 | return sha1(sign).hexdigest() 28 | 29 | 30 | def check_signature(token, timestamp, nonce, signature): 31 | if not (token and timestamp and nonce and signature): 32 | return False 33 | sign = get_signature(token, timestamp, nonce) 34 | return sign == signature 35 | 36 | 37 | def check_token(token): 38 | return re.match('^[A-Za-z0-9]{3,32}#39;, token) 39 | 40 | 41 | def cached_property(method): 42 | prop_name = '_{}'.format(method.__name__) 43 | 44 | @wraps(method) 45 | def wrapped_func(self, *args, **kwargs): 46 | if not hasattr(self, prop_name): 47 | setattr(self, prop_name, method(self, *args, **kwargs)) 48 | return getattr(self, prop_name) 49 | 50 | return property(wrapped_func) 51 | 52 | 53 | def to_text(value, encoding="utf-8") -> str: 54 | if isinstance(value, str): 55 | return value 56 | if isinstance(value, bytes): 57 | return value.decode(encoding) 58 | return str(value) 59 | 60 | 61 | def to_binary(value, encoding="utf-8") -> bytes: 62 | if isinstance(value, bytes): 63 | return value 64 | if isinstance(value, str): 65 | return value.encode(encoding) 66 | return bytes(value) 67 | 68 | 69 | def is_string(value) -> bool: 70 | """Check if value's type is `str` or `bytes` 71 | """ 72 | return isinstance(value, string_types) 73 | 74 | 75 | def byte2int(s, index=0): 76 | """Get the ASCII int value of a character in a string. 77 | 78 | :param s: a string 79 | :param index: the position of desired character 80 | 81 | :return: ASCII int value 82 | """ 83 | return s[index] 84 | 85 | 86 | def generate_token(length=''): 87 | if not length: 88 | length = random.randint(3, 32) 89 | length = int(length) 90 | assert 3 <= length <= 32 91 | letters = string.ascii_letters + string.digits 92 | return ''.join(choice(letters) for _ in range(length)) 93 | 94 | 95 | def json_loads(s): 96 | s = to_text(s) 97 | return json.loads(s) 98 | 99 | 100 | def json_dumps(d): 101 | return json.dumps(d) 102 | 103 | 104 | def pay_sign_dict( 105 | appid, 106 | pay_sign_key, 107 | add_noncestr=True, 108 | add_timestamp=True, 109 | add_appid=True, 110 | **kwargs 111 | ): 112 | """ 113 | 支付参数签名 114 | """ 115 | assert pay_sign_key, "PAY SIGN KEY IS EMPTY" 116 | 117 | if add_appid: 118 | kwargs.update({'appid': appid}) 119 | 120 | if add_noncestr: 121 | kwargs.update({'noncestr': generate_token()}) 122 | 123 | if add_timestamp: 124 | kwargs.update({'timestamp': int(time.time())}) 125 | 126 | params = kwargs.items() 127 | 128 | _params = [ 129 | (k.lower(), v) for k, v in kwargs.items() if k.lower() != "appid" 130 | ] 131 | _params += [('appid', appid), ('appkey', pay_sign_key)] 132 | _params.sort() 133 | 134 | sign = '&'.join(["%s=%s" % (str(p[0]), str(p[1])) 135 | for p in _params]).encode("utf-8") 136 | sign = sha1(sign).hexdigest() 137 | sign_type = 'SHA1' 138 | 139 | return dict(params), sign, sign_type 140 | 141 | 142 | def make_error_page(url): 143 | with io.open( 144 | os.path.join(os.path.dirname(__file__), 'contrib/error.html'), 145 | 'r', 146 | encoding='utf-8' 147 | ) as error_page: 148 | return error_page.read().replace('{url}', url) 149 | 150 | 151 | def is_regex(value): 152 | return isinstance(value, re_type) 153 | --------------------------------------------------------------------------------