diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 59bb975..d84af99 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -4,11 +4,13 @@ :copyright: (c) 2015 - 2019 by Taehoon Kim :license: BSD 3-Clause, see LICENSE for more details. """ - from __future__ import unicode_literals +# These imports are far too general, but they're needed for backwards compatbility. +from .utils import * +from .graphql import * from .models import * -from .client import * +from ._client import Client __title__ = "fbchat" __version__ = "1.6.4" diff --git a/fbchat/_client.py b/fbchat/_client.py new file mode 100644 index 0000000..a4a363d --- /dev/null +++ b/fbchat/_client.py @@ -0,0 +1,4327 @@ +# -*- coding: UTF-8 -*- + +from __future__ import unicode_literals +import requests +import urllib +from uuid import uuid1 +from random import choice +from bs4 import BeautifulSoup as bs +from mimetypes import guess_type +from collections import OrderedDict +from ._util import * +from .models import * +from .graphql import * +import time +import json + +try: + from urllib.parse import urlparse, parse_qs +except ImportError: + from urlparse import urlparse, parse_qs + + +class Client(object): + """A client for the Facebook Chat (Messenger). + + See https://fbchat.readthedocs.io for complete documentation of the API. + """ + + ssl_verify = True + """Verify ssl certificate, set to False to allow debugging with a proxy""" + listening = False + """Whether the client is listening. Used when creating an external event loop to determine when to stop listening""" + uid = None + """ + The ID of the client. + Can be used as `thread_id`. See :ref:`intro_threads` for more info. + + Note: Modifying this results in undefined behaviour + """ + + def __init__( + self, + email, + password, + user_agent=None, + max_tries=5, + session_cookies=None, + logging_level=logging.INFO, + ): + """Initializes and logs in the client + + :param email: Facebook `email`, `id` or `phone number` + :param password: Facebook account password + :param user_agent: Custom user agent to use when sending requests. If `None`, user agent will be chosen from a premade list (see :any:`utils.USER_AGENTS`) + :param max_tries: Maximum number of times to try logging in + :param session_cookies: Cookies from a previous session (Will default to login if these are invalid) + :param logging_level: Configures the `logging level `_. Defaults to `INFO` + :type max_tries: int + :type session_cookies: dict + :type logging_level: int + :raises: FBchatException on failed login + """ + + self.sticky, self.pool = (None, None) + self._session = requests.session() + self.req_counter = 1 + self.seq = "0" + # 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 + self.req_url = ReqUrl() + self._markAlive = True + self._buddylist = dict() + + if not user_agent: + user_agent = choice(USER_AGENTS) + + self._header = { + "Content-Type": "application/x-www-form-urlencoded", + "Referer": self.req_url.BASE, + "Origin": self.req_url.BASE, + "User-Agent": user_agent, + "Connection": "keep-alive", + } + + handler.setLevel(logging_level) + + # If session cookies aren't set, not properly loaded or gives us an invalid session, then do the login + if ( + not session_cookies + or not self.setSession(session_cookies) + or not self.isLoggedIn() + ): + self.login(email, password, max_tries) + else: + self.email = email + self.password = password + + """ + INTERNAL REQUEST METHODS + """ + + def _generatePayload(self, query): + """Adds the following defaults to the payload: + __rev, __user, __a, ttstamp, fb_dtsg, __req + """ + payload = self.payloadDefault.copy() + if query: + payload.update(query) + payload["__req"] = str_base(self.req_counter, 36) + payload["seq"] = self.seq + self.req_counter += 1 + return payload + + def _fix_fb_errors(self, error_code): + """ + This fixes "Please try closing and re-opening your browser window" errors (1357004) + This error usually happens after 1-2 days of inactivity + It may be a bad idea to do this in an exception handler, if you have a better method, please suggest it! + """ + if error_code == "1357004": + log.warning("Got error #1357004. Doing a _postLogin, and resending request") + self._postLogin() + return True + return False + + def _get( + self, + url, + query=None, + timeout=30, + fix_request=False, + as_json=False, + error_retries=3, + ): + payload = self._generatePayload(query) + r = self._session.get( + url, + headers=self._header, + params=payload, + timeout=timeout, + verify=self.ssl_verify, + ) + if not fix_request: + return r + try: + return check_request(r, as_json=as_json) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): + return self._get( + url, + query=query, + timeout=timeout, + fix_request=fix_request, + as_json=as_json, + error_retries=error_retries - 1, + ) + raise e + + def _post( + self, + url, + query=None, + timeout=30, + fix_request=False, + as_json=False, + error_retries=3, + ): + payload = self._generatePayload(query) + r = self._session.post( + url, + headers=self._header, + data=payload, + timeout=timeout, + verify=self.ssl_verify, + ) + if not fix_request: + return r + try: + return check_request(r, as_json=as_json) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): + return self._post( + url, + query=query, + timeout=timeout, + fix_request=fix_request, + as_json=as_json, + error_retries=error_retries - 1, + ) + raise e + + def _graphql(self, payload, error_retries=3): + content = self._post( + self.req_url.GRAPHQL, payload, fix_request=True, as_json=False + ) + try: + return graphql_response_to_json(content) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): + return self._graphql(payload, error_retries=error_retries - 1) + raise e + + def _cleanGet(self, url, query=None, timeout=30, allow_redirects=True): + return self._session.get( + url, + headers=self._header, + params=query, + timeout=timeout, + verify=self.ssl_verify, + allow_redirects=allow_redirects, + ) + + def _cleanPost(self, url, query=None, timeout=30): + self.req_counter += 1 + return self._session.post( + url, + headers=self._header, + data=query, + timeout=timeout, + verify=self.ssl_verify, + ) + + def _postFile( + self, + url, + files=None, + query=None, + timeout=30, + fix_request=False, + as_json=False, + error_retries=3, + ): + payload = self._generatePayload(query) + # Removes 'Content-Type' from the header + headers = dict( + (i, self._header[i]) for i in self._header if i != "Content-Type" + ) + r = self._session.post( + url, + headers=headers, + data=payload, + timeout=timeout, + files=files, + verify=self.ssl_verify, + ) + if not fix_request: + return r + try: + return check_request(r, as_json=as_json) + except FBchatFacebookError as e: + if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): + return self._postFile( + url, + files=files, + query=query, + timeout=timeout, + fix_request=fix_request, + as_json=as_json, + error_retries=error_retries - 1, + ) + raise e + + def graphql_requests(self, *queries): + """ + :param queries: Zero or more GraphQL objects + :type queries: GraphQL + + :raises: FBchatException if request failed + :return: A tuple containing json graphql queries + :rtype: tuple + """ + + return tuple( + self._graphql( + { + "method": "GET", + "response_format": "json", + "queries": graphql_queries_to_json(*queries), + } + ) + ) + + def graphql_request(self, query): + """ + Shorthand for `graphql_requests(query)[0]` + + :raises: FBchatException if request failed + """ + return self.graphql_requests(query)[0] + + """ + END INTERNAL REQUEST METHODS + """ + + """ + LOGIN METHODS + """ + + def _resetValues(self): + self.payloadDefault = OrderedDict() + self._session = requests.session() + self.req_counter = 1 + self.seq = "0" + self.uid = None + + def _postLogin(self): + 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") + if self.uid is None: + raise FBchatException("Could not find c_user cookie") + self.uid = str(self.uid) + self.user_channel = "p_" + self.uid + self.ttstamp = "" + + r = self._get(self.req_url.BASE) + soup = bs(r.text, "html.parser") + + fb_dtsg_element = soup.find("input", {"name": "fb_dtsg"}) + if fb_dtsg_element: + self.fb_dtsg = fb_dtsg_element["value"] + else: + self.fb_dtsg = re.search(r'name="fb_dtsg" value="(.*?)"', r.text).group(1) + + fb_h_element = soup.find("input", {"name": "h"}) + if fb_h_element: + self.fb_h = fb_h_element["value"] + + for i in self.fb_dtsg: + self.ttstamp += str(ord(i)) + self.ttstamp += "2" + # Set default payload + self.payloadDefault["__rev"] = int( + r.text.split('"client_revision":', 1)[1].split(",", 1)[0] + ) + self.payloadDefault["__user"] = self.uid + self.payloadDefault["__a"] = "1" + self.payloadDefault["ttstamp"] = self.ttstamp + self.payloadDefault["fb_dtsg"] = self.fb_dtsg + + def _login(self): + if not (self.email and self.password): + raise FBchatUserError("Email and password not found.") + + soup = bs(self._get(self.req_url.MOBILE).text, "html.parser") + data = dict( + (elem["name"], elem["value"]) + for elem in soup.findAll("input") + if elem.has_attr("value") and elem.has_attr("name") + ) + data["email"] = self.email + data["pass"] = self.password + data["login"] = "Log In" + + r = self._cleanPost(self.req_url.LOGIN, data) + + # Usually, 'Checkpoint' will refer to 2FA + if "checkpoint" in r.url and ('id="approvals_code"' in r.text.lower()): + r = self._2FA(r) + + # Sometimes Facebook tries to show the user a "Save Device" dialog + if "save-device" in r.url: + r = self._cleanGet(self.req_url.SAVE_DEVICE) + + if "home" in r.url: + self._postLogin() + return True, r.url + else: + return False, r.url + + def _2FA(self, r): + soup = bs(r.text, "html.parser") + data = dict() + + s = self.on2FACode() + + data["approvals_code"] = s + data["fb_dtsg"] = soup.find("input", {"name": "fb_dtsg"})["value"] + data["nh"] = soup.find("input", {"name": "nh"})["value"] + data["submit[Submit Code]"] = "Submit Code" + data["codes_submitted"] = 0 + log.info("Submitting 2FA code.") + + r = self._cleanPost(self.req_url.CHECKPOINT, data) + + if "home" in r.url: + return r + + del (data["approvals_code"]) + del (data["submit[Submit Code]"]) + del (data["codes_submitted"]) + + data["name_action_selected"] = "save_device" + data["submit[Continue]"] = "Continue" + log.info( + "Saving browser." + ) # At this stage, we have dtsg, nh, name_action_selected, submit[Continue] + r = self._cleanPost(self.req_url.CHECKPOINT, data) + + if "home" in r.url: + return r + + del (data["name_action_selected"]) + log.info( + "Starting Facebook checkup flow." + ) # At this stage, we have dtsg, nh, submit[Continue] + r = self._cleanPost(self.req_url.CHECKPOINT, data) + + if "home" in r.url: + return r + + del (data["submit[Continue]"]) + data["submit[This was me]"] = "This Was Me" + log.info( + "Verifying login attempt." + ) # At this stage, we have dtsg, nh, submit[This was me] + r = self._cleanPost(self.req_url.CHECKPOINT, data) + + if "home" in r.url: + return r + + del (data["submit[This was me]"]) + data["submit[Continue]"] = "Continue" + data["name_action_selected"] = "save_device" + log.info( + "Saving device again." + ) # At this stage, we have dtsg, nh, submit[Continue], name_action_selected + r = self._cleanPost(self.req_url.CHECKPOINT, data) + return r + + def isLoggedIn(self): + """ + Sends a request to Facebook to check the login status + + :return: True if the client is still logged in + :rtype: bool + """ + # Send a request to the login url, to see if we're directed to the home page + r = self._cleanGet(self.req_url.LOGIN, allow_redirects=False) + return "Location" in r.headers and "home" in r.headers["Location"] + + def getSession(self): + """Retrieves session cookies + + :return: A dictionay containing session cookies + :rtype: dict + """ + return self._session.cookies.get_dict() + + def setSession(self, session_cookies): + """Loads session cookies + + :param session_cookies: A dictionay containing session cookies + :type session_cookies: dict + :return: False if `session_cookies` does not contain proper cookies + :rtype: bool + """ + + # Quick check to see if session_cookies is formatted properly + if not session_cookies or "c_user" not in session_cookies: + return False + + try: + # Load cookies into current session + self._session.cookies = requests.cookies.merge_cookies( + self._session.cookies, session_cookies + ) + self._postLogin() + except Exception as e: + log.exception("Failed loading session") + self._resetValues() + return False + return True + + def login(self, email, password, max_tries=5): + """ + Uses `email` and `password` to login the user (If the user is already logged in, this will do a re-login) + + :param email: Facebook `email` or `id` or `phone number` + :param password: Facebook account password + :param max_tries: Maximum number of times to try logging in + :type max_tries: int + :raises: FBchatException on failed login + """ + self.onLoggingIn(email=email) + + if max_tries < 1: + raise FBchatUserError("Cannot login: max_tries should be at least one") + + if not (email and password): + raise FBchatUserError("Email and password not set") + + self.email = email + self.password = password + + for i in range(1, max_tries + 1): + login_successful, login_url = self._login() + if not login_successful: + log.warning( + "Attempt #{} failed{}".format( + i, {True: ", retrying"}.get(i < max_tries, "") + ) + ) + time.sleep(1) + continue + else: + self.onLoggedIn(email=email) + break + else: + raise FBchatUserError( + "Login failed. Check email/password. (Failed on url: {})".format( + login_url + ) + ) + + def logout(self): + """ + Safely logs out the client + + :param timeout: See `requests timeout `_ + :return: True if the action was successful + :rtype: bool + """ + + if not hasattr(self, "fb_h"): + h_r = self._post(self.req_url.MODERN_SETTINGS_MENU, {"pmid": "4"}) + self.fb_h = re.search(r'name=\\"h\\" value=\\"(.*?)\\"', h_r.text).group(1) + + data = {"ref": "mb", "h": self.fb_h} + + r = self._get(self.req_url.LOGOUT, data) + + self._resetValues() + + return r.ok + + """ + END LOGIN METHODS + """ + + """ + DEFAULT THREAD METHODS + """ + + def _getThread(self, given_thread_id=None, given_thread_type=None): + """ + Checks if thread ID is given, checks if default is set and returns correct values + + :raises ValueError: If thread ID is not given and there is no default + :return: Thread ID and thread type + :rtype: tuple + """ + if given_thread_id is None: + if self.default_thread_id is not None: + return self.default_thread_id, self.default_thread_type + else: + raise ValueError("Thread ID is not set") + else: + return given_thread_id, given_thread_type + + def setDefaultThread(self, thread_id, thread_type): + """ + Sets default thread to send messages to + + :param thread_id: User/Group ID to default to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + """ + self.default_thread_id = thread_id + self.default_thread_type = thread_type + + def resetDefaultThread(self): + """Resets default thread""" + self.setDefaultThread(None, None) + + """ + END DEFAULT THREAD METHODS + """ + + """ + 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 fetchThreads(self, thread_location, before=None, after=None, limit=None): + """ + Get all threads in thread_location. + Threads will be sorted from newest to oldest. + + :param thread_location: models.ThreadLocation: INBOX, PENDING, ARCHIVED or OTHER + :param before: Fetch only thread before this epoch (in ms) (default all threads) + :param after: Fetch only thread after this epoch (in ms) (default all threads) + :param limit: The max. amount of threads to fetch (default all threads) + :return: :class:`models.Thread` objects + :rtype: list + :raises: FBchatException if request failed + """ + threads = [] + + last_thread_timestamp = None + while True: + # break if limit is exceeded + if limit and len(threads) >= limit: + break + + # fetchThreadList returns at max 20 threads before last_thread_timestamp (included) + candidates = self.fetchThreadList( + before=last_thread_timestamp, thread_location=thread_location + ) + + if len(candidates) > 1: + threads += candidates[1:] + else: # End of threads + break + + last_thread_timestamp = threads[-1].last_message_timestamp + + # FB returns a sorted list of threads + if (before is not None and int(last_thread_timestamp) > before) or ( + after is not None and int(last_thread_timestamp) < after + ): + break + + # Return only threads between before and after (if set) + if before is not None or after is not None: + for t in threads: + last_message_timestamp = int(t.last_message_timestamp) + if (before is not None and last_message_timestamp > before) or ( + after is not None and last_message_timestamp < after + ): + threads.remove(t) + + if limit and len(threads) > limit: + return threads[:limit] + + return threads + + def fetchAllUsersFromThreads(self, threads): + """ + Get all users involved in threads. + + :param threads: models.Thread: List of threads to check for users + :return: :class:`models.User` objects + :rtype: list + :raises: FBchatException if request failed + """ + users = [] + users_to_fetch = [] # It's more efficient to fetch all users in one request + for thread in threads: + if thread.type == ThreadType.USER: + if thread.uid not in [user.uid for user in users]: + users.append(thread) + elif thread.type == ThreadType.GROUP: + for user_id in thread.participants: + if ( + user_id not in [user.uid for user in users] + and user_id not in users_to_fetch + ): + users_to_fetch.append(user_id) + else: + pass + for user_id, user in self.fetchUserInfo(*users_to_fetch).items(): + users.append(user) + return users + + def fetchAllUsers(self): + """ + Gets all users the client is currently chatting with + + :return: :class:`models.User` objects + :rtype: list + :raises: FBchatException if request failed + """ + + data = {"viewer": self.uid} + j = self._post( + self.req_url.ALL_USERS, query=data, fix_request=True, as_json=True + ) + if j.get("payload") is None: + raise FBchatException("Missing payload while fetching users: {}".format(j)) + + users = [] + + for key in j["payload"]: + k = j["payload"][key] + if k["type"] in ["user", "friend"]: + if k["id"] in ["0", 0]: + # Skip invalid users + pass + users.append( + User( + k["id"], + first_name=k.get("firstName"), + url=k.get("uri"), + photo=k.get("thumbSrc"), + name=k.get("name"), + is_friend=k.get("is_friend"), + gender=GENDERS.get(k.get("gender")), + ) + ) + + return users + + def searchForUsers(self, name, limit=10): + """ + Find and get user by his/her name + + :param name: Name of the user + :param limit: The max. amount of users to fetch + :return: :class:`models.User` objects, ordered by relevance + :rtype: list + :raises: FBchatException if request failed + """ + + j = self.graphql_request( + GraphQL(query=GraphQL.SEARCH_USER, params={"search": name, "limit": limit}) + ) + + return [graphql_to_user(node) for node in j[name]["users"]["nodes"]] + + def searchForPages(self, name, limit=10): + """ + Find and get page by its name + + :param name: Name of the page + :return: :class:`models.Page` objects, ordered by relevance + :rtype: list + :raises: FBchatException if request failed + """ + + j = self.graphql_request( + GraphQL(query=GraphQL.SEARCH_PAGE, params={"search": name, "limit": limit}) + ) + + return [graphql_to_page(node) for node in j[name]["pages"]["nodes"]] + + def searchForGroups(self, name, limit=10): + """ + Find and get group thread by its name + + :param name: Name of the group thread + :param limit: The max. amount of groups to fetch + :return: :class:`models.Group` objects, ordered by relevance + :rtype: list + :raises: FBchatException if request failed + """ + + j = self.graphql_request( + GraphQL(query=GraphQL.SEARCH_GROUP, params={"search": name, "limit": limit}) + ) + + return [graphql_to_group(node) for node in j["viewer"]["groups"]["nodes"]] + + def searchForThreads(self, name, limit=10): + """ + Find and get a thread by its name + + :param name: Name of the thread + :param limit: The max. amount of groups to fetch + :return: :class:`models.User`, :class:`models.Group` and :class:`models.Page` objects, ordered by relevance + :rtype: list + :raises: FBchatException if request failed + """ + + j = self.graphql_request( + GraphQL( + query=GraphQL.SEARCH_THREAD, params={"search": name, "limit": limit} + ) + ) + + rtn = [] + for node in j[name]["threads"]["nodes"]: + if node["__typename"] == "User": + rtn.append(graphql_to_user(node)) + elif node["__typename"] == "MessageThread": + # MessageThread => Group thread + rtn.append(graphql_to_group(node)) + elif node["__typename"] == "Page": + rtn.append(graphql_to_page(node)) + elif node["__typename"] == "Group": + # We don't handle Facebook "Groups" + pass + else: + log.warning( + "Unknown __typename: {} in {}".format( + repr(node["__typename"]), node + ) + ) + + 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)} + j = self._post(self.req_url.INFO, data, fix_request=True, as_json=True) + + if j.get("payload") is None or j["payload"].get("profiles") is None: + raise FBchatException("No users/pages returned: {}".format(j)) + + entries = {} + for _id in j["payload"]["profiles"]: + k = j["payload"]["profiles"][_id] + if k["type"] in ["user", "friend"]: + entries[_id] = { + "id": _id, + "type": ThreadType.USER, + "url": k.get("uri"), + "first_name": k.get("firstName"), + "is_viewer_friend": k.get("is_friend"), + "gender": k.get("gender"), + "profile_picture": {"uri": k.get("thumbSrc")}, + "name": k.get("name"), + } + elif k["type"] == "page": + entries[_id] = { + "id": _id, + "type": ThreadType.PAGE, + "url": k.get("uri"), + "profile_picture": {"uri": k.get("thumbSrc")}, + "name": k.get("name"), + } + else: + raise FBchatException( + "{} had an unknown thread type: {}".format(_id, k) + ) + + log.debug(entries) + return entries + + def fetchUserInfo(self, *user_ids): + """ + Get users' info from IDs, unordered + + .. warning:: + Sends two requests, to fetch all available info! + + :param user_ids: One or more user ID(s) to query + :return: :class:`models.User` objects, labeled by their ID + :rtype: dict + :raises: FBchatException if request failed + """ + + threads = self.fetchThreadInfo(*user_ids) + users = {} + for k in threads: + if threads[k].type == ThreadType.USER: + users[k] = threads[k] + else: + raise FBchatUserError("Thread {} was not a user".format(threads[k])) + + return users + + def fetchPageInfo(self, *page_ids): + """ + Get pages' info from IDs, unordered + + .. warning:: + Sends two requests, to fetch all available info! + + :param page_ids: One or more page ID(s) to query + :return: :class:`models.Page` objects, labeled by their ID + :rtype: dict + :raises: FBchatException if request failed + """ + + threads = self.fetchThreadInfo(*page_ids) + pages = {} + for k in threads: + if threads[k].type == ThreadType.PAGE: + pages[k] = threads[k] + else: + raise FBchatUserError("Thread {} was not a page".format(threads[k])) + + return pages + + def fetchGroupInfo(self, *group_ids): + """ + Get groups' info from IDs, unordered + + :param group_ids: One or more group ID(s) to query + :return: :class:`models.Group` objects, labeled by their ID + :rtype: dict + :raises: FBchatException if request failed + """ + + threads = self.fetchThreadInfo(*group_ids) + groups = {} + for k in threads: + if threads[k].type == ThreadType.GROUP: + groups[k] = threads[k] + else: + raise FBchatUserError("Thread {} was not a group".format(threads[k])) + + return groups + + def fetchThreadInfo(self, *thread_ids): + """ + Get threads' info from IDs, unordered + + .. warning:: + Sends two requests if users or pages are present, to fetch all available info! + + :param thread_ids: One or more thread ID(s) to query + :return: :class:`models.Thread` objects, labeled by their ID + :rtype: dict + :raises: FBchatException if request failed + """ + + queries = [] + for thread_id in thread_ids: + queries.append( + GraphQL( + doc_id="2147762685294928", + params={ + "id": thread_id, + "message_limit": 0, + "load_messages": False, + "load_read_receipts": False, + "before": None, + }, + ) + ) + + j = self.graphql_requests(*queries) + + for i, entry in enumerate(j): + if entry.get("message_thread") is None: + # If you don't have an existing thread with this person, attempt to retrieve user data anyways + j[i]["message_thread"] = { + "thread_key": {"other_user_id": thread_ids[i]}, + "thread_type": "ONE_TO_ONE", + } + + pages_and_user_ids = [ + k["message_thread"]["thread_key"]["other_user_id"] + for k in j + if k["message_thread"].get("thread_type") == "ONE_TO_ONE" + ] + pages_and_users = {} + if len(pages_and_user_ids) != 0: + pages_and_users = self._fetchInfo(*pages_and_user_ids) + + rtn = {} + for i, entry in enumerate(j): + entry = entry["message_thread"] + if entry.get("thread_type") == "GROUP": + _id = entry["thread_key"]["thread_fbid"] + rtn[_id] = graphql_to_group(entry) + elif entry.get("thread_type") == "ONE_TO_ONE": + _id = entry["thread_key"]["other_user_id"] + if pages_and_users.get(_id) is None: + raise FBchatException("Could not fetch thread {}".format(_id)) + entry.update(pages_and_users[_id]) + if entry["type"] == ThreadType.USER: + rtn[_id] = graphql_to_user(entry) + else: + rtn[_id] = graphql_to_page(entry) + else: + raise FBchatException( + "{} had an unknown thread type: {}".format(thread_ids[i], entry) + ) + + return rtn + + def fetchThreadMessages(self, thread_id=None, limit=20, before=None): + """ + Get the last messages in a thread + + :param thread_id: User/Group ID to get messages from. See :ref:`intro_threads` + :param limit: Max. number of messages to retrieve + :param before: A timestamp, indicating from which point to retrieve messages + :type limit: int + :type before: int + :return: :class:`models.Message` objects + :rtype: list + :raises: FBchatException if request failed + """ + + thread_id, thread_type = self._getThread(thread_id, None) + + j = self.graphql_request( + GraphQL( + doc_id="1386147188135407", + params={ + "id": thread_id, + "message_limit": limit, + "load_messages": True, + "load_read_receipts": True, + "before": before, + }, + ) + ) + + if j.get("message_thread") is None: + raise FBchatException("Could not fetch thread {}: {}".format(thread_id, j)) + + messages = list( + reversed( + [ + graphql_to_message(message) + for message in j["message_thread"]["messages"]["nodes"] + ] + ) + ) + read_receipts = j["message_thread"]["read_receipts"]["nodes"] + + for message in messages: + for receipt in read_receipts: + if int(receipt["watermark"]) >= int(message.timestamp): + message.read_by.append(receipt["actor"]["id"]) + + return messages + + def fetchThreadList( + self, offset=None, limit=20, thread_location=ThreadLocation.INBOX, before=None + ): + """Get thread list of your facebook account + + :param offset: Deprecated. Do not use! + :param limit: Max. number of threads to retrieve. Capped at 20 + :param thread_location: models.ThreadLocation: INBOX, PENDING, ARCHIVED or OTHER + :param before: A timestamp (in milliseconds), indicating from which point to retrieve threads + :type limit: int + :type before: int + :return: :class:`models.Thread` objects + :rtype: list + :raises: FBchatException if request failed + """ + + if offset is not None: + log.warning( + "Using `offset` in `fetchThreadList` is no longer supported, since Facebook migrated to the use of GraphQL in this request. Use `before` instead" + ) + + if limit > 20 or limit < 1: + raise FBchatUserError("`limit` should be between 1 and 20") + + if thread_location in ThreadLocation: + loc_str = thread_location.value + else: + raise FBchatUserError('"thread_location" must be a value of ThreadLocation') + + j = self.graphql_request( + GraphQL( + doc_id="1349387578499440", + params={ + "limit": limit, + "tags": [loc_str], + "before": before, + "includeDeliveryReceipts": True, + "includeSeqID": False, + }, + ) + ) + + return [ + graphql_to_thread(node) for node in j["viewer"]["message_threads"]["nodes"] + ] + + def fetchUnread(self): + """ + Get the unread thread list + + :return: List of unread thread ids + :rtype: list + :raises: FBchatException if request failed + """ + form = { + "folders[0]": "inbox", + "client": "mercury", + "last_action_timestamp": now() - 60 * 1000 + # 'last_action_timestamp': 0 + } + + j = self._post( + self.req_url.UNREAD_THREADS, form, fix_request=True, as_json=True + ) + + payload = j["payload"]["unread_thread_fbids"][0] + + return payload["thread_fbids"] + payload["other_user_fbids"] + + def fetchUnseen(self): + """ + Get the unseen (new) thread list + + :return: List of unseen thread ids + :rtype: list + :raises: FBchatException if request failed + """ + j = self._post( + self.req_url.UNSEEN_THREADS, None, fix_request=True, as_json=True + ) + + payload = j["payload"]["unseen_thread_fbids"][0] + + return payload["thread_fbids"] + payload["other_user_fbids"] + + def fetchImageUrl(self, image_id): + """Fetches the url to the original image from an image attachment ID + + :param image_id: The image you want to fethc + :type image_id: str + :return: An url where you can download the original image + :rtype: str + :raises: FBchatException if request failed + """ + image_id = str(image_id) + j = check_request( + self._get(ReqUrl.ATTACHMENT_PHOTO, query={"photo_id": str(image_id)}) + ) + + url = get_jsmods_require(j, 3) + if url is None: + 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 + + def _getPrivateData(self): + j = self.graphql_request(GraphQL(doc_id="1868889766468115")) + return j["viewer"] + + def getPhoneNumbers(self): + """ + Fetches a list of user phone numbers. + + :return: List of phone numbers + :rtype: list + """ + data = self._getPrivateData() + return [ + j["phone_number"]["universal_number"] for j in data["user"]["all_phones"] + ] + + def getEmails(self): + """ + Fetches a list of user emails. + + :return: List of emails + :rtype: list + """ + data = self._getPrivateData() + return [j["display_email"] for j in data["all_emails"]] + + def getUserActiveStatus(self, user_id): + """ + Gets friend active status as an :class:`models.ActiveStatus` object. + Returns `None` if status isn't known. + + .. warning:: + Only works when listening. + + :param user_id: ID of the user + :return: Given user active status + :rtype: models.ActiveStatus + """ + return self._buddylist.get(str(user_id)) + + """ + END FETCH METHODS + """ + + """ + SEND METHODS + """ + + def _oldMessage(self, message): + return message if isinstance(message, Message) else Message(text=message) + + def _getSendData(self, message=None, thread_id=None, thread_type=ThreadType.USER): + """Returns the data needed to send a request to `SendURL`""" + messageAndOTID = generateOfflineThreadingID() + timestamp = now() + data = { + "client": self.client, + "author": "fbid:" + str(self.uid), + "timestamp": timestamp, + "source": "source:chat:web", + "offline_threading_id": messageAndOTID, + "message_id": messageAndOTID, + "threading_id": generateMessageID(self.client_id), + "ephemeral_ttl_mode:": "0", + } + + # Set recipient + if thread_type in [ThreadType.USER, ThreadType.PAGE]: + data["other_user_fbid"] = thread_id + elif thread_type == ThreadType.GROUP: + data["thread_fbid"] = thread_id + + if message is None: + message = Message() + + if message.text or message.sticker or message.emoji_size: + data["action_type"] = "ma-type:user-generated-message" + + if message.text: + data["body"] = message.text + + for i, mention in enumerate(message.mentions): + data["profile_xmd[{}][id]".format(i)] = mention.thread_id + data["profile_xmd[{}][offset]".format(i)] = mention.offset + data["profile_xmd[{}][length]".format(i)] = mention.length + data["profile_xmd[{}][type]".format(i)] = "p" + + if message.emoji_size: + if message.text: + data["tags[0]"] = "hot_emoji_size:" + message.emoji_size.name.lower() + else: + data["sticker_id"] = message.emoji_size.value + + if message.sticker: + data["sticker_id"] = message.sticker.uid + + if message.quick_replies: + xmd = {"quick_replies": []} + for quick_reply in message.quick_replies: + q = dict() + q["content_type"] = quick_reply._type + q["payload"] = quick_reply.payload + q["external_payload"] = quick_reply.external_payload + q["data"] = quick_reply.data + if quick_reply.is_response: + q["ignore_for_webhook"] = False + if isinstance(quick_reply, QuickReplyText): + q["title"] = quick_reply.title + if not isinstance(quick_reply, QuickReplyLocation): + q["image_url"] = quick_reply.image_url + xmd["quick_replies"].append(q) + if len(message.quick_replies) == 1 and message.quick_replies[0].is_response: + xmd["quick_replies"] = xmd["quick_replies"][0] + data["platform_xmd"] = json.dumps(xmd) + + return 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) + + # 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 + + 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): + """ + Sends a message to a thread + + :param message: Message to send + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type message: models.Message + :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( + message=message, thread_id=thread_id, thread_type=thread_type + ) + + return self._doSendRequest(data) + + def sendMessage(self, message, thread_id=None, thread_type=ThreadType.USER): + """ + Deprecated. Use :func:`fbchat.Client.send` instead + """ + return self.send( + Message(text=message), thread_id=thread_id, thread_type=thread_type + ) + + def sendEmoji( + self, + emoji=None, + size=EmojiSize.SMALL, + thread_id=None, + thread_type=ThreadType.USER, + ): + """ + Deprecated. Use :func:`fbchat.Client.send` instead + """ + return self.send( + Message(text=emoji, emoji_size=size), + thread_id=thread_id, + thread_type=thread_type, + ) + + def wave(self, wave_first=True, thread_id=None, thread_type=None): + """ + 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 quickReply(self, quick_reply, payload=None, thread_id=None, thread_type=None): + """ + Replies to a chosen quick reply + + :param quick_reply: Quick reply to reply to + :param payload: Optional answer to the quick reply + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type quick_reply: models.QuickReply + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent message + :raises: FBchatException if request failed + """ + quick_reply.is_response = True + if isinstance(quick_reply, QuickReplyText): + return self.send( + Message(text=quick_reply.title, quick_replies=[quick_reply]) + ) + elif isinstance(quick_reply, QuickReplyLocation): + if not isinstance(payload, LocationAttachment): + raise ValueError( + "Payload must be an instance of `fbchat.models.LocationAttachment`" + ) + return self.sendLocation( + payload, thread_id=thread_id, thread_type=thread_type + ) + elif isinstance(quick_reply, QuickReplyEmail): + if not payload: + payload = self.getEmails()[0] + quick_reply.external_payload = quick_reply.payload + quick_reply.payload = payload + return self.send(Message(text=payload, quick_replies=[quick_reply])) + elif isinstance(quick_reply, QuickReplyPhoneNumber): + if not payload: + payload = self.getPhoneNumbers()[0] + quick_reply.external_payload = quick_reply.payload + quick_reply.payload = payload + return self.send(Message(text=payload, quick_replies=[quick_reply])) + + def unsend(self, mid): + """ + Unsends a message (removes for everyone) + + :param mid: :ref:`Message ID ` of the message to unsend + """ + data = {"message_id": mid} + r = self._post(self.req_url.UNSEND, data) + r.raise_for_status() + + def _sendLocation(self, location, current=True, thread_id=None, thread_type=None): + 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["location_attachment[coordinates][latitude]"] = location.latitude + data["location_attachment[coordinates][longitude]"] = location.longitude + data["location_attachment[is_current_location]"] = current + return self._doSendRequest(data) + + def sendLocation(self, location, thread_id=None, thread_type=None): + """ + Sends a given location to a thread as the user's current location + + :param location: Location to send + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type location: models.LocationAttachment + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent message + :raises: FBchatException if request failed + """ + self._sendLocation( + location=location, + current=True, + thread_id=thread_id, + thread_type=thread_type, + ) + + def sendPinnedLocation(self, location, thread_id=None, thread_type=None): + """ + Sends a given location to a thread as a pinned location + + :param location: Location to send + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type location: models.LocationAttachment + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent message + :raises: FBchatException if request failed + """ + self._sendLocation( + location=location, + current=False, + thread_id=thread_id, + thread_type=thread_type, + ) + + def _upload(self, files, voice_clip=False): + """ + 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)} + + data = {"voice_clip": voice_clip} + + j = self._postFile( + self.req_url.UPLOAD, + files=file_dict, + query=data, + 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, + ) + + data["action_type"] = "ma-type:user-generated-message" + data["has_attachment"] = True + + for i, (file_id, mimetype) in enumerate(files): + data["{}s[{}]".format(mimetype_to_key(mimetype), i)] = file_id + + return self._doSendRequest(data) + + def sendRemoteFiles( + self, file_urls, message=None, thread_id=None, thread_type=ThreadType.USER + ): + """ + Sends files from URLs to a thread + + :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 files + :raises: FBchatException if request failed + """ + 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_paths: 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 sendRemoteVoiceClips( + self, clip_urls, message=None, thread_id=None, thread_type=ThreadType.USER + ): + """ + Sends voice clips from URLs to a thread + + :param clip_urls: URLs of clips 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 + """ + clip_urls = require_list(clip_urls) + files = self._upload(get_files_from_urls(clip_urls), voice_clip=True) + return self._sendFiles( + files=files, message=message, thread_id=thread_id, thread_type=thread_type + ) + + def sendLocalVoiceClips( + self, clip_paths, message=None, thread_id=None, thread_type=ThreadType.USER + ): + """ + Sends local voice clips to a thread + + :param clip_paths: Paths of clips 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 + """ + clip_paths = require_list(clip_paths) + with get_files_from_paths(clip_paths) as x: + files = self._upload(x, voice_clip=True) + 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 + ): + """ + Deprecated. Use :func:`fbchat.Client.sendLocalFiles` instead + """ + return self.sendLocalFiles( + file_paths=[image_path], + message=message, + thread_id=thread_id, + thread_type=thread_type, + ) + + 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 + """ + 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. + + :param user_ids: One or more user IDs to add + :param thread_id: Group ID to add people to. See :ref:`intro_threads` + :type user_ids: list + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + data = self._getSendData(thread_id=thread_id, thread_type=ThreadType.GROUP) + + data["action_type"] = "ma-type:log-message" + data["log_message_type"] = "log:subscribe" + + user_ids = require_list(user_ids) + + for i, user_id in enumerate(user_ids): + if user_id == self.uid: + raise FBchatUserError( + "Error when adding users: Cannot add self to group thread" + ) + else: + data[ + "log_message_data[added_participants][" + str(i) + "]" + ] = "fbid:" + str(user_id) + + return self._doSendRequest(data) + + def removeUserFromGroup(self, user_id, thread_id=None): + """ + Removes users from a group. + + :param user_id: User ID to remove + :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 = {"uid": user_id, "tid": thread_id} + + j = self._post(self.req_url.REMOVE_USER, data, fix_request=True, as_json=True) + + 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` + :raises: FBchatException if request failed + """ + + 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` + :raises: FBchatException if request failed + """ + + (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` + :raises: FBchatException if request failed + """ + + 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): + """ + Changes title of a thread. + If this is executed on a user thread, this will change the nickname of that user, effectively changing the title + + :param title: New group thread title + :param thread_id: Group ID to change title of. 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.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 + ) + + 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 + ): + """ + Changes the nickname of a user in a thread + + :param nickname: New nickname + :param user_id: User that will have their nickname changed + :param thread_id: User/Group ID to change color of. 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) + + data = { + "nickname": nickname, + "participant_id": user_id, + "thread_or_other_fbid": thread_id, + } + + j = self._post( + self.req_url.THREAD_NICKNAME, data, fix_request=True, as_json=True + ) + + def changeThreadColor(self, color, thread_id=None): + """ + Changes thread color + + :param color: New thread color + :param thread_id: User/Group ID to change color of. See :ref:`intro_threads` + :type color: models.ThreadColor + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + "color_choice": color.value if color != ThreadColor.MESSENGER_BLUE else "", + "thread_or_other_fbid": thread_id, + } + + j = self._post(self.req_url.THREAD_COLOR, data, fix_request=True, as_json=True) + + def changeThreadEmoji(self, emoji, thread_id=None): + """ + Changes thread color + + Trivia: While changing the emoji, the Facebook web client actually sends multiple different requests, though only this one is required to make the change + + :param color: New thread emoji + :param thread_id: User/Group ID to change emoji of. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + data = {"emoji_choice": emoji, "thread_or_other_fbid": thread_id} + + j = self._post(self.req_url.THREAD_EMOJI, data, fix_request=True, as_json=True) + + def reactToMessage(self, message_id, reaction): + """ + Reacts to a message, or removes reaction + + :param message_id: :ref:`Message ID ` to react to + :param reaction: Reaction emoji to use, if None removes reaction + :type reaction: models.MessageReaction or None + :raises: FBchatException if request failed + """ + data = { + "doc_id": 1491398900900362, + "variables": json.dumps( + { + "data": { + "action": "ADD_REACTION" if reaction else "REMOVE_REACTION", + "client_mutation_id": "1", + "actor_id": self.uid, + "message_id": str(message_id), + "reaction": reaction.value if reaction else None, + } + } + ), + } + self._post(self.req_url.MESSAGE_REACTION, data, fix_request=True, as_json=True) + + def createPlan(self, plan, thread_id=None): + """ + Sets a plan + + :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", + "event_time": plan.time, + "title": plan.title, + "thread_id": thread_id, + "location_id": plan.location_id or "", + "location_name": plan.location or "", + "acontext": { + "action_history": [ + {"surface": "messenger_chat_tab", "mechanism": "messenger_composer"} + ] + }, + } + + 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): + """ + Sets users typing status in a thread + + :param status: Specify the typing status + :param thread_id: User/Group ID to change status in. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type status: models.TypingStatus + :type thread_type: models.ThreadType + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, thread_type) + + data = { + "typ": status.value, + "thread": thread_id, + "to": thread_id if thread_type == ThreadType.USER else "", + "source": "mercury-chat", + } + + j = self._post(self.req_url.TYPING, data, fix_request=True, as_json=True) + + """ + END SEND METHODS + """ + + def markAsDelivered(self, thread_id, message_id): + """ + Mark a message as delivered + + :param thread_id: User/Group ID to which the message belongs. See :ref:`intro_threads` + :param message_id: Message ID to set as delivered. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + data = { + "message_ids[0]": message_id, + "thread_ids[%s][0]" % thread_id: message_id, + } + + r = self._post(self.req_url.DELIVERED, data) + return r.ok + + def _readStatus(self, read, thread_ids): + thread_ids = require_list(thread_ids) + + data = {"watermarkTimestamp": now(), "shouldSendReadReceipt": "true"} + + for thread_id in thread_ids: + data["ids[{}]".format(thread_id)] = "true" if read else "false" + + 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:: + Documenting this + """ + r = self._post(self.req_url.MARK_SEEN, {"seen_timestamp": now()}) + return r.ok + + def friendConnect(self, friend_id): + """ + .. todo:: + Documenting this + """ + data = {"to_friend": friend_id, "action": "confirm"} + + r = self._post(self.req_url.CONNECT, data) + return r.ok + + def removeFriend(self, friend_id=None): + """ + Removes a specifed friend from your friend list + + :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 = {"friend_id": friend_id, "unref": "none", "confirm": "Confirm"} + r = self._post(self.req_url.REMOVE_FRIEND, payload) + query = parse_qs(urlparse(r.url).query) + if "err" not in query: + log.debug("Remove was successful!") + return True + else: + 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 + """ + + def _ping(self): + data = { + "channel": self.user_channel, + "clientid": self.client_id, + "partition": -2, + "cap": 0, + "uid": self.uid, + "sticky_token": self.sticky, + "sticky_pool": self.pool, + "viewer_uid": self.uid, + "state": "active", + } + self._get(self.req_url.PING, data, fix_request=True, as_json=False) + + def _pullMessage(self): + """Call pull api with seq value to get message data.""" + + data = { + "msgs_recv": 0, + "sticky_token": self.sticky, + "sticky_pool": self.pool, + "clientid": self.client_id, + "state": "active" if self._markAlive else "offline", + } + + j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True) + + self.seq = j.get("seq", "0") + return j + + def _parseDelta(self, m): + def getThreadIdAndThreadType(msg_metadata): + """Returns a tuple consisting of thread ID and thread type""" + id_thread = None + type_thread = None + if "threadFbId" in msg_metadata["threadKey"]: + id_thread = str(msg_metadata["threadKey"]["threadFbId"]) + type_thread = ThreadType.GROUP + elif "otherUserFbId" in msg_metadata["threadKey"]: + id_thread = str(msg_metadata["threadKey"]["otherUserFbId"]) + type_thread = ThreadType.USER + return id_thread, type_thread + + delta = m["delta"] + delta_type = delta.get("type") + delta_class = delta.get("class") + metadata = delta.get("messageMetadata") + + if metadata: + mid = metadata["messageId"] + author_id = str(metadata["actorFbId"]) + ts = int(metadata.get("timestamp")) + + # Added participants + if "addedParticipants" in delta: + added_ids = [str(x["userFbId"]) for x in delta["addedParticipants"]] + thread_id = str(metadata["threadKey"]["threadFbId"]) + self.onPeopleAdded( + mid=mid, + added_ids=added_ids, + author_id=author_id, + thread_id=thread_id, + ts=ts, + msg=m, + ) + + # Left/removed participants + elif "leftParticipantFbId" in delta: + removed_id = str(delta["leftParticipantFbId"]) + thread_id = str(metadata["threadKey"]["threadFbId"]) + self.onPersonRemoved( + mid=mid, + removed_id=removed_id, + author_id=author_id, + thread_id=thread_id, + ts=ts, + msg=m, + ) + + # Color change + elif delta_type == "change_thread_theme": + new_color = graphql_color_to_enum(delta["untypedData"]["theme_color"]) + thread_id, thread_type = getThreadIdAndThreadType(metadata) + self.onColorChange( + mid=mid, + author_id=author_id, + new_color=new_color, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + metadata=metadata, + msg=m, + ) + + # Emoji change + elif delta_type == "change_thread_icon": + new_emoji = delta["untypedData"]["thread_icon"] + thread_id, thread_type = getThreadIdAndThreadType(metadata) + self.onEmojiChange( + mid=mid, + author_id=author_id, + new_emoji=new_emoji, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + metadata=metadata, + msg=m, + ) + + # Thread title change + elif delta_class == "ThreadName": + new_title = delta["name"] + thread_id, thread_type = getThreadIdAndThreadType(metadata) + 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_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, + msg=m, + ) + + # Nickname change + elif delta_type == "change_thread_nickname": + changed_for = str(delta["untypedData"]["participant_id"]) + new_nickname = delta["untypedData"]["nickname"] + thread_id, thread_type = getThreadIdAndThreadType(metadata) + self.onNicknameChange( + mid=mid, + author_id=author_id, + changed_for=changed_for, + 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_class == "DeliveryReceipt": + message_ids = delta["messageIds"] + delivered_for = str( + delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"] + ) + 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, + ) + + # Message seen + elif delta_class == "ReadReceipt": + seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"]) + seen_ts = int(delta["actionTimestampMs"]) + delivered_ts = int(delta["watermarkTimestampMs"]) + thread_id, thread_type = getThreadIdAndThreadType(delta) + self.onMessageSeen( + seen_by=seen_by, + thread_id=thread_id, + thread_type=thread_type, + seen_ts=seen_ts, + ts=delivered_ts, + metadata=metadata, + msg=m, + ) + + # Messages marked as seen + elif delta_class == "MarkRead": + seen_ts = int( + delta.get("actionTimestampMs") or delta.get("actionTimestamp") + ) + delivered_ts = int( + delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp") + ) + + threads = [] + if "folders" not in delta: + threads = [ + getThreadIdAndThreadType({"threadKey": thr}) + for thr in delta.get("threadKeys") + ] + + # 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, + ) + + # Client payload (that weird numbers) + elif delta_class == "ClientPayload": + payload = json.loads("".join(chr(z) for z in delta["payload"])) + ts = m.get("ofd_ts") + for d in payload.get("deltas", []): + + # Message reaction + if d.get("deltaMessageReaction"): + i = d["deltaMessageReaction"] + thread_id, thread_type = getThreadIdAndThreadType(i) + mid = i["messageId"] + author_id = str(i["userId"]) + reaction = ( + MessageReaction(i["reaction"]) if i.get("reaction") else None + ) + add_reaction = not bool(i["action"]) + if add_reaction: + self.onReactionAdded( + mid=mid, + reaction=reaction, + author_id=author_id, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + msg=m, + ) + else: + self.onReactionRemoved( + mid=mid, + author_id=author_id, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + msg=m, + ) + + # Viewer status change + elif d.get("deltaChangeViewerStatus"): + i = d["deltaChangeViewerStatus"] + thread_id, thread_type = getThreadIdAndThreadType(i) + author_id = str(i["actorFbid"]) + reason = i["reason"] + can_reply = i["canViewerReply"] + if reason == 2: + if can_reply: + self.onUnblock( + author_id=author_id, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + msg=m, + ) + else: + self.onBlock( + author_id=author_id, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + msg=m, + ) + + # Live location info + elif d.get("liveLocationData"): + i = d["liveLocationData"] + thread_id, thread_type = getThreadIdAndThreadType(i) + for l in i["messageLiveLocations"]: + mid = l["messageId"] + author_id = str(l["senderId"]) + location = graphql_to_live_location(l) + self.onLiveLocation( + mid=mid, + location=location, + author_id=author_id, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + msg=m, + ) + + # Message deletion + elif d.get("deltaRecallMessageData"): + i = d["deltaRecallMessageData"] + thread_id, thread_type = getThreadIdAndThreadType(i) + mid = i["messageID"] + ts = i["deletionTimestamp"] + author_id = str(i["senderID"]) + self.onMessageUnsent( + mid=mid, + author_id=author_id, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + msg=m, + ) + + # New message + elif delta.get("class") == "NewMessage": + mentions = [] + if delta.get("data") and delta["data"].get("prng"): + try: + mentions = [ + Mention( + str(mention.get("i")), + offset=mention.get("o"), + length=mention.get("l"), + ) + for mention in parse_json(delta["data"]["prng"]) + ] + except Exception: + log.exception("An exception occured while reading attachments") + + sticker = None + attachments = [] + unsent = False + if delta.get("attachments"): + try: + for a in delta["attachments"]: + mercury = a["mercury"] + if mercury.get("blob_attachment"): + image_metadata = a.get("imageMetadata", {}) + attach_type = mercury["blob_attachment"]["__typename"] + attachment = graphql_to_attachment( + mercury["blob_attachment"] + ) + + if attach_type in [ + "MessageFile", + "MessageVideo", + "MessageAudio", + ]: + # TODO: Add more data here for audio files + attachment.size = int(a["fileSize"]) + attachments.append(attachment) + + elif mercury.get("sticker_attachment"): + sticker = graphql_to_sticker(mercury["sticker_attachment"]) + + elif mercury.get("extensible_attachment"): + attachment = graphql_to_extensible_attachment( + mercury["extensible_attachment"] + ) + if isinstance(attachment, UnsentMessage): + unsent = True + elif attachment: + attachments.append(attachment) + + except Exception: + log.exception( + "An exception occured while reading attachments: {}".format( + delta["attachments"] + ) + ) + + if metadata and metadata.get("tags"): + emoji_size = get_emojisize_from_tags(metadata.get("tags")) + + message = Message( + text=delta.get("body"), + mentions=mentions, + emoji_size=emoji_size, + sticker=sticker, + attachments=attachments, + ) + message.uid = mid + message.author = author_id + message.timestamp = ts + # message.reactions = {} + message.unsent = unsent + thread_id, thread_type = getThreadIdAndThreadType(metadata) + self.onMessage( + mid=mid, + author_id=author_id, + message=delta.get("body", ""), + message_object=message, + thread_id=thread_id, + thread_type=thread_type, + ts=ts, + metadata=metadata, + msg=m, + ) + + # Unknown message type + else: + self.onUnknownMesssageType(msg=m) + + def _parseMessage(self, content): + """Get message and author name from content. May contain multiple messages in the content.""" + + if "lb_info" in content: + self.sticky = content["lb_info"]["sticky"] + self.pool = content["lb_info"]["pool"] + + if "batches" in content: + for batch in content["batches"]: + self._parseMessage(batch) + + if "ms" not in content: + return + + for m in content["ms"]: + mtype = m.get("type") + try: + # Things that directly change chat + if mtype == "delta": + self._parseDelta(m) + # Inbox + elif mtype == "inbox": + self.onInbox( + unseen=m["unseen"], + unread=m["unread"], + recent_unread=m["recent_unread"], + msg=m, + ) + + # Typing + elif mtype == "typ" or mtype == "ttyp": + author_id = str(m.get("from")) + thread_id = m.get("thread_fbid") + if thread_id: + thread_type = ThreadType.GROUP + thread_id = str(thread_id) + else: + thread_type = ThreadType.USER + if author_id == self.uid: + thread_id = m.get("to") + else: + thread_id = author_id + typing_status = TypingStatus(m.get("st")) + self.onTyping( + author_id=author_id, + status=typing_status, + thread_id=thread_id, + thread_type=thread_type, + msg=m, + ) + + # Delivered + + # Seen + # elif mtype == "m_read_receipt": + # + # self.onSeen(m.get('realtime_viewer_fbid'), m.get('reader'), m.get('time')) + + elif mtype in ["jewel_requests_add"]: + from_id = m["from"] + self.onFriendRequest(from_id=from_id, msg=m) + + # Happens on every login + elif mtype == "qprimer": + self.onQprimer(ts=m.get("made"), msg=m) + + # Is sent before any other message + elif mtype == "deltaflow": + pass + + # Chat timestamp + elif mtype == "chatproxy-presence": + buddylist = dict() + for _id in m.get("buddyList", {}): + payload = m["buddyList"][_id] + + last_active = payload.get("lat") + active = payload.get("p") in [2, 3] + in_game = int(_id) in m.get("gamers", {}) + + buddylist[_id] = last_active + + if self._buddylist.get(_id): + self._buddylist[_id].last_active = last_active + self._buddylist[_id].active = active + self._buddylist[_id].in_game = in_game + else: + self._buddylist[_id] = ActiveStatus( + active=active, last_active=last_active, in_game=in_game + ) + + self.onChatTimestamp(buddylist=buddylist, msg=m) + + # Buddylist overlay + elif mtype == "buddylist_overlay": + statuses = dict() + for _id in m.get("overlay", {}): + payload = m["overlay"][_id] + + last_active = payload.get("la") + active = payload.get("a") in [2, 3] + in_game = ( + self._buddylist[_id].in_game + if self._buddylist.get(_id) + else False + ) + + status = ActiveStatus( + active=active, last_active=last_active, in_game=in_game + ) + + if self._buddylist.get(_id): + self._buddylist[_id].last_active = last_active + self._buddylist[_id].active = active + self._buddylist[_id].in_game = in_game + else: + self._buddylist[_id] = status + + statuses[_id] = status + + self.onBuddylistOverlay(statuses=statuses, msg=m) + + # Unknown message type + else: + self.onUnknownMesssageType(msg=m) + + except Exception as e: + self.onMessageError(exception=e, msg=m) + + def startListening(self): + """ + Start listening from an external event loop + + :raises: FBchatException if request failed + """ + self.listening = True + + def doOneListen(self, markAlive=None): + """ + Does one cycle of the listening loop. + This method is useful if you want to control fbchat from an external event loop + + .. warning:: + `markAlive` parameter is deprecated now, use :func:`fbchat.Client.setActiveStatus` + or `markAlive` parameter in :func:`fbchat.Client.listen` instead. + + :return: Whether the loop should keep running + :rtype: bool + """ + if markAlive is not None: + self._markAlive = markAlive + try: + if self._markAlive: + self._ping() + content = self._pullMessage() + if content: + self._parseMessage(content) + except KeyboardInterrupt: + return False + except requests.Timeout: + pass + except requests.ConnectionError: + # If the client has lost their internet connection, keep trying every 30 seconds + time.sleep(30) + except FBchatFacebookError as e: + # Fix 502 and 503 pull errors + if e.request_status_code in [502, 503]: + self.req_url.change_pull_channel() + self.startListening() + else: + raise e + except Exception as e: + return self.onListenError(exception=e) + + return True + + def stopListening(self): + """Cleans up the variables from startListening""" + self.listening = False + self.sticky, self.pool = (None, None) + + def listen(self, markAlive=None): + """ + Initializes and runs the listening loop continually + + :param markAlive: Whether this should ping the Facebook server each time the loop runs + :type markAlive: bool + """ + if markAlive is not None: + self.setActiveStatus(markAlive) + + self.startListening() + self.onListening() + + while self.listening and self.doOneListen(): + pass + + self.stopListening() + + def setActiveStatus(self, markAlive): + """ + Changes client active status while listening + + :param markAlive: Whether to show if client is active + :type markAlive: bool + """ + self._markAlive = markAlive + + """ + END LISTEN METHODS + """ + + """ + EVENTS + """ + + def onLoggingIn(self, email=None): + """ + Called when the client is logging in + + :param email: The email of the client + """ + log.info("Logging in {}...".format(email)) + + def on2FACode(self): + """Called when a 2FA code is needed to progress""" + return input("Please enter your 2FA code --> ") + + def onLoggedIn(self, email=None): + """ + Called when the client is successfully logged in + + :param email: The email of the client + """ + log.info("Login of {} successful.".format(email)) + + def onListening(self): + """Called when the client is listening""" + log.info("Listening...") + + def onListenError(self, exception=None): + """ + Called when an error was encountered while listening + + :param exception: The exception that was encountered + :return: Whether the loop should keep running + """ + log.exception("Got exception while listening") + return True + + def onMessage( + self, + mid=None, + author_id=None, + message=None, + message_object=None, + thread_id=None, + thread_type=ThreadType.USER, + ts=None, + metadata=None, + msg=None, + ): + """ + Called when the client is listening, and somebody sends a message + + :param mid: The message ID + :param author_id: The ID of the author + :param message: (deprecated. Use `message_object.text` instead) + :param message_object: The message (As a `Message` object) + :param thread_id: Thread ID that the message was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the message was sent to. See :ref:`intro_threads` + :param ts: The timestamp of the message + :param metadata: Extra metadata about the message + :param msg: A full set of the data recieved + :type message_object: models.Message + :type thread_type: models.ThreadType + """ + log.info("{} from {} in {}".format(message_object, thread_id, thread_type.name)) + + def onColorChange( + self, + mid=None, + author_id=None, + new_color=None, + thread_id=None, + thread_type=ThreadType.USER, + ts=None, + metadata=None, + msg=None, + ): + """ + Called when the client is listening, and somebody changes a thread's color + + :param mid: The action ID + :param author_id: The ID of the person who changed the color + :param new_color: The new color + :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 new_color: models.ThreadColor + :type thread_type: models.ThreadType + """ + log.info( + "Color change from {} in {} ({}): {}".format( + author_id, thread_id, thread_type.name, new_color + ) + ) + + def onEmojiChange( + self, + mid=None, + author_id=None, + new_emoji=None, + thread_id=None, + thread_type=ThreadType.USER, + ts=None, + metadata=None, + msg=None, + ): + """ + Called when the client is listening, and somebody changes a thread's emoji + + :param mid: The action ID + :param author_id: The ID of the person who changed the emoji + :param new_emoji: The new emoji + :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( + "Emoji change from {} in {} ({}): {}".format( + author_id, thread_id, thread_type.name, new_emoji + ) + ) + + def onTitleChange( + self, + mid=None, + author_id=None, + new_title=None, + thread_id=None, + thread_type=ThreadType.USER, + ts=None, + metadata=None, + msg=None, + ): + """ + Called when the client is listening, and somebody changes the title of a thread + + :param mid: The action ID + :param author_id: The ID of the person who changed the title + :param new_title: The new title + :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( + "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, + msg=None, + ): + """ + Called when the client is listening, and somebody changes the image of a thread + + :param mid: The action ID + :param author_id: The ID of the person who changed the image + :param new_image: The ID of the new image + :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 msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + 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 + + :param mid: The action ID + :param author_id: The ID of the person who changed the nickname + :param changed_for: The ID of the person whom got their nickname changed + :param new_nickname: The new nickname + :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( + "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 + + :param seen_by: The ID of the person who marked the message as seen + :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 seen_ts: A timestamp of when the person saw the message + :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( + "Messages seen by {} in {} ({}) at {}s".format( + seen_by, thread_id, thread_type.name, seen_ts / 1000 + ) + ) + + def onMessageDelivered( + self, + msg_ids=None, + delivered_for=None, + thread_id=None, + thread_type=ThreadType.USER, + ts=None, + metadata=None, + msg=None, + ): + """ + Called when the client is listening, and somebody marks messages as delivered + + :param msg_ids: The messages that are marked as delivered + :param delivered_for: The person that marked the messages as delivered + :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( + "Messages {} delivered to {} in {} ({}) at {}s".format( + msg_ids, delivered_for, thread_id, thread_type.name, ts / 1000 + ) + ) + + def onMarkedSeen( + self, threads=None, seen_ts=None, ts=None, metadata=None, msg=None + ): + """ + Called when the client is listening, and the client has successfully marked threads as seen + + :param threads: The threads that were marked + :param author_id: The ID of the person who changed the emoji + :param seen_ts: A timestamp of when the threads were seen + :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( + "Marked messages as seen in threads {} at {}s".format( + [(x[0], x[1].name) for x in threads], seen_ts / 1000 + ) + ) + + def onMessageUnsent( + self, + mid=None, + author_id=None, + thread_id=None, + thread_type=None, + ts=None, + msg=None, + ): + """ + Called when the client is listening, and someone unsends (deletes for everyone) a message + + :param mid: ID of the unsent message + :param author_id: The ID of the person who unsent the message + :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 msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info( + "{} unsent the message {} in {} ({}) at {}s".format( + author_id, repr(mid), thread_id, thread_type.name, ts / 1000 + ) + ) + + def onPeopleAdded( + self, + mid=None, + added_ids=None, + author_id=None, + thread_id=None, + ts=None, + msg=None, + ): + """ + Called when the client is listening, and somebody adds people to a group thread + + :param mid: The action ID + :param added_ids: The IDs of the people who got added + :param author_id: The ID of the person who added the people + :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: {} 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, + ): + """ + Called when the client is listening, and somebody removes a person from a group thread + + :param mid: The action ID + :param removed_id: The ID of the person who got removed + :param author_id: The ID of the person who removed the person + :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: {} in {}".format(author_id, removed_id, thread_id)) + + def onFriendRequest(self, from_id=None, msg=None): + """ + Called when the client is listening, and somebody sends a friend request + + :param from_id: The ID of the person that sent the request + :param msg: A full set of the data recieved + """ + log.info("Friend request from {}".format(from_id)) + + def onInbox(self, unseen=None, unread=None, recent_unread=None, msg=None): + """ + .. todo:: + Documenting this + + :param unseen: -- + :param unread: -- + :param recent_unread: -- + :param msg: A full set of the data recieved + """ + log.info("Inbox event: {}, {}, {}".format(unseen, unread, recent_unread)) + + def onTyping( + self, author_id=None, status=None, thread_id=None, thread_type=None, msg=None + ): + """ + Called when the client is listening, and somebody starts or stops typing into a chat + + :param author_id: The ID of the person who sent the action + :param status: The typing status + :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 msg: A full set of the data recieved + :type typing_status: models.TypingStatus + :type thread_type: models.ThreadType + """ + 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 onReactionAdded( + self, + mid=None, + reaction=None, + author_id=None, + thread_id=None, + thread_type=None, + ts=None, + msg=None, + ): + """ + Called when the client is listening, and somebody reacts to a message + + :param mid: Message ID, that user reacted to + :param reaction: Reaction + :param add_reaction: Whether user added or removed reaction + :param author_id: The ID of the person who reacted to the message + :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 msg: A full set of the data recieved + :type reaction: models.MessageReaction + :type thread_type: models.ThreadType + """ + log.info( + "{} reacted to message {} with {} in {} ({})".format( + author_id, mid, reaction.name, thread_id, thread_type.name + ) + ) + + def onReactionRemoved( + self, + mid=None, + author_id=None, + thread_id=None, + thread_type=None, + ts=None, + msg=None, + ): + """ + Called when the client is listening, and somebody removes reaction from a message + + :param mid: Message ID, that user reacted to + :param author_id: The ID of the person who removed reaction + :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 msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info( + "{} removed reaction from {} message in {} ({})".format( + author_id, mid, thread_id, thread_type + ) + ) + + def onBlock( + self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None + ): + """ + Called when the client is listening, and somebody blocks client + + :param author_id: The ID of the person who blocked + :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 msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info( + "{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name) + ) + + def onUnblock( + self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None + ): + """ + Called when the client is listening, and somebody blocks client + + :param author_id: The ID of the person who unblocked + :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 msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info( + "{} unblocked {} ({}) thread".format(author_id, thread_id, thread_type.name) + ) + + def onLiveLocation( + self, + mid=None, + location=None, + author_id=None, + thread_id=None, + thread_type=None, + ts=None, + msg=None, + ): + """ + Called when the client is listening and somebody sends live location info + + :param mid: The action ID + :param location: Sent location info + :param author_id: The ID of the person who sent location info + :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 msg: A full set of the data recieved + :type location: models.LiveLocationAttachment + :type thread_type: models.ThreadType + """ + log.info( + "{} sent live location info in {} ({}) with latitude {} and longitude {}".format( + author_id, thread_id, thread_type, location.latitude, location.longitude + ) + ) + + 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 take_part: bool + :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 + ) + ) + + def onQprimer(self, ts=None, msg=None): + """ + Called when the client just started listening + + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + """ + pass + + def onChatTimestamp(self, buddylist=None, msg=None): + """ + Called when the client receives chat online presence update + + :param buddylist: A list of dicts with friend id and last seen timestamp + :param msg: A full set of the data recieved + """ + log.debug("Chat Timestamps received: {}".format(buddylist)) + + def onBuddylistOverlay(self, statuses=None, msg=None): + """ + Called when the client is listening and client receives information about friend active status + + :param statuses: Dictionary with user IDs as keys and :class:`models.ActiveStatus` as values + :param msg: A full set of the data recieved + :type statuses: dict + """ + log.debug("Buddylist overlay received: {}".format(statuses)) + + def onUnknownMesssageType(self, msg=None): + """ + Called when the client is listening, and some unknown data was recieved + + :param msg: A full set of the data recieved + """ + log.debug("Unknown message received: {}".format(msg)) + + def onMessageError(self, exception=None, msg=None): + """ + Called when an error was encountered while parsing recieved data + + :param exception: The exception that was encountered + :param msg: A full set of the data recieved + """ + log.exception("Exception in parsing of {}".format(msg)) + + """ + END EVENTS + """ diff --git a/fbchat/_graphql.py b/fbchat/_graphql.py new file mode 100644 index 0000000..4bf8cab --- /dev/null +++ b/fbchat/_graphql.py @@ -0,0 +1,765 @@ +# -*- coding: UTF-8 -*- + +from __future__ import unicode_literals +import json +import re +from .models import * +from ._util import * + +# Shameless copy from https://stackoverflow.com/a/8730674 +FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL +WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS) + + +class ConcatJSONDecoder(json.JSONDecoder): + def decode(self, s, _w=WHITESPACE.match): + s_len = len(s) + + objs = [] + end = 0 + while end != s_len: + obj, end = self.raw_decode(s, idx=_w(s, end).end()) + end = _w(s, end).end() + objs.append(obj) + return objs + + +# End shameless copy + + +def graphql_color_to_enum(color): + if color is None: + return None + if not color: + return ThreadColor.MESSENGER_BLUE + color = color[2:] # Strip the alpha value + color_value = "#{}".format(color.lower()) + return enum_extend_if_invalid(ThreadColor, color_value) + + +def get_customization_info(thread): + if thread is None or thread.get("customization_info") is None: + return {} + info = thread["customization_info"] + + rtn = { + "emoji": info.get("emoji"), + "color": graphql_color_to_enum(info.get("outgoing_bubble_color")), + } + if ( + thread.get("thread_type") == "GROUP" + or thread.get("is_group_thread") + or thread.get("thread_key", {}).get("thread_fbid") + ): + rtn["nicknames"] = {} + for k in info.get("participant_customizations", []): + rtn["nicknames"][k["participant_id"]] = k.get("nickname") + elif info.get("participant_customizations"): + uid = thread.get("thread_key", {}).get("other_user_id") or thread.get("id") + pc = info["participant_customizations"] + if len(pc) > 0: + if pc[0].get("participant_id") == uid: + rtn["nickname"] = pc[0].get("nickname") + else: + rtn["own_nickname"] = pc[0].get("nickname") + if len(pc) > 1: + if pc[1].get("participant_id") == uid: + rtn["nickname"] = pc[1].get("nickname") + else: + rtn["own_nickname"] = pc[1].get("nickname") + return rtn + + +def graphql_to_sticker(s): + if not s: + return None + sticker = Sticker(uid=s["id"]) + if s.get("pack"): + sticker.pack = s["pack"].get("id") + if s.get("sprite_image"): + sticker.is_animated = True + sticker.medium_sprite_image = s["sprite_image"].get("uri") + sticker.large_sprite_image = s["sprite_image_2x"].get("uri") + sticker.frames_per_row = s.get("frames_per_row") + sticker.frames_per_col = s.get("frames_per_column") + sticker.frame_rate = s.get("frame_rate") + sticker.url = s.get("url") + sticker.width = s.get("width") + sticker.height = s.get("height") + if s.get("label"): + sticker.label = s["label"] + return sticker + + +def graphql_to_attachment(a): + _type = a["__typename"] + if _type in ["MessageImage", "MessageAnimatedImage"]: + return ImageAttachment( + original_extension=a.get("original_extension") + or (a["filename"].split("-")[0] if a.get("filename") else None), + width=a.get("original_dimensions", {}).get("width"), + height=a.get("original_dimensions", {}).get("height"), + is_animated=_type == "MessageAnimatedImage", + thumbnail_url=a.get("thumbnail", {}).get("uri"), + preview=a.get("preview") or a.get("preview_image"), + large_preview=a.get("large_preview"), + animated_preview=a.get("animated_image"), + uid=a.get("legacy_attachment_id"), + ) + elif _type == "MessageVideo": + return VideoAttachment( + width=a.get("original_dimensions", {}).get("width"), + height=a.get("original_dimensions", {}).get("height"), + duration=a.get("playable_duration_in_ms"), + preview_url=a.get("playable_url"), + small_image=a.get("chat_image"), + medium_image=a.get("inbox_image"), + large_image=a.get("large_image"), + uid=a.get("legacy_attachment_id"), + ) + elif _type == "MessageAudio": + return AudioAttachment( + filename=a.get("filename"), + url=a.get("playable_url"), + duration=a.get("playable_duration_in_ms"), + audio_type=a.get("audio_type"), + ) + elif _type == "MessageFile": + return FileAttachment( + url=a.get("url"), + name=a.get("filename"), + is_malicious=a.get("is_malicious"), + uid=a.get("message_file_fbid"), + ) + else: + return Attachment(uid=a.get("legacy_attachment_id")) + + +def graphql_to_extensible_attachment(a): + story = a.get("story_attachment") + if story: + target = story.get("target") + if target: + _type = target["__typename"] + if _type == "MessageLocation": + url = story.get("url") + address = get_url_parameter(get_url_parameter(url, "u"), "where1") + try: + latitude, longitude = [float(x) for x in address.split(", ")] + address = None + except ValueError: + latitude, longitude = None, None + rtn = LocationAttachment( + uid=int(story["deduplication_key"]), + latitude=latitude, + longitude=longitude, + address=address, + ) + media = story.get("media") + if media and media.get("image"): + image = media["image"] + rtn.image_url = image.get("uri") + rtn.image_width = image.get("width") + rtn.image_height = image.get("height") + rtn.url = url + return rtn + elif _type == "MessageLiveLocation": + rtn = LiveLocationAttachment( + uid=int(story["target"]["live_location_id"]), + latitude=story["target"]["coordinate"]["latitude"] + if story["target"].get("coordinate") + else None, + longitude=story["target"]["coordinate"]["longitude"] + if story["target"].get("coordinate") + else None, + name=story["title_with_entities"]["text"], + expiration_time=story["target"].get("expiration_time"), + is_expired=story["target"].get("is_expired"), + ) + media = story.get("media") + if media and media.get("image"): + image = media["image"] + rtn.image_url = image.get("uri") + rtn.image_width = image.get("width") + rtn.image_height = image.get("height") + rtn.url = story.get("url") + return rtn + elif _type in ["ExternalUrl", "Story"]: + url = story.get("url") + rtn = ShareAttachment( + uid=a.get("legacy_attachment_id"), + author=story["target"]["actors"][0]["id"] + if story["target"].get("actors") + else None, + url=url, + original_url=get_url_parameter(url, "u") + if "/l.php?u=" in url + else url, + title=story["title_with_entities"].get("text"), + description=story["description"].get("text") + if story.get("description") + else None, + source=story["source"].get("text"), + attachments=[ + graphql_to_subattachment(attachment) + for attachment in story.get("subattachments") + ], + ) + media = story.get("media") + if media and media.get("image"): + image = media["image"] + rtn.image_url = image.get("uri") + rtn.original_image_url = ( + get_url_parameter(rtn.image_url, "url") + if "/safe_image.php" in rtn.image_url + else rtn.image_url + ) + rtn.image_width = image.get("width") + rtn.image_height = image.get("height") + return rtn + else: + return UnsentMessage(uid=a.get("legacy_attachment_id")) + + +def graphql_to_subattachment(a): + _type = a["target"]["__typename"] + if _type == "Video": + media = a["media"] + return VideoAttachment( + duration=media.get("playable_duration_in_ms"), + preview_url=media.get("playable_url"), + medium_image=media.get("image"), + uid=a["target"].get("video_id"), + ) + + +def graphql_to_live_location(a): + return LiveLocationAttachment( + uid=a["id"], + latitude=a["coordinate"]["latitude"] / (10 ** 8) + if not a.get("stopReason") + else None, + longitude=a["coordinate"]["longitude"] / (10 ** 8) + if not a.get("stopReason") + else None, + name=a.get("locationTitle"), + expiration_time=a["expirationTime"], + is_expired=bool(a.get("stopReason")), + ) + + +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_quick_reply(q, is_response=False): + data = dict() + _type = q.get("content_type").lower() + if q.get("payload"): + data["payload"] = q["payload"] + if q.get("data"): + data["data"] = q["data"] + if q.get("image_url") and _type is not QuickReplyLocation._type: + data["image_url"] = q["image_url"] + data["is_response"] = is_response + if _type == QuickReplyText._type: + if q.get("title") is not None: + data["title"] = q["title"] + rtn = QuickReplyText(**data) + elif _type == QuickReplyLocation._type: + rtn = QuickReplyLocation(**data) + elif _type == QuickReplyPhoneNumber._type: + rtn = QuickReplyPhoneNumber(**data) + elif _type == QuickReplyEmail._type: + rtn = QuickReplyEmail(**data) + return rtn + + +def graphql_to_message(message): + if message.get("message_sender") is None: + message["message_sender"] = {} + if message.get("message") is None: + message["message"] = {} + rtn = Message( + text=message.get("message").get("text"), + mentions=[ + Mention( + m.get("entity", {}).get("id"), + offset=m.get("offset"), + length=m.get("length"), + ) + for m in message.get("message").get("ranges", []) + ], + emoji_size=get_emojisize_from_tags(message.get("tags_list")), + sticker=graphql_to_sticker(message.get("sticker")), + ) + rtn.uid = str(message.get("message_id")) + rtn.author = str(message.get("message_sender").get("id")) + rtn.timestamp = message.get("timestamp_precise") + rtn.unsent = False + if message.get("unread") is not None: + rtn.is_read = not message["unread"] + rtn.reactions = { + str(r["user"]["id"]): enum_extend_if_invalid(MessageReaction, r["reaction"]) + for r in message.get("message_reactions") + } + if message.get("blob_attachments") is not None: + rtn.attachments = [ + graphql_to_attachment(attachment) + for attachment in message["blob_attachments"] + ] + if message.get("platform_xmd_encoded"): + quick_replies = json.loads(message["platform_xmd_encoded"]).get("quick_replies") + if isinstance(quick_replies, list): + rtn.quick_replies = [graphql_to_quick_reply(q) for q in quick_replies] + elif isinstance(quick_replies, dict): + rtn.quick_replies = [ + graphql_to_quick_reply(quick_replies, is_response=True) + ] + if message.get("extensible_attachment") is not None: + attachment = graphql_to_extensible_attachment(message["extensible_attachment"]) + if isinstance(attachment, UnsentMessage): + rtn.unsent = True + elif attachment: + rtn.attachments.append(attachment) + return rtn + + +def graphql_to_user(user): + if user.get("profile_picture") is None: + user["profile_picture"] = {} + c_info = get_customization_info(user) + plan = None + if user.get("event_reminders"): + plan = ( + graphql_to_plan(user["event_reminders"]["nodes"][0]) + if user["event_reminders"].get("nodes") + else None + ) + return User( + user["id"], + url=user.get("url"), + first_name=user.get("first_name"), + last_name=user.get("last_name"), + is_friend=user.get("is_viewer_friend"), + gender=GENDERS.get(user.get("gender")), + affinity=user.get("affinity"), + nickname=c_info.get("nickname"), + color=c_info.get("color"), + emoji=c_info.get("emoji"), + own_nickname=c_info.get("own_nickname"), + photo=user["profile_picture"].get("uri"), + name=user.get("name"), + message_count=user.get("messages_count"), + plan=plan, + ) + + +def graphql_to_thread(thread): + if thread["thread_type"] == "GROUP": + return graphql_to_group(thread) + elif thread["thread_type"] == "ONE_TO_ONE": + if thread.get("big_image_src") is None: + thread["big_image_src"] = {} + c_info = get_customization_info(thread) + participants = [ + node["messaging_actor"] for node in thread["all_participants"]["nodes"] + ] + user = next( + p for p in participants if p["id"] == thread["thread_key"]["other_user_id"] + ) + last_message_timestamp = None + if "last_message" in thread: + last_message_timestamp = thread["last_message"]["nodes"][0][ + "timestamp_precise" + ] + + first_name = user.get("short_name") + if first_name is None: + last_name = None + else: + last_name = user.get("name").split(first_name, 1).pop().strip() + + plan = None + if thread.get("event_reminders"): + plan = ( + graphql_to_plan(thread["event_reminders"]["nodes"][0]) + if thread["event_reminders"].get("nodes") + else None + ) + + return User( + user["id"], + url=user.get("url"), + name=user.get("name"), + first_name=first_name, + last_name=last_name, + is_friend=user.get("is_viewer_friend"), + gender=GENDERS.get(user.get("gender")), + affinity=user.get("affinity"), + nickname=c_info.get("nickname"), + color=c_info.get("color"), + emoji=c_info.get("emoji"), + 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, + plan=plan, + ) + else: + raise FBchatException( + "Unknown thread type: {}, with data: {}".format( + thread.get("thread_type"), thread + ) + ) + + +def graphql_to_group(group): + if group.get("image") is None: + group["image"] = {} + c_info = get_customization_info(group) + last_message_timestamp = None + if "last_message" in group: + last_message_timestamp = group["last_message"]["nodes"][0]["timestamp_precise"] + plan = None + if group.get("event_reminders"): + plan = ( + graphql_to_plan(group["event_reminders"]["nodes"][0]) + if group["event_reminders"].get("nodes") + else None + ) + return Group( + group["thread_key"]["thread_fbid"], + participants=set( + [ + node["messaging_actor"]["id"] + for node in group["all_participants"]["nodes"] + ] + ), + nicknames=c_info.get("nicknames"), + color=c_info.get("color"), + emoji=c_info.get("emoji"), + admins=set([node.get("id") for node in group.get("thread_admins")]), + approval_mode=bool(group.get("approval_mode")) + if group.get("approval_mode") is not None + else None, + approval_requests=set( + node["requester"]["id"] for node in group["group_approval_queue"]["nodes"] + ) + if group.get("group_approval_queue") + else None, + join_link=group["joinable_mode"].get("link"), + photo=group["image"].get("uri"), + name=group.get("name"), + message_count=group.get("messages_count"), + last_message_timestamp=last_message_timestamp, + plan=plan, + ) + + +def graphql_to_page(page): + if page.get("profile_picture") is None: + page["profile_picture"] = {} + if page.get("city") is None: + page["city"] = {} + plan = None + if page.get("event_reminders"): + plan = ( + graphql_to_plan(page["event_reminders"]["nodes"][0]) + if page["event_reminders"].get("nodes") + else None + ) + return Page( + page["id"], + url=page.get("url"), + city=page.get("city").get("name"), + category=page.get("category_type"), + photo=page["profile_picture"].get("uri"), + name=page.get("name"), + message_count=page.get("messages_count"), + plan=plan, + ) + + +def graphql_queries_to_json(*queries): + """ + Queries should be a list of GraphQL objects + """ + rtn = {} + for i, query in enumerate(queries): + rtn["q{}".format(i)] = query.value + return json.dumps(rtn) + + +def graphql_response_to_json(content): + content = strip_to_json(content) # Usually only needed in some error cases + try: + j = json.loads(content, cls=ConcatJSONDecoder) + except Exception: + raise FBchatException("Error while parsing JSON: {}".format(repr(content))) + + rtn = [None] * (len(j)) + for x in j: + if "error_results" in x: + del rtn[-1] + continue + check_json(x) + [(key, value)] = x.items() + check_json(value) + if "response" in value: + rtn[int(key[1:])] = value["response"] + else: + rtn[int(key[1:])] = value["data"] + + log.debug(rtn) + + return rtn + + +class GraphQL(object): + def __init__(self, query=None, doc_id=None, params=None): + if params is None: + params = {} + if query is not None: + self.value = {"priority": 0, "q": query, "query_params": params} + elif doc_id is not None: + self.value = {"doc_id": doc_id, "query_params": params} + else: + raise FBchatUserError("A query or doc_id must be specified") + + FRAGMENT_USER = """ + QueryFragment User: User { + id, + name, + first_name, + last_name, + profile_picture.width().height() { + uri + }, + is_viewer_friend, + url, + gender, + viewer_affinity + } + """ + + FRAGMENT_GROUP = """ + QueryFragment Group: MessageThread { + name, + thread_key { + thread_fbid + }, + image { + uri + }, + is_group_thread, + all_participants { + nodes { + messaging_actor { + id + } + } + }, + customization_info { + participant_customizations { + participant_id, + nickname + }, + outgoing_bubble_color, + emoji + }, + thread_admins { + id + }, + group_approval_queue { + nodes { + requester { + id + } + } + }, + approval_mode, + joinable_mode { + mode, + link + }, + event_reminders { + nodes { + id, + lightweight_event_creator { + id + }, + time, + location_name, + event_title, + event_reminder_members { + edges { + node { + id + }, + guest_list_state + } + } + } + } + } + """ + + FRAGMENT_PAGE = """ + QueryFragment Page: Page { + id, + name, + profile_picture.width(32).height(32) { + uri + }, + url, + category_type, + city { + name + } + } + """ + + SEARCH_USER = ( + """ + Query SearchUser( = '', = 10) { + entities_named() { + search_results.of_type(user).first() as users { + nodes { + @User + } + } + } + } + """ + + FRAGMENT_USER + ) + + SEARCH_GROUP = ( + """ + Query SearchGroup( = '', = 10, = 32) { + viewer() { + message_threads.with_thread_name().last() as groups { + nodes { + @Group + } + } + } + } + """ + + FRAGMENT_GROUP + ) + + SEARCH_PAGE = ( + """ + Query SearchPage( = '', = 10) { + entities_named() { + search_results.of_type(page).first() as pages { + nodes { + @Page + } + } + } + } + """ + + FRAGMENT_PAGE + ) + + SEARCH_THREAD = ( + """ + Query SearchThread( = '', = 10) { + entities_named() { + search_results.first() as threads { + nodes { + __typename, + @User, + @Group, + @Page + } + } + } + } + """ + + FRAGMENT_USER + + FRAGMENT_GROUP + + FRAGMENT_PAGE + ) diff --git a/fbchat/_util.py b/fbchat/_util.py new file mode 100644 index 0000000..8705442 --- /dev/null +++ b/fbchat/_util.py @@ -0,0 +1,377 @@ +# -*- coding: UTF-8 -*- + +from __future__ import unicode_literals +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 +import aenum +from .models import * + +try: + from urllib.parse import urlencode, parse_qs, urlparse + + basestring = (str, bytes) +except ImportError: + from urllib import urlencode + from urlparse import parse_qs, urlparse + + basestring = basestring + +# Python 2's `input` executes the input, whereas `raw_input` just returns the input +try: + input = raw_input +except NameError: + pass + +# Log settings +log = logging.getLogger("client") +log.setLevel(logging.DEBUG) +# Creates the console handler +handler = logging.StreamHandler() +log.addHandler(handler) + +#: Default list of user agents +USER_AGENTS = [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/601.1.10 (KHTML, like Gecko) Version/8.0.5 Safari/601.1.10", + "Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/22.0.1207.1 Safari/537.1", + "Mozilla/5.0 (X11; CrOS i686 2268.111.0) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.57 Safari/536.11", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6", +] + +LIKES = { + "large": EmojiSize.LARGE, + "medium": EmojiSize.MEDIUM, + "small": EmojiSize.SMALL, + "l": EmojiSize.LARGE, + "m": EmojiSize.MEDIUM, + "s": EmojiSize.SMALL, +} + + +GENDERS = { + # For standard requests + 0: "unknown", + 1: "female_singular", + 2: "male_singular", + 3: "female_singular_guess", + 4: "male_singular_guess", + 5: "mixed", + 6: "neuter_singular", + 7: "unknown_singular", + 8: "female_plural", + 9: "male_plural", + 10: "neuter_plural", + 11: "unknown_plural", + # For graphql requests + "UNKNOWN": "unknown", + "FEMALE": "female_singular", + "MALE": "male_singular", + # '': 'female_singular_guess', + # '': 'male_singular_guess', + # '': 'mixed', + "NEUTER": "neuter_singular", + # '': 'unknown_singular', + # '': 'female_plural', + # '': 'male_plural', + # '': 'neuter_plural', + # '': 'unknown_plural', +} + + +class ReqUrl(object): + """A class containing all urls used by `fbchat`""" + + SEARCH = "https://www.facebook.com/ajax/typeahead/search.php" + LOGIN = "https://m.facebook.com/login.php?login_attempt=1" + SEND = "https://www.facebook.com/messaging/send/" + 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" + MARK_SEEN = "https://www.facebook.com/ajax/mercury/mark_seen.php" + BASE = "https://www.facebook.com" + MOBILE = "https://m.facebook.com/" + STICKY = "https://0-edge-chat.facebook.com/pull" + PING = "https://0-edge-chat.facebook.com/active_ping" + UPLOAD = "https://upload.facebook.com/ajax/mercury/upload.php" + INFO = "https://www.facebook.com/chat/user_info/" + CONNECT = "https://www.facebook.com/ajax/add_friend/action.php?dpr=1" + REMOVE_USER = "https://www.facebook.com/chat/remove_participants/" + LOGOUT = "https://www.facebook.com/logout.php" + ALL_USERS = "https://www.facebook.com/chat/user_info_all" + SAVE_DEVICE = "https://m.facebook.com/login/save-device/cancel/" + CHECKPOINT = "https://m.facebook.com/login/checkpoint/" + THREAD_COLOR = "https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1" + THREAD_NICKNAME = "https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1" + THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1" + THREAD_IMAGE = "https://www.facebook.com/messaging/set_thread_image/?dpr=1" + THREAD_NAME = "https://www.facebook.com/messaging/set_thread_name/?dpr=1" + MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation" + 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/" + 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" + UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1" + + pull_channel = 0 + + def change_pull_channel(self, channel=None): + if channel is None: + self.pull_channel = (self.pull_channel + 1) % 5 # Pull channel will be 0-4 + else: + self.pull_channel = channel + self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel) + self.PING = "https://{}-edge-chat.facebook.com/active_ping".format( + self.pull_channel + ) + + +facebookEncoding = "UTF-8" + + +def now(): + return int(time() * 1000) + + +def strip_to_json(text): + try: + return text[text.index("{") :] + except ValueError: + raise FBchatException("No JSON object found: {!r}".format(text)) + + +def get_decoded_r(r): + return get_decoded(r._content) + + +def get_decoded(content): + return content.decode(facebookEncoding) + + +def parse_json(content): + return json.loads(content) + + +def get_json(r): + return json.loads(strip_to_json(get_decoded_r(r))) + + +def digitToChar(digit): + if digit < 10: + return str(digit) + return chr(ord("a") + digit - 10) + + +def str_base(number, base): + if number < 0: + return "-" + str_base(-number, base) + (d, m) = divmod(number, base) + if d > 0: + return str_base(d, base) + digitToChar(m) + return digitToChar(m) + + +def generateMessageID(client_id=None): + k = now() + l = int(random() * 4294967295) + return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id) + + +def getSignatureID(): + return hex(int(random() * 2147483648)) + + +def generateOfflineThreadingID(): + ret = now() + value = int(random() * 4294967295) + string = ("0000000000000000000000" + format(value, "b"))[-22:] + msgs = format(ret, "b") + string + return str(int(msgs, 2)) + + +def check_json(j): + if j.get("error") is None: + return + if "errorDescription" in j: + # 'errorDescription' is in the users own language! + raise FBchatFacebookError( + "Error #{} when sending request: {}".format( + j["error"], j["errorDescription"] + ), + fb_error_code=j["error"], + fb_error_message=j["errorDescription"], + ) + elif "debug_info" in j["error"] and "code" in j["error"]: + raise FBchatFacebookError( + "Error #{} when sending request: {}".format( + j["error"]["code"], repr(j["error"]["debug_info"]) + ), + fb_error_code=j["error"]["code"], + fb_error_message=j["error"]["debug_info"], + ) + else: + raise FBchatFacebookError( + "Error {} when sending request".format(j["error"]), fb_error_code=j["error"] + ) + + +def check_request(r, as_json=True): + if not r.ok: + raise FBchatFacebookError( + "Error when sending request: Got {} response".format(r.status_code), + request_status_code=r.status_code, + ) + + content = get_decoded_r(r) + + if content is None or len(content) == 0: + raise FBchatFacebookError("Error when sending request: Got empty response") + + if as_json: + content = strip_to_json(content) + try: + j = json.loads(content) + except ValueError: + raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content)) + check_json(j) + log.debug(j) + return j + else: + return content + + +def get_jsmods_require(j, index): + if j.get("jsmods") and j["jsmods"].get("require"): + try: + return j["jsmods"]["require"][0][index][0] + except (KeyError, IndexError) as e: + log.warning( + "Error when getting jsmods_require: {}. Facebook might have changed protocol".format( + j + ) + ) + return None + + +def get_emojisize_from_tags(tags): + if tags is None: + return None + tmp = [tag for tag in tags if tag.startswith("hot_emoji_size:")] + if len(tmp) > 0: + try: + return LIKES[tmp[0].split(":")[1]] + 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() + + +def enum_extend_if_invalid(enumeration, value): + try: + return enumeration(value) + except ValueError: + log.warning( + "Failed parsing {.__name__}({!r}). Extending enum.".format( + enumeration, value + ) + ) + aenum.extend_enum(enumeration, "UNKNOWN_{}".format(value).upper(), value) + return enumeration(value) + + +def get_url_parameters(url, *args): + params = parse_qs(urlparse(url).query) + return [params[arg][0] for arg in args if params.get(arg)] + + +def get_url_parameter(url, param): + return get_url_parameters(url, param)[0] diff --git a/fbchat/client.py b/fbchat/client.py index 8726f68..846c9db 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1,4327 +1,8 @@ # -*- coding: UTF-8 -*- - +"""This file is here to maintain backwards compatability.""" from __future__ import unicode_literals -import requests -import urllib -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: - from urlparse import urlparse, parse_qs - - -class Client(object): - """A client for the Facebook Chat (Messenger). - - See https://fbchat.readthedocs.io for complete documentation of the API. - """ - - ssl_verify = True - """Verify ssl certificate, set to False to allow debugging with a proxy""" - listening = False - """Whether the client is listening. Used when creating an external event loop to determine when to stop listening""" - uid = None - """ - The ID of the client. - Can be used as `thread_id`. See :ref:`intro_threads` for more info. - - Note: Modifying this results in undefined behaviour - """ - - def __init__( - self, - email, - password, - user_agent=None, - max_tries=5, - session_cookies=None, - logging_level=logging.INFO, - ): - """Initializes and logs in the client - - :param email: Facebook `email`, `id` or `phone number` - :param password: Facebook account password - :param user_agent: Custom user agent to use when sending requests. If `None`, user agent will be chosen from a premade list (see :any:`utils.USER_AGENTS`) - :param max_tries: Maximum number of times to try logging in - :param session_cookies: Cookies from a previous session (Will default to login if these are invalid) - :param logging_level: Configures the `logging level `_. Defaults to `INFO` - :type max_tries: int - :type session_cookies: dict - :type logging_level: int - :raises: FBchatException on failed login - """ - - self.sticky, self.pool = (None, None) - self._session = requests.session() - self.req_counter = 1 - self.seq = "0" - # 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 - self.req_url = ReqUrl() - self._markAlive = True - self._buddylist = dict() - - if not user_agent: - user_agent = choice(USER_AGENTS) - - self._header = { - "Content-Type": "application/x-www-form-urlencoded", - "Referer": self.req_url.BASE, - "Origin": self.req_url.BASE, - "User-Agent": user_agent, - "Connection": "keep-alive", - } - - handler.setLevel(logging_level) - - # If session cookies aren't set, not properly loaded or gives us an invalid session, then do the login - if ( - not session_cookies - or not self.setSession(session_cookies) - or not self.isLoggedIn() - ): - self.login(email, password, max_tries) - else: - self.email = email - self.password = password - - """ - INTERNAL REQUEST METHODS - """ - - def _generatePayload(self, query): - """Adds the following defaults to the payload: - __rev, __user, __a, ttstamp, fb_dtsg, __req - """ - payload = self.payloadDefault.copy() - if query: - payload.update(query) - payload["__req"] = str_base(self.req_counter, 36) - payload["seq"] = self.seq - self.req_counter += 1 - return payload - - def _fix_fb_errors(self, error_code): - """ - This fixes "Please try closing and re-opening your browser window" errors (1357004) - This error usually happens after 1-2 days of inactivity - It may be a bad idea to do this in an exception handler, if you have a better method, please suggest it! - """ - if error_code == "1357004": - log.warning("Got error #1357004. Doing a _postLogin, and resending request") - self._postLogin() - return True - return False - - def _get( - self, - url, - query=None, - timeout=30, - fix_request=False, - as_json=False, - error_retries=3, - ): - payload = self._generatePayload(query) - r = self._session.get( - url, - headers=self._header, - params=payload, - timeout=timeout, - verify=self.ssl_verify, - ) - if not fix_request: - return r - try: - return check_request(r, as_json=as_json) - except FBchatFacebookError as e: - if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): - return self._get( - url, - query=query, - timeout=timeout, - fix_request=fix_request, - as_json=as_json, - error_retries=error_retries - 1, - ) - raise e - - def _post( - self, - url, - query=None, - timeout=30, - fix_request=False, - as_json=False, - error_retries=3, - ): - payload = self._generatePayload(query) - r = self._session.post( - url, - headers=self._header, - data=payload, - timeout=timeout, - verify=self.ssl_verify, - ) - if not fix_request: - return r - try: - return check_request(r, as_json=as_json) - except FBchatFacebookError as e: - if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): - return self._post( - url, - query=query, - timeout=timeout, - fix_request=fix_request, - as_json=as_json, - error_retries=error_retries - 1, - ) - raise e - - def _graphql(self, payload, error_retries=3): - content = self._post( - self.req_url.GRAPHQL, payload, fix_request=True, as_json=False - ) - try: - return graphql_response_to_json(content) - except FBchatFacebookError as e: - if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): - return self._graphql(payload, error_retries=error_retries - 1) - raise e - - def _cleanGet(self, url, query=None, timeout=30, allow_redirects=True): - return self._session.get( - url, - headers=self._header, - params=query, - timeout=timeout, - verify=self.ssl_verify, - allow_redirects=allow_redirects, - ) - - def _cleanPost(self, url, query=None, timeout=30): - self.req_counter += 1 - return self._session.post( - url, - headers=self._header, - data=query, - timeout=timeout, - verify=self.ssl_verify, - ) - - def _postFile( - self, - url, - files=None, - query=None, - timeout=30, - fix_request=False, - as_json=False, - error_retries=3, - ): - payload = self._generatePayload(query) - # Removes 'Content-Type' from the header - headers = dict( - (i, self._header[i]) for i in self._header if i != "Content-Type" - ) - r = self._session.post( - url, - headers=headers, - data=payload, - timeout=timeout, - files=files, - verify=self.ssl_verify, - ) - if not fix_request: - return r - try: - return check_request(r, as_json=as_json) - except FBchatFacebookError as e: - if error_retries > 0 and self._fix_fb_errors(e.fb_error_code): - return self._postFile( - url, - files=files, - query=query, - timeout=timeout, - fix_request=fix_request, - as_json=as_json, - error_retries=error_retries - 1, - ) - raise e - - def graphql_requests(self, *queries): - """ - :param queries: Zero or more GraphQL objects - :type queries: GraphQL - - :raises: FBchatException if request failed - :return: A tuple containing json graphql queries - :rtype: tuple - """ - - return tuple( - self._graphql( - { - "method": "GET", - "response_format": "json", - "queries": graphql_queries_to_json(*queries), - } - ) - ) - - def graphql_request(self, query): - """ - Shorthand for `graphql_requests(query)[0]` - - :raises: FBchatException if request failed - """ - return self.graphql_requests(query)[0] - - """ - END INTERNAL REQUEST METHODS - """ - - """ - LOGIN METHODS - """ - - def _resetValues(self): - self.payloadDefault = OrderedDict() - self._session = requests.session() - self.req_counter = 1 - self.seq = "0" - self.uid = None - - def _postLogin(self): - 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") - if self.uid is None: - raise FBchatException("Could not find c_user cookie") - self.uid = str(self.uid) - self.user_channel = "p_" + self.uid - self.ttstamp = "" - - r = self._get(self.req_url.BASE) - soup = bs(r.text, "html.parser") - - fb_dtsg_element = soup.find("input", {"name": "fb_dtsg"}) - if fb_dtsg_element: - self.fb_dtsg = fb_dtsg_element["value"] - else: - self.fb_dtsg = re.search(r'name="fb_dtsg" value="(.*?)"', r.text).group(1) - - fb_h_element = soup.find("input", {"name": "h"}) - if fb_h_element: - self.fb_h = fb_h_element["value"] - - for i in self.fb_dtsg: - self.ttstamp += str(ord(i)) - self.ttstamp += "2" - # Set default payload - self.payloadDefault["__rev"] = int( - r.text.split('"client_revision":', 1)[1].split(",", 1)[0] - ) - self.payloadDefault["__user"] = self.uid - self.payloadDefault["__a"] = "1" - self.payloadDefault["ttstamp"] = self.ttstamp - self.payloadDefault["fb_dtsg"] = self.fb_dtsg - - def _login(self): - if not (self.email and self.password): - raise FBchatUserError("Email and password not found.") - - soup = bs(self._get(self.req_url.MOBILE).text, "html.parser") - data = dict( - (elem["name"], elem["value"]) - for elem in soup.findAll("input") - if elem.has_attr("value") and elem.has_attr("name") - ) - data["email"] = self.email - data["pass"] = self.password - data["login"] = "Log In" - - r = self._cleanPost(self.req_url.LOGIN, data) - - # Usually, 'Checkpoint' will refer to 2FA - if "checkpoint" in r.url and ('id="approvals_code"' in r.text.lower()): - r = self._2FA(r) - - # Sometimes Facebook tries to show the user a "Save Device" dialog - if "save-device" in r.url: - r = self._cleanGet(self.req_url.SAVE_DEVICE) - - if "home" in r.url: - self._postLogin() - return True, r.url - else: - return False, r.url - - def _2FA(self, r): - soup = bs(r.text, "html.parser") - data = dict() - - s = self.on2FACode() - - data["approvals_code"] = s - data["fb_dtsg"] = soup.find("input", {"name": "fb_dtsg"})["value"] - data["nh"] = soup.find("input", {"name": "nh"})["value"] - data["submit[Submit Code]"] = "Submit Code" - data["codes_submitted"] = 0 - log.info("Submitting 2FA code.") - - r = self._cleanPost(self.req_url.CHECKPOINT, data) - - if "home" in r.url: - return r - - del (data["approvals_code"]) - del (data["submit[Submit Code]"]) - del (data["codes_submitted"]) - - data["name_action_selected"] = "save_device" - data["submit[Continue]"] = "Continue" - log.info( - "Saving browser." - ) # At this stage, we have dtsg, nh, name_action_selected, submit[Continue] - r = self._cleanPost(self.req_url.CHECKPOINT, data) - - if "home" in r.url: - return r - - del (data["name_action_selected"]) - log.info( - "Starting Facebook checkup flow." - ) # At this stage, we have dtsg, nh, submit[Continue] - r = self._cleanPost(self.req_url.CHECKPOINT, data) - - if "home" in r.url: - return r - - del (data["submit[Continue]"]) - data["submit[This was me]"] = "This Was Me" - log.info( - "Verifying login attempt." - ) # At this stage, we have dtsg, nh, submit[This was me] - r = self._cleanPost(self.req_url.CHECKPOINT, data) - - if "home" in r.url: - return r - - del (data["submit[This was me]"]) - data["submit[Continue]"] = "Continue" - data["name_action_selected"] = "save_device" - log.info( - "Saving device again." - ) # At this stage, we have dtsg, nh, submit[Continue], name_action_selected - r = self._cleanPost(self.req_url.CHECKPOINT, data) - return r - - def isLoggedIn(self): - """ - Sends a request to Facebook to check the login status - - :return: True if the client is still logged in - :rtype: bool - """ - # Send a request to the login url, to see if we're directed to the home page - r = self._cleanGet(self.req_url.LOGIN, allow_redirects=False) - return "Location" in r.headers and "home" in r.headers["Location"] - - def getSession(self): - """Retrieves session cookies - - :return: A dictionay containing session cookies - :rtype: dict - """ - return self._session.cookies.get_dict() - - def setSession(self, session_cookies): - """Loads session cookies - - :param session_cookies: A dictionay containing session cookies - :type session_cookies: dict - :return: False if `session_cookies` does not contain proper cookies - :rtype: bool - """ - - # Quick check to see if session_cookies is formatted properly - if not session_cookies or "c_user" not in session_cookies: - return False - - try: - # Load cookies into current session - self._session.cookies = requests.cookies.merge_cookies( - self._session.cookies, session_cookies - ) - self._postLogin() - except Exception as e: - log.exception("Failed loading session") - self._resetValues() - return False - return True - - def login(self, email, password, max_tries=5): - """ - Uses `email` and `password` to login the user (If the user is already logged in, this will do a re-login) - - :param email: Facebook `email` or `id` or `phone number` - :param password: Facebook account password - :param max_tries: Maximum number of times to try logging in - :type max_tries: int - :raises: FBchatException on failed login - """ - self.onLoggingIn(email=email) - - if max_tries < 1: - raise FBchatUserError("Cannot login: max_tries should be at least one") - - if not (email and password): - raise FBchatUserError("Email and password not set") - - self.email = email - self.password = password - - for i in range(1, max_tries + 1): - login_successful, login_url = self._login() - if not login_successful: - log.warning( - "Attempt #{} failed{}".format( - i, {True: ", retrying"}.get(i < max_tries, "") - ) - ) - time.sleep(1) - continue - else: - self.onLoggedIn(email=email) - break - else: - raise FBchatUserError( - "Login failed. Check email/password. (Failed on url: {})".format( - login_url - ) - ) - - def logout(self): - """ - Safely logs out the client - - :param timeout: See `requests timeout `_ - :return: True if the action was successful - :rtype: bool - """ - - if not hasattr(self, "fb_h"): - h_r = self._post(self.req_url.MODERN_SETTINGS_MENU, {"pmid": "4"}) - self.fb_h = re.search(r'name=\\"h\\" value=\\"(.*?)\\"', h_r.text).group(1) - - data = {"ref": "mb", "h": self.fb_h} - - r = self._get(self.req_url.LOGOUT, data) - - self._resetValues() - - return r.ok - - """ - END LOGIN METHODS - """ - - """ - DEFAULT THREAD METHODS - """ - - def _getThread(self, given_thread_id=None, given_thread_type=None): - """ - Checks if thread ID is given, checks if default is set and returns correct values - - :raises ValueError: If thread ID is not given and there is no default - :return: Thread ID and thread type - :rtype: tuple - """ - if given_thread_id is None: - if self.default_thread_id is not None: - return self.default_thread_id, self.default_thread_type - else: - raise ValueError("Thread ID is not set") - else: - return given_thread_id, given_thread_type - - def setDefaultThread(self, thread_id, thread_type): - """ - Sets default thread to send messages to - - :param thread_id: User/Group ID to default to. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type thread_type: models.ThreadType - """ - self.default_thread_id = thread_id - self.default_thread_type = thread_type - - def resetDefaultThread(self): - """Resets default thread""" - self.setDefaultThread(None, None) - - """ - END DEFAULT THREAD METHODS - """ - - """ - 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 fetchThreads(self, thread_location, before=None, after=None, limit=None): - """ - Get all threads in thread_location. - Threads will be sorted from newest to oldest. - - :param thread_location: models.ThreadLocation: INBOX, PENDING, ARCHIVED or OTHER - :param before: Fetch only thread before this epoch (in ms) (default all threads) - :param after: Fetch only thread after this epoch (in ms) (default all threads) - :param limit: The max. amount of threads to fetch (default all threads) - :return: :class:`models.Thread` objects - :rtype: list - :raises: FBchatException if request failed - """ - threads = [] - - last_thread_timestamp = None - while True: - # break if limit is exceeded - if limit and len(threads) >= limit: - break - - # fetchThreadList returns at max 20 threads before last_thread_timestamp (included) - candidates = self.fetchThreadList( - before=last_thread_timestamp, thread_location=thread_location - ) - - if len(candidates) > 1: - threads += candidates[1:] - else: # End of threads - break - - last_thread_timestamp = threads[-1].last_message_timestamp - - # FB returns a sorted list of threads - if (before is not None and int(last_thread_timestamp) > before) or ( - after is not None and int(last_thread_timestamp) < after - ): - break - - # Return only threads between before and after (if set) - if before is not None or after is not None: - for t in threads: - last_message_timestamp = int(t.last_message_timestamp) - if (before is not None and last_message_timestamp > before) or ( - after is not None and last_message_timestamp < after - ): - threads.remove(t) - - if limit and len(threads) > limit: - return threads[:limit] - - return threads - - def fetchAllUsersFromThreads(self, threads): - """ - Get all users involved in threads. - - :param threads: models.Thread: List of threads to check for users - :return: :class:`models.User` objects - :rtype: list - :raises: FBchatException if request failed - """ - users = [] - users_to_fetch = [] # It's more efficient to fetch all users in one request - for thread in threads: - if thread.type == ThreadType.USER: - if thread.uid not in [user.uid for user in users]: - users.append(thread) - elif thread.type == ThreadType.GROUP: - for user_id in thread.participants: - if ( - user_id not in [user.uid for user in users] - and user_id not in users_to_fetch - ): - users_to_fetch.append(user_id) - else: - pass - for user_id, user in self.fetchUserInfo(*users_to_fetch).items(): - users.append(user) - return users - - def fetchAllUsers(self): - """ - Gets all users the client is currently chatting with - - :return: :class:`models.User` objects - :rtype: list - :raises: FBchatException if request failed - """ - - data = {"viewer": self.uid} - j = self._post( - self.req_url.ALL_USERS, query=data, fix_request=True, as_json=True - ) - if j.get("payload") is None: - raise FBchatException("Missing payload while fetching users: {}".format(j)) - - users = [] - - for key in j["payload"]: - k = j["payload"][key] - if k["type"] in ["user", "friend"]: - if k["id"] in ["0", 0]: - # Skip invalid users - pass - users.append( - User( - k["id"], - first_name=k.get("firstName"), - url=k.get("uri"), - photo=k.get("thumbSrc"), - name=k.get("name"), - is_friend=k.get("is_friend"), - gender=GENDERS.get(k.get("gender")), - ) - ) - - return users - - def searchForUsers(self, name, limit=10): - """ - Find and get user by his/her name - - :param name: Name of the user - :param limit: The max. amount of users to fetch - :return: :class:`models.User` objects, ordered by relevance - :rtype: list - :raises: FBchatException if request failed - """ - - j = self.graphql_request( - GraphQL(query=GraphQL.SEARCH_USER, params={"search": name, "limit": limit}) - ) - - return [graphql_to_user(node) for node in j[name]["users"]["nodes"]] - - def searchForPages(self, name, limit=10): - """ - Find and get page by its name - - :param name: Name of the page - :return: :class:`models.Page` objects, ordered by relevance - :rtype: list - :raises: FBchatException if request failed - """ - - j = self.graphql_request( - GraphQL(query=GraphQL.SEARCH_PAGE, params={"search": name, "limit": limit}) - ) - - return [graphql_to_page(node) for node in j[name]["pages"]["nodes"]] - - def searchForGroups(self, name, limit=10): - """ - Find and get group thread by its name - - :param name: Name of the group thread - :param limit: The max. amount of groups to fetch - :return: :class:`models.Group` objects, ordered by relevance - :rtype: list - :raises: FBchatException if request failed - """ - - j = self.graphql_request( - GraphQL(query=GraphQL.SEARCH_GROUP, params={"search": name, "limit": limit}) - ) - - return [graphql_to_group(node) for node in j["viewer"]["groups"]["nodes"]] - - def searchForThreads(self, name, limit=10): - """ - Find and get a thread by its name - - :param name: Name of the thread - :param limit: The max. amount of groups to fetch - :return: :class:`models.User`, :class:`models.Group` and :class:`models.Page` objects, ordered by relevance - :rtype: list - :raises: FBchatException if request failed - """ - - j = self.graphql_request( - GraphQL( - query=GraphQL.SEARCH_THREAD, params={"search": name, "limit": limit} - ) - ) - - rtn = [] - for node in j[name]["threads"]["nodes"]: - if node["__typename"] == "User": - rtn.append(graphql_to_user(node)) - elif node["__typename"] == "MessageThread": - # MessageThread => Group thread - rtn.append(graphql_to_group(node)) - elif node["__typename"] == "Page": - rtn.append(graphql_to_page(node)) - elif node["__typename"] == "Group": - # We don't handle Facebook "Groups" - pass - else: - log.warning( - "Unknown __typename: {} in {}".format( - repr(node["__typename"]), node - ) - ) - - 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)} - j = self._post(self.req_url.INFO, data, fix_request=True, as_json=True) - - if j.get("payload") is None or j["payload"].get("profiles") is None: - raise FBchatException("No users/pages returned: {}".format(j)) - - entries = {} - for _id in j["payload"]["profiles"]: - k = j["payload"]["profiles"][_id] - if k["type"] in ["user", "friend"]: - entries[_id] = { - "id": _id, - "type": ThreadType.USER, - "url": k.get("uri"), - "first_name": k.get("firstName"), - "is_viewer_friend": k.get("is_friend"), - "gender": k.get("gender"), - "profile_picture": {"uri": k.get("thumbSrc")}, - "name": k.get("name"), - } - elif k["type"] == "page": - entries[_id] = { - "id": _id, - "type": ThreadType.PAGE, - "url": k.get("uri"), - "profile_picture": {"uri": k.get("thumbSrc")}, - "name": k.get("name"), - } - else: - raise FBchatException( - "{} had an unknown thread type: {}".format(_id, k) - ) - - log.debug(entries) - return entries - - def fetchUserInfo(self, *user_ids): - """ - Get users' info from IDs, unordered - - .. warning:: - Sends two requests, to fetch all available info! - - :param user_ids: One or more user ID(s) to query - :return: :class:`models.User` objects, labeled by their ID - :rtype: dict - :raises: FBchatException if request failed - """ - - threads = self.fetchThreadInfo(*user_ids) - users = {} - for k in threads: - if threads[k].type == ThreadType.USER: - users[k] = threads[k] - else: - raise FBchatUserError("Thread {} was not a user".format(threads[k])) - - return users - - def fetchPageInfo(self, *page_ids): - """ - Get pages' info from IDs, unordered - - .. warning:: - Sends two requests, to fetch all available info! - - :param page_ids: One or more page ID(s) to query - :return: :class:`models.Page` objects, labeled by their ID - :rtype: dict - :raises: FBchatException if request failed - """ - - threads = self.fetchThreadInfo(*page_ids) - pages = {} - for k in threads: - if threads[k].type == ThreadType.PAGE: - pages[k] = threads[k] - else: - raise FBchatUserError("Thread {} was not a page".format(threads[k])) - - return pages - - def fetchGroupInfo(self, *group_ids): - """ - Get groups' info from IDs, unordered - - :param group_ids: One or more group ID(s) to query - :return: :class:`models.Group` objects, labeled by their ID - :rtype: dict - :raises: FBchatException if request failed - """ - - threads = self.fetchThreadInfo(*group_ids) - groups = {} - for k in threads: - if threads[k].type == ThreadType.GROUP: - groups[k] = threads[k] - else: - raise FBchatUserError("Thread {} was not a group".format(threads[k])) - - return groups - - def fetchThreadInfo(self, *thread_ids): - """ - Get threads' info from IDs, unordered - - .. warning:: - Sends two requests if users or pages are present, to fetch all available info! - - :param thread_ids: One or more thread ID(s) to query - :return: :class:`models.Thread` objects, labeled by their ID - :rtype: dict - :raises: FBchatException if request failed - """ - - queries = [] - for thread_id in thread_ids: - queries.append( - GraphQL( - doc_id="2147762685294928", - params={ - "id": thread_id, - "message_limit": 0, - "load_messages": False, - "load_read_receipts": False, - "before": None, - }, - ) - ) - - j = self.graphql_requests(*queries) - - for i, entry in enumerate(j): - if entry.get("message_thread") is None: - # If you don't have an existing thread with this person, attempt to retrieve user data anyways - j[i]["message_thread"] = { - "thread_key": {"other_user_id": thread_ids[i]}, - "thread_type": "ONE_TO_ONE", - } - - pages_and_user_ids = [ - k["message_thread"]["thread_key"]["other_user_id"] - for k in j - if k["message_thread"].get("thread_type") == "ONE_TO_ONE" - ] - pages_and_users = {} - if len(pages_and_user_ids) != 0: - pages_and_users = self._fetchInfo(*pages_and_user_ids) - - rtn = {} - for i, entry in enumerate(j): - entry = entry["message_thread"] - if entry.get("thread_type") == "GROUP": - _id = entry["thread_key"]["thread_fbid"] - rtn[_id] = graphql_to_group(entry) - elif entry.get("thread_type") == "ONE_TO_ONE": - _id = entry["thread_key"]["other_user_id"] - if pages_and_users.get(_id) is None: - raise FBchatException("Could not fetch thread {}".format(_id)) - entry.update(pages_and_users[_id]) - if entry["type"] == ThreadType.USER: - rtn[_id] = graphql_to_user(entry) - else: - rtn[_id] = graphql_to_page(entry) - else: - raise FBchatException( - "{} had an unknown thread type: {}".format(thread_ids[i], entry) - ) - - return rtn - - def fetchThreadMessages(self, thread_id=None, limit=20, before=None): - """ - Get the last messages in a thread - - :param thread_id: User/Group ID to get messages from. See :ref:`intro_threads` - :param limit: Max. number of messages to retrieve - :param before: A timestamp, indicating from which point to retrieve messages - :type limit: int - :type before: int - :return: :class:`models.Message` objects - :rtype: list - :raises: FBchatException if request failed - """ - - thread_id, thread_type = self._getThread(thread_id, None) - - j = self.graphql_request( - GraphQL( - doc_id="1386147188135407", - params={ - "id": thread_id, - "message_limit": limit, - "load_messages": True, - "load_read_receipts": True, - "before": before, - }, - ) - ) - - if j.get("message_thread") is None: - raise FBchatException("Could not fetch thread {}: {}".format(thread_id, j)) - - messages = list( - reversed( - [ - graphql_to_message(message) - for message in j["message_thread"]["messages"]["nodes"] - ] - ) - ) - read_receipts = j["message_thread"]["read_receipts"]["nodes"] - - for message in messages: - for receipt in read_receipts: - if int(receipt["watermark"]) >= int(message.timestamp): - message.read_by.append(receipt["actor"]["id"]) - - return messages - - def fetchThreadList( - self, offset=None, limit=20, thread_location=ThreadLocation.INBOX, before=None - ): - """Get thread list of your facebook account - - :param offset: Deprecated. Do not use! - :param limit: Max. number of threads to retrieve. Capped at 20 - :param thread_location: models.ThreadLocation: INBOX, PENDING, ARCHIVED or OTHER - :param before: A timestamp (in milliseconds), indicating from which point to retrieve threads - :type limit: int - :type before: int - :return: :class:`models.Thread` objects - :rtype: list - :raises: FBchatException if request failed - """ - - if offset is not None: - log.warning( - "Using `offset` in `fetchThreadList` is no longer supported, since Facebook migrated to the use of GraphQL in this request. Use `before` instead" - ) - - if limit > 20 or limit < 1: - raise FBchatUserError("`limit` should be between 1 and 20") - - if thread_location in ThreadLocation: - loc_str = thread_location.value - else: - raise FBchatUserError('"thread_location" must be a value of ThreadLocation') - - j = self.graphql_request( - GraphQL( - doc_id="1349387578499440", - params={ - "limit": limit, - "tags": [loc_str], - "before": before, - "includeDeliveryReceipts": True, - "includeSeqID": False, - }, - ) - ) - - return [ - graphql_to_thread(node) for node in j["viewer"]["message_threads"]["nodes"] - ] - - def fetchUnread(self): - """ - Get the unread thread list - - :return: List of unread thread ids - :rtype: list - :raises: FBchatException if request failed - """ - form = { - "folders[0]": "inbox", - "client": "mercury", - "last_action_timestamp": now() - 60 * 1000 - # 'last_action_timestamp': 0 - } - - j = self._post( - self.req_url.UNREAD_THREADS, form, fix_request=True, as_json=True - ) - - payload = j["payload"]["unread_thread_fbids"][0] - - return payload["thread_fbids"] + payload["other_user_fbids"] - - def fetchUnseen(self): - """ - Get the unseen (new) thread list - - :return: List of unseen thread ids - :rtype: list - :raises: FBchatException if request failed - """ - j = self._post( - self.req_url.UNSEEN_THREADS, None, fix_request=True, as_json=True - ) - - payload = j["payload"]["unseen_thread_fbids"][0] - - return payload["thread_fbids"] + payload["other_user_fbids"] - - def fetchImageUrl(self, image_id): - """Fetches the url to the original image from an image attachment ID - - :param image_id: The image you want to fethc - :type image_id: str - :return: An url where you can download the original image - :rtype: str - :raises: FBchatException if request failed - """ - image_id = str(image_id) - j = check_request( - self._get(ReqUrl.ATTACHMENT_PHOTO, query={"photo_id": str(image_id)}) - ) - - url = get_jsmods_require(j, 3) - if url is None: - 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 - - def _getPrivateData(self): - j = self.graphql_request(GraphQL(doc_id="1868889766468115")) - return j["viewer"] - - def getPhoneNumbers(self): - """ - Fetches a list of user phone numbers. - - :return: List of phone numbers - :rtype: list - """ - data = self._getPrivateData() - return [ - j["phone_number"]["universal_number"] for j in data["user"]["all_phones"] - ] - - def getEmails(self): - """ - Fetches a list of user emails. - - :return: List of emails - :rtype: list - """ - data = self._getPrivateData() - return [j["display_email"] for j in data["all_emails"]] - - def getUserActiveStatus(self, user_id): - """ - Gets friend active status as an :class:`models.ActiveStatus` object. - Returns `None` if status isn't known. - - .. warning:: - Only works when listening. - - :param user_id: ID of the user - :return: Given user active status - :rtype: models.ActiveStatus - """ - return self._buddylist.get(str(user_id)) - - """ - END FETCH METHODS - """ - - """ - SEND METHODS - """ - - def _oldMessage(self, message): - return message if isinstance(message, Message) else Message(text=message) - - def _getSendData(self, message=None, thread_id=None, thread_type=ThreadType.USER): - """Returns the data needed to send a request to `SendURL`""" - messageAndOTID = generateOfflineThreadingID() - timestamp = now() - data = { - "client": self.client, - "author": "fbid:" + str(self.uid), - "timestamp": timestamp, - "source": "source:chat:web", - "offline_threading_id": messageAndOTID, - "message_id": messageAndOTID, - "threading_id": generateMessageID(self.client_id), - "ephemeral_ttl_mode:": "0", - } - - # Set recipient - if thread_type in [ThreadType.USER, ThreadType.PAGE]: - data["other_user_fbid"] = thread_id - elif thread_type == ThreadType.GROUP: - data["thread_fbid"] = thread_id - - if message is None: - message = Message() - - if message.text or message.sticker or message.emoji_size: - data["action_type"] = "ma-type:user-generated-message" - - if message.text: - data["body"] = message.text - - for i, mention in enumerate(message.mentions): - data["profile_xmd[{}][id]".format(i)] = mention.thread_id - data["profile_xmd[{}][offset]".format(i)] = mention.offset - data["profile_xmd[{}][length]".format(i)] = mention.length - data["profile_xmd[{}][type]".format(i)] = "p" - - if message.emoji_size: - if message.text: - data["tags[0]"] = "hot_emoji_size:" + message.emoji_size.name.lower() - else: - data["sticker_id"] = message.emoji_size.value - - if message.sticker: - data["sticker_id"] = message.sticker.uid - - if message.quick_replies: - xmd = {"quick_replies": []} - for quick_reply in message.quick_replies: - q = dict() - q["content_type"] = quick_reply._type - q["payload"] = quick_reply.payload - q["external_payload"] = quick_reply.external_payload - q["data"] = quick_reply.data - if quick_reply.is_response: - q["ignore_for_webhook"] = False - if isinstance(quick_reply, QuickReplyText): - q["title"] = quick_reply.title - if not isinstance(quick_reply, QuickReplyLocation): - q["image_url"] = quick_reply.image_url - xmd["quick_replies"].append(q) - if len(message.quick_replies) == 1 and message.quick_replies[0].is_response: - xmd["quick_replies"] = xmd["quick_replies"][0] - data["platform_xmd"] = json.dumps(xmd) - - return 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) - - # 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 - - 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): - """ - Sends a message to a thread - - :param message: Message to send - :param thread_id: User/Group ID to send to. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type message: models.Message - :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( - message=message, thread_id=thread_id, thread_type=thread_type - ) - - return self._doSendRequest(data) - - def sendMessage(self, message, thread_id=None, thread_type=ThreadType.USER): - """ - Deprecated. Use :func:`fbchat.Client.send` instead - """ - return self.send( - Message(text=message), thread_id=thread_id, thread_type=thread_type - ) - - def sendEmoji( - self, - emoji=None, - size=EmojiSize.SMALL, - thread_id=None, - thread_type=ThreadType.USER, - ): - """ - Deprecated. Use :func:`fbchat.Client.send` instead - """ - return self.send( - Message(text=emoji, emoji_size=size), - thread_id=thread_id, - thread_type=thread_type, - ) - - def wave(self, wave_first=True, thread_id=None, thread_type=None): - """ - 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 quickReply(self, quick_reply, payload=None, thread_id=None, thread_type=None): - """ - Replies to a chosen quick reply - - :param quick_reply: Quick reply to reply to - :param payload: Optional answer to the quick reply - :param thread_id: User/Group ID to send to. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type quick_reply: models.QuickReply - :type thread_type: models.ThreadType - :return: :ref:`Message ID ` of the sent message - :raises: FBchatException if request failed - """ - quick_reply.is_response = True - if isinstance(quick_reply, QuickReplyText): - return self.send( - Message(text=quick_reply.title, quick_replies=[quick_reply]) - ) - elif isinstance(quick_reply, QuickReplyLocation): - if not isinstance(payload, LocationAttachment): - raise ValueError( - "Payload must be an instance of `fbchat.models.LocationAttachment`" - ) - return self.sendLocation( - payload, thread_id=thread_id, thread_type=thread_type - ) - elif isinstance(quick_reply, QuickReplyEmail): - if not payload: - payload = self.getEmails()[0] - quick_reply.external_payload = quick_reply.payload - quick_reply.payload = payload - return self.send(Message(text=payload, quick_replies=[quick_reply])) - elif isinstance(quick_reply, QuickReplyPhoneNumber): - if not payload: - payload = self.getPhoneNumbers()[0] - quick_reply.external_payload = quick_reply.payload - quick_reply.payload = payload - return self.send(Message(text=payload, quick_replies=[quick_reply])) - - def unsend(self, mid): - """ - Unsends a message (removes for everyone) - - :param mid: :ref:`Message ID ` of the message to unsend - """ - data = {"message_id": mid} - r = self._post(self.req_url.UNSEND, data) - r.raise_for_status() - - def _sendLocation(self, location, current=True, thread_id=None, thread_type=None): - 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["location_attachment[coordinates][latitude]"] = location.latitude - data["location_attachment[coordinates][longitude]"] = location.longitude - data["location_attachment[is_current_location]"] = current - return self._doSendRequest(data) - - def sendLocation(self, location, thread_id=None, thread_type=None): - """ - Sends a given location to a thread as the user's current location - - :param location: Location to send - :param thread_id: User/Group ID to send to. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type location: models.LocationAttachment - :type thread_type: models.ThreadType - :return: :ref:`Message ID ` of the sent message - :raises: FBchatException if request failed - """ - self._sendLocation( - location=location, - current=True, - thread_id=thread_id, - thread_type=thread_type, - ) - - def sendPinnedLocation(self, location, thread_id=None, thread_type=None): - """ - Sends a given location to a thread as a pinned location - - :param location: Location to send - :param thread_id: User/Group ID to send to. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type location: models.LocationAttachment - :type thread_type: models.ThreadType - :return: :ref:`Message ID ` of the sent message - :raises: FBchatException if request failed - """ - self._sendLocation( - location=location, - current=False, - thread_id=thread_id, - thread_type=thread_type, - ) - - def _upload(self, files, voice_clip=False): - """ - 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)} - - data = {"voice_clip": voice_clip} - - j = self._postFile( - self.req_url.UPLOAD, - files=file_dict, - query=data, - 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, - ) - - data["action_type"] = "ma-type:user-generated-message" - data["has_attachment"] = True - - for i, (file_id, mimetype) in enumerate(files): - data["{}s[{}]".format(mimetype_to_key(mimetype), i)] = file_id - - return self._doSendRequest(data) - - def sendRemoteFiles( - self, file_urls, message=None, thread_id=None, thread_type=ThreadType.USER - ): - """ - Sends files from URLs to a thread - - :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 files - :raises: FBchatException if request failed - """ - 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_paths: 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 sendRemoteVoiceClips( - self, clip_urls, message=None, thread_id=None, thread_type=ThreadType.USER - ): - """ - Sends voice clips from URLs to a thread - - :param clip_urls: URLs of clips 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 - """ - clip_urls = require_list(clip_urls) - files = self._upload(get_files_from_urls(clip_urls), voice_clip=True) - return self._sendFiles( - files=files, message=message, thread_id=thread_id, thread_type=thread_type - ) - - def sendLocalVoiceClips( - self, clip_paths, message=None, thread_id=None, thread_type=ThreadType.USER - ): - """ - Sends local voice clips to a thread - - :param clip_paths: Paths of clips 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 - """ - clip_paths = require_list(clip_paths) - with get_files_from_paths(clip_paths) as x: - files = self._upload(x, voice_clip=True) - 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 - ): - """ - Deprecated. Use :func:`fbchat.Client.sendLocalFiles` instead - """ - return self.sendLocalFiles( - file_paths=[image_path], - message=message, - thread_id=thread_id, - thread_type=thread_type, - ) - - 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 - """ - 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. - - :param user_ids: One or more user IDs to add - :param thread_id: Group ID to add people to. See :ref:`intro_threads` - :type user_ids: list - :raises: FBchatException if request failed - """ - thread_id, thread_type = self._getThread(thread_id, None) - data = self._getSendData(thread_id=thread_id, thread_type=ThreadType.GROUP) - - data["action_type"] = "ma-type:log-message" - data["log_message_type"] = "log:subscribe" - - user_ids = require_list(user_ids) - - for i, user_id in enumerate(user_ids): - if user_id == self.uid: - raise FBchatUserError( - "Error when adding users: Cannot add self to group thread" - ) - else: - data[ - "log_message_data[added_participants][" + str(i) + "]" - ] = "fbid:" + str(user_id) - - return self._doSendRequest(data) - - def removeUserFromGroup(self, user_id, thread_id=None): - """ - Removes users from a group. - - :param user_id: User ID to remove - :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 = {"uid": user_id, "tid": thread_id} - - j = self._post(self.req_url.REMOVE_USER, data, fix_request=True, as_json=True) - - 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` - :raises: FBchatException if request failed - """ - - 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` - :raises: FBchatException if request failed - """ - - (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` - :raises: FBchatException if request failed - """ - - 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): - """ - Changes title of a thread. - If this is executed on a user thread, this will change the nickname of that user, effectively changing the title - - :param title: New group thread title - :param thread_id: Group ID to change title of. 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.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 - ) - - 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 - ): - """ - Changes the nickname of a user in a thread - - :param nickname: New nickname - :param user_id: User that will have their nickname changed - :param thread_id: User/Group ID to change color of. 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) - - data = { - "nickname": nickname, - "participant_id": user_id, - "thread_or_other_fbid": thread_id, - } - - j = self._post( - self.req_url.THREAD_NICKNAME, data, fix_request=True, as_json=True - ) - - def changeThreadColor(self, color, thread_id=None): - """ - Changes thread color - - :param color: New thread color - :param thread_id: User/Group ID to change color of. See :ref:`intro_threads` - :type color: models.ThreadColor - :raises: FBchatException if request failed - """ - thread_id, thread_type = self._getThread(thread_id, None) - - data = { - "color_choice": color.value if color != ThreadColor.MESSENGER_BLUE else "", - "thread_or_other_fbid": thread_id, - } - - j = self._post(self.req_url.THREAD_COLOR, data, fix_request=True, as_json=True) - - def changeThreadEmoji(self, emoji, thread_id=None): - """ - Changes thread color - - Trivia: While changing the emoji, the Facebook web client actually sends multiple different requests, though only this one is required to make the change - - :param color: New thread emoji - :param thread_id: User/Group ID to change emoji of. See :ref:`intro_threads` - :raises: FBchatException if request failed - """ - thread_id, thread_type = self._getThread(thread_id, None) - - data = {"emoji_choice": emoji, "thread_or_other_fbid": thread_id} - - j = self._post(self.req_url.THREAD_EMOJI, data, fix_request=True, as_json=True) - - def reactToMessage(self, message_id, reaction): - """ - Reacts to a message, or removes reaction - - :param message_id: :ref:`Message ID ` to react to - :param reaction: Reaction emoji to use, if None removes reaction - :type reaction: models.MessageReaction or None - :raises: FBchatException if request failed - """ - data = { - "doc_id": 1491398900900362, - "variables": json.dumps( - { - "data": { - "action": "ADD_REACTION" if reaction else "REMOVE_REACTION", - "client_mutation_id": "1", - "actor_id": self.uid, - "message_id": str(message_id), - "reaction": reaction.value if reaction else None, - } - } - ), - } - self._post(self.req_url.MESSAGE_REACTION, data, fix_request=True, as_json=True) - - def createPlan(self, plan, thread_id=None): - """ - Sets a plan - - :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", - "event_time": plan.time, - "title": plan.title, - "thread_id": thread_id, - "location_id": plan.location_id or "", - "location_name": plan.location or "", - "acontext": { - "action_history": [ - {"surface": "messenger_chat_tab", "mechanism": "messenger_composer"} - ] - }, - } - - 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): - """ - Sets users typing status in a thread - - :param status: Specify the typing status - :param thread_id: User/Group ID to change status in. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type status: models.TypingStatus - :type thread_type: models.ThreadType - :raises: FBchatException if request failed - """ - thread_id, thread_type = self._getThread(thread_id, thread_type) - - data = { - "typ": status.value, - "thread": thread_id, - "to": thread_id if thread_type == ThreadType.USER else "", - "source": "mercury-chat", - } - - j = self._post(self.req_url.TYPING, data, fix_request=True, as_json=True) - - """ - END SEND METHODS - """ - - def markAsDelivered(self, thread_id, message_id): - """ - Mark a message as delivered - - :param thread_id: User/Group ID to which the message belongs. See :ref:`intro_threads` - :param message_id: Message ID to set as delivered. See :ref:`intro_threads` - :return: Whether the request was successful - :raises: FBchatException if request failed - """ - data = { - "message_ids[0]": message_id, - "thread_ids[%s][0]" % thread_id: message_id, - } - - r = self._post(self.req_url.DELIVERED, data) - return r.ok - - def _readStatus(self, read, thread_ids): - thread_ids = require_list(thread_ids) - - data = {"watermarkTimestamp": now(), "shouldSendReadReceipt": "true"} - - for thread_id in thread_ids: - data["ids[{}]".format(thread_id)] = "true" if read else "false" - - 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:: - Documenting this - """ - r = self._post(self.req_url.MARK_SEEN, {"seen_timestamp": now()}) - return r.ok - - def friendConnect(self, friend_id): - """ - .. todo:: - Documenting this - """ - data = {"to_friend": friend_id, "action": "confirm"} - - r = self._post(self.req_url.CONNECT, data) - return r.ok - - def removeFriend(self, friend_id=None): - """ - Removes a specifed friend from your friend list - - :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 = {"friend_id": friend_id, "unref": "none", "confirm": "Confirm"} - r = self._post(self.req_url.REMOVE_FRIEND, payload) - query = parse_qs(urlparse(r.url).query) - if "err" not in query: - log.debug("Remove was successful!") - return True - else: - 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 - """ - - def _ping(self): - data = { - "channel": self.user_channel, - "clientid": self.client_id, - "partition": -2, - "cap": 0, - "uid": self.uid, - "sticky_token": self.sticky, - "sticky_pool": self.pool, - "viewer_uid": self.uid, - "state": "active", - } - self._get(self.req_url.PING, data, fix_request=True, as_json=False) - - def _pullMessage(self): - """Call pull api with seq value to get message data.""" - - data = { - "msgs_recv": 0, - "sticky_token": self.sticky, - "sticky_pool": self.pool, - "clientid": self.client_id, - "state": "active" if self._markAlive else "offline", - } - - j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True) - - self.seq = j.get("seq", "0") - return j - - def _parseDelta(self, m): - def getThreadIdAndThreadType(msg_metadata): - """Returns a tuple consisting of thread ID and thread type""" - id_thread = None - type_thread = None - if "threadFbId" in msg_metadata["threadKey"]: - id_thread = str(msg_metadata["threadKey"]["threadFbId"]) - type_thread = ThreadType.GROUP - elif "otherUserFbId" in msg_metadata["threadKey"]: - id_thread = str(msg_metadata["threadKey"]["otherUserFbId"]) - type_thread = ThreadType.USER - return id_thread, type_thread - - delta = m["delta"] - delta_type = delta.get("type") - delta_class = delta.get("class") - metadata = delta.get("messageMetadata") - - if metadata: - mid = metadata["messageId"] - author_id = str(metadata["actorFbId"]) - ts = int(metadata.get("timestamp")) - - # Added participants - if "addedParticipants" in delta: - added_ids = [str(x["userFbId"]) for x in delta["addedParticipants"]] - thread_id = str(metadata["threadKey"]["threadFbId"]) - self.onPeopleAdded( - mid=mid, - added_ids=added_ids, - author_id=author_id, - thread_id=thread_id, - ts=ts, - msg=m, - ) - - # Left/removed participants - elif "leftParticipantFbId" in delta: - removed_id = str(delta["leftParticipantFbId"]) - thread_id = str(metadata["threadKey"]["threadFbId"]) - self.onPersonRemoved( - mid=mid, - removed_id=removed_id, - author_id=author_id, - thread_id=thread_id, - ts=ts, - msg=m, - ) - - # Color change - elif delta_type == "change_thread_theme": - new_color = graphql_color_to_enum(delta["untypedData"]["theme_color"]) - thread_id, thread_type = getThreadIdAndThreadType(metadata) - self.onColorChange( - mid=mid, - author_id=author_id, - new_color=new_color, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - metadata=metadata, - msg=m, - ) - - # Emoji change - elif delta_type == "change_thread_icon": - new_emoji = delta["untypedData"]["thread_icon"] - thread_id, thread_type = getThreadIdAndThreadType(metadata) - self.onEmojiChange( - mid=mid, - author_id=author_id, - new_emoji=new_emoji, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - metadata=metadata, - msg=m, - ) - - # Thread title change - elif delta_class == "ThreadName": - new_title = delta["name"] - thread_id, thread_type = getThreadIdAndThreadType(metadata) - 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_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, - msg=m, - ) - - # Nickname change - elif delta_type == "change_thread_nickname": - changed_for = str(delta["untypedData"]["participant_id"]) - new_nickname = delta["untypedData"]["nickname"] - thread_id, thread_type = getThreadIdAndThreadType(metadata) - self.onNicknameChange( - mid=mid, - author_id=author_id, - changed_for=changed_for, - 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_class == "DeliveryReceipt": - message_ids = delta["messageIds"] - delivered_for = str( - delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"] - ) - 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, - ) - - # Message seen - elif delta_class == "ReadReceipt": - seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"]) - seen_ts = int(delta["actionTimestampMs"]) - delivered_ts = int(delta["watermarkTimestampMs"]) - thread_id, thread_type = getThreadIdAndThreadType(delta) - self.onMessageSeen( - seen_by=seen_by, - thread_id=thread_id, - thread_type=thread_type, - seen_ts=seen_ts, - ts=delivered_ts, - metadata=metadata, - msg=m, - ) - - # Messages marked as seen - elif delta_class == "MarkRead": - seen_ts = int( - delta.get("actionTimestampMs") or delta.get("actionTimestamp") - ) - delivered_ts = int( - delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp") - ) - - threads = [] - if "folders" not in delta: - threads = [ - getThreadIdAndThreadType({"threadKey": thr}) - for thr in delta.get("threadKeys") - ] - - # 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, - ) - - # Client payload (that weird numbers) - elif delta_class == "ClientPayload": - payload = json.loads("".join(chr(z) for z in delta["payload"])) - ts = m.get("ofd_ts") - for d in payload.get("deltas", []): - - # Message reaction - if d.get("deltaMessageReaction"): - i = d["deltaMessageReaction"] - thread_id, thread_type = getThreadIdAndThreadType(i) - mid = i["messageId"] - author_id = str(i["userId"]) - reaction = ( - MessageReaction(i["reaction"]) if i.get("reaction") else None - ) - add_reaction = not bool(i["action"]) - if add_reaction: - self.onReactionAdded( - mid=mid, - reaction=reaction, - author_id=author_id, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - msg=m, - ) - else: - self.onReactionRemoved( - mid=mid, - author_id=author_id, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - msg=m, - ) - - # Viewer status change - elif d.get("deltaChangeViewerStatus"): - i = d["deltaChangeViewerStatus"] - thread_id, thread_type = getThreadIdAndThreadType(i) - author_id = str(i["actorFbid"]) - reason = i["reason"] - can_reply = i["canViewerReply"] - if reason == 2: - if can_reply: - self.onUnblock( - author_id=author_id, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - msg=m, - ) - else: - self.onBlock( - author_id=author_id, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - msg=m, - ) - - # Live location info - elif d.get("liveLocationData"): - i = d["liveLocationData"] - thread_id, thread_type = getThreadIdAndThreadType(i) - for l in i["messageLiveLocations"]: - mid = l["messageId"] - author_id = str(l["senderId"]) - location = graphql_to_live_location(l) - self.onLiveLocation( - mid=mid, - location=location, - author_id=author_id, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - msg=m, - ) - - # Message deletion - elif d.get("deltaRecallMessageData"): - i = d["deltaRecallMessageData"] - thread_id, thread_type = getThreadIdAndThreadType(i) - mid = i["messageID"] - ts = i["deletionTimestamp"] - author_id = str(i["senderID"]) - self.onMessageUnsent( - mid=mid, - author_id=author_id, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - msg=m, - ) - - # New message - elif delta.get("class") == "NewMessage": - mentions = [] - if delta.get("data") and delta["data"].get("prng"): - try: - mentions = [ - Mention( - str(mention.get("i")), - offset=mention.get("o"), - length=mention.get("l"), - ) - for mention in parse_json(delta["data"]["prng"]) - ] - except Exception: - log.exception("An exception occured while reading attachments") - - sticker = None - attachments = [] - unsent = False - if delta.get("attachments"): - try: - for a in delta["attachments"]: - mercury = a["mercury"] - if mercury.get("blob_attachment"): - image_metadata = a.get("imageMetadata", {}) - attach_type = mercury["blob_attachment"]["__typename"] - attachment = graphql_to_attachment( - mercury["blob_attachment"] - ) - - if attach_type in [ - "MessageFile", - "MessageVideo", - "MessageAudio", - ]: - # TODO: Add more data here for audio files - attachment.size = int(a["fileSize"]) - attachments.append(attachment) - - elif mercury.get("sticker_attachment"): - sticker = graphql_to_sticker(mercury["sticker_attachment"]) - - elif mercury.get("extensible_attachment"): - attachment = graphql_to_extensible_attachment( - mercury["extensible_attachment"] - ) - if isinstance(attachment, UnsentMessage): - unsent = True - elif attachment: - attachments.append(attachment) - - except Exception: - log.exception( - "An exception occured while reading attachments: {}".format( - delta["attachments"] - ) - ) - - if metadata and metadata.get("tags"): - emoji_size = get_emojisize_from_tags(metadata.get("tags")) - - message = Message( - text=delta.get("body"), - mentions=mentions, - emoji_size=emoji_size, - sticker=sticker, - attachments=attachments, - ) - message.uid = mid - message.author = author_id - message.timestamp = ts - # message.reactions = {} - message.unsent = unsent - thread_id, thread_type = getThreadIdAndThreadType(metadata) - self.onMessage( - mid=mid, - author_id=author_id, - message=delta.get("body", ""), - message_object=message, - thread_id=thread_id, - thread_type=thread_type, - ts=ts, - metadata=metadata, - msg=m, - ) - - # Unknown message type - else: - self.onUnknownMesssageType(msg=m) - - def _parseMessage(self, content): - """Get message and author name from content. May contain multiple messages in the content.""" - - if "lb_info" in content: - self.sticky = content["lb_info"]["sticky"] - self.pool = content["lb_info"]["pool"] - - if "batches" in content: - for batch in content["batches"]: - self._parseMessage(batch) - - if "ms" not in content: - return - - for m in content["ms"]: - mtype = m.get("type") - try: - # Things that directly change chat - if mtype == "delta": - self._parseDelta(m) - # Inbox - elif mtype == "inbox": - self.onInbox( - unseen=m["unseen"], - unread=m["unread"], - recent_unread=m["recent_unread"], - msg=m, - ) - - # Typing - elif mtype == "typ" or mtype == "ttyp": - author_id = str(m.get("from")) - thread_id = m.get("thread_fbid") - if thread_id: - thread_type = ThreadType.GROUP - thread_id = str(thread_id) - else: - thread_type = ThreadType.USER - if author_id == self.uid: - thread_id = m.get("to") - else: - thread_id = author_id - typing_status = TypingStatus(m.get("st")) - self.onTyping( - author_id=author_id, - status=typing_status, - thread_id=thread_id, - thread_type=thread_type, - msg=m, - ) - - # Delivered - - # Seen - # elif mtype == "m_read_receipt": - # - # self.onSeen(m.get('realtime_viewer_fbid'), m.get('reader'), m.get('time')) - - elif mtype in ["jewel_requests_add"]: - from_id = m["from"] - self.onFriendRequest(from_id=from_id, msg=m) - - # Happens on every login - elif mtype == "qprimer": - self.onQprimer(ts=m.get("made"), msg=m) - - # Is sent before any other message - elif mtype == "deltaflow": - pass - - # Chat timestamp - elif mtype == "chatproxy-presence": - buddylist = dict() - for _id in m.get("buddyList", {}): - payload = m["buddyList"][_id] - - last_active = payload.get("lat") - active = payload.get("p") in [2, 3] - in_game = int(_id) in m.get("gamers", {}) - - buddylist[_id] = last_active - - if self._buddylist.get(_id): - self._buddylist[_id].last_active = last_active - self._buddylist[_id].active = active - self._buddylist[_id].in_game = in_game - else: - self._buddylist[_id] = ActiveStatus( - active=active, last_active=last_active, in_game=in_game - ) - - self.onChatTimestamp(buddylist=buddylist, msg=m) - - # Buddylist overlay - elif mtype == "buddylist_overlay": - statuses = dict() - for _id in m.get("overlay", {}): - payload = m["overlay"][_id] - - last_active = payload.get("la") - active = payload.get("a") in [2, 3] - in_game = ( - self._buddylist[_id].in_game - if self._buddylist.get(_id) - else False - ) - - status = ActiveStatus( - active=active, last_active=last_active, in_game=in_game - ) - - if self._buddylist.get(_id): - self._buddylist[_id].last_active = last_active - self._buddylist[_id].active = active - self._buddylist[_id].in_game = in_game - else: - self._buddylist[_id] = status - - statuses[_id] = status - - self.onBuddylistOverlay(statuses=statuses, msg=m) - - # Unknown message type - else: - self.onUnknownMesssageType(msg=m) - - except Exception as e: - self.onMessageError(exception=e, msg=m) - - def startListening(self): - """ - Start listening from an external event loop - - :raises: FBchatException if request failed - """ - self.listening = True - - def doOneListen(self, markAlive=None): - """ - Does one cycle of the listening loop. - This method is useful if you want to control fbchat from an external event loop - - .. warning:: - `markAlive` parameter is deprecated now, use :func:`fbchat.Client.setActiveStatus` - or `markAlive` parameter in :func:`fbchat.Client.listen` instead. - - :return: Whether the loop should keep running - :rtype: bool - """ - if markAlive is not None: - self._markAlive = markAlive - try: - if self._markAlive: - self._ping() - content = self._pullMessage() - if content: - self._parseMessage(content) - except KeyboardInterrupt: - return False - except requests.Timeout: - pass - except requests.ConnectionError: - # If the client has lost their internet connection, keep trying every 30 seconds - time.sleep(30) - except FBchatFacebookError as e: - # Fix 502 and 503 pull errors - if e.request_status_code in [502, 503]: - self.req_url.change_pull_channel() - self.startListening() - else: - raise e - except Exception as e: - return self.onListenError(exception=e) - - return True - - def stopListening(self): - """Cleans up the variables from startListening""" - self.listening = False - self.sticky, self.pool = (None, None) - - def listen(self, markAlive=None): - """ - Initializes and runs the listening loop continually - - :param markAlive: Whether this should ping the Facebook server each time the loop runs - :type markAlive: bool - """ - if markAlive is not None: - self.setActiveStatus(markAlive) - - self.startListening() - self.onListening() - - while self.listening and self.doOneListen(): - pass - - self.stopListening() - - def setActiveStatus(self, markAlive): - """ - Changes client active status while listening - - :param markAlive: Whether to show if client is active - :type markAlive: bool - """ - self._markAlive = markAlive - - """ - END LISTEN METHODS - """ - - """ - EVENTS - """ - - def onLoggingIn(self, email=None): - """ - Called when the client is logging in - - :param email: The email of the client - """ - log.info("Logging in {}...".format(email)) - - def on2FACode(self): - """Called when a 2FA code is needed to progress""" - return input("Please enter your 2FA code --> ") - - def onLoggedIn(self, email=None): - """ - Called when the client is successfully logged in - - :param email: The email of the client - """ - log.info("Login of {} successful.".format(email)) - - def onListening(self): - """Called when the client is listening""" - log.info("Listening...") - - def onListenError(self, exception=None): - """ - Called when an error was encountered while listening - - :param exception: The exception that was encountered - :return: Whether the loop should keep running - """ - log.exception("Got exception while listening") - return True - - def onMessage( - self, - mid=None, - author_id=None, - message=None, - message_object=None, - thread_id=None, - thread_type=ThreadType.USER, - ts=None, - metadata=None, - msg=None, - ): - """ - Called when the client is listening, and somebody sends a message - - :param mid: The message ID - :param author_id: The ID of the author - :param message: (deprecated. Use `message_object.text` instead) - :param message_object: The message (As a `Message` object) - :param thread_id: Thread ID that the message was sent to. See :ref:`intro_threads` - :param thread_type: Type of thread that the message was sent to. See :ref:`intro_threads` - :param ts: The timestamp of the message - :param metadata: Extra metadata about the message - :param msg: A full set of the data recieved - :type message_object: models.Message - :type thread_type: models.ThreadType - """ - log.info("{} from {} in {}".format(message_object, thread_id, thread_type.name)) - - def onColorChange( - self, - mid=None, - author_id=None, - new_color=None, - thread_id=None, - thread_type=ThreadType.USER, - ts=None, - metadata=None, - msg=None, - ): - """ - Called when the client is listening, and somebody changes a thread's color - - :param mid: The action ID - :param author_id: The ID of the person who changed the color - :param new_color: The new color - :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 new_color: models.ThreadColor - :type thread_type: models.ThreadType - """ - log.info( - "Color change from {} in {} ({}): {}".format( - author_id, thread_id, thread_type.name, new_color - ) - ) - - def onEmojiChange( - self, - mid=None, - author_id=None, - new_emoji=None, - thread_id=None, - thread_type=ThreadType.USER, - ts=None, - metadata=None, - msg=None, - ): - """ - Called when the client is listening, and somebody changes a thread's emoji - - :param mid: The action ID - :param author_id: The ID of the person who changed the emoji - :param new_emoji: The new emoji - :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( - "Emoji change from {} in {} ({}): {}".format( - author_id, thread_id, thread_type.name, new_emoji - ) - ) - - def onTitleChange( - self, - mid=None, - author_id=None, - new_title=None, - thread_id=None, - thread_type=ThreadType.USER, - ts=None, - metadata=None, - msg=None, - ): - """ - Called when the client is listening, and somebody changes the title of a thread - - :param mid: The action ID - :param author_id: The ID of the person who changed the title - :param new_title: The new title - :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( - "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, - msg=None, - ): - """ - Called when the client is listening, and somebody changes the image of a thread - - :param mid: The action ID - :param author_id: The ID of the person who changed the image - :param new_image: The ID of the new image - :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 msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - 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 - - :param mid: The action ID - :param author_id: The ID of the person who changed the nickname - :param changed_for: The ID of the person whom got their nickname changed - :param new_nickname: The new nickname - :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( - "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 - - :param seen_by: The ID of the person who marked the message as seen - :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 seen_ts: A timestamp of when the person saw the message - :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( - "Messages seen by {} in {} ({}) at {}s".format( - seen_by, thread_id, thread_type.name, seen_ts / 1000 - ) - ) - - def onMessageDelivered( - self, - msg_ids=None, - delivered_for=None, - thread_id=None, - thread_type=ThreadType.USER, - ts=None, - metadata=None, - msg=None, - ): - """ - Called when the client is listening, and somebody marks messages as delivered - - :param msg_ids: The messages that are marked as delivered - :param delivered_for: The person that marked the messages as delivered - :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( - "Messages {} delivered to {} in {} ({}) at {}s".format( - msg_ids, delivered_for, thread_id, thread_type.name, ts / 1000 - ) - ) - - def onMarkedSeen( - self, threads=None, seen_ts=None, ts=None, metadata=None, msg=None - ): - """ - Called when the client is listening, and the client has successfully marked threads as seen - - :param threads: The threads that were marked - :param author_id: The ID of the person who changed the emoji - :param seen_ts: A timestamp of when the threads were seen - :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( - "Marked messages as seen in threads {} at {}s".format( - [(x[0], x[1].name) for x in threads], seen_ts / 1000 - ) - ) - - def onMessageUnsent( - self, - mid=None, - author_id=None, - thread_id=None, - thread_type=None, - ts=None, - msg=None, - ): - """ - Called when the client is listening, and someone unsends (deletes for everyone) a message - - :param mid: ID of the unsent message - :param author_id: The ID of the person who unsent the message - :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 msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - log.info( - "{} unsent the message {} in {} ({}) at {}s".format( - author_id, repr(mid), thread_id, thread_type.name, ts / 1000 - ) - ) - - def onPeopleAdded( - self, - mid=None, - added_ids=None, - author_id=None, - thread_id=None, - ts=None, - msg=None, - ): - """ - Called when the client is listening, and somebody adds people to a group thread - - :param mid: The action ID - :param added_ids: The IDs of the people who got added - :param author_id: The ID of the person who added the people - :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: {} 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, - ): - """ - Called when the client is listening, and somebody removes a person from a group thread - - :param mid: The action ID - :param removed_id: The ID of the person who got removed - :param author_id: The ID of the person who removed the person - :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: {} in {}".format(author_id, removed_id, thread_id)) - - def onFriendRequest(self, from_id=None, msg=None): - """ - Called when the client is listening, and somebody sends a friend request - - :param from_id: The ID of the person that sent the request - :param msg: A full set of the data recieved - """ - log.info("Friend request from {}".format(from_id)) - - def onInbox(self, unseen=None, unread=None, recent_unread=None, msg=None): - """ - .. todo:: - Documenting this - - :param unseen: -- - :param unread: -- - :param recent_unread: -- - :param msg: A full set of the data recieved - """ - log.info("Inbox event: {}, {}, {}".format(unseen, unread, recent_unread)) - - def onTyping( - self, author_id=None, status=None, thread_id=None, thread_type=None, msg=None - ): - """ - Called when the client is listening, and somebody starts or stops typing into a chat - - :param author_id: The ID of the person who sent the action - :param status: The typing status - :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 msg: A full set of the data recieved - :type typing_status: models.TypingStatus - :type thread_type: models.ThreadType - """ - 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 onReactionAdded( - self, - mid=None, - reaction=None, - author_id=None, - thread_id=None, - thread_type=None, - ts=None, - msg=None, - ): - """ - Called when the client is listening, and somebody reacts to a message - - :param mid: Message ID, that user reacted to - :param reaction: Reaction - :param add_reaction: Whether user added or removed reaction - :param author_id: The ID of the person who reacted to the message - :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 msg: A full set of the data recieved - :type reaction: models.MessageReaction - :type thread_type: models.ThreadType - """ - log.info( - "{} reacted to message {} with {} in {} ({})".format( - author_id, mid, reaction.name, thread_id, thread_type.name - ) - ) - - def onReactionRemoved( - self, - mid=None, - author_id=None, - thread_id=None, - thread_type=None, - ts=None, - msg=None, - ): - """ - Called when the client is listening, and somebody removes reaction from a message - - :param mid: Message ID, that user reacted to - :param author_id: The ID of the person who removed reaction - :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 msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - log.info( - "{} removed reaction from {} message in {} ({})".format( - author_id, mid, thread_id, thread_type - ) - ) - - def onBlock( - self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None - ): - """ - Called when the client is listening, and somebody blocks client - - :param author_id: The ID of the person who blocked - :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 msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - log.info( - "{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name) - ) - - def onUnblock( - self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None - ): - """ - Called when the client is listening, and somebody blocks client - - :param author_id: The ID of the person who unblocked - :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 msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - log.info( - "{} unblocked {} ({}) thread".format(author_id, thread_id, thread_type.name) - ) - - def onLiveLocation( - self, - mid=None, - location=None, - author_id=None, - thread_id=None, - thread_type=None, - ts=None, - msg=None, - ): - """ - Called when the client is listening and somebody sends live location info - - :param mid: The action ID - :param location: Sent location info - :param author_id: The ID of the person who sent location info - :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 msg: A full set of the data recieved - :type location: models.LiveLocationAttachment - :type thread_type: models.ThreadType - """ - log.info( - "{} sent live location info in {} ({}) with latitude {} and longitude {}".format( - author_id, thread_id, thread_type, location.latitude, location.longitude - ) - ) - - 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 take_part: bool - :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 - ) - ) - - def onQprimer(self, ts=None, msg=None): - """ - Called when the client just started listening - - :param ts: A timestamp of the action - :param msg: A full set of the data recieved - """ - pass - - def onChatTimestamp(self, buddylist=None, msg=None): - """ - Called when the client receives chat online presence update - - :param buddylist: A list of dicts with friend id and last seen timestamp - :param msg: A full set of the data recieved - """ - log.debug("Chat Timestamps received: {}".format(buddylist)) - - def onBuddylistOverlay(self, statuses=None, msg=None): - """ - Called when the client is listening and client receives information about friend active status - - :param statuses: Dictionary with user IDs as keys and :class:`models.ActiveStatus` as values - :param msg: A full set of the data recieved - :type statuses: dict - """ - log.debug("Buddylist overlay received: {}".format(statuses)) - - def onUnknownMesssageType(self, msg=None): - """ - Called when the client is listening, and some unknown data was recieved - - :param msg: A full set of the data recieved - """ - log.debug("Unknown message received: {}".format(msg)) - - def onMessageError(self, exception=None, msg=None): - """ - Called when an error was encountered while parsing recieved data - - :param exception: The exception that was encountered - :param msg: A full set of the data recieved - """ - log.exception("Exception in parsing of {}".format(msg)) - - """ - END EVENTS - """ +from ._client import Client diff --git a/fbchat/graphql.py b/fbchat/graphql.py index a6a2896..aeef27c 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -1,765 +1,30 @@ # -*- coding: UTF-8 -*- - +"""This file is here to maintain backwards compatability.""" from __future__ import unicode_literals -import json -import re + from .models import * from .utils import * - -# Shameless copy from https://stackoverflow.com/a/8730674 -FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL -WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS) - - -class ConcatJSONDecoder(json.JSONDecoder): - def decode(self, s, _w=WHITESPACE.match): - s_len = len(s) - - objs = [] - end = 0 - while end != s_len: - obj, end = self.raw_decode(s, idx=_w(s, end).end()) - end = _w(s, end).end() - objs.append(obj) - return objs - - -# End shameless copy - - -def graphql_color_to_enum(color): - if color is None: - return None - if not color: - return ThreadColor.MESSENGER_BLUE - color = color[2:] # Strip the alpha value - color_value = "#{}".format(color.lower()) - return enum_extend_if_invalid(ThreadColor, color_value) - - -def get_customization_info(thread): - if thread is None or thread.get("customization_info") is None: - return {} - info = thread["customization_info"] - - rtn = { - "emoji": info.get("emoji"), - "color": graphql_color_to_enum(info.get("outgoing_bubble_color")), - } - if ( - thread.get("thread_type") == "GROUP" - or thread.get("is_group_thread") - or thread.get("thread_key", {}).get("thread_fbid") - ): - rtn["nicknames"] = {} - for k in info.get("participant_customizations", []): - rtn["nicknames"][k["participant_id"]] = k.get("nickname") - elif info.get("participant_customizations"): - uid = thread.get("thread_key", {}).get("other_user_id") or thread.get("id") - pc = info["participant_customizations"] - if len(pc) > 0: - if pc[0].get("participant_id") == uid: - rtn["nickname"] = pc[0].get("nickname") - else: - rtn["own_nickname"] = pc[0].get("nickname") - if len(pc) > 1: - if pc[1].get("participant_id") == uid: - rtn["nickname"] = pc[1].get("nickname") - else: - rtn["own_nickname"] = pc[1].get("nickname") - return rtn - - -def graphql_to_sticker(s): - if not s: - return None - sticker = Sticker(uid=s["id"]) - if s.get("pack"): - sticker.pack = s["pack"].get("id") - if s.get("sprite_image"): - sticker.is_animated = True - sticker.medium_sprite_image = s["sprite_image"].get("uri") - sticker.large_sprite_image = s["sprite_image_2x"].get("uri") - sticker.frames_per_row = s.get("frames_per_row") - sticker.frames_per_col = s.get("frames_per_column") - sticker.frame_rate = s.get("frame_rate") - sticker.url = s.get("url") - sticker.width = s.get("width") - sticker.height = s.get("height") - if s.get("label"): - sticker.label = s["label"] - return sticker - - -def graphql_to_attachment(a): - _type = a["__typename"] - if _type in ["MessageImage", "MessageAnimatedImage"]: - return ImageAttachment( - original_extension=a.get("original_extension") - or (a["filename"].split("-")[0] if a.get("filename") else None), - width=a.get("original_dimensions", {}).get("width"), - height=a.get("original_dimensions", {}).get("height"), - is_animated=_type == "MessageAnimatedImage", - thumbnail_url=a.get("thumbnail", {}).get("uri"), - preview=a.get("preview") or a.get("preview_image"), - large_preview=a.get("large_preview"), - animated_preview=a.get("animated_image"), - uid=a.get("legacy_attachment_id"), - ) - elif _type == "MessageVideo": - return VideoAttachment( - width=a.get("original_dimensions", {}).get("width"), - height=a.get("original_dimensions", {}).get("height"), - duration=a.get("playable_duration_in_ms"), - preview_url=a.get("playable_url"), - small_image=a.get("chat_image"), - medium_image=a.get("inbox_image"), - large_image=a.get("large_image"), - uid=a.get("legacy_attachment_id"), - ) - elif _type == "MessageAudio": - return AudioAttachment( - filename=a.get("filename"), - url=a.get("playable_url"), - duration=a.get("playable_duration_in_ms"), - audio_type=a.get("audio_type"), - ) - elif _type == "MessageFile": - return FileAttachment( - url=a.get("url"), - name=a.get("filename"), - is_malicious=a.get("is_malicious"), - uid=a.get("message_file_fbid"), - ) - else: - return Attachment(uid=a.get("legacy_attachment_id")) - - -def graphql_to_extensible_attachment(a): - story = a.get("story_attachment") - if story: - target = story.get("target") - if target: - _type = target["__typename"] - if _type == "MessageLocation": - url = story.get("url") - address = get_url_parameter(get_url_parameter(url, "u"), "where1") - try: - latitude, longitude = [float(x) for x in address.split(", ")] - address = None - except ValueError: - latitude, longitude = None, None - rtn = LocationAttachment( - uid=int(story["deduplication_key"]), - latitude=latitude, - longitude=longitude, - address=address, - ) - media = story.get("media") - if media and media.get("image"): - image = media["image"] - rtn.image_url = image.get("uri") - rtn.image_width = image.get("width") - rtn.image_height = image.get("height") - rtn.url = url - return rtn - elif _type == "MessageLiveLocation": - rtn = LiveLocationAttachment( - uid=int(story["target"]["live_location_id"]), - latitude=story["target"]["coordinate"]["latitude"] - if story["target"].get("coordinate") - else None, - longitude=story["target"]["coordinate"]["longitude"] - if story["target"].get("coordinate") - else None, - name=story["title_with_entities"]["text"], - expiration_time=story["target"].get("expiration_time"), - is_expired=story["target"].get("is_expired"), - ) - media = story.get("media") - if media and media.get("image"): - image = media["image"] - rtn.image_url = image.get("uri") - rtn.image_width = image.get("width") - rtn.image_height = image.get("height") - rtn.url = story.get("url") - return rtn - elif _type in ["ExternalUrl", "Story"]: - url = story.get("url") - rtn = ShareAttachment( - uid=a.get("legacy_attachment_id"), - author=story["target"]["actors"][0]["id"] - if story["target"].get("actors") - else None, - url=url, - original_url=get_url_parameter(url, "u") - if "/l.php?u=" in url - else url, - title=story["title_with_entities"].get("text"), - description=story["description"].get("text") - if story.get("description") - else None, - source=story["source"].get("text"), - attachments=[ - graphql_to_subattachment(attachment) - for attachment in story.get("subattachments") - ], - ) - media = story.get("media") - if media and media.get("image"): - image = media["image"] - rtn.image_url = image.get("uri") - rtn.original_image_url = ( - get_url_parameter(rtn.image_url, "url") - if "/safe_image.php" in rtn.image_url - else rtn.image_url - ) - rtn.image_width = image.get("width") - rtn.image_height = image.get("height") - return rtn - else: - return UnsentMessage(uid=a.get("legacy_attachment_id")) - - -def graphql_to_subattachment(a): - _type = a["target"]["__typename"] - if _type == "Video": - media = a["media"] - return VideoAttachment( - duration=media.get("playable_duration_in_ms"), - preview_url=media.get("playable_url"), - medium_image=media.get("image"), - uid=a["target"].get("video_id"), - ) - - -def graphql_to_live_location(a): - return LiveLocationAttachment( - uid=a["id"], - latitude=a["coordinate"]["latitude"] / (10 ** 8) - if not a.get("stopReason") - else None, - longitude=a["coordinate"]["longitude"] / (10 ** 8) - if not a.get("stopReason") - else None, - name=a.get("locationTitle"), - expiration_time=a["expirationTime"], - is_expired=bool(a.get("stopReason")), - ) - - -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_quick_reply(q, is_response=False): - data = dict() - _type = q.get("content_type").lower() - if q.get("payload"): - data["payload"] = q["payload"] - if q.get("data"): - data["data"] = q["data"] - if q.get("image_url") and _type is not QuickReplyLocation._type: - data["image_url"] = q["image_url"] - data["is_response"] = is_response - if _type == QuickReplyText._type: - if q.get("title") is not None: - data["title"] = q["title"] - rtn = QuickReplyText(**data) - elif _type == QuickReplyLocation._type: - rtn = QuickReplyLocation(**data) - elif _type == QuickReplyPhoneNumber._type: - rtn = QuickReplyPhoneNumber(**data) - elif _type == QuickReplyEmail._type: - rtn = QuickReplyEmail(**data) - return rtn - - -def graphql_to_message(message): - if message.get("message_sender") is None: - message["message_sender"] = {} - if message.get("message") is None: - message["message"] = {} - rtn = Message( - text=message.get("message").get("text"), - mentions=[ - Mention( - m.get("entity", {}).get("id"), - offset=m.get("offset"), - length=m.get("length"), - ) - for m in message.get("message").get("ranges", []) - ], - emoji_size=get_emojisize_from_tags(message.get("tags_list")), - sticker=graphql_to_sticker(message.get("sticker")), - ) - rtn.uid = str(message.get("message_id")) - rtn.author = str(message.get("message_sender").get("id")) - rtn.timestamp = message.get("timestamp_precise") - rtn.unsent = False - if message.get("unread") is not None: - rtn.is_read = not message["unread"] - rtn.reactions = { - str(r["user"]["id"]): enum_extend_if_invalid(MessageReaction, r["reaction"]) - for r in message.get("message_reactions") - } - if message.get("blob_attachments") is not None: - rtn.attachments = [ - graphql_to_attachment(attachment) - for attachment in message["blob_attachments"] - ] - if message.get("platform_xmd_encoded"): - quick_replies = json.loads(message["platform_xmd_encoded"]).get("quick_replies") - if isinstance(quick_replies, list): - rtn.quick_replies = [graphql_to_quick_reply(q) for q in quick_replies] - elif isinstance(quick_replies, dict): - rtn.quick_replies = [ - graphql_to_quick_reply(quick_replies, is_response=True) - ] - if message.get("extensible_attachment") is not None: - attachment = graphql_to_extensible_attachment(message["extensible_attachment"]) - if isinstance(attachment, UnsentMessage): - rtn.unsent = True - elif attachment: - rtn.attachments.append(attachment) - return rtn - - -def graphql_to_user(user): - if user.get("profile_picture") is None: - user["profile_picture"] = {} - c_info = get_customization_info(user) - plan = None - if user.get("event_reminders"): - plan = ( - graphql_to_plan(user["event_reminders"]["nodes"][0]) - if user["event_reminders"].get("nodes") - else None - ) - return User( - user["id"], - url=user.get("url"), - first_name=user.get("first_name"), - last_name=user.get("last_name"), - is_friend=user.get("is_viewer_friend"), - gender=GENDERS.get(user.get("gender")), - affinity=user.get("affinity"), - nickname=c_info.get("nickname"), - color=c_info.get("color"), - emoji=c_info.get("emoji"), - own_nickname=c_info.get("own_nickname"), - photo=user["profile_picture"].get("uri"), - name=user.get("name"), - message_count=user.get("messages_count"), - plan=plan, - ) - - -def graphql_to_thread(thread): - if thread["thread_type"] == "GROUP": - return graphql_to_group(thread) - elif thread["thread_type"] == "ONE_TO_ONE": - if thread.get("big_image_src") is None: - thread["big_image_src"] = {} - c_info = get_customization_info(thread) - participants = [ - node["messaging_actor"] for node in thread["all_participants"]["nodes"] - ] - user = next( - p for p in participants if p["id"] == thread["thread_key"]["other_user_id"] - ) - last_message_timestamp = None - if "last_message" in thread: - last_message_timestamp = thread["last_message"]["nodes"][0][ - "timestamp_precise" - ] - - first_name = user.get("short_name") - if first_name is None: - last_name = None - else: - last_name = user.get("name").split(first_name, 1).pop().strip() - - plan = None - if thread.get("event_reminders"): - plan = ( - graphql_to_plan(thread["event_reminders"]["nodes"][0]) - if thread["event_reminders"].get("nodes") - else None - ) - - return User( - user["id"], - url=user.get("url"), - name=user.get("name"), - first_name=first_name, - last_name=last_name, - is_friend=user.get("is_viewer_friend"), - gender=GENDERS.get(user.get("gender")), - affinity=user.get("affinity"), - nickname=c_info.get("nickname"), - color=c_info.get("color"), - emoji=c_info.get("emoji"), - 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, - plan=plan, - ) - else: - raise FBchatException( - "Unknown thread type: {}, with data: {}".format( - thread.get("thread_type"), thread - ) - ) - - -def graphql_to_group(group): - if group.get("image") is None: - group["image"] = {} - c_info = get_customization_info(group) - last_message_timestamp = None - if "last_message" in group: - last_message_timestamp = group["last_message"]["nodes"][0]["timestamp_precise"] - plan = None - if group.get("event_reminders"): - plan = ( - graphql_to_plan(group["event_reminders"]["nodes"][0]) - if group["event_reminders"].get("nodes") - else None - ) - return Group( - group["thread_key"]["thread_fbid"], - participants=set( - [ - node["messaging_actor"]["id"] - for node in group["all_participants"]["nodes"] - ] - ), - nicknames=c_info.get("nicknames"), - color=c_info.get("color"), - emoji=c_info.get("emoji"), - admins=set([node.get("id") for node in group.get("thread_admins")]), - approval_mode=bool(group.get("approval_mode")) - if group.get("approval_mode") is not None - else None, - approval_requests=set( - node["requester"]["id"] for node in group["group_approval_queue"]["nodes"] - ) - if group.get("group_approval_queue") - else None, - join_link=group["joinable_mode"].get("link"), - photo=group["image"].get("uri"), - name=group.get("name"), - message_count=group.get("messages_count"), - last_message_timestamp=last_message_timestamp, - plan=plan, - ) - - -def graphql_to_page(page): - if page.get("profile_picture") is None: - page["profile_picture"] = {} - if page.get("city") is None: - page["city"] = {} - plan = None - if page.get("event_reminders"): - plan = ( - graphql_to_plan(page["event_reminders"]["nodes"][0]) - if page["event_reminders"].get("nodes") - else None - ) - return Page( - page["id"], - url=page.get("url"), - city=page.get("city").get("name"), - category=page.get("category_type"), - photo=page["profile_picture"].get("uri"), - name=page.get("name"), - message_count=page.get("messages_count"), - plan=plan, - ) - - -def graphql_queries_to_json(*queries): - """ - Queries should be a list of GraphQL objects - """ - rtn = {} - for i, query in enumerate(queries): - rtn["q{}".format(i)] = query.value - return json.dumps(rtn) - - -def graphql_response_to_json(content): - content = strip_to_json(content) # Usually only needed in some error cases - try: - j = json.loads(content, cls=ConcatJSONDecoder) - except Exception: - raise FBchatException("Error while parsing JSON: {}".format(repr(content))) - - rtn = [None] * (len(j)) - for x in j: - if "error_results" in x: - del rtn[-1] - continue - check_json(x) - [(key, value)] = x.items() - check_json(value) - if "response" in value: - rtn[int(key[1:])] = value["response"] - else: - rtn[int(key[1:])] = value["data"] - - log.debug(rtn) - - return rtn - - -class GraphQL(object): - def __init__(self, query=None, doc_id=None, params=None): - if params is None: - params = {} - if query is not None: - self.value = {"priority": 0, "q": query, "query_params": params} - elif doc_id is not None: - self.value = {"doc_id": doc_id, "query_params": params} - else: - raise FBchatUserError("A query or doc_id must be specified") - - FRAGMENT_USER = """ - QueryFragment User: User { - id, - name, - first_name, - last_name, - profile_picture.width().height() { - uri - }, - is_viewer_friend, - url, - gender, - viewer_affinity - } - """ - - FRAGMENT_GROUP = """ - QueryFragment Group: MessageThread { - name, - thread_key { - thread_fbid - }, - image { - uri - }, - is_group_thread, - all_participants { - nodes { - messaging_actor { - id - } - } - }, - customization_info { - participant_customizations { - participant_id, - nickname - }, - outgoing_bubble_color, - emoji - }, - thread_admins { - id - }, - group_approval_queue { - nodes { - requester { - id - } - } - }, - approval_mode, - joinable_mode { - mode, - link - }, - event_reminders { - nodes { - id, - lightweight_event_creator { - id - }, - time, - location_name, - event_title, - event_reminder_members { - edges { - node { - id - }, - guest_list_state - } - } - } - } - } - """ - - FRAGMENT_PAGE = """ - QueryFragment Page: Page { - id, - name, - profile_picture.width(32).height(32) { - uri - }, - url, - category_type, - city { - name - } - } - """ - - SEARCH_USER = ( - """ - Query SearchUser( = '', = 10) { - entities_named() { - search_results.of_type(user).first() as users { - nodes { - @User - } - } - } - } - """ - + FRAGMENT_USER - ) - - SEARCH_GROUP = ( - """ - Query SearchGroup( = '', = 10, = 32) { - viewer() { - message_threads.with_thread_name().last() as groups { - nodes { - @Group - } - } - } - } - """ - + FRAGMENT_GROUP - ) - - SEARCH_PAGE = ( - """ - Query SearchPage( = '', = 10) { - entities_named() { - search_results.of_type(page).first() as pages { - nodes { - @Page - } - } - } - } - """ - + FRAGMENT_PAGE - ) - - SEARCH_THREAD = ( - """ - Query SearchThread( = '', = 10) { - entities_named() { - search_results.first() as threads { - nodes { - __typename, - @User, - @Group, - @Page - } - } - } - } - """ - + FRAGMENT_USER - + FRAGMENT_GROUP - + FRAGMENT_PAGE - ) +from ._graphql import ( + FLAGS, + WHITESPACE, + ConcatJSONDecoder, + graphql_color_to_enum, + get_customization_info, + graphql_to_sticker, + graphql_to_attachment, + graphql_to_extensible_attachment, + graphql_to_subattachment, + graphql_to_live_location, + graphql_to_poll, + graphql_to_poll_option, + graphql_to_plan, + graphql_to_quick_reply, + graphql_to_message, + graphql_to_user, + graphql_to_thread, + graphql_to_group, + graphql_to_page, + graphql_queries_to_json, + graphql_response_to_json, + GraphQL, +) diff --git a/fbchat/utils.py b/fbchat/utils.py index 8705442..fedb1ea 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -1,377 +1,36 @@ # -*- coding: UTF-8 -*- - +"""This file is here to maintain backwards compatability.""" from __future__ import unicode_literals -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 -import aenum + from .models import * - -try: - from urllib.parse import urlencode, parse_qs, urlparse - - basestring = (str, bytes) -except ImportError: - from urllib import urlencode - from urlparse import parse_qs, urlparse - - basestring = basestring - -# Python 2's `input` executes the input, whereas `raw_input` just returns the input -try: - input = raw_input -except NameError: - pass - -# Log settings -log = logging.getLogger("client") -log.setLevel(logging.DEBUG) -# Creates the console handler -handler = logging.StreamHandler() -log.addHandler(handler) - -#: Default list of user agents -USER_AGENTS = [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/601.1.10 (KHTML, like Gecko) Version/8.0.5 Safari/601.1.10", - "Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36", - "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/22.0.1207.1 Safari/537.1", - "Mozilla/5.0 (X11; CrOS i686 2268.111.0) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.57 Safari/536.11", - "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6", -] - -LIKES = { - "large": EmojiSize.LARGE, - "medium": EmojiSize.MEDIUM, - "small": EmojiSize.SMALL, - "l": EmojiSize.LARGE, - "m": EmojiSize.MEDIUM, - "s": EmojiSize.SMALL, -} - - -GENDERS = { - # For standard requests - 0: "unknown", - 1: "female_singular", - 2: "male_singular", - 3: "female_singular_guess", - 4: "male_singular_guess", - 5: "mixed", - 6: "neuter_singular", - 7: "unknown_singular", - 8: "female_plural", - 9: "male_plural", - 10: "neuter_plural", - 11: "unknown_plural", - # For graphql requests - "UNKNOWN": "unknown", - "FEMALE": "female_singular", - "MALE": "male_singular", - # '': 'female_singular_guess', - # '': 'male_singular_guess', - # '': 'mixed', - "NEUTER": "neuter_singular", - # '': 'unknown_singular', - # '': 'female_plural', - # '': 'male_plural', - # '': 'neuter_plural', - # '': 'unknown_plural', -} - - -class ReqUrl(object): - """A class containing all urls used by `fbchat`""" - - SEARCH = "https://www.facebook.com/ajax/typeahead/search.php" - LOGIN = "https://m.facebook.com/login.php?login_attempt=1" - SEND = "https://www.facebook.com/messaging/send/" - 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" - MARK_SEEN = "https://www.facebook.com/ajax/mercury/mark_seen.php" - BASE = "https://www.facebook.com" - MOBILE = "https://m.facebook.com/" - STICKY = "https://0-edge-chat.facebook.com/pull" - PING = "https://0-edge-chat.facebook.com/active_ping" - UPLOAD = "https://upload.facebook.com/ajax/mercury/upload.php" - INFO = "https://www.facebook.com/chat/user_info/" - CONNECT = "https://www.facebook.com/ajax/add_friend/action.php?dpr=1" - REMOVE_USER = "https://www.facebook.com/chat/remove_participants/" - LOGOUT = "https://www.facebook.com/logout.php" - ALL_USERS = "https://www.facebook.com/chat/user_info_all" - SAVE_DEVICE = "https://m.facebook.com/login/save-device/cancel/" - CHECKPOINT = "https://m.facebook.com/login/checkpoint/" - THREAD_COLOR = "https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1" - THREAD_NICKNAME = "https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1" - THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1" - THREAD_IMAGE = "https://www.facebook.com/messaging/set_thread_image/?dpr=1" - THREAD_NAME = "https://www.facebook.com/messaging/set_thread_name/?dpr=1" - MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation" - 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/" - 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" - UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1" - - pull_channel = 0 - - def change_pull_channel(self, channel=None): - if channel is None: - self.pull_channel = (self.pull_channel + 1) % 5 # Pull channel will be 0-4 - else: - self.pull_channel = channel - self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel) - self.PING = "https://{}-edge-chat.facebook.com/active_ping".format( - self.pull_channel - ) - - -facebookEncoding = "UTF-8" - - -def now(): - return int(time() * 1000) - - -def strip_to_json(text): - try: - return text[text.index("{") :] - except ValueError: - raise FBchatException("No JSON object found: {!r}".format(text)) - - -def get_decoded_r(r): - return get_decoded(r._content) - - -def get_decoded(content): - return content.decode(facebookEncoding) - - -def parse_json(content): - return json.loads(content) - - -def get_json(r): - return json.loads(strip_to_json(get_decoded_r(r))) - - -def digitToChar(digit): - if digit < 10: - return str(digit) - return chr(ord("a") + digit - 10) - - -def str_base(number, base): - if number < 0: - return "-" + str_base(-number, base) - (d, m) = divmod(number, base) - if d > 0: - return str_base(d, base) + digitToChar(m) - return digitToChar(m) - - -def generateMessageID(client_id=None): - k = now() - l = int(random() * 4294967295) - return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id) - - -def getSignatureID(): - return hex(int(random() * 2147483648)) - - -def generateOfflineThreadingID(): - ret = now() - value = int(random() * 4294967295) - string = ("0000000000000000000000" + format(value, "b"))[-22:] - msgs = format(ret, "b") + string - return str(int(msgs, 2)) - - -def check_json(j): - if j.get("error") is None: - return - if "errorDescription" in j: - # 'errorDescription' is in the users own language! - raise FBchatFacebookError( - "Error #{} when sending request: {}".format( - j["error"], j["errorDescription"] - ), - fb_error_code=j["error"], - fb_error_message=j["errorDescription"], - ) - elif "debug_info" in j["error"] and "code" in j["error"]: - raise FBchatFacebookError( - "Error #{} when sending request: {}".format( - j["error"]["code"], repr(j["error"]["debug_info"]) - ), - fb_error_code=j["error"]["code"], - fb_error_message=j["error"]["debug_info"], - ) - else: - raise FBchatFacebookError( - "Error {} when sending request".format(j["error"]), fb_error_code=j["error"] - ) - - -def check_request(r, as_json=True): - if not r.ok: - raise FBchatFacebookError( - "Error when sending request: Got {} response".format(r.status_code), - request_status_code=r.status_code, - ) - - content = get_decoded_r(r) - - if content is None or len(content) == 0: - raise FBchatFacebookError("Error when sending request: Got empty response") - - if as_json: - content = strip_to_json(content) - try: - j = json.loads(content) - except ValueError: - raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content)) - check_json(j) - log.debug(j) - return j - else: - return content - - -def get_jsmods_require(j, index): - if j.get("jsmods") and j["jsmods"].get("require"): - try: - return j["jsmods"]["require"][0][index][0] - except (KeyError, IndexError) as e: - log.warning( - "Error when getting jsmods_require: {}. Facebook might have changed protocol".format( - j - ) - ) - return None - - -def get_emojisize_from_tags(tags): - if tags is None: - return None - tmp = [tag for tag in tags if tag.startswith("hot_emoji_size:")] - if len(tmp) > 0: - try: - return LIKES[tmp[0].split(":")[1]] - 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() - - -def enum_extend_if_invalid(enumeration, value): - try: - return enumeration(value) - except ValueError: - log.warning( - "Failed parsing {.__name__}({!r}). Extending enum.".format( - enumeration, value - ) - ) - aenum.extend_enum(enumeration, "UNKNOWN_{}".format(value).upper(), value) - return enumeration(value) - - -def get_url_parameters(url, *args): - params = parse_qs(urlparse(url).query) - return [params[arg][0] for arg in args if params.get(arg)] - - -def get_url_parameter(url, param): - return get_url_parameters(url, param)[0] +from ._util import ( + log, + handler, + USER_AGENTS, + LIKES, + GENDERS, + ReqUrl, + facebookEncoding, + now, + strip_to_json, + get_decoded_r, + get_decoded, + parse_json, + get_json, + digitToChar, + str_base, + generateMessageID, + getSignatureID, + generateOfflineThreadingID, + check_json, + check_request, + get_jsmods_require, + get_emojisize_from_tags, + require_list, + mimetype_to_key, + get_files_from_urls, + get_files_from_paths, + enum_extend_if_invalid, + get_url_parameters, + get_url_parameter, +)