Merge pull request #489 from carpedm20/model-changes

Model changes
This commit is contained in:
Mads Marquart
2019-12-11 16:32:28 +01:00
committed by GitHub
29 changed files with 690 additions and 725 deletions

View File

@@ -13,8 +13,8 @@ You should also make sure that the file's access control is appropriately restri
Logging In 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 `Client`. If you have two factor authentication enabled, type the code in the terminal prompt
(If you want to supply the code in another fashion, overwrite :func:`Client.on_2fa_code`):: (If you want to supply the code in another fashion, overwrite `Client.on_2fa_code`)::
from fbchat import Client from fbchat import Client
from fbchat.models import * from fbchat.models import *
@@ -26,15 +26,15 @@ Replace ``<email>`` and ``<password>`` with your email and password respectively
For ease of use then most of the code snippets in this document will assume you've already completed the login process For ease of use then most of the code snippets in this document will assume you've already completed the login process
Though the second line, ``from fbchat.models import *``, is not strictly necessary here, later code snippets will assume you've done this Though the second line, ``from fbchat.models import *``, is not strictly necessary here, later code snippets will assume you've done this
If you want to change how verbose ``fbchat`` is, change the logging level (in :class:`Client`) If you want to change how verbose ``fbchat`` is, change the logging level (in `Client`)
Throughout your code, if you want to check whether you are still logged in, use :func:`Client.is_logged_in`. Throughout your code, if you want to check whether you are still logged in, use `Client.is_logged_in`.
An example would be to login again if you've been logged out, using :func:`Client.login`:: An example would be to login again if you've been logged out, using `Client.login`::
if not client.is_logged_in(): if not client.is_logged_in():
client.login('<email>', '<password>') client.login('<email>', '<password>')
When you're done using the client, and want to securely logout, use :func:`Client.logout`:: When you're done using the client, and want to securely logout, use `Client.logout`::
client.logout() client.logout()
@@ -46,14 +46,14 @@ Threads
A thread can refer to two things: A Messenger group chat or a single Facebook user A thread can refer to two things: A Messenger group chat or a single Facebook user
:class:`ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``. `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 differentiates 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.search_for_groups`, Searching for group chats and finding their ID can be done via. `Client.search_for_groups`,
and searching for users is possible via. :func:`Client.search_for_users`. See :ref:`intro_fetching` and searching for users is possible via. `Client.search_for_users`. See :ref:`intro_fetching`
You can get your own user ID by using :any:`Client.uid` You can get your own user ID by using `Client.uid`
Getting the ID of a group chat is fairly trivial otherwise, since you only need to navigate to `<https://www.facebook.com/messages/>`_, Getting the ID of a group chat is fairly trivial otherwise, since you only need to navigate to `<https://www.facebook.com/messages/>`_,
click on the group you want to find the ID of, and then read the id from the address bar. click on the group you want to find the ID of, and then read the id from the address bar.
@@ -71,7 +71,7 @@ corresponds to the ID of a single user, and the ID of a group respectively::
client.send(Message(text='<message>'), thread_id='<user id>', thread_type=ThreadType.USER) client.send(Message(text='<message>'), thread_id='<user id>', thread_type=ThreadType.USER)
client.send(Message(text='<message>'), thread_id='<group id>', thread_type=ThreadType.GROUP) client.send(Message(text='<message>'), thread_id='<group id>', thread_type=ThreadType.GROUP)
Some functions (e.g. :func:`Client.change_thread_color`) don't require a thread type, so in these cases you just provide the thread ID:: Some functions (e.g. `Client.change_thread_color`) don't require a thread type, so in these cases you just provide the thread ID::
client.change_thread_color(ThreadColor.BILOBA_FLOWER, thread_id='<user id>') client.change_thread_color(ThreadColor.BILOBA_FLOWER, thread_id='<user id>')
client.change_thread_color(ThreadColor.MESSENGER_BLUE, thread_id='<group id>') client.change_thread_color(ThreadColor.MESSENGER_BLUE, thread_id='<group id>')
@@ -85,8 +85,8 @@ Message IDs
Every message you send on Facebook has a unique ID, and every action you do in a thread, Every message you send on Facebook has a unique ID, and every action you do in a thread,
like changing a nickname or adding a person, has a unique ID too. like changing a nickname or adding a person, has a unique ID too.
Some of ``fbchat``'s functions require these ID's, like :func:`Client.react_to_message`, Some of ``fbchat``'s functions require these ID's, like `Client.react_to_message`,
and some of then provide this ID, like :func:`Client.send`. and some of then provide this ID, like `Client.send`.
This snippet shows how to send a message, and then use the returned ID to react to that message with a 😍 emoji:: This snippet shows how to send a message, and then use the returned ID to react to that message with a 😍 emoji::
message_id = client.send(Message(text='message'), thread_id=thread_id, thread_type=thread_type) message_id = client.send(Message(text='message'), thread_id=thread_id, thread_type=thread_type)
@@ -118,7 +118,7 @@ Fetching Information
You can use ``fbchat`` to fetch basic information like user names, profile pictures, thread names and user IDs You can use ``fbchat`` to fetch basic information like user names, profile pictures, thread names and user IDs
You can retrieve a user's ID with :func:`Client.search_for_users`. You can retrieve a user's ID with `Client.search_for_users`.
The following snippet will search for users by their name, take the first (and most likely) user, and then get their user ID from the result:: The following snippet will search for users by their name, take the first (and most likely) user, and then get their user ID from the result::
users = client.search_for_users('<name of user>') users = client.search_for_users('<name of user>')
@@ -140,11 +140,11 @@ 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 separate 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.get_gession` to retrieve the cookies:: Use `Client.get_gession` to retrieve the cookies::
session_cookies = client.get_gession() session_cookies = client.get_gession()
Then you can use :func:`Client.set_gession`:: Then you can use `Client.set_gession`::
client.set_gession(session_cookies) client.set_gession(session_cookies)
@@ -162,7 +162,7 @@ Or you can set the ``session_cookies`` on your initial login.
Listening & Events Listening & Events
------------------ ------------------
To use the listening functions ``fbchat`` offers (like :func:`Client.listen`), To use the listening functions ``fbchat`` offers (like `Client.listen`),
you have to define what should be executed when certain events happen. you have to define what should be executed when certain events happen.
By default, (most) events will just be a `logging.info` statement, By default, (most) events will just be a `logging.info` statement,
meaning it will simply print information to the console when an event happens meaning it will simply print information to the console when an event happens
@@ -170,7 +170,7 @@ meaning it will simply print information to the console when an event happens
.. note:: .. note::
You can identify the event methods by their ``on`` prefix, e.g. ``on_message`` You can identify the event methods by their ``on`` prefix, e.g. ``on_message``
The event actions can be changed by subclassing the :class:`Client`, and then overwriting the event methods:: The event actions can be changed by subclassing the `Client`, and then overwriting the event methods::
class CustomClient(Client): class CustomClient(Client):
def on_message(self, mid, author_id, message_object, thread_id, thread_type, ts, metadata, msg, **kwargs): def on_message(self, mid, author_id, message_object, thread_id, thread_type, ts, metadata, msg, **kwargs):

View File

@@ -11,6 +11,7 @@ _logging.getLogger(__name__).addHandler(_logging.NullHandler())
# The order of these is somewhat significant, e.g. User has to be imported after Thread! # The order of these is somewhat significant, e.g. User has to be imported after Thread!
from . import _core, _util from . import _core, _util
from ._core import Image
from ._exception import FBchatException, FBchatFacebookError from ._exception import FBchatException, FBchatFacebookError
from ._thread import ThreadType, ThreadLocation, ThreadColor, Thread from ._thread import ThreadType, ThreadLocation, ThreadColor, Thread
from ._user import TypingStatus, User, ActiveStatus from ._user import TypingStatus, User, ActiveStatus

View File

@@ -1,8 +1,9 @@
import attr import attr
from ._core import attrs_default, Image
from . import _util from . import _util
@attr.s @attrs_default
class Attachment: class Attachment:
"""Represents a Facebook attachment.""" """Represents a Facebook attachment."""
@@ -10,12 +11,12 @@ class Attachment:
uid = attr.ib(None) uid = attr.ib(None)
@attr.s @attrs_default
class UnsentMessage(Attachment): class UnsentMessage(Attachment):
"""Represents an unsent message attachment.""" """Represents an unsent message attachment."""
@attr.s @attrs_default
class ShareAttachment(Attachment): class ShareAttachment(Attachment):
"""Represents a shared item (e.g. URL) attachment.""" """Represents a shared item (e.g. URL) attachment."""
@@ -31,26 +32,30 @@ class ShareAttachment(Attachment):
description = attr.ib(None) description = attr.ib(None)
#: Name of the source #: Name of the source
source = attr.ib(None) source = attr.ib(None)
#: URL of the attachment image #: The attached image
image_url = attr.ib(None) image = attr.ib(None)
#: URL of the original image if Facebook uses ``safe_image`` #: URL of the original image if Facebook uses ``safe_image``
original_image_url = attr.ib(None) original_image_url = attr.ib(None)
#: Width of the image
image_width = attr.ib(None)
#: Height of the image
image_height = attr.ib(None)
#: List of additional attachments #: List of additional attachments
attachments = attr.ib(factory=list, converter=lambda x: [] if x is None else x) attachments = attr.ib(factory=list)
# Put here for backwards compatibility, so that the init argument order is preserved
uid = attr.ib(None)
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data):
from . import _file from . import _file
image = None
original_image_url = None
media = data.get("media")
if media and media.get("image"):
image = Image._from_uri(media["image"])
original_image_url = (
_util.get_url_parameter(image.url, "url")
if "/safe_image.php" in image.url
else image.url
)
url = data.get("url") url = data.get("url")
rtn = cls( return cls(
uid=data.get("deduplication_key"), uid=data.get("deduplication_key"),
author=data["target"]["actors"][0]["id"] author=data["target"]["actors"][0]["id"]
if data["target"].get("actors") if data["target"].get("actors")
@@ -64,20 +69,10 @@ class ShareAttachment(Attachment):
if data.get("description") if data.get("description")
else None, else None,
source=data["source"].get("text") if data.get("source") else None, source=data["source"].get("text") if data.get("source") else None,
image=image,
original_image_url=original_image_url,
attachments=[ attachments=[
_file.graphql_to_subattachment(attachment) _file.graphql_to_subattachment(attachment)
for attachment in data.get("subattachments") for attachment in data.get("subattachments")
], ],
) )
media = data.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.original_image_url = (
_util.get_url_parameter(rtn.image_url, "url")
if "/safe_image.php" in rtn.image_url
else rtn.image_url
)
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
return rtn

View File

@@ -38,9 +38,9 @@ ACONTEXT = {
class Client: class Client:
"""A client for the Facebook Chat (Messenger). """A client for the Facebook Chat (Messenger).
This is the main class of ``fbchat``, which contains all the methods you use to This is the main class, which contains all the methods you use to interact with
interact with Facebook. You can extend this class, and overwrite the ``on`` methods, Facebook. You can extend this class, and overwrite the ``on`` methods, to provide
to provide custom event handling (mainly useful while listening). custom event handling (mainly useful while listening).
""" """
@property @property
@@ -215,7 +215,7 @@ class Client:
limit: The max. amount of threads to fetch (default all threads) limit: The max. amount of threads to fetch (default all threads)
Returns: Returns:
list: :class:`Thread` objects list: `Thread` objects
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -266,7 +266,7 @@ class Client:
threads: Thread: List of threads to check for users threads: Thread: List of threads to check for users
Returns: Returns:
list: :class:`User` objects list: `User` objects
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -292,7 +292,7 @@ class Client:
"""Fetch all users the client is currently chatting with. """Fetch all users the client is currently chatting with.
Returns: Returns:
list: :class:`User` objects list: `User` objects
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -317,7 +317,7 @@ class Client:
limit: The max. amount of users to fetch limit: The max. amount of users to fetch
Returns: Returns:
list: :class:`User` objects, ordered by relevance list: `User` objects, ordered by relevance
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -334,7 +334,7 @@ class Client:
name: Name of the page name: Name of the page
Returns: Returns:
list: :class:`Page` objects, ordered by relevance list: `Page` objects, ordered by relevance
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -352,7 +352,7 @@ class Client:
limit: The max. amount of groups to fetch limit: The max. amount of groups to fetch
Returns: Returns:
list: :class:`Group` objects, ordered by relevance list: `Group` objects, ordered by relevance
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -370,7 +370,7 @@ class Client:
limit: The max. amount of groups to fetch limit: The max. amount of groups to fetch
Returns: Returns:
list: :class:`User`, :class:`Group` and :class:`Page` objects, ordered by relevance list: `User`, `Group` and `Page` objects, ordered by relevance
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -441,7 +441,7 @@ class Client:
thread_id: User/Group ID to search in. See :ref:`intro_threads` thread_id: User/Group ID to search in. See :ref:`intro_threads`
Returns: Returns:
typing.Iterable: Found :class:`Message` objects typing.Iterable: Found `Message` objects
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -457,7 +457,7 @@ class Client:
Args: Args:
query: Text to search for query: Text to search for
fetch_messages: Whether to fetch :class:`Message` objects or IDs only fetch_messages: Whether to fetch `Message` objects or IDs only
thread_limit (int): Max. number of threads to retrieve thread_limit (int): Max. number of threads to retrieve
message_limit (int): Max. number of messages to retrieve message_limit (int): Max. number of messages to retrieve
@@ -531,7 +531,7 @@ class Client:
user_ids: One or more user ID(s) to query user_ids: One or more user ID(s) to query
Returns: Returns:
dict: :class:`User` objects, labeled by their ID dict: `User` objects, labeled by their ID
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -556,7 +556,7 @@ class Client:
page_ids: One or more page ID(s) to query page_ids: One or more page ID(s) to query
Returns: Returns:
dict: :class:`Page` objects, labeled by their ID dict: `Page` objects, labeled by their ID
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -578,7 +578,7 @@ class Client:
group_ids: One or more group ID(s) to query group_ids: One or more group ID(s) to query
Returns: Returns:
dict: :class:`Group` objects, labeled by their ID dict: `Group` objects, labeled by their ID
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -603,7 +603,7 @@ class Client:
thread_ids: One or more thread ID(s) to query thread_ids: One or more thread ID(s) to query
Returns: Returns:
dict: :class:`Thread` objects, labeled by their ID dict: `Thread` objects, labeled by their ID
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -669,7 +669,7 @@ class Client:
before (datetime.datetime): The point from which to retrieve messages before (datetime.datetime): The point from which to retrieve messages
Returns: Returns:
list: :class:`Message` objects list: `Message` objects
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -686,22 +686,14 @@ class Client:
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))
read_receipts = j["message_thread"]["read_receipts"]["nodes"]
messages = [ messages = [
Message._from_graphql(message) Message._from_graphql(message, read_receipts)
for message in j["message_thread"]["messages"]["nodes"] for message in j["message_thread"]["messages"]["nodes"]
] ]
messages.reverse() messages.reverse()
read_receipts = j["message_thread"]["read_receipts"]["nodes"]
for message in messages:
for receipt in read_receipts:
if (
_util.millis_to_datetime(int(receipt["watermark"]))
>= message.created_at
):
message.read_by.append(receipt["actor"]["id"])
return messages return messages
def fetch_thread_list( def fetch_thread_list(
@@ -715,7 +707,7 @@ class Client:
before (datetime.datetime): The point from which to retrieve threads before (datetime.datetime): The point from which to retrieve threads
Returns: Returns:
list: :class:`Thread` objects list: `Thread` objects
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -814,7 +806,7 @@ class Client:
thread_id: User/Group ID to get message info from. See :ref:`intro_threads` thread_id: User/Group ID to get message info from. See :ref:`intro_threads`
Returns: Returns:
Message: :class:`Message` object Message: `Message` object
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -845,7 +837,7 @@ class Client:
plan_id: Plan ID to fetch from plan_id: Plan ID to fetch from
Returns: Returns:
Plan: :class:`Plan` object Plan: `Plan` object
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
@@ -901,7 +893,7 @@ class Client:
thread_id: ID of the thread thread_id: ID of the thread
Returns: Returns:
typing.Iterable: :class:`ImageAttachment` or :class:`VideoAttachment` typing.Iterable: `ImageAttachment` or `VideoAttachment`
""" """
data = {"id": thread_id, "first": 48} data = {"id": thread_id, "first": 48}
thread_id = str(thread_id) thread_id = str(thread_id)
@@ -964,7 +956,7 @@ class Client:
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
""" """
thread = thread_type._to_class()(thread_id) thread = thread_type._to_class()(uid=thread_id)
data = thread._to_send_data() data = thread._to_send_data()
data.update(message._to_send_data()) data.update(message._to_send_data())
return self._do_send_request(data) return self._do_send_request(data)
@@ -983,7 +975,7 @@ class Client:
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
""" """
thread = thread_type._to_class()(thread_id) thread = thread_type._to_class()(uid=thread_id)
data = thread._to_send_data() data = thread._to_send_data()
data["action_type"] = "ma-type:user-generated-message" data["action_type"] = "ma-type:user-generated-message"
data["lightweight_action_attachment[lwa_state]"] = ( data["lightweight_action_attachment[lwa_state]"] = (
@@ -1009,31 +1001,40 @@ class Client:
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
""" """
quick_reply.is_response = True
if isinstance(quick_reply, QuickReplyText): if isinstance(quick_reply, QuickReplyText):
return self.send( new = QuickReplyText(
Message(text=quick_reply.title, quick_replies=[quick_reply]) payload=quick_reply.payload,
external_payload=quick_reply.external_payload,
data=quick_reply.data,
is_response=True,
title=quick_reply.title,
image_url=quick_reply.image_url,
) )
return self.send(Message(text=quick_reply.title, quick_replies=[new]))
elif isinstance(quick_reply, QuickReplyLocation): elif isinstance(quick_reply, QuickReplyLocation):
if not isinstance(payload, LocationAttachment): if not isinstance(payload, LocationAttachment):
raise TypeError( raise TypeError("Payload must be an instance of `LocationAttachment`")
"Payload must be an instance of `fbchat.LocationAttachment`"
)
return self.send_location( return self.send_location(
payload, thread_id=thread_id, thread_type=thread_type payload, thread_id=thread_id, thread_type=thread_type
) )
elif isinstance(quick_reply, QuickReplyEmail): elif isinstance(quick_reply, QuickReplyEmail):
if not payload: new = QuickReplyEmail(
payload = self.get_emails()[0] payload=payload if payload else self.get_emails()[0],
quick_reply.external_payload = quick_reply.payload external_payload=quick_reply.payload,
quick_reply.payload = payload data=quick_reply.data,
return self.send(Message(text=payload, quick_replies=[quick_reply])) is_response=True,
image_url=quick_reply.image_url,
)
return self.send(Message(text=payload, quick_replies=[new]))
elif isinstance(quick_reply, QuickReplyPhoneNumber): elif isinstance(quick_reply, QuickReplyPhoneNumber):
if not payload: new = QuickReplyPhoneNumber(
payload = self.get_phone_numbers()[0] payload=payload if payload else self.get_phone_numbers()[0],
quick_reply.external_payload = quick_reply.payload external_payload=quick_reply.payload,
quick_reply.payload = payload data=quick_reply.data,
return self.send(Message(text=payload, quick_replies=[quick_reply])) is_response=True,
image_url=quick_reply.image_url,
)
return self.send(Message(text=payload, quick_replies=[new]))
def unsend(self, mid): def unsend(self, mid):
"""Unsend message by it's ID (removes it for everyone). """Unsend message by it's ID (removes it for everyone).
@@ -1047,7 +1048,7 @@ class Client:
def _send_location( def _send_location(
self, location, current=True, message=None, thread_id=None, thread_type=None self, location, current=True, message=None, thread_id=None, thread_type=None
): ):
thread = thread_type._to_class()(thread_id) thread = thread_type._to_class()(uid=thread_id)
data = thread._to_send_data() data = thread._to_send_data()
if message is not None: if message is not None:
data.update(message._to_send_data()) data.update(message._to_send_data())
@@ -1115,7 +1116,7 @@ class Client:
`files` should be a list of tuples, with a file's ID and mimetype. `files` should be a list of tuples, with a file's ID and mimetype.
""" """
thread = thread_type._to_class()(thread_id) thread = thread_type._to_class()(uid=thread_id)
data = thread._to_send_data() data = thread._to_send_data()
data.update(self._old_message(message)._to_send_data()) data.update(self._old_message(message)._to_send_data())
data["action_type"] = "ma-type:user-generated-message" data["action_type"] = "ma-type:user-generated-message"
@@ -1281,7 +1282,7 @@ class Client:
Raises: Raises:
FBchatException: If request failed FBchatException: If request failed
""" """
data = Group(thread_id)._to_send_data() data = Group(uid=thread_id)._to_send_data()
data["action_type"] = "ma-type:log-message" data["action_type"] = "ma-type:log-message"
data["log_message_type"] = "log:subscribe" data["log_message_type"] = "log:subscribe"
@@ -2533,9 +2534,8 @@ class Client:
i = d["deltaMessageReply"] i = d["deltaMessageReply"]
metadata = i["message"]["messageMetadata"] metadata = i["message"]["messageMetadata"]
thread_id, thread_type = get_thread_id_and_thread_type(metadata) thread_id, thread_type = get_thread_id_and_thread_type(metadata)
message = Message._from_reply(i["message"]) replied_to = Message._from_reply(i["repliedToMessage"])
message.replied_to = Message._from_reply(i["repliedToMessage"]) message = Message._from_reply(i["message"], replied_to)
message.reply_to_id = message.replied_to.uid
self.on_message( self.on_message(
mid=message.uid, mid=message.uid,
author_id=message.author, author_id=message.author,
@@ -3653,7 +3653,7 @@ class Client:
"""Called when the client is listening and client receives information about friend active status. """Called when the client is listening and client receives information about friend active status.
Args: Args:
statuses (dict): Dictionary with user IDs as keys and :class:`ActiveStatus` as values statuses (dict): Dictionary with user IDs as keys and `ActiveStatus` as values
msg: A full set of the data received msg: A full set of the data received
""" """
log.debug("Buddylist overlay received: {}".format(statuses)) log.debug("Buddylist overlay received: {}".format(statuses))

View File

@@ -1,11 +1,19 @@
import sys
import attr
import logging import logging
import aenum import aenum
log = logging.getLogger("fbchat") log = logging.getLogger("fbchat")
# Enable kw_only if the python version supports it
kw_only = sys.version_info[:2] > (3, 5)
#: Default attrs settings for classes
attrs_default = attr.s(slots=True, kw_only=kw_only)
class Enum(aenum.Enum): class Enum(aenum.Enum):
"""Used internally by ``fbchat`` to support enumerations""" """Used internally to support enumerations"""
def __repr__(self): def __repr__(self):
# For documentation: # For documentation:
@@ -21,3 +29,46 @@ class Enum(aenum.Enum):
) )
aenum.extend_enum(cls, "UNKNOWN_{}".format(value).upper(), value) aenum.extend_enum(cls, "UNKNOWN_{}".format(value).upper(), value)
return cls(value) return cls(value)
# Frozen, so that it can be used in sets
@attr.s(frozen=True, slots=True, kw_only=kw_only)
class Image:
#: URL to the image
url = attr.ib(type=str)
#: Width of the image
width = attr.ib(None, type=int)
#: Height of the image
height = attr.ib(None, type=int)
@classmethod
def _from_uri(cls, data):
return cls(
url=data["uri"],
width=int(data["width"]) if data.get("width") else None,
height=int(data["height"]) if data.get("height") else None,
)
@classmethod
def _from_url(cls, data):
return cls(
url=data["url"],
width=int(data["width"]) if data.get("width") else None,
height=int(data["height"]) if data.get("height") else None,
)
@classmethod
def _from_uri_or_none(cls, data):
if data is None:
return None
if data.get("uri") is None:
return None
return cls._from_uri(data)
@classmethod
def _from_url_or_none(cls, data):
if data is None:
return None
if data.get("url") is None:
return None
return cls._from_url(data)

View File

@@ -1,32 +1,32 @@
import attr
# Not frozen, since that doesn't work in PyPy
attrs_exception = attr.s(slots=True, auto_exc=True)
@attrs_exception
class FBchatException(Exception): class FBchatException(Exception):
"""Custom exception thrown by ``fbchat``. """Custom exception thrown by ``fbchat``.
All exceptions in the ``fbchat`` module inherits this. All exceptions in the module inherits this.
""" """
message = attr.ib()
@attrs_exception
class FBchatFacebookError(FBchatException): class FBchatFacebookError(FBchatException):
"""Raised when Facebook returns an error."""
#: The error code that Facebook returned #: The error code that Facebook returned
fb_error_code = None fb_error_code = attr.ib(None)
#: The error message that Facebook returned (In the user's own language) #: The error message that Facebook returned (In the user's own language)
fb_error_message = None fb_error_message = attr.ib(None)
#: The status code that was sent in the HTTP response (e.g. 404) (Usually only set if not successful, aka. not 200) #: The status code that was sent in the HTTP response (e.g. 404) (Usually only set if not successful, aka. not 200)
request_status_code = None request_status_code = attr.ib(None)
def __init__(
self,
message,
fb_error_code=None,
fb_error_message=None,
request_status_code=None,
):
super(FBchatFacebookError, self).__init__(message)
"""Thrown by ``fbchat`` when Facebook returns an error"""
self.fb_error_code = str(fb_error_code)
self.fb_error_message = fb_error_message
self.request_status_code = request_status_code
@attrs_exception
class FBchatInvalidParameters(FBchatFacebookError): class FBchatInvalidParameters(FBchatFacebookError):
"""Raised by Facebook if: """Raised by Facebook if:
@@ -36,17 +36,19 @@ class FBchatInvalidParameters(FBchatFacebookError):
""" """
@attrs_exception
class FBchatNotLoggedIn(FBchatFacebookError): class FBchatNotLoggedIn(FBchatFacebookError):
"""Raised by Facebook if the client has been logged out.""" """Raised by Facebook if the client has been logged out."""
fb_error_code = "1357001" fb_error_code = attr.ib("1357001")
@attrs_exception
class FBchatPleaseRefresh(FBchatFacebookError): class FBchatPleaseRefresh(FBchatFacebookError):
"""Raised by Facebook if the client has been inactive for too long. """Raised by Facebook if the client has been inactive for too long.
This error usually happens after 1-2 days of inactivity. This error usually happens after 1-2 days of inactivity.
""" """
fb_error_code = "1357004" fb_error_code = attr.ib("1357004")
fb_error_message = "Please try closing and re-opening your browser window." fb_error_message = attr.ib("Please try closing and re-opening your browser window.")

View File

@@ -1,9 +1,10 @@
import attr import attr
from ._core import attrs_default, Image
from . import _util from . import _util
from ._attachment import Attachment from ._attachment import Attachment
@attr.s @attrs_default
class FileAttachment(Attachment): class FileAttachment(Attachment):
"""Represents a file that has been sent as a Facebook attachment.""" """Represents a file that has been sent as a Facebook attachment."""
@@ -16,20 +17,18 @@ class FileAttachment(Attachment):
#: Whether Facebook determines that this file may be harmful #: Whether Facebook determines that this file may be harmful
is_malicious = attr.ib(None) is_malicious = attr.ib(None)
# Put here for backwards compatibility, so that the init argument order is preserved
uid = attr.ib(None)
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data, size=None):
return cls( return cls(
url=data.get("url"), url=data.get("url"),
size=size,
name=data.get("filename"), name=data.get("filename"),
is_malicious=data.get("is_malicious"), is_malicious=data.get("is_malicious"),
uid=data.get("message_file_fbid"), uid=data.get("message_file_fbid"),
) )
@attr.s @attrs_default
class AudioAttachment(Attachment): class AudioAttachment(Attachment):
"""Represents an audio file that has been sent as a Facebook attachment.""" """Represents an audio file that has been sent as a Facebook attachment."""
@@ -42,9 +41,6 @@ class AudioAttachment(Attachment):
#: Audio type #: Audio type
audio_type = attr.ib(None) audio_type = attr.ib(None)
# Put here for backwards compatibility, so that the init argument order is preserved
uid = attr.ib(None)
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data):
return cls( return cls(
@@ -55,7 +51,7 @@ class AudioAttachment(Attachment):
) )
@attr.s(init=False) @attrs_default
class ImageAttachment(Attachment): class ImageAttachment(Attachment):
"""Represents an image that has been sent as a Facebook attachment. """Represents an image that has been sent as a Facebook attachment.
@@ -69,104 +65,49 @@ class ImageAttachment(Attachment):
width = attr.ib(None, converter=lambda x: None if x is None else int(x)) width = attr.ib(None, converter=lambda x: None if x is None else int(x))
#: Height of original image #: Height of original image
height = attr.ib(None, converter=lambda x: None if x is None else int(x)) height = attr.ib(None, converter=lambda x: None if x is None else int(x))
#: Whether the image is animated #: Whether the image is animated
is_animated = attr.ib(None) is_animated = attr.ib(None)
#: A set, containing variously sized / various types of previews of the image
#: URL to a thumbnail of the image previews = attr.ib(factory=set)
thumbnail_url = attr.ib(None)
#: URL to a medium preview of the image
preview_url = attr.ib(None)
#: Width of the medium preview image
preview_width = attr.ib(None)
#: Height of the medium preview image
preview_height = attr.ib(None)
#: URL to a large preview of the image
large_preview_url = attr.ib(None)
#: Width of the large preview image
large_preview_width = attr.ib(None)
#: Height of the large preview image
large_preview_height = attr.ib(None)
#: URL to an animated preview of the image (e.g. for GIFs)
animated_preview_url = attr.ib(None)
#: Width of the animated preview image
animated_preview_width = attr.ib(None)
#: Height of the animated preview image
animated_preview_height = attr.ib(None)
def __init__(
self,
original_extension=None,
width=None,
height=None,
is_animated=None,
thumbnail_url=None,
preview=None,
large_preview=None,
animated_preview=None,
**kwargs
):
super(ImageAttachment, self).__init__(**kwargs)
self.original_extension = original_extension
if width is not None:
width = int(width)
self.width = width
if height is not None:
height = int(height)
self.height = height
self.is_animated = is_animated
self.thumbnail_url = thumbnail_url
if preview is None:
preview = {}
self.preview_url = preview.get("uri")
self.preview_width = preview.get("width")
self.preview_height = preview.get("height")
if large_preview is None:
large_preview = {}
self.large_preview_url = large_preview.get("uri")
self.large_preview_width = large_preview.get("width")
self.large_preview_height = large_preview.get("height")
if animated_preview is None:
animated_preview = {}
self.animated_preview_url = animated_preview.get("uri")
self.animated_preview_width = animated_preview.get("width")
self.animated_preview_height = animated_preview.get("height")
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data):
previews = {
Image._from_uri_or_none(data.get("thumbnail")),
Image._from_uri_or_none(data.get("preview") or data.get("preview_image")),
Image._from_uri_or_none(data.get("large_preview")),
Image._from_uri_or_none(data.get("animated_image")),
}
return cls( return cls(
original_extension=data.get("original_extension") original_extension=data.get("original_extension")
or (data["filename"].split("-")[0] if data.get("filename") else None), or (data["filename"].split("-")[0] if data.get("filename") else None),
width=data.get("original_dimensions", {}).get("width"), width=data.get("original_dimensions", {}).get("width"),
height=data.get("original_dimensions", {}).get("height"), height=data.get("original_dimensions", {}).get("height"),
is_animated=data["__typename"] == "MessageAnimatedImage", is_animated=data["__typename"] == "MessageAnimatedImage",
thumbnail_url=data.get("thumbnail", {}).get("uri"), previews={p for p in previews if p},
preview=data.get("preview") or data.get("preview_image"),
large_preview=data.get("large_preview"),
animated_preview=data.get("animated_image"),
uid=data.get("legacy_attachment_id"), uid=data.get("legacy_attachment_id"),
) )
@classmethod @classmethod
def _from_list(cls, data): def _from_list(cls, data):
data = data["node"] data = data["node"]
previews = {
Image._from_uri_or_none(data["image"]),
Image._from_uri(data["image1"]),
Image._from_uri(data["image2"]),
}
return cls( return cls(
width=data["original_dimensions"].get("x"), width=data["original_dimensions"].get("x"),
height=data["original_dimensions"].get("y"), height=data["original_dimensions"].get("y"),
thumbnail_url=data["image"].get("uri"), previews={p for p in previews if p},
large_preview=data["image2"],
preview=data["image1"],
uid=data["legacy_attachment_id"], uid=data["legacy_attachment_id"],
) )
@attr.s(init=False) @attrs_default
class VideoAttachment(Attachment): class VideoAttachment(Attachment):
"""Represents a video that has been sent as a Facebook attachment.""" """Represents a video that has been sent as a Facebook attachment."""
@@ -180,111 +121,66 @@ class VideoAttachment(Attachment):
duration = attr.ib(None) duration = attr.ib(None)
#: URL to very compressed preview video #: URL to very compressed preview video
preview_url = attr.ib(None) preview_url = attr.ib(None)
#: A set, containing variously sized previews of the video
#: URL to a small preview image of the video previews = attr.ib(factory=set)
small_image_url = attr.ib(None)
#: Width of the small preview image
small_image_width = attr.ib(None)
#: Height of the small preview image
small_image_height = attr.ib(None)
#: URL to a medium preview image of the video
medium_image_url = attr.ib(None)
#: Width of the medium preview image
medium_image_width = attr.ib(None)
#: Height of the medium preview image
medium_image_height = attr.ib(None)
#: URL to a large preview image of the video
large_image_url = attr.ib(None)
#: Width of the large preview image
large_image_width = attr.ib(None)
#: Height of the large preview image
large_image_height = attr.ib(None)
def __init__(
self,
size=None,
width=None,
height=None,
duration=None,
preview_url=None,
small_image=None,
medium_image=None,
large_image=None,
**kwargs
):
super(VideoAttachment, self).__init__(**kwargs)
self.size = size
self.width = width
self.height = height
self.duration = duration
self.preview_url = preview_url
if small_image is None:
small_image = {}
self.small_image_url = small_image.get("uri")
self.small_image_width = small_image.get("width")
self.small_image_height = small_image.get("height")
if medium_image is None:
medium_image = {}
self.medium_image_url = medium_image.get("uri")
self.medium_image_width = medium_image.get("width")
self.medium_image_height = medium_image.get("height")
if large_image is None:
large_image = {}
self.large_image_url = large_image.get("uri")
self.large_image_width = large_image.get("width")
self.large_image_height = large_image.get("height")
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data, size=None):
previews = {
Image._from_uri_or_none(data.get("chat_image")),
Image._from_uri_or_none(data.get("inbox_image")),
Image._from_uri_or_none(data.get("large_image")),
}
return cls( return cls(
size=size,
width=data.get("original_dimensions", {}).get("width"), width=data.get("original_dimensions", {}).get("width"),
height=data.get("original_dimensions", {}).get("height"), height=data.get("original_dimensions", {}).get("height"),
duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")), duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")),
preview_url=data.get("playable_url"), preview_url=data.get("playable_url"),
small_image=data.get("chat_image"), previews={p for p in previews if p},
medium_image=data.get("inbox_image"),
large_image=data.get("large_image"),
uid=data.get("legacy_attachment_id"), uid=data.get("legacy_attachment_id"),
) )
@classmethod @classmethod
def _from_subattachment(cls, data): def _from_subattachment(cls, data):
media = data["media"] media = data["media"]
image = Image._from_uri_or_none(media.get("image"))
return cls( return cls(
duration=_util.millis_to_timedelta(media.get("playable_duration_in_ms")), duration=_util.millis_to_timedelta(media.get("playable_duration_in_ms")),
preview_url=media.get("playable_url"), preview_url=media.get("playable_url"),
medium_image=media.get("image"), previews={image} if image else {},
uid=data["target"].get("video_id"), uid=data["target"].get("video_id"),
) )
@classmethod @classmethod
def _from_list(cls, data): def _from_list(cls, data):
data = data["node"] data = data["node"]
previews = {
Image._from_uri(data["image"]),
Image._from_uri(data["image1"]),
Image._from_uri(data["image2"]),
}
return cls( return cls(
width=data["original_dimensions"].get("x"), width=data["original_dimensions"].get("x"),
height=data["original_dimensions"].get("y"), height=data["original_dimensions"].get("y"),
small_image=data["image"], previews=previews,
medium_image=data["image1"],
large_image=data["image2"],
uid=data["legacy_attachment_id"], uid=data["legacy_attachment_id"],
) )
def graphql_to_attachment(data): def graphql_to_attachment(data, size=None):
_type = data["__typename"] _type = data["__typename"]
if _type in ["MessageImage", "MessageAnimatedImage"]: if _type in ["MessageImage", "MessageAnimatedImage"]:
return ImageAttachment._from_graphql(data) return ImageAttachment._from_graphql(data)
elif _type == "MessageVideo": elif _type == "MessageVideo":
return VideoAttachment._from_graphql(data) return VideoAttachment._from_graphql(data, size=size)
elif _type == "MessageAudio": elif _type == "MessageAudio":
return AudioAttachment._from_graphql(data) return AudioAttachment._from_graphql(data)
elif _type == "MessageFile": elif _type == "MessageFile":
return FileAttachment._from_graphql(data) return FileAttachment._from_graphql(data, size=size)
return Attachment(uid=data.get("legacy_attachment_id")) return Attachment(uid=data.get("legacy_attachment_id"))

View File

@@ -1,30 +1,29 @@
import attr import attr
from ._core import attrs_default, Image
from . import _util, _plan from . import _util, _plan
from ._thread import ThreadType, Thread from ._thread import ThreadType, Thread
@attr.s @attrs_default
class Group(Thread): class Group(Thread):
"""Represents a Facebook group. Inherits `Thread`.""" """Represents a Facebook group. Inherits `Thread`."""
type = ThreadType.GROUP type = ThreadType.GROUP
#: Unique list (set) of the group thread's participant user IDs #: Unique list (set) of the group thread's participant user IDs
participants = attr.ib(factory=set, converter=lambda x: set() if x is None else x) participants = attr.ib(factory=set)
#: A dictionary, containing user nicknames mapped to their IDs #: A dictionary, containing user nicknames mapped to their IDs
nicknames = attr.ib(factory=dict, converter=lambda x: {} if x is None else x) nicknames = attr.ib(factory=dict)
#: A :class:`ThreadColor`. The groups's message color #: A `ThreadColor`. The groups's message color
color = attr.ib(None) color = attr.ib(None)
#: The groups's default emoji #: The groups's default emoji
emoji = attr.ib(None) emoji = attr.ib(None)
# Set containing user IDs of thread admins # Set containing user IDs of thread admins
admins = attr.ib(factory=set, converter=lambda x: set() if x is None else x) admins = attr.ib(factory=set)
# True if users need approval to join # True if users need approval to join
approval_mode = attr.ib(None) approval_mode = attr.ib(None)
# Set containing user IDs requesting to join # Set containing user IDs requesting to join
approval_requests = attr.ib( approval_requests = attr.ib(factory=set)
factory=set, converter=lambda x: set() if x is None else x
)
# Link for joining group # Link for joining group
join_link = attr.ib(None) join_link = attr.ib(None)
@@ -43,7 +42,7 @@ class Group(Thread):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
return cls( return cls(
data["thread_key"]["thread_fbid"], uid=data["thread_key"]["thread_fbid"],
participants=set( participants=set(
[ [
node["messaging_actor"]["id"] node["messaging_actor"]["id"]
@@ -64,7 +63,7 @@ class Group(Thread):
if data.get("group_approval_queue") if data.get("group_approval_queue")
else None, else None,
join_link=data["joinable_mode"].get("link"), join_link=data["joinable_mode"].get("link"),
photo=data["image"].get("uri"), photo=Image._from_uri_or_none(data["image"]),
name=data.get("name"), name=data.get("name"),
message_count=data.get("messages_count"), message_count=data.get("messages_count"),
last_active=last_active, last_active=last_active,

View File

@@ -1,9 +1,10 @@
import attr import attr
from ._core import attrs_default, Image
from ._attachment import Attachment from ._attachment import Attachment
from . import _util from . import _util
@attr.s @attrs_default
class LocationAttachment(Attachment): class LocationAttachment(Attachment):
"""Represents a user location. """Represents a user location.
@@ -14,20 +15,13 @@ class LocationAttachment(Attachment):
latitude = attr.ib(None) latitude = attr.ib(None)
#: Longitude of the location #: Longitude of the location
longitude = attr.ib(None) longitude = attr.ib(None)
#: URL of image showing the map of the location #: Image showing the map of the location
image_url = attr.ib(None, init=False) image = attr.ib(None)
#: Width of the image
image_width = attr.ib(None, init=False)
#: Height of the image
image_height = attr.ib(None, init=False)
#: URL to Bing maps with the location #: URL to Bing maps with the location
url = attr.ib(None, init=False) url = attr.ib(None)
# Address of the location # Address of the location
address = attr.ib(None) address = attr.ib(None)
# Put here for backwards compatibility, so that the init argument order is preserved
uid = attr.ib(None)
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data):
url = data.get("url") url = data.get("url")
@@ -37,23 +31,20 @@ class LocationAttachment(Attachment):
address = None address = None
except ValueError: except ValueError:
latitude, longitude = None, None latitude, longitude = None, None
rtn = cls(
return cls(
uid=int(data["deduplication_key"]), uid=int(data["deduplication_key"]),
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
image=Image._from_uri_or_none(data["media"].get("image"))
if data.get("media")
else None,
url=url,
address=address, address=address,
) )
media = data.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
rtn.url = url
return rtn
@attr.s @attrs_default
class LiveLocationAttachment(LocationAttachment): class LiveLocationAttachment(LocationAttachment):
"""Represents a live user location.""" """Represents a live user location."""
@@ -82,7 +73,13 @@ class LiveLocationAttachment(LocationAttachment):
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data):
target = data["target"] target = data["target"]
rtn = cls(
image = None
media = data.get("media")
if media and media.get("image"):
image = Image._from_uri(media["image"])
return cls(
uid=int(target["live_location_id"]), uid=int(target["live_location_id"]),
latitude=target["coordinate"]["latitude"] latitude=target["coordinate"]["latitude"]
if target.get("coordinate") if target.get("coordinate")
@@ -90,15 +87,9 @@ class LiveLocationAttachment(LocationAttachment):
longitude=target["coordinate"]["longitude"] longitude=target["coordinate"]["longitude"]
if target.get("coordinate") if target.get("coordinate")
else None, else None,
image=image,
url=data.get("url"),
name=data["title_with_entities"]["text"], name=data["title_with_entities"]["text"],
expires_at=_util.seconds_to_datetime(target.get("expiration_time")), expires_at=_util.seconds_to_datetime(target.get("expiration_time")),
is_expired=target.get("is_expired"), is_expired=target.get("is_expired"),
) )
media = data.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
rtn.url = data.get("url")
return rtn

View File

@@ -1,7 +1,7 @@
import attr import attr
import json import json
from string import Formatter from string import Formatter
from ._core import log, Enum from ._core import log, attrs_default, Enum
from . import _util, _attachment, _location, _file, _quick_reply, _sticker from . import _util, _attachment, _location, _file, _quick_reply, _sticker
@@ -42,7 +42,7 @@ class MessageReaction(Enum):
NO = "👎" NO = "👎"
@attr.s @attrs_default
class Mention: class Mention:
"""Represents a ``@mention``.""" """Represents a ``@mention``."""
@@ -53,43 +53,63 @@ class Mention:
#: The length of the mention #: The length of the mention
length = attr.ib(10) length = attr.ib(10)
@classmethod
def _from_range(cls, data):
return cls(
thread_id=data.get("entity", {}).get("id"),
offset=data.get("offset"),
length=data.get("length"),
)
@attr.s @classmethod
def _from_prng(cls, data):
return cls(thread_id=data.get("i"), offset=data.get("o"), length=data.get("l"))
def _to_send_data(self, i):
return {
"profile_xmd[{}][id]".format(i): self.thread_id,
"profile_xmd[{}][offset]".format(i): self.offset,
"profile_xmd[{}][length]".format(i): self.length,
"profile_xmd[{}][type]".format(i): "p",
}
@attrs_default
class Message: class Message:
"""Represents a Facebook message.""" """Represents a Facebook message."""
#: The actual message #: The actual message
text = attr.ib(None) text = attr.ib(None)
#: A list of :class:`Mention` objects #: A list of `Mention` objects
mentions = attr.ib(factory=list, converter=lambda x: [] if x is None else x) mentions = attr.ib(factory=list)
#: A :class:`EmojiSize`. Size of a sent emoji #: A `EmojiSize`. Size of a sent emoji
emoji_size = attr.ib(None) emoji_size = attr.ib(None)
#: The message ID #: The message ID
uid = attr.ib(None, init=False) uid = attr.ib(None)
#: ID of the sender #: ID of the sender
author = attr.ib(None, init=False) author = attr.ib(None)
#: Datetime of when the message was sent #: Datetime of when the message was sent
created_at = attr.ib(None, init=False) created_at = attr.ib(None)
#: Whether the message is read #: Whether the message is read
is_read = attr.ib(None, init=False) is_read = attr.ib(None)
#: A list of people IDs who read the message, works only with :func:`fbchat.Client.fetch_thread_messages` #: A list of people IDs who read the message, works only with `Client.fetch_thread_messages`
read_by = attr.ib(factory=list, init=False) read_by = attr.ib(factory=list)
#: A dictionary with user's IDs as keys, and their :class:`MessageReaction` as values #: A dictionary with user's IDs as keys, and their `MessageReaction` as values
reactions = attr.ib(factory=dict, init=False) reactions = attr.ib(factory=dict)
#: A :class:`Sticker` #: A `Sticker`
sticker = attr.ib(None) sticker = attr.ib(None)
#: A list of attachments #: A list of attachments
attachments = attr.ib(factory=list, converter=lambda x: [] if x is None else x) attachments = attr.ib(factory=list)
#: A list of :class:`QuickReply` #: A list of `QuickReply`
quick_replies = attr.ib(factory=list, converter=lambda x: [] if x is None else x) quick_replies = attr.ib(factory=list)
#: Whether the message is unsent (deleted for everyone) #: Whether the message is unsent (deleted for everyone)
unsent = attr.ib(False, init=False) unsent = attr.ib(False)
#: Message ID you want to reply to #: Message ID you want to reply to
reply_to_id = attr.ib(None) reply_to_id = attr.ib(None)
#: Replied message #: Replied message
replied_to = attr.ib(None, init=False) replied_to = attr.ib(None)
#: Whether the message was forwarded #: Whether the message was forwarded
forwarded = attr.ib(False, init=False) forwarded = attr.ib(False)
@classmethod @classmethod
def format_mentions(cls, text, *args, **kwargs): def format_mentions(cls, text, *args, **kwargs):
@@ -139,8 +159,7 @@ class Message:
) )
offset += len(name) offset += len(name)
message = cls(text=result, mentions=mentions) return cls(text=result, mentions=mentions)
return message
@staticmethod @staticmethod
def _get_forwarded_from_tags(tags): def _get_forwarded_from_tags(tags):
@@ -158,10 +177,7 @@ class Message:
data["body"] = self.text data["body"] = self.text
for i, mention in enumerate(self.mentions): for i, mention in enumerate(self.mentions):
data["profile_xmd[{}][id]".format(i)] = mention.thread_id data.update(mention._to_send_data(i))
data["profile_xmd[{}][offset]".format(i)] = mention.offset
data["profile_xmd[{}][length]".format(i)] = mention.length
data["profile_xmd[{}][type]".format(i)] = "p"
if self.emoji_size: if self.emoji_size:
if self.text: if self.text:
@@ -197,99 +213,82 @@ class Message:
return data return data
@staticmethod
def _parse_quick_replies(data):
if data:
data = json.loads(data).get("quick_replies")
if isinstance(data, list):
return [_quick_reply.graphql_to_quick_reply(q) for q in data]
elif isinstance(data, dict):
return [_quick_reply.graphql_to_quick_reply(data, is_response=True)]
return []
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data, read_receipts=None):
if data.get("message_sender") is None: if data.get("message_sender") is None:
data["message_sender"] = {} data["message_sender"] = {}
if data.get("message") is None: if data.get("message") is None:
data["message"] = {} data["message"] = {}
tags = data.get("tags_list") tags = data.get("tags_list")
rtn = cls(
text=data["message"].get("text"), created_at = _util.millis_to_datetime(int(data.get("timestamp_precise")))
mentions=[
Mention( attachments = [
m.get("entity", {}).get("id"),
offset=m.get("offset"),
length=m.get("length"),
)
for m in data["message"].get("ranges") or ()
],
emoji_size=EmojiSize._from_tags(tags),
sticker=_sticker.Sticker._from_graphql(data.get("sticker")),
)
rtn.forwarded = cls._get_forwarded_from_tags(tags)
rtn.uid = str(data["message_id"])
rtn.author = str(data["message_sender"]["id"])
rtn.created_at = _util.millis_to_datetime(int(data.get("timestamp_precise")))
rtn.unsent = False
if data.get("unread") is not None:
rtn.is_read = not data["unread"]
rtn.reactions = {
str(r["user"]["id"]): MessageReaction._extend_if_invalid(r["reaction"])
for r in data["message_reactions"]
}
if data.get("blob_attachments") is not None:
rtn.attachments = [
_file.graphql_to_attachment(attachment) _file.graphql_to_attachment(attachment)
for attachment in data["blob_attachments"] for attachment in data["blob_attachments"] or ()
]
if data.get("platform_xmd_encoded"):
quick_replies = json.loads(data["platform_xmd_encoded"]).get(
"quick_replies"
)
if isinstance(quick_replies, list):
rtn.quick_replies = [
_quick_reply.graphql_to_quick_reply(q) for q in quick_replies
]
elif isinstance(quick_replies, dict):
rtn.quick_replies = [
_quick_reply.graphql_to_quick_reply(quick_replies, is_response=True)
] ]
unsent = False
if data.get("extensible_attachment") is not None: if data.get("extensible_attachment") is not None:
attachment = graphql_to_extensible_attachment(data["extensible_attachment"]) attachment = graphql_to_extensible_attachment(data["extensible_attachment"])
if isinstance(attachment, _attachment.UnsentMessage): if isinstance(attachment, _attachment.UnsentMessage):
rtn.unsent = True unsent = True
elif attachment: elif attachment:
rtn.attachments.append(attachment) attachments.append(attachment)
if data.get("replied_to_message") is not None:
rtn.replied_to = cls._from_graphql(data["replied_to_message"]["message"])
rtn.reply_to_id = rtn.replied_to.uid
return rtn
@classmethod replied_to = None
def _from_reply(cls, data): if data.get("replied_to_message"):
tags = data["messageMetadata"].get("tags") replied_to = cls._from_graphql(data["replied_to_message"]["message"])
rtn = cls(
text=data.get("body"), return cls(
text=data["message"].get("text"),
mentions=[ mentions=[
Mention(m.get("i"), offset=m.get("o"), length=m.get("l")) Mention._from_range(m) for m in data["message"].get("ranges") or ()
for m in json.loads(data.get("data", {}).get("prng", "[]"))
], ],
emoji_size=EmojiSize._from_tags(tags), emoji_size=EmojiSize._from_tags(tags),
uid=str(data["message_id"]),
author=str(data["message_sender"]["id"]),
created_at=created_at,
is_read=not data["unread"] if data.get("unread") is not None else None,
read_by=[
receipt["actor"]["id"]
for receipt in read_receipts or ()
if _util.millis_to_datetime(int(receipt["watermark"])) >= created_at
],
reactions={
str(r["user"]["id"]): MessageReaction._extend_if_invalid(r["reaction"])
for r in data["message_reactions"]
},
sticker=_sticker.Sticker._from_graphql(data.get("sticker")),
attachments=attachments,
quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")),
unsent=unsent,
reply_to_id=replied_to.uid if replied_to else None,
replied_to=replied_to,
forwarded=cls._get_forwarded_from_tags(tags),
) )
@classmethod
def _from_reply(cls, data, replied_to=None):
tags = data["messageMetadata"].get("tags")
metadata = data.get("messageMetadata", {}) metadata = data.get("messageMetadata", {})
rtn.forwarded = cls._get_forwarded_from_tags(tags)
rtn.uid = metadata.get("messageId") attachments = []
rtn.author = str(metadata.get("actorFbId")) unsent = False
rtn.created_at = _util.millis_to_datetime(metadata.get("timestamp")) sticker = None
rtn.unsent = False for attachment in data.get("attachments") or ():
if data.get("data", {}).get("platform_xmd"):
quick_replies = json.loads(data["data"]["platform_xmd"]).get(
"quick_replies"
)
if isinstance(quick_replies, list):
rtn.quick_replies = [
_quick_reply.graphql_to_quick_reply(q) for q in quick_replies
]
elif isinstance(quick_replies, dict):
rtn.quick_replies = [
_quick_reply.graphql_to_quick_reply(quick_replies, is_response=True)
]
if data.get("attachments") is not None:
for attachment in data["attachments"]:
attachment = json.loads(attachment["mercuryJSON"]) attachment = json.loads(attachment["mercuryJSON"])
if attachment.get("blob_attachment"): if attachment.get("blob_attachment"):
rtn.attachments.append( attachments.append(
_file.graphql_to_attachment(attachment["blob_attachment"]) _file.graphql_to_attachment(attachment["blob_attachment"])
) )
if attachment.get("extensible_attachment"): if attachment.get("extensible_attachment"):
@@ -297,57 +296,61 @@ class Message:
attachment["extensible_attachment"] attachment["extensible_attachment"]
) )
if isinstance(extensible_attachment, _attachment.UnsentMessage): if isinstance(extensible_attachment, _attachment.UnsentMessage):
rtn.unsent = True unsent = True
else: else:
rtn.attachments.append(extensible_attachment) attachments.append(extensible_attachment)
if attachment.get("sticker_attachment"): if attachment.get("sticker_attachment"):
rtn.sticker = _sticker.Sticker._from_graphql( sticker = _sticker.Sticker._from_graphql(
attachment["sticker_attachment"] attachment["sticker_attachment"]
) )
return rtn
return cls(
text=data.get("body"),
mentions=[
Mention._from_prng(m)
for m in _util.parse_json(data.get("data", {}).get("prng", "[]"))
],
emoji_size=EmojiSize._from_tags(tags),
uid=metadata.get("messageId"),
author=str(metadata.get("actorFbId")),
created_at=_util.millis_to_datetime(metadata.get("timestamp")),
sticker=sticker,
attachments=attachments,
quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")),
unsent=unsent,
reply_to_id=replied_to.uid if replied_to else None,
replied_to=replied_to,
forwarded=cls._get_forwarded_from_tags(tags),
)
@classmethod @classmethod
def _from_pull(cls, data, mid=None, tags=None, author=None, created_at=None): def _from_pull(cls, data, mid=None, tags=None, author=None, created_at=None):
rtn = cls(text=data.get("body")) mentions = []
rtn.uid = mid
rtn.author = author
rtn.created_at = created_at
if data.get("data") and data["data"].get("prng"): if data.get("data") and data["data"].get("prng"):
try: try:
rtn.mentions = [ mentions = [
Mention( Mention._from_prng(m)
str(mention.get("i")), for m in _util.parse_json(data["data"]["prng"])
offset=mention.get("o"),
length=mention.get("l"),
)
for mention in _util.parse_json(data["data"]["prng"])
] ]
except Exception: except Exception:
log.exception("An exception occured while reading attachments") log.exception("An exception occured while reading attachments")
if data.get("attachments"): attachments = []
unsent = False
sticker = None
try: try:
for a in data["attachments"]: for a in data.get("attachments") or ():
mercury = a["mercury"] mercury = a["mercury"]
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 = _file.graphql_to_attachment( attachment = _file.graphql_to_attachment(
mercury["blob_attachment"] mercury["blob_attachment"], a["fileSize"]
) )
attachments.append(attachment)
if attach_type in [
"MessageFile",
"MessageVideo",
"MessageAudio",
]:
# TODO: Add more data here for audio files
attachment.size = int(a["fileSize"])
rtn.attachments.append(attachment)
elif mercury.get("sticker_attachment"): elif mercury.get("sticker_attachment"):
rtn.sticker = _sticker.Sticker._from_graphql( sticker = _sticker.Sticker._from_graphql(
mercury["sticker_attachment"] mercury["sticker_attachment"]
) )
@@ -356,9 +359,9 @@ class Message:
mercury["extensible_attachment"] mercury["extensible_attachment"]
) )
if isinstance(attachment, _attachment.UnsentMessage): if isinstance(attachment, _attachment.UnsentMessage):
rtn.unsent = True unsent = True
elif attachment: elif attachment:
rtn.attachments.append(attachment) attachments.append(attachment)
except Exception: except Exception:
log.exception( log.exception(
@@ -367,9 +370,18 @@ class Message:
) )
) )
rtn.emoji_size = EmojiSize._from_tags(tags) return cls(
rtn.forwarded = cls._get_forwarded_from_tags(tags) text=data.get("body"),
return rtn mentions=mentions,
emoji_size=EmojiSize._from_tags(tags),
uid=mid,
author=author,
created_at=created_at,
sticker=sticker,
attachments=attachments,
unsent=unsent,
forwarded=cls._get_forwarded_from_tags(tags),
)
def graphql_to_extensible_attachment(data): def graphql_to_extensible_attachment(data):

View File

@@ -1,9 +1,10 @@
import attr import attr
from ._core import attrs_default, Image
from . import _plan from . import _plan
from ._thread import ThreadType, Thread from ._thread import ThreadType, Thread
@attr.s @attrs_default
class Page(Thread): class Page(Thread):
"""Represents a Facebook page. Inherits `Thread`.""" """Represents a Facebook page. Inherits `Thread`."""
@@ -31,11 +32,11 @@ class Page(Thread):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
return cls( return cls(
data["id"], uid=data["id"],
url=data.get("url"), url=data.get("url"),
city=data.get("city").get("name"), city=data.get("city").get("name"),
category=data.get("category_type"), category=data.get("category_type"),
photo=data["profile_picture"].get("uri"), photo=Image._from_uri(data["profile_picture"]),
name=data.get("name"), name=data.get("name"),
message_count=data.get("messages_count"), message_count=data.get("messages_count"),
plan=plan, plan=plan,

View File

@@ -1,6 +1,6 @@
import attr import attr
import json import json
from ._core import Enum from ._core import attrs_default, Enum
from . import _util from . import _util
@@ -10,24 +10,24 @@ class GuestStatus(Enum):
DECLINED = 3 DECLINED = 3
@attr.s @attrs_default
class Plan: class Plan:
"""Represents a plan.""" """Represents a plan."""
#: ID of the plan
uid = attr.ib(None, init=False)
#: Plan time (datetime), only precise down to the minute #: Plan time (datetime), only precise down to the minute
time = attr.ib() time = attr.ib()
#: Plan title #: Plan title
title = attr.ib() title = attr.ib()
#: ID of the plan
uid = attr.ib(None)
#: Plan location name #: Plan location name
location = attr.ib(None, converter=lambda x: x or "") location = attr.ib(None, converter=lambda x: x or "")
#: Plan location ID #: Plan location ID
location_id = attr.ib(None, converter=lambda x: x or "") location_id = attr.ib(None, converter=lambda x: x or "")
#: ID of the plan creator #: ID of the plan creator
author_id = attr.ib(None, init=False) author_id = attr.ib(None)
#: Dictionary of `User` IDs mapped to their `GuestStatus` #: Dictionary of `User` IDs mapped to their `GuestStatus`
guests = attr.ib(None, init=False) guests = attr.ib(None)
@property @property
def going(self): def going(self):
@@ -58,44 +58,41 @@ class Plan:
@classmethod @classmethod
def _from_pull(cls, data): def _from_pull(cls, data):
rtn = cls( return cls(
uid=data.get("event_id"),
time=_util.seconds_to_datetime(int(data.get("event_time"))), time=_util.seconds_to_datetime(int(data.get("event_time"))),
title=data.get("event_title"), title=data.get("event_title"),
location=data.get("event_location_name"), location=data.get("event_location_name"),
location_id=data.get("event_location_id"), location_id=data.get("event_location_id"),
) author_id=data.get("event_creator_id"),
rtn.uid = data.get("event_id") guests={
rtn.author_id = data.get("event_creator_id")
rtn.guests = {
x["node"]["id"]: GuestStatus[x["guest_list_state"]] x["node"]["id"]: GuestStatus[x["guest_list_state"]]
for x in json.loads(data["guest_state_list"]) for x in json.loads(data["guest_state_list"])
} },
return rtn )
@classmethod @classmethod
def _from_fetch(cls, data): def _from_fetch(cls, data):
rtn = cls( return cls(
uid=data.get("oid"),
time=_util.seconds_to_datetime(data.get("event_time")), time=_util.seconds_to_datetime(data.get("event_time")),
title=data.get("title"), title=data.get("title"),
location=data.get("location_name"), location=data.get("location_name"),
location_id=str(data["location_id"]) if data.get("location_id") else None, location_id=str(data["location_id"]) if data.get("location_id") else None,
author_id=data.get("creator_id"),
guests={id_: GuestStatus[s] for id_, s in data["event_members"].items()},
) )
rtn.uid = data.get("oid")
rtn.author_id = data.get("creator_id")
rtn.guests = {id_: GuestStatus[s] for id_, s in data["event_members"].items()}
return rtn
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, data):
rtn = cls( return cls(
uid=data.get("id"),
time=_util.seconds_to_datetime(data.get("time")), time=_util.seconds_to_datetime(data.get("time")),
title=data.get("event_title"), title=data.get("event_title"),
location=data.get("location_name"), location=data.get("location_name"),
) author_id=data["lightweight_event_creator"].get("id"),
rtn.uid = data.get("id") guests={
rtn.author_id = data["lightweight_event_creator"].get("id")
rtn.guests = {
x["node"]["id"]: GuestStatus[x["guest_list_state"]] x["node"]["id"]: GuestStatus[x["guest_list_state"]]
for x in data["event_reminder_members"]["edges"] for x in data["event_reminder_members"]["edges"]
} },
return rtn )

View File

@@ -1,13 +1,14 @@
import attr import attr
from ._core import attrs_default
@attr.s @attrs_default
class Poll: class Poll:
"""Represents a poll.""" """Represents a poll."""
#: Title of the poll #: Title of the poll
title = attr.ib() title = attr.ib()
#: List of :class:`PollOption`, can be fetched with :func:`fbchat.Client.fetch_poll_options` #: List of `PollOption`, can be fetched with `Client.fetch_poll_options`
options = attr.ib() options = attr.ib()
#: Options count #: Options count
options_count = attr.ib(None) options_count = attr.ib(None)
@@ -24,7 +25,7 @@ class Poll:
) )
@attr.s @attrs_default
class PollOption: class PollOption:
"""Represents a poll option.""" """Represents a poll option."""

View File

@@ -1,22 +1,23 @@
import attr import attr
from ._core import attrs_default
from ._attachment import Attachment from ._attachment import Attachment
@attr.s @attrs_default
class QuickReply: class QuickReply:
"""Represents a quick reply.""" """Represents a quick reply."""
#: Payload of the quick reply #: Payload of the quick reply
payload = attr.ib(None) payload = attr.ib(None)
#: External payload for responses #: External payload for responses
external_payload = attr.ib(None, init=False) external_payload = attr.ib(None)
#: Additional data #: Additional data
data = attr.ib(None) data = attr.ib(None)
#: Whether it's a response for a quick reply #: Whether it's a response for a quick reply
is_response = attr.ib(False) is_response = attr.ib(False)
@attr.s @attrs_default
class QuickReplyText(QuickReply): class QuickReplyText(QuickReply):
"""Represents a text quick reply.""" """Represents a text quick reply."""
@@ -28,7 +29,7 @@ class QuickReplyText(QuickReply):
_type = "text" _type = "text"
@attr.s @attrs_default
class QuickReplyLocation(QuickReply): class QuickReplyLocation(QuickReply):
"""Represents a location quick reply (Doesn't work on mobile).""" """Represents a location quick reply (Doesn't work on mobile)."""
@@ -36,7 +37,7 @@ class QuickReplyLocation(QuickReply):
_type = "location" _type = "location"
@attr.s @attrs_default
class QuickReplyPhoneNumber(QuickReply): class QuickReplyPhoneNumber(QuickReply):
"""Represents a phone number quick reply (Doesn't work on mobile).""" """Represents a phone number quick reply (Doesn't work on mobile)."""
@@ -46,7 +47,7 @@ class QuickReplyPhoneNumber(QuickReply):
_type = "user_phone_number" _type = "user_phone_number"
@attr.s @attrs_default
class QuickReplyEmail(QuickReply): class QuickReplyEmail(QuickReply):
"""Represents an email quick reply (Doesn't work on mobile).""" """Represents an email quick reply (Doesn't work on mobile)."""

View File

@@ -5,7 +5,7 @@ import requests
import random import random
import urllib.parse import urllib.parse
from ._core import log from ._core import log, attrs_default
from . import _graphql, _util, _exception from . import _graphql, _util, _exception
FB_DTSG_REGEX = re.compile(r'name="fb_dtsg" value="(.*?)"') FB_DTSG_REGEX = re.compile(r'name="fb_dtsg" value="(.*?)"')
@@ -98,7 +98,7 @@ def _2fa_helper(session, code, r):
return r return r
@attr.s(slots=True) # TODO i Python 3: Add kw_only=True @attrs_default
class State: class State:
"""Stores and manages state required for most Facebook requests.""" """Stores and manages state required for most Facebook requests."""

View File

@@ -1,8 +1,9 @@
import attr import attr
from ._core import attrs_default, Image
from ._attachment import Attachment from ._attachment import Attachment
@attr.s @attrs_default
class Sticker(Attachment): class Sticker(Attachment):
"""Represents a Facebook sticker that has been sent to a thread as an attachment.""" """Represents a Facebook sticker that has been sent to a thread as an attachment."""
@@ -23,12 +24,8 @@ class Sticker(Attachment):
#: The frame rate the spritemap is intended to be played in #: The frame rate the spritemap is intended to be played in
frame_rate = attr.ib(None) frame_rate = attr.ib(None)
#: URL to the sticker's image #: The sticker's image
url = attr.ib(None) image = attr.ib(None)
#: Width of the sticker
width = attr.ib(None)
#: Height of the sticker
height = attr.ib(None)
#: The sticker's label/name #: The sticker's label/name
label = attr.ib(None) label = attr.ib(None)
@@ -36,19 +33,20 @@ class Sticker(Attachment):
def _from_graphql(cls, data): def _from_graphql(cls, data):
if not data: if not data:
return None return None
self = cls(uid=data["id"])
if data.get("pack"): return cls(
self.pack = data["pack"].get("id") uid=data["id"],
if data.get("sprite_image"): pack=data["pack"].get("id") if data.get("pack") else None,
self.is_animated = True is_animated=bool(data.get("sprite_image")),
self.medium_sprite_image = data["sprite_image"].get("uri") medium_sprite_image=data["sprite_image"].get("uri")
self.large_sprite_image = data["sprite_image_2x"].get("uri") if data.get("sprite_image")
self.frames_per_row = data.get("frames_per_row") else None,
self.frames_per_col = data.get("frames_per_column") large_sprite_image=data["sprite_image_2x"].get("uri")
self.frame_rate = data.get("frame_rate") if data.get("sprite_image_2x")
self.url = data.get("url") else None,
self.width = data.get("width") frames_per_row=data.get("frames_per_row"),
self.height = data.get("height") frames_per_col=data.get("frames_per_column"),
if data.get("label"): frame_rate=data.get("frame_rate"),
self.label = data["label"] image=Image._from_url_or_none(data),
return self label=data["label"] if data.get("label") else None,
)

View File

@@ -1,5 +1,5 @@
import attr import attr
from ._core import Enum from ._core import attrs_default, Enum, Image
class ThreadType(Enum): class ThreadType(Enum):
@@ -67,7 +67,7 @@ class ThreadColor(Enum):
return cls._extend_if_invalid(value) return cls._extend_if_invalid(value)
@attr.s @attrs_default
class Thread: class Thread:
"""Represents a Facebook thread.""" """Represents a Facebook thread."""
@@ -75,7 +75,7 @@ class Thread:
uid = attr.ib(converter=str) uid = attr.ib(converter=str)
#: Specifies the type of thread. Can be used a ``thread_type``. See :ref:`intro_threads` for more info #: Specifies the type of thread. Can be used a ``thread_type``. See :ref:`intro_threads` for more info
type = None type = None
#: A URL to the thread's picture #: The thread's picture
photo = attr.ib(None) photo = attr.ib(None)
#: The name of the thread #: The name of the thread
name = attr.ib(None) name = attr.ib(None)
@@ -83,7 +83,7 @@ class Thread:
last_active = attr.ib(None) last_active = attr.ib(None)
#: Number of messages in the thread #: Number of messages in the thread
message_count = attr.ib(None) message_count = attr.ib(None)
#: Set :class:`Plan` #: Set `Plan`
plan = attr.ib(None) plan = attr.ib(None)
@staticmethod @staticmethod

View File

@@ -1,5 +1,5 @@
import attr import attr
from ._core import Enum from ._core import attrs_default, Enum, Image
from . import _util, _plan from . import _util, _plan
from ._thread import ThreadType, Thread from ._thread import ThreadType, Thread
@@ -41,7 +41,7 @@ class TypingStatus(Enum):
TYPING = 1 TYPING = 1
@attr.s @attrs_default
class User(Thread): class User(Thread):
"""Represents a Facebook user. Inherits `Thread`.""" """Represents a Facebook user. Inherits `Thread`."""
@@ -63,7 +63,7 @@ class User(Thread):
nickname = attr.ib(None) nickname = attr.ib(None)
#: The clients nickname, as seen by the user #: The clients nickname, as seen by the user
own_nickname = attr.ib(None) own_nickname = attr.ib(None)
#: A :class:`ThreadColor`. The message color #: A `ThreadColor`. The message color
color = attr.ib(None) color = attr.ib(None)
#: The default emoji #: The default emoji
emoji = attr.ib(None) emoji = attr.ib(None)
@@ -78,7 +78,7 @@ class User(Thread):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
return cls( return cls(
data["id"], uid=data["id"],
url=data.get("url"), url=data.get("url"),
first_name=data.get("first_name"), first_name=data.get("first_name"),
last_name=data.get("last_name"), last_name=data.get("last_name"),
@@ -89,7 +89,7 @@ class User(Thread):
color=c_info.get("color"), color=c_info.get("color"),
emoji=c_info.get("emoji"), emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"), own_nickname=c_info.get("own_nickname"),
photo=data["profile_picture"].get("uri"), photo=Image._from_uri(data["profile_picture"]),
name=data.get("name"), name=data.get("name"),
message_count=data.get("messages_count"), message_count=data.get("messages_count"),
plan=plan, plan=plan,
@@ -123,7 +123,7 @@ class User(Thread):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
return cls( return cls(
user["id"], uid=user["id"],
url=user.get("url"), url=user.get("url"),
name=user.get("name"), name=user.get("name"),
first_name=first_name, first_name=first_name,
@@ -135,7 +135,7 @@ class User(Thread):
color=c_info.get("color"), color=c_info.get("color"),
emoji=c_info.get("emoji"), emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"), own_nickname=c_info.get("own_nickname"),
photo=user["big_image_src"].get("uri"), photo=Image._from_uri(user["big_image_src"]),
message_count=data.get("messages_count"), message_count=data.get("messages_count"),
last_active=last_active, last_active=last_active,
plan=plan, plan=plan,
@@ -144,10 +144,10 @@ class User(Thread):
@classmethod @classmethod
def _from_all_fetch(cls, data): def _from_all_fetch(cls, data):
return cls( return cls(
data["id"], uid=data["id"],
first_name=data.get("firstName"), first_name=data.get("firstName"),
url=data.get("uri"), url=data.get("uri"),
photo=data.get("thumbSrc"), photo=Image(url=data.get("thumbSrc")),
name=data.get("name"), name=data.get("name"),
is_friend=data.get("is_friend"), is_friend=data.get("is_friend"),
gender=GENDERS.get(data.get("gender")), gender=GENDERS.get(data.get("gender")),

View File

@@ -14,7 +14,7 @@ maintainer-email = "madsmtm@gmail.com"
home-page = "https://github.com/carpedm20/fbchat/" home-page = "https://github.com/carpedm20/fbchat/"
requires = [ requires = [
"aenum~=2.0", "aenum~=2.0",
"attrs>=18.2", "attrs>=19.1",
"requests~=2.19", "requests~=2.19",
"beautifulsoup4~=4.0", "beautifulsoup4~=4.0",
] ]

View File

@@ -72,10 +72,8 @@ def test_share_from_graphql_link():
title="a.com", title="a.com",
description="", description="",
source="a.com", source="a.com",
image_url=None, image=None,
original_image_url=None, original_image_url=None,
image_width=None,
image_height=None,
attachments=[], attachments=[],
uid="ee.mid.$xyz", uid="ee.mid.$xyz",
) == ShareAttachment._from_graphql(data) ) == ShareAttachment._from_graphql(data)
@@ -125,10 +123,10 @@ def test_share_from_graphql_link_with_image():
" Share photos and videos, send messages and get updates." " Share photos and videos, send messages and get updates."
), ),
source=None, source=None,
image_url="https://www.facebook.com/rsrc.php/v3/x.png", image=fbchat.Image(
url="https://www.facebook.com/rsrc.php/v3/x.png", width=325, height=325
),
original_image_url="https://www.facebook.com/rsrc.php/v3/x.png", original_image_url="https://www.facebook.com/rsrc.php/v3/x.png",
image_width=325,
image_height=325,
attachments=[], attachments=[],
uid="deadbeef123", uid="deadbeef123",
) == ShareAttachment._from_graphql(data) ) == ShareAttachment._from_graphql(data)
@@ -187,14 +185,14 @@ def test_share_from_graphql_video():
" Subscribe to the official Rick As..." " Subscribe to the official Rick As..."
), ),
source="youtube.com", source="youtube.com",
image_url=( image=fbchat.Image(
"https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123" url="https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123"
"&w=960&h=540&url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdQw4w9WgXcQ" "&w=960&h=540&url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdQw4w9WgXcQ"
"%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123" "%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123",
width=960,
height=540,
), ),
original_image_url="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", original_image_url="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
image_width=960,
image_height=540,
attachments=[], attachments=[],
uid="ee.mid.$gAAT4Sw1WSGhzQ9uRWVtEpZHZ8ZPV", uid="ee.mid.$gAAT4Sw1WSGhzQ9uRWVtEpZHZ8ZPV",
) == ShareAttachment._from_graphql(data) ) == ShareAttachment._from_graphql(data)
@@ -310,10 +308,12 @@ def test_share_with_image_subattachment():
title="", title="",
description="Abc", description="Abc",
source="Def", source="Def",
image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg", image=fbchat.Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
width=720,
height=960,
),
original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg", original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
image_width=720,
image_height=960,
attachments=[None], attachments=[None],
uid="deadbeef123", uid="deadbeef123",
) == ShareAttachment._from_graphql(data) ) == ShareAttachment._from_graphql(data)
@@ -436,19 +436,23 @@ def test_share_with_video_subattachment():
title="", title="",
description="Abc", description="Abc",
source="Def", source="Def",
image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", image=fbchat.Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
width=960,
height=540,
),
original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
image_width=960,
image_height=540,
attachments=[ attachments=[
fbchat.VideoAttachment( fbchat.VideoAttachment(
uid="2222", uid="2222",
duration=datetime.timedelta(seconds=24, microseconds=469000), duration=datetime.timedelta(seconds=24, microseconds=469000),
preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4", preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
medium_image={ previews={
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", fbchat.Image(
"width": 960, url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
"height": 540, width=960,
height=540,
)
}, },
) )
], ],

View File

@@ -33,16 +33,18 @@ def test_imageattachment_from_list():
uid="1234", uid="1234",
width=2833, width=2833,
height=1367, height=1367,
thumbnail_url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg", previews={
preview={ fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg"),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", fbchat.Image(
"width": 960, url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
"height": 463, width=960,
}, height=463,
large_preview={ ),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg", fbchat.Image(
"width": 2048, url="https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg",
"height": 988, width=2048,
height=988,
),
}, },
) == ImageAttachment._from_list({"node": data}) ) == ImageAttachment._from_list({"node": data})
@@ -71,18 +73,20 @@ def test_videoattachment_from_list():
uid="1234", uid="1234",
width=640, width=640,
height=368, height=368,
small_image={ previews={
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg" fbchat.Image(
}, url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg"
medium_image={ ),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg", fbchat.Image(
"width": 640, url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg",
"height": 368, width=640,
}, height=368,
large_image={ ),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg", fbchat.Image(
"width": 640, url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg",
"height": 368, width=640,
height=368,
),
}, },
) == VideoAttachment._from_list({"node": data}) ) == VideoAttachment._from_list({"node": data})
@@ -152,11 +156,11 @@ def test_graphql_to_attachment_image1():
"width": 128, "width": 128,
}, },
"large_preview": { "large_preview": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.png", "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
"height": 128, "height": 128,
"width": 128, "width": 128,
}, },
"thumbnail": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/3.png"}, "thumbnail": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"},
"photo_encodings": [], "photo_encodings": [],
"legacy_attachment_id": "1234", "legacy_attachment_id": "1234",
"original_dimensions": {"x": 128, "y": 128}, "original_dimensions": {"x": 128, "y": 128},
@@ -170,16 +174,13 @@ def test_graphql_to_attachment_image1():
width=None, width=None,
height=None, height=None,
is_animated=False, is_animated=False,
thumbnail_url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/3.png", previews={
preview={ fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png", fbchat.Image(
"width": 128, url="https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
"height": 128, width=128,
}, height=128,
large_preview={ ),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.png",
"width": 128,
"height": 128,
}, },
) == graphql_to_attachment(data) ) == graphql_to_attachment(data)
@@ -209,11 +210,8 @@ def test_graphql_to_attachment_image2():
width=None, width=None,
height=None, height=None,
is_animated=True, is_animated=True,
preview={"uri": "https://cdn.fbsbx.com/v/1.gif", "width": 128, "height": 128}, previews={
animated_preview={ fbchat.Image(url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128)
"uri": "https://cdn.fbsbx.com/v/1.gif",
"width": 128,
"height": 128,
}, },
) == graphql_to_attachment(data) ) == graphql_to_attachment(data)
@@ -251,20 +249,22 @@ def test_graphql_to_attachment_video():
height=None, height=None,
duration=datetime.timedelta(seconds=6), duration=datetime.timedelta(seconds=6),
preview_url="https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4", preview_url="https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4",
small_image={ previews={
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg", fbchat.Image(
"width": 168, url="https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg",
"height": 96, width=168,
}, height=96,
medium_image={ ),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg", fbchat.Image(
"width": 452, url="https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg",
"height": 260, width=452,
}, height=260,
large_image={ ),
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", fbchat.Image(
"width": 640, url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
"height": 368, width=640,
height=368,
),
}, },
) == graphql_to_attachment(data) ) == graphql_to_attachment(data)
@@ -350,9 +350,11 @@ def test_graphql_to_subattachment_video():
uid="1234", uid="1234",
duration=datetime.timedelta(seconds=24, microseconds=469000), duration=datetime.timedelta(seconds=24, microseconds=469000),
preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4", preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
medium_image={ previews={
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", fbchat.Image(
"width": 960, url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
"height": 540, width=960,
height=540,
)
}, },
) == graphql_to_subattachment(data) ) == graphql_to_subattachment(data)

View File

@@ -1,5 +1,6 @@
import pytest import pytest
import datetime import datetime
import fbchat
from fbchat._location import LocationAttachment, LiveLocationAttachment from fbchat._location import LocationAttachment, LiveLocationAttachment
@@ -33,12 +34,17 @@ def test_location_attachment_from_graphql():
"target": {"__typename": "MessageLocation"}, "target": {"__typename": "MessageLocation"},
"subattachments": [], "subattachments": [],
} }
expected = LocationAttachment(latitude=55.4, longitude=12.4322, uid=400828513928715) assert LocationAttachment(
expected.image_url = "https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en" uid=400828513928715,
expected.image_width = 545 latitude=55.4,
expected.image_height = 280 longitude=12.4322,
expected.url = "https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1" image=fbchat.Image(
assert expected == LocationAttachment._from_graphql(data) url="https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en",
width=545,
height=280,
),
url="https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1",
) == LocationAttachment._from_graphql(data)
@pytest.mark.skip(reason="need to gather test data") @pytest.mark.skip(reason="need to gather test data")
@@ -73,16 +79,15 @@ def test_live_location_from_graphql_expired():
}, },
"subattachments": [], "subattachments": [],
} }
expected = LiveLocationAttachment( assert LiveLocationAttachment(
uid=2254535444791641, uid=2254535444791641,
name="Location-sharing ended", name="Location-sharing ended",
expires_at=datetime.datetime( expires_at=datetime.datetime(
2019, 1, 4, 18, 25, 45, tzinfo=datetime.timezone.utc 2019, 1, 4, 18, 25, 45, tzinfo=datetime.timezone.utc
), ),
is_expired=True, is_expired=True,
) url="https://www.facebook.com/",
expected.url = "https://www.facebook.com/" ) == LiveLocationAttachment._from_graphql(data)
assert expected == LiveLocationAttachment._from_graphql(data)
@pytest.mark.skip(reason="need to gather test data") @pytest.mark.skip(reason="need to gather test data")

View File

@@ -1,3 +1,4 @@
import fbchat
from fbchat._page import Page from fbchat._page import Page
@@ -12,7 +13,7 @@ def test_page_from_graphql():
} }
assert Page( assert Page(
uid="123456", uid="123456",
photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
name="Some school", name="Some school",
url="https://www.facebook.com/some-school/", url="https://www.facebook.com/some-school/",
city=None, city=None,

View File

@@ -3,13 +3,16 @@ from fbchat._plan import GuestStatus, Plan
def test_plan_properties(): def test_plan_properties():
plan = Plan(time=..., title=...) plan = Plan(
plan.guests = { time=...,
title=...,
guests={
"1234": GuestStatus.INVITED, "1234": GuestStatus.INVITED,
"2345": GuestStatus.INVITED, "2345": GuestStatus.INVITED,
"3456": GuestStatus.GOING, "3456": GuestStatus.GOING,
"4567": GuestStatus.DECLINED, "4567": GuestStatus.DECLINED,
} },
)
assert set(plan.invited) == {"1234", "2345"} assert set(plan.invited) == {"1234", "2345"}
assert plan.going == ["3456"] assert plan.going == ["3456"]
assert plan.declined == ["4567"] assert plan.declined == ["4567"]
@@ -32,19 +35,18 @@ def test_plan_from_pull():
'{"guest_list_state":"GOING","node":{"id":"4567"}}]' '{"guest_list_state":"GOING","node":{"id":"4567"}}]'
), ),
} }
plan = Plan( assert Plan(
uid="1111",
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc", title="abc",
) author_id="1234",
plan.uid = "1111" guests={
plan.author_id = "1234"
plan.guests = {
"1234": GuestStatus.INVITED, "1234": GuestStatus.INVITED,
"2356": GuestStatus.INVITED, "2356": GuestStatus.INVITED,
"3456": GuestStatus.DECLINED, "3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING, "4567": GuestStatus.GOING,
} },
assert plan == Plan._from_pull(data) ) == Plan._from_pull(data)
def test_plan_from_fetch(): def test_plan_from_fetch():
@@ -90,21 +92,20 @@ def test_plan_from_fetch():
"4567": "GOING", "4567": "GOING",
}, },
} }
plan = Plan( assert Plan(
uid=1111,
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc", title="abc",
location="", location="",
location_id="", location_id="",
) author_id=1234,
plan.uid = 1111 guests={
plan.author_id = 1234
plan.guests = {
"1234": GuestStatus.INVITED, "1234": GuestStatus.INVITED,
"2356": GuestStatus.INVITED, "2356": GuestStatus.INVITED,
"3456": GuestStatus.DECLINED, "3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING, "4567": GuestStatus.GOING,
} },
assert plan == Plan._from_fetch(data) ) == Plan._from_fetch(data)
def test_plan_from_graphql(): def test_plan_from_graphql():
@@ -133,18 +134,17 @@ def test_plan_from_graphql():
] ]
}, },
} }
plan = Plan( assert Plan(
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc", title="abc",
location="", location="",
location_id="", location_id="",
) uid="1111",
plan.uid = "1111" author_id="1234",
plan.author_id = "1234" guests={
plan.guests = {
"1234": GuestStatus.INVITED, "1234": GuestStatus.INVITED,
"2356": GuestStatus.INVITED, "2356": GuestStatus.INVITED,
"3456": GuestStatus.DECLINED, "3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING, "4567": GuestStatus.GOING,
} },
assert plan == Plan._from_graphql(data) ) == Plan._from_graphql(data)

View File

@@ -10,12 +10,12 @@ pytestmark = pytest.mark.online
@pytest.fixture( @pytest.fixture(
scope="module", scope="module",
params=[ params=[
Plan(int(time()) + 100, random_hex()), Plan(time=int(time()) + 100, title=random_hex()),
pytest.param( pytest.param(
Plan(int(time()), random_hex()), Plan(time=int(time()), title=random_hex()),
marks=[pytest.mark.xfail(raises=FBchatFacebookError)], marks=[pytest.mark.xfail(raises=FBchatFacebookError)],
), ),
pytest.param(Plan(0, None), marks=[pytest.mark.xfail()]), pytest.param(Plan(time=0, title=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):

View File

@@ -13,26 +13,26 @@ pytestmark = pytest.mark.online
Poll( Poll(
title=random_hex(), title=random_hex(),
options=[ options=[
PollOption(random_hex(), vote=True), PollOption(text=random_hex(), vote=True),
PollOption(random_hex(), vote=True), PollOption(text=random_hex(), vote=True),
], ],
), ),
Poll( Poll(
title=random_hex(), title=random_hex(),
options=[ options=[
PollOption(random_hex(), vote=False), PollOption(text=random_hex(), vote=False),
PollOption(random_hex(), vote=False), PollOption(text=random_hex(), vote=False),
], ],
), ),
Poll( Poll(
title=random_hex(), title=random_hex(),
options=[ options=[
PollOption(random_hex(), vote=True), PollOption(text=random_hex(), vote=True),
PollOption(random_hex(), vote=True), PollOption(text=random_hex(), vote=True),
PollOption(random_hex(), vote=False), PollOption(text=random_hex(), vote=False),
PollOption(random_hex(), vote=False), PollOption(text=random_hex(), vote=False),
PollOption(random_hex()), PollOption(text=random_hex()),
PollOption(random_hex()), PollOption(text=random_hex()),
], ],
), ),
pytest.param( pytest.param(

View File

@@ -1,4 +1,5 @@
import pytest import pytest
import fbchat
from fbchat._sticker import Sticker from fbchat._sticker import Sticker
@@ -15,14 +16,14 @@ def test_from_graphql_normal():
uid="369239383222810", uid="369239383222810",
pack="227877430692340", pack="227877430692340",
is_animated=False, is_animated=False,
medium_sprite_image=None, frames_per_row=1,
large_sprite_image=None, frames_per_col=1,
frames_per_row=None, frame_rate=83,
frames_per_col=None, image=fbchat.Image(
frame_rate=None,
url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png", url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png",
width=274, width=274,
height=274, height=274,
),
label="Like, thumbs up", label="Like, thumbs up",
) == Sticker._from_graphql( ) == Sticker._from_graphql(
{ {
@@ -54,9 +55,11 @@ def test_from_graphql_animated():
frames_per_row=2, frames_per_row=2,
frames_per_col=2, frames_per_col=2,
frame_rate=142, frame_rate=142,
image=fbchat.Image(
url="https://scontent-arn2-1.fbcdn.net/v/redacted1.png", url="https://scontent-arn2-1.fbcdn.net/v/redacted1.png",
width=240, width=240,
height=293, height=293,
),
label="Love, cat with heart", label="Love, cat with heart",
) == Sticker._from_graphql( ) == Sticker._from_graphql(
{ {

View File

@@ -1,5 +1,6 @@
import pytest import pytest
import datetime import datetime
import fbchat
from fbchat._user import User, ActiveStatus from fbchat._user import User, ActiveStatus
@@ -17,7 +18,7 @@ def test_user_from_graphql():
} }
assert User( assert User(
uid="1234", uid="1234",
photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
name="Abc Def Ghi", name="Abc Def Ghi",
url="https://www.facebook.com/profile.php?id=1234", url="https://www.facebook.com/profile.php?id=1234",
first_name="Abc", first_name="Abc",
@@ -137,7 +138,7 @@ def test_user_from_thread_fetch():
} }
assert User( assert User(
uid="1234", uid="1234",
photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
name="Abc Def Ghi", name="Abc Def Ghi",
last_active=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), last_active=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
message_count=1111, message_count=1111,
@@ -175,7 +176,7 @@ def test_user_from_all_fetch():
} }
assert User( assert User(
uid="1234", uid="1234",
photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
name="Abc Def Ghi", name="Abc Def Ghi",
url="https://www.facebook.com/profile.php?id=1234", url="https://www.facebook.com/profile.php?id=1234",
first_name="Abc", first_name="Abc",

View File

@@ -22,9 +22,13 @@ EMOJI_LIST = [
] ]
STICKER_LIST = [ STICKER_LIST = [
Sticker("767334476626295"), Sticker(uid="767334476626295"),
pytest.param(Sticker("0"), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), pytest.param(
pytest.param(Sticker(None), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), Sticker(uid="0"), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]
),
pytest.param(
Sticker(uid=None), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]
),
] ]
TEXT_LIST = [ TEXT_LIST = [