Merge pull request #501 from carpedm20/split-models

Split User, Group, Page, Plan and Message classes
This commit is contained in:
Mads Marquart
2020-01-09 17:58:31 +01:00
committed by GitHub
18 changed files with 522 additions and 514 deletions

View File

@@ -9,7 +9,7 @@ print("Own id: {}".format(session.user_id))
user = fbchat.Thread(session=session, id=session.user_id) user = fbchat.Thread(session=session, id=session.user_id)
# Send a message to yourself # Send a message to yourself
user.send(fbchat.Message(text="Hi me!")) user.send_text("Hi me!")
# Log the user out # Log the user out
session.logout() session.logout()

View File

@@ -10,7 +10,7 @@ class EchoBot(fbchat.Client):
# If you're not the author, echo # If you're not the author, echo
if author_id != self.session.user_id: if author_id != self.session.user_id:
thread.send(message_object) thread.send_text(message_object.text)
session = fbchat.Session.login("<email>", "<password>") session = fbchat.Session.login("<email>", "<password>")

View File

@@ -1,4 +1,5 @@
import fbchat import fbchat
import requests
session = fbchat.Session.login("<email>", "<password>") session = fbchat.Session.login("<email>", "<password>")
@@ -9,34 +10,32 @@ thread = fbchat.User(session=session, id=session.user_id)
# thread = fbchat.Group(session=session, id="1234567890") # thread = fbchat.Group(session=session, id="1234567890")
# Will send a message to the thread # Will send a message to the thread
thread.send(fbchat.Message(text="<message>")) thread.send_text("<message>")
# Will send the default `like` emoji # Will send the default `like` emoji
thread.send(fbchat.Message(emoji_size=fbchat.EmojiSize.LARGE)) thread.send_sticker(fbchat.EmojiSize.LARGE.value)
# Will send the emoji `👍` # Will send the emoji `👍`
thread.send(fbchat.Message(text="👍", emoji_size=fbchat.EmojiSize.LARGE)) thread.send_emoji("👍", size=fbchat.EmojiSize.LARGE)
# Will send the sticker with ID `767334476626295` # Will send the sticker with ID `767334476626295`
thread.send(fbchat.Message(sticker=fbchat.Sticker("767334476626295"))) thread.send_sticker("767334476626295")
# Will send a message with a mention # Will send a message with a mention
thread.send( thread.send_text(
fbchat.Message(
text="This is a @mention", text="This is a @mention",
mentions=[fbchat.Mention(thread.id, offset=10, length=8)], mentions=[fbchat.Mention(thread.id, offset=10, length=8)],
)
) )
# Will send the image located at `<image path>` # Will send the image located at `<image path>`
thread.send_local_image( with open("<image path>", "rb") as f:
"<image path>", message=fbchat.Message(text="This is a local image") files = session._upload([("image_name.png", f, "image/png")])
) thread.send_text(text="This is a local image", files=files)
# Will download the image at the URL `<image url>`, and then send it # Will download the image at the URL `<image url>`, and then send it
thread.send_remote_image( r = requests.get("<image url>")
"<image url>", message=fbchat.Message(text="This is a remote image") files = session._upload([("image_name.png", r.content, "image/png")])
) thread.send_files(files) # Alternative to .send_text
# Only do these actions if the thread is a group # Only do these actions if the thread is a group
@@ -46,7 +45,7 @@ if isinstance(thread, fbchat.Group):
# Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the group # Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the group
thread.add_participants(["<1st user id>", "<2nd user id>", "<3rd user id>"]) thread.add_participants(["<1st user id>", "<2nd user id>", "<3rd user id>"])
# Will change the title of the group to `<title>` # Will change the title of the group to `<title>`
thread.change_title("<title>") thread.set_title("<title>")
# Will change the nickname of the user `<user_id>` to `<new nickname>` # Will change the nickname of the user `<user_id>` to `<new nickname>`
@@ -61,7 +60,7 @@ thread.set_color(fbchat.ThreadColor.MESSENGER_BLUE)
# Will change the thread emoji to `👍` # Will change the thread emoji to `👍`
thread.set_emoji("👍") thread.set_emoji("👍")
# message = fbchat.Message(session=session, id="<message id>") message = fbchat.Message(session=session, id="<message id>")
#
# # Will react to a message with a 😍 emoji # Will react to a message with a 😍 emoji
# message.react(fbchat.MessageReaction.LOVE) message.react(fbchat.MessageReaction.LOVE)

View File

@@ -15,9 +15,9 @@ from ._core import Image
from ._exception import FBchatException, FBchatFacebookError from ._exception import FBchatException, FBchatFacebookError
from ._session import Session from ._session import Session
from ._thread import ThreadLocation, ThreadColor, ThreadABC, Thread from ._thread import ThreadLocation, ThreadColor, ThreadABC, Thread
from ._user import TypingStatus, User, ActiveStatus from ._user import TypingStatus, User, UserData, ActiveStatus
from ._group import Group from ._group import Group, GroupData
from ._page import Page from ._page import Page, PageData
from ._message import EmojiSize, MessageReaction, Mention, Message from ._message import EmojiSize, MessageReaction, Mention, Message
from ._attachment import Attachment, UnsentMessage, ShareAttachment from ._attachment import Attachment, UnsentMessage, ShareAttachment
from ._sticker import Sticker from ._sticker import Sticker
@@ -31,7 +31,7 @@ from ._quick_reply import (
QuickReplyEmail, QuickReplyEmail,
) )
from ._poll import Poll, PollOption from ._poll import Poll, PollOption
from ._plan import GuestStatus, Plan from ._plan import GuestStatus, Plan, PlanData
from ._client import Client from ._client import Client

View File

@@ -8,9 +8,9 @@ from . import _util, _graphql, _session
from ._exception import FBchatException, FBchatFacebookError from ._exception import FBchatException, FBchatFacebookError
from ._thread import ThreadLocation, ThreadColor from ._thread import ThreadLocation, ThreadColor
from ._user import TypingStatus, User, ActiveStatus from ._user import TypingStatus, User, UserData, ActiveStatus
from ._group import Group from ._group import Group, GroupData
from ._page import Page from ._page import Page, PageData
from ._message import EmojiSize, MessageReaction, Mention, Message from ._message import EmojiSize, MessageReaction, Mention, Message
from ._attachment import Attachment from ._attachment import Attachment
from ._sticker import Sticker from ._sticker import Sticker
@@ -24,7 +24,7 @@ from ._quick_reply import (
QuickReplyEmail, QuickReplyEmail,
) )
from ._poll import Poll, PollOption from ._poll import Poll, PollOption
from ._plan import ACONTEXT, Plan from ._plan import PlanData
class Client: class Client:
@@ -204,7 +204,7 @@ class Client:
if data["id"] in ["0", 0]: if data["id"] in ["0", 0]:
# Skip invalid users # Skip invalid users
continue continue
users.append(User._from_all_fetch(self.session, data)) users.append(UserData._from_all_fetch(self.session, data))
return users return users
def search_for_users(self, name, limit=10): def search_for_users(self, name, limit=10):
@@ -224,7 +224,8 @@ class Client:
(j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_USER, params)) (j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_USER, params))
return [ return [
User._from_graphql(self.session, node) for node in j[name]["users"]["nodes"] UserData._from_graphql(self.session, node)
for node in j[name]["users"]["nodes"]
] ]
def search_for_pages(self, name, limit=10): def search_for_pages(self, name, limit=10):
@@ -243,7 +244,8 @@ class Client:
(j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_PAGE, params)) (j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_PAGE, params))
return [ return [
Page._from_graphql(self.session, node) for node in j[name]["pages"]["nodes"] PageData._from_graphql(self.session, node)
for node in j[name]["pages"]["nodes"]
] ]
def search_for_groups(self, name, limit=10): def search_for_groups(self, name, limit=10):
@@ -263,7 +265,7 @@ class Client:
(j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_GROUP, params)) (j,) = self.graphql_requests(_graphql.from_query(_graphql.SEARCH_GROUP, params))
return [ return [
Group._from_graphql(self.session, node) GroupData._from_graphql(self.session, node)
for node in j["viewer"]["groups"]["nodes"] for node in j["viewer"]["groups"]["nodes"]
] ]
@@ -288,12 +290,12 @@ class Client:
rtn = [] rtn = []
for node in j[name]["threads"]["nodes"]: for node in j[name]["threads"]["nodes"]:
if node["__typename"] == "User": if node["__typename"] == "User":
rtn.append(User._from_graphql(self.session, node)) rtn.append(UserData._from_graphql(self.session, node))
elif node["__typename"] == "MessageThread": elif node["__typename"] == "MessageThread":
# MessageThread => Group thread # MessageThread => Group thread
rtn.append(Group._from_graphql(self.session, node)) rtn.append(GroupData._from_graphql(self.session, node))
elif node["__typename"] == "Page": elif node["__typename"] == "Page":
rtn.append(Page._from_graphql(self.session, node)) rtn.append(PageData._from_graphql(self.session, node))
elif node["__typename"] == "Group": elif node["__typename"] == "Group":
# We don't handle Facebook "Groups" # We don't handle Facebook "Groups"
pass pass
@@ -493,16 +495,16 @@ class Client:
entry = entry["message_thread"] entry = entry["message_thread"]
if entry.get("thread_type") == "GROUP": if entry.get("thread_type") == "GROUP":
_id = entry["thread_key"]["thread_fbid"] _id = entry["thread_key"]["thread_fbid"]
rtn[_id] = Group._from_graphql(self.session, entry) rtn[_id] = GroupData._from_graphql(self.session, entry)
elif entry.get("thread_type") == "ONE_TO_ONE": elif entry.get("thread_type") == "ONE_TO_ONE":
_id = entry["thread_key"]["other_user_id"] _id = entry["thread_key"]["other_user_id"]
if pages_and_users.get(_id) is None: if pages_and_users.get(_id) is None:
raise FBchatException("Could not fetch thread {}".format(_id)) raise FBchatException("Could not fetch thread {}".format(_id))
entry.update(pages_and_users[_id]) entry.update(pages_and_users[_id])
if "first_name" in entry["type"]: if "first_name" in entry:
rtn[_id] = User._from_graphql(self.session, entry) rtn[_id] = UserData._from_graphql(self.session, entry)
else: else:
rtn[_id] = Page._from_graphql(self.session, entry) rtn[_id] = PageData._from_graphql(self.session, entry)
else: else:
raise FBchatException( raise FBchatException(
"{} had an unknown thread type: {}".format(thread_ids[i], entry) "{} had an unknown thread type: {}".format(thread_ids[i], entry)
@@ -547,9 +549,11 @@ class Client:
for node in j["viewer"]["message_threads"]["nodes"]: for node in j["viewer"]["message_threads"]["nodes"]:
_type = node.get("thread_type") _type = node.get("thread_type")
if _type == "GROUP": if _type == "GROUP":
rtn.append(Group._from_graphql(self.session, node)) rtn.append(GroupData._from_graphql(self.session, node))
elif _type == "ONE_TO_ONE": elif _type == "ONE_TO_ONE":
rtn.append(User._from_thread_fetch(self.session, node)) user = UserData._from_thread_fetch(self.session, node)
if user:
rtn.append(user)
else: else:
raise FBchatException( raise FBchatException(
"Unknown thread type: {}, with data: {}".format(_type, node) "Unknown thread type: {}, with data: {}".format(_type, node)
@@ -628,22 +632,6 @@ class Client:
j = self._payload_post("/ajax/mercury/get_poll_options", data) j = self._payload_post("/ajax/mercury/get_poll_options", data)
return [PollOption._from_graphql(m) for m in j] return [PollOption._from_graphql(m) for m in j]
def fetch_plan_info(self, plan_id):
"""Fetch `Plan` object from the plan id.
Args:
plan_id: Plan ID to fetch from
Returns:
Plan: `Plan` object
Raises:
FBchatException: If request failed
"""
data = {"event_reminder_id": plan_id}
j = self._payload_post("/ajax/eventreminder", data)
return Plan._from_fetch(j)
def _get_private_data(self): def _get_private_data(self):
(j,) = self.graphql_requests(_graphql.from_doc_id("1868889766468115", {})) (j,) = self.graphql_requests(_graphql.from_doc_id("1868889766468115", {}))
return j["viewer"] return j["viewer"]
@@ -692,56 +680,6 @@ class Client:
SEND METHODS SEND METHODS
""" """
def edit_plan(self, plan, new_plan):
"""Edit a plan.
Args:
plan (Plan): Plan to edit
new_plan: New plan
Raises:
FBchatException: If request failed
"""
data = {
"event_reminder_id": plan.id,
"delete": "false",
"date": _util.datetime_to_seconds(new_plan.time),
"location_name": new_plan.location or "",
"location_id": new_plan.location_id or "",
"title": new_plan.title,
"acontext": ACONTEXT,
}
j = self._payload_post("/ajax/eventreminder/submit", data)
def delete_plan(self, plan):
"""Delete a plan.
Args:
plan: Plan to delete
Raises:
FBchatException: If request failed
"""
data = {"event_reminder_id": plan.id, "delete": "true", "acontext": ACONTEXT}
j = self._payload_post("/ajax/eventreminder/submit", data)
def change_plan_participation(self, plan, take_part=True):
"""Change participation in a plan.
Args:
plan: Plan to take part in or not
take_part: Whether to take part in the plan
Raises:
FBchatException: If request failed
"""
data = {
"event_reminder_id": plan.id,
"guest_state": "GOING" if take_part else "DECLINED",
"acontext": ACONTEXT,
}
j = self._payload_post("/ajax/eventreminder/rsvp", data)
def update_poll_vote(self, poll_id, option_ids=[], new_options=[]): def update_poll_vote(self, poll_id, option_ids=[], new_options=[]):
"""Update a poll vote. """Update a poll vote.
@@ -1280,7 +1218,7 @@ class Client:
elif delta_type == "lightweight_event_create": elif delta_type == "lightweight_event_create":
self.on_plan_created( self.on_plan_created(
mid=mid, mid=mid,
plan=Plan._from_pull(delta["untypedData"]), plan=PlanData._from_pull(self.session, delta["untypedData"]),
author_id=author_id, author_id=author_id,
thread=get_thread(metadata), thread=get_thread(metadata),
at=at, at=at,
@@ -1292,7 +1230,7 @@ class Client:
elif delta_type == "lightweight_event_notify": elif delta_type == "lightweight_event_notify":
self.on_plan_ended( self.on_plan_ended(
mid=mid, mid=mid,
plan=Plan._from_pull(delta["untypedData"]), plan=PlanData._from_pull(self.session, delta["untypedData"]),
thread=get_thread(metadata), thread=get_thread(metadata),
at=at, at=at,
metadata=metadata, metadata=metadata,
@@ -1303,7 +1241,7 @@ class Client:
elif delta_type == "lightweight_event_update": elif delta_type == "lightweight_event_update":
self.on_plan_edited( self.on_plan_edited(
mid=mid, mid=mid,
plan=Plan._from_pull(delta["untypedData"]), plan=PlanData._from_pull(self.session, delta["untypedData"]),
author_id=author_id, author_id=author_id,
thread=get_thread(metadata), thread=get_thread(metadata),
at=at, at=at,
@@ -1315,7 +1253,7 @@ class Client:
elif delta_type == "lightweight_event_delete": elif delta_type == "lightweight_event_delete":
self.on_plan_deleted( self.on_plan_deleted(
mid=mid, mid=mid,
plan=Plan._from_pull(delta["untypedData"]), plan=PlanData._from_pull(self.session, delta["untypedData"]),
author_id=author_id, author_id=author_id,
thread=get_thread(metadata), thread=get_thread(metadata),
at=at, at=at,
@@ -1328,7 +1266,7 @@ class Client:
take_part = delta["untypedData"]["guest_status"] == "GOING" take_part = delta["untypedData"]["guest_status"] == "GOING"
self.on_plan_participation( self.on_plan_participation(
mid=mid, mid=mid,
plan=Plan._from_pull(delta["untypedData"]), plan=PlanData._from_pull(self.session, delta["untypedData"]),
take_part=take_part, take_part=take_part,
author_id=author_id, author_id=author_id,
thread=get_thread(metadata), thread=get_thread(metadata),
@@ -1424,18 +1362,15 @@ class Client:
elif d.get("deltaMessageReply"): elif d.get("deltaMessageReply"):
i = d["deltaMessageReply"] i = d["deltaMessageReply"]
thread = get_thread(metadata)
metadata = i["message"]["messageMetadata"] metadata = i["message"]["messageMetadata"]
replied_to = Message._from_reply( replied_to = MessageData._from_reply(thread, i["repliedToMessage"])
self.session, i["repliedToMessage"] message = MessageData._from_reply(thread, i["message"], replied_to)
)
message = Message._from_reply(
self.session, i["message"], replied_to
)
self.on_message( self.on_message(
mid=message.id, mid=message.id,
author_id=message.author, author_id=message.author,
message_object=message, message_object=message,
thread=get_thread(metadata), thread=thread,
at=message.created_at, at=message.created_at,
metadata=metadata, metadata=metadata,
msg=m, msg=m,
@@ -1443,18 +1378,19 @@ class Client:
# New message # New message
elif delta.get("class") == "NewMessage": elif delta.get("class") == "NewMessage":
thread = get_thread(metadata)
self.on_message( self.on_message(
mid=mid, mid=mid,
author_id=author_id, author_id=author_id,
message_object=Message._from_pull( message_object=MessageData._from_pull(
self.session, thread,
delta, delta,
mid=mid, mid=mid,
tags=metadata.get("tags"), tags=metadata.get("tags"),
author=author_id, author=author_id,
created_at=at, created_at=at,
), ),
thread=get_thread(metadata), thread=thread,
at=at, at=at,
metadata=metadata, metadata=metadata,
msg=m, msg=m,

View File

@@ -12,32 +12,9 @@ class Group(_thread.ThreadABC):
session = attr.ib(type=_session.Session) session = attr.ib(type=_session.Session)
#: The group's unique identifier. #: The group's unique identifier.
id = attr.ib(converter=str) id = attr.ib(converter=str)
#: The group's picture
photo = attr.ib(None) def _to_send_data(self):
#: The name of the group return {"thread_fbid": self.id}
name = attr.ib(None)
#: Datetime when the group was last active / when the last message was sent
last_active = attr.ib(None)
#: Number of messages in the group
message_count = attr.ib(None)
#: Set `Plan`
plan = attr.ib(None)
#: Unique list (set) of the group thread's participant user IDs
participants = attr.ib(factory=set)
#: A dictionary, containing user nicknames mapped to their IDs
nicknames = attr.ib(factory=dict)
#: A `ThreadColor`. The groups's message color
color = attr.ib(None)
#: The groups's default emoji
emoji = attr.ib(None)
# Set containing user IDs of thread admins
admins = attr.ib(factory=set)
# True if users need approval to join
approval_mode = attr.ib(None)
# Set containing user IDs requesting to join
approval_requests = attr.ib(factory=set)
# Link for joining group
join_link = attr.ib(None)
def add_participants(self, user_ids: Iterable[str]): def add_participants(self, user_ids: Iterable[str]):
"""Add users to the group. """Add users to the group.
@@ -151,6 +128,41 @@ class Group(_thread.ThreadABC):
""" """
self._users_approval(user_ids, False) self._users_approval(user_ids, False)
@attrs_default
class GroupData(Group):
"""Represents data about a Facebook group.
Inherits `Group`, and implements `ThreadABC`.
"""
#: The group's picture
photo = attr.ib(None)
#: The name of the group
name = attr.ib(None)
#: Datetime when the group was last active / when the last message was sent
last_active = attr.ib(None)
#: Number of messages in the group
message_count = attr.ib(None)
#: Set `Plan`
plan = attr.ib(None)
#: Unique list (set) of the group thread's participant user IDs
participants = attr.ib(factory=set)
#: A dictionary, containing user nicknames mapped to their IDs
nicknames = attr.ib(factory=dict)
#: A `ThreadColor`. The groups's message color
color = attr.ib(None)
#: The groups's default emoji
emoji = attr.ib(None)
# Set containing user IDs of thread admins
admins = attr.ib(factory=set)
# True if users need approval to join
approval_mode = attr.ib(None)
# Set containing user IDs requesting to join
approval_requests = attr.ib(factory=set)
# Link for joining group
join_link = attr.ib(None)
@classmethod @classmethod
def _from_graphql(cls, session, data): def _from_graphql(cls, session, data):
if data.get("image") is None: if data.get("image") is None:
@@ -163,7 +175,9 @@ class Group(_thread.ThreadABC):
) )
plan = None plan = None
if data.get("event_reminders") and data["event_reminders"].get("nodes"): if data.get("event_reminders") and data["event_reminders"].get("nodes"):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls( return cls(
session=session, session=session,
@@ -195,9 +209,6 @@ class Group(_thread.ThreadABC):
plan=plan, plan=plan,
) )
def _to_send_data(self):
return {"thread_fbid": self.id}
@attrs_default @attrs_default
class NewGroup(_thread.ThreadABC): class NewGroup(_thread.ThreadABC):

View File

@@ -50,21 +50,21 @@ class Mention:
#: The thread ID the mention is pointing at #: The thread ID the mention is pointing at
thread_id = attr.ib() thread_id = attr.ib()
#: The character where the mention starts #: The character where the mention starts
offset = attr.ib(0) offset = attr.ib()
#: The length of the mention #: The length of the mention
length = attr.ib(10) length = attr.ib()
@classmethod @classmethod
def _from_range(cls, data): def _from_range(cls, data):
return cls( return cls(
thread_id=data.get("entity", {}).get("id"), thread_id=data["entity"]["id"],
offset=data.get("offset"), offset=data["offset"],
length=data.get("length"), length=data["length"],
) )
@classmethod @classmethod
def _from_prng(cls, data): def _from_prng(cls, data):
return cls(thread_id=data.get("i"), offset=data.get("o"), length=data.get("l")) return cls(thread_id=data["i"], offset=data["o"], length=data["l"])
def _to_send_data(self, i): def _to_send_data(self, i):
return { return {
@@ -79,42 +79,15 @@ class Mention:
class Message: class Message:
"""Represents a Facebook message.""" """Represents a Facebook message."""
# TODO: Make these fields required! #: The thread that this message belongs to.
#: The session to use when making requests. thread = attr.ib(type="_thread.ThreadABC")
session = attr.ib(None, type=_session.Session) #: The message ID.
#: The message ID id = attr.ib(converter=str)
id = attr.ib(None, converter=str)
#: The actual message @property
text = attr.ib(None) def session(self):
#: A list of `Mention` objects """The session to use when making requests."""
mentions = attr.ib(factory=list) return self.thread.session
#: A `EmojiSize`. Size of a sent emoji
emoji_size = attr.ib(None)
#: ID of the sender
author = attr.ib(None)
#: Datetime of when the message was sent
created_at = attr.ib(None)
#: Whether the message is read
is_read = attr.ib(None)
#: A list of people IDs who read the message, works only with `Client.fetch_thread_messages`
read_by = attr.ib(factory=list)
#: A dictionary with user's IDs as keys, and their `MessageReaction` as values
reactions = attr.ib(factory=dict)
#: A `Sticker`
sticker = attr.ib(None)
#: A list of attachments
attachments = attr.ib(factory=list)
#: A list of `QuickReply`
quick_replies = attr.ib(factory=list)
#: Whether the message is unsent (deleted for everyone)
unsent = attr.ib(False)
#: Message ID you want to reply to
reply_to_id = attr.ib(None)
#: Replied message
replied_to = attr.ib(None)
#: Whether the message was forwarded
forwarded = attr.ib(False)
def unsend(self): def unsend(self):
"""Unsend the message (removes it for everyone).""" """Unsend the message (removes it for everyone)."""
@@ -125,7 +98,7 @@ class Message:
"""React to the message, or removes reaction. """React to the message, or removes reaction.
Args: Args:
reaction: Reaction emoji to use, if None removes reaction reaction: Reaction emoji to use, if ``None`` removes reaction
""" """
data = { data = {
"action": "ADD_REACTION" if reaction else "REMOVE_REACTION", "action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
@@ -138,27 +111,22 @@ class Message:
j = self.session._payload_post("/webgraphql/mutation", data) j = self.session._payload_post("/webgraphql/mutation", data)
_util.handle_graphql_errors(j) _util.handle_graphql_errors(j)
@classmethod def fetch(self) -> "MessageData":
def from_fetch(cls, thread, message_id: str) -> "Message": """Fetch fresh `MessageData` object."""
"""Fetch `Message` object from the given message id. message_info = self.thread._forced_fetch(self.id).get("message")
return MessageData._from_graphql(self.thread, message_info)
Args: @staticmethod
message_id: Message ID to fetch from def format_mentions(text, *args, **kwargs):
"""
message_info = thread._forced_fetch(message_id).get("message")
return Message._from_graphql(thread.session, message_info)
@classmethod
def format_mentions(cls, text, *args, **kwargs):
"""Like `str.format`, but takes tuples with a thread id and text instead. """Like `str.format`, but takes tuples with a thread id and text instead.
Return a `Message` object, with the formatted string and relevant mentions. Return a tuple, with the formatted string and relevant mentions.
>>> Message.format_mentions("Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael")) >>> Message.format_mentions("Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael"))
<Message (None): "Hey 'Peter'! My name is Michael", mentions=[<Mention 1234: offset=4 length=7>, <Mention 4321: offset=24 length=7>] emoji_size=None attachments=[]> ("Hey 'Peter'! My name is Michael", [<Mention 1234: offset=4 length=7>, <Mention 4321: offset=24 length=7>])
>>> Message.format_mentions("Hey {p}! My name is {}", ("1234", "Michael"), p=("4321", "Peter")) >>> Message.format_mentions("Hey {p}! My name is {}", ("1234", "Michael"), p=("4321", "Peter"))
<Message (None): 'Hey Peter! My name is Michael', mentions=[<Mention 4321: offset=4 length=5>, <Mention 1234: offset=22 length=7>] emoji_size=None attachments=[]> ('Hey Peter! My name is Michael', [<Mention 4321: offset=4 length=5>, <Mention 1234: offset=22 length=7>])
""" """
result = "" result = ""
mentions = list() mentions = list()
@@ -196,7 +164,46 @@ class Message:
) )
offset += len(name) offset += len(name)
return cls(text=result, mentions=mentions) return result, mentions
@attrs_default
class MessageData(Message):
"""Represents data in a Facebook message.
Inherits `Message`.
"""
#: ID of the sender
author = attr.ib()
#: Datetime of when the message was sent
created_at = attr.ib()
#: The actual message
text = attr.ib(None)
#: A list of `Mention` objects
mentions = attr.ib(factory=list)
#: A `EmojiSize`. Size of a sent emoji
emoji_size = attr.ib(None)
#: Whether the message is read
is_read = attr.ib(None)
#: A list of people IDs who read the message, works only with `Client.fetch_thread_messages`
read_by = attr.ib(factory=list)
#: A dictionary with user's IDs as keys, and their `MessageReaction` as values
reactions = attr.ib(factory=dict)
#: A `Sticker`
sticker = attr.ib(None)
#: A list of attachments
attachments = attr.ib(factory=list)
#: A list of `QuickReply`
quick_replies = attr.ib(factory=list)
#: Whether the message is unsent (deleted for everyone)
unsent = attr.ib(False)
#: Message ID you want to reply to
reply_to_id = attr.ib(None)
#: Replied message
replied_to = attr.ib(None)
#: Whether the message was forwarded
forwarded = attr.ib(False)
@staticmethod @staticmethod
def _get_forwarded_from_tags(tags): def _get_forwarded_from_tags(tags):
@@ -204,52 +211,6 @@ class Message:
return False return False
return any(map(lambda tag: "forward" in tag or "copy" in tag, tags)) return any(map(lambda tag: "forward" in tag or "copy" in tag, tags))
def _to_send_data(self):
data = {}
if self.text or self.sticker or self.emoji_size:
data["action_type"] = "ma-type:user-generated-message"
if self.text:
data["body"] = self.text
for i, mention in enumerate(self.mentions):
data.update(mention._to_send_data(i))
if self.emoji_size:
if self.text:
data["tags[0]"] = "hot_emoji_size:" + self.emoji_size.name.lower()
else:
data["sticker_id"] = self.emoji_size.value
if self.sticker:
data["sticker_id"] = self.sticker.id
if self.quick_replies:
xmd = {"quick_replies": []}
for quick_reply in self.quick_replies:
# TODO: Move this to `_quick_reply.py`
q = dict()
q["content_type"] = quick_reply._type
q["payload"] = quick_reply.payload
q["external_payload"] = quick_reply.external_payload
q["data"] = quick_reply.data
if quick_reply.is_response:
q["ignore_for_webhook"] = False
if isinstance(quick_reply, _quick_reply.QuickReplyText):
q["title"] = quick_reply.title
if not isinstance(quick_reply, _quick_reply.QuickReplyLocation):
q["image_url"] = quick_reply.image_url
xmd["quick_replies"].append(q)
if len(self.quick_replies) == 1 and self.quick_replies[0].is_response:
xmd["quick_replies"] = xmd["quick_replies"][0]
data["platform_xmd"] = json.dumps(xmd)
if self.reply_to_id:
data["replied_to_message_id"] = self.reply_to_id
return data
@staticmethod @staticmethod
def _parse_quick_replies(data): def _parse_quick_replies(data):
if data: if data:
@@ -261,7 +222,7 @@ class Message:
return [] return []
@classmethod @classmethod
def _from_graphql(cls, session, data, read_receipts=None): def _from_graphql(cls, thread, data, read_receipts=None):
if data.get("message_sender") is None: if data.get("message_sender") is None:
data["message_sender"] = {} data["message_sender"] = {}
if data.get("message") is None: if data.get("message") is None:
@@ -287,15 +248,15 @@ class Message:
replied_to = cls._from_graphql(data["replied_to_message"]["message"]) replied_to = cls._from_graphql(data["replied_to_message"]["message"])
return cls( return cls(
session=session, thread=thread,
id=str(data["message_id"]), id=str(data["message_id"]),
author=str(data["message_sender"]["id"]),
created_at=created_at,
text=data["message"].get("text"), text=data["message"].get("text"),
mentions=[ mentions=[
Mention._from_range(m) for m in data["message"].get("ranges") or () Mention._from_range(m) for m in data["message"].get("ranges") or ()
], ],
emoji_size=EmojiSize._from_tags(tags), emoji_size=EmojiSize._from_tags(tags),
author=str(data["message_sender"]["id"]),
created_at=created_at,
is_read=not data["unread"] if data.get("unread") is not None else None, is_read=not data["unread"] if data.get("unread") is not None else None,
read_by=[ read_by=[
receipt["actor"]["id"] receipt["actor"]["id"]
@@ -316,7 +277,7 @@ class Message:
) )
@classmethod @classmethod
def _from_reply(cls, session, data, replied_to=None): def _from_reply(cls, thread, data, replied_to=None):
tags = data["messageMetadata"].get("tags") tags = data["messageMetadata"].get("tags")
metadata = data.get("messageMetadata", {}) metadata = data.get("messageMetadata", {})
@@ -343,16 +304,16 @@ class Message:
) )
return cls( return cls(
session=session, thread=thread,
id=metadata.get("messageId"), id=metadata.get("messageId"),
author=str(metadata["actorFbId"]),
created_at=_util.millis_to_datetime(metadata["timestamp"]),
text=data.get("body"), text=data.get("body"),
mentions=[ mentions=[
Mention._from_prng(m) Mention._from_prng(m)
for m in _util.parse_json(data.get("data", {}).get("prng", "[]")) for m in _util.parse_json(data.get("data", {}).get("prng", "[]"))
], ],
emoji_size=EmojiSize._from_tags(tags), emoji_size=EmojiSize._from_tags(tags),
author=str(metadata.get("actorFbId")),
created_at=_util.millis_to_datetime(metadata.get("timestamp")),
sticker=sticker, sticker=sticker,
attachments=attachments, attachments=attachments,
quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")), quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")),
@@ -363,9 +324,7 @@ class Message:
) )
@classmethod @classmethod
def _from_pull( def _from_pull(cls, thread, data, mid, tags, author, created_at):
cls, session, data, mid=None, tags=None, author=None, created_at=None
):
mentions = [] mentions = []
if data.get("data") and data["data"].get("prng"): if data.get("data") and data["data"].get("prng"):
try: try:
@@ -412,13 +371,13 @@ class Message:
) )
return cls( return cls(
session=session, thread=thread,
id=mid, id=mid,
author=author,
created_at=created_at,
text=data.get("body"), text=data.get("body"),
mentions=mentions, mentions=mentions,
emoji_size=EmojiSize._from_tags(tags), emoji_size=EmojiSize._from_tags(tags),
author=author,
created_at=created_at,
sticker=sticker, sticker=sticker,
attachments=attachments, attachments=attachments,
unsent=unsent, unsent=unsent,

View File

@@ -11,10 +11,22 @@ class Page(_thread.ThreadABC):
session = attr.ib(type=_session.Session) session = attr.ib(type=_session.Session)
#: The unique identifier of the page. #: The unique identifier of the page.
id = attr.ib(converter=str) id = attr.ib(converter=str)
def _to_send_data(self):
return {"other_user_fbid": self.id}
@attrs_default
class PageData(Page):
"""Represents data about a Facebook page.
Inherits `Page`, and implements `ThreadABC`.
"""
#: The page's picture #: The page's picture
photo = attr.ib(None) photo = attr.ib()
#: The name of the page #: The name of the page
name = attr.ib(None) name = attr.ib()
#: Datetime when the thread was last active / when the last message was sent #: Datetime when the thread was last active / when the last message was sent
last_active = attr.ib(None) last_active = attr.ib(None)
#: Number of messages in the thread #: Number of messages in the thread
@@ -32,9 +44,6 @@ class Page(_thread.ThreadABC):
#: The page's category #: The page's category
category = attr.ib(None) category = attr.ib(None)
def _to_send_data(self):
return {"other_user_fbid": self.id}
@classmethod @classmethod
def _from_graphql(cls, session, data): def _from_graphql(cls, session, data):
if data.get("profile_picture") is None: if data.get("profile_picture") is None:
@@ -43,7 +52,9 @@ class Page(_thread.ThreadABC):
data["city"] = {} data["city"] = {}
plan = None plan = None
if data.get("event_reminders") and data["event_reminders"].get("nodes"): if data.get("event_reminders") and data["event_reminders"].get("nodes"):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls( return cls(
session=session, session=session,
@@ -52,7 +63,7 @@ class Page(_thread.ThreadABC):
city=data.get("city").get("name"), city=data.get("city").get("name"),
category=data.get("category_type"), category=data.get("category_type"),
photo=Image._from_uri(data["profile_picture"]), photo=Image._from_uri(data["profile_picture"]),
name=data.get("name"), name=data["name"],
message_count=data.get("messages_count"), message_count=data.get("messages_count"),
plan=plan, plan=plan,
) )

View File

@@ -1,7 +1,8 @@
import attr import attr
import datetime
import json import json
from ._core import attrs_default, Enum from ._core import attrs_default, Enum
from . import _util from . import _exception, _util, _session
class GuestStatus(Enum): class GuestStatus(Enum):
@@ -19,14 +20,96 @@ ACONTEXT = {
@attrs_default @attrs_default
class Plan: class Plan:
"""Represents a plan.""" """Base model for plans."""
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
#: The plan's unique identifier.
id = attr.ib(converter=str)
def fetch(self) -> "PlanData":
"""Fetch fresh `PlanData` object."""
data = {"event_reminder_id": self.id}
j = self.session._payload_post("/ajax/eventreminder", data)
return PlanData._from_fetch(self.session, j)
@classmethod
def _create(
cls,
thread,
name: str,
at: datetime.datetime,
location_name: str = None,
location_id: str = None,
):
data = {
"event_type": "EVENT",
"event_time": _util.datetime_to_seconds(at),
"title": name,
"thread_id": thread.id,
"location_id": location_id or "",
"location_name": location_name or "",
"acontext": ACONTEXT,
}
j = thread.session._payload_post("/ajax/eventreminder/create", data)
if "error" in j:
raise _exception.FBchatFacebookError(
"Failed creating plan: {}".format(j["error"]),
fb_error_message=j["error"],
)
def edit(
self,
name: str,
at: datetime.datetime,
location_name: str = None,
location_id: str = None,
):
"""Edit the plan.
# TODO: Arguments
"""
data = {
"event_reminder_id": self.id,
"delete": "false",
"date": _util.datetime_to_seconds(at),
"location_name": location_name or "",
"location_id": location_id or "",
"title": name,
"acontext": ACONTEXT,
}
j = self.session._payload_post("/ajax/eventreminder/submit", data)
def delete(self):
"""Delete the plan."""
data = {"event_reminder_id": self.id, "delete": "true", "acontext": ACONTEXT}
j = self.session._payload_post("/ajax/eventreminder/submit", data)
def _change_participation(self):
data = {
"event_reminder_id": self.id,
"guest_state": "GOING" if take_part else "DECLINED",
"acontext": ACONTEXT,
}
j = self.session._payload_post("/ajax/eventreminder/rsvp", data)
def participate(self):
"""Set yourself as GOING/participating to the plan."""
self._change_participation(True)
def decline(self):
"""Set yourself as having DECLINED the plan."""
self._change_participation(False)
@attrs_default
class PlanData(Plan):
"""Represents data about a plan."""
#: Plan time (datetime), only precise down to the minute #: Plan time (datetime), only precise down to the minute
time = attr.ib() time = attr.ib()
#: Plan title #: Plan title
title = attr.ib() title = attr.ib()
#: ID of the plan
id = attr.ib(None)
#: Plan location name #: Plan location name
location = attr.ib(None, converter=lambda x: x or "") location = attr.ib(None, converter=lambda x: x or "")
#: Plan location ID #: Plan location ID
@@ -64,8 +147,9 @@ class Plan:
] ]
@classmethod @classmethod
def _from_pull(cls, data): def _from_pull(cls, session, data):
return cls( return cls(
session=session,
id=data.get("event_id"), id=data.get("event_id"),
time=_util.seconds_to_datetime(int(data.get("event_time"))), time=_util.seconds_to_datetime(int(data.get("event_time"))),
title=data.get("event_title"), title=data.get("event_title"),
@@ -79,8 +163,9 @@ class Plan:
) )
@classmethod @classmethod
def _from_fetch(cls, data): def _from_fetch(cls, session, data):
return cls( return cls(
session=session,
id=data.get("oid"), id=data.get("oid"),
time=_util.seconds_to_datetime(data.get("event_time")), time=_util.seconds_to_datetime(data.get("event_time")),
title=data.get("title"), title=data.get("title"),
@@ -91,8 +176,9 @@ class Plan:
) )
@classmethod @classmethod
def _from_graphql(cls, data): def _from_graphql(cls, session, data):
return cls( return cls(
session=session,
id=data.get("id"), id=data.get("id"),
time=_util.seconds_to_datetime(data.get("time")), time=_util.seconds_to_datetime(data.get("time")),
title=data.get("event_title"), title=data.get("event_title"),

View File

@@ -73,6 +73,17 @@ class ThreadABC(metaclass=abc.ABCMeta):
def _to_send_data(self) -> MutableMapping[str, str]: def _to_send_data(self) -> MutableMapping[str, str]:
raise NotImplementedError raise NotImplementedError
# Note:
# You can go out of Facebook's spec with `self.session._do_send_request`!
#
# A few examples:
# - You can send a sticker and an emoji at the same time
# - You can wave, send a sticker and text at the same time
# - You can reply to a message with a sticker
#
# We won't support those use cases, it'll make for a confusing API!
# If we absolutely need to in the future, we can always add extra functionality
def wave(self, first: bool = True) -> str: def wave(self, first: bool = True) -> str:
"""Wave hello to the thread. """Wave hello to the thread.
@@ -85,73 +96,127 @@ class ThreadABC(metaclass=abc.ABCMeta):
"INITIATED" if first else "RECIPROCATED" "INITIATED" if first else "RECIPROCATED"
) )
data["lightweight_action_attachment[lwa_type]"] = "WAVE" data["lightweight_action_attachment[lwa_type]"] = "WAVE"
# TODO: This!
# if isinstance(self, _user.User):
# data["specific_to_list[0]"] = "fbid:{}".format(thread_id)
message_id, thread_id = self.session._do_send_request(data) message_id, thread_id = self.session._do_send_request(data)
return message_id return message_id
def send(self, message) -> str: def send_text(
"""Send message to the thread. self,
text: str,
mentions: Iterable["_message.Mention"] = None,
files: Iterable[Tuple[str, str]] = None,
reply_to_id: str = None,
) -> str:
"""Send a message to the thread.
Args: Args:
message (Message): Message to send text: Text to send
mentions: Optional mentions
files: Optional tuples, each containing an uploaded file's ID and mimetype
reply_to_id: Optional message to reply to
Returns: Returns:
:ref:`Message ID <intro_message_ids>` of the sent message :ref:`Message ID <intro_message_ids>` of the sent message
""" """
data = self._to_send_data() data = self._to_send_data()
data.update(message._to_send_data()) data["action_type"] = "ma-type:user-generated-message"
if text is None: # To support `send_files`
data["body"] = text
for i, mention in enumerate(mentions or ()):
data.update(mention._to_send_data(i))
if files:
data["has_attachment"] = True
for i, (file_id, mimetype) in enumerate(files or ()):
data["{}s[{}]".format(_util.mimetype_to_key(mimetype), i)] = file_id
if reply_to_id:
data["replied_to_message_id"] = reply_to_id
return self.session._do_send_request(data) return self.session._do_send_request(data)
def _send_location(self, current, latitude, longitude, message=None) -> str: def send_emoji(self, emoji: str, size: "_message.EmojiSize") -> str:
"""Send an emoji to the thread.
Args:
emoji: The emoji to send
size: The size of the emoji
Returns:
:ref:`Message ID <intro_message_ids>` of the sent message
"""
data = self._to_send_data()
data["action_type"] = "ma-type:user-generated-message"
data["body"] = emoji
data["tags[0]"] = "hot_emoji_size:{}".format(size.name.lower())
return self.session._do_send_request(data)
def send_sticker(self, sticker_id: str) -> str:
"""Send a sticker to the thread.
Args:
sticker_id: ID of the sticker to send
Returns:
:ref:`Message ID <intro_message_ids>` of the sent message
"""
data = self._to_send_data()
data["action_type"] = "ma-type:user-generated-message"
data["sticker_id"] = sticker_id
return self.session._do_send_request(data)
def _send_location(self, current, latitude, longitude) -> str:
data = self._to_send_data() data = self._to_send_data()
if message is not None:
data.update(message._to_send_data())
data["action_type"] = "ma-type:user-generated-message" data["action_type"] = "ma-type:user-generated-message"
data["location_attachment[coordinates][latitude]"] = latitude data["location_attachment[coordinates][latitude]"] = latitude
data["location_attachment[coordinates][longitude]"] = longitude data["location_attachment[coordinates][longitude]"] = longitude
data["location_attachment[is_current_location]"] = current data["location_attachment[is_current_location]"] = current
return self.session._do_send_request(data) return self.session._do_send_request(data)
def send_location(self, latitude: float, longitude: float, message=None): def send_location(self, latitude: float, longitude: float):
"""Send a given location to a thread as the user's current location. """Send a given location to a thread as the user's current location.
Args: Args:
latitude: The location latitude latitude: The location latitude
longitude: The location longitude longitude: The location longitude
message: Additional message
""" """
self._send_location( self._send_location(True, latitude=latitude, longitude=longitude)
True, latitude=latitude, longitude=longitude, message=message,
)
def send_pinned_location(self, latitude: float, longitude: float, message=None): def send_pinned_location(self, latitude: float, longitude: float):
"""Send a given location to a thread as a pinned location. """Send a given location to a thread as a pinned location.
Args: Args:
latitude: The location latitude latitude: The location latitude
longitude: The location longitude longitude: The location longitude
message: Additional message
""" """
self._send_location( self._send_location(False, latitude=latitude, longitude=longitude)
False, latitude=latitude, longitude=longitude, message=message,
)
def send_files(self, files: Iterable[Tuple[str, str]], message): def send_files(self, files: Iterable[Tuple[str, str]]):
"""Send files from file IDs to a thread. """Send files from file IDs to a thread.
`files` should be a list of tuples, with a file's ID and mimetype. `files` should be a list of tuples, with a file's ID and mimetype.
""" """
data = self._to_send_data() return self.send_text(text=None, files=files)
data.update(message._to_send_data())
data["action_type"] = "ma-type:user-generated-message"
data["has_attachment"] = True
for i, (file_id, mimetype) in enumerate(files): # xmd = {"quick_replies": []}
data["{}s[{}]".format(_util.mimetype_to_key(mimetype), i)] = file_id # for quick_reply in quick_replies:
# # TODO: Move this to `_quick_reply.py`
return self.session._do_send_request(data) # q = dict()
# q["content_type"] = quick_reply._type
# q["payload"] = quick_reply.payload
# q["external_payload"] = quick_reply.external_payload
# q["data"] = quick_reply.data
# if quick_reply.is_response:
# q["ignore_for_webhook"] = False
# if isinstance(quick_reply, _quick_reply.QuickReplyText):
# q["title"] = quick_reply.title
# if not isinstance(quick_reply, _quick_reply.QuickReplyLocation):
# q["image_url"] = quick_reply.image_url
# xmd["quick_replies"].append(q)
# if len(quick_replies) == 1 and quick_replies[0].is_response:
# xmd["quick_replies"] = xmd["quick_replies"][0]
# data["platform_xmd"] = json.dumps(xmd)
# TODO: This! # TODO: This!
# def quick_reply(self, quick_reply, payload=None): # def quick_reply(self, quick_reply, payload=None):
@@ -253,8 +318,11 @@ class ThreadABC(metaclass=abc.ABCMeta):
read_receipts = j["message_thread"]["read_receipts"]["nodes"] read_receipts = j["message_thread"]["read_receipts"]["nodes"]
# TODO: May or may not be a good idea to attach the current thread?
# For now, we just create a new thread:
thread = self.__class__(session=self.session, id=self.id)
messages = [ messages = [
_message.Message._from_graphql(self.session, message, read_receipts) _message.MessageData._from_graphql(thread, message, read_receipts)
for message in j["message_thread"]["messages"]["nodes"] for message in j["message_thread"]["messages"]["nodes"]
] ]
messages.reverse() messages.reverse()
@@ -381,24 +449,10 @@ class ThreadABC(metaclass=abc.ABCMeta):
# TODO: Arguments # TODO: Arguments
Args: Args:
title: Name of the new plan name: Name of the new plan
at: When the plan is for at: When the plan is for
""" """
data = { return _plan.Plan._create(self, name, at, location_name, location_id)
"event_type": "EVENT",
"event_time": _util.datetime_to_seconds(at),
"title": name,
"thread_id": self.id,
"location_id": location_id or "",
"location_name": location_name or "",
"acontext": _plan.ACONTEXT,
}
j = self.session._payload_post("/ajax/eventreminder/create", data)
if "error" in j:
raise _exception.FBchatFacebookError(
"Failed creating plan: {}".format(j["error"]),
fb_error_message=j["error"],
)
def create_poll(self, question: str, options=Iterable[Tuple[str, bool]]): def create_poll(self, question: str, options=Iterable[Tuple[str, bool]]):
"""Create poll in a thread. """Create poll in a thread.

View File

@@ -1,5 +1,5 @@
import attr import attr
from ._core import attrs_default, Enum, Image from ._core import log, attrs_default, Enum, Image
from . import _util, _session, _plan, _thread from . import _util, _session, _plan, _thread
@@ -48,39 +48,13 @@ class User(_thread.ThreadABC):
session = attr.ib(type=_session.Session) session = attr.ib(type=_session.Session)
#: The user's unique identifier. #: The user's unique identifier.
id = attr.ib(converter=str) id = attr.ib(converter=str)
#: The user's picture
photo = attr.ib(None)
#: The name of the user
name = attr.ib(None)
#: Datetime when the thread was last active / when the last message was sent
last_active = attr.ib(None)
#: Number of messages in the thread
message_count = attr.ib(None)
#: Set `Plan`
plan = attr.ib(None)
#: The profile URL
url = attr.ib(None)
#: The users first name
first_name = attr.ib(None)
#: The users last name
last_name = attr.ib(None)
#: Whether the user and the client are friends
is_friend = attr.ib(None)
#: The user's gender
gender = attr.ib(None)
#: From 0 to 1. How close the client is to the user
affinity = attr.ib(None)
#: The user's nickname
nickname = attr.ib(None)
#: The clients nickname, as seen by the user
own_nickname = attr.ib(None)
#: A `ThreadColor`. The message color
color = attr.ib(None)
#: The default emoji
emoji = attr.ib(None)
def _to_send_data(self): def _to_send_data(self):
return {"other_user_fbid": self.id} return {
"other_user_fbid": self.id,
# The entry below is to support .wave
"specific_to_list[0]": "fbid:{}".format(self.id),
}
def confirm_friend_request(self): def confirm_friend_request(self):
"""Confirm a friend request, adding the user to your friend list.""" """Confirm a friend request, adding the user to your friend list."""
@@ -102,6 +76,45 @@ class User(_thread.ThreadABC):
data = {"fbid": self.id} data = {"fbid": self.id}
j = self.session._payload_post("/messaging/unblock_messages/?dpr=1", data) j = self.session._payload_post("/messaging/unblock_messages/?dpr=1", data)
@attrs_default
class UserData(User):
"""Represents data about a Facebook user.
Inherits `User`, and implements `ThreadABC`.
"""
#: The user's picture
photo = attr.ib()
#: The name of the user
name = attr.ib()
#: Whether the user and the client are friends
is_friend = attr.ib()
#: The users first name
first_name = attr.ib()
#: The users last name
last_name = attr.ib(None)
#: Datetime when the thread was last active / when the last message was sent
last_active = attr.ib(None)
#: Number of messages in the thread
message_count = attr.ib(None)
#: Set `Plan`
plan = attr.ib(None)
#: The profile URL. ``None`` for Messenger-only users
url = attr.ib(None)
#: The user's gender
gender = attr.ib(None)
#: From 0 to 1. How close the client is to the user
affinity = attr.ib(None)
#: The user's nickname
nickname = attr.ib(None)
#: The clients nickname, as seen by the user
own_nickname = attr.ib(None)
#: A `ThreadColor`. The message color
color = attr.ib(None)
#: The default emoji
emoji = attr.ib(None)
@classmethod @classmethod
def _from_graphql(cls, session, data): def _from_graphql(cls, session, data):
if data.get("profile_picture") is None: if data.get("profile_picture") is None:
@@ -109,23 +122,25 @@ class User(_thread.ThreadABC):
c_info = cls._parse_customization_info(data) c_info = cls._parse_customization_info(data)
plan = None plan = None
if data.get("event_reminders") and data["event_reminders"].get("nodes"): if data.get("event_reminders") and data["event_reminders"].get("nodes"):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls( return cls(
session=session, session=session,
id=data["id"], id=data["id"],
url=data.get("url"), url=data["url"],
first_name=data.get("first_name"), first_name=data["first_name"],
last_name=data.get("last_name"), last_name=data.get("last_name"),
is_friend=data.get("is_viewer_friend"), is_friend=data["is_viewer_friend"],
gender=GENDERS.get(data.get("gender")), gender=GENDERS.get(data["gender"]),
affinity=data.get("viewer_affinity"), affinity=data.get("viewer_affinity"),
nickname=c_info.get("nickname"), nickname=c_info.get("nickname"),
color=c_info.get("color"), color=c_info.get("color"),
emoji=c_info.get("emoji"), emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"), own_nickname=c_info.get("own_nickname"),
photo=Image._from_uri(data["profile_picture"]), photo=Image._from_uri(data["profile_picture"]),
name=data.get("name"), name=data["name"],
message_count=data.get("messages_count"), message_count=data.get("messages_count"),
plan=plan, plan=plan,
) )
@@ -141,38 +156,41 @@ class User(_thread.ThreadABC):
user = next( user = next(
p for p in participants if p["id"] == data["thread_key"]["other_user_id"] p for p in participants if p["id"] == data["thread_key"]["other_user_id"]
) )
if user["__typename"] != "User":
# TODO: Add Page._from_thread_fetch, and parse it there
log.warning("Tried to parse %s as a user.", user["__typename"])
return None
last_active = None last_active = None
if "last_message" in data: if "last_message" in data:
last_active = _util.millis_to_datetime( last_active = _util.millis_to_datetime(
int(data["last_message"]["nodes"][0]["timestamp_precise"]) int(data["last_message"]["nodes"][0]["timestamp_precise"])
) )
first_name = user.get("short_name") first_name = user["short_name"]
if first_name is None:
last_name = None
else:
last_name = user.get("name").split(first_name, 1).pop().strip() last_name = user.get("name").split(first_name, 1).pop().strip()
plan = None plan = None
if data.get("event_reminders") and data["event_reminders"].get("nodes"): if data.get("event_reminders") and data["event_reminders"].get("nodes"):
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) plan = _plan.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls( return cls(
session=session, session=session,
id=user["id"], id=user["id"],
url=user.get("url"), url=user["url"],
name=user.get("name"), name=user["name"],
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
is_friend=user.get("is_viewer_friend"), is_friend=user["is_viewer_friend"],
gender=GENDERS.get(user.get("gender")), gender=GENDERS.get(user["gender"]),
affinity=user.get("affinity"),
nickname=c_info.get("nickname"), nickname=c_info.get("nickname"),
color=c_info.get("color"), color=c_info.get("color"),
emoji=c_info.get("emoji"), emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"), own_nickname=c_info.get("own_nickname"),
photo=Image._from_uri(user["big_image_src"]), photo=Image._from_uri(user["big_image_src"]),
message_count=data.get("messages_count"), message_count=data["messages_count"],
last_active=last_active, last_active=last_active,
plan=plan, plan=plan,
) )
@@ -182,12 +200,12 @@ class User(_thread.ThreadABC):
return cls( return cls(
session=session, session=session,
id=data["id"], id=data["id"],
first_name=data.get("firstName"), first_name=data["firstName"],
url=data.get("uri"), url=data["uri"],
photo=Image(url=data.get("thumbSrc")), photo=Image(url=data["thumbSrc"]),
name=data.get("name"), name=data["name"],
is_friend=data.get("is_friend"), is_friend=data["is_friend"],
gender=GENDERS.get(data.get("gender")), gender=GENDERS.get(data["gender"]),
) )

View File

@@ -2,11 +2,7 @@ import datetime
import json import json
import time import time
import random import random
import contextlib
import mimetypes
import urllib.parse import urllib.parse
import requests
from os import path
from ._core import log from ._core import log
from ._exception import ( from ._exception import (
@@ -188,39 +184,6 @@ def mimetype_to_key(mimetype):
return "file_id" return "file_id"
def get_files_from_urls(file_urls):
files = []
for file_url in file_urls:
r = requests.get(file_url)
# We could possibly use r.headers.get('Content-Disposition'), see
# https://stackoverflow.com/a/37060758
file_name = path.basename(file_url).split("?")[0].split("#")[0]
files.append(
(
file_name,
r.content,
r.headers.get("Content-Type") or mimetypes.guess_type(file_name)[0],
)
)
return files
@contextlib.contextmanager
def get_files_from_paths(filenames):
files = []
for filename in filenames:
files.append(
(
path.basename(filename),
open(filename, "rb"),
mimetypes.guess_type(filename)[0],
)
)
yield files
for fn, fp, ft in files:
fp.close()
def get_url_parameters(url, *args): def get_url_parameters(url, *args):
params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
return [params[arg][0] for arg in args if params.get(arg)] return [params[arg][0] for arg in args if params.get(arg)]

View File

@@ -1,4 +1,4 @@
from fbchat._group import Group from fbchat._group import GroupData
def test_group_from_graphql(session): def test_group_from_graphql(session):
@@ -25,7 +25,7 @@ def test_group_from_graphql(session):
"joinable_mode": {"mode": "0", "link": ""}, "joinable_mode": {"mode": "0", "link": ""},
"event_reminders": {"nodes": []}, "event_reminders": {"nodes": []},
} }
assert Group( assert GroupData(
session=session, session=session,
id="11223344", id="11223344",
photo=None, photo=None,
@@ -41,4 +41,4 @@ def test_group_from_graphql(session):
approval_mode=False, approval_mode=False,
approval_requests=set(), approval_requests=set(),
join_link="", join_link="",
) == Group._from_graphql(session, data) ) == GroupData._from_graphql(session, data)

View File

@@ -4,6 +4,7 @@ from fbchat._message import (
EmojiSize, EmojiSize,
Mention, Mention,
Message, Message,
MessageData,
graphql_to_extensible_attachment, graphql_to_extensible_attachment,
) )
@@ -46,10 +47,25 @@ def test_graphql_to_extensible_attachment_dispatch(monkeypatch, obj, type_):
assert graphql_to_extensible_attachment(data) assert graphql_to_extensible_attachment(data)
def test_mention_to_send_data():
assert {
"profile_xmd[0][id]": "1234",
"profile_xmd[0][length]": 7,
"profile_xmd[0][offset]": 4,
"profile_xmd[0][type]": "p",
} == Mention(thread_id="1234", offset=4, length=7)._to_send_data(0)
assert {
"profile_xmd[1][id]": "4321",
"profile_xmd[1][length]": 7,
"profile_xmd[1][offset]": 24,
"profile_xmd[1][type]": "p",
} == Mention(thread_id="4321", offset=24, length=7)._to_send_data(1)
def test_message_format_mentions(): def test_message_format_mentions():
expected = Message( expected = (
text="Hey 'Peter'! My name is Michael", "Hey 'Peter'! My name is Michael",
mentions=[ [
Mention(thread_id="1234", offset=4, length=7), Mention(thread_id="1234", offset=4, length=7),
Mention(thread_id="4321", offset=24, length=7), Mention(thread_id="4321", offset=24, length=7),
], ],
@@ -63,63 +79,13 @@ def test_message_format_mentions():
def test_message_get_forwarded_from_tags(): def test_message_get_forwarded_from_tags():
assert not Message._get_forwarded_from_tags(None) assert not MessageData._get_forwarded_from_tags(None)
assert not Message._get_forwarded_from_tags(["hot_emoji_size:unknown"]) assert not MessageData._get_forwarded_from_tags(["hot_emoji_size:unknown"])
assert Message._get_forwarded_from_tags( assert MessageData._get_forwarded_from_tags(
["attachment:photo", "inbox", "sent", "source:chat:forward", "tq"] ["attachment:photo", "inbox", "sent", "source:chat:forward", "tq"]
) )
def test_message_to_send_data_minimal():
assert {"action_type": "ma-type:user-generated-message", "body": "Hey"} == Message(
text="Hey"
)._to_send_data()
def test_message_to_send_data_mentions():
msg = Message(
text="Hey 'Peter'! My name is Michael",
mentions=[
Mention(thread_id="1234", offset=4, length=7),
Mention(thread_id="4321", offset=24, length=7),
],
)
assert {
"action_type": "ma-type:user-generated-message",
"body": "Hey 'Peter'! My name is Michael",
"profile_xmd[0][id]": "1234",
"profile_xmd[0][length]": 7,
"profile_xmd[0][offset]": 4,
"profile_xmd[0][type]": "p",
"profile_xmd[1][id]": "4321",
"profile_xmd[1][length]": 7,
"profile_xmd[1][offset]": 24,
"profile_xmd[1][type]": "p",
} == msg._to_send_data()
def test_message_to_send_data_sticker():
msg = Message(sticker=fbchat.Sticker(id="123"))
assert {
"action_type": "ma-type:user-generated-message",
"sticker_id": "123",
} == msg._to_send_data()
def test_message_to_send_data_emoji():
msg = Message(text="😀", emoji_size=EmojiSize.LARGE)
assert {
"action_type": "ma-type:user-generated-message",
"body": "😀",
"tags[0]": "hot_emoji_size:large",
} == msg._to_send_data()
msg = Message(emoji_size=EmojiSize.LARGE)
assert {
"action_type": "ma-type:user-generated-message",
"sticker_id": "369239383222810",
} == msg._to_send_data()
@pytest.mark.skip(reason="need to be added") @pytest.mark.skip(reason="need to be added")
def test_message_to_send_data_quick_replies(): def test_message_to_send_data_quick_replies():
raise NotImplementedError raise NotImplementedError

View File

@@ -1,5 +1,5 @@
import fbchat import fbchat
from fbchat._page import Page from fbchat._page import PageData
def test_page_from_graphql(session): def test_page_from_graphql(session):
@@ -11,7 +11,7 @@ def test_page_from_graphql(session):
"category_type": "SCHOOL", "category_type": "SCHOOL",
"city": None, "city": None,
} }
assert Page( assert PageData(
session=session, session=session,
id="123456", id="123456",
photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
@@ -19,4 +19,4 @@ def test_page_from_graphql(session):
url="https://www.facebook.com/some-school/", url="https://www.facebook.com/some-school/",
city=None, city=None,
category="SCHOOL", category="SCHOOL",
) == Page._from_graphql(session, data) ) == PageData._from_graphql(session, data)

View File

@@ -1,9 +1,11 @@
import datetime import datetime
from fbchat._plan import GuestStatus, Plan from fbchat._plan import GuestStatus, PlanData
def test_plan_properties(): def test_plan_properties(session):
plan = Plan( plan = PlanData(
session=session,
id="1234567890",
time=..., time=...,
title=..., title=...,
guests={ guests={
@@ -18,7 +20,7 @@ def test_plan_properties():
assert plan.declined == ["4567"] assert plan.declined == ["4567"]
def test_plan_from_pull(): def test_plan_from_pull(session):
data = { data = {
"event_timezone": "", "event_timezone": "",
"event_creator_id": "1234", "event_creator_id": "1234",
@@ -35,7 +37,8 @@ def test_plan_from_pull():
'{"guest_list_state":"GOING","node":{"id":"4567"}}]' '{"guest_list_state":"GOING","node":{"id":"4567"}}]'
), ),
} }
assert Plan( assert PlanData(
session=session,
id="1111", id="1111",
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc", title="abc",
@@ -46,10 +49,10 @@ def test_plan_from_pull():
"3456": GuestStatus.DECLINED, "3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING, "4567": GuestStatus.GOING,
}, },
) == Plan._from_pull(data) ) == PlanData._from_pull(session, data)
def test_plan_from_fetch(): def test_plan_from_fetch(session):
data = { data = {
"message_thread_id": 123456789, "message_thread_id": 123456789,
"event_time": 1500000000, "event_time": 1500000000,
@@ -92,7 +95,8 @@ def test_plan_from_fetch():
"4567": "GOING", "4567": "GOING",
}, },
} }
assert Plan( assert PlanData(
session=session,
id=1111, id=1111,
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc", title="abc",
@@ -105,10 +109,10 @@ def test_plan_from_fetch():
"3456": GuestStatus.DECLINED, "3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING, "4567": GuestStatus.GOING,
}, },
) == Plan._from_fetch(data) ) == PlanData._from_fetch(session, data)
def test_plan_from_graphql(): def test_plan_from_graphql(session):
data = { data = {
"id": "1111", "id": "1111",
"lightweight_event_creator": {"id": "1234"}, "lightweight_event_creator": {"id": "1234"},
@@ -134,7 +138,8 @@ def test_plan_from_graphql():
] ]
}, },
} }
assert Plan( assert PlanData(
session=session,
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc", title="abc",
location="", location="",
@@ -147,4 +152,4 @@ def test_plan_from_graphql():
"3456": GuestStatus.DECLINED, "3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING, "4567": GuestStatus.GOING,
}, },
) == Plan._from_graphql(data) ) == PlanData._from_graphql(session, data)

View File

@@ -1,6 +1,6 @@
import pytest import pytest
from fbchat import Plan, FBchatFacebookError from fbchat import PlanData, FBchatFacebookError
from utils import random_hex, subset from utils import random_hex, subset
from time import time from time import time
@@ -10,12 +10,12 @@ pytestmark = pytest.mark.online
@pytest.fixture( @pytest.fixture(
scope="module", scope="module",
params=[ params=[
Plan(time=int(time()) + 100, title=random_hex()), # PlanData(time=int(time()) + 100, title=random_hex()),
pytest.param( # pytest.param(
Plan(time=int(time()), title=random_hex()), # PlanData(time=int(time()), title=random_hex()),
marks=[pytest.mark.xfail(raises=FBchatFacebookError)], # marks=[pytest.mark.xfail(raises=FBchatFacebookError)],
), # ),
pytest.param(Plan(time=0, title=None), marks=[pytest.mark.xfail()]), # pytest.param(PlanData(time=0, title=None), marks=[pytest.mark.xfail()]),
], ],
) )
def plan_data(request, client, user, thread, catch_event, compare): def plan_data(request, client, user, thread, catch_event, compare):
@@ -73,7 +73,7 @@ def test_change_plan_participation(
@pytest.mark.trylast @pytest.mark.trylast
def test_edit_plan(client, thread, catch_event, compare, plan_data): def test_edit_plan(client, thread, catch_event, compare, plan_data):
event, plan = plan_data event, plan = plan_data
new_plan = Plan(plan.time + 100, random_hex()) new_plan = PlanData(plan.time + 100, random_hex())
with catch_event("on_plan_edited") as x: with catch_event("on_plan_edited") as x:
client.edit_plan(plan, new_plan) client.edit_plan(plan, new_plan)
assert compare(x) assert compare(x)
@@ -89,7 +89,7 @@ def test_edit_plan(client, thread, catch_event, compare, plan_data):
@pytest.mark.skip @pytest.mark.skip
def test_on_plan_ended(client, thread, catch_event, compare): def test_on_plan_ended(client, thread, catch_event, compare):
with catch_event("on_plan_ended") as x: with catch_event("on_plan_ended") as x:
client.create_plan(Plan(int(time()) + 120, "Wait for ending")) client.create_plan(PlanData(int(time()) + 120, "Wait for ending"))
x.wait(180) x.wait(180)
assert subset( assert subset(
x.res, x.res,

View File

@@ -1,7 +1,7 @@
import pytest import pytest
import datetime import datetime
import fbchat import fbchat
from fbchat._user import User, ActiveStatus from fbchat._user import UserData, ActiveStatus
def test_user_from_graphql(session): def test_user_from_graphql(session):
@@ -16,7 +16,7 @@ def test_user_from_graphql(session):
"gender": "FEMALE", "gender": "FEMALE",
"viewer_affinity": 0.4560002, "viewer_affinity": 0.4560002,
} }
assert User( assert UserData(
session=session, session=session,
id="1234", id="1234",
photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
@@ -27,7 +27,7 @@ def test_user_from_graphql(session):
is_friend=True, is_friend=True,
gender="female_singular", gender="female_singular",
affinity=0.4560002, affinity=0.4560002,
) == User._from_graphql(session, data) ) == UserData._from_graphql(session, data)
def test_user_from_thread_fetch(session): def test_user_from_thread_fetch(session):
@@ -138,7 +138,7 @@ def test_user_from_thread_fetch(session):
"read_receipts": ..., "read_receipts": ...,
"delivery_receipts": ..., "delivery_receipts": ...,
} }
assert User( assert UserData(
session=session, session=session,
id="1234", id="1234",
photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
@@ -154,7 +154,7 @@ def test_user_from_thread_fetch(session):
own_nickname="B", own_nickname="B",
color=None, color=None,
emoji=None, emoji=None,
) == User._from_thread_fetch(session, data) ) == UserData._from_thread_fetch(session, data)
def test_user_from_all_fetch(session): def test_user_from_all_fetch(session):
@@ -177,7 +177,7 @@ def test_user_from_all_fetch(session):
"is_nonfriend_messenger_contact": False, "is_nonfriend_messenger_contact": False,
"is_blocked": False, "is_blocked": False,
} }
assert User( assert UserData(
session=session, session=session,
id="1234", id="1234",
photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."),
@@ -186,7 +186,7 @@ def test_user_from_all_fetch(session):
first_name="Abc", first_name="Abc",
is_friend=True, is_friend=True,
gender="female_singular", gender="female_singular",
) == User._from_all_fetch(session, data) ) == UserData._from_all_fetch(session, data)
@pytest.mark.skip(reason="can't gather test data, the pulling is broken") @pytest.mark.skip(reason="can't gather test data, the pulling is broken")