Browse Source

Support python3.

* Compatible with python3 and python2.
* Restructure the directory to support install via `python setup.py install`.
* Support recovery before fresh login.
haosdent 8 years ago
parent
commit
00648a9024
7 changed files with 183 additions and 86 deletions
  1. 7 8
      README.md
  2. 0 0
      __init__.py
  3. 21 0
      setup.py
  4. 1 0
      wxbot/__init__.py
  5. 5 2
      wxbot/bot.py
  6. 2 0
      wxbot/test.py
  7. 147 76
      wxbot/wxbot.py

+ 7 - 8
README.md

@@ -46,17 +46,14 @@ Web微信协议参考资料:
 
 ## 1 环境与依赖
 
-此版本能运行于Python 2环境 。
+此版本能运行于Python 2及Python 3环境 。
 
-**wxBot** 用到了Python **requests** , **pypng** , **Pillow** 以及 **pyqrcode** 库。
+**wxBot** 用到了Python **jsonpickle**, **requests** , **pypng** , **Pillow** 以及 **pyqrcode** 库。
 
-使用之前需要所依赖的库:
+安装:
 
 ```bash
-pip install requests
-pip install pyqrcode
-pip install pypng
-pip install Pillow
+python setup.py install
 ```
 
 ## 2 快速开发
@@ -74,6 +71,7 @@ pip install Pillow
 # coding: utf-8
 
 import time
+import logging
 from wxbot import *
 
 class MyWXBot(WXBot):
@@ -90,6 +88,7 @@ class MyWXBot(WXBot):
 def main():
     bot = MyWXBot()
     bot.DEBUG = True
+    logging.basicConfig(level=logging.DEBUG)
     bot.run()
 
 if __name__ == '__main__':
@@ -242,7 +241,7 @@ python test.py
   2. 在 ***bot.py*** 文件所在目录下新建 ***conf.ini*** 文件,内容为:(key字段内容为申请到的图灵key)
 
     ```txt
-    [main]    
+    [main]
     key=1d2678900f734aa0a23734ace8aec5b1
     ```
 

+ 0 - 0
__init__.py


+ 21 - 0
setup.py

@@ -0,0 +1,21 @@
+from setuptools import setup
+
+
+setup(
+    name='wxbot',
+    version='0.1.0',
+    description='Wechat unofficial bot framework.',
+    url='https://github.com/liuwons/wxBot',
+    author='Weston Liu',
+    author_email='a@lwons.com',
+    license='Apache 2.0',
+    classifiers=[],
+    packages=['wxbot'],
+    install_requires=[
+        'jsonpickle',
+        'Pillow',
+        'pyqrcode',
+        'pypng',
+        'requests',
+    ],
+)

+ 1 - 0
wxbot/__init__.py

@@ -0,0 +1 @@
+from .wxbot import *

+ 5 - 2
bot.py → wxbot/bot.py

@@ -1,6 +1,8 @@
 #!/usr/bin/env python
 # coding: utf-8
+from __future__ import print_function
 
+import logging
 from wxbot import *
 import ConfigParser
 import json
@@ -19,7 +21,7 @@ class TulingWXBot(WXBot):
             self.tuling_key = cf.get('main', 'key')
         except Exception:
             pass
-        print 'tuling_key:', self.tuling_key
+        print('tuling_key:', self.tuling_key)
 
     def tuling_auto_reply(self, uid, msg):
         if self.tuling_key:
@@ -42,7 +44,7 @@ class TulingWXBot(WXBot):
                 result = respond['text'].replace('<br>', '  ')
                 result = result.replace(u'\xa0', u' ')
 
-            print '    ROBOT:', result
+            print('    ROBOT:', result)
             return result
         else:
             return u"知道啦"
@@ -99,6 +101,7 @@ class TulingWXBot(WXBot):
 def main():
     bot = TulingWXBot()
     bot.DEBUG = True
+    logging.basicConfig(level=logging.DEBUG)
     bot.conf['qr'] = 'png'
 
     bot.run()

+ 2 - 0
test.py → wxbot/test.py

@@ -1,6 +1,7 @@
 #!/usr/bin/env python
 # coding: utf-8
 
+import logging
 from wxbot import *
 
 
@@ -20,6 +21,7 @@ class MyWXBot(WXBot):
 def main():
     bot = MyWXBot()
     bot.DEBUG = True
+    logging.basicConfig(level=logging.DEBUG)
     bot.conf['qr'] = 'png'
     bot.is_big_contact = False   #如果确定通讯录过大,无法获取,可以直接配置,跳过检查。假如不是过大的话,这个方法可能无法获取所有的联系人
     bot.run()

+ 147 - 76
wxbot.py → wxbot/wxbot.py

@@ -1,10 +1,15 @@
 #!/usr/bin/env python
 # coding: utf-8
+from __future__ import print_function
 
 import os
 import sys
+import tempfile
 import traceback
 import webbrowser
+
+import binascii
+import jsonpickle
 import pyqrcode
 import requests
 import mimetypes
@@ -16,7 +21,19 @@ import re
 import random
 from traceback import format_exc
 from requests.exceptions import ConnectionError, ReadTimeout
-import HTMLParser
+
+import sys
+
+if sys.version_info[0] == 3:
+    from html import parser as HTMLParser
+    from urllib.parse import urlencode
+    unicode = str
+else:
+    import HTMLParser
+    from urllib import urlencode
+
+import logging
+log = logging.getLogger('wxbot')
 
 UNKONWN = 'unkonwn'
 SUCCESS = '200'
@@ -41,7 +58,7 @@ def show_image(file_path):
         command = "open -a /Applications/Preview.app %s&" % quote(file_path)
         os.system(command)
     else:
-        webbrowser.open(os.path.join(os.getcwd(),'temp',file_path))
+        webbrowser.open(os.path.join(tempfile.gettempdir(), 'wxbot', file_path))
 
 
 class SafeSession(requests.Session):
@@ -54,7 +71,7 @@ class SafeSession(requests.Session):
                                                         timeout,
                                                         allow_redirects, proxies, hooks, stream, verify, cert, json)
             except Exception as e:
-                print e.message, traceback.format_exc()
+                log.exception('request %s failed' % url)
                 continue
 
         #重试3次以后再加一次,抛出异常
@@ -85,15 +102,15 @@ class WXBot:
         self.sync_key = []
         self.sync_host = ''
 
-
         self.batch_count = 50    #一次拉取50个联系人的信息
         self.full_user_name_list = []    #直接获取不到通讯录时,获取的username列表
         self.wxid_list = []   #获取到的wxid的列表
         self.cursor = 0   #拉取联系人信息的游标
         self.is_big_contact = False  #通讯录人数过多,无法直接获取
         #文件缓存目录
-        self.temp_pwd  =  os.path.join(os.getcwd(),'temp')
-        if os.path.exists(self.temp_pwd) == False:
+
+        self.temp_pwd = os.path.join(tempfile.gettempdir(), 'wxbot')
+        if not os.path.exists(self.temp_pwd):
             os.makedirs(self.temp_pwd)
 
         self.session = SafeSession()
@@ -119,6 +136,8 @@ class WXBot:
 
         self.file_index = 0
 
+        self.state_file = os.path.join(self.temp_pwd, 'session_state.json')
+
     @staticmethod
     def to_unicode(string, encoding='utf-8'):
         """
@@ -127,10 +146,10 @@ class WXBot:
         :param encoding: 字符串解码方式
         :return: 转换后的Unicode字符串
         """
-        if isinstance(string, str):
-            return string.decode(encoding)
-        elif isinstance(string, unicode):
+        if isinstance(string, unicode):
             return string
+        elif isinstance(string, str):
+            return string.decode(encoding)
         else:
             raise Exception('Unknown Type')
 
@@ -149,7 +168,7 @@ class WXBot:
             return False
         r.encoding = 'utf-8'
         if self.DEBUG:
-            with open(os.path.join(self.temp_pwd,'contacts.json'), 'w') as f:
+            with open(os.path.join(self.temp_pwd,'contacts.json'), 'wb') as f:
                 f.write(r.text.encode('utf-8'))
         dic = json.loads(r.text)
         self.member_list = dic['MemberList']
@@ -220,7 +239,7 @@ class WXBot:
             self.cursor += self.batch_count
             cur_batch = map(map_username_batch, cur_batch)
             user_info_list += self.batch_get_contact(cur_batch)
-            print "[INFO] Get batch contacts"
+            log.info("Get batch contacts")
 
         self.member_list = user_info_list
         special_users = ['newsapp', 'filehelper', 'weibo', 'qqmail',
@@ -282,8 +301,8 @@ class WXBot:
                 f.write(json.dumps(self.group_members))
             with open(os.path.join(self.temp_pwd,'account_info.json'), 'w') as f:
                 f.write(json.dumps(self.account_info))
-        print '[INFO] Get %d contacts' % len(self.contact_list)
-        print '[INFO] Start to process messages .'
+        log.info('Get %d contacts' % len(self.contact_list))
+        log.info('Start to process messages .')
         return True
 
 
@@ -536,7 +555,7 @@ class WXBot:
                 msg_content['data'] = pos
                 msg_content['detail'] = data
                 if self.DEBUG:
-                    print '    %s[Location] %s ' % (msg_prefix, pos)
+                    log.info('    %s[Location] %s ' % (msg_prefix, pos))
             else:
                 msg_content['type'] = 0
                 if msg_type_id == 3 or (msg_type_id == 1 and msg['ToUserName'][:2] == '@@'):  # Group text message
@@ -551,28 +570,28 @@ class WXBot:
                     msg_content['data'] = content
                 if self.DEBUG:
                     try:
-                        print '    %s[Text] %s' % (msg_prefix, msg_content['data'])
+                        log.info('    %s[Text] %s' % (msg_prefix, msg_content['data']))
                     except UnicodeEncodeError:
-                        print '    %s[Text] (illegal text).' % msg_prefix
+                        log.info('    %s[Text] (illegal text).' % msg_prefix)
         elif mtype == 3:
             msg_content['type'] = 3
             msg_content['data'] = self.get_msg_img_url(msg_id)
-            msg_content['img'] = self.session.get(msg_content['data']).content.encode('hex')
+            msg_content['img'] = binascii.hexlify(self.session.get(msg_content['data']).content)
             if self.DEBUG:
                 image = self.get_msg_img(msg_id)
-                print '    %s[Image] %s' % (msg_prefix, image)
+                log.info('    %s[Image] %s' % (msg_prefix, image))
         elif mtype == 34:
             msg_content['type'] = 4
             msg_content['data'] = self.get_voice_url(msg_id)
-            msg_content['voice'] = self.session.get(msg_content['data']).content.encode('hex')
+            msg_content['voice'] = binascii.hexlify(self.session.get(msg_content['data']).content)
             if self.DEBUG:
                 voice = self.get_voice(msg_id)
-                print '    %s[Voice] %s' % (msg_prefix, voice)
+                log.info('    %s[Voice] %s' % (msg_prefix, voice))
         elif mtype == 37:
             msg_content['type'] = 37
             msg_content['data'] = msg['RecommendInfo']
             if self.DEBUG:
-                print '    %s[useradd] %s' % (msg_prefix,msg['RecommendInfo']['NickName'])
+                log.info('    %s[useradd] %s' % (msg_prefix,msg['RecommendInfo']['NickName']))
         elif mtype == 42:
             msg_content['type'] = 5
             info = msg['RecommendInfo']
@@ -582,18 +601,18 @@ class WXBot:
                                    'city': info['City'],
                                    'gender': ['unknown', 'male', 'female'][info['Sex']]}
             if self.DEBUG:
-                print '    %s[Recommend]' % msg_prefix
-                print '    -----------------------------'
-                print '    | NickName: %s' % info['NickName']
-                print '    | Alias: %s' % info['Alias']
-                print '    | Local: %s %s' % (info['Province'], info['City'])
-                print '    | Gender: %s' % ['unknown', 'male', 'female'][info['Sex']]
-                print '    -----------------------------'
+                log.info('    %s[Recommend]' % msg_prefix)
+                log.info('    -----------------------------')
+                log.info('    | NickName: %s' % info['NickName'])
+                log.info('    | Alias: %s' % info['Alias'])
+                log.info('    | Local: %s %s' % (info['Province'], info['City']))
+                log.info('    | Gender: %s' % ['unknown', 'male', 'female'][info['Sex']])
+                log.info('    -----------------------------')
         elif mtype == 47:
             msg_content['type'] = 6
             msg_content['data'] = self.search_content('cdnurl', content)
             if self.DEBUG:
-                print '    %s[Animation] %s' % (msg_prefix, msg_content['data'])
+                log.info('    %s[Animation] %s' % (msg_prefix, msg_content['data']))
         elif mtype == 49:
             msg_content['type'] = 7
             if msg['AppMsgType'] == 3:
@@ -612,45 +631,45 @@ class WXBot:
                                    'content': msg.get('Content')  # 有的公众号会发一次性3 4条链接一个大图,如果只url那只能获取第一条,content里面有所有的链接
                                    }
             if self.DEBUG:
-                print '    %s[Share] %s' % (msg_prefix, app_msg_type)
-                print '    --------------------------'
-                print '    | title: %s' % msg['FileName']
-                print '    | desc: %s' % self.search_content('des', content, 'xml')
-                print '    | link: %s' % msg['Url']
-                print '    | from: %s' % self.search_content('appname', content, 'xml')
-                print '    | content: %s' % (msg.get('content')[:20] if msg.get('content') else "unknown")
-                print '    --------------------------'
+                log.info('    %s[Share] %s' % (msg_prefix, app_msg_type))
+                log.info('    --------------------------')
+                log.info('    | title: %s' % msg['FileName'])
+                log.info('    | desc: %s' % self.search_content('des', content, 'xml'))
+                log.info('    | link: %s' % msg['Url'])
+                log.info('    | from: %s' % self.search_content('appname', content, 'xml'))
+                log.info('    | content: %s' % (msg.get('content')[:20] if msg.get('content') else "unknown"))
+                log.info('    --------------------------')
 
         elif mtype == 62:
             msg_content['type'] = 8
             msg_content['data'] = content
             if self.DEBUG:
-                print '    %s[Video] Please check on mobiles' % msg_prefix
+                log.info('    %s[Video] Please check on mobiles' % msg_prefix)
         elif mtype == 53:
             msg_content['type'] = 9
             msg_content['data'] = content
             if self.DEBUG:
-                print '    %s[Video Call]' % msg_prefix
+                log.info('    %s[Video Call]' % msg_prefix)
         elif mtype == 10002:
             msg_content['type'] = 10
             msg_content['data'] = content
             if self.DEBUG:
-                print '    %s[Redraw]' % msg_prefix
+                log.info('    %s[Redraw]' % msg_prefix)
         elif mtype == 10000:  # unknown, maybe red packet, or group invite
             msg_content['type'] = 12
             msg_content['data'] = msg['Content']
             if self.DEBUG:
-                print '    [Unknown]'
+                log.info('    [Unknown]')
         elif mtype == 43:
             msg_content['type'] = 13
             msg_content['data'] = self.get_video_url(msg_id)
             if self.DEBUG:
-                print '    %s[video] %s' % (msg_prefix, msg_content['data'])
+                log.info('    %s[video] %s' % (msg_prefix, msg_content['data']))
         else:
             msg_content['type'] = 99
             msg_content['data'] = content
             if self.DEBUG:
-                print '    %s[Unknown]' % msg_prefix
+                log.info('    %s[Unknown]' % msg_prefix)
         return msg_content
 
     def handle_msg(self, r):
@@ -680,7 +699,7 @@ class WXBot:
                         f.write(msg['StatusNotifyUserName'])
                     with open(os.path.join(self.temp_pwd,'wxid.txt'), 'w') as f:
                         f.write(json.dumps(self.wxid_list))
-                    print "[INFO] Contact list is too big. Now start to fetch member list ."
+                    log.info('Contact list is too big. Now start to fetch member list .')
                     self.get_big_contact()
 
             elif msg['MsgType'] == 37:  # friend request
@@ -720,7 +739,7 @@ class WXBot:
             user['name'] = HTMLParser.HTMLParser().unescape(user['name'])
 
             if self.DEBUG and msg_type_id != 0:
-                print u'[MSG] %s:' % user['name']
+                log.info(u'[MSG] %s:' % user['name'])
             content = self.extract_msg_content(msg_type_id, msg)
             message = {'msg_type_id': msg_type_id,
                        'msg_id': msg['MsgId'],
@@ -742,7 +761,7 @@ class WXBot:
             check_time = time.time()
             try:
                 [retcode, selector] = self.sync_check()
-                # print '[DEBUG] sync_check:', retcode, selector
+                # log.debug('sync_check:', retcode, selector)
                 if retcode == '1100':  # 从微信客户端上登出
                     break
                 elif retcode == '1101':  # 从其它设备上登了网页微信
@@ -771,17 +790,16 @@ class WXBot:
                     elif selector == '0':  # 无事件
                         pass
                     else:
-                        print '[DEBUG] sync_check:', retcode, selector
+                        log.debug('sync_check:', retcode, selector)
                         r = self.sync()
                         if r is not None:
                             self.handle_msg(r)
                 else:
-                    print '[DEBUG] sync_check:', retcode, selector
+                    log.debug('sync_check:', retcode, selector)
                     time.sleep(10)
                 self.schedule()
             except:
-                print '[ERROR] Except in proc_msg'
-                print format_exc()
+                log.exception('Except in proc_msg')
             check_time = time.time() - check_time
             if check_time < 0.8:
                 time.sleep(1 - check_time)
@@ -981,7 +999,7 @@ class WXBot:
 
     def upload_media(self, fpath, is_img=False):
         if not os.path.exists(fpath):
-            print '[ERROR] File not exists.'
+            log.error('File not exists.')
             return None
         url_1 = 'https://file.'+self.base_host+'/cgi-bin/mmwebwx-bin/webwxuploadmedia?f=json'
         url_2 = 'https://file2.'+self.base_host+'/cgi-bin/mmwebwx-bin/webwxuploadmedia?f=json'
@@ -1013,11 +1031,11 @@ class WXBot:
                 # 当file返回值不为0时则为上传失败,尝试第二服务器上传
                 r = self.session.post(url_2, files=files)
             if json.loads(r.text)['BaseResponse']['Ret'] != 0:
-                print '[ERROR] Upload media failure.'
+                log.error('Upload media failure.')
                 return None
             mid = json.loads(r.text)['MediaId']
             return mid
-        except Exception,e:
+        except:
             return None
 
     def send_file_msg_by_uid(self, fpath, uid):
@@ -1042,7 +1060,7 @@ class WXBot:
                 return True
             else:
                 return False
-        except Exception,e:
+        except:
             return False
 
     def send_img_msg_by_uid(self, fpath, uid):
@@ -1070,7 +1088,7 @@ class WXBot:
                 return True
             else:
                 return False
-        except Exception,e:
+        except:
             return False
 
     def get_user_id(self, name):
@@ -1102,7 +1120,7 @@ class WXBot:
                     result = True
                     for line in f.readlines():
                         line = line.replace('\n', '')
-                        print '-> ' + name + ': ' + line
+                        log.info('-> ' + name + ': ' + line)
                         if self.send_msg_by_uid(line, uid):
                             pass
                         else:
@@ -1117,7 +1135,7 @@ class WXBot:
                     return False
         else:
             if self.DEBUG:
-                print '[ERROR] This user does not exist .'
+                log.error('This user does not exist .')
             return True
 
     @staticmethod
@@ -1132,31 +1150,84 @@ class WXBot:
                 return pm.group(1)
         return 'unknown'
 
-    def run(self):
+    def save_to_file(self):
+        with open(os.path.join(self.temp_pwd, 'session_state.json'), 'w') as fp:
+            json.dump({
+                'uuid': self.uuid,
+                'base_uri': self.base_uri,
+                'base_host': self.base_host,
+                'redirect_uri': self.redirect_uri,
+                'uin': self.uin,
+                'sid': self.sid,
+                'skey': self.skey,
+                'pass_ticket': self.pass_ticket,
+                'device_id': self.device_id,
+                'base_request': self.base_request,
+                'session': jsonpickle.encode(self.session),
+            }, fp)
+
+    def load_from_file(self):
+        if not os.path.exists(self.state_file):
+            return False
+
+        try:
+            with open(self.state_file) as fp:
+                state = json.load(fp)
+                self.uuid = state['uuid']
+                self.base_uri = state['base_uri']
+                self.base_host = state['base_host']
+                self.redirect_uri = state['redirect_uri']
+                self.uin = state['uin']
+                self.sid = state['sid']
+                self.skey = state['skey']
+                self.pass_ticket = state['pass_ticket']
+                self.device_id = state['device_id']
+                self.base_request = state['base_request']
+                self.session = jsonpickle.decode(state['session'])
+        except:
+            log.exception('Failed to parse %s' % self.state_file)
+            return False
+
+        if self.init():
+            log.info('Web WeChat init succeed .')
+            return True
+        else:
+            log.info('Web WeChat init failed')
+            return False
+
+    def login(self):
         self.get_uuid()
         self.gen_qr_code(os.path.join(self.temp_pwd,'wxqr.png'))
-        print '[INFO] Please use WeChat to scan the QR code .'
+        log.info('Please use WeChat to scan the QR code .')
 
         result = self.wait4login()
         if result != SUCCESS:
-            print '[ERROR] Web WeChat login failed. failed code=%s' % (result,)
-            return
+            log.error('Web WeChat login failed. failed code=%s' % (result,))
+            return False
 
-        if self.login():
-            print '[INFO] Web WeChat login succeed .'
+        if self._login():
+            log.info('Web WeChat login succeed .')
         else:
-            print '[ERROR] Web WeChat login failed .'
-            return
+            log.error('Web WeChat login failed .')
+            return False
 
         if self.init():
-            print '[INFO] Web WeChat init succeed .'
+            log.info('Web WeChat init succeed .')
+            self.save_to_file()
+            return True
         else:
-            print '[INFO] Web WeChat init failed'
-            return
+            log.error('Web WeChat init failed')
+            return False
+
+    def run(self):
+        if not self.load_from_file() and not self.login():
+            log.error('Both recovered login status and fresh login are failed.')
+            return False
+
         self.status_notify()
         if self.get_contact():
-            print '[INFO] Get %d contacts' % len(self.contact_list)
-            print '[INFO] Start to process messages .'
+            log.info('Get %d contacts' % len(self.contact_list))
+            log.info('Start to process messages .')
         self.proc_msg()
 
     def get_uuid(self):
@@ -1219,7 +1290,7 @@ class WXBot:
             url = LOGIN_TEMPLATE % (tip, self.uuid, int(time.time()))
             code, data = self.do_request(url)
             if code == SCANED:
-                print '[INFO] Please confirm to login .'
+                log.info('Please confirm to login .')
                 tip = 0
             elif code == SUCCESS:  # 确认登录成功
                 param = re.search(r'window.redirect_uri="(\S+?)";', data)
@@ -1230,13 +1301,13 @@ class WXBot:
                 self.base_host = temp_host[:temp_host.find("/")]
                 return code
             elif code == TIMEOUT:
-                print '[ERROR] WeChat login timeout. retry in %s secs later...' % (try_later_secs,)
+                log.error('WeChat login timeout. retry in %s secs later...' % (try_later_secs,))
 
                 tip = 1  # 重置
                 retry_time -= 1
                 time.sleep(try_later_secs)
             else:
-                print ('[ERROR] WeChat login exception return_code=%s. retry in %s secs later...' %
+                log.error('WeChat login exception return_code=%s. retry in %s secs later...' %
                        (code, try_later_secs))
                 tip = 1
                 retry_time -= 1
@@ -1244,9 +1315,9 @@ class WXBot:
 
         return code
 
-    def login(self):
+    def _login(self):
         if len(self.redirect_uri) < 4:
-            print '[ERROR] Login failed due to network problem, please try again.'
+            log.error('Login failed due to network problem, please try again.')
             return False
         r = self.session.get(self.redirect_uri)
         r.encoding = 'utf-8'
@@ -1325,7 +1396,7 @@ class WXBot:
             'synckey': self.sync_key_str,
             '_': int(time.time()),
         }
-        url = 'https://' + self.sync_host + '/cgi-bin/mmwebwx-bin/synccheck?' + urllib.urlencode(params)
+        url = 'https://' + self.sync_host + '/cgi-bin/mmwebwx-bin/synccheck?' + urlencode(params)
         try:
             r = self.session.get(url, timeout=60)
             r.encoding = 'utf-8'