Move ClientPayload parsing to separate file and add tests
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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,
|
||||
|
136
fbchat/_client_payload.py
Normal file
136
fbchat/_client_payload.py
Normal file
@@ -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
|
@@ -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),
|
||||
)
|
||||
|
||||
|
172
tests/test_client_payload.py
Normal file
172
tests/test_client_payload.py
Normal file
@@ -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))
|
Reference in New Issue
Block a user