diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9903544 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report if you're having trouble with `fbchat` + +--- + +## Description of the problem +Example: Logging in fails when the character `%` is in the password. A specific password that fails is `a_password_with_%` + +## Code to reproduce +```py +# Example code +from fbchat import Client +client = Client("[REDACTED_USERNAME]", "a_password_with_%") +``` + +## Traceback +``` +Traceback (most recent call last): + File "", line 1, in + File "[site-packages]/fbchat/client.py", line 78, in __init__ + self.login(email, password, max_tries) + File "[site-packages]/fbchat/client.py", line 407, in login + raise FBchatUserError('Login failed. Check email/password. (Failed on url: {})'.format(login_url)) +fbchat.models.FBchatUserError: Login failed. Check email/password. (Failed on url: https://m.facebook.com/login.php?login_attempt=1) +``` + +## Environment information +- Python version +- `fbchat` version +- If relevant, output from `$ python -m pip list` + +If you have done any research, include that. +Make sure to redact all personal information. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ac0c918 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest a feature that you'd like to see implemented + +--- + +## Description +Example: There's no way to send messages to groups + +## Research (if applicable) +Example: I've found the URL `https://facebook.com/send_message.php`, to which you can send a POST requests with the following JSON: +```json +{ + "text": message_content, + "fbid": group_id, + "some_variable": ? +} +``` +But I don't know how what `some_variable` does, and it doesn't work without it. I've found some examples of `some_variable` to be: `MTIzNDU2Nzg5MA`, `MTIzNDU2Nzg5MQ` and `MTIzNDU2Nzg5Mg` diff --git a/.gitignore b/.gitignore index 2345f05..cdfa971 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,11 @@ # Packages *.egg *.egg-info +*.dist-info dist build eggs +.eggs parts bin var @@ -29,6 +31,7 @@ my_tests.py my_test_data.json my_data.json tests.data +.pytest_cache # Virtual environment venv/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..18166dd --- /dev/null +++ b/.travis.yml @@ -0,0 +1,90 @@ +sudo: false +language: python +conditions: v1 + +# There are two accounts made specifically for Travis, and the passwords are really only encrypted for obscurity +# The global env variables `client1_email`, `client1_password`, `client2_email`, `client2_password` and `group_id` +# are set on the Travis Settings page + +# The tests are run with `Limit concurrent jobs = 1`, since the tests can't use the clients simultaneously + +install: + - pip install -U -r requirements.txt + - pip install -U -r dev-requirements.txt + +cache: + pip: true + # Pytest caching is disabled, since TravisCI instances have different public IPs. Facebook doesn't like that, + # and redirects you to the url `/checkpoint/block`, where you have to change the account's password + # directories: + # - .pytest_cache + +jobs: + include: + # The tests are split into online and offline versions. + # The online tests are only run against the master branch. + # Because: + # Travis caching is per-branch and per-job, so even though we cache the Facebook sessions via. `.pytest_cache` + # and in `tests.utils.load_client`, we need 6 new sessions per branch. This is usually the point where Facebook + # starts complaining, and we have to manually fix it + + - &test-online + if: (branch = master OR tag IS present) AND type != pull_request + stage: online tests + script: scripts/travis-online + + # Run online tests in all the supported python versions + python: 2.7 + - <<: *test-online + python: 3.4 + - <<: *test-online + python: 3.5 + - <<: *test-online + python: 3.6 + - <<: *test-online + python: pypy + + # Run the expensive tests, with the python version most likely to break, aka. 2 + - <<: *test-online + # Only run if the commit message includes [ci all] or [all ci] + if: commit_message =~ /\[ci\s+all\]|\[all\s+ci\]/ + python: 2.7 + env: PYTEST_ADDOPTS='-m expensive' + + - &test-offline + # Ideally, it'd be nice to run the offline tests in every build, but since we can't run jobs concurrently (yet), + # we'll disable them when they're not needed, and include them inside the online tests instead + if: not ((branch = master OR tag IS present) AND type != pull_request) + stage: offline tests + script: scripts/travis-offline + + # Run offline tests in all the supported python versions + python: 2.7 + - <<: *test-offline + python: 3.4 + - <<: *test-offline + python: 3.5 + - <<: *test-offline + python: 3.6 + - <<: *test-offline + python: 3.6 + - <<: *test-offline + python: pypy + + # Deploy to PyPI + - &deploy + stage: deploy + if: branch = master AND tag IS present + install: skip + deploy: + provider: pypi + user: madsmtm + password: + secure: "VA0MLSrwIW/T2KjMwjLZCzrLHw8pJT6tAvb48t7qpBdm8x192hax61pz1TaBZoJvlzyBPFKvluftuclTc7yEFwzXe7Gjqgd/ODKZl/wXDr36hQ7BBOLPZujdwmWLvTzMh3eJZlvkgcLCzrvK3j2oW8cM/+FZeVi/5/FhVuJ4ofs=" + distributions: sdist bdist_wheel + skip_existing: true + + # We need the bdist_wheels from both Python 2 and 3 + python: 3.6 + - <<: *deploy + python: 2.7 diff --git a/CODE_OF_CONDUCT b/CODE_OF_CONDUCT new file mode 100644 index 0000000..1e2a33b --- /dev/null +++ b/CODE_OF_CONDUCT @@ -0,0 +1,75 @@ +Contributor Covenant Code of Conduct + +Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + + +Examples of unacceptable behavior by participants include: + + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others’ private information, such as a physical or electronic +address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a +professional setting + + +Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at carpedm20@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project’s leadership. + +Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..c6e0767 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,22 @@ +Contributing to fbchat +====================== + +Thanks for reading this, all contributions are very much welcome! + +Please be aware that ``fbchat`` uses `Scemantic Versioning `__ +That means that if you're submitting a breaking change, it will probably take a while before it gets considered. + +In that case, you can point your PR to the ``2.0.0-dev`` branch, where the API is being properly developed. +Otherwise, just point it to ``master``. + +Testing Environment +------------------- + +The tests use `pytest `__, and to work they need two Facebook accounts, and a group thread between these. +To set these up, you should export the following environment variables: + +``client1_email``, ``client1_password``, ``client2_email``, ``client2_password`` and ``group_id`` + +If you're not able to do this, consider simply running ``pytest -m offline``. + +And if you're adding new functionality, if possible, make sure to create a new test for it. diff --git a/LICENSE.txt b/LICENSE similarity index 85% rename from LICENSE.txt rename to LICENSE index 73f2dde..321c518 100644 --- a/LICENSE.txt +++ b/LICENSE @@ -1,4 +1,4 @@ -New BSD License +BSD 3-Clause License Copyright (c) 2015, Taehoon Kim All rights reserved. @@ -13,8 +13,9 @@ modification, are permitted provided that the following conditions are met: this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -* The names of its contributors may not be used to endorse or promote products - derived from this software without specific prior written permission. +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE diff --git a/MANIFEST.in b/MANIFEST.in index 8f0b06d..57c2977 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -include LICENSE.txt -include MANIFEST.in +include LICENSE +include CONTRIBUTING.rst include README.rst -include setup.py diff --git a/README.rst b/README.rst index a1d14ea..718a7f3 100644 --- a/README.rst +++ b/README.rst @@ -2,24 +2,28 @@ fbchat: Facebook Chat (Messenger) for Python ============================================ .. image:: https://img.shields.io/badge/license-BSD-blue.svg - :target: LICENSE.txt - :alt: License: BSD + :target: https://github.com/carpedm20/fbchat/tree/master/LICENSE + :alt: License: BSD 3-Clause -.. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6-blue.svg +.. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6%20pypy-blue.svg :target: https://pypi.python.org/pypi/fbchat - :alt: Supported python versions: 2.7, 3.4, 3.5 and 3.6 + :alt: Supported python versions: 2.7, 3.4, 3.5, 3.6 and pypy .. image:: https://readthedocs.org/projects/fbchat/badge/?version=master :target: https://fbchat.readthedocs.io :alt: Documentation +.. image:: https://travis-ci.org/carpedm20/fbchat.svg?branch=master + :target: https://travis-ci.org/carpedm20/fbchat + :alt: Travis CI + Facebook Chat (`Messenger `__) for Python. This project was inspired by `facebook-chat-api `__. **No XMPP or API key is needed**. Just use your email and password. Go to `Read the Docs `__ to see the full documentation, -or jump right into the code by viewing the `examples `__ +or jump right into the code by viewing the `examples `__ Installation: @@ -27,6 +31,15 @@ Installation: $ pip install fbchat +You can also install from source, by using `setuptools` (You need at least version 30.3.0): + +.. code-block:: console + + $ git clone https://github.com/carpedm20/fbchat.git + $ cd fbchat + $ python setup.py install + + Maintainer ---------- diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..9f73302 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,2 @@ +pytest +six diff --git a/examples/fetch.py b/examples/fetch.py index e59ec63..878300d 100644 --- a/examples/fetch.py +++ b/examples/fetch.py @@ -8,8 +8,8 @@ client = Client('', '') # Fetches a list of all users you're currently chatting with, as `User` objects users = client.fetchAllUsers() -print("users' IDs: {}".format(user.uid for user in users)) -print("users' names: {}".format(user.name for user in users)) +print("users' IDs: {}".format([user.uid for user in users])) +print("users' names: {}".format([user.name for user in users])) # If we have a user id, we can use `fetchUserInfo` to fetch a `User` object @@ -18,7 +18,7 @@ user = client.fetchUserInfo('')[''] users = client.fetchUserInfo('<1st user id>', '<2nd user id>', '<3rd user id>') print("user's name: {}".format(user.name)) -print("users' names: {}".format(users[k].name for k in users)) +print("users' names: {}".format([users[k].name for k in users])) # `searchForUsers` searches for the user and gives us a list of the results, diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 67ef0a9..25dd5d4 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -1,28 +1,28 @@ # -*- coding: UTF-8 -*- -from __future__ import unicode_literals -from datetime import datetime -from .client import * - - """ fbchat ~~~~~~ Facebook Chat (Messenger) for Python - :copyright: (c) 2015 by Taehoon Kim. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2015 - 2018 by Taehoon Kim + :license: BSD 3-Clause, see LICENSE for more details. """ +from __future__ import unicode_literals + +from .client import * + +__title__ = 'fbchat' +__version__ = '1.4.0' +__description__ = 'Facebook Chat (Messenger) for Python' + +__copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim' +__license__ = 'BSD 3-Clause' -__copyright__ = 'Copyright 2015 - {} by Taehoon Kim'.format(datetime.now().year) -__version__ = '1.3.7' -__license__ = 'BSD' __author__ = 'Taehoon Kim; Moreels Pieter-Jan; Mads Marquart' __email__ = 'carpedm20@gmail.com' -__source__ = 'https://github.com/carpedm20/fbchat/' -__description__ = 'Facebook Chat (Messenger) for Python' __all__ = [ 'Client', diff --git a/fbchat/client.py b/fbchat/client.py index f720d94..93c1f2d 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -7,11 +7,16 @@ from uuid import uuid1 from random import choice from bs4 import BeautifulSoup as bs from mimetypes import guess_type +from collections import OrderedDict from .utils import * from .models import * from .graphql import * import time - +import json +try: + from urllib.parse import urlparse, parse_qs +except ImportError: + from urlparse import urlparse, parse_qs class Client(object): @@ -51,7 +56,8 @@ class Client(object): self._session = requests.session() self.req_counter = 1 self.seq = "0" - self.payloadDefault = {} + # See `createPoll` for the reason for using `OrderedDict` here + self.payloadDefault = OrderedDict() self.client = 'mercury' self.default_thread_id = None self.default_thread_type = None @@ -191,14 +197,14 @@ class Client(object): """ def _resetValues(self): - self.payloadDefault={} + self.payloadDefault = OrderedDict() self._session = requests.session() self.req_counter = 1 self.seq = "0" self.uid = None def _postLogin(self): - self.payloadDefault = {} + self.payloadDefault = OrderedDict() self.client_id = hex(int(random()*2147483648))[2:] self.start_time = now() self.uid = self._session.cookies.get_dict().get('c_user') @@ -209,7 +215,7 @@ class Client(object): self.ttstamp = '' r = self._get(self.req_url.BASE) - soup = bs(r.text, "lxml") + soup = bs(r.text, "html.parser") fb_dtsg_element = soup.find("input", {'name': 'fb_dtsg'}) if fb_dtsg_element: @@ -252,7 +258,7 @@ class Client(object): if not (self.email and self.password): raise FBchatUserError("Email and password not found.") - soup = bs(self._get(self.req_url.MOBILE).text, "lxml") + soup = bs(self._get(self.req_url.MOBILE).text, "html.parser") data = dict((elem['name'], elem['value']) for elem in soup.findAll("input") if elem.has_attr('value') and elem.has_attr('name')) data['email'] = self.email data['pass'] = self.password @@ -262,8 +268,7 @@ class Client(object): # Usually, 'Checkpoint' will refer to 2FA if ('checkpoint' in r.url - and ('enter security code to continue' in r.text.lower() - or 'enter login code to continue' in r.text.lower())): + and ('id="approvals_code"' in r.text.lower())): r = self._2FA(r) # Sometimes Facebook tries to show the user a "Save Device" dialog @@ -277,7 +282,7 @@ class Client(object): return False, r.url def _2FA(self, r): - soup = bs(r.text, "lxml") + soup = bs(r.text, "html.parser") data = dict() s = self.on2FACode() @@ -472,7 +477,16 @@ class Client(object): """ FETCH METHODS """ - + + def _forcedFetch(self, thread_id, mid): + j = self.graphql_request(GraphQL(doc_id='1768656253222505', params={ + 'thread_and_message_id': { + 'thread_id': thread_id, + 'message_id': mid + } + })) + return j + def fetchThreads(self, thread_location, after=None, limit=None): """ Get all threads in thread_location. @@ -519,7 +533,7 @@ class Client(object): for user_id,user in self.fetchUserInfo(*users_to_fetch).items(): users.append(user) return users - + def fetchAllUsers(self): """ Gets all users the client is currently chatting with @@ -577,7 +591,6 @@ class Client(object): return [graphql_to_page(node) for node in j[name]['pages']['nodes']] - # TODO intergrate Rooms def searchForGroups(self, name, limit=1): """ Find and get group thread by its name @@ -618,12 +631,89 @@ class Client(object): elif node['__typename'] == 'Group': # We don't handle Facebook "Groups" pass - # TODO Add Rooms else: log.warning('Unknown __typename: {} in {}'.format(repr(node['__typename']), node)) return rtn + def searchForMessageIDs(self, query, offset=0, limit=5, thread_id=None): + """ + Find and get message IDs by query + + :param query: Text to search for + :param offset: Number of messages to skip + :param limit: Max. number of messages to retrieve + :param thread_id: User/Group ID to search in. See :ref:`intro_threads` + :type offset: int + :type limit: int + :return: Found Message IDs + :rtype: generator + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + "query": query, + "snippetOffset": offset, + "snippetLimit": limit, + "identifier": "thread_fbid", + "thread_fbid": thread_id, + } + j = self._post(self.req_url.SEARCH_MESSAGES, data, fix_request=True, as_json=True) + + result = j["payload"]["search_snippets"][query] + snippets = result[thread_id]["snippets"] if result.get(thread_id) else [] + for snippet in snippets: + yield snippet["message_id"] + + def searchForMessages(self, query, offset=0, limit=5, thread_id=None): + """ + Find and get :class:`models.Message` objects by query + + .. warning:: + This method sends request for every found message ID. + + :param query: Text to search for + :param offset: Number of messages to skip + :param limit: Max. number of messages to retrieve + :param thread_id: User/Group ID to search in. See :ref:`intro_threads` + :type offset: int + :type limit: int + :return: Found :class:`models.Message` objects + :rtype: generator + :raises: FBchatException if request failed + """ + message_ids = self.searchForMessageIDs(query, offset=offset, limit=limit, thread_id=thread_id) + for mid in message_ids: + yield self.fetchMessageInfo(mid, thread_id) + + def search(self, query, fetch_messages=False, thread_limit=5, message_limit=5): + """ + Searches for messages in all threads + + :param query: Text to search for + :param fetch_messages: Whether to fetch :class:`models.Message` objects or IDs only + :param thread_limit: Max. number of threads to retrieve + :param message_limit: Max. number of messages to retrieve + :type thread_limit: int + :type message_limit: int + :return: Dictionary with thread IDs as keys and generators to get messages as values + :rtype: generator + :raises: FBchatException if request failed + """ + data = { + "query": query, + "snippetLimit": thread_limit + } + j = self._post(self.req_url.SEARCH_MESSAGES, data, fix_request=True, as_json=True) + + result = j["payload"]["search_snippets"][query] + + if fetch_messages: + return {thread_id: self.searchForMessages(query, limit=message_limit, thread_id=thread_id) for thread_id in result} + else: + return {thread_id: self.searchForMessageIDs(query, limit=message_limit, thread_id=thread_id) for thread_id in result} + def _fetchInfo(self, *ids): data = { "ids[{}]".format(i): _id for i, _id in enumerate(ids) @@ -742,7 +832,7 @@ class Client(object): queries = [] for thread_id in thread_ids: - queries.append(GraphQL(doc_id='1386147188135407', params={ + queries.append(GraphQL(doc_id='2147762685294928', params={ 'id': thread_id, 'message_limit': 0, 'load_messages': False, @@ -773,9 +863,6 @@ class Client(object): if entry.get('thread_type') == 'GROUP': _id = entry['thread_key']['thread_fbid'] rtn[_id] = graphql_to_group(entry) - elif entry.get('thread_type') == 'ROOM': - _id = entry['thread_key']['thread_fbid'] - rtn[_id] = graphql_to_room(entry) elif entry.get('thread_type') == 'ONE_TO_ONE': _id = entry['thread_key']['other_user_id'] if pages_and_users.get(_id) is None: @@ -902,6 +989,53 @@ class Client(object): raise FBChatException('Could not fetch image url from: {}'.format(j)) return url + def fetchMessageInfo(self, mid, thread_id=None): + """ + Fetches :class:`models.Message` object from the message id + + :param mid: Message ID to fetch from + :param thread_id: User/Group ID to get message info from. See :ref:`intro_threads` + :return: :class:`models.Message` object + :rtype: models.Message + :raises: FBChatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + message_info = self._forcedFetch(thread_id, mid).get("message") + message = graphql_to_message(message_info) + return message + + def fetchPollOptions(self, poll_id): + """ + Fetches list of :class:`models.PollOption` objects from the poll id + + :param poll_id: Poll ID to fetch from + :rtype: list + :raises: FBChatException if request failed + """ + data = { + "question_id": poll_id + } + + j = self._post(self.req_url.GET_POLL_OPTIONS, data, fix_request=True, as_json=True) + + return [graphql_to_poll_option(m) for m in j["payload"]] + + def fetchPlanInfo(self, plan_id): + """ + Fetches a :class:`models.Plan` object from the plan id + + :param plan_id: Plan ID to fetch from + :return: :class:`models.Plan` object + :rtype: models.Plan + :raises: FBChatException if request failed + """ + data = { + "event_reminder_id": plan_id + } + j = self._post(self.req_url.PLAN_INFO, data, fix_request=True, as_json=True) + plan = graphql_to_plan(j["payload"]) + return plan + """ END FETCH METHODS """ @@ -960,24 +1094,25 @@ class Client(object): return data - def _doSendRequest(self, data): + def _doSendRequest(self, data, get_thread_id=False): """Sends the data to `SendURL`, and returns the message ID or None on failure""" j = self._post(self.req_url.SEND, data, fix_request=True, as_json=True) - try: - message_ids = [action['message_id'] for action in j['payload']['actions'] if 'message_id' in action] - if len(message_ids) != 1: - log.warning("Got multiple message ids' back: {}".format(message_ids)) - message_id = message_ids[0] - except (KeyError, IndexError) as e: - raise FBchatException('Error when sending message: No message IDs could be found: {}'.format(j)) - # update JS token if received in response fb_dtsg = get_jsmods_require(j, 2) if fb_dtsg is not None: self.payloadDefault['fb_dtsg'] = fb_dtsg - return message_id + try: + message_ids = [(action['message_id'], action['thread_fbid']) for action in j['payload']['actions'] if 'message_id' in action] + if len(message_ids) != 1: + log.warning("Got multiple message ids' back: {}".format(message_ids)) + if get_thread_id: + return message_ids[0] + else: + return message_ids[0][0] + except (KeyError, IndexError, TypeError) as e: + raise FBchatException('Error when sending message: No message IDs could be found: {}'.format(j)) def send(self, message, thread_id=None, thread_type=ThreadType.USER): """ @@ -1008,25 +1143,48 @@ class Client(object): """ return self.send(Message(text=emoji, emoji_size=size), thread_id=thread_id, thread_type=thread_type) - def _uploadImage(self, image_path, data, mimetype): - """Upload an image and get the image_id for sending in a message""" - - j = self._postFile(self.req_url.UPLOAD, { - 'file': ( - image_path, - data, - mimetype - ) - }, fix_request=True, as_json=True) - # Return the image_id - if not mimetype == 'image/gif': - return j['payload']['metadata'][0]['image_id'] - else: - return j['payload']['metadata'][0]['gif_id'] - - def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False): + def wave(self, wave_first=True, thread_id=None, thread_type=None): """ - Deprecated. Use :func:`fbchat.Client.send` instead + Says hello with a wave to a thread! + + :param wave_first: Whether to wave first or wave back + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent message + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, thread_type) + data = self._getSendData(thread_id=thread_id, thread_type=thread_type) + data['action_type'] = 'ma-type:user-generated-message' + data['lightweight_action_attachment[lwa_state]'] = "INITIATED" if wave_first else "RECIPROCATED" + data['lightweight_action_attachment[lwa_type]'] = "WAVE" + if thread_type == ThreadType.USER: + data['specific_to_list[0]'] = "fbid:{}".format(thread_id) + return self._doSendRequest(data) + + def _upload(self, files): + """ + Uploads files to Facebook + + `files` should be a list of files that requests can upload, see: + http://docs.python-requests.org/en/master/api/#requests.request + + Returns a list of tuples with a file's ID and mimetype + """ + file_dict = {'upload_{}'.format(i): f for i, f in enumerate(files)} + j = self._postFile(self.req_url.UPLOAD, files=file_dict, fix_request=True, as_json=True) + + if len(j['payload']['metadata']) != len(files): + raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files)) + + return [(data[mimetype_to_key(data['filetype'])], data['filetype']) for data in j['payload']['metadata']] + + def _sendFiles(self, files, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Sends files from file IDs to a thread + + `files` should be a list of tuples, with a file's ID and mimetype """ thread_id, thread_type = self._getThread(thread_id, thread_type) data = self._getSendData(message=self._oldMessage(message), thread_id=thread_id, thread_type=thread_type) @@ -1034,49 +1192,86 @@ class Client(object): data['action_type'] = 'ma-type:user-generated-message' data['has_attachment'] = True - if not is_gif: - data['image_ids[0]'] = image_id - else: - data['gif_ids[0]'] = image_id + for i, (file_id, mimetype) in enumerate(files): + data['{}s[{}]'.format(mimetype_to_key(mimetype), i)] = file_id return self._doSendRequest(data) - def sendRemoteImage(self, image_url, message=None, thread_id=None, thread_type=ThreadType.USER): + def sendRemoteFiles(self, file_urls, message=None, thread_id=None, thread_type=ThreadType.USER): """ - Sends an image from a URL to a thread + Sends files from URLs to a thread - :param image_url: URL of an image to upload and send + :param file_urls: URLs of files to upload and send :param message: Additional message :param thread_id: User/Group ID to send to. See :ref:`intro_threads` :param thread_type: See :ref:`intro_threads` :type thread_type: models.ThreadType - :return: :ref:`Message ID ` of the sent image + :return: :ref:`Message ID ` of the sent files :raises: FBchatException if request failed """ - thread_id, thread_type = self._getThread(thread_id, thread_type) - mimetype = guess_type(image_url)[0] - is_gif = (mimetype == 'image/gif') - remote_image = requests.get(image_url).content - image_id = self._uploadImage(image_url, remote_image, mimetype) - return self.sendImage(image_id=image_id, message=message, thread_id=thread_id, thread_type=thread_type, is_gif=is_gif) + file_urls = require_list(file_urls) + files = self._upload(get_files_from_urls(file_urls)) + return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) + + def sendLocalFiles(self, file_paths, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Sends local files to a thread + + :param file_path: Paths of files to upload and send + :param message: Additional message + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent files + :raises: FBchatException if request failed + """ + file_paths = require_list(file_paths) + with get_files_from_paths(file_paths) as x: + files = self._upload(x) + return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) + + def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False): + """ + Deprecated. Use :func:`fbchat.Client._sendFiles` instead + """ + if is_gif: + return self._sendFiles(files=[(image_id, "image/png")], message=message, thread_id=thread_id, thread_type=thread_type) + else: + return self._sendFiles(files=[(image_id, "image/gif")], message=message, thread_id=thread_id, thread_type=thread_type) + + def sendRemoteImage(self, image_url, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Deprecated. Use :func:`fbchat.Client.sendRemoteFiles` instead + """ + return self.sendRemoteFiles(file_urls=[image_url], message=message, thread_id=thread_id, thread_type=thread_type) def sendLocalImage(self, image_path, message=None, thread_id=None, thread_type=ThreadType.USER): """ - Sends a local image to a thread + Deprecated. Use :func:`fbchat.Client.sendLocalFiles` instead + """ + return self.sendLocalFiles(file_paths=[image_path], message=message, thread_id=thread_id, thread_type=thread_type) - :param image_path: Path of an image to upload and send - :param message: Additional message - :param thread_id: User/Group ID to send to. See :ref:`intro_threads` - :param thread_type: See :ref:`intro_threads` - :type thread_type: models.ThreadType - :return: :ref:`Message ID ` of the sent image + def createGroup(self, message, user_ids): + """ + Creates a group with the given ids + + :param message: The initial message + :param user_ids: A list of users to create the group with. + :return: ID of the new group :raises: FBchatException if request failed """ - thread_id, thread_type = self._getThread(thread_id, thread_type) - mimetype = guess_type(image_path)[0] - is_gif = (mimetype == 'image/gif') - image_id = self._uploadImage(image_path, open(image_path, 'rb'), mimetype) - return self.sendImage(image_id=image_id, message=message, thread_id=thread_id, thread_type=thread_type, is_gif=is_gif) + data = self._getSendData(message=self._oldMessage(message)) + + if len(user_ids) < 2: + raise FBchatUserError("Error when creating group: Not enough participants") + + for i, user_id in enumerate(user_ids + [self.uid]): + data['specific_to_list[{}]'.format(i)] = 'fbid:{}'.format(user_id) + + message_id, thread_id = self._doSendRequest(data, get_thread_id=True) + if not thread_id: + raise FBchatException("Error when creating group: No thread_id could be found") + return thread_id def addUsersToGroup(self, user_ids, thread_id=None): """ @@ -1085,7 +1280,6 @@ class Client(object): :param user_ids: One or more user IDs to add :param thread_id: Group ID to add people to. See :ref:`intro_threads` :type user_ids: list - :return: :ref:`Message ID ` of the executed action :raises: FBchatException if request failed """ thread_id, thread_type = self._getThread(thread_id, None) @@ -1094,11 +1288,7 @@ class Client(object): data['action_type'] = 'ma-type:log-message' data['log_message_type'] = 'log:subscribe' - if type(user_ids) is not list: - user_ids = [user_ids] - - # Make list of users unique - user_ids = set(user_ids) + user_ids = require_list(user_ids) for i, user_id in enumerate(user_ids): if user_id == self.uid: @@ -1126,6 +1316,139 @@ class Client(object): j = self._post(self.req_url.REMOVE_USER, data, fix_request=True, as_json=True) + def _adminStatus(self, admin_ids, admin, thread_id=None): + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + "add": admin, + "thread_fbid": thread_id + } + + admin_ids = require_list(admin_ids) + + for i, admin_id in enumerate(admin_ids): + data['admin_ids[' + str(i) + ']'] = str(admin_id) + + j = self._post(self.req_url.SAVE_ADMINS, data, fix_request=True, as_json=True) + + def addGroupAdmins(self, admin_ids, thread_id=None): + """ + Sets specifed users as group admins. + + :param admin_ids: One or more user IDs to set admin + :param thread_id: Group ID to remove people from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._adminStatus(admin_ids, True, thread_id) + + def removeGroupAdmins(self, admin_ids, thread_id=None): + """ + Removes admin status from specifed users. + + :param admin_ids: One or more user IDs to remove admin + :param thread_id: Group ID to remove people from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._adminStatus(admin_ids, False, thread_id) + + def changeGroupApprovalMode(self, require_admin_approval, thread_id=None): + """ + Changes group's approval mode + + :param require_admin_approval: True or False + :param thread_id: Group ID to remove people from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + "set_mode": int(require_admin_approval), + "thread_fbid": thread_id + } + + j = self._post(self.req_url.APPROVAL_MODE, data, fix_request=True, as_json=True) + + def _usersApproval(self, user_ids, approve, thread_id=None): + thread_id, thread_type = self._getThread(thread_id, None) + + user_ids = list(require_list(user_ids)) + + j = self.graphql_request(GraphQL(doc_id='1574519202665847', params={ + 'data': { + 'client_mutation_id': '0', + 'actor_id': self.uid, + 'thread_fbid': thread_id, + 'user_ids': user_ids, + 'response': 'ACCEPT' if approve else 'DENY', + 'surface': 'ADMIN_MODEL_APPROVAL_CENTER' + } + })) + + def acceptUsersToGroup(self, user_ids, thread_id=None): + """ + Accepts users to the group from the group's approval + + :param user_ids: One or more user IDs to accept + :param thread_id: Group ID to accept users to. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._usersApproval(user_ids, True, thread_id) + + def denyUsersFromGroup(self, user_ids, thread_id=None): + """ + Denies users from the group's approval + + :param user_ids: One or more user IDs to deny + :param thread_id: Group ID to deny users from. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + self._usersApproval(user_ids, False, thread_id) + + def _changeGroupImage(self, image_id, thread_id=None): + """ + Changes a thread image from an image id + + :param image_id: ID of uploaded image + :param thread_id: User/Group ID to change image. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + 'thread_image_id': image_id, + 'thread_id': thread_id + } + + j = self._post(self.req_url.THREAD_IMAGE, data, fix_request=True, as_json=True) + return image_id + + def changeGroupImageRemote(self, image_url, thread_id=None): + """ + Changes a thread image from a URL + + :param image_url: URL of an image to upload and change + :param thread_id: User/Group ID to change image. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + + (image_id, mimetype), = self._upload(get_files_from_urls([image_url])) + return self._changeGroupImage(image_id, thread_id) + + def changeGroupImageLocal(self, image_path, thread_id=None): + """ + Changes a thread image from a local path + + :param image_path: Path of an image to upload and change + :param thread_id: User/Group ID to change image. See :ref:`intro_threads` + :raises: FBchatException if request failed + """ + + with get_files_from_paths([image_path]) as files: + (image_id, mimetype), = self._upload(files) + + return self._changeGroupImage(image_id, thread_id) + def changeThreadTitle(self, title, thread_id=None, thread_type=ThreadType.USER): """ Changes title of a thread. @@ -1143,14 +1466,13 @@ class Client(object): if thread_type == ThreadType.USER: # The thread is a user, so we change the user's nickname return self.changeNickname(title, thread_id, thread_id=thread_id, thread_type=thread_type) - else: - data = self._getSendData(thread_id=thread_id, thread_type=thread_type) - data['action_type'] = 'ma-type:log-message' - data['log_message_data[name]'] = title - data['log_message_type'] = 'log:thread-name' + data = { + 'thread_name': title, + 'thread_id': thread_id, + } - return self._doSendRequest(data) + j = self._post(self.req_url.THREAD_NAME, data, fix_request=True, as_json=True) def changeNickname(self, nickname, user_id, thread_id=None, thread_type=ThreadType.USER): """ @@ -1185,7 +1507,7 @@ class Client(object): thread_id, thread_type = self._getThread(thread_id, None) data = { - 'color_choice': color.value, + 'color_choice': color.value if color != ThreadColor.MESSENGER_BLUE else '', 'thread_or_other_fbid': thread_id } @@ -1221,8 +1543,7 @@ class Client(object): """ full_data = { "doc_id": 1491398900900362, - "dpr": 1, - "variables": { + "variables": json.dumps({ "data": { "action": "ADD_REACTION", "client_mutation_id": "1", @@ -1230,43 +1551,29 @@ class Client(object): "message_id": str(message_id), "reaction": reaction.value } - } + }) } - try: - url_part = urllib.parse.urlencode(full_data) - except AttributeError: - # This is a very hacky solution for python 2 support, please suggest a better one ;) - url_part = urllib.urlencode(full_data)\ - .replace('u%27', '%27')\ - .replace('%5CU{}'.format(MessageReactionFix[reaction.value][0]), MessageReactionFix[reaction.value][1]) - j = self._post('{}/?{}'.format(self.req_url.MESSAGE_REACTION, url_part), fix_request=True, as_json=True) + j = self._post(self.req_url.MESSAGE_REACTION, full_data, fix_request=True, as_json=True) - def eventReminder(self, thread_id, time, title, location='', location_id=''): + def createPlan(self, plan, thread_id=None): """ - Sets an event reminder + Sets a plan - ..warning:: - Does not work in Python2.7 - - ..todo:: - Make this work in Python2.7 - - :param thread_id: User/Group ID to send event to. See :ref:`intro_threads` - :param time: Event time (unix time stamp) - :param title: Event title - :param location: Event location name - :param location_id: Event location ID + :param plan: Plan to set + :param thread_id: User/Group ID to send plan to. See :ref:`intro_threads` + :type plan: models.Plan :raises: FBchatException if request failed """ + thread_id, thread_type = self._getThread(thread_id, None) + full_data = { "event_type": "EVENT", - "dpr": 1, - "event_time" : time, - "title" : title, + "event_time" : plan.time, + "title" : plan.title, "thread_id" : thread_id, - "location_id" : location_id, - "location_name" : location, + "location_id" : plan.location_id or '', + "location_name" : plan.location or '', "acontext": { "action_history": [{ "surface": "messenger_chat_tab", @@ -1274,10 +1581,134 @@ class Client(object): }] } } - url_part = urllib.parse.urlencode(full_data) - j = self._post('{}/?{}'.format(self.req_url.EVENT_REMINDER, url_part), fix_request=True, as_json=True) + j = self._post(self.req_url.PLAN_CREATE, full_data, fix_request=True, as_json=True) + def editPlan(self, plan, new_plan): + """ + Edits a plan + + :param plan: Plan to edit + :param new_plan: New plan + :type plan: models.Plan + :raises: FBchatException if request failed + """ + full_data = { + "event_reminder_id": plan.uid, + "delete": "false", + "date": new_plan.time, + "location_name": new_plan.location or '', + "location_id": new_plan.location_id or '', + "title": new_plan.title, + "acontext": { + "action_history": [{ + "surface": "messenger_chat_tab", + "mechanism": "reminder_banner" + }] + } + } + + j = self._post(self.req_url.PLAN_CHANGE, full_data, fix_request=True, as_json=True) + + def deletePlan(self, plan): + """ + Deletes a plan + + :param plan: Plan to delete + :raises: FBchatException if request failed + """ + full_data = { + "event_reminder_id": plan.uid, + "delete": "true", + "acontext": { + "action_history": [{ + "surface": "messenger_chat_tab", + "mechanism": "reminder_banner" + }] + } + } + + j = self._post(self.req_url.PLAN_CHANGE, full_data, fix_request=True, as_json=True) + + def changePlanParticipation(self, plan, take_part=True): + """ + Changes participation in a plan + + :param plan: Plan to take part in or not + :param take_part: Whether to take part in the plan + :raises: FBchatException if request failed + """ + full_data = { + "event_reminder_id": plan.uid, + "guest_state": "GOING" if take_part else "DECLINED", + "acontext": { + "action_history": [{ + "surface": "messenger_chat_tab", + "mechanism": "reminder_banner" + }] + } + } + + j = self._post(self.req_url.PLAN_PARTICIPATION, full_data, fix_request=True, as_json=True) + + def eventReminder(self, thread_id, time, title, location='', location_id=''): + """ + Deprecated. Use :func:`fbchat.Client.createPlan` instead + """ + self.createPlan(plan=Plan(time=time, title=title, location=location, location_id=location_id), thread_id=thread_id) + + def createPoll(self, poll, thread_id=None): + """ + Creates poll in a group thread + + :param poll: Poll to create + :param thread_id: User/Group ID to create poll in. See :ref:`intro_threads` + :type poll: models.Poll + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + + # We're using ordered dicts, because the Facebook endpoint that parses the POST + # parameters is badly implemented, and deals with ordering the options wrongly. + # This also means we had to change `client.payloadDefault` to an ordered dict, + # since that's being copied in between this point and the `requests` call + # + # 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", poll.title), + ("target_id", thread_id), + ]) + + for i, option in enumerate(poll.options): + data["option_text_array[{}]".format(i)] = option.text + data["option_is_selected_array[{}]".format(i)] = str(int(option.vote)) + + j = self._post(self.req_url.CREATE_POLL, data, fix_request=True, as_json=True) + + def updatePollVote(self, poll_id, option_ids=[], new_options=[]): + """ + Updates a poll vote + + :param poll_id: ID of the poll to update vote + :param option_ids: List of the option IDs to vote + :param new_options: List of the new option names + :param thread_id: User/Group ID to change status in. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :raises: FBchatException if request failed + """ + data = { + "question_id": poll_id + } + + for i, option_id in enumerate(option_ids): + data["selected_options[{}]".format(i)] = option_id + + for i, option_text in enumerate(new_options): + data["new_options[{}]".format(i)] = option_text + + j = self._post(self.req_url.UPDATE_VOTE, data, fix_request=True, as_json=True) def setTypingStatus(self, status, thread_id=None, thread_type=None): """ @@ -1322,24 +1753,42 @@ class Client(object): r = self._post(self.req_url.DELIVERED, data) return r.ok - def markAsRead(self, thread_id): - """ - Mark a thread as read - All messages inside the thread will be marked as read + def _readStatus(self, read, thread_ids): + thread_ids = require_list(thread_ids) - :param thread_id: User/Group ID to set as read. See :ref:`intro_threads` - :return: Whether the request was successful - :raises: FBchatException if request failed - """ data = { - "ids[%s]" % thread_id: 'true', "watermarkTimestamp": now(), "shouldSendReadReceipt": 'true', } + for thread_id in thread_ids: + data["ids[{}]".format(thread_id)] = read + r = self._post(self.req_url.READ_STATUS, data) return r.ok + def markAsRead(self, thread_ids=None): + """ + Mark threads as read + All messages inside the threads will be marked as read + + :param thread_ids: User/Group IDs to set as read. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + self._readStatus(True, thread_ids) + + def markAsUnread(self, thread_ids=None): + """ + Mark threads as unread + All messages inside the threads will be marked as unread + + :param thread_ids: User/Group IDs to set as unread. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + self._readStatus(False, thread_ids) + def markAsSeen(self): """ .. todo:: @@ -1361,6 +1810,199 @@ class Client(object): r = self._post(self.req_url.CONNECT, data) return r.ok + def removeFriend(self, friend_id=None): + """ + Removes a specifed friend from your friend list + + :param friend_id: The ID of the friend that you want to remove + :return: Returns error if the removing was unsuccessful, returns True when successful. + """ + payload = { + "friend_id": friend_id, + "unref": "none", + "confirm": "Confirm", + } + r = self._post(self.req_url.REMOVE_FRIEND, payload) + query = parse_qs(urlparse(r.url).query) + if "err" not in query: + log.debug("Remove was successful!") + return True + else: + log.warning("Error while removing friend") + return False + + def blockUser(self, user_id): + """ + Blocks messages from a specifed user + + :param user_id: The ID of the user that you want to block + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + data = { + 'fbid': user_id + } + r = self._post(self.req_url.BLOCK_USER, data) + return r.ok + + def unblockUser(self, user_id): + """ + Unblocks messages from a blocked user + + :param user_id: The ID of the user that you want to unblock + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + data = { + 'fbid': user_id + } + r = self._post(self.req_url.UNBLOCK_USER, data) + return r.ok + + def moveThreads(self, location, thread_ids): + """ + Moves threads to specifed location + + :param location: models.ThreadLocation: INBOX, PENDING, ARCHIVED or OTHER + :param thread_ids: Thread IDs to move. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + thread_ids = require_list(thread_ids) + + if location == ThreadLocation.PENDING: + location = ThreadLocation.OTHER + + if location == ThreadLocation.ARCHIVED: + data_archive = dict() + data_unpin = dict() + for thread_id in thread_ids: + data_archive["ids[{}]".format(thread_id)] = 'true' + data_unpin["ids[{}]".format(thread_id)] = 'false' + r_archive = self._post(self.req_url.ARCHIVED_STATUS, data_archive) + r_unpin = self._post(self.req_url.PINNED_STATUS, data_unpin) + return r_archive.ok and r_unpin.ok + else: + data = dict() + for i, thread_id in enumerate(thread_ids): + data["{}[{}]".format(location.name.lower(), i)] = thread_id + r = self._post(self.req_url.MOVE_THREAD, data) + return r.ok + + def deleteThreads(self, thread_ids): + """ + Deletes threads + + :param thread_ids: Thread IDs to delete. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + thread_ids = require_list(thread_ids) + + data_unpin = dict() + data_delete = dict() + for i, thread_id in enumerate(thread_ids): + data_unpin["ids[{}]".format(thread_id)] = "false" + data_delete["ids[{}]".format(i)] = thread_id + r_unpin = self._post(self.req_url.PINNED_STATUS, data_unpin) + r_delete = self._post(self.req_url.DELETE_THREAD, data_delete) + return r_unpin.ok and r_delete.ok + + def markAsSpam(self, thread_id=None): + """ + Mark a thread as spam and delete it + + :param thread_id: User/Group ID to mark as spam. See :ref:`intro_threads` + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, None) + r = self._post(self.req_url.MARK_SPAM, {"id": thread_id}) + return r.ok + + def deleteMessages(self, message_ids): + """ + Deletes specifed messages + + :param message_ids: Message IDs to delete + :return: Whether the request was successful + :raises: FBchatException if request failed + """ + message_ids = require_list(message_ids) + data = dict() + for i, message_id in enumerate(message_ids): + data["message_ids[{}]".format(i)] = message_id + r = self._post(self.req_url.DELETE_MESSAGES, data) + return r.ok + + def muteThread(self, mute_time=-1, thread_id=None): + """ + Mutes thread + + :param mute_time: Mute time in seconds, leave blank to mute forever + :param thread_id: User/Group ID to mute. See :ref:`intro_threads` + """ + thread_id, thread_type = self._getThread(thread_id, None) + data = { + "mute_settings": str(mute_time), + "thread_fbid": thread_id + } + r = self._post(self.req_url.MUTE_THREAD, data) + r.raise_for_status() + + def unmuteThread(self, thread_id=None): + """ + Unmutes thread + + :param thread_id: User/Group ID to unmute. See :ref:`intro_threads` + """ + return self.muteThread(0, thread_id) + + def muteThreadReactions(self, mute=True, thread_id=None): + """ + Mutes thread reactions + + :param mute: Boolean. True to mute, False to unmute + :param thread_id: User/Group ID to mute. See :ref:`intro_threads` + """ + thread_id, thread_type = self._getThread(thread_id, None) + data = { + "reactions_mute_mode": int(mute), + "thread_fbid": thread_id + } + r = self._post(self.req_url.MUTE_REACTIONS, data) + r.raise_for_status() + + def unmuteThreadReactions(self, thread_id=None): + """ + Unmutes thread reactions + + :param thread_id: User/Group ID to unmute. See :ref:`intro_threads` + """ + return self.muteThreadReactions(False, thread_id) + + def muteThreadMentions(self, mute=True, thread_id=None): + """ + Mutes thread mentions + + :param mute: Boolean. True to mute, False to unmute + :param thread_id: User/Group ID to mute. See :ref:`intro_threads` + """ + thread_id, thread_type = self._getThread(thread_id, None) + data = { + "mentions_mute_mode": int(mute), + "thread_fbid": thread_id + } + r = self._post(self.req_url.MUTE_MENTIONS, data) + r.raise_for_status() + + def unmuteThreadMentions(self, thread_id=None): + """ + Unmutes thread mentions + + :param thread_id: User/Group ID to unmute. See :ref:`intro_threads` + """ + return self.muteThreadMentions(False, thread_id) """ LISTEN METHODS @@ -1376,7 +2018,7 @@ class Client(object): 'sticky_token': sticky, 'sticky_pool': pool, 'viewer_uid': self.uid, - 'state': 'active' + 'state': 'active', } self._get(self.req_url.PING, data, fix_request=True, as_json=False) @@ -1396,7 +2038,7 @@ class Client(object): return j['lb_info']['sticky'], j['lb_info']['pool'] - def _pullMessage(self, sticky, pool): + def _pullMessage(self, sticky, pool, markAlive=True): """Call pull api with seq value to get message data.""" data = { @@ -1404,6 +2046,7 @@ class Client(object): "sticky_token": sticky, "sticky_pool": pool, "clientid": self.client_id, + 'state': 'active' if markAlive else 'offline', } j = self._get(ReqUrl.STICKY, data, fix_request=True, as_json=True) @@ -1478,6 +2121,24 @@ class Client(object): self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + # Forced fetch + elif delta.get("class") == "ForcedFetch": + mid = delta.get("messageId") + if mid is None: + self.onUnknownMesssageType(msg=m) + else: + thread_id = str(delta['threadKey']['threadFbId']) + fetch_info = self._forcedFetch(thread_id, mid) + fetch_data = fetch_info["message"] + author_id = fetch_data["message_sender"]["id"] + ts = fetch_data["timestamp_precise"] + if fetch_data.get("__typename") == "ThreadImageMessage": + # Thread image change + image_metadata = fetch_data.get("image_with_metadata") + image_id = int(image_metadata["legacy_attachment_id"]) if image_metadata else None + self.onImageChange(mid=mid, author_id=author_id, new_image=image_id, thread_id=thread_id, + thread_type=ThreadType.GROUP, ts=ts) + # Nickname change elif delta_type == "change_thread_nickname": changed_for = str(delta["untypedData"]["participant_id"]) @@ -1487,6 +2148,25 @@ class Client(object): new_nickname=new_nickname, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + # Admin added or removed in a group thread + elif delta_type == "change_thread_admins": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + target_id = delta["untypedData"]["TARGET_ID"] + admin_event = delta["untypedData"]["ADMIN_EVENT"] + if admin_event == "add_admin": + self.onAdminAdded(mid=mid, added_id=target_id, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, msg=m) + elif admin_event == "remove_admin": + self.onAdminRemoved(mid=mid, removed_id=target_id, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, msg=m) + + # Group approval mode change + elif delta_type == "change_thread_approval_mode": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + approval_mode = bool(int(delta['untypedData']['APPROVAL_MODE'])) + self.onApprovalModeChange(mid=mid, approval_mode=approval_mode, author_id=author_id, + thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) + # Message delivered elif delta.get("class") == "DeliveryReceipt": message_ids = delta["messageIds"] @@ -1494,7 +2174,8 @@ class Client(object): ts = int(delta["deliveredWatermarkTimestampMs"]) thread_id, thread_type = getThreadIdAndThreadType(delta) self.onMessageDelivered(msg_ids=message_ids, delivered_for=delivered_for, - thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + thread_id=thread_id, thread_type=thread_type, ts=ts, + metadata=metadata, msg=m) # Message seen elif delta.get("class") == "ReadReceipt": @@ -1517,6 +2198,95 @@ class Client(object): # thread_id, thread_type = getThreadIdAndThreadType(delta) self.onMarkedSeen(threads=threads, seen_ts=seen_ts, ts=delivered_ts, metadata=delta, msg=m) + # Game played + elif delta_type == "instant_game_update": + game_id = delta["untypedData"]["game_id"] + game_name = delta["untypedData"]["game_name"] + score = delta["untypedData"].get("score") + if score is not None: + score = int(score) + leaderboard = delta["untypedData"].get("leaderboard") + if leaderboard is not None: + leaderboard = json.loads(leaderboard)["scores"] + thread_id, thread_type = getThreadIdAndThreadType(metadata) + self.onGamePlayed(mid=mid, author_id=author_id, game_id=game_id, game_name=game_name, + score=score, leaderboard=leaderboard, thread_id=thread_id, + thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Group call started/ended + elif delta_type == "rtc_call_log": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + call_status = delta["untypedData"]["event"] + call_duration = int(delta["untypedData"]["call_duration"]) + is_video_call = bool(int(delta["untypedData"]["is_video_call"])) + if call_status == "call_started": + self.onCallStarted(mid=mid, caller_id=author_id, is_video_call=is_video_call, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + elif call_status == "call_ended": + self.onCallEnded(mid=mid, caller_id=author_id, is_video_call=is_video_call, call_duration=call_duration, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # User joined to group call + elif delta_type == "participant_joined_group_call": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + is_video_call = bool(int(delta["untypedData"]["group_call_type"])) + self.onUserJoinedCall(mid=mid, joined_id=author_id, is_video_call=is_video_call, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Group poll event + elif delta_type == "group_poll": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + event_type = delta["untypedData"]["event_type"] + poll_json = json.loads(delta["untypedData"]["question_json"]) + poll = graphql_to_poll(poll_json) + if event_type == "question_creation": + # User created group poll + self.onPollCreated(mid=mid, poll=poll, author_id=author_id, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + elif event_type == "update_vote": + # User voted on group poll + added_options = json.loads(delta["untypedData"]["added_option_ids"]) + removed_options = json.loads(delta["untypedData"]["removed_option_ids"]) + self.onPollVoted(mid=mid, poll=poll, added_options=added_options, removed_options=removed_options, + author_id=author_id, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + + # Plan created + elif delta_type == "lightweight_event_create": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanCreated(mid=mid, plan=plan, author_id=author_id, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + + # Plan ended + elif delta_type == "lightweight_event_notify": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanEnded(mid=mid, plan=plan, thread_id=thread_id, thread_type=thread_type, + ts=ts, metadata=metadata, msg=m) + + # Plan edited + elif delta_type == "lightweight_event_update": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanEdited(mid=mid, plan=plan, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Plan deleted + elif delta_type == "lightweight_event_delete": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + self.onPlanDeleted(mid=mid, plan=plan, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + + # Plan participation change + elif delta_type == "lightweight_event_rsvp": + thread_id, thread_type = getThreadIdAndThreadType(metadata) + plan = graphql_to_plan(delta["untypedData"]) + take_part = delta["untypedData"]["guest_status"] == "GOING" + self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id, + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + # New message elif delta.get("class") == "NewMessage": mentions = [] @@ -1576,13 +2346,18 @@ class Client(object): self.onInbox(unseen=m["unseen"], unread=m["unread"], recent_unread=m["recent_unread"], msg=m) # Typing - elif mtype == "typ": + elif mtype == "typ" or mtype == "ttyp": author_id = str(m.get("from")) - thread_id = str(m.get("to")) - if thread_id == self.uid: - thread_type = ThreadType.USER - else: + thread_id = m.get("thread_fbid") + if thread_id: thread_type = ThreadType.GROUP + thread_id = str(thread_id) + else: + thread_type = ThreadType.USER + if author_id == self.uid: + thread_id = m.get("to") + else: + thread_id = author_id typing_status = TypingStatus(m.get("st")) self.onTyping(author_id=author_id, status=typing_status, thread_id=thread_id, thread_type=thread_type, msg=m) @@ -1642,7 +2417,7 @@ class Client(object): try: if markAlive: self._ping(self.sticky, self.pool) - content = self._pullMessage(self.sticky, self.pool) + content = self._pullMessage(self.sticky, self.pool, markAlive) if content: self._parseMessage(content) except KeyboardInterrupt: @@ -1794,6 +2569,20 @@ class Client(object): """ log.info("Title change from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, new_title)) + + def onImageChange(self, mid=None, author_id=None, new_image=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None): + """ + Called when the client is listening, and somebody changes the image of a thread + + :param mid: The action ID + :param new_image: The ID of the new image + :param author_id: The ID of the person who changed the image + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + """ + log.info("{} changed thread image in {}".format(author_id, thread_id)) + + def onNicknameChange(self, mid=None, author_id=None, changed_for=None, new_nickname=None, thread_id=None, thread_type=ThreadType.USER, ts=None, metadata=None, msg=None): """ Called when the client is listening, and somebody changes the nickname of a person @@ -1812,6 +2601,50 @@ class Client(object): log.info("Nickname change from {} in {} ({}) for {}: {}".format(author_id, thread_id, thread_type.name, changed_for, new_nickname)) + def onAdminAdded(self, mid=None, added_id=None, author_id=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None): + """ + Called when the client is listening, and somebody adds an admin to a group thread + + :param mid: The action ID + :param added_id: The ID of the admin who got added + :param author_id: The ID of the person who added the admins + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + """ + log.info("{} added admin: {} in {}".format(author_id, added_id, thread_id)) + + + def onAdminRemoved(self, mid=None, removed_id=None, author_id=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None): + """ + Called when the client is listening, and somebody removes an admin from a group thread + + :param mid: The action ID + :param removed_id: The ID of the admin who got removed + :param author_id: The ID of the person who removed the admins + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + """ + log.info("{} removed admin: {} in {}".format(author_id, removed_id, thread_id)) + + + def onApprovalModeChange(self, mid=None, approval_mode=None, author_id=None, thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None): + """ + Called when the client is listening, and somebody changes approval mode in a group thread + + :param mid: The action ID + :param approval_mode: True if approval mode is activated + :param author_id: The ID of the person who changed approval mode + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + """ + if approval_mode: + log.info("{} activated approval mode in {}".format(author_id, thread_id)) + else: + log.info("{} disabled approval mode in {}".format(author_id, thread_id)) + def onMessageSeen(self, seen_by=None, thread_id=None, thread_type=ThreadType.USER, seen_ts=None, ts=None, metadata=None, msg=None): """ Called when the client is listening, and somebody marks a message as seen @@ -1868,7 +2701,7 @@ class Client(object): :param ts: A timestamp of the action :param msg: A full set of the data recieved """ - log.info("{} added: {}".format(author_id, ', '.join(added_ids))) + log.info("{} added: {} in {}".format(author_id, ', '.join(added_ids), thread_id)) def onPersonRemoved(self, mid=None, removed_id=None, author_id=None, thread_id=None, ts=None, msg=None): """ @@ -1881,7 +2714,7 @@ class Client(object): :param ts: A timestamp of the action :param msg: A full set of the data recieved """ - log.info("{} removed: {}".format(author_id, removed_id)) + log.info("{} removed: {} in {}".format(author_id, removed_id, thread_id)) def onFriendRequest(self, from_id=None, msg=None): """ @@ -1918,6 +2751,25 @@ class Client(object): """ pass + def onGamePlayed(self, mid=None, author_id=None, game_id=None, game_name=None, score=None, leaderboard=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody plays a game + + :param mid: The action ID + :param author_id: The ID of the person who played the game + :param game_id: The ID of the game + :param game_name: Name of the game + :param score: Score obtained in the game + :param leaderboard: Actual leaderboard of the game in the thread + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name)) + def onQprimer(self, ts=None, msg=None): """ Called when the client just started listening @@ -1953,6 +2805,183 @@ class Client(object): """ log.exception('Exception in parsing of {}'.format(msg)) + def onCallStarted(self, mid=None, caller_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + .. todo:: + Make this work with private calls + + Called when the client is listening, and somebody starts a call in a group + + :param mid: The action ID + :param caller_id: The ID of the person who started the call + :param is_video_call: True if it's video call + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} started call in {} ({})".format(caller_id, thread_id, thread_type.name)) + + def onCallEnded(self, mid=None, caller_id=None, is_video_call=None, call_duration=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + .. todo:: + Make this work with private calls + + Called when the client is listening, and somebody ends a call in a group + + :param mid: The action ID + :param caller_id: The ID of the person who ended the call + :param is_video_call: True if it was video call + :param call_duration: Call duration in seconds + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} ended call in {} ({})".format(caller_id, thread_id, thread_type.name)) + + def onUserJoinedCall(self, mid=None, joined_id=None, is_video_call=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody joins a group call + + :param mid: The action ID + :param joined_id: The ID of the person who joined the call + :param is_video_call: True if it's video call + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} joined call in {} ({})".format(joined_id, thread_id, thread_type.name)) + + def onPollCreated(self, mid=None, poll=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody creates a group poll + + :param mid: The action ID + :param poll: Created poll + :param author_id: The ID of the person who created the poll + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type poll: models.Poll + :type thread_type: models.ThreadType + """ + log.info("{} created poll {} in {} ({})".format(author_id, poll, thread_id, thread_type.name)) + + def onPollVoted(self, mid=None, poll=None, added_options=None, removed_options=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody votes in a group poll + + :param mid: The action ID + :param poll: Poll, that user voted in + :param author_id: The ID of the person who voted in the poll + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type poll: models.Poll + :type thread_type: models.ThreadType + """ + log.info("{} voted in poll {} in {} ({})".format(author_id, poll, thread_id, thread_type.name)) + + def onPlanCreated(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody creates a plan + + :param mid: The action ID + :param plan: Created plan + :param author_id: The ID of the person who created the plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("{} created plan {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + + def onPlanEnded(self, mid=None, plan=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and a plan ends + + :param mid: The action ID + :param plan: Ended plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("Plan {} has ended in {} ({})".format(plan, thread_id, thread_type.name)) + + def onPlanEdited(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody edits a plan + + :param mid: The action ID + :param plan: Edited plan + :param author_id: The ID of the person who edited the plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("{} edited plan {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + + def onPlanDeleted(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody deletes a plan + + :param mid: The action ID + :param plan: Deleted plan + :param author_id: The ID of the person who deleted the plan + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + log.info("{} deleted plan {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + + def onPlanParticipation(self, mid=None, plan=None, take_part=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None): + """ + Called when the client is listening, and somebody takes part in a plan or not + + :param mid: The action ID + :param plan: Plan + :param take_part: Whether the person takes part in the plan or not + :param author_id: The ID of the person who will participate in the plan or not + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param metadata: Extra metadata about the action + :param msg: A full set of the data recieved + :type plan: models.Plan + :type thread_type: models.ThreadType + """ + if take_part: + log.info("{} will take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + else: + log.info("{} won't take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + """ END EVENTS """ diff --git a/fbchat/graphql.py b/fbchat/graphql.py index d8e1d1b..f023a16 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -42,7 +42,7 @@ def get_customization_info(thread): 'emoji': info.get('emoji'), 'color': graphql_color_to_enum(info.get('outgoing_bubble_color')) } - if thread.get('thread_type') in ('GROUP', 'ROOM') or thread.get('is_group_thread') or thread.get('thread_key', {}).get('thread_fbid'): + if thread.get('thread_type') == 'GROUP' or thread.get('is_group_thread') or thread.get('thread_key', {}).get('thread_fbid'): rtn['nicknames'] = {} for k in info.get('participant_customizations', []): rtn['nicknames'][k['participant_id']] = k.get('nickname') @@ -128,6 +128,71 @@ def graphql_to_attachment(a): uid=a.get('legacy_attachment_id') ) +def graphql_to_poll(a): + rtn = Poll( + title=a.get('title') if a.get('title') else a.get("text"), + options=[graphql_to_poll_option(m) for m in a.get('options')] + ) + rtn.uid = int(a["id"]) + rtn.options_count = a.get("total_count") + return rtn + +def graphql_to_poll_option(a): + if a.get('viewer_has_voted') is None: + vote = None + elif isinstance(a['viewer_has_voted'], bool): + vote = a['viewer_has_voted'] + else: + vote = a['viewer_has_voted'] == 'true' + rtn = PollOption( + text=a.get('text'), + vote=vote + ) + rtn.uid = int(a["id"]) + rtn.voters = [m.get('node').get('id') for m in a.get('voters').get('edges')] if isinstance(a.get('voters'), dict) else a.get('voters') + rtn.votes_count = a.get('voters').get('count') if isinstance(a.get('voters'), dict) else a.get('total_count') + return rtn + +def graphql_to_plan(a): + if a.get('event_members'): + rtn = Plan( + time=a.get('event_time'), + title=a.get('title'), + location=a.get('location_name') + ) + if a.get('location_id') != 0: + rtn.location_id = str(a.get('location_id')) + rtn.uid = a.get('oid') + rtn.author_id = a.get('creator_id') + guests = a.get("event_members") + rtn.going = [uid for uid in guests if guests[uid] == "GOING"] + rtn.declined = [uid for uid in guests if guests[uid] == "DECLINED"] + rtn.invited = [uid for uid in guests if guests[uid] == "INVITED"] + return rtn + elif a.get('id') is None: + rtn = Plan( + time=a.get('event_time'), + title=a.get('event_title'), + location=a.get('event_location_name'), + location_id=a.get('event_location_id') + ) + rtn.uid = a.get('event_id') + rtn.author_id = a.get('event_creator_id') + guests = json.loads(a.get('guest_state_list')) + else: + rtn = Plan( + time=a.get('time'), + title=a.get('event_title'), + location=a.get('location_name') + ) + rtn.uid = a.get('id') + rtn.author_id = a.get('lightweight_event_creator').get('id') + guests = a.get('event_reminder_members').get('edges') + rtn.going = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "GOING"] + rtn.declined = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "DECLINED"] + rtn.invited = [m.get('node').get('id') for m in guests if m.get('guest_list_state') == "INVITED"] + return rtn + def graphql_to_message(message): if message.get('message_sender') is None: message['message_sender'] = {} @@ -155,6 +220,9 @@ def graphql_to_user(user): if user.get('profile_picture') is None: user['profile_picture'] = {} c_info = get_customization_info(user) + plan = None + if user.get('event_reminders'): + plan = graphql_to_plan(user['event_reminders']['nodes'][0]) if user['event_reminders'].get('nodes') else None return User( user['id'], url=user.get('url'), @@ -169,7 +237,8 @@ def graphql_to_user(user): own_nickname=c_info.get('own_nickname'), photo=user['profile_picture'].get('uri'), name=user.get('name'), - message_count=user.get('messages_count') + message_count=user.get('messages_count'), + plan=plan, ) def graphql_to_thread(thread): @@ -185,12 +254,22 @@ def graphql_to_thread(thread): if 'last_message' in thread: last_message_timestamp = thread['last_message']['nodes'][0]['timestamp_precise'] + first_name = user.get('short_name') + if first_name is None: + last_name = None + else: + last_name = user.get('name').split(first_name, 1).pop().strip() + + plan = None + if thread.get('event_reminders'): + plan = graphql_to_plan(thread['event_reminders']['nodes'][0]) if thread['event_reminders'].get('nodes') else None + return User( user['id'], url=user.get('url'), name=user.get('name'), - first_name=user.get('short_name'), - last_name=user.get('name').split(user.get('short_name'),1).pop().strip(), + first_name=first_name, + last_name=last_name, is_friend=user.get('is_viewer_friend'), gender=GENDERS.get(user.get('gender')), affinity=user.get('affinity'), @@ -200,7 +279,8 @@ def graphql_to_thread(thread): own_nickname=c_info.get('own_nickname'), photo=user['big_image_src'].get('uri'), message_count=thread.get('messages_count'), - last_message_timestamp=last_message_timestamp + last_message_timestamp=last_message_timestamp, + plan=plan, ) else: raise FBchatException('Unknown thread type: {}, with data: {}'.format(thread.get('thread_type'), thread)) @@ -212,36 +292,24 @@ def graphql_to_group(group): last_message_timestamp = None if 'last_message' in group: last_message_timestamp = group['last_message']['nodes'][0]['timestamp_precise'] + plan = None + if group.get('event_reminders'): + plan = graphql_to_plan(group['event_reminders']['nodes'][0]) if group['event_reminders'].get('nodes') else None return Group( group['thread_key']['thread_fbid'], participants=set([node['messaging_actor']['id'] for node in group['all_participants']['nodes']]), nicknames=c_info.get('nicknames'), color=c_info.get('color'), emoji=c_info.get('emoji'), + admins = set([node.get('id') for node in group.get('thread_admins')]), + approval_mode = bool(group.get('approval_mode')), + approval_requests = set(node["requester"]['id'] for node in group['group_approval_queue']['nodes']), + join_link = group['joinable_mode'].get('link'), photo=group['image'].get('uri'), name=group.get('name'), message_count=group.get('messages_count'), - last_message_timestamp=last_message_timestamp - ) - -def graphql_to_room(room): - if room.get('image') is None: - room['image'] = {} - c_info = get_customization_info(room) - return Room( - room['thread_key']['thread_fbid'], - participants=set([node['messaging_actor']['id'] for node in room['all_participants']['nodes']]), - nicknames=c_info.get('nicknames'), - color=c_info.get('color'), - emoji=c_info.get('emoji'), - photo=room['image'].get('uri'), - name=room.get('name'), - message_count=room.get('messages_count'), - admins = set([node.get('id') for node in room.get('thread_admins')]), - approval_mode = bool(room.get('approval_mode')), - approval_requests = set(node.get('id') for node in room['thread_queue_metadata'].get('approval_requests', {}).get('nodes')), - join_link = room['joinable_mode'].get('link'), - privacy_mode = bool(room.get('privacy_mode')), + last_message_timestamp=last_message_timestamp, + plan=plan, ) def graphql_to_page(page): @@ -249,6 +317,9 @@ def graphql_to_page(page): page['profile_picture'] = {} if page.get('city') is None: page['city'] = {} + plan = None + if page.get('event_reminders'): + plan = graphql_to_plan(page['event_reminders']['nodes'][0]) if page['event_reminders'].get('nodes') else None return Page( page['id'], url=page.get('url'), @@ -256,7 +327,8 @@ def graphql_to_page(page): category=page.get('category_type'), photo=page['profile_picture'].get('uri'), name=page.get('name'), - message_count=page.get('messages_count') + message_count=page.get('messages_count'), + plan=plan, ) def graphql_queries_to_json(*queries): @@ -351,6 +423,40 @@ class GraphQL(object): }, outgoing_bubble_color, emoji + }, + thread_admins { + id + }, + group_approval_queue { + nodes { + requester { + id + } + } + }, + approval_mode, + joinable_mode { + mode, + link + }, + event_reminders { + nodes { + id, + lightweight_event_creator { + id + }, + time, + location_name, + event_title, + event_reminder_members { + edges { + node { + id + }, + guest_list_state + } + } + } } } """ diff --git a/fbchat/models.py b/fbchat/models.py index a380599..cb4f678 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -37,7 +37,9 @@ class Thread(object): last_message_timestamp = None #: Number of messages in the thread message_count = None - def __init__(self, _type, uid, photo=None, name=None, last_message_timestamp=None, message_count=None): + #: Set :class:`Plan` + plan = None + def __init__(self, _type, uid, photo=None, name=None, last_message_timestamp=None, message_count=None, plan=None): """Represents a Facebook thread""" self.uid = str(uid) self.type = _type @@ -45,6 +47,7 @@ class Thread(object): self.name = name self.last_message_timestamp = last_message_timestamp self.message_count = message_count + self.plan = plan def __repr__(self): return self.__unicode__() @@ -99,8 +102,16 @@ class Group(Thread): color = None #: The groups's default emoji emoji = None + # Set containing user IDs of thread admins + admins = None + # True if users need approval to join + approval_mode = None + # Set containing user IDs requesting to join + approval_requests = None + # Link for joining group + join_link = None - def __init__(self, uid, participants=None, nicknames=None, color=None, emoji=None, **kwargs): + def __init__(self, uid, participants=None, nicknames=None, color=None, emoji=None, admins=None, approval_mode=None, approval_requests=None, join_link=None, privacy_mode=None, **kwargs): """Represents a Facebook group. Inherits `Thread`""" super(Group, self).__init__(ThreadType.GROUP, uid, **kwargs) if participants is None: @@ -111,24 +122,6 @@ class Group(Thread): self.nicknames = nicknames self.color = color self.emoji = emoji - - -class Room(Group): - # Set containing user IDs of thread admins - admins = None - # True if users need approval to join - approval_mode = None - # Set containing user IDs requesting to join - approval_requests = None - # Link for joining room - join_link = None - # True is room is not discoverable - privacy_mode = None - - def __init__(self, uid, admins=None, approval_mode=None, approval_requests=None, join_link=None, privacy_mode=None, **kwargs): - """Represents a Facebook room. Inherits `Group`""" - super(Room, self).__init__(uid, **kwargs) - self.type = ThreadType.ROOM if admins is None: admins = set() self.admins = admins @@ -137,6 +130,16 @@ class Room(Group): approval_requests = set() self.approval_requests = approval_requests self.join_link = join_link + + +class Room(Group): + # True is room is not discoverable + privacy_mode = None + + def __init__(self, uid, privacy_mode=None, **kwargs): + """Deprecated. Use :class:`Group` instead""" + super(Room, self).__init__(uid, **kwargs) + self.type = ThreadType.ROOM self.privacy_mode = privacy_mode @@ -436,6 +439,87 @@ class Mention(object): def __unicode__(self): return ''.format(self.thread_id, self.offset, self.length) +class Poll(object): + #: ID of the poll + uid = None + #: Title of the poll + title = None + #: List of :class:`PollOption`, can be fetched with :func:`fbchat.Client.fetchPollOptions` + options = None + #: Options count + options_count = None + + def __init__(self, title, options): + """Represents a poll""" + self.title = title + self.options = options + + def __repr__(self): + return self.__unicode__() + + def __unicode__(self): + return ''.format(self.uid, repr(self.title), self.options) + +class PollOption(object): + #: ID of the poll option + uid = None + #: Text of the poll option + text = None + #: Whether vote when creating or client voted + vote = None + #: ID of the users who voted for this poll option + voters = None + #: Votes count + votes_count = None + + def __init__(self, text, vote=False): + """Represents a poll option""" + self.text = text + self.vote = vote + + def __repr__(self): + return self.__unicode__() + + def __unicode__(self): + return ''.format(self.uid, repr(self.text), self.voters) + +class Plan(object): + #: ID of the plan + uid = None + #: Plan time (unix time stamp), only precise down to the minute + time = None + #: Plan title + title = None + #: Plan location name + location = None + #: Plan location ID + location_id = None + #: ID of the plan creator + author_id = None + #: List of the people IDs who will take part in the plan + going = None + #: List of the people IDs who won't take part in the plan + declined = None + #: List of the people IDs who are invited to the plan + invited = None + + def __init__(self, time, title, location=None, location_id=None): + """Represents a plan""" + self.time = int(time) + self.title = title + self.location = location or '' + self.location_id = location_id or '' + self.author_id = None + self.going = [] + self.declined = [] + self.invited = [] + + def __repr__(self): + return self.__unicode__() + + def __unicode__(self): + return ''.format(self.uid, repr(self.title), self.time, repr(self.location), repr(self.location_id)) + class Enum(enum.Enum): """Used internally by fbchat to support enumerations""" def __repr__(self): @@ -446,8 +530,8 @@ 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 + ROOM = 2 PAGE = 3 - ROOM = 4 class ThreadLocation(Enum): """Used to specify where a thread is located (inbox, pending, archived, other).""" diff --git a/fbchat/utils.py b/fbchat/utils.py index c3fe933..92e8a9d 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -5,8 +5,12 @@ import re import json from time import time from random import random +from contextlib import contextmanager +from mimetypes import guess_type +from os.path import basename import warnings import logging +import requests from .models import * try: @@ -48,16 +52,6 @@ LIKES = { 's': EmojiSize.SMALL } -MessageReactionFix = { - '😍': ('0001f60d', '%F0%9F%98%8D'), - '😆': ('0001f606', '%F0%9F%98%86'), - '😮': ('0001f62e', '%F0%9F%98%AE'), - '😢': ('0001f622', '%F0%9F%98%A2'), - '😠': ('0001f620', '%F0%9F%98%A0'), - '👍': ('0001f44d', '%F0%9F%91%8D'), - '👎': ('0001f44e', '%F0%9F%91%8E') -} - GENDERS = { # For standard requests @@ -97,6 +91,9 @@ class ReqUrl(object): UNREAD_THREADS = "https://www.facebook.com/ajax/mercury/unread_threads.php" UNSEEN_THREADS = "https://www.facebook.com/mercury/unseen_thread_ids/" THREADS = "https://www.facebook.com/ajax/mercury/threadlist_info.php" + MOVE_THREAD = "https://www.facebook.com/ajax/mercury/move_thread.php" + ARCHIVED_STATUS = "https://www.facebook.com/ajax/mercury/change_archived_status.php?dpr=1" + PINNED_STATUS = "https://www.facebook.com/ajax/mercury/change_pinned_status.php?dpr=1" MESSAGES = "https://www.facebook.com/ajax/mercury/thread_info.php" READ_STATUS = "https://www.facebook.com/ajax/mercury/change_read_status.php" DELIVERED = "https://www.facebook.com/ajax/mercury/delivery_receipts.php" @@ -116,12 +113,33 @@ class ReqUrl(object): THREAD_COLOR = "https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1" THREAD_NICKNAME = "https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1" THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1" + THREAD_IMAGE = "https://www.facebook.com/messaging/set_thread_image/?dpr=1" + THREAD_NAME = "https://www.facebook.com/messaging/set_thread_name/?dpr=1" MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation" TYPING = "https://www.facebook.com/ajax/messaging/typ.php" GRAPHQL = "https://www.facebook.com/api/graphqlbatch/" ATTACHMENT_PHOTO = "https://www.facebook.com/mercury/attachments/photo/" - EVENT_REMINDER = "https://www.facebook.com/ajax/eventreminder/create" + PLAN_CREATE = "https://www.facebook.com/ajax/eventreminder/create" + PLAN_INFO = "https://www.facebook.com/ajax/eventreminder" + PLAN_CHANGE = "https://www.facebook.com/ajax/eventreminder/submit" + PLAN_PARTICIPATION = "https://www.facebook.com/ajax/eventreminder/rsvp" MODERN_SETTINGS_MENU = "https://www.facebook.com/bluebar/modern_settings_menu/" + REMOVE_FRIEND = "https://m.facebook.com/a/removefriend.php" + BLOCK_USER = "https://www.facebook.com/messaging/block_messages/?dpr=1" + UNBLOCK_USER = "https://www.facebook.com/messaging/unblock_messages/?dpr=1" + SAVE_ADMINS = "https://www.facebook.com/messaging/save_admins/?dpr=1" + APPROVAL_MODE = "https://www.facebook.com/messaging/set_approval_mode/?dpr=1" + CREATE_GROUP = "https://m.facebook.com/messages/send/?icm=1" + DELETE_THREAD = "https://www.facebook.com/ajax/mercury/delete_thread.php?dpr=1" + DELETE_MESSAGES = "https://www.facebook.com/ajax/mercury/delete_messages.php?dpr=1" + MUTE_THREAD = "https://www.facebook.com/ajax/mercury/change_mute_thread.php?dpr=1" + MUTE_REACTIONS = "https://www.facebook.com/ajax/mercury/change_reactions_mute_thread/?dpr=1" + MUTE_MENTIONS = "https://www.facebook.com/ajax/mercury/change_mentions_mute_thread/?dpr=1" + CREATE_POLL = "https://www.facebook.com/messaging/group_polling/create_poll/?dpr=1" + UPDATE_VOTE = "https://www.facebook.com/messaging/group_polling/update_vote/?dpr=1" + GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options" + SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1" + MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1" pull_channel = 0 @@ -143,7 +161,7 @@ def strip_to_json(text): try: return text[text.index('{'):] except ValueError: - raise FBchatException('No JSON object found: {}, {}'.format(repr(text), text.index('{'))) + raise FBchatException('No JSON object found: {!r}'.format(text)) def get_decoded_r(r): return get_decoded(r._content) @@ -210,8 +228,9 @@ def check_request(r, as_json=True): try: j = json.loads(content) except ValueError: - raise FBchatFacebookError('Error while parsing JSON: {}'.format(repr(content))) + raise FBchatFacebookError('Error while parsing JSON: {!r}'.format(content)) check_json(j) + log.debug(j) return j else: return content @@ -234,3 +253,47 @@ def get_emojisize_from_tags(tags): except (KeyError, IndexError): log.exception('Could not determine emoji size from {} - {}'.format(tags, tmp)) return None + +def require_list(list_): + if isinstance(list_, list): + return set(list_) + else: + return set([list_]) + +def mimetype_to_key(mimetype): + if not mimetype: + return "file_id" + if mimetype == "image/gif": + return "gif_id" + x = mimetype.split("/") + if x[0] in ["video", "image", "audio"]: + return "%s_id" % x[0] + 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 + files.append(( + basename(file_url), + r.content, + r.headers.get('Content-Type') or guess_type(file_url)[0], + )) + return files + + +@contextmanager +def get_files_from_paths(filenames): + files = [] + for filename in filenames: + files.append(( + basename(filename), + open(filename, 'rb'), + guess_type(filename)[0], + )) + yield files + for fn, fp, ft in files: + fp.close() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ecbb1b3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +xfail_strict=true +markers = + offline: Offline tests, aka. tests that can be executed without the need of a client + expensive: Expensive tests, which should be executed sparingly +addopts = -m "not expensive" diff --git a/requirements.txt b/requirements.txt index 22069fb..9cb2a6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ requests -lxml beautifulsoup4 enum34; python_version < '3.4' diff --git a/scripts/travis-offline b/scripts/travis-offline new file mode 100755 index 0000000..c0c4714 --- /dev/null +++ b/scripts/travis-offline @@ -0,0 +1,5 @@ +#!/bin/bash + +set -ex + +python -m pytest -m offline --color=yes diff --git a/scripts/travis-online b/scripts/travis-online new file mode 100755 index 0000000..ab4737f --- /dev/null +++ b/scripts/travis-online @@ -0,0 +1,18 @@ +#!/bin/bash + +set -ex + +if ! python -m pytest --color=yes; then + echo << EOF +----------------------------------------------------------------- +----------------------------------------------------------------- +----------------------------------------------------------------- + +Some tests failed! Rerunning them, since they can be kinda flaky. + +----------------------------------------------------------------- +----------------------------------------------------------------- +----------------------------------------------------------------- +EOF + python -m pytest --last-failed --color=yes +fi diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b554b7b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,51 @@ +[metadata] +name = fbchat +version = attr: fbchat.__version__ +license = BSD 3-Clause +license_file = LICENSE + +author = Taehoon Kim +author_email = carpedm20@gmail.com +maintainer = Mads Marquart +maintainer_email = madsmtm@gmail.com + +description = Facebook Chat (Messenger) for Python +long_description = file: README.rst +long_description_content_type = text/x-rst + +keywords = Facebook FB Messenger Chat Api Bot +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: Developers + Intended Audience :: Information Technology + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Natural Language :: English + Programming Language :: Python + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: PyPy + Topic :: Communications :: Chat + Topic :: Internet :: WWW/HTTP :: Dynamic Content + Topic :: Software Development :: Libraries :: Python Modules + +url = https://github.com/carpedm20/fbchat/ +project_urls = + Documentation = https://fbchat.readthedocs.io/ + Repository = https://github.com/carpedm20/fbchat/ + +[options] +zip_safe = True +include_package_data = True +packages = find: +python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4.0 +install_requires = + requests + beautifulsoup4 + # May not work in pip with bdist_wheel + # See https://wheel.readthedocs.io/en/latest/#defining-conditional-dependencies + # It is therefore defined in setup.py + # enum34; python_version < '3.4' diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 0c47c6d..1157ca5 --- a/setup.py +++ b/setup.py @@ -1,82 +1,8 @@ #!/usr/bin/env python +# -*- coding: UTF-8 -*- +from __future__ import unicode_literals -""" -Setup script for fbchat -""" -import os -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +from setuptools import setup -with open('README.rst') as f: - readme_content = f.read().strip() - -requirements = [ - 'requests', - 'lxml', - 'beautifulsoup4' -] - -extras_requirements = { - ':python_version < "3.4"': ['enum34'] -} - -version = None -author = None -email = None -source = None -description = None -with open(os.path.join('fbchat', '__init__.py')) as f: - for line in f: - if line.strip().startswith('__version__'): - version = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__author__'): - author = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__email__'): - email = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__source__'): - source = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__description__'): - description = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif None not in (version, author, email, source, description): - break - -setup( - name='fbchat', - author=author, - author_email=email, - license='BSD License', - keywords=["facebook chat fbchat"], - description=description, - long_description=readme_content, - classifiers=[ - 'Development Status :: 2 - Pre-Alpha', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Programming Language :: Python', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - 'Topic :: Communications :: Chat', - ], - include_package_data=True, - packages=['fbchat'], - install_requires=requirements, - extras_require=extras_requirements, - url=source, - version=version, - zip_safe=True, -) +setup(extras_require={':python_version < "3.4"': ['enum34']}) diff --git a/tests.py b/tests.py deleted file mode 100644 index caf6748..0000000 --- a/tests.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- - -from __future__ import unicode_literals -import json -import logging -import unittest -from getpass import getpass -from sys import argv -from os import path, chdir -from glob import glob -from fbchat import Client -from fbchat.models import * -import py_compile - -logging_level = logging.ERROR - -""" - -Testing script for `fbchat`. -Full documentation on https://fbchat.readthedocs.io/ - -""" - -test_sticker_id = '767334476626295' - -class CustomClient(Client): - def __init__(self, *args, **kwargs): - self.got_qprimer = False - super(type(self), self).__init__(*args, **kwargs) - - def onQprimer(self, msg, **kwargs): - self.got_qprimer = True - -class TestFbchat(unittest.TestCase): - def test_examples(self): - # Checks for syntax errors in the examples - chdir('examples') - for f in glob('*.txt'): - print(f) - with self.assertRaises(py_compile.PyCompileError): - py_compile.compile(f) - - chdir('..') - - def test_loginFunctions(self): - self.assertTrue(client.isLoggedIn()) - - client.logout() - - self.assertFalse(client.isLoggedIn()) - - with self.assertRaises(Exception): - client.login('', '', max_tries=1) - - client.login(email, password) - - self.assertTrue(client.isLoggedIn()) - - def test_sessions(self): - global client - session_cookies = client.getSession() - client = CustomClient(email, password, session_cookies=session_cookies, logging_level=logging_level) - - self.assertTrue(client.isLoggedIn()) - - def test_defaultThread(self): - # setDefaultThread - for thread in threads: - client.setDefaultThread(thread_id=thread['id'], thread_type=thread['type']) - self.assertTrue(client.send(Message(text='test_default_recipient★'))) - - # resetDefaultThread - client.resetDefaultThread() - with self.assertRaises(ValueError): - client.send(Message(text='should_not_send')) - - def test_fetchAllUsers(self): - users = client.fetchAllUsers() - self.assertGreater(len(users), 0) - - def test_searchFor(self): - users = client.searchForUsers('Mark Zuckerberg') - self.assertGreater(len(users), 0) - - u = users[0] - - # Test if values are set correctly - self.assertEqual(u.uid, '4') - self.assertEqual(u.type, ThreadType.USER) - self.assertEqual(u.photo[:4], 'http') - self.assertEqual(u.url[:4], 'http') - self.assertEqual(u.name, 'Mark Zuckerberg') - - group_name = client.changeThreadTitle('tést_searchFor', thread_id=group_id, thread_type=ThreadType.GROUP) - groups = client.searchForGroups('té') - self.assertGreater(len(groups), 0) - - def test_send(self): - for thread in threads: - client.setDefaultThread(thread_id=thread['id'], thread_type=thread['type']) - - self.assertIsNotNone(client.send(Message(emoji_size=EmojiSize.SMALL))) - self.assertIsNotNone(client.send(Message(emoji_size=EmojiSize.MEDIUM))) - self.assertIsNotNone(client.send(Message(text='😆', emoji_size=EmojiSize.LARGE))) - - self.assertIsNotNone(client.send(Message(text='test_send★'))) - with self.assertRaises(FBchatFacebookError): - self.assertIsNotNone(client.send(Message(text='test_send_should_fail★'), thread_id=thread['id'], thread_type=(ThreadType.GROUP if thread['type'] == ThreadType.USER else ThreadType.USER))) - - self.assertIsNotNone(client.send(Message(text='Hi there @user', mentions=[Mention(user_id, offset=9, length=5)]))) - self.assertIsNotNone(client.send(Message(text='Hi there @group', mentions=[Mention(group_id, offset=9, length=6)]))) - - self.assertIsNotNone(client.send(Message(sticker=Sticker(test_sticker_id)))) - - def test_sendImages(self): - image_url = 'https://github.com/carpedm20/fbchat/raw/master/tests/image.png' - image_local_url = path.join(path.dirname(__file__), 'tests/image.png') - for thread in threads: - client.setDefaultThread(thread_id=thread['id'], thread_type=thread['type']) - mentions = [Mention(thread['id'], offset=26, length=4)] - self.assertTrue(client.sendRemoteImage(image_url, Message(text='test_send_image_remote_to_@you★', mentions=mentions))) - self.assertTrue(client.sendLocalImage(image_local_url, Message(text='test_send_image_local_to__@you★', mentions=mentions))) - - def test_fetchThreadList(self): - threads = client.fetchThreadList(limit=2) - self.assertEqual(len(threads), 2) - - def test_fetchThreadMessages(self): - for thread in threads: - client.setDefaultThread(thread_id=thread['id'], thread_type=thread['type']) - client.send(Message(text='test_getThreadInfo★')) - - messages = client.fetchThreadMessages(limit=1) - self.assertEqual(messages[0].author, client.uid) - self.assertEqual(messages[0].text, 'test_getThreadInfo★') - - def test_listen(self): - client.startListening() - client.doOneListen() - client.stopListening() - - self.assertTrue(client.got_qprimer) - - def test_fetchInfo(self): - info = client.fetchUserInfo('4')['4'] - self.assertEqual(info.name, 'Mark Zuckerberg') - - info = client.fetchGroupInfo(group_id)[group_id] - self.assertEqual(info.type, ThreadType.GROUP) - - def test_removeAddFromGroup(self): - client.removeUserFromGroup(user_id, thread_id=group_id) - client.addUsersToGroup(user_id, thread_id=group_id) - - def test_changeThreadTitle(self): - for thread in threads: - client.changeThreadTitle('test_changeThreadTitle★', thread_id=thread['id'], thread_type=thread['type']) - - def test_changeNickname(self): - for thread in threads: - client.setDefaultThread(thread_id=thread['id'], thread_type=thread['type']) - client.changeNickname('test_changeNicknameSelf★', client.uid) - client.changeNickname('test_changeNicknameOther★', user_id) - - def test_changeThreadEmoji(self): - for thread in threads: - client.changeThreadEmoji('😀', thread_id=thread['id']) - client.changeThreadEmoji('😀', thread_id=thread['id']) - - def test_changeThreadColor(self): - for thread in threads: - client.setDefaultThread(thread_id=thread['id'], thread_type=thread['type']) - client.changeThreadColor(ThreadColor.BRILLIANT_ROSE) - client.changeThreadColor(ThreadColor.MESSENGER_BLUE) - - def test_reactToMessage(self): - for thread in threads: - mid = client.send(Message(text='test_reactToMessage★'), thread_id=thread['id'], thread_type=thread['type']) - client.reactToMessage(mid, MessageReaction.LOVE) - - def test_setTypingStatus(self): - for thread in threads: - client.setDefaultThread(thread_id=thread['id'], thread_type=thread['type']) - client.setTypingStatus(TypingStatus.TYPING) - client.setTypingStatus(TypingStatus.STOPPED) - - -def start_test(param_client, param_group_id, param_user_id, param_threads, tests=[]): - global client - global group_id - global user_id - global threads - - client = param_client - group_id = param_group_id - user_id = param_user_id - threads = param_threads - - tests = ['test_' + test if 'test_' != test[:5] else test for test in tests] - - if len(tests) == 0: - suite = unittest.TestLoader().loadTestsFromTestCase(TestFbchat) - else: - suite = unittest.TestSuite(map(TestFbchat, tests)) - print('Starting test(s)') - unittest.TextTestRunner(verbosity=2).run(suite) - - -client = None - -if __name__ == '__main__': - # Python 3 does not use raw_input, whereas Python 2 does - try: - input = raw_input - except Exception as e: - pass - - try: - with open(path.join(path.dirname(__file__), 'tests/my_data.json'), 'r') as f: - j = json.load(f) - email = j['email'] - password = j['password'] - user_id = j['user_thread_id'] - group_id = j['group_thread_id'] - session = j.get('session') - except (IOError, IndexError) as e: - email = input('Email: ') - password = getpass() - group_id = input('Please enter a group thread id (To test group functionality): ') - user_id = input('Please enter a user thread id (To test kicking/adding functionality): ') - threads = [ - { - 'id': user_id, - 'type': ThreadType.USER - }, - { - 'id': group_id, - 'type': ThreadType.GROUP - } - ] - - print('Logging in...') - client = CustomClient(email, password, logging_level=logging_level, session_cookies=session) - - # Warning! Taking user input directly like this could be dangerous! Use only for testing purposes! - start_test(client, group_id, user_id, threads, argv[1:]) - - with open(path.join(path.dirname(__file__), 'tests/my_data.json'), 'w') as f: - session = None - try: - session = client.getSession() - except Exception: - print('Unable to fetch client session!') - json.dump({ - 'email': email, - 'password': password, - 'user_thread_id': user_id, - 'group_thread_id': group_id, - 'session': session - }, f) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..af40730 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest +import json + +from utils import * +from contextlib import contextmanager +from fbchat.models import ThreadType, Message, Mention + + +@pytest.fixture(scope="session") +def user(client2): + return {"id": client2.uid, "type": ThreadType.USER} + + +@pytest.fixture(scope="session") +def group(pytestconfig): + return {"id": load_variable("group_id", pytestconfig.cache), "type": ThreadType.GROUP} + + +@pytest.fixture(scope="session", params=["user", "group", pytest.mark.xfail("none")]) +def thread(request, user, group): + return { + "user": user, + "group": group, + "none": {"id": "0", "type": ThreadType.GROUP} + }[request.param] + + +@pytest.fixture(scope="session") +def client1(pytestconfig): + with load_client(1, pytestconfig.cache) as c: + yield c + + +@pytest.fixture(scope="session") +def client2(pytestconfig): + with load_client(2, pytestconfig.cache) as c: + yield c + + +@pytest.fixture(scope="module") +def client(client1, thread): + client1.setDefaultThread(thread["id"], thread["type"]) + yield client1 + client1.resetDefaultThread() + + +@pytest.fixture(scope="session", params=["client1", "client2"]) +def client_all(request, client1, client2): + return client1 if request.param == "client1" else client2 + + +@pytest.fixture(scope="session") +def catch_event(client2): + t = ClientThread(client2) + t.start() + + @contextmanager + def inner(method_name): + caught = CaughtValue() + old_method = getattr(client2, method_name) + + # Will be called by the other thread + def catch_value(*args, **kwargs): + old_method(*args, **kwargs) + # Make sure the `set` is only called once + if not caught.is_set(): + caught.set(kwargs) + + setattr(client2, method_name, catch_value) + yield caught + caught.wait() + if not caught.is_set(): + raise ValueError("The value could not be caught") + setattr(client2, method_name, old_method) + + yield inner + + t.should_stop.set() + + try: + # Make the client send a messages to itself, so the blocking pull request will return + # This is probably not safe, since the client is making two requests simultaneously + client2.sendMessage(random_hex(), client2.uid) + finally: + t.join() + + +@pytest.fixture(scope="module") +def compare(client, thread): + def inner(caught_event, **kwargs): + d = { + "author_id": client.uid, + "thread_id": client.uid + if thread["type"] == ThreadType.USER + else thread["id"], + "thread_type": thread["type"], + } + d.update(kwargs) + return subset(caught_event.res, **d) + + return inner + + +@pytest.fixture(params=["me", "other", "me other"]) +def message_with_mentions(request, client, client2, group): + text = "Hi there [" + mentions = [] + if 'me' in request.param: + mentions.append(Mention(thread_id=client.uid, offset=len(text), length=2)) + text += "me, " + if 'other' in request.param: + mentions.append(Mention(thread_id=client2.uid, offset=len(text), length=5)) + text += "other, " + # Unused, because Facebook don't properly support sending mentions with groups as targets + if 'group' in request.param: + mentions.append(Mention(thread_id=group["id"], offset=len(text), length=5)) + text += "group, " + text += "nothing]" + return Message(text, mentions=mentions) diff --git a/tests/data.json b/tests/data.json deleted file mode 100644 index d20c682..0000000 --- a/tests/data.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "email": "", - "password": "", - "user_thread_id": "", - "group_thread_id": "" -} diff --git a/tests/resources/audio.mp3 b/tests/resources/audio.mp3 new file mode 100644 index 0000000..084a7d1 Binary files /dev/null and b/tests/resources/audio.mp3 differ diff --git a/tests/resources/file.json b/tests/resources/file.json new file mode 100644 index 0000000..c4036cf --- /dev/null +++ b/tests/resources/file.json @@ -0,0 +1,4 @@ +{ + "some": "data", + "in": "here" +} diff --git a/tests/resources/file.txt b/tests/resources/file.txt new file mode 100644 index 0000000..0204d00 --- /dev/null +++ b/tests/resources/file.txt @@ -0,0 +1 @@ +This is just a text file diff --git a/tests/resources/image.gif b/tests/resources/image.gif new file mode 100644 index 0000000..3db1c95 Binary files /dev/null and b/tests/resources/image.gif differ diff --git a/tests/resources/image.jpg b/tests/resources/image.jpg new file mode 100644 index 0000000..2cbbff0 Binary files /dev/null and b/tests/resources/image.jpg differ diff --git a/tests/image.png b/tests/resources/image.png similarity index 100% rename from tests/image.png rename to tests/resources/image.png diff --git a/tests/resources/video.mp4 b/tests/resources/video.mp4 new file mode 100644 index 0000000..02c2060 Binary files /dev/null and b/tests/resources/video.mp4 differ diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..6a60482 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest +import py_compile + +from glob import glob +from os import path, environ +from fbchat import Client +from fbchat.models import FBchatUserError, Message + + +@pytest.mark.offline +def test_examples(): + # Compiles the examples, to check for syntax errors + for name in glob(path.join(path.dirname(__file__), "../examples", "*.py")): + py_compile.compile(name) + + +@pytest.mark.trylast +@pytest.mark.expensive +def test_login(client1): + assert client1.isLoggedIn() + email = client1.email + password = client1.password + + client1.logout() + + assert not client1.isLoggedIn() + + with pytest.raises(FBchatUserError): + client1.login("", "", max_tries=1) + + client1.login(email, password) + + assert client1.isLoggedIn() + + +@pytest.mark.trylast +def test_sessions(client1): + session = client1.getSession() + Client("no email needed", "no password needed", session_cookies=session) + client1.setSession(session) + assert client1.isLoggedIn() + + +@pytest.mark.tryfirst +def test_default_thread(client1, thread): + client1.setDefaultThread(thread["id"], thread["type"]) + assert client1.send(Message(text="Sent to the specified thread")) + + client1.resetDefaultThread() + with pytest.raises(ValueError): + client1.send(Message(text="Should not be sent")) diff --git a/tests/test_fetch.py b/tests/test_fetch.py new file mode 100644 index 0000000..669de22 --- /dev/null +++ b/tests/test_fetch.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from os import path +from fbchat.models import ThreadType, Message, Mention, EmojiSize, Sticker +from utils import subset, STICKER_LIST, EMOJI_LIST + + +def test_fetch_all_users(client1): + users = client1.fetchAllUsers() + assert len(users) > 0 + + +def test_fetch_thread_list(client1): + threads = client1.fetchThreadList(limit=2) + assert len(threads) == 2 + + +@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) +def test_fetch_message_emoji(client, emoji, emoji_size): + mid = client.sendEmoji(emoji, emoji_size) + message, = client.fetchThreadMessages(limit=1) + + assert subset( + vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size + ) + + +@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) +def test_fetch_message_info_emoji(client, thread, emoji, emoji_size): + mid = client.sendEmoji(emoji, emoji_size) + message = client.fetchMessageInfo(mid, thread_id=thread["id"]) + + assert subset( + vars(message), uid=mid, author=client.uid, text=emoji, emoji_size=emoji_size + ) + + +def test_fetch_message_mentions(client, thread, message_with_mentions): + mid = client.send(message_with_mentions) + message, = client.fetchThreadMessages(limit=1) + + assert subset(vars(message), uid=mid, author=client.uid, text=message_with_mentions.text) + # The mentions are not ordered by offset + for m in message.mentions: + assert vars(m) in [vars(x) for x in message_with_mentions.mentions] + + +def test_fetch_message_info_mentions(client, thread, message_with_mentions): + mid = client.send(message_with_mentions) + message = client.fetchMessageInfo(mid, thread_id=thread["id"]) + + assert subset(vars(message), uid=mid, author=client.uid, text=message_with_mentions.text) + # The mentions are not ordered by offset + for m in message.mentions: + assert vars(m) in [vars(x) for x in message_with_mentions.mentions] + + +@pytest.mark.parametrize("sticker", STICKER_LIST) +def test_fetch_message_sticker(client, sticker): + mid = client.send(Message(sticker=sticker)) + message, = client.fetchThreadMessages(limit=1) + + assert subset(vars(message), uid=mid, author=client.uid) + assert subset(vars(message.sticker), uid=sticker.uid) + + +@pytest.mark.parametrize("sticker", STICKER_LIST) +def test_fetch_message_info_sticker(client, thread, sticker): + mid = client.send(Message(sticker=sticker)) + message = client.fetchMessageInfo(mid, thread_id=thread["id"]) + + assert subset(vars(message), uid=mid, author=client.uid) + assert subset(vars(message.sticker), uid=sticker.uid) + + +def test_fetch_info(client1, group): + info = client1.fetchUserInfo("4")["4"] + assert info.name == "Mark Zuckerberg" + + info = client1.fetchGroupInfo(group["id"])[group["id"]] + assert info.type == ThreadType.GROUP + + +def test_fetch_image_url(client): + client.sendLocalFiles([path.join(path.dirname(__file__), "resources", "image.png")]) + message, = client.fetchThreadMessages(limit=1) + + assert client.fetchImageUrl(message.attachments[0].uid) diff --git a/tests/test_message_management.py b/tests/test_message_management.py new file mode 100644 index 0000000..00291ee --- /dev/null +++ b/tests/test_message_management.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from fbchat.models import Message, MessageReaction +from utils import subset + + +def test_set_reaction(client): + mid = client.send(Message(text="This message will be reacted to")) + client.reactToMessage(mid, MessageReaction.LOVE) + + +def test_delete_messages(client): + text1 = "This message will stay" + text2 = "This message will be removed" + mid1 = client.sendMessage(text1) + mid2 = client.sendMessage(text2) + client.deleteMessages(mid2) + message, = client.fetchThreadMessages(limit=1) + assert subset(vars(message), uid=mid1, author=client.uid, text=text1) diff --git a/tests/test_plans.py b/tests/test_plans.py new file mode 100644 index 0000000..d16c153 --- /dev/null +++ b/tests/test_plans.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from fbchat.models import Plan, FBchatFacebookError, ThreadType +from utils import random_hex, subset +from time import time + + +@pytest.fixture(scope="module", params=[ + Plan(int(time()) + 100, random_hex()), + pytest.mark.xfail(Plan(int(time()), random_hex()), raises=FBchatFacebookError), + pytest.mark.xfail(Plan(0, None)), +]) +def plan_data(request, client, user, thread, catch_event, compare): + with catch_event("onPlanCreated") as x: + client.createPlan(request.param, thread["id"]) + assert compare(x) + assert subset( + vars(x.res["plan"]), + time=request.param.time, + title=request.param.title, + author_id=client.uid, + going=[client.uid], + declined=[], + ) + plan_id = x.res["plan"] + assert user["id"] in x.res["plan"].invited + request.param.uid = x.res["plan"].uid + yield x.res, request.param + with catch_event("onPlanDeleted") as x: + client.deletePlan(plan_id) + assert compare(x) + + +@pytest.mark.tryfirst +def test_create_delete_plan(plan_data): + pass + + +def test_fetch_plan_info(client, catch_event, plan_data): + event, plan = plan_data + fetched_plan = client.fetchPlanInfo(plan.uid) + assert subset( + vars(fetched_plan), + time=plan.time, + title=plan.title, + author_id=int(client.uid), + ) + + +@pytest.mark.parametrize("take_part", [False, True]) +def test_change_plan_participation(client, thread, catch_event, compare, plan_data, take_part): + event, plan = plan_data + with catch_event("onPlanParticipation") as x: + client.changePlanParticipation(plan, take_part=take_part) + assert compare(x, take_part=take_part) + assert subset( + vars(x.res["plan"]), + time=plan.time, + title=plan.title, + author_id=client.uid, + going=[client.uid] if take_part else [], + declined=[client.uid] if not take_part else [], + ) + + +@pytest.mark.trylast +def test_edit_plan(client, thread, catch_event, compare, plan_data): + event, plan = plan_data + new_plan = Plan(plan.time + 100, random_hex()) + with catch_event("onPlanEdited") as x: + client.editPlan(plan, new_plan) + assert compare(x) + assert subset( + vars(x.res["plan"]), + time=new_plan.time, + title=new_plan.title, + author_id=client.uid, + ) + + +@pytest.mark.trylast +@pytest.mark.expensive +def test_on_plan_ended(client, thread, catch_event, compare): + with catch_event("onPlanEnded") as x: + client.createPlan(Plan(int(time()) + 120, "Wait for ending")) + x.wait(180) + assert subset(x.res, thread_id=client.uid if thread["type"] == ThreadType.USER else thread["id"], thread_type=thread["type"]) + + +#createPlan(self, plan, thread_id=None) +#editPlan(self, plan, new_plan) +#deletePlan(self, plan) +#changePlanParticipation(self, plan, take_part=True) + +#onPlanCreated(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanEnded(self, mid=None, plan=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanEdited(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanDeleted(self, mid=None, plan=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) +#onPlanParticipation(self, mid=None, plan=None, take_part=None, author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None, msg=None) + +#fetchPlanInfo(self, plan_id) diff --git a/tests/test_polls.py b/tests/test_polls.py new file mode 100644 index 0000000..96dab76 --- /dev/null +++ b/tests/test_polls.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from fbchat.models import Poll, PollOption, ThreadType +from utils import random_hex, subset + + +@pytest.fixture(scope="module", params=[ + Poll(title=random_hex(), options=[]), + Poll(title=random_hex(), options=[ + PollOption(random_hex(), vote=True), + PollOption(random_hex(), vote=True), + ]), + Poll(title=random_hex(), options=[ + PollOption(random_hex(), vote=False), + PollOption(random_hex(), vote=False), + ]), + Poll(title=random_hex(), options=[ + PollOption(random_hex(), vote=True), + PollOption(random_hex(), vote=True), + PollOption(random_hex(), vote=False), + PollOption(random_hex(), vote=False), + PollOption(random_hex()), + PollOption(random_hex()), + ]), + pytest.mark.xfail(Poll(title=None, options=[]), raises=ValueError), +]) +def poll_data(request, client1, group, catch_event): + with catch_event("onPollCreated") as x: + client1.createPoll(request.param, thread_id=group["id"]) + options = client1.fetchPollOptions(x.res["poll"].uid) + return x.res, request.param, options + + +def test_create_poll(client1, group, catch_event, poll_data): + event, poll, _ = poll_data + assert subset( + event, + author_id=client1.uid, + thread_id=group["id"], + thread_type=ThreadType.GROUP, + ) + assert subset(vars(event["poll"]), title=poll.title, options_count=len(poll.options)) + for recv_option in event["poll"].options: # The recieved options may not be the full list + old_option, = list(filter(lambda o: o.text == recv_option.text, poll.options)) + voters = [client1.uid] if old_option.vote else [] + assert subset(vars(recv_option), voters=voters, votes_count=len(voters), vote=False) + + +def test_fetch_poll_options(client1, group, catch_event, poll_data): + _, poll, options = poll_data + assert len(options) == len(poll.options) + for option in options: + assert subset(vars(option)) + + +@pytest.mark.trylast +def test_update_poll_vote(client1, group, catch_event, poll_data): + event, poll, options = poll_data + new_vote_ids = [o.uid for o in options[0:len(options):2] if not o.vote] + re_vote_ids = [o.uid for o in options[0:len(options):2] if o.vote] + new_options = [random_hex(), random_hex()] + with catch_event("onPollVoted") as x: + client1.updatePollVote(event["poll"].uid, option_ids=new_vote_ids + re_vote_ids, new_options=new_options) + + assert subset( + x.res, + author_id=client1.uid, + thread_id=group["id"], + thread_type=ThreadType.GROUP, + ) + assert subset(vars(x.res["poll"]), title=poll.title, options_count=len(options + new_options)) + for o in new_vote_ids: + assert o in x.res["added_options"] + assert len(x.res["added_options"]) == len(new_vote_ids) + len(new_options) + assert set(x.res["removed_options"]) == set(o.uid for o in options if o.vote and o.uid not in re_vote_ids) diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..dda9568 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from fbchat.models import ThreadType + + +def test_search_for(client1): + users = client1.searchForUsers("Mark Zuckerberg") + assert len(users) > 0 + + u = users[0] + + assert u.uid == "4" + assert u.type == ThreadType.USER + assert u.photo[:4] == "http" + assert u.url[:4] == "http" + assert u.name == "Mark Zuckerberg" diff --git a/tests/test_send.py b/tests/test_send.py new file mode 100644 index 0000000..3b53059 --- /dev/null +++ b/tests/test_send.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from os import path +from fbchat.models import FBchatFacebookError, Message, Mention +from utils import subset, STICKER_LIST, EMOJI_LIST, TEXT_LIST + + +@pytest.mark.parametrize("text", TEXT_LIST) +def test_send_text(client, catch_event, compare, text): + with catch_event("onMessage") as x: + mid = client.sendMessage(text) + + assert compare(x, mid=mid, message=text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) + + +@pytest.mark.parametrize("emoji, emoji_size", EMOJI_LIST) +def test_send_emoji(client, catch_event, compare, emoji, emoji_size): + with catch_event("onMessage") as x: + mid = client.sendEmoji(emoji, emoji_size) + + assert compare(x, mid=mid, message=emoji) + assert subset( + vars(x.res["message_object"]), + uid=mid, + author=client.uid, + text=emoji, + emoji_size=emoji_size, + ) + + +def test_send_mentions(client, catch_event, compare, message_with_mentions): + with catch_event("onMessage") as x: + mid = client.send(message_with_mentions) + + assert compare(x, mid=mid, message=message_with_mentions.text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=message_with_mentions.text) + # The mentions are not ordered by offset + for m in x.res["message_object"].mentions: + assert vars(m) in [vars(x) for x in message_with_mentions.mentions] + + +@pytest.mark.parametrize("sticker", STICKER_LIST) +def test_send_sticker(client, catch_event, compare, sticker): + with catch_event("onMessage") as x: + mid = client.send(Message(sticker=sticker)) + + assert compare(x, mid=mid) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid) + assert subset(vars(x.res["message_object"].sticker), uid=sticker.uid) + + +# Kept for backwards compatibility +@pytest.mark.parametrize( + "method_name, url", + [ + ( + "sendRemoteImage", + "https://github.com/carpedm20/fbchat/raw/master/tests/image.png", + ), + ("sendLocalImage", path.join(path.dirname(__file__), "resources", "image.png")), + ], +) +def test_send_images(client, catch_event, compare, method_name, url): + text = "An image sent with {}".format(method_name) + with catch_event("onMessage") as x: + mid = getattr(client, method_name)(url, Message(text)) + + assert compare(x, mid=mid, message=text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) + assert x.res["message_object"].attachments[0] + + +def test_send_local_files(client, catch_event, compare): + files = ["image.png", "image.jpg", "image.gif", "file.json", "file.txt", "audio.mp3", "video.mp4"] + text = "Files sent locally" + with catch_event("onMessage") as x: + mid = client.sendLocalFiles( + [path.join(path.dirname(__file__), "resources", f) for f in files], + message=Message(text), + ) + + assert compare(x, mid=mid, message=text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) + assert len(x.res["message_object"].attachments) == len(files) + + +# To be changed when merged into master +def test_send_remote_files(client, catch_event, compare): + files = ["image.png", "data.json"] + text = "Files sent from remote" + with catch_event("onMessage") as x: + mid = client.sendRemoteFiles( + ["https://github.com/carpedm20/fbchat/raw/master/tests/{}".format(f) for f in files], + message=Message(text), + ) + + assert compare(x, mid=mid, message=text) + assert subset(vars(x.res["message_object"]), uid=mid, author=client.uid, text=text) + assert len(x.res["message_object"].attachments) == len(files) + + +@pytest.mark.parametrize('wave_first', [True, False]) +def test_wave(client, wave_first): + client.wave(wave_first) diff --git a/tests/test_tests.py b/tests/test_tests.py new file mode 100644 index 0000000..cfb2600 --- /dev/null +++ b/tests/test_tests.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + + +def test_catch_event(client2, catch_event): + mid = "test" + with catch_event("onMessage") as x: + client2.onMessage(mid=mid) + assert x.res['mid'] == mid diff --git a/tests/test_thread_interraction.py b/tests/test_thread_interraction.py new file mode 100644 index 0000000..16e7e9e --- /dev/null +++ b/tests/test_thread_interraction.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import pytest + +from fbchat.models import ( + Message, + ThreadType, + FBchatFacebookError, + TypingStatus, + ThreadColor, +) +from utils import random_hex, subset +from os import path + + +def test_remove_from_and_add_to_group(client1, client2, group, catch_event): + # Test both methods, while ensuring that the user gets added to the group + try: + with catch_event("onPersonRemoved") as x: + client1.removeUserFromGroup(client2.uid, group["id"]) + assert subset( + x.res, removed_id=client2.uid, author_id=client1.uid, thread_id=group["id"] + ) + finally: + with catch_event("onPeopleAdded") as x: + client1.addUsersToGroup(client2.uid, group["id"]) + assert subset( + x.res, added_ids=[client2.uid], author_id=client1.uid, thread_id=group["id"] + ) + + +def test_remove_from_and_add_admins_to_group(client1, client2, group, catch_event): + # Test both methods, while ensuring that the user gets added as group admin + try: + with catch_event("onAdminRemoved") as x: + client1.removeGroupAdmins(client2.uid, group["id"]) + assert subset( + x.res, removed_id=client2.uid, author_id=client1.uid, thread_id=group["id"] + ) + finally: + with catch_event("onAdminAdded") as x: + client1.addGroupAdmins(client2.uid, group["id"]) + assert subset( + x.res, added_id=client2.uid, author_id=client1.uid, thread_id=group["id"] + ) + + +def test_change_title(client1, group, catch_event): + title = random_hex() + with catch_event("onTitleChange") as x: + client1.changeThreadTitle(title, group["id"], thread_type=ThreadType.GROUP) + assert subset( + x.res, + author_id=client1.uid, + new_title=title, + thread_id=group["id"], + thread_type=ThreadType.GROUP, + ) + + +def test_change_nickname(client, client_all, catch_event, compare): + nickname = random_hex() + with catch_event("onNicknameChange") as x: + client.changeNickname(nickname, client_all.uid) + assert compare(x, changed_for=client_all.uid, new_nickname=nickname) + + +@pytest.mark.parametrize("emoji", [ + "😀", + "😂", + "😕", + "😍", + pytest.mark.xfail("🙃", raises=FBchatFacebookError), + pytest.mark.xfail("not an emoji", raises=FBchatFacebookError) +]) +def test_change_emoji(client, catch_event, compare, emoji): + with catch_event("onEmojiChange") as x: + client.changeThreadEmoji(emoji) + assert compare(x, new_emoji=emoji) + + +def test_change_image_local(client1, group, catch_event): + url = path.join(path.dirname(__file__), "resources", "image.png") + with catch_event("onImageChange") as x: + image_id = client1.changeGroupImageLocal(url, group["id"]) + assert subset(x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"]) + + +# To be changed when merged into master +def test_change_image_remote(client1, group, catch_event): + url = "https://github.com/carpedm20/fbchat/raw/master/tests/image.png" + with catch_event("onImageChange") as x: + image_id = client1.changeGroupImageRemote(url, group["id"]) + assert subset(x.res, new_image=image_id, author_id=client1.uid, thread_id=group["id"]) + + +@pytest.mark.parametrize( + "color", + [ + x + if x in [ThreadColor.MESSENGER_BLUE, ThreadColor.PUMPKIN] + else pytest.mark.expensive(x) + for x in ThreadColor + ], +) +def test_change_color(client, catch_event, compare, color): + with catch_event("onColorChange") as x: + client.changeThreadColor(color) + assert compare(x, new_color=color) + + +@pytest.mark.xfail(raises=FBchatFacebookError, reason="Should fail, but doesn't") +def test_change_color_invalid(client): + class InvalidColor: + value = "#0077ff" + + client.changeThreadColor(InvalidColor()) + + +@pytest.mark.parametrize("status", TypingStatus) +def test_typing_status(client, catch_event, compare, status): + with catch_event("onTyping") as x: + client.setTypingStatus(status) + assert compare(x, status=status) + + +@pytest.mark.parametrize('require_admin_approval', [True, False]) +def test_change_approval_mode(client1, group, catch_event, require_admin_approval): + with catch_event("onApprovalModeChange") as x: + client1.changeGroupApprovalMode(require_admin_approval, group["id"]) + + assert subset( + x.res, + approval_mode=require_admin_approval, + author_id=client1.uid, + thread_id=group["id"], + ) + +@pytest.mark.parametrize("mute_time", [0, 10, 100, 1000, -1]) +def test_mute_thread(client, mute_time): + assert client.muteThread(mute_time) + assert client.unmuteThread() + + +def test_mute_thread_reactions(client): + assert client.muteThreadReactions() + assert client.unmuteThreadReactions() + + +def test_mute_thread_mentions(client): + assert client.muteThreadMentions() + assert client.unmuteThreadMentions() diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..51364cb --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import threading +import logging +import six +import pytest + +from os import environ +from random import randrange +from contextlib import contextmanager +from six import viewitems +from fbchat import Client +from fbchat.models import ThreadType, EmojiSize, FBchatFacebookError, Sticker + +log = logging.getLogger("fbchat.tests").addHandler(logging.NullHandler()) + + +EMOJI_LIST = [ + ("😆", EmojiSize.SMALL), + ("😆", EmojiSize.MEDIUM), + ("😆", EmojiSize.LARGE), + # These fail in `catch_event` because the emoji is made into a sticker + # This should be fixed + pytest.mark.xfail((None, EmojiSize.SMALL)), + pytest.mark.xfail((None, EmojiSize.MEDIUM)), + pytest.mark.xfail((None, EmojiSize.LARGE)), +] + +STICKER_LIST = [ + Sticker("767334476626295"), + pytest.mark.xfail(Sticker("0"), raises=FBchatFacebookError), + pytest.mark.xfail(Sticker(None), raises=FBchatFacebookError), +] + +TEXT_LIST = [ + "test_send", + "😆", + "\\\n\t%?&'\"", + "ˁҭʚ¹Ʋջوװ՞ޱɣࠚԹБɑȑңКએ֭ʗыԈٌʼőԈ×௴nચϚࠖణٔє܅Ԇޑط", + "a" * 20000, # Maximum amount of characters you can send + pytest.mark.xfail("a" * 20001, raises=FBchatFacebookError), + pytest.mark.xfail(None, raises=FBchatFacebookError), +] + + +class ClientThread(threading.Thread): + def __init__(self, client, *args, **kwargs): + self.client = client + self.should_stop = threading.Event() + super(ClientThread, self).__init__(*args, **kwargs) + + def start(self): + self.client.startListening() + self.client.doOneListen() # QPrimer, Facebook now knows we're about to start pulling + super(ClientThread, self).start() + + def run(self): + while not self.should_stop.is_set() and self.client.doOneListen(): + pass + + self.client.stopListening() + + +if six.PY2: + event_class = threading._Event +else: + event_class = threading.Event + + +class CaughtValue(event_class): + def set(self, res): + self.res = res + super(CaughtValue, self).set() + + def wait(self, timeout=3): + super(CaughtValue, self).wait(timeout=timeout) + + +def random_hex(length=20): + return "{:X}".format(randrange(16 ** length)) + + +def subset(a, **b): + print(a) + print(b) + return viewitems(b) <= viewitems(a) + + +def load_variable(name, cache): + var = environ.get(name, None) + if var is not None: + if cache.get(name, None) != var: + cache.set(name, var) + return var + + var = cache.get(name, None) + if var is None: + raise ValueError("Variable {!r} neither in environment nor cache".format(name)) + return var + + +@contextmanager +def load_client(n, cache): + client = Client( + load_variable("client{}_email".format(n), cache), + load_variable("client{}_password".format(n), cache), + user_agent='Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36', + session_cookies=cache.get("client{}_session".format(n), None), + max_tries=1, + ) + yield client + cache.set("client{}_session".format(n), client.getSession())