Added GraphQL requests

This commit is contained in:
Mads Marquart
2017-06-22 22:38:15 +02:00
parent c81d7d2bfb
commit 11e59e023c
12 changed files with 564 additions and 202 deletions

View File

@@ -2,8 +2,8 @@
.. module:: fbchat .. module:: fbchat
.. _faq: .. _faq:
Frequently asked questions FAQ
========================== ===
Version X broke my installation Version X broke my installation
------------------------------- -------------------------------

View File

@@ -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`, 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` 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 `<https://www.facebook.com/messages/>`_, Getting the ID of a group chat is fairly trivial though, since you only need to navigate to `<https://www.facebook.com/messages/>`_,
click on the group you want to find the ID of, and then read the id from the address bar. 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 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:: 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` 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('<name of user>') users = client.searchForUsers('<name of user>')
user = users[0] 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 name: {}".format(user.name))
print("User's profile picture url: {}".format(user.photo)) print("User's profile picture url: {}".format(user.photo))
print("User's main url: {}".format(user.url)) print("User's main url: {}".format(user.url))

View File

@@ -5,7 +5,7 @@ pushd %~dp0
REM Command file for Sphinx documentation REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" ( if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=python3.6 -msphinx set SPHINXBUILD=python -msphinx
) )
set SOURCEDIR=. set SOURCEDIR=.
set BUILDDIR=_build set BUILDDIR=_build

View File

@@ -11,13 +11,11 @@ This page will be periodically updated to show missing features and documentatio
Missing Functionality Missing Functionality
--------------------- ---------------------
- Implement Client.searchForThread
- This will use the graphql request API
- Implement Client.searchForMessage - Implement Client.searchForMessage
- This will use the graphql request API - This will use the graphql request API
- Implement chatting with pages - Implement chatting with pages properly
- This might require a new :class:`models.ThreadType`, something like ``ThreadType.PAGE`` - Write better FAQ
- Rework `Message` model, to make the whole process more streamlined - Explain usage of graphql
Documentation Documentation

View File

@@ -5,8 +5,8 @@ from fbchat.models import *
client = Client('<email>', '<password>') client = Client('<email>', '<password>')
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() client.logout()

View File

@@ -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 # Fetches a list of the 20 top threads you're currently chatting with
threads = client.fetchThreadList() threads = client.fetchThreadList()
# Fetches the next 10 threads # 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 # Gets the last 10 messages sent to the thread
messages = client.fetchThreadMessages(offset=0, amount=10, thread_id='<thread id>', thread_type=ThreadType) messages = client.fetchThreadMessages(thread_id='<thread id>', limit=10)
# Since the message come in reversed order, reverse them # Since the message come in reversed order, reverse them
messages.reverse() messages.reverse()
# Prints the content of all the messages # Prints the content of all the messages
for message in messages: for message in messages:
print(message.body) print(message.text)
# Here should be an example of `getUnread` # Here should be an example of `getUnread`

View File

@@ -10,6 +10,7 @@ from bs4 import BeautifulSoup as bs
from mimetypes import guess_type from mimetypes import guess_type
from .utils import * from .utils import *
from .models import * from .models import *
from .graphql import *
import time import time
@@ -22,7 +23,7 @@ class Client(object):
listening = False listening = False
"""Whether the client is listening. Used when creating an external event loop to determine when to stop listening""" """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. The ID of the client.
Can be used as `thread_id`. See :ref:`intro_threads` for more info. Can be used as `thread_id`. See :ref:`intro_threads` for more info.
@@ -53,7 +54,6 @@ class Client(object):
self.client = 'mercury' self.client = 'mercury'
self.default_thread_id = None self.default_thread_id = None
self.default_thread_type = None self.default_thread_type = None
self.threads = []
if not user_agent: if not user_agent:
user_agent = choice(USER_AGENTS) 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') 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) 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 END INTERNAL REQUEST METHODS
""" """
@@ -117,18 +138,23 @@ class Client(object):
LOGIN METHODS LOGIN METHODS
""" """
def _resetValues(self):
self.payloadDefault={}
self._session = requests.session()
self.req_counter = 1
self.seq = "0"
self.uid = None
def _postLogin(self): def _postLogin(self):
self.payloadDefault = {} self.payloadDefault = {}
self.client_id = hex(int(random()*2147483648))[2:] self.client_id = hex(int(random()*2147483648))[2:]
self.start_time = now() self.start_time = now()
self.id = str(self._session.cookies['c_user']) self.uid = str(self._session.cookies['c_user'])
self.user_channel = "p_" + self.id self.user_channel = "p_" + self.uid
self.ttstamp = '' self.ttstamp = ''
r = self._get(ReqUrl.BASE) r = self._get(ReqUrl.BASE)
soup = bs(r.text, "lxml") 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_dtsg = soup.find("input", {'name':'fb_dtsg'})['value']
self.fb_h = soup.find("input", {'name':'h'})['value'] self.fb_h = soup.find("input", {'name':'h'})['value']
for i in self.fb_dtsg: for i in self.fb_dtsg:
@@ -136,7 +162,7 @@ class Client(object):
self.ttstamp += '2' self.ttstamp += '2'
# Set default payload # Set default payload
self.payloadDefault['__rev'] = int(r.text.split('"client_revision":',1)[1].split(",",1)[0]) 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['__a'] = '1'
self.payloadDefault['ttstamp'] = self.ttstamp self.payloadDefault['ttstamp'] = self.ttstamp
self.payloadDefault['fb_dtsg'] = self.fb_dtsg self.payloadDefault['fb_dtsg'] = self.fb_dtsg
@@ -145,8 +171,8 @@ class Client(object):
'channel' : self.user_channel, 'channel' : self.user_channel,
'partition' : '-2', 'partition' : '-2',
'clientid' : self.client_id, 'clientid' : self.client_id,
'viewer_uid' : self.id, 'viewer_uid' : self.uid,
'uid' : self.id, 'uid' : self.uid,
'state' : 'active', 'state' : 'active',
'format' : 'json', 'format' : 'json',
'idle' : 0, 'idle' : 0,
@@ -267,9 +293,13 @@ class Client(object):
if not session_cookies or 'c_user' not in session_cookies: if not session_cookies or 'c_user' not in session_cookies:
return False return False
# Load cookies into current session try:
self._session.cookies = requests.cookies.merge_cookies(self._session.cookies, session_cookies) # Load cookies into current session
self._postLogin() self._session.cookies = requests.cookies.merge_cookies(self._session.cookies, session_cookies)
self._postLogin()
except Exception:
self._resetValues()
return False
return True return True
def login(self, email, password, max_tries=5): def login(self, email, password, max_tries=5):
@@ -320,12 +350,7 @@ class Client(object):
r = self._get(ReqUrl.LOGOUT, data) r = self._get(ReqUrl.LOGOUT, data)
# reset value self._resetValues()
self.payloadDefault={}
self._session = requests.session()
self.req_counter = 1
self.seq = "0"
self.id = None
return r.ok return r.ok
@@ -385,7 +410,7 @@ class Client(object):
""" """
data = { data = {
'viewer': self.id, 'viewer': self.uid,
} }
j = checkRequest(self._post(ReqUrl.ALL_USERS, query=data)) j = checkRequest(self._post(ReqUrl.ALL_USERS, query=data))
if not j['payload']: if not j['payload']:
@@ -399,51 +424,82 @@ class Client(object):
return users return users
def _searchFor(self, name): def searchForUsers(self, name, limit=1):
payload = { """
'value' : name.lower(), Find and get user by his/her name
'viewer' : self.id,
'rsp' : 'search',
'context' : 'search',
'path' : '/home.php',
'request_id' : str(uuid1()),
}
j = checkRequest(self._get(ReqUrl.SEARCH, payload)) :param name: Name of the user
:param limit: The max. amount of users to fetch
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
:return: :class:`models.User` objects, ordered by relevance :return: :class:`models.User` objects, ordered by relevance
:rtype: list :rtype: list
:raises: Exception if request failed :raises: Exception if request failed
""" """
entries = self._searchFor(name) j = self.graphql_request(GraphQL(query=GraphQL.SEARCH_USER, params={'search': name, 'limit': limit}))
return [k for k in entries if k.type == ThreadType.USER]
def searchForPages(self, name): return [graphql_to_user(node) for node in j[name]['users']['nodes']]
"""Find and get page by its name
: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 :return: :class:`models.Page` objects, ordered by relevance
:rtype: list :rtype: list
:raises: Exception if request failed :raises: Exception if request failed
""" """
entries = self._searchFor(name) j = self.graphql_request(GraphQL(query=GraphQL.SEARCH_PAGE, params={'search': name, 'limit': limit}))
return [k for k in entries if k.type == ThreadType.PAGE]
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): def _fetchInfo(self, *ids):
data = { data = {
@@ -459,16 +515,19 @@ class Client(object):
k = j['payload']['profiles'][_id] k = j['payload']['profiles'][_id]
if k['type'] in ['user', 'friend']: 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']) 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': elif k['type'] == 'page':
entries[_id] = Page(_id, url=k['uri'], city=None, likees=None, sub_text=None, photo=k['thumbSrc'], name=k['name']) 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 return entries
def fetchUserInfo(self, *user_ids): 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 :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 :rtype: dict
:raises: Exception if request failed :raises: Exception if request failed
""" """
@@ -482,10 +541,11 @@ class Client(object):
return users return users
def fetchPageInfo(self, *page_ids): 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 :param page_ids: One or more page ID(s) to query
:return: :class:`models.Page` objects :return: :class:`models.Page` objects, labeled by their ID
:rtype: dict :rtype: dict
:raises: Exception if request failed :raises: Exception if request failed
""" """
@@ -498,95 +558,79 @@ class Client(object):
return users return users
def fetchThreadMessages(self, offset=0, amount=20, thread_id=None, thread_type=ThreadType.USER): def fetchThreadMessages(self, thread_id=None, limit=20, before=None):
"""Get the last messages in a thread """
Get the last messages in a thread
.. todo:: :param thread_id: User/Group ID to default to. See :ref:`intro_threads`
Fix this. Facebook broke it somehow. Also, clean up return values :param limit: Max. number of messages to retrieve
:param before: A timestamp, indicating from which point to retrieve messages
:param offset: Where to start retrieving messages from :type limit: int
:param amount: Number of messages to retrieve :type before: int
:param thread_id: User/Group ID to retrieve from. See :ref:`intro_threads` :return: :class:`models.Message` objects
:param thread_type: See :ref:`intro_threads`
:type offset: int
:type amount: int
:type thread_type: models.ThreadType
:return: Dictionaries, containing message data
:rtype: list :rtype: list
:raises: Exception if request failed :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: if j['message_thread'] is None:
raise Exception('`amount` must be a positive integer, got {}'.format(amount)) raise Exception('Could not fetch thread {}'.format(thread_id))
if thread_type == ThreadType.USER: return list(reversed([graphql_to_message(message) for message in j['message_thread']['messages']['nodes']]))
key = 'user_ids'
elif thread_type == ThreadType.GROUP:
key = 'thread_fbids'
data = { def fetchThreadList(self, offset=0, limit=20):
'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):
"""Get thread list of your facebook account """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 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 offset: int
:type amount: int :type limit: int
:return: Dictionaries, containing thread data :return: :class:`models.Thread` objects
:rtype: list :rtype: list
:raises: Exception if request failed :raises: Exception if request failed
""" """
if amount > 20 or amount < 1: if limit > 20 or limit < 1:
raise Exception('`amount` should be between 1 and 20') raise Exception('`limit` should be between 1 and 20')
data = { data = {
'client' : self.client, 'client' : self.client,
'inbox[offset]' : start, 'inbox[offset]' : offset,
'inbox[limit]' : amount, 'inbox[limit]' : limit,
} }
j = checkRequest(self._post(ReqUrl.THREADS, data)) 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 = {} participants = {}
try: for p in j['payload']['participants']:
for participant in j['payload']['participants']: if p['type'] == 'page':
participants[participant["fbid"]] = participant["name"] participants[p['fbid']] = Page(p['fbid'], url=p['href'], photo=p['image_src'], name=p['name'])
except Exception: elif p['type'] == 'user':
log.exception('Exception while getting names for people in getThreadList. {}'.format(j)) 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 entries = []
threadIDs = [getattr(x, "thread_id") for x in self.threads] for k in j['payload']['threads']:
for thread in j['payload']['threads']: if k['thread_type'] == 1:
if thread["thread_id"] not in threadIDs: if k['other_user_fbid'] not in participants:
try: raise Exception('A thread was not in participants: {}'.format(j['payload']))
thread["other_user_name"] = participants[int(thread["other_user_fbid"])] entries.append(participants[k['other_user_fbid']])
except: elif k['thread_type'] == 2:
thread["other_user_name"] = "" entries.append(Group(k['thread_fbid'], participants=[p.strip('fbid:') for p in k['participants']], photo=k['image_src'], name=k['name']))
t = Thread(**thread) else:
self.threads.append(t) raise Exception('A thread had an unknown thread type: {}'.format(k))
return self.threads return entries
def fetchUnread(self): def fetchUnread(self):
""" """
@@ -624,7 +668,7 @@ class Client(object):
date = datetime.now() date = datetime.now()
data = { data = {
'client': self.client, 'client': self.client,
'author' : 'fbid:' + str(self.id), 'author' : 'fbid:' + str(self.uid),
'timestamp' : timestamp, 'timestamp' : timestamp,
'timestamp_absolute' : 'Today', 'timestamp_absolute' : 'Today',
'timestamp_relative' : str(date.hour) + ":" + str(date.minute).zfill(2), '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)) log.warning("Got multiple message ids' back: {}".format(message_ids))
message_id = message_ids[0] message_id = message_ids[0]
except (KeyError, IndexError) as e: 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 return message_id
def sendMessage(self, message, thread_id=None, thread_type=ThreadType.USER): def sendMessage(self, message, thread_id=None, thread_type=ThreadType.USER):
@@ -694,7 +735,7 @@ class Client(object):
data['body'] = message or '' data['body'] = message or ''
data['has_attachment'] = False data['has_attachment'] = False
data['specific_to_list[0]'] = 'fbid:' + thread_id 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) return self._doSendRequest(data)
@@ -716,7 +757,7 @@ class Client(object):
data['action_type'] = 'ma-type:user-generated-message' data['action_type'] = 'ma-type:user-generated-message'
data['has_attachment'] = False data['has_attachment'] = False
data['specific_to_list[0]'] = 'fbid:' + thread_id 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: if emoji:
data['body'] = emoji data['body'] = emoji
@@ -758,7 +799,7 @@ class Client(object):
data['body'] = message or '' data['body'] = message or ''
data['has_attachment'] = True data['has_attachment'] = True
data['specific_to_list[0]'] = 'fbid:' + str(thread_id) 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 data['image_ids[0]'] = image_id
@@ -822,7 +863,7 @@ class Client(object):
user_ids = set(user_ids) user_ids = set(user_ids)
for i, user_id in enumerate(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') raise Exception('Error when adding users: Cannot add self to group thread')
else: else:
data['log_message_data[added_participants][' + str(i) + ']'] = "fbid:" + str(user_id) data['log_message_data[added_participants][' + str(i) + ']'] = "fbid:" + str(user_id)
@@ -944,7 +985,7 @@ class Client(object):
"data": { "data": {
"action": "ADD_REACTION", "action": "ADD_REACTION",
"client_mutation_id": "1", "client_mutation_id": "1",
"actor_id": self.id, "actor_id": self.uid,
"message_id": str(message_id), "message_id": str(message_id),
"reaction": reaction.value "reaction": reaction.value
} }
@@ -1039,17 +1080,19 @@ class Client(object):
LISTEN METHODS LISTEN METHODS
""" """
def _ping(self, sticky): def _ping(self, sticky, pool):
data = { data = {
'channel': self.user_channel, 'channel': self.user_channel,
'clientid': self.client_id, 'clientid': self.client_id,
'partition': -2, 'partition': -2,
'cap': 0, 'cap': 0,
'uid': self.id, 'uid': self.uid,
'sticky': sticky, 'sticky_token': sticky,
'viewer_uid': self.id '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): def _fetchSticky(self):
"""Call pull api to get sticky and pool parameter, newer api needs these parameters to work""" """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 if 'ms' not in content: return
log.debug("Received {}".format(content["ms"]))
for m in content["ms"]: for m in content["ms"]:
mtype = m.get("type") mtype = m.get("type")
try: try:
@@ -1265,7 +1307,7 @@ class Client(object):
:rtype: bool :rtype: bool
""" """
try: try:
#if markAlive: self._ping(self.sticky) if markAlive: self._ping(self.sticky, self.pool)
try: try:
content = self._pullMessage(self.sticky, self.pool) content = self._pullMessage(self.sticky, self.pool)
if content: self._parseMessage(content) if content: self._parseMessage(content)

231
fbchat/graphql.py Normal file
View File

@@ -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(<pic_size>).height(<pic_size>) {
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(<search> = '', <limit> = 1) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
}
}
}
""" + FRAGMENT_USER
SEARCH_GROUP = """
Query SearchGroup(<search> = '', <limit> = 1, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@Group
}
}
}
}
""" + FRAGMENT_GROUP
SEARCH_PAGE = """
Query SearchPage(<search> = '', <limit> = 1) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
""" + FRAGMENT_PAGE
SEARCH_THREAD = """
Query SearchThread(<search> = '', <limit> = 1) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
""" + FRAGMENT_USER + FRAGMENT_GROUP + FRAGMENT_PAGE

View File

@@ -3,19 +3,20 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import enum import enum
class Thread(object): class Thread(object):
#: The unique identifier of the user. Can be used a `thread_id`. See :ref:`intro_threads` for more info #: The unique identifier of the thread. Can be used a `thread_id`. See :ref:`intro_threads` for more info
id = None uid = str
#: Specifies the type of thread. Uses ThreadType #: Specifies the type of thread. Can be used a `thread_type`. See :ref:`intro_threads` for more info
type = None type = None
#: The thread's picture #: The thread's picture
photo = None photo = str
#: The name of the thread #: 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""" """Represents a Facebook thread"""
self.id = str(_id) self.uid = str(uid)
self.type = _type self.type = _type
self.photo = photo self.photo = photo
self.name = name self.name = name
@@ -24,60 +25,112 @@ class Thread(object):
return self.__unicode__() return self.__unicode__()
def __unicode__(self): def __unicode__(self):
return '<{} {} ({})>'.format(self.type.name, self.name, self.id) return '<{} {} ({})>'.format(self.type.name, self.name, self.uid)
class User(Thread): class User(Thread):
#: The profile url #: The profile url
url = None url = str
#: The users first name #: The users first name
first_name = None first_name = str
#: The users last name #: The users last name
last_name = None last_name = str
#: Whether the user and the client are friends #: Whether the user and the client are friends
is_friend = None is_friend = bool
#: The user's gender #: 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`""" """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.url = url
self.first_name = first_name self.first_name = first_name
self.last_name = last_name self.last_name = last_name
self.is_friend = is_friend self.is_friend = is_friend
self.gender = gender self.gender = gender
self.affinity = affinity
class Group(Thread): 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`""" """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): class Page(Thread):
#: The page's custom url #: The page's custom url
url = None url = str
#: The name of the page's location city #: The name of the page's location city
city = None city = str
#: Amount of likes the page has #: Amount of likes the page has
likees = None likes = int
#: Some extra information about the page #: 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`""" """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.url = url
self.city = city self.city = city
self.likees = likees self.likes = likes
self.sub_text = sub_text self.sub_title = sub_title
self.category = category
class Message(object): class Message(object):
"""Represents a message. Currently just acts as a dict""" #: The message ID
def __init__(self, **entries): uid = str
self.__dict__.update(entries) #: 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): class Enum(enum.Enum):
"""Used internally by fbchat to support enumerations""" """Used internally by fbchat to support enumerations"""

View File

@@ -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" "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 = { GENDERS = {
# For standard requests
0: 'unknown', 0: 'unknown',
1: 'female_singular', 1: 'female_singular',
2: 'male_singular', 2: 'male_singular',
@@ -45,6 +52,21 @@ GENDERS = {
9: 'male_plural', 9: 'male_plural',
10: 'neuter_plural', 10: 'neuter_plural',
11: 'unknown_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): class ReqUrl(object):
@@ -61,7 +83,7 @@ class ReqUrl(object):
BASE = "https://www.facebook.com" BASE = "https://www.facebook.com"
MOBILE = "https://m.facebook.com/" MOBILE = "https://m.facebook.com/"
STICKY = "https://0-edge-chat.facebook.com/pull" 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" UPLOAD = "https://upload.facebook.com/ajax/mercury/upload.php"
INFO = "https://www.facebook.com/chat/user_info/" INFO = "https://www.facebook.com/chat/user_info/"
CONNECT = "https://www.facebook.com/ajax/add_friend/action.php?dpr=1" 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" THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1"
MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation" MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation"
TYPING = "https://www.facebook.com/ajax/messaging/typ.php" TYPING = "https://www.facebook.com/ajax/messaging/typ.php"
GRAPHQL = "https://www.facebook.com/api/graphqlbatch/"
facebookEncoding = 'UTF-8' facebookEncoding = 'UTF-8'
@@ -112,7 +136,7 @@ def str_base(number, base):
def generateMessageID(client_id=None): def generateMessageID(client_id=None):
k = now() k = now()
l = int(random() * 4294967295) 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(): def getSignatureID():
return hex(int(random() * 2147483648)) return hex(int(random() * 2147483648))
@@ -124,7 +148,17 @@ def generateOfflineThreadingID():
msgs = format(ret, 'b') + string msgs = format(ret, 'b') + string
return str(int(msgs, 2)) 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: if not r.ok:
raise Exception('Error when sending request: Got {} response'.format(r.status_code)) 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: if content is None or len(content) == 0:
raise Exception('Error when sending request: Got empty response') raise Exception('Error when sending request: Got empty response')
if check_json: if do_json_check:
j = json.loads(strip_to_json(content)) try:
if 'error' in j: j = json.loads(strip_to_json(content))
# 'errorDescription' is in the users own language! except Exception as e:
raise Exception('Error #{} when sending request: {}'.format(j['error'], j['errorDescription'])) raise Exception('Error while parsing JSON: {}'.format(repr(content)))
check_json(j)
return j return j
else: else:
return r return content

View File

@@ -86,7 +86,7 @@ class TestFbchat(unittest.TestCase):
u = users[0] u = users[0]
# Test if values are set correctly # 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.type, ThreadType.USER)
self.assertEqual(u.photo[:4], 'http') self.assertEqual(u.photo[:4], 'http')
self.assertEqual(u.url[: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★', user_id, ThreadType.USER))
self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local★', group_id, ThreadType.GROUP)) 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): def test_fetchThreadMessages(self):
client.sendMessage('test_user_getThreadInfo★', thread_id=user_id, thread_type=ThreadType.USER) 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) messages = client.fetchThreadMessages(thread_id=user_id, limit=1)
self.assertEqual(info[0].author, 'fbid:' + client.uid) self.assertEqual(messages[0].author, client.uid)
self.assertEqual(info[0].body, 'test_user_getThreadInfo★') self.assertEqual(messages[0].text, 'test_user_getThreadInfo★')
client.sendMessage('test_group_getThreadInfo★', thread_id=group_id, thread_type=ThreadType.GROUP) 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) messages = client.fetchThreadMessages(thread_id=group_id, limit=1)
self.assertEqual(info[0].author, 'fbid:' + client.uid) self.assertEqual(messages[0].author, client.uid)
self.assertEqual(info[0].body, 'test_group_getThreadInfo★') self.assertEqual(messages[0].text, 'test_group_getThreadInfo★')
def test_listen(self): def test_listen(self):
client.startListening() client.startListening()
@@ -150,9 +153,9 @@ class TestFbchat(unittest.TestCase):
client.changeThreadTitle('test_changeThreadTitle★', thread_id=user_id, thread_type=ThreadType.USER) client.changeThreadTitle('test_changeThreadTitle★', thread_id=user_id, thread_type=ThreadType.USER)
def test_changeNickname(self): 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_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) client.changeNickname('test_changeNicknameOther★', user_id, thread_id=group_id, thread_type=ThreadType.GROUP)
def test_changeThreadEmoji(self): def test_changeThreadEmoji(self):