diff --git a/fbchat/client.py b/fbchat/client.py index 2f9d818..fd40153 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -43,7 +43,7 @@ class Client(object): :type max_tries: int :type session_cookies: dict :type logging_level: int - :raises: Exception on failed login + :raises: FBchatException on failed login """ self.sticky, self.pool = (None, None) @@ -54,14 +54,15 @@ class Client(object): self.client = 'mercury' self.default_thread_id = None self.default_thread_type = None + self.req_url = ReqUrl() if not user_agent: user_agent = choice(USER_AGENTS) self._header = { 'Content-Type' : 'application/x-www-form-urlencoded', - 'Referer' : ReqUrl.BASE, - 'Origin' : ReqUrl.BASE, + 'Referer' : self.req_url.BASE, + 'Origin' : self.req_url.BASE, 'User-Agent' : user_agent, 'Connection' : 'keep-alive', } @@ -91,13 +92,50 @@ class Client(object): self.req_counter += 1 return payload - def _get(self, url, query=None, timeout=30): - payload = self._generatePayload(query) - return self._session.get(url, headers=self._header, params=payload, timeout=timeout) + def _fix_1357004(self, error_code): + """ + This fixes "Please try closing and re-opening your browser window" errors (1357004) + This error usually happens after 1-2 days of inactivity + It may be a bad idea to do this in an exception handler, if you have a better method, please suggest it! + """ + if error_code == '1357004': + log.warning('Got error #1357004. Doing a _postLogin, and resending request') + self._postLogin() + return True + return False - def _post(self, url, query=None, timeout=30): + def _get(self, url, query=None, timeout=30, fix_request=False, as_json=False, error_retries=3): payload = self._generatePayload(query) - return self._session.post(url, headers=self._header, data=payload, timeout=timeout) + r = self._session.get(url, headers=self._header, params=payload, timeout=timeout) + if not fix_request: + return r + try: + return check_request(r, as_json=as_json) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_1357004(e.fb_error_code): + return self._get(url, query=query, timeout=timeout, fix_request=fix_request, as_json=as_json, error_retries=error_retries-1) + raise e + + def _post(self, url, query=None, timeout=30, fix_request=False, as_json=False, error_retries=3): + payload = self._generatePayload(query) + r = self._session.post(url, headers=self._header, data=payload, timeout=timeout) + if not fix_request: + return r + try: + return check_request(r, as_json=as_json) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_1357004(e.fb_error_code): + return self._post(url, query=query, timeout=timeout, fix_request=fix_request, as_json=as_json, error_retries=error_retries-1) + raise e + + def _graphql(self, payload, error_retries=3): + content = self._post(self.req_url.GRAPHQL, payload, fix_request=True, as_json=False) + try: + return graphql_response_to_json(content) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_1357004(e.fb_error_code): + return self._graphql(payload, error_retries=error_retries-1) + raise e def _cleanGet(self, url, query=None, timeout=30): return self._session.get(url, headers=self._header, params=query, timeout=timeout) @@ -106,38 +144,42 @@ class Client(object): self.req_counter += 1 return self._session.post(url, headers=self._header, data=query, timeout=timeout) - def _postFile(self, url, files=None, query=None, timeout=30): + def _postFile(self, url, files=None, query=None, timeout=30, fix_request=False, as_json=False, error_retries=3): payload=self._generatePayload(query) # Removes 'Content-Type' from the header headers = dict((i, self._header[i]) for i in self._header if i != 'Content-Type') - return self._session.post(url, headers=headers, data=payload, timeout=timeout, files=files) + r = self._session.post(url, headers=headers, data=payload, timeout=timeout, files=files) + if not fix_request: + return r + try: + return check_request(r, as_json=as_json) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_1357004(e.fb_error_code): + return self._postFile(url, files=files, query=query, timeout=timeout, fix_request=fix_request, as_json=as_json, error_retries=error_retries-1) + raise e def graphql_requests(self, *queries): """ .. todo:: Documenting this - :raises: Exception if request failed + :raises: FBchatException if request failed """ - payload = { + + return tuple(self._graphql({ 'method': 'GET', 'response_format': 'json', 'queries': graphql_queries_to_json(*queries) - } - - j = graphql_response_to_json(checkRequest(self._post(ReqUrl.GRAPHQL, payload), do_json_check=False)) - - return tuple(j) + })) def graphql_request(self, query): """ Shorthand for `graphql_requests(query)[0]` - :raises: Exception if request failed + :raises: FBchatException if request failed """ return self.graphql_requests(query)[0] - """ END INTERNAL REQUEST METHODS """ @@ -159,12 +201,12 @@ class Client(object): self.start_time = now() self.uid = self._session.cookies.get_dict().get('c_user') if self.uid is None: - raise Exception('Could not find c_user cookie') + raise FBchatException('Could not find c_user cookie') self.uid = str(self.uid) self.user_channel = "p_" + self.uid self.ttstamp = '' - r = self._get(ReqUrl.BASE) + r = self._get(self.req_url.BASE) soup = bs(r.text, "lxml") self.fb_dtsg = soup.find("input", {'name':'fb_dtsg'})['value'] self.fb_h = soup.find("input", {'name':'h'})['value'] @@ -198,13 +240,13 @@ class Client(object): if not (self.email and self.password): raise FBchatUserError("Email and password not found.") - soup = bs(self._get(ReqUrl.MOBILE).text, "lxml") + soup = bs(self._get(self.req_url.MOBILE).text, "lxml") data = dict((elem['name'], elem['value']) for elem in soup.findAll("input") if elem.has_attr('value') and elem.has_attr('name')) data['email'] = self.email data['pass'] = self.password data['login'] = 'Log In' - r = self._cleanPost(ReqUrl.LOGIN, data) + r = self._cleanPost(self.req_url.LOGIN, data) # Usually, 'Checkpoint' will refer to 2FA if ('checkpoint' in r.url and @@ -213,7 +255,7 @@ class Client(object): # Sometimes Facebook tries to show the user a "Save Device" dialog if 'save-device' in r.url: - r = self._cleanGet(ReqUrl.SAVE_DEVICE) + r = self._cleanGet(self.req_url.SAVE_DEVICE) if 'home' in r.url: self._postLogin() @@ -234,7 +276,7 @@ class Client(object): data['codes_submitted'] = 0 log.info('Submitting 2FA code.') - r = self._cleanPost(ReqUrl.CHECKPOINT, data) + r = self._cleanPost(self.req_url.CHECKPOINT, data) if 'home' in r.url: return r @@ -246,14 +288,14 @@ class Client(object): data['name_action_selected'] = 'save_device' data['submit[Continue]'] = 'Continue' log.info('Saving browser.') # At this stage, we have dtsg, nh, name_action_selected, submit[Continue] - r = self._cleanPost(ReqUrl.CHECKPOINT, data) + r = self._cleanPost(self.req_url.CHECKPOINT, data) if 'home' in r.url: return r del(data['name_action_selected']) log.info('Starting Facebook checkup flow.') # At this stage, we have dtsg, nh, submit[Continue] - r = self._cleanPost(ReqUrl.CHECKPOINT, data) + r = self._cleanPost(self.req_url.CHECKPOINT, data) if 'home' in r.url: return r @@ -261,7 +303,7 @@ class Client(object): del(data['submit[Continue]']) data['submit[This was me]'] = 'This Was Me' log.info('Verifying login attempt.') # At this stage, we have dtsg, nh, submit[This was me] - r = self._cleanPost(ReqUrl.CHECKPOINT, data) + r = self._cleanPost(self.req_url.CHECKPOINT, data) if 'home' in r.url: return r @@ -270,7 +312,7 @@ class Client(object): data['submit[Continue]'] = 'Continue' data['name_action_selected'] = 'save_device' log.info('Saving device again.') # At this stage, we have dtsg, nh, submit[Continue], name_action_selected - r = self._cleanPost(ReqUrl.CHECKPOINT, data) + r = self._cleanPost(self.req_url.CHECKPOINT, data) return r def isLoggedIn(self): @@ -281,7 +323,7 @@ class Client(object): :rtype: bool """ # Send a request to the login url, to see if we're directed to the home page - r = self._cleanGet(ReqUrl.LOGIN) + r = self._cleanGet(self.req_url.LOGIN) return 'home' in r.url def getSession(self): @@ -309,7 +351,7 @@ class Client(object): # Load cookies into current session self._session.cookies = requests.cookies.merge_cookies(self._session.cookies, session_cookies) self._postLogin() - except Exception as e: + except FBchatException as e: self.exception('Failed loading session') self._resetValues() return False @@ -361,7 +403,7 @@ class Client(object): 'h': self.fb_h } - r = self._get(ReqUrl.LOGOUT, data) + r = self._get(self.req_url.LOGOUT, data) self._resetValues() @@ -425,7 +467,7 @@ class Client(object): data = { 'viewer': self.uid, } - j = checkRequest(self._post(ReqUrl.ALL_USERS, query=data)) + j = self._post(self.req_url.ALL_USERS, query=data, fix_request=True, as_json=True) if j.get('payload') is None: raise FBchatException('Missing payload while fetching users: {}'.format(j)) @@ -519,7 +561,7 @@ class Client(object): data = { "ids[{}]".format(i): _id for i, _id in enumerate(ids) } - j = checkRequest(self._post(ReqUrl.INFO, data)) + j = self._post(self.req_url.INFO, data, fix_request=True, as_json=True) if j.get('payload') is None or j['payload'].get('profiles') is None: raise FBchatException('No users/pages returned: {}'.format(j)) @@ -726,7 +768,7 @@ class Client(object): 'inbox[limit]' : limit, } - j = checkRequest(self._post(ReqUrl.THREADS, data)) + j = self._post(self.req_url.THREADS, data, fix_request=True, as_json=True) if j.get('payload') is None: raise FBchatException('Missing payload: {}, with data: {}'.format(j, data)) @@ -767,7 +809,7 @@ class Client(object): # 'last_action_timestamp': 0 } - j = checkRequest(self._post(ReqUrl.THREAD_SYNC, form)) + j = self._post(self.req_url.THREAD_SYNC, form, fix_request=True, as_json=True) return { "message_counts": j['payload']['message_counts'], @@ -826,7 +868,7 @@ class Client(object): def _doSendRequest(self, data): """Sends the data to `SendURL`, and returns the message ID or None on failure""" - j = checkRequest(self._post(ReqUrl.SEND, data)) + j = self._post(self.req_url.SEND, data, fix_request=True, as_json=True) try: message_ids = [action['message_id'] for action in j['payload']['actions'] if 'message_id' in action] @@ -898,13 +940,13 @@ class Client(object): def _uploadImage(self, image_path, data, mimetype): """Upload an image and get the image_id for sending in a message""" - j = checkRequest(self._postFile(ReqUrl.UPLOAD, { + j = self._postFile(self.req_url.UPLOAD, { 'file': ( image_path, data, mimetype ) - })) + }, fix_request=True, as_json=True) # Return the image_id return j['payload']['metadata'][0]['image_id'] @@ -1014,7 +1056,7 @@ class Client(object): "tid": thread_id } - j = checkRequest(self._post(ReqUrl.REMOVE_USER, data)) + j = self._post(self.req_url.REMOVE_USER, data, fix_request=True, as_json=True) def changeThreadTitle(self, title, thread_id=None, thread_type=ThreadType.USER): """ @@ -1061,7 +1103,7 @@ class Client(object): 'thread_or_other_fbid': thread_id } - j = checkRequest(self._post(ReqUrl.THREAD_NICKNAME, data)) + j = self._post(self.req_url.THREAD_NICKNAME, data, fix_request=True, as_json=True) def changeThreadColor(self, color, thread_id=None): """ @@ -1079,7 +1121,7 @@ class Client(object): 'thread_or_other_fbid': thread_id } - j = checkRequest(self._post(ReqUrl.THREAD_COLOR, data)) + j = self._post(self.req_url.THREAD_COLOR, data, fix_request=True, as_json=True) def changeThreadEmoji(self, emoji, thread_id=None): """ @@ -1098,7 +1140,7 @@ class Client(object): 'thread_or_other_fbid': thread_id } - j = checkRequest(self._post(ReqUrl.THREAD_EMOJI, data)) + j = self._post(self.req_url.THREAD_EMOJI, data, fix_request=True, as_json=True) def reactToMessage(self, message_id, reaction): """ @@ -1130,7 +1172,7 @@ class Client(object): .replace('u%27', '%27')\ .replace('%5CU{}'.format(MessageReactionFix[reaction.value][0]), MessageReactionFix[reaction.value][1]) - j = checkRequest(self._post('{}/?{}'.format(ReqUrl.MESSAGE_REACTION, url_part))) + j = self._post('{}/?{}'.format(self.req_url.MESSAGE_REACTION, url_part), fix_request=True, as_json=True) def setTypingStatus(self, status, thread_id=None, thread_type=None): """ @@ -1152,7 +1194,7 @@ class Client(object): "source": "mercury-chat" } - j = checkRequest(self._post(ReqUrl.TYPING, data)) + j = self._post(self.req_url.TYPING, data, fix_request=True, as_json=True) """ END SEND METHODS @@ -1168,7 +1210,7 @@ class Client(object): "thread_ids[%s][0]" % userID: threadID } - r = self._post(ReqUrl.DELIVERED, data) + r = self._post(self.req_url.DELIVERED, data) return r.ok def markAsRead(self, userID): @@ -1182,7 +1224,7 @@ class Client(object): "ids[%s]" % userID: True } - r = self._post(ReqUrl.READ_STATUS, data) + r = self._post(self.req_url.READ_STATUS, data) return r.ok def markAsSeen(self): @@ -1190,7 +1232,7 @@ class Client(object): .. todo:: Documenting this """ - r = self._post(ReqUrl.MARK_SEEN, {"seen_timestamp": 0}) + r = self._post(self.req_url.MARK_SEEN, {"seen_timestamp": 0}) return r.ok def friendConnect(self, friend_id): @@ -1203,7 +1245,7 @@ class Client(object): "action": "confirm" } - r = self._post(ReqUrl.CONNECT, data) + r = self._post(self.req_url.CONNECT, data) return r.ok @@ -1223,7 +1265,7 @@ class Client(object): 'viewer_uid': self.uid, 'state': 'active' } - checkRequest(self._get(ReqUrl.PING, data), do_json_check=False) + self._get(self.req_url.PING, data, fix_request=True, as_json=False) def _fetchSticky(self): """Call pull api to get sticky and pool parameter, newer api needs these parameters to work""" @@ -1234,7 +1276,7 @@ class Client(object): "clientid": self.client_id } - j = checkRequest(self._get(ReqUrl.STICKY, data)) + j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True) if j.get('lb_info') is None: raise FBchatException('Missing lb_info: {}'.format(j)) @@ -1251,7 +1293,7 @@ class Client(object): "clientid": self.client_id, } - j = checkRequest(self._get(ReqUrl.STICKY, data)) + j = self._get(ReqUrl.STICKY, data, fix_request=True, as_json=True) self.seq = j.get('seq', '0') return j @@ -1437,7 +1479,8 @@ class Client(object): :rtype: bool """ try: - if markAlive: self._ping(self.sticky, self.pool) + if markAlive: + self._ping(self.sticky, self.pool) content = self._pullMessage(self.sticky, self.pool) if content: self._parseMessage(content) @@ -1446,7 +1489,15 @@ class Client(object): except requests.Timeout: pass except requests.ConnectionError: + # If the client has lost their internet connection, keep trying every 30 seconds time.sleep(30) + except FBchatFacebookError as e: + # Fix 502 and 503 pull errors + if e.request_status_code in [502, 503]: + self.req_url.change_pull_channel() + self.startListening() + else: + raise e except Exception as e: return self.onListenError(exception=e) diff --git a/fbchat/utils.py b/fbchat/utils.py index 4f38183..8f49f26 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -93,6 +93,16 @@ class ReqUrl(object): TYPING = "https://www.facebook.com/ajax/messaging/typ.php" GRAPHQL = "https://www.facebook.com/api/graphqlbatch/" + pull_channel = 0 + + def change_pull_channel(self, channel=None): + if channel is None: + self.pull_channel = (self.pull_channel + 1) % 5 # Pull channel will be 0-4 + else: + self.pull_channel = channel + self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel) + self.PING = "https://{}-edge-chat.facebook.com/active_ping".format(self.pull_channel) + facebookEncoding = 'UTF-8' @@ -153,7 +163,7 @@ def check_json(j): else: raise FBchatFacebookError('Error {} when sending request'.format(j['error']), fb_error_code=j['error']) -def checkRequest(r, do_json_check=True): +def check_request(r, as_json=True): if not r.ok: raise FBchatFacebookError('Error when sending request: Got {} response'.format(r.status_code), request_error_code=r.status_code) @@ -162,7 +172,7 @@ def checkRequest(r, do_json_check=True): if content is None or len(content) == 0: raise FBchatFacebookError('Error when sending request: Got empty response') - if do_json_check: + if as_json: content = strip_to_json(content) try: j = json.loads(content)