├── fbchat ├── py.typed ├── _threads │ ├── __init__.py │ ├── _page.py │ └── _user.py ├── _models │ ├── __init__.py │ ├── _sticker.py │ ├── _common.py │ ├── _quick_reply.py │ ├── _attachment.py │ ├── _location.py │ ├── _poll.py │ ├── _plan.py │ └── _file.py ├── _common.py ├── _fix_module_metadata.py ├── _events │ ├── _common.py │ ├── __init__.py │ ├── _client_payload.py │ └── _delta_class.py ├── __init__.py ├── _util.py ├── _exception.py └── _graphql.py ├── tests ├── resources │ ├── file.txt │ ├── file.json │ ├── audio.mp3 │ ├── image.gif │ ├── image.jpg │ ├── image.png │ └── video.mp4 ├── conftest.py ├── test_examples.py ├── test_module_renaming.py ├── threads │ ├── test_page.py │ ├── test_group.py │ ├── test_thread.py │ └── test_user.py ├── test_graphql.py ├── online │ ├── test_send.py │ ├── conftest.py │ └── test_client.py ├── models │ ├── test_quick_reply.py │ ├── test_poll.py │ ├── test_sticker.py │ ├── test_location.py │ ├── test_message.py │ └── test_plan.py ├── events │ ├── test_common.py │ ├── test_main.py │ └── test_client_payload.py ├── test_exception.py └── test_util.py ├── docs ├── spelling │ ├── fixes.txt │ ├── names.txt │ └── technical.txt ├── api │ ├── client.rst │ ├── events.rst │ ├── session.rst │ ├── thread_data.rst │ ├── threads.rst │ ├── messages.rst │ ├── exceptions.rst │ ├── attachments.rst │ ├── index.rst │ └── misc.rst ├── _static │ └── find-group-id.png ├── _templates │ ├── sidebar.html │ └── layout.html ├── index.rst ├── Makefile ├── make.bat ├── faq.rst ├── examples.rst ├── conf.py └── intro.rst ├── examples ├── basic_usage.py ├── echobot.py ├── removebot.py ├── session_handling.py ├── interract.py ├── fetch.py └── keepbot.py ├── pytest.ini ├── .readthedocs.yml ├── .gitignore ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .travis.yml ├── CONTRIBUTING.rst ├── LICENSE ├── pyproject.toml ├── CODE_OF_CONDUCT └── README.rst /fbchat/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/resources/file.txt: -------------------------------------------------------------------------------- 1 | This is just a text file 2 | -------------------------------------------------------------------------------- /docs/spelling/fixes.txt: -------------------------------------------------------------------------------- 1 | premade 2 | todo 3 | emoji 4 | -------------------------------------------------------------------------------- /docs/spelling/names.txt: -------------------------------------------------------------------------------- 1 | Facebook 2 | GraphQL 3 | GitHub 4 | -------------------------------------------------------------------------------- /docs/api/client.rst: -------------------------------------------------------------------------------- 1 | Client 2 | ====== 3 | 4 | .. autoclass:: Client 5 | -------------------------------------------------------------------------------- /docs/api/events.rst: -------------------------------------------------------------------------------- 1 | Events 2 | ====== 3 | 4 | .. autoclass:: Listener 5 | -------------------------------------------------------------------------------- /docs/api/session.rst: -------------------------------------------------------------------------------- 1 | Session 2 | ======= 3 | 4 | .. autoclass:: Session() 5 | -------------------------------------------------------------------------------- /tests/resources/file.json: -------------------------------------------------------------------------------- 1 | { 2 | "some": "data", 3 | "in": "here" 4 | } 5 | -------------------------------------------------------------------------------- /tests/resources/audio.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbchat-dev/fbchat/HEAD/tests/resources/audio.mp3 -------------------------------------------------------------------------------- /tests/resources/image.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbchat-dev/fbchat/HEAD/tests/resources/image.gif -------------------------------------------------------------------------------- /tests/resources/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbchat-dev/fbchat/HEAD/tests/resources/image.jpg -------------------------------------------------------------------------------- /tests/resources/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbchat-dev/fbchat/HEAD/tests/resources/image.png -------------------------------------------------------------------------------- /tests/resources/video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbchat-dev/fbchat/HEAD/tests/resources/video.mp4 -------------------------------------------------------------------------------- /docs/_static/find-group-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbchat-dev/fbchat/HEAD/docs/_static/find-group-id.png -------------------------------------------------------------------------------- /fbchat/_threads/__init__.py: -------------------------------------------------------------------------------- 1 | from ._abc import * 2 | from ._group import * 3 | from ._user import * 4 | from ._page import * 5 | -------------------------------------------------------------------------------- /docs/api/thread_data.rst: -------------------------------------------------------------------------------- 1 | Thread Data 2 | =========== 3 | 4 | .. autoclass:: PageData() 5 | .. autoclass:: UserData() 6 | .. autoclass:: GroupData() 7 | -------------------------------------------------------------------------------- /docs/api/threads.rst: -------------------------------------------------------------------------------- 1 | Threads 2 | ======= 3 | 4 | .. autoclass:: ThreadABC() 5 | .. autoclass:: Thread 6 | .. autoclass:: Page 7 | .. autoclass:: User 8 | .. autoclass:: Group 9 | -------------------------------------------------------------------------------- /docs/api/messages.rst: -------------------------------------------------------------------------------- 1 | Messages 2 | ======== 3 | 4 | .. autoclass:: Message 5 | .. autoclass:: Mention 6 | .. autoclass:: EmojiSize(Enum) 7 | :undoc-members: 8 | .. autoclass:: MessageData() 9 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import fbchat 3 | 4 | 5 | @pytest.fixture(scope="session") 6 | def session(): 7 | return fbchat.Session( 8 | user_id="31415926536", fb_dtsg=None, revision=None, session=None 9 | ) 10 | -------------------------------------------------------------------------------- /docs/spelling/technical.txt: -------------------------------------------------------------------------------- 1 | iterables 2 | iterable 3 | mimetype 4 | timestamp 5 | metadata 6 | spam 7 | spammy 8 | admin 9 | admins 10 | unsend 11 | unsends 12 | unmute 13 | spritemap 14 | online 15 | inbox 16 | subclassing 17 | codebase 18 | -------------------------------------------------------------------------------- /fbchat/_models/__init__.py: -------------------------------------------------------------------------------- 1 | from ._common import * 2 | from ._attachment import * 3 | from ._file import * 4 | from ._location import * 5 | from ._plan import * 6 | from ._poll import * 7 | from ._quick_reply import * 8 | from ._sticker import * 9 | from ._message import * 10 | -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | 3 | # Log the user in 4 | session = fbchat.Session.login("", "") 5 | 6 | print("Own id: {}".format(session.user.id)) 7 | 8 | # Send a message to yourself 9 | session.user.send_text("Hi me!") 10 | 11 | # Log the user out 12 | session.logout() 13 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | xfail_strict = true 3 | markers = 4 | online: Online tests, that require a user account set up. Meant to be used \ 5 | manually, to check whether Facebook has broken something. 6 | addopts = 7 | --strict 8 | -m "not online" 9 | testpaths = tests 10 | filterwarnings = error 11 | -------------------------------------------------------------------------------- /fbchat/_common.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import attr 3 | import logging 4 | 5 | log = logging.getLogger("fbchat") 6 | 7 | # Enable kw_only if the python version supports it 8 | kw_only = sys.version_info[:2] > (3, 5) 9 | 10 | #: Default attrs settings for classes 11 | attrs_default = attr.s(frozen=True, slots=True, kw_only=kw_only) 12 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import py_compile 3 | import glob 4 | from os import path 5 | 6 | 7 | def test_examples_compiles(): 8 | # Compiles the examples, to check for syntax errors 9 | for name in glob.glob(path.join(path.dirname(__file__), "../examples", "*.py")): 10 | py_compile.compile(name) 11 | -------------------------------------------------------------------------------- /docs/api/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | .. autoexception:: FacebookError() 5 | .. autoexception:: HTTPError() 6 | .. autoexception:: ParseError() 7 | .. autoexception:: NotLoggedIn() 8 | .. autoexception:: ExternalError() 9 | .. autoexception:: GraphQLError() 10 | .. autoexception:: InvalidParameters() 11 | .. autoexception:: PleaseRefresh() 12 | -------------------------------------------------------------------------------- /docs/_templates/sidebar.html: -------------------------------------------------------------------------------- 1 |

2 | {{ _(project) }} 3 |

4 | 5 |

6 | Star 7 |

8 | 9 |

10 | {{ _(shorttitle) }} 11 |

12 | 13 | {{ toctree() }} -------------------------------------------------------------------------------- /docs/api/attachments.rst: -------------------------------------------------------------------------------- 1 | Attachments 2 | =========== 3 | 4 | .. autoclass:: Attachment() 5 | .. autoclass:: ShareAttachment() 6 | .. autoclass:: Sticker() 7 | .. autoclass:: LocationAttachment() 8 | .. autoclass:: LiveLocationAttachment() 9 | .. autoclass:: FileAttachment() 10 | .. autoclass:: AudioAttachment() 11 | .. autoclass:: ImageAttachment() 12 | .. autoclass:: VideoAttachment() 13 | .. autoclass:: ImageAttachment() 14 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | .. module:: fbchat 2 | 3 | .. Note: we're using () to hide the __init__ method where relevant 4 | 5 | Full API 6 | ======== 7 | 8 | If you are looking for information on a specific function, class, or method, this part of the documentation is for you. 9 | 10 | .. toctree:: 11 | :maxdepth: 1 12 | 13 | session 14 | client 15 | threads 16 | thread_data 17 | messages 18 | exceptions 19 | attachments 20 | events 21 | misc 22 | -------------------------------------------------------------------------------- /examples/echobot.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | 3 | session = fbchat.Session.login("", "") 4 | listener = fbchat.Listener(session=session, chat_on=False, foreground=False) 5 | 6 | for event in listener.listen(): 7 | if isinstance(event, fbchat.MessageEvent): 8 | print(f"{event.message.text} from {event.author.id} in {event.thread.id}") 9 | # If you're not the author, echo 10 | if event.author.id != session.user.id: 11 | event.thread.send_text(event.message.text) 12 | -------------------------------------------------------------------------------- /docs/api/misc.rst: -------------------------------------------------------------------------------- 1 | Miscellaneous 2 | ============= 3 | 4 | .. autoclass:: ThreadLocation(Enum) 5 | :undoc-members: 6 | .. autoclass:: ActiveStatus() 7 | 8 | .. autoclass:: QuickReply 9 | .. autoclass:: QuickReplyText 10 | .. autoclass:: QuickReplyLocation 11 | .. autoclass:: QuickReplyPhoneNumber 12 | .. autoclass:: QuickReplyEmail 13 | 14 | .. autoclass:: Poll 15 | .. autoclass:: PollOption 16 | 17 | .. autoclass:: Plan 18 | .. autoclass:: PlanData() 19 | .. autoclass:: GuestStatus(Enum) 20 | :undoc-members: 21 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | version: 2 3 | 4 | formats: 5 | - pdf 6 | - htmlzip 7 | 8 | python: 9 | version: 3.6 10 | install: 11 | - path: . 12 | extra_requirements: 13 | - docs 14 | 15 | # Build documentation in the docs/ directory with Sphinx 16 | sphinx: 17 | configuration: docs/conf.py 18 | # Disabled, until we can find a way to get sphinx-autodoc-typehints play nice with our 19 | # module renaming! 20 | fail_on_warning: false 21 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: sh 2 | .. See README.rst for explanation of these markers 3 | 4 | .. include:: ../README.rst 5 | :end-before: inclusion-marker-intro-end 6 | 7 | With that said, let's get started! 8 | 9 | .. include:: ../README.rst 10 | :start-after: inclusion-marker-installation-start 11 | :end-before: inclusion-marker-installation-end 12 | 13 | 14 | Documentation Overview 15 | ---------------------- 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | 20 | intro 21 | examples 22 | faq 23 | api/index 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *py[co] 2 | 3 | .idea/ 4 | 5 | # Test scripts 6 | *.sh 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | *.dist-info 12 | dist 13 | build 14 | eggs 15 | .eggs 16 | parts 17 | bin 18 | var 19 | sdist 20 | develop-eggs 21 | .installed.cfg 22 | 23 | # Vim 24 | .*.sw[op] 25 | 26 | # Sphinx documentation 27 | docs/_build/ 28 | 29 | # Scripts and data for tests 30 | my_tests.py 31 | my_test_data.json 32 | my_data.json 33 | tests.data 34 | .pytest_cache 35 | 36 | # MyPy 37 | .mypy_cache/ 38 | 39 | # Virtual environment 40 | venv/ 41 | .venv*/ 42 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | -------------------------------------------------------------------------------- /tests/test_module_renaming.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | 3 | 4 | def test_module_renaming(): 5 | assert fbchat.Message.__module__ == "fbchat" 6 | assert fbchat.Group.__module__ == "fbchat" 7 | assert fbchat.Event.__module__ == "fbchat" 8 | assert fbchat.User.block.__module__ == "fbchat" 9 | assert fbchat.Session.login.__func__.__module__ == "fbchat" 10 | assert fbchat.Session._from_session.__func__.__module__ == "fbchat" 11 | assert fbchat.Message.session.fget.__module__ == "fbchat" 12 | assert fbchat.Session.__repr__.__module__ == "fbchat" 13 | 14 | 15 | def test_did_not_rename(): 16 | assert fbchat._graphql.queries_to_json.__module__ != "fbchat" 17 | -------------------------------------------------------------------------------- /examples/removebot.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | 3 | 4 | def on_message(event): 5 | # We can only kick people from group chats, so no need to try if it's a user chat 6 | if not isinstance(event.thread, fbchat.Group): 7 | return 8 | if event.message.text == "Remove me!": 9 | print(f"{event.author.id} will be removed from {event.thread.id}") 10 | event.thread.remove_participant(event.author.id) 11 | 12 | 13 | session = fbchat.Session.login("", "") 14 | listener = fbchat.Listener(session=session, chat_on=False, foreground=False) 15 | for event in listener.listen(): 16 | if isinstance(event, fbchat.MessageEvent): 17 | on_message(event) 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature that you'd like to see implemented 4 | 5 | --- 6 | 7 | ## Description 8 | Example: There's no way to send messages to groups 9 | 10 | ## Research (if applicable) 11 | Example: I've found the URL `https://facebook.com/send_message.php`, to which you can send a POST requests with the following JSON: 12 | ```json 13 | { 14 | "text": message_content, 15 | "fbid": group_id, 16 | "some_variable": ? 17 | } 18 | ``` 19 | 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` 20 | -------------------------------------------------------------------------------- /tests/threads/test_page.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | from fbchat import PageData 3 | 4 | 5 | def test_page_from_graphql(session): 6 | data = { 7 | "id": "123456", 8 | "name": "Some school", 9 | "profile_picture": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/..."}, 10 | "url": "https://www.facebook.com/some-school/", 11 | "category_type": "SCHOOL", 12 | "city": None, 13 | } 14 | assert PageData( 15 | session=session, 16 | id="123456", 17 | photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), 18 | name="Some school", 19 | url="https://www.facebook.com/some-school/", 20 | city=None, 21 | category="SCHOOL", 22 | ) == PageData._from_graphql(session, data) 23 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | The new version broke my application 5 | ------------------------------------ 6 | 7 | ``fbchat`` follows `Scemantic Versioning `__ quite rigorously! 8 | 9 | That means that breaking changes can *only* occur in major versions (e.g. ``v1.9.6`` -> ``v2.0.0``). 10 | 11 | If you find that something breaks, and you didn't update to a new major version, then it is a bug, and we would be grateful if you reported it! 12 | 13 | In case you're stuck with an old codebase, you can downgrade to a previous version of ``fbchat``, e.g. version ``1.9.6``: 14 | 15 | .. code-block:: sh 16 | 17 | $ pip install fbchat==1.9.6 18 | 19 | 20 | Will you be supporting creating posts/events/pages and so on? 21 | ------------------------------------------------------------- 22 | 23 | We won't be focusing on anything else than chat-related things. This library is called ``fbCHAT``, after all! 24 | -------------------------------------------------------------------------------- /tests/test_graphql.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from fbchat._graphql import ConcatJSONDecoder, queries_to_json, response_to_json 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "text,result", 8 | [ 9 | ("", []), 10 | ('{"a":"b"}', [{"a": "b"}]), 11 | ('{"a":"b"}{"b":"c"}', [{"a": "b"}, {"b": "c"}]), 12 | (' \n{"a": "b" } \n { "b" \n\n : "c" }', [{"a": "b"}, {"b": "c"}]), 13 | ], 14 | ) 15 | def test_concat_json_decoder(text, result): 16 | assert result == json.loads(text, cls=ConcatJSONDecoder) 17 | 18 | 19 | def test_queries_to_json(): 20 | assert {"q0": "A", "q1": "B", "q2": "C"} == json.loads( 21 | queries_to_json("A", "B", "C") 22 | ) 23 | 24 | 25 | def test_response_to_json(): 26 | data = ( 27 | '{"q1":{"data":{"b":"c"}}}\r\n' 28 | '{"q0":{"response":[1,2]}}\r\n' 29 | "{\n" 30 | ' "successful_results": 2,\n' 31 | ' "error_results": 0,\n' 32 | ' "skipped_results": 0\n' 33 | "}" 34 | ) 35 | assert [[1, 2], {"b": "c"}] == response_to_json(data) 36 | -------------------------------------------------------------------------------- /tests/online/test_send.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import fbchat 3 | 4 | pytestmark = pytest.mark.online 5 | 6 | 7 | # TODO: Verify return values 8 | 9 | 10 | def test_wave(any_thread): 11 | assert any_thread.wave(True) 12 | assert any_thread.wave(False) 13 | 14 | 15 | def test_send_text(any_thread): 16 | assert any_thread.send_text("Test") 17 | 18 | 19 | def test_send_text_with_mention(any_thread): 20 | mention = fbchat.Mention(thread_id=any_thread.id, offset=5, length=8) 21 | assert any_thread.send_text("Test @mention", mentions=[mention]) 22 | 23 | 24 | def test_send_emoji(any_thread): 25 | assert any_thread.send_emoji("😀", size=fbchat.EmojiSize.LARGE) 26 | 27 | 28 | def test_send_sticker(any_thread): 29 | assert any_thread.send_sticker("1889713947839631") 30 | 31 | 32 | def test_send_location(any_thread): 33 | any_thread.send_location(51.5287718, -0.2416815) 34 | 35 | 36 | def test_send_pinned_location(any_thread): 37 | any_thread.send_pinned_location(39.9390731, 116.117273) 38 | 39 | 40 | @pytest.mark.skip(reason="need a way to use the uploaded files from test_client.py") 41 | def test_send_files(any_thread): 42 | pass 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 3.6 4 | 5 | cache: pip 6 | 7 | before_install: pip install flit 8 | # Use `--deps production` so that we don't install unnecessary dependencies 9 | install: flit install --deps production --extras test 10 | script: pytest 11 | 12 | jobs: 13 | include: 14 | - python: 3.5 15 | - python: 3.6 16 | - python: 3.7 17 | - python: pypy3.5 18 | 19 | - name: Lint 20 | before_install: skip 21 | install: pip install black 22 | script: black --check --verbose . 23 | 24 | - stage: deploy 25 | name: GitHub Releases 26 | if: tag IS present 27 | install: skip 28 | script: flit build 29 | deploy: 30 | provider: releases 31 | api_key: $GITHUB_OAUTH_TOKEN 32 | file_glob: true 33 | file: dist/* 34 | skip_cleanup: true 35 | draft: false 36 | on: 37 | tags: true 38 | 39 | - stage: deploy 40 | name: PyPI 41 | if: tag IS present 42 | install: skip 43 | script: skip 44 | deploy: 45 | provider: script 46 | script: flit publish 47 | on: 48 | tags: true 49 | 50 | notifications: 51 | email: 52 | on_success: never 53 | on_failure: change 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report if you're having trouble with `fbchat` 4 | 5 | --- 6 | 7 | ## Description of the problem 8 | Example: Logging in fails when the character `%` is in the password. A specific password that fails is `a_password_with_%` 9 | 10 | ## Code to reproduce 11 | ```py 12 | # Example code 13 | from fbchat import Client 14 | client = Client("[REDACTED_USERNAME]", "a_password_with_%") 15 | ``` 16 | 17 | ## Traceback 18 | ``` 19 | Traceback (most recent call last): 20 | File "", line 1, in 21 | File "[site-packages]/fbchat/client.py", line 78, in __init__ 22 | self.login(email, password, max_tries) 23 | File "[site-packages]/fbchat/client.py", line 407, in login 24 | raise FBchatException('Login failed. Check email/password. (Failed on URL: {})'.format(login_url)) 25 | fbchat.FBchatException: Login failed. Check email/password. (Failed on URL: https://m.facebook.com/login.php?login_attempt=1) 26 | ``` 27 | 28 | ## Environment information 29 | - Python version 30 | - `fbchat` version 31 | - If relevant, output from `$ python -m pip list` 32 | 33 | If you have done any research, include that. 34 | Make sure to redact all personal information. 35 | -------------------------------------------------------------------------------- /examples/session_handling.py: -------------------------------------------------------------------------------- 1 | # TODO: Consider adding Session.from_file and Session.to_file, 2 | # which would make this example a lot easier! 3 | 4 | import atexit 5 | import json 6 | import getpass 7 | import fbchat 8 | 9 | 10 | def load_cookies(filename): 11 | try: 12 | # Load cookies from file 13 | with open(filename) as f: 14 | return json.load(f) 15 | except FileNotFoundError: 16 | return # No cookies yet 17 | 18 | 19 | def save_cookies(filename, cookies): 20 | with open(filename, "w") as f: 21 | json.dump(cookies, f) 22 | 23 | 24 | def load_session(cookies): 25 | if not cookies: 26 | return 27 | try: 28 | return fbchat.Session.from_cookies(cookies) 29 | except fbchat.FacebookError: 30 | return # Failed loading from cookies 31 | 32 | 33 | cookies = load_cookies("session.json") 34 | session = load_session(cookies) 35 | if not session: 36 | # Session could not be loaded, login instead! 37 | session = fbchat.Session.login("", getpass.getpass()) 38 | 39 | # Save session cookies to file when the program exits 40 | atexit.register(lambda: save_cookies("session.json", session.get_cookies())) 41 | 42 | # Do stuff with session here 43 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends '!layout.html' %} 2 | 3 | {% block extrahead %} 4 | 5 | 6 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | Examples 4 | ======== 5 | 6 | These are a few examples on how to use ``fbchat``. Remember to swap out ```` and ```` for your email and password 7 | 8 | 9 | Basic example 10 | ------------- 11 | 12 | This will show basic usage of ``fbchat`` 13 | 14 | .. literalinclude:: ../examples/basic_usage.py 15 | 16 | 17 | Interacting with Threads 18 | ------------------------ 19 | 20 | This will interact with the thread in every way ``fbchat`` supports 21 | 22 | .. literalinclude:: ../examples/interract.py 23 | 24 | 25 | Fetching Information 26 | -------------------- 27 | 28 | This will show the different ways of fetching information about users and threads 29 | 30 | .. literalinclude:: ../examples/fetch.py 31 | 32 | 33 | ``Echobot`` 34 | ----------- 35 | 36 | This will reply to any message with the same message 37 | 38 | .. literalinclude:: ../examples/echobot.py 39 | 40 | 41 | Remove Bot 42 | ---------- 43 | 44 | This will remove a user from a group if they write the message ``Remove me!`` 45 | 46 | .. literalinclude:: ../examples/removebot.py 47 | 48 | 49 | "Prevent changes"-Bot 50 | --------------------- 51 | 52 | This will prevent chat color, emoji, nicknames and chat name from being changed. 53 | It will also prevent people from being added and removed 54 | 55 | .. literalinclude:: ../examples/keepbot.py 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to ``fbchat`` 2 | ========================== 3 | 4 | Thanks for reading this, all contributions are very much welcome! 5 | 6 | Please be aware that ``fbchat`` uses `Scemantic Versioning `__ quite rigorously! 7 | That means that if you're submitting a breaking change, it will probably take a while before it gets considered. 8 | 9 | Development Environment 10 | ----------------------- 11 | 12 | This project uses ``flit`` to configure development environments. You can install it using: 13 | 14 | .. code-block:: sh 15 | 16 | $ pip install flit 17 | 18 | And now you can install ``fbchat`` as a symlink: 19 | 20 | .. code-block:: sh 21 | 22 | $ git clone https://github.com/carpedm20/fbchat.git 23 | $ cd fbchat 24 | $ # *nix: 25 | $ flit install --symlink 26 | $ # Windows: 27 | $ flit install --pth-file 28 | 29 | This will also install required development tools like ``black``, ``pytest`` and ``sphinx``. 30 | 31 | After that, you can ``import`` the module as normal. 32 | 33 | Checklist 34 | --------- 35 | 36 | Once you're done with your work, please follow the steps below: 37 | 38 | - Run ``black .`` to format your code. 39 | - Run ``pytest`` to test your code. 40 | - Run ``make -C docs html``, and view the generated docs, to verify that the docs still work. 41 | - Run ``make -C docs spelling`` to check your spelling in docstrings. 42 | - Create a pull request, and point it to ``master`` `here `__. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2015, Taehoon Kim 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tests/models/test_quick_reply.py: -------------------------------------------------------------------------------- 1 | from fbchat import ( 2 | QuickReplyText, 3 | QuickReplyLocation, 4 | QuickReplyPhoneNumber, 5 | QuickReplyEmail, 6 | ) 7 | from fbchat._models._quick_reply import graphql_to_quick_reply 8 | 9 | 10 | def test_parse_minimal(): 11 | data = { 12 | "content_type": "text", 13 | "payload": None, 14 | "external_payload": None, 15 | "data": None, 16 | "title": "A", 17 | "image_url": None, 18 | } 19 | assert QuickReplyText(title="A") == graphql_to_quick_reply(data) 20 | data = {"content_type": "location"} 21 | assert QuickReplyLocation() == graphql_to_quick_reply(data) 22 | data = {"content_type": "user_phone_number"} 23 | assert QuickReplyPhoneNumber() == graphql_to_quick_reply(data) 24 | data = {"content_type": "user_email"} 25 | assert QuickReplyEmail() == graphql_to_quick_reply(data) 26 | 27 | 28 | def test_parse_text_full(): 29 | data = { 30 | "content_type": "text", 31 | "title": "A", 32 | "payload": "Some payload", 33 | "image_url": "https://example.com/image.jpg", 34 | "data": None, 35 | } 36 | assert QuickReplyText( 37 | payload="Some payload", 38 | data=None, 39 | is_response=False, 40 | title="A", 41 | image_url="https://example.com/image.jpg", 42 | ) == graphql_to_quick_reply(data) 43 | 44 | 45 | def test_parse_with_is_response(): 46 | data = {"content_type": "text"} 47 | assert QuickReplyText(is_response=True) == graphql_to_quick_reply( 48 | data, is_response=True 49 | ) 50 | -------------------------------------------------------------------------------- /tests/threads/test_group.py: -------------------------------------------------------------------------------- 1 | from fbchat import GroupData, User 2 | 3 | 4 | def test_group_from_graphql(session): 5 | data = { 6 | "name": "Group ABC", 7 | "thread_key": {"thread_fbid": "11223344"}, 8 | "image": None, 9 | "is_group_thread": True, 10 | "all_participants": { 11 | "nodes": [ 12 | {"messaging_actor": {"__typename": "User", "id": "1234"}}, 13 | {"messaging_actor": {"__typename": "User", "id": "2345"}}, 14 | {"messaging_actor": {"__typename": "User", "id": "3456"}}, 15 | ] 16 | }, 17 | "customization_info": { 18 | "participant_customizations": [], 19 | "outgoing_bubble_color": None, 20 | "emoji": "😀", 21 | }, 22 | "thread_admins": [{"id": "1234"}], 23 | "group_approval_queue": {"nodes": []}, 24 | "approval_mode": 0, 25 | "joinable_mode": {"mode": "0", "link": ""}, 26 | "event_reminders": {"nodes": []}, 27 | } 28 | assert GroupData( 29 | session=session, 30 | id="11223344", 31 | photo=None, 32 | name="Group ABC", 33 | last_active=None, 34 | message_count=None, 35 | plan=None, 36 | participants=[ 37 | User(session=session, id="1234"), 38 | User(session=session, id="2345"), 39 | User(session=session, id="3456"), 40 | ], 41 | nicknames={}, 42 | color="#0084ff", 43 | emoji="😀", 44 | admins={"1234"}, 45 | approval_mode=False, 46 | approval_requests=set(), 47 | join_link="", 48 | ) == GroupData._from_graphql(session, data) 49 | -------------------------------------------------------------------------------- /fbchat/_fix_module_metadata.py: -------------------------------------------------------------------------------- 1 | """Everything in this module is taken from the excellent trio project. 2 | 3 | Having the public path in .__module__ attributes is important for: 4 | - exception names in printed tracebacks 5 | - ~sphinx :show-inheritance:~ 6 | - deprecation warnings 7 | - pickle 8 | - probably other stuff 9 | """ 10 | 11 | import os 12 | 13 | 14 | def fixup_module_metadata(namespace): 15 | def fix_one(qualname, name, obj): 16 | # Custom extension, to handle classmethods, staticmethods and properties 17 | if isinstance(obj, (classmethod, staticmethod)): 18 | obj = obj.__func__ 19 | if isinstance(obj, property): 20 | obj = obj.fget 21 | 22 | mod = getattr(obj, "__module__", None) 23 | if mod is not None and mod.startswith("fbchat."): 24 | obj.__module__ = "fbchat" 25 | # Modules, unlike everything else in Python, put fully-qualitied 26 | # names into their __name__ attribute. We check for "." to avoid 27 | # rewriting these. 28 | if hasattr(obj, "__name__") and "." not in obj.__name__: 29 | obj.__name__ = name 30 | obj.__qualname__ = qualname 31 | if isinstance(obj, type): 32 | # Fix methods 33 | for attr_name, attr_value in obj.__dict__.items(): 34 | fix_one(objname + "." + attr_name, attr_name, attr_value) 35 | 36 | for objname, obj in namespace.items(): 37 | if not objname.startswith("_"): # ignore private attributes 38 | fix_one(objname, objname, obj) 39 | 40 | 41 | # Allow disabling this when running Sphinx 42 | # This is done so that Sphinx autodoc can detect the file's source 43 | # TODO: Find a better way to detect when we're running Sphinx! 44 | if os.environ.get("_FBCHAT_DISABLE_FIX_MODULE_METADATA") == "1": 45 | fixup_module_metadata = lambda namespace: None 46 | -------------------------------------------------------------------------------- /tests/online/conftest.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | import pytest 3 | import logging 4 | import getpass 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def session(pytestconfig): 9 | session_cookies = pytestconfig.cache.get("session_cookies", None) 10 | try: 11 | session = fbchat.Session.from_cookies(session_cookies) 12 | except fbchat.FacebookError: 13 | logging.exception("Error while logging in with cookies!") 14 | session = fbchat.Session.login(input("Email: "), getpass.getpass("Password: ")) 15 | 16 | yield session 17 | 18 | pytestconfig.cache.set("session_cookies", session.get_cookies()) 19 | 20 | # TODO: Allow the main session object to be closed - and perhaps used in `with`? 21 | session._session.close() 22 | 23 | 24 | @pytest.fixture 25 | def client(session): 26 | return fbchat.Client(session=session) 27 | 28 | 29 | @pytest.fixture(scope="session") 30 | def user(pytestconfig, session): 31 | user_id = pytestconfig.cache.get("user_id", None) 32 | if not user_id: 33 | user_id = input("A user you're chatting with's id: ") 34 | pytestconfig.cache.set("user_id", user_id) 35 | return fbchat.User(session=session, id=user_id) 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def group(pytestconfig, session): 40 | group_id = pytestconfig.cache.get("group_id", None) 41 | if not group_id: 42 | group_id = input("A group you're chatting with's id: ") 43 | pytestconfig.cache.set("group_id", group_id) 44 | return fbchat.Group(session=session, id=group_id) 45 | 46 | 47 | @pytest.fixture( 48 | scope="session", 49 | params=[ 50 | "user", 51 | "group", 52 | "self", 53 | pytest.param("invalid", marks=[pytest.mark.xfail()]), 54 | ], 55 | ) 56 | def any_thread(request, session, user, group): 57 | return { 58 | "user": user, 59 | "group": group, 60 | "self": session.user, 61 | "invalid": fbchat.Thread(session=session, id="0"), 62 | }[request.param] 63 | 64 | 65 | @pytest.fixture 66 | def listener(session): 67 | return fbchat.Listener(session=session, chat_on=False, foreground=False) 68 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py36', 'py37', 'py38'] 4 | 5 | [build-system] 6 | requires = ["flit"] 7 | build-backend = "flit.buildapi" 8 | 9 | [tool.flit.metadata] 10 | module = "fbchat" 11 | author = "Taehoon Kim" 12 | author-email = "carpedm20@gmail.com" 13 | maintainer = "Mads Marquart" 14 | maintainer-email = "madsmtm@gmail.com" 15 | home-page = "https://github.com/carpedm20/fbchat/" 16 | requires = [ 17 | "attrs>=19.1", 18 | "requests~=2.19", 19 | "beautifulsoup4~=4.0", 20 | "paho-mqtt~=1.5", 21 | ] 22 | description-file = "README.rst" 23 | classifiers = [ 24 | "Development Status :: 3 - Alpha", 25 | "Intended Audience :: Developers", 26 | "Intended Audience :: Information Technology", 27 | "License :: OSI Approved :: BSD License", 28 | "Operating System :: OS Independent", 29 | "Natural Language :: English", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Programming Language :: Python :: 3.5", 34 | "Programming Language :: Python :: 3.6", 35 | "Programming Language :: Python :: 3.7", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: Implementation :: CPython", 38 | "Programming Language :: Python :: Implementation :: PyPy", 39 | "Topic :: Communications :: Chat", 40 | "Topic :: Internet :: WWW/HTTP", 41 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 42 | "Topic :: Software Development :: Libraries", 43 | "Topic :: Software Development :: Libraries :: Python Modules", 44 | ] 45 | requires-python = ">=3.5, <4.0" 46 | keywords = "Facebook FB Messenger Library Chat Api Bot" 47 | license = "BSD 3-Clause" 48 | 49 | [tool.flit.metadata.urls] 50 | Documentation = "https://fbchat.readthedocs.io/" 51 | Repository = "https://github.com/carpedm20/fbchat/" 52 | 53 | [tool.flit.metadata.requires-extra] 54 | test = [ 55 | "pytest>=4.3,<6.0", 56 | ] 57 | docs = [ 58 | "sphinx~=2.0", 59 | "sphinxcontrib-spelling~=4.0", 60 | "sphinx-autodoc-typehints~=1.10", 61 | ] 62 | lint = [ 63 | "black", 64 | ] 65 | -------------------------------------------------------------------------------- /fbchat/_events/_common.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from .._common import kw_only 3 | from .. import _exception, _util, _threads 4 | 5 | from typing import Any 6 | 7 | #: Default attrs settings for events 8 | attrs_event = attr.s(slots=True, kw_only=kw_only, frozen=True) 9 | 10 | 11 | @attrs_event 12 | class Event: 13 | """Base class for all events.""" 14 | 15 | @staticmethod 16 | def _get_thread(session, data): 17 | # TODO: Handle pages? Is it even possible? 18 | key = data["threadKey"] 19 | 20 | if "threadFbId" in key: 21 | return _threads.Group(session=session, id=str(key["threadFbId"])) 22 | elif "otherUserFbId" in key: 23 | return _threads.User(session=session, id=str(key["otherUserFbId"])) 24 | raise _exception.ParseError("Could not find thread data", data=data) 25 | 26 | 27 | @attrs_event 28 | class UnknownEvent(Event): 29 | """Represent an unknown event.""" 30 | 31 | #: Some data describing the unknown event's origin 32 | source = attr.ib(type=str) 33 | #: The unknown data. This cannot be relied on, it's only for debugging purposes. 34 | data = attr.ib(type=Any) 35 | 36 | @classmethod 37 | def _parse(cls, session, data): 38 | raise NotImplementedError 39 | 40 | 41 | @attrs_event 42 | class ThreadEvent(Event): 43 | """Represent an event that was done by a user/page in a thread.""" 44 | 45 | #: The person who did the action 46 | author = attr.ib(type="_threads.User") # Or Union[User, Page]? 47 | #: Thread that the action was done in 48 | thread = attr.ib(type="_threads.ThreadABC") 49 | 50 | @classmethod 51 | def _parse_metadata(cls, session, data): 52 | metadata = data["messageMetadata"] 53 | author = _threads.User(session=session, id=metadata["actorFbId"]) 54 | thread = cls._get_thread(session, metadata) 55 | at = _util.millis_to_datetime(int(metadata["timestamp"])) 56 | return author, thread, at 57 | 58 | @classmethod 59 | def _parse_fetch(cls, session, data): 60 | author = _threads.User(session=session, id=data["message_sender"]["id"]) 61 | at = _util.millis_to_datetime(int(data["timestamp_precise"])) 62 | return author, at 63 | -------------------------------------------------------------------------------- /fbchat/_models/_sticker.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from . import Image, Attachment 3 | from .._common import attrs_default 4 | 5 | from typing import Optional 6 | 7 | 8 | @attrs_default 9 | class Sticker(Attachment): 10 | """Represents a Facebook sticker that has been sent to a thread as an attachment.""" 11 | 12 | #: The sticker-pack's ID 13 | pack = attr.ib(None, type=Optional[str]) 14 | #: Whether the sticker is animated 15 | is_animated = attr.ib(False, type=bool) 16 | 17 | # If the sticker is animated, the following should be present 18 | #: URL to a medium spritemap 19 | medium_sprite_image = attr.ib(None, type=Optional[str]) 20 | #: URL to a large spritemap 21 | large_sprite_image = attr.ib(None, type=Optional[str]) 22 | #: The amount of frames present in the spritemap pr. row 23 | frames_per_row = attr.ib(None, type=Optional[int]) 24 | #: The amount of frames present in the spritemap pr. column 25 | frames_per_col = attr.ib(None, type=Optional[int]) 26 | #: The total amount of frames in the spritemap 27 | frame_count = attr.ib(None, type=Optional[int]) 28 | #: The frame rate the spritemap is intended to be played in 29 | frame_rate = attr.ib(None, type=Optional[int]) 30 | 31 | #: The sticker's image 32 | image = attr.ib(None, type=Optional[Image]) 33 | #: The sticker's label/name 34 | label = attr.ib(None, type=Optional[str]) 35 | 36 | @classmethod 37 | def _from_graphql(cls, data): 38 | if not data: 39 | return None 40 | 41 | return cls( 42 | id=data["id"], 43 | pack=data["pack"].get("id") if data.get("pack") else None, 44 | is_animated=bool(data.get("sprite_image")), 45 | medium_sprite_image=data["sprite_image"].get("uri") 46 | if data.get("sprite_image") 47 | else None, 48 | large_sprite_image=data["sprite_image_2x"].get("uri") 49 | if data.get("sprite_image_2x") 50 | else None, 51 | frames_per_row=data.get("frames_per_row"), 52 | frames_per_col=data.get("frames_per_column"), 53 | frame_count=data.get("frame_count"), 54 | frame_rate=data.get("frame_rate"), 55 | image=Image._from_url_or_none(data), 56 | label=data["label"] if data.get("label") else None, 57 | ) 58 | -------------------------------------------------------------------------------- /examples/interract.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | import requests 3 | 4 | session = fbchat.Session.login("", "") 5 | 6 | client = fbchat.Client(session) 7 | 8 | thread = session.user 9 | # thread = fbchat.User(session=session, id="0987654321") 10 | # thread = fbchat.Group(session=session, id="1234567890") 11 | 12 | # Will send a message to the thread 13 | thread.send_text("") 14 | 15 | # Will send the default `like` emoji 16 | thread.send_sticker(fbchat.EmojiSize.LARGE.value) 17 | 18 | # Will send the emoji `👍` 19 | thread.send_emoji("👍", size=fbchat.EmojiSize.LARGE) 20 | 21 | # Will send the sticker with ID `767334476626295` 22 | thread.send_sticker("767334476626295") 23 | 24 | # Will send a message with a mention 25 | thread.send_text( 26 | text="This is a @mention", 27 | mentions=[fbchat.Mention(thread.id, offset=10, length=8)], 28 | ) 29 | 30 | # Will send the image located at `` 31 | with open("", "rb") as f: 32 | files = client.upload([("image_name.png", f, "image/png")]) 33 | thread.send_text(text="This is a local image", files=files) 34 | 35 | # Will download the image at the URL ``, and then send it 36 | r = requests.get("") 37 | files = client.upload([("image_name.png", r.content, "image/png")]) 38 | thread.send_files(files) # Alternative to .send_text 39 | 40 | 41 | # Only do these actions if the thread is a group 42 | if isinstance(thread, fbchat.Group): 43 | # Will remove the user with ID `` from the group 44 | thread.remove_participant("") 45 | # Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the group 46 | thread.add_participants(["<1st user id>", "<2nd user id>", "<3rd user id>"]) 47 | # Will change the title of the group to `` 48 | thread.set_title("<title>") 49 | 50 | 51 | # Will change the nickname of the user `<user id>` to `<new nickname>` 52 | thread.set_nickname(fbchat.User(session=session, id="<user id>"), "<new nickname>") 53 | 54 | # Will set the typing status of the thread 55 | thread.start_typing() 56 | 57 | # Will change the thread color to #0084ff 58 | thread.set_color("#0084ff") 59 | 60 | # Will change the thread emoji to `👍` 61 | thread.set_emoji("👍") 62 | 63 | message = fbchat.Message(thread=thread, id="<message id>") 64 | 65 | # Will react to a message with a 😍 emoji 66 | message.react("😍") 67 | -------------------------------------------------------------------------------- /fbchat/_models/_common.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | import enum 4 | from .._common import attrs_default 5 | from .. import _util 6 | 7 | from typing import Optional 8 | 9 | 10 | class ThreadLocation(enum.Enum): 11 | """Used to specify where a thread is located (inbox, pending, archived, other).""" 12 | 13 | INBOX = "INBOX" 14 | PENDING = "PENDING" 15 | ARCHIVED = "ARCHIVED" 16 | OTHER = "OTHER" 17 | 18 | @classmethod 19 | def _parse(cls, value: str): 20 | return cls(value.lstrip("FOLDER_")) 21 | 22 | 23 | @attrs_default 24 | class ActiveStatus: 25 | #: Whether the user is active now 26 | active = attr.ib(type=bool) 27 | #: When the user was last active 28 | last_active = attr.ib(None, type=Optional[datetime.datetime]) 29 | #: Whether the user is playing Messenger game now 30 | in_game = attr.ib(None, type=Optional[bool]) 31 | 32 | @classmethod 33 | def _from_orca_presence(cls, data): 34 | # TODO: Handle `c` and `vc` keys (Probably some binary data) 35 | return cls( 36 | active=data["p"] in [2, 3], 37 | last_active=_util.seconds_to_datetime(data["l"]) if "l" in data else None, 38 | in_game=None, 39 | ) 40 | 41 | 42 | @attrs_default 43 | class Image: 44 | #: URL to the image 45 | url = attr.ib(type=str) 46 | #: Width of the image 47 | width = attr.ib(None, type=Optional[int]) 48 | #: Height of the image 49 | height = attr.ib(None, type=Optional[int]) 50 | 51 | @classmethod 52 | def _from_uri(cls, data): 53 | return cls( 54 | url=data["uri"], 55 | width=int(data["width"]) if data.get("width") else None, 56 | height=int(data["height"]) if data.get("height") else None, 57 | ) 58 | 59 | @classmethod 60 | def _from_url(cls, data): 61 | return cls( 62 | url=data["url"], 63 | width=int(data["width"]) if data.get("width") else None, 64 | height=int(data["height"]) if data.get("height") else None, 65 | ) 66 | 67 | @classmethod 68 | def _from_uri_or_none(cls, data): 69 | if data is None: 70 | return None 71 | if data.get("uri") is None: 72 | return None 73 | return cls._from_uri(data) 74 | 75 | @classmethod 76 | def _from_url_or_none(cls, data): 77 | if data is None: 78 | return None 79 | if data.get("url") is None: 80 | return None 81 | return cls._from_url(data) 82 | -------------------------------------------------------------------------------- /examples/fetch.py: -------------------------------------------------------------------------------- 1 | import fbchat 2 | 3 | session = fbchat.Session.login("<email>", "<password>") 4 | 5 | client = fbchat.Client(session=session) 6 | 7 | # Fetches a list of all users you're currently chatting with, as `User` objects 8 | users = client.fetch_all_users() 9 | 10 | print("users' IDs: {}".format([user.id for user in users])) 11 | print("users' names: {}".format([user.name for user in users])) 12 | 13 | 14 | # If we have a user id, we can use `fetch_user_info` to fetch a `User` object 15 | user = client.fetch_user_info("<user id>")["<user id>"] 16 | # We can also query both mutiple users together, which returns list of `User` objects 17 | users = client.fetch_user_info("<1st user id>", "<2nd user id>", "<3rd user id>") 18 | 19 | print("user's name: {}".format(user.name)) 20 | print("users' names: {}".format([users[k].name for k in users])) 21 | 22 | 23 | # `search_for_users` searches for the user and gives us a list of the results, 24 | # and then we just take the first one, aka. the most likely one: 25 | user = client.search_for_users("<name of user>")[0] 26 | 27 | print("user ID: {}".format(user.id)) 28 | print("user's name: {}".format(user.name)) 29 | print("user's photo: {}".format(user.photo)) 30 | print("Is user client's friend: {}".format(user.is_friend)) 31 | 32 | 33 | # Fetches a list of the 20 top threads you're currently chatting with 34 | threads = client.fetch_thread_list() 35 | # Fetches the next 10 threads 36 | threads += client.fetch_thread_list(offset=20, limit=10) 37 | 38 | print("Threads: {}".format(threads)) 39 | 40 | 41 | # If we have a thread id, we can use `fetch_thread_info` to fetch a `Thread` object 42 | thread = client.fetch_thread_info("<thread id>")["<thread id>"] 43 | print("thread's name: {}".format(thread.name)) 44 | 45 | 46 | # Gets the last 10 messages sent to the thread 47 | messages = thread.fetch_messages(limit=10) 48 | # Since the message come in reversed order, reverse them 49 | messages.reverse() 50 | 51 | # Prints the content of all the messages 52 | for message in messages: 53 | print(message.text) 54 | 55 | 56 | # `search_for_threads` searches works like `search_for_users`, but gives us a list of threads instead 57 | thread = client.search_for_threads("<name of thread>")[0] 58 | print("thread's name: {}".format(thread.name)) 59 | 60 | 61 | # Here should be an example of `getUnread` 62 | 63 | 64 | # Print image url for up to 20 last images from thread. 65 | images = list(thread.fetch_images(limit=20)) 66 | for image in images: 67 | if isinstance(image, fbchat.ImageAttachment): 68 | url = client.fetch_image_url(image.id) 69 | print(url) 70 | -------------------------------------------------------------------------------- /fbchat/_models/_quick_reply.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from . import Attachment 3 | from .._common import attrs_default 4 | 5 | from typing import Any, Optional 6 | 7 | 8 | @attrs_default 9 | class QuickReply: 10 | """Represents a quick reply.""" 11 | 12 | #: Payload of the quick reply 13 | payload = attr.ib(None, type=Any) 14 | #: External payload for responses 15 | external_payload = attr.ib(None, type=Any) 16 | #: Additional data 17 | data = attr.ib(None, type=Any) 18 | #: Whether it's a response for a quick reply 19 | is_response = attr.ib(False, type=bool) 20 | 21 | 22 | @attrs_default 23 | class QuickReplyText(QuickReply): 24 | """Represents a text quick reply.""" 25 | 26 | #: Title of the quick reply 27 | title = attr.ib(None, type=Optional[str]) 28 | #: URL of the quick reply image 29 | image_url = attr.ib(None, type=Optional[str]) 30 | #: Type of the quick reply 31 | _type = "text" 32 | 33 | 34 | @attrs_default 35 | class QuickReplyLocation(QuickReply): 36 | """Represents a location quick reply (Doesn't work on mobile).""" 37 | 38 | #: Type of the quick reply 39 | _type = "location" 40 | 41 | 42 | @attrs_default 43 | class QuickReplyPhoneNumber(QuickReply): 44 | """Represents a phone number quick reply (Doesn't work on mobile).""" 45 | 46 | #: URL of the quick reply image 47 | image_url = attr.ib(None, type=Optional[str]) 48 | #: Type of the quick reply 49 | _type = "user_phone_number" 50 | 51 | 52 | @attrs_default 53 | class QuickReplyEmail(QuickReply): 54 | """Represents an email quick reply (Doesn't work on mobile).""" 55 | 56 | #: URL of the quick reply image 57 | image_url = attr.ib(None, type=Optional[str]) 58 | #: Type of the quick reply 59 | _type = "user_email" 60 | 61 | 62 | def graphql_to_quick_reply(q, is_response=False): 63 | data = dict() 64 | _type = q.get("content_type").lower() 65 | if q.get("payload"): 66 | data["payload"] = q["payload"] 67 | if q.get("data"): 68 | data["data"] = q["data"] 69 | if q.get("image_url") and _type is not QuickReplyLocation._type: 70 | data["image_url"] = q["image_url"] 71 | data["is_response"] = is_response 72 | if _type == QuickReplyText._type: 73 | if q.get("title") is not None: 74 | data["title"] = q["title"] 75 | rtn = QuickReplyText(**data) 76 | elif _type == QuickReplyLocation._type: 77 | rtn = QuickReplyLocation(**data) 78 | elif _type == QuickReplyPhoneNumber._type: 79 | rtn = QuickReplyPhoneNumber(**data) 80 | elif _type == QuickReplyEmail._type: 81 | rtn = QuickReplyEmail(**data) 82 | return rtn 83 | -------------------------------------------------------------------------------- /tests/events/test_common.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import datetime 3 | from fbchat import Group, User, ParseError, Event, ThreadEvent 4 | 5 | 6 | def test_event_get_thread_group1(session): 7 | data = { 8 | "threadKey": {"threadFbId": 1234}, 9 | "messageId": "mid.$gAAT4Sw1WSGh14A3MOFvrsiDvr3Yc", 10 | "offlineThreadingId": "6623583531508397596", 11 | "actorFbId": 4321, 12 | "timestamp": 1500000000000, 13 | "tags": [ 14 | "inbox", 15 | "sent", 16 | "tq", 17 | "blindly_apply_message_folder", 18 | "source:messenger:web", 19 | ], 20 | } 21 | assert Group(session=session, id="1234") == Event._get_thread(session, data) 22 | 23 | 24 | def test_event_get_thread_group2(session): 25 | data = { 26 | "actorFbId": "4321", 27 | "folderId": {"systemFolderId": "INBOX"}, 28 | "messageId": "mid.$XYZ", 29 | "offlineThreadingId": "112233445566", 30 | "skipBumpThread": False, 31 | "tags": ["source:messenger:web"], 32 | "threadKey": {"threadFbId": "1234"}, 33 | "threadReadStateEffect": "KEEP_AS_IS", 34 | "timestamp": "1500000000000", 35 | } 36 | assert Group(session=session, id="1234") == Event._get_thread(session, data) 37 | 38 | 39 | def test_event_get_thread_user(session): 40 | data = { 41 | "actorFbId": "4321", 42 | "folderId": {"systemFolderId": "INBOX"}, 43 | "messageId": "mid.$XYZ", 44 | "offlineThreadingId": "112233445566", 45 | "skipBumpThread": False, 46 | "skipSnippetUpdate": False, 47 | "tags": ["source:messenger:web"], 48 | "threadKey": {"otherUserFbId": "1234"}, 49 | "threadReadStateEffect": "KEEP_AS_IS", 50 | "timestamp": "1500000000000", 51 | } 52 | assert User(session=session, id="1234") == Event._get_thread(session, data) 53 | 54 | 55 | def test_event_get_thread_unknown(session): 56 | data = {"threadKey": {"abc": "1234"}} 57 | with pytest.raises(ParseError, match="Could not find thread data"): 58 | Event._get_thread(session, data) 59 | 60 | 61 | def test_thread_event_parse_metadata(session): 62 | data = { 63 | "actorFbId": "4321", 64 | "folderId": {"systemFolderId": "INBOX"}, 65 | "messageId": "mid.$XYZ", 66 | "offlineThreadingId": "112233445566", 67 | "skipBumpThread": False, 68 | "skipSnippetUpdate": False, 69 | "tags": ["source:messenger:web"], 70 | "threadKey": {"otherUserFbId": "1234"}, 71 | "threadReadStateEffect": "KEEP_AS_IS", 72 | "timestamp": "1500000000000", 73 | } 74 | assert ( 75 | User(session=session, id="4321"), 76 | User(session=session, id="1234"), 77 | datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), 78 | ) == ThreadEvent._parse_metadata(session, {"messageMetadata": data}) 79 | -------------------------------------------------------------------------------- /fbchat/_threads/_page.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | from ._abc import ThreadABC 4 | from .._common import attrs_default 5 | from .. import _session, _models 6 | 7 | from typing import Optional 8 | 9 | 10 | @attrs_default 11 | class Page(ThreadABC): 12 | """Represents a Facebook page. Implements `ThreadABC`. 13 | 14 | Example: 15 | >>> page = fbchat.Page(session=session, id="1234") 16 | """ 17 | 18 | # TODO: Implement pages properly, the implementation is lacking in a lot of places! 19 | 20 | #: The session to use when making requests. 21 | session = attr.ib(type=_session.Session) 22 | #: The unique identifier of the page. 23 | id = attr.ib(converter=str, type=str) 24 | 25 | def _to_send_data(self): 26 | return {"other_user_fbid": self.id} 27 | 28 | def _copy(self) -> "Page": 29 | return Page(session=self.session, id=self.id) 30 | 31 | 32 | @attrs_default 33 | class PageData(Page): 34 | """Represents data about a Facebook page. 35 | 36 | Inherits `Page`, and implements `ThreadABC`. 37 | """ 38 | 39 | #: The page's picture 40 | photo = attr.ib(type=_models.Image) 41 | #: The name of the page 42 | name = attr.ib(type=str) 43 | #: When the thread was last active / when the last message was sent 44 | last_active = attr.ib(None, type=Optional[datetime.datetime]) 45 | #: Number of messages in the thread 46 | message_count = attr.ib(None, type=Optional[int]) 47 | #: Set `Plan` 48 | plan = attr.ib(None, type=Optional[_models.PlanData]) 49 | #: The page's custom URL 50 | url = attr.ib(None, type=Optional[str]) 51 | #: The name of the page's location city 52 | city = attr.ib(None, type=Optional[str]) 53 | #: Amount of likes the page has 54 | likes = attr.ib(None, type=Optional[int]) 55 | #: Some extra information about the page 56 | sub_title = attr.ib(None, type=Optional[str]) 57 | #: The page's category 58 | category = attr.ib(None, type=Optional[str]) 59 | 60 | @classmethod 61 | def _from_graphql(cls, session, data): 62 | if data.get("profile_picture") is None: 63 | data["profile_picture"] = {} 64 | if data.get("city") is None: 65 | data["city"] = {} 66 | plan = None 67 | if data.get("event_reminders") and data["event_reminders"].get("nodes"): 68 | plan = _models.PlanData._from_graphql( 69 | session, data["event_reminders"]["nodes"][0] 70 | ) 71 | 72 | return cls( 73 | session=session, 74 | id=data["id"], 75 | url=data.get("url"), 76 | city=data.get("city").get("name"), 77 | category=data.get("category_type"), 78 | photo=_models.Image._from_uri(data["profile_picture"]), 79 | name=data["name"], 80 | message_count=data.get("messages_count"), 81 | plan=plan, 82 | ) 83 | -------------------------------------------------------------------------------- /fbchat/_models/_attachment.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from . import Image 3 | from .._common import attrs_default 4 | from .. import _util 5 | 6 | from typing import Optional, Sequence 7 | 8 | 9 | @attrs_default 10 | class Attachment: 11 | """Represents a Facebook attachment.""" 12 | 13 | #: The attachment ID 14 | id = attr.ib(None, type=Optional[str]) 15 | 16 | 17 | @attrs_default 18 | class UnsentMessage(Attachment): 19 | """Represents an unsent message attachment.""" 20 | 21 | 22 | @attrs_default 23 | class ShareAttachment(Attachment): 24 | """Represents a shared item (e.g. URL) attachment.""" 25 | 26 | #: ID of the author of the shared post 27 | author = attr.ib(None, type=Optional[str]) 28 | #: Target URL 29 | url = attr.ib(None, type=Optional[str]) 30 | #: Original URL if Facebook redirects the URL 31 | original_url = attr.ib(None, type=Optional[str]) 32 | #: Title of the attachment 33 | title = attr.ib(None, type=Optional[str]) 34 | #: Description of the attachment 35 | description = attr.ib(None, type=Optional[str]) 36 | #: Name of the source 37 | source = attr.ib(None, type=Optional[str]) 38 | #: The attached image 39 | image = attr.ib(None, type=Optional[Image]) 40 | #: URL of the original image if Facebook uses ``safe_image`` 41 | original_image_url = attr.ib(None, type=Optional[str]) 42 | #: List of additional attachments 43 | attachments = attr.ib(factory=list, type=Sequence[Attachment]) 44 | 45 | @classmethod 46 | def _from_graphql(cls, data): 47 | from . import _file 48 | 49 | image = None 50 | original_image_url = None 51 | media = data.get("media") 52 | if media and media.get("image"): 53 | image = Image._from_uri(media["image"]) 54 | original_image_url = ( 55 | _util.get_url_parameter(image.url, "url") 56 | if "/safe_image.php" in image.url 57 | else image.url 58 | ) 59 | 60 | url = data.get("url") 61 | return cls( 62 | id=data.get("deduplication_key"), 63 | author=data["target"]["actors"][0]["id"] 64 | if data["target"].get("actors") 65 | else None, 66 | url=url, 67 | original_url=_util.get_url_parameter(url, "u") 68 | if "/l.php?u=" in url 69 | else url, 70 | title=data["title_with_entities"].get("text"), 71 | description=data["description"].get("text") 72 | if data.get("description") 73 | else None, 74 | source=data["source"].get("text") if data.get("source") else None, 75 | image=image, 76 | original_image_url=original_image_url, 77 | attachments=[ 78 | _file.graphql_to_subattachment(attachment) 79 | for attachment in data.get("subattachments") 80 | ], 81 | ) 82 | -------------------------------------------------------------------------------- /tests/models/test_poll.py: -------------------------------------------------------------------------------- 1 | from fbchat import Poll, PollOption 2 | 3 | 4 | def test_poll_option_from_graphql_unvoted(): 5 | data = { 6 | "id": "123456789", 7 | "text": "abc", 8 | "total_count": 0, 9 | "viewer_has_voted": "false", 10 | "voters": [], 11 | } 12 | assert PollOption( 13 | text="abc", vote=False, voters=[], votes_count=0, id="123456789" 14 | ) == PollOption._from_graphql(data) 15 | 16 | 17 | def test_poll_option_from_graphql_voted(): 18 | data = { 19 | "id": "123456789", 20 | "text": "abc", 21 | "total_count": 2, 22 | "viewer_has_voted": "true", 23 | "voters": ["1234", "2345"], 24 | } 25 | assert PollOption( 26 | text="abc", vote=True, voters=["1234", "2345"], votes_count=2, id="123456789" 27 | ) == PollOption._from_graphql(data) 28 | 29 | 30 | def test_poll_option_from_graphql_alternate_format(): 31 | # Format received when fetching poll options 32 | data = { 33 | "id": "123456789", 34 | "text": "abc", 35 | "viewer_has_voted": True, 36 | "voters": { 37 | "count": 2, 38 | "edges": [{"node": {"id": "1234"}}, {"node": {"id": "2345"}}], 39 | }, 40 | } 41 | assert PollOption( 42 | text="abc", vote=True, voters=["1234", "2345"], votes_count=2, id="123456789" 43 | ) == PollOption._from_graphql(data) 44 | 45 | 46 | def test_poll_from_graphql(session): 47 | data = { 48 | "id": "123456789", 49 | "text": "Some poll", 50 | "total_count": 5, 51 | "viewer_has_voted": "true", 52 | "options": [ 53 | { 54 | "id": "1111", 55 | "text": "Abc", 56 | "total_count": 1, 57 | "viewer_has_voted": "true", 58 | "voters": ["1234"], 59 | }, 60 | { 61 | "id": "2222", 62 | "text": "Def", 63 | "total_count": 2, 64 | "viewer_has_voted": "false", 65 | "voters": ["2345", "3456"], 66 | }, 67 | { 68 | "id": "3333", 69 | "text": "Ghi", 70 | "total_count": 0, 71 | "viewer_has_voted": "false", 72 | "voters": [], 73 | }, 74 | ], 75 | } 76 | assert Poll( 77 | session=session, 78 | question="Some poll", 79 | options=[ 80 | PollOption( 81 | text="Abc", vote=True, voters=["1234"], votes_count=1, id="1111" 82 | ), 83 | PollOption( 84 | text="Def", 85 | vote=False, 86 | voters=["2345", "3456"], 87 | votes_count=2, 88 | id="2222", 89 | ), 90 | PollOption(text="Ghi", vote=False, voters=[], votes_count=0, id="3333"), 91 | ], 92 | options_count=5, 93 | id=123456789, 94 | ) == Poll._from_graphql(session, data) 95 | -------------------------------------------------------------------------------- /fbchat/__init__.py: -------------------------------------------------------------------------------- 1 | """Facebook Messenger for Python. 2 | 3 | Copyright: 4 | (c) 2015 - 2018 by Taehoon Kim 5 | (c) 2018 - 2020 by Mads Marquart 6 | 7 | License: 8 | BSD 3-Clause, see LICENSE for more details. 9 | """ 10 | 11 | import logging as _logging 12 | 13 | # Set default logging handler to avoid "No handler found" warnings. 14 | _logging.getLogger(__name__).addHandler(_logging.NullHandler()) 15 | 16 | # The order of these is somewhat significant, e.g. User has to be imported after Thread! 17 | from . import _common, _util 18 | from ._exception import ( 19 | FacebookError, 20 | HTTPError, 21 | ParseError, 22 | ExternalError, 23 | GraphQLError, 24 | InvalidParameters, 25 | NotLoggedIn, 26 | PleaseRefresh, 27 | ) 28 | from ._session import Session 29 | from ._threads import ( 30 | ThreadABC, 31 | Thread, 32 | User, 33 | UserData, 34 | Group, 35 | GroupData, 36 | Page, 37 | PageData, 38 | ) 39 | 40 | # Models 41 | from ._models import ( 42 | Image, 43 | ThreadLocation, 44 | ActiveStatus, 45 | Attachment, 46 | UnsentMessage, 47 | ShareAttachment, 48 | LocationAttachment, 49 | LiveLocationAttachment, 50 | Sticker, 51 | FileAttachment, 52 | AudioAttachment, 53 | ImageAttachment, 54 | VideoAttachment, 55 | Poll, 56 | PollOption, 57 | GuestStatus, 58 | Plan, 59 | PlanData, 60 | QuickReply, 61 | QuickReplyText, 62 | QuickReplyLocation, 63 | QuickReplyPhoneNumber, 64 | QuickReplyEmail, 65 | EmojiSize, 66 | Mention, 67 | Message, 68 | MessageSnippet, 69 | MessageData, 70 | ) 71 | 72 | # Events 73 | from ._events import ( 74 | # _common 75 | Event, 76 | UnknownEvent, 77 | ThreadEvent, 78 | Connect, 79 | Disconnect, 80 | # _client_payload 81 | ReactionEvent, 82 | UserStatusEvent, 83 | LiveLocationEvent, 84 | UnsendEvent, 85 | MessageReplyEvent, 86 | # _delta_class 87 | PeopleAdded, 88 | PersonRemoved, 89 | TitleSet, 90 | UnfetchedThreadEvent, 91 | MessagesDelivered, 92 | ThreadsRead, 93 | MessageEvent, 94 | ThreadFolder, 95 | # _delta_type 96 | ColorSet, 97 | EmojiSet, 98 | NicknameSet, 99 | AdminsAdded, 100 | AdminsRemoved, 101 | ApprovalModeSet, 102 | CallStarted, 103 | CallEnded, 104 | CallJoined, 105 | PollCreated, 106 | PollVoted, 107 | PlanCreated, 108 | PlanEnded, 109 | PlanEdited, 110 | PlanDeleted, 111 | PlanResponded, 112 | # __init__ 113 | Typing, 114 | FriendRequest, 115 | Presence, 116 | ) 117 | from ._listen import Listener 118 | 119 | from ._client import Client 120 | 121 | __version__ = "2.0.0a5" 122 | 123 | __all__ = ("Session", "Listener", "Client") 124 | 125 | 126 | from . import _fix_module_metadata 127 | 128 | _fix_module_metadata.fixup_module_metadata(globals()) 129 | del _fix_module_metadata 130 | -------------------------------------------------------------------------------- /tests/models/test_sticker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import fbchat 3 | from fbchat import Image, Sticker 4 | 5 | 6 | def test_from_graphql_none(): 7 | assert None == Sticker._from_graphql(None) 8 | 9 | 10 | def test_from_graphql_minimal(): 11 | assert Sticker(id=1) == Sticker._from_graphql({"id": 1}) 12 | 13 | 14 | def test_from_graphql_normal(): 15 | assert Sticker( 16 | id="369239383222810", 17 | pack="227877430692340", 18 | is_animated=False, 19 | frames_per_row=1, 20 | frames_per_col=1, 21 | frame_count=1, 22 | frame_rate=83, 23 | image=Image( 24 | url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png", 25 | width=274, 26 | height=274, 27 | ), 28 | label="Like, thumbs up", 29 | ) == Sticker._from_graphql( 30 | { 31 | "id": "369239383222810", 32 | "pack": {"id": "227877430692340"}, 33 | "label": "Like, thumbs up", 34 | "frame_count": 1, 35 | "frame_rate": 83, 36 | "frames_per_row": 1, 37 | "frames_per_column": 1, 38 | "sprite_image_2x": None, 39 | "sprite_image": None, 40 | "padded_sprite_image": None, 41 | "padded_sprite_image_2x": None, 42 | "url": "https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png", 43 | "height": 274, 44 | "width": 274, 45 | } 46 | ) 47 | 48 | 49 | def test_from_graphql_animated(): 50 | assert Sticker( 51 | id="144885035685763", 52 | pack="350357561732812", 53 | is_animated=True, 54 | medium_sprite_image="https://scontent-arn2-1.xx.fbcdn.net/v/redacted2.png", 55 | large_sprite_image="https://scontent-arn2-1.fbcdn.net/v/redacted3.png", 56 | frames_per_row=2, 57 | frames_per_col=2, 58 | frame_count=4, 59 | frame_rate=142, 60 | image=Image( 61 | url="https://scontent-arn2-1.fbcdn.net/v/redacted1.png", 62 | width=240, 63 | height=293, 64 | ), 65 | label="Love, cat with heart", 66 | ) == Sticker._from_graphql( 67 | { 68 | "id": "144885035685763", 69 | "pack": {"id": "350357561732812"}, 70 | "label": "Love, cat with heart", 71 | "frame_count": 4, 72 | "frame_rate": 142, 73 | "frames_per_row": 2, 74 | "frames_per_column": 2, 75 | "sprite_image_2x": { 76 | "uri": "https://scontent-arn2-1.fbcdn.net/v/redacted3.png" 77 | }, 78 | "sprite_image": { 79 | "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/redacted2.png" 80 | }, 81 | "padded_sprite_image": { 82 | "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/unused1.png" 83 | }, 84 | "padded_sprite_image_2x": { 85 | "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/unused2.png" 86 | }, 87 | "url": "https://scontent-arn2-1.fbcdn.net/v/redacted1.png", 88 | "height": 293, 89 | "width": 240, 90 | } 91 | ) 92 | -------------------------------------------------------------------------------- /tests/threads/test_thread.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import fbchat 3 | from fbchat import ThreadABC, Thread, User, Group, Page 4 | 5 | 6 | def test_parse_color(): 7 | assert "#0084ff" == ThreadABC._parse_color(None) 8 | assert "#0084ff" == ThreadABC._parse_color("") 9 | assert "#44bec7" == ThreadABC._parse_color("FF44BEC7") 10 | assert "#adbeef" == ThreadABC._parse_color("DEADBEEF") 11 | 12 | 13 | def test_thread_parse_customization_info_empty(): 14 | default = {"color": "#0084ff", "emoji": None} 15 | assert default == ThreadABC._parse_customization_info(None) 16 | assert default == ThreadABC._parse_customization_info({"customization_info": None}) 17 | 18 | 19 | def test_thread_parse_customization_info_group(): 20 | data = { 21 | "thread_key": {"thread_fbid": "11111", "other_user_id": None}, 22 | "customization_info": { 23 | "emoji": "🎉", 24 | "participant_customizations": [ 25 | {"participant_id": "123456789", "nickname": "A"}, 26 | {"participant_id": "987654321", "nickname": "B"}, 27 | ], 28 | "outgoing_bubble_color": "FFFF5CA1", 29 | }, 30 | "customization_enabled": True, 31 | "thread_type": "GROUP", 32 | # ... Other irrelevant fields 33 | } 34 | expected = { 35 | "emoji": "🎉", 36 | "color": "#ff5ca1", 37 | "nicknames": {"123456789": "A", "987654321": "B"}, 38 | } 39 | assert expected == ThreadABC._parse_customization_info(data) 40 | 41 | 42 | def test_thread_parse_customization_info_user(): 43 | data = { 44 | "thread_key": {"thread_fbid": None, "other_user_id": "987654321"}, 45 | "customization_info": { 46 | "emoji": None, 47 | "participant_customizations": [ 48 | {"participant_id": "123456789", "nickname": "A"}, 49 | {"participant_id": "987654321", "nickname": "B"}, 50 | ], 51 | "outgoing_bubble_color": None, 52 | }, 53 | "customization_enabled": True, 54 | "thread_type": "ONE_TO_ONE", 55 | # ... Other irrelevant fields 56 | } 57 | expected = {"emoji": None, "color": "#0084ff", "own_nickname": "A", "nickname": "B"} 58 | assert expected == ThreadABC._parse_customization_info(data) 59 | 60 | 61 | def test_thread_parse_participants(session): 62 | nodes = [ 63 | {"messaging_actor": {"__typename": "User", "id": "1234"}}, 64 | {"messaging_actor": {"__typename": "User", "id": "2345"}}, 65 | {"messaging_actor": {"__typename": "Page", "id": "3456"}}, 66 | {"messaging_actor": {"__typename": "MessageThread", "id": "4567"}}, 67 | {"messaging_actor": {"__typename": "UnavailableMessagingActor", "id": "5678"}}, 68 | ] 69 | assert [ 70 | User(session=session, id="1234"), 71 | User(session=session, id="2345"), 72 | Page(session=session, id="3456"), 73 | Group(session=session, id="4567"), 74 | ] == list(ThreadABC._parse_participants(session, {"nodes": nodes})) 75 | 76 | 77 | def test_thread_create_and_implements_thread_abc(session): 78 | thread = Thread(session=session, id="123") 79 | assert thread._parse_customization_info 80 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | 3 | Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | 18 | * Using welcoming and inclusive language 19 | * Being respectful of differing viewpoints and experiences 20 | * Gracefully accepting constructive criticism 21 | * Focusing on what is best for the community 22 | * Showing empathy towards other community members 23 | 24 | 25 | Examples of unacceptable behavior by participants include: 26 | 27 | 28 | * The use of sexualized language or imagery and unwelcome sexual attention or 29 | advances 30 | * Trolling, insulting/derogatory comments, and personal or political attacks 31 | * Public or private harassment 32 | * Publishing others’ private information, such as a physical or electronic 33 | address, without explicit permission 34 | * Other conduct which could reasonably be considered inappropriate in a 35 | professional setting 36 | 37 | 38 | Our Responsibilities 39 | 40 | Project maintainers are responsible for clarifying the standards of acceptable 41 | behavior and are expected to take appropriate and fair corrective action in 42 | response to any instances of unacceptable behavior. 43 | 44 | Project maintainers have the right and responsibility to remove, edit, or 45 | reject comments, commits, code, wiki edits, issues, and other contributions 46 | that are not aligned to this Code of Conduct, or to ban temporarily or 47 | permanently any contributor for other behaviors that they deem inappropriate, 48 | threatening, offensive, or harmful. 49 | 50 | Scope 51 | 52 | This Code of Conduct applies both within project spaces and in public spaces 53 | when an individual is representing the project or its community. Examples of 54 | representing a project or community include using an official project e-mail 55 | address, posting via an official social media account, or acting as an appointed 56 | representative at an online or offline event. Representation of a project may be 57 | further defined and clarified by project maintainers. 58 | 59 | Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported by contacting the project team at carpedm20@gmail.com. All 63 | complaints will be reviewed and investigated and will result in a response that 64 | is deemed necessary and appropriate to the circumstances. The project team is 65 | obligated to maintain confidentiality with regard to the reporter of an incident. 66 | Further details of specific enforcement policies may be posted separately. 67 | 68 | Project maintainers who do not follow or enforce the Code of Conduct in good 69 | faith may face temporary or permanent repercussions as determined by other 70 | members of the project’s leadership. 71 | 72 | Attribution 73 | 74 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, 75 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 76 | -------------------------------------------------------------------------------- /examples/keepbot.py: -------------------------------------------------------------------------------- 1 | # This example uses the `blinker` library to dispatch events. See echobot.py for how 2 | # this could be done differenly. The decision is entirely up to you! 3 | import fbchat 4 | import blinker 5 | 6 | # Change this to your group id 7 | old_thread_id = "1234567890" 8 | 9 | # Change these to match your liking 10 | old_color = "#0084ff" 11 | old_emoji = "👍" 12 | old_title = "Old group chat name" 13 | old_nicknames = { 14 | "12345678901": "User nr. 1's nickname", 15 | "12345678902": "User nr. 2's nickname", 16 | "12345678903": "User nr. 3's nickname", 17 | "12345678904": "User nr. 4's nickname", 18 | } 19 | 20 | # Create a blinker signal 21 | events = blinker.Signal() 22 | 23 | # Register various event handlers on the signal 24 | @events.connect_via(fbchat.ColorSet) 25 | def on_color_set(sender, event: fbchat.ColorSet): 26 | if old_thread_id != event.thread.id: 27 | return 28 | if old_color != event.color: 29 | print(f"{event.author.id} changed the thread color. It will be changed back") 30 | event.thread.set_color(old_color) 31 | 32 | 33 | @events.connect_via(fbchat.EmojiSet) 34 | def on_emoji_set(sender, event: fbchat.EmojiSet): 35 | if old_thread_id != event.thread.id: 36 | return 37 | if old_emoji != event.emoji: 38 | print(f"{event.author.id} changed the thread emoji. It will be changed back") 39 | event.thread.set_emoji(old_emoji) 40 | 41 | 42 | @events.connect_via(fbchat.TitleSet) 43 | def on_title_set(sender, event: fbchat.TitleSet): 44 | if old_thread_id != event.thread.id: 45 | return 46 | if old_title != event.title: 47 | print(f"{event.author.id} changed the thread title. It will be changed back") 48 | event.thread.set_title(old_title) 49 | 50 | 51 | @events.connect_via(fbchat.NicknameSet) 52 | def on_nickname_set(sender, event: fbchat.NicknameSet): 53 | if old_thread_id != event.thread.id: 54 | return 55 | old_nickname = old_nicknames.get(event.subject.id) 56 | if old_nickname != event.nickname: 57 | print( 58 | f"{event.author.id} changed {event.subject.id}'s' nickname." 59 | " It will be changed back" 60 | ) 61 | event.thread.set_nickname(event.subject.id, old_nickname) 62 | 63 | 64 | @events.connect_via(fbchat.PeopleAdded) 65 | def on_people_added(sender, event: fbchat.PeopleAdded): 66 | if old_thread_id != event.thread.id: 67 | return 68 | if event.author.id != session.user.id: 69 | print(f"{', '.join(x.id for x in event.added)} got added. They will be removed") 70 | for added in event.added: 71 | event.thread.remove_participant(added.id) 72 | 73 | 74 | @events.connect_via(fbchat.PersonRemoved) 75 | def on_person_removed(sender, event: fbchat.PersonRemoved): 76 | if old_thread_id != event.thread.id: 77 | return 78 | # No point in trying to add ourself 79 | if event.removed.id == session.user.id: 80 | return 81 | if event.author.id != session.user.id: 82 | print(f"{event.removed.id} got removed. They will be re-added") 83 | event.thread.add_participants([event.removed.id]) 84 | 85 | 86 | # Login, and start listening for events 87 | session = fbchat.Session.login("<email>", "<password>") 88 | listener = fbchat.Listener(session=session, chat_on=False, foreground=False) 89 | 90 | for event in listener.listen(): 91 | # Dispatch the event to the subscribed handlers 92 | events.send(type(event), event=event) 93 | -------------------------------------------------------------------------------- /tests/online/test_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import fbchat 3 | import os 4 | 5 | pytestmark = pytest.mark.online 6 | 7 | 8 | def test_fetch(client): 9 | client.fetch_users() 10 | 11 | 12 | def test_search_for_users(client): 13 | list(client.search_for_users("test", 10)) 14 | 15 | 16 | def test_search_for_pages(client): 17 | list(client.search_for_pages("test", 100)) 18 | 19 | 20 | def test_search_for_groups(client): 21 | list(client.search_for_groups("test", 1000)) 22 | 23 | 24 | def test_search_for_threads(client): 25 | list(client.search_for_threads("test", 1000)) 26 | 27 | with pytest.raises(fbchat.HTTPError, match="rate limited"): 28 | list(client.search_for_threads("test", 10000)) 29 | 30 | 31 | def test_message_search(client): 32 | list(client.search_messages("test", 500)) 33 | 34 | 35 | def test_fetch_thread_info(client): 36 | list(client.fetch_thread_info(["4"]))[0] 37 | 38 | 39 | def test_fetch_threads(client): 40 | list(client.fetch_threads(20)) 41 | list(client.fetch_threads(200)) 42 | 43 | 44 | def test_undocumented(client): 45 | client.fetch_unread() 46 | client.fetch_unseen() 47 | 48 | 49 | @pytest.fixture 50 | def open_resource(pytestconfig): 51 | def get_resource_inner(filename): 52 | path = os.path.join(pytestconfig.rootdir, "tests", "resources", filename) 53 | return open(path, "rb") 54 | 55 | return get_resource_inner 56 | 57 | 58 | def test_upload_and_fetch_image_url(client, open_resource): 59 | with open_resource("image.png") as f: 60 | ((id, mimetype),) = client.upload([("image.png", f, "image/png")]) 61 | assert mimetype == "image/png" 62 | 63 | assert client.fetch_image_url(id).startswith("http") 64 | 65 | 66 | def test_upload_image(client, open_resource): 67 | with open_resource("image.png") as f: 68 | _ = client.upload([("image.png", f, "image/png")]) 69 | 70 | 71 | def test_upload_many(client, open_resource): 72 | with open_resource("image.png") as f_png, open_resource( 73 | "image.jpg" 74 | ) as f_jpg, open_resource("image.gif") as f_gif, open_resource( 75 | "file.json" 76 | ) as f_json, open_resource( 77 | "file.txt" 78 | ) as f_txt, open_resource( 79 | "audio.mp3" 80 | ) as f_mp3, open_resource( 81 | "video.mp4" 82 | ) as f_mp4: 83 | _ = client.upload( 84 | [ 85 | ("image.png", f_png, "image/png"), 86 | ("image.jpg", f_jpg, "image/jpeg"), 87 | ("image.gif", f_gif, "image/gif"), 88 | ("file.json", f_json, "application/json"), 89 | ("file.txt", f_txt, "text/plain"), 90 | ("audio.mp3", f_mp3, "audio/mpeg"), 91 | ("video.mp4", f_mp4, "video/mp4"), 92 | ] 93 | ) 94 | 95 | 96 | def test_mark_as_read(client, user, group): 97 | client.mark_as_read([user, group], fbchat._util.now()) 98 | 99 | 100 | def test_mark_as_unread(client, user, group): 101 | client.mark_as_unread([user, group], fbchat._util.now()) 102 | 103 | 104 | def test_move_threads(client, user, group): 105 | client.move_threads(fbchat.ThreadLocation.PENDING, [user, group]) 106 | client.move_threads(fbchat.ThreadLocation.INBOX, [user, group]) 107 | 108 | 109 | @pytest.mark.skip(reason="need to have threads to delete") 110 | def test_delete_threads(): 111 | pass 112 | 113 | 114 | @pytest.mark.skip(reason="need to have messages to delete") 115 | def test_delete_messages(): 116 | pass 117 | -------------------------------------------------------------------------------- /fbchat/_models/_location.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | from . import Image, Attachment 4 | from .._common import attrs_default 5 | from .. import _util, _exception 6 | 7 | from typing import Optional 8 | 9 | 10 | @attrs_default 11 | class LocationAttachment(Attachment): 12 | """Represents a user location. 13 | 14 | Latitude and longitude OR address is provided by Facebook. 15 | """ 16 | 17 | #: Latitude of the location 18 | latitude = attr.ib(None, type=Optional[float]) 19 | #: Longitude of the location 20 | longitude = attr.ib(None, type=Optional[float]) 21 | #: Image showing the map of the location 22 | image = attr.ib(None, type=Optional[Image]) 23 | #: URL to Bing maps with the location 24 | url = attr.ib(None, type=Optional[str]) 25 | # Address of the location 26 | address = attr.ib(None, type=Optional[str]) 27 | 28 | @classmethod 29 | def _from_graphql(cls, data): 30 | url = data.get("url") 31 | address = _util.get_url_parameter(_util.get_url_parameter(url, "u"), "where1") 32 | if not address: 33 | raise _exception.ParseError("Could not find location address", data=data) 34 | try: 35 | latitude, longitude = [float(x) for x in address.split(", ")] 36 | address = None 37 | except ValueError: 38 | latitude, longitude = None, None 39 | 40 | return cls( 41 | id=int(data["deduplication_key"]), 42 | latitude=latitude, 43 | longitude=longitude, 44 | image=Image._from_uri_or_none(data["media"].get("image")) 45 | if data.get("media") 46 | else None, 47 | url=url, 48 | address=address, 49 | ) 50 | 51 | 52 | @attrs_default 53 | class LiveLocationAttachment(LocationAttachment): 54 | """Represents a live user location.""" 55 | 56 | #: Name of the location 57 | name = attr.ib(None, type=Optional[str]) 58 | #: When live location expires 59 | expires_at = attr.ib(None, type=Optional[datetime.datetime]) 60 | #: True if live location is expired 61 | is_expired = attr.ib(None, type=Optional[bool]) 62 | 63 | @classmethod 64 | def _from_pull(cls, data): 65 | return cls( 66 | id=data["id"], 67 | latitude=data["coordinate"]["latitude"] / (10 ** 8) 68 | if not data.get("stopReason") 69 | else None, 70 | longitude=data["coordinate"]["longitude"] / (10 ** 8) 71 | if not data.get("stopReason") 72 | else None, 73 | name=data.get("locationTitle"), 74 | expires_at=_util.millis_to_datetime(data["expirationTime"]), 75 | is_expired=bool(data.get("stopReason")), 76 | ) 77 | 78 | @classmethod 79 | def _from_graphql(cls, data): 80 | target = data["target"] 81 | 82 | image = None 83 | media = data.get("media") 84 | if media and media.get("image"): 85 | image = Image._from_uri(media["image"]) 86 | 87 | return cls( 88 | id=int(target["live_location_id"]), 89 | latitude=target["coordinate"]["latitude"] 90 | if target.get("coordinate") 91 | else None, 92 | longitude=target["coordinate"]["longitude"] 93 | if target.get("coordinate") 94 | else None, 95 | image=image, 96 | url=data.get("url"), 97 | name=data["title_with_entities"]["text"], 98 | expires_at=_util.seconds_to_datetime(target.get("expiration_time")), 99 | is_expired=target.get("is_expired"), 100 | ) 101 | -------------------------------------------------------------------------------- /tests/models/test_location.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import datetime 3 | import fbchat 4 | from fbchat import Image, LocationAttachment, LiveLocationAttachment 5 | 6 | 7 | def test_location_attachment_from_graphql(): 8 | data = { 9 | "description": {"text": ""}, 10 | "media": { 11 | "animated_image": None, 12 | "image": { 13 | "uri": "https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en", 14 | "height": 280, 15 | "width": 545, 16 | }, 17 | "playable_duration_in_ms": 0, 18 | "is_playable": False, 19 | "playable_url": None, 20 | }, 21 | "source": None, 22 | "style_list": ["message_location", "fallback"], 23 | "title_with_entities": {"text": "Your location"}, 24 | "properties": [ 25 | {"key": "width", "value": {"text": "545"}}, 26 | {"key": "height", "value": {"text": "280"}}, 27 | ], 28 | "url": "https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1", 29 | "deduplication_key": "400828513928715", 30 | "action_links": [], 31 | "messaging_attribution": None, 32 | "messenger_call_to_actions": [], 33 | "xma_layout_info": None, 34 | "target": {"__typename": "MessageLocation"}, 35 | "subattachments": [], 36 | } 37 | assert LocationAttachment( 38 | id=400828513928715, 39 | latitude=55.4, 40 | longitude=12.4322, 41 | image=Image( 42 | url="https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en", 43 | width=545, 44 | height=280, 45 | ), 46 | url="https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1", 47 | ) == LocationAttachment._from_graphql(data) 48 | 49 | 50 | @pytest.mark.skip(reason="need to gather test data") 51 | def test_live_location_from_pull(): 52 | data = ... 53 | assert LiveLocationAttachment(...) == LiveLocationAttachment._from_pull(data) 54 | 55 | 56 | def test_live_location_from_graphql_expired(): 57 | data = { 58 | "description": {"text": "Last update 4 Jan"}, 59 | "media": None, 60 | "source": None, 61 | "style_list": ["message_live_location", "fallback"], 62 | "title_with_entities": {"text": "Location-sharing ended"}, 63 | "properties": [], 64 | "url": "https://www.facebook.com/", 65 | "deduplication_key": "2254535444791641", 66 | "action_links": [], 67 | "messaging_attribution": None, 68 | "messenger_call_to_actions": [], 69 | "target": { 70 | "__typename": "MessageLiveLocation", 71 | "live_location_id": "2254535444791641", 72 | "is_expired": True, 73 | "expiration_time": 1546626345, 74 | "sender": {"id": "100007056224713"}, 75 | "coordinate": None, 76 | "location_title": None, 77 | "sender_destination": None, 78 | "stop_reason": "CANCELED", 79 | }, 80 | "subattachments": [], 81 | } 82 | assert LiveLocationAttachment( 83 | id=2254535444791641, 84 | name="Location-sharing ended", 85 | expires_at=datetime.datetime( 86 | 2019, 1, 4, 18, 25, 45, tzinfo=datetime.timezone.utc 87 | ), 88 | is_expired=True, 89 | url="https://www.facebook.com/", 90 | ) == LiveLocationAttachment._from_graphql(data) 91 | 92 | 93 | @pytest.mark.skip(reason="need to gather test data") 94 | def test_live_location_from_graphql(): 95 | data = ... 96 | assert LiveLocationAttachment(...) == LiveLocationAttachment._from_graphql(data) 97 | -------------------------------------------------------------------------------- /fbchat/_models/_poll.py: -------------------------------------------------------------------------------- 1 | import attr 2 | from .._common import attrs_default 3 | from .. import _exception, _session 4 | from typing import Iterable, Sequence 5 | 6 | 7 | @attrs_default 8 | class PollOption: 9 | """Represents a poll option.""" 10 | 11 | #: ID of the poll option 12 | id = attr.ib(converter=str, type=str) 13 | #: Text of the poll option 14 | text = attr.ib(type=str) 15 | #: Whether vote when creating or client voted 16 | vote = attr.ib(type=bool) 17 | #: ID of the users who voted for this poll option 18 | voters = attr.ib(type=Sequence[str]) 19 | #: Votes count 20 | votes_count = attr.ib(type=int) 21 | 22 | @classmethod 23 | def _from_graphql(cls, data): 24 | if data.get("viewer_has_voted") is None: 25 | vote = False 26 | elif isinstance(data["viewer_has_voted"], bool): 27 | vote = data["viewer_has_voted"] 28 | else: 29 | vote = data["viewer_has_voted"] == "true" 30 | return cls( 31 | id=int(data["id"]), 32 | text=data.get("text"), 33 | vote=vote, 34 | voters=( 35 | [m["node"]["id"] for m in data["voters"]["edges"]] 36 | if isinstance(data.get("voters"), dict) 37 | else data["voters"] 38 | ), 39 | votes_count=( 40 | data["voters"]["count"] 41 | if isinstance(data.get("voters"), dict) 42 | else data["total_count"] 43 | ), 44 | ) 45 | 46 | 47 | @attrs_default 48 | class Poll: 49 | """Represents a poll.""" 50 | 51 | #: ID of the poll 52 | session = attr.ib(type=_session.Session) 53 | #: ID of the poll 54 | id = attr.ib(converter=str, type=str) 55 | #: The poll's question 56 | question = attr.ib(type=str) 57 | #: The poll's top few options. The full list can be fetched with `fetch_options` 58 | options = attr.ib(type=Sequence[PollOption]) 59 | #: Options count 60 | options_count = attr.ib(type=int) 61 | 62 | @classmethod 63 | def _from_graphql(cls, session, data): 64 | return cls( 65 | session=session, 66 | id=data["id"], 67 | question=data["title"] if data.get("title") else data["text"], 68 | options=[PollOption._from_graphql(m) for m in data["options"]], 69 | options_count=data["total_count"], 70 | ) 71 | 72 | def fetch_options(self) -> Sequence[PollOption]: 73 | """Fetch all `PollOption` objects on the poll. 74 | 75 | The result is ordered with options with the most votes first. 76 | 77 | Example: 78 | >>> options = poll.fetch_options() 79 | >>> options[0].text 80 | "An option" 81 | """ 82 | data = {"question_id": self.id} 83 | j = self.session._payload_post("/ajax/mercury/get_poll_options", data) 84 | return [PollOption._from_graphql(m) for m in j] 85 | 86 | def set_votes(self, option_ids: Iterable[str], new_options: Iterable[str] = None): 87 | """Update the user's poll vote. 88 | 89 | Args: 90 | option_ids: Option ids to vote for / keep voting for 91 | new_options: New options to add 92 | 93 | Example: 94 | >>> options = poll.fetch_options() 95 | >>> # Add option 96 | >>> poll.set_votes([o.id for o in options], new_options=["New option"]) 97 | >>> # Remove vote from option 98 | >>> poll.set_votes([o.id for o in options if o.text != "Option 1"]) 99 | """ 100 | data = {"question_id": self.id} 101 | 102 | for i, option_id in enumerate(option_ids or ()): 103 | data["selected_options[{}]".format(i)] = option_id 104 | 105 | for i, option_text in enumerate(new_options or ()): 106 | data["new_options[{}]".format(i)] = option_text 107 | 108 | j = self.session._payload_post( 109 | "/messaging/group_polling/update_vote/?dpr=1", data 110 | ) 111 | if j.get("status") != "success": 112 | raise _exception.ExternalError( 113 | "Failed updating poll vote: {}".format(j.get("errorTitle")), 114 | j.get("errorMessage"), 115 | ) 116 | -------------------------------------------------------------------------------- /tests/models/test_message.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import fbchat 3 | from fbchat import EmojiSize, Mention, Message, MessageData 4 | from fbchat._models._message import graphql_to_extensible_attachment 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "tags,size", 9 | [ 10 | (None, None), 11 | (["hot_emoji_size:unknown"], None), 12 | (["bunch", "of:different", "tags:large", "hot_emoji_size:s"], EmojiSize.SMALL), 13 | (["hot_emoji_size:s"], EmojiSize.SMALL), 14 | (["hot_emoji_size:m"], EmojiSize.MEDIUM), 15 | (["hot_emoji_size:l"], EmojiSize.LARGE), 16 | (["hot_emoji_size:small"], EmojiSize.SMALL), 17 | (["hot_emoji_size:medium"], EmojiSize.MEDIUM), 18 | (["hot_emoji_size:large"], EmojiSize.LARGE), 19 | ], 20 | ) 21 | def test_emojisize_from_tags(tags, size): 22 | assert size is EmojiSize._from_tags(tags) 23 | 24 | 25 | def test_graphql_to_extensible_attachment_empty(): 26 | assert None is graphql_to_extensible_attachment({}) 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "obj,type_", 31 | [ 32 | # UnsentMessage testing is done in test_attachment.py 33 | (fbchat.LocationAttachment, "MessageLocation"), 34 | (fbchat.LiveLocationAttachment, "MessageLiveLocation"), 35 | (fbchat.ShareAttachment, "ExternalUrl"), 36 | (fbchat.ShareAttachment, "Story"), 37 | ], 38 | ) 39 | def test_graphql_to_extensible_attachment_dispatch(monkeypatch, obj, type_): 40 | monkeypatch.setattr(obj, "_from_graphql", lambda data: True) 41 | data = {"story_attachment": {"target": {"__typename": type_}}} 42 | assert graphql_to_extensible_attachment(data) 43 | 44 | 45 | def test_mention_from_range(): 46 | data = {"length": 17, "offset": 0, "entity": {"__typename": "User", "id": "1234"}} 47 | assert Mention(thread_id="1234", offset=0, length=17) == Mention._from_range(data) 48 | data = { 49 | "length": 2, 50 | "offset": 10, 51 | "entity": {"__typename": "MessengerViewer1To1Thread"}, 52 | } 53 | assert Mention(thread_id=None, offset=10, length=2) == Mention._from_range(data) 54 | data = { 55 | "length": 5, 56 | "offset": 21, 57 | "entity": {"__typename": "MessengerViewerGroupThread"}, 58 | } 59 | assert Mention(thread_id=None, offset=21, length=5) == Mention._from_range(data) 60 | 61 | 62 | def test_mention_to_send_data(): 63 | assert { 64 | "profile_xmd[0][id]": "1234", 65 | "profile_xmd[0][length]": 7, 66 | "profile_xmd[0][offset]": 4, 67 | "profile_xmd[0][type]": "p", 68 | } == Mention(thread_id="1234", offset=4, length=7)._to_send_data(0) 69 | assert { 70 | "profile_xmd[1][id]": "4321", 71 | "profile_xmd[1][length]": 7, 72 | "profile_xmd[1][offset]": 24, 73 | "profile_xmd[1][type]": "p", 74 | } == Mention(thread_id="4321", offset=24, length=7)._to_send_data(1) 75 | 76 | 77 | def test_message_format_mentions(): 78 | expected = ( 79 | "Hey 'Peter'! My name is Michael", 80 | [ 81 | Mention(thread_id="1234", offset=4, length=7), 82 | Mention(thread_id="4321", offset=24, length=7), 83 | ], 84 | ) 85 | assert expected == Message.format_mentions( 86 | "Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael") 87 | ) 88 | assert expected == Message.format_mentions( 89 | "Hey {p!r}! My name is {}", ("4321", "Michael"), p=("1234", "Peter") 90 | ) 91 | 92 | 93 | def test_message_get_forwarded_from_tags(): 94 | assert not MessageData._get_forwarded_from_tags(None) 95 | assert not MessageData._get_forwarded_from_tags(["hot_emoji_size:unknown"]) 96 | assert MessageData._get_forwarded_from_tags( 97 | ["attachment:photo", "inbox", "sent", "source:chat:forward", "tq"] 98 | ) 99 | 100 | 101 | @pytest.mark.skip(reason="need to be added") 102 | def test_message_to_send_data_quick_replies(): 103 | raise NotImplementedError 104 | 105 | 106 | @pytest.mark.skip(reason="need to gather test data") 107 | def test_message_from_graphql(): 108 | pass 109 | 110 | 111 | @pytest.mark.skip(reason="need to gather test data") 112 | def test_message_from_reply(): 113 | pass 114 | 115 | 116 | @pytest.mark.skip(reason="need to gather test data") 117 | def test_message_from_pull(): 118 | pass 119 | -------------------------------------------------------------------------------- /fbchat/_events/__init__.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | from ._common import attrs_event, Event, UnknownEvent, ThreadEvent 4 | from ._client_payload import * 5 | from ._delta_class import * 6 | from ._delta_type import * 7 | 8 | from .. import _exception, _threads, _models 9 | 10 | from typing import Mapping 11 | 12 | 13 | @attrs_event 14 | class Typing(ThreadEvent): 15 | """Somebody started/stopped typing in a thread.""" 16 | 17 | #: ``True`` if the user started typing, ``False`` if they stopped 18 | status = attr.ib(type=bool) 19 | 20 | @classmethod 21 | def _parse_orca(cls, session, data): 22 | author = _threads.User(session=session, id=str(data["sender_fbid"])) 23 | status = data["state"] == 1 24 | return cls(author=author, thread=author, status=status) 25 | 26 | @classmethod 27 | def _parse_thread_typing(cls, session, data): 28 | author = _threads.User(session=session, id=str(data["sender_fbid"])) 29 | thread = _threads.Group(session=session, id=str(data["thread"])) 30 | status = data["state"] == 1 31 | return cls(author=author, thread=thread, status=status) 32 | 33 | 34 | @attrs_event 35 | class FriendRequest(Event): 36 | """Somebody sent a friend request.""" 37 | 38 | #: The user that sent the request 39 | author = attr.ib(type="_threads.User") 40 | 41 | @classmethod 42 | def _parse(cls, session, data): 43 | author = _threads.User(session=session, id=str(data["from"])) 44 | return cls(author=author) 45 | 46 | 47 | @attrs_event 48 | class Presence(Event): 49 | """The list of active statuses was updated. 50 | 51 | Chat online presence update. 52 | """ 53 | 54 | # TODO: Document this better! 55 | 56 | #: User ids mapped to their active status 57 | statuses = attr.ib(type=Mapping[str, "_models.ActiveStatus"]) 58 | #: ``True`` if the list is fully updated and ``False`` if it's partially updated 59 | full = attr.ib(type=bool) 60 | 61 | @classmethod 62 | def _parse(cls, session, data): 63 | statuses = { 64 | str(d["u"]): _models.ActiveStatus._from_orca_presence(d) 65 | for d in data["list"] 66 | } 67 | return cls(statuses=statuses, full=data["list_type"] == "full") 68 | 69 | 70 | @attrs_event 71 | class Connect(Event): 72 | """The client was connected to Facebook. 73 | 74 | This is not guaranteed to be triggered the same amount of times `Disconnect`! 75 | """ 76 | 77 | 78 | @attrs_event 79 | class Disconnect(Event): 80 | """The client lost the connection to Facebook. 81 | 82 | This is not guaranteed to be triggered the same amount of times `Connect`! 83 | """ 84 | 85 | #: The reason / error string for the disconnect 86 | reason = attr.ib(type=str) 87 | 88 | 89 | def parse_events(session, topic, data): 90 | # See Mqtt._configure_connect_options for information about these topics 91 | try: 92 | if topic == "/t_ms": 93 | # `deltas` will always be available, since we're filtering out the things 94 | # that don't have it earlier in the MQTT listener 95 | for delta in data["deltas"]: 96 | if delta["class"] == "ClientPayload": 97 | yield from parse_client_payloads(session, delta) 98 | continue 99 | try: 100 | event = parse_delta(session, delta) 101 | if event: # Skip `None` 102 | yield event 103 | except _exception.ParseError: 104 | raise 105 | except Exception as e: 106 | raise _exception.ParseError( 107 | "Error parsing delta", data=delta 108 | ) from e 109 | 110 | elif topic == "/thread_typing": 111 | yield Typing._parse_thread_typing(session, data) 112 | 113 | elif topic == "/orca_typing_notifications": 114 | yield Typing._parse_orca(session, data) 115 | 116 | elif topic == "/legacy_web": 117 | if data["type"] == "jewel_requests_add": 118 | yield FriendRequest._parse(session, data) 119 | else: 120 | yield UnknownEvent(source="/legacy_web", data=data) 121 | 122 | elif topic == "/orca_presence": 123 | yield Presence._parse(session, data) 124 | 125 | else: 126 | yield UnknownEvent(source=topic, data=data) 127 | except _exception.ParseError: 128 | raise 129 | except Exception as e: 130 | raise _exception.ParseError( 131 | "Error parsing MQTT topic {}".format(topic), data=data 132 | ) from e 133 | -------------------------------------------------------------------------------- /tests/events/test_main.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from fbchat import ( 3 | _util, 4 | User, 5 | Group, 6 | Message, 7 | ParseError, 8 | UnknownEvent, 9 | Typing, 10 | FriendRequest, 11 | Presence, 12 | ReactionEvent, 13 | UnfetchedThreadEvent, 14 | ActiveStatus, 15 | ) 16 | from fbchat._events import parse_events 17 | 18 | 19 | def test_t_ms_full(session): 20 | """A full example of parsing of data in /t_ms.""" 21 | payload = { 22 | "deltas": [ 23 | { 24 | "deltaMessageReaction": { 25 | "threadKey": {"threadFbId": 4321}, 26 | "messageId": "mid.$XYZ", 27 | "action": 0, 28 | "userId": 1234, 29 | "reaction": "😢", 30 | "senderId": 1234, 31 | "offlineThreadingId": "1122334455", 32 | } 33 | } 34 | ] 35 | } 36 | data = { 37 | "deltas": [ 38 | { 39 | "payload": [ord(x) for x in _util.json_minimal(payload)], 40 | "class": "ClientPayload", 41 | }, 42 | {"class": "NoOp",}, 43 | { 44 | "forceInsert": False, 45 | "messageId": "mid.$ABC", 46 | "threadKey": {"threadFbId": "4321"}, 47 | "class": "ForcedFetch", 48 | }, 49 | ], 50 | "firstDeltaSeqId": 111111, 51 | "lastIssuedSeqId": 111113, 52 | "queueEntityId": 1234, 53 | } 54 | thread = Group(session=session, id="4321") 55 | assert [ 56 | ReactionEvent( 57 | author=User(session=session, id="1234"), 58 | thread=thread, 59 | message=Message(thread=thread, id="mid.$XYZ"), 60 | reaction="😢", 61 | ), 62 | UnfetchedThreadEvent( 63 | thread=thread, message=Message(thread=thread, id="mid.$ABC"), 64 | ), 65 | ] == list(parse_events(session, "/t_ms", data)) 66 | 67 | 68 | def test_thread_typing(session): 69 | data = {"sender_fbid": 1234, "state": 0, "type": "typ", "thread": "4321"} 70 | (event,) = parse_events(session, "/thread_typing", data) 71 | assert event == Typing( 72 | author=User(session=session, id="1234"), 73 | thread=Group(session=session, id="4321"), 74 | status=False, 75 | ) 76 | 77 | 78 | def test_orca_typing_notifications(session): 79 | data = {"type": "typ", "sender_fbid": 1234, "state": 1} 80 | (event,) = parse_events(session, "/orca_typing_notifications", data) 81 | assert event == Typing( 82 | author=User(session=session, id="1234"), 83 | thread=User(session=session, id="1234"), 84 | status=True, 85 | ) 86 | 87 | 88 | def test_friend_request(session): 89 | data = {"type": "jewel_requests_add", "from": "1234"} 90 | (event,) = parse_events(session, "/legacy_web", data) 91 | assert event == FriendRequest(author=User(session=session, id="1234")) 92 | 93 | 94 | def test_orca_presence_inc(session): 95 | data = { 96 | "list_type": "inc", 97 | "list": [ 98 | {"u": 1234, "p": 0, "l": 1500000000, "vc": 74}, 99 | {"u": 2345, "p": 2, "c": 9969664, "vc": 10}, 100 | ], 101 | } 102 | (event,) = parse_events(session, "/orca_presence", data) 103 | la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc) 104 | assert event == Presence( 105 | statuses={ 106 | "1234": ActiveStatus(active=False, last_active=la), 107 | "2345": ActiveStatus(active=True), 108 | }, 109 | full=False, 110 | ) 111 | 112 | 113 | def test_orca_presence_full(session): 114 | data = { 115 | "list_type": "full", 116 | "list": [ 117 | {"u": 1234, "p": 2, "c": 5767242}, 118 | {"u": 2345, "p": 2, "l": 1500000000}, 119 | {"u": 3456, "p": 2, "c": 9961482}, 120 | {"u": 4567, "p": 0, "l": 1500000000}, 121 | {"u": 5678, "p": 0}, 122 | {"u": 6789, "p": 2, "c": 14168154}, 123 | ], 124 | } 125 | (event,) = parse_events(session, "/orca_presence", data) 126 | la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc) 127 | assert event == Presence( 128 | statuses={ 129 | "1234": ActiveStatus(active=True), 130 | "2345": ActiveStatus(active=True, last_active=la), 131 | "3456": ActiveStatus(active=True), 132 | "4567": ActiveStatus(active=False, last_active=la), 133 | "5678": ActiveStatus(active=False), 134 | "6789": ActiveStatus(active=True), 135 | }, 136 | full=True, 137 | ) 138 | -------------------------------------------------------------------------------- /fbchat/_events/_client_payload.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | from ._common import attrs_event, UnknownEvent, ThreadEvent 4 | from .. import _exception, _util, _threads, _models 5 | 6 | from typing import Optional 7 | 8 | 9 | @attrs_event 10 | class ReactionEvent(ThreadEvent): 11 | """Somebody reacted to a message.""" 12 | 13 | #: Message that the user reacted to 14 | message = attr.ib(type="_models.Message") 15 | 16 | reaction = attr.ib(type=Optional[str]) 17 | """The reaction. 18 | 19 | Not limited to the ones in `Message.react`. 20 | 21 | If ``None``, the reaction was removed. 22 | """ 23 | 24 | @classmethod 25 | def _parse(cls, session, data): 26 | thread = cls._get_thread(session, data) 27 | return cls( 28 | author=_threads.User(session=session, id=str(data["userId"])), 29 | thread=thread, 30 | message=_models.Message(thread=thread, id=data["messageId"]), 31 | reaction=data["reaction"] if data["action"] == 0 else None, 32 | ) 33 | 34 | 35 | @attrs_event 36 | class UserStatusEvent(ThreadEvent): 37 | #: Whether the user was blocked or unblocked 38 | blocked = attr.ib(type=bool) 39 | 40 | @classmethod 41 | def _parse(cls, session, data): 42 | return cls( 43 | author=_threads.User(session=session, id=str(data["actorFbid"])), 44 | thread=cls._get_thread(session, data), 45 | blocked=not data["canViewerReply"], 46 | ) 47 | 48 | 49 | @attrs_event 50 | class LiveLocationEvent(ThreadEvent): 51 | """Somebody sent live location info.""" 52 | 53 | # TODO: This! 54 | 55 | @classmethod 56 | def _parse(cls, session, data): 57 | from . import _location 58 | 59 | thread = cls._get_thread(session, data) 60 | for location_data in data["messageLiveLocations"]: 61 | message = _models.Message(thread=thread, id=data["messageId"]) 62 | author = _threads.User(session=session, id=str(location_data["senderId"])) 63 | location = _location.LiveLocationAttachment._from_pull(location_data) 64 | 65 | return None 66 | 67 | 68 | @attrs_event 69 | class UnsendEvent(ThreadEvent): 70 | """Somebody unsent a message (which deletes it for everyone).""" 71 | 72 | #: The unsent message 73 | message = attr.ib(type="_models.Message") 74 | #: When the message was unsent 75 | at = attr.ib(type=datetime.datetime) 76 | 77 | @classmethod 78 | def _parse(cls, session, data): 79 | thread = cls._get_thread(session, data) 80 | return cls( 81 | author=_threads.User(session=session, id=str(data["senderID"])), 82 | thread=thread, 83 | message=_models.Message(thread=thread, id=data["messageID"]), 84 | at=_util.millis_to_datetime(data["deletionTimestamp"]), 85 | ) 86 | 87 | 88 | @attrs_event 89 | class MessageReplyEvent(ThreadEvent): 90 | """Somebody replied to a message.""" 91 | 92 | #: The sent message 93 | message = attr.ib(type="_models.MessageData") 94 | #: The message that was replied to 95 | replied_to = attr.ib(type="_models.MessageData") 96 | 97 | @classmethod 98 | def _parse(cls, session, data): 99 | metadata = data["message"]["messageMetadata"] 100 | thread = cls._get_thread(session, metadata) 101 | return cls( 102 | author=_threads.User(session=session, id=str(metadata["actorFbId"])), 103 | thread=thread, 104 | message=_models.MessageData._from_reply(thread, data["message"]), 105 | replied_to=_models.MessageData._from_reply( 106 | thread, data["repliedToMessage"] 107 | ), 108 | ) 109 | 110 | 111 | def parse_client_delta(session, data): 112 | if "deltaMessageReaction" in data: 113 | return ReactionEvent._parse(session, data["deltaMessageReaction"]) 114 | elif "deltaChangeViewerStatus" in data: 115 | # TODO: Parse all `reason` 116 | if data["deltaChangeViewerStatus"]["reason"] == 2: 117 | return UserStatusEvent._parse(session, data["deltaChangeViewerStatus"]) 118 | elif "liveLocationData" in data: 119 | return LiveLocationEvent._parse(session, data["liveLocationData"]) 120 | elif "deltaRecallMessageData" in data: 121 | return UnsendEvent._parse(session, data["deltaRecallMessageData"]) 122 | elif "deltaMessageReply" in data: 123 | return MessageReplyEvent._parse(session, data["deltaMessageReply"]) 124 | return UnknownEvent(source="client payload", data=data) 125 | 126 | 127 | def parse_client_payloads(session, data): 128 | payload = _util.parse_json("".join(chr(z) for z in data["payload"])) 129 | 130 | try: 131 | for delta in payload["deltas"]: 132 | yield parse_client_delta(session, delta) 133 | except _exception.ParseError: 134 | raise 135 | except Exception as e: 136 | raise _exception.ParseError("Error parsing ClientPayload", data=payload) from e 137 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This project is unmaintained 2 | ============================ 3 | 4 | This project is officially marked as unmaintained, since my life is somewhere where I just do not have the time and energy to lead this project. 5 | 6 | If there is someone out there willing to take the lead, please get in contact, but even if there is, I can't get in contact with the original author `@carpedm20 <https://github.com/carpedm20>`__ and get the ability to add others as maintainers, see `issue 390 <https://github.com/carpedm20/fbchat/issues/390>`__. So a fork might be preferable. 7 | 8 | I have opened for further discussion `in issue 613 <https://github.com/carpedm20/fbchat/issues/613>`__. 9 | 10 | Thanks for serving you all these years. 11 | 12 | \- Mads Marquart / `@madsmtm <https://github.com/madsmtm>`__. 13 | 14 | 15 | Original project description below 16 | ---------------------------------- 17 | 18 | 19 | ``fbchat`` - Facebook Messenger for Python 20 | ========================================== 21 | 22 | .. image:: https://badgen.net/pypi/v/fbchat 23 | :target: https://pypi.python.org/pypi/fbchat 24 | :alt: Project version 25 | 26 | .. image:: https://badgen.net/badge/python/3.5,3.6,3.7,3.8,pypy?list=| 27 | :target: https://pypi.python.org/pypi/fbchat 28 | :alt: Supported python versions: 3.5, 3.6, 3.7, 3.8 and pypy 29 | 30 | .. image:: https://badgen.net/pypi/license/fbchat 31 | :target: https://github.com/carpedm20/fbchat/tree/master/LICENSE 32 | :alt: License: BSD 3-Clause 33 | 34 | .. image:: https://readthedocs.org/projects/fbchat/badge/?version=stable 35 | :target: https://fbchat.readthedocs.io 36 | :alt: Documentation 37 | 38 | .. image:: https://badgen.net/travis/carpedm20/fbchat 39 | :target: https://travis-ci.org/carpedm20/fbchat 40 | :alt: Travis CI 41 | 42 | .. image:: https://badgen.net/badge/code%20style/black/black 43 | :target: https://github.com/ambv/black 44 | :alt: Code style 45 | 46 | A powerful and efficient library to interact with 47 | `Facebook's Messenger <https://www.facebook.com/messages/>`__, using just your email and password. 48 | 49 | This is *not* an official API, Facebook has that `over here <https://developers.facebook.com/docs/messenger-platform>`__ for chat bots. This library differs by using a normal Facebook account instead. 50 | 51 | ``fbchat`` currently support: 52 | 53 | - Sending many types of messages, with files, stickers, mentions, etc. 54 | - Fetching all messages, threads and images in threads. 55 | - Searching for messages and threads. 56 | - Creating groups, setting the group emoji, changing nicknames, creating polls, etc. 57 | - Listening for, an reacting to messages and other events in real-time. 58 | - Type hints, and it has a modern codebase (e.g. only Python 3.5 and upwards). 59 | - ``async``/``await`` (COMING). 60 | 61 | Essentially, everything you need to make an amazing Facebook bot! 62 | 63 | 64 | Version Warning 65 | --------------- 66 | ``v2`` is currently being developed at the ``master`` branch and it's highly unstable. If you want to view the old ``v1``, go `here <https://github.com/carpedm20/fbchat/tree/v1>`__. 67 | 68 | Additionally, you can view the project's progress `here <https://github.com/carpedm20/fbchat/projects/2>`__. 69 | 70 | 71 | Caveats 72 | ------- 73 | 74 | ``fbchat`` works by imitating what the browser does, and thereby tricking Facebook into thinking it's accessing the website normally. 75 | 76 | However, there's a catch! **Using this library may not comply with Facebook's Terms Of Service!**, so be responsible Facebook citizens! We are not responsible if your account gets banned! 77 | 78 | Additionally, **the APIs the library is calling is undocumented!** In theory, this means that your code could break tomorrow, without the slightest warning! 79 | If this happens to you, please report it, so that we can fix it as soon as possible! 80 | 81 | .. inclusion-marker-intro-end 82 | .. This message doesn't make sense in the docs at Read The Docs, so we exclude it 83 | 84 | With that out of the way, you may go to `Read The Docs <https://fbchat.readthedocs.io/>`__ to see the full documentation! 85 | 86 | .. inclusion-marker-installation-start 87 | 88 | 89 | Installation 90 | ------------ 91 | 92 | .. code-block:: 93 | 94 | $ pip install fbchat 95 | 96 | If you don't have `pip <https://pip.pypa.io/>`_, `this guide <http://docs.python-guide.org/en/latest/starting/installation/>`_ can guide you through the process. 97 | 98 | You can also install directly from source, provided you have ``pip>=19.0``: 99 | 100 | .. code-block:: 101 | 102 | $ pip install git+https://github.com/carpedm20/fbchat.git 103 | 104 | .. inclusion-marker-installation-end 105 | 106 | 107 | Example Usage 108 | ------------- 109 | 110 | .. code-block:: 111 | 112 | import getpass 113 | import fbchat 114 | session = fbchat.Session.login("<email/phone number>", getpass.getpass()) 115 | user = fbchat.User(session=session, id=session.user_id) 116 | user.send_text("Test message!") 117 | 118 | More examples are available `here <https://github.com/carpedm20/fbchat/tree/master/examples>`__. 119 | 120 | 121 | Maintainer 122 | ---------- 123 | 124 | No one, see notice at the top. 125 | 126 | Acknowledgements 127 | ---------------- 128 | 129 | This project was originally inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__. 130 | -------------------------------------------------------------------------------- /tests/models/test_plan.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from fbchat import GuestStatus, PlanData 3 | 4 | 5 | def test_plan_properties(session): 6 | plan = PlanData( 7 | session=session, 8 | id="1234567890", 9 | time=..., 10 | title=..., 11 | guests={ 12 | "1234": GuestStatus.INVITED, 13 | "2345": GuestStatus.INVITED, 14 | "3456": GuestStatus.GOING, 15 | "4567": GuestStatus.DECLINED, 16 | }, 17 | ) 18 | assert set(plan.invited) == {"1234", "2345"} 19 | assert plan.going == ["3456"] 20 | assert plan.declined == ["4567"] 21 | 22 | 23 | def test_plan_from_pull(session): 24 | data = { 25 | "event_timezone": "", 26 | "event_creator_id": "1234", 27 | "event_id": "1111", 28 | "event_type": "EVENT", 29 | "event_track_rsvp": "1", 30 | "event_title": "abc", 31 | "event_time": "1500000000", 32 | "event_seconds_to_notify_before": "3600", 33 | "guest_state_list": ( 34 | '[{"guest_list_state":"INVITED","node":{"id":"1234"}},' 35 | '{"guest_list_state":"INVITED","node":{"id":"2356"}},' 36 | '{"guest_list_state":"DECLINED","node":{"id":"3456"}},' 37 | '{"guest_list_state":"GOING","node":{"id":"4567"}}]' 38 | ), 39 | } 40 | assert PlanData( 41 | session=session, 42 | id="1111", 43 | time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), 44 | title="abc", 45 | author_id="1234", 46 | guests={ 47 | "1234": GuestStatus.INVITED, 48 | "2356": GuestStatus.INVITED, 49 | "3456": GuestStatus.DECLINED, 50 | "4567": GuestStatus.GOING, 51 | }, 52 | ) == PlanData._from_pull(session, data) 53 | 54 | 55 | def test_plan_from_fetch(session): 56 | data = { 57 | "message_thread_id": 123456789, 58 | "event_time": 1500000000, 59 | "creator_id": 1234, 60 | "event_time_updated_time": 1450000000, 61 | "title": "abc", 62 | "track_rsvp": 1, 63 | "event_type": "EVENT", 64 | "status": "created", 65 | "message_id": "mid.xyz", 66 | "seconds_to_notify_before": 3600, 67 | "event_time_source": "user", 68 | "repeat_mode": "once", 69 | "creation_time": 1400000000, 70 | "location_id": 0, 71 | "location_name": None, 72 | "latitude": "", 73 | "longitude": "", 74 | "event_id": 0, 75 | "trigger_message_id": "", 76 | "note": "", 77 | "timezone_id": 0, 78 | "end_time": 0, 79 | "list_id": 0, 80 | "payload_id": 0, 81 | "cu_app": "", 82 | "location_sharing_subtype": "", 83 | "reminder_notif_param": [], 84 | "workplace_meeting_id": "", 85 | "genie_fbid": 0, 86 | "galaxy": "", 87 | "oid": 1111, 88 | "type": 8128, 89 | "is_active": True, 90 | "location_address": None, 91 | "event_members": { 92 | "1234": "INVITED", 93 | "2356": "INVITED", 94 | "3456": "DECLINED", 95 | "4567": "GOING", 96 | }, 97 | } 98 | assert PlanData( 99 | session=session, 100 | id=1111, 101 | time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), 102 | title="abc", 103 | location="", 104 | location_id="", 105 | author_id=1234, 106 | guests={ 107 | "1234": GuestStatus.INVITED, 108 | "2356": GuestStatus.INVITED, 109 | "3456": GuestStatus.DECLINED, 110 | "4567": GuestStatus.GOING, 111 | }, 112 | ) == PlanData._from_fetch(session, data) 113 | 114 | 115 | def test_plan_from_graphql(session): 116 | data = { 117 | "id": "1111", 118 | "lightweight_event_creator": {"id": "1234"}, 119 | "time": 1500000000, 120 | "lightweight_event_type": "EVENT", 121 | "location_name": None, 122 | "location_coordinates": None, 123 | "location_page": None, 124 | "lightweight_event_status": "CREATED", 125 | "note": "", 126 | "repeat_mode": "ONCE", 127 | "event_title": "abc", 128 | "trigger_message": None, 129 | "seconds_to_notify_before": 3600, 130 | "allows_rsvp": True, 131 | "related_event": None, 132 | "event_reminder_members": { 133 | "edges": [ 134 | {"node": {"id": "1234"}, "guest_list_state": "INVITED"}, 135 | {"node": {"id": "2356"}, "guest_list_state": "INVITED"}, 136 | {"node": {"id": "3456"}, "guest_list_state": "DECLINED"}, 137 | {"node": {"id": "4567"}, "guest_list_state": "GOING"}, 138 | ] 139 | }, 140 | } 141 | assert PlanData( 142 | session=session, 143 | time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), 144 | title="abc", 145 | location="", 146 | location_id="", 147 | id="1111", 148 | author_id="1234", 149 | guests={ 150 | "1234": GuestStatus.INVITED, 151 | "2356": GuestStatus.INVITED, 152 | "3456": GuestStatus.DECLINED, 153 | "4567": GuestStatus.GOING, 154 | }, 155 | ) == PlanData._from_graphql(session, data) 156 | -------------------------------------------------------------------------------- /fbchat/_util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import time 4 | import random 5 | import urllib.parse 6 | 7 | from ._common import log 8 | from . import _exception 9 | 10 | from typing import Iterable, Optional, Any, Mapping, Sequence 11 | 12 | 13 | def int_or_none(inp: Any) -> Optional[int]: 14 | try: 15 | return int(inp) 16 | except Exception: 17 | return None 18 | 19 | 20 | def get_limits(limit: Optional[int], max_limit: int) -> Iterable[int]: 21 | """Helper that generates limits based on a max limit.""" 22 | if limit is None: 23 | # Generate infinite items 24 | while True: 25 | yield max_limit 26 | 27 | if limit < 0: 28 | raise ValueError("Limit cannot be negative") 29 | 30 | # Generate n items 31 | yield from [max_limit] * (limit // max_limit) 32 | 33 | remainder = limit % max_limit 34 | if remainder: 35 | yield remainder 36 | 37 | 38 | def json_minimal(data: Any) -> str: 39 | """Get JSON data in minimal form.""" 40 | return json.dumps(data, separators=(",", ":")) 41 | 42 | 43 | def strip_json_cruft(text: str) -> str: 44 | """Removes `for(;;);` (and other cruft) that preceeds JSON responses.""" 45 | try: 46 | return text[text.index("{") :] 47 | except ValueError as e: 48 | raise _exception.ParseError("No JSON object found", data=text) from e 49 | 50 | 51 | def parse_json(text: str) -> Any: 52 | try: 53 | return json.loads(text) 54 | except ValueError as e: 55 | raise _exception.ParseError("Error while parsing JSON", data=text) from e 56 | 57 | 58 | def generate_offline_threading_id(): 59 | ret = datetime_to_millis(now()) 60 | value = int(random.random() * 4294967295) 61 | string = ("0000000000000000000000" + format(value, "b"))[-22:] 62 | msgs = format(ret, "b") + string 63 | return str(int(msgs, 2)) 64 | 65 | 66 | def remove_version_from_module(module): 67 | return module.split("@", 1)[0] 68 | 69 | 70 | def get_jsmods_require(require) -> Mapping[str, Sequence[Any]]: 71 | rtn = {} 72 | for item in require: 73 | if len(item) == 1: 74 | (module,) = item 75 | rtn[remove_version_from_module(module)] = [] 76 | continue 77 | module, method, requirements, arguments = item 78 | method = "{}.{}".format(remove_version_from_module(module), method) 79 | rtn[method] = arguments 80 | return rtn 81 | 82 | 83 | def get_jsmods_define(define) -> Mapping[str, Mapping[str, Any]]: 84 | rtn = {} 85 | for item in define: 86 | module, requirements, data, _ = item 87 | rtn[module] = data 88 | return rtn 89 | 90 | 91 | def mimetype_to_key(mimetype: str) -> str: 92 | if not mimetype: 93 | return "file_id" 94 | if mimetype == "image/gif": 95 | return "gif_id" 96 | x = mimetype.split("/") 97 | if x[0] in ["video", "image", "audio"]: 98 | return "%s_id" % x[0] 99 | return "file_id" 100 | 101 | 102 | def get_url_parameter(url: str, param: str) -> Optional[str]: 103 | params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) 104 | if not params.get(param): 105 | return None 106 | return params[param][0] 107 | 108 | 109 | def seconds_to_datetime(timestamp_in_seconds: float) -> datetime.datetime: 110 | """Convert an UTC timestamp to a timezone-aware datetime object.""" 111 | # `.utcfromtimestamp` will return a "naive" datetime object, which is why we use the 112 | # following: 113 | return datetime.datetime.fromtimestamp( 114 | timestamp_in_seconds, tz=datetime.timezone.utc 115 | ) 116 | 117 | 118 | def millis_to_datetime(timestamp_in_milliseconds: int) -> datetime.datetime: 119 | """Convert an UTC timestamp, in milliseconds, to a timezone-aware datetime.""" 120 | return seconds_to_datetime(timestamp_in_milliseconds / 1000) 121 | 122 | 123 | def datetime_to_seconds(dt: datetime.datetime) -> int: 124 | """Convert a datetime to an UTC timestamp. 125 | 126 | Naive datetime objects are presumed to represent time in the system timezone. 127 | 128 | The returned seconds will be rounded to the nearest whole number. 129 | """ 130 | # We could've implemented some fancy "convert naive timezones to UTC" logic, but 131 | # it's not really worth the effort. 132 | return round(dt.timestamp()) 133 | 134 | 135 | def datetime_to_millis(dt: datetime.datetime) -> int: 136 | """Convert a datetime to an UTC timestamp, in milliseconds. 137 | 138 | Naive datetime objects are presumed to represent time in the system timezone. 139 | 140 | The returned milliseconds will be rounded to the nearest whole number. 141 | """ 142 | return round(dt.timestamp() * 1000) 143 | 144 | 145 | def seconds_to_timedelta(seconds: float) -> datetime.timedelta: 146 | """Convert seconds to a timedelta.""" 147 | return datetime.timedelta(seconds=seconds) 148 | 149 | 150 | def millis_to_timedelta(milliseconds: int) -> datetime.timedelta: 151 | """Convert a duration (in milliseconds) to a timedelta object.""" 152 | return datetime.timedelta(milliseconds=milliseconds) 153 | 154 | 155 | def timedelta_to_seconds(td: datetime.timedelta) -> int: 156 | """Convert a timedelta to seconds. 157 | 158 | The returned seconds will be rounded to the nearest whole number. 159 | """ 160 | return round(td.total_seconds()) 161 | 162 | 163 | def now() -> datetime.datetime: 164 | """The current time. 165 | 166 | Similar to datetime.datetime.now(), but returns a non-naive datetime. 167 | """ 168 | return datetime.datetime.now(tz=datetime.timezone.utc) 169 | -------------------------------------------------------------------------------- /fbchat/_exception.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import requests 3 | 4 | from typing import Any, Optional 5 | 6 | # Not frozen, since that doesn't work in PyPy 7 | @attr.s(slots=True, auto_exc=True) 8 | class FacebookError(Exception): 9 | """Base class for all custom exceptions raised by ``fbchat``. 10 | 11 | All exceptions in the module inherit this. 12 | """ 13 | 14 | #: A message describing the error 15 | message = attr.ib(type=str) 16 | 17 | 18 | @attr.s(slots=True, auto_exc=True) 19 | class HTTPError(FacebookError): 20 | """Base class for errors with the HTTP(s) connection to Facebook.""" 21 | 22 | #: The returned HTTP status code, if relevant 23 | status_code = attr.ib(None, type=Optional[int]) 24 | 25 | def __str__(self): 26 | if not self.status_code: 27 | return self.message 28 | return "Got {} response: {}".format(self.status_code, self.message) 29 | 30 | 31 | @attr.s(slots=True, auto_exc=True) 32 | class ParseError(FacebookError): 33 | """Raised when we fail parsing a response from Facebook. 34 | 35 | This may contain sensitive data, so should not be logged to file. 36 | """ 37 | 38 | data = attr.ib(type=Any) 39 | """The data that triggered the error. 40 | 41 | The format of this cannot be relied on, it's only for debugging purposes. 42 | """ 43 | 44 | def __str__(self): 45 | msg = "{}. Please report this, along with the data below!\n{}" 46 | return msg.format(self.message, self.data) 47 | 48 | 49 | @attr.s(slots=True, auto_exc=True) 50 | class NotLoggedIn(FacebookError): 51 | """Raised by Facebook if the client has been logged out.""" 52 | 53 | 54 | @attr.s(slots=True, auto_exc=True) 55 | class ExternalError(FacebookError): 56 | """Base class for errors that Facebook return.""" 57 | 58 | #: The error message that Facebook returned (Possibly in the user's own language) 59 | description = attr.ib(type=str) 60 | #: The error code that Facebook returned 61 | code = attr.ib(None, type=Optional[int]) 62 | 63 | def __str__(self): 64 | if self.code: 65 | return "#{} {}: {}".format(self.code, self.message, self.description) 66 | return "{}: {}".format(self.message, self.description) 67 | 68 | 69 | @attr.s(slots=True, auto_exc=True) 70 | class GraphQLError(ExternalError): 71 | """Raised by Facebook if there was an error in the GraphQL query.""" 72 | 73 | # TODO: Handle multiple errors 74 | 75 | #: Query debug information 76 | debug_info = attr.ib(None, type=Optional[str]) 77 | 78 | def __str__(self): 79 | if self.debug_info: 80 | return "{}, {}".format(super().__str__(), self.debug_info) 81 | return super().__str__() 82 | 83 | 84 | @attr.s(slots=True, auto_exc=True) 85 | class InvalidParameters(ExternalError): 86 | """Raised by Facebook if: 87 | 88 | - Some function supplied invalid parameters. 89 | - Some content is not found. 90 | - Some content is no longer available. 91 | """ 92 | 93 | 94 | @attr.s(slots=True, auto_exc=True) 95 | class PleaseRefresh(ExternalError): 96 | """Raised by Facebook if the client has been inactive for too long. 97 | 98 | This error usually happens after 1-2 days of inactivity. 99 | """ 100 | 101 | code = attr.ib(1357004) 102 | 103 | 104 | def handle_payload_error(j): 105 | if "error" not in j: 106 | return 107 | code = j["error"] 108 | if code == 1357001: 109 | raise NotLoggedIn(j["errorSummary"]) 110 | elif code == 1357004: 111 | error_cls = PleaseRefresh 112 | elif code in (1357031, 1545010, 1545003): 113 | error_cls = InvalidParameters 114 | else: 115 | error_cls = ExternalError 116 | raise error_cls(j["errorSummary"], description=j["errorDescription"], code=code) 117 | 118 | 119 | def handle_graphql_errors(j): 120 | errors = [] 121 | if j.get("error"): 122 | errors = [j["error"]] 123 | if "errors" in j: 124 | errors = j["errors"] 125 | if errors: 126 | error = errors[0] # TODO: Handle multiple errors 127 | # TODO: Use `severity` 128 | raise GraphQLError( 129 | # TODO: What data is always available? 130 | message=error.get("summary", "Unknown error"), 131 | description=error.get("message") or error.get("description") or "", 132 | code=error.get("code"), 133 | debug_info=error.get("debug_info"), 134 | ) 135 | 136 | 137 | def handle_http_error(code): 138 | if code == 404: 139 | raise HTTPError( 140 | "This might be because you provided an invalid id" 141 | + " (Facebook usually require integer ids)", 142 | status_code=code, 143 | ) 144 | if code == 500: 145 | raise HTTPError( 146 | "There is probably an error on the endpoint, or it might be rate limited", 147 | status_code=code, 148 | ) 149 | if 400 <= code < 600: 150 | raise HTTPError("Failed sending request", status_code=code) 151 | 152 | 153 | def handle_requests_error(e): 154 | if isinstance(e, requests.ConnectionError): 155 | raise HTTPError("Connection error") from e 156 | if isinstance(e, requests.HTTPError): 157 | pass # Raised when using .raise_for_status, so should never happen 158 | if isinstance(e, requests.URLRequired): 159 | pass # Should never happen, we always prove valid URLs 160 | if isinstance(e, requests.TooManyRedirects): 161 | pass # TODO: Consider using allow_redirects=False to prevent this 162 | if isinstance(e, requests.Timeout): 163 | pass # Should never happen, we don't set timeouts 164 | 165 | raise HTTPError("Requests error") from e 166 | -------------------------------------------------------------------------------- /fbchat/_graphql.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from ._common import log 4 | from . import _util, _exception 5 | 6 | # Shameless copy from https://stackoverflow.com/a/8730674 7 | FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL 8 | WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS) 9 | 10 | 11 | class ConcatJSONDecoder(json.JSONDecoder): 12 | def decode(self, s, _w=WHITESPACE.match): 13 | s_len = len(s) 14 | 15 | objs = [] 16 | end = 0 17 | while end != s_len: 18 | obj, end = self.raw_decode(s, idx=_w(s, end).end()) 19 | end = _w(s, end).end() 20 | objs.append(obj) 21 | return objs 22 | 23 | 24 | # End shameless copy 25 | 26 | 27 | def queries_to_json(*queries): 28 | """ 29 | Queries should be a list of GraphQL objects 30 | """ 31 | rtn = {} 32 | for i, query in enumerate(queries): 33 | rtn["q{}".format(i)] = query 34 | return _util.json_minimal(rtn) 35 | 36 | 37 | def response_to_json(text): 38 | text = _util.strip_json_cruft(text) # Usually only needed in some error cases 39 | try: 40 | j = json.loads(text, cls=ConcatJSONDecoder) 41 | except Exception as e: 42 | raise _exception.ParseError("Error while parsing JSON", data=text) from e 43 | 44 | rtn = [None] * (len(j)) 45 | for x in j: 46 | if "error_results" in x: 47 | del rtn[-1] 48 | continue 49 | _exception.handle_payload_error(x) 50 | [(key, value)] = x.items() 51 | _exception.handle_graphql_errors(value) 52 | if "response" in value: 53 | rtn[int(key[1:])] = value["response"] 54 | else: 55 | rtn[int(key[1:])] = value["data"] 56 | 57 | log.debug(rtn) 58 | 59 | return rtn 60 | 61 | 62 | def from_query(query, params): 63 | return {"priority": 0, "q": query, "query_params": params} 64 | 65 | 66 | def from_query_id(query_id, params): 67 | return {"query_id": query_id, "query_params": params} 68 | 69 | 70 | def from_doc(doc, params): 71 | return {"doc": doc, "query_params": params} 72 | 73 | 74 | def from_doc_id(doc_id, params): 75 | return {"doc_id": doc_id, "query_params": params} 76 | 77 | 78 | FRAGMENT_USER = """ 79 | QueryFragment User: User { 80 | id, 81 | name, 82 | first_name, 83 | last_name, 84 | profile_picture.width(<pic_size>).height(<pic_size>) { 85 | uri 86 | }, 87 | is_viewer_friend, 88 | url, 89 | gender, 90 | viewer_affinity 91 | } 92 | """ 93 | 94 | FRAGMENT_GROUP = """ 95 | QueryFragment Group: MessageThread { 96 | name, 97 | thread_key { 98 | thread_fbid 99 | }, 100 | image { 101 | uri 102 | }, 103 | is_group_thread, 104 | all_participants { 105 | nodes { 106 | messaging_actor { 107 | __typename, 108 | id 109 | } 110 | } 111 | }, 112 | customization_info { 113 | participant_customizations { 114 | participant_id, 115 | nickname 116 | }, 117 | outgoing_bubble_color, 118 | emoji 119 | }, 120 | thread_admins { 121 | id 122 | }, 123 | group_approval_queue { 124 | nodes { 125 | requester { 126 | id 127 | } 128 | } 129 | }, 130 | approval_mode, 131 | joinable_mode { 132 | mode, 133 | link 134 | }, 135 | event_reminders { 136 | nodes { 137 | id, 138 | lightweight_event_creator { 139 | id 140 | }, 141 | time, 142 | location_name, 143 | event_title, 144 | event_reminder_members { 145 | edges { 146 | node { 147 | id 148 | }, 149 | guest_list_state 150 | } 151 | } 152 | } 153 | } 154 | } 155 | """ 156 | 157 | FRAGMENT_PAGE = """ 158 | QueryFragment Page: Page { 159 | id, 160 | name, 161 | profile_picture.width(32).height(32) { 162 | uri 163 | }, 164 | url, 165 | category_type, 166 | city { 167 | name 168 | } 169 | } 170 | """ 171 | 172 | SEARCH_USER = ( 173 | """ 174 | Query SearchUser(<search> = '', <limit> = 10) { 175 | entities_named(<search>) { 176 | search_results.of_type(user).first(<limit>) as users { 177 | nodes { 178 | @User 179 | } 180 | } 181 | } 182 | } 183 | """ 184 | + FRAGMENT_USER 185 | ) 186 | 187 | SEARCH_GROUP = ( 188 | """ 189 | Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) { 190 | viewer() { 191 | message_threads.with_thread_name(<search>).last(<limit>) as groups { 192 | nodes { 193 | @Group 194 | } 195 | } 196 | } 197 | } 198 | """ 199 | + FRAGMENT_GROUP 200 | ) 201 | 202 | SEARCH_PAGE = ( 203 | """ 204 | Query SearchPage(<search> = '', <limit> = 10) { 205 | entities_named(<search>) { 206 | search_results.of_type(page).first(<limit>) as pages { 207 | nodes { 208 | @Page 209 | } 210 | } 211 | } 212 | } 213 | """ 214 | + FRAGMENT_PAGE 215 | ) 216 | 217 | SEARCH_THREAD = ( 218 | """ 219 | Query SearchThread(<search> = '', <limit> = 10) { 220 | entities_named(<search>) { 221 | search_results.first(<limit>) as threads { 222 | nodes { 223 | __typename, 224 | @User, 225 | @Group, 226 | @Page 227 | } 228 | } 229 | } 230 | } 231 | """ 232 | + FRAGMENT_USER 233 | + FRAGMENT_GROUP 234 | + FRAGMENT_PAGE 235 | ) 236 | -------------------------------------------------------------------------------- /tests/events/test_client_payload.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytest 3 | from fbchat import ( 4 | ParseError, 5 | User, 6 | Group, 7 | Message, 8 | MessageData, 9 | UnknownEvent, 10 | ReactionEvent, 11 | UserStatusEvent, 12 | LiveLocationEvent, 13 | UnsendEvent, 14 | MessageReplyEvent, 15 | ) 16 | from fbchat._events import parse_client_delta, parse_client_payloads 17 | 18 | 19 | def test_reaction_event_added(session): 20 | data = { 21 | "threadKey": {"otherUserFbId": 1234}, 22 | "messageId": "mid.$XYZ", 23 | "action": 0, 24 | "userId": 4321, 25 | "reaction": "😍", 26 | "senderId": 4321, 27 | "offlineThreadingId": "6623596674408921967", 28 | } 29 | thread = User(session=session, id="1234") 30 | assert ReactionEvent( 31 | author=User(session=session, id="4321"), 32 | thread=thread, 33 | message=Message(thread=thread, id="mid.$XYZ"), 34 | reaction="😍", 35 | ) == parse_client_delta(session, {"deltaMessageReaction": data}) 36 | 37 | 38 | def test_reaction_event_removed(session): 39 | data = { 40 | "threadKey": {"threadFbId": 1234}, 41 | "messageId": "mid.$XYZ", 42 | "action": 1, 43 | "userId": 4321, 44 | "senderId": 4321, 45 | "offlineThreadingId": "6623586106713014836", 46 | } 47 | thread = Group(session=session, id="1234") 48 | assert ReactionEvent( 49 | author=User(session=session, id="4321"), 50 | thread=thread, 51 | message=Message(thread=thread, id="mid.$XYZ"), 52 | reaction=None, 53 | ) == parse_client_delta(session, {"deltaMessageReaction": data}) 54 | 55 | 56 | def test_user_status_blocked(session): 57 | data = { 58 | "threadKey": {"otherUserFbId": 1234}, 59 | "canViewerReply": False, 60 | "reason": 2, 61 | "actorFbid": 4321, 62 | } 63 | assert UserStatusEvent( 64 | author=User(session=session, id="4321"), 65 | thread=User(session=session, id="1234"), 66 | blocked=True, 67 | ) == parse_client_delta(session, {"deltaChangeViewerStatus": data}) 68 | 69 | 70 | def test_user_status_unblocked(session): 71 | data = { 72 | "threadKey": {"otherUserFbId": 1234}, 73 | "canViewerReply": True, 74 | "reason": 2, 75 | "actorFbid": 1234, 76 | } 77 | assert UserStatusEvent( 78 | author=User(session=session, id="1234"), 79 | thread=User(session=session, id="1234"), 80 | blocked=False, 81 | ) == parse_client_delta(session, {"deltaChangeViewerStatus": data}) 82 | 83 | 84 | @pytest.mark.skip(reason="need to gather test data") 85 | def test_live_location(session): 86 | pass 87 | 88 | 89 | def test_message_reply(session): 90 | message = { 91 | "messageMetadata": { 92 | "threadKey": {"otherUserFbId": 1234}, 93 | "messageId": "mid.$XYZ", 94 | "offlineThreadingId": "112233445566", 95 | "actorFbId": 1234, 96 | "timestamp": 1500000000000, 97 | "tags": ["source:messenger:web", "cg-enabled", "sent", "inbox"], 98 | "threadReadStateEffect": 3, 99 | "skipBumpThread": False, 100 | "skipSnippetUpdate": False, 101 | "unsendType": "can_unsend", 102 | "folderId": {"systemFolderId": 0}, 103 | }, 104 | "body": "xyz", 105 | "attachments": [], 106 | "irisSeqId": 1111111, 107 | "messageReply": {"replyToMessageId": {"id": "mid.$ABC"}, "status": 0,}, 108 | "requestContext": {"apiArgs": "..."}, 109 | "irisTags": ["DeltaNewMessage"], 110 | } 111 | reply = { 112 | "messageMetadata": { 113 | "threadKey": {"otherUserFbId": 1234}, 114 | "messageId": "mid.$ABC", 115 | "offlineThreadingId": "665544332211", 116 | "actorFbId": 4321, 117 | "timestamp": 1600000000000, 118 | "tags": ["inbox", "sent", "source:messenger:web"], 119 | }, 120 | "body": "abc", 121 | "attachments": [], 122 | "requestContext": {"apiArgs": "..."}, 123 | "irisTags": [], 124 | } 125 | data = { 126 | "message": message, 127 | "repliedToMessage": reply, 128 | "status": 0, 129 | } 130 | thread = User(session=session, id="1234") 131 | assert MessageReplyEvent( 132 | author=User(session=session, id="1234"), 133 | thread=thread, 134 | message=MessageData( 135 | thread=thread, 136 | id="mid.$XYZ", 137 | author="1234", 138 | created_at=datetime.datetime( 139 | 2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc 140 | ), 141 | text="xyz", 142 | reply_to_id="mid.$ABC", 143 | ), 144 | replied_to=MessageData( 145 | thread=thread, 146 | id="mid.$ABC", 147 | author="4321", 148 | created_at=datetime.datetime( 149 | 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc 150 | ), 151 | text="abc", 152 | ), 153 | ) == parse_client_delta(session, {"deltaMessageReply": data}) 154 | 155 | 156 | def test_parse_client_delta_unknown(session): 157 | assert UnknownEvent( 158 | source="client payload", data={"abc": 10} 159 | ) == parse_client_delta(session, {"abc": 10}) 160 | 161 | 162 | def test_parse_client_payloads_empty(session): 163 | # This is never something that happens, it's just so that we can test the parsing 164 | # payload = '{"deltas":[]}' 165 | payload = [123, 34, 100, 101, 108, 116, 97, 115, 34, 58, 91, 93, 125] 166 | data = {"payload": payload, "class": "ClientPayload"} 167 | assert [] == list(parse_client_payloads(session, data)) 168 | 169 | 170 | def test_parse_client_payloads_invalid(session): 171 | # payload = '{"invalid":"data"}' 172 | payload = [123, 34, 105, 110, 118, 97, 108, 105, 100, 34, 58, 34, 97, 34, 125] 173 | data = {"payload": payload, "class": "ClientPayload"} 174 | with pytest.raises(ParseError, match="Error parsing ClientPayload"): 175 | list(parse_client_payloads(session, data)) 176 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file does only contain a selection of the most common options. For a 4 | # full list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import os 10 | import sys 11 | 12 | sys.path.insert(0, os.path.abspath("..")) 13 | 14 | os.environ["_FBCHAT_DISABLE_FIX_MODULE_METADATA"] = "1" 15 | 16 | import fbchat 17 | 18 | del os.environ["_FBCHAT_DISABLE_FIX_MODULE_METADATA"] 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = fbchat.__name__ 23 | copyright = "Copyright 2015 - 2018 by Taehoon Kim and 2018 - 2020 by Mads Marquart" 24 | author = "Taehoon Kim; Moreels Pieter-Jan; Mads Marquart" 25 | description = fbchat.__doc__.split("\n")[0] 26 | 27 | # The short X.Y version 28 | version = fbchat.__version__ 29 | # The full version, including alpha/beta/rc tags 30 | release = fbchat.__version__ 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | needs_sphinx = "2.0" 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.intersphinx", 45 | "sphinx.ext.viewcode", 46 | "sphinx.ext.napoleon", 47 | "sphinxcontrib.spelling", 48 | "sphinx_autodoc_typehints", 49 | ] 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ["_templates"] 53 | 54 | # The master toctree document. 55 | master_doc = "index" 56 | 57 | # List of patterns, relative to source directory, that match files and 58 | # directories to ignore when looking for source files. 59 | # This pattern also affects html_static_path and html_extra_path. 60 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 61 | 62 | rst_prolog = ".. currentmodule:: " + project 63 | 64 | # The reST default role (used for this markup: `text`) to use for all 65 | # documents. 66 | # 67 | default_role = "any" 68 | 69 | # Make the reference parsing more strict 70 | # 71 | nitpicky = True 72 | 73 | # Prefer strict Python highlighting 74 | # 75 | highlight_language = "python3" 76 | 77 | # If true, '()' will be appended to :func: etc. cross-reference text. 78 | # 79 | add_function_parentheses = False 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = "alabaster" 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | html_theme_options = { 94 | "show_powered_by": False, 95 | "github_user": "carpedm20", 96 | "github_repo": project, 97 | "github_banner": True, 98 | "show_related": False, 99 | } 100 | 101 | # Custom sidebar templates, must be a dictionary that maps document names 102 | # to template names. 103 | # 104 | # The default sidebars (for documents that don't match any pattern) are 105 | # defined by theme itself. Builtin themes are using these templates by 106 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 107 | # 'searchbox.html']``. 108 | # 109 | html_sidebars = {"**": ["sidebar.html", "searchbox.html"]} 110 | 111 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 112 | # 113 | html_show_sphinx = False 114 | 115 | # If true, links to the reST sources are added to the pages. 116 | # 117 | html_show_sourcelink = False 118 | 119 | # A shorter title for the navigation bar. Default is the same as html_title. 120 | # 121 | html_short_title = description 122 | 123 | 124 | # -- Options for HTMLHelp output --------------------------------------------- 125 | 126 | # Output file base name for HTML help builder. 127 | htmlhelp_basename = project + "doc" 128 | 129 | 130 | # -- Options for LaTeX output ------------------------------------------------ 131 | 132 | # Grouping the document tree into LaTeX files. List of tuples 133 | # (source start file, target name, title, 134 | # author, documentclass [howto, manual, or own class]). 135 | latex_documents = [(master_doc, project + ".tex", project, author, "manual")] 136 | 137 | 138 | # -- Options for manual page output ------------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [(master_doc, project, project, [x.strip() for x in author.split(";")], 1)] 143 | 144 | 145 | # -- Options for Texinfo output ---------------------------------------------- 146 | 147 | # Grouping the document tree into Texinfo files. List of tuples 148 | # (source start file, target name, title, author, 149 | # dir menu entry, description, category) 150 | texinfo_documents = [ 151 | (master_doc, project, project, author, project, description, "Miscellaneous",) 152 | ] 153 | 154 | 155 | # -- Options for Epub output ------------------------------------------------- 156 | 157 | # A list of files that should not be packed into the epub file. 158 | epub_exclude_files = ["search.html"] 159 | 160 | 161 | # -- Extension configuration ------------------------------------------------- 162 | 163 | # -- Options for autodoc extension --------------------------------------- 164 | 165 | autoclass_content = "class" 166 | autodoc_member_order = "bysource" 167 | autodoc_default_options = {"members": True} 168 | 169 | # -- Options for intersphinx extension --------------------------------------- 170 | 171 | # Example configuration for intersphinx: refer to the Python standard library. 172 | intersphinx_mapping = {"https://docs.python.org/": None} 173 | 174 | # -- Options for napoleon extension ---------------------------------------------- 175 | 176 | # Use Google style docstrings 177 | napoleon_google_docstring = True 178 | napoleon_numpy_docstring = False 179 | 180 | # napoleon_use_admonition_for_examples = False 181 | # napoleon_use_admonition_for_notes = False 182 | # napoleon_use_admonition_for_references = False 183 | 184 | # -- Options for spelling extension ---------------------------------------------- 185 | 186 | spelling_word_list_filename = [ 187 | "spelling/names.txt", 188 | "spelling/technical.txt", 189 | "spelling/fixes.txt", 190 | ] 191 | spelling_ignore_wiki_words = False 192 | # spelling_ignore_acronyms = False 193 | spelling_ignore_python_builtins = False 194 | spelling_ignore_importable_modules = False 195 | -------------------------------------------------------------------------------- /docs/intro.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Welcome, this page will guide you through the basic concepts of using ``fbchat``. 5 | 6 | The hardest, and most error prone part is logging in, and managing your login session, so that is what we will look at first. 7 | 8 | 9 | Logging In 10 | ---------- 11 | 12 | Everything in ``fbchat`` starts with getting an instance of `Session`. Currently there are two ways of doing that, `Session.login` and `Session.from_cookies`. 13 | 14 | The follow example will prompt you for you password, and use it to login:: 15 | 16 | import getpass 17 | import fbchat 18 | session = fbchat.Session.login("<email/phone number>", getpass.getpass()) 19 | # If your account requires a two factor authentication code: 20 | session = fbchat.Session.login( 21 | "<your email/phone number>", 22 | getpass.getpass(), 23 | lambda: getpass.getpass("2FA code"), 24 | ) 25 | 26 | However, **this is not something you should do often!** Logging in/out all the time *will* get your Facebook account locked! 27 | 28 | Instead, you should start by using `Session.login`, and then store the cookies with `Session.get_cookies`, so that they can be used instead the next time your application starts. 29 | 30 | Usability-wise, this is also better, since you won't have to re-type your password every time you want to login. 31 | 32 | The following, quite lengthy, yet very import example, illustrates a way to do this: 33 | 34 | .. literalinclude:: ../examples/session_handling.py 35 | 36 | Assuming you have successfully completed the above, congratulations! Using ``fbchat`` should be mostly trouble free from now on! 37 | 38 | 39 | Understanding Thread Ids 40 | ------------------------ 41 | 42 | At the core of any thread is its unique identifier, its ID. 43 | 44 | A thread basically just means "something I can chat with", but more precisely, it can refer to a few things: 45 | - A Messenger group thread (`Group`) 46 | - The conversation between you and a single Facebook user (`User`) 47 | - The conversation between you and a Facebook Page (`Page`) 48 | 49 | You can get your own user ID from `Session.user` with ``session.user.id``. 50 | 51 | Getting the ID of a specific group thread is fairly trivial, you only need to login to `<https://www.messenger.com/>`_, click on the group you want to find the ID of, and then read the id from the address bar. 52 | The URL will look something like this: ``https://www.messenger.com/t/1234567890``, where ``1234567890`` would be the ID of the group. 53 | 54 | The same method can be applied to some user accounts, though if they have set a custom URL, then you will have to use a different method. 55 | 56 | An image to illustrate the process is shown below: 57 | 58 | .. image:: /_static/find-group-id.png 59 | :alt: An image illustrating how to find the ID of a group 60 | 61 | Once you have an ID, you can use it to create a `Group` or a `User` instance, which will allow you to do all sorts of things. To do this, you need a valid, logged in session:: 62 | 63 | group = fbchat.Group(session=session, id="<The id you found>") 64 | # Or for user threads 65 | user = fbchat.User(session=session, id="<The id you found>") 66 | 67 | Just like threads, every message, poll, plan, attachment, action etc. you send or do on Facebook has a unique ID. 68 | 69 | Below is an example of using such a message ID to get a `Message` instance:: 70 | 71 | # Provide the thread the message was created in, and it's ID 72 | message = fbchat.Message(thread=user, id="<The message id>") 73 | 74 | 75 | Fetching Information 76 | -------------------- 77 | 78 | Managing these ids yourself quickly becomes very cumbersome! Luckily, there are other, easier ways of getting `Group`/`User` instances. 79 | 80 | You would start by creating a `Client` instance, which is basically just a helper on top of `Session`, that will allow you to do various things:: 81 | 82 | client = fbchat.Client(session=session) 83 | 84 | Now, you could search for threads using `Client.search_for_threads`, or fetch a list of them using `Client.fetch_threads`:: 85 | 86 | # Fetch the 5 most likely search results 87 | # Uses Facebook's search functions, you don't have to specify the whole name, first names will usually be enough 88 | threads = list(client.search_for_threads("<name of the thread to search for>", limit=5)) 89 | # Fetch the 5 most recent threads in your account 90 | threads = list(client.fetch_threads(limit=5)) 91 | 92 | Note the `list` statements; this is because the methods actually return `generators <https://wiki.python.org/moin/Generators>`__. If you don't know what that means, don't worry, it is just something you can use to make your code faster later. 93 | 94 | The examples above will actually fetch `UserData`/`GroupData`, which are subclasses of `User`/`Group`. These model have extra properties, so you could for example print the names and ids of the fetched threads like this:: 95 | 96 | for thread in threads: 97 | print(f"{thread.id}: {thread.name}") 98 | 99 | Once you have a thread, you can use that to fetch the messages therein:: 100 | 101 | for message in thread.fetch_messages(limit=20): 102 | print(message.text) 103 | 104 | 105 | Interacting with Threads 106 | ------------------------ 107 | 108 | Once you have a `User`/`Group` instance, you can do things on them as described in `ThreadABC`, since they are subclasses of that. 109 | 110 | Some functionality, like adding users to a `Group`, or blocking a `User`, logically only works the relevant threads, so see the full API documentation for that. 111 | 112 | With that out of the way, let's see some examples! 113 | 114 | The simplest way of interacting with a thread is by sending a message:: 115 | 116 | # Send a message to the user 117 | message = user.send_text("test message") 118 | 119 | There are many types of messages you can send, see the full API documentation for more. 120 | 121 | Notice how we held on to the sent message? The return type i a `Message` instance, so you can interact with it afterwards:: 122 | 123 | # React to the message with the 😍 emoji 124 | message.react("😍") 125 | 126 | Besides sending messages, you can also interact with threads in other ways. An example is to change the thread color:: 127 | 128 | # Will change the thread color to the default blue 129 | thread.set_color("#0084ff") 130 | 131 | 132 | Listening & Events 133 | ------------------ 134 | 135 | Now, we are finally at the point we have all been waiting for: Creating an automatic Facebook bot! 136 | 137 | To get started, you create the functions you want to call on certain events:: 138 | 139 | def my_function(event: fbchat.MessageEvent): 140 | print(f"Message from {event.author.id}: {event.message.text}") 141 | 142 | Then you create a `fbchat.Listener` object:: 143 | 144 | listener = fbchat.Listener(session=session, chat_on=False, foreground=False) 145 | 146 | Which you can then use to receive events, and send them to your functions:: 147 | 148 | for event in listener.listen(): 149 | if isinstance(event, fbchat.MessageEvent): 150 | my_function(event) 151 | 152 | View the :ref:`examples` to see some more examples illustrating the event system. 153 | -------------------------------------------------------------------------------- /fbchat/_models/_plan.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | import enum 4 | from .._common import attrs_default 5 | from .. import _exception, _util, _session 6 | 7 | from typing import Mapping, Sequence, Optional 8 | 9 | 10 | class GuestStatus(enum.Enum): 11 | INVITED = 1 12 | GOING = 2 13 | DECLINED = 3 14 | 15 | 16 | ACONTEXT = { 17 | "action_history": [ 18 | {"surface": "messenger_chat_tab", "mechanism": "messenger_composer"} 19 | ] 20 | } 21 | 22 | 23 | @attrs_default 24 | class Plan: 25 | """Base model for plans. 26 | 27 | Example: 28 | >>> plan = fbchat.Plan(session=session, id="1234") 29 | """ 30 | 31 | #: The session to use when making requests. 32 | session = attr.ib(type=_session.Session) 33 | #: The plan's unique identifier. 34 | id = attr.ib(converter=str, type=str) 35 | 36 | def fetch(self) -> "PlanData": 37 | """Fetch fresh `PlanData` object. 38 | 39 | Example: 40 | >>> plan = plan.fetch() 41 | >>> plan.title 42 | "A plan" 43 | """ 44 | data = {"event_reminder_id": self.id} 45 | j = self.session._payload_post("/ajax/eventreminder", data) 46 | return PlanData._from_fetch(self.session, j) 47 | 48 | @classmethod 49 | def _create( 50 | cls, 51 | thread, 52 | name: str, 53 | at: datetime.datetime, 54 | location_name: str = None, 55 | location_id: str = None, 56 | ): 57 | data = { 58 | "event_type": "EVENT", 59 | "event_time": _util.datetime_to_seconds(at), 60 | "title": name, 61 | "thread_id": thread.id, 62 | "location_id": location_id or "", 63 | "location_name": location_name or "", 64 | "acontext": ACONTEXT, 65 | } 66 | j = thread.session._payload_post("/ajax/eventreminder/create", data) 67 | if "error" in j: 68 | raise _exception.ExternalError("Failed creating plan", j["error"]) 69 | 70 | def edit( 71 | self, 72 | name: str, 73 | at: datetime.datetime, 74 | location_name: str = None, 75 | location_id: str = None, 76 | ): 77 | """Edit the plan. 78 | 79 | # TODO: Arguments 80 | """ 81 | data = { 82 | "event_reminder_id": self.id, 83 | "delete": "false", 84 | "date": _util.datetime_to_seconds(at), 85 | "location_name": location_name or "", 86 | "location_id": location_id or "", 87 | "title": name, 88 | "acontext": ACONTEXT, 89 | } 90 | j = self.session._payload_post("/ajax/eventreminder/submit", data) 91 | 92 | def delete(self): 93 | """Delete the plan. 94 | 95 | Example: 96 | >>> plan.delete() 97 | """ 98 | data = {"event_reminder_id": self.id, "delete": "true", "acontext": ACONTEXT} 99 | j = self.session._payload_post("/ajax/eventreminder/submit", data) 100 | 101 | def _change_participation(self): 102 | data = { 103 | "event_reminder_id": self.id, 104 | "guest_state": "GOING" if take_part else "DECLINED", 105 | "acontext": ACONTEXT, 106 | } 107 | j = self.session._payload_post("/ajax/eventreminder/rsvp", data) 108 | 109 | def participate(self): 110 | """Set yourself as GOING/participating to the plan. 111 | 112 | Example: 113 | >>> plan.participate() 114 | """ 115 | return self._change_participation(True) 116 | 117 | def decline(self): 118 | """Set yourself as having DECLINED the plan. 119 | 120 | Example: 121 | >>> plan.decline() 122 | """ 123 | return self._change_participation(False) 124 | 125 | 126 | @attrs_default 127 | class PlanData(Plan): 128 | """Represents data about a plan.""" 129 | 130 | #: Plan time, only precise down to the minute 131 | time = attr.ib(type=datetime.datetime) 132 | #: Plan title 133 | title = attr.ib(type=str) 134 | #: Plan location name 135 | location = attr.ib(None, converter=lambda x: x or "", type=Optional[str]) 136 | #: Plan location ID 137 | location_id = attr.ib(None, converter=lambda x: x or "", type=Optional[str]) 138 | #: ID of the plan creator 139 | author_id = attr.ib(None, type=Optional[str]) 140 | #: `User` ids mapped to their `GuestStatus` 141 | guests = attr.ib(None, type=Optional[Mapping[str, GuestStatus]]) 142 | 143 | @property 144 | def going(self) -> Sequence[str]: 145 | """List of the `User` IDs who will take part in the plan.""" 146 | return [ 147 | id_ 148 | for id_, status in (self.guests or {}).items() 149 | if status is GuestStatus.GOING 150 | ] 151 | 152 | @property 153 | def declined(self) -> Sequence[str]: 154 | """List of the `User` IDs who won't take part in the plan.""" 155 | return [ 156 | id_ 157 | for id_, status in (self.guests or {}).items() 158 | if status is GuestStatus.DECLINED 159 | ] 160 | 161 | @property 162 | def invited(self) -> Sequence[str]: 163 | """List of the `User` IDs who are invited to the plan.""" 164 | return [ 165 | id_ 166 | for id_, status in (self.guests or {}).items() 167 | if status is GuestStatus.INVITED 168 | ] 169 | 170 | @classmethod 171 | def _from_pull(cls, session, data): 172 | return cls( 173 | session=session, 174 | id=data.get("event_id"), 175 | time=_util.seconds_to_datetime(int(data.get("event_time"))), 176 | title=data.get("event_title"), 177 | location=data.get("event_location_name"), 178 | location_id=data.get("event_location_id"), 179 | author_id=data.get("event_creator_id"), 180 | guests={ 181 | x["node"]["id"]: GuestStatus[x["guest_list_state"]] 182 | for x in _util.parse_json(data["guest_state_list"]) 183 | }, 184 | ) 185 | 186 | @classmethod 187 | def _from_fetch(cls, session, data): 188 | return cls( 189 | session=session, 190 | id=data.get("oid"), 191 | time=_util.seconds_to_datetime(data.get("event_time")), 192 | title=data.get("title"), 193 | location=data.get("location_name"), 194 | location_id=str(data["location_id"]) if data.get("location_id") else None, 195 | author_id=data.get("creator_id"), 196 | guests={id_: GuestStatus[s] for id_, s in data["event_members"].items()}, 197 | ) 198 | 199 | @classmethod 200 | def _from_graphql(cls, session, data): 201 | return cls( 202 | session=session, 203 | id=data.get("id"), 204 | time=_util.seconds_to_datetime(data.get("time")), 205 | title=data.get("event_title"), 206 | location=data.get("location_name"), 207 | author_id=data["lightweight_event_creator"].get("id"), 208 | guests={ 209 | x["node"]["id"]: GuestStatus[x["guest_list_state"]] 210 | for x in data["event_reminder_members"]["edges"] 211 | }, 212 | ) 213 | -------------------------------------------------------------------------------- /fbchat/_models/_file.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | from . import Image, Attachment 4 | from .._common import attrs_default 5 | from .. import _util 6 | 7 | from typing import Set, Optional 8 | 9 | 10 | @attrs_default 11 | class FileAttachment(Attachment): 12 | """Represents a file that has been sent as a Facebook attachment.""" 13 | 14 | #: URL where you can download the file 15 | url = attr.ib(None, type=Optional[str]) 16 | #: Size of the file in bytes 17 | size = attr.ib(None, type=Optional[int]) 18 | #: Name of the file 19 | name = attr.ib(None, type=Optional[str]) 20 | #: Whether Facebook determines that this file may be harmful 21 | is_malicious = attr.ib(None, type=Optional[bool]) 22 | 23 | @classmethod 24 | def _from_graphql(cls, data, size=None): 25 | return cls( 26 | url=data.get("url"), 27 | size=size, 28 | name=data.get("filename"), 29 | is_malicious=data.get("is_malicious"), 30 | id=data.get("message_file_fbid"), 31 | ) 32 | 33 | 34 | @attrs_default 35 | class AudioAttachment(Attachment): 36 | """Represents an audio file that has been sent as a Facebook attachment.""" 37 | 38 | #: Name of the file 39 | filename = attr.ib(None, type=Optional[str]) 40 | #: URL of the audio file 41 | url = attr.ib(None, type=Optional[str]) 42 | #: Duration of the audio clip 43 | duration = attr.ib(None, type=Optional[datetime.timedelta]) 44 | #: Audio type 45 | audio_type = attr.ib(None, type=Optional[str]) 46 | 47 | @classmethod 48 | def _from_graphql(cls, data): 49 | return cls( 50 | filename=data.get("filename"), 51 | url=data.get("playable_url"), 52 | duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")), 53 | audio_type=data.get("audio_type"), 54 | ) 55 | 56 | 57 | @attrs_default 58 | class ImageAttachment(Attachment): 59 | """Represents an image that has been sent as a Facebook attachment. 60 | 61 | To retrieve the full image URL, use: `Client.fetch_image_url`, and pass it the id of 62 | the image attachment. 63 | """ 64 | 65 | #: The extension of the original image (e.g. ``png``) 66 | original_extension = attr.ib(None, type=Optional[str]) 67 | #: Width of original image 68 | width = attr.ib(None, converter=_util.int_or_none, type=Optional[int]) 69 | #: Height of original image 70 | height = attr.ib(None, converter=_util.int_or_none, type=Optional[int]) 71 | #: Whether the image is animated 72 | is_animated = attr.ib(None, type=Optional[bool]) 73 | #: A set, containing variously sized / various types of previews of the image 74 | previews = attr.ib(factory=set, type=Set[Image]) 75 | 76 | @classmethod 77 | def _from_graphql(cls, data): 78 | previews = { 79 | Image._from_uri_or_none(data.get("thumbnail")), 80 | Image._from_uri_or_none(data.get("preview") or data.get("preview_image")), 81 | Image._from_uri_or_none(data.get("large_preview")), 82 | Image._from_uri_or_none(data.get("animated_image")), 83 | } 84 | 85 | return cls( 86 | original_extension=data.get("original_extension") 87 | or (data["filename"].split("-")[0] if data.get("filename") else None), 88 | width=data.get("original_dimensions", {}).get("width"), 89 | height=data.get("original_dimensions", {}).get("height"), 90 | is_animated=data["__typename"] == "MessageAnimatedImage", 91 | previews={p for p in previews if p}, 92 | id=data.get("legacy_attachment_id"), 93 | ) 94 | 95 | @classmethod 96 | def _from_list(cls, data): 97 | previews = { 98 | Image._from_uri_or_none(data["image"]), 99 | Image._from_uri(data["image1"]), 100 | Image._from_uri(data["image2"]), 101 | } 102 | 103 | return cls( 104 | width=data["original_dimensions"].get("x"), 105 | height=data["original_dimensions"].get("y"), 106 | previews={p for p in previews if p}, 107 | id=data["legacy_attachment_id"], 108 | ) 109 | 110 | 111 | @attrs_default 112 | class VideoAttachment(Attachment): 113 | """Represents a video that has been sent as a Facebook attachment.""" 114 | 115 | #: Size of the original video in bytes 116 | size = attr.ib(None, type=Optional[int]) 117 | #: Width of original video 118 | width = attr.ib(None, type=Optional[int]) 119 | #: Height of original video 120 | height = attr.ib(None, type=Optional[int]) 121 | #: Length of video 122 | duration = attr.ib(None, type=Optional[datetime.timedelta]) 123 | #: URL to very compressed preview video 124 | preview_url = attr.ib(None, type=Optional[str]) 125 | #: A set, containing variously sized previews of the video 126 | previews = attr.ib(factory=set, type=Set[Image]) 127 | 128 | @classmethod 129 | def _from_graphql(cls, data, size=None): 130 | previews = { 131 | Image._from_uri_or_none(data.get("chat_image")), 132 | Image._from_uri_or_none(data.get("inbox_image")), 133 | Image._from_uri_or_none(data.get("large_image")), 134 | } 135 | 136 | return cls( 137 | size=size, 138 | width=data.get("original_dimensions", {}).get("width"), 139 | height=data.get("original_dimensions", {}).get("height"), 140 | duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")), 141 | preview_url=data.get("playable_url"), 142 | previews={p for p in previews if p}, 143 | id=data.get("legacy_attachment_id"), 144 | ) 145 | 146 | @classmethod 147 | def _from_subattachment(cls, data): 148 | media = data["media"] 149 | image = Image._from_uri_or_none(media.get("image")) 150 | 151 | return cls( 152 | duration=_util.millis_to_timedelta(media.get("playable_duration_in_ms")), 153 | preview_url=media.get("playable_url"), 154 | previews={image} if image else {}, 155 | id=data["target"].get("video_id"), 156 | ) 157 | 158 | @classmethod 159 | def _from_list(cls, data): 160 | previews = { 161 | Image._from_uri(data["image"]), 162 | Image._from_uri(data["image1"]), 163 | Image._from_uri(data["image2"]), 164 | } 165 | 166 | return cls( 167 | width=data["original_dimensions"].get("x"), 168 | height=data["original_dimensions"].get("y"), 169 | previews=previews, 170 | id=data["legacy_attachment_id"], 171 | ) 172 | 173 | 174 | def graphql_to_attachment(data, size=None): 175 | _type = data["__typename"] 176 | if _type in ["MessageImage", "MessageAnimatedImage"]: 177 | return ImageAttachment._from_graphql(data) 178 | elif _type == "MessageVideo": 179 | return VideoAttachment._from_graphql(data, size=size) 180 | elif _type == "MessageAudio": 181 | return AudioAttachment._from_graphql(data) 182 | elif _type == "MessageFile": 183 | return FileAttachment._from_graphql(data, size=size) 184 | 185 | return Attachment(id=data.get("legacy_attachment_id")) 186 | 187 | 188 | def graphql_to_subattachment(data): 189 | target = data.get("target") 190 | type_ = target.get("__typename") if target else None 191 | 192 | if type_ == "Video": 193 | return VideoAttachment._from_subattachment(data) 194 | 195 | return None 196 | -------------------------------------------------------------------------------- /tests/threads/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import datetime 3 | import fbchat 4 | from fbchat import UserData, ActiveStatus 5 | 6 | 7 | def test_user_from_graphql(session): 8 | data = { 9 | "id": "1234", 10 | "name": "Abc Def Ghi", 11 | "first_name": "Abc", 12 | "last_name": "Ghi", 13 | "profile_picture": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/..."}, 14 | "is_viewer_friend": True, 15 | "url": "https://www.facebook.com/profile.php?id=1234", 16 | "gender": "FEMALE", 17 | "viewer_affinity": 0.4560002, 18 | } 19 | assert UserData( 20 | session=session, 21 | id="1234", 22 | photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), 23 | name="Abc Def Ghi", 24 | url="https://www.facebook.com/profile.php?id=1234", 25 | first_name="Abc", 26 | last_name="Ghi", 27 | is_friend=True, 28 | gender="female_singular", 29 | affinity=0.4560002, 30 | color="#0084ff", 31 | ) == UserData._from_graphql(session, data) 32 | 33 | 34 | def test_user_from_thread_fetch(session): 35 | data = { 36 | "thread_key": {"thread_fbid": None, "other_user_id": "1234"}, 37 | "name": None, 38 | "last_message": { 39 | "nodes": [ 40 | { 41 | "snippet": "aaa", 42 | "message_sender": {"messaging_actor": {"id": "1234"}}, 43 | "timestamp_precise": "1500000000000", 44 | "commerce_message_type": None, 45 | "extensible_attachment": None, 46 | "sticker": None, 47 | "blob_attachments": [], 48 | } 49 | ] 50 | }, 51 | "unread_count": 0, 52 | "messages_count": 1111, 53 | "image": None, 54 | "updated_time_precise": "1500000000000", 55 | "mute_until": None, 56 | "is_pin_protected": False, 57 | "is_viewer_subscribed": True, 58 | "thread_queue_enabled": False, 59 | "folder": "INBOX", 60 | "has_viewer_archived": False, 61 | "is_page_follow_up": False, 62 | "cannot_reply_reason": None, 63 | "ephemeral_ttl_mode": 0, 64 | "customization_info": { 65 | "emoji": None, 66 | "participant_customizations": [ 67 | {"participant_id": "4321", "nickname": "B"}, 68 | {"participant_id": "1234", "nickname": "A"}, 69 | ], 70 | "outgoing_bubble_color": None, 71 | }, 72 | "thread_admins": [], 73 | "approval_mode": None, 74 | "joinable_mode": {"mode": "0", "link": ""}, 75 | "thread_queue_metadata": None, 76 | "event_reminders": {"nodes": []}, 77 | "montage_thread": None, 78 | "last_read_receipt": {"nodes": [{"timestamp_precise": "1500000050000"}]}, 79 | "related_page_thread": None, 80 | "rtc_call_data": { 81 | "call_state": "NO_ONGOING_CALL", 82 | "server_info_data": "", 83 | "initiator": None, 84 | }, 85 | "associated_object": None, 86 | "privacy_mode": 1, 87 | "reactions_mute_mode": "REACTIONS_NOT_MUTED", 88 | "mentions_mute_mode": "MENTIONS_NOT_MUTED", 89 | "customization_enabled": True, 90 | "thread_type": "ONE_TO_ONE", 91 | "participant_add_mode_as_string": None, 92 | "is_canonical_neo_user": False, 93 | "participants_event_status": [], 94 | "page_comm_item": None, 95 | "all_participants": { 96 | "nodes": [ 97 | { 98 | "messaging_actor": { 99 | "id": "1234", 100 | "__typename": "User", 101 | "name": "Abc Def Ghi", 102 | "gender": "FEMALE", 103 | "url": "https://www.facebook.com/profile.php?id=1234", 104 | "big_image_src": { 105 | "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/..." 106 | }, 107 | "short_name": "Abc", 108 | "username": "", 109 | "is_viewer_friend": True, 110 | "is_messenger_user": True, 111 | "is_verified": False, 112 | "is_message_blocked_by_viewer": False, 113 | "is_viewer_coworker": False, 114 | "is_employee": None, 115 | } 116 | }, 117 | { 118 | "messaging_actor": { 119 | "id": "4321", 120 | "__typename": "User", 121 | "name": "Aaa Bbb Ccc", 122 | "gender": "NEUTER", 123 | "url": "https://www.facebook.com/aaabbbccc", 124 | "big_image_src": { 125 | "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/..." 126 | }, 127 | "short_name": "Aaa", 128 | "username": "aaabbbccc", 129 | "is_viewer_friend": False, 130 | "is_messenger_user": True, 131 | "is_verified": False, 132 | "is_message_blocked_by_viewer": False, 133 | "is_viewer_coworker": False, 134 | "is_employee": None, 135 | } 136 | }, 137 | ] 138 | }, 139 | "read_receipts": ..., 140 | "delivery_receipts": ..., 141 | } 142 | assert UserData( 143 | session=session, 144 | id="1234", 145 | photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), 146 | name="Abc Def Ghi", 147 | last_active=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), 148 | message_count=1111, 149 | url="https://www.facebook.com/profile.php?id=1234", 150 | first_name="Abc", 151 | is_friend=True, 152 | gender="female_singular", 153 | nickname="A", 154 | own_nickname="B", 155 | color="#0084ff", 156 | emoji=None, 157 | ) == UserData._from_thread_fetch(session, data) 158 | 159 | 160 | def test_user_from_all_fetch(session): 161 | data = { 162 | "id": "1234", 163 | "name": "Abc Def Ghi", 164 | "firstName": "Abc", 165 | "vanity": "", 166 | "thumbSrc": "https://scontent-arn2-1.xx.fbcdn.net/v/...", 167 | "uri": "https://www.facebook.com/profile.php?id=1234", 168 | "gender": 1, 169 | "i18nGender": 2, 170 | "type": "friend", 171 | "is_friend": True, 172 | "mThumbSrcSmall": None, 173 | "mThumbSrcLarge": None, 174 | "dir": None, 175 | "searchTokens": ["Abc", "Ghi"], 176 | "alternateName": "", 177 | "is_nonfriend_messenger_contact": False, 178 | "is_blocked": False, 179 | } 180 | assert UserData( 181 | session=session, 182 | id="1234", 183 | photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), 184 | name="Abc Def Ghi", 185 | url="https://www.facebook.com/profile.php?id=1234", 186 | first_name="Abc", 187 | is_friend=True, 188 | gender="female_singular", 189 | ) == UserData._from_all_fetch(session, data) 190 | 191 | 192 | @pytest.mark.skip(reason="can't gather test data, the pulling is broken") 193 | def test_active_status_from_chatproxy_presence(): 194 | assert ActiveStatus() == ActiveStatus._from_chatproxy_presence(data) 195 | 196 | 197 | @pytest.mark.skip(reason="can't gather test data, the pulling is broken") 198 | def test_active_status_from_buddylist_overlay(): 199 | assert ActiveStatus() == ActiveStatus._from_buddylist_overlay(data) 200 | -------------------------------------------------------------------------------- /fbchat/_threads/_user.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | from ._abc import ThreadABC 4 | from .._common import log, attrs_default 5 | from .. import _util, _session, _models 6 | 7 | from typing import Optional 8 | 9 | 10 | GENDERS = { 11 | # For standard requests 12 | 0: "unknown", 13 | 1: "female_singular", 14 | 2: "male_singular", 15 | 3: "female_singular_guess", 16 | 4: "male_singular_guess", 17 | 5: "mixed", 18 | 6: "neuter_singular", 19 | 7: "unknown_singular", 20 | 8: "female_plural", 21 | 9: "male_plural", 22 | 10: "neuter_plural", 23 | 11: "unknown_plural", 24 | # For graphql requests 25 | "UNKNOWN": "unknown", 26 | "FEMALE": "female_singular", 27 | "MALE": "male_singular", 28 | # '': 'female_singular_guess', 29 | # '': 'male_singular_guess', 30 | # '': 'mixed', 31 | "NEUTER": "neuter_singular", 32 | # '': 'unknown_singular', 33 | # '': 'female_plural', 34 | # '': 'male_plural', 35 | # '': 'neuter_plural', 36 | # '': 'unknown_plural', 37 | } 38 | 39 | 40 | @attrs_default 41 | class User(ThreadABC): 42 | """Represents a Facebook user. Implements `ThreadABC`. 43 | 44 | Example: 45 | >>> user = fbchat.User(session=session, id="1234") 46 | """ 47 | 48 | #: The session to use when making requests. 49 | session = attr.ib(type=_session.Session) 50 | #: The user's unique identifier. 51 | id = attr.ib(converter=str, type=str) 52 | 53 | def _to_send_data(self): 54 | return { 55 | "other_user_fbid": self.id, 56 | # The entry below is to support .wave 57 | "specific_to_list[0]": "fbid:{}".format(self.id), 58 | } 59 | 60 | def _copy(self) -> "User": 61 | return User(session=self.session, id=self.id) 62 | 63 | def confirm_friend_request(self): 64 | """Confirm a friend request, adding the user to your friend list. 65 | 66 | Example: 67 | >>> user.confirm_friend_request() 68 | """ 69 | data = {"to_friend": self.id, "action": "confirm"} 70 | j = self.session._payload_post("/ajax/add_friend/action.php?dpr=1", data) 71 | 72 | def remove_friend(self): 73 | """Remove the user from the client's friend list. 74 | 75 | Example: 76 | >>> user.remove_friend() 77 | """ 78 | data = {"uid": self.id} 79 | j = self.session._payload_post("/ajax/profile/removefriendconfirm.php", data) 80 | 81 | def block(self): 82 | """Block messages from the user. 83 | 84 | Example: 85 | >>> user.block() 86 | """ 87 | data = {"fbid": self.id} 88 | j = self.session._payload_post("/messaging/block_messages/?dpr=1", data) 89 | 90 | def unblock(self): 91 | """Unblock a previously blocked user. 92 | 93 | Example: 94 | >>> user.unblock() 95 | """ 96 | data = {"fbid": self.id} 97 | j = self.session._payload_post("/messaging/unblock_messages/?dpr=1", data) 98 | 99 | 100 | @attrs_default 101 | class UserData(User): 102 | """Represents data about a Facebook user. 103 | 104 | Inherits `User`, and implements `ThreadABC`. 105 | """ 106 | 107 | #: The user's picture 108 | photo = attr.ib(type=_models.Image) 109 | #: The name of the user 110 | name = attr.ib(type=str) 111 | #: Whether the user and the client are friends 112 | is_friend = attr.ib(type=bool) 113 | #: The users first name 114 | first_name = attr.ib(type=str) 115 | #: The users last name 116 | last_name = attr.ib(None, type=Optional[str]) 117 | #: When the thread was last active / when the last message was sent 118 | last_active = attr.ib(None, type=Optional[datetime.datetime]) 119 | #: Number of messages in the thread 120 | message_count = attr.ib(None, type=Optional[int]) 121 | #: Set `Plan` 122 | plan = attr.ib(None, type=Optional[_models.PlanData]) 123 | #: The profile URL. ``None`` for Messenger-only users 124 | url = attr.ib(None, type=Optional[str]) 125 | #: The user's gender 126 | gender = attr.ib(None, type=Optional[str]) 127 | #: From 0 to 1. How close the client is to the user 128 | affinity = attr.ib(None, type=Optional[float]) 129 | #: The user's nickname 130 | nickname = attr.ib(None, type=Optional[str]) 131 | #: The clients nickname, as seen by the user 132 | own_nickname = attr.ib(None, type=Optional[str]) 133 | #: The message color 134 | color = attr.ib(None, type=Optional[str]) 135 | #: The default emoji 136 | emoji = attr.ib(None, type=Optional[str]) 137 | 138 | @staticmethod 139 | def _get_other_user(data): 140 | (user,) = ( 141 | node["messaging_actor"] 142 | for node in data["all_participants"]["nodes"] 143 | if node["messaging_actor"]["id"] == data["thread_key"]["other_user_id"] 144 | ) 145 | return user 146 | 147 | @classmethod 148 | def _from_graphql(cls, session, data): 149 | c_info = cls._parse_customization_info(data) 150 | 151 | plan = None 152 | if data.get("event_reminders") and data["event_reminders"].get("nodes"): 153 | plan = _models.PlanData._from_graphql( 154 | session, data["event_reminders"]["nodes"][0] 155 | ) 156 | 157 | return cls( 158 | session=session, 159 | id=data["id"], 160 | url=data["url"], 161 | first_name=data["first_name"], 162 | last_name=data.get("last_name"), 163 | is_friend=data["is_viewer_friend"], 164 | gender=GENDERS.get(data["gender"]), 165 | affinity=data.get("viewer_affinity"), 166 | nickname=c_info.get("nickname"), 167 | color=c_info["color"], 168 | emoji=c_info["emoji"], 169 | own_nickname=c_info.get("own_nickname"), 170 | photo=_models.Image._from_uri(data["profile_picture"]), 171 | name=data["name"], 172 | message_count=data.get("messages_count"), 173 | plan=plan, 174 | ) 175 | 176 | @classmethod 177 | def _from_thread_fetch(cls, session, data): 178 | user = cls._get_other_user(data) 179 | if user["__typename"] != "User": 180 | # TODO: Add Page._from_thread_fetch, and parse it there 181 | log.warning("Tried to parse %s as a user.", user["__typename"]) 182 | return None 183 | 184 | c_info = cls._parse_customization_info(data) 185 | 186 | plan = None 187 | if data["event_reminders"]["nodes"]: 188 | plan = _models.PlanData._from_graphql( 189 | session, data["event_reminders"]["nodes"][0] 190 | ) 191 | 192 | return cls( 193 | session=session, 194 | id=user["id"], 195 | url=user["url"], 196 | name=user["name"], 197 | first_name=user["short_name"], 198 | is_friend=user["is_viewer_friend"], 199 | gender=GENDERS.get(user["gender"]), 200 | nickname=c_info.get("nickname"), 201 | color=c_info["color"], 202 | emoji=c_info["emoji"], 203 | own_nickname=c_info.get("own_nickname"), 204 | photo=_models.Image._from_uri(user["big_image_src"]), 205 | message_count=data["messages_count"], 206 | last_active=_util.millis_to_datetime(int(data["updated_time_precise"])), 207 | plan=plan, 208 | ) 209 | 210 | @classmethod 211 | def _from_all_fetch(cls, session, data): 212 | return cls( 213 | session=session, 214 | id=data["id"], 215 | first_name=data["firstName"], 216 | url=data["uri"], 217 | photo=_models.Image(url=data["thumbSrc"]), 218 | name=data["name"], 219 | is_friend=data["is_friend"], 220 | gender=GENDERS.get(data["gender"]), 221 | ) 222 | -------------------------------------------------------------------------------- /fbchat/_events/_delta_class.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import datetime 3 | from ._common import attrs_event, Event, UnknownEvent, ThreadEvent 4 | from . import _delta_type 5 | from .. import _util, _threads, _models 6 | 7 | from typing import Sequence, Optional 8 | 9 | 10 | @attrs_event 11 | class PeopleAdded(ThreadEvent): 12 | """somebody added people to a group thread.""" 13 | 14 | # TODO: Add message id 15 | 16 | thread = attr.ib(type="_threads.Group") # Set the correct type 17 | #: The people who got added 18 | added = attr.ib(type=Sequence["_threads.User"]) 19 | #: When the people were added 20 | at = attr.ib(type=datetime.datetime) 21 | 22 | @classmethod 23 | def _parse(cls, session, data): 24 | author, thread, at = cls._parse_metadata(session, data) 25 | added = [ 26 | # TODO: Parse user name 27 | _threads.User(session=session, id=x["userFbId"]) 28 | for x in data["addedParticipants"] 29 | ] 30 | return cls(author=author, thread=thread, added=added, at=at) 31 | 32 | 33 | @attrs_event 34 | class PersonRemoved(ThreadEvent): 35 | """Somebody removed a person from a group thread.""" 36 | 37 | # TODO: Add message id 38 | 39 | thread = attr.ib(type="_threads.Group") # Set the correct type 40 | #: Person who got removed 41 | removed = attr.ib(type="_models.Message") 42 | #: When the person were removed 43 | at = attr.ib(type=datetime.datetime) 44 | 45 | @classmethod 46 | def _parse(cls, session, data): 47 | author, thread, at = cls._parse_metadata(session, data) 48 | removed = _threads.User(session=session, id=data["leftParticipantFbId"]) 49 | return cls(author=author, thread=thread, removed=removed, at=at) 50 | 51 | 52 | @attrs_event 53 | class TitleSet(ThreadEvent): 54 | """Somebody changed a group's title.""" 55 | 56 | thread = attr.ib(type="_threads.Group") # Set the correct type 57 | #: The new title. If ``None``, the title was removed 58 | title = attr.ib(type=Optional[str]) 59 | #: When the title was set 60 | at = attr.ib(type=datetime.datetime) 61 | 62 | @classmethod 63 | def _parse(cls, session, data): 64 | author, thread, at = cls._parse_metadata(session, data) 65 | return cls(author=author, thread=thread, title=data["name"] or None, at=at) 66 | 67 | 68 | @attrs_event 69 | class UnfetchedThreadEvent(Event): 70 | """A message was received, but the data must be fetched manually. 71 | 72 | Use `Message.fetch` to retrieve the message data. 73 | 74 | This is usually used when somebody changes the group's photo, or when a new pending 75 | group is created. 76 | """ 77 | 78 | # TODO: Present this in a way that users can fetch the changed group photo easily 79 | 80 | #: The thread the message was sent to 81 | thread = attr.ib(type="_threads.ThreadABC") 82 | #: The message 83 | message = attr.ib(type=Optional["_models.Message"]) 84 | 85 | @classmethod 86 | def _parse(cls, session, data): 87 | thread = cls._get_thread(session, data) 88 | message = None 89 | if "messageId" in data: 90 | message = _models.Message(thread=thread, id=data["messageId"]) 91 | return cls(thread=thread, message=message) 92 | 93 | 94 | @attrs_event 95 | class MessagesDelivered(ThreadEvent): 96 | """Somebody marked messages as delivered in a thread.""" 97 | 98 | #: The messages that were marked as delivered 99 | messages = attr.ib(type=Sequence["_models.Message"]) 100 | #: When the messages were delivered 101 | at = attr.ib(type=datetime.datetime) 102 | 103 | @classmethod 104 | def _parse(cls, session, data): 105 | thread = cls._get_thread(session, data) 106 | if "actorFbId" in data: 107 | author = _threads.User(session=session, id=data["actorFbId"]) 108 | else: 109 | author = thread 110 | messages = [_models.Message(thread=thread, id=x) for x in data["messageIds"]] 111 | at = _util.millis_to_datetime(int(data["deliveredWatermarkTimestampMs"])) 112 | return cls(author=author, thread=thread, messages=messages, at=at) 113 | 114 | 115 | @attrs_event 116 | class ThreadsRead(Event): 117 | """Somebody marked threads as read/seen.""" 118 | 119 | #: The person who marked the threads as read 120 | author = attr.ib(type="_threads.ThreadABC") 121 | #: The threads that were marked as read 122 | threads = attr.ib(type=Sequence["_threads.ThreadABC"]) 123 | #: When the threads were read 124 | at = attr.ib(type=datetime.datetime) 125 | 126 | @classmethod 127 | def _parse_read_receipt(cls, session, data): 128 | author = _threads.User(session=session, id=data["actorFbId"]) 129 | thread = cls._get_thread(session, data) 130 | at = _util.millis_to_datetime(int(data["actionTimestampMs"])) 131 | return cls(author=author, threads=[thread], at=at) 132 | 133 | @classmethod 134 | def _parse(cls, session, data): 135 | threads = [ 136 | cls._get_thread(session, {"threadKey": x}) for x in data["threadKeys"] 137 | ] 138 | at = _util.millis_to_datetime(int(data["actionTimestamp"])) 139 | return cls(author=session.user, threads=threads, at=at) 140 | 141 | 142 | @attrs_event 143 | class MessageEvent(ThreadEvent): 144 | """Somebody sent a message to a thread.""" 145 | 146 | #: The sent message 147 | message = attr.ib(type="_models.Message") 148 | #: When the threads were read 149 | at = attr.ib(type=datetime.datetime) 150 | 151 | @classmethod 152 | def _parse(cls, session, data): 153 | author, thread, at = cls._parse_metadata(session, data) 154 | message = _models.MessageData._from_pull( 155 | thread, data, author=author.id, created_at=at, 156 | ) 157 | return cls(author=author, thread=thread, message=message, at=at) 158 | 159 | 160 | @attrs_event 161 | class ThreadFolder(Event): 162 | """A thread was created in a folder. 163 | 164 | Somebody that isn't connected with you on either Facebook or Messenger sends a 165 | message. After that, you need to use `ThreadABC.fetch_messages` to actually read it. 166 | """ 167 | 168 | # TODO: Finish this 169 | 170 | #: The created thread 171 | thread = attr.ib(type="_threads.ThreadABC") 172 | #: The folder/location 173 | folder = attr.ib(type="_models.ThreadLocation") 174 | 175 | @classmethod 176 | def _parse(cls, session, data): 177 | thread = cls._get_thread(session, data) 178 | folder = _models.ThreadLocation._parse(data["folder"]) 179 | return cls(thread=thread, folder=folder) 180 | 181 | 182 | def parse_delta(session, data): 183 | class_ = data["class"] 184 | if class_ == "AdminTextMessage": 185 | return _delta_type.parse_admin_message(session, data) 186 | elif class_ == "ParticipantsAddedToGroupThread": 187 | return PeopleAdded._parse(session, data) 188 | elif class_ == "ParticipantLeftGroupThread": 189 | return PersonRemoved._parse(session, data) 190 | elif class_ == "MarkFolderSeen": 191 | # TODO: Finish this 192 | folders = [_models.ThreadLocation._parse(folder) for folder in data["folders"]] 193 | at = _util.millis_to_datetime(int(data["timestamp"])) 194 | return None 195 | elif class_ == "ThreadName": 196 | return TitleSet._parse(session, data) 197 | elif class_ == "ForcedFetch": 198 | return UnfetchedThreadEvent._parse(session, data) 199 | elif class_ == "DeliveryReceipt": 200 | return MessagesDelivered._parse(session, data) 201 | elif class_ == "ReadReceipt": 202 | return ThreadsRead._parse_read_receipt(session, data) 203 | elif class_ == "MarkRead": 204 | return ThreadsRead._parse(session, data) 205 | elif class_ == "NoOp": 206 | # Skip "no operation" events 207 | return None 208 | elif class_ == "NewMessage": 209 | return MessageEvent._parse(session, data) 210 | elif class_ == "ThreadFolder": 211 | return ThreadFolder._parse(session, data) 212 | elif class_ == "ClientPayload": 213 | raise ValueError("This is implemented in `parse_events`") 214 | return UnknownEvent(source="Delta class", data=data) 215 | -------------------------------------------------------------------------------- /tests/test_exception.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from fbchat import ( 4 | FacebookError, 5 | HTTPError, 6 | ParseError, 7 | ExternalError, 8 | GraphQLError, 9 | InvalidParameters, 10 | NotLoggedIn, 11 | PleaseRefresh, 12 | ) 13 | from fbchat._exception import ( 14 | handle_payload_error, 15 | handle_graphql_errors, 16 | handle_http_error, 17 | handle_requests_error, 18 | ) 19 | 20 | 21 | ERROR_DATA = [ 22 | ( 23 | PleaseRefresh, 24 | 1357004, 25 | "Sorry, something went wrong", 26 | "Please try closing and re-opening your browser window.", 27 | ), 28 | ( 29 | InvalidParameters, 30 | 1357031, 31 | "This content is no longer available", 32 | ( 33 | "The content you requested cannot be displayed at the moment. It may be" 34 | " temporarily unavailable, the link you clicked on may have expired or you" 35 | " may not have permission to view this page." 36 | ), 37 | ), 38 | ( 39 | InvalidParameters, 40 | 1545010, 41 | "Messages Unavailable", 42 | ( 43 | "Sorry, messages are temporarily unavailable." 44 | " Please try again in a few minutes." 45 | ), 46 | ), 47 | ( 48 | ExternalError, 49 | 1545026, 50 | "Unable to Attach File", 51 | ( 52 | "The type of file you're trying to attach isn't allowed." 53 | " Please try again with a different format." 54 | ), 55 | ), 56 | (InvalidParameters, 1545003, "Invalid action", "You cannot perform that action."), 57 | ( 58 | ExternalError, 59 | 1545012, 60 | "Temporary Failure", 61 | "There was a temporary error, please try again.", 62 | ), 63 | ] 64 | 65 | 66 | @pytest.mark.parametrize("exception,code,summary,description", ERROR_DATA) 67 | def test_handle_payload_error(exception, code, summary, description): 68 | data = {"error": code, "errorSummary": summary, "errorDescription": description} 69 | with pytest.raises(exception, match=r"#\d+ .+:"): 70 | handle_payload_error(data) 71 | 72 | 73 | def test_handle_not_logged_in_error(): 74 | data = { 75 | "error": 1357001, 76 | "errorSummary": "Not logged in", 77 | "errorDescription": "Please log in to continue.", 78 | } 79 | with pytest.raises(NotLoggedIn, match="Not logged in"): 80 | handle_payload_error(data) 81 | 82 | 83 | def test_handle_payload_error_no_error(): 84 | assert handle_payload_error({}) is None 85 | assert handle_payload_error({"payload": {"abc": ["Something", "else"]}}) is None 86 | 87 | 88 | def test_handle_graphql_crash(): 89 | error = { 90 | "allow_user_retry": False, 91 | "api_error_code": -1, 92 | "code": 1675030, 93 | "debug_info": None, 94 | "description": "Error performing query.", 95 | "fbtrace_id": "ABCDEFG", 96 | "is_silent": False, 97 | "is_transient": False, 98 | "message": ( 99 | 'Errors while executing operation "MessengerThreadSharedLinks":' 100 | " At Query.message_thread: Field implementation threw an exception." 101 | " Check your server logs for more information." 102 | ), 103 | "path": ["message_thread"], 104 | "query_path": None, 105 | "requires_reauth": False, 106 | "severity": "CRITICAL", 107 | "summary": "Query error", 108 | } 109 | with pytest.raises( 110 | GraphQLError, match="#1675030 Query error: Errors while executing" 111 | ): 112 | handle_graphql_errors({"data": {"message_thread": None}, "errors": [error]}) 113 | 114 | 115 | def test_handle_graphql_invalid_values(): 116 | error = { 117 | "message": ( 118 | 'Invalid values provided for variables of operation "MessengerThreadlist":' 119 | ' Value ""as"" cannot be used for variable "$limit": Expected an integer' 120 | ' value, got "as".' 121 | ), 122 | "severity": "CRITICAL", 123 | "code": 1675012, 124 | "api_error_code": None, 125 | "summary": "Your request couldn't be processed", 126 | "description": ( 127 | "There was a problem with this request." 128 | " We're working on getting it fixed as soon as we can." 129 | ), 130 | "is_silent": False, 131 | "is_transient": False, 132 | "requires_reauth": False, 133 | "allow_user_retry": False, 134 | "debug_info": None, 135 | "query_path": None, 136 | "fbtrace_id": "ABCDEFG", 137 | "www_request_id": "AABBCCDDEEFFGG", 138 | } 139 | msg = "#1675012 Your request couldn't be processed: Invalid values" 140 | with pytest.raises(GraphQLError, match=msg): 141 | handle_graphql_errors({"errors": [error]}) 142 | 143 | 144 | def test_handle_graphql_no_message(): 145 | error = { 146 | "code": 1675012, 147 | "api_error_code": None, 148 | "summary": "Your request couldn't be processed", 149 | "description": ( 150 | "There was a problem with this request." 151 | " We're working on getting it fixed as soon as we can." 152 | ), 153 | "is_silent": False, 154 | "is_transient": False, 155 | "requires_reauth": False, 156 | "allow_user_retry": False, 157 | "debug_info": None, 158 | "query_path": None, 159 | "fbtrace_id": "ABCDEFG", 160 | "www_request_id": "AABBCCDDEEFFGG", 161 | "sentry_block_user_info": None, 162 | "help_center_id": None, 163 | } 164 | msg = "#1675012 Your request couldn't be processed: " 165 | with pytest.raises(GraphQLError, match=msg): 166 | handle_graphql_errors({"errors": [error]}) 167 | 168 | 169 | def test_handle_graphql_no_summary(): 170 | error = { 171 | "message": ( 172 | 'Errors while executing operation "MessengerViewerContactMethods":' 173 | " At Query.viewer:Viewer.all_emails: Field implementation threw an" 174 | " exception. Check your server logs for more information." 175 | ), 176 | "severity": "ERROR", 177 | "path": ["viewer", "all_emails"], 178 | } 179 | with pytest.raises(GraphQLError, match="Unknown error: Errors while executing"): 180 | handle_graphql_errors( 181 | {"data": {"viewer": {"user": None, "all_emails": []}}, "errors": [error]} 182 | ) 183 | 184 | 185 | def test_handle_graphql_syntax_error(): 186 | error = { 187 | "code": 1675001, 188 | "api_error_code": None, 189 | "summary": "Query Syntax Error", 190 | "description": "Syntax error.", 191 | "is_silent": True, 192 | "is_transient": False, 193 | "requires_reauth": False, 194 | "allow_user_retry": False, 195 | "debug_info": 'Unexpected ">" at character 328: Expected ")".', 196 | "query_path": None, 197 | "fbtrace_id": "ABCDEFG", 198 | "www_request_id": "AABBCCDDEEFFGG", 199 | "sentry_block_user_info": None, 200 | "help_center_id": None, 201 | } 202 | msg = "#1675001 Query Syntax Error: " 203 | with pytest.raises(GraphQLError, match=msg): 204 | handle_graphql_errors({"response": None, "error": error}) 205 | 206 | 207 | def test_handle_graphql_errors_singular_error_key(): 208 | with pytest.raises(GraphQLError, match="#123"): 209 | handle_graphql_errors({"error": {"code": 123}}) 210 | 211 | 212 | def test_handle_graphql_errors_no_error(): 213 | assert handle_graphql_errors({"data": {"message_thread": None}}) is None 214 | 215 | 216 | def test_handle_http_error(): 217 | with pytest.raises(HTTPError): 218 | handle_http_error(400) 219 | with pytest.raises(HTTPError): 220 | handle_http_error(500) 221 | 222 | 223 | def test_handle_http_error_404_handling(): 224 | with pytest.raises(HTTPError, match="invalid id"): 225 | handle_http_error(404) 226 | 227 | 228 | def test_handle_http_error_no_error(): 229 | assert handle_http_error(200) is None 230 | assert handle_http_error(302) is None 231 | 232 | 233 | def test_handle_requests_error(): 234 | with pytest.raises(HTTPError, match="Connection error"): 235 | handle_requests_error(requests.ConnectionError()) 236 | with pytest.raises(HTTPError, match="Requests error"): 237 | handle_requests_error(requests.RequestException()) 238 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import fbchat 3 | import datetime 4 | from fbchat._util import ( 5 | strip_json_cruft, 6 | parse_json, 7 | get_jsmods_require, 8 | get_jsmods_define, 9 | mimetype_to_key, 10 | get_url_parameter, 11 | seconds_to_datetime, 12 | millis_to_datetime, 13 | datetime_to_seconds, 14 | datetime_to_millis, 15 | seconds_to_timedelta, 16 | millis_to_timedelta, 17 | timedelta_to_seconds, 18 | ) 19 | 20 | 21 | def test_strip_json_cruft(): 22 | assert strip_json_cruft('for(;;);{"abc": "def"}') == '{"abc": "def"}' 23 | assert strip_json_cruft('{"abc": "def"}') == '{"abc": "def"}' 24 | 25 | 26 | def test_strip_json_cruft_invalid(): 27 | with pytest.raises(AttributeError): 28 | strip_json_cruft(None) 29 | with pytest.raises(fbchat.ParseError, match="No JSON object found"): 30 | strip_json_cruft("No JSON object here!") 31 | 32 | 33 | def test_parse_json(): 34 | assert parse_json('{"a":"b"}') == {"a": "b"} 35 | 36 | 37 | def test_parse_json_invalid(): 38 | with pytest.raises(fbchat.ParseError, match="Error while parsing JSON"): 39 | parse_json("No JSON object here!") 40 | 41 | 42 | def test_get_jsmods_require(): 43 | argument = { 44 | "signalsToCollect": [ 45 | 30000, 46 | 30001, 47 | 30003, 48 | 30004, 49 | 30005, 50 | 30002, 51 | 30007, 52 | 30008, 53 | 30009, 54 | ] 55 | } 56 | data = [ 57 | ["BanzaiODS"], 58 | [ 59 | "TuringClientSignalCollectionTrigger", 60 | "startStaticSignalCollection", 61 | [], 62 | [argument], 63 | ], 64 | ] 65 | assert get_jsmods_require(data) == { 66 | "BanzaiODS": [], 67 | "TuringClientSignalCollectionTrigger.startStaticSignalCollection": [argument], 68 | } 69 | 70 | 71 | def test_get_jsmods_require_version_specifier(): 72 | data = [ 73 | ["DimensionTracking@1234"], 74 | ["CavalryLoggerImpl@2345", "startInstrumentation", [], []], 75 | ] 76 | assert get_jsmods_require(data) == { 77 | "DimensionTracking": [], 78 | "CavalryLoggerImpl.startInstrumentation": [], 79 | } 80 | 81 | 82 | def test_get_jsmods_require_get_image_url(): 83 | data = [ 84 | [ 85 | "ServerRedirect", 86 | "redirectPageTo", 87 | [], 88 | ["https://scontent-arn2-1.xx.fbcdn.net/v/image.png&dl=1", False, False], 89 | ], 90 | ["TuringClientSignalCollectionTrigger", "...", [], [...]], 91 | ["TuringClientSignalCollectionTrigger", "retrieveSignals", [], [...]], 92 | ["BanzaiODS"], 93 | ["BanzaiScuba"], 94 | ] 95 | url = "https://scontent-arn2-1.xx.fbcdn.net/v/image.png&dl=1" 96 | assert get_jsmods_require(data)["ServerRedirect.redirectPageTo"][0] == url 97 | 98 | 99 | def test_get_jsmods_define(): 100 | data = [ 101 | [ 102 | "BootloaderConfig", 103 | [], 104 | { 105 | "jsRetries": [200, 500], 106 | "jsRetryAbortNum": 2, 107 | "jsRetryAbortTime": 5, 108 | "payloadEndpointURI": "https://www.facebook.com/ajax/bootloader-endpoint/", 109 | "preloadBE": False, 110 | "assumeNotNonblocking": True, 111 | "shouldCoalesceModuleRequestsMadeInSameTick": True, 112 | "staggerJsDownloads": False, 113 | "preloader_num_preloads": 0, 114 | "preloader_preload_after_dd": False, 115 | "preloader_num_loads": 1, 116 | "preloader_enabled": False, 117 | "retryQueuedBootloads": False, 118 | "silentDups": False, 119 | "asyncPreloadBoost": True, 120 | }, 121 | 123, 122 | ], 123 | [ 124 | "CSSLoaderConfig", 125 | [], 126 | {"timeout": 5000, "modulePrefix": "BLCSS:", "loadEventSupported": True}, 127 | 456, 128 | ], 129 | ["CurrentCommunityInitialData", [], {}, 789], 130 | [ 131 | "CurrentEnvironment", 132 | [], 133 | {"facebookdotcom": True, "messengerdotcom": False}, 134 | 987, 135 | ], 136 | ] 137 | assert get_jsmods_define(data) == { 138 | "BootloaderConfig": { 139 | "jsRetries": [200, 500], 140 | "jsRetryAbortNum": 2, 141 | "jsRetryAbortTime": 5, 142 | "payloadEndpointURI": "https://www.facebook.com/ajax/bootloader-endpoint/", 143 | "preloadBE": False, 144 | "assumeNotNonblocking": True, 145 | "shouldCoalesceModuleRequestsMadeInSameTick": True, 146 | "staggerJsDownloads": False, 147 | "preloader_num_preloads": 0, 148 | "preloader_preload_after_dd": False, 149 | "preloader_num_loads": 1, 150 | "preloader_enabled": False, 151 | "retryQueuedBootloads": False, 152 | "silentDups": False, 153 | "asyncPreloadBoost": True, 154 | }, 155 | "CSSLoaderConfig": { 156 | "timeout": 5000, 157 | "modulePrefix": "BLCSS:", 158 | "loadEventSupported": True, 159 | }, 160 | "CurrentCommunityInitialData": {}, 161 | "CurrentEnvironment": {"facebookdotcom": True, "messengerdotcom": False}, 162 | } 163 | 164 | 165 | def test_get_jsmods_define_get_fb_dtsg(): 166 | data = [ 167 | ["DTSGInitialData", [], {"token": "AQG-abcdefgh:AQGijklmnopq"}, 258], 168 | [ 169 | "DTSGInitData", 170 | [], 171 | {"token": "AQG-abcdefgh:AQGijklmnopq", "async_get_token": "ABC123:DEF456"}, 172 | 3515, 173 | ], 174 | ] 175 | jsmods = get_jsmods_define(data) 176 | assert ( 177 | jsmods["DTSGInitData"]["token"] 178 | == jsmods["DTSGInitialData"]["token"] 179 | == "AQG-abcdefgh:AQGijklmnopq" 180 | ) 181 | 182 | 183 | def test_mimetype_to_key(): 184 | assert mimetype_to_key(None) == "file_id" 185 | assert mimetype_to_key("image/gif") == "gif_id" 186 | assert mimetype_to_key("video/mp4") == "video_id" 187 | assert mimetype_to_key("video/quicktime") == "video_id" 188 | assert mimetype_to_key("image/png") == "image_id" 189 | assert mimetype_to_key("image/jpeg") == "image_id" 190 | assert mimetype_to_key("audio/mpeg") == "audio_id" 191 | assert mimetype_to_key("application/json") == "file_id" 192 | 193 | 194 | def test_get_url_parameter(): 195 | assert get_url_parameter("http://example.com?a=b&c=d", "c") == "d" 196 | assert get_url_parameter("http://example.com?a=b&a=c", "a") == "b" 197 | assert get_url_parameter("http://example.com", "a") is None 198 | 199 | 200 | DT_0 = datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) 201 | DT = datetime.datetime(2018, 11, 16, 1, 51, 4, 162000, tzinfo=datetime.timezone.utc) 202 | DT_NO_TIMEZONE = datetime.datetime(2018, 11, 16, 1, 51, 4, 162000) 203 | 204 | 205 | def test_seconds_to_datetime(): 206 | assert seconds_to_datetime(0) == DT_0 207 | assert seconds_to_datetime(1542333064.162) == DT 208 | assert seconds_to_datetime(1542333064.162) != DT_NO_TIMEZONE 209 | 210 | 211 | def test_millis_to_datetime(): 212 | assert millis_to_datetime(0) == DT_0 213 | assert millis_to_datetime(1542333064162) == DT 214 | assert millis_to_datetime(1542333064162) != DT_NO_TIMEZONE 215 | 216 | 217 | def test_datetime_to_seconds(): 218 | assert datetime_to_seconds(DT_0) == 0 219 | assert datetime_to_seconds(DT) == 1542333064 # Rounded 220 | datetime_to_seconds(DT_NO_TIMEZONE) # Depends on system timezone 221 | 222 | 223 | def test_datetime_to_millis(): 224 | assert datetime_to_millis(DT_0) == 0 225 | assert datetime_to_millis(DT) == 1542333064162 226 | datetime_to_millis(DT_NO_TIMEZONE) # Depends on system timezone 227 | 228 | 229 | def test_seconds_to_timedelta(): 230 | assert seconds_to_timedelta(0.001) == datetime.timedelta(microseconds=1000) 231 | assert seconds_to_timedelta(1) == datetime.timedelta(seconds=1) 232 | assert seconds_to_timedelta(3600) == datetime.timedelta(hours=1) 233 | assert seconds_to_timedelta(86400) == datetime.timedelta(days=1) 234 | 235 | 236 | def test_millis_to_timedelta(): 237 | assert millis_to_timedelta(1) == datetime.timedelta(microseconds=1000) 238 | assert millis_to_timedelta(1000) == datetime.timedelta(seconds=1) 239 | assert millis_to_timedelta(3600000) == datetime.timedelta(hours=1) 240 | assert millis_to_timedelta(86400000) == datetime.timedelta(days=1) 241 | 242 | 243 | def test_timedelta_to_seconds(): 244 | assert timedelta_to_seconds(datetime.timedelta(microseconds=1000)) == 0 # Rounded 245 | assert timedelta_to_seconds(datetime.timedelta(seconds=1)) == 1 246 | assert timedelta_to_seconds(datetime.timedelta(hours=1)) == 3600 247 | assert timedelta_to_seconds(datetime.timedelta(days=1)) == 86400 248 | --------------------------------------------------------------------------------