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):