diff --git a/fbchat/client.py b/fbchat/client.py index 19b2fbb..e943200 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -7,10 +7,12 @@ from uuid import uuid1 from random import choice from bs4 import BeautifulSoup as bs from mimetypes import guess_type +from collections import OrderedDict from .utils import * from .models import * from .graphql import * import time +import json try: from urllib.parse import urlparse, parse_qs except ImportError: @@ -54,7 +56,8 @@ class Client(object): self._session = requests.session() self.req_counter = 1 self.seq = "0" - self.payloadDefault = {} + # See `createPoll` for the reason for using `OrderedDict` here + self.payloadDefault = OrderedDict() self.client = 'mercury' self.default_thread_id = None self.default_thread_type = None @@ -194,14 +197,14 @@ class Client(object): """ def _resetValues(self): - self.payloadDefault={} + self.payloadDefault = OrderedDict() self._session = requests.session() self.req_counter = 1 self.seq = "0" self.uid = None def _postLogin(self): - self.payloadDefault = {} + self.payloadDefault = OrderedDict() self.client_id = hex(int(random()*2147483648))[2:] self.start_time = now() self.uid = self._session.cookies.get_dict().get('c_user') @@ -475,6 +478,15 @@ class Client(object): FETCH METHODS """ + def _forcedFetch(self, thread_id, mid): + j = self.graphql_request(GraphQL(doc_id='1768656253222505', params={ + 'thread_and_message_id': { + 'thread_id': thread_id, + 'message_id': mid + } + })) + return j + def fetchAllUsers(self): """ Gets all users the client is currently chatting with @@ -579,6 +591,84 @@ class Client(object): return rtn + def searchForMessageIDs(self, query, offset=0, limit=5, thread_id=None): + """ + Find and get message IDs by query + + :param query: Text to search for + :param offset: Number of messages to skip + :param limit: Max. number of messages to retrieve + :param thread_id: User/Group ID to search in. See :ref:`intro_threads` + :type offset: int + :type limit: int + :return: Found Message IDs + :rtype: generator + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + "query": query, + "snippetOffset": offset, + "snippetLimit": limit, + "identifier": "thread_fbid", + "thread_fbid": thread_id, + } + j = self._post(self.req_url.SEARCH_MESSAGES, data, fix_request=True, as_json=True) + + result = j["payload"]["search_snippets"][query] + snippets = result[thread_id]["snippets"] if result.get(thread_id) else [] + for snippet in snippets: + yield snippet["message_id"] + + def searchForMessages(self, query, offset=0, limit=5, thread_id=None): + """ + Find and get :class:`models.Message` objects by query + + .. warning:: + This method sends request for every found message ID. + + :param query: Text to search for + :param offset: Number of messages to skip + :param limit: Max. number of messages to retrieve + :param thread_id: User/Group ID to search in. See :ref:`intro_threads` + :type offset: int + :type limit: int + :return: Found :class:`models.Message` objects + :rtype: generator + :raises: FBchatException if request failed + """ + message_ids = self.searchForMessageIDs(query, offset=offset, limit=limit, thread_id=thread_id) + for mid in message_ids: + yield self.fetchMessageInfo(mid, thread_id) + + def search(self, query, fetch_messages=False, thread_limit=5, message_limit=5): + """ + Searches for messages in all threads + + :param query: Text to search for + :param fetch_messages: Whether to fetch :class:`models.Message` objects or IDs only + :param thread_limit: Max. number of threads to retrieve + :param message_limit: Max. number of messages to retrieve + :type thread_limit: int + :type message_limit: int + :return: Dictionary with thread IDs as keys and generators to get messages as values + :rtype: generator + :raises: FBchatException if request failed + """ + data = { + "query": query, + "snippetLimit": thread_limit + } + j = self._post(self.req_url.SEARCH_MESSAGES, data, fix_request=True, as_json=True) + + result = j["payload"]["search_snippets"][query] + + if fetch_messages: + return {thread_id: self.searchForMessages(query, limit=message_limit, thread_id=thread_id) for thread_id in result} + else: + return {thread_id: self.searchForMessageIDs(query, limit=message_limit, thread_id=thread_id) for thread_id in result} + def _fetchInfo(self, *ids): data = { "ids[{}]".format(i): _id for i, _id in enumerate(ids) @@ -697,7 +787,7 @@ class Client(object): queries = [] for thread_id in thread_ids: - queries.append(GraphQL(doc_id='1386147188135407', params={ + queries.append(GraphQL(doc_id='2147762685294928', params={ 'id': thread_id, 'message_limit': 0, 'load_messages': False, @@ -857,6 +947,53 @@ class Client(object): raise FBChatException('Could not fetch image url from: {}'.format(j)) return url + def fetchMessageInfo(self, mid, thread_id=None): + """ + Fetches :class:`models.Message` object from the message id + + :param mid: Message ID to fetch from + :param thread_id: User/Group ID to get message info from. See :ref:`intro_threads` + :return: :class:`models.Message` object + :rtype: models.Message + :raises: FBChatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + message_info = self._forcedFetch(thread_id, mid).get("message") + message = graphql_to_message(message_info) + return message + + def fetchPollOptions(self, poll_id): + """ + Fetches list of :class:`models.PollOption` objects from the poll id + + :param poll_id: Poll ID to fetch from + :rtype: list + :raises: FBChatException if request failed + """ + data = { + "question_id": poll_id + } + + j = self._post(self.req_url.GET_POLL_OPTIONS, data, fix_request=True, as_json=True) + + return [graphql_to_poll_option(m) for m in j["payload"]] + + def fetchPlanInfo(self, plan_id): + """ + Fetches a :class:`models.Plan` object from the plan id + + :param plan_id: Plan ID to fetch from + :return: :class:`models.Plan` object + :rtype: models.Plan + :raises: FBChatException if request failed + """ + data = { + "event_reminder_id": plan_id + } + j = self._post(self.req_url.PLAN_INFO, data, fix_request=True, as_json=True) + plan = graphql_to_plan(j["payload"]) + return plan + """ END FETCH METHODS """ @@ -915,24 +1052,25 @@ class Client(object): return data - def _doSendRequest(self, data): + def _doSendRequest(self, data, get_thread_id=False): """Sends the data to `SendURL`, and returns the message ID or None on failure""" 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] - if len(message_ids) != 1: - log.warning("Got multiple message ids' back: {}".format(message_ids)) - message_id = message_ids[0] - except (KeyError, IndexError, TypeError) as e: - raise FBchatException('Error when sending message: No message IDs could be found: {}'.format(j)) - # update JS token if received in response fb_dtsg = get_jsmods_require(j, 2) if fb_dtsg is not None: self.payloadDefault['fb_dtsg'] = fb_dtsg - return message_id + try: + message_ids = [(action['message_id'], action['thread_fbid']) for action in j['payload']['actions'] if 'message_id' in action] + if len(message_ids) != 1: + log.warning("Got multiple message ids' back: {}".format(message_ids)) + if get_thread_id: + return message_ids[0] + else: + return message_ids[0][0] + except (KeyError, IndexError, TypeError) as e: + raise FBchatException('Error when sending message: No message IDs could be found: {}'.format(j)) def send(self, message, thread_id=None, thread_type=ThreadType.USER): """ @@ -963,25 +1101,48 @@ class Client(object): """ return self.send(Message(text=emoji, emoji_size=size), thread_id=thread_id, thread_type=thread_type) - def _uploadImage(self, image_path, data, mimetype): - """Upload an image and get the image_id for sending in a message""" - - j = self._postFile(self.req_url.UPLOAD, { - 'file': ( - image_path, - data, - mimetype - ) - }, fix_request=True, as_json=True) - # Return the image_id - if not mimetype == 'image/gif': - return j['payload']['metadata'][0]['image_id'] - else: - return j['payload']['metadata'][0]['gif_id'] - - def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False): + def wave(self, wave_first=True, thread_id=None, thread_type=None): """ - Deprecated. Use :func:`fbchat.Client.send` instead + Says hello with a wave to a thread! + + :param wave_first: Whether to wave first or wave back + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent message + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, thread_type) + data = self._getSendData(thread_id=thread_id, thread_type=thread_type) + data['action_type'] = 'ma-type:user-generated-message' + data['lightweight_action_attachment[lwa_state]'] = "INITIATED" if wave_first else "RECIPROCATED" + data['lightweight_action_attachment[lwa_type]'] = "WAVE" + if thread_type == ThreadType.USER: + data['specific_to_list[0]'] = "fbid:{}".format(thread_id) + return self._doSendRequest(data) + + def _upload(self, files): + """ + Uploads files to Facebook + + `files` should be a list of files that requests can upload, see: + http://docs.python-requests.org/en/master/api/#requests.request + + Returns a list of tuples with a file's ID and mimetype + """ + file_dict = {'upload_{}'.format(i): f for i, f in enumerate(files)} + j = self._postFile(self.req_url.UPLOAD, files=file_dict, fix_request=True, as_json=True) + + if len(j['payload']['metadata']) != len(files): + raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files)) + + return [(data[mimetype_to_key(data['filetype'])], data['filetype']) for data in j['payload']['metadata']] + + def _sendFiles(self, files, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Sends files from file IDs to a thread + + `files` should be a list of tuples, with a file's ID and mimetype """ thread_id, thread_type = self._getThread(thread_id, thread_type) data = self._getSendData(message=self._oldMessage(message), thread_id=thread_id, thread_type=thread_type) @@ -989,68 +1150,87 @@ class Client(object): data['action_type'] = 'ma-type:user-generated-message' data['has_attachment'] = True - if not is_gif: - data['image_ids[0]'] = image_id - else: - data['gif_ids[0]'] = image_id + for i, (file_id, mimetype) in enumerate(files): + data['{}s[{}]'.format(mimetype_to_key(mimetype), i)] = file_id return self._doSendRequest(data) - def sendRemoteImage(self, image_url, message=None, thread_id=None, thread_type=ThreadType.USER): + def sendRemoteFiles(self, file_urls, message=None, thread_id=None, thread_type=ThreadType.USER): """ - Sends an image from a URL to a thread + Sends files from URLs to a thread - :param image_url: URL of an image to upload and send + :param file_urls: URLs of files to upload and send :param message: Additional message :param thread_id: User/Group ID to send to. See :ref:`intro_threads` :param thread_type: See :ref:`intro_threads` :type thread_type: models.ThreadType - :return: :ref:`Message ID ` of the sent image + :return: :ref:`Message ID ` of the sent files :raises: FBchatException if request failed """ - thread_id, thread_type = self._getThread(thread_id, thread_type) - mimetype = guess_type(image_url)[0] - is_gif = (mimetype == 'image/gif') - remote_image = requests.get(image_url).content - image_id = self._uploadImage(image_url, remote_image, mimetype) - return self.sendImage(image_id=image_id, message=message, thread_id=thread_id, thread_type=thread_type, is_gif=is_gif) + file_urls = require_list(file_urls) + files = self._upload(get_files_from_urls(file_urls)) + return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) + + def sendLocalFiles(self, file_paths, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Sends local files to a thread + + :param file_path: Paths of files to upload and send + :param message: Additional message + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent files + :raises: FBchatException if request failed + """ + file_paths = require_list(file_paths) + with get_files_from_paths(file_paths) as x: + files = self._upload(x) + return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) + + def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False): + """ + Deprecated. Use :func:`fbchat.Client._sendFiles` instead + """ + if is_gif: + return self._sendFiles(files=[(image_id, "image/png")], message=message, thread_id=thread_id, thread_type=thread_type) + else: + return self._sendFiles(files=[(image_id, "image/gif")], message=message, thread_id=thread_id, thread_type=thread_type) + + def sendRemoteImage(self, image_url, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Deprecated. Use :func:`fbchat.Client.sendRemoteFiles` instead + """ + return self.sendRemoteFiles(file_urls=[image_url], message=message, thread_id=thread_id, thread_type=thread_type) def sendLocalImage(self, image_path, message=None, thread_id=None, thread_type=ThreadType.USER): """ - Sends a local image to a thread + Deprecated. Use :func:`fbchat.Client.sendLocalFiles` instead + """ + return self.sendLocalFiles(file_paths=[image_path], message=message, thread_id=thread_id, thread_type=thread_type) - :param image_path: Path of an image to upload and send - :param message: Additional message - :param thread_id: User/Group ID to send to. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type thread_type: models.ThreadType - :return: :ref:`Message ID ` of the sent image + def createGroup(self, message, user_ids): + """ + Creates a group with the given ids + + :param message: The initial message + :param user_ids: A list of users to create the group with. + :return: ID of the new group :raises: FBchatException if request failed """ - thread_id, thread_type = self._getThread(thread_id, thread_type) - mimetype = guess_type(image_path)[0] - is_gif = (mimetype == 'image/gif') - image_id = self._uploadImage(image_path, open(image_path, 'rb'), mimetype) - return self.sendImage(image_id=image_id, message=message, thread_id=thread_id, thread_type=thread_type, is_gif=is_gif) - - def createGroup(self, message, person_ids=None): - """Creates a group with the given ids - :param person_ids: A list of people to create the group with. - :return: Returns error if couldn't create group, returns True when the group created. - """ - payload = { - "send" : "send", - "body": message, - "ids" : person_ids - } - r = self._post(self.req_url.CREATE_GROUP, payload) - if "send_success" in r.url: - log.debug("The group was created successfully!") - return True - else: - log.warning("Error while creating group") - return False - + data = self._getSendData(message=self._oldMessage(message)) + + if len(user_ids) < 2: + raise FBchatUserError("Error when creating group: Not enough participants") + + for i, user_id in enumerate(user_ids + [self.uid]): + data['specific_to_list[{}]'.format(i)] = 'fbid:{}'.format(user_id) + + message_id, thread_id = self._doSendRequest(data, get_thread_id=True) + if not thread_id: + raise FBchatException("Error when creating group: No thread_id could be found") + return thread_id + def addUsersToGroup(self, user_ids, thread_id=None): """ Adds users to a group. @@ -1066,11 +1246,7 @@ class Client(object): data['action_type'] = 'ma-type:log-message' data['log_message_type'] = 'log:subscribe' - if type(user_ids) is not list: - user_ids = [user_ids] - - # Make list of users unique - user_ids = set(user_ids) + user_ids = require_list(user_ids) for i, user_id in enumerate(user_ids): if user_id == self.uid: @@ -1097,74 +1273,139 @@ class Client(object): } j = self._post(self.req_url.REMOVE_USER, data, fix_request=True, as_json=True) - - def changeThreadImage(self, image_id, thread_id=None, thread_type=ThreadType.USER): + + def _adminStatus(self, admin_ids, admin, thread_id=None): + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + "add": admin, + "thread_fbid": thread_id + } + + admin_ids = require_list(admin_ids) + + for i, admin_id in enumerate(admin_ids): + data['admin_ids[' + str(i) + ']'] = str(admin_id) + + j = self._post(self.req_url.SAVE_ADMINS, data, fix_request=True, as_json=True) + + def addGroupAdmins(self, admin_ids, thread_id=None): + """ + Sets specifed users as group admins. + + :param admin_ids: One or more user IDs to set admin + :param thread_id: Group ID to remove people from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._adminStatus(admin_ids, True, thread_id) + + def removeGroupAdmins(self, admin_ids, thread_id=None): + """ + Removes admin status from specifed users. + + :param admin_ids: One or more user IDs to remove admin + :param thread_id: Group ID to remove people from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._adminStatus(admin_ids, False, thread_id) + + def changeGroupApprovalMode(self, require_admin_approval, thread_id=None): + """ + Changes group's approval mode + + :param require_admin_approval: True or False + :param thread_id: Group ID to remove people from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + "set_mode": int(require_admin_approval), + "thread_fbid": thread_id + } + + j = self._post(self.req_url.APPROVAL_MODE, data, fix_request=True, as_json=True) + + def _usersApproval(self, user_ids, approve, thread_id=None): + thread_id, thread_type = self._getThread(thread_id, None) + + user_ids = list(require_list(user_ids)) + + j = self.graphql_request(GraphQL(doc_id='1574519202665847', params={ + 'data': { + 'client_mutation_id': '0', + 'actor_id': self.uid, + 'thread_fbid': thread_id, + 'user_ids': user_ids, + 'response': 'ACCEPT' if approve else 'DENY', + 'surface': 'ADMIN_MODEL_APPROVAL_CENTER' + } + })) + + def acceptUsersToGroup(self, user_ids, thread_id=None): + """ + Accepts users to the group from the group's approval + + :param user_ids: One or more user IDs to accept + :param thread_id: Group ID to accept users to. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._usersApproval(user_ids, True, thread_id) + + def denyUsersFromGroup(self, user_ids, thread_id=None): + """ + Denies users from the group's approval + + :param user_ids: One or more user IDs to deny + :param thread_id: Group ID to deny users from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._usersApproval(user_ids, False, thread_id) + + def _changeGroupImage(self, image_id, thread_id=None): """ Changes a thread image from an image id :param image_id: ID of uploaded image - :param thread_id User/Group ID to change image. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type thread_type: models.ThreadType + :param thread_id: User/Group ID to change image. See :ref:`intro_threads` :raises: FBchatException if request failed """ - - thread_id, thread_type = self._getThread(thread_id, thread_type) - - if thread_type != ThreadType.GROUP: - raise FBchatUserError('Can only change the image of group threads') - else: - data = { - 'thread_image_id': image_id, - 'thread_id': thread_id - } - j = self._post(self.req_url.THREAD_IMAGE, data, fix_request=True, as_json=True) - - def changeThreadImageRemote(self, image_url, thread_id=None, thread_type=ThreadType.USER): + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + 'thread_image_id': image_id, + 'thread_id': thread_id + } + + j = self._post(self.req_url.THREAD_IMAGE, data, fix_request=True, as_json=True) + return image_id + + def changeGroupImageRemote(self, image_url, thread_id=None): """ Changes a thread image from a URL :param image_url: URL of an image to upload and change :param thread_id: User/Group ID to change image. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type thread_type: models.ThreadType :raises: FBchatException if request failed """ - - thread_id, thread_type = self._getThread(thread_id, thread_type) - - if thread_type != ThreadType.GROUP: - raise FBchatUserError('Can only change the image of group threads') - else: - mimetype = guess_type(image_url)[0] - is_gif = (mimetype == 'image/gif') - remote_image = requests.get(image_url).content - image_id = self._uploadImage(image_url, remote_image, mimetype) - self.changeThreadImage(image_id, thread_id, thread_type) - - def changeThreadImageLocal(self, image_path, thread_id=None, thread_type=ThreadType.USER): + (image_id, mimetype), = self._upload(get_files_from_urls([image_url])) + return self._changeGroupImage(image_id, thread_id) + + def changeGroupImageLocal(self, image_path, thread_id=None): """ Changes a thread image from a local path :param image_path: Path of an image to upload and change :param thread_id: User/Group ID to change image. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type thread_type: models.ThreadType :raises: FBchatException if request failed """ - - thread_id, thread_type = self._getThread(thread_id, thread_type) - - if thread_type != ThreadType.GROUP: - raise FBchatUserError('Can only change the image of group threads') - else: - mimetype = guess_type(image_path)[0] - is_gif = (mimetype == 'image/gif') - image_id = self._uploadImage(image_path, open(image_path, 'rb'), mimetype) - self.changeThreadImage(image_id, thread_id, thread_type) + with get_files_from_paths([image_path]) as files: + (image_id, mimetype), = self._upload(files) + + return self._changeGroupImage(image_id, thread_id) def changeThreadTitle(self, title, thread_id=None, thread_type=ThreadType.USER): """ @@ -1179,17 +1420,17 @@ class Client(object): """ thread_id, thread_type = self._getThread(thread_id, thread_type) - + if thread_type == ThreadType.USER: # The thread is a user, so we change the user's nickname return self.changeNickname(title, thread_id, thread_id=thread_id, thread_type=thread_type) - else: - data = { - 'thread_name': title, - 'thread_id': thread_id, - } - j = self._post(self.req_url.THREAD_NAME, data, fix_request=True, as_json=True) + data = { + 'thread_name': title, + 'thread_id': thread_id, + } + + j = self._post(self.req_url.THREAD_NAME, data, fix_request=True, as_json=True) def changeNickname(self, nickname, user_id, thread_id=None, thread_type=ThreadType.USER): """ @@ -1260,8 +1501,7 @@ class Client(object): """ full_data = { "doc_id": 1491398900900362, - "dpr": 1, - "variables": { + "variables": json.dumps({ "data": { "action": "ADD_REACTION", "client_mutation_id": "1", @@ -1269,43 +1509,29 @@ class Client(object): "message_id": str(message_id), "reaction": reaction.value } - } + }) } - try: - url_part = urllib.parse.urlencode(full_data) - except AttributeError: - # This is a very hacky solution for python 2 support, please suggest a better one ;) - url_part = urllib.urlencode(full_data)\ - .replace('u%27', '%27')\ - .replace('%5CU{}'.format(MessageReactionFix[reaction.value][0]), MessageReactionFix[reaction.value][1]) - j = self._post('{}/?{}'.format(self.req_url.MESSAGE_REACTION, url_part), fix_request=True, as_json=True) + j = self._post(self.req_url.MESSAGE_REACTION, full_data, fix_request=True, as_json=True) - def eventReminder(self, thread_id, time, title, location='', location_id=''): + def createPlan(self, plan, thread_id=None): """ - Sets an event reminder + Sets a plan - ..warning:: - Does not work in Python2.7 - - ..todo:: - Make this work in Python2.7 - - :param thread_id: User/Group ID to send event to. See :ref:`intro_threads` - :param time: Event time (unix time stamp) - :param title: Event title - :param location: Event location name - :param location_id: Event location ID + :param plan: Plan to set + :param thread_id: User/Group ID to send plan to. See :ref:`intro_threads` + :type plan: models.Plan :raises: FBchatException if request failed """ + thread_id, thread_type = self._getThread(thread_id, None) + full_data = { "event_type": "EVENT", - "dpr": 1, - "event_time" : time, - "title" : title, + "event_time" : plan.time, + "title" : plan.title, "thread_id" : thread_id, - "location_id" : location_id, - "location_name" : location, + "location_id" : plan.location_id or '', + "location_name" : plan.location or '', "acontext": { "action_history": [{ "surface": "messenger_chat_tab", @@ -1313,10 +1539,134 @@ class Client(object): }] } } - url_part = urllib.parse.urlencode(full_data) - j = self._post('{}/?{}'.format(self.req_url.EVENT_REMINDER, url_part), fix_request=True, as_json=True) + j = self._post(self.req_url.PLAN_CREATE, full_data, fix_request=True, as_json=True) + def editPlan(self, plan, new_plan): + """ + Edits a plan + + :param plan: Plan to edit + :param new_plan: New plan + :type plan: models.Plan + :raises: FBchatException if request failed + """ + full_data = { + "event_reminder_id": plan.uid, + "delete": "false", + "date": new_plan.time, + "location_name": new_plan.location or '', + "location_id": new_plan.location_id or '', + "title": new_plan.title, + "acontext": { + "action_history": [{ + "surface": "messenger_chat_tab", + "mechanism": "reminder_banner" + }] + } + } + + j = self._post(self.req_url.PLAN_CHANGE, full_data, fix_request=True, as_json=True) + + def deletePlan(self, plan): + """ + Deletes a plan + + :param plan: Plan to delete + :raises: FBchatException if request failed + """ + full_data = { + "event_reminder_id": plan.uid, + "delete": "true", + "acontext": { + "action_history": [{ + "surface": "messenger_chat_tab", + "mechanism": "reminder_banner" + }] + } + } + + j = self._post(self.req_url.PLAN_CHANGE, full_data, fix_request=True, as_json=True) + + def changePlanParticipation(self, plan, take_part=True): + """ + Changes participation in a plan + + :param plan: Plan to take part in or not + :param take_part: Whether to take part in the plan + :raises: FBchatException if request failed + """ + full_data = { + "event_reminder_id": plan.uid, + "guest_state": "GOING" if take_part else "DECLINED", + "acontext": { + "action_history": [{ + "surface": "messenger_chat_tab", + "mechanism": "reminder_banner" + }] + } + } + + j = self._post(self.req_url.PLAN_PARTICIPATION, full_data, fix_request=True, as_json=True) + + def eventReminder(self, thread_id, time, title, location='', location_id=''): + """ + Deprecated. Use :func:`fbchat.Client.createPlan` instead + """ + self.createPlan(plan=Plan(time=time, title=title, location=location, location_id=location_id), thread_id=thread_id) + + def createPoll(self, poll, thread_id=None): + """ + Creates poll in a group thread + + :param poll: Poll to create + :param thread_id: User/Group ID to create poll in. See :ref:`intro_threads` + :type poll: models.Poll + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + # We're using ordered dicts, because the Facebook endpoint that parses the POST + # parameters is badly implemented, and deals with ordering the options wrongly. + # This also means we had to change `client.payloadDefault` to an ordered dict, + # since that's being copied in between this point and the `requests` call + # + # If you can find a way to fix this for the endpoint, or if you find another + # endpoint, please do suggest it ;) + data = OrderedDict([ + ("question_text", poll.title), + ("target_id", thread_id), + ]) + + for i, option in enumerate(poll.options): + data["option_text_array[{}]".format(i)] = option.text + data["option_is_selected_array[{}]".format(i)] = str(int(option.vote)) + + j = self._post(self.req_url.CREATE_POLL, data, fix_request=True, as_json=True) + + def updatePollVote(self, poll_id, option_ids=[], new_options=[]): + """ + Updates a poll vote + + :param poll_id: ID of the poll to update vote + :param option_ids: List of the option IDs to vote + :param new_options: List of the new option names + :param thread_id: User/Group ID to change status in. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :raises: FBchatException if request failed + """ + data = { + "question_id": poll_id + } + + for i, option_id in enumerate(option_ids): + data["selected_options[{}]".format(i)] = option_id + + for i, option_text in enumerate(new_options): + data["new_options[{}]".format(i)] = option_text + + j = self._post(self.req_url.UPDATE_VOTE, data, fix_request=True, as_json=True) def setTypingStatus(self, status, thread_id=None, thread_type=None): """ @@ -1361,24 +1711,42 @@ class Client(object): r = self._post(self.req_url.DELIVERED, data) return r.ok - def markAsRead(self, thread_id): - """ - Mark a thread as read - All messages inside the thread will be marked as read + def _readStatus(self, read, thread_ids): + thread_ids = require_list(thread_ids) - :param thread_id: User/Group ID to set as read. See :ref:`intro_threads` - :return: Whether the request was successful - :raises: FBchatException if request failed - """ data = { - "ids[%s]" % thread_id: 'true', "watermarkTimestamp": now(), "shouldSendReadReceipt": 'true', } + for thread_id in thread_ids: + data["ids[{}]".format(thread_id)] = read + r = self._post(self.req_url.READ_STATUS, data) return r.ok + def markAsRead(self, thread_ids=None): + """ + Mark threads as read + All messages inside the threads will be marked as read + + :param thread_ids: User/Group IDs to set as read. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + self._readStatus(True, thread_ids) + + def markAsUnread(self, thread_ids=None): + """ + Mark threads as unread + All messages inside the threads will be marked as unread + + :param thread_ids: User/Group IDs to set as unread. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + self._readStatus(False, thread_ids) + def markAsSeen(self): """ .. todo:: @@ -1401,9 +1769,10 @@ class Client(object): return r.ok def removeFriend(self, friend_id=None): - """Removes a specifed friend from your friend list + """ + Removes a specifed friend from your friend list - :param friend_id: The id of the friend that you want to remove + :param friend_id: The ID of the friend that you want to remove :return: Returns error if the removing was unsuccessful, returns True when successful. """ payload = { @@ -1420,6 +1789,179 @@ class Client(object): log.warning("Error while removing friend") return False + def blockUser(self, user_id): + """ + Blocks messages from a specifed user + + :param user_id: The ID of the user that you want to block + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + data = { + 'fbid': user_id + } + r = self._post(self.req_url.BLOCK_USER, data) + return r.ok + + def unblockUser(self, user_id): + """ + Unblocks messages from a blocked user + + :param user_id: The ID of the user that you want to unblock + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + data = { + 'fbid': user_id + } + r = self._post(self.req_url.UNBLOCK_USER, data) + return r.ok + + def moveThreads(self, location, thread_ids): + """ + Moves threads to specifed location + + :param location: models.ThreadLocation: INBOX, PENDING, ARCHIVED or OTHER + :param thread_ids: Thread IDs to move. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + thread_ids = require_list(thread_ids) + + if location == ThreadLocation.PENDING: + location = ThreadLocation.OTHER + + if location == ThreadLocation.ARCHIVED: + data_archive = dict() + data_unpin = dict() + for thread_id in thread_ids: + data_archive["ids[{}]".format(thread_id)] = 'true' + data_unpin["ids[{}]".format(thread_id)] = 'false' + r_archive = self._post(self.req_url.ARCHIVED_STATUS, data_archive) + r_unpin = self._post(self.req_url.PINNED_STATUS, data_unpin) + return r_archive.ok and r_unpin.ok + else: + data = dict() + for i, thread_id in enumerate(thread_ids): + data["{}[{}]".format(location.name.lower(), i)] = thread_id + r = self._post(self.req_url.MOVE_THREAD, data) + return r.ok + + def deleteThreads(self, thread_ids): + """ + Deletes threads + + :param thread_ids: Thread IDs to delete. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + thread_ids = require_list(thread_ids) + + data_unpin = dict() + data_delete = dict() + for i, thread_id in enumerate(thread_ids): + data_unpin["ids[{}]".format(thread_id)] = "false" + data_delete["ids[{}]".format(i)] = thread_id + r_unpin = self._post(self.req_url.PINNED_STATUS, data_unpin) + r_delete = self._post(self.req_url.DELETE_THREAD, data_delete) + return r_unpin.ok and r_delete.ok + + def markAsSpam(self, thread_id=None): + """ + Mark a thread as spam and delete it + + :param thread_id: User/Group ID to mark as spam. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + r = self._post(self.req_url.MARK_SPAM, {"id": thread_id}) + return r.ok + + def deleteMessages(self, message_ids): + """ + Deletes specifed messages + + :param message_ids: Message IDs to delete + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + message_ids = require_list(message_ids) + data = dict() + for i, message_id in enumerate(message_ids): + data["message_ids[{}]".format(i)] = message_id + r = self._post(self.req_url.DELETE_MESSAGES, data) + return r.ok + + def muteThread(self, mute_time=-1, thread_id=None): + """ + Mutes thread + + :param mute_time: Mute time in seconds, leave blank to mute forever + :param thread_id: User/Group ID to mute. See :ref:`intro_threads` + """ + thread_id, thread_type = self._getThread(thread_id, None) + data = { + "mute_settings": str(mute_time), + "thread_fbid": thread_id + } + r = self._post(self.req_url.MUTE_THREAD, data) + r.raise_for_status() + + def unmuteThread(self, thread_id=None): + """ + Unmutes thread + + :param thread_id: User/Group ID to unmute. See :ref:`intro_threads` + """ + return self.muteThread(0, thread_id) + + def muteThreadReactions(self, mute=True, thread_id=None): + """ + Mutes thread reactions + + :param mute: Boolean. True to mute, False to unmute + :param thread_id: User/Group ID to mute. See :ref:`intro_threads` + """ + thread_id, thread_type = self._getThread(thread_id, None) + data = { + "reactions_mute_mode": int(mute), + "thread_fbid": thread_id + } + r = self._post(self.req_url.MUTE_REACTIONS, data) + r.raise_for_status() + + def unmuteThreadReactions(self, thread_id=None): + """ + Unmutes thread reactions + + :param thread_id: User/Group ID to unmute. See :ref:`intro_threads` + """ + return self.muteThreadReactions(False, thread_id) + + def muteThreadMentions(self, mute=True, thread_id=None): + """ + Mutes thread mentions + + :param mute: Boolean. True to mute, False to unmute + :param thread_id: User/Group ID to mute. See :ref:`intro_threads` + """ + thread_id, thread_type = self._getThread(thread_id, None) + data = { + "mentions_mute_mode": int(mute), + "thread_fbid": thread_id + } + r = self._post(self.req_url.MUTE_MENTIONS, data) + r.raise_for_status() + + def unmuteThreadMentions(self, thread_id=None): + """ + Unmutes thread mentions + + :param thread_id: User/Group ID to unmute. See :ref:`intro_threads` + """ + return self.muteThreadMentions(False, thread_id) + """ LISTEN METHODS """ @@ -1536,6 +2078,24 @@ class Client(object): self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + # Forced fetch + elif delta.get("class") == "ForcedFetch": + mid = delta.get("messageId") + if mid is None: + self.onUnknownMesssageType(msg=m) + else: + thread_id = str(delta['threadKey']['threadFbId']) + fetch_info = self._forcedFetch(thread_id, mid) + fetch_data = fetch_info["message"] + author_id = fetch_data["message_sender"]["id"] + ts = fetch_data["timestamp_precise"] + if fetch_data.get("__typename") == "ThreadImageMessage": + # Thread image change + image_metadata = fetch_data.get("image_with_metadata") + image_id = int(image_metadata["legacy_attachment_id"]) if image_metadata else None + self.onImageChange(mid=mid, author_id=author_id, new_image=image_id, thread_id=thread_id, + thread_type=ThreadType.GROUP, ts=ts) + # Nickname change elif delta_type == "change_thread_nickname": changed_for = str(delta["untypedData"]["participant_id"]) @@ -1545,6 +2105,25 @@ class Client(object): new_nickname=new_nickname, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + # Admin added or removed in a group thread + elif delta_type == "change_thread_admins": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + target_id = delta["untypedData"]["TARGET_ID"] + admin_event = delta["untypedData"]["ADMIN_EVENT"] + if admin_event == "add_admin": + self.onAdminAdded(mid=mid, added_id=target_id, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, msg=m) + elif admin_event == "remove_admin": + self.onAdminRemoved(mid=mid, removed_id=target_id, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, msg=m) + + # Group approval mode change + elif delta_type == "change_thread_approval_mode": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + approval_mode = bool(int(delta['untypedData']['APPROVAL_MODE'])) + self.onApprovalModeChange(mid=mid, approval_mode=approval_mode, author_id=author_id, + thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) + # Message delivered elif delta.get("class") == "DeliveryReceipt": message_ids = delta["messageIds"] @@ -1552,7 +2131,8 @@ class Client(object): ts = int(delta["deliveredWatermarkTimestampMs"]) thread_id, thread_type = getThreadIdAndThreadType(delta) self.onMessageDelivered(msg_ids=message_ids, delivered_for=delivered_for, - thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + thread_id=thread_id, thread_type=thread_type, ts=ts, + metadata=metadata, msg=m) # Message seen elif delta.get("class") == "ReadReceipt": @@ -1575,6 +2155,95 @@ class Client(object): # thread_id, thread_type = getThreadIdAndThreadType(delta) self.onMarkedSeen(threads=threads, seen_ts=seen_ts, ts=delivered_ts, metadata=delta, msg=m) + # Game played + elif delta_type == "instant_game_update": + game_id = delta["untypedData"]["game_id"] + game_name = delta["untypedData"]["game_name"] + score = delta["untypedData"].get("score") + if score is not None: + score = int(score) + leaderboard = delta["untypedData"].get("leaderboard") + if leaderboard is not None: + leaderboard = json.loads(leaderboard)["scores"] + thread_id, thread_type = getThreadIdAndThreadType(metadata) + self.onGamePlayed(mid=mid, author_id=author_id, game_id=game_id, game_name=game_name, + score=score, leaderboard=leaderboard, thread_id=thread_id, + thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Group call started/ended + elif delta_type == "rtc_call_log": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + call_status = delta["untypedData"]["event"] + call_duration = int(delta["untypedData"]["call_duration"]) + is_video_call = bool(int(delta["untypedData"]["is_video_call"])) + if call_status == "call_started": + self.onCallStarted(mid=mid, caller_id=author_id, is_video_call=is_video_call, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + elif call_status == "call_ended": + self.onCallEnded(mid=mid, caller_id=author_id, is_video_call=is_video_call, call_duration=call_duration, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # User joined to group call + elif delta_type == "participant_joined_group_call": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + is_video_call = bool(int(delta["untypedData"]["group_call_type"])) + self.onUserJoinedCall(mid=mid, joined_id=author_id, is_video_call=is_video_call, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Group poll event + elif delta_type == "group_poll": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + event_type = delta["untypedData"]["event_type"] + poll_json = json.loads(delta["untypedData"]["question_json"]) + poll = graphql_to_poll(poll_json) + if event_type == "question_creation": + # User created group poll + self.onPollCreated(mid=mid, poll=poll, author_id=author_id, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + elif event_type == "update_vote": + # User voted on group poll + added_options = json.loads(delta["untypedData"]["added_option_ids"]) + removed_options = json.loads(delta["untypedData"]["removed_option_ids"]) + self.onPollVoted(mid=mid, poll=poll, added_options=added_options, removed_options=removed_options, + author_id=author_id, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + + # Plan created + elif delta_type == "lightweight_event_create": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanCreated(mid=mid, plan=plan, author_id=author_id, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + + # Plan ended + elif delta_type == "lightweight_event_notify": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanEnded(mid=mid, plan=plan, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + + # Plan edited + elif delta_type == "lightweight_event_update": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanEdited(mid=mid, plan=plan, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Plan deleted + elif delta_type == "lightweight_event_delete": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanDeleted(mid=mid, plan=plan, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Plan participation change + elif delta_type == "lightweight_event_rsvp": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + take_part = delta["untypedData"]["guest_status"] == "GOING" + self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + # New message elif delta.get("class") == "NewMessage": mentions = [] @@ -1857,6 +2526,20 @@ class Client(object): """ log.info("Title change from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, new_title)) + + def onImageChange(self, mid=None, author_id=None, new_image=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None): + """ + Called when the client is listening, and somebody changes the image of a thread + + :param mid: The action ID + :param new_image: The ID of the new image + :param author_id: The ID of the person who changed the image + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + """ + log.info("{} changed thread image in {}".format(author_id, thread_id)) + + def onNicknameChange(self, mid=None, author_id=None, changed_for=None, new_nickname=None, thread_id=None, thread_type=ThreadType.USER, ts=None, metadata=None, msg=None): """ Called when the client is listening, and somebody changes the nickname of a person @@ -1875,6 +2558,50 @@ class Client(object): log.info("Nickname change from {} in {} ({}) for {}: {}".format(author_id, thread_id, thread_type.name, changed_for, new_nickname)) + def onAdminAdded(self, mid=None, added_id=None, author_id=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None): + """ + Called when the client is listening, and somebody adds an admin to a group thread + + :param mid: The action ID + :param added_id: The ID of the admin who got added + :param author_id: The ID of the person who added the admins + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + """ + log.info("{} added admin: {} in {}".format(author_id, added_id, thread_id)) + + + def onAdminRemoved(self, mid=None, removed_id=None, author_id=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None): + """ + Called when the client is listening, and somebody removes an admin from a group thread + + :param mid: The action ID + :param removed_id: The ID of the admin who got removed + :param author_id: The ID of the person who removed the admins + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + """ + log.info("{} removed admin: {} in {}".format(author_id, removed_id, thread_id)) + + + def onApprovalModeChange(self, mid=None, approval_mode=None, author_id=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None): + """ + Called when the client is listening, and somebody changes approval mode in a group thread + + :param mid: The action ID + :param approval_mode: True if approval mode is activated + :param author_id: The ID of the person who changed approval mode + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + """ + if approval_mode: + log.info("{} activated approval mode in {}".format(author_id, thread_id)) + else: + log.info("{} disabled approval mode in {}".format(author_id, thread_id)) + def onMessageSeen(self, seen_by=None, thread_id=None, thread_type=ThreadType.USER, seen_ts=None, ts=None, metadata=None, msg=None): """ Called when the client is listening, and somebody marks a message as seen @@ -1931,7 +2658,7 @@ class Client(object): :param ts: A timestamp of the action :param msg: A full set of the data recieved """ - log.info("{} added: {}".format(author_id, ', '.join(added_ids))) + log.info("{} added: {} in {}".format(author_id, ', '.join(added_ids), thread_id)) def onPersonRemoved(self, mid=None, removed_id=None, author_id=None, thread_id=None, ts=None, msg=None): """ @@ -1944,7 +2671,7 @@ class Client(object): :param ts: A timestamp of the action :param msg: A full set of the data recieved """ - log.info("{} removed: {}".format(author_id, removed_id)) + log.info("{} removed: {} in {}".format(author_id, removed_id, thread_id)) def onFriendRequest(self, from_id=None, msg=None): """ @@ -1981,6 +2708,25 @@ class Client(object): """ pass + def onGamePlayed(self, mid=None, author_id=None, game_id=None, game_name=None, score=None, leaderboard=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody plays a game + + :param mid: The action ID + :param author_id: The ID of the person who played the game + :param game_id: The ID of the game + :param game_name: Name of the game + :param score: Score obtained in the game + :param leaderboard: Actual leaderboard of the game in the thread + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name)) + def onQprimer(self, ts=None, msg=None): """ Called when the client just started listening @@ -2016,6 +2762,183 @@ class Client(object): """ log.exception('Exception in parsing of {}'.format(msg)) + def onCallStarted(self, mid=None, caller_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + .. todo:: + Make this work with private calls + + Called when the client is listening, and somebody starts a call in a group + + :param mid: The action ID + :param caller_id: The ID of the person who started the call + :param is_video_call: True if it's video call + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} started call in {} ({})".format(caller_id, thread_id, thread_type.name)) + + def onCallEnded(self, mid=None, caller_id=None, is_video_call=None, call_duration=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + .. todo:: + Make this work with private calls + + Called when the client is listening, and somebody ends a call in a group + + :param mid: The action ID + :param caller_id: The ID of the person who ended the call + :param is_video_call: True if it was video call + :param call_duration: Call duration in seconds + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} ended call in {} ({})".format(caller_id, thread_id, thread_type.name)) + + def onUserJoinedCall(self, mid=None, joined_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody joins a group call + + :param mid: The action ID + :param joined_id: The ID of the person who joined the call + :param is_video_call: True if it's video call + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} joined call in {} ({})".format(joined_id, thread_id, thread_type.name)) + + def onPollCreated(self, mid=None, poll=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody creates a group poll + + :param mid: The action ID + :param poll: Created poll + :param author_id: The ID of the person who created the poll + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type poll: models.Poll + :type thread_type: models.ThreadType + """ + log.info("{} created poll {} in {} ({})".format(author_id, poll, thread_id, thread_type.name)) + + def onPollVoted(self, mid=None, poll=None, added_options=None, removed_options=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody votes in a group poll + + :param mid: The action ID + :param poll: Poll, that user voted in + :param author_id: The ID of the person who voted in the poll + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type poll: models.Poll + :type thread_type: models.ThreadType + """ + log.info("{} voted in poll {} in {} ({})".format(author_id, poll, thread_id, thread_type.name)) + + def onPlanCreated(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody creates a plan + + :param mid: The action ID + :param plan: Created plan + :param author_id: The ID of the person who created the plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("{} created plan {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + + def onPlanEnded(self, mid=None, plan=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and a plan ends + + :param mid: The action ID + :param plan: Ended plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("Plan {} has ended in {} ({})".format(plan, thread_id, thread_type.name)) + + def onPlanEdited(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody edits a plan + + :param mid: The action ID + :param plan: Edited plan + :param author_id: The ID of the person who edited the plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("{} edited plan {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + + def onPlanDeleted(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody deletes a plan + + :param mid: The action ID + :param plan: Deleted plan + :param author_id: The ID of the person who deleted the plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("{} deleted plan {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + + def onPlanParticipation(self, mid=None, plan=None, take_part=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody takes part in a plan or not + + :param mid: The action ID + :param plan: Plan + :param take_part: Whether the person takes part in the plan or not + :param author_id: The ID of the person who will participate in the plan or not + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + if take_part: + log.info("{} will take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + else: + log.info("{} won't take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + """ END EVENTS """ diff --git a/fbchat/graphql.py b/fbchat/graphql.py index de1bfbc..ba73903 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -128,6 +128,71 @@ def graphql_to_attachment(a): uid=a.get('legacy_attachment_id') ) +def graphql_to_poll(a): + rtn = Poll( + title=a.get('title') if a.get('title') else a.get("text"), + options=[graphql_to_poll_option(m) for m in a.get('options')] + ) + rtn.uid = int(a["id"]) + rtn.options_count = a.get("total_count") + return rtn + +def graphql_to_poll_option(a): + if a.get('viewer_has_voted') is None: + vote = None + elif isinstance(a['viewer_has_voted'], bool): + vote = a['viewer_has_voted'] + else: + vote = a['viewer_has_voted'] == 'true' + rtn = PollOption( + text=a.get('text'), + vote=vote + ) + rtn.uid = int(a["id"]) + rtn.voters = [m.get('node').get('id') for m in a.get('voters').get('edges')] if isinstance(a.get('voters'), dict) else a.get('voters') + rtn.votes_count = a.get('voters').get('count') if isinstance(a.get('voters'), dict) else a.get('total_count') + return rtn + +def graphql_to_plan(a): + if a.get('event_members'): + rtn = Plan( + time=a.get('event_time'), + title=a.get('title'), + location=a.get('location_name') + ) + if a.get('location_id') != 0: + rtn.location_id = str(a.get('location_id')) + rtn.uid = a.get('oid') + rtn.author_id = a.get('creator_id') + guests = a.get("event_members") + rtn.going = [uid for uid in guests if guests[uid] == "GOING"] + rtn.declined = [uid for uid in guests if guests[uid] == "DECLINED"] + rtn.invited = [uid for uid in guests if guests[uid] == "INVITED"] + return rtn + elif a.get('id') is None: + rtn = Plan( + time=a.get('event_time'), + title=a.get('event_title'), + location=a.get('event_location_name'), + location_id=a.get('event_location_id') + ) + rtn.uid = a.get('event_id') + rtn.author_id = a.get('event_creator_id') + guests = json.loads(a.get('guest_state_list')) + else: + rtn = Plan( + time=a.get('time'), + title=a.get('event_title'), + location=a.get('location_name') + ) + rtn.uid = a.get('id') + rtn.author_id = a.get('lightweight_event_creator').get('id') + guests = a.get('event_reminder_members').get('edges') + rtn.going = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "GOING"] + rtn.declined = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "DECLINED"] + rtn.invited = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "INVITED"] + return rtn + def graphql_to_message(message): if message.get('message_sender') is None: message['message_sender'] = {} @@ -155,6 +220,7 @@ def graphql_to_user(user): if user.get('profile_picture') is None: user['profile_picture'] = {} c_info = get_customization_info(user) + plan = graphql_to_plan(user['event_reminders']['nodes'][0]) if user.get('event_reminders', dict()).get('nodes') else None return User( user['id'], url=user.get('url'), @@ -169,7 +235,8 @@ def graphql_to_user(user): own_nickname=c_info.get('own_nickname'), photo=user['profile_picture'].get('uri'), name=user.get('name'), - message_count=user.get('messages_count') + message_count=user.get('messages_count'), + plan=plan, ) def graphql_to_thread(thread): @@ -191,6 +258,8 @@ def graphql_to_thread(thread): else: last_name = user.get('name').split(first_name, 1).pop().strip() + plan = graphql_to_plan(thread['event_reminders']['nodes'][0]) if thread.get('event_reminders', dict()).get('nodes') else None + return User( user['id'], url=user.get('url'), @@ -206,7 +275,8 @@ def graphql_to_thread(thread): own_nickname=c_info.get('own_nickname'), photo=user['big_image_src'].get('uri'), message_count=thread.get('messages_count'), - last_message_timestamp=last_message_timestamp + last_message_timestamp=last_message_timestamp, + plan=plan, ) else: raise FBchatException('Unknown thread type: {}, with data: {}'.format(thread.get('thread_type'), thread)) @@ -218,6 +288,7 @@ def graphql_to_group(group): last_message_timestamp = None if 'last_message' in group: last_message_timestamp = group['last_message']['nodes'][0]['timestamp_precise'] + plan = graphql_to_plan(group['event_reminders']['nodes'][0]) if group.get('event_reminders', dict()).get('nodes') else None return Group( group['thread_key']['thread_fbid'], participants=set([node['messaging_actor']['id'] for node in group['all_participants']['nodes']]), @@ -227,13 +298,15 @@ def graphql_to_group(group): photo=group['image'].get('uri'), name=group.get('name'), message_count=group.get('messages_count'), - last_message_timestamp=last_message_timestamp + last_message_timestamp=last_message_timestamp, + plan=plan, ) def graphql_to_room(room): if room.get('image') is None: room['image'] = {} c_info = get_customization_info(room) + plan = graphql_to_plan(room['event_reminders']['nodes'][0]) if room.get('event_reminders', dict()).get('nodes') else None return Room( room['thread_key']['thread_fbid'], participants=set([node['messaging_actor']['id'] for node in room['all_participants']['nodes']]), @@ -248,6 +321,7 @@ def graphql_to_room(room): approval_requests = set(node.get('id') for node in room['thread_queue_metadata'].get('approval_requests', {}).get('nodes')), join_link = room['joinable_mode'].get('link'), privacy_mode = bool(room.get('privacy_mode')), + plan=plan, ) def graphql_to_page(page): @@ -255,6 +329,7 @@ def graphql_to_page(page): page['profile_picture'] = {} if page.get('city') is None: page['city'] = {} + plan = graphql_to_plan(page['event_reminders']['nodes'][0]) if page.get('event_reminders', dict()).get('nodes') else None return Page( page['id'], url=page.get('url'), @@ -262,7 +337,8 @@ def graphql_to_page(page): category=page.get('category_type'), photo=page['profile_picture'].get('uri'), name=page.get('name'), - message_count=page.get('messages_count') + message_count=page.get('messages_count'), + plan=plan, ) def graphql_queries_to_json(*queries): diff --git a/fbchat/models.py b/fbchat/models.py index a380599..5fce5a7 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -37,7 +37,9 @@ class Thread(object): last_message_timestamp = None #: Number of messages in the thread message_count = None - def __init__(self, _type, uid, photo=None, name=None, last_message_timestamp=None, message_count=None): + #: Set :class:`Plan` + plan = None + def __init__(self, _type, uid, photo=None, name=None, last_message_timestamp=None, message_count=None, plan=None): """Represents a Facebook thread""" self.uid = str(uid) self.type = _type @@ -45,6 +47,7 @@ class Thread(object): self.name = name self.last_message_timestamp = last_message_timestamp self.message_count = message_count + self.plan = plan def __repr__(self): return self.__unicode__() @@ -436,6 +439,87 @@ class Mention(object): def __unicode__(self): return ''.format(self.thread_id, self.offset, self.length) +class Poll(object): + #: ID of the poll + uid = None + #: Title of the poll + title = None + #: List of :class:`PollOption`, can be fetched with :func:`fbchat.Client.fetchPollOptions` + options = None + #: Options count + options_count = None + + def __init__(self, title, options): + """Represents a poll""" + self.title = title + self.options = options + + def __repr__(self): + return self.__unicode__() + + def __unicode__(self): + return ''.format(self.uid, repr(self.title), self.options) + +class PollOption(object): + #: ID of the poll option + uid = None + #: Text of the poll option + text = None + #: Whether vote when creating or client voted + vote = None + #: ID of the users who voted for this poll option + voters = None + #: Votes count + votes_count = None + + def __init__(self, text, vote=False): + """Represents a poll option""" + self.text = text + self.vote = vote + + def __repr__(self): + return self.__unicode__() + + def __unicode__(self): + return ''.format(self.uid, repr(self.text), self.voters) + +class Plan(object): + #: ID of the plan + uid = None + #: Plan time (unix time stamp), only precise down to the minute + time = None + #: Plan title + title = None + #: Plan location name + location = None + #: Plan location ID + location_id = None + #: ID of the plan creator + author_id = None + #: List of the people IDs who will take part in the plan + going = None + #: List of the people IDs who won't take part in the plan + declined = None + #: List of the people IDs who are invited to the plan + invited = None + + def __init__(self, time, title, location=None, location_id=None): + """Represents a plan""" + self.time = int(time) + self.title = title + self.location = location or '' + self.location_id = location_id or '' + self.author_id = None + self.going = [] + self.declined = [] + self.invited = [] + + def __repr__(self): + return self.__unicode__() + + def __unicode__(self): + return ''.format(self.uid, repr(self.title), self.time, repr(self.location), repr(self.location_id)) + class Enum(enum.Enum): """Used internally by fbchat to support enumerations""" def __repr__(self): diff --git a/fbchat/utils.py b/fbchat/utils.py index 2517459..92e8a9d 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -5,8 +5,12 @@ import re import json from time import time from random import random +from contextlib import contextmanager +from mimetypes import guess_type +from os.path import basename import warnings import logging +import requests from .models import * try: @@ -48,16 +52,6 @@ LIKES = { 's': EmojiSize.SMALL } -MessageReactionFix = { - 'šŸ˜': ('0001f60d', '%F0%9F%98%8D'), - 'šŸ˜†': ('0001f606', '%F0%9F%98%86'), - '😮': ('0001f62e', '%F0%9F%98%AE'), - '😢': ('0001f622', '%F0%9F%98%A2'), - '😠': ('0001f620', '%F0%9F%98%A0'), - 'šŸ‘': ('0001f44d', '%F0%9F%91%8D'), - 'šŸ‘Ž': ('0001f44e', '%F0%9F%91%8E') -} - GENDERS = { # For standard requests @@ -97,6 +91,9 @@ class ReqUrl(object): UNREAD_THREADS = "https://www.facebook.com/ajax/mercury/unread_threads.php" UNSEEN_THREADS = "https://www.facebook.com/mercury/unseen_thread_ids/" THREADS = "https://www.facebook.com/ajax/mercury/threadlist_info.php" + MOVE_THREAD = "https://www.facebook.com/ajax/mercury/move_thread.php" + ARCHIVED_STATUS = "https://www.facebook.com/ajax/mercury/change_archived_status.php?dpr=1" + PINNED_STATUS = "https://www.facebook.com/ajax/mercury/change_pinned_status.php?dpr=1" MESSAGES = "https://www.facebook.com/ajax/mercury/thread_info.php" READ_STATUS = "https://www.facebook.com/ajax/mercury/change_read_status.php" DELIVERED = "https://www.facebook.com/ajax/mercury/delivery_receipts.php" @@ -122,10 +119,27 @@ class ReqUrl(object): TYPING = "https://www.facebook.com/ajax/messaging/typ.php" GRAPHQL = "https://www.facebook.com/api/graphqlbatch/" ATTACHMENT_PHOTO = "https://www.facebook.com/mercury/attachments/photo/" - EVENT_REMINDER = "https://www.facebook.com/ajax/eventreminder/create" + PLAN_CREATE = "https://www.facebook.com/ajax/eventreminder/create" + PLAN_INFO = "https://www.facebook.com/ajax/eventreminder" + PLAN_CHANGE = "https://www.facebook.com/ajax/eventreminder/submit" + PLAN_PARTICIPATION = "https://www.facebook.com/ajax/eventreminder/rsvp" MODERN_SETTINGS_MENU = "https://www.facebook.com/bluebar/modern_settings_menu/" REMOVE_FRIEND = "https://m.facebook.com/a/removefriend.php" + BLOCK_USER = "https://www.facebook.com/messaging/block_messages/?dpr=1" + UNBLOCK_USER = "https://www.facebook.com/messaging/unblock_messages/?dpr=1" + SAVE_ADMINS = "https://www.facebook.com/messaging/save_admins/?dpr=1" + APPROVAL_MODE = "https://www.facebook.com/messaging/set_approval_mode/?dpr=1" CREATE_GROUP = "https://m.facebook.com/messages/send/?icm=1" + DELETE_THREAD = "https://www.facebook.com/ajax/mercury/delete_thread.php?dpr=1" + DELETE_MESSAGES = "https://www.facebook.com/ajax/mercury/delete_messages.php?dpr=1" + MUTE_THREAD = "https://www.facebook.com/ajax/mercury/change_mute_thread.php?dpr=1" + MUTE_REACTIONS = "https://www.facebook.com/ajax/mercury/change_reactions_mute_thread/?dpr=1" + MUTE_MENTIONS = "https://www.facebook.com/ajax/mercury/change_mentions_mute_thread/?dpr=1" + CREATE_POLL = "https://www.facebook.com/messaging/group_polling/create_poll/?dpr=1" + UPDATE_VOTE = "https://www.facebook.com/messaging/group_polling/update_vote/?dpr=1" + GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options" + SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1" + MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1" pull_channel = 0 @@ -239,3 +253,47 @@ def get_emojisize_from_tags(tags): except (KeyError, IndexError): log.exception('Could not determine emoji size from {} - {}'.format(tags, tmp)) return None + +def require_list(list_): + if isinstance(list_, list): + return set(list_) + else: + return set([list_]) + +def mimetype_to_key(mimetype): + if not mimetype: + return "file_id" + if mimetype == "image/gif": + return "gif_id" + x = mimetype.split("/") + if x[0] in ["video", "image", "audio"]: + return "%s_id" % x[0] + return "file_id" + + +def get_files_from_urls(file_urls): + files = [] + for file_url in file_urls: + r = requests.get(file_url) + # We could possibly use r.headers.get('Content-Disposition'), see + # https://stackoverflow.com/a/37060758 + files.append(( + basename(file_url), + r.content, + r.headers.get('Content-Type') or guess_type(file_url)[0], + )) + return files + + +@contextmanager +def get_files_from_paths(filenames): + files = [] + for filename in filenames: + files.append(( + basename(filename), + open(filename, 'rb'), + guess_type(filename)[0], + )) + yield files + for fn, fp, ft in files: + fp.close() diff --git a/tests/conftest.py b/tests/conftest.py index 879e3dc..af40730 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import json from utils import * from contextlib import contextmanager -from fbchat.models import ThreadType +from fbchat.models import ThreadType, Message, Mention @pytest.fixture(scope="session") @@ -20,9 +20,13 @@ def group(pytestconfig): return {"id": load_variable("group_id", pytestconfig.cache), "type": ThreadType.GROUP} -@pytest.fixture(scope="session", params=["user", "group"]) +@pytest.fixture(scope="session", params=["user", "group", pytest.mark.xfail("none")]) def thread(request, user, group): - return user if request.param == "user" else group + return { + "user": user, + "group": group, + "none": {"id": "0", "type": ThreadType.GROUP} + }[request.param] @pytest.fixture(scope="session") @@ -37,7 +41,7 @@ def client2(pytestconfig): yield c -@pytest.fixture # (scope="session") +@pytest.fixture(scope="module") def client(client1, thread): client1.setDefaultThread(thread["id"], thread["type"]) yield client1 @@ -80,12 +84,12 @@ def catch_event(client2): try: # Make the client send a messages to itself, so the blocking pull request will return # This is probably not safe, since the client is making two requests simultaneously - client2.sendMessage("Shutdown", client2.uid) + client2.sendMessage(random_hex(), client2.uid) finally: t.join() -@pytest.fixture # (scope="session") +@pytest.fixture(scope="module") def compare(client, thread): def inner(caught_event, **kwargs): d = { @@ -99,3 +103,21 @@ def compare(client, thread): return subset(caught_event.res, **d) return inner + + +@pytest.fixture(params=["me", "other", "me other"]) +def message_with_mentions(request, client, client2, group): + text = "Hi there [" + mentions = [] + if 'me' in request.param: + mentions.append(Mention(thread_id=client.uid, offset=len(text), length=2)) + text += "me, " + if 'other' in request.param: + mentions.append(Mention(thread_id=client2.uid, offset=len(text), length=5)) + text += "other, " + # Unused, because Facebook don't properly support sending mentions with groups as targets + if 'group' in request.param: + mentions.append(Mention(thread_id=group["id"], offset=len(text), length=5)) + text += "group, " + text += "nothing]" + return Message(text, mentions=mentions) diff --git a/tests/data.json b/tests/data.json deleted file mode 100644 index d20c682..0000000 --- a/tests/data.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "email": "", - "password": "", - "user_thread_id": "", - "group_thread_id": "" -} diff --git a/tests/resources/audio.mp3 b/tests/resources/audio.mp3 new file mode 100644 index 0000000..084a7d1 Binary files /dev/null and b/tests/resources/audio.mp3 differ diff --git a/tests/resources/file.json b/tests/resources/file.json new file mode 100644 index 0000000..c4036cf --- /dev/null +++ b/tests/resources/file.json @@ -0,0 +1,4 @@ +{ + "some": "data", + "in": "here" +} diff --git a/tests/resources/file.txt b/tests/resources/file.txt new file mode 100644 index 0000000..0204d00 --- /dev/null +++ b/tests/resources/file.txt @@ -0,0 +1 @@ +This is just a text file diff --git a/tests/resources/image.gif b/tests/resources/image.gif new file mode 100644 index 0000000..3db1c95 Binary files /dev/null and b/tests/resources/image.gif differ diff --git a/tests/resources/image.jpg b/tests/resources/image.jpg new file mode 100644 index 0000000..2cbbff0 Binary files /dev/null and b/tests/resources/image.jpg differ diff --git a/tests/image.png b/tests/resources/image.png similarity index 100% rename from tests/image.png rename to tests/resources/image.png diff --git a/tests/resources/video.mp4 b/tests/resources/video.mp4 new file mode 100644 index 0000000..02c2060 Binary files /dev/null and b/tests/resources/video.mp4 differ diff --git a/tests/test_fetch.py b/tests/test_fetch.py index d755c5a..669de22 100644 --- a/tests/test_fetch.py +++ b/tests/test_fetch.py @@ -6,32 +6,20 @@ import pytest from os import path from fbchat.models import ThreadType, Message, Mention, EmojiSize, Sticker -from utils import subset +from utils import subset, STICKER_LIST, EMOJI_LIST -def test_fetch_all_users(client): - users = client.fetchAllUsers() +def test_fetch_all_users(client1): + users = client1.fetchAllUsers() assert len(users) > 0 -def test_fetch_thread_list(client): - threads = client.fetchThreadList(limit=2) +def test_fetch_thread_list(client1): + threads = client1.fetchThreadList(limit=2) assert len(threads) == 2 -@pytest.mark.parametrize( - "emoji, emoji_size", - [ - ("šŸ˜†", EmojiSize.SMALL), - ("šŸ˜†", EmojiSize.MEDIUM), - ("šŸ˜†", EmojiSize.LARGE), - # These fail because the emoji is made into a sticker - # This should be fixed - pytest.mark.xfail((None, EmojiSize.SMALL)), - pytest.mark.xfail((None, EmojiSize.MEDIUM)), - pytest.mark.xfail((None, EmojiSize.LARGE)), - ], -) +@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) def test_fetch_message_emoji(client, emoji, emoji_size): mid = client.sendEmoji(emoji, emoji_size) message, = client.fetchThreadMessages(limit=1) @@ -41,25 +29,52 @@ def test_fetch_message_emoji(client, emoji, emoji_size): ) -def test_fetch_message_mentions(client): - text = "This is a test of fetchThreadMessages" - mentions = [Mention(client.uid, offset=10, length=4)] +@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) +def test_fetch_message_info_emoji(client, thread, emoji, emoji_size): + mid = client.sendEmoji(emoji, emoji_size) + message = client.fetchMessageInfo(mid, thread_id=thread["id"]) - mid = client.send(Message(text, mentions=mentions)) + assert subset( + vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size + ) + + +def test_fetch_message_mentions(client, thread, message_with_mentions): + mid = client.send(message_with_mentions) message, = client.fetchThreadMessages(limit=1) - assert subset(vars(message), uid=mid, author=client.uid, text=text) - for i, m in enumerate(mentions): - assert vars(message.mentions[i]) == vars(m) + assert subset(vars(message), uid=mid, author=client.uid, text=message_with_mentions.text) + # The mentions are not ordered by offset + for m in message.mentions: + assert vars(m) in [vars(x) for x in message_with_mentions.mentions] -@pytest.mark.parametrize("sticker_id", ["767334476626295"]) -def test_fetch_message_sticker(client, sticker_id): - mid = client.send(Message(sticker=Sticker(sticker_id))) +def test_fetch_message_info_mentions(client, thread, message_with_mentions): + mid = client.send(message_with_mentions) + message = client.fetchMessageInfo(mid, thread_id=thread["id"]) + + assert subset(vars(message), uid=mid, author=client.uid, text=message_with_mentions.text) + # The mentions are not ordered by offset + for m in message.mentions: + assert vars(m) in [vars(x) for x in message_with_mentions.mentions] + + +@pytest.mark.parametrize("sticker", STICKER_LIST) +def test_fetch_message_sticker(client, sticker): + mid = client.send(Message(sticker=sticker)) message, = client.fetchThreadMessages(limit=1) assert subset(vars(message), uid=mid, author=client.uid) - assert subset(vars(message.sticker), uid=sticker_id) + assert subset(vars(message.sticker), uid=sticker.uid) + + +@pytest.mark.parametrize("sticker", STICKER_LIST) +def test_fetch_message_info_sticker(client, thread, sticker): + mid = client.send(Message(sticker=sticker)) + message = client.fetchMessageInfo(mid, thread_id=thread["id"]) + + assert subset(vars(message), uid=mid, author=client.uid) + assert subset(vars(message.sticker), uid=sticker.uid) def test_fetch_info(client1, group): @@ -71,9 +86,7 @@ def test_fetch_info(client1, group): def test_fetch_image_url(client): - url = path.join(path.dirname(__file__), "image.png") - - client.sendLocalImage(url) + client.sendLocalFiles([path.join(path.dirname(__file__), "resources", "image.png")]) message, = client.fetchThreadMessages(limit=1) assert client.fetchImageUrl(message.attachments[0].uid) diff --git a/tests/test_message_management.py b/tests/test_message_management.py index ed6c447..00291ee 100644 --- a/tests/test_message_management.py +++ b/tests/test_message_management.py @@ -5,8 +5,19 @@ from __future__ import unicode_literals import pytest from fbchat.models import Message, MessageReaction +from utils import subset def test_set_reaction(client): mid = client.send(Message(text="This message will be reacted to")) client.reactToMessage(mid, MessageReaction.LOVE) + + +def test_delete_messages(client): + text1 = "This message will stay" + text2 = "This message will be removed" + mid1 = client.sendMessage(text1) + mid2 = client.sendMessage(text2) + client.deleteMessages(mid2) + message, = client.fetchThreadMessages(limit=1) + assert subset(vars(message), uid=mid1, author=client.uid, text=text1) diff --git a/tests/test_plans.py b/tests/test_plans.py new file mode 100644 index 0000000..d16c153 --- /dev/null +++ b/tests/test_plans.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from fbchat.models import Plan, FBchatFacebookError, ThreadType +from utils import random_hex, subset +from time import time + + +@pytest.fixture(scope="module", params=[ + Plan(int(time()) + 100, random_hex()), + pytest.mark.xfail(Plan(int(time()), random_hex()), raises=FBchatFacebookError), + pytest.mark.xfail(Plan(0, None)), +]) +def plan_data(request, client, user, thread, catch_event, compare): + with catch_event("onPlanCreated") as x: + client.createPlan(request.param, thread["id"]) + assert compare(x) + assert subset( + vars(x.res["plan"]), + time=request.param.time, + title=request.param.title, + author_id=client.uid, + going=[client.uid], + declined=[], + ) + plan_id = x.res["plan"] + assert user["id"] in x.res["plan"].invited + request.param.uid = x.res["plan"].uid + yield x.res, request.param + with catch_event("onPlanDeleted") as x: + client.deletePlan(plan_id) + assert compare(x) + + +@pytest.mark.tryfirst +def test_create_delete_plan(plan_data): + pass + + +def test_fetch_plan_info(client, catch_event, plan_data): + event, plan = plan_data + fetched_plan = client.fetchPlanInfo(plan.uid) + assert subset( + vars(fetched_plan), + time=plan.time, + title=plan.title, + author_id=int(client.uid), + ) + + +@pytest.mark.parametrize("take_part", [False, True]) +def test_change_plan_participation(client, thread, catch_event, compare, plan_data, take_part): + event, plan = plan_data + with catch_event("onPlanParticipation") as x: + client.changePlanParticipation(plan, take_part=take_part) + assert compare(x, take_part=take_part) + assert subset( + vars(x.res["plan"]), + time=plan.time, + title=plan.title, + author_id=client.uid, + going=[client.uid] if take_part else [], + declined=[client.uid] if not take_part else [], + ) + + +@pytest.mark.trylast +def test_edit_plan(client, thread, catch_event, compare, plan_data): + event, plan = plan_data + new_plan = Plan(plan.time + 100, random_hex()) + with catch_event("onPlanEdited") as x: + client.editPlan(plan, new_plan) + assert compare(x) + assert subset( + vars(x.res["plan"]), + time=new_plan.time, + title=new_plan.title, + author_id=client.uid, + ) + + +@pytest.mark.trylast +@pytest.mark.expensive +def test_on_plan_ended(client, thread, catch_event, compare): + with catch_event("onPlanEnded") as x: + client.createPlan(Plan(int(time()) + 120, "Wait for ending")) + x.wait(180) + assert subset(x.res, thread_id=client.uid if thread["type"] == ThreadType.USER else thread["id"], thread_type=thread["type"]) + + +#createPlan(self, plan, thread_id=None) +#editPlan(self, plan, new_plan) +#deletePlan(self, plan) +#changePlanParticipation(self, plan, take_part=True) + +#onPlanCreated(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanEnded(self, mid=None, plan=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanEdited(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanDeleted(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanParticipation(self, mid=None, plan=None, take_part=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) + +#fetchPlanInfo(self, plan_id) diff --git a/tests/test_polls.py b/tests/test_polls.py new file mode 100644 index 0000000..96dab76 --- /dev/null +++ b/tests/test_polls.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from fbchat.models import Poll, PollOption, ThreadType +from utils import random_hex, subset + + +@pytest.fixture(scope="module", params=[ + Poll(title=random_hex(), options=[]), + Poll(title=random_hex(), options=[ + PollOption(random_hex(), vote=True), + PollOption(random_hex(), vote=True), + ]), + Poll(title=random_hex(), options=[ + PollOption(random_hex(), vote=False), + PollOption(random_hex(), vote=False), + ]), + Poll(title=random_hex(), options=[ + PollOption(random_hex(), vote=True), + PollOption(random_hex(), vote=True), + PollOption(random_hex(), vote=False), + PollOption(random_hex(), vote=False), + PollOption(random_hex()), + PollOption(random_hex()), + ]), + pytest.mark.xfail(Poll(title=None, options=[]), raises=ValueError), +]) +def poll_data(request, client1, group, catch_event): + with catch_event("onPollCreated") as x: + client1.createPoll(request.param, thread_id=group["id"]) + options = client1.fetchPollOptions(x.res["poll"].uid) + return x.res, request.param, options + + +def test_create_poll(client1, group, catch_event, poll_data): + event, poll, _ = poll_data + assert subset( + event, + author_id=client1.uid, + thread_id=group["id"], + thread_type=ThreadType.GROUP, + ) + assert subset(vars(event["poll"]), title=poll.title, options_count=len(poll.options)) + for recv_option in event["poll"].options: # The recieved options may not be the full list + old_option, = list(filter(lambda o: o.text == recv_option.text, poll.options)) + voters = [client1.uid] if old_option.vote else [] + assert subset(vars(recv_option), voters=voters, votes_count=len(voters), vote=False) + + +def test_fetch_poll_options(client1, group, catch_event, poll_data): + _, poll, options = poll_data + assert len(options) == len(poll.options) + for option in options: + assert subset(vars(option)) + + +@pytest.mark.trylast +def test_update_poll_vote(client1, group, catch_event, poll_data): + event, poll, options = poll_data + new_vote_ids = [o.uid for o in options[0:len(options):2] if not o.vote] + re_vote_ids = [o.uid for o in options[0:len(options):2] if o.vote] + new_options = [random_hex(), random_hex()] + with catch_event("onPollVoted") as x: + client1.updatePollVote(event["poll"].uid, option_ids=new_vote_ids + re_vote_ids, new_options=new_options) + + assert subset( + x.res, + author_id=client1.uid, + thread_id=group["id"], + thread_type=ThreadType.GROUP, + ) + assert subset(vars(x.res["poll"]), title=poll.title, options_count=len(options + new_options)) + for o in new_vote_ids: + assert o in x.res["added_options"] + assert len(x.res["added_options"]) == len(new_vote_ids) + len(new_options) + assert set(x.res["removed_options"]) == set(o.uid for o in options if o.vote and o.uid not in re_vote_ids) diff --git a/tests/test_send.py b/tests/test_send.py index 82098d7..3b53059 100644 --- a/tests/test_send.py +++ b/tests/test_send.py @@ -5,20 +5,11 @@ from __future__ import unicode_literals import pytest from os import path -from fbchat.models import Message, Mention, EmojiSize, FBchatFacebookError, Sticker -from utils import subset +from fbchat.models import FBchatFacebookError, Message, Mention +from utils import subset, STICKER_LIST, EMOJI_LIST, TEXT_LIST -@pytest.mark.parametrize( - "text", - [ - "test_send", - "šŸ˜†", - "\\\n\t%?&'\"", - "ĖŅ­ŹšĀ¹Ę²Õ»Łˆ×°ÕžŽ±É£ą šŌ¹Š‘É‘Č‘Ņ£ŠšąŖÖ­Ź—Ń‹ŌˆŁŒŹ¼Å‘ŌˆĆ—ąÆ“nąŖšĻšą –ą°£Ł”Ń”Ü…Ō†Ž‘Ų·", - "a" * 20000, # Maximum amount of characters you can send - ], -) +@pytest.mark.parametrize("text", TEXT_LIST) def test_send_text(client, catch_event, compare, text): with catch_event("onMessage") as x: mid = client.sendMessage(text) @@ -27,19 +18,7 @@ def test_send_text(client, catch_event, compare, text): assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) -@pytest.mark.parametrize( - "emoji, emoji_size", - [ - ("šŸ˜†", EmojiSize.SMALL), - ("šŸ˜†", EmojiSize.MEDIUM), - ("šŸ˜†", EmojiSize.LARGE), - # These fail because the emoji is made into a sticker - # This should be fixed - pytest.mark.xfail((None, EmojiSize.SMALL)), - pytest.mark.xfail((None, EmojiSize.MEDIUM)), - pytest.mark.xfail((None, EmojiSize.LARGE)), - ], -) +@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) def test_send_emoji(client, catch_event, compare, emoji, emoji_size): with catch_event("onMessage") as x: mid = client.sendEmoji(emoji, emoji_size) @@ -54,42 +33,28 @@ def test_send_emoji(client, catch_event, compare, emoji, emoji_size): ) -@pytest.mark.xfail(raises=FBchatFacebookError) -@pytest.mark.parametrize("message", [Message("a" * 20001)]) -def test_send_invalid(client, message): - client.send(message) - - -def test_send_mentions(client, client2, thread, catch_event, compare): - text = "Hi there @me, @other and @thread" - mentions = [ - dict(thread_id=client.uid, offset=9, length=3), - dict(thread_id=client2.uid, offset=14, length=6), - dict(thread_id=thread["id"], offset=26, length=7), - ] +def test_send_mentions(client, catch_event, compare, message_with_mentions): with catch_event("onMessage") as x: - mid = client.send(Message(text, mentions=[Mention(**d) for d in mentions])) + mid = client.send(message_with_mentions) - assert compare(x, mid=mid, message=text) - assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) + assert compare(x, mid=mid, message=message_with_mentions.text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=message_with_mentions.text) # The mentions are not ordered by offset for m in x.res["message_object"].mentions: - assert vars(m) in mentions + assert vars(m) in [vars(x) for x in message_with_mentions.mentions] -@pytest.mark.parametrize( - "sticker_id", - ["767334476626295", pytest.mark.xfail("0", raises=FBchatFacebookError)], -) -def test_send_sticker(client, catch_event, compare, sticker_id): +@pytest.mark.parametrize("sticker", STICKER_LIST) +def test_send_sticker(client, catch_event, compare, sticker): with catch_event("onMessage") as x: - mid = client.send(Message(sticker=Sticker(sticker_id))) + mid = client.send(Message(sticker=sticker)) assert compare(x, mid=mid) assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid) - assert subset(vars(x.res["message_object"].sticker), uid=sticker_id) + assert subset(vars(x.res["message_object"].sticker), uid=sticker.uid) +# Kept for backwards compatibility @pytest.mark.parametrize( "method_name, url", [ @@ -97,7 +62,7 @@ def test_send_sticker(client, catch_event, compare, sticker_id): "sendRemoteImage", "https://github.com/carpedm20/fbchat/raw/master/tests/image.png", ), - ("sendLocalImage", path.join(path.dirname(__file__), "image.png")), + ("sendLocalImage", path.join(path.dirname(__file__), "resources", "image.png")), ], ) def test_send_images(client, catch_event, compare, method_name, url): @@ -108,3 +73,37 @@ def test_send_images(client, catch_event, compare, method_name, url): assert compare(x, mid=mid, message=text) assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) assert x.res["message_object"].attachments[0] + + +def test_send_local_files(client, catch_event, compare): + files = ["image.png", "image.jpg", "image.gif", "file.json", "file.txt", "audio.mp3", "video.mp4"] + text = "Files sent locally" + with catch_event("onMessage") as x: + mid = client.sendLocalFiles( + [path.join(path.dirname(__file__), "resources", f) for f in files], + message=Message(text), + ) + + assert compare(x, mid=mid, message=text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) + assert len(x.res["message_object"].attachments) == len(files) + + +# To be changed when merged into master +def test_send_remote_files(client, catch_event, compare): + files = ["image.png", "data.json"] + text = "Files sent from remote" + with catch_event("onMessage") as x: + mid = client.sendRemoteFiles( + ["https://github.com/carpedm20/fbchat/raw/master/tests/{}".format(f) for f in files], + message=Message(text), + ) + + assert compare(x, mid=mid, message=text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) + assert len(x.res["message_object"].attachments) == len(files) + + +@pytest.mark.parametrize('wave_first', [True, False]) +def test_wave(client, wave_first): + client.wave(wave_first) diff --git a/tests/test_thread_interraction.py b/tests/test_thread_interraction.py index e897dbb..16e7e9e 100644 --- a/tests/test_thread_interraction.py +++ b/tests/test_thread_interraction.py @@ -12,7 +12,7 @@ from fbchat.models import ( ThreadColor, ) from utils import random_hex, subset -from os import environ +from os import path def test_remove_from_and_add_to_group(client1, client2, group, catch_event): @@ -31,16 +31,28 @@ def test_remove_from_and_add_to_group(client1, client2, group, catch_event): ) -@pytest.mark.xfail( - raises=FBchatFacebookError, reason="Apparently changeThreadTitle is broken" -) -def test_change_title(client1, catch_event, group): +def test_remove_from_and_add_admins_to_group(client1, client2, group, catch_event): + # Test both methods, while ensuring that the user gets added as group admin + try: + with catch_event("onAdminRemoved") as x: + client1.removeGroupAdmins(client2.uid, group["id"]) + assert subset( + x.res, removed_id=client2.uid, author_id=client1.uid, thread_id=group["id"] + ) + finally: + with catch_event("onAdminAdded") as x: + client1.addGroupAdmins(client2.uid, group["id"]) + assert subset( + x.res, added_id=client2.uid, author_id=client1.uid, thread_id=group["id"] + ) + + +def test_change_title(client1, group, catch_event): title = random_hex() with catch_event("onTitleChange") as x: - mid = client1.changeThreadTitle(title, group["id"]) + client1.changeThreadTitle(title, group["id"], thread_type=ThreadType.GROUP) assert subset( x.res, - mid=mid, author_id=client1.uid, new_title=title, thread_id=group["id"], @@ -55,17 +67,33 @@ def test_change_nickname(client, client_all, catch_event, compare): assert compare(x, changed_for=client_all.uid, new_nickname=nickname) -@pytest.mark.parametrize("emoji", ["šŸ˜€", "šŸ˜‚", "šŸ˜•", "šŸ˜"]) +@pytest.mark.parametrize("emoji", [ + "šŸ˜€", + "šŸ˜‚", + "šŸ˜•", + "šŸ˜", + pytest.mark.xfail("šŸ™ƒ", raises=FBchatFacebookError), + pytest.mark.xfail("not an emoji", raises=FBchatFacebookError) +]) def test_change_emoji(client, catch_event, compare, emoji): with catch_event("onEmojiChange") as x: client.changeThreadEmoji(emoji) assert compare(x, new_emoji=emoji) -@pytest.mark.xfail(raises=FBchatFacebookError) -@pytest.mark.parametrize("emoji", ["šŸ™ƒ", "not an emoji"]) -def test_change_emoji_invalid(client, emoji): - client.changeThreadEmoji(emoji) +def test_change_image_local(client1, group, catch_event): + url = path.join(path.dirname(__file__), "resources", "image.png") + with catch_event("onImageChange") as x: + image_id = client1.changeGroupImageLocal(url, group["id"]) + assert subset(x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"]) + + +# To be changed when merged into master +def test_change_image_remote(client1, group, catch_event): + url = "https://github.com/carpedm20/fbchat/raw/master/tests/image.png" + with catch_event("onImageChange") as x: + image_id = client1.changeGroupImageRemote(url, group["id"]) + assert subset(x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"]) @pytest.mark.parametrize( @@ -83,9 +111,7 @@ def test_change_color(client, catch_event, compare, color): assert compare(x, new_color=color) -@pytest.mark.xfail( - raises=FBchatFacebookError, strict=False, reason="Should fail, but doesn't" -) +@pytest.mark.xfail(raises=FBchatFacebookError, reason="Should fail, but doesn't") def test_change_color_invalid(client): class InvalidColor: value = "#0077ff" @@ -98,3 +124,31 @@ def test_typing_status(client, catch_event, compare, status): with catch_event("onTyping") as x: client.setTypingStatus(status) assert compare(x, status=status) + + +@pytest.mark.parametrize('require_admin_approval', [True, False]) +def test_change_approval_mode(client1, group, catch_event, require_admin_approval): + with catch_event("onApprovalModeChange") as x: + client1.changeGroupApprovalMode(require_admin_approval, group["id"]) + + assert subset( + x.res, + approval_mode=require_admin_approval, + author_id=client1.uid, + thread_id=group["id"], + ) + +@pytest.mark.parametrize("mute_time", [0, 10, 100, 1000, -1]) +def test_mute_thread(client, mute_time): + assert client.muteThread(mute_time) + assert client.unmuteThread() + + +def test_mute_thread_reactions(client): + assert client.muteThreadReactions() + assert client.unmuteThreadReactions() + + +def test_mute_thread_mentions(client): + assert client.muteThreadMentions() + assert client.unmuteThreadMentions() diff --git a/tests/utils.py b/tests/utils.py index 0da1571..51364cb 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,17 +5,46 @@ from __future__ import unicode_literals import threading import logging import six +import pytest from os import environ from random import randrange from contextlib import contextmanager from six import viewitems from fbchat import Client -from fbchat.models import ThreadType +from fbchat.models import ThreadType, EmojiSize, FBchatFacebookError, Sticker log = logging.getLogger("fbchat.tests").addHandler(logging.NullHandler()) +EMOJI_LIST = [ + ("šŸ˜†", EmojiSize.SMALL), + ("šŸ˜†", EmojiSize.MEDIUM), + ("šŸ˜†", EmojiSize.LARGE), + # These fail in `catch_event` because the emoji is made into a sticker + # This should be fixed + pytest.mark.xfail((None, EmojiSize.SMALL)), + pytest.mark.xfail((None, EmojiSize.MEDIUM)), + pytest.mark.xfail((None, EmojiSize.LARGE)), +] + +STICKER_LIST = [ + Sticker("767334476626295"), + pytest.mark.xfail(Sticker("0"), raises=FBchatFacebookError), + pytest.mark.xfail(Sticker(None), raises=FBchatFacebookError), +] + +TEXT_LIST = [ + "test_send", + "šŸ˜†", + "\\\n\t%?&'\"", + "ĖŅ­ŹšĀ¹Ę²Õ»Łˆ×°ÕžŽ±É£ą šŌ¹Š‘É‘Č‘Ņ£ŠšąŖÖ­Ź—Ń‹ŌˆŁŒŹ¼Å‘ŌˆĆ—ąÆ“nąŖšĻšą –ą°£Ł”Ń”Ü…Ō†Ž‘Ų·", + "a" * 20000, # Maximum amount of characters you can send + pytest.mark.xfail("a" * 20001, raises=FBchatFacebookError), + pytest.mark.xfail(None, raises=FBchatFacebookError), +] + + class ClientThread(threading.Thread): def __init__(self, client, *args, **kwargs): self.client = client @@ -79,6 +108,7 @@ def load_client(n, cache): load_variable("client{}_password".format(n), cache), user_agent='Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36', session_cookies=cache.get("client{}_session".format(n), None), + max_tries=1, ) yield client cache.set("client{}_session".format(n), client.getSession())