From 11e59e023cc943eed25dd56385d9cb1911f12ac2 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 22 Jun 2017 22:38:15 +0200 Subject: [PATCH] Added GraphQL requests --- docs/Makefile | 2 +- docs/faq.rst | 4 +- docs/intro.rst | 6 +- docs/make.bat | 2 +- docs/todo.rst | 8 +- examples/basic_usage.py | 4 +- examples/fetch.py | 8 +- fbchat/client.py | 318 +++++++++++++++++++++++----------------- fbchat/graphql.py | 231 +++++++++++++++++++++++++++++ fbchat/models.py | 109 ++++++++++---- fbchat/utils.py | 53 +++++-- tests.py | 21 +-- 12 files changed, 564 insertions(+), 202 deletions(-) create mode 100644 fbchat/graphql.py diff --git a/docs/Makefile b/docs/Makefile index 1c50583..f343705 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,4 +17,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/faq.rst b/docs/faq.rst index 900f8a7..16b8c59 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -2,8 +2,8 @@ .. module:: fbchat .. _faq: -Frequently asked questions -========================== +FAQ +=== Version X broke my installation ------------------------------- diff --git a/docs/intro.rst b/docs/intro.rst index 12c4d1f..5e1963d 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -55,7 +55,7 @@ This is required for many of `fbchat`'s functions, since Facebook differetiates Searching for group chats and finding their ID is not yet possible with `fbchat`, but searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching` -You can get your own user ID by using :any:`Client.id` +You can get your own user ID by using :any:`Client.uid` Getting the ID of a group chat is fairly trivial though, since you only need to navigate to ``_, click on the group you want to find the ID of, and then read the id from the address bar. @@ -108,7 +108,7 @@ like adding users to and removing users from a group chat, logically only works The simplest way of using `fbchat` is to send a message. The following snippet will, as you've probably already figured out, send the message `test message` to your account:: - message_id = client.sendMessage('test message', thread_id=client.id, thread_type=ThreadType.USER) + message_id = client.sendMessage('test message', thread_id=client.uid, thread_type=ThreadType.USER) You can see a full example showing all the possible thread interactions with `fbchat` by going to :ref:`examples` @@ -125,7 +125,7 @@ The following snippet will search for users by their name, take the first (and m users = client.searchForUsers('') user = users[0] - print("User's ID: {}".format(user.id)) + print("User's ID: {}".format(user.uid)) print("User's name: {}".format(user.name)) print("User's profile picture url: {}".format(user.photo)) print("User's main url: {}".format(user.url)) diff --git a/docs/make.bat b/docs/make.bat index 3e14b5f..c11e517 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -5,7 +5,7 @@ pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=python3.6 -msphinx + set SPHINXBUILD=python -msphinx ) set SOURCEDIR=. set BUILDDIR=_build diff --git a/docs/todo.rst b/docs/todo.rst index 8059ab1..d0af13b 100644 --- a/docs/todo.rst +++ b/docs/todo.rst @@ -11,13 +11,11 @@ This page will be periodically updated to show missing features and documentatio Missing Functionality --------------------- -- Implement Client.searchForThread - - This will use the graphql request API - Implement Client.searchForMessage - This will use the graphql request API -- Implement chatting with pages - - This might require a new :class:`models.ThreadType`, something like ``ThreadType.PAGE`` -- Rework `Message` model, to make the whole process more streamlined +- Implement chatting with pages properly +- Write better FAQ +- Explain usage of graphql Documentation diff --git a/examples/basic_usage.py b/examples/basic_usage.py index ebd3268..5f5d4a5 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -5,8 +5,8 @@ from fbchat.models import * client = Client('', '') -print('Own id: {}'.format(client.id)) +print('Own id: {}'.format(client.uid)) -client.sendMessage('Hi me!', thread_id=self.id, thread_type=ThreadType.USER) +client.sendMessage('Hi me!', thread_id=client.uid, thread_type=ThreadType.USER) client.logout() diff --git a/examples/fetch.py b/examples/fetch.py index 3ed48ec..101ec09 100644 --- a/examples/fetch.py +++ b/examples/fetch.py @@ -34,19 +34,19 @@ print("Is user client's friend: {}".format(user.is_friend)) # Fetches a list of the 20 top threads you're currently chatting with threads = client.fetchThreadList() # Fetches the next 10 threads -threads += client.fetchThreadList(offset=20, amount=10) +threads += client.fetchThreadList(offset=20, limit=10) -print("Thread's INFO: {}".format(threads)) +print("Threads: {}".format(threads)) # Gets the last 10 messages sent to the thread -messages = client.fetchThreadMessages(offset=0, amount=10, thread_id='', thread_type=ThreadType) +messages = client.fetchThreadMessages(thread_id='', limit=10) # Since the message come in reversed order, reverse them messages.reverse() # Prints the content of all the messages for message in messages: - print(message.body) + print(message.text) # Here should be an example of `getUnread` diff --git a/fbchat/client.py b/fbchat/client.py index 69900e7..e4bc99e 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -10,6 +10,7 @@ from bs4 import BeautifulSoup as bs from mimetypes import guess_type from .utils import * from .models import * +from .graphql import * import time @@ -22,7 +23,7 @@ class Client(object): listening = False """Whether the client is listening. Used when creating an external event loop to determine when to stop listening""" - id = None + uid = None """ The ID of the client. Can be used as `thread_id`. See :ref:`intro_threads` for more info. @@ -53,7 +54,6 @@ class Client(object): self.client = 'mercury' self.default_thread_id = None self.default_thread_type = None - self.threads = [] if not user_agent: user_agent = choice(USER_AGENTS) @@ -109,6 +109,27 @@ class Client(object): headers = dict((i, self._header[i]) for i in self._header if i != 'Content-Type') return self._session.post(url, headers=headers, data=payload, timeout=timeout, files=files) + def graphql_request(self, *queries): + """ + .. todo:: + Documenting this + + :raises: Exception if request failed + """ + payload = { + 'method': 'GET', + 'response_format': 'json', + 'queries': graphql_queries_to_json(*queries) + } + + j = graphql_response_to_json(checkRequest(self._post(ReqUrl.GRAPHQL, payload), do_json_check=False)) + + if len(j) == 1: + return j[0] + else: + return tuple(j) + + """ END INTERNAL REQUEST METHODS """ @@ -117,18 +138,23 @@ class Client(object): LOGIN METHODS """ + def _resetValues(self): + self.payloadDefault={} + self._session = requests.session() + self.req_counter = 1 + self.seq = "0" + self.uid = None + def _postLogin(self): self.payloadDefault = {} self.client_id = hex(int(random()*2147483648))[2:] self.start_time = now() - self.id = str(self._session.cookies['c_user']) - self.user_channel = "p_" + self.id + self.uid = str(self._session.cookies['c_user']) + self.user_channel = "p_" + self.uid self.ttstamp = '' r = self._get(ReqUrl.BASE) soup = bs(r.text, "lxml") - log.debug(r.text) - log.debug(r.url) self.fb_dtsg = soup.find("input", {'name':'fb_dtsg'})['value'] self.fb_h = soup.find("input", {'name':'h'})['value'] for i in self.fb_dtsg: @@ -136,7 +162,7 @@ class Client(object): self.ttstamp += '2' # Set default payload self.payloadDefault['__rev'] = int(r.text.split('"client_revision":',1)[1].split(",",1)[0]) - self.payloadDefault['__user'] = self.id + self.payloadDefault['__user'] = self.uid self.payloadDefault['__a'] = '1' self.payloadDefault['ttstamp'] = self.ttstamp self.payloadDefault['fb_dtsg'] = self.fb_dtsg @@ -145,8 +171,8 @@ class Client(object): 'channel' : self.user_channel, 'partition' : '-2', 'clientid' : self.client_id, - 'viewer_uid' : self.id, - 'uid' : self.id, + 'viewer_uid' : self.uid, + 'uid' : self.uid, 'state' : 'active', 'format' : 'json', 'idle' : 0, @@ -267,9 +293,13 @@ class Client(object): if not session_cookies or 'c_user' not in session_cookies: return False - # Load cookies into current session - self._session.cookies = requests.cookies.merge_cookies(self._session.cookies, session_cookies) - self._postLogin() + try: + # Load cookies into current session + self._session.cookies = requests.cookies.merge_cookies(self._session.cookies, session_cookies) + self._postLogin() + except Exception: + self._resetValues() + return False return True def login(self, email, password, max_tries=5): @@ -320,12 +350,7 @@ class Client(object): r = self._get(ReqUrl.LOGOUT, data) - # reset value - self.payloadDefault={} - self._session = requests.session() - self.req_counter = 1 - self.seq = "0" - self.id = None + self._resetValues() return r.ok @@ -385,7 +410,7 @@ class Client(object): """ data = { - 'viewer': self.id, + 'viewer': self.uid, } j = checkRequest(self._post(ReqUrl.ALL_USERS, query=data)) if not j['payload']: @@ -399,51 +424,82 @@ class Client(object): return users - def _searchFor(self, name): - payload = { - 'value' : name.lower(), - 'viewer' : self.id, - 'rsp' : 'search', - 'context' : 'search', - 'path' : '/home.php', - 'request_id' : str(uuid1()), - } + def searchForUsers(self, name, limit=1): + """ + Find and get user by his/her name - j = checkRequest(self._get(ReqUrl.SEARCH, payload)) - - entries = [] - for k in j['payload']['entries']: - if k['type'] in ['user', 'friend']: - entries.append(User(k['uid'], url=k['path'], first_name=k['firstname'], last_name=k['lastname'], is_friend=k['is_connected'], photo=k['photo'], name=k['text'])) - if k['type'] == 'page': - if 'city_text' not in k: - k['city_text'] = None - entries.append(Page(k['uid'], url=k['path'], city=k['city_text'], likees=k['feedback_count'], sub_text=k['subtext'], photo=k['photo'], name=k['text'])) - return entries - - def searchForUsers(self, name): - """Find and get user by his/her name - - :param name: name of a user + :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: Exception if request failed """ - entries = self._searchFor(name) - return [k for k in entries if k.type == ThreadType.USER] + j = self.graphql_request(GraphQL(query=GraphQL.SEARCH_USER, params={'search': name, 'limit': limit})) - def searchForPages(self, name): - """Find and get page by its name + return [graphql_to_user(node) for node in j[name]['users']['nodes']] - :param name: name of a page + def searchForPages(self, name, limit=1): + """ + Find and get page by its name + + :param name: Name of the page :return: :class:`models.Page` objects, ordered by relevance :rtype: list :raises: Exception if request failed """ - entries = self._searchFor(name) - return [k for k in entries if k.type == ThreadType.PAGE] + 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']] + + #entries = self._searchFor(name) + #return [k for k in entries if k.type == ThreadType.PAGE] + + def searchForGroups(self, name, limit=1): + """ + 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: Exception 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=1): + """ + 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: Exception 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 _fetchInfo(self, *ids): data = { @@ -459,16 +515,19 @@ class Client(object): k = j['payload']['profiles'][_id] if k['type'] in ['user', 'friend']: entries[_id] = User(_id, url=k['uri'], first_name=k['firstName'], is_friend=k['is_friend'], gender=GENDERS[k['gender']], photo=k['thumbSrc'], name=k['name']) - if k['type'] == 'page': - entries[_id] = Page(_id, url=k['uri'], city=None, likees=None, sub_text=None, photo=k['thumbSrc'], name=k['name']) + elif k['type'] == 'page': + entries[_id] = Page(_id, url=k['uri'], photo=k['thumbSrc'], name=k['name']) + else: + raise Exception('{} had an unknown thread type: {}'.format(_id, k)) return entries def fetchUserInfo(self, *user_ids): - """Get users' info from ids, unordered + """ + Get users' info from IDs, unordered :param user_ids: One or more user ID(s) to query - :return: :class:`models.User` objects + :return: :class:`models.User` objects, labeled by their ID :rtype: dict :raises: Exception if request failed """ @@ -482,10 +541,11 @@ class Client(object): return users def fetchPageInfo(self, *page_ids): - """Get page's info from ids, unordered + """ + Get pages' info from IDs, unordered - :param user_ids: One or more page ID(s) to query - :return: :class:`models.Page` objects + :param page_ids: One or more page ID(s) to query + :return: :class:`models.Page` objects, labeled by their ID :rtype: dict :raises: Exception if request failed """ @@ -498,95 +558,79 @@ class Client(object): return users - def fetchThreadMessages(self, offset=0, amount=20, thread_id=None, thread_type=ThreadType.USER): - """Get the last messages in a thread + def fetchThreadMessages(self, thread_id=None, limit=20, before=None): + """ + Get the last messages in a thread - .. todo:: - Fix this. Facebook broke it somehow. Also, clean up return values - - :param offset: Where to start retrieving messages from - :param amount: Number of messages to retrieve - :param thread_id: User/Group ID to retrieve from. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type offset: int - :type amount: int - :type thread_type: models.ThreadType - :return: Dictionaries, containing message data + :param thread_id: User/Group ID to default to. 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: Exception if request failed """ - thread_id, thread_type = self._getThread(thread_id, thread_type) + j = self.graphql_request(GraphQL(doc_id='1386147188135407', params={ + 'id': thread_id, + 'message_limit': limit, + 'load_messages': True, + 'load_read_receipts': False, + 'before': before + })) - if amount < 1: - raise Exception('`amount` must be a positive integer, got {}'.format(amount)) + if j['message_thread'] is None: + raise Exception('Could not fetch thread {}'.format(thread_id)) - if thread_type == ThreadType.USER: - key = 'user_ids' - elif thread_type == ThreadType.GROUP: - key = 'thread_fbids' + return list(reversed([graphql_to_message(message) for message in j['message_thread']['messages']['nodes']])) - data = { - 'messages[{}][{}][offset]'.format(key, thread_id): offset, - 'messages[{}][{}][limit]'.format(key, thread_id): 19, - 'messages[{}][{}][timestamp]'.format(key, thread_id): now() - } - - j = checkRequest(self._post(ReqUrl.MESSAGES, query=data)) - if not j['payload']: - raise Exception('Missing payload: {}, with data: {}'.format(j, data)) - - messages = [] - for message in j['payload'].get('actions'): - messages.append(Message(**message)) - return list(reversed(messages)) - - def fetchThreadList(self, offset=0, amount=20): + def fetchThreadList(self, offset=0, limit=20): """Get thread list of your facebook account - .. todo:: - Clean up return values - :param offset: The offset, from where in the list to recieve threads from - :param amount: The amount of threads to recieve. Maximum of 20 + :param limit: Max. number of threads to retrieve. Capped at 20 :type offset: int - :type amount: int - :return: Dictionaries, containing thread data + :type limit: int + :return: :class:`models.Thread` objects :rtype: list :raises: Exception if request failed """ - if amount > 20 or amount < 1: - raise Exception('`amount` should be between 1 and 20') + if limit > 20 or limit < 1: + raise Exception('`limit` should be between 1 and 20') data = { 'client' : self.client, - 'inbox[offset]' : start, - 'inbox[limit]' : amount, + 'inbox[offset]' : offset, + 'inbox[limit]' : limit, } j = checkRequest(self._post(ReqUrl.THREADS, data)) + if j.get('payload') is None: + raise Exception('Missing payload: {}, with data: {}'.format(j, data)) - # Get names for people participants = {} - try: - for participant in j['payload']['participants']: - participants[participant["fbid"]] = participant["name"] - except Exception: - log.exception('Exception while getting names for people in getThreadList. {}'.format(j)) + for p in j['payload']['participants']: + if p['type'] == 'page': + participants[p['fbid']] = Page(p['fbid'], url=p['href'], photo=p['image_src'], name=p['name']) + elif p['type'] == 'user': + participants[p['fbid']] = User(p['fbid'], url=p['href'], first_name=p['short_name'], is_friend=p['is_friend'], gender=GENDERS[p['gender']], photo=p['image_src'], name=p['name']) + else: + raise Exception('A participant had an unknown type {}: {}'.format(p['type'], p)) - # Prevent duplicates in self.threads - threadIDs = [getattr(x, "thread_id") for x in self.threads] - for thread in j['payload']['threads']: - if thread["thread_id"] not in threadIDs: - try: - thread["other_user_name"] = participants[int(thread["other_user_fbid"])] - except: - thread["other_user_name"] = "" - t = Thread(**thread) - self.threads.append(t) + entries = [] + for k in j['payload']['threads']: + if k['thread_type'] == 1: + if k['other_user_fbid'] not in participants: + raise Exception('A thread was not in participants: {}'.format(j['payload'])) + entries.append(participants[k['other_user_fbid']]) + elif k['thread_type'] == 2: + entries.append(Group(k['thread_fbid'], participants=[p.strip('fbid:') for p in k['participants']], photo=k['image_src'], name=k['name'])) + else: + raise Exception('A thread had an unknown thread type: {}'.format(k)) - return self.threads + return entries def fetchUnread(self): """ @@ -624,7 +668,7 @@ class Client(object): date = datetime.now() data = { 'client': self.client, - 'author' : 'fbid:' + str(self.id), + 'author' : 'fbid:' + str(self.uid), 'timestamp' : timestamp, 'timestamp_absolute' : 'Today', 'timestamp_relative' : str(date.hour) + ":" + str(date.minute).zfill(2), @@ -669,11 +713,8 @@ class Client(object): log.warning("Got multiple message ids' back: {}".format(message_ids)) message_id = message_ids[0] except (KeyError, IndexError) as e: - raise Exception('Error when sending message: No message IDs could be found') + raise Exception('Error when sending message: No message IDs could be found: {}'.format(j)) - log.info('Message sent.') - log.debug('Sending with data {}'.format(data)) - log.debug('Recieved message ID {}'.format(message_id)) return message_id def sendMessage(self, message, thread_id=None, thread_type=ThreadType.USER): @@ -694,7 +735,7 @@ class Client(object): data['body'] = message or '' data['has_attachment'] = False data['specific_to_list[0]'] = 'fbid:' + thread_id - data['specific_to_list[1]'] = 'fbid:' + self.id + data['specific_to_list[1]'] = 'fbid:' + self.uid return self._doSendRequest(data) @@ -716,7 +757,7 @@ class Client(object): data['action_type'] = 'ma-type:user-generated-message' data['has_attachment'] = False data['specific_to_list[0]'] = 'fbid:' + thread_id - data['specific_to_list[1]'] = 'fbid:' + self.id + data['specific_to_list[1]'] = 'fbid:' + self.uid if emoji: data['body'] = emoji @@ -758,7 +799,7 @@ class Client(object): data['body'] = message or '' data['has_attachment'] = True data['specific_to_list[0]'] = 'fbid:' + str(thread_id) - data['specific_to_list[1]'] = 'fbid:' + str(self.id) + data['specific_to_list[1]'] = 'fbid:' + str(self.uid) data['image_ids[0]'] = image_id @@ -822,7 +863,7 @@ class Client(object): user_ids = set(user_ids) for i, user_id in enumerate(user_ids): - if user_id == self.id: + if user_id == self.uid: raise Exception('Error when adding users: Cannot add self to group thread') else: data['log_message_data[added_participants][' + str(i) + ']'] = "fbid:" + str(user_id) @@ -944,7 +985,7 @@ class Client(object): "data": { "action": "ADD_REACTION", "client_mutation_id": "1", - "actor_id": self.id, + "actor_id": self.uid, "message_id": str(message_id), "reaction": reaction.value } @@ -1039,17 +1080,19 @@ class Client(object): LISTEN METHODS """ - def _ping(self, sticky): + def _ping(self, sticky, pool): data = { 'channel': self.user_channel, 'clientid': self.client_id, 'partition': -2, 'cap': 0, - 'uid': self.id, - 'sticky': sticky, - 'viewer_uid': self.id + 'uid': self.uid, + 'sticky_token': sticky, + 'sticky_pool': pool, + 'viewer_uid': self.uid, + 'state': 'active' } - checkRequest(self._get(ReqUrl.PING, data), check_json=False) + checkRequest(self._get(ReqUrl.PING, data), do_json_check=False) def _fetchSticky(self): """Call pull api to get sticky and pool parameter, newer api needs these parameters to work""" @@ -1087,7 +1130,6 @@ class Client(object): if 'ms' not in content: return - log.debug("Received {}".format(content["ms"])) for m in content["ms"]: mtype = m.get("type") try: @@ -1265,7 +1307,7 @@ class Client(object): :rtype: bool """ try: - #if markAlive: self._ping(self.sticky) + if markAlive: self._ping(self.sticky, self.pool) try: content = self._pullMessage(self.sticky, self.pool) if content: self._parseMessage(content) diff --git a/fbchat/graphql.py b/fbchat/graphql.py new file mode 100644 index 0000000..ec0e594 --- /dev/null +++ b/fbchat/graphql.py @@ -0,0 +1,231 @@ +# -*- coding: UTF-8 -*- + +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_to_message(message): + if message.get('message_sender') is None: + message['message_sender'] = {} + if message.get('message') is None: + message['message'] = {} + is_read = None + if message.get('unread') is not None: + is_read = not message['unread'] + return Message( + message.get('message_id'), + author=message.get('message_sender').get('id'), + timestamp=message.get('timestamp_precise'), + is_read=is_read, + reactions=message.get('message_reactions'), + 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', [])], + sticker=message.get('sticker'), + attachments=message.get('blob_attachments') + ) + +def graphql_to_user(user): + if user.get('profile_picture') is None: + user['profile_picture'] = {} + 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[user.get('gender')], + affinity=user.get('affinity'), + photo=user['profile_picture'].get('uri'), + name=user.get('name') + ) + +def graphql_to_group(group): + if group.get('image') is None: + group['image'] = {} + return Group( + group['thread_key']['thread_fbid'], + participants=[node['messaging_actor']['id'] for node in group['all_participants']['nodes']], + photo=group['image'].get('uri'), + name=group.get('name') + ) + +def graphql_to_page(page): + if page.get('profile_picture') is None: + page['profile_picture'] = {} + if page.get('city') is None: + page['city'] = {} + 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') + ) + +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): + j = json.loads(content, cls=ConcatJSONDecoder) + + 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={}): + 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 Exception('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 + } + } + } + } + """ + + FRAGMENT_PAGE = """ + QueryFragment Page: Page { + id, + name, + profile_picture.width(32).height(32) { + uri + }, + url, + category_type, + city { + name + } + } + """ + + SEARCH_USER = """ + Query SearchUser( = '', = 1) { + entities_named() { + search_results.of_type(user).first() as users { + nodes { + @User + } + } + } + } + """ + FRAGMENT_USER + + SEARCH_GROUP = """ + Query SearchGroup( = '', = 1, = 32) { + viewer() { + message_threads.with_thread_name().last() as groups { + nodes { + @Group + } + } + } + } + """ + FRAGMENT_GROUP + + SEARCH_PAGE = """ + Query SearchPage( = '', = 1) { + entities_named() { + search_results.of_type(page).first() as pages { + nodes { + @Page + } + } + } + } + """ + FRAGMENT_PAGE + + SEARCH_THREAD = """ + Query SearchThread( = '', = 1) { + entities_named() { + search_results.first() as threads { + nodes { + __typename, + @User, + @Group, + @Page + } + } + } + } + """ + FRAGMENT_USER + FRAGMENT_GROUP + FRAGMENT_PAGE diff --git a/fbchat/models.py b/fbchat/models.py index 517424e..fc4b35b 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -3,19 +3,20 @@ from __future__ import unicode_literals import enum + class Thread(object): - #: The unique identifier of the user. Can be used a `thread_id`. See :ref:`intro_threads` for more info - id = None - #: Specifies the type of thread. Uses ThreadType + #: The unique identifier of the thread. Can be used a `thread_id`. See :ref:`intro_threads` for more info + uid = str + #: Specifies the type of thread. Can be used a `thread_type`. See :ref:`intro_threads` for more info type = None #: The thread's picture - photo = None + photo = str #: The name of the thread - name = None + name = str - def __init__(self, _type, _id, photo=None, name=None): + def __init__(self, _type, uid, photo=None, name=None): """Represents a Facebook thread""" - self.id = str(_id) + self.uid = str(uid) self.type = _type self.photo = photo self.name = name @@ -24,60 +25,112 @@ class Thread(object): return self.__unicode__() def __unicode__(self): - return '<{} {} ({})>'.format(self.type.name, self.name, self.id) + return '<{} {} ({})>'.format(self.type.name, self.name, self.uid) class User(Thread): #: The profile url - url = None + url = str #: The users first name - first_name = None + first_name = str #: The users last name - last_name = None + last_name = str #: Whether the user and the client are friends - is_friend = None + is_friend = bool #: The user's gender - gender = None + gender = str + #: From 0 to 1. How close the client is to the user + affinity = float - def __init__(self, _id, url=None, first_name=None, last_name=None, is_friend=None, gender=None, **kwargs): + def __init__(self, uid, url=None, first_name=None, last_name=None, is_friend=None, gender=None, affinity=None, **kwargs): """Represents a Facebook user. Inherits `Thread`""" - super(User, self).__init__(ThreadType.USER, _id, **kwargs) + super(User, self).__init__(ThreadType.USER, uid, **kwargs) self.url = url self.first_name = first_name self.last_name = last_name self.is_friend = is_friend self.gender = gender + self.affinity = affinity class Group(Thread): - def __init__(self, _id, **kwargs): + #: List of the group thread's participant user IDs + participants = list + + def __init__(self, uid, participants=[], **kwargs): """Represents a Facebook group. Inherits `Thread`""" - super(Group, self).__init__(ThreadType.GROUP, _id, **kwargs) + super(Group, self).__init__(ThreadType.GROUP, uid, **kwargs) + self.participants = participants class Page(Thread): #: The page's custom url - url = None + url = str #: The name of the page's location city - city = None + city = str #: Amount of likes the page has - likees = None + likes = int #: Some extra information about the page - sub_text = None + sub_title = str + #: The page's category + category = str - def __init__(self, _id, url=None, city=None, likees=None, sub_text=None, **kwargs): + def __init__(self, uid, url=None, city=None, likes=None, sub_title=None, category=None, **kwargs): """Represents a Facebook page. Inherits `Thread`""" - super(Page, self).__init__(ThreadType.PAGE, _id, **kwargs) + super(Page, self).__init__(ThreadType.PAGE, uid, **kwargs) self.url = url self.city = city - self.likees = likees - self.sub_text = sub_text + self.likes = likes + self.sub_title = sub_title + self.category = category class Message(object): - """Represents a message. Currently just acts as a dict""" - def __init__(self, **entries): - self.__dict__.update(entries) + #: The message ID + uid = str + #: ID of the sender + author = int + #: Timestamp of when the message was sent + timestamp = str + #: Whether the message is read + is_read = bool + #: A list of message reactions + reactions = list + #: The actual message + text = str + #: A list of :class:`Mention` objects + mentions = list + #: An ID of a sent sticker + sticker = str + #: A list of attachments + attachments = list + + def __init__(self, uid, author=None, timestamp=None, is_read=None, reactions=[], text=None, mentions=[], sticker=None, attachments=[]): + """Represents a Facebook message""" + self.uid = uid + self.author = author + self.timestamp = timestamp + self.is_read = is_read + self.reactions = reactions + self.text = text + self.mentions = mentions + self.sticker = sticker + self.attachments = attachments + + +class Mention(object): + #: The user ID the mention is pointing at + user_id = str + #: The character where the mention starts + offset = int + #: The length of the mention + length = int + + def __init__(self, user_id, offset=0, length=10): + """Represents a @mention""" + self.user_id = user_id + self.offset = offset + self.length = length class Enum(enum.Enum): """Used internally by fbchat to support enumerations""" diff --git a/fbchat/utils.py b/fbchat/utils.py index e36b882..c3a70eb 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -32,7 +32,14 @@ USER_AGENTS = [ "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6" ] +TYPES = { + 'Page': ThreadType.PAGE, + 'User': ThreadType.USER, + 'Group': ThreadType.GROUP +} + GENDERS = { + # For standard requests 0: 'unknown', 1: 'female_singular', 2: 'male_singular', @@ -45,6 +52,21 @@ GENDERS = { 9: 'male_plural', 10: 'neuter_plural', 11: 'unknown_plural', + + # For graphql requests + #'': 'unknown', + 'FEMALE': 'female_singular', + 'MALE': 'male_singular', + #'': 'female_singular_guess', + #'': 'male_singular_guess', + #'': 'mixed', + #'': 'neuter_singular', + #'': 'unknown_singular', + #'': 'female_plural', + #'': 'male_plural', + #'': 'neuter_plural', + #'': 'unknown_plural', + None: None } class ReqUrl(object): @@ -61,7 +83,7 @@ class ReqUrl(object): BASE = "https://www.facebook.com" MOBILE = "https://m.facebook.com/" STICKY = "https://0-edge-chat.facebook.com/pull" - PING = "https://0-channel-proxy-06-ash2.facebook.com/active_ping" + 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" @@ -75,6 +97,8 @@ class ReqUrl(object): THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&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/" + facebookEncoding = 'UTF-8' @@ -112,7 +136,7 @@ def str_base(number, base): def generateMessageID(client_id=None): k = now() l = int(random() * 4294967295) - return "<%s:%s-%s@mail.projektitan.com>" % (k, l, client_id) + return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id) def getSignatureID(): return hex(int(random() * 2147483648)) @@ -124,7 +148,17 @@ def generateOfflineThreadingID(): msgs = format(ret, 'b') + string return str(int(msgs, 2)) -def checkRequest(r, check_json=True): +def check_json(j): + if 'error' in j and j['error'] is not None: + if 'errorDescription' in j: + # 'errorDescription' is in the users own language! + raise Exception('Error #{} when sending request: {}'.format(j['error'], j['errorDescription'])) + elif 'debug_info' in j['error']: + raise Exception('Error #{} when sending request: {}'.format(j['error']['code'], repr(j['error']['debug_info']))) + else: + raise Exception('Error {} when sending request'.format(j['error'])) + +def checkRequest(r, do_json_check=True): if not r.ok: raise Exception('Error when sending request: Got {} response'.format(r.status_code)) @@ -133,11 +167,12 @@ def checkRequest(r, check_json=True): if content is None or len(content) == 0: raise Exception('Error when sending request: Got empty response') - if check_json: - j = json.loads(strip_to_json(content)) - if 'error' in j: - # 'errorDescription' is in the users own language! - raise Exception('Error #{} when sending request: {}'.format(j['error'], j['errorDescription'])) + if do_json_check: + try: + j = json.loads(strip_to_json(content)) + except Exception as e: + raise Exception('Error while parsing JSON: {}'.format(repr(content))) + check_json(j) return j else: - return r + return content diff --git a/tests.py b/tests.py index cc322d7..1758657 100644 --- a/tests.py +++ b/tests.py @@ -86,7 +86,7 @@ class TestFbchat(unittest.TestCase): u = users[0] # Test if values are set correctly - self.assertEqual(u.id, '4') + self.assertEqual(u.uid, '4') self.assertEqual(u.type, ThreadType.USER) self.assertEqual(u.photo[:4], 'http') self.assertEqual(u.url[:4], 'http') @@ -117,18 +117,21 @@ class TestFbchat(unittest.TestCase): self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local★', user_id, ThreadType.USER)) self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local★', group_id, ThreadType.GROUP)) + def test_fetchThreadList(self): + client.fetchThreadList(offset=0, limit=20) + def test_fetchThreadMessages(self): client.sendMessage('test_user_getThreadInfo★', thread_id=user_id, thread_type=ThreadType.USER) - info = client.fetchThreadMessages(offset=0, amount=2, thread_id=user_id, thread_type=ThreadType.USER) - self.assertEqual(info[0].author, 'fbid:' + client.uid) - self.assertEqual(info[0].body, 'test_user_getThreadInfo★') + messages = client.fetchThreadMessages(thread_id=user_id, limit=1) + self.assertEqual(messages[0].author, client.uid) + self.assertEqual(messages[0].text, 'test_user_getThreadInfo★') client.sendMessage('test_group_getThreadInfo★', thread_id=group_id, thread_type=ThreadType.GROUP) - info = client.fetchThreadMessages(offset=0, amount=2, thread_id=group_id, thread_type=ThreadType.GROUP) - self.assertEqual(info[0].author, 'fbid:' + client.uid) - self.assertEqual(info[0].body, 'test_group_getThreadInfo★') + messages = client.fetchThreadMessages(thread_id=group_id, limit=1) + self.assertEqual(messages[0].author, client.uid) + self.assertEqual(messages[0].text, 'test_group_getThreadInfo★') def test_listen(self): client.startListening() @@ -150,9 +153,9 @@ class TestFbchat(unittest.TestCase): client.changeThreadTitle('test_changeThreadTitle★', thread_id=user_id, thread_type=ThreadType.USER) def test_changeNickname(self): - client.changeNickname('test_changeNicknameSelf★', client.id, thread_id=user_id, thread_type=ThreadType.USER) + client.changeNickname('test_changeNicknameSelf★', client.uid, thread_id=user_id, thread_type=ThreadType.USER) client.changeNickname('test_changeNicknameOther★', user_id, thread_id=user_id, thread_type=ThreadType.USER) - client.changeNickname('test_changeNicknameSelf★', client.id, thread_id=group_id, thread_type=ThreadType.GROUP) + client.changeNickname('test_changeNicknameSelf★', client.uid, thread_id=group_id, thread_type=ThreadType.GROUP) client.changeNickname('test_changeNicknameOther★', user_id, thread_id=group_id, thread_type=ThreadType.GROUP) def test_changeThreadEmoji(self):