Merge remote-tracking branch 'origin/master' into active_status
This commit is contained in:
@@ -13,7 +13,7 @@ If you are looking for information on a specific function, class, or method, thi
|
|||||||
Client
|
Client
|
||||||
------
|
------
|
||||||
|
|
||||||
This is the main class of `fbchat`, which contains all the methods you use to interract with Facebook.
|
This is the main class of `fbchat`, which contains all the methods you use to interact with Facebook.
|
||||||
You can extend this class, and overwrite the events, to provide custom event handling (mainly used while listening)
|
You can extend this class, and overwrite the events, to provide custom event handling (mainly used while listening)
|
||||||
|
|
||||||
.. autoclass:: Client(email, password, user_agent=None, max_tries=5, session_cookies=None, logging_level=logging.INFO)
|
.. autoclass:: Client(email, password, user_agent=None, max_tries=5, session_cookies=None, logging_level=logging.INFO)
|
||||||
|
@@ -18,7 +18,7 @@ This will show basic usage of `fbchat`
|
|||||||
Interacting with Threads
|
Interacting with Threads
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
This will interract with the thread in every way `fbchat` supports
|
This will interact with the thread in every way `fbchat` supports
|
||||||
|
|
||||||
.. literalinclude:: ../examples/interract.py
|
.. literalinclude:: ../examples/interract.py
|
||||||
|
|
||||||
|
@@ -8,7 +8,7 @@ FAQ
|
|||||||
Version X broke my installation
|
Version X broke my installation
|
||||||
-------------------------------
|
-------------------------------
|
||||||
|
|
||||||
We try to provide backwards compatability where possible, but since we're not part of Facebook,
|
We try to provide backwards compatibility where possible, but since we're not part of Facebook,
|
||||||
most of the things may be broken at any point in time
|
most of the things may be broken at any point in time
|
||||||
|
|
||||||
Downgrade to an earlier version of fbchat, run this command
|
Downgrade to an earlier version of fbchat, run this command
|
||||||
|
@@ -6,7 +6,7 @@ Introduction
|
|||||||
============
|
============
|
||||||
|
|
||||||
`fbchat` uses your email and password to communicate with the Facebook server.
|
`fbchat` uses your email and password to communicate with the Facebook server.
|
||||||
That means that you should always store your password in a seperate file, in case e.g. someone looks over your shoulder while you're writing code.
|
That means that you should always store your password in a separate file, in case e.g. someone looks over your shoulder while you're writing code.
|
||||||
You should also make sure that the file's access control is appropriately restrictive
|
You should also make sure that the file's access control is appropriately restrictive
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Logging In
|
|||||||
----------
|
----------
|
||||||
|
|
||||||
Simply create an instance of :class:`Client`. If you have two factor authentication enabled, type the code in the terminal prompt
|
Simply create an instance of :class:`Client`. If you have two factor authentication enabled, type the code in the terminal prompt
|
||||||
(If you want to supply the code in another fasion, overwrite :func:`Client.on2FACode`)::
|
(If you want to supply the code in another fashion, overwrite :func:`Client.on2FACode`)::
|
||||||
|
|
||||||
from fbchat import Client
|
from fbchat import Client
|
||||||
from fbchat.models import *
|
from fbchat.models import *
|
||||||
@@ -50,7 +50,7 @@ A thread can refer to two things: A Messenger group chat or a single Facebook us
|
|||||||
|
|
||||||
:class:`models.ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``.
|
:class:`models.ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``.
|
||||||
These will specify whether the thread is a single user chat or a group chat.
|
These will specify whether the thread is a single user chat or a group chat.
|
||||||
This is required for many of `fbchat`'s functions, since Facebook differetiates between these two internally
|
This is required for many of `fbchat`'s functions, since Facebook differentiates between these two internally
|
||||||
|
|
||||||
Searching for group chats and finding their ID can be done via. :func:`Client.searchForGroups`,
|
Searching for group chats and finding their ID can be done via. :func:`Client.searchForGroups`,
|
||||||
and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching`
|
and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching`
|
||||||
@@ -141,7 +141,7 @@ Sessions
|
|||||||
--------
|
--------
|
||||||
|
|
||||||
`fbchat` provides functions to retrieve and set the session cookies.
|
`fbchat` provides functions to retrieve and set the session cookies.
|
||||||
This will enable you to store the session cookies in a seperate file, so that you don't have to login each time you start your script.
|
This will enable you to store the session cookies in a separate file, so that you don't have to login each time you start your script.
|
||||||
Use :func:`Client.getSession` to retrieve the cookies::
|
Use :func:`Client.getSession` to retrieve the cookies::
|
||||||
|
|
||||||
session_cookies = client.getSession()
|
session_cookies = client.getSession()
|
||||||
|
@@ -15,7 +15,7 @@ To use the tests, copy ``tests/data.json`` to ``tests/my_data.json`` or type the
|
|||||||
Please remember to test all supported python versions.
|
Please remember to test all supported python versions.
|
||||||
If you've made any changes to the 2FA functionality, test it with a 2FA enabled account.
|
If you've made any changes to the 2FA functionality, test it with a 2FA enabled account.
|
||||||
|
|
||||||
If you only want to execute specific tests, pass the function names in the commandline (not including the `test_` prefix). Example::
|
If you only want to execute specific tests, pass the function names in the command line (not including the `test_` prefix). Example::
|
||||||
|
|
||||||
$ python tests.py sendMessage sessions sendEmoji
|
$ python tests.py sendMessage sessions sendEmoji
|
||||||
|
|
||||||
|
@@ -15,7 +15,7 @@ from __future__ import unicode_literals
|
|||||||
from .client import *
|
from .client import *
|
||||||
|
|
||||||
__title__ = 'fbchat'
|
__title__ = 'fbchat'
|
||||||
__version__ = '1.4.0'
|
__version__ = '1.4.2'
|
||||||
__description__ = 'Facebook Chat (Messenger) for Python'
|
__description__ = 'Facebook Chat (Messenger) for Python'
|
||||||
|
|
||||||
__copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim'
|
__copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim'
|
||||||
|
371
fbchat/client.py
371
fbchat/client.py
@@ -170,10 +170,12 @@ class Client(object):
|
|||||||
|
|
||||||
def graphql_requests(self, *queries):
|
def graphql_requests(self, *queries):
|
||||||
"""
|
"""
|
||||||
.. todo::
|
:param queries: Zero or more GraphQL objects
|
||||||
Documenting this
|
:type queries: GraphQL
|
||||||
|
|
||||||
:raises: FBchatException if request failed
|
:raises: FBchatException if request failed
|
||||||
|
:return: A tuple containing json graphql queries
|
||||||
|
:rtype: tuple
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return tuple(self._graphql({
|
return tuple(self._graphql({
|
||||||
@@ -240,22 +242,6 @@ class Client(object):
|
|||||||
self.payloadDefault['ttstamp'] = self.ttstamp
|
self.payloadDefault['ttstamp'] = self.ttstamp
|
||||||
self.payloadDefault['fb_dtsg'] = self.fb_dtsg
|
self.payloadDefault['fb_dtsg'] = self.fb_dtsg
|
||||||
|
|
||||||
self.form = {
|
|
||||||
'channel' : self.user_channel,
|
|
||||||
'partition' : '-2',
|
|
||||||
'clientid' : self.client_id,
|
|
||||||
'viewer_uid' : self.uid,
|
|
||||||
'uid' : self.uid,
|
|
||||||
'state' : 'active',
|
|
||||||
'format' : 'json',
|
|
||||||
'idle' : 0,
|
|
||||||
'cap' : '8'
|
|
||||||
}
|
|
||||||
|
|
||||||
self.prev = now()
|
|
||||||
self.tmp_prev = now()
|
|
||||||
self.last_sync = now()
|
|
||||||
|
|
||||||
def _login(self):
|
def _login(self):
|
||||||
if not (self.email and self.password):
|
if not (self.email and self.password):
|
||||||
raise FBchatUserError("Email and password not found.")
|
raise FBchatUserError("Email and password not found.")
|
||||||
@@ -459,7 +445,8 @@ class Client(object):
|
|||||||
return given_thread_id, given_thread_type
|
return given_thread_id, given_thread_type
|
||||||
|
|
||||||
def setDefaultThread(self, thread_id, thread_type):
|
def setDefaultThread(self, thread_id, thread_type):
|
||||||
"""Sets default thread to send messages to
|
"""
|
||||||
|
Sets default thread to send messages to
|
||||||
|
|
||||||
:param thread_id: User/Group ID to default to. See :ref:`intro_threads`
|
:param thread_id: User/Group ID to default to. See :ref:`intro_threads`
|
||||||
:param thread_type: See :ref:`intro_threads`
|
:param thread_type: See :ref:`intro_threads`
|
||||||
@@ -517,7 +504,7 @@ class Client(object):
|
|||||||
|
|
||||||
return users
|
return users
|
||||||
|
|
||||||
def searchForUsers(self, name, limit=1):
|
def searchForUsers(self, name, limit=10):
|
||||||
"""
|
"""
|
||||||
Find and get user by his/her name
|
Find and get user by his/her name
|
||||||
|
|
||||||
@@ -532,7 +519,7 @@ class Client(object):
|
|||||||
|
|
||||||
return [graphql_to_user(node) for node in j[name]['users']['nodes']]
|
return [graphql_to_user(node) for node in j[name]['users']['nodes']]
|
||||||
|
|
||||||
def searchForPages(self, name, limit=1):
|
def searchForPages(self, name, limit=10):
|
||||||
"""
|
"""
|
||||||
Find and get page by its name
|
Find and get page by its name
|
||||||
|
|
||||||
@@ -546,7 +533,7 @@ class Client(object):
|
|||||||
|
|
||||||
return [graphql_to_page(node) for node in j[name]['pages']['nodes']]
|
return [graphql_to_page(node) for node in j[name]['pages']['nodes']]
|
||||||
|
|
||||||
def searchForGroups(self, name, limit=1):
|
def searchForGroups(self, name, limit=10):
|
||||||
"""
|
"""
|
||||||
Find and get group thread by its name
|
Find and get group thread by its name
|
||||||
|
|
||||||
@@ -561,7 +548,7 @@ class Client(object):
|
|||||||
|
|
||||||
return [graphql_to_group(node) for node in j['viewer']['groups']['nodes']]
|
return [graphql_to_group(node) for node in j['viewer']['groups']['nodes']]
|
||||||
|
|
||||||
def searchForThreads(self, name, limit=1):
|
def searchForThreads(self, name, limit=10):
|
||||||
"""
|
"""
|
||||||
Find and get a thread by its name
|
Find and get a thread by its name
|
||||||
|
|
||||||
@@ -852,14 +839,22 @@ class Client(object):
|
|||||||
'id': thread_id,
|
'id': thread_id,
|
||||||
'message_limit': limit,
|
'message_limit': limit,
|
||||||
'load_messages': True,
|
'load_messages': True,
|
||||||
'load_read_receipts': False,
|
'load_read_receipts': True,
|
||||||
'before': before
|
'before': before
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if j.get('message_thread') is None:
|
if j.get('message_thread') is None:
|
||||||
raise FBchatException('Could not fetch thread {}: {}'.format(thread_id, j))
|
raise FBchatException('Could not fetch thread {}: {}'.format(thread_id, j))
|
||||||
|
|
||||||
return list(reversed([graphql_to_message(message) for message in j['message_thread']['messages']['nodes']]))
|
messages = list(reversed([graphql_to_message(message) for message in j['message_thread']['messages']['nodes']]))
|
||||||
|
read_receipts = j['message_thread']['read_receipts']['nodes']
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
for receipt in read_receipts:
|
||||||
|
if int(receipt['watermark']) >= int(message.timestamp):
|
||||||
|
message.read_by.append(receipt['actor']['id'])
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
def fetchThreadList(self, offset=None, limit=20, thread_location=ThreadLocation.INBOX, before=None):
|
def fetchThreadList(self, offset=None, limit=20, thread_location=ThreadLocation.INBOX, before=None):
|
||||||
"""Get thread list of your facebook account
|
"""Get thread list of your facebook account
|
||||||
@@ -1130,7 +1125,56 @@ class Client(object):
|
|||||||
data['specific_to_list[0]'] = "fbid:{}".format(thread_id)
|
data['specific_to_list[0]'] = "fbid:{}".format(thread_id)
|
||||||
return self._doSendRequest(data)
|
return self._doSendRequest(data)
|
||||||
|
|
||||||
def _upload(self, files):
|
def unsend(self, mid):
|
||||||
|
"""
|
||||||
|
Unsends a message (removes for everyone)
|
||||||
|
|
||||||
|
:param mid: :ref:`Message ID <intro_message_ids>` of the message to unsend
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'message_id': mid,
|
||||||
|
}
|
||||||
|
r = self._post(self.req_url.UNSEND, data)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
def _sendLocation(self, location, current=True, thread_id=None, thread_type=None):
|
||||||
|
thread_id, thread_type = self._getThread(thread_id, thread_type)
|
||||||
|
data = self._getSendData(thread_id=thread_id, thread_type=thread_type)
|
||||||
|
data['action_type'] = 'ma-type:user-generated-message'
|
||||||
|
data['location_attachment[coordinates][latitude]'] = location.latitude
|
||||||
|
data['location_attachment[coordinates][longitude]'] = location.longitude
|
||||||
|
data['location_attachment[is_current_location]'] = current
|
||||||
|
return self._doSendRequest(data)
|
||||||
|
|
||||||
|
def sendLocation(self, location, thread_id=None, thread_type=None):
|
||||||
|
"""
|
||||||
|
Sends a given location to a thread as the user's current location
|
||||||
|
|
||||||
|
:param location: Location to send
|
||||||
|
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: See :ref:`intro_threads`
|
||||||
|
:type location: models.LocationAttachment
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
:return: :ref:`Message ID <intro_message_ids>` of the sent message
|
||||||
|
:raises: FBchatException if request failed
|
||||||
|
"""
|
||||||
|
self._sendLocation(location=location, current=True, thread_id=thread_id, thread_type=thread_type)
|
||||||
|
|
||||||
|
def sendPinnedLocation(self, location, thread_id=None, thread_type=None):
|
||||||
|
"""
|
||||||
|
Sends a given location to a thread as a pinned location
|
||||||
|
|
||||||
|
:param location: Location to send
|
||||||
|
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: See :ref:`intro_threads`
|
||||||
|
:type location: models.LocationAttachment
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
:return: :ref:`Message ID <intro_message_ids>` of the sent message
|
||||||
|
:raises: FBchatException if request failed
|
||||||
|
"""
|
||||||
|
self._sendLocation(location=location, current=False, thread_id=thread_id, thread_type=thread_type)
|
||||||
|
|
||||||
|
def _upload(self, files, voice_clip=False):
|
||||||
"""
|
"""
|
||||||
Uploads files to Facebook
|
Uploads files to Facebook
|
||||||
|
|
||||||
@@ -1140,7 +1184,12 @@ class Client(object):
|
|||||||
Returns a list of tuples with a file's ID and mimetype
|
Returns a list of tuples with a file's ID and mimetype
|
||||||
"""
|
"""
|
||||||
file_dict = {'upload_{}'.format(i): f for i, f in enumerate(files)}
|
file_dict = {'upload_{}'.format(i): f for i, f in enumerate(files)}
|
||||||
j = self._postFile(self.req_url.UPLOAD, files=file_dict, fix_request=True, as_json=True)
|
|
||||||
|
data = {
|
||||||
|
"voice_clip": voice_clip,
|
||||||
|
}
|
||||||
|
|
||||||
|
j = self._postFile(self.req_url.UPLOAD, files=file_dict, query=data, fix_request=True, as_json=True)
|
||||||
|
|
||||||
if len(j['payload']['metadata']) != len(files):
|
if len(j['payload']['metadata']) != len(files):
|
||||||
raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files))
|
raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files))
|
||||||
@@ -1184,7 +1233,7 @@ class Client(object):
|
|||||||
"""
|
"""
|
||||||
Sends local files to a thread
|
Sends local files to a thread
|
||||||
|
|
||||||
:param file_path: Paths of files to upload and send
|
:param file_paths: Paths of files to upload and send
|
||||||
:param message: Additional message
|
:param message: Additional message
|
||||||
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
|
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
|
||||||
:param thread_type: See :ref:`intro_threads`
|
:param thread_type: See :ref:`intro_threads`
|
||||||
@@ -1197,6 +1246,39 @@ class Client(object):
|
|||||||
files = self._upload(x)
|
files = self._upload(x)
|
||||||
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
|
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
|
||||||
|
|
||||||
|
def sendRemoteVoiceClips(self, clip_urls, message=None, thread_id=None, thread_type=ThreadType.USER):
|
||||||
|
"""
|
||||||
|
Sends voice clips from URLs to a thread
|
||||||
|
|
||||||
|
:param clip_urls: URLs of clips to upload and send
|
||||||
|
:param message: Additional message
|
||||||
|
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: See :ref:`intro_threads`
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
:return: :ref:`Message ID <intro_message_ids>` of the sent files
|
||||||
|
:raises: FBchatException if request failed
|
||||||
|
"""
|
||||||
|
clip_urls = require_list(clip_urls)
|
||||||
|
files = self._upload(get_files_from_urls(clip_urls), voice_clip=True)
|
||||||
|
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
|
||||||
|
|
||||||
|
def sendLocalVoiceClips(self, clip_paths, message=None, thread_id=None, thread_type=ThreadType.USER):
|
||||||
|
"""
|
||||||
|
Sends local voice clips to a thread
|
||||||
|
|
||||||
|
:param clip_paths: Paths of clips to upload and send
|
||||||
|
:param message: Additional message
|
||||||
|
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: See :ref:`intro_threads`
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
:return: :ref:`Message ID <intro_message_ids>` of the sent files
|
||||||
|
:raises: FBchatException if request failed
|
||||||
|
"""
|
||||||
|
clip_paths = require_list(clip_paths)
|
||||||
|
with get_files_from_paths(clip_paths) as x:
|
||||||
|
files = self._upload(x, voice_clip=True)
|
||||||
|
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
|
||||||
|
|
||||||
def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False):
|
def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False):
|
||||||
"""
|
"""
|
||||||
Deprecated. Use :func:`fbchat.Client._sendFiles` instead
|
Deprecated. Use :func:`fbchat.Client._sendFiles` instead
|
||||||
@@ -1501,27 +1583,26 @@ class Client(object):
|
|||||||
|
|
||||||
def reactToMessage(self, message_id, reaction):
|
def reactToMessage(self, message_id, reaction):
|
||||||
"""
|
"""
|
||||||
Reacts to a message
|
Reacts to a message, or removes reaction
|
||||||
|
|
||||||
:param message_id: :ref:`Message ID <intro_message_ids>` to react to
|
:param message_id: :ref:`Message ID <intro_message_ids>` to react to
|
||||||
:param reaction: Reaction emoji to use
|
:param reaction: Reaction emoji to use, if None removes reaction
|
||||||
:type reaction: models.MessageReaction
|
:type reaction: models.MessageReaction or None
|
||||||
:raises: FBchatException if request failed
|
:raises: FBchatException if request failed
|
||||||
"""
|
"""
|
||||||
full_data = {
|
data = {
|
||||||
"doc_id": 1491398900900362,
|
"doc_id": 1491398900900362,
|
||||||
"variables": json.dumps({
|
"variables": json.dumps({
|
||||||
"data": {
|
"data": {
|
||||||
"action": "ADD_REACTION",
|
"action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
|
||||||
"client_mutation_id": "1",
|
"client_mutation_id": "1",
|
||||||
"actor_id": self.uid,
|
"actor_id": self.uid,
|
||||||
"message_id": str(message_id),
|
"message_id": str(message_id),
|
||||||
"reaction": reaction.value
|
"reaction": reaction.value if reaction else None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
self._post(self.req_url.MESSAGE_REACTION, data, fix_request=True, as_json=True)
|
||||||
j = self._post(self.req_url.MESSAGE_REACTION, full_data, fix_request=True, as_json=True)
|
|
||||||
|
|
||||||
def createPlan(self, plan, thread_id=None):
|
def createPlan(self, plan, thread_id=None):
|
||||||
"""
|
"""
|
||||||
@@ -1729,7 +1810,7 @@ class Client(object):
|
|||||||
}
|
}
|
||||||
|
|
||||||
for thread_id in thread_ids:
|
for thread_id in thread_ids:
|
||||||
data["ids[{}]".format(thread_id)] = read
|
data["ids[{}]".format(thread_id)] = 'true' if read else 'false'
|
||||||
|
|
||||||
r = self._post(self.req_url.READ_STATUS, data)
|
r = self._post(self.req_url.READ_STATUS, data)
|
||||||
return r.ok
|
return r.ok
|
||||||
@@ -1975,48 +2056,32 @@ class Client(object):
|
|||||||
LISTEN METHODS
|
LISTEN METHODS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _ping(self, sticky, pool):
|
def _ping(self):
|
||||||
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.uid,
|
'uid': self.uid,
|
||||||
'sticky_token': sticky,
|
'sticky_token': self.sticky,
|
||||||
'sticky_pool': pool,
|
'sticky_pool': self.pool,
|
||||||
'viewer_uid': self.uid,
|
'viewer_uid': self.uid,
|
||||||
'state': 'active',
|
'state': 'active',
|
||||||
}
|
}
|
||||||
self._get(self.req_url.PING, data, fix_request=True, as_json=False)
|
self._get(self.req_url.PING, data, fix_request=True, as_json=False)
|
||||||
|
|
||||||
def _fetchSticky(self):
|
def _pullMessage(self, markAlive=True):
|
||||||
"""Call pull api to get sticky and pool parameter, newer api needs these parameters to work"""
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"msgs_recv": 0,
|
|
||||||
"channel": self.user_channel,
|
|
||||||
"clientid": self.client_id
|
|
||||||
}
|
|
||||||
|
|
||||||
j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True)
|
|
||||||
|
|
||||||
if j.get('lb_info') is None:
|
|
||||||
raise FBchatException('Missing lb_info: {}'.format(j))
|
|
||||||
|
|
||||||
return j['lb_info']['sticky'], j['lb_info']['pool']
|
|
||||||
|
|
||||||
def _pullMessage(self, sticky, pool, markAlive=True):
|
|
||||||
"""Call pull api with seq value to get message data."""
|
"""Call pull api with seq value to get message data."""
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"msgs_recv": 0,
|
"msgs_recv": 0,
|
||||||
"sticky_token": sticky,
|
"sticky_token": self.sticky,
|
||||||
"sticky_pool": pool,
|
"sticky_pool": self.pool,
|
||||||
"clientid": self.client_id,
|
"clientid": self.client_id,
|
||||||
'state': 'active' if markAlive else 'offline',
|
'state': 'active' if markAlive else 'offline',
|
||||||
}
|
}
|
||||||
|
|
||||||
j = self._get(ReqUrl.STICKY, data, fix_request=True, as_json=True)
|
j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True)
|
||||||
|
|
||||||
self.seq = j.get('seq', '0')
|
self.seq = j.get('seq', '0')
|
||||||
return j
|
return j
|
||||||
@@ -2024,6 +2089,14 @@ class Client(object):
|
|||||||
def _parseMessage(self, content):
|
def _parseMessage(self, content):
|
||||||
"""Get message and author name from content. May contain multiple messages in the content."""
|
"""Get message and author name from content. May contain multiple messages in the content."""
|
||||||
|
|
||||||
|
if 'lb_info' in content:
|
||||||
|
self.sticky = content['lb_info']['sticky']
|
||||||
|
self.pool = content['lb_info']['pool']
|
||||||
|
|
||||||
|
if 'batches' in content:
|
||||||
|
for batch in content['batches']:
|
||||||
|
self._parseMessage(batch)
|
||||||
|
|
||||||
if 'ms' not in content: return
|
if 'ms' not in content: return
|
||||||
|
|
||||||
for m in content["ms"]:
|
for m in content["ms"]:
|
||||||
@@ -2046,6 +2119,7 @@ class Client(object):
|
|||||||
|
|
||||||
delta = m["delta"]
|
delta = m["delta"]
|
||||||
delta_type = delta.get("type")
|
delta_type = delta.get("type")
|
||||||
|
delta_class = delta.get("class")
|
||||||
metadata = delta.get("messageMetadata")
|
metadata = delta.get("messageMetadata")
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
@@ -2082,14 +2156,14 @@ class Client(object):
|
|||||||
thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
||||||
|
|
||||||
# Thread title change
|
# Thread title change
|
||||||
elif delta.get("class") == "ThreadName":
|
elif delta_class == "ThreadName":
|
||||||
new_title = delta["name"]
|
new_title = delta["name"]
|
||||||
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
||||||
self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id,
|
self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id,
|
||||||
thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
||||||
|
|
||||||
# Forced fetch
|
# Forced fetch
|
||||||
elif delta.get("class") == "ForcedFetch":
|
elif delta_class == "ForcedFetch":
|
||||||
mid = delta.get("messageId")
|
mid = delta.get("messageId")
|
||||||
if mid is None:
|
if mid is None:
|
||||||
self.onUnknownMesssageType(msg=m)
|
self.onUnknownMesssageType(msg=m)
|
||||||
@@ -2135,7 +2209,7 @@ class Client(object):
|
|||||||
thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
|
thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
|
||||||
|
|
||||||
# Message delivered
|
# Message delivered
|
||||||
elif delta.get("class") == "DeliveryReceipt":
|
elif delta_class == "DeliveryReceipt":
|
||||||
message_ids = delta["messageIds"]
|
message_ids = delta["messageIds"]
|
||||||
delivered_for = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
|
delivered_for = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
|
||||||
ts = int(delta["deliveredWatermarkTimestampMs"])
|
ts = int(delta["deliveredWatermarkTimestampMs"])
|
||||||
@@ -2145,7 +2219,7 @@ class Client(object):
|
|||||||
metadata=metadata, msg=m)
|
metadata=metadata, msg=m)
|
||||||
|
|
||||||
# Message seen
|
# Message seen
|
||||||
elif delta.get("class") == "ReadReceipt":
|
elif delta_class == "ReadReceipt":
|
||||||
seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
|
seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
|
||||||
seen_ts = int(delta["actionTimestampMs"])
|
seen_ts = int(delta["actionTimestampMs"])
|
||||||
delivered_ts = int(delta["watermarkTimestampMs"])
|
delivered_ts = int(delta["watermarkTimestampMs"])
|
||||||
@@ -2154,7 +2228,7 @@ class Client(object):
|
|||||||
seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, msg=m)
|
seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, msg=m)
|
||||||
|
|
||||||
# Messages marked as seen
|
# Messages marked as seen
|
||||||
elif delta.get("class") == "MarkRead":
|
elif delta_class == "MarkRead":
|
||||||
seen_ts = int(delta.get("actionTimestampMs") or delta.get("actionTimestamp"))
|
seen_ts = int(delta.get("actionTimestampMs") or delta.get("actionTimestamp"))
|
||||||
delivered_ts = int(delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp"))
|
delivered_ts = int(delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp"))
|
||||||
|
|
||||||
@@ -2254,6 +2328,61 @@ class Client(object):
|
|||||||
self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id,
|
self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id,
|
||||||
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
||||||
|
|
||||||
|
# Client payload (that weird numbers)
|
||||||
|
elif delta_class == "ClientPayload":
|
||||||
|
payload = json.loads("".join(chr(z) for z in delta['payload']))
|
||||||
|
ts = m.get("ofd_ts")
|
||||||
|
for d in payload.get('deltas', []):
|
||||||
|
|
||||||
|
# Message reaction
|
||||||
|
if d.get('deltaMessageReaction'):
|
||||||
|
i = d['deltaMessageReaction']
|
||||||
|
thread_id, thread_type = getThreadIdAndThreadType(i)
|
||||||
|
mid = i["messageId"]
|
||||||
|
author_id = str(i["userId"])
|
||||||
|
reaction = MessageReaction(i["reaction"]) if i.get("reaction") else None
|
||||||
|
add_reaction = not bool(i["action"])
|
||||||
|
if add_reaction:
|
||||||
|
self.onReactionAdded(mid=mid, reaction=reaction, author_id=author_id,
|
||||||
|
thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
|
||||||
|
else:
|
||||||
|
self.onReactionRemoved(mid=mid, author_id=author_id, thread_id=thread_id,
|
||||||
|
thread_type=thread_type, ts=ts, msg=m)
|
||||||
|
|
||||||
|
# Viewer status change
|
||||||
|
elif d.get('deltaChangeViewerStatus'):
|
||||||
|
i = d['deltaChangeViewerStatus']
|
||||||
|
thread_id, thread_type = getThreadIdAndThreadType(i)
|
||||||
|
author_id = str(i["actorFbid"])
|
||||||
|
reason = i["reason"]
|
||||||
|
can_reply = i["canViewerReply"]
|
||||||
|
if reason == 2:
|
||||||
|
if can_reply:
|
||||||
|
self.onUnblock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
|
||||||
|
else:
|
||||||
|
self.onBlock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
|
||||||
|
|
||||||
|
# Live location info
|
||||||
|
elif d.get('liveLocationData'):
|
||||||
|
i = d['liveLocationData']
|
||||||
|
thread_id, thread_type = getThreadIdAndThreadType(i)
|
||||||
|
for l in i['messageLiveLocations']:
|
||||||
|
mid = l["messageId"]
|
||||||
|
author_id = str(l["senderId"])
|
||||||
|
location = graphql_to_live_location(l)
|
||||||
|
self.onLiveLocation(mid=mid, location=location, author_id=author_id, thread_id=thread_id,
|
||||||
|
thread_type=thread_type, ts=ts, msg=m)
|
||||||
|
|
||||||
|
# Message deletion
|
||||||
|
elif d.get('deltaRecallMessageData'):
|
||||||
|
i = d['deltaRecallMessageData']
|
||||||
|
thread_id, thread_type = getThreadIdAndThreadType(i)
|
||||||
|
mid = i['messageID']
|
||||||
|
ts = i['deletionTimestamp']
|
||||||
|
author_id = str(i['senderID'])
|
||||||
|
self.onMessageUnsent(mid=mid, author_id=author_id, thread_id=thread_id, thread_type=thread_type,
|
||||||
|
ts=ts, msg=m)
|
||||||
|
|
||||||
# New message
|
# New message
|
||||||
elif delta.get("class") == "NewMessage":
|
elif delta.get("class") == "NewMessage":
|
||||||
mentions = []
|
mentions = []
|
||||||
@@ -2265,6 +2394,7 @@ class Client(object):
|
|||||||
|
|
||||||
sticker = None
|
sticker = None
|
||||||
attachments = []
|
attachments = []
|
||||||
|
unsent = False
|
||||||
if delta.get('attachments'):
|
if delta.get('attachments'):
|
||||||
try:
|
try:
|
||||||
for a in delta['attachments']:
|
for a in delta['attachments']:
|
||||||
@@ -2272,17 +2402,23 @@ class Client(object):
|
|||||||
if mercury.get('blob_attachment'):
|
if mercury.get('blob_attachment'):
|
||||||
image_metadata = a.get('imageMetadata', {})
|
image_metadata = a.get('imageMetadata', {})
|
||||||
attach_type = mercury['blob_attachment']['__typename']
|
attach_type = mercury['blob_attachment']['__typename']
|
||||||
attachment = graphql_to_attachment(mercury.get('blob_attachment', {}))
|
attachment = graphql_to_attachment(mercury['blob_attachment'])
|
||||||
|
|
||||||
if attach_type == ['MessageFile', 'MessageVideo', 'MessageAudio']:
|
if attach_type in ['MessageFile', 'MessageVideo', 'MessageAudio']:
|
||||||
# TODO: Add more data here for audio files
|
# TODO: Add more data here for audio files
|
||||||
attachment.size = int(a['fileSize'])
|
attachment.size = int(a['fileSize'])
|
||||||
attachments.append(attachment)
|
attachments.append(attachment)
|
||||||
|
|
||||||
elif mercury.get('sticker_attachment'):
|
elif mercury.get('sticker_attachment'):
|
||||||
sticker = graphql_to_sticker(a['mercury']['sticker_attachment'])
|
sticker = graphql_to_sticker(mercury['sticker_attachment'])
|
||||||
|
|
||||||
elif mercury.get('extensible_attachment'):
|
elif mercury.get('extensible_attachment'):
|
||||||
# TODO: Add more data here for shared stuff (URLs, events and so on)
|
attachment = graphql_to_extensible_attachment(mercury['extensible_attachment'])
|
||||||
pass
|
if isinstance(attachment, UnsentMessage):
|
||||||
|
unsent = True
|
||||||
|
elif attachment:
|
||||||
|
attachments.append(attachment)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception('An exception occured while reading attachments: {}'.format(delta['attachments']))
|
log.exception('An exception occured while reading attachments: {}'.format(delta['attachments']))
|
||||||
|
|
||||||
@@ -2294,12 +2430,13 @@ class Client(object):
|
|||||||
mentions=mentions,
|
mentions=mentions,
|
||||||
emoji_size=emoji_size,
|
emoji_size=emoji_size,
|
||||||
sticker=sticker,
|
sticker=sticker,
|
||||||
attachments=attachments
|
attachments=attachments,
|
||||||
)
|
)
|
||||||
message.uid = mid
|
message.uid = mid
|
||||||
message.author = author_id
|
message.author = author_id
|
||||||
message.timestamp = ts
|
message.timestamp = ts
|
||||||
#message.reactions = {}
|
#message.reactions = {}
|
||||||
|
message.unsent = unsent
|
||||||
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
thread_id, thread_type = getThreadIdAndThreadType(metadata)
|
||||||
self.onMessage(mid=mid, author_id=author_id, message=delta.get('body', ''), message_object=message,
|
self.onMessage(mid=mid, author_id=author_id, message=delta.get('body', ''), message_object=message,
|
||||||
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
|
||||||
@@ -2384,7 +2521,6 @@ class Client(object):
|
|||||||
:raises: FBchatException if request failed
|
:raises: FBchatException if request failed
|
||||||
"""
|
"""
|
||||||
self.listening = True
|
self.listening = True
|
||||||
self.sticky, self.pool = self._fetchSticky()
|
|
||||||
|
|
||||||
def doOneListen(self, markAlive=None):
|
def doOneListen(self, markAlive=None):
|
||||||
"""
|
"""
|
||||||
@@ -2400,8 +2536,8 @@ class Client(object):
|
|||||||
markAlive = self._markAlive
|
markAlive = self._markAlive
|
||||||
try:
|
try:
|
||||||
if markAlive:
|
if markAlive:
|
||||||
self._ping(self.sticky, self.pool)
|
self._ping()
|
||||||
content = self._pullMessage(self.sticky, self.pool, markAlive)
|
content = self._pullMessage(markAlive)
|
||||||
if content:
|
if content:
|
||||||
self._parseMessage(content)
|
self._parseMessage(content)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -2688,6 +2824,19 @@ class Client(object):
|
|||||||
"""
|
"""
|
||||||
log.info("Marked messages as seen in threads {} at {}s".format([(x[0], x[1].name) for x in threads], seen_ts/1000))
|
log.info("Marked messages as seen in threads {} at {}s".format([(x[0], x[1].name) for x in threads], seen_ts/1000))
|
||||||
|
|
||||||
|
def onMessageUnsent(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
|
||||||
|
"""
|
||||||
|
Called when the client is listening, and someone unsends (deletes for everyone) a message
|
||||||
|
|
||||||
|
:param mid: ID of the unsent message
|
||||||
|
:param author_id: The ID of the person who unsent the message
|
||||||
|
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param ts: A timestamp of the action
|
||||||
|
:param msg: A full set of the data recieved
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
"""
|
||||||
|
log.info("{} unsent the message {} in {} ({}) at {}s".format(author_id, repr(mid), thread_id, thread_type.name, ts/1000))
|
||||||
|
|
||||||
def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None, ts=None, msg=None):
|
def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None, ts=None, msg=None):
|
||||||
"""
|
"""
|
||||||
@@ -2769,6 +2918,79 @@ class Client(object):
|
|||||||
"""
|
"""
|
||||||
log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name))
|
log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name))
|
||||||
|
|
||||||
|
def onReactionAdded(self, mid=None, reaction=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
|
||||||
|
"""
|
||||||
|
Called when the client is listening, and somebody reacts to a message
|
||||||
|
|
||||||
|
:param mid: Message ID, that user reacted to
|
||||||
|
:param reaction: Reaction
|
||||||
|
:param add_reaction: Whether user added or removed reaction
|
||||||
|
:param author_id: The ID of the person who reacted to the message
|
||||||
|
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param ts: A timestamp of the action
|
||||||
|
:param msg: A full set of the data recieved
|
||||||
|
:type reaction: models.MessageReaction
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
"""
|
||||||
|
log.info("{} reacted to message {} with {} in {} ({})".format(author_id, mid, reaction.name, thread_id, thread_type.name))
|
||||||
|
|
||||||
|
def onReactionRemoved(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
|
||||||
|
"""
|
||||||
|
Called when the client is listening, and somebody removes reaction from a message
|
||||||
|
|
||||||
|
:param mid: Message ID, that user reacted to
|
||||||
|
:param author_id: The ID of the person who removed reaction
|
||||||
|
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param ts: A timestamp of the action
|
||||||
|
:param msg: A full set of the data recieved
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
"""
|
||||||
|
log.info("{} removed reaction from {} message in {} ({})".format(author_id, mid, thread_id, thread_type))
|
||||||
|
|
||||||
|
def onBlock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
|
||||||
|
"""
|
||||||
|
Called when the client is listening, and somebody blocks client
|
||||||
|
|
||||||
|
:param author_id: The ID of the person who blocked
|
||||||
|
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param ts: A timestamp of the action
|
||||||
|
:param msg: A full set of the data recieved
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
"""
|
||||||
|
log.info("{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name))
|
||||||
|
|
||||||
|
def onUnblock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
|
||||||
|
"""
|
||||||
|
Called when the client is listening, and somebody blocks client
|
||||||
|
|
||||||
|
:param author_id: The ID of the person who unblocked
|
||||||
|
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param ts: A timestamp of the action
|
||||||
|
:param msg: A full set of the data recieved
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
"""
|
||||||
|
log.info("{} unblocked {} ({}) thread".format(author_id, thread_id, thread_type.name))
|
||||||
|
|
||||||
|
def onLiveLocation(self, mid=None, location=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
|
||||||
|
"""
|
||||||
|
Called when the client is listening and somebody sends live location info
|
||||||
|
|
||||||
|
:param mid: The action ID
|
||||||
|
:param location: Sent location info
|
||||||
|
:param author_id: The ID of the person who sent location info
|
||||||
|
:param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
|
||||||
|
:param ts: A timestamp of the action
|
||||||
|
:param msg: A full set of the data recieved
|
||||||
|
:type location: models.LiveLocationAttachment
|
||||||
|
:type thread_type: models.ThreadType
|
||||||
|
"""
|
||||||
|
log.info("{} sent live location info in {} ({}) with latitude {} and longitude {}".format(author_id, thread_id, thread_type, location.latitude, location.longitude))
|
||||||
|
|
||||||
def onCallStarted(self, mid=None, caller_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None):
|
def onCallStarted(self, mid=None, caller_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None):
|
||||||
"""
|
"""
|
||||||
.. todo::
|
.. todo::
|
||||||
@@ -2939,6 +3161,7 @@ class Client(object):
|
|||||||
:param metadata: Extra metadata about the action
|
:param metadata: Extra metadata about the action
|
||||||
:param msg: A full set of the data recieved
|
:param msg: A full set of the data recieved
|
||||||
:type plan: models.Plan
|
:type plan: models.Plan
|
||||||
|
:type take_part: bool
|
||||||
:type thread_type: models.ThreadType
|
:type thread_type: models.ThreadType
|
||||||
"""
|
"""
|
||||||
if take_part:
|
if take_part:
|
||||||
|
@@ -128,9 +128,84 @@ def graphql_to_attachment(a):
|
|||||||
uid=a.get('legacy_attachment_id')
|
uid=a.get('legacy_attachment_id')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def graphql_to_extensible_attachment(a):
|
||||||
|
story = a.get('story_attachment')
|
||||||
|
if story:
|
||||||
|
target = story.get('target')
|
||||||
|
if target:
|
||||||
|
_type = target['__typename']
|
||||||
|
if _type == 'MessageLocation':
|
||||||
|
latitude, longitude = get_url_parameter(get_url_parameter(story['url'], 'u'), 'where1').split(", ")
|
||||||
|
rtn = LocationAttachment(
|
||||||
|
uid=int(story['deduplication_key']),
|
||||||
|
latitude=float(latitude),
|
||||||
|
longitude=float(longitude),
|
||||||
|
)
|
||||||
|
if story['media']:
|
||||||
|
rtn.image_url = story['media']['image']['uri']
|
||||||
|
rtn.image_width = story['media']['image']['width']
|
||||||
|
rtn.image_height = story['media']['image']['height']
|
||||||
|
rtn.url = story['url']
|
||||||
|
return rtn
|
||||||
|
elif _type == 'MessageLiveLocation':
|
||||||
|
rtn = LiveLocationAttachment(
|
||||||
|
uid=int(story['target']['live_location_id']),
|
||||||
|
latitude=story['target']['coordinate']['latitude'] if story['target'].get('coordinate') else None,
|
||||||
|
longitude=story['target']['coordinate']['longitude'] if story['target'].get('coordinate') else None,
|
||||||
|
name=story['title_with_entities']['text'],
|
||||||
|
expiration_time=story['target']['expiration_time'] if story['target'].get('expiration_time') else None,
|
||||||
|
is_expired=story['target']['is_expired'],
|
||||||
|
)
|
||||||
|
if story['media']:
|
||||||
|
rtn.image_url = story['media']['image']['uri']
|
||||||
|
rtn.image_width = story['media']['image']['width']
|
||||||
|
rtn.image_height = story['media']['image']['height']
|
||||||
|
rtn.url = story['url']
|
||||||
|
return rtn
|
||||||
|
elif _type in ['ExternalUrl', 'Story']:
|
||||||
|
return ShareAttachment(
|
||||||
|
uid=a.get('legacy_attachment_id'),
|
||||||
|
author=story['target']['actors'][0]['id'] if story['target'].get('actors') else None,
|
||||||
|
url=story['url'],
|
||||||
|
original_url=get_url_parameter(story['url'], 'u') if "/l.php?u=" in story['url'] else story['url'],
|
||||||
|
title=story['title_with_entities'].get('text'),
|
||||||
|
description=story['description'].get('text'),
|
||||||
|
source=story['source']['text'],
|
||||||
|
image_url=story['media']['image']['uri'] if story.get('media') else None,
|
||||||
|
original_image_url=(get_url_parameter(story['media']['image']['uri'], 'url') if "/safe_image.php" in story['media']['image']['uri'] else story['media']['image']['uri']) if story.get('media') else None,
|
||||||
|
image_width=story['media']['image']['width'] if story.get('media') else None,
|
||||||
|
image_height=story['media']['image']['height'] if story.get('media') else None,
|
||||||
|
attachments=[graphql_to_subattachment(attachment) for attachment in story.get('subattachments')],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return UnsentMessage(
|
||||||
|
uid=a.get('legacy_attachment_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def graphql_to_subattachment(a):
|
||||||
|
_type = a['target']['__typename']
|
||||||
|
if _type == 'Video':
|
||||||
|
return VideoAttachment(
|
||||||
|
duration=a['media'].get('playable_duration_in_ms'),
|
||||||
|
preview_url=a['media'].get('playable_url'),
|
||||||
|
medium_image=a['media'].get('image'),
|
||||||
|
uid=a['target'].get('video_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def graphql_to_live_location(a):
|
||||||
|
return LiveLocationAttachment(
|
||||||
|
uid=a['id'],
|
||||||
|
latitude=a['coordinate']['latitude'] / (10 ** 8) if not a.get('stopReason') else None,
|
||||||
|
longitude=a['coordinate']['longitude'] / (10 ** 8) if not a.get('stopReason') else None,
|
||||||
|
name=a.get('locationTitle'),
|
||||||
|
expiration_time=a['expirationTime'],
|
||||||
|
is_expired=bool(a.get('stopReason')),
|
||||||
|
)
|
||||||
|
|
||||||
def graphql_to_poll(a):
|
def graphql_to_poll(a):
|
||||||
rtn = Poll(
|
rtn = Poll(
|
||||||
title=a.get('title') if a.get('title') else a.get("text"),
|
title=a.get('title') if a.get('title') else a.get('text'),
|
||||||
options=[graphql_to_poll_option(m) for m in a.get('options')]
|
options=[graphql_to_poll_option(m) for m in a.get('options')]
|
||||||
)
|
)
|
||||||
rtn.uid = int(a["id"])
|
rtn.uid = int(a["id"])
|
||||||
@@ -207,13 +282,18 @@ def graphql_to_message(message):
|
|||||||
rtn.uid = str(message.get('message_id'))
|
rtn.uid = str(message.get('message_id'))
|
||||||
rtn.author = str(message.get('message_sender').get('id'))
|
rtn.author = str(message.get('message_sender').get('id'))
|
||||||
rtn.timestamp = message.get('timestamp_precise')
|
rtn.timestamp = message.get('timestamp_precise')
|
||||||
|
rtn.unsent = False
|
||||||
if message.get('unread') is not None:
|
if message.get('unread') is not None:
|
||||||
rtn.is_read = not message['unread']
|
rtn.is_read = not message['unread']
|
||||||
rtn.reactions = {str(r['user']['id']):MessageReaction(r['reaction']) for r in message.get('message_reactions')}
|
rtn.reactions = {str(r['user']['id']):MessageReaction(r['reaction']) for r in message.get('message_reactions')}
|
||||||
if message.get('blob_attachments') is not None:
|
if message.get('blob_attachments') is not None:
|
||||||
rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']]
|
rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']]
|
||||||
# TODO: This is still missing parsing:
|
if message.get('extensible_attachment') is not None:
|
||||||
# message.get('extensible_attachment')
|
attachment = graphql_to_extensible_attachment(message['extensible_attachment'])
|
||||||
|
if isinstance(attachment, UnsentMessage):
|
||||||
|
rtn.unsent = True
|
||||||
|
elif attachment:
|
||||||
|
rtn.attachments.append(attachment)
|
||||||
return rtn
|
return rtn
|
||||||
|
|
||||||
def graphql_to_user(user):
|
def graphql_to_user(user):
|
||||||
@@ -302,8 +382,8 @@ def graphql_to_group(group):
|
|||||||
color=c_info.get('color'),
|
color=c_info.get('color'),
|
||||||
emoji=c_info.get('emoji'),
|
emoji=c_info.get('emoji'),
|
||||||
admins = set([node.get('id') for node in group.get('thread_admins')]),
|
admins = set([node.get('id') for node in group.get('thread_admins')]),
|
||||||
approval_mode = bool(group.get('approval_mode')),
|
approval_mode = bool(group.get('approval_mode')) if group.get('approval_mode') is not None else None,
|
||||||
approval_requests = set(node["requester"]['id'] for node in group['group_approval_queue']['nodes']),
|
approval_requests = set(node["requester"]['id'] for node in group['group_approval_queue']['nodes']) if group.get('group_approval_queue') else None,
|
||||||
join_link = group['joinable_mode'].get('link'),
|
join_link = group['joinable_mode'].get('link'),
|
||||||
photo=group['image'].get('uri'),
|
photo=group['image'].get('uri'),
|
||||||
name=group.get('name'),
|
name=group.get('name'),
|
||||||
@@ -477,7 +557,7 @@ class GraphQL(object):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SEARCH_USER = """
|
SEARCH_USER = """
|
||||||
Query SearchUser(<search> = '', <limit> = 1) {
|
Query SearchUser(<search> = '', <limit> = 10) {
|
||||||
entities_named(<search>) {
|
entities_named(<search>) {
|
||||||
search_results.of_type(user).first(<limit>) as users {
|
search_results.of_type(user).first(<limit>) as users {
|
||||||
nodes {
|
nodes {
|
||||||
@@ -489,7 +569,7 @@ class GraphQL(object):
|
|||||||
""" + FRAGMENT_USER
|
""" + FRAGMENT_USER
|
||||||
|
|
||||||
SEARCH_GROUP = """
|
SEARCH_GROUP = """
|
||||||
Query SearchGroup(<search> = '', <limit> = 1, <pic_size> = 32) {
|
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
|
||||||
viewer() {
|
viewer() {
|
||||||
message_threads.with_thread_name(<search>).last(<limit>) as groups {
|
message_threads.with_thread_name(<search>).last(<limit>) as groups {
|
||||||
nodes {
|
nodes {
|
||||||
@@ -501,7 +581,7 @@ class GraphQL(object):
|
|||||||
""" + FRAGMENT_GROUP
|
""" + FRAGMENT_GROUP
|
||||||
|
|
||||||
SEARCH_PAGE = """
|
SEARCH_PAGE = """
|
||||||
Query SearchPage(<search> = '', <limit> = 1) {
|
Query SearchPage(<search> = '', <limit> = 10) {
|
||||||
entities_named(<search>) {
|
entities_named(<search>) {
|
||||||
search_results.of_type(page).first(<limit>) as pages {
|
search_results.of_type(page).first(<limit>) as pages {
|
||||||
nodes {
|
nodes {
|
||||||
@@ -513,7 +593,7 @@ class GraphQL(object):
|
|||||||
""" + FRAGMENT_PAGE
|
""" + FRAGMENT_PAGE
|
||||||
|
|
||||||
SEARCH_THREAD = """
|
SEARCH_THREAD = """
|
||||||
Query SearchThread(<search> = '', <limit> = 1) {
|
Query SearchThread(<search> = '', <limit> = 10) {
|
||||||
entities_named(<search>) {
|
entities_named(<search>) {
|
||||||
search_results.first(<limit>) as threads {
|
search_results.first(<limit>) as threads {
|
||||||
nodes {
|
nodes {
|
||||||
|
@@ -180,6 +180,8 @@ class Message(object):
|
|||||||
timestamp = None
|
timestamp = None
|
||||||
#: Whether the message is read
|
#: Whether the message is read
|
||||||
is_read = None
|
is_read = None
|
||||||
|
#: A list of pepole IDs who read the message, works only with :func:`fbchat.Client.fetchThreadMessages`
|
||||||
|
read_by = None
|
||||||
#: A dict with user's IDs as keys, and their :class:`MessageReaction` as values
|
#: A dict with user's IDs as keys, and their :class:`MessageReaction` as values
|
||||||
reactions = None
|
reactions = None
|
||||||
#: The actual message
|
#: The actual message
|
||||||
@@ -188,6 +190,8 @@ class Message(object):
|
|||||||
sticker = None
|
sticker = None
|
||||||
#: A list of attachments
|
#: A list of attachments
|
||||||
attachments = None
|
attachments = None
|
||||||
|
#: Whether the message is unsent (deleted for everyone)
|
||||||
|
unsent = None
|
||||||
|
|
||||||
def __init__(self, text=None, mentions=None, emoji_size=None, sticker=None, attachments=None):
|
def __init__(self, text=None, mentions=None, emoji_size=None, sticker=None, attachments=None):
|
||||||
"""Represents a Facebook message"""
|
"""Represents a Facebook message"""
|
||||||
@@ -201,6 +205,8 @@ class Message(object):
|
|||||||
attachments = []
|
attachments = []
|
||||||
self.attachments = attachments
|
self.attachments = attachments
|
||||||
self.reactions = {}
|
self.reactions = {}
|
||||||
|
self.read_by = []
|
||||||
|
self.deleted = False
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__unicode__()
|
return self.__unicode__()
|
||||||
@@ -216,6 +222,12 @@ class Attachment(object):
|
|||||||
"""Represents a Facebook attachment"""
|
"""Represents a Facebook attachment"""
|
||||||
self.uid = uid
|
self.uid = uid
|
||||||
|
|
||||||
|
class UnsentMessage(Attachment):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Represents an unsent message attachment"""
|
||||||
|
super(UnsentMessage, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
class Sticker(Attachment):
|
class Sticker(Attachment):
|
||||||
#: The sticker-pack's ID
|
#: The sticker-pack's ID
|
||||||
pack = None
|
pack = None
|
||||||
@@ -248,9 +260,79 @@ class Sticker(Attachment):
|
|||||||
super(Sticker, self).__init__(*args, **kwargs)
|
super(Sticker, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
class ShareAttachment(Attachment):
|
class ShareAttachment(Attachment):
|
||||||
def __init__(self, **kwargs):
|
#: ID of the author of the shared post
|
||||||
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment - *Currently Incomplete!*"""
|
author = None
|
||||||
|
#: Target URL
|
||||||
|
url = None
|
||||||
|
#: Original URL if Facebook redirects the URL
|
||||||
|
original_url = None
|
||||||
|
#: Title of the attachment
|
||||||
|
title = None
|
||||||
|
#: Description of the attachment
|
||||||
|
description = None
|
||||||
|
#: Name of the source
|
||||||
|
source = None
|
||||||
|
#: URL of the attachment image
|
||||||
|
image_url = None
|
||||||
|
#: URL of the original image if Facebook uses `safe_image`
|
||||||
|
original_image_url = None
|
||||||
|
#: Width of the image
|
||||||
|
image_width = None
|
||||||
|
#: Height of the image
|
||||||
|
image_height = None
|
||||||
|
#: List of additional attachments
|
||||||
|
attachments = None
|
||||||
|
|
||||||
|
def __init__(self, author=None, url=None, original_url=None, title=None, description=None, source=None, image_url=None, original_image_url=None, image_width=None, image_height=None, attachments=None, **kwargs):
|
||||||
|
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment"""
|
||||||
super(ShareAttachment, self).__init__(**kwargs)
|
super(ShareAttachment, self).__init__(**kwargs)
|
||||||
|
self.author = author
|
||||||
|
self.url = url
|
||||||
|
self.original_url = original_url
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.source = source
|
||||||
|
self.image_url = image_url
|
||||||
|
self.original_image_url = original_image_url
|
||||||
|
self.image_width = image_width
|
||||||
|
self.image_height = image_height
|
||||||
|
if attachments is None:
|
||||||
|
attachments = []
|
||||||
|
self.attachments = attachments
|
||||||
|
|
||||||
|
class LocationAttachment(Attachment):
|
||||||
|
#: Latidute of the location
|
||||||
|
latitude = None
|
||||||
|
#: Longitude of the location
|
||||||
|
longitude = None
|
||||||
|
#: URL of image showing the map of the location
|
||||||
|
image_url = None
|
||||||
|
#: Width of the image
|
||||||
|
image_width = None
|
||||||
|
#: Height of the image
|
||||||
|
image_height = None
|
||||||
|
#: URL to Bing maps with the location
|
||||||
|
url = None
|
||||||
|
|
||||||
|
def __init__(self, latitude=None, longitude=None, **kwargs):
|
||||||
|
"""Represents a user location"""
|
||||||
|
super(LocationAttachment, self).__init__(**kwargs)
|
||||||
|
self.latitude = latitude
|
||||||
|
self.longitude = longitude
|
||||||
|
|
||||||
|
class LiveLocationAttachment(LocationAttachment):
|
||||||
|
#: Name of the location
|
||||||
|
name = None
|
||||||
|
#: Timestamp when live location expires
|
||||||
|
expiration_time = None
|
||||||
|
#: True if live location is expired
|
||||||
|
is_expired = None
|
||||||
|
|
||||||
|
def __init__(self, name=None, expiration_time=None, is_expired=None, **kwargs):
|
||||||
|
"""Represents a live user location"""
|
||||||
|
super(LiveLocationAttachment, self).__init__(**kwargs)
|
||||||
|
self.expiration_time = expiration_time
|
||||||
|
self.is_expired = is_expired
|
||||||
|
|
||||||
class FileAttachment(Attachment):
|
class FileAttachment(Attachment):
|
||||||
#: Url where you can download the file
|
#: Url where you can download the file
|
||||||
|
@@ -14,10 +14,11 @@ import requests
|
|||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode, parse_qs, urlparse
|
||||||
basestring = (str, bytes)
|
basestring = (str, bytes)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
|
from urlparse import parse_qs, urlparse
|
||||||
basestring = basestring
|
basestring = basestring
|
||||||
|
|
||||||
# Python 2's `input` executes the input, whereas `raw_input` just returns the input
|
# Python 2's `input` executes the input, whereas `raw_input` just returns the input
|
||||||
@@ -140,6 +141,7 @@ class ReqUrl(object):
|
|||||||
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options"
|
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options"
|
||||||
SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1"
|
SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1"
|
||||||
MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
|
MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
|
||||||
|
UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1"
|
||||||
|
|
||||||
pull_channel = 0
|
pull_channel = 0
|
||||||
|
|
||||||
@@ -297,3 +299,10 @@ def get_files_from_paths(filenames):
|
|||||||
yield files
|
yield files
|
||||||
for fn, fp, ft in files:
|
for fn, fp, ft in files:
|
||||||
fp.close()
|
fp.close()
|
||||||
|
|
||||||
|
def get_url_parameters(url, *args):
|
||||||
|
params = parse_qs(urlparse(url).query)
|
||||||
|
return [params[arg][0] for arg in args if params.get(arg)]
|
||||||
|
|
||||||
|
def get_url_parameter(url, param):
|
||||||
|
return get_url_parameters(url, param)[0]
|
||||||
|
@@ -20,7 +20,9 @@ def group(pytestconfig):
|
|||||||
return {"id": load_variable("group_id", pytestconfig.cache), "type": ThreadType.GROUP}
|
return {"id": load_variable("group_id", pytestconfig.cache), "type": ThreadType.GROUP}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", params=["user", "group", pytest.mark.xfail("none")])
|
@pytest.fixture(scope="session", params=[
|
||||||
|
"user", "group", pytest.param("none", marks=[pytest.mark.xfail()])
|
||||||
|
])
|
||||||
def thread(request, user, group):
|
def thread(request, user, group):
|
||||||
return {
|
return {
|
||||||
"user": user,
|
"user": user,
|
||||||
|
@@ -11,8 +11,14 @@ from time import time
|
|||||||
|
|
||||||
@pytest.fixture(scope="module", params=[
|
@pytest.fixture(scope="module", params=[
|
||||||
Plan(int(time()) + 100, random_hex()),
|
Plan(int(time()) + 100, random_hex()),
|
||||||
pytest.mark.xfail(Plan(int(time()), random_hex()), raises=FBchatFacebookError),
|
pytest.param(
|
||||||
pytest.mark.xfail(Plan(0, None)),
|
Plan(int(time()), random_hex()),
|
||||||
|
marks=[pytest.mark.xfail(raises=FBchatFacebookError)]
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
Plan(0, None),
|
||||||
|
marks=[pytest.mark.xfail()],
|
||||||
|
),
|
||||||
])
|
])
|
||||||
def plan_data(request, client, user, thread, catch_event, compare):
|
def plan_data(request, client, user, thread, catch_event, compare):
|
||||||
with catch_event("onPlanCreated") as x:
|
with catch_event("onPlanCreated") as x:
|
||||||
|
@@ -26,7 +26,9 @@ from utils import random_hex, subset
|
|||||||
PollOption(random_hex()),
|
PollOption(random_hex()),
|
||||||
PollOption(random_hex()),
|
PollOption(random_hex()),
|
||||||
]),
|
]),
|
||||||
pytest.mark.xfail(Poll(title=None, options=[]), raises=ValueError),
|
pytest.param(
|
||||||
|
Poll(title=None, options=[]), marks=[pytest.mark.xfail(raises=ValueError)]
|
||||||
|
),
|
||||||
])
|
])
|
||||||
def poll_data(request, client1, group, catch_event):
|
def poll_data(request, client1, group, catch_event):
|
||||||
with catch_event("onPollCreated") as x:
|
with catch_event("onPollCreated") as x:
|
||||||
|
@@ -72,8 +72,8 @@ def test_change_nickname(client, client_all, catch_event, compare):
|
|||||||
"😂",
|
"😂",
|
||||||
"😕",
|
"😕",
|
||||||
"😍",
|
"😍",
|
||||||
pytest.mark.xfail("🙃", raises=FBchatFacebookError),
|
pytest.param("🙃", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
|
||||||
pytest.mark.xfail("not an emoji", raises=FBchatFacebookError)
|
pytest.param("not an emoji", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
|
||||||
])
|
])
|
||||||
def test_change_emoji(client, catch_event, compare, emoji):
|
def test_change_emoji(client, catch_event, compare, emoji):
|
||||||
with catch_event("onEmojiChange") as x:
|
with catch_event("onEmojiChange") as x:
|
||||||
@@ -101,7 +101,7 @@ def test_change_image_remote(client1, group, catch_event):
|
|||||||
[
|
[
|
||||||
x
|
x
|
||||||
if x in [ThreadColor.MESSENGER_BLUE, ThreadColor.PUMPKIN]
|
if x in [ThreadColor.MESSENGER_BLUE, ThreadColor.PUMPKIN]
|
||||||
else pytest.mark.expensive(x)
|
else pytest.param(x, marks=[pytest.mark.expensive()])
|
||||||
for x in ThreadColor
|
for x in ThreadColor
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@@ -23,15 +23,15 @@ EMOJI_LIST = [
|
|||||||
("😆", EmojiSize.LARGE),
|
("😆", EmojiSize.LARGE),
|
||||||
# These fail in `catch_event` because the emoji is made into a sticker
|
# These fail in `catch_event` because the emoji is made into a sticker
|
||||||
# This should be fixed
|
# This should be fixed
|
||||||
pytest.mark.xfail((None, EmojiSize.SMALL)),
|
pytest.param(None, EmojiSize.SMALL, marks=[pytest.mark.xfail()]),
|
||||||
pytest.mark.xfail((None, EmojiSize.MEDIUM)),
|
pytest.param(None, EmojiSize.MEDIUM, marks=[pytest.mark.xfail()]),
|
||||||
pytest.mark.xfail((None, EmojiSize.LARGE)),
|
pytest.param(None, EmojiSize.LARGE, marks=[pytest.mark.xfail()]),
|
||||||
]
|
]
|
||||||
|
|
||||||
STICKER_LIST = [
|
STICKER_LIST = [
|
||||||
Sticker("767334476626295"),
|
Sticker("767334476626295"),
|
||||||
pytest.mark.xfail(Sticker("0"), raises=FBchatFacebookError),
|
pytest.param(Sticker("0"), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
|
||||||
pytest.mark.xfail(Sticker(None), raises=FBchatFacebookError),
|
pytest.param(Sticker(None), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
|
||||||
]
|
]
|
||||||
|
|
||||||
TEXT_LIST = [
|
TEXT_LIST = [
|
||||||
@@ -40,8 +40,8 @@ TEXT_LIST = [
|
|||||||
"\\\n\t%?&'\"",
|
"\\\n\t%?&'\"",
|
||||||
"ˁҭʚ¹Ʋջوװ՞ޱɣࠚԹБɑȑңКએ֭ʗыԈٌʼőԈ×௴nચϚࠖణٔє܅Ԇޑط",
|
"ˁҭʚ¹Ʋջوװ՞ޱɣࠚԹБɑȑңКએ֭ʗыԈٌʼőԈ×௴nચϚࠖణٔє܅Ԇޑط",
|
||||||
"a" * 20000, # Maximum amount of characters you can send
|
"a" * 20000, # Maximum amount of characters you can send
|
||||||
pytest.mark.xfail("a" * 20001, raises=FBchatFacebookError),
|
pytest.param("a" * 20001, marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
|
||||||
pytest.mark.xfail(None, raises=FBchatFacebookError),
|
pytest.param(None, marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user