Merge pull request #499 from carpedm20/session-in-models
Add ThreadABC helper, and move a bunch of methods out of Client
This commit is contained in:
@@ -14,7 +14,7 @@ from . import _core, _util
|
||||
from ._core import Image
|
||||
from ._exception import FBchatException, FBchatFacebookError
|
||||
from ._session import Session
|
||||
from ._thread import ThreadType, ThreadLocation, ThreadColor, Thread
|
||||
from ._thread import ThreadLocation, ThreadColor, ThreadABC, Thread
|
||||
from ._user import TypingStatus, User, ActiveStatus
|
||||
from ._group import Group
|
||||
from ._page import Page
|
||||
|
1568
fbchat/_client.py
1568
fbchat/_client.py
File diff suppressed because it is too large
Load Diff
158
fbchat/_group.py
158
fbchat/_group.py
@@ -1,15 +1,17 @@
|
||||
import attr
|
||||
from ._core import attrs_default, Image
|
||||
from . import _util, _plan
|
||||
from ._thread import ThreadType, Thread
|
||||
from . import _util, _session, _plan, _thread, _user
|
||||
from typing import Sequence, Iterable
|
||||
|
||||
|
||||
@attrs_default
|
||||
class Group(Thread):
|
||||
"""Represents a Facebook group. Inherits `Thread`."""
|
||||
|
||||
type = ThreadType.GROUP
|
||||
class Group(_thread.ThreadABC):
|
||||
"""Represents a Facebook group. Implements `ThreadABC`."""
|
||||
|
||||
#: The session to use when making requests.
|
||||
session = attr.ib(type=_session.Session)
|
||||
#: The group's unique identifier.
|
||||
id = attr.ib(converter=str)
|
||||
#: The group's picture
|
||||
photo = attr.ib(None)
|
||||
#: The name of the group
|
||||
@@ -37,8 +39,120 @@ class Group(Thread):
|
||||
# Link for joining group
|
||||
join_link = attr.ib(None)
|
||||
|
||||
def add_participants(self, user_ids: Iterable[str]):
|
||||
"""Add users to the group.
|
||||
|
||||
Args:
|
||||
user_ids: One or more user IDs to add
|
||||
"""
|
||||
data = self._to_send_data()
|
||||
|
||||
data["action_type"] = "ma-type:log-message"
|
||||
data["log_message_type"] = "log:subscribe"
|
||||
|
||||
for i, user_id in enumerate(user_ids):
|
||||
if user_id == self.session.user_id:
|
||||
raise ValueError(
|
||||
"Error when adding users: Cannot add self to group thread"
|
||||
)
|
||||
else:
|
||||
data[
|
||||
"log_message_data[added_participants][{}]".format(i)
|
||||
] = "fbid:{}".format(user_id)
|
||||
|
||||
return self.session._do_send_request(data)
|
||||
|
||||
def remove_participant(self, user_id: str):
|
||||
"""Remove user from the group.
|
||||
|
||||
Args:
|
||||
user_id: User ID to remove
|
||||
"""
|
||||
data = {"uid": user_id, "tid": self.id}
|
||||
j = self._payload_post("/chat/remove_participants/", data)
|
||||
|
||||
def _admin_status(self, user_ids: Iterable[str], status: bool):
|
||||
data = {"add": admin, "thread_fbid": self.id}
|
||||
|
||||
for i, user_id in enumerate(user_ids):
|
||||
data["admin_ids[{}]".format(i)] = str(user_id)
|
||||
|
||||
j = self.session._payload_post("/messaging/save_admins/?dpr=1", data)
|
||||
|
||||
def add_admins(self, user_ids: Iterable[str]):
|
||||
"""Set specified users as group admins.
|
||||
|
||||
Args:
|
||||
user_ids: One or more user IDs to set admin
|
||||
"""
|
||||
self._admin_status(user_ids, True)
|
||||
|
||||
def remove_admins(self, user_ids: Iterable[str]):
|
||||
"""Remove admin status from specified users.
|
||||
|
||||
Args:
|
||||
user_ids: One or more user IDs to remove admin
|
||||
"""
|
||||
self._admin_status(user_ids, False)
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""Change title of the group.
|
||||
|
||||
Args:
|
||||
title: New title
|
||||
"""
|
||||
data = {"thread_name": title, "thread_id": self.id}
|
||||
j = self.session._payload_post("/messaging/set_thread_name/?dpr=1", data)
|
||||
|
||||
def set_image(self, image_id: str):
|
||||
"""Change the group image from an image id.
|
||||
|
||||
Args:
|
||||
image_id: ID of uploaded image
|
||||
"""
|
||||
data = {"thread_image_id": image_id, "thread_id": self.id}
|
||||
j = self.session._payload_post("/messaging/set_thread_image/?dpr=1", data)
|
||||
|
||||
def set_approval_mode(self, require_admin_approval: bool):
|
||||
"""Change the group's approval mode.
|
||||
|
||||
Args:
|
||||
require_admin_approval: True or False
|
||||
"""
|
||||
data = {"set_mode": int(require_admin_approval), "thread_fbid": thread_id}
|
||||
j = self.session._payload_post("/messaging/set_approval_mode/?dpr=1", data)
|
||||
|
||||
def _users_approval(self, user_ids: Iterable[str], approve: bool):
|
||||
data = {
|
||||
"client_mutation_id": "0",
|
||||
"actor_id": self.session.user_id,
|
||||
"thread_fbid": self.id,
|
||||
"user_ids": list(user_ids),
|
||||
"response": "ACCEPT" if approve else "DENY",
|
||||
"surface": "ADMIN_MODEL_APPROVAL_CENTER",
|
||||
}
|
||||
(j,) = self.session._graphql_requests(
|
||||
_graphql.from_doc_id("1574519202665847", {"data": data})
|
||||
)
|
||||
|
||||
def accept_users(self, user_ids: Iterable[str]):
|
||||
"""Accept users to the group from the group's approval.
|
||||
|
||||
Args:
|
||||
user_ids: One or more user IDs to accept
|
||||
"""
|
||||
self._users_approval(user_ids, True)
|
||||
|
||||
def deny_users(self, user_ids: Iterable[str]):
|
||||
"""Deny users from joining the group.
|
||||
|
||||
Args:
|
||||
user_ids: One or more user IDs to deny
|
||||
"""
|
||||
self._users_approval(user_ids, False)
|
||||
|
||||
@classmethod
|
||||
def _from_graphql(cls, data):
|
||||
def _from_graphql(cls, session, data):
|
||||
if data.get("image") is None:
|
||||
data["image"] = {}
|
||||
c_info = cls._parse_customization_info(data)
|
||||
@@ -52,6 +166,7 @@ class Group(Thread):
|
||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
||||
|
||||
return cls(
|
||||
session=session,
|
||||
id=data["thread_key"]["thread_fbid"],
|
||||
participants=set(
|
||||
[
|
||||
@@ -82,3 +197,32 @@ class Group(Thread):
|
||||
|
||||
def _to_send_data(self):
|
||||
return {"thread_fbid": self.id}
|
||||
|
||||
|
||||
@attrs_default
|
||||
class NewGroup(_thread.ThreadABC):
|
||||
"""Helper class to create new groups.
|
||||
|
||||
TODO: Complete this!
|
||||
|
||||
Construct this class with the desired users, and call a method like `wave`, to...
|
||||
"""
|
||||
|
||||
#: The session to use when making requests.
|
||||
session = attr.ib(type=_session.Session)
|
||||
#: The users that should be added to the group.
|
||||
_users = attr.ib(type=Sequence[_user.User])
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
raise NotImplementedError(
|
||||
"The method you called is not supported on NewGroup objects."
|
||||
" Please use the supported methods to create the group, before attempting"
|
||||
" to call the method."
|
||||
)
|
||||
|
||||
def _to_send_data(self) -> dict:
|
||||
return {
|
||||
"specific_to_list[{}]".format(i): "fbid:{}".format(user.id)
|
||||
for i, user in enumerate(self._users)
|
||||
}
|
||||
|
@@ -2,7 +2,8 @@ import attr
|
||||
import json
|
||||
from string import Formatter
|
||||
from ._core import log, attrs_default, Enum
|
||||
from . import _util, _attachment, _location, _file, _quick_reply, _sticker
|
||||
from . import _util, _session, _attachment, _location, _file, _quick_reply, _sticker
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class EmojiSize(Enum):
|
||||
@@ -78,14 +79,18 @@ class Mention:
|
||||
class Message:
|
||||
"""Represents a Facebook message."""
|
||||
|
||||
# TODO: Make these fields required!
|
||||
#: The session to use when making requests.
|
||||
session = attr.ib(None, type=_session.Session)
|
||||
#: The message ID
|
||||
id = attr.ib(None, converter=str)
|
||||
|
||||
#: 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)
|
||||
#: The message ID
|
||||
id = attr.ib(None)
|
||||
#: ID of the sender
|
||||
author = attr.ib(None)
|
||||
#: Datetime of when the message was sent
|
||||
@@ -111,6 +116,38 @@ class Message:
|
||||
#: Whether the message was forwarded
|
||||
forwarded = attr.ib(False)
|
||||
|
||||
def unsend(self):
|
||||
"""Unsend the message (removes it for everyone)."""
|
||||
data = {"message_id": self.id}
|
||||
j = self.session._payload_post("/messaging/unsend_message/?dpr=1", data)
|
||||
|
||||
def react(self, reaction: Optional[MessageReaction]):
|
||||
"""React to the message, or removes reaction.
|
||||
|
||||
Args:
|
||||
reaction: Reaction emoji to use, if None removes reaction
|
||||
"""
|
||||
data = {
|
||||
"action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
|
||||
"client_mutation_id": "1",
|
||||
"actor_id": self.session.user_id,
|
||||
"message_id": self.id,
|
||||
"reaction": reaction.value if reaction else None,
|
||||
}
|
||||
data = {"doc_id": 1491398900900362, "variables": json.dumps({"data": data})}
|
||||
j = self.session._payload_post("/webgraphql/mutation", data)
|
||||
_util.handle_graphql_errors(j)
|
||||
|
||||
@classmethod
|
||||
def from_fetch(cls, thread, message_id: str) -> "Message":
|
||||
"""Fetch `Message` object from the given message id.
|
||||
|
||||
Args:
|
||||
message_id: Message ID to fetch from
|
||||
"""
|
||||
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.
|
||||
@@ -224,7 +261,7 @@ class Message:
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _from_graphql(cls, data, read_receipts=None):
|
||||
def _from_graphql(cls, session, data, read_receipts=None):
|
||||
if data.get("message_sender") is None:
|
||||
data["message_sender"] = {}
|
||||
if data.get("message") is None:
|
||||
@@ -250,12 +287,13 @@ class Message:
|
||||
replied_to = cls._from_graphql(data["replied_to_message"]["message"])
|
||||
|
||||
return cls(
|
||||
session=session,
|
||||
id=str(data["message_id"]),
|
||||
text=data["message"].get("text"),
|
||||
mentions=[
|
||||
Mention._from_range(m) for m in data["message"].get("ranges") or ()
|
||||
],
|
||||
emoji_size=EmojiSize._from_tags(tags),
|
||||
id=str(data["message_id"]),
|
||||
author=str(data["message_sender"]["id"]),
|
||||
created_at=created_at,
|
||||
is_read=not data["unread"] if data.get("unread") is not None else None,
|
||||
@@ -278,7 +316,7 @@ class Message:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_reply(cls, data, replied_to=None):
|
||||
def _from_reply(cls, session, data, replied_to=None):
|
||||
tags = data["messageMetadata"].get("tags")
|
||||
metadata = data.get("messageMetadata", {})
|
||||
|
||||
@@ -305,13 +343,14 @@ class Message:
|
||||
)
|
||||
|
||||
return cls(
|
||||
session=session,
|
||||
id=metadata.get("messageId"),
|
||||
text=data.get("body"),
|
||||
mentions=[
|
||||
Mention._from_prng(m)
|
||||
for m in _util.parse_json(data.get("data", {}).get("prng", "[]"))
|
||||
],
|
||||
emoji_size=EmojiSize._from_tags(tags),
|
||||
id=metadata.get("messageId"),
|
||||
author=str(metadata.get("actorFbId")),
|
||||
created_at=_util.millis_to_datetime(metadata.get("timestamp")),
|
||||
sticker=sticker,
|
||||
@@ -324,7 +363,9 @@ class Message:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_pull(cls, data, mid=None, tags=None, author=None, created_at=None):
|
||||
def _from_pull(
|
||||
cls, session, data, mid=None, tags=None, author=None, created_at=None
|
||||
):
|
||||
mentions = []
|
||||
if data.get("data") and data["data"].get("prng"):
|
||||
try:
|
||||
@@ -371,10 +412,11 @@ class Message:
|
||||
)
|
||||
|
||||
return cls(
|
||||
session=session,
|
||||
id=mid,
|
||||
text=data.get("body"),
|
||||
mentions=mentions,
|
||||
emoji_size=EmojiSize._from_tags(tags),
|
||||
id=mid,
|
||||
author=author,
|
||||
created_at=created_at,
|
||||
sticker=sticker,
|
||||
|
@@ -1,15 +1,16 @@
|
||||
import attr
|
||||
from ._core import attrs_default, Image
|
||||
from . import _plan
|
||||
from ._thread import ThreadType, Thread
|
||||
from . import _session, _plan, _thread
|
||||
|
||||
|
||||
@attrs_default
|
||||
class Page(Thread):
|
||||
"""Represents a Facebook page. Inherits `Thread`."""
|
||||
|
||||
type = ThreadType.PAGE
|
||||
class Page(_thread.ThreadABC):
|
||||
"""Represents a Facebook page. Implements `ThreadABC`."""
|
||||
|
||||
#: The session to use when making requests.
|
||||
session = attr.ib(type=_session.Session)
|
||||
#: The unique identifier of the page.
|
||||
id = attr.ib(converter=str)
|
||||
#: The page's picture
|
||||
photo = attr.ib(None)
|
||||
#: The name of the page
|
||||
@@ -31,8 +32,11 @@ class Page(Thread):
|
||||
#: The page's category
|
||||
category = attr.ib(None)
|
||||
|
||||
def _to_send_data(self):
|
||||
return {"other_user_fbid": self.id}
|
||||
|
||||
@classmethod
|
||||
def _from_graphql(cls, data):
|
||||
def _from_graphql(cls, session, data):
|
||||
if data.get("profile_picture") is None:
|
||||
data["profile_picture"] = {}
|
||||
if data.get("city") is None:
|
||||
@@ -42,6 +46,7 @@ class Page(Thread):
|
||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
||||
|
||||
return cls(
|
||||
session=session,
|
||||
id=data["id"],
|
||||
url=data.get("url"),
|
||||
city=data.get("city").get("name"),
|
||||
|
@@ -1,26 +1,9 @@
|
||||
import abc
|
||||
import attr
|
||||
import datetime
|
||||
from ._core import attrs_default, Enum, Image
|
||||
|
||||
|
||||
class ThreadType(Enum):
|
||||
"""Used to specify what type of Facebook thread is being used.
|
||||
|
||||
See :ref:`intro_threads` for more info.
|
||||
"""
|
||||
|
||||
USER = 1
|
||||
GROUP = 2
|
||||
PAGE = 3
|
||||
|
||||
def _to_class(self):
|
||||
"""Convert this enum value to the corresponding class."""
|
||||
from . import _user, _group, _page
|
||||
|
||||
return {
|
||||
ThreadType.USER: _user.User,
|
||||
ThreadType.GROUP: _group.Group,
|
||||
ThreadType.PAGE: _page.Page,
|
||||
}[self]
|
||||
from . import _util, _exception, _session
|
||||
from typing import MutableMapping, Any, Iterable, Tuple
|
||||
|
||||
|
||||
class ThreadLocation(Enum):
|
||||
@@ -67,17 +50,438 @@ class ThreadColor(Enum):
|
||||
return cls._extend_if_invalid(value)
|
||||
|
||||
|
||||
@attrs_default
|
||||
class Thread:
|
||||
"""Represents a Facebook thread."""
|
||||
class ThreadABC(metaclass=abc.ABCMeta):
|
||||
"""Implemented by thread-like classes.
|
||||
|
||||
#: The unique identifier of the thread.
|
||||
id = attr.ib(converter=str)
|
||||
#: Specifies the type of thread. Can be used a ``thread_type``. See :ref:`intro_threads` for more info
|
||||
type = None
|
||||
This is private to implement.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def session(self) -> _session.Session:
|
||||
"""The session to use when making requests."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def id(self) -> str:
|
||||
"""The unique identifier of the thread."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def _to_send_data(self) -> MutableMapping[str, str]:
|
||||
raise NotImplementedError
|
||||
|
||||
def wave(self, first: bool = True) -> str:
|
||||
"""Wave hello to the thread.
|
||||
|
||||
Args:
|
||||
first: Whether to wave first or wave back
|
||||
"""
|
||||
data = self._to_send_data()
|
||||
data["action_type"] = "ma-type:user-generated-message"
|
||||
data["lightweight_action_attachment[lwa_state]"] = (
|
||||
"INITIATED" if first else "RECIPROCATED"
|
||||
)
|
||||
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)
|
||||
return message_id
|
||||
|
||||
def send(self, message) -> str:
|
||||
"""Send message to the thread.
|
||||
|
||||
Args:
|
||||
message (Message): Message to send
|
||||
|
||||
Returns:
|
||||
:ref:`Message ID <intro_message_ids>` of the sent message
|
||||
"""
|
||||
data = self._to_send_data()
|
||||
data.update(message._to_send_data())
|
||||
return self.session._do_send_request(data)
|
||||
|
||||
def _send_location(self, current, latitude, longitude, message=None) -> str:
|
||||
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["location_attachment[coordinates][latitude]"] = latitude
|
||||
data["location_attachment[coordinates][longitude]"] = longitude
|
||||
data["location_attachment[is_current_location]"] = current
|
||||
return self.session._do_send_request(data)
|
||||
|
||||
def send_location(self, latitude: float, longitude: float, message=None):
|
||||
"""Send a given location to a thread as the user's current location.
|
||||
|
||||
Args:
|
||||
latitude: The location latitude
|
||||
longitude: The location longitude
|
||||
message: Additional message
|
||||
"""
|
||||
self._send_location(
|
||||
True, latitude=latitude, longitude=longitude, message=message,
|
||||
)
|
||||
|
||||
def send_pinned_location(self, latitude: float, longitude: float, message=None):
|
||||
"""Send a given location to a thread as a pinned location.
|
||||
|
||||
Args:
|
||||
latitude: The location latitude
|
||||
longitude: The location longitude
|
||||
message: Additional message
|
||||
"""
|
||||
self._send_location(
|
||||
False, latitude=latitude, longitude=longitude, message=message,
|
||||
)
|
||||
|
||||
def send_files(self, files: Iterable[Tuple[str, str]], message):
|
||||
"""Send files from file IDs to a thread.
|
||||
|
||||
`files` should be a list of tuples, with a file's ID and mimetype.
|
||||
"""
|
||||
data = self._to_send_data()
|
||||
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):
|
||||
data["{}s[{}]".format(_util.mimetype_to_key(mimetype), i)] = file_id
|
||||
|
||||
return self.session._do_send_request(data)
|
||||
|
||||
# TODO: This!
|
||||
# def quick_reply(self, quick_reply, payload=None):
|
||||
# """Reply to chosen quick reply.
|
||||
#
|
||||
# Args:
|
||||
# quick_reply (QuickReply): Quick reply to reply to
|
||||
# payload: Optional answer to the quick reply
|
||||
# """
|
||||
# if isinstance(quick_reply, QuickReplyText):
|
||||
# new = QuickReplyText(
|
||||
# payload=quick_reply.payload,
|
||||
# external_payload=quick_reply.external_payload,
|
||||
# data=quick_reply.data,
|
||||
# is_response=True,
|
||||
# title=quick_reply.title,
|
||||
# image_url=quick_reply.image_url,
|
||||
# )
|
||||
# return self.send(Message(text=quick_reply.title, quick_replies=[new]))
|
||||
# elif isinstance(quick_reply, QuickReplyLocation):
|
||||
# if not isinstance(payload, LocationAttachment):
|
||||
# raise TypeError("Payload must be an instance of `LocationAttachment`")
|
||||
# return self.send_location(payload)
|
||||
# elif isinstance(quick_reply, QuickReplyEmail):
|
||||
# new = QuickReplyEmail(
|
||||
# payload=payload if payload else self.get_emails()[0],
|
||||
# external_payload=quick_reply.payload,
|
||||
# data=quick_reply.data,
|
||||
# is_response=True,
|
||||
# image_url=quick_reply.image_url,
|
||||
# )
|
||||
# return self.send(Message(text=payload, quick_replies=[new]))
|
||||
# elif isinstance(quick_reply, QuickReplyPhoneNumber):
|
||||
# new = QuickReplyPhoneNumber(
|
||||
# payload=payload if payload else self.get_phone_numbers()[0],
|
||||
# external_payload=quick_reply.payload,
|
||||
# data=quick_reply.data,
|
||||
# is_response=True,
|
||||
# image_url=quick_reply.image_url,
|
||||
# )
|
||||
# return self.send(Message(text=payload, quick_replies=[new]))
|
||||
|
||||
def search_messages(
|
||||
self, query: str, offset: int = 0, limit: int = 5
|
||||
) -> Iterable[str]:
|
||||
"""Find and get message IDs by query.
|
||||
|
||||
Args:
|
||||
query: Text to search for
|
||||
offset (int): Number of messages to skip
|
||||
limit (int): Max. number of messages to retrieve
|
||||
|
||||
Returns:
|
||||
typing.Iterable: Found Message IDs
|
||||
"""
|
||||
# TODO: Return proper searchable iterator
|
||||
data = {
|
||||
"query": query,
|
||||
"snippetOffset": offset,
|
||||
"snippetLimit": limit,
|
||||
"identifier": "thread_fbid",
|
||||
"thread_fbid": self.id,
|
||||
}
|
||||
j = self.session._payload_post("/ajax/mercury/search_snippets.php?dpr=1", data)
|
||||
|
||||
result = j["search_snippets"][query]
|
||||
snippets = result[self.id]["snippets"] if result.get(self.id) else []
|
||||
for snippet in snippets:
|
||||
yield snippet["message_id"]
|
||||
|
||||
def fetch_messages(self, limit: int = 20, before: datetime.datetime = None):
|
||||
"""Fetch messages in a thread, ordered by most recent.
|
||||
|
||||
Args:
|
||||
limit: Max. number of messages to retrieve
|
||||
before: The point from which to retrieve messages
|
||||
|
||||
Returns:
|
||||
list: `Message` objects
|
||||
"""
|
||||
# TODO: Return proper searchable iterator
|
||||
params = {
|
||||
"id": self.id,
|
||||
"message_limit": limit,
|
||||
"load_messages": True,
|
||||
"load_read_receipts": True,
|
||||
"before": _util.datetime_to_millis(before) if before else None,
|
||||
}
|
||||
(j,) = self.session._graphql_requests(
|
||||
_graphql.from_doc_id("1860982147341344", params)
|
||||
)
|
||||
|
||||
if j.get("message_thread") is None:
|
||||
raise FBchatException("Could not fetch thread {}: {}".format(self.id, j))
|
||||
|
||||
read_receipts = j["message_thread"]["read_receipts"]["nodes"]
|
||||
|
||||
messages = [
|
||||
Message._from_graphql(self.session, message, read_receipts)
|
||||
for message in j["message_thread"]["messages"]["nodes"]
|
||||
]
|
||||
messages.reverse()
|
||||
|
||||
return messages
|
||||
|
||||
def fetch_images(self):
|
||||
"""Fetch images/videos posted in the thread."""
|
||||
# TODO: Return proper searchable iterator
|
||||
data = {"id": self.id, "first": 48}
|
||||
(j,) = self.session._graphql_requests(
|
||||
_graphql.from_query_id("515216185516880", data)
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
i = j[self.id]["message_shared_media"]["edges"][0]
|
||||
except IndexError:
|
||||
if j[self.id]["message_shared_media"]["page_info"].get("has_next_page"):
|
||||
data["after"] = j[self.id]["message_shared_media"]["page_info"].get(
|
||||
"end_cursor"
|
||||
)
|
||||
(j,) = self.session._graphql_requests(
|
||||
_graphql.from_query_id("515216185516880", data)
|
||||
)
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
if i["node"].get("__typename") == "MessageImage":
|
||||
yield ImageAttachment._from_list(i)
|
||||
elif i["node"].get("__typename") == "MessageVideo":
|
||||
yield VideoAttachment._from_list(i)
|
||||
else:
|
||||
yield Attachment(id=i["node"].get("legacy_attachment_id"))
|
||||
del j[self.id]["message_shared_media"]["edges"][0]
|
||||
|
||||
def set_nickname(self, user_id: str, nickname: str):
|
||||
"""Change the nickname of a user in the thread.
|
||||
|
||||
Args:
|
||||
user_id: User that will have their nickname changed
|
||||
nickname: New nickname
|
||||
"""
|
||||
data = {
|
||||
"nickname": nickname,
|
||||
"participant_id": user_id,
|
||||
"thread_or_other_fbid": self.id,
|
||||
}
|
||||
j = self.session._payload_post(
|
||||
"/messaging/save_thread_nickname/?source=thread_settings&dpr=1", data
|
||||
)
|
||||
|
||||
def set_color(self, color: ThreadColor):
|
||||
"""Change thread color.
|
||||
|
||||
Args:
|
||||
color: New thread color
|
||||
"""
|
||||
data = {
|
||||
"color_choice": color.value if color != ThreadColor.MESSENGER_BLUE else "",
|
||||
"thread_or_other_fbid": self.id,
|
||||
}
|
||||
j = self.session._payload_post(
|
||||
"/messaging/save_thread_color/?source=thread_settings&dpr=1", data
|
||||
)
|
||||
|
||||
def set_emoji(self, emoji: str):
|
||||
"""Change thread color.
|
||||
|
||||
Args:
|
||||
emoji: New thread emoji
|
||||
"""
|
||||
data = {"emoji_choice": emoji, "thread_or_other_fbid": self.id}
|
||||
# While changing the emoji, the Facebook web client actually sends multiple
|
||||
# different requests, though only this one is required to make the change.
|
||||
j = self.session._payload_post(
|
||||
"/messaging/save_thread_emoji/?source=thread_settings&dpr=1", data
|
||||
)
|
||||
|
||||
def forward_attachment(self, attachment_id):
|
||||
"""Forward an attachment.
|
||||
|
||||
Args:
|
||||
attachment_id: Attachment ID to forward
|
||||
"""
|
||||
data = {
|
||||
"attachment_id": attachment_id,
|
||||
"recipient_map[{}]".format(_util.generate_offline_threading_id()): self.id,
|
||||
}
|
||||
j = self.session._payload_post("/mercury/attachments/forward/", data)
|
||||
if not j.get("success"):
|
||||
raise _exception.FBchatFacebookError(
|
||||
"Failed forwarding attachment: {}".format(j["error"]),
|
||||
fb_error_message=j["error"],
|
||||
)
|
||||
|
||||
def _set_typing(self, typing):
|
||||
data = {
|
||||
"typ": "1" if typing else "0",
|
||||
"thread": self.id,
|
||||
# TODO: This
|
||||
# "to": self.id if isinstance(self, _user.User) else "",
|
||||
"source": "mercury-chat",
|
||||
}
|
||||
j = self.session._payload_post("/ajax/messaging/typ.php", data)
|
||||
|
||||
def start_typing(self):
|
||||
"""Set the current user to start typing in the thread."""
|
||||
self._set_typing(True)
|
||||
|
||||
def stop_typing(self):
|
||||
"""Set the current user to stop typing in the thread."""
|
||||
self._set_typing(False)
|
||||
|
||||
def create_plan(
|
||||
self,
|
||||
name: str,
|
||||
at: datetime.datetime,
|
||||
location_name: str = None,
|
||||
location_id: str = None,
|
||||
):
|
||||
"""Create a new plan.
|
||||
|
||||
# TODO: Arguments
|
||||
|
||||
Args:
|
||||
title: Name of the new plan
|
||||
at: When the plan is for
|
||||
"""
|
||||
data = {
|
||||
"event_type": "EVENT",
|
||||
"event_time": _util.datetime_to_seconds(at),
|
||||
"title": name,
|
||||
"thread_id": self.id,
|
||||
"location_id": location_id or "",
|
||||
"location_name": location or "",
|
||||
"acontext": 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]]):
|
||||
"""Create poll in a thread.
|
||||
|
||||
# TODO: Arguments
|
||||
"""
|
||||
# We're using ordered dictionaries, because the Facebook endpoint that parses
|
||||
# the POST parameters is badly implemented, and deals with ordering the options
|
||||
# wrongly. If you can find a way to fix this for the endpoint, or if you find
|
||||
# another endpoint, please do suggest it ;)
|
||||
data = OrderedDict([("question_text", question), ("target_id", self.id)])
|
||||
|
||||
for i, (text, vote) in enumerate(options):
|
||||
data["option_text_array[{}]".format(i)] = text
|
||||
data["option_is_selected_array[{}]".format(i)] = str(int(vote))
|
||||
|
||||
j = self.session._payload_post(
|
||||
"/messaging/group_polling/create_poll/?dpr=1", data
|
||||
)
|
||||
if j.get("status") != "success":
|
||||
raise _exception.FBchatFacebookError(
|
||||
"Failed creating poll: {}".format(j.get("errorTitle")),
|
||||
fb_error_message=j.get("errorMessage"),
|
||||
)
|
||||
|
||||
def mute(self, duration: datetime.timedelta = None):
|
||||
"""Mute the thread.
|
||||
|
||||
Args:
|
||||
duration: Time to mute, use ``None`` to mute forever
|
||||
"""
|
||||
if duration is None:
|
||||
setting = "-1"
|
||||
else:
|
||||
setting = str(_util.timedelta_to_seconds(duration))
|
||||
data = {"mute_settings": setting, "thread_fbid": self.id}
|
||||
j = self.session._payload_post(
|
||||
"/ajax/mercury/change_mute_thread.php?dpr=1", data
|
||||
)
|
||||
|
||||
def unmute(self):
|
||||
"""Unmute the thread."""
|
||||
return self.mute(datetime.timedelta(0))
|
||||
|
||||
def _mute_reactions(self, mode: bool):
|
||||
data = {"reactions_mute_mode": "1" if mode else "0", "thread_fbid": self.id}
|
||||
j = self.session._payload_post(
|
||||
"/ajax/mercury/change_reactions_mute_thread/?dpr=1", data
|
||||
)
|
||||
|
||||
def mute_reactions(self):
|
||||
"""Mute thread reactions."""
|
||||
self._mute_reactions(True)
|
||||
|
||||
def unmute_reactions(self):
|
||||
"""Unmute thread reactions."""
|
||||
self._mute_reactions(False)
|
||||
|
||||
def _mute_mentions(self, mode: bool):
|
||||
data = {"mentions_mute_mode": "1" if mode else "0", "thread_fbid": self.id}
|
||||
j = self.session._payload_post(
|
||||
"/ajax/mercury/change_mentions_mute_thread/?dpr=1", data
|
||||
)
|
||||
|
||||
def mute_mentions(self):
|
||||
"""Mute thread mentions."""
|
||||
self._mute_mentions(True)
|
||||
|
||||
def unmute_mentions(self):
|
||||
"""Unmute thread mentions."""
|
||||
self._mute_mentions(False)
|
||||
|
||||
def mark_as_spam(self):
|
||||
"""Mark the thread as spam, and delete it."""
|
||||
data = {"id": self.id}
|
||||
j = self.session._payload_post("/ajax/mercury/mark_spam.php?dpr=1", data)
|
||||
|
||||
def _forced_fetch(self, message_id: str) -> dict:
|
||||
params = {
|
||||
"thread_and_message_id": {"thread_id": self.id, "message_id": message_id}
|
||||
}
|
||||
(j,) = self.session._graphql_requests(
|
||||
_graphql.from_doc_id("1768656253222505", params)
|
||||
)
|
||||
return j
|
||||
|
||||
@staticmethod
|
||||
def _parse_customization_info(data):
|
||||
def _parse_customization_info(data: Any) -> MutableMapping[str, Any]:
|
||||
if data is None or data.get("customization_info") is None:
|
||||
return {}
|
||||
info = data["customization_info"]
|
||||
@@ -109,6 +513,24 @@ class Thread:
|
||||
rtn["own_nickname"] = pc[1].get("nickname")
|
||||
return rtn
|
||||
|
||||
|
||||
@attrs_default
|
||||
class Thread(ThreadABC):
|
||||
"""Represents a Facebook thread, where the actual type is unknown.
|
||||
|
||||
Implements parts of `ThreadABC`, call the method to figure out if your use case is
|
||||
supported. Otherwise, you'll have to use an `User`/`Group`/`Page` object.
|
||||
|
||||
Note: This list may change in minor versions!
|
||||
"""
|
||||
|
||||
#: The session to use when making requests.
|
||||
session = attr.ib(type=_session.Session)
|
||||
#: The unique identifier of the thread.
|
||||
id = attr.ib(converter=str)
|
||||
|
||||
def _to_send_data(self):
|
||||
# TODO: Only implement this in subclasses
|
||||
return {"other_user_fbid": self.id}
|
||||
raise NotImplementedError(
|
||||
"The method you called is not supported on raw Thread objects."
|
||||
" Please use an appropriate User/Group/Page object instead!"
|
||||
)
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import attr
|
||||
from ._core import attrs_default, Enum, Image
|
||||
from . import _util, _plan
|
||||
from ._thread import ThreadType, Thread
|
||||
from . import _util, _session, _plan, _thread
|
||||
|
||||
|
||||
GENDERS = {
|
||||
@@ -42,11 +41,13 @@ class TypingStatus(Enum):
|
||||
|
||||
|
||||
@attrs_default
|
||||
class User(Thread):
|
||||
"""Represents a Facebook user. Inherits `Thread`."""
|
||||
|
||||
type = ThreadType.USER
|
||||
class User(_thread.ThreadABC):
|
||||
"""Represents a Facebook user. Implements `ThreadABC`."""
|
||||
|
||||
#: The session to use when making requests.
|
||||
session = attr.ib(type=_session.Session)
|
||||
#: The user's unique identifier.
|
||||
id = attr.ib(converter=str)
|
||||
#: The user's picture
|
||||
photo = attr.ib(None)
|
||||
#: The name of the user
|
||||
@@ -78,8 +79,31 @@ class User(Thread):
|
||||
#: The default emoji
|
||||
emoji = attr.ib(None)
|
||||
|
||||
def _to_send_data(self):
|
||||
return {"other_user_fbid": self.id}
|
||||
|
||||
def confirm_friend_request(self):
|
||||
"""Confirm a friend request, adding the user to your friend list."""
|
||||
data = {"to_friend": self.id, "action": "confirm"}
|
||||
j = self.session._payload_post("/ajax/add_friend/action.php?dpr=1", data)
|
||||
|
||||
def remove_friend(self):
|
||||
"""Remove the user from the client's friend list."""
|
||||
data = {"uid": self.id}
|
||||
j = self.session._payload_post("/ajax/profile/removefriendconfirm.php", data)
|
||||
|
||||
def block(self):
|
||||
"""Block messages from the user."""
|
||||
data = {"fbid": self.id}
|
||||
j = self.session._payload_post("/messaging/block_messages/?dpr=1", data)
|
||||
|
||||
def unblock(self):
|
||||
"""Unblock a previously blocked user."""
|
||||
data = {"fbid": self.id}
|
||||
j = self.session._payload_post("/messaging/unblock_messages/?dpr=1", data)
|
||||
|
||||
@classmethod
|
||||
def _from_graphql(cls, data):
|
||||
def _from_graphql(cls, session, data):
|
||||
if data.get("profile_picture") is None:
|
||||
data["profile_picture"] = {}
|
||||
c_info = cls._parse_customization_info(data)
|
||||
@@ -88,6 +112,7 @@ class User(Thread):
|
||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
||||
|
||||
return cls(
|
||||
session=session,
|
||||
id=data["id"],
|
||||
url=data.get("url"),
|
||||
first_name=data.get("first_name"),
|
||||
@@ -106,7 +131,7 @@ class User(Thread):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_thread_fetch(cls, data):
|
||||
def _from_thread_fetch(cls, session, data):
|
||||
if data.get("big_image_src") is None:
|
||||
data["big_image_src"] = {}
|
||||
c_info = cls._parse_customization_info(data)
|
||||
@@ -133,6 +158,7 @@ class User(Thread):
|
||||
plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0])
|
||||
|
||||
return cls(
|
||||
session=session,
|
||||
id=user["id"],
|
||||
url=user.get("url"),
|
||||
name=user.get("name"),
|
||||
@@ -152,8 +178,9 @@ class User(Thread):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_all_fetch(cls, data):
|
||||
def _from_all_fetch(cls, session, data):
|
||||
return cls(
|
||||
session=session,
|
||||
id=data["id"],
|
||||
first_name=data.get("firstName"),
|
||||
url=data.get("uri"),
|
||||
|
Reference in New Issue
Block a user