diff --git a/examples/basic_usage.py b/examples/basic_usage.py index b64f30e..f5c8900 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,10 +1,19 @@ -from fbchat import Client -from fbchat.models import * +import fbchat -client = Client("", "") +# Log the user in +session = fbchat.Session.login("", "") -print("Own id: {}".format(client.uid)) +print("Own id: {}".format(sesion.user_id)) -client.send(Message(text="Hi me!"), thread_id=client.uid, thread_type=ThreadType.USER) +# Create helper client class +client = fbchat.Client(session) -client.logout() +# Send a message to yourself +client.send( + fbchat.Message(text="Hi me!"), + thread_id=session.user_id, + thread_type=fbchat.ThreadType.USER, +) + +# Log the user out +session.logout() diff --git a/examples/echobot.py b/examples/echobot.py index 2a4ae0f..4935248 100644 --- a/examples/echobot.py +++ b/examples/echobot.py @@ -1,7 +1,7 @@ -from fbchat import Client +import fbchat # Subclass fbchat.Client and override required methods -class EchoBot(Client): +class EchoBot(fbchat.Client): def on_message(self, author_id, message_object, thread_id, thread_type, **kwargs): self.mark_as_delivered(thread_id, message_object.uid) self.mark_as_read(thread_id) @@ -13,5 +13,7 @@ class EchoBot(Client): self.send(message_object, thread_id=thread_id, thread_type=thread_type) -client = EchoBot("", "") -client.listen() +session = fbchat.Session.login("", "") + +echo_bot = EchoBot(session) +echo_bot.listen() diff --git a/examples/fetch.py b/examples/fetch.py index 26be45b..0c9518f 100644 --- a/examples/fetch.py +++ b/examples/fetch.py @@ -1,8 +1,9 @@ -from itertools import islice -from fbchat import Client -from fbchat.models import * +import itertools +import fbchat -client = Client("", "") +session = fbchat.Session.login("", "") + +client = fbchat.Client(session) # Fetches a list of all users you're currently chatting with, as `User` objects users = client.fetch_all_users() @@ -65,5 +66,5 @@ print("thread's type: {}".format(thread.type)) # Print image url for 20 last images from thread. images = client.fetch_thread_images("") -for image in islice(image, 20): +for image in itertools.islice(image, 20): print(image.large_preview_url) diff --git a/examples/interract.py b/examples/interract.py index 2d2ed27..5b26108 100644 --- a/examples/interract.py +++ b/examples/interract.py @@ -1,37 +1,43 @@ -from fbchat import Client -from fbchat.models import * +import fbchat -client = Client("", "") +session = fbchat.Session.login("", "") + +client = fbchat.Client(session) thread_id = "1234567890" -thread_type = ThreadType.GROUP +thread_type = fbchat.ThreadType.GROUP # Will send a message to the thread -client.send(Message(text=""), thread_id=thread_id, thread_type=thread_type) +client.send( + fbchat.Message(text=""), thread_id=thread_id, thread_type=thread_type +) # Will send the default `like` emoji client.send( - Message(emoji_size=EmojiSize.LARGE), thread_id=thread_id, thread_type=thread_type + fbchat.Message(emoji_size=fbchat.EmojiSize.LARGE), + thread_id=thread_id, + thread_type=thread_type, ) # Will send the emoji `👍` client.send( - Message(text="👍", emoji_size=EmojiSize.LARGE), + fbchat.Message(text="👍", emoji_size=fbchat.EmojiSize.LARGE), thread_id=thread_id, thread_type=thread_type, ) # Will send the sticker with ID `767334476626295` client.send( - Message(sticker=Sticker("767334476626295")), + fbchat.Message(sticker=fbchat.Sticker("767334476626295")), thread_id=thread_id, thread_type=thread_type, ) # Will send a message with a mention client.send( - Message( - text="This is a @mention", mentions=[Mention(thread_id, offset=10, length=8)] + fbchat.Message( + text="This is a @mention", + mentions=[fbchat.Mention(thread_id, offset=10, length=8)], ), thread_id=thread_id, thread_type=thread_type, @@ -40,7 +46,7 @@ client.send( # Will send the image located at `` client.send_local_image( "", - message=Message(text="This is a local image"), + message=fbchat.Message(text="This is a local image"), thread_id=thread_id, thread_type=thread_type, ) @@ -48,14 +54,14 @@ client.send_local_image( # Will download the image at the URL ``, and then send it client.send_remote_image( "", - message=Message(text="This is a remote image"), + message=fbchat.Message(text="This is a remote image"), thread_id=thread_id, thread_type=thread_type, ) # Only do these actions if the thread is a group -if thread_type == ThreadType.GROUP: +if thread_type == fbchat.ThreadType.GROUP: # Will remove the user with ID `` from the thread client.remove_user_from_group("", thread_id=thread_id) @@ -78,14 +84,14 @@ client.change_thread_title("", thread_id=thread_id, thread_type=thread_ty # Will set the typing status of the thread to `TYPING` client.set_typing_status( - TypingStatus.TYPING, thread_id=thread_id, thread_type=thread_type + fbchat.TypingStatus.TYPING, thread_id=thread_id, thread_type=thread_type ) # Will change the thread color to `MESSENGER_BLUE` -client.change_thread_color(ThreadColor.MESSENGER_BLUE, thread_id=thread_id) +client.change_thread_color(fbchat.ThreadColor.MESSENGER_BLUE, thread_id=thread_id) # Will change the thread emoji to `👍` client.change_thread_emoji("👍", thread_id=thread_id) # Will react to a message with a 😍 emoji -client.react_to_message("<message id>", MessageReaction.LOVE) +client.react_to_message("<message id>", fbchat.MessageReaction.LOVE) diff --git a/examples/keepbot.py b/examples/keepbot.py index e4a701b..414048c 100644 --- a/examples/keepbot.py +++ b/examples/keepbot.py @@ -1,11 +1,10 @@ -from fbchat import Client -from fbchat.models import * +import fbchat # Change this to your group id old_thread_id = "1234567890" # Change these to match your liking -old_color = ThreadColor.MESSENGER_BLUE +old_color = fbchat.ThreadColor.MESSENGER_BLUE old_emoji = "👍" old_title = "Old group chat name" old_nicknames = { @@ -16,7 +15,7 @@ old_nicknames = { } -class KeepBot(Client): +class KeepBot(fbchat.Client): def on_color_change(self, author_id, new_color, thread_id, thread_type, **kwargs): if old_thread_id == thread_id and old_color != new_color: print( @@ -77,5 +76,7 @@ class KeepBot(Client): ) -client = KeepBot("<email>", "<password>") -client.listen() +session = fbchat.Session.login("<email>", "<password>") + +keep_bot = KeepBot(session) +keep_bot.listen() diff --git a/examples/removebot.py b/examples/removebot.py index 90f63c1..4bb6d57 100644 --- a/examples/removebot.py +++ b/examples/removebot.py @@ -1,11 +1,13 @@ -from fbchat import Client -from fbchat.models import * +import fbchat -class RemoveBot(Client): +class RemoveBot(fbchat.Client): def on_message(self, author_id, message_object, thread_id, thread_type, **kwargs): # We can only kick people from group chats, so no need to try if it's a user chat - if message_object.text == "Remove me!" and thread_type == ThreadType.GROUP: + if ( + message_object.text == "Remove me!" + and thread_type == fbchat.ThreadType.GROUP + ): print("{} will be removed from {}".format(author_id, thread_id)) self.remove_user_from_group(author_id, thread_id=thread_id) else: @@ -19,5 +21,7 @@ class RemoveBot(Client): ) -client = RemoveBot("<email>", "<password>") -client.listen() +session = fbchat.Session.login("<email>", "<password>") + +remove_bot = RemoveBot(session) +remove_bot.listen() diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 1586476..4f88896 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -13,6 +13,7 @@ _logging.getLogger(__name__).addHandler(_logging.NullHandler()) 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 ._user import TypingStatus, User, ActiveStatus from ._group import Group @@ -44,4 +45,4 @@ __license__ = "BSD 3-Clause" __author__ = "Taehoon Kim; Moreels Pieter-Jan; Mads Marquart" __email__ = "carpedm20@gmail.com" -__all__ = ("Client",) +__all__ = ("Session", "Client") diff --git a/fbchat/_client.py b/fbchat/_client.py index 845e941..7d50339 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -5,7 +5,7 @@ import requests from collections import OrderedDict from ._core import log -from . import _util, _graphql, _state +from . import _util, _graphql, _session from ._exception import FBchatException, FBchatFacebookError from ._thread import ThreadType, ThreadLocation, ThreadColor @@ -38,9 +38,9 @@ ACONTEXT = { class Client: """A client for the Facebook Chat (Messenger). - This is the main class, which contains all the methods you use to interact with - Facebook. You can extend this class, and overwrite the ``on`` methods, to provide - custom event handling (mainly useful while listening). + This contains all the methods you use to interact with Facebook. You can extend this + class, and overwrite the ``on`` methods, to provide custom event handling (mainly + useful while listening). """ @property @@ -51,7 +51,7 @@ class Client: """ return self._uid - def __init__(self, email, password, session_cookies=None): + def __init__(self, session): """Initialize and log in the client. Args: @@ -67,27 +67,24 @@ class Client: self._pull_channel = 0 self._mark_alive = True self._buddylist = dict() + self._session = session + self._uid = session.user_id - # If session cookies aren't set, not properly loaded or gives us an invalid session, then do the login - if ( - not session_cookies - or not self.set_session(session_cookies) - or not self.is_logged_in() - ): - self.login(email, password) + def __repr__(self): + return "Client(session={!r})".format(self._session) """ INTERNAL REQUEST METHODS """ def _get(self, url, params): - return self._state._get(url, params) + return self._session._get(url, params) def _post(self, url, params, files=None): - return self._state._post(url, params, files=files) + return self._session._post(url, params, files=files) def _payload_post(self, url, data, files=None): - return self._state._payload_post(url, data, files=files) + return self._session._payload_post(url, data, files=files) def graphql_requests(self, *queries): """Execute GraphQL queries. @@ -101,7 +98,7 @@ class Client: Raises: FBchatException: If request failed """ - return tuple(self._state._graphql_requests(*queries)) + return tuple(self._session._graphql_requests(*queries)) def graphql_request(self, query): """Shorthand for ``graphql_requests(query)[0]``. @@ -115,83 +112,6 @@ class Client: END INTERNAL REQUEST METHODS """ - """ - LOGIN METHODS - """ - - def is_logged_in(self): - """Send a request to Facebook to check the login status. - - Returns: - bool: True if the client is still logged in - """ - return self._state.is_logged_in() - - def get_session(self): - """Retrieve session cookies. - - Returns: - dict: A dictionary containing session cookies - """ - return self._state.get_cookies() - - def set_session(self, session_cookies): - """Load session cookies. - - Args: - session_cookies (dict): A dictionary containing session cookies - - Returns: - bool: False if ``session_cookies`` does not contain proper cookies - """ - try: - # Load cookies into current session - self._state = _state.State.from_cookies(session_cookies) - self._uid = self._state.user_id - except Exception as e: - log.exception("Failed loading session") - return False - return True - - def login(self, email, password): - """Login the user, using ``email`` and ``password``. - - If the user is already logged in, this will do a re-login. - - Args: - email: Facebook ``email`` or ``id`` or ``phone number`` - password: Facebook account password - - Raises: - FBchatException: On failed login - """ - self.on_logging_in(email=email) - - if not (email and password): - raise ValueError("Email and password not set") - - self._state = _state.State.login( - email, password, on_2fa_callback=self.on_2fa_code - ) - self._uid = self._state.user_id - self.on_logged_in(email=email) - - def logout(self): - """Safely log out the client. - - Returns: - bool: True if the action was successful - """ - if self._state.logout(): - self._state = None - self._uid = None - return True - return False - - """ - END LOGIN METHODS - """ - """ FETCH METHODS """ @@ -936,7 +856,7 @@ class Client: def _do_send_request(self, data, get_thread_id=False): """Send the data to `SendURL`, and returns the message ID or None on failure.""" - mid, thread_id = self._state._do_send_request(data) + mid, thread_id = self._session._do_send_request(data) if get_thread_id: return mid, thread_id else: @@ -1107,7 +1027,7 @@ class Client: ) def _upload(self, files, voice_clip=False): - return self._state._upload(files, voice_clip=voice_clip) + return self._session._upload(files, voice_clip=voice_clip) def _send_files( self, files, message=None, thread_id=None, thread_type=ThreadType.USER @@ -1997,7 +1917,7 @@ class Client: data = { "seq": self._seq, "channel": "p_" + self._uid, - "clientid": self._state._client_id, + "clientid": self._session._client_id, "partition": -2, "cap": 0, "uid": self._uid, @@ -2019,7 +1939,7 @@ class Client: "msgs_recv": 0, "sticky_token": self._sticky, "sticky_pool": self._pool, - "clientid": self._state._client_id, + "clientid": self._session._client_id, "state": "active" if self._mark_alive else "offline", } j = self._get( @@ -2743,26 +2663,6 @@ class Client: EVENTS """ - def on_logging_in(self, email=None): - """Called when the client is logging in. - - Args: - email: The email of the client - """ - log.info("Logging in {}...".format(email)) - - def on_2fa_code(self): - """Called when a 2FA code is needed to progress.""" - return input("Please enter your 2FA code --> ") - - def on_logged_in(self, email=None): - """Called when the client is successfully logged in. - - Args: - email: The email of the client - """ - log.info("Login of {} successful.".format(email)) - def on_listening(self): """Called when the client is listening.""" log.info("Listening...") diff --git a/fbchat/_state.py b/fbchat/_session.py similarity index 79% rename from fbchat/_state.py rename to fbchat/_session.py index 9f69a16..886acc8 100644 --- a/fbchat/_state.py +++ b/fbchat/_session.py @@ -5,7 +5,7 @@ import requests import random import urllib.parse -from ._core import log, attrs_default +from ._core import log, kw_only from . import _graphql, _util, _exception FB_DTSG_REGEX = re.compile(r'name="fb_dtsg" value="(.*?)"') @@ -98,11 +98,14 @@ def _2fa_helper(session, code, r): return r -@attrs_default -class State: - """Stores and manages state required for most Facebook requests.""" +@attr.s(slots=True, kw_only=kw_only, repr=False) +class Session: + """Stores and manages state required for most Facebook requests. - user_id = attr.ib() + This is the main class, which is used to login to Facebook. + """ + + _user_id = attr.ib() _fb_dtsg = attr.ib() _revision = attr.ib() _session = attr.ib(factory=session_factory) @@ -110,7 +113,16 @@ class State: _client_id = attr.ib(factory=client_id_factory) _logout_h = attr.ib(None) - def get_params(self): + @property + def user_id(self): + """The logged in user's ID.""" + return self._user_id + + def __repr__(self): + # An alternative repr, to illustrate that you can't create the class directly + return "<fbchat.Session user_id={}>".format(self._user_id) + + def _get_params(self): self._counter += 1 # TODO: Make this operation atomic / thread-safe return { "__a": 1, @@ -120,7 +132,18 @@ class State: } @classmethod - def login(cls, email, password, on_2fa_callback): + def login(cls, email, password, on_2fa_callback=None): + """Login the user, using ``email`` and ``password``. + + Args: + email: Facebook ``email`` or ``id`` or ``phone number`` + password: Facebook account password + on_2fa_callback: Function that will be called, in case a 2FA code is needed. + This should return the requested 2FA code. + + Raises: + FBchatException: On failed login + """ session = session_factory() soup = find_input_fields(session.get("https://m.facebook.com/").text) @@ -137,6 +160,10 @@ class State: # Usually, 'Checkpoint' will refer to 2FA if "checkpoint" in r.url and ('id="approvals_code"' in r.text.lower()): + if not on_2fa_callback: + raise _exception.FBchatException( + "2FA code required, please add `on_2fa_callback` to .login" + ) code = on_2fa_callback() r = _2fa_helper(session, code, r) @@ -145,7 +172,7 @@ class State: r = session.get("https://m.facebook.com/login/save-device/cancel/") if is_home(r.url): - return cls.from_session(session=session) + return cls._from_session(session=session) else: raise _exception.FBchatException( "Login failed. Check email/password. " @@ -153,12 +180,24 @@ class State: ) def is_logged_in(self): + """Send a request to Facebook to check the login status. + + Returns: + bool: Whether the user is still logged in + """ # Send a request to the login url, to see if we're directed to the home page url = "https://m.facebook.com/login.php?login_attempt=1" r = self._session.get(url, allow_redirects=False) return "Location" in r.headers and is_home(r.headers["Location"]) def logout(self): + """Safely log out the user. + + The session object must not be used after this action has been performed! + + Raises: + FBchatException: On failed logout + """ logout_h = self._logout_h if not logout_h: url = _util.prefix_url("/bluebar/modern_settings_menu/") @@ -166,10 +205,14 @@ class State: logout_h = re.search(r'name=\\"h\\" value=\\"(.*?)\\"', h_r.text).group(1) url = _util.prefix_url("/logout.php") - return self._session.get(url, params={"ref": "mb", "h": logout_h}).ok + r = self._session.get(url, params={"ref": "mb", "h": logout_h}) + if not r.ok: + raise exception.FBchatException( + "Failed logging out: {}".format(r.status_code) + ) @classmethod - def from_session(cls, session): + def _from_session(cls, session): # TODO: Automatically set user_id when the cookie changes in the session user_id = get_user_id(session) @@ -198,22 +241,35 @@ class State: ) def get_cookies(self): + """Retrieve session cookies, that can later be used in `from_cookies`. + + Returns: + dict: A dictionary containing session cookies + """ return self._session.cookies.get_dict() @classmethod def from_cookies(cls, cookies): + """Load a session from session cookies. + + Args: + cookies (dict): A dictionary containing session cookies + + Raises: + FBchatException: If given invalid cookies + """ session = session_factory() session.cookies = requests.cookies.merge_cookies(session.cookies, cookies) - return cls.from_session(session=session) + return cls._from_session(session=session) def _get(self, url, params, error_retries=3): - params.update(self.get_params()) + params.update(self._get_params()) r = self._session.get(_util.prefix_url(url), params=params) content = _util.check_request(r) return _util.to_json(content) def _post(self, url, data, files=None, as_graphql=False): - data.update(self.get_params()) + data.update(self._get_params()) r = self._session.post(_util.prefix_url(url), data=data, files=files) content = _util.check_request(r) if as_graphql: @@ -266,7 +322,7 @@ class State: def _do_send_request(self, data): offline_threading_id = _util.generate_offline_threading_id() data["client"] = "mercury" - data["author"] = "fbid:{}".format(self.user_id) + data["author"] = "fbid:{}".format(self._user_id) data["timestamp"] = _util.now() data["source"] = "source:chat:web" data["offline_threading_id"] = offline_threading_id