From e735823d3794d8cfd3e998b0c7eb8ed0dd05a9b3 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 16 Jan 2020 15:54:35 +0100 Subject: [PATCH 1/7] Add initial common/core listen events --- fbchat/__init__.py | 2 ++ fbchat/_client.py | 5 ++++ fbchat/_event_common.py | 50 +++++++++++++++++++++++++++++++++ tests/test_event_common.py | 57 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 fbchat/_event_common.py create mode 100644 tests/test_event_common.py diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 13dd54e..6c769d9 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -42,6 +42,8 @@ from ._quick_reply import ( from ._poll import Poll, PollOption from ._plan import GuestStatus, Plan, PlanData +from ._event_common import Event, UnknownEvent, ThreadEvent + from ._client import Client __title__ = "fbchat" diff --git a/fbchat/_client.py b/fbchat/_client.py index f3c3d05..eee243a 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -13,6 +13,7 @@ from . import ( _group, _thread, _message, + _event_common, ) from ._thread import ThreadLocation @@ -1194,6 +1195,10 @@ class Client: EVENTS """ + def on_event(self, event: _event_common.Event): + """Called when the client is listening, and an event happens.""" + log.info("Got event: %s", event) + def on_message( self, mid=None, diff --git a/fbchat/_event_common.py b/fbchat/_event_common.py new file mode 100644 index 0000000..5da21c4 --- /dev/null +++ b/fbchat/_event_common.py @@ -0,0 +1,50 @@ +import attr +import abc +from ._core import kw_only +from . import _exception, _thread, _group, _user, _message + +#: Default attrs settings for events +attrs_event = attr.s(slots=True, kw_only=kw_only, frozen=True) + + +@attrs_event +class Event(metaclass=abc.ABCMeta): + """Base class for all events.""" + + @classmethod + @abc.abstractmethod + def _parse(cls, session, data): + raise NotImplementedError + + +@attrs_event +class UnknownEvent(Event): + """Represent an unknown event.""" + + #: The unknown data. This cannot be relied on, it's only for debugging purposes. + data = attr.ib() + + @classmethod + def _parse(cls, session, data): + raise NotImplementedError + + +@attrs_event +class ThreadEvent(Event): + """Represent an event that was done by a user/page in a thread.""" + + #: The person who did the action + author = attr.ib(type=_user.User) # Or Union[User, Page]? + #: Thread that the action was done in + thread = attr.ib(type=_thread.ThreadABC) + + @staticmethod + def _get_thread(session, data): + # TODO: Handle pages? Is it even possible? + key = data["threadKey"] + + if "threadFbId" in key: + return _group.Group(session=session, id=str(key["threadFbId"])) + elif "otherUserFbId" in key: + return _user.User(session=session, id=str(key["otherUserFbId"])) + raise _exception.ParseError("Could not find thread data", data=data) diff --git a/tests/test_event_common.py b/tests/test_event_common.py new file mode 100644 index 0000000..2b462f9 --- /dev/null +++ b/tests/test_event_common.py @@ -0,0 +1,57 @@ +import pytest +from fbchat import Group, User, ParseError, ThreadEvent + + +def test_thread_event_get_thread_group1(session): + data = { + "threadKey": {"threadFbId": 1234}, + "messageId": "mid.$gAAT4Sw1WSGh14A3MOFvrsiDvr3Yc", + "offlineThreadingId": "6623583531508397596", + "actorFbId": 4321, + "timestamp": 1500000000000, + "tags": [ + "inbox", + "sent", + "tq", + "blindly_apply_message_folder", + "source:messenger:web", + ], + } + assert Group(session=session, id="1234") == ThreadEvent._get_thread(session, data) + + +def test_thread_event_get_thread_group2(session): + data = { + "actorFbId": "4321", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "112233445566", + "skipBumpThread": False, + "tags": ["source:messenger:web"], + "threadKey": {"threadFbId": "1234"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + } + assert Group(session=session, id="1234") == ThreadEvent._get_thread(session, data) + + +def test_thread_event_get_thread_user(session): + data = { + "actorFbId": "4321", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "112233445566", + "skipBumpThread": False, + "skipSnippetUpdate": False, + "tags": ["source:messenger:web"], + "threadKey": {"otherUserFbId": "1234"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + } + assert User(session=session, id="1234") == ThreadEvent._get_thread(session, data) + + +def test_thread_event_get_thread_unknown(session): + data = {"threadKey": {"abc": "1234"}} + with pytest.raises(ParseError, match="Could not find thread data"): + ThreadEvent._get_thread(session, data) From 0696ff9f4b0639e859c29edadf659961e545fe54 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 16 Jan 2020 16:53:18 +0100 Subject: [PATCH 2/7] Move ClientPayload parsing to separate file and add tests --- fbchat/__init__.py | 7 ++ fbchat/_client.py | 174 +---------------------------------- fbchat/_client_payload.py | 136 +++++++++++++++++++++++++++ fbchat/_message.py | 7 +- tests/test_client_payload.py | 172 ++++++++++++++++++++++++++++++++++ 5 files changed, 322 insertions(+), 174 deletions(-) create mode 100644 fbchat/_client_payload.py create mode 100644 tests/test_client_payload.py diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 6c769d9..0cd6d70 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -43,6 +43,13 @@ from ._poll import Poll, PollOption from ._plan import GuestStatus, Plan, PlanData from ._event_common import Event, UnknownEvent, ThreadEvent +from ._client_payload import ( + ReactionEvent, + UserStatusEvent, + LiveLocationEvent, + UnsendEvent, + MessageReplyEvent, +) from ._client import Client diff --git a/fbchat/_client.py b/fbchat/_client.py index eee243a..ddde55e 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -14,6 +14,7 @@ from . import ( _thread, _message, _event_common, + _client_payload, ) from ._thread import ThreadLocation @@ -964,92 +965,8 @@ class Client: # Client payload (that weird numbers) elif delta_class == "ClientPayload": - payload = _util.parse_json("".join(chr(z) for z in delta["payload"])) - # Hack - at = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) - for d in payload.get("deltas", []): - - # Message reaction - if d.get("deltaMessageReaction"): - i = d["deltaMessageReaction"] - mid = i["messageId"] - author_id = str(i["userId"]) - add_reaction = not bool(i["action"]) - if add_reaction: - self.on_reaction_added( - mid=mid, - reaction=i.get("reaction"), - author_id=author_id, - thread=get_thread(i), - at=at, - ) - else: - self.on_reaction_removed( - mid=mid, author_id=author_id, thread=get_thread(i), at=at, - ) - - # Viewer status change - elif d.get("deltaChangeViewerStatus"): - i = d["deltaChangeViewerStatus"] - author_id = str(i["actorFbid"]) - reason = i["reason"] - can_reply = i["canViewerReply"] - if reason == 2: - if can_reply: - self.on_unblock( - author_id=author_id, thread=get_thread(i), at=at - ) - else: - self.on_block( - author_id=author_id, thread=get_thread(i), at=at - ) - - # Live location info - elif d.get("liveLocationData"): - i = d["liveLocationData"] - for l in i["messageLiveLocations"]: - mid = l["messageId"] - author_id = str(l["senderId"]) - location = LiveLocationAttachment._from_pull(l) - self.on_live_location( - mid=mid, - location=location, - author_id=author_id, - thread=get_thread(i), - at=at, - ) - - # Message deletion - elif d.get("deltaRecallMessageData"): - i = d["deltaRecallMessageData"] - mid = i["messageID"] - at = _util.millis_to_datetime(i["deletionTimestamp"]) - author_id = str(i["senderID"]) - self.on_message_unsent( - mid=mid, author_id=author_id, thread=get_thread(i), at=at - ) - - elif d.get("deltaMessageReply"): - i = d["deltaMessageReply"] - metadata = i["message"]["messageMetadata"] - thread = get_thread(metadata) - replied_to = _message.MessageData._from_reply( - thread, i["repliedToMessage"] - ) - message = _message.MessageData._from_reply( - thread, i["message"], replied_to - ) - self.on_message( - mid=message.id, - author_id=message.author, - message_object=message, - thread=thread, - at=message.created_at, - metadata=metadata, - ) - - else: - self.on_unknown_messsage_type(msg=d) + for event in _client_payload.parse_client_payloads(self.session, delta): + self.on_event(event) # New message elif delta.get("class") == "NewMessage": @@ -1413,21 +1330,6 @@ class Client: """ log.info("Marked messages as seen in threads {} at {}".format(threads, seen_at)) - def on_message_unsent(self, mid=None, author_id=None, thread=None, at=None): - """Called when the client is listening, and someone unsends (deletes for everyone) a message. - - Args: - mid: ID of the unsent message - author_id: The ID of the person who unsent the message - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} unsent the message {} in {} at {}".format( - author_id, repr(mid), thread, at - ) - ) - def on_people_added( self, mid=None, added_ids=None, author_id=None, group=None, at=None ): @@ -1523,76 +1425,6 @@ class Client: """ log.info('{} played "{}" in {}'.format(author_id, game_name, thread)) - def on_reaction_added( - self, mid=None, reaction=None, author_id=None, thread=None, at=None - ): - """Called when the client is listening, and somebody reacts to a message. - - Args: - mid: Message ID, that user reacted to - reaction: The added reaction. Not limited to the ones in `Message.react` - add_reaction: Whether user added or removed reaction - author_id: The ID of the person who reacted to the message - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} reacted to message {} with {} in {}".format( - author_id, mid, reaction, thread - ) - ) - - def on_reaction_removed(self, mid=None, author_id=None, thread=None, at=None): - """Called when the client is listening, and somebody removes reaction from a message. - - Args: - mid: Message ID, that user reacted to - author_id: The ID of the person who removed reaction - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} removed reaction from {} message in {}".format(author_id, mid, thread) - ) - - def on_block(self, author_id=None, thread=None, at=None): - """Called when the client is listening, and somebody blocks client. - - Args: - author_id: The ID of the person who blocked - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} blocked {}".format(author_id, thread)) - - def on_unblock(self, author_id=None, thread=None, at=None): - """Called when the client is listening, and somebody blocks client. - - Args: - author_id: The ID of the person who unblocked - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} unblocked {}".format(author_id, thread)) - - def on_live_location( - self, mid=None, location=None, author_id=None, thread=None, at=None - ): - """Called when the client is listening and somebody sends live location info. - - Args: - mid: The action ID - location (LiveLocationAttachment): Sent location info - author_id: The ID of the person who sent location info - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} sent live location info in {} with latitude {} and longitude {}".format( - author_id, thread, location.latitude, location.longitude - ) - ) - def on_call_started( self, mid=None, diff --git a/fbchat/_client_payload.py b/fbchat/_client_payload.py new file mode 100644 index 0000000..2543a2a --- /dev/null +++ b/fbchat/_client_payload.py @@ -0,0 +1,136 @@ +import attr +import datetime +from ._event_common import attrs_event, UnknownEvent, ThreadEvent +from . import _exception, _util, _user, _message + +from typing import Optional + + +@attrs_event +class ReactionEvent(ThreadEvent): + """Somebody reacted to a message.""" + + #: Message that the user reacted to + message = attr.ib(type=_message.Message) + + reaction = attr.ib(type=Optional[str]) + """The reaction. + + Not limited to the ones in `Message.react`. + + If ``None``, the reaction was removed. + """ + + @classmethod + def _parse(cls, session, data): + thread = cls._get_thread(session, data) + return cls( + author=_user.User(session=session, id=str(data["userId"])), + thread=thread, + message=_message.Message(thread=thread, id=data["messageId"]), + reaction=data["reaction"] if data["action"] == 0 else None, + ) + + +@attrs_event +class UserStatusEvent(ThreadEvent): + #: Whether the user was blocked or unblocked + blocked = attr.ib(type=bool) + + @classmethod + def _parse(cls, session, data): + return cls( + author=_user.User(session=session, id=str(data["actorFbid"])), + thread=cls._get_thread(session, data), + blocked=not data["canViewerReply"], + ) + + +@attrs_event +class LiveLocationEvent(ThreadEvent): + """Somebody sent live location info.""" + + # TODO: This! + + @classmethod + def _parse(cls, session, data): + from . import _location + + thread = cls._get_thread(session, data) + for location_data in data["messageLiveLocations"]: + message = _message.Message(thread=thread, id=data["messageId"]) + author = _user.User(session=session, id=str(location_data["senderId"])) + location = _location.LiveLocationAttachment._from_pull(location_data) + + return None + + +@attrs_event +class UnsendEvent(ThreadEvent): + """Somebody unsent a message (which deletes it for everyone).""" + + #: The unsent message + message = attr.ib(type=_message.Message) + #: When the message was unsent + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + thread = cls._get_thread(session, data) + return cls( + author=_user.User(session=session, id=str(data["senderID"])), + thread=thread, + message=_message.Message(thread=thread, id=data["messageID"]), + at=_util.millis_to_datetime(data["deletionTimestamp"]), + ) + + +@attrs_event +class MessageReplyEvent(ThreadEvent): + """Somebody replied to a message.""" + + #: The sent message + message = attr.ib(type=_message.MessageData) + #: The message that was replied to + replied_to = attr.ib(type=_message.MessageData) + + @classmethod + def _parse(cls, session, data): + metadata = data["message"]["messageMetadata"] + thread = cls._get_thread(session, metadata) + return cls( + author=_user.User(session=session, id=str(metadata["actorFbId"])), + thread=thread, + message=_message.MessageData._from_reply(thread, data["message"]), + replied_to=_message.MessageData._from_reply( + thread, data["repliedToMessage"] + ), + ) + + +def parse_client_delta(session, data): + if "deltaMessageReaction" in data: + return ReactionEvent._parse(session, data["deltaMessageReaction"]) + elif "deltaChangeViewerStatus" in data: + # TODO: Parse all `reason` + if data["deltaChangeViewerStatus"]["reason"] == 2: + return UserStatusEvent._parse(session, data["deltaChangeViewerStatus"]) + elif "liveLocationData" in data: + return LiveLocationEvent._parse(session, data["liveLocationData"]) + elif "deltaRecallMessageData" in data: + return UnsendEvent._parse(session, data["deltaRecallMessageData"]) + elif "deltaMessageReply" in data: + return MessageReplyEvent._parse(session, data["deltaMessageReply"]) + return UnknownEvent(data=data) + + +def parse_client_payloads(session, data): + payload = _util.parse_json("".join(chr(z) for z in data["payload"])) + + try: + for delta in payload["deltas"]: + yield parse_client_delta(session, delta) + except _exception.ParseError: + raise + except Exception as e: + raise _exception.ParseError("Error parsing ClientPayload", data=payload) from e diff --git a/fbchat/_message.py b/fbchat/_message.py index b230477..ab33aa3 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -319,7 +319,7 @@ class MessageData(Message): ) @classmethod - def _from_reply(cls, thread, data, replied_to=None): + def _from_reply(cls, thread, data): tags = data["messageMetadata"].get("tags") metadata = data.get("messageMetadata", {}) @@ -360,8 +360,9 @@ class MessageData(Message): attachments=attachments, quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")), unsent=unsent, - reply_to_id=replied_to.id if replied_to else None, - replied_to=replied_to, + reply_to_id=data["messageReply"]["replyToMessageId"]["id"] + if "messageReply" in data + else None, forwarded=cls._get_forwarded_from_tags(tags), ) diff --git a/tests/test_client_payload.py b/tests/test_client_payload.py new file mode 100644 index 0000000..b9edc3e --- /dev/null +++ b/tests/test_client_payload.py @@ -0,0 +1,172 @@ +import datetime +import pytest +from fbchat import ( + ParseError, + User, + Group, + UnknownEvent, + ReactionEvent, + UserStatusEvent, + LiveLocationEvent, + UnsendEvent, + MessageReplyEvent, +) +from fbchat._message import Message, MessageData +from fbchat._client_payload import parse_client_delta, parse_client_payloads + + +def test_reaction_event_added(session): + data = { + "threadKey": {"otherUserFbId": 1234}, + "messageId": "mid.$XYZ", + "action": 0, + "userId": 4321, + "reaction": "😍", + "senderId": 4321, + "offlineThreadingId": "6623596674408921967", + } + thread = User(session=session, id="1234") + assert ReactionEvent( + author=User(session=session, id="4321"), + thread=thread, + message=Message(thread=thread, id="mid.$XYZ"), + reaction="😍", + ) == parse_client_delta(session, {"deltaMessageReaction": data}) + + +def test_reaction_event_removed(session): + data = { + "threadKey": {"threadFbId": 1234}, + "messageId": "mid.$XYZ", + "action": 1, + "userId": 4321, + "senderId": 4321, + "offlineThreadingId": "6623586106713014836", + } + thread = Group(session=session, id="1234") + assert ReactionEvent( + author=User(session=session, id="4321"), + thread=thread, + message=Message(thread=thread, id="mid.$XYZ"), + reaction=None, + ) == parse_client_delta(session, {"deltaMessageReaction": data}) + + +def test_user_status_blocked(session): + data = { + "threadKey": {"otherUserFbId": 1234}, + "canViewerReply": False, + "reason": 2, + "actorFbid": 4321, + } + assert UserStatusEvent( + author=User(session=session, id="4321"), + thread=User(session=session, id="1234"), + blocked=True, + ) == parse_client_delta(session, {"deltaChangeViewerStatus": data}) + + +def test_user_status_unblocked(session): + data = { + "threadKey": {"otherUserFbId": 1234}, + "canViewerReply": True, + "reason": 2, + "actorFbid": 1234, + } + assert UserStatusEvent( + author=User(session=session, id="1234"), + thread=User(session=session, id="1234"), + blocked=False, + ) == parse_client_delta(session, {"deltaChangeViewerStatus": data}) + + +@pytest.mark.skip(reason="need to gather test data") +def test_live_location(session): + pass + + +def test_message_reply(session): + message = { + "messageMetadata": { + "threadKey": {"otherUserFbId": 1234}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "112233445566", + "actorFbId": 1234, + "timestamp": 1500000000000, + "tags": ["source:messenger:web", "cg-enabled", "sent", "inbox"], + "threadReadStateEffect": 3, + "skipBumpThread": False, + "skipSnippetUpdate": False, + "unsendType": "can_unsend", + "folderId": {"systemFolderId": 0}, + }, + "body": "xyz", + "attachments": [], + "irisSeqId": 1111111, + "messageReply": {"replyToMessageId": {"id": "mid.$ABC"}, "status": 0,}, + "requestContext": {"apiArgs": "..."}, + "irisTags": ["DeltaNewMessage"], + } + reply = { + "messageMetadata": { + "threadKey": {"otherUserFbId": 1234}, + "messageId": "mid.$ABC", + "offlineThreadingId": "665544332211", + "actorFbId": 4321, + "timestamp": 1600000000000, + "tags": ["inbox", "sent", "source:messenger:web"], + }, + "body": "abc", + "attachments": [], + "requestContext": {"apiArgs": "..."}, + "irisTags": [], + } + data = { + "message": message, + "repliedToMessage": reply, + "status": 0, + } + thread = User(session=session, id="1234") + assert MessageReplyEvent( + author=User(session=session, id="1234"), + thread=thread, + message=MessageData( + thread=thread, + id="mid.$XYZ", + author="1234", + created_at=datetime.datetime( + 2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc + ), + text="xyz", + reply_to_id="mid.$ABC", + ), + replied_to=MessageData( + thread=thread, + id="mid.$ABC", + author="4321", + created_at=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + text="abc", + ), + ) == parse_client_delta(session, {"deltaMessageReply": data}) + + +def test_parse_client_delta_unknown(session): + assert UnknownEvent(data={"abc": 10}) == parse_client_delta(session, {"abc": 10}) + + +def test_parse_client_payloads_empty(session): + # This is never something that happens, it's just so that we can test the parsing + # payload = '{"deltas":[]}' + payload = [123, 34, 100, 101, 108, 116, 97, 115, 34, 58, 91, 93, 125] + data = {"payload": payload, "class": "ClientPayload"} + assert [] == list(parse_client_payloads(session, data)) + + +def test_parse_client_payloads_invalid(session): + # payload = '{"invalid":"data"}' + payload = [123, 34, 105, 110, 118, 97, 108, 105, 100, 34, 58, 34, 97, 34, 125] + data = {"payload": payload, "class": "ClientPayload"} + with pytest.raises(ParseError, match="Error parsing ClientPayload"): + list(parse_client_payloads(session, data)) From 4abe5659ae55850f982cf89bf4655aa32e47d280 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Mon, 20 Jan 2020 18:02:55 +0100 Subject: [PATCH 3/7] Move /t_ms delta class parsing to separate file and add tests --- fbchat/__init__.py | 9 ++ fbchat/_client.py | 284 +------------------------------------ fbchat/_delta_class.py | 187 ++++++++++++++++++++++++ fbchat/_event_common.py | 10 +- fbchat/_message.py | 8 +- tests/conftest.py | 9 +- tests/test_delta_class.py | 275 +++++++++++++++++++++++++++++++++++ tests/test_event_common.py | 21 +++ 8 files changed, 522 insertions(+), 281 deletions(-) create mode 100644 fbchat/_delta_class.py create mode 100644 tests/test_delta_class.py diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 0cd6d70..4e67a7f 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -50,6 +50,15 @@ from ._client_payload import ( UnsendEvent, MessageReplyEvent, ) +from ._delta_class import ( + PeopleAdded, + PersonRemoved, + TitleSet, + UnfetchedThreadEvent, + MessagesDelivered, + ThreadsRead, + MessageEvent, +) from ._client import Client diff --git a/fbchat/_client.py b/fbchat/_client.py index ddde55e..4e33a71 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -15,6 +15,7 @@ from . import ( _message, _event_common, _client_payload, + _delta_class, ) from ._thread import ThreadLocation @@ -618,7 +619,6 @@ class Client: return None delta_type = delta.get("type") - delta_class = delta.get("class") metadata = delta.get("messageMetadata") if metadata: @@ -626,30 +626,8 @@ class Client: author_id = str(metadata["actorFbId"]) at = _util.millis_to_datetime(int(metadata.get("timestamp"))) - # Added participants - if "addedParticipants" in delta: - added_ids = [str(x["userFbId"]) for x in delta["addedParticipants"]] - self.on_people_added( - mid=mid, - added_ids=added_ids, - author_id=author_id, - group=get_thread(metadata), - at=at, - ) - - # Left/removed participants - elif "leftParticipantFbId" in delta: - removed_id = str(delta["leftParticipantFbId"]) - self.on_person_removed( - mid=mid, - removed_id=removed_id, - author_id=author_id, - group=get_thread(metadata), - at=at, - ) - # Color change - elif delta_type == "change_thread_theme": + if delta_type == "change_thread_theme": thread = get_thread(metadata) self.on_color_change( mid=mid, @@ -662,13 +640,6 @@ class Client: metadata=metadata, ) - elif delta_class == "MarkFolderSeen": - locations = [ - ThreadLocation(folder.lstrip("FOLDER_")) for folder in delta["folders"] - ] - at = _util.millis_to_datetime(int(delta["timestamp"])) - self._on_seen(locations=locations, at=at) - # Emoji change elif delta_type == "change_thread_icon": new_emoji = delta["untypedData"]["thread_icon"] @@ -681,45 +652,6 @@ class Client: metadata=metadata, ) - # Thread title change - elif delta_class == "ThreadName": - new_title = delta["name"] - self.on_title_change( - mid=mid, - author_id=author_id, - new_title=new_title, - group=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Forced fetch - elif delta_class == "ForcedFetch": - mid = delta.get("messageId") - if mid is None: - self.on_unknown_messsage_type(msg=delta) - else: - group = get_thread(delta) - fetch_info = group._forced_fetch(mid) - fetch_data = fetch_info["message"] - author_id = fetch_data["message_sender"]["id"] - at = _util.millis_to_datetime(int(fetch_data["timestamp_precise"])) - if fetch_data.get("__typename") == "ThreadImageMessage": - # Thread image change - image_metadata = fetch_data.get("image_with_metadata") - image_id = ( - int(image_metadata["legacy_attachment_id"]) - if image_metadata - else None - ) - self.on_image_change( - mid=mid, - author_id=author_id, - new_image=image_id, - group=group, - at=at, - ) - # Nickname change elif delta_type == "change_thread_nickname": changed_for = str(delta["untypedData"]["participant_id"]) @@ -766,52 +698,6 @@ class Client: at=at, ) - # Message delivered - elif delta_class == "DeliveryReceipt": - message_ids = delta["messageIds"] - delivered_for = str( - delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"] - ) - at = _util.millis_to_datetime(int(delta["deliveredWatermarkTimestampMs"])) - self.on_message_delivered( - msg_ids=message_ids, - delivered_for=delivered_for, - thread=get_thread(delta), - at=at, - metadata=metadata, - ) - - # Message seen - elif delta_class == "ReadReceipt": - seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"]) - seen_at = _util.millis_to_datetime(int(delta["actionTimestampMs"])) - at = _util.millis_to_datetime(int(delta["watermarkTimestampMs"])) - self.on_message_seen( - seen_by=seen_by, - thread=get_thread(delta), - seen_at=seen_at, - at=at, - metadata=metadata, - ) - - # Messages marked as seen - elif delta_class == "MarkRead": - seen_at = _util.millis_to_datetime( - int(delta.get("actionTimestampMs") or delta.get("actionTimestamp")) - ) - watermark_ts = delta.get("watermarkTimestampMs") or delta.get( - "watermarkTimestamp" - ) - at = _util.millis_to_datetime(int(watermark_ts)) - - threads = [] - if "folders" not in delta: - threads = [ - get_thread({"threadKey": thr}) for thr in delta.get("threadKeys") - ] - - self.on_marked_seen(threads=threads, seen_at=seen_at, at=at, metadata=delta) - # Game played elif delta_type == "instant_game_update": game_id = delta["untypedData"]["game_id"] @@ -834,10 +720,6 @@ class Client: metadata=metadata, ) - # Skip "no operation" events - elif delta_class == "NoOp": - pass - # Group call started/ended elif delta_type == "rtc_call_log": call_status = delta["untypedData"]["event"] @@ -964,28 +846,14 @@ class Client: ) # Client payload (that weird numbers) - elif delta_class == "ClientPayload": + elif delta.get("class") == "ClientPayload": for event in _client_payload.parse_client_payloads(self.session, delta): self.on_event(event) - # New message - elif delta.get("class") == "NewMessage": - thread = get_thread(metadata) - self.on_message( - mid=mid, - author_id=author_id, - message_object=_message.MessageData._from_pull( - thread, - delta, - mid=mid, - tags=metadata.get("tags"), - author=author_id, - created_at=at, - ), - thread=thread, - at=at, - metadata=metadata, - ) + elif delta.get("class"): + event = _delta_class.parse_delta(self.session, delta) + if event: + self.on_event(event) # Unknown message type else: @@ -1116,27 +984,6 @@ class Client: """Called when the client is listening, and an event happens.""" log.info("Got event: %s", event) - def on_message( - self, - mid=None, - author_id=None, - message_object=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody sends a message. - - Args: - mid: The message ID - author_id: The ID of the author - message_object (Message): The message (As a `Message` object) - thread: Thread that the message was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the message was sent - metadata: Extra metadata about the message - """ - log.info("{} from {} in {}".format(message_object, author_id, thread)) - def on_color_change( self, mid=None, @@ -1179,41 +1026,6 @@ class Client: """ log.info("Emoji change from {} in {}: {}".format(author_id, thread, new_emoji)) - def on_title_change( - self, - mid=None, - author_id=None, - new_title=None, - group=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody changes a thread's title. - - Args: - mid: The action ID - author_id: The ID of the person who changed the title - new_title: The new title - group: Group that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("Title change from {} in {}: {}".format(author_id, group, new_title)) - - def on_image_change( - self, mid=None, author_id=None, new_image=None, group=None, at=None - ): - """Called when the client is listening, and somebody changes a thread's image. - - Args: - mid: The action ID - author_id: The ID of the person who changed the image - new_image: The ID of the new image - group: Group that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} changed group image in {}".format(author_id, group)) - def on_nickname_change( self, mid=None, @@ -1286,78 +1098,6 @@ class Client: else: log.info("{} disabled approval mode in {}".format(author_id, group)) - def on_message_seen( - self, seen_by=None, thread=None, seen_at=None, at=None, metadata=None - ): - """Called when the client is listening, and somebody marks a message as seen. - - Args: - seen_by: The ID of the person who marked the message as seen - thread: Thread that the action was sent to. See :ref:`intro_threads` - seen_at (datetime.datetime): When the person saw the message - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("Messages seen by {} in {} at {}".format(seen_by, thread, seen_at)) - - def on_message_delivered( - self, msg_ids=None, delivered_for=None, thread=None, at=None, metadata=None, - ): - """Called when the client is listening, and somebody marks messages as delivered. - - Args: - msg_ids: The messages that are marked as delivered - delivered_for: The person that marked the messages as delivered - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info( - "Messages {} delivered to {} in {} at {}".format( - msg_ids, delivered_for, thread, at - ) - ) - - def on_marked_seen(self, threads=None, seen_at=None, at=None, metadata=None): - """Called when the client is listening, and the client has successfully marked threads as seen. - - Args: - threads: The threads that were marked - author_id: The ID of the person who changed the emoji - seen_at (datetime.datetime): When the threads were seen - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("Marked messages as seen in threads {} at {}".format(threads, seen_at)) - - def on_people_added( - self, mid=None, added_ids=None, author_id=None, group=None, at=None - ): - """Called when the client is listening, and somebody adds people to a group thread. - - Args: - mid: The action ID - added_ids: The IDs of the people who got added - author_id: The ID of the person who added the people - group: Group that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} added: {} in {}".format(author_id, ", ".join(added_ids), group)) - - def on_person_removed( - self, mid=None, removed_id=None, author_id=None, group=None, at=None - ): - """Called when the client is listening, and somebody removes a person from a group thread. - - Args: - mid: The action ID - removed_id: The ID of the person who got removed - author_id: The ID of the person who removed the person - group: Group that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} removed: {} in {}".format(author_id, removed_id, group)) - def on_friend_request(self, from_id=None): """Called when the client is listening, and somebody sends a friend request. @@ -1366,16 +1106,6 @@ class Client: """ log.info("Friend request from {}".format(from_id)) - def _on_seen(self, locations=None, at=None): - """ - Todo: - Document this, and make it public - - Args: - locations: --- - at: A timestamp of the action - """ - def on_inbox(self, unseen=None, unread=None, recent_unread=None): """ Todo: diff --git a/fbchat/_delta_class.py b/fbchat/_delta_class.py new file mode 100644 index 0000000..62cffae --- /dev/null +++ b/fbchat/_delta_class.py @@ -0,0 +1,187 @@ +import attr +import datetime +from ._event_common import attrs_event, Event, UnknownEvent, ThreadEvent +from . import _util, _user, _group, _thread, _message + +from typing import Sequence + + +@attrs_event +class PeopleAdded(ThreadEvent): + """somebody added people to a group thread.""" + + # TODO: Add message id + + thread = attr.ib(type=_group.Group) # Set the correct type + #: The people who got added + added = attr.ib(type=Sequence[_user.User]) + #: When the people were added + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + added = [ + # TODO: Parse user name + _user.User(session=session, id=x["userFbId"]) + for x in data["addedParticipants"] + ] + return cls(author=author, thread=thread, added=added, at=at) + + +@attrs_event +class PersonRemoved(ThreadEvent): + """Somebody removed a person from a group thread.""" + + # TODO: Add message id + + thread = attr.ib(type=_group.Group) # Set the correct type + #: Person who got removed + removed = attr.ib(type=_message.Message) + #: When the person were removed + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + removed = _user.User(session=session, id=data["leftParticipantFbId"]) + return cls(author=author, thread=thread, removed=removed, at=at) + + +@attrs_event +class TitleSet(ThreadEvent): + """Somebody changed a group's title.""" + + thread = attr.ib(type=_group.Group) # Set the correct type + #: The new title + title = attr.ib(type=str) + #: When the title was set + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + return cls(author=author, thread=thread, title=data["name"], at=at) + + +@attrs_event +class UnfetchedThreadEvent(Event): + """A message was received, but the data must be fetched manually. + + Use `Message.fetch` to retrieve the message data. + + This is usually used when somebody changes the group's photo, or when a new pending + group is created. + """ + + # TODO: Present this in a way that users can fetch the changed group photo easily + + #: The thread the message was sent to + thread = attr.ib(type=_thread.ThreadABC) + #: The message + message = attr.ib(type=_message.Message) + + @classmethod + def _parse(cls, session, data): + thread = ThreadEvent._get_thread(session, data) + message = _message.Message(thread=thread, id=data["messageId"]) + return cls(thread=thread, message=message) + + +@attrs_event +class MessagesDelivered(ThreadEvent): + """Somebody marked messages as delivered in a thread.""" + + #: The messages that were marked as delivered + messages = attr.ib(type=Sequence[_message.Message]) + #: When the messages were delivered + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author = _user.User(session=session, id=data["actorFbId"]) + thread = cls._get_thread(session, data) + messages = [_message.Message(thread=thread, id=x) for x in data["messageIds"]] + at = _util.millis_to_datetime(int(data["deliveredWatermarkTimestampMs"])) + return cls(author=author, thread=thread, messages=messages, at=at) + + +@attrs_event +class ThreadsRead(Event): + """Somebody marked threads as read/seen.""" + + #: The person who marked the threads as read + author = attr.ib(type=_thread.ThreadABC) + #: The threads that were marked as read + threads = attr.ib(type=Sequence[_thread.ThreadABC]) + #: When the threads were read + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse_read_receipt(cls, session, data): + author = _user.User(session=session, id=data["actorFbId"]) + thread = ThreadEvent._get_thread(session, data) + at = _util.millis_to_datetime(int(data["actionTimestampMs"])) + return cls(author=author, threads=[thread], at=at) + + @classmethod + def _parse(cls, session, data): + author = _user.User(session=session, id=session.user_id) + threads = [ + ThreadEvent._get_thread(session, {"threadKey": x}) + for x in data["threadKeys"] + ] + at = _util.millis_to_datetime(int(data["actionTimestamp"])) + return cls(author=author, threads=threads, at=at) + + +@attrs_event +class MessageEvent(ThreadEvent): + """Somebody sent a message to a thread.""" + + #: The sent message + message = attr.ib(type=_message.Message) + #: When the threads were read + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + message = _message.MessageData._from_pull( + thread, data, author=author.id, created_at=at, + ) + return cls(author=author, thread=thread, message=message, at=at) + + +def parse_delta(session, data): + class_ = data.get("class") + if class_ == "ParticipantsAddedToGroupThread": + return PeopleAdded._parse(session, data) + elif class_ == "ParticipantLeftGroupThread": + return PersonRemoved._parse(session, data) + elif class_ == "MarkFolderSeen": + # TODO: Finish this + folders = [ + _thread.ThreadLocation(folder.lstrip("FOLDER_")) + for folder in data["folders"] + ] + at = _util.millis_to_datetime(int(data["timestamp"])) + return None + elif class_ == "ThreadName": + return TitleSet._parse(session, data) + elif class_ == "ForcedFetch": + return UnfetchedThreadEvent._parse(session, data) + elif class_ == "DeliveryReceipt": + return MessagesDelivered._parse(session, data) + elif class_ == "ReadReceipt": + return ThreadsRead._parse_read_receipt(session, data) + elif class_ == "MarkRead": + return ThreadsRead._parse(session, data) + elif class_ == "NoOp": + # Skip "no operation" events + return None + elif class_ == "ClientPayload": + return X._parse(session, data) + elif class_ == "NewMessage": + return MessageEvent._parse(session, data) + return UnknownEvent(data=data) diff --git a/fbchat/_event_common.py b/fbchat/_event_common.py index 5da21c4..169fc9a 100644 --- a/fbchat/_event_common.py +++ b/fbchat/_event_common.py @@ -1,7 +1,7 @@ import attr import abc from ._core import kw_only -from . import _exception, _thread, _group, _user, _message +from . import _exception, _util, _thread, _group, _user, _message #: Default attrs settings for events attrs_event = attr.s(slots=True, kw_only=kw_only, frozen=True) @@ -48,3 +48,11 @@ class ThreadEvent(Event): elif "otherUserFbId" in key: return _user.User(session=session, id=str(key["otherUserFbId"])) raise _exception.ParseError("Could not find thread data", data=data) + + @staticmethod + def _parse_metadata(session, data): + metadata = data["messageMetadata"] + author = _user.User(session=session, id=metadata["actorFbId"]) + thread = ThreadEvent._get_thread(session, metadata) + at = _util.millis_to_datetime(int(metadata["timestamp"])) + return author, thread, at diff --git a/fbchat/_message.py b/fbchat/_message.py index ab33aa3..d08aba2 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -367,7 +367,11 @@ class MessageData(Message): ) @classmethod - def _from_pull(cls, thread, data, mid, tags, author, created_at): + def _from_pull(cls, thread, data, author, created_at): + metadata = data["messageMetadata"] + + tags = metadata.get("tags") + mentions = [] if data.get("data") and data["data"].get("prng"): try: @@ -415,7 +419,7 @@ class MessageData(Message): return cls( thread=thread, - id=mid, + id=metadata["messageId"], author=author, created_at=created_at, text=data.get("body"), diff --git a/tests/conftest.py b/tests/conftest.py index ad71946..a327ac8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,14 @@ from fbchat import Message, Mention @pytest.fixture(scope="session") def session(): - return object() # TODO: Add a mocked session + class FakeSession: + # TODO: Add a further mocked session + user_id = "31415926536" + + def __repr__(self): + return "" + + return FakeSession() @pytest.fixture(scope="session") diff --git a/tests/test_delta_class.py b/tests/test_delta_class.py new file mode 100644 index 0000000..a15432f --- /dev/null +++ b/tests/test_delta_class.py @@ -0,0 +1,275 @@ +import datetime +import pytest +from fbchat import ( + ParseError, + User, + Group, + UnknownEvent, + PeopleAdded, + PersonRemoved, + TitleSet, + UnfetchedThreadEvent, + MessagesDelivered, + ThreadsRead, + MessageEvent, +) +from fbchat._message import Message, MessageData +from fbchat._delta_class import parse_delta + + +def test_people_added(session): + data = { + "addedParticipants": [ + { + "fanoutPolicy": "IRIS_MESSAGE_QUEUE", + "firstName": "Abc", + "fullName": "Abc Def", + "initialFolder": "FOLDER_INBOX", + "initialFolderId": {"systemFolderId": "INBOX"}, + "isMessengerUser": False, + "userFbId": "1234", + } + ], + "irisSeqId": "11223344", + "irisTags": ["DeltaParticipantsAddedToGroupThread", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "3456", + "adminText": "You added Abc Def to the group.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "1122334455", + "skipBumpThread": False, + "tags": [], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456", "4567"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "class": "ParticipantsAddedToGroupThread", + } + assert PeopleAdded( + author=User(session=session, id="3456"), + thread=Group(session=session, id="4321"), + added=[User(session=session, id="1234")], + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_person_removed(session): + data = { + "irisSeqId": "11223344", + "irisTags": ["DeltaParticipantLeftGroupThread", "is_from_iris_fanout"], + "leftParticipantFbId": "1234", + "messageMetadata": { + "actorFbId": "3456", + "adminText": "You removed Abc Def from the group.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "1122334455", + "skipBumpThread": True, + "tags": [], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456", "4567"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "class": "ParticipantLeftGroupThread", + } + assert PersonRemoved( + author=User(session=session, id="3456"), + thread=Group(session=session, id="4321"), + removed=User(session=session, id="1234"), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_title_set(session): + data = { + "irisSeqId": "11223344", + "irisTags": ["DeltaThreadName", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "3456", + "adminText": "You named the group abc.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "1122334455", + "skipBumpThread": False, + "tags": [], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "name": "abc", + "participants": ["1234", "2345", "3456", "4567"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "class": "ThreadName", + } + assert TitleSet( + author=User(session=session, id="3456"), + thread=Group(session=session, id="4321"), + title="abc", + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_forced_fetch(session): + data = { + "forceInsert": False, + "messageId": "mid.$XYZ", + "threadKey": {"threadFbId": "1234"}, + "class": "ForcedFetch", + } + thread = Group(session=session, id="1234") + assert UnfetchedThreadEvent( + thread=thread, message=Message(thread=thread, id="mid.$XYZ") + ) == parse_delta(session, data) + + +def test_delivery_receipt(session): + data = { + "actorFbId": "1234", + "deliveredWatermarkTimestampMs": "1500000000000", + "irisSeqId": "1111111", + "irisTags": ["DeltaDeliveryReceipt"], + "messageIds": ["mid.$XYZ", "mid.$ABC"], + "requestContext": {"apiArgs": {}}, + "threadKey": {"threadFbId": "4321"}, + "class": "DeliveryReceipt", + } + thread = Group(session=session, id="4321") + assert MessagesDelivered( + author=User(session=session, id="1234"), + thread=thread, + messages=[ + Message(thread=thread, id="mid.$XYZ"), + Message(thread=thread, id="mid.$ABC"), + ], + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_read_receipt(session): + data = { + "actionTimestampMs": "1600000000000", + "actorFbId": "1234", + "irisSeqId": "1111111", + "irisTags": ["DeltaReadReceipt", "is_from_iris_fanout"], + "requestContext": {"apiArgs": {}}, + "threadKey": {"threadFbId": "4321"}, + "tqSeqId": "1111", + "watermarkTimestampMs": "1500000000000", + "class": "ReadReceipt", + } + assert ThreadsRead( + author=User(session=session, id="1234"), + threads=[Group(session=session, id="4321")], + at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_mark_read(session): + data = { + "actionTimestamp": "1600000000000", + "irisSeqId": "1111111", + "irisTags": ["DeltaMarkRead", "is_from_iris_fanout"], + "threadKeys": [{"threadFbId": "1234"}, {"otherUserFbId": "2345"}], + "tqSeqId": "1111", + "watermarkTimestamp": "1500000000000", + "class": "MarkRead", + } + assert ThreadsRead( + author=User(session=session, id=session.user_id), + threads=[Group(session=session, id="1234"), User(session=session, id="2345")], + at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_new_message_user(session): + data = { + "attachments": [], + "body": "test", + "irisSeqId": "1111111", + "irisTags": ["DeltaNewMessage"], + "messageMetadata": { + "actorFbId": "1234", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "skipSnippetUpdate": False, + "tags": ["source:messenger:web"], + "threadKey": {"otherUserFbId": "1234"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1600000000000", + }, + "requestContext": {"apiArgs": {}}, + "class": "NewMessage", + } + assert MessageEvent( + author=User(session=session, id="1234"), + thread=User(session=session, id="1234"), + message=MessageData( + thread=User(session=session, id="1234"), + id="mid.$XYZ", + author="1234", + text="test", + created_at=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + ), + at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_new_message_group(session): + data = { + "attachments": [], + "body": "test", + "irisSeqId": "1111111", + "irisTags": ["DeltaNewMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "4321", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:messenger:web"], + "threadKey": {"threadFbId": "1234"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1600000000000", + }, + "participants": ["4321", "5432", "6543"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "class": "NewMessage", + } + assert MessageEvent( + author=User(session=session, id="4321"), + thread=Group(session=session, id="1234"), + message=MessageData( + thread=Group(session=session, id="1234"), + id="mid.$XYZ", + author="4321", + text="test", + created_at=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + ), + at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_noop(session): + assert parse_delta(session, {"class": "NoOp"}) is None + + +def test_parse_delta_unknown(session): + assert UnknownEvent(data={"abc": 10}) == parse_delta(session, {"abc": 10}) diff --git a/tests/test_event_common.py b/tests/test_event_common.py index 2b462f9..74f73eb 100644 --- a/tests/test_event_common.py +++ b/tests/test_event_common.py @@ -1,4 +1,5 @@ import pytest +import datetime from fbchat import Group, User, ParseError, ThreadEvent @@ -55,3 +56,23 @@ def test_thread_event_get_thread_unknown(session): data = {"threadKey": {"abc": "1234"}} with pytest.raises(ParseError, match="Could not find thread data"): ThreadEvent._get_thread(session, data) + + +def test_thread_event_parse_metadata(session): + data = { + "actorFbId": "4321", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "112233445566", + "skipBumpThread": False, + "skipSnippetUpdate": False, + "tags": ["source:messenger:web"], + "threadKey": {"otherUserFbId": "1234"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + } + assert ( + User(session=session, id="4321"), + User(session=session, id="1234"), + datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == ThreadEvent._parse_metadata(session, {"messageMetadata": data}) From 0a6bf221e617b544fb50758d15d5e43b20c54a84 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Mon, 20 Jan 2020 16:56:35 +0100 Subject: [PATCH 4/7] Move /t_ms delta admin text type parsing to separate file and add tests --- fbchat/__init__.py | 18 + fbchat/_client.py | 579 +----------------------- fbchat/_delta_type.py | 329 ++++++++++++++ tests/test_delta_type.py | 954 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 1306 insertions(+), 574 deletions(-) create mode 100644 fbchat/_delta_type.py create mode 100644 tests/test_delta_type.py diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 4e67a7f..274c228 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -59,6 +59,24 @@ from ._delta_class import ( ThreadsRead, MessageEvent, ) +from ._delta_type import ( + ColorSet, + EmojiSet, + NicknameSet, + AdminsAdded, + AdminsRemoved, + ApprovalModeSet, + CallStarted, + CallEnded, + CallJoined, + PollCreated, + PollVoted, + PlanCreated, + PlanEnded, + PlanEdited, + PlanDeleted, + PlanResponded, +) from ._client import Client diff --git a/fbchat/_client.py b/fbchat/_client.py index 4e33a71..42b1c93 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -16,6 +16,7 @@ from . import ( _event_common, _client_payload, _delta_class, + _delta_type, ) from ._thread import ThreadLocation @@ -609,244 +610,8 @@ class Client: """ def _parse_delta(self, delta): - def get_thread(data): - if "threadFbId" in data["threadKey"]: - group_id = str(data["threadKey"]["threadFbId"]) - return Group(session=self.session, id=group_id) - elif "otherUserFbId" in data["threadKey"]: - user_id = str(data["threadKey"]["otherUserFbId"]) - return User(session=self.session, id=user_id) - return None - - delta_type = delta.get("type") - metadata = delta.get("messageMetadata") - - if metadata: - mid = metadata["messageId"] - author_id = str(metadata["actorFbId"]) - at = _util.millis_to_datetime(int(metadata.get("timestamp"))) - - # Color change - if delta_type == "change_thread_theme": - thread = get_thread(metadata) - self.on_color_change( - mid=mid, - author_id=author_id, - new_color=_thread.ThreadABC._parse_color( - delta["untypedData"]["theme_color"] - ), - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Emoji change - elif delta_type == "change_thread_icon": - new_emoji = delta["untypedData"]["thread_icon"] - self.on_emoji_change( - mid=mid, - author_id=author_id, - new_emoji=new_emoji, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Nickname change - elif delta_type == "change_thread_nickname": - changed_for = str(delta["untypedData"]["participant_id"]) - new_nickname = delta["untypedData"]["nickname"] - self.on_nickname_change( - mid=mid, - author_id=author_id, - changed_for=changed_for, - new_nickname=new_nickname, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Admin added or removed in a group thread - elif delta_type == "change_thread_admins": - target_id = delta["untypedData"]["TARGET_ID"] - admin_event = delta["untypedData"]["ADMIN_EVENT"] - if admin_event == "add_admin": - self.on_admin_added( - mid=mid, - added_id=target_id, - author_id=author_id, - group=get_thread(metadata), - at=at, - ) - elif admin_event == "remove_admin": - self.on_admin_removed( - mid=mid, - removed_id=target_id, - author_id=author_id, - group=get_thread(metadata), - at=at, - ) - - # Group approval mode change - elif delta_type == "change_thread_approval_mode": - approval_mode = bool(int(delta["untypedData"]["APPROVAL_MODE"])) - self.on_approval_mode_change( - mid=mid, - approval_mode=approval_mode, - author_id=author_id, - group=get_thread(metadata), - at=at, - ) - - # Game played - elif delta_type == "instant_game_update": - game_id = delta["untypedData"]["game_id"] - game_name = delta["untypedData"]["game_name"] - score = delta["untypedData"].get("score") - if score is not None: - score = int(score) - leaderboard = delta["untypedData"].get("leaderboard") - if leaderboard is not None: - leaderboard = _util.parse_json(leaderboard)["scores"] - self.on_game_played( - mid=mid, - author_id=author_id, - game_id=game_id, - game_name=game_name, - score=score, - leaderboard=leaderboard, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Group call started/ended - elif delta_type == "rtc_call_log": - call_status = delta["untypedData"]["event"] - call_duration = _util.seconds_to_timedelta( - int(delta["untypedData"]["call_duration"]) - ) - is_video_call = bool(int(delta["untypedData"]["is_video_call"])) - if call_status == "call_started": - self.on_call_started( - mid=mid, - caller_id=author_id, - is_video_call=is_video_call, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - elif call_status == "call_ended": - self.on_call_ended( - mid=mid, - caller_id=author_id, - is_video_call=is_video_call, - call_duration=call_duration, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # User joined to group call - elif delta_type == "participant_joined_group_call": - is_video_call = bool(int(delta["untypedData"]["group_call_type"])) - self.on_user_joined_call( - mid=mid, - joined_id=author_id, - is_video_call=is_video_call, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Group poll event - elif delta_type == "group_poll": - event_type = delta["untypedData"]["event_type"] - poll_json = _util.parse_json(delta["untypedData"]["question_json"]) - poll = _poll.Poll._from_graphql(self.session, poll_json) - if event_type == "question_creation": - # User created group poll - self.on_poll_created( - mid=mid, - poll=poll, - author_id=author_id, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - elif event_type == "update_vote": - # User voted on group poll - added = _util.parse_json(delta["untypedData"]["added_option_ids"]) - removed = _util.parse_json(delta["untypedData"]["removed_option_ids"]) - self.on_poll_voted( - mid=mid, - poll=poll, - added_options=added, - removed_options=removed, - author_id=author_id, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Plan created - elif delta_type == "lightweight_event_create": - self.on_plan_created( - mid=mid, - plan=PlanData._from_pull(self.session, delta["untypedData"]), - author_id=author_id, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Plan ended - elif delta_type == "lightweight_event_notify": - self.on_plan_ended( - mid=mid, - plan=PlanData._from_pull(self.session, delta["untypedData"]), - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Plan edited - elif delta_type == "lightweight_event_update": - self.on_plan_edited( - mid=mid, - plan=PlanData._from_pull(self.session, delta["untypedData"]), - author_id=author_id, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Plan deleted - elif delta_type == "lightweight_event_delete": - self.on_plan_deleted( - mid=mid, - plan=PlanData._from_pull(self.session, delta["untypedData"]), - author_id=author_id, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - - # Plan participation change - elif delta_type == "lightweight_event_rsvp": - take_part = delta["untypedData"]["guest_status"] == "GOING" - self.on_plan_participation( - mid=mid, - plan=PlanData._from_pull(self.session, delta["untypedData"]), - take_part=take_part, - author_id=author_id, - thread=get_thread(metadata), - at=at, - metadata=metadata, - ) - # Client payload (that weird numbers) - elif delta.get("class") == "ClientPayload": + if delta.get("class") == "ClientPayload": for event in _client_payload.parse_client_payloads(self.session, delta): self.on_event(event) @@ -855,6 +620,9 @@ class Client: if event: self.on_event(event) + elif delta.get("type"): + self.on_event(_delta_type.parse_delta(self.session, delta)) + # Unknown message type else: self.on_unknown_messsage_type(msg=delta) @@ -984,120 +752,6 @@ class Client: """Called when the client is listening, and an event happens.""" log.info("Got event: %s", event) - def on_color_change( - self, - mid=None, - author_id=None, - new_color=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody changes a thread's color. - - Args: - mid: The action ID - author_id: The ID of the person who changed the color - new_color: The new color. Not limited to the ones in `ThreadABC.set_color` - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("Color change from {} in {}: {}".format(author_id, thread, new_color)) - - def on_emoji_change( - self, - mid=None, - author_id=None, - new_emoji=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody changes a thread's emoji. - - Args: - mid: The action ID - author_id: The ID of the person who changed the emoji - new_emoji: The new emoji - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("Emoji change from {} in {}: {}".format(author_id, thread, new_emoji)) - - def on_nickname_change( - self, - mid=None, - author_id=None, - changed_for=None, - new_nickname=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody changes a nickname. - - Args: - mid: The action ID - author_id: The ID of the person who changed the nickname - changed_for: The ID of the person whom got their nickname changed - new_nickname: The new nickname - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info( - "Nickname change from {} in {} for {}: {}".format( - author_id, thread, changed_for, new_nickname - ) - ) - - def on_admin_added( - self, mid=None, added_id=None, author_id=None, group=None, at=None - ): - """Called when the client is listening, and somebody adds an admin to a group. - - Args: - mid: The action ID - added_id: The ID of the admin who got added - author_id: The ID of the person who added the admins - group: Group that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} added admin: {} in {}".format(author_id, added_id, group)) - - def on_admin_removed( - self, mid=None, removed_id=None, author_id=None, group=None, at=None - ): - """Called when the client is listening, and somebody is removed as an admin in a group. - - Args: - mid: The action ID - removed_id: The ID of the admin who got removed - author_id: The ID of the person who removed the admins - group: Group that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} removed admin: {} in {}".format(author_id, removed_id, group)) - - def on_approval_mode_change( - self, mid=None, approval_mode=None, author_id=None, group=None, at=None, - ): - """Called when the client is listening, and somebody changes approval mode in a group. - - Args: - mid: The action ID - approval_mode: True if approval mode is activated - author_id: The ID of the person who changed approval mode - group: Group that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - if approval_mode: - log.info("{} activated approval mode in {}".format(author_id, group)) - else: - log.info("{} disabled approval mode in {}".format(author_id, group)) - def on_friend_request(self, from_id=None): """Called when the client is listening, and somebody sends a friend request. @@ -1128,229 +782,6 @@ class Client: """ pass - def on_game_played( - self, - mid=None, - author_id=None, - game_id=None, - game_name=None, - score=None, - leaderboard=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody plays a game. - - Args: - mid: The action ID - author_id: The ID of the person who played the game - game_id: The ID of the game - game_name: Name of the game - score: Score obtained in the game - leaderboard: Actual leader board of the game in the thread - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info('{} played "{}" in {}'.format(author_id, game_name, thread)) - - def on_call_started( - self, - mid=None, - caller_id=None, - is_video_call=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody starts a call in a group. - - Todo: - Make this work with private calls. - - Args: - mid: The action ID - caller_id: The ID of the person who started the call - is_video_call: True if it's video call - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} started call in {}".format(caller_id, thread)) - - def on_call_ended( - self, - mid=None, - caller_id=None, - is_video_call=None, - call_duration=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody ends a call in a group. - - Todo: - Make this work with private calls. - - Args: - mid: The action ID - caller_id: The ID of the person who ended the call - is_video_call: True if it was video call - call_duration (datetime.timedelta): Call duration - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} ended call in {}".format(caller_id, thread)) - - def on_user_joined_call( - self, - mid=None, - joined_id=None, - is_video_call=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody joins a group call. - - Args: - mid: The action ID - joined_id: The ID of the person who joined the call - is_video_call: True if it's video call - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} joined call in {}".format(joined_id, thread)) - - def on_poll_created( - self, mid=None, poll=None, author_id=None, thread=None, at=None, metadata=None, - ): - """Called when the client is listening, and somebody creates a group poll. - - Args: - mid: The action ID - poll (Poll): Created poll - author_id: The ID of the person who created the poll - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} created poll {} in {}".format(author_id, poll, thread)) - - def on_poll_voted( - self, - mid=None, - poll=None, - added_options=None, - removed_options=None, - author_id=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody votes in a group poll. - - Args: - mid: The action ID - poll (Poll): Poll, that user voted in - author_id: The ID of the person who voted in the poll - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} voted in poll {} in {}".format(author_id, poll, thread)) - - def on_plan_created( - self, mid=None, plan=None, author_id=None, thread=None, at=None, metadata=None, - ): - """Called when the client is listening, and somebody creates a plan. - - Args: - mid: The action ID - plan (Plan): Created plan - author_id: The ID of the person who created the plan - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} created plan {} in {}".format(author_id, plan, thread)) - - def on_plan_ended(self, mid=None, plan=None, thread=None, at=None, metadata=None): - """Called when the client is listening, and a plan ends. - - Args: - mid: The action ID - plan (Plan): Ended plan - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("Plan {} has ended in {}".format(plan, thread)) - - def on_plan_edited( - self, mid=None, plan=None, author_id=None, thread=None, at=None, metadata=None, - ): - """Called when the client is listening, and somebody edits a plan. - - Args: - mid: The action ID - plan (Plan): Edited plan - author_id: The ID of the person who edited the plan - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} edited plan {} in {}".format(author_id, plan, thread)) - - def on_plan_deleted( - self, mid=None, plan=None, author_id=None, thread=None, at=None, metadata=None, - ): - """Called when the client is listening, and somebody deletes a plan. - - Args: - mid: The action ID - plan (Plan): Deleted plan - author_id: The ID of the person who deleted the plan - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - log.info("{} deleted plan {} in {}".format(author_id, plan, thread)) - - def on_plan_participation( - self, - mid=None, - plan=None, - take_part=None, - author_id=None, - thread=None, - at=None, - metadata=None, - ): - """Called when the client is listening, and somebody takes part in a plan or not. - - Args: - mid: The action ID - plan (Plan): Plan - take_part (bool): Whether the person takes part in the plan or not - author_id: The ID of the person who will participate in the plan or not - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - metadata: Extra metadata about the action - """ - if take_part: - log.info( - "{} will take part in {} in {} ({})".format(author_id, plan, thread) - ) - else: - log.info( - "{} won't take part in {} in {} ({})".format(author_id, plan, thread) - ) - def on_chat_timestamp(self, buddylist=None): """Called when the client receives chat online presence update. diff --git a/fbchat/_delta_type.py b/fbchat/_delta_type.py new file mode 100644 index 0000000..127c976 --- /dev/null +++ b/fbchat/_delta_type.py @@ -0,0 +1,329 @@ +import attr +import datetime +from ._event_common import attrs_event, Event, UnknownEvent, ThreadEvent +from . import _util, _user, _thread, _poll, _plan + +from typing import Sequence, Optional + + +@attrs_event +class ColorSet(ThreadEvent): + """Somebody set the color in a thread.""" + + #: The new color. Not limited to the ones in `ThreadABC.set_color` + color = attr.ib(type=str) + #: When the color was set + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + color = _thread.ThreadABC._parse_color(data["untypedData"]["theme_color"]) + return cls(author=author, thread=thread, color=color, at=at) + + +@attrs_event +class EmojiSet(ThreadEvent): + """Somebody set the emoji in a thread.""" + + #: The new emoji + emoji = attr.ib(type=str) + #: When the emoji was set + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + emoji = data["untypedData"]["thread_icon"] + return cls(author=author, thread=thread, emoji=emoji, at=at) + + +@attrs_event +class NicknameSet(ThreadEvent): + """Somebody set the nickname of a person in a thread.""" + + #: The person whose nickname was set + subject = attr.ib(type=str) + #: The new nickname. If ``None``, the nickname was cleared + nickname = attr.ib(type=Optional[str]) + #: When the nickname was set + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + subject = _user.User(session=session, id=data["untypedData"]["participant_id"]) + nickname = data["untypedData"]["nickname"] or None # None if "" + return cls( + author=author, thread=thread, subject=subject, nickname=nickname, at=at + ) + + +@attrs_event +class AdminsAdded(ThreadEvent): + """Somebody added admins to a group.""" + + #: The people that were set as admins + added = attr.ib(type=Sequence[_user.User]) + #: When the admins were added + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + subject = _user.User(session=session, id=data["untypedData"]["TARGET_ID"]) + return cls(author=author, thread=thread, added=[subject], at=at) + + +@attrs_event +class AdminsRemoved(ThreadEvent): + """Somebody removed admins from a group.""" + + #: The people that were removed as admins + removed = attr.ib(type=Sequence[_user.User]) + #: When the admins were removed + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + subject = _user.User(session=session, id=data["untypedData"]["TARGET_ID"]) + return cls(author=author, thread=thread, removed=[subject], at=at) + + +@attrs_event +class ApprovalModeSet(ThreadEvent): + """Somebody changed the approval mode in a group.""" + + require_admin_approval = attr.ib(type=bool) + #: When the approval mode was set + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + raa = data["untypedData"]["APPROVAL_MODE"] == "1" + return cls(author=author, thread=thread, require_admin_approval=raa, at=at) + + +@attrs_event +class CallStarted(ThreadEvent): + """Somebody started a call.""" + + #: When the call was started + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + return cls(author=author, thread=thread, at=at) + + +@attrs_event +class CallEnded(ThreadEvent): + """Somebody ended a call.""" + + #: How long the call took + duration = attr.ib(type=datetime.timedelta) + #: When the call ended + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + duration = _util.seconds_to_timedelta(int(data["untypedData"]["call_duration"])) + return cls(author=author, thread=thread, duration=duration, at=at) + + +@attrs_event +class CallJoined(ThreadEvent): + """Somebody joined a call.""" + + #: When the call ended + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + return cls(author=author, thread=thread, at=at) + + +@attrs_event +class PollCreated(ThreadEvent): + """Somebody created a group poll.""" + + #: The new poll + poll = attr.ib(type=_poll.Poll) + #: When the poll was created + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + poll_data = _util.parse_json(data["untypedData"]["question_json"]) + poll = _poll.Poll._from_graphql(session, poll_data) + return cls(author=author, thread=thread, poll=poll, at=at) + + +@attrs_event +class PollVoted(ThreadEvent): + """Somebody voted in a group poll.""" + + #: The updated poll + poll = attr.ib(type=_poll.Poll) + #: Ids of the voted options + added_ids = attr.ib(type=Sequence[str]) + #: Ids of the un-voted options + removed_ids = attr.ib(type=Sequence[str]) + #: When the poll was voted in + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + poll_data = _util.parse_json(data["untypedData"]["question_json"]) + poll = _poll.Poll._from_graphql(session, poll_data) + added_ids = _util.parse_json(data["untypedData"]["added_option_ids"]) + removed_ids = _util.parse_json(data["untypedData"]["removed_option_ids"]) + return cls( + author=author, + thread=thread, + poll=poll, + added_ids=[str(x) for x in added_ids], + removed_ids=[str(x) for x in removed_ids], + at=at, + ) + + +@attrs_event +class PlanCreated(ThreadEvent): + """Somebody created a plan in a group.""" + + #: The new plan + plan = attr.ib(type=_plan.PlanData) + #: When the plan was created + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + plan = _plan.PlanData._from_pull(session, data["untypedData"]) + return cls(author=author, thread=thread, plan=plan, at=at) + + +@attrs_event +class PlanEnded(ThreadEvent): + """A plan ended.""" + + #: The ended plan + plan = attr.ib(type=_plan.PlanData) + #: When the plan ended + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + plan = _plan.PlanData._from_pull(session, data["untypedData"]) + return cls(author=author, thread=thread, plan=plan, at=at) + + +@attrs_event +class PlanEdited(ThreadEvent): + """Somebody changed a plan in a group.""" + + #: The updated plan + plan = attr.ib(type=_plan.PlanData) + #: When the plan was updated + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + plan = _plan.PlanData._from_pull(session, data["untypedData"]) + return cls(author=author, thread=thread, plan=plan, at=at) + + +@attrs_event +class PlanDeleted(ThreadEvent): + """Somebody removed a plan in a group.""" + + #: The removed plan + plan = attr.ib(type=_plan.PlanData) + #: When the plan was removed + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + plan = _plan.PlanData._from_pull(session, data["untypedData"]) + return cls(author=author, thread=thread, plan=plan, at=at) + + +@attrs_event +class PlanResponded(ThreadEvent): + """Somebody responded to a plan in a group.""" + + #: The plan that was responded to + plan = attr.ib(type=_plan.PlanData) + #: Whether the author will go to the plan or not + take_part = attr.ib(type=bool) + #: When the plan was removed + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + author, thread, at = cls._parse_metadata(session, data) + plan = _plan.PlanData._from_pull(session, data["untypedData"]) + take_part = data["untypedData"]["guest_status"] == "GOING" + return cls(author=author, thread=thread, plan=plan, take_part=take_part, at=at) + + +def parse_delta(session, data): + type_ = data.get("type") + if type_ == "change_thread_theme": + return ColorSet._parse(session, data) + elif type_ == "change_thread_icon": + return EmojiSet._parse(session, data) + elif type_ == "change_thread_nickname": + return NicknameSet._parse(session, data) + elif type_ == "change_thread_admins": + event_type = data["untypedData"]["ADMIN_EVENT"] + if event_type == "add_admin": + return AdminsAdded._parse(session, data) + elif event_type == "remove_admin": + return AdminsRemoved._parse(session, data) + else: + pass + elif type_ == "change_thread_approval_mode": + return ApprovalModeSet._parse(session, data) + elif type_ == "instant_game_update": + pass # TODO: This + elif type_ == "messenger_call_log": # Previously "rtc_call_log" + event_type = data["untypedData"]["event"] + if event_type == "group_call_started": + return CallStarted._parse(session, data) + elif event_type in ["group_call_ended", "one_on_one_call_ended"]: + return CallEnded._parse(session, data) + else: + pass + elif type_ == "participant_joined_group_call": + return CallJoined._parse(session, data) + elif type_ == "group_poll": + event_type = data["untypedData"]["event_type"] + if event_type == "question_creation": + return PollCreated._parse(session, data) + elif event_type == "update_vote": + return PollVoted._parse(session, data) + else: + pass + elif type_ == "lightweight_event_create": + return PlanCreated._parse(session, data) + elif type_ == "lightweight_event_notify": + return PlanEnded._parse(session, data) + elif type_ == "lightweight_event_update": + return PlanEdited._parse(session, data) + elif type_ == "lightweight_event_delete": + return PlanDeleted._parse(session, data) + elif type_ == "lightweight_event_rsvp": + return PlanResponded._parse(session, data) + return UnknownEvent(data=data) diff --git a/tests/test_delta_type.py b/tests/test_delta_type.py new file mode 100644 index 0000000..151b5e2 --- /dev/null +++ b/tests/test_delta_type.py @@ -0,0 +1,954 @@ +import datetime +import pytest +from fbchat import ( + _util, + ParseError, + User, + Group, + Poll, + PollOption, + PlanData, + GuestStatus, + UnknownEvent, + ColorSet, + EmojiSet, + NicknameSet, + AdminsAdded, + AdminsRemoved, + ApprovalModeSet, + CallStarted, + CallEnded, + CallJoined, + PollCreated, + PollVoted, + PlanCreated, + PlanEnded, + PlanEdited, + PlanDeleted, + PlanResponded, +) +from fbchat._message import Message, MessageData +from fbchat._delta_type import parse_delta + + +def test_color_set(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You changed the chat theme to Orange.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "change_thread_theme", + "untypedData": { + "should_show_icon": "1", + "theme_color": "FFFF7E29", + "accessibility_label": "Orange", + }, + "class": "AdminTextMessage", + } + assert ColorSet( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + color="#ff7e29", + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_emoji_set(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You set the emoji to 🌟.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "skipSnippetUpdate": False, + "tags": ["source:generic_admin_text"], + "threadKey": {"otherUserFbId": "1234"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "requestContext": {"apiArgs": {}}, + "type": "change_thread_icon", + "untypedData": { + "thread_icon_url": "https://www.facebook.com/images/emoji.php/v9/te0/1/16/1f31f.png", + "thread_icon": "🌟", + }, + "class": "AdminTextMessage", + } + assert EmojiSet( + author=User(session=session, id="1234"), + thread=User(session=session, id="1234"), + emoji="🌟", + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_nickname_set(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You set the nickname for Abc Def to abc.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "change_thread_nickname", + "untypedData": {"nickname": "abc", "participant_id": "2345"}, + "class": "AdminTextMessage", + } + assert NicknameSet( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + subject=User(session=session, id="2345"), + nickname="abc", + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_nickname_clear(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You cleared your nickname.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "skipSnippetUpdate": False, + "tags": ["source:generic_admin_text"], + "threadKey": {"otherUserFbId": "1234"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "requestContext": {"apiArgs": {}}, + "type": "change_thread_nickname", + "untypedData": {"nickname": "", "participant_id": "1234"}, + "class": "AdminTextMessage", + } + assert NicknameSet( + author=User(session=session, id="1234"), + thread=User(session=session, id="1234"), + subject=User(session=session, id="1234"), + nickname=None, + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_admins_added(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You added Abc Def as a group admin.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": True, + "tags": ["source:titan:web"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "change_thread_admins", + "untypedData": { + "THREAD_CATEGORY": "GROUP", + "TARGET_ID": "2345", + "ADMIN_TYPE": "0", + "ADMIN_EVENT": "add_admin", + }, + "class": "AdminTextMessage", + } + assert AdminsAdded( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + added=[User(session=session, id="2345")], + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_admins_removed(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You removed yourself as a group admin.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": True, + "tags": ["source:titan:web"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "change_thread_admins", + "untypedData": { + "THREAD_CATEGORY": "GROUP", + "TARGET_ID": "1234", + "ADMIN_TYPE": "0", + "ADMIN_EVENT": "remove_admin", + }, + "class": "AdminTextMessage", + } + assert AdminsRemoved( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + removed=[User(session=session, id="1234")], + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_approvalmode_set(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You turned on member approval and will review requests to join the group.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": True, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "change_thread_approval_mode", + "untypedData": {"APPROVAL_MODE": "1", "THREAD_CATEGORY": "GROUP"}, + "class": "AdminTextMessage", + } + assert ApprovalModeSet( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + require_admin_approval=True, + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_approvalmode_unset(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You turned off member approval. Anyone with the link can join the group.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": True, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "change_thread_approval_mode", + "untypedData": {"APPROVAL_MODE": "0", "THREAD_CATEGORY": "GROUP"}, + "class": "AdminTextMessage", + } + assert ApprovalModeSet( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + require_admin_approval=False, + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_call_started(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You started a call.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "messenger_call_log", + "untypedData": { + "call_capture_attachments": "", + "caller_id": "1234", + "conference_name": "MESSENGER:134845267536444", + "rating": "", + "messenger_call_instance_id": "0", + "video": "", + "event": "group_call_started", + "server_info": "XYZ123ABC", + "call_duration": "0", + "callee_id": "0", + }, + "class": "AdminTextMessage", + } + data2 = { + "callState": "AUDIO_GROUP_CALL", + "messageMetadata": { + "actorFbId": "1234", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": [], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + }, + "serverInfoData": "XYZ123ABC", + "class": "RtcCallData", + } + assert CallStarted( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_group_call_ended(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "The call ended.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "messenger_call_log", + "untypedData": { + "call_capture_attachments": "", + "caller_id": "1234", + "conference_name": "MESSENGER:1234567890", + "rating": "0", + "messenger_call_instance_id": "1234567890", + "video": "", + "event": "group_call_ended", + "server_info": "XYZ123ABC", + "call_duration": "31", + "callee_id": "0", + }, + "class": "AdminTextMessage", + } + data2 = { + "callState": "NO_ONGOING_CALL", + "messageMetadata": { + "actorFbId": "1234", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": [], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + }, + "class": "RtcCallData", + } + assert CallEnded( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + duration=datetime.timedelta(seconds=31), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_user_call_ended(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "Abc called you.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "skipSnippetUpdate": False, + "tags": ["source:generic_admin_text", "no_push"], + "threadKey": {"otherUserFbId": "1234"}, + "threadReadStateEffect": "KEEP_AS_IS", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "requestContext": {"apiArgs": {}}, + "type": "messenger_call_log", + "untypedData": { + "call_capture_attachments": "", + "caller_id": "1234", + "conference_name": "MESSENGER:1234567890", + "rating": "0", + "messenger_call_instance_id": "1234567890", + "video": "", + "event": "one_on_one_call_ended", + "server_info": "", + "call_duration": "3", + "callee_id": "100002950119740", + }, + "class": "AdminTextMessage", + } + assert CallEnded( + author=User(session=session, id="1234"), + thread=User(session=session, id="1234"), + duration=datetime.timedelta(seconds=3), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_call_joined(session): + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "Abc joined the call.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "participant_joined_group_call", + "untypedData": { + "server_info_data": "XYZ123ABC", + "group_call_type": "0", + "joining_user": "2345", + }, + "class": "AdminTextMessage", + } + assert CallJoined( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_poll_created(session): + poll_data = { + "id": "112233", + "text": "A poll", + "total_count": 2, + "viewer_has_voted": "true", + "options": [ + { + "id": "1001", + "text": "Option A", + "total_count": 1, + "viewer_has_voted": "true", + "voters": ["1234"], + }, + { + "id": "1002", + "text": "Option B", + "total_count": 0, + "viewer_has_voted": "false", + "voters": [], + }, + ], + } + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You created a poll: A poll.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "group_poll", + "untypedData": { + "added_option_ids": "[]", + "removed_option_ids": "[]", + "question_json": _util.json_minimal(poll_data), + "event_type": "question_creation", + "question_id": "112233", + }, + "class": "AdminTextMessage", + } + assert PollCreated( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + poll=Poll( + session=session, + id="112233", + question="A poll", + options=[ + PollOption( + id="1001", + text="Option A", + vote=True, + voters=["1234"], + votes_count=1, + ), + PollOption( + id="1002", text="Option B", vote=False, voters=[], votes_count=0 + ), + ], + options_count=2, + ), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_poll_answered(session): + poll_data = { + "id": "112233", + "text": "A poll", + "total_count": 3, + "viewer_has_voted": "true", + "options": [ + { + "id": "1002", + "text": "Option B", + "total_count": 2, + "viewer_has_voted": "true", + "voters": ["1234", "2345"], + }, + { + "id": "1003", + "text": "Option C", + "total_count": 1, + "viewer_has_voted": "true", + "voters": ["1234"], + }, + { + "id": "1001", + "text": "Option A", + "total_count": 0, + "viewer_has_voted": "false", + "voters": [], + }, + ], + } + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": 'You changed your vote to "Option B" and 1 other option in the poll: A poll.', + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "group_poll", + "untypedData": { + "added_option_ids": "[1002,1003]", + "removed_option_ids": "[1001]", + "question_json": _util.json_minimal(poll_data), + "event_type": "update_vote", + "question_id": "112233", + }, + "class": "AdminTextMessage", + } + assert PollVoted( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + poll=Poll( + session=session, + id="112233", + question="A poll", + options=[ + PollOption( + id="1002", + text="Option B", + vote=True, + voters=["1234", "2345"], + votes_count=2, + ), + PollOption( + id="1003", + text="Option C", + vote=True, + voters=["1234"], + votes_count=1, + ), + PollOption( + id="1001", text="Option A", vote=False, voters=[], votes_count=0 + ), + ], + options_count=3, + ), + added_ids=["1002", "1003"], + removed_ids=["1001"], + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_plan_created(session): + guest_list = [ + {"guest_list_state": "INVITED", "node": {"id": "3456"}}, + {"guest_list_state": "INVITED", "node": {"id": "2345"}}, + {"guest_list_state": "GOING", "node": {"id": "1234"}}, + ] + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You created a plan.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "lightweight_event_create", + "untypedData": { + "event_timezone": "", + "event_creator_id": "1234", + "event_id": "112233", + "event_type": "EVENT", + "event_track_rsvp": "1", + "event_title": "A plan", + "event_time": "1600000000", + "event_seconds_to_notify_before": "3600", + "guest_state_list": _util.json_minimal(guest_list), + }, + "class": "AdminTextMessage", + } + assert PlanCreated( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + plan=PlanData( + session=session, + id="112233", + time=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + title="A plan", + author_id="1234", + guests={ + "1234": GuestStatus.GOING, + "2345": GuestStatus.INVITED, + "3456": GuestStatus.INVITED, + }, + ), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +@pytest.mark.skip(reason="Need to gather test data") +def test_plan_ended(session): + data = {} + assert PlanEnded( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + plan=PlanData( + session=session, + id="112233", + time=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + title="A plan", + author_id="1234", + guests={ + "1234": GuestStatus.GOING, + "2345": GuestStatus.INVITED, + "3456": GuestStatus.INVITED, + }, + ), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_plan_edited(session): + guest_list = [ + {"guest_list_state": "INVITED", "node": {"id": "3456"}}, + {"guest_list_state": "INVITED", "node": {"id": "2345"}}, + {"guest_list_state": "GOING", "node": {"id": "1234"}}, + ] + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You named the plan A plan.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "lightweight_event_update", + "untypedData": { + "event_creator_id": "1234", + "latitude": "0", + "event_title": "A plan", + "event_seconds_to_notify_before": "3600", + "guest_state_list": _util.json_minimal(guest_list), + "event_end_time": "0", + "event_timezone": "", + "event_id": "112233", + "event_type": "EVENT", + "event_location_id": "2233445566", + "event_location_name": "", + "event_time": "1600000000", + "event_note": "", + "longitude": "0", + }, + "class": "AdminTextMessage", + } + assert PlanEdited( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + plan=PlanData( + session=session, + id="112233", + time=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + title="A plan", + location_id="2233445566", + author_id="1234", + guests={ + "1234": GuestStatus.GOING, + "2345": GuestStatus.INVITED, + "3456": GuestStatus.INVITED, + }, + ), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_plan_deleted(session): + guest_list = [ + {"guest_list_state": "GOING", "node": {"id": "1234"}}, + {"guest_list_state": "INVITED", "node": {"id": "3456"}}, + {"guest_list_state": "INVITED", "node": {"id": "2345"}}, + ] + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You deleted the plan A plan for Mon, 20 Jan at 15:30.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "lightweight_event_delete", + "untypedData": { + "event_end_time": "0", + "event_timezone": "", + "event_id": "112233", + "event_type": "EVENT", + "event_location_id": "2233445566", + "latitude": "0", + "event_title": "A plan", + "event_time": "1600000000", + "event_seconds_to_notify_before": "3600", + "guest_state_list": _util.json_minimal(guest_list), + "event_note": "", + "longitude": "0", + }, + "class": "AdminTextMessage", + } + assert PlanDeleted( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + plan=PlanData( + session=session, + id="112233", + time=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + title="A plan", + location_id="2233445566", + author_id=None, + guests={ + "1234": GuestStatus.GOING, + "2345": GuestStatus.INVITED, + "3456": GuestStatus.INVITED, + }, + ), + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_plan_participation(session): + guest_list = [ + {"guest_list_state": "DECLINED", "node": {"id": "1234"}}, + {"guest_list_state": "GOING", "node": {"id": "2345"}}, + {"guest_list_state": "INVITED", "node": {"id": "3456"}}, + ] + data = { + "irisSeqId": "1111111", + "irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"], + "messageMetadata": { + "actorFbId": "1234", + "adminText": "You responded Can't Go to def.", + "folderId": {"systemFolderId": "INBOX"}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "11223344556677889900", + "skipBumpThread": False, + "tags": ["source:titan:web", "no_push"], + "threadKey": {"threadFbId": "4321"}, + "threadReadStateEffect": "MARK_UNREAD", + "timestamp": "1500000000000", + "unsendType": "deny_log_message", + }, + "participants": ["1234", "2345", "3456"], + "requestContext": {"apiArgs": {}}, + "tqSeqId": "1111", + "type": "lightweight_event_rsvp", + "untypedData": { + "event_creator_id": "2345", + "guest_status": "DECLINED", + "latitude": "0", + "event_track_rsvp": "1", + "event_title": "A plan", + "event_seconds_to_notify_before": "3600", + "guest_state_list": _util.json_minimal(guest_list), + "event_end_time": "0", + "event_timezone": "", + "event_id": "112233", + "event_type": "EVENT", + "guest_id": "1234", + "event_location_id": "2233445566", + "event_time": "1600000000", + "event_note": "", + "longitude": "0", + }, + "class": "AdminTextMessage", + } + assert PlanResponded( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + plan=PlanData( + session=session, + id="112233", + time=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + title="A plan", + location_id="2233445566", + author_id="2345", + guests={ + "1234": GuestStatus.DECLINED, + "2345": GuestStatus.GOING, + "3456": GuestStatus.INVITED, + }, + ), + take_part=False, + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + +def test_parse_delta_unknown(session): + assert UnknownEvent(data={"abc": 10}) == parse_delta(session, {"abc": 10}) From 01f8578dea966fb980f57e98f69a47d9fa875573 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Mon, 20 Jan 2020 16:46:26 +0100 Subject: [PATCH 5/7] Add top level MQTT topic parsing to a separate file --- fbchat/__init__.py | 2 + fbchat/_client.py | 177 +---------------------------------- fbchat/_client_payload.py | 2 +- fbchat/_delta_class.py | 2 +- fbchat/_delta_type.py | 2 +- fbchat/_event.py | 123 ++++++++++++++++++++++++ fbchat/_event_common.py | 2 + tests/test_client_payload.py | 4 +- tests/test_delta_class.py | 4 +- tests/test_delta_type.py | 4 +- tests/test_event.py | 137 +++++++++++++++++++++++++++ 11 files changed, 281 insertions(+), 178 deletions(-) create mode 100644 fbchat/_event.py create mode 100644 tests/test_event.py diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 274c228..d03dacd 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -42,6 +42,7 @@ from ._quick_reply import ( from ._poll import Poll, PollOption from ._plan import GuestStatus, Plan, PlanData +# Listen events from ._event_common import Event, UnknownEvent, ThreadEvent from ._client_payload import ( ReactionEvent, @@ -77,6 +78,7 @@ from ._delta_type import ( PlanDeleted, PlanResponded, ) +from ._event import Typing, FriendRequest, Presence from ._client import Client diff --git a/fbchat/_client.py b/fbchat/_client.py index 42b1c93..b1036f8 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -14,9 +14,7 @@ from . import ( _thread, _message, _event_common, - _client_payload, - _delta_class, - _delta_type, + _event, ) from ._thread import ThreadLocation @@ -55,7 +53,6 @@ class Client: session: The session to use when making requests. """ self._mark_alive = True - self._buddylist = dict() self._session = session self._mqtt = None @@ -463,22 +460,6 @@ class Client: data = self._get_private_data() return [j["display_email"] for j in data["all_emails"]] - def get_user_active_status(self, user_id): - """Fetch friend active status as an `ActiveStatus` object. - - Return ``None`` if status isn't known. - - Warning: - Only works when listening. - - Args: - user_id: ID of the user - - Returns: - ActiveStatus: Given user active status - """ - return self._buddylist.get(str(user_id)) - def mark_as_delivered(self, thread_id, message_id): """Mark a message as delivered. @@ -609,88 +590,12 @@ class Client: LISTEN METHODS """ - def _parse_delta(self, delta): - # Client payload (that weird numbers) - if delta.get("class") == "ClientPayload": - for event in _client_payload.parse_client_payloads(self.session, delta): - self.on_event(event) - - elif delta.get("class"): - event = _delta_class.parse_delta(self.session, delta) - if event: - self.on_event(event) - - elif delta.get("type"): - self.on_event(_delta_type.parse_delta(self.session, delta)) - - # Unknown message type - else: - self.on_unknown_messsage_type(msg=delta) - - def _parse_payload(self, topic, m): - # Things that directly change chat - if topic == "/t_ms": - if "deltas" not in m: - return - for delta in m["deltas"]: - self._parse_delta(delta) - - # TODO: Remove old parsing below - - # Inbox - elif topic == "inbox": - self.on_inbox( - unseen=m["unseen"], - unread=m["unread"], - recent_unread=m["recent_unread"], - ) - - # Typing - # /thread_typing {'sender_fbid': X, 'state': 1, 'type': 'typ', 'thread': 'Y'} - # /orca_typing_notifications {'type': 'typ', 'sender_fbid': X, 'state': 0} - elif topic in ("/thread_typing", "/orca_typing_notifications"): - author_id = str(m["sender_fbid"]) - thread_id = m.get("thread") - if thread_id: - thread = _group.Group(session=self.session, id=str(thread_id)) - else: - thread = _user.User(session=self.session, id=author_id) - self.on_typing( - author_id=author_id, status=m["state"] == 1, thread=thread, - ) - - # Other notifications - elif topic == "/legacy_web": - # Friend request - if m["type"] == "jewel_requests_add": - self.on_friend_request(from_id=str(m["from"])) - else: - self.on_unknown_messsage_type(msg=m) - - # Chat timestamp / Buddylist overlay - elif topic == "/orca_presence": - if m["list_type"] == "full": - self._buddylist = {} # Refresh internal list - - statuses = dict() - for data in m["list"]: - user_id = str(data["u"]) - statuses[user_id] = ActiveStatus._from_orca_presence(data) - self._buddylist[user_id] = statuses[user_id] - - # TODO: Which one should we call? - self.on_chat_timestamp(buddylist=statuses) - self.on_buddylist_overlay(statuses=statuses) - - # Unknown message type - else: - self.on_unknown_messsage_type(msg=m) - def _parse_message(self, topic, data): try: - self._parse_payload(topic, data) - except Exception as e: - self.on_message_error(exception=e, msg=data) + for event in _event.parse_events(self.session, topic, data): + self.on_event(event) + except _exception.ParseError: + log.exception("Failed parsing MQTT data") def _start_listening(self): if not self._mqtt: @@ -740,78 +645,6 @@ class Client: """ self._mark_alive = markAlive - """ - END LISTEN METHODS - """ - - """ - EVENTS - """ - def on_event(self, event: _event_common.Event): """Called when the client is listening, and an event happens.""" log.info("Got event: %s", event) - - def on_friend_request(self, from_id=None): - """Called when the client is listening, and somebody sends a friend request. - - Args: - from_id: The ID of the person that sent the request - """ - log.info("Friend request from {}".format(from_id)) - - def on_inbox(self, unseen=None, unread=None, recent_unread=None): - """ - Todo: - Documenting this - - Args: - unseen: -- - unread: -- - recent_unread: -- - """ - log.info("Inbox event: {}, {}, {}".format(unseen, unread, recent_unread)) - - def on_typing(self, author_id=None, status=None, thread=None): - """Called when the client is listening, and somebody starts or stops typing into a chat. - - Args: - author_id: The ID of the person who sent the action - is_typing: ``True`` if the user started typing, ``False`` if they stopped. - thread: Thread that the action was sent to. See :ref:`intro_threads` - """ - pass - - def on_chat_timestamp(self, buddylist=None): - """Called when the client receives chat online presence update. - - Args: - buddylist: A list of dictionaries with friend id and last seen timestamp - """ - log.debug("Chat Timestamps received: {}".format(buddylist)) - - def on_buddylist_overlay(self, statuses=None): - """Called when the client is listening and client receives information about friend active status. - - Args: - statuses (dict): Dictionary with user IDs as keys and `ActiveStatus` as values - """ - - def on_unknown_messsage_type(self, msg=None): - """Called when the client is listening, and some unknown data was received. - - Args: - """ - log.debug("Unknown message received: {}".format(msg)) - - def on_message_error(self, exception=None, msg=None): - """Called when an error was encountered while parsing received data. - - Args: - exception: The exception that was encountered - """ - log.exception("Exception in parsing of {}".format(msg)) - - """ - END EVENTS - """ diff --git a/fbchat/_client_payload.py b/fbchat/_client_payload.py index 2543a2a..a4be88d 100644 --- a/fbchat/_client_payload.py +++ b/fbchat/_client_payload.py @@ -121,7 +121,7 @@ def parse_client_delta(session, data): return UnsendEvent._parse(session, data["deltaRecallMessageData"]) elif "deltaMessageReply" in data: return MessageReplyEvent._parse(session, data["deltaMessageReply"]) - return UnknownEvent(data=data) + return UnknownEvent(source="client payload", data=data) def parse_client_payloads(session, data): diff --git a/fbchat/_delta_class.py b/fbchat/_delta_class.py index 62cffae..401e3d3 100644 --- a/fbchat/_delta_class.py +++ b/fbchat/_delta_class.py @@ -184,4 +184,4 @@ def parse_delta(session, data): return X._parse(session, data) elif class_ == "NewMessage": return MessageEvent._parse(session, data) - return UnknownEvent(data=data) + return UnknownEvent(source="Delta class", data=data) diff --git a/fbchat/_delta_type.py b/fbchat/_delta_type.py index 127c976..2ea78f1 100644 --- a/fbchat/_delta_type.py +++ b/fbchat/_delta_type.py @@ -326,4 +326,4 @@ def parse_delta(session, data): return PlanDeleted._parse(session, data) elif type_ == "lightweight_event_rsvp": return PlanResponded._parse(session, data) - return UnknownEvent(data=data) + return UnknownEvent(source="Delta type", data=data) diff --git a/fbchat/_event.py b/fbchat/_event.py new file mode 100644 index 0000000..c55fb8b --- /dev/null +++ b/fbchat/_event.py @@ -0,0 +1,123 @@ +import attr +import datetime +from ._event_common import attrs_event, Event, UnknownEvent, ThreadEvent +from . import ( + _exception, + _util, + _user, + _group, + _thread, + _client_payload, + _delta_class, + _delta_type, +) + +from typing import Mapping + + +@attrs_event +class Typing(ThreadEvent): + """Somebody started/stopped typing in a thread.""" + + #: ``True`` if the user started typing, ``False`` if they stopped + status = attr.ib(type=bool) + + @classmethod + def _parse_orca(cls, session, data): + author = _user.User(session=session, id=str(data["sender_fbid"])) + status = data["state"] == 1 + return cls(author=author, thread=author, status=status) + + @classmethod + def _parse(cls, session, data): + # TODO: Rename this method + author = _user.User(session=session, id=str(data["sender_fbid"])) + thread = _group.Group(session=session, id=str(data["thread"])) + status = data["state"] == 1 + return cls(author=author, thread=thread, status=status) + + +@attrs_event +class FriendRequest(Event): + """Somebody sent a friend request.""" + + #: The user that sent the request + author = attr.ib(type=_user.User) + + @classmethod + def _parse(cls, session, data): + author = _user.User(session=session, id=str(data["from"])) + return cls(author=author) + + +@attrs_event +class Presence(Event): + """The list of active statuses was updated. + + Chat online presence update. + """ + + # TODO: Document this better! + + #: User ids mapped to their active status + statuses = attr.ib(type=Mapping[str, _user.ActiveStatus]) + #: ``True`` if the list is fully updated and ``False`` if it's partially updated + full = attr.ib(type=bool) + + @classmethod + def _parse(cls, session, data): + statuses = { + str(d["u"]): _user.ActiveStatus._from_orca_presence(d) for d in data["list"] + } + return cls(statuses=statuses, full=data["list_type"] == "full") + + +def parse_delta(session, data): + try: + class_ = data.get("class") + if class_ == "ClientPayload": + yield from _client_payload.parse_client_payloads(session, data) + elif class_ == "AdminTextMessage": + yield _delta_type.parse_delta(session, data) + else: + event = _delta_class.parse_delta(session, data) + if event: # Skip `None` + yield event + except _exception.ParseError: + raise + except Exception as e: + raise _exception.ParseError("Error parsing delta", data=data) from e + + +def parse_events(session, topic, data): + # See Mqtt._configure_connect_options for information about these topics + try: + if topic == "/t_ms": + if "deltas" not in data: + return + for delta in data["deltas"]: + yield from parse_delta(session, delta) + + elif topic == "/thread_typing": + yield Typing._parse(session, data) + + elif topic == "/orca_typing_notifications": + yield Typing._parse_orca(session, data) + + elif topic == "/legacy_web": + if data.get("type") == "jewel_requests_add": + yield FriendRequest._parse(session, data) + else: + yield UnknownEvent(source="/legacy_web", data=data) + + elif topic == "/orca_presence": + yield Presence._parse(session, data) + + else: + yield UnknownEvent(source=topic, data=data) + except _exception.ParseError: + raise + except Exception as e: + raise _exception.ParseError( + "Error parsing MQTT topic {}".format(topic), data=data + ) from e diff --git a/fbchat/_event_common.py b/fbchat/_event_common.py index 169fc9a..e0ceabc 100644 --- a/fbchat/_event_common.py +++ b/fbchat/_event_common.py @@ -21,6 +21,8 @@ class Event(metaclass=abc.ABCMeta): class UnknownEvent(Event): """Represent an unknown event.""" + #: Some data describing the unknown event's origin + source = attr.ib() #: The unknown data. This cannot be relied on, it's only for debugging purposes. data = attr.ib() diff --git a/tests/test_client_payload.py b/tests/test_client_payload.py index b9edc3e..c85018f 100644 --- a/tests/test_client_payload.py +++ b/tests/test_client_payload.py @@ -153,7 +153,9 @@ def test_message_reply(session): def test_parse_client_delta_unknown(session): - assert UnknownEvent(data={"abc": 10}) == parse_client_delta(session, {"abc": 10}) + assert UnknownEvent( + source="client payload", data={"abc": 10} + ) == parse_client_delta(session, {"abc": 10}) def test_parse_client_payloads_empty(session): diff --git a/tests/test_delta_class.py b/tests/test_delta_class.py index a15432f..864f5e4 100644 --- a/tests/test_delta_class.py +++ b/tests/test_delta_class.py @@ -272,4 +272,6 @@ def test_noop(session): def test_parse_delta_unknown(session): - assert UnknownEvent(data={"abc": 10}) == parse_delta(session, {"abc": 10}) + assert UnknownEvent(source="Delta class", data={"abc": 10}) == parse_delta( + session, {"abc": 10} + ) diff --git a/tests/test_delta_type.py b/tests/test_delta_type.py index 151b5e2..38b4dc8 100644 --- a/tests/test_delta_type.py +++ b/tests/test_delta_type.py @@ -951,4 +951,6 @@ def test_plan_participation(session): def test_parse_delta_unknown(session): - assert UnknownEvent(data={"abc": 10}) == parse_delta(session, {"abc": 10}) + assert UnknownEvent(source="Delta type", data={"abc": 10}) == parse_delta( + session, {"abc": 10} + ) diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 0000000..d9defe7 --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,137 @@ +import datetime +from fbchat import ( + _util, + User, + Group, + Message, + ParseError, + UnknownEvent, + Typing, + FriendRequest, + Presence, + ReactionEvent, + UnfetchedThreadEvent, + ActiveStatus, +) +from fbchat._event import parse_delta, parse_events + + +def test_t_ms_full(session): + """A full example of parsing of data in /t_ms.""" + payload = { + "deltas": [ + { + "deltaMessageReaction": { + "threadKey": {"threadFbId": 4321}, + "messageId": "mid.$XYZ", + "action": 0, + "userId": 1234, + "reaction": "😢", + "senderId": 1234, + "offlineThreadingId": "1122334455", + } + } + ] + } + data = { + "deltas": [ + { + "payload": [ord(x) for x in _util.json_minimal(payload)], + "class": "ClientPayload", + }, + {"class": "NoOp",}, + { + "forceInsert": False, + "messageId": "mid.$ABC", + "threadKey": {"threadFbId": "4321"}, + "class": "ForcedFetch", + }, + ], + "firstDeltaSeqId": 111111, + "lastIssuedSeqId": 111113, + "queueEntityId": 1234, + } + thread = Group(session=session, id="4321") + assert [ + ReactionEvent( + author=User(session=session, id="1234"), + thread=thread, + message=Message(thread=thread, id="mid.$XYZ"), + reaction="😢", + ), + UnfetchedThreadEvent( + thread=thread, message=Message(thread=thread, id="mid.$ABC"), + ), + ] == list(parse_events(session, "/t_ms", data)) + + +def test_thread_typing(session): + data = {"sender_fbid": 1234, "state": 0, "type": "typ", "thread": "4321"} + (event,) = parse_events(session, "/thread_typing", data) + assert event == Typing( + author=User(session=session, id="1234"), + thread=Group(session=session, id="4321"), + status=False, + ) + + +def test_orca_typing_notifications(session): + data = {"type": "typ", "sender_fbid": 1234, "state": 1} + (event,) = parse_events(session, "/orca_typing_notifications", data) + assert event == Typing( + author=User(session=session, id="1234"), + thread=User(session=session, id="1234"), + status=True, + ) + + +def test_friend_request(session): + data = {"type": "jewel_requests_add", "from": "1234"} + (event,) = parse_events(session, "/legacy_web", data) + assert event == FriendRequest(author=User(session=session, id="1234")) + + +def test_orca_presence_inc(session): + data = { + "list_type": "inc", + "list": [ + {"u": 1234, "p": 0, "l": 1500000000, "vc": 74}, + {"u": 2345, "p": 2, "c": 9969664, "vc": 10}, + ], + } + (event,) = parse_events(session, "/orca_presence", data) + la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc) + assert event == Presence( + statuses={ + "1234": ActiveStatus(active=False, last_active=la), + "2345": ActiveStatus(active=True), + }, + full=False, + ) + + +def test_orca_presence_full(session): + data = { + "list_type": "full", + "list": [ + {"u": 1234, "p": 2, "c": 5767242}, + {"u": 2345, "p": 2, "l": 1500000000}, + {"u": 3456, "p": 2, "c": 9961482}, + {"u": 4567, "p": 0, "l": 1500000000}, + {"u": 5678, "p": 0}, + {"u": 6789, "p": 2, "c": 14168154}, + ], + } + (event,) = parse_events(session, "/orca_presence", data) + la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc) + assert event == Presence( + statuses={ + "1234": ActiveStatus(active=True), + "2345": ActiveStatus(active=True, last_active=la), + "3456": ActiveStatus(active=True), + "4567": ActiveStatus(active=False, last_active=la), + "5678": ActiveStatus(active=False), + "6789": ActiveStatus(active=True), + }, + full=True, + ) From 9b75db898acde96f7c24c2887ec0b28d56306676 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Tue, 21 Jan 2020 01:29:43 +0100 Subject: [PATCH 6/7] Move listen methods out of Client and into MQTT class Make MQTT class / `Listener` public --- examples/echobot.py | 28 +++++---- examples/keepbot.py | 131 +++++++++++++++++++++++------------------- examples/removebot.py | 34 +++++------ fbchat/__init__.py | 3 +- fbchat/_client.py | 101 ++------------------------------ fbchat/_mqtt.py | 88 +++++++++++++++++++--------- 6 files changed, 168 insertions(+), 217 deletions(-) diff --git a/examples/echobot.py b/examples/echobot.py index e1d5f1e..affc8fc 100644 --- a/examples/echobot.py +++ b/examples/echobot.py @@ -1,19 +1,17 @@ import fbchat -# Subclass fbchat.Client and override required methods -class EchoBot(fbchat.Client): - def on_message(self, author_id, message_object, thread, **kwargs): - self.mark_as_delivered(thread.id, message_object.id) - self.mark_as_read(thread.id) - - print("{} from {} in {}".format(message_object, thread)) - - # If you're not the author, echo - if author_id != self.session.user_id: - thread.send_text(message_object.text) - - session = fbchat.Session.login("", "") -echo_bot = EchoBot(session) -echo_bot.listen() +listener = fbchat.Listener.connect(session, chat_on=False, foreground=False) + + +def on_message(event): + print(f"{event.message.text} from {event.author.id} in {event.thread.id}") + # If you're not the author, echo + if event.author.id != session.user_id: + event.thread.send_text(event.message.text) + + +for event in listener.listen(): + if isinstance(event, fbchat.MessageEvent): + on_message(event) diff --git a/examples/keepbot.py b/examples/keepbot.py index 54a2c36..56bef6c 100644 --- a/examples/keepbot.py +++ b/examples/keepbot.py @@ -14,64 +14,77 @@ old_nicknames = { "12345678904": "User nr. 4's nickname", } - -class KeepBot(fbchat.Client): - def on_color_change(self, author_id, new_color, thread, **kwargs): - if old_thread_id == thread.id and old_color != new_color: - print( - "{} changed the thread color. It will be changed back".format(author_id) - ) - thread.set_color(old_color) - - def on_emoji_change(self, author_id, new_emoji, thread, **kwargs): - if old_thread_id == thread.id and new_emoji != old_emoji: - print( - "{} changed the thread emoji. It will be changed back".format(author_id) - ) - thread.set_emoji(old_emoji) - - def on_people_added(self, added_ids, author_id, thread, **kwargs): - if old_thread_id == thread.id and author_id != self.session.user_id: - print("{} got added. They will be removed".format(added_ids)) - for added_id in added_ids: - thread.remove_participant(added_id) - - def on_person_removed(self, removed_id, author_id, thread, **kwargs): - # No point in trying to add ourself - if ( - old_thread_id == thread.id - and removed_id != self.session.user_id - and author_id != self.session.user_id - ): - print("{} got removed. They will be re-added".format(removed_id)) - thread.add_participants(removed_id) - - def on_title_change(self, author_id, new_title, thread, **kwargs): - if old_thread_id == thread.id and old_title != new_title: - print( - "{} changed the thread title. It will be changed back".format(author_id) - ) - thread.set_title(old_title) - - def on_nickname_change( - self, author_id, changed_for, new_nickname, thread, **kwargs - ): - if ( - old_thread_id == thread.id - and changed_for in old_nicknames - and old_nicknames[changed_for] != new_nickname - ): - print( - "{} changed {}'s' nickname. It will be changed back".format( - author_id, changed_for - ) - ) - thread.set_nickname( - changed_for, old_nicknames[changed_for], - ) - - session = fbchat.Session.login("", "") -keep_bot = KeepBot(session) -keep_bot.listen() +listener = fbchat.Listener.connect(session, chat_on=False, foreground=False) + + +def on_color_set(event: fbchat.ColorSet): + if old_thread_id != event.thread.id: + return + if old_color != event.color: + print(f"{event.author.id} changed the thread color. It will be changed back") + event.thread.set_color(old_color) + + +def on_emoji_set(event: fbchat.EmojiSet): + if old_thread_id != event.thread.id: + return + if old_emoji != event.emoji: + print(f"{event.author.id} changed the thread emoji. It will be changed back") + event.thread.set_emoji(old_emoji) + + +def on_title_set(event: fbchat.TitleSet): + if old_thread_id != event.thread.id: + return + if old_title != event.title: + print(f"{event.author.id} changed the thread title. It will be changed back") + event.thread.set_title(old_title) + + +def on_nickname_set(event: fbchat.NicknameSet): + if old_thread_id != event.thread.id: + return + old_nickname = old_nicknames.get(event.subject.id) + if old_nickname != event.nickname: + print( + f"{event.author.id} changed {event.subject.id}'s' nickname." + " It will be changed back" + ) + event.thread.set_nickname(event.subject.id, old_nickname) + + +def on_people_added(event: fbchat.PeopleAdded): + if old_thread_id != event.thread.id: + return + if event.author.id != session.user_id: + print(f"{', '.join(x.id for x in event.added)} got added. They will be removed") + for added in event.added: + event.thread.remove_participant(added.id) + + +def on_person_removed(event: fbchat.PersonRemoved): + if old_thread_id != event.thread.id: + return + # No point in trying to add ourself + if event.removed.id == session.user_id: + return + if event.author.id != session.user_id: + print(f"{event.removed.id} got removed. They will be re-added") + event.thread.add_participants([removed.id]) + + +for event in listener.listen(): + if isinstance(event, fbchat.ColorSet): + on_color_set(event) + elif isinstance(event, fbchat.EmojiSet): + on_emoji_set(event) + elif isinstance(event, fbchat.TitleSet): + on_title_set(event) + elif isinstance(event, fbchat.NicknameSet): + on_nickname_set(event) + elif isinstance(event, fbchat.PeopleAdded): + on_people_added(event) + elif isinstance(event, fbchat.PersonRemoved): + on_person_removed(event) diff --git a/examples/removebot.py b/examples/removebot.py index 984ed98..14d449a 100644 --- a/examples/removebot.py +++ b/examples/removebot.py @@ -1,23 +1,19 @@ import fbchat - -class RemoveBot(fbchat.Client): - def on_message(self, author_id, message_object, thread, **kwargs): - # We can only kick people from group chats, so no need to try if it's a user chat - if message_object.text == "Remove me!" and isinstance(thread, fbchat.Group): - print("{} will be removed from {}".format(author_id, thread)) - thread.remove_participant(author_id) - else: - # Sends the data to the inherited on_message, so that we can still see when a message is recieved - super(RemoveBot, self).on_message( - author_id=author_id, - message_object=message_object, - thread=thread, - **kwargs, - ) - - session = fbchat.Session.login("", "") -remove_bot = RemoveBot(session) -remove_bot.listen() +listener = fbchat.Listener.connect(session, chat_on=False, foreground=False) + + +def on_message(event): + # We can only kick people from group chats, so no need to try if it's a user chat + if not isinstance(event.thread, fbchat.Group): + return + if message.text == "Remove me!": + print(f"{event.author.id} will be removed from {event.thread.id}") + event.thread.remove_participant(event.author.id) + + +for event in listener.listen(): + if isinstance(event, fbchat.MessageEvent): + on_message(event) diff --git a/fbchat/__init__.py b/fbchat/__init__.py index d03dacd..aba433e 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -79,6 +79,7 @@ from ._delta_type import ( PlanResponded, ) from ._event import Typing, FriendRequest, Presence +from ._mqtt import Listener from ._client import Client @@ -92,7 +93,7 @@ __license__ = "BSD 3-Clause" __author__ = "Taehoon Kim; Moreels Pieter-Jan; Mads Marquart" __email__ = "carpedm20@gmail.com" -__all__ = ("Session", "Client") +__all__ = ("Session", "Listener", "Client") # Everything below is taken from the excellent trio project: diff --git a/fbchat/_client.py b/fbchat/_client.py index b1036f8..e977bf9 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -1,43 +1,27 @@ +import attr import datetime -from ._core import log +from ._core import log, attrs_default from . import ( _exception, _util, _graphql, - _mqtt, _session, - _poll, _user, _page, _group, _thread, - _message, - _event_common, - _event, ) from ._thread import ThreadLocation -from ._user import User, UserData, ActiveStatus +from ._user import User, UserData from ._group import Group, GroupData from ._page import Page, PageData -from ._message import EmojiSize, Mention, Message -from ._attachment import Attachment -from ._sticker import Sticker -from ._location import LocationAttachment, LiveLocationAttachment -from ._file import ImageAttachment, VideoAttachment -from ._quick_reply import ( - QuickReply, - QuickReplyText, - QuickReplyLocation, - QuickReplyPhoneNumber, - QuickReplyEmail, -) -from ._plan import PlanData from typing import Sequence, Iterable, Tuple, Optional +@attrs_default class Client: """A client for the Facebook Chat (Messenger). @@ -46,24 +30,14 @@ class Client: useful while listening). """ - def __init__(self, session): - """Initialize the client model. - - Args: - session: The session to use when making requests. - """ - self._mark_alive = True - self._session = session - self._mqtt = None + #: The session to use when making requests. + _session = attr.ib(type=_session.Session) @property def session(self): """The session that's used when making requests.""" return self._session - def __repr__(self): - return "Client(session={!r})".format(self._session) - def fetch_users(self) -> Sequence[_user.UserData]: """Fetch users the client is currently chatting with. @@ -585,66 +559,3 @@ class Client: data["message_ids[{}]".format(i)] = message_id j = self.session._payload_post("/ajax/mercury/delete_messages.php?dpr=1", data) return True - - """ - LISTEN METHODS - """ - - def _parse_message(self, topic, data): - try: - for event in _event.parse_events(self.session, topic, data): - self.on_event(event) - except _exception.ParseError: - log.exception("Failed parsing MQTT data") - - def _start_listening(self): - if not self._mqtt: - self._mqtt = _mqtt.Mqtt.connect( - session=self.session, - on_message=self._parse_message, - chat_on=self._mark_alive, - foreground=True, - ) - - def _do_one_listen(self): - # TODO: Remove this wierd check, and let the user handle the chat_on parameter - if self._mark_alive != self._mqtt._chat_on: - self._mqtt.set_chat_on(self._mark_alive) - - return self._mqtt.loop_once() - - def _stop_listening(self): - if not self._mqtt: - return - self._mqtt.disconnect() - # TODO: Preserve the _mqtt object - # Currently, there's some issues when disconnecting - self._mqtt = None - - def listen(self, markAlive=None): - """Initialize and runs the listening loop continually. - - Args: - markAlive (bool): Whether this should ping the Facebook server each time the loop runs - """ - if markAlive is not None: - self.set_active_status(markAlive) - - self._start_listening() - - while self._do_one_listen(): - pass - - self._stop_listening() - - def set_active_status(self, markAlive): - """Change active status while listening. - - Args: - markAlive (bool): Whether to show if client is active - """ - self._mark_alive = markAlive - - def on_event(self, event: _event_common.Event): - """Called when the client is listening, and an event happens.""" - log.info("Got event: %s", event) diff --git a/fbchat/_mqtt.py b/fbchat/_mqtt.py index f4f0a25..798544f 100644 --- a/fbchat/_mqtt.py +++ b/fbchat/_mqtt.py @@ -2,11 +2,13 @@ import attr import random import paho.mqtt.client import requests -from ._core import log -from . import _util, _exception, _graphql +from ._core import log, attrs_default +from . import _util, _exception, _session, _graphql, _event_common, _event + +from typing import Iterable -def get_cookie_header(session, url): +def get_cookie_header(session: requests.Session, url: str) -> str: """Extract a cookie header from a requests session.""" # The cookies are extracted this way to make sure they're escaped correctly return requests.cookies.get_cookie_header( @@ -14,25 +16,34 @@ def get_cookie_header(session, url): ) -def generate_session_id(): +def generate_session_id() -> int: """Generate a random session ID between 1 and 9007199254740991.""" return random.randint(1, 2 ** 53) -@attr.s(slots=True) -class Mqtt(object): - _session = attr.ib() - _mqtt = attr.ib() - _on_message = attr.ib() - _chat_on = attr.ib() - _foreground = attr.ib() - _sequence_id = attr.ib() - _sync_token = attr.ib(None) +@attrs_default +class Listener: + """Helper, to listen for incoming Facebook events.""" + + _session = attr.ib(type=_session.Session) + _mqtt = attr.ib(type=paho.mqtt.client.Client) + _chat_on = attr.ib(type=bool) + _foreground = attr.ib(type=bool) + _sequence_id = attr.ib(type=int) + _sync_token = attr.ib(None, type=str) + _events = attr.ib(None, type=Iterable[_event_common.Event]) _HOST = "edge-chat.facebook.com" @classmethod - def connect(cls, session, on_message, chat_on, foreground): + def connect(cls, session, chat_on: bool, foreground: bool): + """Initialize a connection to the Facebook MQTT service. + + Args: + session: The session to use when making requests. + chat_on: Whether ... + foreground: Whether ... + """ mqtt = paho.mqtt.client.Client( client_id="mqttwsclient", clean_session=True, @@ -50,7 +61,6 @@ class Mqtt(object): self = cls( session=session, mqtt=mqtt, - on_message=on_message, chat_on=chat_on, foreground=foreground, sequence_id=cls._fetch_sequence_id(session), @@ -108,11 +118,14 @@ class Mqtt(object): log.debug("MQTT payload: %s, %s", message.topic, j) - # Call the external callback - self._on_message(message.topic, j) + try: + # TODO: Don't handle this in a callback + self._events = list(_event.parse_events(self._session, message.topic, j)) + except _exception.ParseError: + log.exception("Failed parsing MQTT data") @staticmethod - def _fetch_sequence_id(session): + def _fetch_sequence_id(session) -> int: """Fetch sequence ID.""" params = { "limit": 1, @@ -258,14 +271,11 @@ class Mqtt(object): path="/chat?sid={}".format(session_id), headers=headers ) - def loop_once(self): - """Run the listening loop once. - - Returns whether to keep listening or not. - """ + def _loop_once(self) -> bool: rc = self._mqtt.loop(timeout=1.0) # If disconnect() has been called + # Beware, internal API, may have to change this to something more stable! if self._mqtt._state == paho.mqtt.client.mqtt_cs_disconnecting: return False # Stop listening @@ -298,23 +308,45 @@ class Mqtt(object): return True # Keep listening - def disconnect(self): + def listen(self) -> Iterable[_event_common.Event]: + """Run the listening loop continually. + + Yields events when they arrive. + + This will automatically reconnect on errors. + """ + while self._loop_once(): + if self._events: + yield from self._events + self._events = None + + def disconnect(self) -> None: + """Disconnect the MQTT listener. + + Can be called while listening, which will stop the listening loop. + + The `Listener` object should not be used after this is called! + """ self._mqtt.disconnect() - def set_foreground(self, value): + def set_foreground(self, value: bool) -> None: + """Set the `foreground` value while listening.""" + # TODO: Document what this actually does! payload = _util.json_minimal({"foreground": value}) info = self._mqtt.publish("/foreground_state", payload=payload, qos=1) self._foreground = value - # TODO: We can't wait for this, since the loop is running with .loop_forever() + # TODO: We can't wait for this, since the loop is running within the same thread # info.wait_for_publish() - def set_chat_on(self, value): + def set_chat_on(self, value: bool) -> None: + """Set the `chat_on` value while listening.""" + # TODO: Document what this actually does! # TODO: Is this the right request to make? data = {"make_user_available_when_in_foreground": value} payload = _util.json_minimal(data) info = self._mqtt.publish("/set_client_settings", payload=payload, qos=1) self._chat_on = value - # TODO: We can't wait for this, since the loop is running with .loop_forever() + # TODO: We can't wait for this, since the loop is running within the same thread # info.wait_for_publish() # def send_additional_contacts(self, additional_contacts): From 74a98d7eb3dfd39cf3226b270ba837c08fb611f8 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Tue, 21 Jan 2020 19:50:33 +0100 Subject: [PATCH 7/7] Fix MessagesDelivered user parsing --- fbchat/_delta_class.py | 5 ++++- tests/test_delta_class.py | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/fbchat/_delta_class.py b/fbchat/_delta_class.py index 401e3d3..e2c2c12 100644 --- a/fbchat/_delta_class.py +++ b/fbchat/_delta_class.py @@ -99,8 +99,11 @@ class MessagesDelivered(ThreadEvent): @classmethod def _parse(cls, session, data): - author = _user.User(session=session, id=data["actorFbId"]) thread = cls._get_thread(session, data) + if "actorFbId" in data: + author = _user.User(session=session, id=data["actorFbId"]) + else: + author = thread messages = [_message.Message(thread=thread, id=x) for x in data["messageIds"]] at = _util.millis_to_datetime(int(data["deliveredWatermarkTimestampMs"])) return cls(author=author, thread=thread, messages=messages, at=at) diff --git a/tests/test_delta_class.py b/tests/test_delta_class.py index 864f5e4..027964b 100644 --- a/tests/test_delta_class.py +++ b/tests/test_delta_class.py @@ -133,7 +133,7 @@ def test_forced_fetch(session): ) == parse_delta(session, data) -def test_delivery_receipt(session): +def test_delivery_receipt_group(session): data = { "actorFbId": "1234", "deliveredWatermarkTimestampMs": "1500000000000", @@ -156,6 +156,28 @@ def test_delivery_receipt(session): ) == parse_delta(session, data) +def test_delivery_receipt_user(session): + data = { + "deliveredWatermarkTimestampMs": "1500000000000", + "irisSeqId": "1111111", + "irisTags": ["DeltaDeliveryReceipt", "is_from_iris_fanout"], + "messageIds": ["mid.$XYZ", "mid.$ABC"], + "requestContext": {"apiArgs": {}}, + "threadKey": {"otherUserFbId": "1234"}, + "class": "DeliveryReceipt", + } + thread = User(session=session, id="1234") + assert MessagesDelivered( + author=thread, + thread=thread, + messages=[ + Message(thread=thread, id="mid.$XYZ"), + Message(thread=thread, id="mid.$ABC"), + ], + at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), + ) == parse_delta(session, data) + + def test_read_receipt(session): data = { "actionTimestampMs": "1600000000000",