ymao 3 tahun lalu
melakukan
94cf33cac8
41 mengubah file dengan 4262 tambahan dan 0 penghapusan
  1. 85 0
      .gitignore
  2. 0 0
      __init__.py
  3. 10 0
      authen/__init__.py
  4. 1313 0
      authen/client.py
  5. 46 0
      authen/config.py
  6. 0 0
      authen/contrib/__init__.py
  7. 57 0
      authen/contrib/bottle.py
  8. 45 0
      authen/contrib/django.py
  9. 67 0
      authen/contrib/error.html
  10. 58 0
      authen/contrib/flask.py
  11. 70 0
      authen/contrib/tornado.py
  12. 142 0
      authen/crypto/__init__.py
  13. 13 0
      authen/crypto/exceptions.py
  14. 15 0
      authen/crypto/pkcs7.py
  15. 5 0
      authen/exceptions.py
  16. 78 0
      authen/logger.py
  17. 1 0
      authen/messages/__init__.py
  18. TEMPAT SAMPAH
      authen/messages/__pycache__/__init__.cpython-38.pyc
  19. TEMPAT SAMPAH
      authen/messages/__pycache__/base.cpython-38.pyc
  20. TEMPAT SAMPAH
      authen/messages/__pycache__/entries.cpython-38.pyc
  21. TEMPAT SAMPAH
      authen/messages/__pycache__/events.cpython-38.pyc
  22. TEMPAT SAMPAH
      authen/messages/__pycache__/messages.cpython-38.pyc
  23. 14 0
      authen/messages/base.py
  24. 41 0
      authen/messages/entries.py
  25. 245 0
      authen/messages/events.py
  26. 64 0
      authen/messages/messages.py
  27. 33 0
      authen/parser.py
  28. 203 0
      authen/pay.py
  29. 271 0
      authen/replies.py
  30. 692 0
      authen/robot.py
  31. 18 0
      authen/session/__init__.py
  32. 55 0
      authen/session/filestorage.py
  33. 67 0
      authen/session/mongodbstorage.py
  34. 96 0
      authen/session/mysqlstorage.py
  35. 79 0
      authen/session/postgresqlstorage.py
  36. 63 0
      authen/session/redisstorage.py
  37. 56 0
      authen/session/saekvstorage.py
  38. 66 0
      authen/session/sqlitestorage.py
  39. 12 0
      authen/testing.py
  40. 152 0
      authen/utils.py
  41. 30 0
      setup.py

+ 85 - 0
.gitignore

@@ -0,0 +1,85 @@
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+

+ 0 - 0
__init__.py


+ 10 - 0
authen/__init__.py

@@ -0,0 +1,10 @@
+__version__ = '1.13.1'
+__author__ = 'whtsky'
+__license__ = 'MIT'
+
+__all__ = ["WeRoBot"]
+
+try:
+    from werobot.robot import WeRoBot
+except ImportError:  # pragma: no cover
+    pass  # pragma: no cover

+ 1313 - 0
authen/client.py

@@ -0,0 +1,1313 @@
+# -*- coding: utf-8 -*-
+
+import time
+import requests
+import urllib.parse
+import redis
+from requests.compat import json as _json
+from werobot.utils import to_text
+from werobot.replies import Article
+
+
+class ClientException(Exception):
+    pass
+
+
+def check_error(json):
+    """
+    检测微信公众平台返回值中是否包含错误的返回码。
+    如果返回码提示有错误,抛出一个 :class:`ClientException` 异常。否则返回 True 。
+    """
+    if "errcode" in json and json["errcode"] != 0:
+        raise ClientException("{}: {}".format(json["errcode"], json["errmsg"]))
+    return json
+
+
+def _build_send_data(msg_type, content):
+    """
+    编译群发参数
+    :param msg_type: 群发类型,图文消息为 mpnews,文本消息为 text,语音为 voice,音乐为 music,图片为 image,视频为 video,卡券为 wxcard。
+    :param content: 群发内容。
+    :return: 群发参数。
+    """
+    send_data = {}
+    send_data['msgtype'] = msg_type
+    if msg_type in ['mpnews', 'voice', 'music', 'image']:
+        send_data[msg_type] = {'media_id': content}
+    elif msg_type == 'video':
+        send_data['mpvideo'] = {'media_id': content}
+        send_data['msgtype'] = 'mpvideo'
+    elif msg_type == 'text':
+        send_data['text'] = {'content': content}
+    elif msg_type == 'wxcard':
+        send_data['wxcard'] = {'card_id': content}
+    else:
+        send_data['text'] = {'content': content}
+        send_data['msgtype'] = 'text'
+    return send_data
+
+
+class Client(object):
+    """
+    微信 API 操作类
+    通过这个类可以方便的通过微信 API 进行一系列操作,比如主动发送消息、创建自定义菜单等
+    """
+    def __init__(self, config):
+        self.config = config
+        self._token = None
+        self.token_expires_at = None
+        self.redis_url = self.config.get("WXMP_REDIS_URL")
+        if self.redis_url:
+            self.redis_store = redis.StrictRedis.from_url(self.redis_url)
+        else:
+            self.redis_store = None
+
+    @property
+    def appid(self):
+        return self.config.get("APP_ID", None)
+
+    @property
+    def appsecret(self):
+        return self.config.get("APP_SECRET", None)
+
+    @staticmethod
+    def _url_encode_files(file):
+        if hasattr(file, "name"):
+            file = (urllib.parse.quote(file.name), file)
+        return file
+
+    def request(self, method, url, **kwargs):
+        if "params" not in kwargs:
+            kwargs["params"] = {"access_token": self.token}
+        if isinstance(kwargs.get("data", ""), dict):
+            body = _json.dumps(kwargs["data"], ensure_ascii=False)
+            body = body.encode('utf8')
+            kwargs["data"] = body
+
+        r = requests.request(method=method, url=url, **kwargs)
+        r.raise_for_status()
+        r.encoding = "utf-8"
+        json = r.json()
+        if check_error(json):
+            return json
+
+    def get(self, url, **kwargs):
+        return self.request(method="get", url=url, **kwargs)
+
+    def post(self, url, **kwargs):
+        if "files" in kwargs:
+            # Although there is only one key "media" possible in "files" now,
+            # we decide to check every key to support possible keys in the future
+            # Fix chinese file name error #292
+            kwargs["files"] = dict(
+                zip(
+                    kwargs["files"],
+                    map(self._url_encode_files, kwargs["files"].values())
+                )
+            )
+        return self.request(method="post", url=url, **kwargs)
+
+    def grant_token(self):
+        """
+        获取 Access Token。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(
+            url="https://api.weixin.qq.com/cgi-bin/token",
+            params={
+                "grant_type": "client_credential",
+                "appid": self.appid,
+                "secret": self.appsecret
+            }
+        )
+
+    def get_access_token(self):
+        """
+        判断现有的token是否过期。
+        用户需要多进程或者多机部署可以手动重写这个函数
+        来自定义token的存储,刷新策略。
+
+        :return: 返回token
+        """
+        key = "trops:wxmp:gzh:access_token"
+        if self.redis_store:
+            self._token = self.redis_store.get(key)
+            # return self._token
+        if self._token:
+            now = time.time()
+            if self.token_expires_at - now > 60:
+                return self._token
+        json = self.grant_token()
+        self._token = json["access_token"]
+        self.token_expires_at = int(time.time()) + json["expires_in"]
+        if self.redis_store:
+            self.redis_store.setex(key, json["expires_in"], self._token)
+        return self._token
+
+    @property
+    def token(self):
+        return self.get_access_token()
+
+    def get_ip_list(self):
+        """
+        获取微信服务器IP地址。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(url="https://api.weixin.qq.com/cgi-bin/getcallbackip")
+
+    def create_menu(self, menu_data):
+        """
+        创建自定义菜单::
+
+            client.create_menu({
+                "button":[
+                    {
+                        "type":"click",
+                        "name":"今日歌曲",
+                        "key":"V1001_TODAY_MUSIC"
+                    },
+                    {
+                        "type":"click",
+                        "name":"歌手简介",
+                        "key":"V1001_TODAY_SINGER"
+                    },
+                    {
+                        "name":"菜单",
+                        "sub_button":[
+                            {
+                                "type":"view",
+                                "name":"搜索",
+                                "url":"http://www.soso.com/"
+                            },
+                            {
+                                "type":"view",
+                                "name":"视频",
+                                "url":"http://v.qq.com/"
+                            },
+                            {
+                                "type":"click",
+                                "name":"赞一下我们",
+                                "key":"V1001_GOOD"
+                            }
+                        ]
+                    }
+                ]})
+
+        :param menu_data: Python 字典
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/menu/create",
+            data=menu_data
+        )
+
+    def get_menu(self):
+        """
+        查询自定义菜单。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get("https://api.weixin.qq.com/cgi-bin/menu/get")
+
+    def delete_menu(self):
+        """
+        删除自定义菜单。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get("https://api.weixin.qq.com/cgi-bin/menu/delete")
+
+    def create_custom_menu(self, menu_data, matchrule):
+        """
+        创建个性化菜单::
+
+            button = [
+                {
+                    "type":"click",
+                    "name":"今日歌曲",
+                    "key":"V1001_TODAY_MUSIC"
+                },
+                {
+                    "name":"菜单",
+                    "sub_button":[
+                    {
+                        "type":"view",
+                        "name":"搜索",
+                        "url":"http://www.soso.com/"
+                    },
+                    {
+                        "type":"view",
+                        "name":"视频",
+                        "url":"http://v.qq.com/"
+                    },
+                    {
+                        "type":"click",
+                        "name":"赞一下我们",
+                        "key":"V1001_GOOD"
+                    }]
+             }]
+             matchrule = {
+                "group_id":"2",
+                "sex":"1",
+                "country":"中国",
+                "province":"广东",
+                "city":"广州",
+                "client_platform_type":"2",
+                "language":"zh_CN"
+            }
+            client.create_custom_menu(button, matchrule)
+
+        :param menu_data: 如上所示的 Python 字典
+        :param matchrule: 如上所示的匹配规则
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/menu/addconditional",
+            data={
+                "button": menu_data,
+                "matchrule": matchrule
+            }
+        )
+
+    def delete_custom_menu(self, menu_id):
+        """
+        删除个性化菜单。
+
+        :param menu_id: 菜单的 ID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/menu/delconditional",
+            data={"menuid": menu_id}
+        )
+
+    def match_custom_menu(self, user_id):
+        """
+        测试个性化菜单匹配结果。
+
+        :param user_id: 要测试匹配的用户 ID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/menu/trymatch",
+            data={"user_id": user_id}
+        )
+
+    def get_custom_menu_config(self):
+        """
+        获取自定义菜单配置接口。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(
+            url="https://api.weixin.qq.com/cgi-bin/get_current_selfmenu_info"
+        )
+
+    def add_custom_service_account(self, account, nickname, password):
+        """
+        添加客服帐号。
+
+        :param account: 客服账号的用户名
+        :param nickname: 客服账号的昵称
+        :param password: 客服账号的密码
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/customservice/kfaccount/add",
+            data={
+                "kf_account": account,
+                "nickname": nickname,
+                "password": password
+            }
+        )
+
+    def update_custom_service_account(self, account, nickname, password):
+        """
+        修改客服帐号。
+
+        :param account: 客服账号的用户名
+        :param nickname: 客服账号的昵称
+        :param password: 客服账号的密码
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/customservice/kfaccount/update",
+            data={
+                "kf_account": account,
+                "nickname": nickname,
+                "password": password
+            }
+        )
+
+    def delete_custom_service_account(self, account, nickname, password):
+        """
+        删除客服帐号。
+
+        :param account: 客服账号的用户名
+        :param nickname: 客服账号的昵称
+        :param password: 客服账号的密码
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/customservice/kfaccount/del",
+            data={
+                "kf_account": account,
+                "nickname": nickname,
+                "password": password
+            }
+        )
+
+    def upload_custom_service_account_avatar(self, account, avatar):
+        """
+        设置客服帐号的头像。
+
+        :param account: 客服账号的用户名
+        :param avatar: 头像文件,必须是 jpg 格式
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url=
+            "http://api.weixin.qq.com/customservice/kfaccount/uploadheadimg",
+            params={
+                "access_token": self.token,
+                "kf_account": account
+            },
+            files={"media": avatar}
+        )
+
+    def get_custom_service_account_list(self):
+        """
+        获取所有客服账号。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(
+            url="https://api.weixin.qq.com/cgi-bin/customservice/getkflist"
+        )
+
+    def get_online_custom_service_account_list(self):
+        """
+        获取状态为"在线"的客服账号列表。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(
+            url="https://api.weixin.qq.com/cgi-bin/customservice/getonlinekflist"
+        )
+
+    def upload_media(self, media_type, media_file):
+        """
+        上传临时多媒体文件。
+
+        :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb)
+        :param media_file: 要上传的文件,一个 File-object
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/media/upload",
+            params={
+                "access_token": self.token,
+                "type": media_type
+            },
+            files={"media": media_file}
+        )
+
+    def download_media(self, media_id):
+        """
+        下载临时多媒体文件。
+
+        :param media_id: 媒体文件 ID
+        :return: requests 的 Response 实例
+        """
+        return requests.get(
+            url="https://api.weixin.qq.com/cgi-bin/media/get",
+            params={
+                "access_token": self.token,
+                "media_id": media_id
+            }
+        )
+
+    def add_news(self, articles):
+        """
+        新增永久图文素材::
+
+            articles = [{
+               "title": TITLE,
+               "thumb_media_id": THUMB_MEDIA_ID,
+               "author": AUTHOR,
+               "digest": DIGEST,
+               "show_cover_pic": SHOW_COVER_PIC(0 / 1),
+               "content": CONTENT,
+               "content_source_url": CONTENT_SOURCE_URL
+            }
+            # 若新增的是多图文素材,则此处应有几段articles结构,最多8段
+            ]
+            client.add_news(articles)
+
+        :param articles: 如示例中的数组
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/material/add_news",
+            data={"articles": articles}
+        )
+
+    def upload_news_picture(self, file):
+        """
+        上传图文消息内的图片。
+
+        :param file: 要上传的文件,一个 File-object
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/media/uploadimg",
+            params={"access_token": self.token},
+            files={"media": file}
+        )
+
+    def upload_permanent_media(self, media_type, media_file):
+        """
+        上传其他类型永久素材。
+
+        :param media_type: 媒体文件类型,分别有图片(image)、语音(voice)和缩略图(thumb)
+        :param media_file: 要上传的文件,一个 File-object
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/material/add_material",
+            params={
+                "access_token": self.token,
+                "type": media_type
+            },
+            files={"media": media_file}
+        )
+
+    def upload_permanent_video(self, title, introduction, video):
+        """
+        上传永久视频。
+
+        :param title: 视频素材的标题
+        :param introduction: 视频素材的描述
+        :param video: 要上传的视频,一个 File-object
+        :return: requests 的 Response 实例
+        """
+        return requests.post(
+            url="https://api.weixin.qq.com/cgi-bin/material/add_material",
+            params={
+                "access_token": self.token,
+                "type": "video"
+            },
+            data={
+                "description": _json.dumps(
+                    {
+                        "title": title,
+                        "introduction": introduction
+                    },
+                    ensure_ascii=False
+                ).encode("utf-8")
+            },
+            files={"media": video}
+        )
+
+    def download_permanent_media(self, media_id):
+        """
+        获取永久素材。
+
+        :param media_id: 媒体文件 ID
+        :return: requests 的 Response 实例
+        """
+        return requests.post(
+            url="https://api.weixin.qq.com/cgi-bin/material/get_material",
+            params={"access_token": self.token},
+            data=_json.dumps({
+                "media_id": media_id
+            }, ensure_ascii=False).encode("utf-8")
+        )
+
+    def delete_permanent_media(self, media_id):
+        """
+        删除永久素材。
+
+        :param media_id: 媒体文件 ID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/material/del_material",
+            data={"media_id": media_id}
+        )
+
+    def update_news(self, update_data):
+        """
+        修改永久图文素材::
+
+            update_data = {
+                "media_id":MEDIA_ID,
+                "index":INDEX,
+                "articles": {
+                    "title": TITLE,
+                    "thumb_media_id": THUMB_MEDIA_ID,
+                    "author": AUTHOR,
+                    "digest": DIGEST,
+                    "show_cover_pic": SHOW_COVER_PIC(0 / 1),
+                    "content": CONTENT,
+                    "content_source_url": CONTENT_SOURCE_URL
+                }
+            }
+            client.update_news(update_data)
+
+        :param update_data: 更新的数据,要包含 media_id(图文素材的 ID),index(要更新的文章在图文消息中的位置),articles(新的图文素材数据)
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/material/update_news",
+            data=update_data
+        )
+
+    def get_media_count(self):
+        """
+        获取素材总数。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(
+            url="https://api.weixin.qq.com/cgi-bin/material/get_materialcount"
+        )
+
+    def get_media_list(self, media_type, offset, count):
+        """
+        获取素材列表。
+
+        :param media_type: 素材的类型,图片(image)、视频(video)、语音 (voice)、图文(news)
+        :param offset: 从全部素材的该偏移位置开始返回,0表示从第一个素材返回
+        :param count: 返回素材的数量,取值在1到20之间
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/material/batchget_material",
+            data={
+                "type": media_type,
+                "offset": offset,
+                "count": count
+            }
+        )
+
+    def create_group(self, name):
+        """
+        创建分组。
+
+        :param name: 分组名字(30个字符以内)
+        :return: 返回的 JSON 数据包
+
+        """
+        name = to_text(name)
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/groups/create",
+            data={"group": {
+                "name": name
+            }}
+        )
+
+    def get_groups(self):
+        """
+        查询所有分组。
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get("https://api.weixin.qq.com/cgi-bin/groups/get")
+
+    def get_group_by_id(self, openid):
+        """
+        查询用户所在分组。
+
+        :param openid: 用户的OpenID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/groups/getid",
+            data={"openid": openid}
+        )
+
+    def update_group(self, group_id, name):
+        """
+        修改分组名。
+
+        :param group_id: 分组 ID,由微信分配
+        :param name: 分组名字(30个字符以内)
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/groups/update",
+            data={"group": {
+                "id": int(group_id),
+                "name": to_text(name)
+            }}
+        )
+
+    def move_user(self, user_id, group_id):
+        """
+        移动用户分组。
+
+        :param user_id: 用户 ID,即收到的 `Message` 的 source
+        :param group_id: 分组 ID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/groups/members/update",
+            data={
+                "openid": user_id,
+                "to_groupid": group_id
+            }
+        )
+
+    def move_users(self, user_id_list, group_id):
+        """
+        批量移动用户分组。
+
+        :param user_id_list: 用户 ID 的列表(长度不能超过50)
+        :param group_id: 分组 ID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/groups/members/batchupdate",
+            data={
+                "openid_list": user_id_list,
+                "to_groupid": group_id
+            }
+        )
+
+    def delete_group(self, group_id):
+        """
+        删除分组。
+
+        :param group_id: 要删除的分组的 ID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/groups/delete",
+            data={"group": {
+                "id": group_id
+            }}
+        )
+
+    def remark_user(self, user_id, remark):
+        """
+        设置备注名。
+
+        :param user_id: 设置备注名的用户 ID
+        :param remark: 新的备注名,长度必须小于30字符
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/user/info/updateremark",
+            data={
+                "openid": user_id,
+                "remark": remark
+            }
+        )
+
+    def get_user_info(self, user_id, lang="zh_CN"):
+        """
+        获取用户基本信息。
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param lang: 返回国家地区语言版本,zh_CN 简体,zh_TW 繁体,en 英语
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(
+            url="https://api.weixin.qq.com/cgi-bin/user/info",
+            params={
+                "access_token": self.token,
+                "openid": user_id,
+                "lang": lang
+            }
+        )
+
+    def get_users_info(self, user_id_list, lang="zh_CN"):
+        """
+        批量获取用户基本信息。
+
+        :param user_id_list: 用户 ID 的列表
+        :param lang: 返回国家地区语言版本,zh_CN 简体,zh_TW 繁体,en 英语
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/user/info/batchget",
+            data={
+                "user_list": [
+                    {
+                        "openid": user_id,
+                        "lang": lang
+                    } for user_id in user_id_list
+                ]
+            }
+        )
+
+    def get_followers(self, first_user_id=None):
+        """
+        获取关注者列表
+        详情请参考 http://mp.weixin.qq.com/wiki/index.php?title=获取关注者列表
+
+        :param first_user_id: 可选。第一个拉取的OPENID,不填默认从头开始拉取
+        :return: 返回的 JSON 数据包
+        """
+        params = {"access_token": self.token}
+        if first_user_id:
+            params["next_openid"] = first_user_id
+        return self.get(
+            "https://api.weixin.qq.com/cgi-bin/user/get", params=params
+        )
+
+    def send_text_message(self, user_id, content, kf_account=None):
+        """
+        发送文本消息。
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param content: 消息正文
+        :param kf_account: 发送消息的客服账户,默认值为 None,None 为不指定
+        :return: 返回的 JSON 数据包
+        """
+        data = {
+            "touser": user_id,
+            "msgtype": "text",
+            "text": {
+                "content": content
+            }
+        }
+        if kf_account is not None:
+            data['customservice'] = {'kf_account': kf_account}
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def send_image_message(self, user_id, media_id, kf_account=None):
+        """
+        发送图片消息。
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param media_id: 图片的媒体ID。 可以通过 :func:`upload_media` 上传。
+        :param kf_account: 发送消息的客服账户,默认值为 None,None 为不指定
+        :return: 返回的 JSON 数据包
+        """
+        data = {
+            "touser": user_id,
+            "msgtype": "image",
+            "image": {
+                "media_id": media_id
+            }
+        }
+        if kf_account is not None:
+            data['customservice'] = {'kf_account': kf_account}
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def send_voice_message(self, user_id, media_id, kf_account=None):
+        """
+        发送语音消息。
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param media_id: 发送的语音的媒体ID。 可以通过 :func:`upload_media` 上传。
+        :param kf_account: 发送消息的客服账户,默认值为 None,None 为不指定
+        :return: 返回的 JSON 数据包
+        """
+        data = {
+            "touser": user_id,
+            "msgtype": "voice",
+            "voice": {
+                "media_id": media_id
+            }
+        }
+        if kf_account is not None:
+            data['customservice'] = {'kf_account': kf_account}
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def send_video_message(
+        self,
+        user_id,
+        media_id,
+        title=None,
+        description=None,
+        kf_account=None
+    ):
+        """
+        发送视频消息。
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param media_id: 发送的视频的媒体ID。 可以通过 :func:`upload_media` 上传。
+        :param title: 视频消息的标题
+        :param description: 视频消息的描述
+        :param kf_account: 发送消息的客服账户,默认值为 None,None 为不指定
+        :return: 返回的 JSON 数据包
+        """
+        video_data = {
+            "media_id": media_id,
+        }
+        if title:
+            video_data["title"] = title
+        if description:
+            video_data["description"] = description
+        data = {"touser": user_id, "msgtype": "video", "video": video_data}
+        if kf_account is not None:
+            data['customservice'] = {'kf_account': kf_account}
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def send_music_message(
+        self,
+        user_id,
+        url,
+        hq_url,
+        thumb_media_id,
+        title=None,
+        description=None,
+        kf_account=None
+    ):
+        """
+        发送音乐消息。
+        注意如果你遇到了缩略图不能正常显示的问题, 不要慌张; 目前来看是微信服务器端的问题。
+        对此我们也无能为力 ( `#197 <https://github.com/whtsky/WeRoBot/issues/197>`_ )
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param url: 音乐链接
+        :param hq_url: 高品质音乐链接,wifi环境优先使用该链接播放音乐
+        :param thumb_media_id: 缩略图的媒体ID。 可以通过 :func:`upload_media` 上传。
+        :param title: 音乐标题
+        :param description: 音乐描述
+        :param kf_account: 发送消息的客服账户,默认值为 None,None 为不指定
+        :return: 返回的 JSON 数据包
+        """
+        music_data = {
+            "musicurl": url,
+            "hqmusicurl": hq_url,
+            "thumb_media_id": thumb_media_id
+        }
+        if title:
+            music_data["title"] = title
+        if description:
+            music_data["description"] = description
+        data = {"touser": user_id, "msgtype": "music", "music": music_data}
+        if kf_account is not None:
+            data['customservice'] = {'kf_account': kf_account}
+
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def send_article_message(self, user_id, articles, kf_account=None):
+        """
+        发送图文消息::
+
+            articles = [
+                {
+                    "title":"Happy Day",
+                    "description":"Is Really A Happy Day",
+                    "url":"URL",
+                    "picurl":"PIC_URL"
+                },
+                {
+                    "title":"Happy Day",
+                    "description":"Is Really A Happy Day",
+                    "url":"URL",
+                    "picurl":"PIC_URL"
+                }
+            ]
+            client.send_acticle_message("user_id", acticles)
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param articles: 一个包含至多8个 article 字典或 Article 对象的数组
+        :param kf_account: 发送消息的客服账户,默认值为 None,None 为不指定
+        :return: 返回的 JSON 数据包
+        """
+        if isinstance(articles[0], Article):
+            formatted_articles = []
+            for article in articles:
+                result = article.args
+                result["picurl"] = result.pop("img")
+                formatted_articles.append(result)
+        else:
+            formatted_articles = articles
+        data = {
+            "touser": user_id,
+            "msgtype": "news",
+            "news": {
+                "articles": formatted_articles
+            }
+        }
+        if kf_account is not None:
+            data['customservice'] = {'kf_account': kf_account}
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def send_news_message(self, user_id, media_id, kf_account=None):
+        """
+        发送永久素材中的图文消息。
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param media_id: 媒体文件 ID
+        :param kf_account: 发送消息的客服账户,默认值为 None,None 为不指定
+        :return: 返回的 JSON 数据包
+        """
+        data = {
+            "touser": user_id,
+            "msgtype": "mpnews",
+            "mpnews": {
+                "media_id": media_id
+            }
+        }
+        if kf_account is not None:
+            data['customservice'] = {'kf_account': kf_account}
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def send_miniprogrampage_message(
+        self,
+        user_id,
+        title,
+        appid,
+        pagepath,
+        thumb_media_id,
+        kf_account=None
+    ):
+        """
+        发送小程序卡片(要求小程序与公众号已关联)
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param title: 小程序卡片的标题
+        :param appid: 小程序的 appid,要求小程序的 appid 需要与公众号有关联关系
+        :param pagepath: 小程序的页面路径,跟 app.json 对齐,支持参数,比如 pages/index/index?foo=bar
+        :param thumb_media_id: 小程序卡片图片的媒体 ID,小程序卡片图片建议大小为 520*416
+        :param kf_account: 需要以某个客服帐号来发消息时指定的客服账户
+        :return: 返回的 JSON 数据包
+        """
+        data = {
+            "touser": user_id,
+            "msgtype": "miniprogrampage",
+            "miniprogrampage": {
+                "title": title,
+                "appid": appid,
+                "pagepath": pagepath,
+                "thumb_media_id": thumb_media_id
+            }
+        }
+        if kf_account is not None:
+            data["customservice"] = {"kf_account": kf_account}
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
+            data=data
+        )
+
+    def create_qrcode(self, data):
+        """
+        创建二维码。
+
+        :param data: 你要发送的参数 dict
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/qrcode/create", data=data
+        )
+
+    def show_qrcode(self, ticket):
+        """
+        通过ticket换取二维码。
+
+        :param ticket: 二维码 ticket 。可以通过 :func:`create_qrcode` 获取到
+        :return: 返回的 Request 对象
+        """
+        return requests.get(
+            url="https://mp.weixin.qq.com/cgi-bin/showqrcode",
+            params={"ticket": ticket}
+        )
+
+    def send_template_message(
+        self, user_id, template_id, data, url='', miniprogram=None
+    ):
+        """
+        发送模板消息
+        详情请参考 https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Template_Message_Interface.html
+
+        :param user_id: 用户 ID 。 就是你收到的 `Message` 的 source
+        :param template_id: 模板 ID。
+        :param data: 用于渲染模板的数据。
+        :param url: 模板消息的可选链接。
+        :param miniprogram: 跳小程序所需数据的可选数据。
+        :return: 返回的 JSON 数据包
+        """
+        payload = {
+            "touser": user_id,
+            "template_id": template_id,
+            "url": url,
+            "data": data
+        }
+        if miniprogram:
+            payload["miniprogram"] = miniprogram
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/template/send",
+            data=payload
+        )
+
+    def create_tag(self, tag_name):
+        """
+        创建一个新标签
+
+        :param tag_name: 标签名
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/tags/create",
+            data={"tag": {
+                "name": tag_name
+            }}
+        )
+
+    def get_tags(self):
+        """
+        获取已经存在的标签
+
+        :return: 返回的 JSON 数据包
+        """
+        return self.get(url="https://api.weixin.qq.com/cgi-bin/tags/get", )
+
+    def update_tag(self, tag_id, tag_name):
+        """
+        修改标签
+
+        :param tag_id: 标签 ID
+        :param tag_name: 新的标签名
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/tags/update",
+            data={"tag": {
+                "id": tag_id,
+                "name": tag_name
+            }}
+        )
+
+    def delete_tag(self, tag_id):
+        """
+        删除标签
+
+        :param tag_id: 标签 ID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/tags/delete",
+            data={"tag": {
+                "id": tag_id,
+            }}
+        )
+
+    def get_users_by_tag(self, tag_id, next_open_id=""):
+        """
+        获取标签下粉丝列表
+
+        :param tag_id: 标签 ID
+        :param next_open_id: 第一个拉取用户的 OPENID,默认从头开始拉取
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/user/tag/get",
+            data={
+                "tagid": tag_id,
+                "next_openid": next_open_id
+            }
+        )
+
+    def get_tags_by_user(self, open_id):
+        """
+        获取用户身上的标签列表
+
+        :param open_id: 用户的 OPENID
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/tags/getidlist",
+            data={
+                "openid": open_id,
+            }
+        )
+
+    def tag_users(self, tag_id, open_id_list):
+        """
+        批量为用户打标签
+
+        :param tag_id: 标签 ID
+        :param open_id_list: 包含一个或多个用户的 OPENID 的列表
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/tags/members/batchtagging",
+            data={
+                "openid_list": open_id_list,
+                "tagid": tag_id
+            }
+        )
+
+    def untag_users(self, tag_id, open_id_list):
+        """
+        批量为用户取消标签
+
+        :param tag_id: 标签 ID
+        :param open_id_list: 包含一个或多个用户的 OPENID 的列表
+        :return: 返回的 JSON 数据包
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/tags/members/batchuntagging",
+            data={
+                "openid_list": open_id_list,
+                "tagid": tag_id
+            }
+        )
+
+    def upload_news(self, articles):
+        """
+        上传图文消息素材 ::
+
+            articles = [{
+                "thumb_media_id":"qI6_Ze_6PtV7svjolgs-rN6stStuHIjs9_DidOHaj0Q-mwvBelOXCFZiq2OsIU-p",
+                "author":"xxx",
+                "title":"Happy Day",
+                "content_source_url":"www.qq.com",
+                "content":"content",
+                "digest":"digest",
+                "show_cover_pic":1,
+                "need_open_comment":1,
+                "only_fans_can_comment":1
+            }]
+
+        具体请参考: https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Batch_Sends_and_Originality_Checks.html#1
+
+
+        :param articles: 上传的图文消息数据。
+        :return: 返回的 JSON 数据包。
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/media/uploadnews",
+            data={
+                "articles": articles,
+            }
+        )
+
+    def send_mass_msg(
+        self,
+        msg_type,
+        content,
+        user_list=None,
+        send_ignore_reprint=False,
+        client_msg_id=None
+    ):
+        """
+        向指定对象群发信息。
+
+        :param msg_type: 群发类型,图文消息为 mpnews,文本消息为 text,语音为 voice,音乐为 music,图片为 image,视频为 video,卡券为 wxcard。
+        :param content: 群发内容。
+        :param user_list: 发送对象,整型代表用户组,列表代表指定用户,如果为 None 则代表全部发送。
+        :param send_ignore_reprint: 图文消息被判定为转载时,是否继续群发。 True 为继续群发(转载),False 为停止群发。 该参数默认为 False。
+        :param client_msg_id: 群发时,微信后台将对 24 小时内的群发记录进行检查,如果该 clientmsgid 已经存在一条群发记录,则会拒绝本次群发请求,返回已存在的群发 msgid, 控制再 64 个字符内。
+        :return: 返回的 JSON 数据包。
+        """
+        send_data = _build_send_data(msg_type, content)
+        send_data['send_ignore_reprint'] = send_ignore_reprint
+        if client_msg_id is not None:
+            send_data['clientmsgid'] = client_msg_id
+        if isinstance(user_list, list):
+            url = 'https://api.weixin.qq.com/cgi-bin/message/mass/send'
+            send_data['touser'] = user_list
+        else:
+            url = 'https://api.weixin.qq.com/cgi-bin/message/mass/sendall'
+            if user_list == None:
+                send_data['filter'] = {
+                    "is_to_all": True,
+                }
+            else:
+                send_data['filter'] = {"is_to_all": False, "tag_id": user_list}
+
+        return self.post(url=url, data=send_data)
+
+    def delete_mass_msg(self, msg_id, article_idx=0):
+        """
+        群发之后,随时可以通过该接口删除群发。
+
+        :param msg_id: 发送出去的消息 ID。
+        :param article_idx: 要删除的文章在图文消息中的位置,第一篇编号为 1,该字段不填或填 0 会删除全部文章。
+        :return: 微信返回的 json 数据。
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/mass/delete",
+            data={
+                "msg_id": msg_id,
+                "article_idx": article_idx
+            }
+        )
+
+    def send_mass_preview_to_user(
+        self, msg_type, content, user, user_type='openid'
+    ):
+        """
+        开发者可通过该接口发送消息给指定用户,在手机端查看消息的样式和排版。为了满足第三方平台开发者的需求,在保留对 openID 预览能力的同时,增加了对指定微信号发送预览的能力,但该能力每日调用次数有限制(100 次),请勿滥用。
+
+        :param user_type: 预览对象,`openid` 代表以 openid 发送,`wxname` 代表以微信号发送。
+        :param msg_type: 发送类型,图文消息为 mpnews,文本消息为 text,语音为 voice,音乐为 music,图片为 image,视频为 video,卡券为 wxcard。
+        :param content: 预览内容。
+        :param user: 预览用户。
+        :return: 返回的 json。
+        """
+        send_data = _build_send_data(msg_type, content)
+        if user_type == 'openid':
+            send_data['touser'] = user
+        else:
+            send_data['towxname'] = user
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/mass/preview",
+            data=send_data
+        )
+
+    def get_mass_msg_status(self, msg_id):
+        """
+        查询群发消息发送状态。
+
+        :param msg_id: 群发消息后返回的消息 id。
+        :return: 返回的 json。
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/mass/get",
+            data={'msg_id': msg_id}
+        )
+
+    def get_mass_msg_speed(self):
+        """
+        获取群发速度。
+
+        :return: 返回的 json。
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/mass/speed/get",
+            data={}
+        )
+
+    def set_mass_msg_speed(self, speed):
+        """
+        设置群发速度。
+
+        :param speed: 群发速度的级别,是一个 0 到 4 的整数,数字越大表示群发速度越慢。
+        :return: 返回的 json。
+        """
+        return self.post(
+            url="https://api.weixin.qq.com/cgi-bin/message/mass/speed/set",
+            data={"speed": speed}
+        )

+ 46 - 0
authen/config.py

@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+
+import types
+
+
+class ConfigAttribute(object):
+    """
+    让一个属性指向一个配置
+    """
+    def __init__(self, name):
+        self.__name__ = name
+
+    def __get__(self, obj, type=None):
+        if obj is None:
+            return self
+        rv = obj.config[self.__name__]
+        return rv
+
+    def __set__(self, obj, value):
+        obj.config[self.__name__] = value
+
+
+class Config(dict):
+    def from_pyfile(self, filename):
+        """
+        在一个 Python 文件中读取配置。
+
+        :param filename: 配置文件的文件名
+        :return: 如果读取成功,返回 ``True``,如果失败,会抛出错误异常
+        """
+        d = types.ModuleType('config')
+        d.__file__ = filename
+        with open(filename) as config_file:
+            exec(compile(config_file.read(), filename, 'exec'), d.__dict__)
+        self.from_object(d)
+        return True
+
+    def from_object(self, obj):
+        """
+        在给定的 Python 对象中读取配置。
+
+        :param obj: 一个 Python 对象
+        """
+        for key in dir(obj):
+            if key.isupper():
+                self[key] = getattr(obj, key)

+ 0 - 0
authen/contrib/__init__.py


+ 57 - 0
authen/contrib/bottle.py

@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+from bottle import request, HTTPResponse
+
+import html
+
+
+def make_view(robot):
+    """
+    为一个 BaseRoBot 生成 Bottle view。
+
+    Usage ::
+
+        from werobot import WeRoBot
+
+        robot = WeRoBot(token='token')
+
+
+        @robot.handler
+        def hello(message):
+            return 'Hello World!'
+
+        from bottle import Bottle
+        from werobot.contrib.bottle import make_view
+
+        app = Bottle()
+        app.route(
+            '/robot',  # WeRoBot 挂载地址
+            ['GET', 'POST'],
+            make_view(robot)
+        )
+
+
+    :param robot: 一个 BaseRoBot 实例
+    :return: 一个标准的 Bottle view
+    """
+    def werobot_view(*args, **kwargs):
+        if not robot.check_signature(
+            request.query.timestamp, request.query.nonce,
+            request.query.signature
+        ):
+            return HTTPResponse(
+                status=403,
+                body=robot.make_error_page(html.escape(request.url))
+            )
+        if request.method == 'GET':
+            return request.query.echostr
+        else:
+            body = request.body.read()
+            message = robot.parse_message(
+                body,
+                timestamp=request.query.timestamp,
+                nonce=request.query.nonce,
+                msg_signature=request.query.msg_signature
+            )
+            return robot.get_encrypted_reply(message)
+
+    return werobot_view

+ 45 - 0
authen/contrib/django.py

@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+
+from django.http import HttpResponse, HttpResponseNotAllowed, HttpResponseForbidden
+from django.views.decorators.csrf import csrf_exempt
+
+import html
+
+
+def make_view(robot):
+    """
+    为一个 BaseRoBot 生成 Django view。
+
+    :param robot: 一个 BaseRoBot 实例。
+    :return: 一个标准的 Django view
+    """
+    @csrf_exempt
+    def werobot_view(request):
+        timestamp = request.GET.get("timestamp", "")
+        nonce = request.GET.get("nonce", "")
+        signature = request.GET.get("signature", "")
+
+        if not robot.check_signature(
+            timestamp=timestamp, nonce=nonce, signature=signature
+        ):
+            return HttpResponseForbidden(
+                robot.make_error_page(
+                    html.escape(request.build_absolute_uri())
+                )
+            )
+        if request.method == "GET":
+            return HttpResponse(request.GET.get("echostr", ""))
+        elif request.method == "POST":
+            message = robot.parse_message(
+                request.body,
+                timestamp=timestamp,
+                nonce=nonce,
+                msg_signature=request.GET.get("msg_signature", "")
+            )
+            return HttpResponse(
+                robot.get_encrypted_reply(message),
+                content_type="application/xml;charset=utf-8"
+            )
+        return HttpResponseNotAllowed(['GET', 'POST'])
+
+    return werobot_view

+ 67 - 0
authen/contrib/error.html

@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html lang="zh">
+    <head>
+        <meta charset="UTF-8" />
+        <title>WeRoBot</title>
+        <style type="text/css">
+            body,
+            html {
+                height: 100%;
+                width: 100%;
+                color: #424242;
+                margin: 0;
+            }
+
+            body {
+                display: -webkit-flex;
+                display: -moz-flex;
+                display: -ms-flex;
+                display: -o-flex;
+                display: flex;
+                -webkit-flex-direction: column;
+                -moz-flex-direction: column;
+                -ms-flex-direction: column;
+                -o-flex-direction: column;
+                flex-direction: column;
+                -ms-align-items: center;
+                align-items: center;
+                justify-content: center;
+                background-color: #f5f5f5;
+            }
+
+            a {
+                color: #424242;
+                text-decoration: none;
+                border-bottom: 0;
+                transition: all ease-in-out 0.5s;
+                -webkit-transition: all ease-in-out 0.5s;
+                border-color: #f5f5f5;
+            }
+
+            a:hover {
+                border-bottom: 1px solid transparent;
+                border-color: #424242;
+            }
+
+            img {
+                margin-bottom: 1em;
+                height: 300px;
+                width: 300px;
+            }
+        </style>
+    </head>
+    <body>
+        <img
+            src="https://cdn.rawgit.com/whtsky/WeRoBot/master/artwork/logo.svg"
+            alt=""
+        />
+        <h1>
+            这是一个
+            <a href="https://github.com/whtsky/WeRoBot/">WeRoBot</a> 应用
+        </h1>
+        <p>想要使用本机器人,请在微信后台中将 URL 设置为</p>
+        <pre>{url}</pre>
+        <p>并将 Token 值设置正确。</p>
+        <p>更多信息请与网站所有者联系</p>
+    </body>
+</html>

+ 58 - 0
authen/contrib/flask.py

@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+
+from flask import request, make_response
+
+import html
+
+
+def make_view(robot):
+    """
+    为一个 BaseRoBot 生成 Flask view。
+
+    Usage ::
+
+        from werobot import WeRoBot
+
+        robot = WeRoBot(token='token')
+
+
+        @robot.handler
+        def hello(message):
+            return 'Hello World!'
+
+        from flask import Flask
+        from werobot.contrib.flask import make_view
+
+        app = Flask(__name__)
+        app.add_url_rule(rule='/robot/', # WeRoBot 的绑定地址
+                        endpoint='werobot', # Flask 的 endpoint
+                        view_func=make_view(robot),
+                        methods=['GET', 'POST'])
+
+    :param robot: 一个 BaseRoBot 实例
+    :return: 一个标准的 Flask view
+    """
+    def werobot_view():
+        timestamp = request.args.get('timestamp', '')
+        nonce = request.args.get('nonce', '')
+        signature = request.args.get('signature', '')
+        if not robot.check_signature(
+            timestamp,
+            nonce,
+            signature,
+        ):
+            return robot.make_error_page(html.escape(request.url)), 403
+        if request.method == 'GET':
+            return request.args['echostr']
+
+        message = robot.parse_message(
+            request.data,
+            timestamp=timestamp,
+            nonce=nonce,
+            msg_signature=request.args.get('msg_signature', '')
+        )
+        response = make_response(robot.get_encrypted_reply(message))
+        response.headers['content_type'] = 'application/xml'
+        return response
+
+    return werobot_view

+ 70 - 0
authen/contrib/tornado.py

@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+
+from tornado.web import RequestHandler
+
+import html
+
+
+def make_handler(robot):
+    """
+    为一个 BaseRoBot 生成 Tornado Handler。
+
+    Usage ::
+
+        import tornado.ioloop
+        import tornado.web
+        from werobot import WeRoBot
+        from tornado_werobot import make_handler
+
+        robot = WeRoBot(token='token')
+
+
+        @robot.handler
+        def hello(message):
+            return 'Hello World!'
+
+        application = tornado.web.Application([
+            (r"/", make_handler(robot)),
+        ])
+
+    :param robot: 一个 BaseRoBot 实例。
+    :return: 一个标准的 Tornado Handler
+    """
+    class WeRoBotHandler(RequestHandler):
+        def prepare(self):
+            timestamp = self.get_argument('timestamp', '')
+            nonce = self.get_argument('nonce', '')
+            signature = self.get_argument('signature', '')
+
+            if not robot.check_signature(
+                timestamp=timestamp, nonce=nonce, signature=signature
+            ):
+                self.set_status(403)
+                self.write(
+                    robot.make_error_page(
+                        html.escape(
+                            self.request.protocol + "://" + self.request.host +
+                            self.request.uri
+                        )
+                    )
+                )
+                return
+
+        def get(self):
+            echostr = self.get_argument('echostr', '')
+            self.write(echostr)
+
+        def post(self):
+            timestamp = self.get_argument('timestamp', '')
+            nonce = self.get_argument('nonce', '')
+            msg_signature = self.get_argument('msg_signature', '')
+            message = robot.parse_message(
+                self.request.body,
+                timestamp=timestamp,
+                nonce=nonce,
+                msg_signature=msg_signature
+            )
+            self.set_header("Content-Type", "application/xml;charset=utf-8")
+            self.write(robot.get_encrypted_reply(message))
+
+    return WeRoBotHandler

+ 142 - 0
authen/crypto/__init__.py

@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+
+import base64
+import socket
+import struct
+import time
+
+try:
+    from cryptography.hazmat.primitives.ciphers import (
+        Cipher, algorithms, modes
+    )
+    from cryptography.hazmat.backends import default_backend
+except ImportError:  # pragma: no cover
+    raise RuntimeError("You need to install Cryptography.")  # pragma: no cover
+
+from . import pkcs7
+from .exceptions import (
+    UnvalidEncodingAESKey, AppIdValidationError, InvalidSignature
+)
+from werobot.utils import (
+    to_text, to_binary, generate_token, byte2int, get_signature
+)
+
+
+class PrpCrypto(object):
+    """
+    提供接收和推送给公众平台消息的加解密接口
+    """
+    def __init__(self, key):
+        key = to_binary(key)
+        self.cipher = Cipher(
+            algorithms.AES(key),
+            modes.CBC(key[:16]),
+            backend=default_backend()
+        )
+
+    def get_random_string(self):
+        """
+        :return: 长度为16的随即字符串
+        """
+        return generate_token(16)
+
+    def encrypt(self, text, app_id):
+        """
+        对明文进行加密
+        :param text: 需要加密的明文
+        :param app_id: 微信公众平台的 AppID
+        :return: 加密后的字符串
+        """
+        text = b"".join(
+            [
+                to_binary(self.get_random_string()),
+                struct.pack(b"I", socket.htonl(len(to_binary(text)))),
+                to_binary(text),
+                to_binary(app_id)
+            ]
+        )
+        text = pkcs7.encode(text)
+        encryptor = self.cipher.encryptor()
+        ciphertext = to_binary(encryptor.update(text) + encryptor.finalize())
+        return base64.b64encode(ciphertext)
+
+    def decrypt(self, text, app_id):
+        """
+        对密文进行解密
+        :param text: 需要解密的密文
+        :param app_id: 微信公众平台的 AppID
+        :return: 解密后的字符串
+        """
+        text = to_binary(text)
+        decryptor = self.cipher.decryptor()
+        plain_text = decryptor.update(base64.b64decode(text)
+                                      ) + decryptor.finalize()
+
+        padding = byte2int(plain_text, -1)
+        content = plain_text[16:-padding]
+
+        xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
+        xml_content = content[4:xml_len + 4]
+        from_appid = content[xml_len + 4:]
+
+        if to_text(from_appid) != app_id:
+            raise AppIdValidationError(text, app_id)
+
+        return xml_content
+
+
+class MessageCrypt(object):
+    ENCRYPTED_MESSAGE_XML = """
+<xml>
+<Encrypt><![CDATA[{encrypt}]]></Encrypt>
+<MsgSignature><![CDATA[{signature}]]></MsgSignature>
+<TimeStamp>{timestamp}</TimeStamp>
+<Nonce><![CDATA[{nonce}]]></Nonce>
+</xml>
+    """.strip()
+
+    def __init__(self, token, encoding_aes_key, app_id):
+        key = base64.b64decode(to_binary(encoding_aes_key + '='))
+        if len(key) != 32:
+            raise UnvalidEncodingAESKey(encoding_aes_key)
+        self.prp_crypto = PrpCrypto(key)
+
+        self.token = token
+        self.app_id = app_id
+
+    def decrypt_message(self, timestamp, nonce, msg_signature, encrypt_msg):
+        """
+        解密收到的微信消息
+        :param timestamp: 请求 URL 中收到的 timestamp
+        :param nonce: 请求 URL 中收到的 nonce
+        :param msg_signature: 请求 URL 中收到的 msg_signature
+        :param encrypt_msg: 收到的加密文本. ( XML 中的 <Encrypt> 部分 )
+        :return: 解密后的 XML 文本
+        """
+        signature = get_signature(self.token, timestamp, nonce, encrypt_msg)
+        if signature != msg_signature:
+            raise InvalidSignature(msg_signature)
+        return self.prp_crypto.decrypt(encrypt_msg, self.app_id)
+
+    def encrypt_message(self, reply, timestamp=None, nonce=None):
+        """
+        加密微信回复
+        :param reply: 加密前的回复
+        :type reply: WeChatReply 或 XML 文本
+        :return: 加密后的回复文本
+        """
+        if hasattr(reply, "render"):
+            reply = reply.render()
+
+        timestamp = timestamp or to_text(int(time.time()))
+        nonce = nonce or generate_token(5)
+        encrypt = to_text(self.prp_crypto.encrypt(reply, self.app_id))
+        signature = get_signature(self.token, timestamp, nonce, encrypt)
+        return to_text(
+            self.ENCRYPTED_MESSAGE_XML.format(
+                encrypt=encrypt,
+                signature=signature,
+                timestamp=timestamp,
+                nonce=nonce
+            )
+        )

+ 13 - 0
authen/crypto/exceptions.py

@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+
+class UnvalidEncodingAESKey(Exception):
+    pass
+
+
+class AppIdValidationError(Exception):
+    pass
+
+
+class InvalidSignature(Exception):
+    pass

+ 15 - 0
authen/crypto/pkcs7.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+
+from werobot.utils import to_binary
+
+_BLOCK_SIZE = 32
+
+
+def encode(text):
+    # 计算需要填充的位数
+    amount_to_pad = _BLOCK_SIZE - (len(text) % _BLOCK_SIZE)
+    if not amount_to_pad:
+        amount_to_pad = _BLOCK_SIZE
+    # 获得补位所用的字符
+    pad = chr(amount_to_pad)
+    return text + to_binary(pad * amount_to_pad)

+ 5 - 0
authen/exceptions.py

@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+
+class ConfigError(Exception):
+    pass

+ 78 - 0
authen/logger.py

@@ -0,0 +1,78 @@
+# -*- coding:utf-8 -*-
+
+import sys
+import time
+import logging
+
+try:
+    import curses
+
+    assert curses
+except ImportError:
+    curses = None
+
+logger = logging.getLogger("WeRoBot")
+
+
+def enable_pretty_logging(logger, level='info'):
+    """
+    按照配置开启 log 的格式化优化。
+
+    :param logger: 配置的 logger 对象
+    :param level: 要为 logger 设置的等级
+    """
+    logger.setLevel(getattr(logging, level.upper()))
+
+    if not logger.handlers:
+        # Set up color if we are in a tty and curses is installed
+        color = False
+        if curses and sys.stderr.isatty():
+            try:
+                curses.setupterm()
+                if curses.tigetnum("colors") > 0:
+                    color = True
+            finally:
+                pass
+        channel = logging.StreamHandler()
+        channel.setFormatter(_LogFormatter(color=color))
+        logger.addHandler(channel)
+
+
+class _LogFormatter(logging.Formatter):
+    def __init__(self, color, *args, **kwargs):
+        logging.Formatter.__init__(self, *args, **kwargs)
+        self._color = color
+        if color:
+            fg_color = (
+                curses.tigetstr("setaf") or curses.tigetstr("setf") or b""
+            )
+            self._colors = {
+                logging.DEBUG: str(curses.tparm(fg_color, 4), "ascii"),  # Blue
+                logging.INFO: str(curses.tparm(fg_color, 2), "ascii"),  # Green
+                logging.WARNING: str(curses.tparm(fg_color, 3),
+                                     "ascii"),  # Yellow
+                logging.ERROR: str(curses.tparm(fg_color, 1), "ascii"),  # Red
+            }
+            self._normal = str(curses.tigetstr("sgr0"), "ascii")
+
+    def format(self, record):
+        try:
+            record.message = record.getMessage()
+        except Exception as e:
+            record.message = "Bad message (%r): %r" % (e, record.__dict__)
+        record.asctime = time.strftime(
+            "%y%m%d %H:%M:%S", self.converter(record.created)
+        )
+        prefix = '[%(levelname)1.1s %(asctime)s %(module)s:%(lineno)d]' % record.__dict__
+        if self._color:
+            prefix = (
+                self._colors.get(record.levelno, self._normal) + prefix +
+                self._normal
+            )
+        formatted = prefix + " " + record.message
+        if record.exc_info:
+            if not record.exc_text:
+                record.exc_text = self.formatException(record.exc_info)
+        if record.exc_text:
+            formatted = formatted.rstrip() + "\n" + record.exc_text
+        return formatted.replace("\n", "\n    ")

+ 1 - 0
authen/messages/__init__.py

@@ -0,0 +1 @@
+# -*- coding: utf-8 -*-

TEMPAT SAMPAH
authen/messages/__pycache__/__init__.cpython-38.pyc


TEMPAT SAMPAH
authen/messages/__pycache__/base.cpython-38.pyc


TEMPAT SAMPAH
authen/messages/__pycache__/entries.cpython-38.pyc


TEMPAT SAMPAH
authen/messages/__pycache__/events.cpython-38.pyc


TEMPAT SAMPAH
authen/messages/__pycache__/messages.cpython-38.pyc


+ 14 - 0
authen/messages/base.py

@@ -0,0 +1,14 @@
+class WeRoBotMetaClass(type):
+    TYPES = {}
+
+    def __new__(mcs, name, bases, attrs):
+        return type.__new__(mcs, name, bases, attrs)
+
+    def __init__(cls, name, bases, attrs):
+        if '__type__' in attrs:
+            if isinstance(attrs['__type__'], list):
+                for _type in attrs['__type__']:
+                    cls.TYPES[_type] = cls
+            else:
+                cls.TYPES[attrs['__type__']] = cls
+        type.__init__(cls, name, bases, attrs)

+ 41 - 0
authen/messages/entries.py

@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+from werobot.utils import to_text
+
+
+def get_value(instance, path, default=None):
+    dic = instance.__dict__
+    for entry in path.split('.'):
+        dic = dic.get(entry)
+        if dic is None:
+            return default
+    return dic or default
+
+
+class BaseEntry(object):
+    def __init__(self, entry, default=None):
+        self.entry = entry
+        self.default = default
+
+
+class IntEntry(BaseEntry):
+    def __get__(self, instance, owner):
+        try:
+            return int(get_value(instance, self.entry, self.default))
+        except TypeError:
+            return
+
+
+class FloatEntry(BaseEntry):
+    def __get__(self, instance, owner):
+        try:
+            return float(get_value(instance, self.entry, self.default))
+        except TypeError:
+            return
+
+
+class StringEntry(BaseEntry):
+    def __get__(self, instance, owner):
+        v = get_value(instance, self.entry, self.default)
+        if v is not None:
+            return to_text(v)
+        return v

+ 245 - 0
authen/messages/events.py

@@ -0,0 +1,245 @@
+# -*- coding: utf-8 -*-
+
+from werobot.messages.entries import StringEntry, IntEntry, FloatEntry
+from werobot.messages.base import WeRoBotMetaClass
+
+
+class EventMetaClass(WeRoBotMetaClass):
+    pass
+
+
+class WeChatEvent(object, metaclass=EventMetaClass):
+    target = StringEntry('ToUserName')
+    source = StringEntry('FromUserName')
+    time = IntEntry('CreateTime')
+    message_id = IntEntry('MsgID', 0)
+
+    def __init__(self, message):
+        self.__dict__.update(message)
+
+
+class SimpleEvent(WeChatEvent):
+    key = StringEntry('EventKey')
+
+
+class TicketEvent(WeChatEvent):
+    key = StringEntry('EventKey')
+    ticket = StringEntry('Ticket')
+
+
+class SubscribeEvent(TicketEvent):
+    __type__ = 'subscribe_event'
+
+
+class UnSubscribeEvent(WeChatEvent):
+    __type__ = 'unsubscribe_event'
+
+
+class ScanEvent(TicketEvent):
+    __type__ = 'scan_event'
+
+
+class ScanCodePushEvent(SimpleEvent):
+    __type__ = 'scancode_push_event'
+    scan_type = StringEntry('ScanCodeInfo.ScanType')
+    scan_result = StringEntry('ScanCodeInfo.ScanResult')
+
+
+class ScanCodeWaitMsgEvent(ScanCodePushEvent):
+    __type__ = 'scancode_waitmsg_event'
+    scan_type = StringEntry('ScanCodeInfo.ScanType')
+    scan_result = StringEntry('ScanCodeInfo.ScanResult')
+
+
+class BasePicEvent(SimpleEvent):
+    count = IntEntry('SendPicsInfo.Count')
+
+    def __init__(self, message):
+        super(BasePicEvent, self).__init__(message)
+        self.pic_list = list()
+        if self.count > 1:
+            for item in message['SendPicsInfo']['PicList'].pop('item'):
+                self.pic_list.append({'pic_md5_sum': item['PicMd5Sum']})
+        else:
+            self.pic_list.append(
+                {
+                    'pic_md5_sum': message['SendPicsInfo']
+                    ['PicList'].pop('item')['PicMd5Sum']
+                }
+            )
+
+
+class PicSysphotoEvent(BasePicEvent):
+    __type__ = 'pic_sysphoto_event'
+
+
+class PicPhotoOrAlbumEvent(BasePicEvent):
+    __type__ = 'pic_photo_or_album_event'
+
+
+class PicWeixinEvent(BasePicEvent):
+    __type__ = 'pic_weixin_event'
+
+
+class LocationSelectEvent(SimpleEvent):
+    __type__ = 'location_select_event'
+    location_x = StringEntry('SendLocationInfo.Location_X')
+    location_y = StringEntry('SendLocationInfo.Location_Y')
+    scale = StringEntry('SendLocationInfo.Scale')
+    label = StringEntry('SendLocationInfo.Label')
+    poi_name = StringEntry('SendLocationInfo.Poiname')
+
+
+class ClickEvent(SimpleEvent):
+    __type__ = 'click_event'
+
+
+class ViewEvent(SimpleEvent):
+    __type__ = 'view_event'
+
+
+class LocationEvent(WeChatEvent):
+    __type__ = 'location_event'
+    latitude = FloatEntry('Latitude')
+    longitude = FloatEntry('Longitude')
+    precision = FloatEntry('Precision')
+
+
+class TemplateSendJobFinishEvent(WeChatEvent):
+    __type__ = 'templatesendjobfinish_event'
+    status = StringEntry('Status')
+
+
+class BaseProductEvent(WeChatEvent):
+    key_standard = StringEntry('KeyStandard')
+    key_str = StringEntry('KeyStr')
+    ext_info = StringEntry('ExtInfo')
+
+
+class UserScanProductEvent(BaseProductEvent):
+    __type__ = 'user_scan_product_event'
+    country = StringEntry('Country')
+    province = StringEntry('Province')
+    city = StringEntry('City')
+    sex = IntEntry('Sex')
+    scene = IntEntry('Scene')
+
+
+class UserScanProductEnterSessionEvent(BaseProductEvent):
+    __type__ = 'user_scan_product_enter_session_event'
+
+
+class UserScanProductAsyncEvent(BaseProductEvent):
+    __type__ = 'user_scan_product_async_event'
+    region_code = StringEntry('RegionCode')
+
+
+class UserScanProductVerifyActionEvent(WeChatEvent):
+    __type__ = 'user_scan_product_verify_action_event'
+    key_standard = StringEntry('KeyStandard')
+    key_str = StringEntry('KeyStr')
+    result = StringEntry('Result')
+    reason_msg = StringEntry('ReasonMsg')
+
+
+class BaseCardCheckEvent(WeChatEvent):
+    card_id = StringEntry('CardId')
+    refuse_reason = StringEntry('RefuseReason')
+
+
+class CardPassCheckEvent(BaseCardCheckEvent):
+    __type__ = 'card_pass_check_event'
+
+
+class CardNotPassCheckEvent(BaseCardCheckEvent):
+    __type__ = 'card_not_pass_check_event'
+
+
+class BaseCardEvent(WeChatEvent):
+    card_id = StringEntry('CardId')
+    user_card_code = StringEntry('UserCardCode')
+
+
+class UserGetCardEvent(BaseCardEvent):
+    __type__ = 'user_get_card_event'
+    is_give_by_friend = IntEntry('IsGiveByFriend')
+    friend_user_name = StringEntry('FriendUserName')
+    outer_id = IntEntry('OuterId')
+    old_user_card_code = StringEntry('OldUserCardCode')
+    outer_str = StringEntry('OuterStr')
+    is_restore_member_card = IntEntry('IsRestoreMemberCard')
+    is_recommend_by_friend = IntEntry('IsRecommendByFriend')
+
+
+class UserGiftingCardEvent(BaseCardEvent):
+    __type__ = 'user_gifting_card_event'
+    is_return_back = IntEntry('IsReturnBack')
+    friend_user_name = StringEntry('FriendUserName')
+    is_chat_room = IntEntry('IsChatRoom')
+
+
+class UserDelCardEvent(BaseCardEvent):
+    __type__ = 'user_del_card_event'
+
+
+class UserConsumeCardEvent(BaseCardEvent):
+    __type__ = 'user_consume_card_event'
+    consume_source = StringEntry('ConsumeSource')
+    location_name = StringEntry('LocationName')
+    staff_open_id = StringEntry('StaffOpenId')
+    verify_code = StringEntry('VerifyCode')
+    remark_amount = StringEntry('RemarkAmount')
+    outer_str = StringEntry('OuterStr')
+
+
+class UserPayFromPayCellEvent(BaseCardEvent):
+    __type__ = 'user_pay_from_pay_cell_event'
+    trans_id = StringEntry('TransId')
+    location_id = IntEntry('LocationId')
+    fee = StringEntry('Fee')
+    original_fee = StringEntry('OriginalFee')
+
+
+class UserViewCardEvent(BaseCardEvent):
+    __type__ = 'user_view_card_event'
+    outer_str = StringEntry('OuterStr')
+
+
+class UserEnterSessionFromCardEvent(BaseCardEvent):
+    __type__ = 'user_enter_session_from_card_event'
+
+
+class UpdateMemberCardEvent(BaseCardEvent):
+    __type__ = 'update_member_card_event'
+    modify_bonus = IntEntry('ModifyBonus')
+    modify_balance = IntEntry('ModifyBalance')
+
+
+class CardSkuRemindEvent(WeChatEvent):
+    __type__ = 'card_sku_remind_event'
+    card_id = StringEntry('CardId')
+    detail = StringEntry('Detail')
+
+
+class CardPayOrderEvent(WeChatEvent):
+    __type__ = 'card_pay_order_event'
+    order_id = StringEntry('OrderId')
+    status = StringEntry('Status')
+    create_order_time = IntEntry('CreateOrderTime')
+    pay_finish_time = IntEntry('PayFinishTime')
+    desc = StringEntry('Desc')
+    free_coin_count = StringEntry('FreeCoinCount')
+    pay_coin_count = StringEntry('PayCoinCount')
+    refund_free_coin_count = StringEntry('RefundFreeCoinCount')
+    refund_pay_coin_count = StringEntry('RefundPayCoinCount')
+    order_type = StringEntry('OrderType')
+    memo = StringEntry('Memo')
+    receipt_info = StringEntry('ReceiptInfo')
+
+
+class SubmitMembercardUserInfoEvent(BaseCardEvent):
+    __type__ = 'submit_membercard_user_info_event'
+
+
+class UnknownEvent(WeChatEvent):
+    __type__ = 'unknown_event'

+ 64 - 0
authen/messages/messages.py

@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+
+from werobot.messages.entries import StringEntry, IntEntry, FloatEntry
+from werobot.messages.base import WeRoBotMetaClass
+
+
+class MessageMetaClass(WeRoBotMetaClass):
+    pass
+
+
+class WeChatMessage(object, metaclass=MessageMetaClass):
+    message_id = IntEntry('MsgId', 0)
+    target = StringEntry('ToUserName')
+    source = StringEntry('FromUserName')
+    time = IntEntry('CreateTime', 0)
+
+    def __init__(self, message):
+        self.__dict__.update(message)
+
+
+class TextMessage(WeChatMessage):
+    __type__ = 'text'
+    content = StringEntry('Content')
+
+
+class ImageMessage(WeChatMessage):
+    __type__ = 'image'
+    img = StringEntry('PicUrl')
+
+
+class LocationMessage(WeChatMessage):
+    __type__ = 'location'
+    location_x = FloatEntry('Location_X')
+    location_y = FloatEntry('Location_Y')
+    label = StringEntry('Label')
+    scale = IntEntry('Scale')
+
+    @property
+    def location(self):
+        return self.location_x, self.location_y
+
+
+class LinkMessage(WeChatMessage):
+    __type__ = 'link'
+    title = StringEntry('Title')
+    description = StringEntry('Description')
+    url = StringEntry('Url')
+
+
+class VoiceMessage(WeChatMessage):
+    __type__ = 'voice'
+    media_id = StringEntry('MediaId')
+    format = StringEntry('Format')
+    recognition = StringEntry('Recognition')
+
+
+class VideoMessage(WeChatMessage):
+    __type__ = ['video', 'shortvideo']
+    media_id = StringEntry('MediaId')
+    thumb_media_id = StringEntry('ThumbMediaId')
+
+
+class UnknownMessage(WeChatMessage):
+    __type__ = 'unknown'

+ 33 - 0
authen/parser.py

@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+import xmltodict
+from werobot.messages.messages import MessageMetaClass, UnknownMessage
+from werobot.messages.events import EventMetaClass, UnknownEvent
+
+
+def parse_user_msg(xml):
+    message = process_message(parse_xml(xml)) if xml else None
+    return message
+
+
+def parse_xml(text):
+    xml_dict = xmltodict.parse(text)["xml"]
+    xml_dict["raw"] = text
+    return xml_dict
+
+
+def process_message(message):
+    """
+    Process a message dict and return a Message Object
+    :param message: Message dict returned by `parse_xml` function
+    :return: Message Object
+    """
+    message["type"] = message.pop("MsgType").lower()
+    if message["type"] == 'event':
+        message["type"] = str(message.pop("Event")).lower() + '_event'
+        message_type = EventMetaClass.TYPES.get(message["type"], UnknownEvent)
+    else:
+        message_type = MessageMetaClass.TYPES.get(
+            message["type"], UnknownMessage
+        )
+    return message_type(message)

+ 203 - 0
authen/pay.py

@@ -0,0 +1,203 @@
+# -*- coding:utf-8 -*-
+
+from hashlib import sha1, md5
+from urllib import urlencode
+import time
+from werobot.client import Client
+from werobot.utils import pay_sign_dict, generate_token
+from functools import partial
+
+NATIVE_BASE_URL = 'weixin://wxpay/bizpayurl?'
+
+
+class WeixinPayClient(Client):
+    """
+    简化微信支付API操作
+    """
+    def __init__(self, appid, pay_sign_key, pay_partner_id, pay_partner_key):
+        self.pay_sign_key = pay_sign_key
+        self.pay_partner_id = pay_partner_id
+        self.pay_partner_key = pay_partner_key
+        self._pay_sign_dict = partial(pay_sign_dict, appid, pay_sign_key)
+
+        self._token = None
+        self.token_expires_at = None
+
+    def create_js_pay_package(self, **package):
+        """
+        签名 pay package 需要的参数
+        详情请参考 支付开发文档
+
+        :param package: 需要签名的的参数
+        :return: 可以使用的packagestr
+        """
+        assert self.pay_partner_id, "PAY_PARTNER_ID IS EMPTY"
+        assert self.pay_partner_key, "PAY_PARTNER_KEY IS EMPTY"
+
+        package.update({
+            'partner': self.pay_partner_id,
+        })
+
+        package.setdefault('bank_type', 'WX')
+        package.setdefault('fee_type', '1')
+        package.setdefault('input_charset', 'UTF-8')
+
+        params = package.items()
+        params.sort()
+
+        sign = md5(
+            '&'.join(
+                [
+                    "%s=%s" % (str(p[0]), str(p[1]))
+                    for p in params + [('key', self.pay_partner_key)]
+                ]
+            )
+        ).hexdigest().upper()
+
+        return urlencode(params + [('sign', sign)])
+
+    def create_js_pay_params(self, **package):
+        """
+        签名 js 需要的参数
+        详情请参考 支付开发文档
+
+        ::
+
+            wxclient.create_js_pay_params(
+                body=标题, out_trade_no=本地订单号, total_fee=价格单位分,
+                notify_url=通知url,
+                spbill_create_ip=建议为支付人ip,
+            )
+
+        :param package: 需要签名的的参数
+        :return: 支付需要的对象
+        """
+        pay_param, sign, sign_type = self._pay_sign_dict(
+            package=self.create_js_pay_package(**package)
+        )
+        pay_param['paySign'] = sign
+        pay_param['signType'] = sign_type
+
+        # 腾讯这个还得转成大写 JS 才认
+        for key in ['appId', 'timeStamp', 'nonceStr']:
+            pay_param[key] = str(pay_param.pop(key.lower()))
+
+        return pay_param
+
+    def create_js_edit_address_param(self, accesstoken, **params):
+        """
+        alpha
+        暂时不建议使用
+        这个接口使用起来十分不友好
+        而且会引起巨大的误解
+
+        url 需要带上 code 和 state (url?code=xxx&state=1)
+        code 和state 是 oauth 时候回来的
+
+        token 要传用户的 token
+
+        这尼玛 你能相信这些支付接口都是腾讯出的?
+        """
+        params.update(
+            {
+                'appId': self.appid,
+                'nonceStr': generate_token(8),
+                'timeStamp': int(time.time())
+            }
+        )
+
+        _params = [(k.lower(), str(v)) for k, v in params.items()]
+        _params += [('accesstoken', accesstoken)]
+        _params.sort()
+
+        string1 = '&'.join(["%s=%s" % (p[0], p[1]) for p in _params])
+        sign = sha1(string1).hexdigest()
+
+        params = dict([(k, str(v)) for k, v in params.items()])
+
+        params['addrSign'] = sign
+        params['signType'] = 'sha1'
+        params['scope'] = params.get('scope', 'jsapi_address')
+
+        return params
+
+    def create_native_pay_url(self, productid):
+        """
+        创建 native pay url
+        详情请参考 支付开发文档
+
+        :param productid: 本地商品ID
+        :return: 返回URL
+        """
+
+        params, sign, = self._pay_sign_dict(productid=productid)
+
+        params['sign'] = sign
+
+        return NATIVE_BASE_URL + urlencode(params)
+
+    def pay_deliver_notify(self, **deliver_info):
+        """
+        通知 腾讯发货
+
+        一般形式 ::
+            wxclient.pay_delivernotify(
+                openid=openid,
+                transid=transaction_id,
+                out_trade_no=本地订单号,
+                deliver_timestamp=int(time.time()),
+                deliver_status="1",
+                deliver_msg="ok"
+            )
+
+        :param 需要签名的的参数
+        :return: 支付需要的对象
+        """
+        params, sign, _ = self._pay_sign_dict(
+            add_noncestr=False, add_timestamp=False, **deliver_info
+        )
+
+        params['app_signature'] = sign
+        params['sign_method'] = 'sha1'
+
+        return self.post(
+            url="https://api.weixin.qq.com/pay/delivernotify", data=params
+        )
+
+    def pay_order_query(self, out_trade_no):
+        """
+        查询订单状态
+        一般用于无法确定 订单状态时候补偿
+
+        :param out_trade_no: 本地订单号
+        :return: 订单信息dict
+        """
+
+        package = {
+            'partner': self.pay_partner_id,
+            'out_trade_no': out_trade_no,
+        }
+
+        _package = package.items()
+        _package.sort()
+
+        s = '&'.join(
+            [
+                "%s=%s" % (p[0], str(p[1]))
+                for p in (_package + [('key', self.pay_partner_key)])
+            ]
+        )
+        package['sign'] = md5(s).hexdigest().upper()
+
+        package = '&'.join(["%s=%s" % (p[0], p[1]) for p in package.items()])
+
+        params, sign, _ = self._pay_sign_dict(
+            add_noncestr=False, package=package
+        )
+
+        params['app_signature'] = sign
+        params['sign_method'] = 'sha1'
+
+        return self.post(
+            url="https://api.weixin.qq.com/pay/orderquery", data=params
+        )

+ 271 - 0
authen/replies.py

@@ -0,0 +1,271 @@
+# -*- coding: utf-8 -*-
+import time
+
+from collections import defaultdict, namedtuple
+from werobot.utils import is_string, to_text
+
+
+def renderable_named_tuple(typename, field_names, tempalte):
+    class TMP(namedtuple(typename=typename, field_names=field_names)):
+        __TEMPLATE__ = tempalte
+
+        @property
+        def args(self):
+            # https://bugs.python.org/issue24931
+            return dict(zip(self._fields, self))
+
+        def process_args(self, kwargs):
+            args = defaultdict(str)
+            for k, v in kwargs.items():
+                if is_string(v):
+                    v = to_text(v)
+                args[k] = v
+            return args
+
+        def render(self):
+            return to_text(
+                self.__TEMPLATE__.format(**self.process_args(self.args))
+            )
+
+    TMP.__name__ = typename
+    return TMP
+
+
+class WeChatReply(object):
+    def process_args(self, args):
+        pass
+
+    def __init__(self, message=None, **kwargs):
+        if message and "source" not in kwargs:
+            kwargs["source"] = message.target
+
+        if message and "target" not in kwargs:
+            kwargs["target"] = message.source
+
+        if 'time' not in kwargs:
+            kwargs["time"] = int(time.time())
+
+        args = defaultdict(str)
+        for k, v in kwargs.items():
+            if is_string(v):
+                v = to_text(v)
+            args[k] = v
+        self.process_args(args)
+        self._args = args
+
+    def render(self):
+        return to_text(self.TEMPLATE.format(**self._args))
+
+    def __getattr__(self, item):
+        if item in self._args:
+            return self._args[item]
+
+
+class TextReply(WeChatReply):
+    TEMPLATE = to_text(
+        """
+    <xml>
+    <ToUserName><![CDATA[{target}]]></ToUserName>
+    <FromUserName><![CDATA[{source}]]></FromUserName>
+    <CreateTime>{time}</CreateTime>
+    <MsgType><![CDATA[text]]></MsgType>
+    <Content><![CDATA[{content}]]></Content>
+    </xml>
+    """
+    )
+
+
+class ImageReply(WeChatReply):
+    TEMPLATE = to_text(
+        """
+    <xml>
+    <ToUserName><![CDATA[{target}]]></ToUserName>
+    <FromUserName><![CDATA[{source}]]></FromUserName>
+    <CreateTime>{time}</CreateTime>
+    <MsgType><![CDATA[image]]></MsgType>
+    <Image>
+    <MediaId><![CDATA[{media_id}]]></MediaId>
+    </Image>
+    </xml>
+    """
+    )
+
+
+class VoiceReply(WeChatReply):
+    TEMPLATE = to_text(
+        """
+    <xml>
+    <ToUserName><![CDATA[{target}]]></ToUserName>
+    <FromUserName><![CDATA[{source}]]></FromUserName>
+    <CreateTime>{time}</CreateTime>
+    <MsgType><![CDATA[voice]]></MsgType>
+    <Voice>
+    <MediaId><![CDATA[{media_id}]]></MediaId>
+    </Voice>
+    </xml>
+    """
+    )
+
+
+class VideoReply(WeChatReply):
+    TEMPLATE = to_text(
+        """
+    <xml>
+    <ToUserName><![CDATA[{target}]]></ToUserName>
+    <FromUserName><![CDATA[{source}]]></FromUserName>
+    <CreateTime>{time}</CreateTime>
+    <MsgType><![CDATA[video]]></MsgType>
+    <Video>
+    <MediaId><![CDATA[{media_id}]]></MediaId>
+    <Title><![CDATA[{title}]]></Title>
+    <Description><![CDATA[{description}]]></Description>
+    </Video>
+    </xml>
+    """
+    )
+
+    def process_args(self, args):
+        args.setdefault('title', '')
+        args.setdefault('description', '')
+
+
+Article = renderable_named_tuple(
+    typename="Article",
+    field_names=("title", "description", "img", "url"),
+    tempalte=to_text(
+        """
+    <item>
+    <Title><![CDATA[{title}]]></Title>
+    <Description><![CDATA[{description}]]></Description>
+    <PicUrl><![CDATA[{img}]]></PicUrl>
+    <Url><![CDATA[{url}]]></Url>
+    </item>
+    """
+    )
+)
+
+
+class ArticlesReply(WeChatReply):
+    TEMPLATE = to_text(
+        """
+    <xml>
+    <ToUserName><![CDATA[{target}]]></ToUserName>
+    <FromUserName><![CDATA[{source}]]></FromUserName>
+    <CreateTime>{time}</CreateTime>
+    <MsgType><![CDATA[news]]></MsgType>
+    <Content><![CDATA[{content}]]></Content>
+    <ArticleCount>{count}</ArticleCount>
+    <Articles>{items}</Articles>
+    </xml>
+    """
+    )
+
+    def __init__(self, message=None, **kwargs):
+        super(ArticlesReply, self).__init__(message, **kwargs)
+        self._articles = []
+
+    def add_article(self, article):
+        if len(self._articles) >= 10:
+            raise AttributeError(
+                "Can't add more than 10 articles"
+                " in an ArticlesReply"
+            )
+        else:
+            self._articles.append(article)
+
+    def render(self):
+        items = []
+        for article in self._articles:
+            items.append(article.render())
+        self._args["items"] = ''.join(items)
+        self._args["count"] = len(items)
+        if "content" not in self._args:
+            self._args["content"] = ''
+        return ArticlesReply.TEMPLATE.format(**self._args)
+
+
+class MusicReply(WeChatReply):
+    TEMPLATE = to_text(
+        """
+    <xml>
+    <ToUserName><![CDATA[{target}]]></ToUserName>
+    <FromUserName><![CDATA[{source}]]></FromUserName>
+    <CreateTime>{time}</CreateTime>
+    <MsgType><![CDATA[music]]></MsgType>
+    <Music>
+    <Title><![CDATA[{title}]]></Title>
+    <Description><![CDATA[{description}]]></Description>
+    <MusicUrl><![CDATA[{url}]]></MusicUrl>
+    <HQMusicUrl><![CDATA[{hq_url}]]></HQMusicUrl>
+    </Music>
+    </xml>
+    """
+    )
+
+    def process_args(self, args):
+        if 'hq_url' not in args:
+            args['hq_url'] = args['url']
+
+
+class TransferCustomerServiceReply(WeChatReply):
+    @property
+    def TEMPLATE(self):
+        if 'account' in self._args:
+            return to_text(
+                """
+            <xml>
+            <ToUserName><![CDATA[{target}]]></ToUserName>
+            <FromUserName><![CDATA[{source}]]></FromUserName>
+            <CreateTime>{time}</CreateTime>
+            <MsgType><![CDATA[transfer_customer_service]]></MsgType>
+            <TransInfo>
+                 <KfAccount><![CDATA[{account}]]></KfAccount>
+             </TransInfo>
+            </xml>
+            """
+            )
+        else:
+            return to_text(
+                """
+            <xml>
+            <ToUserName><![CDATA[{target}]]></ToUserName>
+            <FromUserName><![CDATA[{source}]]></FromUserName>
+            <CreateTime>{time}</CreateTime>
+            <MsgType><![CDATA[transfer_customer_service]]></MsgType>
+            </xml>
+            """
+            )
+
+
+class SuccessReply(WeChatReply):
+    def render(self):
+        return "success"
+
+
+def process_function_reply(reply, message=None):
+    if is_string(reply):
+        return TextReply(message=message, content=reply)
+    elif isinstance(reply, list) and all([len(x) == 4 for x in reply]):
+        if len(reply) > 10:
+            raise AttributeError(
+                "Can't add more than 10 articles"
+                " in an ArticlesReply"
+            )
+        r = ArticlesReply(message=message)
+        for article in reply:
+            article = Article(*article)
+            r.add_article(article)
+        return r
+    elif isinstance(reply, list) and 3 <= len(reply) <= 4:
+        if len(reply) == 3:
+            # 如果数组长度为3, 那么高质量音乐链接的网址和普通质量的网址相同。
+            reply.append(reply[-1])
+        title, description, url, hq_url = reply
+        return MusicReply(
+            message=message,
+            title=title,
+            description=description,
+            url=url,
+            hq_url=hq_url
+        )
+    return reply

+ 692 - 0
authen/robot.py

@@ -0,0 +1,692 @@
+# -*- coding: utf-8 -*-
+
+import warnings
+
+from werobot.config import Config, ConfigAttribute
+from werobot.client import Client
+from werobot.exceptions import ConfigError
+from werobot.parser import parse_xml, process_message
+from werobot.replies import process_function_reply
+from werobot.utils import (
+    to_binary, to_text, check_signature, make_error_page, cached_property,
+    is_regex
+)
+
+from inspect import signature
+
+__all__ = ['BaseRoBot', 'WeRoBot']
+
+_DEFAULT_CONFIG = dict(
+    TOKEN=None,
+    SERVER="auto",
+    HOST="127.0.0.1",
+    PORT="8888",
+    SESSION_STORAGE=None,
+    APP_ID=None,
+    APP_SECRET=None,
+    ENCODING_AES_KEY=None,
+    WXMP_REDIS_URL=None
+)
+
+
+class BaseRoBot(object):
+    """
+    BaseRoBot 是整个应用的核心对象,负责提供 handler 的维护,消息和事件的处理等核心功能。
+
+    :param logger: 用来输出 log 的 logger,如果是 ``None``,将使用 werobot.logger
+    :param config: 用来设置的 :class:`werobot.config.Config` 对象 \\
+
+    .. note:: 对于下面的参数推荐使用 :class:`~werobot.config.Config` 进行设置,\
+    并且以下参数均已 **deprecated**。
+
+    :param token: 微信公众号设置的 token **(deprecated)**
+    :param enable_session: 是否开启 session **(deprecated)**
+    :param session_storage: 用来储存 session 的对象,如果为 ``None``,\
+    将使用 werobot.session.sqlitestorage.SQLiteStorage **(deprecated)**
+    :param app_id: 微信公众号设置的 app id **(deprecated)**
+    :param app_secret: 微信公众号设置的 app secret **(deprecated)**
+    :param encoding_aes_key: 用来加解密消息的 aes key **(deprecated)**
+    """
+    message_types = [
+        'subscribe_event',
+        'unsubscribe_event',
+        'click_event',
+        'view_event',
+        'scan_event',
+        'scancode_waitmsg_event',
+        'scancode_push_event',
+        'pic_sysphoto_event',
+        'pic_photo_or_album_event',
+        'pic_weixin_event',
+        'location_select_event',
+        'location_event',
+        'unknown_event',
+        'user_scan_product_event',
+        'user_scan_product_enter_session_event',
+        'user_scan_product_async_event',
+        'user_scan_product_verify_action_event',
+        'card_pass_check_event',
+        'card_not_pass_check_event',
+        'user_get_card_event',
+        'user_gifting_card_event',
+        'user_del_card_event',
+        'user_consume_card_event',
+        'user_pay_from_pay_cell_event',
+        'user_view_card_event',
+        'user_enter_session_from_card_event',
+        'update_member_card_event',
+        'card_sku_remind_event',
+        'card_pay_order_event',
+        'templatesendjobfinish_event',
+        'submit_membercard_user_info_event',  # event
+        'text',
+        'image',
+        'link',
+        'location',
+        'voice',
+        'unknown',
+        'video',
+        'shortvideo'
+    ]
+
+    token = ConfigAttribute("TOKEN")
+    session_storage = ConfigAttribute("SESSION_STORAGE")
+
+    def __init__(
+        self,
+        token=None,
+        logger=None,
+        enable_session=None,
+        session_storage=None,
+        app_id=None,
+        app_secret=None,
+        encoding_aes_key=None,
+        config=None,
+        wxmp_redis_url=None,
+        **kwargs
+    ):
+
+        self._handlers = {k: [] for k in self.message_types}
+        self._handlers['all'] = []
+        self.make_error_page = make_error_page
+
+        if logger is None:
+            import werobot.logger
+            logger = werobot.logger.logger
+        self.logger = logger
+
+        if config is None:
+            self.config = Config(_DEFAULT_CONFIG)
+            self.config.update(
+                TOKEN=token,
+                APP_ID=app_id,
+                APP_SECRET=app_secret,
+                ENCODING_AES_KEY=encoding_aes_key,
+                WXMP_REDIS_URL=wxmp_redis_url
+
+            )
+            for k, v in kwargs.items():
+                self.config[k.upper()] = v
+
+            if enable_session is not None:
+                warnings.warn(
+                    "enable_session is deprecated."
+                    "set SESSION_STORAGE to False if you want to disable Session",
+                    DeprecationWarning,
+                    stacklevel=2
+                )
+                if not enable_session:
+                    self.config["SESSION_STORAGE"] = False
+
+            if session_storage:
+                self.config["SESSION_STORAGE"] = session_storage
+        else:
+            self.config = config
+
+        self.use_encryption = False
+
+    @cached_property
+    def crypto(self):
+        app_id = self.config.get("APP_ID", None)
+        if not app_id:
+            raise ConfigError(
+                "You need to provide app_id to encrypt/decrypt messages"
+            )
+
+        encoding_aes_key = self.config.get("ENCODING_AES_KEY", None)
+        if not encoding_aes_key:
+            raise ConfigError(
+                "You need to provide encoding_aes_key "
+                "to encrypt/decrypt messages"
+            )
+        self.use_encryption = True
+
+        from .crypto import MessageCrypt
+        return MessageCrypt(
+            token=self.config["TOKEN"],
+            encoding_aes_key=encoding_aes_key,
+            app_id=app_id
+        )
+
+    @cached_property
+    def client(self):
+        return Client(self.config)
+
+    @cached_property
+    def session_storage(self):
+        if self.config["SESSION_STORAGE"] is False:
+            return None
+        if not self.config["SESSION_STORAGE"]:
+            from .session.sqlitestorage import SQLiteStorage
+            self.config["SESSION_STORAGE"] = SQLiteStorage()
+        return self.config["SESSION_STORAGE"]
+
+    @session_storage.setter
+    def session_storage(self, value):
+        warnings.warn(
+            "You should set session storage in config",
+            DeprecationWarning,
+            stacklevel=2
+        )
+        self.config["SESSION_STORAGE"] = value
+
+    def handler(self, f):
+        """
+        为每一条消息或事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='all')
+        return f
+
+    def text(self, f):
+        """
+        为文本 ``(text)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='text')
+        return f
+
+    def image(self, f):
+        """
+        为图像 ``(image)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='image')
+        return f
+
+    def location(self, f):
+        """
+        为位置 ``(location)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='location')
+        return f
+
+    def link(self, f):
+        """
+        为链接 ``(link)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='link')
+        return f
+
+    def voice(self, f):
+        """
+        为语音 ``(voice)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='voice')
+        return f
+
+    def video(self, f):
+        """
+        为视频 ``(video)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='video')
+        return f
+
+    def shortvideo(self, f):
+        """
+        为小视频 ``(shortvideo)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='shortvideo')
+        return f
+
+    def unknown(self, f):
+        """
+        为未知类型 ``(unknown)`` 消息添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='unknown')
+        return f
+
+    def subscribe(self, f):
+        """
+        为被关注 ``(subscribe)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='subscribe_event')
+        return f
+
+    def unsubscribe(self, f):
+        """
+        为被取消关注 ``(unsubscribe)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='unsubscribe_event')
+        return f
+
+    def click(self, f):
+        """
+        为自定义菜单事件 ``(click)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='click_event')
+        return f
+
+    def scan(self, f):
+        """
+        为扫描推送 ``(scan)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='scan_event')
+        return f
+
+    def scancode_push(self, f):
+        """
+        为扫描推送 ``(scancode_push)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='scancode_push_event')
+        return f
+
+    def scancode_waitmsg(self, f):
+        """
+        为扫描弹消息 ``(scancode_waitmsg)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='scancode_waitmsg_event')
+        return f
+
+    def pic_sysphoto(self, f):
+        """
+        为弹出系统拍照发图的事件推送 ``(pic_sysphoto_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='pic_sysphoto_event')
+        return f
+
+    def pic_photo_or_album(self, f):
+        """
+        为弹出拍照或者相册发图的事件推送 ``(pic_photo_or_album_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='pic_photo_or_album_event')
+        return f
+
+    def pic_weixin(self, f):
+        """
+        为弹出微信相册发图器的事件推送 ``(pic_weixin_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='pic_weixin_event')
+        return f
+
+    def location_select(self, f):
+        """
+        为弹出地理位置选择器的事件推送 ``(location_select_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='location_select_event')
+        return f
+
+    def location_event(self, f):
+        """
+        为上报位置 ``(location_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='location_event')
+        return f
+
+    def view(self, f):
+        """
+        为链接 ``(view)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='view_event')
+        return f
+
+    def user_scan_product(self, f):
+        """
+        为打开商品主页事件推送 ``(user_scan_product_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_scan_product_event')
+        return f
+
+    def user_scan_product_enter_session(self, f):
+        """
+        为进入公众号事件推送 ``(user_scan_product_enter_session_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_scan_product_enter_session_event')
+        return f
+
+    def user_scan_product_async(self, f):
+        """
+        为地理位置信息异步推送 ``(user_scan_product_async_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_scan_product_async_event')
+        return f
+
+    def user_scan_product_verify_action(self, f):
+        """
+        为商品审核结果推送 ``(user_scan_product_verify_action_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_scan_product_verify_action_event')
+        return f
+
+    def card_pass_check(self, f):
+        """
+        为生成的卡券通过审核 ``(card_pass_check_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='card_pass_check_event')
+        return f
+
+    def card_not_pass_check(self, f):
+        """
+        为生成的卡券未通过审核 ``(card_not_pass_check_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='card_not_pass_check_event')
+        return f
+
+    def user_get_card(self, f):
+        """
+        为用户领取卡券 ``(user_get_card_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_get_card_event')
+        return f
+
+    def user_gifting_card(self, f):
+        """
+        为用户转赠卡券 ``(user_gifting_card_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_gifting_card_event')
+        return f
+
+    def user_del_card(self, f):
+        """
+        为用户删除卡券 ``(user_del_card_event)``  事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_del_card_event')
+        return f
+
+    def user_consume_card(self, f):
+        """
+        为卡券被核销 ``(user_consume_card_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_consume_card_event')
+        return f
+
+    def user_pay_from_pay_cell(self, f):
+        """
+        为微信买单完成 ``(user_pay_from_pay_cell_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_pay_from_pay_cell_event')
+        return f
+
+    def user_view_card(self, f):
+        """
+        为用户进入会员卡 ``(user_view_card_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_view_card_event')
+        return f
+
+    def user_enter_session_from_card(self, f):
+        """
+        为用户卡券里点击查看公众号进入会话 ``(user_enter_session_from_card_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='user_enter_session_from_card_event')
+        return f
+
+    def update_member_card(self, f):
+        """
+        为用户的会员卡积分余额发生变动 ``(update_member_card_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='update_member_card_event')
+        return f
+
+    def card_sku_remind(self, f):
+        """
+        为某个card_id的初始库存数大于200且当前库存小于等于100 ``(card_sku_remind_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='card_sku_remind_event')
+        return f
+
+    def card_pay_order(self, f):
+        """
+        为券点发生变动 ``(card_pay_order_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='card_pay_order_event')
+        return f
+
+    def submit_membercard_user_info(self, f):
+        """
+        为用户通过一键激活的方式提交信息并点击激活或者用户修改会员卡信息 ``(submit_membercard_user_info_event)``
+        事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='submit_membercard_user_info_event')
+        return f
+
+    def templatesendjobfinish_event(self, f):
+        """在模版消息发送任务完成后,微信服务器会将是否送达成功作为通知,发送到开发者中心中填写的服务器配置地址中
+        """
+        self.add_handler(f, type='templatesendjobfinish_event')
+        return f
+
+    def unknown_event(self, f):
+        """
+        为未知类型 ``(unknown_event)`` 事件添加一个 handler 方法的装饰器。
+        """
+        self.add_handler(f, type='unknown_event')
+        return f
+
+    def key_click(self, key):
+        """
+        为自定义菜单 ``(click)`` 事件添加 handler 的简便方法。
+
+        **@key_click('KEYNAME')** 用来为特定 key 的点击事件添加 handler 方法。
+        """
+        def wraps(f):
+            argc = len(signature(f).parameters.keys())
+
+            @self.click
+            def onclick(message, session=None):
+                if message.key == key:
+                    return f(*[message, session][:argc])
+
+            return f
+
+        return wraps
+
+    def filter(self, *args):
+        """
+        为文本 ``(text)`` 消息添加 handler 的简便方法。
+
+        使用 ``@filter("xxx")``, ``@filter(re.compile("xxx"))``
+        或 ``@filter("xxx", "xxx2")`` 的形式为特定内容添加 handler。
+        """
+        def wraps(f):
+            self.add_filter(func=f, rules=list(args))
+            return f
+
+        return wraps
+
+    def add_handler(self, func, type='all'):
+        """
+        为 BaseRoBot 实例添加一个 handler。
+
+        :param func: 要作为 handler 的方法。
+        :param type: handler 的种类。
+        :return: None
+        """
+        if not callable(func):
+            raise ValueError("{} is not callable".format(func))
+
+        self._handlers[type].append(
+            (func, len(signature(func).parameters.keys()))
+        )
+
+    def get_handlers(self, type):
+        return self._handlers.get(type, []) + self._handlers['all']
+
+    def add_filter(self, func, rules):
+        """
+        为 BaseRoBot 添加一个 ``filter handler``。
+
+        :param func: 如果 rules 通过,则处理该消息的 handler。
+        :param rules: 一个 list,包含要匹配的字符串或者正则表达式。
+        :return: None
+        """
+        if not callable(func):
+            raise ValueError("{} is not callable".format(func))
+        if not isinstance(rules, list):
+            raise ValueError("{} is not list".format(rules))
+        if len(rules) > 1:
+            for x in rules:
+                self.add_filter(func, [x])
+        else:
+            target_content = rules[0]
+            if isinstance(target_content, str):
+                target_content = to_text(target_content)
+
+                def _check_content(message):
+                    return message.content == target_content
+            elif is_regex(target_content):
+
+                def _check_content(message):
+                    return target_content.match(message.content)
+            else:
+                raise TypeError("%s is not a valid rule" % target_content)
+            argc = len(signature(func).parameters.keys())
+
+            @self.text
+            def _f(message, session=None):
+                _check_result = _check_content(message)
+                if _check_result:
+                    if isinstance(_check_result, bool):
+                        _check_result = None
+                    return func(*[message, session, _check_result][:argc])
+
+    def parse_message(
+        self, body, timestamp=None, nonce=None, msg_signature=None
+    ):
+        """
+        解析获取到的 Raw XML ,如果需要的话进行解密,返回 WeRoBot Message。
+        :param body: 微信服务器发来的请求中的 Body。
+        :return: WeRoBot Message
+        """
+        message_dict = parse_xml(body)
+        if "Encrypt" in message_dict:
+            xml = self.crypto.decrypt_message(
+                timestamp=timestamp,
+                nonce=nonce,
+                msg_signature=msg_signature,
+                encrypt_msg=message_dict["Encrypt"]
+            )
+            message_dict = parse_xml(xml)
+        return process_message(message_dict)
+
+    def get_reply(self, message):
+        """
+        根据 message 的内容获取 Reply 对象。
+
+        :param message: 要处理的 message
+        :return: 获取的 Reply 对象
+        """
+        session_storage = self.session_storage
+
+        id = None
+        session = None
+        if session_storage and hasattr(message, "source"):
+            id = to_binary(message.source)
+            session = session_storage[id]
+
+        handlers = self.get_handlers(message.type)
+        try:
+            for handler, args_count in handlers:
+                args = [message, session][:args_count]
+                reply = handler(*args)
+                if session_storage and id:
+                    session_storage[id] = session
+                if reply:
+                    return process_function_reply(reply, message=message)
+        except:
+            self.logger.exception("Catch an exception")
+
+    def get_encrypted_reply(self, message):
+        """
+        对一个指定的 WeRoBot Message ,获取 handlers 处理后得到的 Reply。
+        如果可能,对该 Reply 进行加密。
+        返回 Reply Render 后的文本。
+
+        :param message: 一个 WeRoBot Message 实例。
+        :return: reply (纯文本)
+        """
+        reply = self.get_reply(message)
+        if not reply:
+            self.logger.warning("No handler responded message %s" % message)
+            return ''
+        if self.use_encryption:
+            return self.crypto.encrypt_message(reply)
+        else:
+            return reply.render()
+
+    def check_signature(self, timestamp, nonce, signature):
+        """
+        根据时间戳和生成签名的字符串 (nonce) 检查签名。
+
+        :param timestamp: 时间戳
+        :param nonce: 生成签名的随机字符串
+        :param signature: 要检查的签名
+        :return: 如果签名合法将返回 ``True``,不合法将返回 ``False``
+        """
+        return check_signature(
+            self.config["TOKEN"], timestamp, nonce, signature
+        )
+
+    def error_page(self, f):
+        """
+        为 robot 指定 Signature 验证不通过时显示的错误页面。
+
+        Usage::
+
+            @robot.error_page
+            def make_error_page(url):
+                return "<h1>喵喵喵 %s 不是给麻瓜访问的快走开</h1>" % url
+
+        """
+        self.make_error_page = f
+        return f
+
+
+class WeRoBot(BaseRoBot):
+    """
+    WeRoBot 是一个继承自 BaseRoBot 的对象,在 BaseRoBot 的基础上使用了 bottle 框架,
+    提供接收微信服务器发来的请求的功能。
+    """
+    @cached_property
+    def wsgi(self):
+        if not self._handlers:
+            raise RuntimeError('No Handler.')
+        from bottle import Bottle
+        from werobot.contrib.bottle import make_view
+
+        app = Bottle()
+        app.route('<t:path>', ['GET', 'POST'], make_view(self))
+        return app
+
+    def run(
+        self, server=None, host=None, port=None, enable_pretty_logging=True
+    ):
+        """
+        运行 WeRoBot。
+
+        :param server: 传递给 Bottle 框架 run 方法的参数,详情见\
+        `bottle 文档 <https://bottlepy.org/docs/dev/deployment.html#switching-the-server-backend>`_
+        :param host: 运行时绑定的主机地址
+        :param port: 运行时绑定的主机端口
+        :param enable_pretty_logging: 是否开启 log 的输出格式优化
+        """
+        if enable_pretty_logging:
+            from werobot.logger import enable_pretty_logging
+            enable_pretty_logging(self.logger)
+        if server is None:
+            server = self.config["SERVER"]
+        if host is None:
+            host = self.config["HOST"]
+        if port is None:
+            port = self.config["PORT"]
+        try:
+            self.wsgi.run(server=server, host=host, port=port)
+        except KeyboardInterrupt:
+            exit(0)

+ 18 - 0
authen/session/__init__.py

@@ -0,0 +1,18 @@
+class SessionStorage(object):
+    def get(self, id):
+        raise NotImplementedError()
+
+    def set(self, id, value):
+        raise NotImplementedError()
+
+    def delete(self, id):
+        raise NotImplementedError()
+
+    def __getitem__(self, id):
+        return self.get(id)
+
+    def __setitem__(self, id, session):
+        self.set(id, session)
+
+    def __delitem__(self, id):
+        self.delete(id)

+ 55 - 0
authen/session/filestorage.py

@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+
+try:
+    import anydbm as dbm
+
+    assert dbm
+except ImportError:
+    import dbm
+
+from werobot.session import SessionStorage
+from werobot.utils import json_loads, json_dumps, to_binary
+
+
+class FileStorage(SessionStorage):
+    """
+    FileStorage 会把你的 Session 数据以 dbm 形式储存在文件中。
+
+    :param filename: 文件名, 默认为 ``werobot_session``
+    """
+    def __init__(self, filename: str = 'werobot_session'):
+        try:
+            self.db = dbm.open(filename, "c")
+        except TypeError:  # pragma: no cover
+            # dbm in PyPy requires filename to be binary
+            self.db = dbm.open(to_binary(filename), "c")
+
+    def get(self, id):
+        """
+        根据 id 获取数据。
+
+        :param id: 要获取的数据的 id
+        :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象
+        """
+        try:
+            session_json = self.db[id]
+        except KeyError:
+            session_json = "{}"
+        return json_loads(session_json)
+
+    def set(self, id, value):
+        """
+        根据 id 写入数据。
+
+        :param id: 要写入的 id
+        :param value: 要写入的数据,可以是一个 ``dict`` 对象
+        """
+        self.db[id] = json_dumps(value)
+
+    def delete(self, id):
+        """
+        根据 id 删除数据。
+
+        :param id: 要删除的数据的 id
+        """
+        del self.db[id]

+ 67 - 0
authen/session/mongodbstorage.py

@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+
+from werobot.session import SessionStorage
+from werobot.utils import json_loads, json_dumps
+
+
+class MongoDBStorage(SessionStorage):
+    """
+    MongoDBStorage 会把你的 Session 数据储存在一个 MongoDB Collection 中 ::
+
+        import pymongo
+        import werobot
+        from werobot.session.mongodbstorage import MongoDBStorage
+
+        collection = pymongo.MongoClient()["wechat"]["session"]
+        session_storage = MongoDBStorage(collection)
+        robot = werobot.WeRoBot(token="token", enable_session=True,
+                                session_storage=session_storage)
+
+
+    你需要安装 ``pymongo`` 才能使用 MongoDBStorage 。
+
+    :param collection: 一个 MongoDB Collection。
+    """
+    def __init__(self, collection):
+        self.collection = collection
+        collection.create_index("wechat_id")
+
+    def _get_document(self, id):
+        return self.collection.find_one({"wechat_id": id})
+
+    def get(self, id):
+        """
+        根据 id 获取数据。
+
+        :param id: 要获取的数据的 id
+        :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象
+        """
+        document = self._get_document(id)
+        if document:
+            session_json = document["session"]
+            return json_loads(session_json)
+        return {}
+
+    def set(self, id, value):
+        """
+        根据 id 写入数据。
+
+        :param id: 要写入的 id
+        :param value: 要写入的数据,可以是一个 ``dict`` 对象
+                """
+        session = json_dumps(value)
+        self.collection.replace_one(
+            {"wechat_id": id}, {
+                "wechat_id": id,
+                "session": session
+            },
+            upsert=True
+        )
+
+    def delete(self, id):
+        """
+        根据 id 删除数据。
+
+        :param id: 要删除的数据的 id
+        """
+        self.collection.delete_one({"wechat_id": id})

+ 96 - 0
authen/session/mysqlstorage.py

@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+
+from werobot.session import SessionStorage
+from werobot.utils import json_loads, json_dumps
+
+__CREATE_TABLE_SQL__ = """
+CREATE TABLE IF NOT EXISTS WeRoBot(
+id VARCHAR(100) NOT NULL ,
+value BLOB NOT NULL,
+PRIMARY KEY (id)
+)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+"""
+
+
+class MySQLStorage(SessionStorage):
+    """
+    MySQLStorage 会把你的 Session 数据储存在 MySQL 中 ::
+
+        import MySQLdb # 使用 mysqlclient
+        import werobot
+        from werobot.session.mysqlstorage import MySQLStorage
+
+        conn = MySQLdb.connect(user='', db='', passwd='', host='')
+        session_storage = MySQLStorage(conn)
+        robot = werobot.WeRoBot(token="token", enable_session=True,
+                                session_storage=session_storage)
+
+    或者 ::
+
+        import pymysql # 使用 pymysql
+        import werobot
+        from werobot.session.mysqlstorage import MySQLStorage
+
+        session_storage = MySQLStorage(
+        conn=pymysql.connect(
+            user='喵',
+            password='喵喵',
+            db='werobot',
+            host='127.0.0.1',
+            charset='utf8'
+        ))
+        robot = werobot.WeRoBot(token="token", enable_session=True,
+                                session_storage=session_storage)
+
+    你需要安装一个 MySQL Client 才能使用 MySQLStorage,比如 ``pymysql``,``mysqlclient`` 。
+
+    理论上符合 `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_ 的库都可以使用,\
+    测试时使用的是 ``pymysql``。
+
+    :param conn: `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_\
+    定义的 Connection 对象
+    """
+    def __init__(self, conn):
+        self.conn = conn
+        self.conn.cursor().execute(__CREATE_TABLE_SQL__)
+
+    def get(self, id):
+        """
+        根据 id 获取数据。
+
+        :param id: 要获取的数据的 id
+        :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象
+        """
+        cur = self.conn.cursor()
+        cur.execute("SELECT value FROM WeRoBot WHERE id=%s LIMIT 1;", (id, ))
+        session_json = cur.fetchone()
+        if session_json is None:
+            return {}
+        return json_loads(session_json[0])
+
+    def set(self, id, value):
+        """
+        根据 id 写入数据。
+
+        :param id: 要写入的 id
+        :param value: 要写入的数据,可以是一个 ``dict`` 对象
+        """
+        value = json_dumps(value)
+        self.conn.cursor().execute(
+            "INSERT INTO WeRoBot (id, value) VALUES (%s,%s) \
+                ON DUPLICATE KEY UPDATE value=%s", (
+                id,
+                value,
+                value,
+            )
+        )
+        self.conn.commit()
+
+    def delete(self, id):
+        """
+        根据 id 删除数据。
+
+        :param id: 要删除的数据的 id
+        """
+        self.conn.cursor().execute("DELETE FROM WeRoBot WHERE id=%s", (id, ))
+        self.conn.commit()

+ 79 - 0
authen/session/postgresqlstorage.py

@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+
+from werobot.session import SessionStorage
+from werobot.utils import json_loads, json_dumps
+
+__CREATE_TABLE_SQL__ = """
+CREATE TABLE IF NOT EXISTS WeRoBot
+(
+id VARCHAR(100) PRIMARY KEY,
+value TEXT NOT NULL
+);
+"""
+
+
+class PostgreSQLStorage(SessionStorage):
+    """
+    PostgreSQLStorage 会把你的 Session 数据储存在 PostgreSQL 中 ::
+
+        import psycopg2  # pip install psycopg2-binary
+        import werobot
+        from werobot.session.postgresqlstorage import PostgreSQLStorage
+
+        conn = psycopg2.connect(host='127.0.0.1', port='5432', dbname='werobot', user='nya', password='nyanya')
+        session_storage = PostgreSQLStorage(conn)
+        robot = werobot.WeRoBot(token="token", enable_session=True,
+                                session_storage=session_storage)
+
+    你需要安装一个 ``PostgreSQL Client`` 才能使用 PostgreSQLStorage,比如 ``psycopg2``。
+
+    理论上符合 `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_ 的库都可以使用,\
+    测试时使用的是 ``psycopg2``。
+
+    :param conn: `PEP-249 <https://www.python.org/dev/peps/pep-0249/#connection-objects>`_\
+    定义的 Connection 对象
+    """
+    def __init__(self, conn):
+        self.conn = conn
+        self.conn.cursor().execute(__CREATE_TABLE_SQL__)
+
+    def get(self, id):
+        """
+        根据 id 获取数据。
+
+        :param id: 要获取的数据的 id
+        :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象
+        """
+        cur = self.conn.cursor()
+        cur.execute("SELECT value FROM WeRoBot WHERE id=%s LIMIT 1;", (id, ))
+        session_json = cur.fetchone()
+        if session_json is None:
+            return {}
+        return json_loads(session_json[0])
+
+    def set(self, id, value):
+        """
+        根据 id 写入数据。
+
+        :param id: 要写入的 id
+        :param value: 要写入的数据,可以是一个 ``dict`` 对象
+        """
+        value = json_dumps(value)
+        self.conn.cursor().execute(
+            "INSERT INTO WeRoBot (id, value) values (%s, %s) ON CONFLICT (id) DO UPDATE SET value = %s;",
+            (
+                id,
+                value,
+                value,
+            )
+        )
+        self.conn.commit()
+
+    def delete(self, id):
+        """
+        根据 id 删除数据。
+
+        :param id: 要删除的数据的 id
+        """
+        self.conn.cursor().execute("DELETE FROM WeRoBot WHERE id=%s", (id, ))
+        self.conn.commit()

+ 63 - 0
authen/session/redisstorage.py

@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+
+from werobot.session import SessionStorage
+from werobot.utils import json_loads, json_dumps
+
+
+class RedisStorage(SessionStorage):
+    """
+    RedisStorage 会把你的 Session 数据储存在 Redis 中 ::
+
+        import redis
+        import werobot
+        from werobot.session.redisstorage import RedisStorage
+
+        db = redis.Redis()
+        session_storage = RedisStorage(db, prefix="my_prefix_")
+        robot = werobot.WeRoBot(token="token", enable_session=True,
+                                session_storage=session_storage)
+
+
+    你需要安装 ``redis`` 才能使用 RedisStorage 。
+
+    :param redis: 一个 Redis Client。
+    :param prefix: Reids 中 Session 数据 key 的 prefix 。默认为 ``ws_``
+    """
+    def __init__(self, redis, prefix='ws_'):
+        for method_name in ['get', 'set', 'delete']:
+            assert hasattr(redis, method_name)
+        self.redis = redis
+        self.prefix = prefix
+
+    def key_name(self, s):
+        return '{prefix}{s}'.format(prefix=self.prefix, s=s)
+
+    def get(self, id):
+        """
+        根据 id 获取数据。
+
+        :param id: 要获取的数据的 id
+        :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象
+        """
+        id = self.key_name(id)
+        session_json = self.redis.get(id) or '{}'
+        return json_loads(session_json)
+
+    def set(self, id, value):
+        """
+        根据 id 写入数据。
+
+        :param id: 要写入的 id
+        :param value: 要写入的数据,可以是一个 ``dict`` 对象
+        """
+        id = self.key_name(id)
+        self.redis.set(id, json_dumps(value))
+
+    def delete(self, id):
+        """
+        根据 id 删除数据。
+
+        :param id: 要删除的数据的 id
+        """
+        id = self.key_name(id)
+        self.redis.delete(id)

+ 56 - 0
authen/session/saekvstorage.py

@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+
+from . import SessionStorage
+
+
+class SaeKVDBStorage(SessionStorage):
+    """
+    SaeKVDBStorage 使用SAE 的 KVDB 来保存你的session ::
+
+        import werobot
+        from werobot.session.saekvstorage import SaeKVDBStorage
+
+        session_storage = SaeKVDBStorage()
+        robot = werobot.WeRoBot(token="token", enable_session=True,
+                                session_storage=session_storage)
+
+    需要先在后台开启 KVDB 支持
+
+    :param prefix: KVDB 中 Session 数据 key 的 prefix 。默认为 ``ws_``
+    """
+    def __init__(self, prefix='ws_'):
+        try:
+            import sae.kvdb
+        except ImportError:
+            raise RuntimeError("SaeKVDBStorage requires SAE environment")
+        self.kv = sae.kvdb.KVClient()  # pragma: no cover
+        self.prefix = prefix  # pragma: no cover
+
+    def key_name(self, s):
+        return '{prefix}{s}'.format(prefix=self.prefix, s=s)
+
+    def get(self, id):
+        """
+        根据 id 获取数据。
+
+        :param id: 要获取的数据的 id
+        :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象
+        """
+        return self.kv.get(self.key_name(id)) or {}
+
+    def set(self, id, value):
+        """
+        根据 id 写入数据。
+
+        :param id: 要写入的 id
+        :param value: 要写入的数据,可以是一个 ``dict`` 对象
+        """
+        return self.kv.set(self.key_name(id), value)
+
+    def delete(self, id):
+        """
+        根据 id 删除数据。
+
+        :param id: 要删除的数据的 id
+        """
+        return self.kv.delete(self.key_name(id))

+ 66 - 0
authen/session/sqlitestorage.py

@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+
+from werobot.session import SessionStorage
+from werobot.utils import json_loads, json_dumps
+import sqlite3
+
+__CREATE_TABLE_SQL__ = """
+CREATE TABLE IF NOT EXISTS WeRoBot
+(id TEXT PRIMARY KEY NOT NULL ,
+value TEXT NOT NULL );
+"""
+
+
+class SQLiteStorage(SessionStorage):
+    """
+    SQLiteStorge 会把 Session 数据储存在一个 SQLite 数据库文件中 ::
+
+        import werobot
+        from werobot.session.sqlitestorage import SQLiteStorage
+
+        session_storage = SQLiteStorage
+        robot = werobot.WeRoBot(token="token", enable_session=True,
+                                session_storage=session_storage)
+
+    :param filename: SQLite数据库的文件名, 默认是 ``werobot_session.sqlite3``
+    """
+    def __init__(self, filename='werobot_session.sqlite3'):
+        self.db = sqlite3.connect(filename, check_same_thread=False)
+        self.db.text_factory = str
+        self.db.execute(__CREATE_TABLE_SQL__)
+
+    def get(self, id):
+        """
+        根据 id 获取数据。
+
+        :param id: 要获取的数据的 id
+        :return: 返回取到的数据,如果是空则返回一个空的 ``dict`` 对象
+        """
+        session_json = self.db.execute(
+            "SELECT value FROM WeRoBot WHERE id=? LIMIT 1;", (id, )
+        ).fetchone()
+        if session_json is None:
+            return {}
+        return json_loads(session_json[0])
+
+    def set(self, id, value):
+        """
+        根据 id 写入数据。
+
+        :param id: 要写入的 id
+        :param value: 要写入的数据,可以是一个 ``dict`` 对象
+        """
+        self.db.execute(
+            "INSERT OR REPLACE INTO WeRoBot (id, value) VALUES (?,?);",
+            (id, json_dumps(value))
+        )
+        self.db.commit()
+
+    def delete(self, id):
+        """
+        根据 id 删除数据。
+
+        :param id: 要删除的数据的 id
+        """
+        self.db.execute("DELETE FROM WeRoBot WHERE id=?;", (id, ))
+        self.db.commit()

+ 12 - 0
authen/testing.py

@@ -0,0 +1,12 @@
+from .parser import parse_user_msg
+
+__all__ = ['WeTest']
+
+
+class WeTest(object):
+    def __init__(self, app):
+        self._app = app
+
+    def send_xml(self, xml):
+        message = parse_user_msg(xml)
+        return self._app.get_reply(message)

+ 152 - 0
authen/utils.py

@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+
+import io
+import json
+import os
+import random
+import re
+import string
+import time
+from functools import wraps
+from hashlib import sha1
+
+try:
+    from secrets import choice
+except ImportError:
+    from random import choice
+
+string_types = (str, bytes)
+
+re_type = type(re.compile("regex_test"))
+
+
+def get_signature(token, timestamp, nonce, *args):
+    sign = [token, timestamp, nonce] + list(args)
+    sign.sort()
+    sign = to_binary(''.join(sign))
+    return sha1(sign).hexdigest()
+
+
+def check_signature(token, timestamp, nonce, signature):
+    if not (token and timestamp and nonce and signature):
+        return False
+    sign = get_signature(token, timestamp, nonce)
+    return sign == signature
+
+
+def check_token(token):
+    return re.match('^[A-Za-z0-9]{3,32}$', token)
+
+
+def cached_property(method):
+    prop_name = '_{}'.format(method.__name__)
+
+    @wraps(method)
+    def wrapped_func(self, *args, **kwargs):
+        if not hasattr(self, prop_name):
+            setattr(self, prop_name, method(self, *args, **kwargs))
+        return getattr(self, prop_name)
+
+    return property(wrapped_func)
+
+
+def to_text(value, encoding="utf-8") -> str:
+    if isinstance(value, str):
+        return value
+    if isinstance(value, bytes):
+        return value.decode(encoding)
+    return str(value)
+
+
+def to_binary(value, encoding="utf-8") -> bytes:
+    if isinstance(value, bytes):
+        return value
+    if isinstance(value, str):
+        return value.encode(encoding)
+    return bytes(value)
+
+
+def is_string(value) -> bool:
+    """Check if value's type is `str` or `bytes`
+    """
+    return isinstance(value, string_types)
+
+
+def byte2int(s, index=0):
+    """Get the ASCII int value of a character in a string.
+
+    :param s: a string
+    :param index: the position of desired character
+
+    :return: ASCII int value
+    """
+    return s[index]
+
+
+def generate_token(length=''):
+    if not length:
+        length = random.randint(3, 32)
+    length = int(length)
+    assert 3 <= length <= 32
+    letters = string.ascii_letters + string.digits
+    return ''.join(choice(letters) for _ in range(length))
+
+
+def json_loads(s):
+    s = to_text(s)
+    return json.loads(s)
+
+
+def json_dumps(d):
+    return json.dumps(d)
+
+
+def pay_sign_dict(
+    appid,
+    pay_sign_key,
+    add_noncestr=True,
+    add_timestamp=True,
+    add_appid=True,
+    **kwargs
+):
+    """
+    支付参数签名
+    """
+    assert pay_sign_key, "PAY SIGN KEY IS EMPTY"
+
+    if add_appid:
+        kwargs.update({'appid': appid})
+
+    if add_noncestr:
+        kwargs.update({'noncestr': generate_token()})
+
+    if add_timestamp:
+        kwargs.update({'timestamp': int(time.time())})
+
+    params = kwargs.items()
+
+    _params = [
+        (k.lower(), v) for k, v in kwargs.items() if k.lower() != "appid"
+    ]
+    _params += [('appid', appid), ('appkey', pay_sign_key)]
+    _params.sort()
+
+    sign = '&'.join(["%s=%s" % (str(p[0]), str(p[1]))
+                     for p in _params]).encode("utf-8")
+    sign = sha1(sign).hexdigest()
+    sign_type = 'SHA1'
+
+    return dict(params), sign, sign_type
+
+
+def make_error_page(url):
+    with io.open(
+        os.path.join(os.path.dirname(__file__), 'contrib/error.html'),
+        'r',
+        encoding='utf-8'
+    ) as error_page:
+        return error_page.read().replace('{url}', url)
+
+
+def is_regex(value):
+    return isinstance(value, re_type)

+ 30 - 0
setup.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+
+from setuptools import setup, find_packages
+
+setup(
+    name='authen.wxmp_utils',
+    version=0.1,
+    url='http://git.trops-global.com/authen/wxmp_utils.git',
+    license='GPL',
+    author='authen',
+    author_email='295002887@qq.com',
+    description='wxmp_utils func',
+    long_description=__doc__,
+    packages=find_packages(exclude=['ez_setup']),
+    namespace_packages=['authen'],
+    include_package_data=True,
+    install_requires=[
+        'setuptools'
+    ],
+    classifiers=[
+        "Framework :: Plone",
+        "Framework :: Zope2",
+        "Framework :: Zope3",
+        "Programming Language :: Python",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+    ],
+    entry_points="""
+    # -*- Entry points: -*-
+    """
+)