├── .gitignore ├── images ├── stream.png ├── wechat.png └── monkey_monitor.jpg ├── easy_wechat ├── test │ ├── __init__.py │ ├── test_utils.py │ └── test_wechat.py ├── __init__.py ├── ierror.py ├── utils.py └── wechat.py ├── config_test.ini ├── config.ini.example ├── app ├── demo.py └── monkey_monitor.py └── Readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | config.ini 3 | *.pyc 4 | -------------------------------------------------------------------------------- /images/stream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FinalTheory/EasyWeChat/HEAD/images/stream.png -------------------------------------------------------------------------------- /images/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FinalTheory/EasyWeChat/HEAD/images/wechat.png -------------------------------------------------------------------------------- /images/monkey_monitor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FinalTheory/EasyWeChat/HEAD/images/monkey_monitor.jpg -------------------------------------------------------------------------------- /easy_wechat/test/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 15:14 7 | # File Name: __init__.py 8 | # Description: 9 | # 10 | # Copyright (c) 2014 Baidu.com, Inc. All Rights Reserved 11 | # 12 | 13 | """ 14 | 单测package初始化文件 15 | """ -------------------------------------------------------------------------------- /config_test.ini: -------------------------------------------------------------------------------- 1 | [demo] 2 | corpid = wx82ef843a5129db66 3 | secret = nqbKGIxUcWNeGk-tR0yLD485CGf_lEHv3BZfhW4HxtfXjNyqXpMC5wakZpnI07Tx 4 | appid = 0 5 | 6 | token = KbPhZCLxBPgQwTzozQxPxtaWSQ1GQb 7 | encoding_aes_key = MAtJ8Wpg3yXnyGjqZLvbEQzCFxuCA3DS5ss9pJRyAGH 8 | 9 | [system] 10 | log_path = 11 | log_name = easy_wechat.log 12 | route_name = weixin 13 | -------------------------------------------------------------------------------- /easy_wechat/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 12:44 7 | # Description: 8 | # 9 | # Copyright (c) 2015 Baidu.com, Inc. All Rights Reserved 10 | # 11 | 12 | """ 13 | package初始化, 导入类 14 | """ 15 | 16 | from easy_wechat.wechat import WeChatServer 17 | from easy_wechat.wechat import WeChatClient 18 | -------------------------------------------------------------------------------- /config.ini.example: -------------------------------------------------------------------------------- 1 | ; 注意: 下述所有参数均可以在微信企业号的账号管理以及APP管理页面中查询 2 | 3 | ; APP名称, 自定义 4 | [demo] 5 | ; 企业号ID 6 | corpid = 7 | ; 企业号秘钥 8 | secret = 9 | ; 企业号应用ID(整数) 10 | appid = 11 | 12 | ; 回调服务token 13 | token = 14 | ; 回调服务加密key 15 | encoding_aes_key = 16 | 17 | [system] 18 | ; 日志存储路径, 留空则默认为系统TMP目录 19 | log_path = '' 20 | ; 日志文件名, 留空则默认为'easy_wechat.log' 21 | log_name = 'easy_wechat.log' 22 | ; 路由路径名, 如下设置表示URL路径为: 'http:\\test.com/weixin' 23 | route_name = weixin 24 | -------------------------------------------------------------------------------- /easy_wechat/ierror.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 12:44 7 | # Description: 8 | # 9 | # Copyright (c) 2015 Baidu.com, Inc. All Rights Reserved 10 | # 11 | 12 | """ 13 | 错误码描述模块 14 | """ 15 | 16 | WXBizMsgCrypt_OK = 0 17 | WXBizMsgCrypt_ValidateSignature_Error = -40001 18 | WXBizMsgCrypt_ParseXml_Error = -40002 19 | WXBizMsgCrypt_ComputeSignature_Error = -40003 20 | WXBizMsgCrypt_IllegalAesKey = -40004 21 | WXBizMsgCrypt_ValidateCorpid_Error = -40005 22 | WXBizMsgCrypt_EncryptAES_Error = -40006 23 | WXBizMsgCrypt_DecryptAES_Error = -40007 24 | WXBizMsgCrypt_IllegalBuffer = -40008 25 | WXBizMsgCrypt_EncodeBase64_Error = -40009 26 | WXBizMsgCrypt_DecodeBase64_Error = -40010 27 | WXBizMsgCrypt_GenReturnXml_Error = -40011 28 | -------------------------------------------------------------------------------- /app/demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 12:44 7 | # Description: 8 | # 9 | # Copyright (c) 2015 Baidu.com, Inc. All Rights Reserved 10 | # 11 | 12 | """ 13 | 入口文件, 用于启动服务端接口 14 | """ 15 | import os 16 | import sys 17 | module_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | sys.path.append(module_dir) 19 | 20 | import easy_wechat 21 | # from gevent.wsgi import WSGIServer 22 | 23 | 24 | class TestClass(object): 25 | """ 26 | 测试类, 无实际含义 27 | """ 28 | def reply_func(self, param_dict): 29 | """ 30 | 一个简答的自动回复函数, 自动给每条消息回复'hello, world' 31 | @param param_dict: 用户消息的参数 32 | @return: 向字典中填充对应回复内容后返回 33 | """ 34 | param_dict['Content'] = 'hello, world' 35 | return param_dict 36 | 37 | 38 | if __name__ == '__main__': 39 | server = easy_wechat.WeChatServer('demo') 40 | server.register_callback('text', TestClass().reply_func) 41 | server.run(host='0.0.0.0', port=6000, debug=False, threaded=False) 42 | # 使用gevent可以提高并发处理能力 43 | # http_server = WSGIServer(('', 8000), server.app) 44 | # http_server.serve_forever() 45 | -------------------------------------------------------------------------------- /easy_wechat/test/test_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 15:11 7 | # File Name: test_xml.py 8 | # Description: 9 | # 10 | # Copyright (c) 2014 Baidu.com, Inc. All Rights Reserved 11 | # 12 | 13 | """ 14 | XML模块单元测试 15 | """ 16 | 17 | import sys 18 | import unittest 19 | import collections 20 | 21 | import easy_wechat.utils as utils 22 | 23 | 24 | class XMLTestCase(unittest.TestCase): 25 | """ 26 | XML模块单元测试类 27 | """ 28 | xml_string = ''' 29 | 30 | 31 | 1348831860 32 | 33 | 34 | 1234567890123456 35 | 128 36 | ''' 37 | 38 | def test_xml2dict(self): 39 | """ 40 | 测试XML转Dict 41 | @return: None 42 | """ 43 | dict_data = utils.xml_to_dict(self.xml_string) 44 | self.assertIsInstance(dict_data, collections.OrderedDict) 45 | self.assertIn('ToUserName', dict_data) 46 | self.assertIn('FromUserName', dict_data) 47 | self.assertIn('CreateTime', dict_data) 48 | self.assertIn('MsgType', dict_data) 49 | self.assertIn('Content', dict_data) 50 | self.assertIn('MsgId', dict_data) 51 | self.assertIn('AgentID', dict_data) 52 | 53 | def test_dict2xml(self): 54 | """ 55 | 测试Dict转XML 56 | @return: None 57 | """ 58 | dict_data = utils.xml_to_dict(self.xml_string) 59 | xml_data = utils.dict_to_xml(dict_data) 60 | self.assertEqual(xml_data, self.xml_string.replace('\n', '').replace(' ', '')) 61 | 62 | def test_wrap_cdata(self): 63 | """ 64 | 测试将Dict中字符串全部包裹上标签的函数 65 | 该函数是为了符合微信接口的调用约定 66 | @return: None 67 | """ 68 | dict_data = { 69 | 'a': 'test', 70 | 'b': 'test1', 71 | 'c': { 72 | 'test': '123' 73 | }, 74 | 'd': { 75 | 'e': { 76 | 'f': { 77 | 'g': { 78 | 'h': 'test2' 79 | } 80 | } 81 | } 82 | } 83 | } 84 | res_data = utils.wrap_cdata(dict_data) 85 | self.assertEqual(res_data['a'], '') 86 | self.assertEqual(res_data['c']['test'], 123) 87 | self.assertEqual(res_data['d']['e']['f']['g']['h'], '') 88 | 89 | def test_dict_trans(self): 90 | """ 91 | 测试用于将OrderedDict转换为普通Dict的函数 92 | @return: None 93 | """ 94 | ordered = collections.OrderedDict() 95 | ordered['a'] = 'test' 96 | ordered['b'] = 'test1' 97 | ordered['c'] = collections.OrderedDict() 98 | ordered['c']['d'] = 'test2' 99 | ordered['c']['e'] = collections.OrderedDict() 100 | ordered['c']['e']['f'] = 'test3' 101 | dict_data = utils.ordered_to_dict(ordered) 102 | self.assertIsInstance(dict_data, dict) 103 | self.assertIsInstance(dict_data['c'], dict) 104 | self.assertIsInstance(dict_data['c']['e'], dict) 105 | 106 | 107 | # test entry 108 | if __name__ == '__main__': 109 | unittest.main() 110 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## EasyWeChat: 超轻量的微信企业号快速开发框架 2 | 3 | ### 简介 4 | 5 | 在某些不涉及传输敏感数据的情况下,为了监控自己的服务器(如VPS、NAS),或者实现一个自动应答的机器人,我们可能需要利用微信的消息服务来实现自动且及时的提醒。微信的“企业号”服务提供了每天最大30*200条的主动消息推送,以及不限次数的消息自动回复,因此十分适合这类需求。然而微信的接口设计非常繁琐,因此本框架使用了非常少的代码来对微信的接口进行封装,使得轻度用户可以只关注所需要实现的逻辑本身,而不用与微信的繁琐接口打交道。 6 | 7 | 本项目的目标,是希望用户在仅仅阅览该页面以及部分微信接口文档的情况下,能够**快速开发**出一个可用的微信企业号服务。 8 | 9 | 10 | ### 特性 11 | 12 | - 支持文本、图片、视频、语音等消息类型的发送与接收 13 | - 极简的开发流程,只需注册一个回复消息的回调函数 14 | - 基于Flask框架,自身代码量非常少,方便按需定制 15 | 16 | 17 | ### 应用 18 | 19 | #### 程序猿搬砖监控器 20 | 21 | 为了方便家人以及妹子查看码农是否在公司搬砖,还是已经回家打PS4,并且避免打扰到程序猿写代码时的思路,故开发这个监控器,其硬件如下: 22 | 23 | 24 | 25 | 实现的效果是,通过发送消息给对应的企业号,可以让摄像头自动拍照并回复该照片,或者录一段视频并回复。 26 | 27 | 28 | 29 | 也可以直接访问某域名(如pi.xxx.com)实时查看摄像头所拍摄到的视频流,但这样由于经过VPS中转,速度可能略卡。 30 | 31 | 32 | 33 | 至此,家人和妹子就不用发消息询问程序猿是否还在加班了。 34 | 35 | 配置方法: 36 | 37 | - 在树莓派上运行EasyWeChat,监听8000端口; 38 | - 使用`ngrok`将树莓派的8000端口映射到远程VPS上的某端口,例如同样为8000端口; 39 | - 增加`nginx`转发规则,将对80端口的特定域名的请求(如pi.xxx.com)转发到8000端口。 40 | 41 | 其中`nginx`转发规则示意如下: 42 | 43 | server { 44 | listen 80; 45 | server_name pi.xxx.com; 46 | location / { 47 | proxy_pass http://pi.xxx.com:8000; 48 | } 49 | } 50 | 51 | 52 | #### 12306余票监控 53 | 54 | Loading…… 55 | 56 | 57 | ### 依赖库 58 | 59 | - `flask` 60 | - `requests` 61 | - `pycrypto` 62 | - `dicttoxml/xmltodict` 63 | - `gevent`(可选) 64 | 65 | 66 | ### 文件布局 67 | 68 | EasyWeChat 69 | ├── Readme.md 项目文档 70 | ├── app 用于放置用户代码的目录 71 | │   ├── demo.py 示例应用:回显服务器(echo server) 72 | │   ├── monkey_monitor.py 示例应用:程序猿监控器 73 | │   └── ticket_watcher.py 示例应用:12306余票监控 74 | ├── config.ini 配置文件(私密) 75 | ├── config.ini.example 示例配置文件(公开) 76 | ├── config_test.ini 单元测试配置文件(可安全公开) 77 | └── easy_wechat package目录 78 | ├── __init__.py package初始化文件 79 | ├── ierror.py 加解密库错误码定义 80 | ├── test 单元测试目录 81 | │   ├── __init__.py 初始化文件 82 | │   ├── test_utils.py utils.py的单元测试 83 | │   └── test_wechat.py wechat.py的单元测试 84 | ├── utils.py 辅助函数(官方加解密库等) 85 | └── wechat.py 主模块文件,包含与微信企业号接口的交互逻辑 86 | 87 | 88 | ### 示例 89 | 90 | 为了实现一个最简单的回显服务器(Echo Server,即返回你所发送的内容),仅仅需要以下几行代码: 91 | 92 | import easy_wechat 93 | 94 | def reply_func(param_dict): 95 | return param_dict 96 | 97 | if __name__ == '__main__': 98 | server = easy_wechat.WeChatServer('demo') 99 | server.register_callback('text', reply_func) 100 | server.run(host='0.0.0.0', port=6000, debug=False) 101 | 102 | 并按照格式填写`config.ini`即可。 103 | 104 | 为了获得持久化的存储,并同时避免使用全局变量,我们也可以直接实例化一个对象,然后将这个对象的成员函数作为回调函数注册给`EasyWeChat`: 105 | 106 | class TestClass(object): 107 | def reply_func(self, param_dict): 108 | param_dict['Content'] = 'hello, world' 109 | return param_dict 110 | 111 | if __name__ == '__main__': 112 | server.register_callback('text', TestClass() 113 | 114 | 这样,在成员函数内部,我们就可以直接访问对象的成员变量,来实现我们所需要的操作了。 115 | 116 | 此外,如果需要获得更好的并发处理能力,建议不要使用Flask自带的Server,因为它主要用来调试的,只是简单的多线程实现。可以直接采用`gevent`并发框架来获得更好的并发性能。示例代码如下: 117 | 118 | from gevent.wsgi import WSGIServer 119 | 120 | http_server = WSGIServer(('', 8000), server.app) 121 | http_server.serve_forever() 122 | 123 | 124 | ### 文档 125 | 126 | #### 主动发送消息 127 | 128 | 首先实例化一个`client`对象: 129 | 130 | client = WeChatClient('demo') 131 | 132 | 其中`demo`是`config.ini`中对应section的名称。 133 | 134 | 然后调用`client`对象的`send_media`方法以发送消息。示例代码如下: 135 | 136 | client.send_media( 137 | 'text', {"content": 'message'}, 'user_name') 138 | 139 | `send_media`函数的各个参数含义请直接参考代码注释。总而言之,这些参数遵循参考链接[1]中的微信企业号接口约定,并去除了冗余部分。 140 | 141 | 142 | #### 回调式响应消息 143 | 144 | 首先实例化一个`server`对象: 145 | 146 | server = MonkeyServer('demo') 147 | 148 | 然后为需要自动回复的消息类型注册回调函数,如下所示表示注册一个回复`text`文本类型消息的回调函数: 149 | 150 | server.register_callback('text', reply_func) 151 | 152 | EasyWeChat会以一个字典作为传入参数来调用回调函数,**字典的内容符合参考链接[2]中的企业号消息回调约定**。开发者所注册的回调函数需要返回一个**字典**,其中内容应该“大致”符合参考链接[3]中的消息返回值约定。 153 | 154 | 最后启动Server接受请求即可,可以选择使用gevent框架或者Flask自带Server。 155 | 156 | 其他细节请参阅源代码以及示例应用。 157 | 158 | 159 | ### 已知限制: 160 | 161 | - 由于Flask框架的内部使用了Python的signal等机制,因此`EasyWeChat`必须运行于主线程。 162 | 163 | 164 | ### 参考资料 165 | 166 | [1] http://qydev.weixin.qq.com/wiki/index.php?title=消息类型及数据格式 167 | [2] http://qydev.weixin.qq.com/wiki/index.php?title=接收普通消息 168 | [3] http://qydev.weixin.qq.com/wiki/index.php?title=被动响应消息 169 | -------------------------------------------------------------------------------- /easy_wechat/test/test_wechat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 19:21 7 | # File Name: test_wechat.py 8 | # Description: 9 | # 10 | # Copyright (c) 2014 Baidu.com, Inc. All Rights Reserved 11 | # 12 | 13 | """ 14 | 微信接口单元测试 15 | """ 16 | 17 | import mock 18 | import json 19 | import unittest 20 | import urllib 21 | 22 | import flask 23 | import werkzeug.exceptions as http_exceptions 24 | 25 | import easy_wechat.utils as utils 26 | import easy_wechat 27 | 28 | 29 | class MockResponse(object): 30 | """ 31 | 模拟的Response对象 32 | """ 33 | def __init__(self, json_data, status_code): 34 | """ 35 | 构造函数 36 | @param json_data: 返回的json数据 37 | @param status_code: 返回的状态码 38 | @return: 构造完成的Response对象 39 | """ 40 | self.json_data = json_data 41 | self.status_code = status_code 42 | 43 | @property 44 | def content(self): 45 | """ 46 | 返回请求内容 47 | @return: 请求内容 48 | """ 49 | return self.json_data 50 | 51 | 52 | def mocked_requests_get(*args, **kwargs): 53 | """ 54 | Mock Request库中的get操作 55 | @param args: 参数列表 56 | @param kwargs: 参数字典 57 | @return: 模拟的Response对象 58 | """ 59 | return MockResponse(json.dumps({'access_token': 'fuck'}), 200) 60 | 61 | 62 | def mocked_requests_post(*args, **kwargs): 63 | """ 64 | Mock Request库中的post操作 65 | @param args: 参数列表 66 | @param kwargs: 参数字典 67 | @return: 模拟的Response对象 68 | """ 69 | data = json.loads(args[1]) 70 | if data['touser'] == 'FinalTheory': 71 | return MockResponse(json.dumps({'errcode': 0, 72 | 'errmsg': 'ok'}), 200) 73 | else: 74 | return MockResponse(json.dumps({'errcode': 1, 75 | 'errmsg': 'error'}), 400) 76 | 77 | 78 | class TestWeChat(unittest.TestCase): 79 | """ 80 | 单测入口类 81 | """ 82 | def test_send_test(self): 83 | """ 84 | 测试能否正常发送消息 85 | @return: None 86 | """ 87 | with mock.patch('requests.get', side_effect=mocked_requests_get): 88 | with mock.patch('requests.post', side_effect=mocked_requests_post): 89 | client = easy_wechat.WeChatClient('demo', 'config_test.ini') 90 | res_dict = client.send_media('text', {'content': 'hello, world'}, 'FinalTheory') 91 | self.assertEqual(res_dict['errcode'], 0) 92 | self.assertNotEqual( 93 | client.send_media('text', {'content': 'hello, world'}, 94 | '645645')['errcode'], 0) 95 | 96 | def test_callback(self): 97 | """ 98 | 测试回调是否正常工作 99 | @return: None 100 | """ 101 | with mock.patch('flask.request') as mock_request: 102 | type(mock_request).method = mock.PropertyMock(return_value='FUCK') 103 | server = easy_wechat.WeChatServer('demo', 'config_test.ini') 104 | self.assertRaises(http_exceptions.MethodNotAllowed, 105 | server.callback) 106 | 107 | def test_receive_msg_err(self): 108 | """ 109 | 测试接收消息时参数错误是否抛出异常 110 | @return: None 111 | """ 112 | param_dict = { 113 | 'msg_signature': '', 114 | 'timestamp': '', 115 | 'nonce': '', 116 | } 117 | with mock.patch('flask.request') as mock_request: 118 | type(mock_request).method = mock.PropertyMock(return_value='POST') 119 | type(mock_request).args = mock.PropertyMock(return_value=param_dict) 120 | type(mock_request).data = mock.PropertyMock(return_value='') 121 | server = easy_wechat.WeChatServer('demo', 'config_test.ini') 122 | self.assertRaises(http_exceptions.BadRequest, server.callback) 123 | 124 | def test_receive_msg_OK(self): 125 | """ 126 | 测试接收正确消息时是否能够返回Response对象 127 | @return: None 128 | """ 129 | recv_data = ('' 130 | '') 141 | param_dict = { 142 | 'msg_signature': '3483710b6ece7efff2dfcebb8aa258347eea6313', 143 | 'timestamp': '1450092658', 144 | 'nonce': '2095700682', 145 | } 146 | 147 | def reply_func(param): 148 | """ 149 | 自动回复函数 150 | @param param: 输入参数 151 | @return: 返回参数 152 | """ 153 | param['Content'] = 'hello, world' 154 | return param 155 | with mock.patch('flask.request') as mock_request: 156 | # mock properties 157 | type(mock_request).method = mock.PropertyMock(return_value='POST') 158 | type(mock_request).args = mock.PropertyMock(return_value=param_dict) 159 | type(mock_request).data = mock.PropertyMock(return_value=recv_data) 160 | # load the server and do tests 161 | server = easy_wechat.WeChatServer('demo', 'config_test.ini') 162 | server.register_callback('text', reply_func) 163 | self.assertIsInstance(server.callback(), flask.Response) 164 | 165 | def test_verify_err(self): 166 | """ 167 | 测试微信接口验证功能是否能够正确报错 168 | @return: None 169 | """ 170 | param_dict = { 171 | 'msg_signature': '', 172 | 'timestamp': 0, 173 | 'nonce': '', 174 | 'echostr': '123', 175 | } 176 | with mock.patch('flask.request') as mock_request: 177 | type(mock_request).method = mock.PropertyMock(return_value='GET') 178 | type(mock_request).args = mock.PropertyMock(return_value=param_dict) 179 | type(mock_request).data = mock.PropertyMock(return_value='') 180 | server = easy_wechat.WeChatServer('demo', 'config_test.ini') 181 | self.assertRaises(http_exceptions.Forbidden, server.callback) 182 | 183 | def test_verify_OK(self): 184 | """ 185 | 测试是否能够正确完成接口验证 186 | @return: None 187 | """ 188 | echo_str = ('JJbZng5xDFUaE4UhtGVct3ksIzuIeh2Hik4Jf%2BHtuJPppSyv7' 189 | 'gaNY%2FplvblHZRe1JVMv3XGj9fST8ppx62lArQ%3D%3D') 190 | param_dict = { 191 | 'msg_signature': 'fa5e779b1eeb709358f9742db4704ff932754a45', 192 | 'timestamp': '1450093931', 193 | 'nonce': '112775', 194 | 'echostr': urllib.unquote(echo_str), 195 | } 196 | with mock.patch('flask.request') as mock_request: 197 | type(mock_request).method = mock.PropertyMock(return_value='GET') 198 | type(mock_request).args = mock.PropertyMock(return_value=param_dict) 199 | type(mock_request).data = mock.PropertyMock(return_value='') 200 | config = utils.get_config('config_test.ini') 201 | token = '8wdYqOgJWQlFRE13FaBAUOU2FxXVtGr' 202 | aes_key = config.get('demo', 'encoding_aes_key') 203 | corp_id = config.get('demo', 'corpid') 204 | 205 | server = easy_wechat.WeChatServer('demo', 'config_test.ini') 206 | server.wxcpt = utils.WXBizMsgCrypt(token, aes_key, corp_id) 207 | self.assertIsInstance(server.callback(), str) 208 | 209 | 210 | if __name__ == '__main__': 211 | unittest.main() 212 | -------------------------------------------------------------------------------- /app/monkey_monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/30 22:31 7 | # File Name: monkey_monitor.py.py 8 | # Description: 9 | # 10 | # Copyright (c) 2015 Baidu.com, Inc. All Rights Reserved 11 | # 12 | 13 | import os 14 | import sys 15 | import time 16 | import io 17 | 18 | module_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | sys.path.append(module_dir) 20 | import Queue 21 | import threading 22 | import tempfile 23 | import subprocess 24 | import datetime 25 | 26 | import gevent 27 | import picamera 28 | from flask import Response 29 | from flask import request 30 | from gevent.wsgi import WSGIServer 31 | 32 | from easy_wechat.wechat import WeChatServer 33 | from easy_wechat.wechat import WeChatClient 34 | 35 | 36 | class Camera(object): 37 | thread = None # background thread that reads frames from camera 38 | frame = None # current frame is stored here by background thread 39 | last_access = 0 # time of last client access to the camera 40 | 41 | def initialize(self): 42 | if Camera.thread is None: 43 | # start background frame thread 44 | Camera.thread = threading.Thread(target=self._thread) 45 | Camera.thread.start() 46 | 47 | # wait until frames start to be available 48 | while self.frame is None: 49 | time.sleep(0) 50 | 51 | def get_frame(self): 52 | Camera.last_access = time.time() 53 | self.initialize() 54 | return self.frame 55 | 56 | @classmethod 57 | def _thread(cls): 58 | with picamera.PiCamera() as camera: 59 | # camera setup 60 | camera.resolution = (320, 240) 61 | camera.hflip = False 62 | camera.vflip = False 63 | 64 | # let camera warm up 65 | camera.start_preview() 66 | time.sleep(2) 67 | 68 | stream = io.BytesIO() 69 | for foo in camera.capture_continuous(stream, 'jpeg', 70 | use_video_port=True): 71 | # store frame 72 | stream.seek(0) 73 | cls.frame = stream.read() 74 | 75 | # reset stream for next frame 76 | stream.seek(0) 77 | stream.truncate() 78 | 79 | # if there hasn't been any clients asking for frames in 80 | # the last 5 seconds stop the thread 81 | if time.time() - cls.last_access > 5: 82 | break 83 | 84 | # sleep for a while to reduce CPU usage 85 | gevent.sleep(0.1) 86 | cls.thread = None 87 | 88 | 89 | class MonkeyServer(WeChatServer): 90 | def __init__(self, appname, ini_name=None): 91 | super(MonkeyServer, self).__init__(appname, ini_name) 92 | self.app.add_url_rule('/', None, self.index, 93 | methods=['POST', 'GET']) 94 | self.app.add_url_rule('/video_feed', None, 95 | self.video_feed, methods=['GET']) 96 | 97 | def video_feed(self): 98 | def gen(camera): 99 | """Video streaming generator function.""" 100 | while True: 101 | try: 102 | gevent.sleep(0.1) 103 | frame = camera.get_frame() 104 | yield (b'--frame\r\n' 105 | b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') 106 | except GeneratorExit: 107 | break 108 | 109 | return Response(gen(Camera()), 110 | mimetype='multipart/x-mixed-replace; boundary=frame') 111 | 112 | def index(self): 113 | filename = '/tmp/hello.txt' 114 | index_str = """ 115 | 116 | Code Monkey Monitor 117 |

Real Time Video Streaming

118 | 119 |

Say Hello To Me

120 |
121 | Your name: 122 | 123 | 124 |
125 | 126 | See temperature data from Yeelink 127 | 128 | """ 129 | 130 | def write_file(name): 131 | """ 132 | 这个函数没神马用, 只是为了好玩 133 | 用来在树莓派LCD屏幕上显示一行字 134 | @param name: 135 | @return: 136 | """ 137 | if not name: 138 | name = 'null' 139 | hello_str = 'Hi,I\'m ' + name 140 | if len(hello_str) > 13: 141 | hello_str = name[0:min(12, len(name))] + '~' 142 | with open(filename, 'w') as fid: 143 | fid.write(hello_str) 144 | return 'Nice to meet you, ' + name + '~' 145 | 146 | if request.method == 'GET': 147 | return index_str 148 | else: 149 | return write_file(request.form.get('name', '')) 150 | 151 | 152 | class CameraError(Exception): 153 | pass 154 | 155 | 156 | class MonkeyMonitor(object): 157 | class Worker(threading.Thread): 158 | def __init__(self, parent): 159 | super(MonkeyMonitor.Worker, self).__init__() 160 | self.parent = parent 161 | 162 | def run(self): 163 | client = self.parent.client 164 | while True: 165 | task = self.parent.queue.get() 166 | if task is None: 167 | break 168 | try: 169 | if task[0] == 'image': 170 | img_path = self.parent.get_image() 171 | res_dict = client.upload_media('image', img_path) 172 | if 'media_id' in res_dict: 173 | ret = client.send_media( 174 | 'image', {'media_id': res_dict['media_id']}, task[1]) 175 | if ret['errcode'] != 0: 176 | raise RuntimeError(ret['errmsg']) 177 | os.remove(img_path) 178 | else: 179 | mp4_path = self.parent.get_video() 180 | res_dict = client.upload_media('video', mp4_path) 181 | if 'media_id' in res_dict: 182 | ret = client.send_media( 183 | 'video', { 184 | 'media_id': res_dict['media_id'], 185 | "title": datetime.datetime.now().ctime(), 186 | "description": u"如果提示系统繁忙, 请等待一分钟后再试, " 187 | u"微信服务器需要耗费时间对视频进行转码.", 188 | }, task[1]) 189 | if ret['errcode'] != 0: 190 | raise RuntimeError(ret['errmsg']) 191 | os.remove(mp4_path) 192 | except Exception as e: 193 | try: 194 | client.send_media( 195 | 'text', { 196 | "content": e.message 197 | }, task[1]) 198 | except Exception as e: 199 | sys.stderr.write('Exception occurred: %s\n' % e.message) 200 | 201 | def __init__(self): 202 | self.client = WeChatClient('monitor') 203 | self.queue = Queue.Queue(maxsize=100) 204 | self.worker = self.Worker(self) 205 | self.worker.start() 206 | 207 | def get_image(self): 208 | file_path = tempfile.mktemp('.jpg') 209 | ret_code = subprocess.call(['raspistill', '-t', '1500', 210 | '-w', '1280', '-h', '920', '-o', file_path]) 211 | if ret_code != 0: 212 | raise CameraError(u"raspistill命令失败, 可能是由于摄像头被占用") 213 | 214 | return file_path 215 | 216 | def get_video(self): 217 | file_path = tempfile.mktemp('.h264') 218 | ret_code = subprocess.call(['raspivid', '-t', '10000', 219 | '-w', '640', '-h', '480', '-o', file_path]) 220 | if ret_code != 0: 221 | raise CameraError(u"raspivid命令失败, 可能是由于摄像头被占用") 222 | 223 | mp4_path = tempfile.mktemp('.mp4') 224 | ret_code = subprocess.call(['MP4Box', '-add', file_path, mp4_path]) 225 | if ret_code != 0: 226 | raise RuntimeError(u"MP4Box转码失败, 请检查环境配置") 227 | os.remove(file_path) 228 | return mp4_path 229 | 230 | def reply_func(self, param_dict): 231 | msg = param_dict['Content'] 232 | if u'拍照' in msg: 233 | param_dict['Content'] = u'请耐心等待拍照' 234 | self.queue.put(['image', param_dict['FromUserName']]) 235 | elif u'视频' in msg: 236 | param_dict['Content'] = u'请耐心等待视频录制, 若想查看实时视频流(可能较卡), ' \ 237 | u'请访问: http://pi.finaltheory.me' 238 | self.queue.put(['video', param_dict['FromUserName']]) 239 | else: 240 | param_dict['Content'] = u'无法理解您的要求' 241 | return param_dict 242 | 243 | 244 | if __name__ == '__main__': 245 | monitor = MonkeyMonitor() 246 | # 实例化自动回复类 247 | server = MonkeyServer('monitor') 248 | # 注册回调函数 249 | server.register_callback('text', monitor.reply_func) 250 | # 使用gevent启动服务器实例 251 | http_server = WSGIServer(('', 8000), server.app) 252 | try: 253 | http_server.serve_forever() 254 | except KeyboardInterrupt as e: 255 | sys.stderr.write(e.message + '\n') 256 | monitor.queue.put(None) 257 | -------------------------------------------------------------------------------- /easy_wechat/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 12:44 7 | # Description: 8 | # 9 | # Copyright (c) 2015 Baidu.com, Inc. All Rights Reserved 10 | # 11 | 12 | """ 13 | 微信企业号基础加解密工具 14 | """ 15 | 16 | import os 17 | import sys 18 | import socket 19 | import base64 20 | import string 21 | import random 22 | import hashlib 23 | import time 24 | import struct 25 | import logging 26 | import collections 27 | 28 | import dicttoxml 29 | import xmltodict 30 | import ConfigParser 31 | import Crypto.Cipher.AES as AES 32 | import xml.etree.cElementTree as ElementTree 33 | 34 | import easy_wechat.ierror as ierror 35 | 36 | 37 | def ordered_to_dict(layer): 38 | """ 39 | 将OrderedDict数据类型转换为普通Dict类型 40 | @param layer: OrderedDict对象 41 | @return: 转换过后的Dict对象 42 | """ 43 | to_ret = layer 44 | if isinstance(layer, collections.OrderedDict): 45 | to_ret = dict(layer) 46 | 47 | try: 48 | for key, value in to_ret.items(): 49 | to_ret[key] = ordered_to_dict(value) 50 | except AttributeError: 51 | pass 52 | 53 | return to_ret 54 | 55 | 56 | def wrap_cdata(dict_data): 57 | """ 58 | 使用包裹字典对象中的所有字符串 59 | @param dict_data: 字典对象 60 | @return: 修改后的字典对象 61 | """ 62 | for key, val in dict_data.items(): 63 | if val: 64 | try: 65 | dict_data[key] = int(val) 66 | val = dict_data[key] 67 | except ValueError: 68 | pass 69 | except TypeError: 70 | pass 71 | if isinstance(val, dict): 72 | dict_data[key] = wrap_cdata(val) 73 | elif isinstance(val, str) or isinstance(val, unicode): 74 | dict_data[key] = '' % val 75 | return dict_data 76 | 77 | 78 | def dict_to_xml(dict_data): 79 | """ 80 | 将字典对象转换为XML字符串 81 | @param dict_data: OrderedDict对象 82 | @return: XML字符串 83 | """ 84 | # this fucking module would escape '<' and '>' 85 | # so we should replace it back 86 | return ('%s' % dicttoxml.dicttoxml(wrap_cdata(dict_data), root=False, 87 | custom_root='xml', attr_type=False, ids=False)) \ 88 | .replace('<', '<').replace('>', '>') 89 | 90 | 91 | def xml_to_dict(xml_string): 92 | """ 93 | 将XML字符串转换为字典对象 94 | @param xml_string: XML字符串 95 | @return: OrderedDict对象 96 | """ 97 | return xmltodict.parse(xml_string)['xml'] 98 | 99 | 100 | def get_config(ini_name="config.ini"): 101 | """ 102 | 根据命令行参数, 自动查找配置文件路径并返回ConfigParser对象 103 | @param ini_name: 配置文件默认名称 104 | @return: ConfigParser对象 105 | """ 106 | config = ConfigParser.ConfigParser() 107 | dirname = os.path.dirname(os.path.abspath(sys.argv[0])) 108 | file_name = os.path.join(dirname, ini_name) 109 | if not os.path.exists(file_name): 110 | dirname = os.path.dirname(os.path.abspath(__file__)) 111 | dirname, unused = os.path.split(dirname) 112 | file_name = os.path.join(dirname, ini_name) 113 | config.read(file_name) 114 | return config 115 | 116 | 117 | class FormatException(Exception): 118 | """ 119 | 格式错误异常类 120 | """ 121 | pass 122 | 123 | 124 | def throw_exception(message, exception_class=None): 125 | """ 126 | 自定义的异常抛出工具函数 127 | @param message: 异常信息 128 | @param exception_class: 异常类 129 | """ 130 | if not exception_class: 131 | exception_class = FormatException 132 | raise exception_class(message) 133 | 134 | 135 | class SHA1(object): 136 | """计算公众平台的消息签名接口""" 137 | logger = logging.getLogger('easy_wechat') 138 | 139 | def getSHA1(self, token, timestamp, nonce, encrypt): 140 | """ 141 | 用SHA1算法生成安全签名 142 | @param token: 票据 143 | @param timestamp: 时间戳 144 | @param encrypt: 密文 145 | @param nonce: 随机字符串 146 | @return: 安全签名 147 | """ 148 | try: 149 | sortlist = [token, timestamp, nonce, encrypt] 150 | sortlist.sort() 151 | sha = hashlib.sha1() 152 | sha.update("".join(sortlist)) 153 | return ierror.WXBizMsgCrypt_OK, sha.hexdigest() 154 | except Exception as e: 155 | self.logger.error('SHA1 failed with exception: %s' % e.message) 156 | return ierror.WXBizMsgCrypt_ComputeSignature_Error, None 157 | 158 | 159 | class XMLParse(object): 160 | """提供提取消息格式中的密文及生成回复消息格式的接口""" 161 | 162 | logger = logging.getLogger('easy_wechat') 163 | 164 | # xml消息模板 165 | AES_TEXT_RESPONSE_TEMPLATE = """ 166 | 167 | 168 | %(timestamp)s 169 | 170 | """ 171 | 172 | def extract(self, xmltext): 173 | """ 174 | 提取出xml数据包中的加密消息 175 | @param xmltext: 待提取的xml字符串 176 | @return: 提取出的加密消息字符串 177 | """ 178 | try: 179 | xml_tree = ElementTree.fromstring(xmltext) 180 | encrypt = xml_tree.find("Encrypt") 181 | touser_name = xml_tree.find("ToUserName") 182 | return ierror.WXBizMsgCrypt_OK, encrypt.text, touser_name.text 183 | except Exception as e: 184 | self.logger.error('XMLParse extract failed with exception: %s' % e.message) 185 | return ierror.WXBizMsgCrypt_ParseXml_Error, None, None 186 | 187 | def generate(self, encrypt, signature, timestamp, nonce): 188 | """生成xml消息 189 | @param encrypt: 加密后的消息密文 190 | @param signature: 安全签名 191 | @param timestamp: 时间戳 192 | @param nonce: 随机字符串 193 | @return: 生成的xml字符串 194 | """ 195 | resp_dict = { 196 | 'msg_encrypt': encrypt, 197 | 'msg_signaturet': signature, 198 | 'timestamp': timestamp, 199 | 'nonce': nonce, 200 | } 201 | resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict 202 | return resp_xml 203 | 204 | 205 | class PKCS7Encoder(object): 206 | """提供基于PKCS7算法的加解密接口""" 207 | 208 | block_size = 32 209 | 210 | def encode(self, text): 211 | """ 对需要加密的明文进行填充补位 212 | @param text: 需要进行填充补位操作的明文 213 | @return: 补齐明文字符串 214 | """ 215 | text_length = len(text) 216 | # 计算需要填充的位数 217 | amount_to_pad = self.block_size - (text_length % self.block_size) 218 | if amount_to_pad == 0: 219 | amount_to_pad = self.block_size 220 | # 获得补位所用的字符 221 | pad = chr(amount_to_pad) 222 | return text + pad * amount_to_pad 223 | 224 | def decode(self, decrypted): 225 | """删除解密后明文的补位字符 226 | @param decrypted: 解密后的明文 227 | @return: 删除补位字符后的明文 228 | """ 229 | pad = ord(decrypted[-1]) 230 | if pad < 1 or pad > 32: 231 | pad = 0 232 | return decrypted[:-pad] 233 | 234 | 235 | class Prpcrypt(object): 236 | """提供接收和推送给公众平台消息的加解密接口""" 237 | 238 | logger = logging.getLogger('easy_wechat') 239 | 240 | def __init__(self, key): 241 | """ 242 | 构造函数 243 | @param key: AES秘钥 244 | @return: 245 | """ 246 | # self.key = base64.b64decode(key+"=") 247 | self.key = key 248 | # 设置加解密模式为AES的CBC模式 249 | self.mode = AES.MODE_CBC 250 | 251 | def encrypt(self, text, corpid): 252 | """对明文进行加密 253 | @param text: 需要加密的明文 254 | @param corpid: 企业号ID 255 | @return: 加密得到的字符串 256 | """ 257 | # 16位随机字符串添加到明文开头 258 | text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + corpid 259 | # 使用自定义的填充方式对明文进行补位填充 260 | pkcs7 = PKCS7Encoder() 261 | text = pkcs7.encode(text) 262 | # 加密 263 | cryptor = AES.new(self.key, self.mode, self.key[:16]) 264 | try: 265 | ciphertext = cryptor.encrypt(text) 266 | # 使用BASE64对加密后的字符串进行编码 267 | return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext) 268 | except Exception as e: 269 | self.logger.error('encrypt failed with exception: %s' % e.message) 270 | return ierror.WXBizMsgCrypt_EncryptAES_Error, None 271 | 272 | def decrypt(self, text, corpid): 273 | """对解密后的明文进行补位删除 274 | @param text: 密文 275 | @param corpid: 企业号ID 276 | @return: 删除填充补位后的明文 277 | """ 278 | try: 279 | cryptor = AES.new(self.key, self.mode, self.key[:16]) 280 | # 使用BASE64对密文进行解码,然后AES-CBC解密 281 | plain_text = cryptor.decrypt(base64.b64decode(text)) 282 | except Exception as e: 283 | self.logger.error('base64 decrypt failed with exception: %s' % e.message) 284 | return ierror.WXBizMsgCrypt_DecryptAES_Error, None 285 | try: 286 | pad = ord(plain_text[-1]) 287 | # 去掉补位字符串 288 | # pkcs7 = PKCS7Encoder() 289 | # plain_text = pkcs7.encode(plain_text) 290 | # 去除16位随机字符串 291 | content = plain_text[16:-pad] 292 | xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0]) 293 | xml_content = content[4: xml_len + 4] 294 | from_corpid = content[xml_len + 4:] 295 | except Exception as e: 296 | self.logger.error('data unpack failed with exception: %s' % e.message) 297 | return ierror.WXBizMsgCrypt_IllegalBuffer, None 298 | if from_corpid != corpid: 299 | return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None 300 | return 0, xml_content 301 | 302 | def get_random_str(self): 303 | """ 随机生成16位字符串 304 | @return: 16位字符串 305 | """ 306 | rule = string.letters + string.digits 307 | str_data = random.sample(rule, 16) 308 | return "".join(str_data) 309 | 310 | 311 | class WXBizMsgCrypt(object): 312 | """ 313 | 基本加解密类 314 | """ 315 | 316 | logger = logging.getLogger('easy_wechat') 317 | 318 | def __init__(self, sToken, sEncodingAESKey, sCorpId): 319 | """ 320 | 构造函数 321 | @param sToken: 公众平台上,开发者设置的Token 322 | @param sEncodingAESKey: 公众平台上,开发者设置的EncodingAESKey 323 | @param sCorpId: 企业号的CorpId 324 | @return: 325 | """ 326 | try: 327 | self.key = base64.b64decode(sEncodingAESKey + "=") 328 | assert len(self.key) == 32 329 | except Exception as e: 330 | self.logger.error('base64 decode failed with exception: %s' % e.message) 331 | throw_exception("[error]: EncodingAESKey invalid !", FormatException) 332 | self.m_sToken = sToken 333 | self.m_sCorpid = sCorpId 334 | 335 | def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): 336 | """ 337 | 验证URL 338 | @param sMsgSignature: 签名串,对应URL参数的msg_signature 339 | @param sTimeStamp: 时间戳,对应URL参数的timestamp 340 | @param sNonce: 随机串,对应URL参数的nonce 341 | @param sEchoStr: 随机串,对应URL参数的echostr 342 | @return sReplyEchoStr: 解密之后的echostr,当return返回0时有效 343 | @return:成功0,失败返回对应的错误码 344 | """ 345 | sha1 = SHA1() 346 | ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) 347 | if ret != 0: 348 | return ret, None 349 | if not signature == sMsgSignature: 350 | return ierror.WXBizMsgCrypt_ValidateSignature_Error, None 351 | pc = Prpcrypt(self.key) 352 | ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sCorpid) 353 | return ret, sReplyEchoStr 354 | 355 | def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): 356 | """ 357 | 将公众号回复用户的消息加密打包 358 | @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串 359 | @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 360 | @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce 361 | 362 | @return sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串 363 | @return 成功0,sEncryptMsg, 失败返回对应的错误码None 364 | """ 365 | pc = Prpcrypt(self.key) 366 | ret, encrypt = pc.encrypt(sReplyMsg, self.m_sCorpid) 367 | if ret != 0: 368 | return ret, None 369 | if timestamp is None: 370 | timestamp = str(int(time.time())) 371 | # 生成安全签名 372 | sha1 = SHA1() 373 | ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) 374 | if ret != 0: 375 | return ret, None 376 | xmlParse = XMLParse() 377 | return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) 378 | 379 | def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): 380 | """ 381 | 检验消息的真实性,并且获取解密后的明文 382 | @param sMsgSignature: 签名串,对应URL参数的msg_signature 383 | @param sTimeStamp: 时间戳,对应URL参数的timestamp 384 | @param sNonce: 随机串,对应URL参数的nonce 385 | @param sPostData: 密文,对应POST请求的数据 386 | @return xml_content: 解密后的原文,当return返回0时有效 387 | @return: 成功0,失败返回对应的错误码 388 | """ 389 | # 验证安全签名 390 | xml_parse = XMLParse() 391 | ret, encrypt, touser_name = xml_parse.extract(sPostData) 392 | if ret != 0: 393 | return ret, None 394 | sha1 = SHA1() 395 | ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) 396 | if ret != 0: 397 | return ret, None 398 | if not signature == sMsgSignature: 399 | return ierror.WXBizMsgCrypt_ValidateSignature_Error, None 400 | pc = Prpcrypt(self.key) 401 | ret, xml_content = pc.decrypt(encrypt, self.m_sCorpid) 402 | return ret, xml_content 403 | -------------------------------------------------------------------------------- /easy_wechat/wechat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # 5 | # Author: 黄龑(huangyan13@baidu.com) 6 | # Created Time: 2015/12/11 12:44 7 | # Description: 8 | # 9 | # Copyright (c) 2015 Baidu.com, Inc. All Rights Reserved 10 | # 11 | 12 | """ 13 | 微信企业号消息接口封装模块 14 | 封装了多媒体消息接口以及普通文本消息接口 15 | """ 16 | 17 | import os 18 | import sys 19 | import time 20 | import copy 21 | import json 22 | import logging 23 | import tempfile 24 | import mimetypes 25 | 26 | import flask 27 | import requests 28 | 29 | import easy_wechat.utils as utils 30 | 31 | WEIXIN_URL = 'https://qyapi.weixin.qq.com' 32 | 33 | 34 | class WeChatBase(object): 35 | """ 36 | 微信消息发送与接收类的公共基类 37 | 负责进行日志的初始化工作等 38 | """ 39 | logger_ok = False 40 | 41 | def __init__(self, appname, ini_name): 42 | """ 43 | 基类构造函数 44 | @param appname: 应用名称 45 | @param ini_name: 配置文件路径 46 | @return: 47 | """ 48 | self.appname = appname 49 | if ini_name: 50 | self.config = utils.get_config(ini_name) 51 | else: 52 | self.config = utils.get_config() 53 | self.logger = logging.getLogger('easy_wechat') 54 | self.init_logger() 55 | 56 | def init_logger(self): 57 | """ 58 | 初始化日志模块相关参数 59 | @return: None 60 | """ 61 | if WeChatBase.logger_ok: 62 | return 63 | self.logger.setLevel(logging.INFO) 64 | log_path = self.config.get('system', 'log_path') 65 | log_name = self.config.get('system', 'log_name') 66 | 67 | if not (os.path.exists(log_path) and os.path.isdir(log_path)): 68 | log_path = tempfile.gettempdir() 69 | 70 | if not log_name: 71 | log_name = 'easy_wechat.log' 72 | 73 | # 日志格式化 74 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 75 | 76 | # 记录日志到文件 77 | log_handler = logging.FileHandler(os.path.join(log_path, log_name)) 78 | log_handler.setFormatter(formatter) 79 | self.logger.addHandler(log_handler) 80 | 81 | # 记录日志到stderr 82 | stream_handler = logging.StreamHandler(sys.stderr) 83 | stream_handler.setFormatter(formatter) 84 | self.logger.addHandler(stream_handler) 85 | 86 | # 注意日志模块只需要被初始化一次 87 | # 所以要在这里进行标记 88 | WeChatBase.logger_ok = True 89 | 90 | 91 | class WeChatClient(WeChatBase): 92 | """ 93 | 消息发送类 94 | """ 95 | 96 | def __init__(self, appname, ini_name=None): 97 | """ 98 | 构造函数 99 | @param appname: 应用名称, 需要与配置文件中section对应 100 | @return: WeChatClient对象实例 101 | """ 102 | super(WeChatClient, self).__init__(appname, ini_name) 103 | self.CorpID = self.config.get(self.appname, 'corpid') 104 | self.Secret = self.config.get(self.appname, 'secret') 105 | self.AppID = self.config.get(self.appname, 'appid') 106 | 107 | @staticmethod 108 | def url_request(url, get=True, data=''): 109 | """ 110 | 请求指定的URL 111 | @param url: 字符串 112 | @param get: 是否使用GET方法 113 | @param data: 附加数据(POST) 114 | @return: 转换为dict的请求结果 115 | """ 116 | try: 117 | if get: 118 | req = requests.get(url) 119 | else: 120 | req = requests.post(url, data) 121 | except Exception as e: 122 | # wrap exception message with more detail 123 | raise type(e)('unable to retrieve URL: %s with exception: %s', (url, e.message)) 124 | else: 125 | try: 126 | res_dict = json.loads(req.content) 127 | except Exception as e: 128 | raise type(e)('invalid json content: %s with exception: %s', 129 | (req.content, e.message)) 130 | return res_dict 131 | 132 | def get_token(self): 133 | """ 134 | 获取发送消息时的验证token 135 | @return: token字符串 136 | """ 137 | token_url = '%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s' \ 138 | % (WEIXIN_URL, self.CorpID, self.Secret) 139 | res = self.url_request(token_url) 140 | return res['access_token'] 141 | 142 | def upload_media(self, file_type, file_path): 143 | """ 144 | 上传临时媒体素材到微信服务器 145 | @param file_type: 文件类型 146 | @param file_path: 文件绝对路径 147 | @return: 服务器返回值dict 148 | """ 149 | 150 | def make_err_return(err_msg): 151 | """ 152 | 一个简单的内部函数, 将错误信息包装并返回 153 | @param err_msg: 错误信息 154 | @return: 155 | """ 156 | return { 157 | 'errcode': -1, 158 | 'errmsg': err_msg, 159 | } 160 | 161 | if file_type not in ('image', 'voice', 'video', 'file'): 162 | errmsg = 'Invalid media/message format' 163 | self.logger.error(errmsg) 164 | return make_err_return(errmsg) 165 | try: 166 | post_url = '%s/cgi-bin/media/upload?access_token=%s&type=%s' \ 167 | % (WEIXIN_URL, self.get_token(), file_type) 168 | except Exception as e: 169 | self.logger.error(e.message) 170 | return make_err_return(e.message) 171 | try: 172 | file_dir, file_name = os.path.split(file_path) 173 | files = {'file': (file_name, open(file_path, 'rb'), 174 | mimetypes.guess_type(file_path, strict=False), {'Expires': '0'})} 175 | r = requests.post(post_url, files=files) 176 | except Exception as e: 177 | self.logger.error(e.message) 178 | return make_err_return(e.message) 179 | try: 180 | res_dict = json.loads(r.content) 181 | except Exception as e: 182 | raise type(e)('invalid json content: %s with exception: %s', 183 | (r.content, e.message)) 184 | return res_dict 185 | 186 | def send_media(self, media_type, media_content, touser, 187 | toparty='', totag=''): 188 | """ 189 | 发送消息/视频/图片给指定的用户 190 | @param media_type: 消息类型 191 | @param media_content: 消息内容, 是一个dict, 包含具体的描述信息 192 | @param touser: 用户名, '|'分割 193 | @param toparty: 分组名, '|'分割 194 | @param totag: 标签名, '|'分割 195 | @return: 服务器返回值dict 196 | """ 197 | if media_type not in ('text', 'image', 'voice', 'video', 'file'): 198 | errmsg = 'Invalid media/message format' 199 | self.logger.error(errmsg) 200 | return { 201 | 'errcode': -1, 202 | 'errmsg': errmsg, 203 | } 204 | try: 205 | send_url = '%s/cgi-bin/message/send?access_token=%s' \ 206 | % (WEIXIN_URL, self.get_token()) 207 | except Exception as e: 208 | # since all error code definitions of wechat is unknown 209 | # we simply just return -1 as our error code 210 | self.logger.error(e.message) 211 | return { 212 | 'errcode': -1, 213 | 'errmsg': e.message, 214 | } 215 | message_data = { 216 | "touser": touser, 217 | "toparty": toparty, 218 | "totag": totag, 219 | "msgtype": media_type, 220 | "agentid": self.AppID, 221 | media_type: media_content, 222 | "safe": "0" 223 | } 224 | raw_data = json.dumps(message_data, ensure_ascii=False) 225 | try: 226 | res = self.url_request(send_url, False, raw_data.encode('utf-8')) 227 | except Exception as e: 228 | sys.stderr.write(str(e) + '\n') 229 | errmsg = 'failed when post json data: %s to wechat server with exception: %s' \ 230 | % (raw_data, e.message) 231 | self.logger.error(errmsg) 232 | return { 233 | 'errcode': -1, 234 | 'errmsg': errmsg, 235 | } 236 | if int(res['errcode']) == 0: 237 | self.logger.info('send message successful to %s' % touser) 238 | else: 239 | self.logger.error('send message failed with error: %s' % res['errmsg']) 240 | return res 241 | 242 | 243 | class WeChatServer(WeChatBase): 244 | """ 245 | 消息接收类(server) 246 | """ 247 | 248 | # def __new__(cls, *args, **kwargs): 249 | # """ 250 | # 重载__new__函数, 实现单例模式 251 | # @param args: 252 | # @param kwargs: 253 | # @return: 254 | # """ 255 | # if not hasattr(cls, '_instance'): 256 | # orig = super(WeChatServer, cls) 257 | # cls._instance = orig.__new__(cls, *args) 258 | # return cls._instance 259 | 260 | def __init__(self, appname, ini_name=None): 261 | """ 262 | 构造函数 263 | @param appname: APP名称, 与配置文件中section对应 264 | @return: 构造的对象 265 | """ 266 | super(WeChatServer, self).__init__(appname, ini_name) 267 | self.callback_funcs = { 268 | 'text': None, 269 | 'image': None, 270 | 'voice': None, 271 | 'video': None, 272 | 'shortvideo': None, 273 | 'location': None, 274 | } 275 | token = self.config.get(appname, 'token') 276 | aes_key = self.config.get(appname, 'encoding_aes_key') 277 | corp_id = self.config.get(appname, 'corpid') 278 | self.wxcpt = utils.WXBizMsgCrypt(token, aes_key, corp_id) 279 | # 初始化Flask对象 280 | self.app = flask.Flask(__name__) 281 | # 添加路由规则 282 | self.app.add_url_rule('/' + self.config.get('system', 'route_name'), 283 | None, self.callback, methods=['GET', 'POST']) 284 | 285 | def register_callback(self, msg_type, func): 286 | """ 287 | 注册收到某种类型消息后的回调函数 288 | @param msg_type: 消息类型 289 | @param func: 回调函数 290 | @return: None 291 | """ 292 | if msg_type in self.callback_funcs: 293 | self.callback_funcs[msg_type] = func 294 | else: 295 | raise KeyError('Invalid media type.') 296 | 297 | def callback(self): 298 | """ 299 | 响应对/weixin请求的函数 300 | @return: 返回响应内容 301 | """ 302 | method = flask.request.method 303 | if method == 'GET': 304 | return self.verify() 305 | elif method == 'POST': 306 | return self.do_reply() 307 | else: 308 | self.logger.error('unsupported method, return 405 method not allowed') 309 | # unknown method, return 405 method not allowed 310 | flask.abort(405) 311 | 312 | def verify(self): 313 | """ 314 | 验证接口可用性 315 | @return: 回显字符串 316 | """ 317 | verify_msg_sig = flask.request.args.get('msg_signature', '') 318 | timestamp = flask.request.args.get('timestamp', '') 319 | nonce = flask.request.args.get('nonce', '') 320 | echo_str = flask.request.args.get('echostr', '') 321 | # do decoding and return 322 | ret, echo_str_res = self.wxcpt.VerifyURL(verify_msg_sig, timestamp, nonce, echo_str) 323 | if ret != 0: 324 | self.logger.error('verification failed with return value %d' % ret) 325 | # if verification failed, return 403 forbidden 326 | flask.abort(403) 327 | else: 328 | return echo_str_res 329 | 330 | def do_reply(self): 331 | """ 332 | 根据消息类型调用对应的回调函数进行回复 333 | @return: 回复的消息, 按照微信接口加密 334 | """ 335 | req_msg_sig = flask.request.args.get('msg_signature', '') 336 | timestamp = flask.request.args.get('timestamp', '') 337 | nonce = flask.request.args.get('nonce', '') 338 | req_data = flask.request.data 339 | ret, xml_str = self.wxcpt.DecryptMsg(req_data, req_msg_sig, timestamp, nonce) 340 | if ret == 0: 341 | param_dict = utils.xml_to_dict(xml_str) 342 | msg_type = param_dict.get("MsgType", '') 343 | if msg_type in self.callback_funcs: 344 | callback_func = self.callback_funcs[msg_type] 345 | if callback_func: 346 | # call the callback function and get return message (dict) 347 | res_dict = callback_func(copy.deepcopy(param_dict)) 348 | default_params = { 349 | 'MsgType': param_dict['MsgType'], 350 | 'CreateTime': int(time.time()) 351 | } 352 | for key, val in default_params.items(): 353 | if not res_dict.get(key, None): 354 | res_dict[key] = val 355 | res_dict['ToUserName'] = param_dict['FromUserName'] 356 | res_dict['FromUserName'] = param_dict['ToUserName'] 357 | xml_data = utils.dict_to_xml(res_dict) 358 | ret_val, encrypted_data = \ 359 | self.wxcpt.EncryptMsg(xml_data, nonce, timestamp) 360 | if ret_val == 0: 361 | self.logger.info('replied a message to %s' % 362 | res_dict.get('ToUserName', 'null')) 363 | return flask.Response(encrypted_data, mimetype='text/xml') 364 | # 如果没有正确走完这个流程, 就记录日志返回错误 365 | self.logger.error('request failed with request data: %r' % req_data) 366 | # if decryption failed or all other reasons, return 400 bad request code 367 | flask.abort(400) 368 | 369 | def run(self, *args, **kwargs): 370 | """ 371 | 启动server循环 372 | @param args: 参数列表 373 | @param kwargs: 参数字典 374 | @return: None 375 | """ 376 | self.app.run(*args, **kwargs) 377 | --------------------------------------------------------------------------------