├── .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 |
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 |
--------------------------------------------------------------------------------