Bladeren bron

feat: intergrate itchat to lib

lanvent 3 jaren geleden
bovenliggende
commit
92caeed7ab

+ 2 - 2
channel/wechat/wechat_channel.py

@@ -5,9 +5,9 @@ wechat channel
 """
 
 import os
-import itchat
+from lib import itchat
 import json
-from itchat.content import *
+from lib.itchat.content import *
 from bridge.reply import *
 from bridge.context import *
 from channel.channel import Channel

+ 96 - 0
lib/itchat/__init__.py

@@ -0,0 +1,96 @@
+from .core import Core
+from .config import VERSION, ASYNC_COMPONENTS
+from .log import set_logging
+
+if ASYNC_COMPONENTS:
+    from .async_components import load_components
+else:
+    from .components import load_components
+
+
+__version__ = VERSION
+
+
+instanceList = []
+
+def load_async_itchat() -> Core:
+    """load async-based itchat instance
+
+    Returns:
+        Core: the abstract interface of itchat
+    """
+    from .async_components import load_components
+    load_components(Core)
+    return Core()
+
+
+def load_sync_itchat() -> Core:
+    """load sync-based itchat instance
+
+    Returns:
+        Core: the abstract interface of itchat
+    """
+    from .components import load_components
+    load_components(Core)
+    return Core()
+
+
+if ASYNC_COMPONENTS:
+    instance = load_async_itchat()
+else:
+    instance = load_sync_itchat()
+
+
+instanceList = [instance]
+
+# I really want to use sys.modules[__name__] = originInstance
+# but it makes auto-fill a real mess, so forgive me for my following **
+# actually it toke me less than 30 seconds, god bless Uganda
+
+# components.login
+login                       = instance.login
+get_QRuuid                  = instance.get_QRuuid
+get_QR                      = instance.get_QR
+check_login                 = instance.check_login
+web_init                    = instance.web_init
+show_mobile_login           = instance.show_mobile_login
+start_receiving             = instance.start_receiving
+get_msg                     = instance.get_msg
+logout                      = instance.logout
+# components.contact
+update_chatroom             = instance.update_chatroom
+update_friend               = instance.update_friend
+get_contact                 = instance.get_contact
+get_friends                 = instance.get_friends
+get_chatrooms               = instance.get_chatrooms
+get_mps                     = instance.get_mps
+set_alias                   = instance.set_alias
+set_pinned                  = instance.set_pinned
+accept_friend               = instance.accept_friend
+get_head_img                = instance.get_head_img
+create_chatroom             = instance.create_chatroom
+set_chatroom_name           = instance.set_chatroom_name
+delete_member_from_chatroom = instance.delete_member_from_chatroom
+add_member_into_chatroom    = instance.add_member_into_chatroom
+# components.messages
+send_raw_msg                = instance.send_raw_msg
+send_msg                    = instance.send_msg
+upload_file                 = instance.upload_file
+send_file                   = instance.send_file
+send_image                  = instance.send_image
+send_video                  = instance.send_video
+send                        = instance.send
+revoke                      = instance.revoke
+# components.hotreload
+dump_login_status           = instance.dump_login_status
+load_login_status           = instance.load_login_status
+# components.register
+auto_login                  = instance.auto_login
+configured_reply            = instance.configured_reply
+msg_register                = instance.msg_register
+run                         = instance.run
+# other functions
+search_friends              = instance.search_friends
+search_chatrooms            = instance.search_chatrooms
+search_mps                  = instance.search_mps
+set_logging                 = set_logging

+ 12 - 0
lib/itchat/async_components/__init__.py

@@ -0,0 +1,12 @@
+from .contact import load_contact
+from .hotreload import load_hotreload
+from .login import load_login
+from .messages import load_messages
+from .register import load_register
+
+def load_components(core):
+    load_contact(core)
+    load_hotreload(core)
+    load_login(core)
+    load_messages(core)
+    load_register(core)

+ 488 - 0
lib/itchat/async_components/contact.py

@@ -0,0 +1,488 @@
+import time, re, io
+import json, copy
+import logging
+
+from .. import config, utils
+from ..components.contact import accept_friend
+from ..returnvalues import ReturnValue
+from ..storage import contact_change
+from ..utils import update_info_dict
+
+logger = logging.getLogger('itchat')
+
+def load_contact(core):
+    core.update_chatroom             = update_chatroom
+    core.update_friend               = update_friend
+    core.get_contact                 = get_contact
+    core.get_friends                 = get_friends
+    core.get_chatrooms               = get_chatrooms
+    core.get_mps                     = get_mps
+    core.set_alias                   = set_alias
+    core.set_pinned                  = set_pinned
+    core.accept_friend               = accept_friend
+    core.get_head_img                = get_head_img
+    core.create_chatroom             = create_chatroom
+    core.set_chatroom_name           = set_chatroom_name
+    core.delete_member_from_chatroom = delete_member_from_chatroom
+    core.add_member_into_chatroom    = add_member_into_chatroom
+
+def update_chatroom(self, userName, detailedMember=False):
+    if not isinstance(userName, list):
+        userName = [userName]
+    url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
+        self.loginInfo['url'], int(time.time()))
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Count': len(userName),
+        'List': [{
+            'UserName': u,
+            'ChatRoomId': '', } for u in userName], }
+    chatroomList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
+            ).content.decode('utf8', 'replace')).get('ContactList')
+    if not chatroomList:
+        return ReturnValue({'BaseResponse': {
+                'ErrMsg': 'No chatroom found',
+                'Ret': -1001, }})
+
+    if detailedMember:
+        def get_detailed_member_info(encryChatroomId, memberList):
+            url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
+                self.loginInfo['url'], int(time.time()))
+            headers = {
+                'ContentType': 'application/json; charset=UTF-8',
+                'User-Agent' : config.USER_AGENT, }
+            data = {
+                'BaseRequest': self.loginInfo['BaseRequest'],
+                'Count': len(memberList),
+                'List': [{
+                    'UserName': member['UserName'],
+                    'EncryChatRoomId': encryChatroomId} \
+                        for member in memberList], }
+            return json.loads(self.s.post(url, data=json.dumps(data), headers=headers
+                    ).content.decode('utf8', 'replace'))['ContactList']
+        MAX_GET_NUMBER = 50
+        for chatroom in chatroomList:
+            totalMemberList = []
+            for i in range(int(len(chatroom['MemberList']) / MAX_GET_NUMBER + 1)):
+                memberList = chatroom['MemberList'][i*MAX_GET_NUMBER: (i+1)*MAX_GET_NUMBER]
+                totalMemberList += get_detailed_member_info(chatroom['EncryChatRoomId'], memberList)
+            chatroom['MemberList'] = totalMemberList
+
+    update_local_chatrooms(self, chatroomList)
+    r = [self.storageClass.search_chatrooms(userName=c['UserName'])
+        for c in chatroomList]
+    return r if 1 < len(r) else r[0]
+
+def update_friend(self, userName):
+    if not isinstance(userName, list):
+        userName = [userName]
+    url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
+        self.loginInfo['url'], int(time.time()))
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Count': len(userName),
+        'List': [{
+            'UserName': u,
+            'EncryChatRoomId': '', } for u in userName], }
+    friendList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
+            ).content.decode('utf8', 'replace')).get('ContactList')
+
+    update_local_friends(self, friendList)
+    r = [self.storageClass.search_friends(userName=f['UserName'])
+        for f in friendList]
+    return r if len(r) != 1 else r[0]
+
+@contact_change
+def update_local_chatrooms(core, l):
+    '''
+        get a list of chatrooms for updating local chatrooms
+        return a list of given chatrooms with updated info
+    '''
+    for chatroom in l:
+        # format new chatrooms
+        utils.emoji_formatter(chatroom, 'NickName')
+        for member in chatroom['MemberList']:
+            if 'NickName' in member:
+                utils.emoji_formatter(member, 'NickName')
+            if 'DisplayName' in member:
+                utils.emoji_formatter(member, 'DisplayName')
+            if 'RemarkName' in member:
+                utils.emoji_formatter(member, 'RemarkName')
+        # update it to old chatrooms
+        oldChatroom = utils.search_dict_list(
+            core.chatroomList, 'UserName', chatroom['UserName'])
+        if oldChatroom:
+            update_info_dict(oldChatroom, chatroom)
+            #  - update other values
+            memberList = chatroom.get('MemberList', [])
+            oldMemberList = oldChatroom['MemberList']
+            if memberList:
+                for member in memberList:
+                    oldMember = utils.search_dict_list(
+                        oldMemberList, 'UserName', member['UserName'])
+                    if oldMember:
+                        update_info_dict(oldMember, member)
+                    else:
+                        oldMemberList.append(member)
+        else:
+            core.chatroomList.append(chatroom)
+            oldChatroom = utils.search_dict_list(
+                core.chatroomList, 'UserName', chatroom['UserName'])
+        # delete useless members
+        if len(chatroom['MemberList']) != len(oldChatroom['MemberList']) and \
+                chatroom['MemberList']:
+            existsUserNames = [member['UserName'] for member in chatroom['MemberList']]
+            delList = []
+            for i, member in enumerate(oldChatroom['MemberList']):
+                if member['UserName'] not in existsUserNames:
+                    delList.append(i)
+            delList.sort(reverse=True)
+            for i in delList:
+                del oldChatroom['MemberList'][i]
+        #  - update OwnerUin
+        if oldChatroom.get('ChatRoomOwner') and oldChatroom.get('MemberList'):
+            owner = utils.search_dict_list(oldChatroom['MemberList'],
+                'UserName', oldChatroom['ChatRoomOwner'])
+            oldChatroom['OwnerUin'] = (owner or {}).get('Uin', 0)
+        #  - update IsAdmin
+        if 'OwnerUin' in oldChatroom and oldChatroom['OwnerUin'] != 0:
+            oldChatroom['IsAdmin'] = \
+                oldChatroom['OwnerUin'] == int(core.loginInfo['wxuin'])
+        else:
+            oldChatroom['IsAdmin'] = None
+        #  - update Self
+        newSelf = utils.search_dict_list(oldChatroom['MemberList'],
+            'UserName', core.storageClass.userName)
+        oldChatroom['Self'] = newSelf or copy.deepcopy(core.loginInfo['User'])
+    return {
+        'Type'         : 'System',
+        'Text'         : [chatroom['UserName'] for chatroom in l],
+        'SystemInfo'   : 'chatrooms',
+        'FromUserName' : core.storageClass.userName,
+        'ToUserName'   : core.storageClass.userName, }
+
+@contact_change
+def update_local_friends(core, l):
+    '''
+        get a list of friends or mps for updating local contact
+    '''
+    fullList = core.memberList + core.mpList
+    for friend in l:
+        if 'NickName' in friend:
+            utils.emoji_formatter(friend, 'NickName')
+        if 'DisplayName' in friend:
+            utils.emoji_formatter(friend, 'DisplayName')
+        if 'RemarkName' in friend:
+            utils.emoji_formatter(friend, 'RemarkName')
+        oldInfoDict = utils.search_dict_list(
+            fullList, 'UserName', friend['UserName'])
+        if oldInfoDict is None:
+            oldInfoDict = copy.deepcopy(friend)
+            if oldInfoDict['VerifyFlag'] & 8 == 0:
+                core.memberList.append(oldInfoDict)
+            else:
+                core.mpList.append(oldInfoDict)
+        else:
+            update_info_dict(oldInfoDict, friend)
+
+@contact_change
+def update_local_uin(core, msg):
+    '''
+        content contains uins and StatusNotifyUserName contains username
+        they are in same order, so what I do is to pair them together
+
+        I caught an exception in this method while not knowing why
+        but don't worry, it won't cause any problem
+    '''
+    uins = re.search('<username>([^<]*?)<', msg['Content'])
+    usernameChangedList = []
+    r = {
+        'Type': 'System',
+        'Text': usernameChangedList,
+        'SystemInfo': 'uins', }
+    if uins:
+        uins = uins.group(1).split(',')
+        usernames = msg['StatusNotifyUserName'].split(',')
+        if 0 < len(uins) == len(usernames):
+            for uin, username in zip(uins, usernames):
+                if not '@' in username: continue
+                fullContact = core.memberList + core.chatroomList + core.mpList
+                userDicts = utils.search_dict_list(fullContact,
+                    'UserName', username)
+                if userDicts:
+                    if userDicts.get('Uin', 0) == 0:
+                        userDicts['Uin'] = uin
+                        usernameChangedList.append(username)
+                        logger.debug('Uin fetched: %s, %s' % (username, uin))
+                    else:
+                        if userDicts['Uin'] != uin:
+                            logger.debug('Uin changed: %s, %s' % (
+                                userDicts['Uin'], uin))
+                else:
+                    if '@@' in username:
+                        core.storageClass.updateLock.release()
+                        update_chatroom(core, username)
+                        core.storageClass.updateLock.acquire()
+                        newChatroomDict = utils.search_dict_list(
+                            core.chatroomList, 'UserName', username)
+                        if newChatroomDict is None:
+                            newChatroomDict = utils.struct_friend_info({
+                                'UserName': username,
+                                'Uin': uin,
+                                'Self': copy.deepcopy(core.loginInfo['User'])})
+                            core.chatroomList.append(newChatroomDict)
+                        else:
+                            newChatroomDict['Uin'] = uin
+                    elif '@' in username:
+                        core.storageClass.updateLock.release()
+                        update_friend(core, username)
+                        core.storageClass.updateLock.acquire()
+                        newFriendDict = utils.search_dict_list(
+                            core.memberList, 'UserName', username)
+                        if newFriendDict is None:
+                            newFriendDict = utils.struct_friend_info({
+                                'UserName': username,
+                                'Uin': uin, })
+                            core.memberList.append(newFriendDict)
+                        else:
+                            newFriendDict['Uin'] = uin
+                    usernameChangedList.append(username)
+                    logger.debug('Uin fetched: %s, %s' % (username, uin))
+        else:
+            logger.debug('Wrong length of uins & usernames: %s, %s' % (
+                len(uins), len(usernames)))
+    else:
+        logger.debug('No uins in 51 message')
+        logger.debug(msg['Content'])
+    return r
+
+def get_contact(self, update=False):
+    if not update:
+        return utils.contact_deep_copy(self, self.chatroomList)
+    def _get_contact(seq=0):
+        url = '%s/webwxgetcontact?r=%s&seq=%s&skey=%s' % (self.loginInfo['url'],
+            int(time.time()), seq, self.loginInfo['skey'])
+        headers = {
+            'ContentType': 'application/json; charset=UTF-8',
+            'User-Agent' : config.USER_AGENT, }
+        try:
+            r = self.s.get(url, headers=headers)
+        except:
+            logger.info('Failed to fetch contact, that may because of the amount of your chatrooms')
+            for chatroom in self.get_chatrooms():
+                self.update_chatroom(chatroom['UserName'], detailedMember=True)
+            return 0, []
+        j = json.loads(r.content.decode('utf-8', 'replace'))
+        return j.get('Seq', 0), j.get('MemberList')
+    seq, memberList = 0, []
+    while 1:
+        seq, batchMemberList = _get_contact(seq)
+        memberList.extend(batchMemberList)
+        if seq == 0:
+            break
+    chatroomList, otherList = [], []
+    for m in memberList:
+        if m['Sex'] != 0:
+            otherList.append(m)
+        elif '@@' in m['UserName']:
+            chatroomList.append(m)
+        elif '@' in m['UserName']:
+            # mp will be dealt in update_local_friends as well
+            otherList.append(m)
+    if chatroomList:
+        update_local_chatrooms(self, chatroomList)
+    if otherList:
+        update_local_friends(self, otherList)
+    return utils.contact_deep_copy(self, chatroomList)
+
+def get_friends(self, update=False):
+    if update:
+        self.get_contact(update=True)
+    return utils.contact_deep_copy(self, self.memberList)
+
+def get_chatrooms(self, update=False, contactOnly=False):
+    if contactOnly:
+        return self.get_contact(update=True)
+    else:
+        if update:
+            self.get_contact(True)
+        return utils.contact_deep_copy(self, self.chatroomList)
+
+def get_mps(self, update=False):
+    if update: self.get_contact(update=True)
+    return utils.contact_deep_copy(self, self.mpList)
+
+def set_alias(self, userName, alias):
+    oldFriendInfo = utils.search_dict_list(
+        self.memberList, 'UserName', userName)
+    if oldFriendInfo is None:
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1001, }})
+    url = '%s/webwxoplog?lang=%s&pass_ticket=%s' % (
+        self.loginInfo['url'], 'zh_CN', self.loginInfo['pass_ticket'])
+    data = {
+        'UserName'    : userName,
+        'CmdId'       : 2,
+        'RemarkName'  : alias,
+        'BaseRequest' : self.loginInfo['BaseRequest'], }
+    headers = { 'User-Agent' : config.USER_AGENT}
+    r = self.s.post(url, json.dumps(data, ensure_ascii=False).encode('utf8'),
+        headers=headers)
+    r = ReturnValue(rawResponse=r)
+    if r:
+        oldFriendInfo['RemarkName'] = alias
+    return r
+
+def set_pinned(self, userName, isPinned=True):
+    url = '%s/webwxoplog?pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'UserName'    : userName,
+        'CmdId'       : 3,
+        'OP'          : int(isPinned),
+        'BaseRequest' : self.loginInfo['BaseRequest'], }
+    headers = { 'User-Agent' : config.USER_AGENT}
+    r = self.s.post(url, json=data, headers=headers)
+    return ReturnValue(rawResponse=r)
+
+def accept_friend(self, userName, v4= '', autoUpdate=True):
+    url = f"{self.loginInfo['url']}/webwxverifyuser?r={int(time.time())}&pass_ticket={self.loginInfo['pass_ticket']}"
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Opcode': 3, # 3
+        'VerifyUserListSize': 1,
+        'VerifyUserList': [{
+            'Value': userName,
+            'VerifyUserTicket': v4, }],
+        'VerifyContent': '',
+        'SceneListCount': 1,
+        'SceneList': [33],
+        'skey': self.loginInfo['skey'], }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8', 'replace'))
+    if autoUpdate:
+        self.update_friend(userName)
+    return ReturnValue(rawResponse=r)
+
+def get_head_img(self, userName=None, chatroomUserName=None, picDir=None):
+    ''' get head image
+     * if you want to get chatroom header: only set chatroomUserName
+     * if you want to get friend header: only set userName
+     * if you want to get chatroom member header: set both
+    '''
+    params = {
+        'userName': userName or chatroomUserName or self.storageClass.userName,
+        'skey': self.loginInfo['skey'],
+        'type': 'big', }
+    url = '%s/webwxgeticon' % self.loginInfo['url']
+    if chatroomUserName is None:
+        infoDict = self.storageClass.search_friends(userName=userName)
+        if infoDict is None:
+            return ReturnValue({'BaseResponse': {
+                'ErrMsg': 'No friend found',
+                'Ret': -1001, }})
+    else:
+        if userName is None:
+            url = '%s/webwxgetheadimg' % self.loginInfo['url']
+        else:
+            chatroom = self.storageClass.search_chatrooms(userName=chatroomUserName)
+            if chatroomUserName is None:
+                return ReturnValue({'BaseResponse': {
+                    'ErrMsg': 'No chatroom found',
+                    'Ret': -1001, }})
+            if 'EncryChatRoomId' in chatroom:
+                params['chatroomid'] = chatroom['EncryChatRoomId']
+            params['chatroomid'] =  params.get('chatroomid') or chatroom['UserName']
+    headers = { 'User-Agent' : config.USER_AGENT}
+    r = self.s.get(url, params=params, stream=True, headers=headers)
+    tempStorage = io.BytesIO()
+    for block in r.iter_content(1024):
+        tempStorage.write(block)
+    if picDir is None:
+        return tempStorage.getvalue()
+    with open(picDir, 'wb') as f:
+        f.write(tempStorage.getvalue())
+    tempStorage.seek(0)
+    return ReturnValue({'BaseResponse': {
+        'ErrMsg': 'Successfully downloaded',
+        'Ret': 0, },
+        'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
+
+def create_chatroom(self, memberList, topic=''):
+    url = '%s/webwxcreatechatroom?pass_ticket=%s&r=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'], int(time.time()))
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'MemberCount': len(memberList.split(',')),
+        'MemberList': [{'UserName': member} for member in memberList.split(',')],
+        'Topic': topic, }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
+    return ReturnValue(rawResponse=r)
+
+def set_chatroom_name(self, chatroomUserName, name):
+    url = '%s/webwxupdatechatroom?fun=modtopic&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'ChatRoomName': chatroomUserName,
+        'NewTopic': name, }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
+    return ReturnValue(rawResponse=r)
+
+def delete_member_from_chatroom(self, chatroomUserName, memberList):
+    url = '%s/webwxupdatechatroom?fun=delmember&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'ChatRoomName': chatroomUserName,
+        'DelMemberList': ','.join([member['UserName'] for member in memberList]), }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT}
+    r = self.s.post(url, data=json.dumps(data),headers=headers)
+    return ReturnValue(rawResponse=r)
+
+def add_member_into_chatroom(self, chatroomUserName, memberList,
+        useInvitation=False):
+    ''' add or invite member into chatroom
+     * there are two ways to get members into chatroom: invite or directly add
+     * but for chatrooms with more than 40 users, you can only use invite
+     * but don't worry we will auto-force userInvitation for you when necessary
+    '''
+    if not useInvitation:
+        chatroom = self.storageClass.search_chatrooms(userName=chatroomUserName)
+        if not chatroom: chatroom = self.update_chatroom(chatroomUserName)
+        if len(chatroom['MemberList']) > self.loginInfo['InviteStartCount']:
+            useInvitation = True
+    if useInvitation:
+        fun, memberKeyName = 'invitemember', 'InviteMemberList'
+    else:
+        fun, memberKeyName = 'addmember', 'AddMemberList'
+    url = '%s/webwxupdatechatroom?fun=%s&pass_ticket=%s' % (
+        self.loginInfo['url'], fun, self.loginInfo['pass_ticket'])
+    params = {
+        'BaseRequest'  : self.loginInfo['BaseRequest'],
+        'ChatRoomName' : chatroomUserName,
+        memberKeyName  : memberList, }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT}
+    r = self.s.post(url, data=json.dumps(params),headers=headers)
+    return ReturnValue(rawResponse=r)

+ 102 - 0
lib/itchat/async_components/hotreload.py

@@ -0,0 +1,102 @@
+import pickle, os
+import logging
+
+import requests  # type: ignore
+
+from ..config import VERSION
+from ..returnvalues import ReturnValue
+from ..storage import templates
+from .contact import update_local_chatrooms, update_local_friends
+from .messages import produce_msg
+
+logger = logging.getLogger('itchat')
+
+def load_hotreload(core):
+    core.dump_login_status = dump_login_status
+    core.load_login_status = load_login_status
+
+async def dump_login_status(self, fileDir=None):
+    fileDir = fileDir or self.hotReloadDir
+    try:
+        with open(fileDir, 'w') as f:
+            f.write('itchat - DELETE THIS')
+        os.remove(fileDir)
+    except:
+        raise Exception('Incorrect fileDir')
+    status = {
+        'version'   : VERSION,
+        'loginInfo' : self.loginInfo,
+        'cookies'   : self.s.cookies.get_dict(),
+        'storage'   : self.storageClass.dumps()}
+    with open(fileDir, 'wb') as f:
+        pickle.dump(status, f)
+    logger.debug('Dump login status for hot reload successfully.')
+
+async def load_login_status(self, fileDir,
+        loginCallback=None, exitCallback=None):
+    try:
+        with open(fileDir, 'rb') as f:
+            j = pickle.load(f)
+    except Exception as e:
+        logger.debug('No such file, loading login status failed.')
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'No such file, loading login status failed.',
+            'Ret': -1002, }})
+
+    if j.get('version', '') != VERSION:
+        logger.debug(('you have updated itchat from %s to %s, ' +
+            'so cached status is ignored') % (
+            j.get('version', 'old version'), VERSION))
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'cached status ignored because of version',
+            'Ret': -1005, }})
+    self.loginInfo = j['loginInfo']
+    self.loginInfo['User'] = templates.User(self.loginInfo['User'])
+    self.loginInfo['User'].core = self
+    self.s.cookies = requests.utils.cookiejar_from_dict(j['cookies'])
+    self.storageClass.loads(j['storage'])
+    try:
+        msgList, contactList = self.get_msg()
+    except:
+        msgList = contactList = None
+    if (msgList or contactList) is None:
+        self.logout()
+        await load_last_login_status(self.s, j['cookies'])
+        logger.debug('server refused, loading login status failed.')
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'server refused, loading login status failed.',
+            'Ret': -1003, }})
+    else:
+        if contactList:
+            for contact in contactList:
+                if '@@' in contact['UserName']:
+                    update_local_chatrooms(self, [contact])
+                else:
+                    update_local_friends(self, [contact])
+        if msgList:
+            msgList = produce_msg(self, msgList)
+            for msg in msgList: self.msgList.put(msg)
+        await self.start_receiving(exitCallback)
+        logger.debug('loading login status succeeded.')
+        if hasattr(loginCallback, '__call__'):
+            await loginCallback(self.storageClass.userName)
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'loading login status succeeded.',
+            'Ret': 0, }})
+
+async def load_last_login_status(session, cookiesDict):
+    try:
+        session.cookies = requests.utils.cookiejar_from_dict({
+            'webwxuvid': cookiesDict['webwxuvid'],
+            'webwx_auth_ticket': cookiesDict['webwx_auth_ticket'],
+            'login_frequency': '2',
+            'last_wxuin': cookiesDict['wxuin'],
+            'wxloadtime': cookiesDict['wxloadtime'] + '_expired',
+            'wxpluginkey': cookiesDict['wxloadtime'],
+            'wxuin': cookiesDict['wxuin'],
+            'mm_lang': 'zh_CN',
+            'MM_WX_NOTIFY_STATE': '1',
+            'MM_WX_SOUND_STATE': '1', })
+    except:
+        logger.info('Load status for push login failed, we may have experienced a cookies change.')
+        logger.info('If you are using the newest version of itchat, you may report a bug.')

+ 422 - 0
lib/itchat/async_components/login.py

@@ -0,0 +1,422 @@
+import asyncio
+import os, time, re, io
+import threading
+import json
+import random
+import traceback
+import logging
+try:
+    from httplib import BadStatusLine
+except ImportError:
+    from http.client import BadStatusLine
+
+import requests  # type: ignore
+from pyqrcode import QRCode
+
+from .. import config, utils
+from ..returnvalues import ReturnValue
+from ..storage.templates import wrap_user_dict
+from .contact import update_local_chatrooms, update_local_friends
+from .messages import produce_msg
+
+logger = logging.getLogger('itchat')
+
+
+def load_login(core):
+    core.login             = login
+    core.get_QRuuid        = get_QRuuid
+    core.get_QR            = get_QR
+    core.check_login       = check_login
+    core.web_init          = web_init
+    core.show_mobile_login = show_mobile_login
+    core.start_receiving   = start_receiving
+    core.get_msg           = get_msg
+    core.logout            = logout
+
+async def login(self, enableCmdQR=False, picDir=None, qrCallback=None, EventScanPayload=None,ScanStatus=None,event_stream=None,
+        loginCallback=None, exitCallback=None):
+    if self.alive or self.isLogging:
+        logger.warning('itchat has already logged in.')
+        return
+    self.isLogging = True
+
+    while self.isLogging:
+        uuid = await push_login(self)
+        if uuid:
+            payload = EventScanPayload(
+                status=ScanStatus.Waiting,
+                qrcode=f"qrcode/https://login.weixin.qq.com/l/{uuid}"
+            )
+            event_stream.emit('scan', payload)
+            await asyncio.sleep(0.1)
+        else:
+            logger.info('Getting uuid of QR code.')
+            self.get_QRuuid()
+            payload = EventScanPayload(
+                status=ScanStatus.Waiting,
+                qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
+            )
+            print(f"https://wechaty.js.org/qrcode/https://login.weixin.qq.com/l/{self.uuid}")
+            event_stream.emit('scan', payload)
+            await asyncio.sleep(0.1)
+            # logger.info('Please scan the QR code to log in.')
+        isLoggedIn = False
+        while not isLoggedIn:
+            status = await self.check_login()
+            # if hasattr(qrCallback, '__call__'):
+                # await qrCallback(uuid=self.uuid, status=status, qrcode=self.qrStorage.getvalue())
+            if status == '200':
+                isLoggedIn = True
+                payload = EventScanPayload(
+                    status=ScanStatus.Scanned,
+                    qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
+                )
+                event_stream.emit('scan', payload)
+                await asyncio.sleep(0.1)
+            elif status == '201':
+                if isLoggedIn is not None:
+                    logger.info('Please press confirm on your phone.')
+                    isLoggedIn = None
+                    payload = EventScanPayload(
+                        status=ScanStatus.Waiting,
+                        qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
+                    )
+                    event_stream.emit('scan', payload)
+                    await asyncio.sleep(0.1)
+            elif status != '408':
+                payload = EventScanPayload(
+                    status=ScanStatus.Cancel,
+                    qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
+                )
+                event_stream.emit('scan', payload)
+                await asyncio.sleep(0.1)
+                break
+        if isLoggedIn:
+            payload = EventScanPayload(
+                status=ScanStatus.Confirmed,
+                qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
+            )
+            event_stream.emit('scan', payload)
+            await asyncio.sleep(0.1)
+            break
+        elif self.isLogging:
+            logger.info('Log in time out, reloading QR code.')
+            payload = EventScanPayload(
+                status=ScanStatus.Timeout,
+                qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
+            )
+            event_stream.emit('scan', payload)
+            await asyncio.sleep(0.1)
+    else:
+        return
+    logger.info('Loading the contact, this may take a little while.')
+    await self.web_init()
+    await self.show_mobile_login()
+    self.get_contact(True)
+    if hasattr(loginCallback, '__call__'):
+        r = await loginCallback(self.storageClass.userName)
+    else:
+        utils.clear_screen()
+        if os.path.exists(picDir or config.DEFAULT_QR):
+            os.remove(picDir or config.DEFAULT_QR)
+    logger.info('Login successfully as %s' % self.storageClass.nickName)
+    await self.start_receiving(exitCallback)
+    self.isLogging = False
+
+async def push_login(core):
+    cookiesDict = core.s.cookies.get_dict()
+    if 'wxuin' in cookiesDict:
+        url = '%s/cgi-bin/mmwebwx-bin/webwxpushloginurl?uin=%s' % (
+            config.BASE_URL, cookiesDict['wxuin'])
+        headers = { 'User-Agent' : config.USER_AGENT}
+        r = core.s.get(url, headers=headers).json()
+        if 'uuid' in r and r.get('ret') in (0, '0'):
+            core.uuid = r['uuid']
+            return r['uuid']
+    return False
+
+def get_QRuuid(self):
+    url = '%s/jslogin' % config.BASE_URL
+    params = {
+        'appid' : 'wx782c26e4c19acffb',
+        'fun'   : 'new',
+        'redirect_uri' : 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?mod=desktop',
+        'lang'  : 'zh_CN' }
+    headers = { 'User-Agent' : config.USER_AGENT}
+    r = self.s.get(url, params=params, headers=headers)
+    regx = r'window.QRLogin.code = (\d+); window.QRLogin.uuid = "(\S+?)";'
+    data = re.search(regx, r.text)
+    if data and data.group(1) == '200':
+        self.uuid = data.group(2)
+        return self.uuid
+
+async def get_QR(self, uuid=None, enableCmdQR=False, picDir=None, qrCallback=None):
+    uuid = uuid or self.uuid
+    picDir = picDir or config.DEFAULT_QR
+    qrStorage = io.BytesIO()
+    qrCode = QRCode('https://login.weixin.qq.com/l/' + uuid)
+    qrCode.png(qrStorage, scale=10)
+    if hasattr(qrCallback, '__call__'):
+        await qrCallback(uuid=uuid, status='0', qrcode=qrStorage.getvalue())
+    else:
+        with open(picDir, 'wb') as f:
+            f.write(qrStorage.getvalue())
+        if enableCmdQR:
+            utils.print_cmd_qr(qrCode.text(1), enableCmdQR=enableCmdQR)
+        else:
+            utils.print_qr(picDir)
+    return qrStorage
+
+async def check_login(self, uuid=None):
+    uuid = uuid or self.uuid
+    url = '%s/cgi-bin/mmwebwx-bin/login' % config.BASE_URL
+    localTime = int(time.time())
+    params = 'loginicon=true&uuid=%s&tip=1&r=%s&_=%s' % (
+        uuid, int(-localTime / 1579), localTime)
+    headers = { 'User-Agent' : config.USER_AGENT}
+    r = self.s.get(url, params=params, headers=headers)
+    regx = r'window.code=(\d+)'
+    data = re.search(regx, r.text)
+    if data and data.group(1) == '200':
+        if await process_login_info(self, r.text):
+            return '200'
+        else:
+            return '400'
+    elif data:
+        return data.group(1)
+    else:
+        return '400'
+
+async def process_login_info(core, loginContent):
+    ''' when finish login (scanning qrcode)
+     * syncUrl and fileUploadingUrl will be fetched
+     * deviceid and msgid will be generated
+     * skey, wxsid, wxuin, pass_ticket will be fetched
+    '''
+    regx = r'window.redirect_uri="(\S+)";'
+    core.loginInfo['url'] = re.search(regx, loginContent).group(1)
+    headers = { 'User-Agent' : config.USER_AGENT,
+                'client-version' : config.UOS_PATCH_CLIENT_VERSION,
+                'extspam' : config.UOS_PATCH_EXTSPAM,
+                'referer' : 'https://wx.qq.com/?&lang=zh_CN&target=t'
+              }
+    r = core.s.get(core.loginInfo['url'], headers=headers, allow_redirects=False)
+    core.loginInfo['url'] = core.loginInfo['url'][:core.loginInfo['url'].rfind('/')]
+    for indexUrl, detailedUrl in (
+            ("wx2.qq.com"      , ("file.wx2.qq.com", "webpush.wx2.qq.com")),
+            ("wx8.qq.com"      , ("file.wx8.qq.com", "webpush.wx8.qq.com")),
+            ("qq.com"          , ("file.wx.qq.com", "webpush.wx.qq.com")),
+            ("web2.wechat.com" , ("file.web2.wechat.com", "webpush.web2.wechat.com")),
+            ("wechat.com"      , ("file.web.wechat.com", "webpush.web.wechat.com"))):
+        fileUrl, syncUrl = ['https://%s/cgi-bin/mmwebwx-bin' % url for url in detailedUrl]
+        if indexUrl in core.loginInfo['url']:
+            core.loginInfo['fileUrl'], core.loginInfo['syncUrl'] = \
+                fileUrl, syncUrl
+            break
+    else:
+        core.loginInfo['fileUrl'] = core.loginInfo['syncUrl'] = core.loginInfo['url']
+    core.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
+    core.loginInfo['logintime'] = int(time.time() * 1e3)
+    core.loginInfo['BaseRequest'] = {}
+    cookies = core.s.cookies.get_dict()
+    skey = re.findall('<skey>(.*?)</skey>', r.text, re.S)[0]
+    pass_ticket = re.findall('<pass_ticket>(.*?)</pass_ticket>', r.text, re.S)[0]
+    core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = skey
+    core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = cookies["wxsid"]
+    core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = cookies["wxuin"]
+    core.loginInfo['pass_ticket'] = pass_ticket
+
+    # A question : why pass_ticket == DeviceID ?
+    #               deviceID is only a randomly generated number
+
+    # UOS PATCH By luvletter2333, Sun Feb 28 10:00 PM
+    # for node in xml.dom.minidom.parseString(r.text).documentElement.childNodes:
+    #     if node.nodeName == 'skey':
+    #         core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = node.childNodes[0].data
+    #     elif node.nodeName == 'wxsid':
+    #         core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = node.childNodes[0].data
+    #     elif node.nodeName == 'wxuin':
+    #         core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = node.childNodes[0].data
+    #     elif node.nodeName == 'pass_ticket':
+    #         core.loginInfo['pass_ticket'] = core.loginInfo['BaseRequest']['DeviceID'] = node.childNodes[0].data
+    if not all([key in core.loginInfo for key in ('skey', 'wxsid', 'wxuin', 'pass_ticket')]):
+        logger.error('Your wechat account may be LIMITED to log in WEB wechat, error info:\n%s' % r.text)
+        core.isLogging = False
+        return False
+    return True
+
+async def web_init(self):
+    url = '%s/webwxinit' % self.loginInfo['url']
+    params = {
+        'r': int(-time.time() / 1579),
+        'pass_ticket': self.loginInfo['pass_ticket'], }
+    data = { 'BaseRequest': self.loginInfo['BaseRequest'], }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT, }
+    r = self.s.post(url, params=params, data=json.dumps(data), headers=headers)
+    dic = json.loads(r.content.decode('utf-8', 'replace'))
+    # deal with login info
+    utils.emoji_formatter(dic['User'], 'NickName')
+    self.loginInfo['InviteStartCount'] = int(dic['InviteStartCount'])
+    self.loginInfo['User'] = wrap_user_dict(utils.struct_friend_info(dic['User']))
+    self.memberList.append(self.loginInfo['User'])
+    self.loginInfo['SyncKey'] = dic['SyncKey']
+    self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
+        for item in dic['SyncKey']['List']])
+    self.storageClass.userName = dic['User']['UserName']
+    self.storageClass.nickName = dic['User']['NickName']
+    # deal with contact list returned when init
+    contactList = dic.get('ContactList', [])
+    chatroomList, otherList = [], []
+    for m in contactList:
+        if m['Sex'] != 0:
+            otherList.append(m)
+        elif '@@' in m['UserName']:
+            m['MemberList'] = [] # don't let dirty info pollute the list
+            chatroomList.append(m)
+        elif '@' in m['UserName']:
+            # mp will be dealt in update_local_friends as well
+            otherList.append(m)
+    if chatroomList:
+        update_local_chatrooms(self, chatroomList)
+    if otherList:
+        update_local_friends(self, otherList)
+    return dic
+
+async def show_mobile_login(self):
+    url = '%s/webwxstatusnotify?lang=zh_CN&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest'  : self.loginInfo['BaseRequest'],
+        'Code'         : 3,
+        'FromUserName' : self.storageClass.userName,
+        'ToUserName'   : self.storageClass.userName,
+        'ClientMsgId'  : int(time.time()), }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT, }
+    r = self.s.post(url, data=json.dumps(data), headers=headers)
+    return ReturnValue(rawResponse=r)
+
+async def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
+    self.alive = True
+    def maintain_loop():
+        retryCount = 0
+        while self.alive:
+            try:
+                i = sync_check(self)
+                if i is None:
+                    self.alive = False
+                elif i == '0':
+                    pass
+                else:
+                    msgList, contactList = self.get_msg()
+                    if msgList:
+                        msgList = produce_msg(self, msgList)
+                        for msg in msgList:
+                            self.msgList.put(msg)
+                    if contactList:
+                        chatroomList, otherList = [], []
+                        for contact in contactList:
+                            if '@@' in contact['UserName']:
+                                chatroomList.append(contact)
+                            else:
+                                otherList.append(contact)
+                        chatroomMsg = update_local_chatrooms(self, chatroomList)
+                        chatroomMsg['User'] = self.loginInfo['User']
+                        self.msgList.put(chatroomMsg)
+                        update_local_friends(self, otherList)
+                retryCount = 0
+            except requests.exceptions.ReadTimeout:
+                pass
+            except:
+                retryCount += 1
+                logger.error(traceback.format_exc())
+                if self.receivingRetryCount < retryCount:
+                    self.alive = False
+                else:
+                    time.sleep(1)
+        self.logout()
+        if hasattr(exitCallback, '__call__'):
+            exitCallback(self.storageClass.userName)
+        else:
+            logger.info('LOG OUT!')
+    if getReceivingFnOnly:
+        return maintain_loop
+    else:
+        maintainThread = threading.Thread(target=maintain_loop)
+        maintainThread.setDaemon(True)
+        maintainThread.start()
+
+def sync_check(self):
+    url = '%s/synccheck' % self.loginInfo.get('syncUrl', self.loginInfo['url'])
+    params = {
+        'r'        : int(time.time() * 1000),
+        'skey'     : self.loginInfo['skey'],
+        'sid'      : self.loginInfo['wxsid'],
+        'uin'      : self.loginInfo['wxuin'],
+        'deviceid' : self.loginInfo['deviceid'],
+        'synckey'  : self.loginInfo['synckey'],
+        '_'        : self.loginInfo['logintime'], }
+    headers = { 'User-Agent' : config.USER_AGENT}
+    self.loginInfo['logintime'] += 1
+    try:
+        r = self.s.get(url, params=params, headers=headers, timeout=config.TIMEOUT)
+    except requests.exceptions.ConnectionError as e:
+        try:
+            if not isinstance(e.args[0].args[1], BadStatusLine):
+                raise
+            # will return a package with status '0 -'
+            # and value like:
+            # 6f:00:8a:9c:09:74:e4:d8:e0:14:bf:96:3a:56:a0:64:1b:a4:25:5d:12:f4:31:a5:30:f1:c6:48:5f:c3:75:6a:99:93
+            # seems like status of typing, but before I make further achievement code will remain like this
+            return '2'
+        except:
+            raise
+    r.raise_for_status()
+    regx = r'window.synccheck={retcode:"(\d+)",selector:"(\d+)"}'
+    pm = re.search(regx, r.text)
+    if pm is None or pm.group(1) != '0':
+        logger.debug('Unexpected sync check result: %s' % r.text)
+        return None
+    return pm.group(2)
+
+def get_msg(self):
+    self.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
+    url = '%s/webwxsync?sid=%s&skey=%s&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['wxsid'],
+        self.loginInfo['skey'],self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest' : self.loginInfo['BaseRequest'],
+        'SyncKey'     : self.loginInfo['SyncKey'],
+        'rr'          : ~int(time.time()), }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    r = self.s.post(url, data=json.dumps(data), headers=headers, timeout=config.TIMEOUT)
+    dic = json.loads(r.content.decode('utf-8', 'replace'))
+    if dic['BaseResponse']['Ret'] != 0: return None, None
+    self.loginInfo['SyncKey'] = dic['SyncKey']
+    self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
+        for item in dic['SyncCheckKey']['List']])
+    return dic['AddMsgList'], dic['ModContactList']
+
+def logout(self):
+    if self.alive:
+        url = '%s/webwxlogout' % self.loginInfo['url']
+        params = {
+            'redirect' : 1,
+            'type'     : 1,
+            'skey'     : self.loginInfo['skey'], }
+        headers = { 'User-Agent' : config.USER_AGENT}
+        self.s.get(url, params=params, headers=headers)
+        self.alive = False
+    self.isLogging = False
+    self.s.cookies.clear()
+    del self.chatroomList[:]
+    del self.memberList[:]
+    del self.mpList[:]
+    return ReturnValue({'BaseResponse': {
+        'ErrMsg': 'logout successfully.',
+        'Ret': 0, }})

+ 527 - 0
lib/itchat/async_components/messages.py

@@ -0,0 +1,527 @@
+import os, time, re, io
+import json
+import mimetypes, hashlib
+import logging
+from collections import OrderedDict
+
+
+from .. import config, utils
+from ..returnvalues import ReturnValue
+from ..storage import templates
+from .contact import update_local_uin
+
+logger = logging.getLogger('itchat')
+
+def load_messages(core):
+    core.send_raw_msg = send_raw_msg
+    core.send_msg     = send_msg
+    core.upload_file  = upload_file
+    core.send_file    = send_file
+    core.send_image   = send_image
+    core.send_video   = send_video
+    core.send         = send
+    core.revoke       = revoke
+
+async def get_download_fn(core, url, msgId):
+    async def download_fn(downloadDir=None):
+        params = {
+            'msgid': msgId,
+            'skey': core.loginInfo['skey'],}
+        headers = { 'User-Agent' : config.USER_AGENT}
+        r = core.s.get(url, params=params, stream=True, headers = headers)
+        tempStorage = io.BytesIO()
+        for block in r.iter_content(1024):
+            tempStorage.write(block)
+        if downloadDir is None:
+            return tempStorage.getvalue()
+        with open(downloadDir, 'wb') as f:
+            f.write(tempStorage.getvalue())
+        tempStorage.seek(0)
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'Successfully downloaded',
+            'Ret': 0, },
+            'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
+    return download_fn
+
+def produce_msg(core, msgList):
+    ''' for messages types
+     * 40 msg, 43 videochat, 50 VOIPMSG, 52 voipnotifymsg
+     * 53 webwxvoipnotifymsg, 9999 sysnotice
+    '''
+    rl = []
+    srl = [40, 43, 50, 52, 53, 9999]
+    for m in msgList:
+        # get actual opposite
+        if m['FromUserName'] == core.storageClass.userName:
+            actualOpposite = m['ToUserName']
+        else:
+            actualOpposite = m['FromUserName']
+        # produce basic message
+        if '@@' in m['FromUserName'] or '@@' in m['ToUserName']:
+            produce_group_chat(core, m)
+        else:
+            utils.msg_formatter(m, 'Content')
+        # set user of msg
+        if '@@' in actualOpposite:
+            m['User'] = core.search_chatrooms(userName=actualOpposite) or \
+                        templates.Chatroom({'UserName': actualOpposite})
+            # we don't need to update chatroom here because we have
+            # updated once when producing basic message
+        elif actualOpposite in ('filehelper', 'fmessage'):
+            m['User'] = templates.User({'UserName': actualOpposite})
+        else:
+            m['User'] = core.search_mps(userName=actualOpposite) or \
+                        core.search_friends(userName=actualOpposite) or \
+                        templates.User(userName=actualOpposite)
+            # by default we think there may be a user missing not a mp
+        m['User'].core = core
+        if m['MsgType'] == 1: # words
+            if m['Url']:
+                regx = r'(.+?\(.+?\))'
+                data = re.search(regx, m['Content'])
+                data = 'Map' if data is None else data.group(1)
+                msg = {
+                    'Type': 'Map',
+                    'Text': data,}
+            else:
+                msg = {
+                    'Type': 'Text',
+                    'Text': m['Content'],}
+        elif m['MsgType'] == 3 or m['MsgType'] == 47: # picture
+            download_fn = get_download_fn(core,
+                '%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
+            msg = {
+                'Type'     : 'Picture',
+                'FileName' : '%s.%s' % (time.strftime('%y%m%d-%H%M%S', time.localtime()),
+                    'png' if m['MsgType'] == 3 else 'gif'),
+                'Text'     : download_fn, }
+        elif m['MsgType'] == 34: # voice
+            download_fn = get_download_fn(core,
+                '%s/webwxgetvoice' % core.loginInfo['url'], m['NewMsgId'])
+            msg = {
+                'Type': 'Recording',
+                'FileName' : '%s.mp3' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
+                'Text': download_fn,}
+        elif m['MsgType'] == 37: # friends
+            m['User']['UserName'] = m['RecommendInfo']['UserName']
+            msg = {
+                'Type': 'Friends',
+                'Text': {
+                    'status'        : m['Status'],
+                    'userName'      : m['RecommendInfo']['UserName'],
+                    'verifyContent' : m['Ticket'],
+                    'autoUpdate'    : m['RecommendInfo'], }, }
+            m['User'].verifyDict = msg['Text']
+        elif m['MsgType'] == 42: # name card
+            msg = {
+                'Type': 'Card',
+                'Text': m['RecommendInfo'], }
+        elif m['MsgType'] in (43, 62): # tiny video
+            msgId = m['MsgId']
+            async def download_video(videoDir=None):
+                url = '%s/webwxgetvideo' % core.loginInfo['url']
+                params = {
+                    'msgid': msgId,
+                    'skey': core.loginInfo['skey'],}
+                headers = {'Range': 'bytes=0-', 'User-Agent' : config.USER_AGENT}
+                r = core.s.get(url, params=params, headers=headers, stream=True)
+                tempStorage = io.BytesIO()
+                for block in r.iter_content(1024):
+                    tempStorage.write(block)
+                if videoDir is None:
+                    return tempStorage.getvalue()
+                with open(videoDir, 'wb') as f:
+                    f.write(tempStorage.getvalue())
+                return ReturnValue({'BaseResponse': {
+                    'ErrMsg': 'Successfully downloaded',
+                    'Ret': 0, }})
+            msg = {
+                'Type': 'Video',
+                'FileName' : '%s.mp4' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
+                'Text': download_video, }
+        elif m['MsgType'] == 49: # sharing
+            if m['AppMsgType'] == 0: # chat history
+                msg = {
+                    'Type': 'Note',
+                    'Text': m['Content'], }
+            elif m['AppMsgType'] == 6:
+                rawMsg = m
+                cookiesList = {name:data for name,data in core.s.cookies.items()}
+                async def download_atta(attaDir=None):
+                    url = core.loginInfo['fileUrl'] + '/webwxgetmedia'
+                    params = {
+                        'sender': rawMsg['FromUserName'],
+                        'mediaid': rawMsg['MediaId'],
+                        'filename': rawMsg['FileName'],
+                        'fromuser': core.loginInfo['wxuin'],
+                        'pass_ticket': 'undefined',
+                        'webwx_data_ticket': cookiesList['webwx_data_ticket'],}
+                    headers = { 'User-Agent' : config.USER_AGENT}
+                    r = core.s.get(url, params=params, stream=True, headers=headers)
+                    tempStorage = io.BytesIO()
+                    for block in r.iter_content(1024):
+                        tempStorage.write(block)
+                    if attaDir is None:
+                        return tempStorage.getvalue()
+                    with open(attaDir, 'wb') as f:
+                        f.write(tempStorage.getvalue())
+                    return ReturnValue({'BaseResponse': {
+                        'ErrMsg': 'Successfully downloaded',
+                        'Ret': 0, }})
+                msg = {
+                    'Type': 'Attachment',
+                    'Text': download_atta, }
+            elif m['AppMsgType'] == 8:
+                download_fn = get_download_fn(core,
+                    '%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
+                msg = {
+                    'Type'     : 'Picture',
+                    'FileName' : '%s.gif' % (
+                        time.strftime('%y%m%d-%H%M%S', time.localtime())),
+                    'Text'     : download_fn, }
+            elif m['AppMsgType'] == 17:
+                msg = {
+                    'Type': 'Note',
+                    'Text': m['FileName'], }
+            elif m['AppMsgType'] == 2000:
+                regx = r'\[CDATA\[(.+?)\][\s\S]+?\[CDATA\[(.+?)\]'
+                data = re.search(regx, m['Content'])
+                if data:
+                    data = data.group(2).split(u'\u3002')[0]
+                else:
+                    data = 'You may found detailed info in Content key.'
+                msg = {
+                    'Type': 'Note',
+                    'Text': data, }
+            else:
+                msg = {
+                    'Type': 'Sharing',
+                    'Text': m['FileName'], }
+        elif m['MsgType'] == 51: # phone init
+            msg = update_local_uin(core, m)
+        elif m['MsgType'] == 10000:
+            msg = {
+                'Type': 'Note',
+                'Text': m['Content'],}
+        elif m['MsgType'] == 10002:
+            regx = r'\[CDATA\[(.+?)\]\]'
+            data = re.search(regx, m['Content'])
+            data = 'System message' if data is None else data.group(1).replace('\\', '')
+            msg = {
+                'Type': 'Note',
+                'Text': data, }
+        elif m['MsgType'] in srl:
+            msg = {
+                'Type': 'Useless',
+                'Text': 'UselessMsg', }
+        else:
+            logger.debug('Useless message received: %s\n%s' % (m['MsgType'], str(m)))
+            msg = {
+                'Type': 'Useless',
+                'Text': 'UselessMsg', }
+        m = dict(m, **msg)
+        rl.append(m)
+    return rl
+
+def produce_group_chat(core, msg):
+    r = re.match('(@[0-9a-z]*?):<br/>(.*)$', msg['Content'])
+    if r:
+        actualUserName, content = r.groups()
+        chatroomUserName = msg['FromUserName']
+    elif msg['FromUserName'] == core.storageClass.userName:
+        actualUserName = core.storageClass.userName
+        content = msg['Content']
+        chatroomUserName = msg['ToUserName']
+    else:
+        msg['ActualUserName'] = core.storageClass.userName
+        msg['ActualNickName'] = core.storageClass.nickName
+        msg['IsAt'] = False
+        utils.msg_formatter(msg, 'Content')
+        return
+    chatroom = core.storageClass.search_chatrooms(userName=chatroomUserName)
+    member = utils.search_dict_list((chatroom or {}).get(
+        'MemberList') or [], 'UserName', actualUserName)
+    if member is None:
+        chatroom = core.update_chatroom(chatroomUserName)
+        member = utils.search_dict_list((chatroom or {}).get(
+            'MemberList') or [], 'UserName', actualUserName)
+    if member is None:
+        logger.debug('chatroom member fetch failed with %s' % actualUserName)
+        msg['ActualNickName'] = ''
+        msg['IsAt'] = False
+    else:
+        msg['ActualNickName'] = member.get('DisplayName', '') or member['NickName']
+        atFlag = '@' + (chatroom['Self'].get('DisplayName', '') or core.storageClass.nickName)
+        msg['IsAt'] = (
+            (atFlag + (u'\u2005' if u'\u2005' in msg['Content'] else ' '))
+            in msg['Content'] or msg['Content'].endswith(atFlag))
+    msg['ActualUserName'] = actualUserName
+    msg['Content']        = content
+    utils.msg_formatter(msg, 'Content')
+
+async def send_raw_msg(self, msgType, content, toUserName):
+    url = '%s/webwxsendmsg' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type': msgType,
+            'Content': content,
+            'FromUserName': self.storageClass.userName,
+            'ToUserName': (toUserName if toUserName else self.storageClass.userName),
+            'LocalID': int(time.time() * 1e4),
+            'ClientMsgId': int(time.time() * 1e4),
+            },
+        'Scene': 0, }
+    headers = { 'ContentType': 'application/json; charset=UTF-8', 'User-Agent' : config.USER_AGENT}
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+async def send_msg(self, msg='Test Message', toUserName=None):
+    logger.debug('Request to send a text message to %s: %s' % (toUserName, msg))
+    r = await self.send_raw_msg(1, msg, toUserName)
+    return r
+
+def _prepare_file(fileDir, file_=None):
+    fileDict = {}
+    if file_:
+        if hasattr(file_, 'read'):
+            file_ = file_.read()
+        else:
+            return ReturnValue({'BaseResponse': {
+                'ErrMsg': 'file_ param should be opened file',
+                'Ret': -1005, }})
+    else:
+        if not utils.check_file(fileDir):
+            return ReturnValue({'BaseResponse': {
+                'ErrMsg': 'No file found in specific dir',
+                'Ret': -1002, }})
+        with open(fileDir, 'rb') as f:
+            file_ = f.read()
+    fileDict['fileSize'] = len(file_)
+    fileDict['fileMd5'] = hashlib.md5(file_).hexdigest()
+    fileDict['file_'] = io.BytesIO(file_)
+    return fileDict
+
+def upload_file(self, fileDir, isPicture=False, isVideo=False,
+        toUserName='filehelper', file_=None, preparedFile=None):
+    logger.debug('Request to upload a %s: %s' % (
+        'picture' if isPicture else 'video' if isVideo else 'file', fileDir))
+    if not preparedFile:
+        preparedFile = _prepare_file(fileDir, file_)
+        if not preparedFile:
+            return preparedFile
+    fileSize, fileMd5, file_ = \
+        preparedFile['fileSize'], preparedFile['fileMd5'], preparedFile['file_']
+    fileSymbol = 'pic' if isPicture else 'video' if isVideo else'doc'
+    chunks = int((fileSize - 1) / 524288) + 1
+    clientMediaId = int(time.time() * 1e4)
+    uploadMediaRequest = json.dumps(OrderedDict([
+        ('UploadType', 2),
+        ('BaseRequest', self.loginInfo['BaseRequest']),
+        ('ClientMediaId', clientMediaId),
+        ('TotalLen', fileSize),
+        ('StartPos', 0),
+        ('DataLen', fileSize),
+        ('MediaType', 4),
+        ('FromUserName', self.storageClass.userName),
+        ('ToUserName', toUserName),
+        ('FileMd5', fileMd5)]
+        ), separators = (',', ':'))
+    r = {'BaseResponse': {'Ret': -1005, 'ErrMsg': 'Empty file detected'}}
+    for chunk in range(chunks):
+        r = upload_chunk_file(self, fileDir, fileSymbol, fileSize,
+            file_, chunk, chunks, uploadMediaRequest)
+    file_.close()
+    if isinstance(r, dict):
+        return ReturnValue(r)
+    return ReturnValue(rawResponse=r)
+
+def upload_chunk_file(core, fileDir, fileSymbol, fileSize,
+        file_, chunk, chunks, uploadMediaRequest):
+    url = core.loginInfo.get('fileUrl', core.loginInfo['url']) + \
+        '/webwxuploadmedia?f=json'
+    # save it on server
+    cookiesList = {name:data for name,data in core.s.cookies.items()}
+    fileType = mimetypes.guess_type(fileDir)[0] or 'application/octet-stream'
+    fileName = utils.quote(os.path.basename(fileDir))
+    files = OrderedDict([
+        ('id', (None, 'WU_FILE_0')),
+        ('name', (None, fileName)),
+        ('type', (None, fileType)),
+        ('lastModifiedDate', (None, time.strftime('%a %b %d %Y %H:%M:%S GMT+0800 (CST)'))),
+        ('size', (None, str(fileSize))),
+        ('chunks', (None, None)),
+        ('chunk', (None, None)),
+        ('mediatype', (None, fileSymbol)),
+        ('uploadmediarequest', (None, uploadMediaRequest)),
+        ('webwx_data_ticket', (None, cookiesList['webwx_data_ticket'])),
+        ('pass_ticket', (None, core.loginInfo['pass_ticket'])),
+        ('filename' , (fileName, file_.read(524288), 'application/octet-stream'))])
+    if chunks == 1:
+        del files['chunk']; del files['chunks']
+    else:
+        files['chunk'], files['chunks'] = (None, str(chunk)), (None, str(chunks))
+    headers = { 'User-Agent' : config.USER_AGENT}
+    return core.s.post(url, files=files, headers=headers, timeout=config.TIMEOUT)
+
+async def send_file(self, fileDir, toUserName=None, mediaId=None, file_=None):
+    logger.debug('Request to send a file(mediaId: %s) to %s: %s' % (
+        mediaId, toUserName, fileDir))
+    if hasattr(fileDir, 'read'):
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'fileDir param should not be an opened file in send_file',
+            'Ret': -1005, }})
+    if toUserName is None:
+        toUserName = self.storageClass.userName
+    preparedFile = _prepare_file(fileDir, file_)
+    if not preparedFile:
+        return preparedFile
+    fileSize = preparedFile['fileSize']
+    if mediaId is None:
+        r = self.upload_file(fileDir, preparedFile=preparedFile)
+        if r:
+            mediaId = r['MediaId']
+        else:
+            return r
+    url = '%s/webwxsendappmsg?fun=async&f=json' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type': 6,
+            'Content': ("<appmsg appid='wxeb7ec651dd0aefa9' sdkver=''><title>%s</title>" % os.path.basename(fileDir) +
+                "<des></des><action></action><type>6</type><content></content><url></url><lowurl></lowurl>" +
+                "<appattach><totallen>%s</totallen><attachid>%s</attachid>" % (str(fileSize), mediaId) +
+                "<fileext>%s</fileext></appattach><extinfo></extinfo></appmsg>" % os.path.splitext(fileDir)[1].replace('.','')),
+            'FromUserName': self.storageClass.userName,
+            'ToUserName': toUserName,
+            'LocalID': int(time.time() * 1e4),
+            'ClientMsgId': int(time.time() * 1e4), },
+        'Scene': 0, }
+    headers = {
+        'User-Agent': config.USER_AGENT,
+        'Content-Type': 'application/json;charset=UTF-8', }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+async def send_image(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
+    logger.debug('Request to send a image(mediaId: %s) to %s: %s' % (
+        mediaId, toUserName, fileDir))
+    if fileDir or file_:
+        if hasattr(fileDir, 'read'):
+            file_, fileDir = fileDir, None
+        if fileDir is None:
+            fileDir = 'tmp.jpg' # specific fileDir to send gifs
+    else:
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'Either fileDir or file_ should be specific',
+            'Ret': -1005, }})
+    if toUserName is None:
+        toUserName = self.storageClass.userName
+    if mediaId is None:
+        r = self.upload_file(fileDir, isPicture=not fileDir[-4:] == '.gif', file_=file_)
+        if r:
+            mediaId = r['MediaId']
+        else:
+            return r
+    url = '%s/webwxsendmsgimg?fun=async&f=json' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type': 3,
+            'MediaId': mediaId,
+            'FromUserName': self.storageClass.userName,
+            'ToUserName': toUserName,
+            'LocalID': int(time.time() * 1e4),
+            'ClientMsgId': int(time.time() * 1e4), },
+        'Scene': 0, }
+    if fileDir[-4:] == '.gif':
+        url = '%s/webwxsendemoticon?fun=sys' % self.loginInfo['url']
+        data['Msg']['Type'] = 47
+        data['Msg']['EmojiFlag'] = 2
+    headers = {
+        'User-Agent': config.USER_AGENT,
+        'Content-Type': 'application/json;charset=UTF-8', }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+async def send_video(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
+    logger.debug('Request to send a video(mediaId: %s) to %s: %s' % (
+        mediaId, toUserName, fileDir))
+    if fileDir or file_:
+        if hasattr(fileDir, 'read'):
+            file_, fileDir = fileDir, None
+        if fileDir is None:
+            fileDir = 'tmp.mp4' # specific fileDir to send other formats
+    else:
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'Either fileDir or file_ should be specific',
+            'Ret': -1005, }})
+    if toUserName is None:
+        toUserName = self.storageClass.userName
+    if mediaId is None:
+        r = self.upload_file(fileDir, isVideo=True, file_=file_)
+        if r:
+            mediaId = r['MediaId']
+        else:
+            return r
+    url = '%s/webwxsendvideomsg?fun=async&f=json&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type'         : 43,
+            'MediaId'      : mediaId,
+            'FromUserName' : self.storageClass.userName,
+            'ToUserName'   : toUserName,
+            'LocalID'      : int(time.time() * 1e4),
+            'ClientMsgId'  : int(time.time() * 1e4), },
+        'Scene': 0, }
+    headers = {
+        'User-Agent' : config.USER_AGENT,
+        'Content-Type': 'application/json;charset=UTF-8', }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+async def send(self, msg, toUserName=None, mediaId=None):
+    if not msg:
+        r = ReturnValue({'BaseResponse': {
+            'ErrMsg': 'No message.',
+            'Ret': -1005, }})
+    elif msg[:5] == '@fil@':
+        if mediaId is None:
+            r = await self.send_file(msg[5:], toUserName)
+        else:
+            r = await self.send_file(msg[5:], toUserName, mediaId)
+    elif msg[:5] == '@img@':
+        if mediaId is None:
+            r = await self.send_image(msg[5:], toUserName)
+        else:
+            r = await self.send_image(msg[5:], toUserName, mediaId)
+    elif msg[:5] == '@msg@':
+        r = await self.send_msg(msg[5:], toUserName)
+    elif msg[:5] == '@vid@':
+        if mediaId is None:
+            r = await self.send_video(msg[5:], toUserName)
+        else:
+            r = await self.send_video(msg[5:], toUserName, mediaId)
+    else:
+        r = await self.send_msg(msg, toUserName)
+    return r
+
+async def revoke(self, msgId, toUserName, localId=None):
+    url = '%s/webwxrevokemsg' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        "ClientMsgId": localId or str(time.time() * 1e3),
+        "SvrMsgId": msgId,
+        "ToUserName": toUserName}
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)

+ 106 - 0
lib/itchat/async_components/register.py

@@ -0,0 +1,106 @@
+import logging, traceback, sys, threading
+try:
+    import Queue
+except ImportError:
+    import queue as Queue  # type: ignore
+
+from ..log import set_logging
+from ..utils import test_connect
+from ..storage import templates
+
+logger = logging.getLogger('itchat')
+
+def load_register(core):
+    core.auto_login       = auto_login
+    core.configured_reply = configured_reply
+    core.msg_register     = msg_register
+    core.run              = run
+
+async def auto_login(self, EventScanPayload=None,ScanStatus=None,event_stream=None,
+        hotReload=True, statusStorageDir='itchat.pkl',
+        enableCmdQR=False, picDir=None, qrCallback=None,
+        loginCallback=None, exitCallback=None):
+    if not test_connect():
+        logger.info("You can't get access to internet or wechat domain, so exit.")
+        sys.exit()
+    self.useHotReload = hotReload
+    self.hotReloadDir = statusStorageDir
+    if hotReload:
+        if await self.load_login_status(statusStorageDir,
+                loginCallback=loginCallback, exitCallback=exitCallback):
+            return
+        await self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback, EventScanPayload=EventScanPayload, ScanStatus=ScanStatus, event_stream=event_stream,
+            loginCallback=loginCallback, exitCallback=exitCallback)
+        await self.dump_login_status(statusStorageDir)
+    else:
+        await self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback, EventScanPayload=EventScanPayload, ScanStatus=ScanStatus, event_stream=event_stream,
+            loginCallback=loginCallback, exitCallback=exitCallback)
+
+async def configured_reply(self, event_stream, payload, message_container):
+    ''' determine the type of message and reply if its method is defined
+        however, I use a strange way to determine whether a msg is from massive platform
+        I haven't found a better solution here
+        The main problem I'm worrying about is the mismatching of new friends added on phone
+        If you have any good idea, pleeeease report an issue. I will be more than grateful.
+    '''
+    try:
+        msg = self.msgList.get(timeout=1)
+        if 'MsgId' in msg.keys():
+            message_container[msg['MsgId']] = msg
+    except Queue.Empty:
+        pass
+    else:
+        if isinstance(msg['User'], templates.User):
+            replyFn = self.functionDict['FriendChat'].get(msg['Type'])
+        elif isinstance(msg['User'], templates.MassivePlatform):
+            replyFn = self.functionDict['MpChat'].get(msg['Type'])
+        elif isinstance(msg['User'], templates.Chatroom):
+            replyFn = self.functionDict['GroupChat'].get(msg['Type'])
+        if replyFn is None:
+            r = None
+        else:
+            try:
+                r = await replyFn(msg)
+                if r is not None:
+                    await self.send(r, msg.get('FromUserName'))
+            except:
+                logger.warning(traceback.format_exc())
+
+def msg_register(self, msgType, isFriendChat=False, isGroupChat=False, isMpChat=False):
+    ''' a decorator constructor
+        return a specific decorator based on information given '''
+    if not (isinstance(msgType, list) or isinstance(msgType, tuple)):
+        msgType = [msgType]
+    def _msg_register(fn):
+        for _msgType in msgType:
+            if isFriendChat:
+                self.functionDict['FriendChat'][_msgType] = fn
+            if isGroupChat:
+                self.functionDict['GroupChat'][_msgType] = fn
+            if isMpChat:
+                self.functionDict['MpChat'][_msgType] = fn
+            if not any((isFriendChat, isGroupChat, isMpChat)):
+                self.functionDict['FriendChat'][_msgType] = fn
+        return fn
+    return _msg_register
+
+async def run(self, debug=False, blockThread=True):
+    logger.info('Start auto replying.')
+    if debug:
+        set_logging(loggingLevel=logging.DEBUG)
+    async def reply_fn():
+        try:
+            while self.alive:
+                await self.configured_reply()
+        except KeyboardInterrupt:
+            if self.useHotReload:
+                await self.dump_login_status()
+            self.alive = False
+            logger.debug('itchat received an ^C and exit.')
+            logger.info('Bye~')
+    if blockThread:
+        await reply_fn()
+    else:
+        replyThread = threading.Thread(target=reply_fn)
+        replyThread.setDaemon(True)
+        replyThread.start()

+ 12 - 0
lib/itchat/components/__init__.py

@@ -0,0 +1,12 @@
+from .contact import load_contact
+from .hotreload import load_hotreload
+from .login import load_login
+from .messages import load_messages
+from .register import load_register
+
+def load_components(core):
+    load_contact(core)
+    load_hotreload(core)
+    load_login(core)
+    load_messages(core)
+    load_register(core)

+ 519 - 0
lib/itchat/components/contact.py

@@ -0,0 +1,519 @@
+import time
+import re
+import io
+import json
+import copy
+import logging
+
+from .. import config, utils
+from ..returnvalues import ReturnValue
+from ..storage import contact_change
+from ..utils import update_info_dict
+
+logger = logging.getLogger('itchat')
+
+
+def load_contact(core):
+    core.update_chatroom = update_chatroom
+    core.update_friend = update_friend
+    core.get_contact = get_contact
+    core.get_friends = get_friends
+    core.get_chatrooms = get_chatrooms
+    core.get_mps = get_mps
+    core.set_alias = set_alias
+    core.set_pinned = set_pinned
+    core.accept_friend = accept_friend
+    core.get_head_img = get_head_img
+    core.create_chatroom = create_chatroom
+    core.set_chatroom_name = set_chatroom_name
+    core.delete_member_from_chatroom = delete_member_from_chatroom
+    core.add_member_into_chatroom = add_member_into_chatroom
+
+
+def update_chatroom(self, userName, detailedMember=False):
+    if not isinstance(userName, list):
+        userName = [userName]
+    url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
+        self.loginInfo['url'], int(time.time()))
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Count': len(userName),
+        'List': [{
+            'UserName': u,
+            'ChatRoomId': '', } for u in userName], }
+    chatroomList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
+                                          ).content.decode('utf8', 'replace')).get('ContactList')
+    if not chatroomList:
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'No chatroom found',
+            'Ret': -1001, }})
+
+    if detailedMember:
+        def get_detailed_member_info(encryChatroomId, memberList):
+            url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
+                self.loginInfo['url'], int(time.time()))
+            headers = {
+                'ContentType': 'application/json; charset=UTF-8',
+                'User-Agent': config.USER_AGENT, }
+            data = {
+                'BaseRequest': self.loginInfo['BaseRequest'],
+                'Count': len(memberList),
+                'List': [{
+                    'UserName': member['UserName'],
+                    'EncryChatRoomId': encryChatroomId}
+                    for member in memberList], }
+            return json.loads(self.s.post(url, data=json.dumps(data), headers=headers
+                                          ).content.decode('utf8', 'replace'))['ContactList']
+        MAX_GET_NUMBER = 50
+        for chatroom in chatroomList:
+            totalMemberList = []
+            for i in range(int(len(chatroom['MemberList']) / MAX_GET_NUMBER + 1)):
+                memberList = chatroom['MemberList'][i *
+                                                    MAX_GET_NUMBER: (i+1)*MAX_GET_NUMBER]
+                totalMemberList += get_detailed_member_info(
+                    chatroom['EncryChatRoomId'], memberList)
+            chatroom['MemberList'] = totalMemberList
+
+    update_local_chatrooms(self, chatroomList)
+    r = [self.storageClass.search_chatrooms(userName=c['UserName'])
+         for c in chatroomList]
+    return r if 1 < len(r) else r[0]
+
+
+def update_friend(self, userName):
+    if not isinstance(userName, list):
+        userName = [userName]
+    url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
+        self.loginInfo['url'], int(time.time()))
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Count': len(userName),
+        'List': [{
+            'UserName': u,
+            'EncryChatRoomId': '', } for u in userName], }
+    friendList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
+                                        ).content.decode('utf8', 'replace')).get('ContactList')
+
+    update_local_friends(self, friendList)
+    r = [self.storageClass.search_friends(userName=f['UserName'])
+         for f in friendList]
+    return r if len(r) != 1 else r[0]
+
+
+@contact_change
+def update_local_chatrooms(core, l):
+    '''
+        get a list of chatrooms for updating local chatrooms
+        return a list of given chatrooms with updated info
+    '''
+    for chatroom in l:
+        # format new chatrooms
+        utils.emoji_formatter(chatroom, 'NickName')
+        for member in chatroom['MemberList']:
+            if 'NickName' in member:
+                utils.emoji_formatter(member, 'NickName')
+            if 'DisplayName' in member:
+                utils.emoji_formatter(member, 'DisplayName')
+            if 'RemarkName' in member:
+                utils.emoji_formatter(member, 'RemarkName')
+        # update it to old chatrooms
+        oldChatroom = utils.search_dict_list(
+            core.chatroomList, 'UserName', chatroom['UserName'])
+        if oldChatroom:
+            update_info_dict(oldChatroom, chatroom)
+            #  - update other values
+            memberList = chatroom.get('MemberList', [])
+            oldMemberList = oldChatroom['MemberList']
+            if memberList:
+                for member in memberList:
+                    oldMember = utils.search_dict_list(
+                        oldMemberList, 'UserName', member['UserName'])
+                    if oldMember:
+                        update_info_dict(oldMember, member)
+                    else:
+                        oldMemberList.append(member)
+        else:
+            core.chatroomList.append(chatroom)
+            oldChatroom = utils.search_dict_list(
+                core.chatroomList, 'UserName', chatroom['UserName'])
+        # delete useless members
+        if len(chatroom['MemberList']) != len(oldChatroom['MemberList']) and \
+                chatroom['MemberList']:
+            existsUserNames = [member['UserName']
+                               for member in chatroom['MemberList']]
+            delList = []
+            for i, member in enumerate(oldChatroom['MemberList']):
+                if member['UserName'] not in existsUserNames:
+                    delList.append(i)
+            delList.sort(reverse=True)
+            for i in delList:
+                del oldChatroom['MemberList'][i]
+        #  - update OwnerUin
+        if oldChatroom.get('ChatRoomOwner') and oldChatroom.get('MemberList'):
+            owner = utils.search_dict_list(oldChatroom['MemberList'],
+                                           'UserName', oldChatroom['ChatRoomOwner'])
+            oldChatroom['OwnerUin'] = (owner or {}).get('Uin', 0)
+        #  - update IsAdmin
+        if 'OwnerUin' in oldChatroom and oldChatroom['OwnerUin'] != 0:
+            oldChatroom['IsAdmin'] = \
+                oldChatroom['OwnerUin'] == int(core.loginInfo['wxuin'])
+        else:
+            oldChatroom['IsAdmin'] = None
+        #  - update Self
+        newSelf = utils.search_dict_list(oldChatroom['MemberList'],
+                                         'UserName', core.storageClass.userName)
+        oldChatroom['Self'] = newSelf or copy.deepcopy(core.loginInfo['User'])
+    return {
+        'Type': 'System',
+        'Text': [chatroom['UserName'] for chatroom in l],
+        'SystemInfo': 'chatrooms',
+        'FromUserName': core.storageClass.userName,
+        'ToUserName': core.storageClass.userName, }
+
+
+@contact_change
+def update_local_friends(core, l):
+    '''
+        get a list of friends or mps for updating local contact
+    '''
+    fullList = core.memberList + core.mpList
+    for friend in l:
+        if 'NickName' in friend:
+            utils.emoji_formatter(friend, 'NickName')
+        if 'DisplayName' in friend:
+            utils.emoji_formatter(friend, 'DisplayName')
+        if 'RemarkName' in friend:
+            utils.emoji_formatter(friend, 'RemarkName')
+        oldInfoDict = utils.search_dict_list(
+            fullList, 'UserName', friend['UserName'])
+        if oldInfoDict is None:
+            oldInfoDict = copy.deepcopy(friend)
+            if oldInfoDict['VerifyFlag'] & 8 == 0:
+                core.memberList.append(oldInfoDict)
+            else:
+                core.mpList.append(oldInfoDict)
+        else:
+            update_info_dict(oldInfoDict, friend)
+
+
+@contact_change
+def update_local_uin(core, msg):
+    '''
+        content contains uins and StatusNotifyUserName contains username
+        they are in same order, so what I do is to pair them together
+
+        I caught an exception in this method while not knowing why
+        but don't worry, it won't cause any problem
+    '''
+    uins = re.search('<username>([^<]*?)<', msg['Content'])
+    usernameChangedList = []
+    r = {
+        'Type': 'System',
+        'Text': usernameChangedList,
+        'SystemInfo': 'uins', }
+    if uins:
+        uins = uins.group(1).split(',')
+        usernames = msg['StatusNotifyUserName'].split(',')
+        if 0 < len(uins) == len(usernames):
+            for uin, username in zip(uins, usernames):
+                if not '@' in username:
+                    continue
+                fullContact = core.memberList + core.chatroomList + core.mpList
+                userDicts = utils.search_dict_list(fullContact,
+                                                   'UserName', username)
+                if userDicts:
+                    if userDicts.get('Uin', 0) == 0:
+                        userDicts['Uin'] = uin
+                        usernameChangedList.append(username)
+                        logger.debug('Uin fetched: %s, %s' % (username, uin))
+                    else:
+                        if userDicts['Uin'] != uin:
+                            logger.debug('Uin changed: %s, %s' % (
+                                userDicts['Uin'], uin))
+                else:
+                    if '@@' in username:
+                        core.storageClass.updateLock.release()
+                        update_chatroom(core, username)
+                        core.storageClass.updateLock.acquire()
+                        newChatroomDict = utils.search_dict_list(
+                            core.chatroomList, 'UserName', username)
+                        if newChatroomDict is None:
+                            newChatroomDict = utils.struct_friend_info({
+                                'UserName': username,
+                                'Uin': uin,
+                                'Self': copy.deepcopy(core.loginInfo['User'])})
+                            core.chatroomList.append(newChatroomDict)
+                        else:
+                            newChatroomDict['Uin'] = uin
+                    elif '@' in username:
+                        core.storageClass.updateLock.release()
+                        update_friend(core, username)
+                        core.storageClass.updateLock.acquire()
+                        newFriendDict = utils.search_dict_list(
+                            core.memberList, 'UserName', username)
+                        if newFriendDict is None:
+                            newFriendDict = utils.struct_friend_info({
+                                'UserName': username,
+                                'Uin': uin, })
+                            core.memberList.append(newFriendDict)
+                        else:
+                            newFriendDict['Uin'] = uin
+                    usernameChangedList.append(username)
+                    logger.debug('Uin fetched: %s, %s' % (username, uin))
+        else:
+            logger.debug('Wrong length of uins & usernames: %s, %s' % (
+                len(uins), len(usernames)))
+    else:
+        logger.debug('No uins in 51 message')
+        logger.debug(msg['Content'])
+    return r
+
+
+def get_contact(self, update=False):
+    if not update:
+        return utils.contact_deep_copy(self, self.chatroomList)
+
+    def _get_contact(seq=0):
+        url = '%s/webwxgetcontact?r=%s&seq=%s&skey=%s' % (self.loginInfo['url'],
+                                                          int(time.time()), seq, self.loginInfo['skey'])
+        headers = {
+            'ContentType': 'application/json; charset=UTF-8',
+            'User-Agent': config.USER_AGENT, }
+        try:
+            r = self.s.get(url, headers=headers)
+        except:
+            logger.info(
+                'Failed to fetch contact, that may because of the amount of your chatrooms')
+            for chatroom in self.get_chatrooms():
+                self.update_chatroom(chatroom['UserName'], detailedMember=True)
+            return 0, []
+        j = json.loads(r.content.decode('utf-8', 'replace'))
+        return j.get('Seq', 0), j.get('MemberList')
+    seq, memberList = 0, []
+    while 1:
+        seq, batchMemberList = _get_contact(seq)
+        memberList.extend(batchMemberList)
+        if seq == 0:
+            break
+    chatroomList, otherList = [], []
+    for m in memberList:
+        if m['Sex'] != 0:
+            otherList.append(m)
+        elif '@@' in m['UserName']:
+            chatroomList.append(m)
+        elif '@' in m['UserName']:
+            # mp will be dealt in update_local_friends as well
+            otherList.append(m)
+    if chatroomList:
+        update_local_chatrooms(self, chatroomList)
+    if otherList:
+        update_local_friends(self, otherList)
+    return utils.contact_deep_copy(self, chatroomList)
+
+
+def get_friends(self, update=False):
+    if update:
+        self.get_contact(update=True)
+    return utils.contact_deep_copy(self, self.memberList)
+
+
+def get_chatrooms(self, update=False, contactOnly=False):
+    if contactOnly:
+        return self.get_contact(update=True)
+    else:
+        if update:
+            self.get_contact(True)
+        return utils.contact_deep_copy(self, self.chatroomList)
+
+
+def get_mps(self, update=False):
+    if update:
+        self.get_contact(update=True)
+    return utils.contact_deep_copy(self, self.mpList)
+
+
+def set_alias(self, userName, alias):
+    oldFriendInfo = utils.search_dict_list(
+        self.memberList, 'UserName', userName)
+    if oldFriendInfo is None:
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1001, }})
+    url = '%s/webwxoplog?lang=%s&pass_ticket=%s' % (
+        self.loginInfo['url'], 'zh_CN', self.loginInfo['pass_ticket'])
+    data = {
+        'UserName': userName,
+        'CmdId': 2,
+        'RemarkName': alias,
+        'BaseRequest': self.loginInfo['BaseRequest'], }
+    headers = {'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, json.dumps(data, ensure_ascii=False).encode('utf8'),
+                    headers=headers)
+    r = ReturnValue(rawResponse=r)
+    if r:
+        oldFriendInfo['RemarkName'] = alias
+    return r
+
+
+def set_pinned(self, userName, isPinned=True):
+    url = '%s/webwxoplog?pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'UserName': userName,
+        'CmdId': 3,
+        'OP': int(isPinned),
+        'BaseRequest': self.loginInfo['BaseRequest'], }
+    headers = {'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, json=data, headers=headers)
+    return ReturnValue(rawResponse=r)
+
+
+def accept_friend(self, userName, v4='', autoUpdate=True):
+    url = f"{self.loginInfo['url']}/webwxverifyuser?r={int(time.time())}&pass_ticket={self.loginInfo['pass_ticket']}"
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Opcode': 3,  # 3
+        'VerifyUserListSize': 1,
+        'VerifyUserList': [{
+            'Value': userName,
+            'VerifyUserTicket': v4, }],
+        'VerifyContent': '',
+        'SceneListCount': 1,
+        'SceneList': [33],
+        'skey': self.loginInfo['skey'], }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, headers=headers,
+                    data=json.dumps(data, ensure_ascii=False).encode('utf8', 'replace'))
+    if autoUpdate:
+        self.update_friend(userName)
+    return ReturnValue(rawResponse=r)
+
+
+def get_head_img(self, userName=None, chatroomUserName=None, picDir=None):
+    ''' get head image
+     * if you want to get chatroom header: only set chatroomUserName
+     * if you want to get friend header: only set userName
+     * if you want to get chatroom member header: set both
+    '''
+    params = {
+        'userName': userName or chatroomUserName or self.storageClass.userName,
+        'skey': self.loginInfo['skey'],
+        'type': 'big', }
+    url = '%s/webwxgeticon' % self.loginInfo['url']
+    if chatroomUserName is None:
+        infoDict = self.storageClass.search_friends(userName=userName)
+        if infoDict is None:
+            return ReturnValue({'BaseResponse': {
+                'ErrMsg': 'No friend found',
+                'Ret': -1001, }})
+    else:
+        if userName is None:
+            url = '%s/webwxgetheadimg' % self.loginInfo['url']
+        else:
+            chatroom = self.storageClass.search_chatrooms(
+                userName=chatroomUserName)
+            if chatroomUserName is None:
+                return ReturnValue({'BaseResponse': {
+                    'ErrMsg': 'No chatroom found',
+                    'Ret': -1001, }})
+            if 'EncryChatRoomId' in chatroom:
+                params['chatroomid'] = chatroom['EncryChatRoomId']
+            params['chatroomid'] = params.get(
+                'chatroomid') or chatroom['UserName']
+    headers = {'User-Agent': config.USER_AGENT}
+    r = self.s.get(url, params=params, stream=True, headers=headers)
+    tempStorage = io.BytesIO()
+    for block in r.iter_content(1024):
+        tempStorage.write(block)
+    if picDir is None:
+        return tempStorage.getvalue()
+    with open(picDir, 'wb') as f:
+        f.write(tempStorage.getvalue())
+    tempStorage.seek(0)
+    return ReturnValue({'BaseResponse': {
+        'ErrMsg': 'Successfully downloaded',
+        'Ret': 0, },
+        'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
+
+
+def create_chatroom(self, memberList, topic=''):
+    url = '%s/webwxcreatechatroom?pass_ticket=%s&r=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'], int(time.time()))
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'MemberCount': len(memberList.split(',')),
+        'MemberList': [{'UserName': member} for member in memberList.split(',')],
+        'Topic': topic, }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, headers=headers,
+                    data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
+    return ReturnValue(rawResponse=r)
+
+
+def set_chatroom_name(self, chatroomUserName, name):
+    url = '%s/webwxupdatechatroom?fun=modtopic&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'ChatRoomName': chatroomUserName,
+        'NewTopic': name, }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, headers=headers,
+                    data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
+    return ReturnValue(rawResponse=r)
+
+
+def delete_member_from_chatroom(self, chatroomUserName, memberList):
+    url = '%s/webwxupdatechatroom?fun=delmember&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'ChatRoomName': chatroomUserName,
+        'DelMemberList': ','.join([member['UserName'] for member in memberList]), }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, data=json.dumps(data), headers=headers)
+    return ReturnValue(rawResponse=r)
+
+
+def add_member_into_chatroom(self, chatroomUserName, memberList,
+                             useInvitation=False):
+    ''' add or invite member into chatroom
+     * there are two ways to get members into chatroom: invite or directly add
+     * but for chatrooms with more than 40 users, you can only use invite
+     * but don't worry we will auto-force userInvitation for you when necessary
+    '''
+    if not useInvitation:
+        chatroom = self.storageClass.search_chatrooms(
+            userName=chatroomUserName)
+        if not chatroom:
+            chatroom = self.update_chatroom(chatroomUserName)
+        if len(chatroom['MemberList']) > self.loginInfo['InviteStartCount']:
+            useInvitation = True
+    if useInvitation:
+        fun, memberKeyName = 'invitemember', 'InviteMemberList'
+    else:
+        fun, memberKeyName = 'addmember', 'AddMemberList'
+    url = '%s/webwxupdatechatroom?fun=%s&pass_ticket=%s' % (
+        self.loginInfo['url'], fun, self.loginInfo['pass_ticket'])
+    params = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'ChatRoomName': chatroomUserName,
+        memberKeyName: memberList, }
+    headers = {
+        'content-type': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, data=json.dumps(params), headers=headers)
+    return ReturnValue(rawResponse=r)

+ 102 - 0
lib/itchat/components/hotreload.py

@@ -0,0 +1,102 @@
+import pickle, os
+import logging
+
+import requests
+
+from ..config import VERSION
+from ..returnvalues import ReturnValue
+from ..storage import templates
+from .contact import update_local_chatrooms, update_local_friends
+from .messages import produce_msg
+
+logger = logging.getLogger('itchat')
+
+def load_hotreload(core):
+    core.dump_login_status = dump_login_status
+    core.load_login_status = load_login_status
+
+def dump_login_status(self, fileDir=None):
+    fileDir = fileDir or self.hotReloadDir
+    try:
+        with open(fileDir, 'w') as f:
+            f.write('itchat - DELETE THIS')
+        os.remove(fileDir)
+    except:
+        raise Exception('Incorrect fileDir')
+    status = {
+        'version'   : VERSION,
+        'loginInfo' : self.loginInfo,
+        'cookies'   : self.s.cookies.get_dict(),
+        'storage'   : self.storageClass.dumps()}
+    with open(fileDir, 'wb') as f:
+        pickle.dump(status, f)
+    logger.debug('Dump login status for hot reload successfully.')
+
+def load_login_status(self, fileDir,
+        loginCallback=None, exitCallback=None):
+    try:
+        with open(fileDir, 'rb') as f:
+            j = pickle.load(f)
+    except Exception as e:
+        logger.debug('No such file, loading login status failed.')
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'No such file, loading login status failed.',
+            'Ret': -1002, }})
+
+    if j.get('version', '') != VERSION:
+        logger.debug(('you have updated itchat from %s to %s, ' + 
+            'so cached status is ignored') % (
+            j.get('version', 'old version'), VERSION))
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'cached status ignored because of version',
+            'Ret': -1005, }})
+    self.loginInfo = j['loginInfo']
+    self.loginInfo['User'] = templates.User(self.loginInfo['User'])
+    self.loginInfo['User'].core = self
+    self.s.cookies = requests.utils.cookiejar_from_dict(j['cookies'])
+    self.storageClass.loads(j['storage'])
+    try:
+        msgList, contactList = self.get_msg()
+    except:
+        msgList = contactList = None
+    if (msgList or contactList) is None:
+        self.logout()
+        load_last_login_status(self.s, j['cookies'])
+        logger.debug('server refused, loading login status failed.')
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'server refused, loading login status failed.',
+            'Ret': -1003, }})
+    else:
+        if contactList:
+            for contact in contactList:
+                if '@@' in contact['UserName']:
+                    update_local_chatrooms(self, [contact])
+                else:
+                    update_local_friends(self, [contact])
+        if msgList:
+            msgList = produce_msg(self, msgList)
+            for msg in msgList: self.msgList.put(msg)
+        self.start_receiving(exitCallback)
+        logger.debug('loading login status succeeded.')
+        if hasattr(loginCallback, '__call__'):
+            loginCallback()
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'loading login status succeeded.',
+            'Ret': 0, }})
+
+def load_last_login_status(session, cookiesDict):
+    try:
+        session.cookies = requests.utils.cookiejar_from_dict({
+            'webwxuvid': cookiesDict['webwxuvid'],
+            'webwx_auth_ticket': cookiesDict['webwx_auth_ticket'],
+            'login_frequency': '2',
+            'last_wxuin': cookiesDict['wxuin'],
+            'wxloadtime': cookiesDict['wxloadtime'] + '_expired',
+            'wxpluginkey': cookiesDict['wxloadtime'],
+            'wxuin': cookiesDict['wxuin'],
+            'mm_lang': 'zh_CN',
+            'MM_WX_NOTIFY_STATE': '1',
+            'MM_WX_SOUND_STATE': '1', })
+    except:
+        logger.info('Load status for push login failed, we may have experienced a cookies change.')
+        logger.info('If you are using the newest version of itchat, you may report a bug.')

+ 410 - 0
lib/itchat/components/login.py

@@ -0,0 +1,410 @@
+import os
+import time
+import re
+import io
+import threading
+import json
+import xml.dom.minidom
+import random
+import traceback
+import logging
+try:
+    from httplib import BadStatusLine
+except ImportError:
+    from http.client import BadStatusLine
+
+import requests
+from pyqrcode import QRCode
+
+from .. import config, utils
+from ..returnvalues import ReturnValue
+from ..storage.templates import wrap_user_dict
+from .contact import update_local_chatrooms, update_local_friends
+from .messages import produce_msg
+
+logger = logging.getLogger('itchat')
+
+
+def load_login(core):
+    core.login = login
+    core.get_QRuuid = get_QRuuid
+    core.get_QR = get_QR
+    core.check_login = check_login
+    core.web_init = web_init
+    core.show_mobile_login = show_mobile_login
+    core.start_receiving = start_receiving
+    core.get_msg = get_msg
+    core.logout = logout
+
+
+def login(self, enableCmdQR=False, picDir=None, qrCallback=None,
+          loginCallback=None, exitCallback=None):
+    if self.alive or self.isLogging:
+        logger.warning('itchat has already logged in.')
+        return
+    self.isLogging = True
+    while self.isLogging:
+        uuid = push_login(self)
+        if uuid:
+            qrStorage = io.BytesIO()
+        else:
+            logger.info('Getting uuid of QR code.')
+            while not self.get_QRuuid():
+                time.sleep(1)
+            logger.info('Downloading QR code.')
+            qrStorage = self.get_QR(enableCmdQR=enableCmdQR,
+                                    picDir=picDir, qrCallback=qrCallback)
+            logger.info('Please scan the QR code to log in.')
+        isLoggedIn = False
+        while not isLoggedIn:
+            status = self.check_login()
+            if hasattr(qrCallback, '__call__'):
+                qrCallback(uuid=self.uuid, status=status,
+                           qrcode=qrStorage.getvalue())
+            if status == '200':
+                isLoggedIn = True
+            elif status == '201':
+                if isLoggedIn is not None:
+                    logger.info('Please press confirm on your phone.')
+                    isLoggedIn = None
+                    time.sleep(7)
+            elif status != '408':
+                break
+        if isLoggedIn:
+            break
+        elif self.isLogging:
+            logger.info('Log in time out, reloading QR code.')
+    else:
+        return  # log in process is stopped by user
+    logger.info('Loading the contact, this may take a little while.')
+    self.web_init()
+    self.show_mobile_login()
+    self.get_contact(True)
+    if hasattr(loginCallback, '__call__'):
+        r = loginCallback()
+    else:
+        utils.clear_screen()
+        if os.path.exists(picDir or config.DEFAULT_QR):
+            os.remove(picDir or config.DEFAULT_QR)
+        logger.info('Login successfully as %s' % self.storageClass.nickName)
+    self.start_receiving(exitCallback)
+    self.isLogging = False
+
+
+def push_login(core):
+    cookiesDict = core.s.cookies.get_dict()
+    if 'wxuin' in cookiesDict:
+        url = '%s/cgi-bin/mmwebwx-bin/webwxpushloginurl?uin=%s' % (
+            config.BASE_URL, cookiesDict['wxuin'])
+        headers = {'User-Agent': config.USER_AGENT}
+        r = core.s.get(url, headers=headers).json()
+        if 'uuid' in r and r.get('ret') in (0, '0'):
+            core.uuid = r['uuid']
+            return r['uuid']
+    return False
+
+
+def get_QRuuid(self):
+    url = '%s/jslogin' % config.BASE_URL
+    params = {
+        'appid': 'wx782c26e4c19acffb',
+        'fun': 'new',
+        'redirect_uri': 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?mod=desktop',
+        'lang': 'zh_CN'}
+    headers = {'User-Agent': config.USER_AGENT}
+    r = self.s.get(url, params=params, headers=headers)
+    regx = r'window.QRLogin.code = (\d+); window.QRLogin.uuid = "(\S+?)";'
+    data = re.search(regx, r.text)
+    if data and data.group(1) == '200':
+        self.uuid = data.group(2)
+        return self.uuid
+
+
+def get_QR(self, uuid=None, enableCmdQR=False, picDir=None, qrCallback=None):
+    uuid = uuid or self.uuid
+    picDir = picDir or config.DEFAULT_QR
+    qrStorage = io.BytesIO()
+    qrCode = QRCode('https://login.weixin.qq.com/l/' + uuid)
+    qrCode.png(qrStorage, scale=10)
+    if hasattr(qrCallback, '__call__'):
+        qrCallback(uuid=uuid, status='0', qrcode=qrStorage.getvalue())
+    else:
+        with open(picDir, 'wb') as f:
+            f.write(qrStorage.getvalue())
+        if enableCmdQR:
+            utils.print_cmd_qr(qrCode.text(1), enableCmdQR=enableCmdQR)
+        else:
+            utils.print_qr(picDir)
+    return qrStorage
+
+
+def check_login(self, uuid=None):
+    uuid = uuid or self.uuid
+    url = '%s/cgi-bin/mmwebwx-bin/login' % config.BASE_URL
+    localTime = int(time.time())
+    params = 'loginicon=true&uuid=%s&tip=1&r=%s&_=%s' % (
+        uuid, int(-localTime / 1579), localTime)
+    headers = {'User-Agent': config.USER_AGENT}
+    r = self.s.get(url, params=params, headers=headers)
+    regx = r'window.code=(\d+)'
+    data = re.search(regx, r.text)
+    if data and data.group(1) == '200':
+        if process_login_info(self, r.text):
+            return '200'
+        else:
+            return '400'
+    elif data:
+        return data.group(1)
+    else:
+        return '400'
+
+
+def process_login_info(core, loginContent):
+    ''' when finish login (scanning qrcode)
+     * syncUrl and fileUploadingUrl will be fetched
+     * deviceid and msgid will be generated
+     * skey, wxsid, wxuin, pass_ticket will be fetched
+    '''
+    regx = r'window.redirect_uri="(\S+)";'
+    core.loginInfo['url'] = re.search(regx, loginContent).group(1)
+    headers = {'User-Agent': config.USER_AGENT,
+               'client-version': config.UOS_PATCH_CLIENT_VERSION,
+               'extspam': config.UOS_PATCH_EXTSPAM,
+               'referer': 'https://wx.qq.com/?&lang=zh_CN&target=t'
+               }
+    r = core.s.get(core.loginInfo['url'],
+                   headers=headers, allow_redirects=False)
+    core.loginInfo['url'] = core.loginInfo['url'][:core.loginInfo['url'].rfind(
+        '/')]
+    for indexUrl, detailedUrl in (
+            ("wx2.qq.com", ("file.wx2.qq.com", "webpush.wx2.qq.com")),
+            ("wx8.qq.com", ("file.wx8.qq.com", "webpush.wx8.qq.com")),
+            ("qq.com", ("file.wx.qq.com", "webpush.wx.qq.com")),
+            ("web2.wechat.com", ("file.web2.wechat.com", "webpush.web2.wechat.com")),
+            ("wechat.com", ("file.web.wechat.com", "webpush.web.wechat.com"))):
+        fileUrl, syncUrl = ['https://%s/cgi-bin/mmwebwx-bin' %
+                            url for url in detailedUrl]
+        if indexUrl in core.loginInfo['url']:
+            core.loginInfo['fileUrl'], core.loginInfo['syncUrl'] = \
+                fileUrl, syncUrl
+            break
+    else:
+        core.loginInfo['fileUrl'] = core.loginInfo['syncUrl'] = core.loginInfo['url']
+    core.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
+    core.loginInfo['logintime'] = int(time.time() * 1e3)
+    core.loginInfo['BaseRequest'] = {}
+    cookies = core.s.cookies.get_dict()
+    skey = re.findall('<skey>(.*?)</skey>', r.text, re.S)[0]
+    pass_ticket = re.findall(
+        '<pass_ticket>(.*?)</pass_ticket>', r.text, re.S)[0]
+    core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = skey
+    core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = cookies["wxsid"]
+    core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = cookies["wxuin"]
+    core.loginInfo['pass_ticket'] = pass_ticket
+    # A question : why pass_ticket == DeviceID ?
+    #               deviceID is only a randomly generated number
+
+    # UOS PATCH By luvletter2333, Sun Feb 28 10:00 PM
+    # for node in xml.dom.minidom.parseString(r.text).documentElement.childNodes:
+    #     if node.nodeName == 'skey':
+    #         core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = node.childNodes[0].data
+    #     elif node.nodeName == 'wxsid':
+    #         core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = node.childNodes[0].data
+    #     elif node.nodeName == 'wxuin':
+    #         core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = node.childNodes[0].data
+    #     elif node.nodeName == 'pass_ticket':
+    #         core.loginInfo['pass_ticket'] = core.loginInfo['BaseRequest']['DeviceID'] = node.childNodes[0].data
+    if not all([key in core.loginInfo for key in ('skey', 'wxsid', 'wxuin', 'pass_ticket')]):
+        logger.error(
+            'Your wechat account may be LIMITED to log in WEB wechat, error info:\n%s' % r.text)
+        core.isLogging = False
+        return False
+    return True
+
+
+def web_init(self):
+    url = '%s/webwxinit' % self.loginInfo['url']
+    params = {
+        'r': int(-time.time() / 1579),
+        'pass_ticket': self.loginInfo['pass_ticket'], }
+    data = {'BaseRequest': self.loginInfo['BaseRequest'], }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT, }
+    r = self.s.post(url, params=params, data=json.dumps(data), headers=headers)
+    dic = json.loads(r.content.decode('utf-8', 'replace'))
+    # deal with login info
+    utils.emoji_formatter(dic['User'], 'NickName')
+    self.loginInfo['InviteStartCount'] = int(dic['InviteStartCount'])
+    self.loginInfo['User'] = wrap_user_dict(
+        utils.struct_friend_info(dic['User']))
+    self.memberList.append(self.loginInfo['User'])
+    self.loginInfo['SyncKey'] = dic['SyncKey']
+    self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
+                                          for item in dic['SyncKey']['List']])
+    self.storageClass.userName = dic['User']['UserName']
+    self.storageClass.nickName = dic['User']['NickName']
+    # deal with contact list returned when init
+    contactList = dic.get('ContactList', [])
+    chatroomList, otherList = [], []
+    for m in contactList:
+        if m['Sex'] != 0:
+            otherList.append(m)
+        elif '@@' in m['UserName']:
+            m['MemberList'] = []  # don't let dirty info pollute the list
+            chatroomList.append(m)
+        elif '@' in m['UserName']:
+            # mp will be dealt in update_local_friends as well
+            otherList.append(m)
+    if chatroomList:
+        update_local_chatrooms(self, chatroomList)
+    if otherList:
+        update_local_friends(self, otherList)
+    return dic
+
+
+def show_mobile_login(self):
+    url = '%s/webwxstatusnotify?lang=zh_CN&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Code': 3,
+        'FromUserName': self.storageClass.userName,
+        'ToUserName': self.storageClass.userName,
+        'ClientMsgId': int(time.time()), }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT, }
+    r = self.s.post(url, data=json.dumps(data), headers=headers)
+    return ReturnValue(rawResponse=r)
+
+
+def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
+    self.alive = True
+
+    def maintain_loop():
+        retryCount = 0
+        while self.alive:
+            try:
+                i = sync_check(self)
+                if i is None:
+                    self.alive = False
+                elif i == '0':
+                    pass
+                else:
+                    msgList, contactList = self.get_msg()
+                    if msgList:
+                        msgList = produce_msg(self, msgList)
+                        for msg in msgList:
+                            self.msgList.put(msg)
+                    if contactList:
+                        chatroomList, otherList = [], []
+                        for contact in contactList:
+                            if '@@' in contact['UserName']:
+                                chatroomList.append(contact)
+                            else:
+                                otherList.append(contact)
+                        chatroomMsg = update_local_chatrooms(
+                            self, chatroomList)
+                        chatroomMsg['User'] = self.loginInfo['User']
+                        self.msgList.put(chatroomMsg)
+                        update_local_friends(self, otherList)
+                retryCount = 0
+            except requests.exceptions.ReadTimeout:
+                pass
+            except:
+                retryCount += 1
+                logger.error(traceback.format_exc())
+                if self.receivingRetryCount < retryCount:
+                    self.alive = False
+                else:
+                    time.sleep(1)
+        self.logout()
+        if hasattr(exitCallback, '__call__'):
+            exitCallback()
+        else:
+            logger.info('LOG OUT!')
+    if getReceivingFnOnly:
+        return maintain_loop
+    else:
+        maintainThread = threading.Thread(target=maintain_loop)
+        maintainThread.setDaemon(True)
+        maintainThread.start()
+
+
+def sync_check(self):
+    url = '%s/synccheck' % self.loginInfo.get('syncUrl', self.loginInfo['url'])
+    params = {
+        'r': int(time.time() * 1000),
+        'skey': self.loginInfo['skey'],
+        'sid': self.loginInfo['wxsid'],
+        'uin': self.loginInfo['wxuin'],
+        'deviceid': self.loginInfo['deviceid'],
+        'synckey': self.loginInfo['synckey'],
+        '_': self.loginInfo['logintime'], }
+    headers = {'User-Agent': config.USER_AGENT}
+    self.loginInfo['logintime'] += 1
+    try:
+        r = self.s.get(url, params=params, headers=headers,
+                       timeout=config.TIMEOUT)
+    except requests.exceptions.ConnectionError as e:
+        try:
+            if not isinstance(e.args[0].args[1], BadStatusLine):
+                raise
+            # will return a package with status '0 -'
+            # and value like:
+            # 6f:00:8a:9c:09:74:e4:d8:e0:14:bf:96:3a:56:a0:64:1b:a4:25:5d:12:f4:31:a5:30:f1:c6:48:5f:c3:75:6a:99:93
+            # seems like status of typing, but before I make further achievement code will remain like this
+            return '2'
+        except:
+            raise
+    r.raise_for_status()
+    regx = r'window.synccheck={retcode:"(\d+)",selector:"(\d+)"}'
+    pm = re.search(regx, r.text)
+    if pm is None or pm.group(1) != '0':
+        logger.debug('Unexpected sync check result: %s' % r.text)
+        return None
+    return pm.group(2)
+
+
+def get_msg(self):
+    self.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
+    url = '%s/webwxsync?sid=%s&skey=%s&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['wxsid'],
+        self.loginInfo['skey'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'SyncKey': self.loginInfo['SyncKey'],
+        'rr': ~int(time.time()), }
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent': config.USER_AGENT}
+    r = self.s.post(url, data=json.dumps(data),
+                    headers=headers, timeout=config.TIMEOUT)
+    dic = json.loads(r.content.decode('utf-8', 'replace'))
+    if dic['BaseResponse']['Ret'] != 0:
+        return None, None
+    self.loginInfo['SyncKey'] = dic['SyncKey']
+    self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
+                                          for item in dic['SyncCheckKey']['List']])
+    return dic['AddMsgList'], dic['ModContactList']
+
+
+def logout(self):
+    if self.alive:
+        url = '%s/webwxlogout' % self.loginInfo['url']
+        params = {
+            'redirect': 1,
+            'type': 1,
+            'skey': self.loginInfo['skey'], }
+        headers = {'User-Agent': config.USER_AGENT}
+        self.s.get(url, params=params, headers=headers)
+        self.alive = False
+    self.isLogging = False
+    self.s.cookies.clear()
+    del self.chatroomList[:]
+    del self.memberList[:]
+    del self.mpList[:]
+    return ReturnValue({'BaseResponse': {
+        'ErrMsg': 'logout successfully.',
+        'Ret': 0, }})

+ 528 - 0
lib/itchat/components/messages.py

@@ -0,0 +1,528 @@
+import os, time, re, io
+import json
+import mimetypes, hashlib
+import logging
+from collections import OrderedDict
+
+import requests
+
+from .. import config, utils
+from ..returnvalues import ReturnValue
+from ..storage import templates
+from .contact import update_local_uin
+
+logger = logging.getLogger('itchat')
+
+def load_messages(core):
+    core.send_raw_msg = send_raw_msg
+    core.send_msg     = send_msg
+    core.upload_file  = upload_file
+    core.send_file    = send_file
+    core.send_image   = send_image
+    core.send_video   = send_video
+    core.send         = send
+    core.revoke       = revoke
+
+def get_download_fn(core, url, msgId):
+    def download_fn(downloadDir=None):
+        params = {
+            'msgid': msgId,
+            'skey': core.loginInfo['skey'],}
+        headers = { 'User-Agent' : config.USER_AGENT }
+        r = core.s.get(url, params=params, stream=True, headers = headers)
+        tempStorage = io.BytesIO()
+        for block in r.iter_content(1024):
+            tempStorage.write(block)
+        if downloadDir is None:
+            return tempStorage.getvalue()
+        with open(downloadDir, 'wb') as f:
+            f.write(tempStorage.getvalue())
+        tempStorage.seek(0)
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'Successfully downloaded',
+            'Ret': 0, },
+            'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
+    return download_fn
+
+def produce_msg(core, msgList):
+    ''' for messages types
+     * 40 msg, 43 videochat, 50 VOIPMSG, 52 voipnotifymsg
+     * 53 webwxvoipnotifymsg, 9999 sysnotice
+    '''
+    rl = []
+    srl = [40, 43, 50, 52, 53, 9999]
+    for m in msgList:
+        # get actual opposite
+        if m['FromUserName'] == core.storageClass.userName:
+            actualOpposite = m['ToUserName']
+        else:
+            actualOpposite = m['FromUserName']
+        # produce basic message
+        if '@@' in m['FromUserName'] or '@@' in m['ToUserName']:
+            produce_group_chat(core, m)
+        else:
+            utils.msg_formatter(m, 'Content')
+        # set user of msg
+        if '@@' in actualOpposite:
+            m['User'] = core.search_chatrooms(userName=actualOpposite) or \
+                templates.Chatroom({'UserName': actualOpposite})
+            # we don't need to update chatroom here because we have
+            # updated once when producing basic message
+        elif actualOpposite in ('filehelper', 'fmessage'):
+            m['User'] = templates.User({'UserName': actualOpposite})
+        else:
+            m['User'] = core.search_mps(userName=actualOpposite) or \
+                core.search_friends(userName=actualOpposite) or \
+                templates.User(userName=actualOpposite)
+            # by default we think there may be a user missing not a mp
+        m['User'].core = core
+        if m['MsgType'] == 1: # words
+            if m['Url']:
+                regx = r'(.+?\(.+?\))'
+                data = re.search(regx, m['Content'])
+                data = 'Map' if data is None else data.group(1)
+                msg = {
+                    'Type': 'Map',
+                    'Text': data,}
+            else:
+                msg = {
+                    'Type': 'Text',
+                    'Text': m['Content'],}
+        elif m['MsgType'] == 3 or m['MsgType'] == 47: # picture
+            download_fn = get_download_fn(core,
+                '%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
+            msg = {
+                'Type'     : 'Picture',
+                'FileName' : '%s.%s' % (time.strftime('%y%m%d-%H%M%S', time.localtime()),
+                    'png' if m['MsgType'] == 3 else 'gif'),
+                'Text'     : download_fn, }
+        elif m['MsgType'] == 34: # voice
+            download_fn = get_download_fn(core,
+                '%s/webwxgetvoice' % core.loginInfo['url'], m['NewMsgId'])
+            msg = {
+                'Type': 'Recording',
+                'FileName' : '%s.mp3' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
+                'Text': download_fn,}
+        elif m['MsgType'] == 37: # friends
+            m['User']['UserName'] = m['RecommendInfo']['UserName']
+            msg = {
+                'Type': 'Friends',
+                'Text': {
+                    'status'        : m['Status'],
+                    'userName'      : m['RecommendInfo']['UserName'],
+                    'verifyContent' : m['Ticket'],
+                    'autoUpdate'    : m['RecommendInfo'], }, }
+            m['User'].verifyDict = msg['Text']
+        elif m['MsgType'] == 42: # name card
+            msg = {
+                'Type': 'Card',
+                'Text': m['RecommendInfo'], }
+        elif m['MsgType'] in (43, 62): # tiny video
+            msgId = m['MsgId']
+            def download_video(videoDir=None):
+                url = '%s/webwxgetvideo' % core.loginInfo['url']
+                params = {
+                    'msgid': msgId,
+                    'skey': core.loginInfo['skey'],}
+                headers = {'Range': 'bytes=0-', 'User-Agent' : config.USER_AGENT }
+                r = core.s.get(url, params=params, headers=headers, stream=True)
+                tempStorage = io.BytesIO()
+                for block in r.iter_content(1024):
+                    tempStorage.write(block)
+                if videoDir is None:
+                    return tempStorage.getvalue()
+                with open(videoDir, 'wb') as f:
+                    f.write(tempStorage.getvalue())
+                return ReturnValue({'BaseResponse': {
+                    'ErrMsg': 'Successfully downloaded',
+                    'Ret': 0, }})
+            msg = {
+                'Type': 'Video',
+                'FileName' : '%s.mp4' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
+                'Text': download_video, }
+        elif m['MsgType'] == 49: # sharing
+            if m['AppMsgType'] == 0: # chat history
+                msg = {
+                    'Type': 'Note',
+                    'Text': m['Content'], }
+            elif m['AppMsgType'] == 6:
+                rawMsg = m
+                cookiesList = {name:data for name,data in core.s.cookies.items()}
+                def download_atta(attaDir=None):
+                    url = core.loginInfo['fileUrl'] + '/webwxgetmedia'
+                    params = {
+                        'sender': rawMsg['FromUserName'],
+                        'mediaid': rawMsg['MediaId'],
+                        'filename': rawMsg['FileName'],
+                        'fromuser': core.loginInfo['wxuin'],
+                        'pass_ticket': 'undefined',
+                        'webwx_data_ticket': cookiesList['webwx_data_ticket'],}
+                    headers = { 'User-Agent' : config.USER_AGENT }
+                    r = core.s.get(url, params=params, stream=True, headers=headers)
+                    tempStorage = io.BytesIO()
+                    for block in r.iter_content(1024):
+                        tempStorage.write(block)
+                    if attaDir is None:
+                        return tempStorage.getvalue()
+                    with open(attaDir, 'wb') as f:
+                        f.write(tempStorage.getvalue())
+                    return ReturnValue({'BaseResponse': {
+                        'ErrMsg': 'Successfully downloaded',
+                        'Ret': 0, }})
+                msg = {
+                    'Type': 'Attachment',
+                    'Text': download_atta, }
+            elif m['AppMsgType'] == 8:
+                download_fn = get_download_fn(core,
+                    '%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
+                msg = {
+                    'Type'     : 'Picture',
+                    'FileName' : '%s.gif' % (
+                        time.strftime('%y%m%d-%H%M%S', time.localtime())),
+                    'Text'     : download_fn, }
+            elif m['AppMsgType'] == 17:
+                msg = {
+                    'Type': 'Note',
+                    'Text': m['FileName'], }
+            elif m['AppMsgType'] == 2000:
+                regx = r'\[CDATA\[(.+?)\][\s\S]+?\[CDATA\[(.+?)\]'
+                data = re.search(regx, m['Content'])
+                if data:
+                    data = data.group(2).split(u'\u3002')[0]
+                else:
+                    data = 'You may found detailed info in Content key.'
+                msg = {
+                    'Type': 'Note',
+                    'Text': data, }
+            else:
+                msg = {
+                    'Type': 'Sharing',
+                    'Text': m['FileName'], }
+        elif m['MsgType'] == 51: # phone init
+            msg = update_local_uin(core, m)
+        elif m['MsgType'] == 10000:
+            msg = {
+                'Type': 'Note',
+                'Text': m['Content'],}
+        elif m['MsgType'] == 10002:
+            regx = r'\[CDATA\[(.+?)\]\]'
+            data = re.search(regx, m['Content'])
+            data = 'System message' if data is None else data.group(1).replace('\\', '')
+            msg = {
+                'Type': 'Note',
+                'Text': data, }
+        elif m['MsgType'] in srl:
+            msg = {
+                'Type': 'Useless',
+                'Text': 'UselessMsg', }
+        else:
+            logger.debug('Useless message received: %s\n%s' % (m['MsgType'], str(m)))
+            msg = {
+                'Type': 'Useless',
+                'Text': 'UselessMsg', }
+        m = dict(m, **msg)
+        rl.append(m)
+    return rl
+
+def produce_group_chat(core, msg):
+    r = re.match('(@[0-9a-z]*?):<br/>(.*)$', msg['Content'])
+    if r:
+        actualUserName, content = r.groups()
+        chatroomUserName = msg['FromUserName']
+    elif msg['FromUserName'] == core.storageClass.userName:
+        actualUserName = core.storageClass.userName
+        content = msg['Content']
+        chatroomUserName = msg['ToUserName']
+    else:
+        msg['ActualUserName'] = core.storageClass.userName
+        msg['ActualNickName'] = core.storageClass.nickName
+        msg['IsAt'] = False
+        utils.msg_formatter(msg, 'Content')
+        return
+    chatroom = core.storageClass.search_chatrooms(userName=chatroomUserName)
+    member = utils.search_dict_list((chatroom or {}).get(
+        'MemberList') or [], 'UserName', actualUserName)
+    if member is None:
+        chatroom = core.update_chatroom(chatroomUserName)
+        member = utils.search_dict_list((chatroom or {}).get(
+            'MemberList') or [], 'UserName', actualUserName)
+    if member is None:
+        logger.debug('chatroom member fetch failed with %s' % actualUserName)
+        msg['ActualNickName'] = ''
+        msg['IsAt'] = False
+    else:
+        msg['ActualNickName'] = member.get('DisplayName', '') or member['NickName']
+        atFlag = '@' + (chatroom['Self'].get('DisplayName', '') or core.storageClass.nickName)
+        msg['IsAt'] = (
+            (atFlag + (u'\u2005' if u'\u2005' in msg['Content'] else ' '))
+            in msg['Content'] or msg['Content'].endswith(atFlag))
+    msg['ActualUserName'] = actualUserName
+    msg['Content']        = content
+    utils.msg_formatter(msg, 'Content')
+
+def send_raw_msg(self, msgType, content, toUserName):
+    url = '%s/webwxsendmsg' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type': msgType,
+            'Content': content,
+            'FromUserName': self.storageClass.userName,
+            'ToUserName': (toUserName if toUserName else self.storageClass.userName),
+            'LocalID': int(time.time() * 1e4),
+            'ClientMsgId': int(time.time() * 1e4),
+            },
+        'Scene': 0, }
+    headers = { 'ContentType': 'application/json; charset=UTF-8', 'User-Agent' : config.USER_AGENT }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+def send_msg(self, msg='Test Message', toUserName=None):
+    logger.debug('Request to send a text message to %s: %s' % (toUserName, msg))
+    r = self.send_raw_msg(1, msg, toUserName)
+    return r
+
+def _prepare_file(fileDir, file_=None):
+    fileDict = {}
+    if file_:
+        if hasattr(file_, 'read'):
+            file_ = file_.read()
+        else:
+            return ReturnValue({'BaseResponse': {
+                'ErrMsg': 'file_ param should be opened file',
+                'Ret': -1005, }})
+    else:
+        if not utils.check_file(fileDir):
+            return ReturnValue({'BaseResponse': {
+                'ErrMsg': 'No file found in specific dir',
+                'Ret': -1002, }})
+        with open(fileDir, 'rb') as f:
+            file_ = f.read()
+    fileDict['fileSize'] = len(file_)
+    fileDict['fileMd5'] = hashlib.md5(file_).hexdigest()
+    fileDict['file_'] = io.BytesIO(file_)
+    return fileDict
+
+def upload_file(self, fileDir, isPicture=False, isVideo=False,
+        toUserName='filehelper', file_=None, preparedFile=None):
+    logger.debug('Request to upload a %s: %s' % (
+        'picture' if isPicture else 'video' if isVideo else 'file', fileDir))
+    if not preparedFile:
+        preparedFile = _prepare_file(fileDir, file_)
+        if not preparedFile:
+            return preparedFile
+    fileSize, fileMd5, file_ = \
+        preparedFile['fileSize'], preparedFile['fileMd5'], preparedFile['file_']
+    fileSymbol = 'pic' if isPicture else 'video' if isVideo else'doc'
+    chunks = int((fileSize - 1) / 524288) + 1
+    clientMediaId = int(time.time() * 1e4)
+    uploadMediaRequest = json.dumps(OrderedDict([
+        ('UploadType', 2),
+        ('BaseRequest', self.loginInfo['BaseRequest']),
+        ('ClientMediaId', clientMediaId),
+        ('TotalLen', fileSize),
+        ('StartPos', 0),
+        ('DataLen', fileSize),
+        ('MediaType', 4),
+        ('FromUserName', self.storageClass.userName),
+        ('ToUserName', toUserName),
+        ('FileMd5', fileMd5)]
+        ), separators = (',', ':'))
+    r = {'BaseResponse': {'Ret': -1005, 'ErrMsg': 'Empty file detected'}}
+    for chunk in range(chunks):
+        r = upload_chunk_file(self, fileDir, fileSymbol, fileSize,
+            file_, chunk, chunks, uploadMediaRequest)
+    file_.close()
+    if isinstance(r, dict):
+        return ReturnValue(r)
+    return ReturnValue(rawResponse=r)
+
+def upload_chunk_file(core, fileDir, fileSymbol, fileSize,
+        file_, chunk, chunks, uploadMediaRequest):
+    url = core.loginInfo.get('fileUrl', core.loginInfo['url']) + \
+        '/webwxuploadmedia?f=json'
+    # save it on server
+    cookiesList = {name:data for name,data in core.s.cookies.items()}
+    fileType = mimetypes.guess_type(fileDir)[0] or 'application/octet-stream'
+    fileName = utils.quote(os.path.basename(fileDir))
+    files = OrderedDict([
+        ('id', (None, 'WU_FILE_0')),
+        ('name', (None, fileName)),
+        ('type', (None, fileType)),
+        ('lastModifiedDate', (None, time.strftime('%a %b %d %Y %H:%M:%S GMT+0800 (CST)'))),
+        ('size', (None, str(fileSize))),
+        ('chunks', (None, None)),
+        ('chunk', (None, None)),
+        ('mediatype', (None, fileSymbol)),
+        ('uploadmediarequest', (None, uploadMediaRequest)),
+        ('webwx_data_ticket', (None, cookiesList['webwx_data_ticket'])),
+        ('pass_ticket', (None, core.loginInfo['pass_ticket'])),
+        ('filename' , (fileName, file_.read(524288), 'application/octet-stream'))])
+    if chunks == 1:
+        del files['chunk']; del files['chunks']
+    else:
+        files['chunk'], files['chunks'] = (None, str(chunk)), (None, str(chunks))
+    headers = { 'User-Agent' : config.USER_AGENT }
+    return core.s.post(url, files=files, headers=headers, timeout=config.TIMEOUT)
+
+def send_file(self, fileDir, toUserName=None, mediaId=None, file_=None):
+    logger.debug('Request to send a file(mediaId: %s) to %s: %s' % (
+        mediaId, toUserName, fileDir))
+    if hasattr(fileDir, 'read'):
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'fileDir param should not be an opened file in send_file',
+            'Ret': -1005, }})
+    if toUserName is None:
+        toUserName = self.storageClass.userName
+    preparedFile = _prepare_file(fileDir, file_)
+    if not preparedFile:
+        return preparedFile
+    fileSize = preparedFile['fileSize']
+    if mediaId is None:
+        r = self.upload_file(fileDir, preparedFile=preparedFile)
+        if r:
+            mediaId = r['MediaId']
+        else:
+            return r
+    url = '%s/webwxsendappmsg?fun=async&f=json' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type': 6,
+            'Content': ("<appmsg appid='wxeb7ec651dd0aefa9' sdkver=''><title>%s</title>" % os.path.basename(fileDir) +
+                "<des></des><action></action><type>6</type><content></content><url></url><lowurl></lowurl>" +
+                "<appattach><totallen>%s</totallen><attachid>%s</attachid>" % (str(fileSize), mediaId) +
+                "<fileext>%s</fileext></appattach><extinfo></extinfo></appmsg>" % os.path.splitext(fileDir)[1].replace('.','')),
+            'FromUserName': self.storageClass.userName,
+            'ToUserName': toUserName,
+            'LocalID': int(time.time() * 1e4),
+            'ClientMsgId': int(time.time() * 1e4), },
+        'Scene': 0, }
+    headers = {
+        'User-Agent': config.USER_AGENT,
+        'Content-Type': 'application/json;charset=UTF-8', }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+def send_image(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
+    logger.debug('Request to send a image(mediaId: %s) to %s: %s' % (
+        mediaId, toUserName, fileDir))
+    if fileDir or file_:
+        if hasattr(fileDir, 'read'):
+            file_, fileDir = fileDir, None
+        if fileDir is None:
+            fileDir = 'tmp.jpg' # specific fileDir to send gifs
+    else:
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'Either fileDir or file_ should be specific',
+            'Ret': -1005, }})
+    if toUserName is None:
+        toUserName = self.storageClass.userName
+    if mediaId is None:
+        r = self.upload_file(fileDir, isPicture=not fileDir[-4:] == '.gif', file_=file_)
+        if r:
+            mediaId = r['MediaId']
+        else:
+            return r
+    url = '%s/webwxsendmsgimg?fun=async&f=json' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type': 3,
+            'MediaId': mediaId,
+            'FromUserName': self.storageClass.userName,
+            'ToUserName': toUserName,
+            'LocalID': int(time.time() * 1e4),
+            'ClientMsgId': int(time.time() * 1e4), },
+        'Scene': 0, }
+    if fileDir[-4:] == '.gif':
+        url = '%s/webwxsendemoticon?fun=sys' % self.loginInfo['url']
+        data['Msg']['Type'] = 47
+        data['Msg']['EmojiFlag'] = 2
+    headers = {
+        'User-Agent': config.USER_AGENT,
+        'Content-Type': 'application/json;charset=UTF-8', }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+def send_video(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
+    logger.debug('Request to send a video(mediaId: %s) to %s: %s' % (
+        mediaId, toUserName, fileDir))
+    if fileDir or file_:
+        if hasattr(fileDir, 'read'):
+            file_, fileDir = fileDir, None
+        if fileDir is None:
+            fileDir = 'tmp.mp4' # specific fileDir to send other formats
+    else:
+        return ReturnValue({'BaseResponse': {
+            'ErrMsg': 'Either fileDir or file_ should be specific',
+            'Ret': -1005, }})
+    if toUserName is None:
+        toUserName = self.storageClass.userName
+    if mediaId is None:
+        r = self.upload_file(fileDir, isVideo=True, file_=file_)
+        if r:
+            mediaId = r['MediaId']
+        else:
+            return r
+    url = '%s/webwxsendvideomsg?fun=async&f=json&pass_ticket=%s' % (
+        self.loginInfo['url'], self.loginInfo['pass_ticket'])
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        'Msg': {
+            'Type'         : 43,
+            'MediaId'      : mediaId,
+            'FromUserName' : self.storageClass.userName,
+            'ToUserName'   : toUserName,
+            'LocalID'      : int(time.time() * 1e4),
+            'ClientMsgId'  : int(time.time() * 1e4), },
+        'Scene': 0, }
+    headers = {
+        'User-Agent' : config.USER_AGENT,
+        'Content-Type': 'application/json;charset=UTF-8', }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)
+
+def send(self, msg, toUserName=None, mediaId=None):
+    if not msg:
+        r = ReturnValue({'BaseResponse': {
+            'ErrMsg': 'No message.',
+            'Ret': -1005, }})
+    elif msg[:5] == '@fil@':
+        if mediaId is None:
+            r = self.send_file(msg[5:], toUserName)
+        else:
+            r = self.send_file(msg[5:], toUserName, mediaId)
+    elif msg[:5] == '@img@':
+        if mediaId is None:
+            r = self.send_image(msg[5:], toUserName)
+        else:
+            r = self.send_image(msg[5:], toUserName, mediaId)
+    elif msg[:5] == '@msg@':
+        r = self.send_msg(msg[5:], toUserName)
+    elif msg[:5] == '@vid@':
+        if mediaId is None:
+            r = self.send_video(msg[5:], toUserName)
+        else:
+            r = self.send_video(msg[5:], toUserName, mediaId)
+    else:
+        r = self.send_msg(msg, toUserName)
+    return r
+
+def revoke(self, msgId, toUserName, localId=None):
+    url = '%s/webwxrevokemsg' % self.loginInfo['url']
+    data = {
+        'BaseRequest': self.loginInfo['BaseRequest'],
+        "ClientMsgId": localId or str(time.time() * 1e3),
+        "SvrMsgId": msgId,
+        "ToUserName": toUserName}
+    headers = {
+        'ContentType': 'application/json; charset=UTF-8',
+        'User-Agent' : config.USER_AGENT }
+    r = self.s.post(url, headers=headers,
+        data=json.dumps(data, ensure_ascii=False).encode('utf8'))
+    return ReturnValue(rawResponse=r)

+ 103 - 0
lib/itchat/components/register.py

@@ -0,0 +1,103 @@
+import logging, traceback, sys, threading
+try:
+    import Queue
+except ImportError:
+    import queue as Queue
+
+from ..log import set_logging
+from ..utils import test_connect
+from ..storage import templates
+
+logger = logging.getLogger('itchat')
+
+def load_register(core):
+    core.auto_login       = auto_login
+    core.configured_reply = configured_reply
+    core.msg_register     = msg_register
+    core.run              = run
+
+def auto_login(self, hotReload=False, statusStorageDir='itchat.pkl',
+        enableCmdQR=False, picDir=None, qrCallback=None,
+        loginCallback=None, exitCallback=None):
+    if not test_connect():
+        logger.info("You can't get access to internet or wechat domain, so exit.")
+        sys.exit()
+    self.useHotReload = hotReload
+    self.hotReloadDir = statusStorageDir
+    if hotReload:
+        if self.load_login_status(statusStorageDir,
+                loginCallback=loginCallback, exitCallback=exitCallback):
+            return
+        self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback,
+            loginCallback=loginCallback, exitCallback=exitCallback)
+        self.dump_login_status(statusStorageDir)
+    else:
+        self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback,
+            loginCallback=loginCallback, exitCallback=exitCallback)
+
+def configured_reply(self):
+    ''' determine the type of message and reply if its method is defined
+        however, I use a strange way to determine whether a msg is from massive platform
+        I haven't found a better solution here
+        The main problem I'm worrying about is the mismatching of new friends added on phone
+        If you have any good idea, pleeeease report an issue. I will be more than grateful.
+    '''
+    try:
+        msg = self.msgList.get(timeout=1)
+    except Queue.Empty:
+        pass
+    else:
+        if isinstance(msg['User'], templates.User):
+            replyFn = self.functionDict['FriendChat'].get(msg['Type'])
+        elif isinstance(msg['User'], templates.MassivePlatform):
+            replyFn = self.functionDict['MpChat'].get(msg['Type'])
+        elif isinstance(msg['User'], templates.Chatroom):
+            replyFn = self.functionDict['GroupChat'].get(msg['Type'])
+        if replyFn is None:
+            r = None
+        else:
+            try:
+                r = replyFn(msg)
+                if r is not None:
+                    self.send(r, msg.get('FromUserName'))
+            except:
+                logger.warning(traceback.format_exc())
+
+def msg_register(self, msgType, isFriendChat=False, isGroupChat=False, isMpChat=False):
+    ''' a decorator constructor
+        return a specific decorator based on information given '''
+    if not (isinstance(msgType, list) or isinstance(msgType, tuple)):
+        msgType = [msgType]
+    def _msg_register(fn):
+        for _msgType in msgType:
+            if isFriendChat:
+                self.functionDict['FriendChat'][_msgType] = fn
+            if isGroupChat:
+                self.functionDict['GroupChat'][_msgType] = fn
+            if isMpChat:
+                self.functionDict['MpChat'][_msgType] = fn
+            if not any((isFriendChat, isGroupChat, isMpChat)):
+                self.functionDict['FriendChat'][_msgType] = fn
+        return fn
+    return _msg_register
+
+def run(self, debug=False, blockThread=True):
+    logger.info('Start auto replying.')
+    if debug:
+        set_logging(loggingLevel=logging.DEBUG)
+    def reply_fn():
+        try:
+            while self.alive:
+                self.configured_reply()
+        except KeyboardInterrupt:
+            if self.useHotReload:
+                self.dump_login_status()
+            self.alive = False
+            logger.debug('itchat received an ^C and exit.')
+            logger.info('Bye~')
+    if blockThread:
+        reply_fn()
+    else:
+        replyThread = threading.Thread(target=reply_fn)
+        replyThread.setDaemon(True)
+        replyThread.start()

+ 17 - 0
lib/itchat/config.py

@@ -0,0 +1,17 @@
+import os, platform
+
+VERSION = '1.5.0.dev'
+
+# use this envrionment to initialize the async & sync componment
+ASYNC_COMPONENTS = os.environ.get('ITCHAT_UOS_ASYNC', False)
+
+BASE_URL = 'https://login.weixin.qq.com'
+OS = platform.system() # Windows, Linux, Darwin
+DIR = os.getcwd()
+DEFAULT_QR = 'QR.png'
+TIMEOUT = (10, 60)
+
+USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.71 Safari/537.36'
+
+UOS_PATCH_CLIENT_VERSION = '2.0.0'
+UOS_PATCH_EXTSPAM = 'Go8FCIkFEokFCggwMDAwMDAwMRAGGvAESySibk50w5Wb3uTl2c2h64jVVrV7gNs06GFlWplHQbY/5FfiO++1yH4ykCyNPWKXmco+wfQzK5R98D3so7rJ5LmGFvBLjGceleySrc3SOf2Pc1gVehzJgODeS0lDL3/I/0S2SSE98YgKleq6Uqx6ndTy9yaL9qFxJL7eiA/R3SEfTaW1SBoSITIu+EEkXff+Pv8NHOk7N57rcGk1w0ZzRrQDkXTOXFN2iHYIzAAZPIOY45Lsh+A4slpgnDiaOvRtlQYCt97nmPLuTipOJ8Qc5pM7ZsOsAPPrCQL7nK0I7aPrFDF0q4ziUUKettzW8MrAaiVfmbD1/VkmLNVqqZVvBCtRblXb5FHmtS8FxnqCzYP4WFvz3T0TcrOqwLX1M/DQvcHaGGw0B0y4bZMs7lVScGBFxMj3vbFi2SRKbKhaitxHfYHAOAa0X7/MSS0RNAjdwoyGHeOepXOKY+h3iHeqCvgOH6LOifdHf/1aaZNwSkGotYnYScW8Yx63LnSwba7+hESrtPa/huRmB9KWvMCKbDThL/nne14hnL277EDCSocPu3rOSYjuB9gKSOdVmWsj9Dxb/iZIe+S6AiG29Esm+/eUacSba0k8wn5HhHg9d4tIcixrxveflc8vi2/wNQGVFNsGO6tB5WF0xf/plngOvQ1/ivGV/C1Qpdhzznh0ExAVJ6dwzNg7qIEBaw+BzTJTUuRcPk92Sn6QDn2Pu3mpONaEumacjW4w6ipPnPw+g2TfywJjeEcpSZaP4Q3YV5HG8D6UjWA4GSkBKculWpdCMadx0usMomsSS/74QgpYqcPkmamB4nVv1JxczYITIqItIKjD35IGKAUwAA=='

+ 14 - 0
lib/itchat/content.py

@@ -0,0 +1,14 @@
+TEXT       = 'Text'
+MAP        = 'Map'
+CARD       = 'Card'
+NOTE       = 'Note'
+SHARING    = 'Sharing'
+PICTURE    = 'Picture'
+RECORDING  = VOICE = 'Recording'
+ATTACHMENT = 'Attachment'
+VIDEO      = 'Video'
+FRIENDS    = 'Friends'
+SYSTEM     = 'System'
+
+INCOME_MSG = [TEXT, MAP, CARD, NOTE, SHARING, PICTURE,
+    RECORDING, VOICE, ATTACHMENT, VIDEO, FRIENDS, SYSTEM]

+ 456 - 0
lib/itchat/core.py

@@ -0,0 +1,456 @@
+import requests
+
+from . import storage
+
+class Core(object):
+    def __init__(self):
+        ''' init is the only method defined in core.py
+            alive is value showing whether core is running
+                - you should call logout method to change it
+                - after logout, a core object can login again
+            storageClass only uses basic python types
+                - so for advanced uses, inherit it yourself
+            receivingRetryCount is for receiving loop retry
+                - it's 5 now, but actually even 1 is enough
+                - failing is failing
+        '''
+        self.alive, self.isLogging = False, False
+        self.storageClass = storage.Storage(self)
+        self.memberList = self.storageClass.memberList
+        self.mpList = self.storageClass.mpList
+        self.chatroomList = self.storageClass.chatroomList
+        self.msgList = self.storageClass.msgList
+        self.loginInfo = {}
+        self.s = requests.Session()
+        self.uuid = None
+        self.functionDict = {'FriendChat': {}, 'GroupChat': {}, 'MpChat': {}}
+        self.useHotReload, self.hotReloadDir = False, 'itchat.pkl'
+        self.receivingRetryCount = 5
+    def login(self, enableCmdQR=False, picDir=None, qrCallback=None,
+            loginCallback=None, exitCallback=None):
+        ''' log in like web wechat does
+            for log in
+                - a QR code will be downloaded and opened
+                - then scanning status is logged, it paused for you confirm
+                - finally it logged in and show your nickName
+            for options
+                - enableCmdQR: show qrcode in command line
+                    - integers can be used to fit strange char length
+                - picDir: place for storing qrcode
+                - qrCallback: method that should accept uuid, status, qrcode
+                - loginCallback: callback after successfully logged in
+                    - if not set, screen is cleared and qrcode is deleted
+                - exitCallback: callback after logged out
+                    - it contains calling of logout
+            for usage
+                ..code::python
+
+                    import itchat
+                    itchat.login()
+
+            it is defined in components/login.py
+            and of course every single move in login can be called outside
+                - you may scan source code to see how
+                - and modified according to your own demand
+        '''
+        raise NotImplementedError()
+    def get_QRuuid(self):
+        ''' get uuid for qrcode
+            uuid is the symbol of qrcode
+                - for logging in, you need to get a uuid first
+                - for downloading qrcode, you need to pass uuid to it
+                - for checking login status, uuid is also required
+            if uuid has timed out, just get another
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def get_QR(self, uuid=None, enableCmdQR=False, picDir=None, qrCallback=None):
+        ''' download and show qrcode
+            for options
+                - uuid: if uuid is not set, latest uuid you fetched will be used
+                - enableCmdQR: show qrcode in cmd
+                - picDir: where to store qrcode
+                - qrCallback: method that should accept uuid, status, qrcode
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def check_login(self, uuid=None):
+        ''' check login status
+            for options:
+                - uuid: if uuid is not set, latest uuid you fetched will be used
+            for return values:
+                - a string will be returned
+                - for meaning of return values
+                    - 200: log in successfully
+                    - 201: waiting for press confirm
+                    - 408: uuid timed out
+                    - 0  : unknown error
+            for processing:
+                - syncUrl and fileUrl is set
+                - BaseRequest is set
+            blocks until reaches any of above status
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def web_init(self):
+        ''' get info necessary for initializing
+            for processing:
+                - own account info is set
+                - inviteStartCount is set
+                - syncKey is set
+                - part of contact is fetched
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def show_mobile_login(self):
+        ''' show web wechat login sign
+            the sign is on the top of mobile phone wechat
+            sign will be added after sometime even without calling this function
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
+        ''' open a thread for heart loop and receiving messages
+            for options:
+                - exitCallback: callback after logged out
+                    - it contains calling of logout
+                - getReceivingFnOnly: if True thread will not be created and started. Instead, receive fn will be returned.
+            for processing:
+                - messages: msgs are formatted and passed on to registered fns
+                - contact : chatrooms are updated when related info is received
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def get_msg(self):
+        ''' fetch messages
+            for fetching
+                - method blocks for sometime until
+                    - new messages are to be received
+                    - or anytime they like
+                - synckey is updated with returned synccheckkey
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def logout(self):
+        ''' logout
+            if core is now alive
+                logout will tell wechat backstage to logout
+            and core gets ready for another login
+            it is defined in components/login.py
+        '''
+        raise NotImplementedError()
+    def update_chatroom(self, userName, detailedMember=False):
+        ''' update chatroom
+            for chatroom contact
+                - a chatroom contact need updating to be detailed
+                - detailed means members, encryid, etc
+                - auto updating of heart loop is a more detailed updating
+                    - member uin will also be filled
+                - once called, updated info will be stored
+            for options
+                - userName: 'UserName' key of chatroom or a list of it
+                - detailedMember: whether to get members of contact
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def update_friend(self, userName):
+        ''' update chatroom
+            for friend contact
+                - once called, updated info will be stored
+            for options
+                - userName: 'UserName' key of a friend or a list of it
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def get_contact(self, update=False):
+        ''' fetch part of contact
+            for part
+                - all the massive platforms and friends are fetched
+                - if update, only starred chatrooms are fetched
+            for options
+                - update: if not set, local value will be returned
+            for results
+                - chatroomList will be returned
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def get_friends(self, update=False):
+        ''' fetch friends list
+            for options
+                - update: if not set, local value will be returned
+            for results
+                - a list of friends' info dicts will be returned
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def get_chatrooms(self, update=False, contactOnly=False):
+        ''' fetch chatrooms list
+            for options
+                - update: if not set, local value will be returned
+                - contactOnly: if set, only starred chatrooms will be returned
+            for results
+                - a list of chatrooms' info dicts will be returned
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def get_mps(self, update=False):
+        ''' fetch massive platforms list
+            for options
+                - update: if not set, local value will be returned
+            for results
+                - a list of platforms' info dicts will be returned
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def set_alias(self, userName, alias):
+        ''' set alias for a friend
+            for options
+                - userName: 'UserName' key of info dict
+                - alias: new alias
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def set_pinned(self, userName, isPinned=True):
+        ''' set pinned for a friend or a chatroom
+            for options
+                - userName: 'UserName' key of info dict
+                - isPinned: whether to pin
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def accept_friend(self, userName, v4,autoUpdate=True):
+        ''' accept a friend or accept a friend
+            for options
+                - userName: 'UserName' for friend's info dict
+                - status:
+                    - for adding status should be 2
+                    - for accepting status should be 3
+                - ticket: greeting message
+                - userInfo: friend's other info for adding into local storage
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def get_head_img(self, userName=None, chatroomUserName=None, picDir=None):
+        ''' place for docs
+            for options
+                - if you want to get chatroom header: only set chatroomUserName
+                - if you want to get friend header: only set userName
+                - if you want to get chatroom member header: set both
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def create_chatroom(self, memberList, topic=''):
+        ''' create a chatroom
+            for creating
+                - its calling frequency is strictly limited
+            for options
+                - memberList: list of member info dict
+                - topic: topic of new chatroom
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def set_chatroom_name(self, chatroomUserName, name):
+        ''' set chatroom name
+            for setting
+                - it makes an updating of chatroom
+                - which means detailed info will be returned in heart loop
+            for options
+                - chatroomUserName: 'UserName' key of chatroom info dict
+                - name: new chatroom name
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def delete_member_from_chatroom(self, chatroomUserName, memberList):
+        ''' deletes members from chatroom
+            for deleting
+                - you can't delete yourself
+                - if so, no one will be deleted
+                - strict-limited frequency
+            for options
+                - chatroomUserName: 'UserName' key of chatroom info dict
+                - memberList: list of members' info dict
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def add_member_into_chatroom(self, chatroomUserName, memberList,
+            useInvitation=False):
+        ''' add members into chatroom
+            for adding
+                - you can't add yourself or member already in chatroom
+                - if so, no one will be added
+                - if member will over 40 after adding, invitation must be used
+                - strict-limited frequency
+            for options
+                - chatroomUserName: 'UserName' key of chatroom info dict
+                - memberList: list of members' info dict
+                - useInvitation: if invitation is not required, set this to use
+            it is defined in components/contact.py
+        '''
+        raise NotImplementedError()
+    def send_raw_msg(self, msgType, content, toUserName):
+        ''' many messages are sent in a common way
+            for demo
+                .. code:: python
+
+                    @itchat.msg_register(itchat.content.CARD)
+                    def reply(msg):
+                        itchat.send_raw_msg(msg['MsgType'], msg['Content'], msg['FromUserName'])
+
+            there are some little tricks here, you may discover them yourself
+            but remember they are tricks
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def send_msg(self, msg='Test Message', toUserName=None):
+        ''' send plain text message
+            for options
+                - msg: should be unicode if there's non-ascii words in msg
+                - toUserName: 'UserName' key of friend dict
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def upload_file(self, fileDir, isPicture=False, isVideo=False,
+            toUserName='filehelper', file_=None, preparedFile=None):
+        ''' upload file to server and get mediaId
+            for options
+                - fileDir: dir for file ready for upload
+                - isPicture: whether file is a picture
+                - isVideo: whether file is a video
+            for return values
+                will return a ReturnValue
+                if succeeded, mediaId is in r['MediaId']
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def send_file(self, fileDir, toUserName=None, mediaId=None, file_=None):
+        ''' send attachment
+            for options
+                - fileDir: dir for file ready for upload
+                - mediaId: mediaId for file. 
+                    - if set, file will not be uploaded twice
+                - toUserName: 'UserName' key of friend dict
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def send_image(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
+        ''' send image
+            for options
+                - fileDir: dir for file ready for upload
+                    - if it's a gif, name it like 'xx.gif'
+                - mediaId: mediaId for file. 
+                    - if set, file will not be uploaded twice
+                - toUserName: 'UserName' key of friend dict
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def send_video(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
+        ''' send video
+            for options
+                - fileDir: dir for file ready for upload
+                    - if mediaId is set, it's unnecessary to set fileDir
+                - mediaId: mediaId for file. 
+                    - if set, file will not be uploaded twice
+                - toUserName: 'UserName' key of friend dict
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def send(self, msg, toUserName=None, mediaId=None):
+        ''' wrapped function for all the sending functions
+            for options
+                - msg: message starts with different string indicates different type
+                    - list of type string: ['@fil@', '@img@', '@msg@', '@vid@']
+                    - they are for file, image, plain text, video
+                    - if none of them matches, it will be sent like plain text
+                - toUserName: 'UserName' key of friend dict
+                - mediaId: if set, uploading will not be repeated
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def revoke(self, msgId, toUserName, localId=None):
+        ''' revoke message with its and msgId
+            for options
+                - msgId: message Id on server
+                - toUserName: 'UserName' key of friend dict
+                - localId: message Id at local (optional)
+            it is defined in components/messages.py
+        '''
+        raise NotImplementedError()
+    def dump_login_status(self, fileDir=None):
+        ''' dump login status to a specific file
+            for option
+                - fileDir: dir for dumping login status
+            it is defined in components/hotreload.py
+        '''
+        raise NotImplementedError()
+    def load_login_status(self, fileDir,
+            loginCallback=None, exitCallback=None):
+        ''' load login status from a specific file
+            for option
+                - fileDir: file for loading login status
+                - loginCallback: callback after successfully logged in
+                    - if not set, screen is cleared and qrcode is deleted
+                - exitCallback: callback after logged out
+                    - it contains calling of logout
+            it is defined in components/hotreload.py
+        '''
+        raise NotImplementedError()
+    def auto_login(self, hotReload=False, statusStorageDir='itchat.pkl',
+            enableCmdQR=False, picDir=None, qrCallback=None,
+            loginCallback=None, exitCallback=None):
+        ''' log in like web wechat does
+            for log in
+                - a QR code will be downloaded and opened
+                - then scanning status is logged, it paused for you confirm
+                - finally it logged in and show your nickName
+            for options
+                - hotReload: enable hot reload
+                - statusStorageDir: dir for storing log in status
+                - enableCmdQR: show qrcode in command line
+                    - integers can be used to fit strange char length
+                - picDir: place for storing qrcode
+                - loginCallback: callback after successfully logged in
+                    - if not set, screen is cleared and qrcode is deleted
+                - exitCallback: callback after logged out
+                    - it contains calling of logout
+                - qrCallback: method that should accept uuid, status, qrcode
+            for usage
+                ..code::python
+
+                    import itchat
+                    itchat.auto_login()
+
+            it is defined in components/register.py
+            and of course every single move in login can be called outside
+                - you may scan source code to see how
+                - and modified according to your own demond
+        '''
+        raise NotImplementedError()
+    def configured_reply(self):
+        ''' determine the type of message and reply if its method is defined
+            however, I use a strange way to determine whether a msg is from massive platform
+            I haven't found a better solution here
+            The main problem I'm worrying about is the mismatching of new friends added on phone
+            If you have any good idea, pleeeease report an issue. I will be more than grateful.
+        '''
+        raise NotImplementedError()
+    def msg_register(self, msgType,
+            isFriendChat=False, isGroupChat=False, isMpChat=False):
+        ''' a decorator constructor
+            return a specific decorator based on information given
+        '''
+        raise NotImplementedError()
+    def run(self, debug=True, blockThread=True):
+        ''' start auto respond
+            for option
+                - debug: if set, debug info will be shown on screen
+            it is defined in components/register.py
+        '''
+        raise NotImplementedError()
+    def search_friends(self, name=None, userName=None, remarkName=None, nickName=None,
+            wechatAccount=None):
+        return self.storageClass.search_friends(name, userName, remarkName,
+            nickName, wechatAccount)
+    def search_chatrooms(self, name=None, userName=None):
+        return self.storageClass.search_chatrooms(name, userName)
+    def search_mps(self, name=None, userName=None):
+        return self.storageClass.search_mps(name, userName)

+ 36 - 0
lib/itchat/log.py

@@ -0,0 +1,36 @@
+import logging
+
+class LogSystem(object):
+    handlerList = []
+    showOnCmd = True
+    loggingLevel = logging.INFO
+    loggingFile = None
+    def __init__(self):
+        self.logger = logging.getLogger('itchat')
+        self.logger.addHandler(logging.NullHandler())
+        self.logger.setLevel(self.loggingLevel)
+        self.cmdHandler = logging.StreamHandler()
+        self.fileHandler = None
+        self.logger.addHandler(self.cmdHandler)
+    def set_logging(self, showOnCmd=True, loggingFile=None,
+            loggingLevel=logging.INFO):
+        if showOnCmd != self.showOnCmd:
+            if showOnCmd:
+                self.logger.addHandler(self.cmdHandler)
+            else:
+                self.logger.removeHandler(self.cmdHandler)
+            self.showOnCmd = showOnCmd
+        if loggingFile != self.loggingFile:
+            if self.loggingFile is not None: # clear old fileHandler
+                self.logger.removeHandler(self.fileHandler)
+                self.fileHandler.close()
+            if loggingFile is not None: # add new fileHandler
+                self.fileHandler = logging.FileHandler(loggingFile)
+                self.logger.addHandler(self.fileHandler)
+            self.loggingFile = loggingFile
+        if loggingLevel != self.loggingLevel:
+            self.logger.setLevel(loggingLevel)
+            self.loggingLevel = loggingLevel
+
+ls = LogSystem()
+set_logging = ls.set_logging

+ 67 - 0
lib/itchat/returnvalues.py

@@ -0,0 +1,67 @@
+#coding=utf8
+TRANSLATE = 'Chinese'
+
+class ReturnValue(dict):
+    ''' turn return value of itchat into a boolean value
+    for requests:
+        ..code::python
+
+            import requests
+            r = requests.get('http://httpbin.org/get')
+            print(ReturnValue(rawResponse=r)
+    
+    for normal dict:
+        ..code::python
+
+            returnDict = {
+                'BaseResponse': {
+                    'Ret': 0,
+                    'ErrMsg': 'My error msg', }, }
+            print(ReturnValue(returnDict))
+    '''
+    def __init__(self, returnValueDict={}, rawResponse=None):
+        if rawResponse:
+            try:
+                returnValueDict = rawResponse.json()
+            except ValueError:
+                returnValueDict = {
+                    'BaseResponse': {
+                        'Ret': -1004,
+                        'ErrMsg': 'Unexpected return value', },
+                    'Data': rawResponse.content, }
+        for k, v in returnValueDict.items():
+            self[k] = v
+        if not 'BaseResponse' in self:
+            self['BaseResponse'] = {
+                'ErrMsg': 'no BaseResponse in raw response',
+                'Ret': -1000, }
+        if TRANSLATE:
+            self['BaseResponse']['RawMsg'] = self['BaseResponse'].get('ErrMsg', '')
+            self['BaseResponse']['ErrMsg'] = \
+                TRANSLATION[TRANSLATE].get(
+                self['BaseResponse'].get('Ret', '')) \
+                or self['BaseResponse'].get('ErrMsg', u'No ErrMsg')
+            self['BaseResponse']['RawMsg'] = \
+                self['BaseResponse']['RawMsg'] or self['BaseResponse']['ErrMsg']
+    def __nonzero__(self):
+        return self['BaseResponse'].get('Ret') == 0
+    def __bool__(self):
+        return self.__nonzero__()
+    def __str__(self):
+        return '{%s}' % ', '.join(
+            ['%s: %s' % (repr(k),repr(v)) for k,v in self.items()])
+    def __repr__(self):
+        return '<ItchatReturnValue: %s>' % self.__str__()
+
+TRANSLATION = {
+    'Chinese': {
+        -1000: u'返回值不带BaseResponse',
+        -1001: u'无法找到对应的成员',
+        -1002: u'文件位置错误',
+        -1003: u'服务器拒绝连接',
+        -1004: u'服务器返回异常值',
+        -1005: u'参数错误',
+        -1006: u'无效操作',
+        0: u'请求成功',
+    },
+}

+ 117 - 0
lib/itchat/storage/__init__.py

@@ -0,0 +1,117 @@
+import os, time, copy
+from threading import Lock
+
+from .messagequeue import Queue
+from .templates import (
+    ContactList, AbstractUserDict, User,
+    MassivePlatform, Chatroom, ChatroomMember)
+
+def contact_change(fn):
+    def _contact_change(core, *args, **kwargs):
+        with core.storageClass.updateLock:
+            return fn(core, *args, **kwargs)
+    return _contact_change
+
+class Storage(object):
+    def __init__(self, core):
+        self.userName          = None
+        self.nickName          = None
+        self.updateLock        = Lock()
+        self.memberList        = ContactList()
+        self.mpList            = ContactList()
+        self.chatroomList      = ContactList()
+        self.msgList           = Queue(-1)
+        self.lastInputUserName = None
+        self.memberList.set_default_value(contactClass=User)
+        self.memberList.core = core
+        self.mpList.set_default_value(contactClass=MassivePlatform)
+        self.mpList.core = core
+        self.chatroomList.set_default_value(contactClass=Chatroom)
+        self.chatroomList.core = core
+    def dumps(self):
+        return {
+            'userName'          : self.userName,
+            'nickName'          : self.nickName,
+            'memberList'        : self.memberList,
+            'mpList'            : self.mpList,
+            'chatroomList'      : self.chatroomList,
+            'lastInputUserName' : self.lastInputUserName, }
+    def loads(self, j):
+        self.userName = j.get('userName', None)
+        self.nickName = j.get('nickName', None)
+        del self.memberList[:]
+        for i in j.get('memberList', []):
+            self.memberList.append(i)
+        del self.mpList[:]
+        for i in j.get('mpList', []):
+            self.mpList.append(i)
+        del self.chatroomList[:]
+        for i in j.get('chatroomList', []):
+            self.chatroomList.append(i)
+        # I tried to solve everything in pickle
+        # but this way is easier and more storage-saving
+        for chatroom in self.chatroomList:
+            if 'MemberList' in chatroom:
+                for member in chatroom['MemberList']:
+                    member.core = chatroom.core
+                    member.chatroom = chatroom
+            if 'Self' in chatroom:
+                chatroom['Self'].core = chatroom.core
+                chatroom['Self'].chatroom = chatroom
+        self.lastInputUserName = j.get('lastInputUserName', None)
+    def search_friends(self, name=None, userName=None, remarkName=None, nickName=None,
+            wechatAccount=None):
+        with self.updateLock:
+            if (name or userName or remarkName or nickName or wechatAccount) is None:
+                return copy.deepcopy(self.memberList[0]) # my own account
+            elif userName: # return the only userName match
+                for m in self.memberList:
+                    if m['UserName'] == userName:
+                        return copy.deepcopy(m)
+            else:
+                matchDict = {
+                    'RemarkName' : remarkName,
+                    'NickName'   : nickName,
+                    'Alias'      : wechatAccount, }
+                for k in ('RemarkName', 'NickName', 'Alias'):
+                    if matchDict[k] is None:
+                        del matchDict[k]
+                if name: # select based on name
+                    contact = []
+                    for m in self.memberList:
+                        if any([m.get(k) == name for k in ('RemarkName', 'NickName', 'Alias')]):
+                            contact.append(m)
+                else:
+                    contact = self.memberList[:]
+                if matchDict: # select again based on matchDict
+                    friendList = []
+                    for m in contact:
+                        if all([m.get(k) == v for k, v in matchDict.items()]):
+                            friendList.append(m)
+                    return copy.deepcopy(friendList)
+                else:
+                    return copy.deepcopy(contact)
+    def search_chatrooms(self, name=None, userName=None):
+        with self.updateLock:
+            if userName is not None:
+                for m in self.chatroomList:
+                    if m['UserName'] == userName:
+                        return copy.deepcopy(m)
+            elif name is not None:
+                matchList = []
+                for m in self.chatroomList:
+                    if name in m['NickName']:
+                        matchList.append(copy.deepcopy(m))
+                return matchList
+    def search_mps(self, name=None, userName=None):
+        with self.updateLock:
+            if userName is not None:
+                for m in self.mpList:
+                    if m['UserName'] == userName:
+                        return copy.deepcopy(m)
+            elif name is not None:
+                matchList = []
+                for m in self.mpList:
+                    if name in m['NickName']:
+                        matchList.append(copy.deepcopy(m))
+                return matchList

+ 32 - 0
lib/itchat/storage/messagequeue.py

@@ -0,0 +1,32 @@
+import logging
+try:
+    import Queue as queue
+except ImportError:
+    import queue
+
+from .templates import AttributeDict
+
+logger = logging.getLogger('itchat')
+
+class Queue(queue.Queue):
+    def put(self, message):
+        queue.Queue.put(self, Message(message))
+
+class Message(AttributeDict):
+    def download(self, fileName):
+        if hasattr(self.text, '__call__'):
+            return self.text(fileName)
+        else:
+            return b''
+    def __getitem__(self, value):
+        if value in ('isAdmin', 'isAt'):
+            v = value[0].upper() + value[1:] # ''[1:] == ''
+            logger.debug('%s is expired in 1.3.0, use %s instead.' % (value, v))
+            value = v
+        return super(Message, self).__getitem__(value)
+    def __str__(self):
+        return '{%s}' % ', '.join(
+            ['%s: %s' % (repr(k),repr(v)) for k,v in self.items()])
+    def __repr__(self):
+        return '<%s: %s>' % (self.__class__.__name__.split('.')[-1],
+            self.__str__())

+ 318 - 0
lib/itchat/storage/templates.py

@@ -0,0 +1,318 @@
+import logging, copy, pickle
+from weakref import ref
+
+from ..returnvalues import ReturnValue
+from ..utils import update_info_dict
+
+logger = logging.getLogger('itchat')
+
+class AttributeDict(dict):
+    def __getattr__(self, value):
+        keyName = value[0].upper() + value[1:]
+        try:
+            return self[keyName]
+        except KeyError:
+            raise AttributeError("'%s' object has no attribute '%s'" % (
+                self.__class__.__name__.split('.')[-1], keyName))
+    def get(self, v, d=None):
+        try:
+            return self[v]
+        except KeyError:
+            return d
+
+class UnInitializedItchat(object):
+    def _raise_error(self, *args, **kwargs):
+        logger.warning('An itchat instance is called before initialized')
+    def __getattr__(self, value):
+        return self._raise_error
+
+class ContactList(list):
+    ''' when a dict is append, init function will be called to format that dict '''
+    def __init__(self, *args, **kwargs):
+        super(ContactList, self).__init__(*args, **kwargs)
+        self.__setstate__(None)
+    @property
+    def core(self):
+        return getattr(self, '_core', lambda: fakeItchat)() or fakeItchat
+    @core.setter
+    def core(self, value):
+        self._core = ref(value)
+    def set_default_value(self, initFunction=None, contactClass=None):
+        if hasattr(initFunction, '__call__'):
+            self.contactInitFn = initFunction
+        if hasattr(contactClass, '__call__'):
+            self.contactClass = contactClass
+    def append(self, value):
+        contact = self.contactClass(value)
+        contact.core = self.core
+        if self.contactInitFn is not None:
+            contact = self.contactInitFn(self, contact) or contact
+        super(ContactList, self).append(contact)
+    def __deepcopy__(self, memo):
+        r = self.__class__([copy.deepcopy(v) for v in self])
+        r.contactInitFn = self.contactInitFn
+        r.contactClass = self.contactClass
+        r.core = self.core
+        return r
+    def __getstate__(self):
+        return 1
+    def __setstate__(self, state):
+        self.contactInitFn = None
+        self.contactClass = User
+    def __str__(self):
+        return '[%s]' % ', '.join([repr(v) for v in self])
+    def __repr__(self):
+        return '<%s: %s>' % (self.__class__.__name__.split('.')[-1],
+            self.__str__())
+
+class AbstractUserDict(AttributeDict):
+    def __init__(self, *args, **kwargs):
+        super(AbstractUserDict, self).__init__(*args, **kwargs)
+    @property
+    def core(self):
+        return getattr(self, '_core', lambda: fakeItchat)() or fakeItchat
+    @core.setter
+    def core(self, value):
+        self._core = ref(value)
+    def update(self):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not be updated' % \
+                self.__class__.__name__, }, })
+    def set_alias(self, alias):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not set alias' % \
+                self.__class__.__name__, }, })
+    def set_pinned(self, isPinned=True):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not be pinned' % \
+                self.__class__.__name__, }, })
+    def verify(self):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s do not need verify' % \
+                self.__class__.__name__, }, })
+    def get_head_image(self, imageDir=None):
+        return self.core.get_head_img(self.userName, picDir=imageDir)
+    def delete_member(self, userName):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not delete member' % \
+                self.__class__.__name__, }, })
+    def add_member(self, userName):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not add member' % \
+                self.__class__.__name__, }, })
+    def send_raw_msg(self, msgType, content):
+        return self.core.send_raw_msg(msgType, content, self.userName)
+    def send_msg(self, msg='Test Message'):
+        return self.core.send_msg(msg, self.userName)
+    def send_file(self, fileDir, mediaId=None):
+        return self.core.send_file(fileDir, self.userName, mediaId)
+    def send_image(self, fileDir, mediaId=None):
+        return self.core.send_image(fileDir, self.userName, mediaId)
+    def send_video(self, fileDir=None, mediaId=None):
+        return self.core.send_video(fileDir, self.userName, mediaId)
+    def send(self, msg, mediaId=None):
+        return self.core.send(msg, self.userName, mediaId)
+    def search_member(self, name=None, userName=None, remarkName=None, nickName=None,
+            wechatAccount=None):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s do not have members' % \
+                self.__class__.__name__, }, })
+    def __deepcopy__(self, memo):
+        r = self.__class__()
+        for k, v in self.items():
+            r[copy.deepcopy(k)] = copy.deepcopy(v)
+        r.core = self.core
+        return r
+    def __str__(self):
+        return '{%s}' % ', '.join(
+            ['%s: %s' % (repr(k),repr(v)) for k,v in self.items()])
+    def __repr__(self):
+        return '<%s: %s>' % (self.__class__.__name__.split('.')[-1],
+            self.__str__())
+    def __getstate__(self):
+        return 1
+    def __setstate__(self, state):
+        pass
+        
+class User(AbstractUserDict):
+    def __init__(self, *args, **kwargs):
+        super(User, self).__init__(*args, **kwargs)
+        self.__setstate__(None)
+    def update(self):
+        r = self.core.update_friend(self.userName)
+        if r:
+            update_info_dict(self, r)
+        return r
+    def set_alias(self, alias):
+        return self.core.set_alias(self.userName, alias)
+    def set_pinned(self, isPinned=True):
+        return self.core.set_pinned(self.userName, isPinned)
+    def verify(self):
+        return self.core.add_friend(**self.verifyDict)
+    def __deepcopy__(self, memo):
+        r = super(User, self).__deepcopy__(memo)
+        r.verifyDict = copy.deepcopy(self.verifyDict)
+        return r
+    def __setstate__(self, state):
+        super(User, self).__setstate__(state)
+        self.verifyDict = {}
+        self['MemberList'] = fakeContactList
+
+class MassivePlatform(AbstractUserDict):
+    def __init__(self, *args, **kwargs):
+        super(MassivePlatform, self).__init__(*args, **kwargs)
+        self.__setstate__(None)
+    def __setstate__(self, state):
+        super(MassivePlatform, self).__setstate__(state)
+        self['MemberList'] = fakeContactList
+
+class Chatroom(AbstractUserDict):
+    def __init__(self, *args, **kwargs):
+        super(Chatroom, self).__init__(*args, **kwargs)
+        memberList = ContactList()
+        userName = self.get('UserName', '')
+        refSelf = ref(self)
+        def init_fn(parentList, d):
+            d.chatroom = refSelf() or \
+                parentList.core.search_chatrooms(userName=userName)
+        memberList.set_default_value(init_fn, ChatroomMember)
+        if 'MemberList' in self:
+            for member in self.memberList:
+                memberList.append(member)
+        self['MemberList'] = memberList
+    @property
+    def core(self):
+        return getattr(self, '_core', lambda: fakeItchat)() or fakeItchat
+    @core.setter
+    def core(self, value):
+        self._core = ref(value)
+        self.memberList.core = value
+        for member in self.memberList:
+            member.core = value
+    def update(self, detailedMember=False):
+        r = self.core.update_chatroom(self.userName, detailedMember)
+        if r:
+            update_info_dict(self, r)
+            self['MemberList'] = r['MemberList']
+        return r
+    def set_alias(self, alias):
+        return self.core.set_chatroom_name(self.userName, alias)
+    def set_pinned(self, isPinned=True):
+        return self.core.set_pinned(self.userName, isPinned)
+    def delete_member(self, userName):
+        return self.core.delete_member_from_chatroom(self.userName, userName)
+    def add_member(self, userName):
+        return self.core.add_member_into_chatroom(self.userName, userName)
+    def search_member(self, name=None, userName=None, remarkName=None, nickName=None,
+            wechatAccount=None):
+        with self.core.storageClass.updateLock:
+            if (name or userName or remarkName or nickName or wechatAccount) is None:
+                return None
+            elif userName: # return the only userName match
+                for m in self.memberList:
+                    if m.userName == userName:
+                        return copy.deepcopy(m)
+            else:
+                matchDict = {
+                    'RemarkName' : remarkName,
+                    'NickName'   : nickName,
+                    'Alias'      : wechatAccount, }
+                for k in ('RemarkName', 'NickName', 'Alias'):
+                    if matchDict[k] is None:
+                        del matchDict[k]
+                if name: # select based on name
+                    contact = []
+                    for m in self.memberList:
+                        if any([m.get(k) == name for k in ('RemarkName', 'NickName', 'Alias')]):
+                            contact.append(m)
+                else:
+                    contact = self.memberList[:]
+                if matchDict: # select again based on matchDict
+                    friendList = []
+                    for m in contact:
+                        if all([m.get(k) == v for k, v in matchDict.items()]):
+                            friendList.append(m)
+                    return copy.deepcopy(friendList)
+                else:
+                    return copy.deepcopy(contact)
+    def __setstate__(self, state):
+        super(Chatroom, self).__setstate__(state)
+        if not 'MemberList' in self:
+            self['MemberList'] = fakeContactList
+
+class ChatroomMember(AbstractUserDict):
+    def __init__(self, *args, **kwargs):
+        super(AbstractUserDict, self).__init__(*args, **kwargs)
+        self.__setstate__(None)
+    @property
+    def chatroom(self):
+        r = getattr(self, '_chatroom', lambda: fakeChatroom)()
+        if r is None:
+            userName = getattr(self, '_chatroomUserName', '')
+            r = self.core.search_chatrooms(userName=userName)
+            if isinstance(r, dict):
+                self.chatroom = r
+        return r or fakeChatroom
+    @chatroom.setter
+    def chatroom(self, value):
+        if isinstance(value, dict) and 'UserName' in value:
+            self._chatroom = ref(value)
+            self._chatroomUserName = value['UserName']
+    def get_head_image(self, imageDir=None):
+        return self.core.get_head_img(self.userName, self.chatroom.userName, picDir=imageDir)
+    def delete_member(self, userName):
+        return self.core.delete_member_from_chatroom(self.chatroom.userName, self.userName)
+    def send_raw_msg(self, msgType, content):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not send message directly' % \
+                self.__class__.__name__, }, })
+    def send_msg(self, msg='Test Message'):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not send message directly' % \
+                self.__class__.__name__, }, })
+    def send_file(self, fileDir, mediaId=None):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not send message directly' % \
+                self.__class__.__name__, }, })
+    def send_image(self, fileDir, mediaId=None):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not send message directly' % \
+                self.__class__.__name__, }, })
+    def send_video(self, fileDir=None, mediaId=None):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not send message directly' % \
+                self.__class__.__name__, }, })
+    def send(self, msg, mediaId=None):
+        return ReturnValue({'BaseResponse': {
+            'Ret': -1006,
+            'ErrMsg': '%s can not send message directly' % \
+                self.__class__.__name__, }, })
+    def __setstate__(self, state):
+        super(ChatroomMember, self).__setstate__(state)
+        self['MemberList'] = fakeContactList
+
+def wrap_user_dict(d):
+    userName = d.get('UserName')
+    if '@@' in userName:
+        r = Chatroom(d)
+    elif d.get('VerifyFlag', 8) & 8 == 0:
+        r = User(d)
+    else:
+        r = MassivePlatform(d)
+    return r
+
+fakeItchat = UnInitializedItchat()
+fakeContactList = ContactList()
+fakeChatroom = Chatroom()

+ 163 - 0
lib/itchat/utils.py

@@ -0,0 +1,163 @@
+import re, os, sys, subprocess, copy, traceback, logging
+
+try:
+    from HTMLParser import HTMLParser
+except ImportError:
+    from html.parser import HTMLParser
+try:
+    from urllib import quote as _quote
+    quote = lambda n: _quote(n.encode('utf8', 'replace'))
+except ImportError:
+    from urllib.parse import quote
+
+import requests
+
+from . import config
+
+logger = logging.getLogger('itchat')
+
+emojiRegex = re.compile(r'<span class="emoji emoji(.{1,10})"></span>')
+htmlParser = HTMLParser()
+if not hasattr(htmlParser, 'unescape'):
+    import html
+    htmlParser.unescape = html.unescape
+    # FIX Python 3.9 HTMLParser.unescape is removed. See https://docs.python.org/3.9/whatsnew/3.9.html
+try:
+    b = u'\u2588'
+    sys.stdout.write(b + '\r')
+    sys.stdout.flush()
+except UnicodeEncodeError:
+    BLOCK = 'MM'
+else:
+    BLOCK = b
+friendInfoTemplate = {}
+for k in ('UserName', 'City', 'DisplayName', 'PYQuanPin', 'RemarkPYInitial', 'Province',
+        'KeyWord', 'RemarkName', 'PYInitial', 'EncryChatRoomId', 'Alias', 'Signature', 
+        'NickName', 'RemarkPYQuanPin', 'HeadImgUrl'):
+    friendInfoTemplate[k] = ''
+for k in ('UniFriend', 'Sex', 'AppAccountFlag', 'VerifyFlag', 'ChatRoomId', 'HideInputBarFlag',
+        'AttrStatus', 'SnsFlag', 'MemberCount', 'OwnerUin', 'ContactFlag', 'Uin',
+        'StarFriend', 'Statues'):
+    friendInfoTemplate[k] = 0
+friendInfoTemplate['MemberList'] = []
+
+def clear_screen():
+    os.system('cls' if config.OS == 'Windows' else 'clear')
+
+def emoji_formatter(d, k):
+    ''' _emoji_deebugger is for bugs about emoji match caused by wechat backstage
+    like :face with tears of joy: will be replaced with :cat face with tears of joy:
+    '''
+    def _emoji_debugger(d, k):
+        s = d[k].replace('<span class="emoji emoji1f450"></span',
+            '<span class="emoji emoji1f450"></span>') # fix missing bug
+        def __fix_miss_match(m):
+            return '<span class="emoji emoji%s"></span>' % ({
+                '1f63c': '1f601', '1f639': '1f602', '1f63a': '1f603',
+                '1f4ab': '1f616', '1f64d': '1f614', '1f63b': '1f60d',
+                '1f63d': '1f618', '1f64e': '1f621', '1f63f': '1f622',
+                }.get(m.group(1), m.group(1)))
+        return emojiRegex.sub(__fix_miss_match, s)
+    def _emoji_formatter(m):
+        s = m.group(1)
+        if len(s) == 6:
+            return ('\\U%s\\U%s'%(s[:2].rjust(8, '0'), s[2:].rjust(8, '0'))
+                ).encode('utf8').decode('unicode-escape', 'replace')
+        elif len(s) == 10:
+            return ('\\U%s\\U%s'%(s[:5].rjust(8, '0'), s[5:].rjust(8, '0'))
+                ).encode('utf8').decode('unicode-escape', 'replace')
+        else:
+            return ('\\U%s'%m.group(1).rjust(8, '0')
+                ).encode('utf8').decode('unicode-escape', 'replace')
+    d[k] = _emoji_debugger(d, k)
+    d[k] = emojiRegex.sub(_emoji_formatter, d[k])
+
+def msg_formatter(d, k):
+    emoji_formatter(d, k)
+    d[k] = d[k].replace('<br/>', '\n')
+    d[k] = htmlParser.unescape(d[k])
+
+def check_file(fileDir):
+    try:
+        with open(fileDir):
+            pass
+        return True
+    except:
+        return False
+
+def print_qr(fileDir):
+    if config.OS == 'Darwin':
+        subprocess.call(['open', fileDir])
+    elif config.OS == 'Linux':
+        subprocess.call(['xdg-open', fileDir])
+    else:
+        os.startfile(fileDir)
+
+def print_cmd_qr(qrText, white=BLOCK, black='  ', enableCmdQR=True):
+    blockCount = int(enableCmdQR)
+    if abs(blockCount) == 0:
+        blockCount = 1
+    white *= abs(blockCount)
+    if blockCount < 0:
+        white, black = black, white
+    sys.stdout.write(' '*50 + '\r')
+    sys.stdout.flush()
+    qr = qrText.replace('0', white).replace('1', black)
+    sys.stdout.write(qr)
+    sys.stdout.flush()
+
+def struct_friend_info(knownInfo):
+    member = copy.deepcopy(friendInfoTemplate)
+    for k, v in copy.deepcopy(knownInfo).items(): member[k] = v
+    return member
+
+def search_dict_list(l, key, value):
+    ''' Search a list of dict
+        * return dict with specific value & key '''
+    for i in l:
+        if i.get(key) == value:
+            return i
+
+def print_line(msg, oneLine = False):
+    if oneLine:
+        sys.stdout.write(' '*40 + '\r')
+        sys.stdout.flush()
+    else:
+        sys.stdout.write('\n')
+    sys.stdout.write(msg.encode(sys.stdin.encoding or 'utf8', 'replace'
+        ).decode(sys.stdin.encoding or 'utf8', 'replace'))
+    sys.stdout.flush()
+
+def test_connect(retryTime=5):
+    for i in range(retryTime):
+        try:
+            r = requests.get(config.BASE_URL)
+            return True
+        except:
+            if i == retryTime - 1:
+                logger.error(traceback.format_exc())
+                return False
+
+def contact_deep_copy(core, contact):
+    with core.storageClass.updateLock:
+        return copy.deepcopy(contact)
+
+def get_image_postfix(data):
+    data = data[:20]
+    if b'GIF' in data:
+        return 'gif'
+    elif b'PNG' in data:
+        return 'png'
+    elif b'JFIF' in data:
+        return 'jpg'
+    return ''
+
+def update_info_dict(oldInfoDict, newInfoDict):
+    ''' only normal values will be updated here
+        because newInfoDict is normal dict, so it's not necessary to consider templates
+    '''
+    for k, v in newInfoDict.items():
+        if any((isinstance(v, t) for t in (tuple, list, dict))):
+            pass # these values will be updated somewhere else
+        elif oldInfoDict.get(k) is None or v not in (None, '', '0', 0):
+            oldInfoDict[k] = v