Compare commits

...

62 Commits

Author SHA1 Message Date
Mads Marquart
4f947cdbb5 Version up, thanks to @kapi2289 and @kaushalvivek 2019-01-25 16:01:47 +01:00
Mads Marquart
ec6c29052a Merge pull request #371 from carpedm20/fix-enums
Fix `ThreadColor` and `MessageReaction` enums
2019-01-24 22:42:41 +01:00
Mads Marquart
6b117502f3 Merge branch 'master' into fix-enums 2019-01-24 22:40:44 +01:00
Mads Marquart
3e7b20c379 Merge pull request #377 from kapi2289/fix-fbchatexception
Fixed typos in FBchatException
2019-01-05 18:20:38 +01:00
Kacper Ziubryniewicz
f4a997c0ef Fixed typos in FBchatException 2019-01-05 17:55:54 +01:00
Mads Marquart
84fa15e44c Merge pull request #333 from kapi2289/extensible_attachments
[Feature] Extensible attachments
2019-01-04 21:06:11 +01:00
Kacper Ziubryniewicz
7b8ecf8fe3 Changed deleted to unsent 2019-01-04 20:02:00 +01:00
Kacper Ziubryniewicz
95989b6da7 Merge branch 'master' into extensible_attachments 2018-12-23 14:58:03 +01:00
Kacper Ziubryniewicz
22e57f99a1 deleted attribute of Message
and batter handling of deleted (unsended) messages
2018-12-23 14:56:27 +01:00
Kacper Ziubryniewicz
b9d29c0417 Removed addReaction, removeReaction, _react
(and undeprecated `reactToMessage`)
2018-12-23 14:45:17 +01:00
Mads Marquart
45d8b45d96 Fix enum_extend_if_invalid warning 2018-12-12 23:22:08 +01:00
Mads Marquart
b6a6d7dc68 Move enum_extend_if_invalid to utils.py 2018-12-12 23:06:16 +01:00
Mads Marquart
c57b84cd0b Refactor enum extending 2018-12-12 23:04:26 +01:00
Mads Marquart
78e7841b5e Extend MessageReaction when encountering unknown values 2018-12-12 22:53:23 +01:00
Mads Marquart
e41d981449 Extend ThreadColor when encountering unknown values 2018-12-12 22:44:19 +01:00
Mads Marquart
381227af66 Make use aenum instead of the default enum 2018-12-12 22:39:31 +01:00
Mads Marquart
2f8d0728ba Merge pull request #366 from kaushalvivek/master
Fix for issue #365
2018-12-10 21:16:57 +01:00
kaushalvivek
13bfc5f2f9 Fix for search limit 2018-12-10 14:46:04 +05:30
Mads Marquart
f8d3b571ba Version up, thanks to @ekohilas and @kapi2289 2018-12-09 21:21:00 +01:00
Mads Marquart
64b1e52d4c Merge pull request #357 from carpedm20/fixed-listening
Fixed listening
2018-12-09 19:23:33 +01:00
Mads Marquart
b650f7ee9a Merge pull request #367 from carpedm20/fix-pytest-deprecation
Fix pytest "Applying marks directly to parameters" deprecation
2018-12-09 19:23:20 +01:00
Kacper Ziubryniewicz
d4446280c7 Detecting when someone unsends a message 2018-12-09 15:27:01 +01:00
Mads Marquart
3443a233f4 Fix pytest "Applying marks directly to parameters" deprecation 2018-12-09 15:02:48 +01:00
Kacper Ziubryniewicz
861f17bc4d Added DeletedMessage attachment 2018-12-09 14:55:10 +01:00
Kacper Ziubryniewicz
41bbe18e3d Unsending messages 2018-12-09 14:36:23 +01:00
Vivek Kaushal
d32b7b612a Fix for issue #365 2018-12-07 21:26:48 +05:30
Mads Marquart
160386be62 Added support for request_batch parsing in _parseMessage 2018-11-09 20:08:26 +01:00
Mads Marquart
64bdde8f33 Sticky and pool parameters can be set after the inital _fetchSticky 2018-11-07 20:06:10 +01:00
Kacper Ziubryniewicz
8739318101 Sending voice clips 2018-10-30 22:24:47 +01:00
Kacper Ziubryniewicz
1ac569badd Sending pinned or current location 2018-10-30 22:21:05 +01:00
Mads Marquart
89a277c354 Merge pull request #354 from ekohilas/master
separate spellchecked docs
2018-10-28 12:46:48 +01:00
Mads Marquart
8238387c7d Merge pull request #353 from ekohilas/docstrings
completed todo for graphql_requests
2018-10-28 12:45:37 +01:00
ekohilas
6c829581af completed todo for graphql_requests 2018-10-27 02:02:15 +11:00
ekohilas
d180650c1b spellchecked docs 2018-10-25 18:18:19 +11:00
Mads Marquart
772bf5518f Merge pull request #346 from kapi2289/remove_unnecessary
Remove unnecessary code
2018-10-07 16:50:31 +02:00
Kacper Ziubryniewicz
153dc0bdad Remove unnecessary code 2018-10-07 16:27:19 +02:00
Kacper Ziubryniewicz
b7ea8e6001 New sendLocation method 2018-09-29 13:48:08 +02:00
Kacper Ziubryniewicz
b0bf5ba8e0 Update graphql.py 2018-09-29 13:42:11 +02:00
Kacper Ziubryniewicz
8169a5f776 Changed LocationAttachment 2018-09-29 13:40:38 +02:00
Mads Marquart
b4b8914448 Version up, thanks to @kapi2289 2018-09-27 21:53:12 +02:00
Mads Marquart
2ea2c89b4a Fixed markAsRead and markAsUnread, fixes #336 2018-09-27 21:44:04 +02:00
Mads Marquart
479ca59a6a Merge pull request #341 from kapi2289/read_by
[Feature] New `read_by` attribute of `Message`
2018-09-27 20:56:13 +02:00
Mads Marquart
343f987a78 Merge pull request #340 from kapi2289/fix_fetch_thread_list
[Fix] `fetchThreadList` fix
2018-09-27 20:27:03 +02:00
Kacper Ziubryniewicz
bad9c7a4b9 read_by handling 2018-09-24 20:33:43 +02:00
Kacper Ziubryniewicz
576e0949e0 New read_by attribute in Message 2018-09-24 20:32:04 +02:00
Kacper Ziubryniewicz
d807648d2b fetchThreadList fix 2018-09-24 16:50:15 +02:00
Kacper Ziubryniewicz
c6dc432d06 Move on methods to the right place 2018-09-22 20:39:41 +02:00
Kacper Ziubryniewicz
9e8fe7bc1e Fix Python 2.7 compability 2018-09-15 11:34:16 +02:00
Kacper Ziubryniewicz
90813c959d Added get_url_parameters util method 2018-09-15 11:21:35 +02:00
Kacper Ziubryniewicz
940a65954c Read commit description
Added:
- Detecting extensible attachments
- Fetching live user location
- New methods for message reacting
- New `on` methods: `onReactionAdded`, `onReactionRemoved`, `onBlock`, `onUnblock`, `onLiveLocation`
- Fixed `size` of attachments
2018-09-12 17:52:38 +02:00
Kacper Ziubryniewicz
9b4e753a79 Added graphql methods for extensible attachments 2018-09-12 17:48:35 +02:00
Kacper Ziubryniewicz
e0be9029e4 Added extensible attachments models 2018-09-12 17:48:00 +02:00
Kacper Ziubryniewicz
0ae213c240 Merge pull request #1 from carpedm20/master
Merge `master`
2018-09-12 17:41:53 +02:00
Mads Marquart
08117e7a54 Fixed examples, see #332
The examples were using generator expressions instead of list comprehensions
2018-09-09 14:24:20 +02:00
Mads Marquart
51c3226070 Merge pull request #326 from kapi2289/merge_rooms
Merge `Room` with `Group`
2018-09-09 14:09:36 +02:00
Mads Marquart
5396d19d7d Merge pull request #327 from kapi2289/fix_active
`markAlive` fix
2018-09-09 14:07:48 +02:00
Kacper Ziubryniewicz
11501e6899 Fix Room model initialization 2018-09-03 15:05:11 +02:00
Kacper Ziubryniewicz
4eb49b9119 Backwards compability for Rooms 2018-08-31 13:25:37 +02:00
Kacper Ziubryniewicz
4c2da22750 markAlive fix 2018-08-30 20:28:32 +02:00
Kacper Ziubryniewicz
753b9cbae2 Merge Room with Group methods 2018-08-30 19:57:47 +02:00
Kacper Ziubryniewicz
2c73cabe22 Merge Room with Group graphql methods 2018-08-30 19:57:12 +02:00
Kacper Ziubryniewicz
d6ca091b7b Merge Room with Group model 2018-08-30 19:56:18 +02:00
19 changed files with 614 additions and 182 deletions

View File

@@ -13,7 +13,7 @@ If you are looking for information on a specific function, class, or method, thi
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)
.. autoclass:: Client(email, password, user_agent=None, max_tries=5, session_cookies=None, logging_level=logging.INFO)

View File

@@ -18,7 +18,7 @@ This will show basic usage of `fbchat`
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

View File

@@ -8,7 +8,7 @@ FAQ
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
Downgrade to an earlier version of fbchat, run this command

View File

@@ -6,7 +6,7 @@ Introduction
============
`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
@@ -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
(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.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``.
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`,
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.
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::
session_cookies = client.getSession()

View File

@@ -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.
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

View File

@@ -8,8 +8,8 @@ client = Client('<email>', '<password>')
# Fetches a list of all users you're currently chatting with, as `User` objects
users = client.fetchAllUsers()
print("users' IDs: {}".format(user.uid for user in users))
print("users' names: {}".format(user.name for user in users))
print("users' IDs: {}".format([user.uid for user in users]))
print("users' names: {}".format([user.name for user in users]))
# If we have a user id, we can use `fetchUserInfo` to fetch a `User` object
@@ -18,7 +18,7 @@ user = client.fetchUserInfo('<user id>')['<user id>']
users = client.fetchUserInfo('<1st user id>', '<2nd user id>', '<3rd user id>')
print("user's name: {}".format(user.name))
print("users' names: {}".format(users[k].name for k in users))
print("users' names: {}".format([users[k].name for k in users]))
# `searchForUsers` searches for the user and gives us a list of the results,

View File

@@ -15,7 +15,7 @@ from __future__ import unicode_literals
from .client import *
__title__ = 'fbchat'
__version__ = '1.4.0'
__version__ = '1.5.0'
__description__ = 'Facebook Chat (Messenger) for Python'
__copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim'

View File

@@ -168,10 +168,12 @@ class Client(object):
def graphql_requests(self, *queries):
"""
.. todo::
Documenting this
:param queries: Zero or more GraphQL objects
:type queries: GraphQL
:raises: FBchatException if request failed
:return: A tuple containing json graphql queries
:rtype: tuple
"""
return tuple(self._graphql({
@@ -238,22 +240,6 @@ class Client(object):
self.payloadDefault['ttstamp'] = self.ttstamp
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):
if not (self.email and self.password):
raise FBchatUserError("Email and password not found.")
@@ -457,7 +443,8 @@ class Client(object):
return given_thread_id, given_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_type: See :ref:`intro_threads`
@@ -515,7 +502,7 @@ class Client(object):
return users
def searchForUsers(self, name, limit=1):
def searchForUsers(self, name, limit=10):
"""
Find and get user by his/her name
@@ -530,7 +517,7 @@ class Client(object):
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
@@ -544,8 +531,7 @@ class Client(object):
return [graphql_to_page(node) for node in j[name]['pages']['nodes']]
# TODO intergrate Rooms
def searchForGroups(self, name, limit=1):
def searchForGroups(self, name, limit=10):
"""
Find and get group thread by its name
@@ -560,7 +546,7 @@ class Client(object):
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
@@ -585,7 +571,6 @@ class Client(object):
elif node['__typename'] == 'Group':
# We don't handle Facebook "Groups"
pass
# TODO Add Rooms
else:
log.warning('Unknown __typename: {} in {}'.format(repr(node['__typename']), node))
@@ -818,9 +803,6 @@ class Client(object):
if entry.get('thread_type') == 'GROUP':
_id = entry['thread_key']['thread_fbid']
rtn[_id] = graphql_to_group(entry)
elif entry.get('thread_type') == 'ROOM':
_id = entry['thread_key']['thread_fbid']
rtn[_id] = graphql_to_room(entry)
elif entry.get('thread_type') == 'ONE_TO_ONE':
_id = entry['thread_key']['other_user_id']
if pages_and_users.get(_id) is None:
@@ -855,14 +837,22 @@ class Client(object):
'id': thread_id,
'message_limit': limit,
'load_messages': True,
'load_read_receipts': False,
'load_read_receipts': True,
'before': before
}))
if j.get('message_thread') is None:
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):
"""Get thread list of your facebook account
@@ -937,14 +927,14 @@ class Client(object):
:type image_id: str
:return: An url where you can download the original image
:rtype: str
:raises: FBChatException if request failed
:raises: FBchatException if request failed
"""
image_id = str(image_id)
j = check_request(self._get(ReqUrl.ATTACHMENT_PHOTO, query={'photo_id': str(image_id)}))
url = get_jsmods_require(j, 3)
if url is None:
raise FBChatException('Could not fetch image url from: {}'.format(j))
raise FBchatException('Could not fetch image url from: {}'.format(j))
return url
def fetchMessageInfo(self, mid, thread_id=None):
@@ -955,7 +945,7 @@ class Client(object):
:param thread_id: User/Group ID to get message info from. See :ref:`intro_threads`
:return: :class:`models.Message` object
:rtype: models.Message
:raises: FBChatException if request failed
:raises: FBchatException if request failed
"""
thread_id, thread_type = self._getThread(thread_id, None)
message_info = self._forcedFetch(thread_id, mid).get("message")
@@ -968,7 +958,7 @@ class Client(object):
:param poll_id: Poll ID to fetch from
:rtype: list
:raises: FBChatException if request failed
:raises: FBchatException if request failed
"""
data = {
"question_id": poll_id
@@ -985,7 +975,7 @@ class Client(object):
:param plan_id: Plan ID to fetch from
:return: :class:`models.Plan` object
:rtype: models.Plan
:raises: FBChatException if request failed
:raises: FBchatException if request failed
"""
data = {
"event_reminder_id": plan_id
@@ -1121,7 +1111,56 @@ class Client(object):
data['specific_to_list[0]'] = "fbid:{}".format(thread_id)
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
@@ -1131,7 +1170,12 @@ class Client(object):
Returns a list of tuples with a file's ID and mimetype
"""
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):
raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files))
@@ -1175,7 +1219,7 @@ class Client(object):
"""
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 thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads`
@@ -1188,6 +1232,39 @@ class Client(object):
files = self._upload(x)
return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type)
def sendRemoteVoiceClips(self, clip_urls, message=None, thread_id=None, thread_type=ThreadType.USER):
"""
Sends voice clips from URLs to a thread
:param clip_urls: URLs of clips to upload and send
:param message: Additional message
:param thread_id: User/Group ID to send to. See :ref:`intro_threads`
:param thread_type: See :ref:`intro_threads`
:type thread_type: models.ThreadType
:return: :ref:`Message ID <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):
"""
Deprecated. Use :func:`fbchat.Client._sendFiles` instead
@@ -1492,27 +1569,26 @@ class Client(object):
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 reaction: Reaction emoji to use
:type reaction: models.MessageReaction
:param reaction: Reaction emoji to use, if None removes reaction
:type reaction: models.MessageReaction or None
:raises: FBchatException if request failed
"""
full_data = {
data = {
"doc_id": 1491398900900362,
"variables": json.dumps({
"data": {
"action": "ADD_REACTION",
"action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
"client_mutation_id": "1",
"actor_id": self.uid,
"message_id": str(message_id),
"reaction": reaction.value
"reaction": reaction.value if reaction else None
}
})
}
j = self._post(self.req_url.MESSAGE_REACTION, full_data, fix_request=True, as_json=True)
self._post(self.req_url.MESSAGE_REACTION, data, fix_request=True, as_json=True)
def createPlan(self, plan, thread_id=None):
"""
@@ -1720,7 +1796,7 @@ class Client(object):
}
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)
return r.ok
@@ -1966,47 +2042,32 @@ class Client(object):
LISTEN METHODS
"""
def _ping(self, sticky, pool):
def _ping(self):
data = {
'channel': self.user_channel,
'clientid': self.client_id,
'partition': -2,
'cap': 0,
'uid': self.uid,
'sticky_token': sticky,
'sticky_pool': pool,
'sticky_token': self.sticky,
'sticky_pool': self.pool,
'viewer_uid': self.uid,
'state': 'active'
'state': 'active',
}
self._get(self.req_url.PING, data, fix_request=True, as_json=False)
def _fetchSticky(self):
"""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):
def _pullMessage(self, markAlive=True):
"""Call pull api with seq value to get message data."""
data = {
"msgs_recv": 0,
"sticky_token": sticky,
"sticky_pool": pool,
"sticky_token": self.sticky,
"sticky_pool": self.pool,
"clientid": self.client_id,
'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')
return j
@@ -2014,6 +2075,14 @@ class Client(object):
def _parseMessage(self, content):
"""Get message and author name from content. May contain multiple messages in the content."""
if 'lb_info' in content:
self.sticky = content['lb_info']['sticky']
self.pool = content['lb_info']['pool']
if 'batches' in content:
for batch in content['batches']:
self._parseMessage(batch)
if 'ms' not in content: return
for m in content["ms"]:
@@ -2036,6 +2105,7 @@ class Client(object):
delta = m["delta"]
delta_type = delta.get("type")
delta_class = delta.get("class")
metadata = delta.get("messageMetadata")
if metadata:
@@ -2072,14 +2142,14 @@ class Client(object):
thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
# Thread title change
elif delta.get("class") == "ThreadName":
elif delta_class == "ThreadName":
new_title = delta["name"]
thread_id, thread_type = getThreadIdAndThreadType(metadata)
self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id,
thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
# Forced fetch
elif delta.get("class") == "ForcedFetch":
elif delta_class == "ForcedFetch":
mid = delta.get("messageId")
if mid is None:
self.onUnknownMesssageType(msg=m)
@@ -2125,7 +2195,7 @@ class Client(object):
thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
# Message delivered
elif delta.get("class") == "DeliveryReceipt":
elif delta_class == "DeliveryReceipt":
message_ids = delta["messageIds"]
delivered_for = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
ts = int(delta["deliveredWatermarkTimestampMs"])
@@ -2135,7 +2205,7 @@ class Client(object):
metadata=metadata, msg=m)
# Message seen
elif delta.get("class") == "ReadReceipt":
elif delta_class == "ReadReceipt":
seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"])
seen_ts = int(delta["actionTimestampMs"])
delivered_ts = int(delta["watermarkTimestampMs"])
@@ -2144,7 +2214,7 @@ class Client(object):
seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, msg=m)
# Messages marked as seen
elif delta.get("class") == "MarkRead":
elif delta_class == "MarkRead":
seen_ts = int(delta.get("actionTimestampMs") or delta.get("actionTimestamp"))
delivered_ts = int(delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp"))
@@ -2244,6 +2314,61 @@ class Client(object):
self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id,
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
# Client payload (that weird numbers)
elif delta_class == "ClientPayload":
payload = json.loads("".join(chr(z) for z in delta['payload']))
ts = m.get("ofd_ts")
for d in payload.get('deltas', []):
# Message reaction
if d.get('deltaMessageReaction'):
i = d['deltaMessageReaction']
thread_id, thread_type = getThreadIdAndThreadType(i)
mid = i["messageId"]
author_id = str(i["userId"])
reaction = MessageReaction(i["reaction"]) if i.get("reaction") else None
add_reaction = not bool(i["action"])
if add_reaction:
self.onReactionAdded(mid=mid, reaction=reaction, author_id=author_id,
thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
else:
self.onReactionRemoved(mid=mid, author_id=author_id, thread_id=thread_id,
thread_type=thread_type, ts=ts, msg=m)
# Viewer status change
elif d.get('deltaChangeViewerStatus'):
i = d['deltaChangeViewerStatus']
thread_id, thread_type = getThreadIdAndThreadType(i)
author_id = str(i["actorFbid"])
reason = i["reason"]
can_reply = i["canViewerReply"]
if reason == 2:
if can_reply:
self.onUnblock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
else:
self.onBlock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m)
# Live location info
elif d.get('liveLocationData'):
i = d['liveLocationData']
thread_id, thread_type = getThreadIdAndThreadType(i)
for l in i['messageLiveLocations']:
mid = l["messageId"]
author_id = str(l["senderId"])
location = graphql_to_live_location(l)
self.onLiveLocation(mid=mid, location=location, author_id=author_id, thread_id=thread_id,
thread_type=thread_type, ts=ts, msg=m)
# Message deletion
elif d.get('deltaRecallMessageData'):
i = d['deltaRecallMessageData']
thread_id, thread_type = getThreadIdAndThreadType(i)
mid = i['messageID']
ts = i['deletionTimestamp']
author_id = str(i['senderID'])
self.onMessageUnsent(mid=mid, author_id=author_id, thread_id=thread_id, thread_type=thread_type,
ts=ts, msg=m)
# New message
elif delta.get("class") == "NewMessage":
mentions = []
@@ -2255,6 +2380,7 @@ class Client(object):
sticker = None
attachments = []
unsent = False
if delta.get('attachments'):
try:
for a in delta['attachments']:
@@ -2262,17 +2388,23 @@ class Client(object):
if mercury.get('blob_attachment'):
image_metadata = a.get('imageMetadata', {})
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
attachment.size = int(a['fileSize'])
attachments.append(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'):
# TODO: Add more data here for shared stuff (URLs, events and so on)
pass
attachment = graphql_to_extensible_attachment(mercury['extensible_attachment'])
if isinstance(attachment, UnsentMessage):
unsent = True
elif attachment:
attachments.append(attachment)
except Exception:
log.exception('An exception occured while reading attachments: {}'.format(delta['attachments']))
@@ -2284,12 +2416,13 @@ class Client(object):
mentions=mentions,
emoji_size=emoji_size,
sticker=sticker,
attachments=attachments
attachments=attachments,
)
message.uid = mid
message.author = author_id
message.timestamp = ts
#message.reactions = {}
message.unsent = unsent
thread_id, thread_type = getThreadIdAndThreadType(metadata)
self.onMessage(mid=mid, author_id=author_id, message=delta.get('body', ''), message_object=message,
thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m)
@@ -2359,7 +2492,6 @@ class Client(object):
:raises: FBchatException if request failed
"""
self.listening = True
self.sticky, self.pool = self._fetchSticky()
def doOneListen(self, markAlive=True):
"""
@@ -2373,8 +2505,8 @@ class Client(object):
"""
try:
if markAlive:
self._ping(self.sticky, self.pool)
content = self._pullMessage(self.sticky, self.pool)
self._ping()
content = self._pullMessage(markAlive)
if content:
self._parseMessage(content)
except KeyboardInterrupt:
@@ -2646,6 +2778,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))
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):
"""
@@ -2727,6 +2872,79 @@ class Client(object):
"""
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 onQprimer(self, ts=None, msg=None):
"""
Called when the client just started listening
@@ -2932,6 +3150,7 @@ class Client(object):
:param metadata: Extra metadata about the action
:param msg: A full set of the data recieved
:type plan: models.Plan
:type take_part: bool
:type thread_type: models.ThreadType
"""
if take_part:

View File

@@ -26,12 +26,11 @@ class ConcatJSONDecoder(json.JSONDecoder):
def graphql_color_to_enum(color):
if color is None:
return None
if len(color) == 0:
if not color:
return ThreadColor.MESSENGER_BLUE
try:
return ThreadColor('#{}'.format(color[2:].lower()))
except ValueError:
raise FBchatException('Could not get ThreadColor from color: {}'.format(color))
color = color[2:] # Strip the alpha value
color_value = '#{}'.format(color.lower())
return enum_extend_if_invalid(ThreadColor, color_value)
def get_customization_info(thread):
if thread is None or thread.get('customization_info') is None:
@@ -42,7 +41,7 @@ def get_customization_info(thread):
'emoji': info.get('emoji'),
'color': graphql_color_to_enum(info.get('outgoing_bubble_color'))
}
if thread.get('thread_type') in ('GROUP', 'ROOM') or thread.get('is_group_thread') or thread.get('thread_key', {}).get('thread_fbid'):
if thread.get('thread_type') == 'GROUP' or thread.get('is_group_thread') or thread.get('thread_key', {}).get('thread_fbid'):
rtn['nicknames'] = {}
for k in info.get('participant_customizations', []):
rtn['nicknames'][k['participant_id']] = k.get('nickname')
@@ -128,9 +127,84 @@ def graphql_to_attachment(a):
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):
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')]
)
rtn.uid = int(a["id"])
@@ -207,20 +281,30 @@ def graphql_to_message(message):
rtn.uid = str(message.get('message_id'))
rtn.author = str(message.get('message_sender').get('id'))
rtn.timestamp = message.get('timestamp_precise')
rtn.unsent = False
if message.get('unread') is not None:
rtn.is_read = not message['unread']
rtn.reactions = {str(r['user']['id']):MessageReaction(r['reaction']) for r in message.get('message_reactions')}
rtn.reactions = {
str(r['user']['id']): enum_extend_if_invalid(MessageReaction, r['reaction'])
for r in message.get('message_reactions')
}
if message.get('blob_attachments') is not None:
rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']]
# TODO: This is still missing parsing:
# message.get('extensible_attachment')
if message.get('extensible_attachment') is not None:
attachment = graphql_to_extensible_attachment(message['extensible_attachment'])
if isinstance(attachment, UnsentMessage):
rtn.unsent = True
elif attachment:
rtn.attachments.append(attachment)
return rtn
def graphql_to_user(user):
if user.get('profile_picture') is None:
user['profile_picture'] = {}
c_info = get_customization_info(user)
plan = graphql_to_plan(user['event_reminders']['nodes'][0]) if user.get('event_reminders', dict()).get('nodes') else None
plan = None
if user.get('event_reminders'):
plan = graphql_to_plan(user['event_reminders']['nodes'][0]) if user['event_reminders'].get('nodes') else None
return User(
user['id'],
url=user.get('url'),
@@ -258,7 +342,9 @@ def graphql_to_thread(thread):
else:
last_name = user.get('name').split(first_name, 1).pop().strip()
plan = graphql_to_plan(thread['event_reminders']['nodes'][0]) if thread.get('event_reminders', dict()).get('nodes') else None
plan = None
if thread.get('event_reminders'):
plan = graphql_to_plan(thread['event_reminders']['nodes'][0]) if thread['event_reminders'].get('nodes') else None
return User(
user['id'],
@@ -288,13 +374,19 @@ def graphql_to_group(group):
last_message_timestamp = None
if 'last_message' in group:
last_message_timestamp = group['last_message']['nodes'][0]['timestamp_precise']
plan = graphql_to_plan(group['event_reminders']['nodes'][0]) if group.get('event_reminders', dict()).get('nodes') else None
plan = None
if group.get('event_reminders'):
plan = graphql_to_plan(group['event_reminders']['nodes'][0]) if group['event_reminders'].get('nodes') else None
return Group(
group['thread_key']['thread_fbid'],
participants=set([node['messaging_actor']['id'] for node in group['all_participants']['nodes']]),
nicknames=c_info.get('nicknames'),
color=c_info.get('color'),
emoji=c_info.get('emoji'),
admins = set([node.get('id') for node in group.get('thread_admins')]),
approval_mode = bool(group.get('approval_mode')) if group.get('approval_mode') is not None else None,
approval_requests = set(node["requester"]['id'] for node in group['group_approval_queue']['nodes']) if group.get('group_approval_queue') else None,
join_link = group['joinable_mode'].get('link'),
photo=group['image'].get('uri'),
name=group.get('name'),
message_count=group.get('messages_count'),
@@ -302,34 +394,14 @@ def graphql_to_group(group):
plan=plan,
)
def graphql_to_room(room):
if room.get('image') is None:
room['image'] = {}
c_info = get_customization_info(room)
plan = graphql_to_plan(room['event_reminders']['nodes'][0]) if room.get('event_reminders', dict()).get('nodes') else None
return Room(
room['thread_key']['thread_fbid'],
participants=set([node['messaging_actor']['id'] for node in room['all_participants']['nodes']]),
nicknames=c_info.get('nicknames'),
color=c_info.get('color'),
emoji=c_info.get('emoji'),
photo=room['image'].get('uri'),
name=room.get('name'),
message_count=room.get('messages_count'),
admins = set([node.get('id') for node in room.get('thread_admins')]),
approval_mode = bool(room.get('approval_mode')),
approval_requests = set(node.get('id') for node in room['thread_queue_metadata'].get('approval_requests', {}).get('nodes')),
join_link = room['joinable_mode'].get('link'),
privacy_mode = bool(room.get('privacy_mode')),
plan=plan,
)
def graphql_to_page(page):
if page.get('profile_picture') is None:
page['profile_picture'] = {}
if page.get('city') is None:
page['city'] = {}
plan = graphql_to_plan(page['event_reminders']['nodes'][0]) if page.get('event_reminders', dict()).get('nodes') else None
plan = None
if page.get('event_reminders'):
plan = graphql_to_plan(page['event_reminders']['nodes'][0]) if page['event_reminders'].get('nodes') else None
return Page(
page['id'],
url=page.get('url'),
@@ -433,6 +505,40 @@ class GraphQL(object):
},
outgoing_bubble_color,
emoji
},
thread_admins {
id
},
group_approval_queue {
nodes {
requester {
id
}
}
},
approval_mode,
joinable_mode {
mode,
link
},
event_reminders {
nodes {
id,
lightweight_event_creator {
id
},
time,
location_name,
event_title,
event_reminder_members {
edges {
node {
id
},
guest_list_state
}
}
}
}
}
"""
@@ -453,7 +559,7 @@ class GraphQL(object):
"""
SEARCH_USER = """
Query SearchUser(<search> = '', <limit> = 1) {
Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@@ -465,7 +571,7 @@ class GraphQL(object):
""" + FRAGMENT_USER
SEARCH_GROUP = """
Query SearchGroup(<search> = '', <limit> = 1, <pic_size> = 32) {
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@@ -477,7 +583,7 @@ class GraphQL(object):
""" + FRAGMENT_GROUP
SEARCH_PAGE = """
Query SearchPage(<search> = '', <limit> = 1) {
Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@@ -489,7 +595,7 @@ class GraphQL(object):
""" + FRAGMENT_PAGE
SEARCH_THREAD = """
Query SearchThread(<search> = '', <limit> = 1) {
Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {

View File

@@ -1,7 +1,7 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import enum
import aenum
class FBchatException(Exception):
@@ -102,8 +102,16 @@ class Group(Thread):
color = None
#: The groups's default emoji
emoji = None
# Set containing user IDs of thread admins
admins = None
# True if users need approval to join
approval_mode = None
# Set containing user IDs requesting to join
approval_requests = None
# Link for joining group
join_link = None
def __init__(self, uid, participants=None, nicknames=None, color=None, emoji=None, **kwargs):
def __init__(self, uid, participants=None, nicknames=None, color=None, emoji=None, admins=None, approval_mode=None, approval_requests=None, join_link=None, privacy_mode=None, **kwargs):
"""Represents a Facebook group. Inherits `Thread`"""
super(Group, self).__init__(ThreadType.GROUP, uid, **kwargs)
if participants is None:
@@ -114,24 +122,6 @@ class Group(Thread):
self.nicknames = nicknames
self.color = color
self.emoji = emoji
class Room(Group):
# Set containing user IDs of thread admins
admins = None
# True if users need approval to join
approval_mode = None
# Set containing user IDs requesting to join
approval_requests = None
# Link for joining room
join_link = None
# True is room is not discoverable
privacy_mode = None
def __init__(self, uid, admins=None, approval_mode=None, approval_requests=None, join_link=None, privacy_mode=None, **kwargs):
"""Represents a Facebook room. Inherits `Group`"""
super(Room, self).__init__(uid, **kwargs)
self.type = ThreadType.ROOM
if admins is None:
admins = set()
self.admins = admins
@@ -140,6 +130,16 @@ class Room(Group):
approval_requests = set()
self.approval_requests = approval_requests
self.join_link = join_link
class Room(Group):
# True is room is not discoverable
privacy_mode = None
def __init__(self, uid, privacy_mode=None, **kwargs):
"""Deprecated. Use :class:`Group` instead"""
super(Room, self).__init__(uid, **kwargs)
self.type = ThreadType.ROOM
self.privacy_mode = privacy_mode
@@ -180,6 +180,8 @@ class Message(object):
timestamp = None
#: Whether the message is read
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
reactions = None
#: The actual message
@@ -188,6 +190,8 @@ class Message(object):
sticker = None
#: A list of attachments
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):
"""Represents a Facebook message"""
@@ -201,6 +205,8 @@ class Message(object):
attachments = []
self.attachments = attachments
self.reactions = {}
self.read_by = []
self.deleted = False
def __repr__(self):
return self.__unicode__()
@@ -216,6 +222,12 @@ class Attachment(object):
"""Represents a Facebook attachment"""
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):
#: The sticker-pack's ID
pack = None
@@ -248,9 +260,79 @@ class Sticker(Attachment):
super(Sticker, self).__init__(*args, **kwargs)
class ShareAttachment(Attachment):
def __init__(self, **kwargs):
"""Represents a shared item (eg. URL) that has been sent as a Facebook attachment - *Currently Incomplete!*"""
#: ID of the author of the shared post
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)
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):
#: Url where you can download the file
@@ -520,7 +602,7 @@ class Plan(object):
def __unicode__(self):
return '<Plan ({}): {} time={}, location={}, location_id={}>'.format(self.uid, repr(self.title), self.time, repr(self.location), repr(self.location_id))
class Enum(enum.Enum):
class Enum(aenum.Enum):
"""Used internally by fbchat to support enumerations"""
def __repr__(self):
# For documentation:
@@ -530,8 +612,8 @@ class ThreadType(Enum):
"""Used to specify what type of Facebook thread is being used. See :ref:`intro_threads` for more info"""
USER = 1
GROUP = 2
ROOM = 2
PAGE = 3
ROOM = 4
class ThreadLocation(Enum):
"""Used to specify where a thread is located (inbox, pending, archived, other)."""

View File

@@ -11,13 +11,15 @@ from os.path import basename
import warnings
import logging
import requests
import aenum
from .models import *
try:
from urllib.parse import urlencode
from urllib.parse import urlencode, parse_qs, urlparse
basestring = (str, bytes)
except ImportError:
from urllib import urlencode
from urlparse import parse_qs, urlparse
basestring = basestring
# Python 2's `input` executes the input, whereas `raw_input` just returns the input
@@ -140,6 +142,7 @@ class ReqUrl(object):
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options"
SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1"
MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1"
pull_channel = 0
@@ -297,3 +300,18 @@ def get_files_from_paths(filenames):
yield files
for fn, fp, ft in files:
fp.close()
def enum_extend_if_invalid(enumeration, value):
try:
return enumeration(value)
except ValueError:
log.warning("Failed parsing {.__name__}({!r}). Extending enum.".format(enumeration, value))
aenum.extend_enum(enumeration, "UNKNOWN_{}".format(value).upper(), value)
return enumeration(value)
def get_url_parameters(url, *args):
params = parse_qs(urlparse(url).query)
return [params[arg][0] for arg in args if params.get(arg)]
def get_url_parameter(url, param):
return get_url_parameters(url, param)[0]

View File

@@ -1,3 +1,3 @@
requests
beautifulsoup4
enum34; python_version < '3.4'
aenum

View File

@@ -43,9 +43,6 @@ include_package_data = True
packages = find:
python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4.0
install_requires =
aenum
requests
beautifulsoup4
# May not work in pip with bdist_wheel
# See https://wheel.readthedocs.io/en/latest/#defining-conditional-dependencies
# It is therefore defined in setup.py
# enum34; python_version < '3.4'

View File

@@ -5,4 +5,4 @@ from __future__ import unicode_literals
from setuptools import setup
setup(extras_require={':python_version < "3.4"': ['enum34']})
setup()

View File

@@ -20,7 +20,9 @@ def group(pytestconfig):
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):
return {
"user": user,

View File

@@ -11,8 +11,14 @@ from time import time
@pytest.fixture(scope="module", params=[
Plan(int(time()) + 100, random_hex()),
pytest.mark.xfail(Plan(int(time()), random_hex()), raises=FBchatFacebookError),
pytest.mark.xfail(Plan(0, None)),
pytest.param(
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):
with catch_event("onPlanCreated") as x:

View File

@@ -26,7 +26,9 @@ from utils import random_hex, subset
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):
with catch_event("onPollCreated") as x:

View File

@@ -72,8 +72,8 @@ def test_change_nickname(client, client_all, catch_event, compare):
"😂",
"😕",
"😍",
pytest.mark.xfail("🙃", raises=FBchatFacebookError),
pytest.mark.xfail("not an emoji", raises=FBchatFacebookError)
pytest.param("🙃", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
pytest.param("not an emoji", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
])
def test_change_emoji(client, catch_event, compare, emoji):
with catch_event("onEmojiChange") as x:
@@ -101,7 +101,7 @@ def test_change_image_remote(client1, group, catch_event):
[
x
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
],
)

View File

@@ -23,15 +23,15 @@ EMOJI_LIST = [
("😆", EmojiSize.LARGE),
# These fail in `catch_event` because the emoji is made into a sticker
# This should be fixed
pytest.mark.xfail((None, EmojiSize.SMALL)),
pytest.mark.xfail((None, EmojiSize.MEDIUM)),
pytest.mark.xfail((None, EmojiSize.LARGE)),
pytest.param(None, EmojiSize.SMALL, marks=[pytest.mark.xfail()]),
pytest.param(None, EmojiSize.MEDIUM, marks=[pytest.mark.xfail()]),
pytest.param(None, EmojiSize.LARGE, marks=[pytest.mark.xfail()]),
]
STICKER_LIST = [
Sticker("767334476626295"),
pytest.mark.xfail(Sticker("0"), raises=FBchatFacebookError),
pytest.mark.xfail(Sticker(None), raises=FBchatFacebookError),
pytest.param(Sticker("0"), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
pytest.param(Sticker(None), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
]
TEXT_LIST = [
@@ -40,8 +40,8 @@ TEXT_LIST = [
"\\\n\t%?&'\"",
"ˁҭʚ¹Ʋջوװ՞ޱɣࠚԹБɑȑңКએ֭ʗыԈٌʼőԈ×௴nચϚࠖణٔє܅Ԇޑط",
"a" * 20000, # Maximum amount of characters you can send
pytest.mark.xfail("a" * 20001, raises=FBchatFacebookError),
pytest.mark.xfail(None, raises=FBchatFacebookError),
pytest.param("a" * 20001, marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
pytest.param(None, marks=[pytest.mark.xfail(raises=FBchatFacebookError)]),
]