diff --git a/channel/wechat/wechat_channel.py b/channel/wechat/wechat_channel.py index f1ff5d3..fa303d2 100644 --- a/channel/wechat/wechat_channel.py +++ b/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 diff --git a/lib/itchat/__init__.py b/lib/itchat/__init__.py new file mode 100644 index 0000000..cccbdef --- /dev/null +++ b/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 diff --git a/lib/itchat/async_components/__init__.py b/lib/itchat/async_components/__init__.py new file mode 100644 index 0000000..0fc321c --- /dev/null +++ b/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) diff --git a/lib/itchat/async_components/contact.py b/lib/itchat/async_components/contact.py new file mode 100644 index 0000000..440c288 --- /dev/null +++ b/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('([^<]*?)<', 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) diff --git a/lib/itchat/async_components/hotreload.py b/lib/itchat/async_components/hotreload.py new file mode 100644 index 0000000..b0bb54c --- /dev/null +++ b/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.') diff --git a/lib/itchat/async_components/login.py b/lib/itchat/async_components/login.py new file mode 100644 index 0000000..59f3542 --- /dev/null +++ b/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('(.*?)', r.text, re.S)[0] + pass_ticket = re.findall('(.*?)', 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, }}) diff --git a/lib/itchat/async_components/messages.py b/lib/itchat/async_components/messages.py new file mode 100644 index 0000000..f842f1f --- /dev/null +++ b/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]*?):
(.*)$', 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': ("%s" % os.path.basename(fileDir) + + "6" + + "%s%s" % (str(fileSize), mediaId) + + "%s" % 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) diff --git a/lib/itchat/async_components/register.py b/lib/itchat/async_components/register.py new file mode 100644 index 0000000..cb4f12b --- /dev/null +++ b/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() diff --git a/lib/itchat/components/__init__.py b/lib/itchat/components/__init__.py new file mode 100644 index 0000000..0fc321c --- /dev/null +++ b/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) diff --git a/lib/itchat/components/contact.py b/lib/itchat/components/contact.py new file mode 100644 index 0000000..93e3d16 --- /dev/null +++ b/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('([^<]*?)<', 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) diff --git a/lib/itchat/components/hotreload.py b/lib/itchat/components/hotreload.py new file mode 100644 index 0000000..1003c67 --- /dev/null +++ b/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.') diff --git a/lib/itchat/components/login.py b/lib/itchat/components/login.py new file mode 100644 index 0000000..f0fc22d --- /dev/null +++ b/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('(.*?)', r.text, re.S)[0] + pass_ticket = re.findall( + '(.*?)', 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, }}) diff --git a/lib/itchat/components/messages.py b/lib/itchat/components/messages.py new file mode 100644 index 0000000..85c0ca2 --- /dev/null +++ b/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]*?):
(.*)$', 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': ("%s" % os.path.basename(fileDir) + + "6" + + "%s%s" % (str(fileSize), mediaId) + + "%s" % 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) diff --git a/lib/itchat/components/register.py b/lib/itchat/components/register.py new file mode 100644 index 0000000..78a3f0b --- /dev/null +++ b/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() diff --git a/lib/itchat/config.py b/lib/itchat/config.py new file mode 100644 index 0000000..2ac6328 --- /dev/null +++ b/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==' diff --git a/lib/itchat/content.py b/lib/itchat/content.py new file mode 100644 index 0000000..41dc0b1 --- /dev/null +++ b/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] diff --git a/lib/itchat/core.py b/lib/itchat/core.py new file mode 100644 index 0000000..f3871b5 --- /dev/null +++ b/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) diff --git a/lib/itchat/log.py b/lib/itchat/log.py new file mode 100644 index 0000000..4485cc9 --- /dev/null +++ b/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 diff --git a/lib/itchat/returnvalues.py b/lib/itchat/returnvalues.py new file mode 100644 index 0000000..f42f4e8 --- /dev/null +++ b/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 '' % self.__str__() + +TRANSLATION = { + 'Chinese': { + -1000: u'返回值不带BaseResponse', + -1001: u'无法找到对应的成员', + -1002: u'文件位置错误', + -1003: u'服务器拒绝连接', + -1004: u'服务器返回异常值', + -1005: u'参数错误', + -1006: u'无效操作', + 0: u'请求成功', + }, +} diff --git a/lib/itchat/storage/__init__.py b/lib/itchat/storage/__init__.py new file mode 100644 index 0000000..5c65724 --- /dev/null +++ b/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 diff --git a/lib/itchat/storage/messagequeue.py b/lib/itchat/storage/messagequeue.py new file mode 100644 index 0000000..53ed669 --- /dev/null +++ b/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__()) diff --git a/lib/itchat/storage/templates.py b/lib/itchat/storage/templates.py new file mode 100644 index 0000000..6a670d7 --- /dev/null +++ b/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() diff --git a/lib/itchat/utils.py b/lib/itchat/utils.py new file mode 100644 index 0000000..c5dfe24 --- /dev/null +++ b/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'') +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('') # fix missing bug + def __fix_miss_match(m): + return '' % ({ + '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('
', '\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 \ No newline at end of file